From 5f3869c0941004579f1656e1d5136488ef4c81db Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Wed, 5 Aug 2026 15:36:39 +0200 Subject: [PATCH 01/36] feat(opentrons): Opentrons Flex liquid handler (plain-class, mount-addressed 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) --- docs/api/pylabrobot.opentrons.rst | 17 + docs/api/pylabrobot.rst | 1 + docs/user_guide/index.md | 1 + .../opentrons/flex/hello-world.ipynb | 328 +++++ docs/user_guide/opentrons/index.md | 7 + pylabrobot/opentrons/__init__.py | 17 + pylabrobot/opentrons/flex.py | 184 +++ pylabrobot/opentrons/flex_head.py | 1204 +++++++++++++++++ pylabrobot/opentrons/flex_tests.py | 1061 +++++++++++++++ pylabrobot/opentrons/robot.py | 291 ++++ pylabrobot/opentrons/transport.py | 216 +++ pylabrobot/opentrons/transport_tests.py | 166 +++ pylabrobot/resources/opentrons/__init__.py | 3 + pylabrobot/resources/opentrons/flex_deck.py | 362 +++++ pylabrobot/resources/opentrons/flex_plates.py | 111 ++ .../resources/opentrons/flex_tip_racks.py | 151 +++ 16 files changed, 4120 insertions(+) create mode 100644 docs/api/pylabrobot.opentrons.rst create mode 100644 docs/user_guide/opentrons/flex/hello-world.ipynb create mode 100644 docs/user_guide/opentrons/index.md create mode 100644 pylabrobot/opentrons/__init__.py create mode 100644 pylabrobot/opentrons/flex.py create mode 100644 pylabrobot/opentrons/flex_head.py create mode 100644 pylabrobot/opentrons/flex_tests.py create mode 100644 pylabrobot/opentrons/robot.py create mode 100644 pylabrobot/opentrons/transport.py create mode 100644 pylabrobot/opentrons/transport_tests.py create mode 100644 pylabrobot/resources/opentrons/flex_deck.py create mode 100644 pylabrobot/resources/opentrons/flex_plates.py create mode 100644 pylabrobot/resources/opentrons/flex_tip_racks.py diff --git a/docs/api/pylabrobot.opentrons.rst b/docs/api/pylabrobot.opentrons.rst new file mode 100644 index 00000000000..5986f5315f0 --- /dev/null +++ b/docs/api/pylabrobot.opentrons.rst @@ -0,0 +1,17 @@ +.. currentmodule:: pylabrobot.opentrons + +pylabrobot.opentrons package +============================ + +Flex +---- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + OpentronsRobot + OpentronsFlex + OpentronsError + PipetteInfo diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 206ff03dd1e..8251ebc05b6 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -33,6 +33,7 @@ Manufacturers pylabrobot.kbiosystems pylabrobot.mettler_toledo pylabrobot.molecular_devices + pylabrobot.opentrons pylabrobot.qinstruments pylabrobot.sartorius pylabrobot.thermo_fisher diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index c058aa8dca0..b9c257c5b08 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -41,6 +41,7 @@ kbioscience/index kbiosystems/index mettler_toledo/index molecular_devices/index +opentrons/index qinstruments/index sartorius/index thermo_fisher/index diff --git a/docs/user_guide/opentrons/flex/hello-world.ipynb b/docs/user_guide/opentrons/flex/hello-world.ipynb new file mode 100644 index 00000000000..98e9f546191 --- /dev/null +++ b/docs/user_guide/opentrons/flex/hello-world.ipynb @@ -0,0 +1,328 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "flex-intro", + "metadata": {}, + "source": [ + "# Opentrons Flex — hello world (real hardware)\n", + "\n", + "This notebook drives a **real Opentrons Flex** over its robot-server HTTP API\n", + "using the mount-addressed head model:\n", + "\n", + "- `OpentronsFlex` is the device. It owns the deck, the HTTP connection, and\n", + " discovers whichever pipette(s) are actually mounted at `setup()` time,\n", + " composing a head sub-object onto `flex.left`, `flex.right`, and/or\n", + " `flex.head96` — there is no `flex.pick_up_tips(...)`; you always go\n", + " through the head that matches the mounted pipette (e.g. `FlexHead8` for\n", + " an 8-channel head).\n", + "- **The robot owns labware geometry, not PLR.** A tip rack or plate built\n", + " here carries only a *nominal* SBS grid (named `TipSpot`/`Well` objects for\n", + " tip/volume tracking) — when it's loaded, PLR sends the robot its\n", + " Opentrons load name (`ot_load_name`, e.g.\n", + " `\"opentrons_flex_96_tiprack_50ul\"`) and the robot resolves the real,\n", + " authoritative definition. We just *name* what we loaded.\n", + "- **The Flex hardware tip sensor is authority for tip presence.** Every\n", + " `pick_up_tips()` is verified against the real per-pipette `tipDetected`\n", + " sensor (`GET /instruments`) after the wire command succeeds — PLR's tip\n", + " trackers only commit if the sensor confirms a tip actually seated, and\n", + " roll back otherwise.\n", + "\n", + "```{warning}\n", + "**Safety note before running:**\n", + "\n", + "- Clear the deck of anything you don't want the gantry to hit.\n", + "- Load a **real Flex 50 uL tip rack** in slot **C1** and a **real 96-well\n", + " plate** in slot **D1** (matching the labware constructed in the cells\n", + " below).\n", + "- Confirm the robot-server is reachable on port `31950` (the Opentrons App\n", + " can already talk to it — that's the same server).\n", + "- **Close the Flex's front door before running.** The gantry moves more\n", + " safely with the enclosure shut, and the Flex expects the door closed\n", + " during motion.\n", + "- Running this notebook **homes all axes and moves the gantry**. Keep hands\n", + " and obstructions clear of the deck while cells are executing.\n", + "```\n", + "\n", + "```{note}\n", + "`FlexHead8` is verified on real Opentrons Flex hardware, so it no longer\n", + "emits an untested-hardware warning. `FlexHead1` and `FlexHead96` remain\n", + "unverified (they need 1-channel / 96-channel pipettes) and still log a\n", + "one-time warning on first use.\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-imports-code", + "metadata": {}, + "outputs": [], + "source": [ + "FLEX_HOST = \"169.254.1.1\" # <-- SET to your Flex's IP / USB address\n", + "\n", + "from pylabrobot.opentrons import FlexHead8, OpentronsFlex\n", + "from pylabrobot.resources.opentrons import (\n", + " FlexDeck,\n", + " corning_96_wellplate_360ul_flat,\n", + " flex_96_tiprack_50ul,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "flex-deck-md", + "metadata": {}, + "source": [ + "## Build the deck and labware\n", + "\n", + "Construct a `FlexDeck` (12 standard slots + trash, auto-placed at `A3`),\n", + "then create a Flex 50 uL tip rack and a Corning 96-well plate and place them\n", + "on real deck slots with `deck.assign_child_at_slot(...)`. These must match\n", + "the physical labware you loaded onto the robot in the safety step above.\n", + "\n", + "Both factories build a *nominal* PLR grid (for tracking/addressing) and set\n", + "`ot_load_name` to the Opentrons Labware Library name — that name is how the\n", + "labware is identified to the robot; the robot looks up its own authoritative\n", + "geometry from it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-deck-code", + "metadata": {}, + "outputs": [], + "source": [ + "deck = FlexDeck()\n", + "\n", + "tip_rack = flex_96_tiprack_50ul(name=\"tips_01\")\n", + "plate = corning_96_wellplate_360ul_flat(name=\"plate_01\")\n", + "\n", + "deck.assign_child_at_slot(tip_rack, \"C1\")\n", + "deck.assign_child_at_slot(plate, \"D1\")" + ] + }, + { + "cell_type": "markdown", + "id": "flex-connect-md", + "metadata": {}, + "source": [ + "## Connect\n", + "\n", + "`OpentronsFlex(deck, host=FLEX_HOST)` builds the device; `await flex.setup()`\n", + "opens the HTTP connection, checks `/health`, creates an empty run, and\n", + "discovers + loads the mounted pipette(s) — composing a head (`FlexHead1`,\n", + "`FlexHead8`, or `FlexHead96`) onto `flex.left`/`flex.right`/`flex.head96`\n", + "depending on what's actually mounted." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-connect-code", + "metadata": {}, + "outputs": [], + "source": [ + "flex = OpentronsFlex(deck, host=FLEX_HOST)\n", + "await flex.setup()\n", + "\n", + "print(\"api_version:\", flex.api_version)\n", + "print(\"robot_model:\", flex.robot_model)\n", + "print(\"left mount: \", flex.left)\n", + "print(\"right mount:\", flex.right)\n", + "print(\"96-head: \", flex.head96)" + ] + }, + { + "cell_type": "markdown", + "id": "flex-head-md", + "metadata": {}, + "source": [ + "## Pick the active 8-channel head\n", + "\n", + "Grab whichever mount discovery populated (`flex.left` or `flex.right`) and\n", + "confirm it's the `FlexHead8` this notebook is written for.\n", + "`get_mounted_tips()` reports per-channel tip state — PLR-side bookkeeping,\n", + "`None` per channel until a pickup happens." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-head-code", + "metadata": {}, + "outputs": [], + "source": [ + "head = flex.left or flex.right\n", + "assert isinstance(head, FlexHead8), f\"expected FlexHead8, got {type(head)}\"\n", + "\n", + "print(\"mounted tips:\", head.get_mounted_tips())" + ] + }, + { + "cell_type": "markdown", + "id": "flex-home-md", + "metadata": {}, + "source": [ + "## Home\n", + "\n", + "Homes all axes — the gantry moves to the rear-left-top reference position." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-home-code", + "metadata": {}, + "outputs": [], + "source": [ + "await flex.home()" + ] + }, + { + "cell_type": "markdown", + "id": "flex-pickup-md", + "metadata": {}, + "source": [ + "## Pick up a column of tips\n", + "\n", + "One `pickUpTip` command anchored at column 0's A-row well (`A1`); the\n", + "hardware fans it out to all 8 physical nozzles, picking up the whole column\n", + "at once." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-pickup-code", + "metadata": {}, + "outputs": [], + "source": [ + "await head.pick_up_tips(tip_rack, column=0)\n", + "\n", + "print(\"mounted tips:\", head.get_mounted_tips())" + ] + }, + { + "cell_type": "markdown", + "id": "flex-tip-presence-md", + "metadata": {}, + "source": [ + "## Verify tip presence against the hardware sensor\n", + "\n", + "`pick_up_tips()` already checked this internally — it verifies the pickup\n", + "against the Flex's real per-pipette `tipDetected` sensor\n", + "(`GET /instruments`) before committing PLR's tip trackers, and rolls the\n", + "pickup back (raising) if the sensor never reports a seated tip. This cell\n", + "just re-queries that same sensor explicitly (`has_tip_on_hardware()`) so you\n", + "can see the hardware ground truth next to PLR's own per-channel bookkeeping\n", + "(`get_mounted_tips()`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-tip-presence-code", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"hardware tipDetected:\", await head.has_tip_on_hardware())\n", + "print(\"mounted tips:\", head.get_mounted_tips())" + ] + }, + { + "cell_type": "markdown", + "id": "flex-liquid-md", + "metadata": {}, + "source": [ + "## Aspirate and dispense\n", + "\n", + "Aspirate 50 uL from column 0 of the plate, then dispense it back — each is a\n", + "single command anchored at the column's A-row well (`A1`), fanned to all 8\n", + "channels. The first aspirate since the last tip pickup automatically fires a\n", + "`prepareToAspirate` command before the `aspirate` itself — the Flex requires\n", + "this explicit plunger-priming step (unlike the STAR, where it's implicit)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-liquid-code", + "metadata": {}, + "outputs": [], + "source": [ + "await head.aspirate(plate, column=0, volume=50)\n", + "await head.dispense(plate, column=0, volume=50)" + ] + }, + { + "cell_type": "markdown", + "id": "flex-discard-md", + "metadata": {}, + "source": [ + "## Discard the tips\n", + "\n", + "Drop the mounted column of tips into the deck's trash (auto-placed at slot\n", + "`A3` by `FlexDeck`), then re-query the hardware tip-presence sensor — it\n", + "should now report no tip seated." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-discard-code", + "metadata": {}, + "outputs": [], + "source": [ + "trash = flex.deck.get_trash_area()\n", + "await head.discard_tips(trash)\n", + "\n", + "print(\"after drop, tipDetected:\", await head.has_tip_on_hardware())" + ] + }, + { + "cell_type": "markdown", + "id": "flex-teardown-md", + "metadata": {}, + "source": [ + "## Teardown\n", + "\n", + "`flex.stop()` drops any mounted tips into the trash (distributed across the\n", + "bin via `alternateDropLocation`), homes the gantry, then cancels the run and\n", + "closes the HTTP connection — so the robot is left parked and empty-handed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "flex-teardown-code", + "metadata": {}, + "outputs": [], + "source": [ + "await flex.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/opentrons/index.md b/docs/user_guide/opentrons/index.md new file mode 100644 index 00000000000..c9accb591e6 --- /dev/null +++ b/docs/user_guide/opentrons/index.md @@ -0,0 +1,7 @@ +# Opentrons + +```{toctree} +:maxdepth: 1 + +flex/hello-world +``` diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py new file mode 100644 index 00000000000..83fedf8826a --- /dev/null +++ b/pylabrobot/opentrons/__init__.py @@ -0,0 +1,17 @@ +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96 +from pylabrobot.opentrons.robot import OpentronsError, OpentronsRobot, PipetteInfo +from pylabrobot.opentrons.transport import ChatterboxTransport, HttpxTransport, OpentronsTransport + +__all__ = [ + "ChatterboxTransport", + "FlexHead1", + "FlexHead8", + "FlexHead96", + "HttpxTransport", + "OpentronsError", + "OpentronsFlex", + "OpentronsRobot", + "OpentronsTransport", + "PipetteInfo", +] diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py new file mode 100644 index 00000000000..b9453be962b --- /dev/null +++ b/pylabrobot/opentrons/flex.py @@ -0,0 +1,184 @@ +import logging +import uuid +from typing import Dict, List, Optional, Type, cast + +from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead +from pylabrobot.opentrons.robot import OpentronsError, OpentronsRobot +from pylabrobot.opentrons.transport import OpentronsTransport +from pylabrobot.resources import Resource +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.trash import Trash + +logger = logging.getLogger(__name__) + +_OT_NAMESPACE = "opentrons" +_OT_VERSION = 1 + +_TIP_RACK_MAP = { + "flex_96_tiprack_50ul": "opentrons_flex_96_tiprack_50ul", + "flex_96_tiprack_200ul": "opentrons_flex_96_tiprack_200ul", + "flex_96_tiprack_1000ul": "opentrons_flex_96_tiprack_1000ul", + "flex_96_tiprack_20ul": "opentrons_flex_96_tiprack_20ul", + "flex_96_filtertiprack_50ul": "opentrons_flex_96_filtertiprack_50ul", + "flex_96_filtertiprack_200ul": "opentrons_flex_96_filtertiprack_200ul", + "flex_96_filtertiprack_1000ul": "opentrons_flex_96_filtertiprack_1000ul", + "flex_96_filtertiprack_20ul": "opentrons_flex_96_filtertiprack_20ul", +} + +# Discovered pipette channel count -> matching head class. +_CHANNELS_TO_HEAD: Dict[int, Type[_FlexHead]] = { + 1: FlexHead1, + 8: FlexHead8, + 96: FlexHead96, +} + + +class OpentronsFlex(OpentronsRobot): + """Opentrons Flex liquid handler (plain class, post-#1180 architecture). + + A device shell: it owns the deck, deck-scoped labware loading, and the + discover-then-compose lifecycle that builds mount-addressed head + sub-objects (``left``/``right``/``head96``). Liquid-handling ops live on + the heads, not here — see :mod:`pylabrobot.opentrons.flex_head`. + """ + + def __init__( + self, + deck: FlexDeck, + host: str, + port: int = 31950, + transport: Optional[OpentronsTransport] = None, + ) -> None: + super().__init__(host=host, port=port, transport=transport) + self.deck = deck + self._loaded_labware: Dict[str, str] = {} + self.left: Optional[_FlexHead] = None + self.right: Optional[_FlexHead] = None + self.head96: Optional[_FlexHead] = None + self._heads: List[_FlexHead] = [] + + async def _model_setup(self) -> None: + await self.home() + + # Discover ALL mounted pipettes (not just the first — _discover_pipette + # only surfaces one) and compose the matching head per mount. The base + # setup() no longer discovers/loads a pipette itself (that would double + # `loadPipette` the first mount), so this is the only place a Flex loads + # its pipettes. + instruments_data = await self._get_instruments() + pipettes = self._parse_pipettes(instruments_data) + + if not pipettes: + raise OpentronsError("No pipette detected", f"{self.host}:{self.port}") + + if any(pip.channels == 96 for pip in pipettes) and len(pipettes) > 1: + raise OpentronsError( + "Impossible instrument combination", + "A 96-channel head cannot be mounted alongside another pipette on a Flex.", + ) + + for pip in pipettes: + pipette_id = await self._load_pipette(pip.pipette_name, pip.mount) + head_cls = _CHANNELS_TO_HEAD.get(pip.channels) + if head_cls is None: + raise OpentronsError( + "Unsupported pipette channel count", + f"{pip.channels} channels (mount '{pip.mount}') has no matching FlexHead.", + ) + head = head_cls(self, pip.mount, pipette_id, pip.channels) + + if pip.channels == 96: + self.head96 = head + elif pip.mount == "left": + self.left = head + elif pip.mount == "right": + self.right = head + else: + raise OpentronsError("Unknown mount", f"mount '{pip.mount}' is neither 'left' nor 'right'.") + self._heads.append(head) + + for head in self._heads: + await head._on_setup() + + async def stop(self) -> None: + # Drop any mounted tips to the trash BEFORE parking/disconnecting, so the + # robot is never left holding tips. A failure here must not block the + # home/cancel/disconnect that follows. + try: + trash: Optional[Trash] = self.deck.get_trash_area() + except ValueError: + trash = None + if trash is not None: + for head in reversed(self._heads): + try: + if any(tip is not None for tip in head.get_mounted_tips()): + await head.discard_tips(trash) + except Exception: + logger.warning( + "Dropping tips on stop failed for the %s head; continuing to disconnect.", + head.mount, + exc_info=True, + ) + for head in reversed(self._heads): + await head._on_stop() + await super().stop() # homes the gantry, then cancels the run + disconnects + + async def _ensure_labware_loaded(self, resource: Resource) -> str: + """Load labware into the Flex run if not already loaded.""" + name = getattr(resource, "name", str(resource)) + if name in self._loaded_labware: + return self._loaded_labware[name] + + slot = self.deck.get_slot(resource) + if slot is None: + raise OpentronsError( + "Resource not on deck", + f"'{name}' is not on a deck slot. Use deck.assign_child_at_slot(resource, slot='C1').", + ) + + load_name = self._ot_load_name(resource) + labware_id = uuid.uuid4().hex[:12] + + result = await self._execute_command( + "loadLabware", + { + "loadName": load_name, + "location": {"slotName": slot}, + "namespace": _OT_NAMESPACE, + "version": _OT_VERSION, + "labwareId": labware_id, + "displayName": name, + }, + ) + labware_id = cast(str, result.get("result", {}).get("labwareId", labware_id)) + + self._loaded_labware[name] = labware_id + logger.info( + "Loaded labware '%s' at slot %s -> ID: %s (OT: %s)", + name, + slot, + labware_id, + load_name, + ) + return labware_id + + @staticmethod + def _ot_load_name(resource: Resource) -> str: + """Resolve a PLR resource to its Opentrons labware load name.""" + if hasattr(resource, "ot_load_name"): + return cast(str, resource.ot_load_name) + + name_lower = getattr(resource, "name", "").lower() + + for key, ot_name in _TIP_RACK_MAP.items(): + if key in name_lower: + return ot_name + + if name_lower.startswith("opentrons_"): + return name_lower + + raise OpentronsError( + "Cannot determine Opentrons load name", + f"'{name_lower}' — set resource.ot_load_name = 'opentrons_flex_96_tiprack_50ul' " + f"or use a standard Flex labware name.", + ) diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py new file mode 100644 index 00000000000..3f2ce583716 --- /dev/null +++ b/pylabrobot/opentrons/flex_head.py @@ -0,0 +1,1204 @@ +"""Head sub-objects for :class:`~pylabrobot.opentrons.flex.OpentronsFlex`. + +Each head is a plain-class sub-object (EL406/Cytation5 idiom), not a +Capability/CapabilityBackend split: it holds a back-reference to the owning +``OpentronsFlex`` device and issues commands through the shared transport via +``self.flex._execute_command``. Deck-scoped labware loading stays on +``OpentronsFlex`` (heads call ``self.flex._ensure_labware_loaded(...)``); only +which physical channel holds which tip is genuine head-local state +(``self._channel_tips``). + +This module holds the ``_FlexHead`` base plus ``FlexHead1`` (single-channel, +well-addressed), ``FlexHead8`` (column-addressed, anchor-well fan-out) and +``FlexHead96`` (96 fixed nozzles, whole-plate-addressed). The transactional +stage->wire->verify->commit/rollback flow, hardware tip-presence +verification, and ``prepareToAspirate`` priming are factored onto the +``_FlexHead`` base (``_execute_pickup``/``_execute_liquid_op``/ +``_execute_with_prepare``/``_execute_trash_drop``) so ``FlexHead1`` and +``FlexHead96`` reuse the exact machinery ``FlexHead8`` established -- only +the addressing (single well vs. column vs. whole-plate anchor) and nozzle +layout differ per head. +""" + +import logging +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast + +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.resources import ( + Plate, + TipRack, + TipSpot, + Trash, + Well, + does_tip_tracking, + does_volume_tracking, +) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.itemized_resource import ItemizedResource +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.tip import Tip + +if TYPE_CHECKING: + from pylabrobot.opentrons.flex import OpentronsFlex + +logger = logging.getLogger(__name__) + + +class _FlexHead: + """Base class for a mount- (or 96-head-) addressed pipette on an ``OpentronsFlex``. + + Subclasses implement the liquid-handling ops appropriate to their channel + count. This base holds the shared plumbing: the back-reference to the + owning device, per-channel tip telemetry, and the well/labware helpers ops + need to build robot-server command params. + """ + + def __init__(self, flex: "OpentronsFlex", mount: str, pipette_id: str, channels: int) -> None: + self.flex = flex + self.mount = mount + self.pipette_id = pipette_id + self.channels = channels + self._channel_tips: List[Optional[Tip]] = [None] * channels + # Whether the plunger has been prepared (primed) since the last tip + # pickup. The Flex requires an explicit `prepareToAspirate` command + # before the FIRST aspirate after a pickup (implicit on the STAR, + # explicit on the Flex) -- True means no prepare is currently pending. + self._prepared: bool = True + self._untested_hardware_warned: bool = False + + def _warn_untested_hardware(self) -> None: + """Log a one-time notice that this head is not yet verified on real hardware. + + Called by ``FlexHead1``/``FlexHead96`` at the top of every op -- guarded + so only the FIRST call on a given instance actually logs. ``FlexHead8`` + does not call this (it has its own hardware-verification history); this + exists specifically for the hardware-unverified heads. + """ + if self._untested_hardware_warned: + return + self._untested_hardware_warned = True + logger.warning( + "%s ops are coded but NOT YET VERIFIED on real Opentrons Flex hardware -- " + "tested only against ChatterboxTransport/simulated transport. Verify behavior " + "on real hardware before relying on it in a production protocol.", + type(self).__name__, + ) + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Per-channel tip state (Case-2: no private-attribute peeking by consumers). + + Returns a copy — mutating the result never affects head state. This is + PLR-side bookkeeping only; it is not queried from the robot. The Flex's + hardware tip-presence sensor (see ``has_tip_on_hardware()``) is the + aggregate ground truth for whether *a* tip is actually seated on this + head's pipette — it reports one bool per pipette, not per channel, so it + cannot replace this per-channel cache, only verify/reconcile against it. + """ + return list(self._channel_tips) + + async def discard_tips(self, trash: Trash) -> None: + """Discard all mounted tips into ``trash``. Implemented by each head.""" + raise NotImplementedError + + async def has_tip_on_hardware(self) -> Optional[bool]: + """Query the Flex's hardware tip-presence sensor for THIS head's pipette. + + The Flex reports tip presence as one boolean per pipette (mount), not + per nozzle/channel: ``GET /instruments`` -> ``data[i].state.tipDetected``. + This is the aggregate hardware ground truth, used to verify/reconcile + the per-channel ``_channel_tips`` bookkeeping -- it cannot tell you + *which* channel(s) hold a tip. + + Returns: + ``True``/``False`` if a pipette is found on ``self.mount`` and reports + a tip-detection state, ``None`` if unknown (no ``state`` field) or no + pipette is found on this mount. + """ + instruments_data = await self.flex._get_instruments() + for instrument in instruments_data.get("data", []): + if instrument.get("instrumentType") != "pipette": + continue + if instrument.get("mount") != self.mount: + continue + state = instrument.get("state", {}) + return cast(Optional[bool], state.get("tipDetected")) + return None + + async def _verify_tips_seated(self) -> None: + """Raise if the hardware tip-presence sensor reports no tip after a pickup. + + Called immediately after a ``pickUpTip`` wire command succeeds. A + ``False`` reading means the pipette moved through the pickup motion but + the sensor did not detect a seated tip (e.g. an empty/damaged tip spot); + ``None`` (unknown/no pipette found) is not treated as a failure. + """ + if await self.has_tip_on_hardware() is False: + raise OpentronsError( + "Tip pickup not detected", + f"Hardware tip-presence sensor reports no tip seated on mount {self.mount!r} " + "after pickUpTip.", + ) + + async def _confirm_tips_cleared(self) -> None: + """Warn if the hardware tip-presence sensor still reports a tip after a drop. + + Called after a drop wire command + tracker commit. A ``True`` reading + means the drop motion completed but the sensor still detects a tip + (e.g. stuck to the nozzle) -- logged as a warning rather than raised, + since the tracker-side bookkeeping has already been committed by the + time this runs. + """ + if await self.has_tip_on_hardware() is True: + logger.warning( + "Tip drop may not have cleared: hardware tip-presence sensor still reports a " + "tip seated on mount %r after drop.", + self.mount, + ) + + # --- Shared transactional command flows --- + # + # These four helpers are the machinery every op (Head1/Head8/Head96 alike) + # threads through: stage trackers (commit=False) BEFORE any of these run, + # then the helper sends the wire command(s) and commits/rolls back the + # staged trackers depending on outcome. Only the ADDRESSING (which well(s), + # which labware) and nozzle-layout handling differ per head/op. + + async def _execute_pickup( + self, + command_type: str, + params: Dict[str, Any], + staged_trackers: List[Any], + ) -> None: + """wire -> verify (hardware tip-presence) -> commit/rollback. + + Shared by every ``pick_up_tips``/``pick_up_single_tip`` variant. Tip + trackers must already be staged (``commit=False``) in ``staged_trackers`` + before calling this. Rolls back and re-raises if the wire command itself + fails, or if it succeeds but ``_verify_tips_seated()`` reports no tip + seated; commits only once both the wire command and hardware + verification succeed. Callers are responsible for updating + ``_channel_tips`` and ``_prepared`` AFTER this returns successfully. + """ + try: + await self._execute(command_type, params) + except Exception: + for tracker in staged_trackers: + tracker.rollback() + raise + + try: + await self._verify_tips_seated() + except Exception: + for tracker in staged_trackers: + tracker.rollback() + raise + + for tracker in staged_trackers: + tracker.commit() + + async def _execute_liquid_op( + self, + command_type: str, + params: Dict[str, Any], + staged_trackers: List[Any], + ) -> None: + """wire -> commit/rollback (no hardware verification step). + + Shared by ``dispense``/``dispense_single`` and rack-return ``drop_tips`` + (tip and volume trackers alike -- no hardware sensor check applies to + these). Trackers must already be staged (``commit=False``) before + calling this. + """ + try: + await self._execute(command_type, params) + except Exception: + for tracker in staged_trackers: + tracker.rollback() + raise + else: + for tracker in staged_trackers: + tracker.commit() + + async def _execute_with_prepare( + self, + command_type: str, + params: Dict[str, Any], + staged_trackers: List[Any], + ) -> None: + """``prepareToAspirate`` (if pending) -> wire -> commit/rollback. + + Shared by every ``aspirate``/``aspirate_single`` variant. Sends + ``prepareToAspirate`` first if this is the first aspirate since the last + tip pickup (``self._prepared`` False), then the aspirate command itself. + A successful prepare sets ``self._prepared = True`` immediately -- even + if the following aspirate then fails and trackers roll back -- since + priming is physical plunger state, not tracker state, and is not + reversed by a tracker rollback. + """ + try: + if not self._prepared: + await self._execute("prepareToAspirate", {"pipetteId": self.pipette_id}) + self._prepared = True + await self._execute(command_type, params) + except Exception: + for tracker in staged_trackers: + tracker.rollback() + raise + else: + for tracker in staged_trackers: + tracker.commit() + + async def _execute_trash_drop(self) -> None: + """Send the two-command addressable-area trash-drop sequence. + + Shared by every ``discard_tips``/``drop_single_tip`` variant. No tracker + involvement (trash has none); callers update ``_channel_tips`` and call + ``_confirm_tips_cleared()`` themselves after this returns. + """ + await self._execute( + "moveToAddressableAreaForDropTip", + { + "pipetteId": self.pipette_id, + "addressableAreaName": "movableTrashA3", + "alternateDropLocation": True, + }, + ) + await self._execute("dropTipInPlace", {"pipetteId": self.pipette_id}) + + async def _on_setup(self) -> None: + """Hook for head-specific post-discovery setup. Default: no-op.""" + + async def _on_stop(self) -> None: + """Hook for head-specific teardown. Default: no-op.""" + + async def _execute(self, command_type: str, params: Dict[str, Any]) -> Dict[str, Any]: + """Issue a robot-server command through the owning device's shared transport.""" + return await self.flex._execute_command(command_type, params) + + @staticmethod + def _require_itemized_parent(item: Resource) -> ItemizedResource: + """Return ``item.parent``, asserted to be an addressable-by-name container.""" + parent = item.parent + assert isinstance(parent, ItemizedResource), ( + f"'{item.name}' has no itemized parent resource (rack/plate)." + ) + return parent + + @staticmethod + def _well_location( + offsets: Optional[List[Optional[Coordinate]]], + liquid_height: Optional[List[Optional[float]]], + origin: str = "bottom", + ) -> Optional[dict]: + """Build the Flex ``wellLocation`` param from an offset and/or liquid height. + + Merges an explicit x/y/z offset with ``liquid_height`` (added to z). + ``origin`` defaults to ``"bottom"`` (aspirate/dispense); tip-pickup + callers must pass ``origin="top"`` -- a tip-rack well's "bottom" is deep + inside the tip, not the pickup engagement point. Returns ``None`` if + neither offset nor liquid height is given. + """ + offset = None + if offsets is not None and offsets[0] is not None: + o = offsets[0] + offset = {"x": o.x, "y": o.y, "z": o.z} + if liquid_height is not None and liquid_height[0] is not None: + offset = offset or {"x": 0, "y": 0, "z": 0} + offset["z"] += liquid_height[0] + if offset is None: + if origin == "bottom": + # No explicit position given: default to just above the well bottom + # rather than let the Protocol Engine fall back to origin "top" (the + # rim, above the liquid). Pickup callers (origin "top") keep None. + offset = {"x": 0, "y": 0, "z": _DEFAULT_WELL_BOTTOM_CLEARANCE} + else: + return None + return {"origin": origin, "offset": offset} + + +# Column index -> A-row well name (the Flex API's anchor well for 8-channel +# ALL-mode column ops; the hardware fans a single command out to all 8 +# physical nozzles from there). +_COLUMN_WELL_NAMES = [f"A{c + 1}" for c in range(12)] + +# Row letters front-to-back as the Flex API names single nozzles ("H1" is the +# frontmost/primary nozzle, "A1" the rearmost). +_ROW_LETTERS = "ABCDEFGH" + +_NUM_CHANNELS = 8 + +# Flex-managed positioning flow-rate defaults (uL/s), matching the +# p50_multi_v3.5 pipette defaults. Shared by FlexHead1/FlexHead8/FlexHead96 -- +# the Flex applies the same defaults regardless of channel count. +_DEFAULT_ASPIRATE_FLOW_RATE = 35.0 +_DEFAULT_DISPENSE_FLOW_RATE = 57.0 + +# Default aspirate/dispense position: 1mm above the well bottom, matching the +# Opentrons Python-API default. The raw Protocol-Engine /commands API defaults +# an OMITTED wellLocation to origin "top" (the well rim -- above the liquid), +# so a plain aspirate would draw air. We therefore always send an explicit +# bottom-referenced wellLocation for liquid ops. +_DEFAULT_WELL_BOTTOM_CLEARANCE = 1.0 + + +class FlexHead1(_FlexHead): + """Single-channel pipette head, well-addressed. + + Every op sends exactly ONE robot-server command naming the single well + (tip spot or well) it addresses -- no anchor-well fan-out, no nozzle + layout (there is only ever one physical nozzle). ``_channel_tips`` has + length 1; the sole channel is index 0. + + Reuses the ``_FlexHead`` base's transactional stage -> wire -> verify -> + commit/rollback flow, hardware tip-presence verification + (``_verify_tips_seated``/``_confirm_tips_cleared``), and + ``prepareToAspirate`` priming -- the same machinery ``FlexHead8`` uses for + its column ops, applied to a single well instead of a column. + + Coded but **not yet verified on real single-channel Flex hardware** -- + Vincent's bench Flex carries an 8-channel pipette, not a single-channel + one. A one-time ``logger.warning`` fires on the first op issued by an + instance, and this docstring makes no "validated on hardware" claim. + """ + + async def pick_up_tips( + self, + tip_spot: TipSpot, + offset: Optional[Coordinate] = None, + ) -> None: + """Pick up one tip -- one ``pickUpTip`` command naming ``tip_spot``. + + Raises ``OpentronsError`` if the (sole) channel already holds a tip + (double-pickup guard, mirrors ``FlexHead8``'s). Tip tracker change is + staged (``commit=False``) before the wire command; after the wire + command succeeds, the hardware tip-presence sensor is checked + (``_verify_tips_seated()``) -- the tracker and ``_channel_tips`` are + committed only if that verification passes, rolled back (with no + ``_channel_tips`` mutation) if the sensor reports a missed pickup. + """ + self._warn_untested_hardware() + if self._channel_tips[0] is not None: + raise OpentronsError( + "HasTipError", + "Channel already holds a tip; drop it before picking up another.", + ) + + rack = self._require_itemized_parent(tip_spot) + labware_id = await self.flex._ensure_labware_loaded(rack) + well_name = rack.get_child_identifier(tip_spot) + + tip = tip_spot.get_tip() + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + if tracking and not tip_spot.tracker.is_disabled: + tip_spot.tracker.remove_tip() # commit=False: stages + validates + staged_trackers.append(tip_spot.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + } + well_location = self._well_location([offset], [None], origin="top") + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_pickup("pickUpTip", params, staged_trackers) + self._channel_tips[0] = tip + self._prepared = False + + async def drop_tips( + self, + target: Union[TipSpot, Trash], + ) -> None: + """Drop the mounted tip -- one wire command naming ``target``. + + A ``TipSpot`` target returns the tip (one ``dropTip`` command); a + ``Trash`` target discards via the addressable-area drop sequence. The + tip tracker is committed only for a ``TipSpot`` target (None-skip: a + no-op if the channel holds no tip). After the wire drop + tracker + commit, ``_confirm_tips_cleared()`` checks the hardware tip-presence + sensor and logs a warning (does not raise) if it still reports a tip. + """ + self._warn_untested_hardware() + + if isinstance(target, Trash): + await self._execute_trash_drop() + self._channel_tips[0] = None + await self._confirm_tips_cleared() + return + + tip = self._channel_tips[0] + rack = self._require_itemized_parent(target) + labware_id = await self.flex._ensure_labware_loaded(rack) + well_name = rack.get_child_identifier(target) + + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + if tip is not None and tracking and not target.tracker.is_disabled: + target.tracker.add_tip(tip, commit=False) # stages + validates (HasTipError if occupied) + staged_trackers.append(target.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + } + + await self._execute_liquid_op("dropTip", params, staged_trackers) + self._channel_tips[0] = None + await self._confirm_tips_cleared() + + async def discard_tips(self, trash: Trash) -> None: + """Discard the mounted tip into the trash.""" + await self.drop_tips(trash) + + async def aspirate( + self, + well: Well, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Aspirate from ``well`` -- one ``aspirate`` command naming it. + + Follows stage -> validate -> wire -> commit/rollback: ``well.tracker`` + (``remove_liquid``) is staged BEFORE the wire command, so an infeasible + aspirate raises before any hardware motion. A ``prepareToAspirate`` + command is sent first if this is the first aspirate since the last tip + pickup. + """ + self._warn_untested_hardware() + parent = self._require_itemized_parent(well) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(well) + rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + + tracking = does_volume_tracking() + staged_trackers: List[Any] = [] + if tracking and not well.tracker.is_disabled: + well.tracker.remove_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "volume": volume, + "flowRate": rate, + } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_with_prepare("aspirate", params, staged_trackers) + + async def dispense( + self, + well: Well, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Dispense to ``well`` -- one ``dispense`` command naming it. + + Follows stage -> validate -> wire -> commit/rollback: ``well.tracker`` + (``add_liquid``) is staged BEFORE the wire command, so an infeasible + dispense raises before any hardware motion. + """ + self._warn_untested_hardware() + parent = self._require_itemized_parent(well) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(well) + rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + + tracking = does_volume_tracking() + staged_trackers: List[Any] = [] + if tracking and not well.tracker.is_disabled: + well.tracker.add_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "volume": volume, + "flowRate": rate, + } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_liquid_op("dispense", params, staged_trackers) + + +class FlexHead8(_FlexHead): + """8-channel pipette head, column-addressed (anchor-well fan-out). + + Every op sends exactly ONE robot-server command anchored at the column's + A-row well (e.g. column 2 -> wellName "A3"); the Flex hardware fans that + single command out to all 8 physical nozzles. Tip/volume trackers are + committed only for the channels/wells actually actuated, skipping ``None`` + (inactive) channels (None-skip) -- and only after the wire command + succeeds. + + Single-tip cherry-pick (``pick_up_single_tip``/``aspirate_single``/ + ``dispense_single``/``drop_single_tip``) switches the pipette to SINGLE + nozzle mode first via ``configureNozzleLayout``; column ops reset back to + ALL mode if a prior single-tip op left the layout otherwise + (``_ensure_all_mode``). + + Verified on real 8-channel Flex hardware (Opentrons Flex, robot-server + API 8.8): setup, homing, and column tip pickup confirmed against the + hardware tip-presence sensor. + """ + + def __init__(self, flex: "OpentronsFlex", mount: str, pipette_id: str, channels: int) -> None: + super().__init__(flex, mount, pipette_id, channels) + self._nozzle_layout: str = "ALL" # "ALL" | "SINGLE" + + # --- Nozzle layout guard --- + + async def _ensure_all_mode(self) -> None: + """Reset to the ALL nozzle layout before a column op. + + A prior single-tip op may have left the pipette in SINGLE mode. Column + ops always address all 8 physical channels, so they must not silently + run under a stale single-nozzle configuration -- if the layout isn't + already ALL, reset it first. + """ + if self._nozzle_layout == "ALL": + return + await self._execute( + "configureNozzleLayout", + {"pipetteId": self.pipette_id, "configurationParams": {"style": "ALL"}}, + ) + self._nozzle_layout = "ALL" + + # --- Column helpers --- + + @staticmethod + def _column_items(itemized: ItemizedResource, column: int) -> List[Any]: + """Return the 8 column resources (TipSpots or Wells), in row order A..H. + + Mirrors the column-major slice used throughout PLR's itemized resources: + item 0 is A1, item 1 is B1, ..., item 8 is A2, etc. -- so one column is + ``items[column * 8 : (column + 1) * 8]``. + """ + items = itemized.get_all_items() + num_columns = len(items) // _NUM_CHANNELS + if not 0 <= column < num_columns: + raise ValueError( + f"Column {column} out of range for resource with {num_columns} columns " + f"(0-{num_columns - 1})." + ) + return items[column * _NUM_CHANNELS : (column + 1) * _NUM_CHANNELS] + + # --- Column tip operations --- + + async def pick_up_tips( + self, + tip_rack: TipRack, + column: int, + offset: Optional[Coordinate] = None, + ) -> None: + """Pick up a full column (8 tips) with a single ``pickUpTip`` command. + + Anchored at the column's A-row well; the hardware fans the pickup motion + out to all 8 physical nozzles. Follows stage -> validate -> wire -> + verify -> commit/rollback: tip trackers are staged (``commit=False``) + BEFORE the wire command -- so an already-occupied channel (fix #4) or an + invalid tracker state raises before any hardware motion -- then, after + the wire command succeeds, the hardware tip-presence sensor is checked + (``_verify_tips_seated()``); trackers and ``_channel_tips`` are committed + only if that verification passes, and rolled back (with no + ``_channel_tips`` mutation) if the sensor reports a missed pickup. Only + spots that actually had a tip are staged (None-skip). + """ + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(tip_rack) + well_name = _COLUMN_WELL_NAMES[column] + column_spots = self._column_items(tip_rack, column) + + for i, spot in enumerate(column_spots): + if spot.has_tip() and self._channel_tips[i] is not None: + raise OpentronsError( + "HasTipError", + f"Channel {i} already holds a tip; drop it before picking up another.", + ) + + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + tips: List[Optional[Tip]] = [None] * len(column_spots) + for i, spot in enumerate(column_spots): + if not spot.has_tip(): + continue + tips[i] = spot.get_tip() + if tracking and not spot.tracker.is_disabled: + spot.tracker.remove_tip() # commit=False: stages + validates + staged_trackers.append(spot.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + } + well_location = self._well_location([offset], [None], origin="top") + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_pickup("pickUpTip", params, staged_trackers) + for i, tip in enumerate(tips): + self._channel_tips[i] = tip + self._prepared = False + + async def drop_tips( + self, + target: Union[TipRack, Trash], + column: Optional[int] = None, + ) -> None: + """Drop a full column of tips -- one wire command, fanned to 8 channels. + + A ``TipRack`` target returns tips to ``column`` (required, one + ``dropTip`` command); a ``Trash`` target discards via the + addressable-area drop sequence (``column`` ignored). Tip trackers are + committed only for channels that actually held a tip (None-skip); trash + drops never return tips to a rack tracker. After the wire drop + tracker + commit, ``_confirm_tips_cleared()`` checks the hardware tip-presence + sensor and logs a warning (does not raise) if it still reports a tip. + """ + await self._ensure_all_mode() + + if isinstance(target, Trash): + await self._execute_trash_drop() + self._channel_tips = [None] * self.channels + await self._confirm_tips_cleared() + return + + if column is None: + raise ValueError("column is required when dropping tips to a TipRack.") + + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = _COLUMN_WELL_NAMES[column] + column_spots = self._column_items(target, column) + + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + for i, spot in enumerate(column_spots): + tip = self._channel_tips[i] + if tip is not None and tracking and not spot.tracker.is_disabled: + spot.tracker.add_tip(tip, commit=False) # stages + validates (HasTipError if occupied) + staged_trackers.append(spot.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + } + + await self._execute_liquid_op("dropTip", params, staged_trackers) + for i in range(len(column_spots)): + self._channel_tips[i] = None + await self._confirm_tips_cleared() + + async def discard_tips(self, trash: Trash) -> None: + """Discard the mounted column of tips into the trash.""" + await self.drop_tips(trash) + + # --- Column liquid handling --- + + async def aspirate( + self, + plate: Plate, + column: int, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Aspirate a column -- one ``aspirate`` command anchored at the A-row well. + + Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` + (``remove_liquid``) is staged for every well whose channel actually + holds a tip (None-skip; wells outside ``column`` are never touched -- + the Case-1 regression guard) BEFORE the wire command, so an infeasible + aspirate (e.g. ``TooLittleLiquidError``) raises before any hardware + motion. A ``prepareToAspirate`` command is sent first if this is the + first aspirate since the last tip pickup. + """ + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + well_name = _COLUMN_WELL_NAMES[column] + rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + + tracking = does_volume_tracking() + staged_trackers: List[Any] = [] + if tracking: + for i, well in enumerate(self._column_items(plate, column)): + if self._channel_tips[i] is None or well.tracker.is_disabled: + continue + well.tracker.remove_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "volume": volume, + "flowRate": rate, + } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_with_prepare("aspirate", params, staged_trackers) + + async def dispense( + self, + plate: Plate, + column: int, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Dispense a column -- one ``dispense`` command anchored at the A-row well. + + Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` + (``add_liquid``) is staged for every well whose channel actually holds + a tip (None-skip) BEFORE the wire command, so an infeasible dispense + (e.g. ``TooLittleVolumeError``) raises before any hardware motion. + """ + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + well_name = _COLUMN_WELL_NAMES[column] + rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + + tracking = does_volume_tracking() + staged_trackers: List[Any] = [] + if tracking: + for i, well in enumerate(self._column_items(plate, column)): + if self._channel_tips[i] is None or well.tracker.is_disabled: + continue + well.tracker.add_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "volume": volume, + "flowRate": rate, + } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_liquid_op("dispense", params, staged_trackers) + + # --- Single-tip cherry-pick --- + + @staticmethod + def _channel_for_well(well: str) -> int: + """Map a well name's row letter to its physical channel index (A=0..H=7).""" + row_letter = well[0].upper() + try: + return _ROW_LETTERS.index(row_letter) + except ValueError: + raise ValueError(f"'{well}' has no recognized row letter (expected A-H).") from None + + def _active_single_channel(self) -> int: + """Return the sole channel holding a tip in single-tip mode. + + Raises if zero or more than one channel is active -- aspirate_single/ + dispense_single/drop_single_tip only make sense with exactly one tip + mounted. + """ + active = [i for i, tip in enumerate(self._channel_tips) if tip is not None] + if len(active) != 1: + raise RuntimeError( + f"Single-tip op requires exactly one mounted tip; found {len(active)}. " + "Call pick_up_single_tip() first." + ) + return active[0] + + async def pick_up_single_tip( + self, + tip_rack: TipRack, + well: str, + offset: Optional[Coordinate] = None, + ) -> None: + """Pick up one tip in SINGLE nozzle mode. + + Switches to SINGLE layout (``configureNozzleLayout``) before the + ``pickUpTip`` command. The physical nozzle engaged is the one whose row + matches ``well``'s row letter (e.g. well "H2" -> nozzle "H1" -> channel + 7); only that channel's tip state changes. Raises ``OpentronsError`` if + that channel already holds a tip (fix #4) -- checked before any wire + command. Tip tracker changes are staged (``commit=False``) before the + wire command, then, after the wire command succeeds, the hardware + tip-presence sensor is checked (``_verify_tips_seated()``) -- the + tracker and ``_channel_tips`` are committed only if that verification + passes, and rolled back (with no ``_channel_tips`` mutation) if the + sensor reports a missed pickup (stage -> validate -> wire -> verify -> + commit/rollback). + """ + channel = self._channel_for_well(well) + if self._channel_tips[channel] is not None: + raise OpentronsError( + "HasTipError", + f"Channel {channel} already holds a tip; drop it before picking up another.", + ) + + primary_nozzle = f"{_ROW_LETTERS[channel]}1" + await self._execute( + "configureNozzleLayout", + { + "pipetteId": self.pipette_id, + "configurationParams": {"style": "SINGLE", "primaryNozzle": primary_nozzle}, + }, + ) + self._nozzle_layout = "SINGLE" + + labware_id = await self.flex._ensure_labware_loaded(tip_rack) + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well, + } + well_location = self._well_location([offset], [None], origin="top") + if well_location is not None: + params["wellLocation"] = well_location + + spot = tip_rack.get_item(well) + tip = spot.get_tip() + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + if tracking and not spot.tracker.is_disabled: + spot.tracker.remove_tip() # commit=False: stages + validates + staged_trackers.append(spot.tracker) + + await self._execute_pickup("pickUpTip", params, staged_trackers) + self._channel_tips[channel] = tip + self._prepared = False + + async def aspirate_single( + self, + plate: Plate, + well: str, + volume: float, + flow_rate: Optional[float] = None, + ) -> None: + """Aspirate a single well with the currently mounted single tip. + + Sends ``prepareToAspirate`` first if this is the first aspirate since + the last (single-tip) pickup. Follows stage -> validate -> wire -> + commit/rollback for the well tracker, same as the column ``aspirate``. + """ + self._active_single_channel() + labware_id = await self.flex._ensure_labware_loaded(plate) + rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well, + "volume": volume, + "flowRate": rate, + "wellLocation": { + "origin": "bottom", + "offset": {"x": 0, "y": 0, "z": _DEFAULT_WELL_BOTTOM_CLEARANCE}, + }, + } + + target = plate.get_item(well) + tracking = does_volume_tracking() + staged_trackers: List[Any] = [] + if tracking and not target.tracker.is_disabled: + target.tracker.remove_liquid(volume=volume) # stages + validates + staged_trackers.append(target.tracker) + + await self._execute_with_prepare("aspirate", params, staged_trackers) + + async def dispense_single( + self, + plate: Plate, + well: str, + volume: float, + flow_rate: Optional[float] = None, + ) -> None: + """Dispense to a single well with the currently mounted single tip.""" + self._active_single_channel() + labware_id = await self.flex._ensure_labware_loaded(plate) + rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well, + "volume": volume, + "flowRate": rate, + "wellLocation": { + "origin": "bottom", + "offset": {"x": 0, "y": 0, "z": _DEFAULT_WELL_BOTTOM_CLEARANCE}, + }, + } + + target = plate.get_item(well) + tracking = does_volume_tracking() + staged_trackers: List[Any] = [] + if tracking and not target.tracker.is_disabled: + target.tracker.add_liquid(volume=volume) # stages + validates + staged_trackers.append(target.tracker) + + await self._execute_liquid_op("dispense", params, staged_trackers) + + async def drop_single_tip(self, trash: Trash) -> None: + """Drop the single mounted tip to trash and restore ALL nozzle mode. + + After the wire drop + ``_channel_tips`` update, ``_confirm_tips_cleared()`` + checks the hardware tip-presence sensor and logs a warning (does not + raise) if it still reports a tip. + """ + channel = self._active_single_channel() + await self._execute_trash_drop() + self._channel_tips[channel] = None + await self._confirm_tips_cleared() + + await self._execute( + "configureNozzleLayout", + {"pipetteId": self.pipette_id, "configurationParams": {"style": "ALL"}}, + ) + self._nozzle_layout = "ALL" + + +class FlexHead96(_FlexHead): + """96-channel pipette head, whole-plate-addressed (anchor-well fan-out). + + All 96 nozzles are physically fixed -- there is no partial/single-tip + mode, unlike ``FlexHead8``. Every op sends exactly ONE robot-server + command anchored at well "A1"; the Flex hardware fans that single command + out to all 96 physical nozzles. Tip/volume trackers are committed only for + the channels/wells that actually held a tip (None-skip), same as + ``FlexHead8``'s column ops. ``_channel_tips`` has length 96, index i + corresponding to ``plate.get_all_items()[i]`` / ``tip_rack.get_all_items()[i]`` + (PLR's column-major A1, B1, ..., H1, A2, ... order). + + Reuses the ``_FlexHead`` base's transactional stage -> wire -> verify -> + commit/rollback flow and hardware tip-presence verification -- the same + machinery ``FlexHead8`` uses for its column ops, applied to the whole + plate/rack instead of one column. + + Coded but **not yet verified on real 96-channel Flex hardware** -- + Vincent's bench Flex carries an 8-channel pipette, not a 96-channel head. + A one-time ``logger.warning`` fires on the first op issued by an instance, + and this docstring makes no "validated on hardware" claim. + """ + + # The Flex API's anchor well for 96-channel ALL-mode whole-plate ops; the + # hardware fans a single command out to all 96 physical nozzles from here. + _ANCHOR_WELL_NAME = "A1" + + def _check_full_coverage(self, itemized: ItemizedResource) -> List[Any]: + """Return ``itemized``'s 96 items, asserting it matches this head's channel count.""" + items = itemized.get_all_items() + if len(items) != self.channels: + raise OpentronsError( + "Labware size mismatch", + f"'{itemized.name}' has {len(items)} positions; FlexHead96 addresses {self.channels}.", + ) + return items + + async def pick_up_tips( + self, + tip_rack: TipRack, + offset: Optional[Coordinate] = None, + ) -> None: + """Pick up all 96 tips: ``configureNozzleLayout`` (ALL) then ONE ``pickUpTip``. + + Anchored at well "A1"; the hardware fans the pickup motion out to all 96 + physical nozzles. Follows stage -> validate -> wire -> verify -> + commit/rollback, same as ``FlexHead8.pick_up_tips``: tip trackers are + staged (``commit=False``) BEFORE the wire command -- so an + already-occupied channel or an invalid tracker state raises before any + hardware motion -- then, after the wire command succeeds, the hardware + tip-presence sensor is checked (``_verify_tips_seated()``); trackers and + ``_channel_tips`` are committed only if that verification passes, and + rolled back (with no ``_channel_tips`` mutation) if the sensor reports a + missed pickup. Only spots that actually had a tip are staged + (None-skip). + """ + self._warn_untested_hardware() + spots = self._check_full_coverage(tip_rack) + + for i, spot in enumerate(spots): + if spot.has_tip() and self._channel_tips[i] is not None: + raise OpentronsError( + "HasTipError", + f"Channel {i} already holds a tip; drop it before picking up another.", + ) + + await self._execute( + "configureNozzleLayout", + {"pipetteId": self.pipette_id, "configurationParams": {"style": "ALL"}}, + ) + + labware_id = await self.flex._ensure_labware_loaded(tip_rack) + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + tips: List[Optional[Tip]] = [None] * len(spots) + for i, spot in enumerate(spots): + if not spot.has_tip(): + continue + tips[i] = spot.get_tip() + if tracking and not spot.tracker.is_disabled: + spot.tracker.remove_tip() # commit=False: stages + validates + staged_trackers.append(spot.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": self._ANCHOR_WELL_NAME, + } + well_location = self._well_location([offset], [None], origin="top") + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_pickup("pickUpTip", params, staged_trackers) + for i, tip in enumerate(tips): + self._channel_tips[i] = tip + self._prepared = False + + async def drop_tips( + self, + target: Union[TipRack, Trash], + ) -> None: + """Drop all 96 tips -- one wire command, fanned to 96 channels. + + A ``TipRack`` target returns tips (one ``dropTip`` command anchored at + "A1"); a ``Trash`` target discards via the addressable-area drop + sequence. Tip trackers are committed only for channels that actually + held a tip (None-skip); trash drops never return tips to a rack + tracker. After the wire drop + tracker commit, ``_confirm_tips_cleared()`` + checks the hardware tip-presence sensor and logs a warning (does not + raise) if it still reports a tip. + """ + self._warn_untested_hardware() + + if isinstance(target, Trash): + await self._execute_trash_drop() + self._channel_tips = [None] * self.channels + await self._confirm_tips_cleared() + return + + spots = self._check_full_coverage(target) + labware_id = await self.flex._ensure_labware_loaded(target) + + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + for i, spot in enumerate(spots): + tip = self._channel_tips[i] + if tip is not None and tracking and not spot.tracker.is_disabled: + spot.tracker.add_tip(tip, commit=False) # stages + validates (HasTipError if occupied) + staged_trackers.append(spot.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": self._ANCHOR_WELL_NAME, + } + + await self._execute_liquid_op("dropTip", params, staged_trackers) + for i in range(len(spots)): + self._channel_tips[i] = None + await self._confirm_tips_cleared() + + async def discard_tips(self, trash: Trash) -> None: + """Discard the mounted 96 tips into the trash.""" + await self.drop_tips(trash) + + async def aspirate( + self, + plate: Plate, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Aspirate the whole plate -- one ``aspirate`` command anchored at "A1". + + Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` + (``remove_liquid``) is staged for every well whose channel actually + holds a tip (None-skip) BEFORE the wire command, so an infeasible + aspirate raises before any hardware motion. A ``prepareToAspirate`` + command is sent first if this is the first aspirate since the last tip + pickup. + """ + self._warn_untested_hardware() + wells = self._check_full_coverage(plate) + labware_id = await self.flex._ensure_labware_loaded(plate) + rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + + tracking = does_volume_tracking() + staged_trackers: List[Any] = [] + if tracking: + for i, well in enumerate(wells): + if self._channel_tips[i] is None or well.tracker.is_disabled: + continue + well.tracker.remove_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": self._ANCHOR_WELL_NAME, + "volume": volume, + "flowRate": rate, + } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_with_prepare("aspirate", params, staged_trackers) + + async def dispense( + self, + plate: Plate, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Dispense to the whole plate -- one ``dispense`` command anchored at "A1". + + Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` + (``add_liquid``) is staged for every well whose channel actually holds a + tip (None-skip) BEFORE the wire command, so an infeasible dispense + raises before any hardware motion. + """ + self._warn_untested_hardware() + wells = self._check_full_coverage(plate) + labware_id = await self.flex._ensure_labware_loaded(plate) + rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + + tracking = does_volume_tracking() + staged_trackers: List[Any] = [] + if tracking: + for i, well in enumerate(wells): + if self._channel_tips[i] is None or well.tracker.is_disabled: + continue + well.tracker.add_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": self._ANCHOR_WELL_NAME, + "volume": volume, + "flowRate": rate, + } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_liquid_op("dispense", params, staged_trackers) diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py new file mode 100644 index 00000000000..9707fbcd34c --- /dev/null +++ b/pylabrobot/opentrons/flex_tests.py @@ -0,0 +1,1061 @@ +"""Tests for OpentronsFlex device shell + head composition (Task 2). + +Drives ``OpentronsFlex.setup()`` with an injected ``ChatterboxTransport`` (no +network) reporting a configurable mounted pipette, and asserts discovery +composes the matching head onto the right attribute (``left``/``right``/ +``head96``). +""" + +import asyncio +import unittest +from typing import List, Tuple + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96 +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import cor_96_wellplate_360uL_Fb, set_tip_tracking, set_volume_tracking +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.errors import TooLittleLiquidError +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + +def _flex(pipette: Tuple[str, int, float, float], mount: str = "right") -> OpentronsFlex: + transport = ChatterboxTransport(pipette=pipette, mount=mount) + return OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + + +def _flex_with_transport( + pipettes: List[Tuple[str, int, float, float, str]], + **transport_kwargs, +) -> Tuple[OpentronsFlex, ChatterboxTransport]: + """Like ``_flex`` but simulates multiple mounted pipettes and returns the + transport too, so a test can inspect recorded commands. + + ``transport_kwargs`` are forwarded to ``ChatterboxTransport`` (e.g. + ``simulate_failed_pickup=True``/``simulate_stuck_tip=True`` to drive the + hardware tip-presence sensor model). + """ + transport = ChatterboxTransport(pipettes=pipettes, **transport_kwargs) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + return flex, transport + + +class TestHeadDiscovery(unittest.TestCase): + """setup() discovers mounted pipettes and composes the matching head per mount.""" + + def test_eight_channel_left_mount_becomes_flex_head8(self): + flex = _flex(("p50_multi_flex", 8, 1.0, 50.0), mount="left") + asyncio.run(flex.setup()) + try: + self.assertIsInstance(flex.left, FlexHead8) + self.assertIsNone(flex.right) + self.assertIsNone(flex.head96) + finally: + asyncio.run(flex.stop()) + + def test_eight_channel_right_mount_becomes_flex_head8(self): + flex = _flex(("p50_multi_flex", 8, 1.0, 50.0), mount="right") + asyncio.run(flex.setup()) + try: + self.assertIsInstance(flex.right, FlexHead8) + self.assertIsNone(flex.left) + self.assertIsNone(flex.head96) + finally: + asyncio.run(flex.stop()) + + def test_single_channel_becomes_flex_head1(self): + flex = _flex(("p1000_single_flex", 1, 1.0, 1000.0), mount="right") + asyncio.run(flex.setup()) + try: + self.assertIsInstance(flex.right, FlexHead1) + self.assertIsNone(flex.left) + self.assertIsNone(flex.head96) + finally: + asyncio.run(flex.stop()) + + def test_ninety_six_channel_becomes_head96_leaves_mounts_none(self): + flex = _flex(("p1000_96", 96, 1.0, 1000.0), mount="left") + asyncio.run(flex.setup()) + try: + self.assertIsInstance(flex.head96, FlexHead96) + self.assertIsNone(flex.left) + self.assertIsNone(flex.right) + finally: + asyncio.run(flex.stop()) + + def test_unsupported_channel_count_raises_opentrons_error(self): + flex = _flex(("weird_pipette", 4, 1.0, 100.0), mount="right") + with self.assertRaises(OpentronsError): + asyncio.run(flex.setup()) + + +class TestNoDoubleLoad(unittest.TestCase): + """Regression for the double-``loadPipette`` bug (base ``setup()`` used to + discover+load the first mount, then ``_model_setup()`` loaded it again). + """ + + def test_single_pipette_is_loaded_exactly_once(self): + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + asyncio.run(flex.setup()) + try: + self.assertEqual(len(transport.load_pipette_commands), 1) + finally: + asyncio.run(flex.stop()) + + +class TestDualMount(unittest.TestCase): + """setup() discovers and composes BOTH mounts when two pipettes are present.""" + + def test_left_and_right_mounts_become_distinct_heads(self): + flex, transport = _flex_with_transport( + [ + ("p50_multi_flex", 8, 1.0, 50.0, "left"), + ("p1000_single_flex", 1, 1.0, 1000.0, "right"), + ] + ) + asyncio.run(flex.setup()) + try: + self.assertIsInstance(flex.left, FlexHead8) + self.assertIsInstance(flex.right, FlexHead1) + assert flex.left is not None and flex.right is not None + self.assertNotEqual(flex.left.pipette_id, flex.right.pipette_id) + self.assertEqual(len(transport.load_pipette_commands), 2) + finally: + asyncio.run(flex.stop()) + + +class TestNoPipetteMounted(unittest.TestCase): + """setup() raises OpentronsError when no pipette is mounted at all.""" + + def test_empty_pipette_list_raises_opentrons_error(self): + flex, _transport = _flex_with_transport([]) + with self.assertRaises(OpentronsError): + asyncio.run(flex.setup()) + + +class TestImpossibleHead96PlusMountCombo(unittest.TestCase): + """A 96-channel head cannot physically coexist with a mount pipette.""" + + def test_head96_plus_mount_pipette_raises_opentrons_error(self): + flex, _transport = _flex_with_transport( + [ + ("p1000_96", 96, 1.0, 1000.0, "left"), + ("p1000_single_flex", 1, 1.0, 1000.0, "right"), + ] + ) + with self.assertRaises(OpentronsError): + asyncio.run(flex.setup()) + + +class TestGetMountedTips(unittest.TestCase): + """get_mounted_tips() returns a list sized to the head's channel count, and a copy.""" + + def test_eight_channel_head_reports_eight_slots(self): + flex = _flex(("p50_multi_flex", 8, 1.0, 50.0), mount="left") + asyncio.run(flex.setup()) + try: + head = flex.left + assert head is not None + tips = head.get_mounted_tips() + self.assertEqual(len(tips), 8) + self.assertTrue(all(tip is None for tip in tips)) + finally: + asyncio.run(flex.stop()) + + def test_returned_list_is_a_copy(self): + flex = _flex(("p1000_single_flex", 1, 1.0, 1000.0), mount="right") + asyncio.run(flex.setup()) + try: + head = flex.right + assert head is not None + tips = head.get_mounted_tips() + tips.append(None) # mutate the returned list; must not affect head state + self.assertEqual(len(head.get_mounted_tips()), 1) + finally: + asyncio.run(flex.stop()) + + def test_ninety_six_channel_head_reports_ninety_six_slots(self): + flex = _flex(("p1000_96", 96, 1.0, 1000.0), mount="left") + asyncio.run(flex.setup()) + try: + head = flex.head96 + assert head is not None + self.assertEqual(len(head.get_mounted_tips()), 96) + finally: + asyncio.run(flex.stop()) + + +def _flex_head8(**transport_kwargs) -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead8]: + """An ``OpentronsFlex`` with an 8-channel head on the left mount, plus the + transport (for command inspection) and the head itself. + + ``transport_kwargs`` are forwarded to ``ChatterboxTransport``. + """ + flex, transport = _flex_with_transport( + [("p50_multi_flex", 8, 1.0, 50.0, "left")], **transport_kwargs + ) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, transport, head + + +class TestFlexHead8ColumnOps(unittest.TestCase): + """Task 3: column ops send exactly ONE wire command anchored at the + column's A-row well; the hardware fans it out to all 8 physical channels; + trackers commit only for wells/spots the head actually actuated. + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_pick_up_tips_emits_one_command_and_fans_to_all_8_channels(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(pickup_cmds), 1) + self.assertEqual(pickup_cmds[0]["params"]["wellName"], "A1") + + column_spots = rack.get_all_items()[0:8] + for spot in column_spots: + self.assertFalse(spot.has_tip()) + + tips = head.get_mounted_tips() + self.assertEqual(sum(1 for t in tips if t is not None), 8) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_emits_one_command_and_only_column_wells_change(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + # Pre-load every well with 100uL so aspirating 50uL is valid, and so a + # baseline exists to prove non-column wells are untouched. + wells = plate.get_all_items() + for well in wells: + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate, column=2, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A3") + + column_2 = set(wells[16:24]) + for well in wells: + expected = 50.0 if well in column_2 else 100.0 + self.assertAlmostEqual(well.tracker.volume, expected, msg=well.name) + finally: + asyncio.run(flex.stop()) + + def test_pick_up_single_tip_configures_nozzle_then_picks_named_well(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_single_tip(rack, well="H2")) + + cmd_types = [c["commandType"] for c in transport.commands] + configure_idx = cmd_types.index("configureNozzleLayout") + pickup_idx = cmd_types.index("pickUpTip") + self.assertLess(configure_idx, pickup_idx) + self.assertEqual(transport.commands[pickup_idx]["params"]["wellName"], "H2") + + tips = head.get_mounted_tips() + self.assertIsNotNone(tips[7]) + for i in range(7): + self.assertIsNone(tips[i], msg=f"channel {i}") + finally: + asyncio.run(flex.stop()) + + def test_dispense_emits_one_command_and_only_column_wells_change(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.dispense(plate, column=5, volume=30)) + + dispense_cmds = [c for c in transport.commands if c["commandType"] == "dispense"] + self.assertEqual(len(dispense_cmds), 1) + self.assertEqual(dispense_cmds[0]["params"]["wellName"], "A6") + + wells = plate.get_all_items() + column_5 = set(wells[40:48]) + for well in wells: + expected = 30.0 if well in column_5 else 0.0 + self.assertAlmostEqual(well.tracker.volume, expected, msg=well.name) + finally: + asyncio.run(flex.stop()) + + def test_drop_tips_to_rack_returns_tips_and_clears_channels(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.drop_tips(rack, column=0)) # return to the column it came from + + drop_cmds = [c for c in transport.commands if c["commandType"] == "dropTip"] + self.assertEqual(len(drop_cmds), 1) + self.assertEqual(drop_cmds[0]["params"]["wellName"], "A1") + + column_0_spots = rack.get_all_items()[0:8] + for spot in column_0_spots: + self.assertTrue(spot.has_tip()) + + self.assertTrue(all(t is None for t in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_discard_tips_uses_addressable_area_trash_sequence(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + trash = flex.deck.get_trash_area() + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.discard_tips(trash)) + + cmd_types = [c["commandType"] for c in transport.commands] + self.assertIn("moveToAddressableAreaForDropTip", cmd_types) + self.assertIn("dropTipInPlace", cmd_types) + move_cmd = next( + c for c in transport.commands if c["commandType"] == "moveToAddressableAreaForDropTip" + ) + self.assertEqual(move_cmd["params"]["addressableAreaName"], "movableTrashA3") + self.assertTrue(all(t is None for t in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_single_tip_aspirate_dispense_and_drop_round_trip(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + trash = flex.deck.get_trash_area() + + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + self.assertIsNotNone(head.get_mounted_tips()[0]) + + asyncio.run(head.dispense_single(plate, well="B3", volume=20)) + target = plate.get_item("B3") + self.assertAlmostEqual(target.tracker.volume, 20.0) + + # Every other well is untouched (single-tip is a strict None-skip case). + for well in plate.get_all_items(): + if well is target: + continue + self.assertAlmostEqual(well.tracker.volume, 0.0, msg=well.name) + + target.tracker.set_volume(20.0) # aspirate needs liquid present + asyncio.run(head.aspirate_single(plate, well="B3", volume=20)) + self.assertAlmostEqual(target.tracker.volume, 0.0) + + asyncio.run(head.drop_single_tip(trash)) + self.assertTrue(all(t is None for t in head.get_mounted_tips())) + + # Nozzle layout is restored to ALL, so a subsequent column op needs no + # extra reset command beyond the ones already issued. + asyncio.run(head.pick_up_tips(rack, column=1)) + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(pickup_cmds[-1]["params"]["wellName"], "A2") + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead8PrepareToAspirate(unittest.TestCase): + """Task 3 fix #1: `prepareToAspirate` must fire once, immediately before the + FIRST aspirate after a tip pickup, and NOT before subsequent aspirates.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_prepare_to_aspirate_sent_before_first_aspirate_only(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate, column=0, volume=10)) + asyncio.run(head.aspirate(plate, column=1, volume=10)) + + cmd_types = [c["commandType"] for c in transport.commands] + prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] + aspirate_indices = [i for i, t in enumerate(cmd_types) if t == "aspirate"] + + self.assertEqual(len(prepare_indices), 1, "prepareToAspirate must fire exactly once") + self.assertEqual(len(aspirate_indices), 2) + self.assertEqual(prepare_indices[0], aspirate_indices[0] - 1) + + prepare_cmd = transport.commands[prepare_indices[0]] + self.assertEqual(prepare_cmd["params"], {"pipetteId": head.pipette_id}) + finally: + asyncio.run(flex.stop()) + + def test_prepare_to_aspirate_refires_after_a_new_pickup(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate, column=0, volume=10)) + asyncio.run(head.drop_tips(rack, column=0)) + asyncio.run(head.pick_up_tips(rack, column=1)) + asyncio.run(head.aspirate(plate, column=1, volume=10)) + + cmd_types = [c["commandType"] for c in transport.commands] + prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] + self.assertEqual(len(prepare_indices), 2, "a new pickup must require a new prepare") + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead8PickupOrigin(unittest.TestCase): + """Task 3 fix #2: tip-pickup offsets must use wellLocation.origin == 'top', + not the 'bottom' origin used for aspirate/dispense.""" + + def test_pick_up_tips_offset_uses_top_origin(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0, offset=Coordinate(x=0, y=0, z=1))) + + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(pickup_cmds), 1) + self.assertEqual(pickup_cmds[0]["params"]["wellLocation"]["origin"], "top") + finally: + asyncio.run(flex.stop()) + + def test_aspirate_offset_still_uses_bottom_origin(self): + set_tip_tracking(True) + set_volume_tracking(True) + try: + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate, column=0, volume=10, offset=Coordinate(x=0, y=0, z=1))) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(aspirate_cmds[0]["params"]["wellLocation"]["origin"], "bottom") + finally: + asyncio.run(flex.stop()) + finally: + set_tip_tracking(False) + set_volume_tracking(False) + + +class TestFlexHead8TransactionalTrackers(unittest.TestCase): + """Task 3 fix #3: infeasible tracker operations must raise BEFORE any wire + command is sent, and must not leave trackers mutated.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_infeasible_aspirate_raises_before_wire_command_and_leaves_trackers_unchanged(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + # Column 0 wells are left at 0uL -- aspirating 50uL is infeasible. + asyncio.run(head.pick_up_tips(rack, column=0)) + + with self.assertRaises(TooLittleLiquidError): + asyncio.run(head.aspirate(plate, column=0, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 0, "no aspirate wire command may be sent") + + for well in plate.get_all_items()[0:8]: + self.assertAlmostEqual(well.tracker.volume, 0.0) + self.assertAlmostEqual(well.tracker.get_used_volume(), 0.0) + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead8DoublePickupGuard(unittest.TestCase): + """Task 3 fix #4: picking up onto an already-occupied channel must raise + OpentronsError rather than silently overwrite head state.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def test_pick_up_tips_onto_occupied_channels_raises(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + with self.assertRaises(OpentronsError): + asyncio.run(head.pick_up_tips(rack, column=1)) + + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(pickup_cmds), 1, "the second (invalid) pickup must not reach the wire") + finally: + asyncio.run(flex.stop()) + + def test_pick_up_single_tip_onto_occupied_channel_raises(self): + set_tip_tracking(True) + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + with self.assertRaises(OpentronsError): + asyncio.run(head.pick_up_single_tip(rack, well="A2")) # same channel (row A) + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead8EnsureAllModeReset(unittest.TestCase): + """Task 3 fix #5: a column op directly after a single-tip pickup (no + intervening drop) must emit a configureNozzleLayout(ALL) reset first.""" + + def test_column_op_after_single_pickup_resets_nozzle_layout(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + # Simulate the mounted single tip having been cleared through a path + # not under test here, so the column op's occupied-channel guard + # (fix #4) doesn't fire -- isolating the nozzle-layout reset (fix #5). + head._channel_tips = [None] * head.channels + self.assertEqual(head._nozzle_layout, "SINGLE") + + asyncio.run(head.pick_up_tips(rack, column=1)) + + cmd_types = [c["commandType"] for c in transport.commands] + configure_indices = [i for i, t in enumerate(cmd_types) if t == "configureNozzleLayout"] + pickup_indices = [i for i, t in enumerate(cmd_types) if t == "pickUpTip"] + # The reset configureNozzleLayout (the one before the column pickUpTip) + # must come before that pickUpTip. + self.assertGreater(len(configure_indices), 1) + self.assertLess(configure_indices[-1], pickup_indices[-1]) + self.assertEqual( + transport.commands[configure_indices[-1]]["params"]["configurationParams"]["style"], + "ALL", + ) + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead8SingleOpFlowRateAndNoneSkip(unittest.TestCase): + """Task 3 fix #7: aspirate_single/dispense_single accept a flow_rate + override, and a partially-filled column pickup leaves missing-tip + channels' wells untouched (None-skip) on a later column aspirate.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_aspirate_single_and_dispense_single_accept_flow_rate_override(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + asyncio.run(head.dispense_single(plate, well="A1", volume=20, flow_rate=99.0)) + asyncio.run(head.aspirate_single(plate, well="A1", volume=20, flow_rate=88.0)) + + dispense_cmd = next(c for c in transport.commands if c["commandType"] == "dispense") + aspirate_cmd = next(c for c in transport.commands if c["commandType"] == "aspirate") + self.assertEqual(dispense_cmd["params"]["flowRate"], 99.0) + self.assertEqual(aspirate_cmd["params"]["flowRate"], 88.0) + finally: + asyncio.run(flex.stop()) + + def test_partially_filled_column_pickup_skips_missing_tip_wells_on_aspirate(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + # Empty out channels B (index 1) and G (index 6) of column 0 before pickup. + column_0_spots = rack.get_all_items()[0:8] + column_0_spots[1].tracker.remove_tip(commit=True) + column_0_spots[6].tracker.remove_tip(commit=True) + + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + tips = head.get_mounted_tips() + self.assertIsNone(tips[1]) + self.assertIsNone(tips[6]) + + asyncio.run(head.aspirate(plate, column=0, volume=20)) + + wells = plate.get_all_items()[0:8] + for i, well in enumerate(wells): + expected = 100.0 if i in (1, 6) else 80.0 + self.assertAlmostEqual(well.tracker.volume, expected, msg=f"channel {i}") + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead8HardwareTipPresence(unittest.TestCase): + """Task 5: the Flex hardware tip-presence sensor (one bool per pipette, + via /instruments -> state.tipDetected) is the aggregate authority used to + verify a pickup seated a tip and to confirm a drop cleared it. + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_has_tip_on_hardware_true_after_successful_pickup(self): + flex, _transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + + self.assertTrue(asyncio.run(head.has_tip_on_hardware())) + finally: + asyncio.run(flex.stop()) + + def test_simulated_failed_pickup_raises_and_leaves_no_tracker_mutation(self): + flex, transport, head = _flex_head8(simulate_failed_pickup=True) + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + with self.assertRaises(OpentronsError): + asyncio.run(head.pick_up_tips(rack, column=0)) + + # The pickUpTip wire command WAS sent (the sensor is what caught the + # failure, not a pre-wire guard) -- but nothing downstream persisted. + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(pickup_cmds), 1) + + column_0_spots = rack.get_all_items()[0:8] + for spot in column_0_spots: + self.assertTrue(spot.has_tip(), msg=f"{spot.name} tracker must not have been committed") + + self.assertTrue(all(t is None for t in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_simulated_failed_pickup_single_tip_raises_and_leaves_no_tracker_mutation(self): + flex, transport, head = _flex_head8(simulate_failed_pickup=True) + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + with self.assertRaises(OpentronsError): + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + + spot = rack.get_item("A1") + self.assertTrue(spot.has_tip()) + self.assertTrue(all(t is None for t in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_has_tip_on_hardware_false_after_drop_tips(self): + flex, _transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.drop_tips(rack, column=0)) + + self.assertFalse(asyncio.run(head.has_tip_on_hardware())) + finally: + asyncio.run(flex.stop()) + + def test_has_tip_on_hardware_false_after_discard_tips(self): + flex, _transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + trash = flex.deck.get_trash_area() + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.discard_tips(trash)) + + self.assertFalse(asyncio.run(head.has_tip_on_hardware())) + finally: + asyncio.run(flex.stop()) + + def test_simulated_stuck_tip_after_drop_logs_warning(self): + flex, _transport, head = _flex_head8(simulate_stuck_tip=True) + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING") as log_ctx: + asyncio.run(head.drop_tips(rack, column=0)) + + self.assertTrue( + any("stuck" in msg.lower() or "clear" in msg.lower() for msg in log_ctx.output) + ) + # Trackers still commit -- the confirm step only warns, never raises. + column_0_spots = rack.get_all_items()[0:8] + for spot in column_0_spots: + self.assertTrue(spot.has_tip()) + self.assertTrue(all(t is None for t in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + +def _flex_head1(**transport_kwargs) -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead1]: + """An ``OpentronsFlex`` with a single-channel head on the right mount, plus + the transport (for command inspection) and the head itself. + + ``transport_kwargs`` are forwarded to ``ChatterboxTransport``. + """ + flex, transport = _flex_with_transport( + [("p1000_single_flex", 1, 1.0, 1000.0, "right")], **transport_kwargs + ) + asyncio.run(flex.setup()) + head = flex.right + assert isinstance(head, FlexHead1) + return flex, transport, head + + +def _flex_head96(**transport_kwargs) -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead96]: + """An ``OpentronsFlex`` with a 96-channel head, plus the transport (for + command inspection) and the head itself. + + ``transport_kwargs`` are forwarded to ``ChatterboxTransport``. + """ + flex, transport = _flex_with_transport( + [("p1000_96", 96, 1.0, 1000.0, "left")], **transport_kwargs + ) + asyncio.run(flex.setup()) + head = flex.head96 + assert isinstance(head, FlexHead96) + return flex, transport, head + + +class TestFlexHead1Ops(unittest.TestCase): + """Task 5: FlexHead1 (single-channel, well-addressed) reuses the FlexHead8 + transactional stage -> wire -> verify -> commit/rollback flow and hardware + tip-presence machinery, addressing exactly one well/tip spot per command + instead of a whole column. + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_pick_up_tips_and_aspirate_emit_one_command_each_and_warn_untested(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + target_well = plate.get_item("B3") + target_well.tracker.set_volume(100.0) + + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING") as log_ctx: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + self.assertTrue(any("not yet verified" in msg.lower() for msg in log_ctx.output)) + + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(pickup_cmds), 1) + self.assertEqual(pickup_cmds[0]["params"]["wellName"], "A1") + self.assertIsNotNone(head.get_mounted_tips()[0]) + self.assertEqual(len(head.get_mounted_tips()), 1) + + # A 2nd warning call must be a no-op (only the FIRST op logs). + with self.assertRaises(AssertionError): + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING"): + asyncio.run(head.aspirate(target_well, volume=10)) + + cmd_types = [c["commandType"] for c in transport.commands] + prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] + aspirate_indices = [i for i, t in enumerate(cmd_types) if t == "aspirate"] + self.assertEqual(len(prepare_indices), 1, "prepareToAspirate must fire exactly once") + self.assertEqual(len(aspirate_indices), 1) + self.assertEqual(prepare_indices[0], aspirate_indices[0] - 1) + self.assertEqual(transport.commands[aspirate_indices[0]]["params"]["wellName"], "B3") + + # Exactly 1 Well tracked -- every other well on the plate is untouched. + for well in plate.get_all_items(): + expected = 90.0 if well is target_well else 0.0 + self.assertAlmostEqual(well.tracker.volume, expected, msg=well.name) + finally: + asyncio.run(flex.stop()) + + def test_double_pickup_onto_occupied_channel_raises(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack1") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(OpentronsError): + asyncio.run(head.pick_up_tips(rack.get_item("A2"))) + + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(pickup_cmds), 1, "the second (invalid) pickup must not reach the wire") + finally: + asyncio.run(flex.stop()) + + def test_simulated_failed_pickup_raises_and_leaves_no_tracker_mutation(self): + flex, transport, head = _flex_head1(simulate_failed_pickup=True) + try: + rack = flex_96_tiprack_50ul(name="rack1") + flex.deck.assign_child_at_slot(rack, "C1") + + with self.assertRaises(OpentronsError): + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + + # The pickUpTip wire command WAS sent (the sensor is what caught the + # failure, not a pre-wire guard) -- but nothing downstream persisted. + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(pickup_cmds), 1) + self.assertTrue(rack.get_item("A1").has_tip(), "tracker must not have been committed") + self.assertTrue(all(t is None for t in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_drop_tips_to_rack_and_discard_to_trash(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack1") + flex.deck.assign_child_at_slot(rack, "C1") + trash = flex.deck.get_trash_area() + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.drop_tips(rack.get_item("A1"))) + self.assertTrue(rack.get_item("A1").has_tip()) + self.assertIsNone(head.get_mounted_tips()[0]) + + asyncio.run(head.pick_up_tips(rack.get_item("A2"))) + asyncio.run(head.discard_tips(trash)) + cmd_types = [c["commandType"] for c in transport.commands] + self.assertIn("moveToAddressableAreaForDropTip", cmd_types) + self.assertIn("dropTipInPlace", cmd_types) + self.assertIsNone(head.get_mounted_tips()[0]) + finally: + asyncio.run(flex.stop()) + + def test_docstring_does_not_claim_hardware_validation(self): + doc = FlexHead1.__doc__ or "" + self.assertNotIn("Validated on real", doc) + + +class TestFlexHead96Ops(unittest.TestCase): + """Task 5: FlexHead96 (96 fixed nozzles, whole-plate-addressed) reuses the + FlexHead8 transactional stage -> wire -> verify -> commit/rollback flow and + hardware tip-presence machinery, fanning ONE command out to all 96 + channels anchored at well "A1". + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_pick_up_tips_configures_all_nozzles_and_picks_at_a1(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack96") + flex.deck.assign_child_at_slot(rack, "C1") + + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING") as log_ctx: + asyncio.run(head.pick_up_tips(rack)) + self.assertTrue(any("not yet verified" in msg.lower() for msg in log_ctx.output)) + + cmd_types = [c["commandType"] for c in transport.commands] + configure_cmds = [ + c for c in transport.commands if c["commandType"] == "configureNozzleLayout" + ] + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(configure_cmds), 1) + self.assertEqual(configure_cmds[0]["params"]["configurationParams"]["style"], "ALL") + self.assertEqual(len(pickup_cmds), 1) + self.assertEqual(pickup_cmds[0]["params"]["wellName"], "A1") + self.assertLess(cmd_types.index("configureNozzleLayout"), cmd_types.index("pickUpTip")) + + tips = head.get_mounted_tips() + self.assertEqual(len(tips), 96) + self.assertTrue(all(t is not None for t in tips)) + for spot in rack.get_all_items(): + self.assertFalse(spot.has_tip()) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_emits_one_command_and_tracks_all_96_wells(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack96") + plate = cor_96_wellplate_360uL_Fb(name="plate96") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.aspirate(plate, volume=50)) + + cmd_types = [c["commandType"] for c in transport.commands] + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") + self.assertEqual(len(prepare_indices), 1, "prepareToAspirate must fire before the aspirate") + + wells = plate.get_all_items() + self.assertEqual(len(wells), 96) + for well in wells: + self.assertAlmostEqual(well.tracker.volume, 50.0, msg=well.name) + finally: + asyncio.run(flex.stop()) + + def test_dispense_and_drop_tips_round_trip(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack96") + plate = cor_96_wellplate_360uL_Fb(name="plate96") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.dispense(plate, volume=30)) + + dispense_cmds = [c for c in transport.commands if c["commandType"] == "dispense"] + self.assertEqual(len(dispense_cmds), 1) + self.assertEqual(dispense_cmds[0]["params"]["wellName"], "A1") + for well in plate.get_all_items(): + self.assertAlmostEqual(well.tracker.volume, 30.0, msg=well.name) + + asyncio.run(head.drop_tips(rack)) + drop_cmds = [c for c in transport.commands if c["commandType"] == "dropTip"] + self.assertEqual(len(drop_cmds), 1) + self.assertEqual(drop_cmds[0]["params"]["wellName"], "A1") + for spot in rack.get_all_items(): + self.assertTrue(spot.has_tip()) + self.assertTrue(all(t is None for t in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_simulated_failed_pickup_raises_and_leaves_no_tracker_mutation(self): + flex, transport, head = _flex_head96(simulate_failed_pickup=True) + try: + rack = flex_96_tiprack_50ul(name="rack96") + flex.deck.assign_child_at_slot(rack, "C1") + + with self.assertRaises(OpentronsError): + asyncio.run(head.pick_up_tips(rack)) + + # The pickUpTip wire command WAS sent (the sensor is what caught the + # failure, not a pre-wire guard) -- but nothing downstream persisted. + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(pickup_cmds), 1) + for spot in rack.get_all_items(): + self.assertTrue(spot.has_tip(), msg=f"{spot.name} tracker must not have been committed") + self.assertTrue(all(t is None for t in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_docstring_does_not_claim_hardware_validation(self): + doc = FlexHead96.__doc__ or "" + self.assertNotIn("Validated on real", doc) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/robot.py b/pylabrobot/opentrons/robot.py new file mode 100644 index 00000000000..f8ab0f39343 --- /dev/null +++ b/pylabrobot/opentrons/robot.py @@ -0,0 +1,291 @@ +import abc +import asyncio +import logging +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, cast + +from pylabrobot.opentrons.transport import HttpxTransport, OpentronsTransport + +logger = logging.getLogger(__name__) + + +class OpentronsError(Exception): + def __init__(self, title: str, message: Optional[str] = None) -> None: + self.title, self.message = title, message + super().__init__(f"{title}: {message}" if message else title) + + +@dataclass +class PipetteInfo: + mount: str + pipette_name: str + pipette_model: str + pipette_id: str + channels: int + min_volume: float + max_volume: float + + +class OpentronsRobot(abc.ABC): + """Shared base for Opentrons HTTP robots (Flex, OT-2). + + Owns the wire transport, the run/command protocol, and instrument discovery. + Subclasses implement the liquid-handling ops and any model-specific setup. + + Transport is an :class:`~pylabrobot.opentrons.transport.OpentronsTransport` + held on the instance — a real ``HttpxTransport`` by default, or a stand-in + (e.g. ``ChatterboxTransport``) injected by the caller for offline use. + PyLabRobot has no pylabrobot.io HTTP transport yet, so this seam lives here + rather than behind a pylabrobot.io primitive. + """ + + def __init__( + self, + host: str, + port: int = 31950, + transport: Optional[OpentronsTransport] = None, + ) -> None: + self.host, self.port = host, port + self.base_url = f"http://{host}:{port}" + self._transport: Optional[OpentronsTransport] = transport + self.run_id: Optional[str] = None + self.pipette: Optional[PipetteInfo] = None + self.api_version: Optional[str] = None + self.robot_model: Optional[str] = None + + async def setup(self) -> None: + await self._connect() + await self._create_run() + await self._model_setup() + + async def stop(self) -> None: + # Always home before releasing the robot so the gantry parks in a known + # pose. Done inside the run (before cancel); a failure here must not block + # disconnect. + try: + await self.home() + except Exception: + logger.warning("home() before stop failed; continuing to disconnect", exc_info=True) + await self._cancel_run() + await self._disconnect() + + @abc.abstractmethod + async def _model_setup(self) -> None: + """Model-specific post-connection setup (home, discover + load pipette(s), etc.). + + Pipette discovery is entirely the subclass's job: the base ``setup()`` + does not call ``_discover_pipette()`` itself, so a model that loads a + single pipette (e.g. a future OT-2 subclass) should call + ``self.pipette = await self._discover_pipette()`` here; a model that + composes multiple mount-addressed heads (e.g. ``OpentronsFlex``) should + discover and load each pipette itself instead. This avoids loading the + same pipette twice. + """ + + # --- Connection Lifecycle --- + + async def _connect(self) -> None: + """Create the transport (unless one was injected) and verify connectivity. + + Sends a health check to confirm the robot is reachable and the robot + server is running (not in Jupyter/Python API mode). + """ + if self._transport is None: + self._transport = HttpxTransport(base_url=self.base_url) + health = await self._get("/health") + self.api_version = health.get("api_version") + self.robot_model = health.get("robot_model", "") + robot_name = health.get("name", "unknown") + logger.info( + "Connected to robot '%s' at %s:%s (API %s, model: %s)", + robot_name, + self.host, + self.port, + self.api_version, + self.robot_model, + ) + + async def _disconnect(self) -> None: + """Close the transport.""" + if self._transport is not None: + await self._transport.close() + self._transport = None + + # --- Low-Level Wire Calls --- + + async def _get(self, path: str) -> Dict[str, Any]: + """Wire GET, return parsed JSON.""" + assert self._transport is not None, "Not connected. Call connect() first." + return await self._transport.get(path) + + async def _post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Wire POST, return parsed JSON.""" + assert self._transport is not None, "Not connected. Call connect() first." + return await self._transport.post(path, json=data or {}) + + async def _delete(self, path: str) -> Dict[str, Any]: + """Wire DELETE, return parsed JSON.""" + assert self._transport is not None, "Not connected. Call connect() first." + return await self._transport.delete(path) + + # --- Run Management --- + + async def _create_run(self) -> str: + """Create a new empty run on the robot. Returns the run ID. + + An empty run (no protocolId) allows sending setup commands + interactively, which is how PLR controls the robot. + """ + result = await self._post("/runs", {"data": {}}) + run_id = cast(str, result["data"]["id"]) + self.run_id = run_id + logger.info("Created run %s", self.run_id) + return run_id + + async def _cancel_run(self) -> None: + """Cancel the current run. Safe to call if no run is active.""" + if self.run_id is None: + return + try: + await self._post( + f"/runs/{self.run_id}/actions", + {"data": {"actionType": "stop"}}, + ) + except Exception: + try: + await self._delete(f"/runs/{self.run_id}") + except Exception: + pass + self.run_id = None + + # --- Command Execution --- + + async def _execute_command( + self, + command_type: str, + params: Dict[str, Any], + wait: bool = True, + timeout: float = 30.0, + ) -> Dict[str, Any]: + """Execute a command within the current run. + + Commands on the robot are asynchronous: the POST returns + immediately with status "queued". If ``wait=True`` (default), + this method polls until the command succeeds or fails. + + Args: + command_type: e.g., "home", "moveToCoordinates", + "aspirateInPlace", "pickUpTip", "loadLabware". + params: Command-specific parameters. + wait: If True, poll until completion. + timeout: Max seconds to wait. + + Returns: + The completed command data dict (includes "result" field). + + Raises: + RuntimeError: If the command fails or times out. + """ + assert self.run_id is not None, "No active run. Call create_run() first." + payload = { + "data": { + "commandType": command_type, + "params": params, + "intent": "setup", + } + } + result = await self._post(f"/runs/{self.run_id}/commands", payload) + cmd_data: Dict[str, Any] = result.get("data", {}) + + if not wait: + return cmd_data + + cmd_id = cmd_data.get("id", "") + if not cmd_id: + return cmd_data + + # Poll for completion + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + resp = await self._get(f"/runs/{self.run_id}/commands/{cmd_id}") + cmd_data = resp.get("data", {}) + status = cmd_data.get("status", "") + if status == "succeeded": + return cmd_data + elif status == "failed": + error = cmd_data.get("error", {}) + raise RuntimeError( + f"Opentrons command '{command_type}' failed: {error.get('detail', error)}" + ) + await asyncio.sleep(0.2) + + raise RuntimeError(f"Opentrons command '{command_type}' timed out after {timeout}s") + + # --- Instrument Discovery --- + + async def _get_instruments(self) -> Dict[str, Any]: + """Query mounted instruments (pipettes, gripper).""" + return await self._get("/instruments") + + def _parse_pipettes(self, instruments_data: Dict[str, Any]) -> List[PipetteInfo]: + """Parse the /instruments response into PipetteInfo objects. + + Uses actual data from the API (channels, min_volume, max_volume) + rather than guessing from pipette names. + """ + pipettes = [] + for instrument in instruments_data.get("data", []): + if instrument.get("instrumentType") != "pipette": + continue + pip_data = instrument.get("data", {}) + pipettes.append( + PipetteInfo( + mount=instrument.get("mount", "unknown"), + pipette_name=instrument.get("instrumentName", "unknown"), + pipette_model=instrument.get("instrumentModel", "unknown"), + pipette_id="", # set by _load_pipette() later + channels=pip_data.get("channels", 1), + min_volume=pip_data.get("min_volume", 1.0), + max_volume=pip_data.get("max_volume", 1000.0), + ) + ) + return pipettes + + # --- Pipette Loading --- + + async def _load_pipette(self, pipette_name: str, mount: str) -> str: + """Load a pipette into the current run. + + Returns the run-scoped pipette ID required by all subsequent + commands (pickUpTip, aspirateInPlace, moveToCoordinates, etc.). + Must be called after _create_run(). + """ + result = await self._execute_command( + "loadPipette", + {"pipetteName": pipette_name, "mount": mount}, + wait=True, + ) + pipette_id: str = result.get("result", {}).get("pipetteId", "") + logger.info( + "Loaded pipette %s on %s mount -> ID: %s", + pipette_name, + mount, + pipette_id, + ) + return pipette_id + + # --- Homing --- + + async def home(self) -> Dict[str, Any]: + """Home all axes. The gantry moves to the rear-left-top.""" + return await self._execute_command("home", {}) + + async def _discover_pipette(self) -> PipetteInfo: + data = await self._get_instruments() + pipettes = self._parse_pipettes(data) + if not pipettes: + raise OpentronsError("No pipette detected", f"{self.host}:{self.port}") + pip = pipettes[0] + pip.pipette_id = await self._load_pipette(pip.pipette_name, pip.mount) + return pip diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py new file mode 100644 index 00000000000..9071ad3072e --- /dev/null +++ b/pylabrobot/opentrons/transport.py @@ -0,0 +1,216 @@ +"""Swappable wire-level transport for :class:`~pylabrobot.opentrons.robot.OpentronsRobot`. + +``OpentronsRobot`` talks to the robot-server's Protocol-Engine HTTP API +(``/health``, ``/runs``, ``/instruments``, ``/runs/{id}/commands``). Everything +it needs from the wire is three verbs (``get``/``post``/``delete``) that return +parsed JSON, plus a ``close()`` to tear the connection down. That surface is +captured here as the :class:`OpentronsTransport` Protocol so the robot can be +driven by a real ``httpx.AsyncClient`` (:class:`HttpxTransport`) or by an +offline recording stand-in (:class:`ChatterboxTransport`) without knowing the +difference. + +``ChatterboxTransport`` is the transport-level analog of Hamilton's +``STARChatterboxDriver`` (which logs firmware commands instead of sending them +over USB): it logs each command and returns a canned "succeeded" response, so +the robot lifecycle (health check, create-run, instrument discovery) — and any +PLR-native checks layered on top of it — can run with no network. +""" + +import logging +from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, cast, runtime_checkable + +try: + import httpx # type: ignore[import-not-found] + + _HAS_HTTPX = True +except ImportError: + _HAS_HTTPX = False + +logger = logging.getLogger(__name__) + + +@runtime_checkable +class OpentronsTransport(Protocol): + """Wire-level seam: the subset of HTTP that ``OpentronsRobot`` needs. + + Implementations return parsed JSON bodies directly (no response object) — + raising for non-2xx status is the transport's job, not the robot's. + """ + + async def get(self, path: str) -> Dict[str, Any]: ... + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ... + + async def delete(self, path: str) -> Dict[str, Any]: ... + + async def close(self) -> None: ... + + +class HttpxTransport: + """Real transport: wraps an ``httpx.AsyncClient`` against the robot-server.""" + + def __init__( + self, + base_url: str, + timeout: float = 30.0, + headers: Optional[Dict[str, str]] = None, + ) -> None: + if not _HAS_HTTPX: + raise RuntimeError("httpx is required. Install with: pip install httpx") + self._client = httpx.AsyncClient( + base_url=base_url, + timeout=timeout, + headers=headers or {"opentrons-version": "3"}, + ) + + async def get(self, path: str) -> Dict[str, Any]: + response = await self._client.get(path) + response.raise_for_status() + return cast(Dict[str, Any], response.json()) + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + response = await self._client.post(path, json=json or {}) + response.raise_for_status() + return cast(Dict[str, Any], response.json()) + + async def delete(self, path: str) -> Dict[str, Any]: + response = await self._client.delete(path) + response.raise_for_status() + return cast(Dict[str, Any], response.json()) + + async def close(self) -> None: + await self._client.aclose() + + +class ChatterboxTransport: + """Offline transport: logs commands, returns canned 'succeeded' responses. + + Instead of reaching a robot server it returns the fixed ``/health``, + ``/instruments``, ``/runs`` and ``/runs/{id}/commands`` shapes the + ``OpentronsRobot`` lifecycle (``setup()``: health check, create-run, + discover pipette) reads, so a caller can drive the robot with no network. + + Scope: this exercises PLR-native checks only. It does NOT reproduce the + Opentrons Protocol Engine's *analysis* stage (deck-conflict, capacity, + partial-tip extents) — that is protocol-file based (``opentrons_simulate`` + against a virtual Protocol Engine) and needs the ``opentrons`` package, + which an HTTP transport cannot reach. + """ + + def __init__( + self, + pipette: Tuple[str, int, float, float] = ("p1000_single_flex", 1, 1.0, 1000.0), + mount: str = "right", + pipettes: Optional[List[Tuple[str, int, float, float, str]]] = None, + log: Optional[Callable[..., None]] = None, + simulate_failed_pickup: bool = False, + simulate_stuck_tip: bool = False, + ) -> None: + """Args: + pipette: the simulated mounted pipette as ``(name, channels, min_vol, max_vol)``. + Configurable so callers can simulate a 1/8/96-channel head. Ignored if + ``pipettes`` is given. + mount: which mount ``/instruments`` reports ``pipette`` on (``"left"`` or + ``"right"``), so tests can drive left- vs right-mount discovery. Ignored + if ``pipettes`` is given. + pipettes: the simulated mounted pipettes as a list of + ``(name, channels, min_vol, max_vol, mount)`` — one entry per mount, so + tests can simulate multiple pipettes (e.g. left + right) at once. Pass + ``[]`` to simulate no pipette mounted. Takes precedence over + ``pipette``/``mount`` when given (even when empty). + log: where to send the per-command chatter (defaults to this module's logger). + simulate_failed_pickup: if True, a ``pickUpTip`` command does NOT flip the + issuing pipette's simulated tip-presence sensor to detected -- models a + hardware pickup that moved through the motion but never seated a tip, + so ``_FlexHead._verify_tips_seated()`` sees ``tipDetected: False`` and + raises. Default False: a pickup always seats a tip (existing behavior). + simulate_stuck_tip: if True, ``dropTip``/``dropTipInPlace`` do NOT clear + the issuing pipette's simulated tip-presence sensor -- models a tip + stuck to the nozzle after a drop, so ``_FlexHead._confirm_tips_cleared()`` + sees ``tipDetected: True`` and logs a warning. Default False: a drop + always clears the sensor (existing behavior). + """ + if pipettes is not None: + self._pipettes: List[Tuple[str, int, float, float, str]] = list(pipettes) + else: + name, channels, min_v, max_v = pipette + self._pipettes = [(name, channels, min_v, max_v, mount)] + self._log = log or logger.info + self._cmds: Dict[str, Dict[str, Any]] = {} # cmd_id -> full command data + self._n = 0 + self._pipette_load_count = 0 + self.load_pipette_commands: List[Dict[str, Any]] = [] # recorded loadPipette params + self.commands: List[Dict[str, Any]] = [] # every command, in send order: {commandType, params} + self.simulate_failed_pickup = simulate_failed_pickup + self.simulate_stuck_tip = simulate_stuck_tip + # Per-mount simulated hardware tip-presence sensor state (Flex reports + # ONE bool per pipette, not per nozzle -- see /instruments below). + self._tip_detected: Dict[str, bool] = {mount: False for *_rest, mount in self._pipettes} + # pipetteId (as returned by loadPipette) -> mount, so a later + # pickUpTip/dropTip command's pipetteId can be resolved back to a mount. + self._pipette_id_to_mount: Dict[str, str] = {} + + async def get(self, path: str) -> Dict[str, Any]: + if path == "/health": + return {"api_version": "dry-run", "robot_model": "OT-3 Standard", "name": "chatterbox"} + if path == "/instruments": + return { + "data": [ + { + "instrumentType": "pipette", + "mount": mount, + "instrumentName": name, + "instrumentModel": name, + "data": {"channels": channels, "min_volume": min_v, "max_volume": max_v}, + "state": {"tipDetected": self._tip_detected.get(mount, False)}, + } + for name, channels, min_v, max_v, mount in self._pipettes + ] + } + if "/commands/" in path: # a poll for one command's status + cmd_id = path.rsplit("/", 1)[-1] + cmd_data = self._cmds.get( + cmd_id, {"id": cmd_id, "commandType": "", "status": "succeeded", "result": {}} + ) + return {"data": cmd_data} + return {"data": {}} + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if path == "/runs": + return {"data": {"id": "chatterbox-run"}} + if path.endswith("/commands"): + data = (json or {}).get("data", {}) + ctype = data.get("commandType", "?") + params = data.get("params", {}) + self._n += 1 + cmd_id = f"cmd-{self._n}" + self.commands.append({"commandType": ctype, "params": dict(params)}) + if ctype == "loadPipette": + self._pipette_load_count += 1 + pipette_id = f"chatterbox-pip-{self._pipette_load_count}" + result = {"pipetteId": pipette_id} + self.load_pipette_commands.append(dict(params)) + mount = params.get("mount") + if mount is not None: + self._pipette_id_to_mount[pipette_id] = mount + else: + result = {} + if ctype == "pickUpTip": + mount = self._pipette_id_to_mount.get(params.get("pipetteId")) + if mount is not None: + self._tip_detected[mount] = not self.simulate_failed_pickup + elif ctype in ("dropTip", "dropTipInPlace"): + mount = self._pipette_id_to_mount.get(params.get("pipetteId")) + if mount is not None: + self._tip_detected[mount] = self.simulate_stuck_tip + cmd_data = {"id": cmd_id, "commandType": ctype, "status": "succeeded", "result": result} + self._cmds[cmd_id] = cmd_data + self._log("Chatterbox: %s %s", ctype, params) + return {"data": cmd_data} + return {"data": {}} # e.g. /actions + + async def delete(self, path: str) -> Dict[str, Any]: + return {"data": {}} + + async def close(self) -> None: + return None diff --git a/pylabrobot/opentrons/transport_tests.py b/pylabrobot/opentrons/transport_tests.py new file mode 100644 index 00000000000..f854fbea9ff --- /dev/null +++ b/pylabrobot/opentrons/transport_tests.py @@ -0,0 +1,166 @@ +"""Tests for the OpentronsRobot transport seam (Protocol + chatterbox).""" + +import asyncio +import unittest +from typing import Any, Dict, List + +from pylabrobot.opentrons.robot import OpentronsRobot +from pylabrobot.opentrons.transport import ChatterboxTransport, OpentronsTransport + + +class _StubRobot(OpentronsRobot): + """Minimal concrete subclass so we can exercise the shared lifecycle. + + Stands in for a future single-pipette OT-2 subclass: discovery is the + subclass's job (the base ``setup()`` no longer calls + ``_discover_pipette()`` itself), so ``_model_setup()`` calls it here. + """ + + async def _model_setup(self) -> None: + self.pipette = await self._discover_pipette() + + +class _NoOpStubRobot(OpentronsRobot): + """A subclass whose ``_model_setup()`` does nothing — no pipette discovery.""" + + async def _model_setup(self) -> None: + pass + + +class TestChatterboxTransportProtocol(unittest.TestCase): + """ChatterboxTransport satisfies the OpentronsTransport Protocol.""" + + def test_is_instance_of_protocol(self): + transport: OpentronsTransport = ChatterboxTransport() + self.assertIsInstance(transport, OpentronsTransport) + + def test_post_commands_returns_succeeded_shaped_dict(self): + transport = ChatterboxTransport() + payload: Dict[str, Any] = {"data": {"commandType": "home", "params": {}, "intent": "setup"}} + result = asyncio.run(transport.post("/runs/some-run/commands", json=payload)) + data = result["data"] + self.assertEqual(data["status"], "succeeded") + self.assertEqual(data["commandType"], "home") + self.assertIn("result", data) + + def test_close_is_a_noop(self): + transport = ChatterboxTransport() + asyncio.run(transport.close()) # must not raise + + +class TestOpentronsRobotWithInjectedChatterbox(unittest.TestCase): + """An injected ChatterboxTransport lets setup() complete with no network.""" + + def test_setup_completes_offline(self): + transport = ChatterboxTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0)) + robot = _StubRobot(host="localhost", transport=transport) + asyncio.run(robot.setup()) + try: + self.assertIs(robot._transport, transport) + self.assertEqual(robot.api_version, "dry-run") + self.assertIsNotNone(robot.run_id) + self.assertIsNotNone(robot.pipette) + assert robot.pipette is not None + self.assertEqual(robot.pipette.channels, 1) + self.assertEqual(robot.pipette.pipette_id, "chatterbox-pip-1") + finally: + asyncio.run(robot.stop()) + + def test_setup_discovers_configured_channel_count(self): + transport = ChatterboxTransport(pipette=("p50_multi_flex", 8, 1.0, 50.0)) + robot = _StubRobot(host="localhost", transport=transport) + asyncio.run(robot.setup()) + try: + assert robot.pipette is not None + self.assertEqual(robot.pipette.channels, 8) + finally: + asyncio.run(robot.stop()) + + def test_home_routes_through_injected_transport(self): + transport = ChatterboxTransport() + robot = _StubRobot(host="localhost", transport=transport) + asyncio.run(robot.setup()) + try: + result = asyncio.run(robot.home()) + self.assertEqual(result["status"], "succeeded") + finally: + asyncio.run(robot.stop()) + + +class TestBaseSetupDoesNotDiscoverPipette(unittest.TestCase): + """Regression for the double-``loadPipette`` bug: base ``setup()`` must not + call ``_discover_pipette()`` itself — that is entirely ``_model_setup()``'s + job, so a subclass whose ``_model_setup()`` skips discovery loads zero + pipettes, not one. + """ + + def test_no_op_model_setup_loads_no_pipette(self): + transport = ChatterboxTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0)) + robot = _NoOpStubRobot(host="localhost", transport=transport) + asyncio.run(robot.setup()) + try: + self.assertIsNone(robot.pipette) + self.assertEqual(len(transport.load_pipette_commands), 0) + finally: + asyncio.run(robot.stop()) + + +class TestChatterboxTransportMultiplePipettes(unittest.TestCase): + """ChatterboxTransport can simulate more than one mounted pipette.""" + + def test_pipettes_kwarg_reports_both_mounts(self): + transport = ChatterboxTransport( + pipettes=[ + ("p50_multi_flex", 8, 1.0, 50.0, "left"), + ("p1000_single_flex", 1, 1.0, 1000.0, "right"), + ] + ) + result = asyncio.run(transport.get("/instruments")) + mounts = {entry["mount"]: entry for entry in result["data"]} + self.assertEqual(set(mounts), {"left", "right"}) + self.assertEqual(mounts["left"]["data"]["channels"], 8) + self.assertEqual(mounts["right"]["data"]["channels"], 1) + + def test_empty_pipettes_list_reports_no_instruments(self): + transport = ChatterboxTransport(pipettes=[]) + result = asyncio.run(transport.get("/instruments")) + self.assertEqual(result["data"], []) + + def test_single_pipette_kwarg_still_works(self): + transport = ChatterboxTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="left") + result = asyncio.run(transport.get("/instruments")) + self.assertEqual(len(result["data"]), 1) + self.assertEqual(result["data"][0]["mount"], "left") + + def test_load_pipette_commands_are_recorded_with_distinct_ids(self): + transport = ChatterboxTransport( + pipettes=[ + ("p50_multi_flex", 8, 1.0, 50.0, "left"), + ("p1000_single_flex", 1, 1.0, 1000.0, "right"), + ] + ) + + async def _load_both() -> List[str]: + ids: List[str] = [] + for name, mount in (("p50_multi_flex", "left"), ("p1000_single_flex", "right")): + result = await transport.post( + "/runs/some-run/commands", + json={ + "data": { + "commandType": "loadPipette", + "params": {"pipetteName": name, "mount": mount}, + "intent": "setup", + } + }, + ) + ids.append(result["data"]["result"]["pipetteId"]) + return ids + + ids = asyncio.run(_load_both()) + self.assertEqual(len(ids), 2) + self.assertEqual(len(set(ids)), 2) # distinct pipetteIds + self.assertEqual(len(transport.load_pipette_commands), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/resources/opentrons/__init__.py b/pylabrobot/resources/opentrons/__init__.py index 38d1dc2e55c..30e16b0809c 100644 --- a/pylabrobot/resources/opentrons/__init__.py +++ b/pylabrobot/resources/opentrons/__init__.py @@ -1,4 +1,7 @@ from .deck import OTDeck +from .flex_deck import FlexDeck +from .flex_plates import corning_96_wellplate_360ul_flat, flex_plate +from .flex_tip_racks import * from .load import load_ot_tip_rack from .module import OTModule from .ot2_geometry import OT2RobotGeometry diff --git a/pylabrobot/resources/opentrons/flex_deck.py b/pylabrobot/resources/opentrons/flex_deck.py new file mode 100644 index 00000000000..4cd847bc2a7 --- /dev/null +++ b/pylabrobot/resources/opentrons/flex_deck.py @@ -0,0 +1,362 @@ +"""FlexDeck — Opentrons Flex deck with A1–D3 grid layout plus staging area. + +The Flex has 12 standard slots in a 4-row x 3-column grid (rows A–D +from rear to front, columns 1–3 from left to right), plus 4 staging +area slots in column 4. + +Coordinates sourced from Opentrons ot3_standard deck definition v5. +Slot bounding box: 128.0 x 86.0 mm. + +Provides collision detection for single-nozzle tip pickup: when +the 8-channel pipette uses only 1 nozzle, the other 7 extend into +the adjacent slot's airspace and could hit tall labware. +""" + +from __future__ import annotations + +import re +from typing import Dict, Optional + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.deck import Deck +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.trash import Trash + +# OT-2 slot number → Flex slot identifier mapping +_OT2_TO_FLEX = { + 1: "D1", + 2: "D2", + 3: "D3", + 4: "C1", + 5: "C2", + 6: "C3", + 7: "B1", + 8: "B2", + 9: "B3", + 10: "A1", + 11: "A2", + 12: "A3", +} + +# Valid slot pattern: A-D followed by 1-4 +_SLOT_PATTERN = re.compile(r"^[A-D][1-4]$") + +# Row ordering from front (D, index 0) to rear (A, index 3) +_ROW_ORDER = ["D", "C", "B", "A"] + +# Slot coordinates (mm) from ot3_standard.json v5 cutout positions. +# Origin is front-left corner of slot D1. +SLOT_LOCATIONS: Dict[str, Dict[str, float]] = { + "D1": {"x": 0.0, "y": 0.0, "z": 0.0}, + "D2": {"x": 164.0, "y": 0.0, "z": 0.0}, + "D3": {"x": 328.0, "y": 0.0, "z": 0.0}, + "C1": {"x": 0.0, "y": 107.0, "z": 0.0}, + "C2": {"x": 164.0, "y": 107.0, "z": 0.0}, + "C3": {"x": 328.0, "y": 107.0, "z": 0.0}, + "B1": {"x": 0.0, "y": 214.0, "z": 0.0}, + "B2": {"x": 164.0, "y": 214.0, "z": 0.0}, + "B3": {"x": 328.0, "y": 214.0, "z": 0.0}, + "A1": {"x": 0.0, "y": 321.0, "z": 0.0}, + "A2": {"x": 164.0, "y": 321.0, "z": 0.0}, + "A3": {"x": 328.0, "y": 321.0, "z": 0.0}, +} + +# Staging area coordinates (column 4). +STAGING_LOCATIONS: Dict[str, Dict[str, float]] = { + "D4": {"x": 492.0, "y": 0.0, "z": 14.5}, + "C4": {"x": 492.0, "y": 107.0, "z": 14.5}, + "B4": {"x": 492.0, "y": 214.0, "z": 14.5}, + "A4": {"x": 492.0, "y": 321.0, "z": 14.5}, +} + +# Slot bounding box (mm) +SLOT_WIDTH = 128.0 # x dimension +SLOT_DEPTH = 86.0 # y dimension + +# Overall deck footprint (mm), including the frame around the slot grid. +_DECK_SIZE_X = 855.0 +_DECK_SIZE_Y = 582.0 + +# Default clearance Z when no operation height is provided. +# Conservative estimate — measured at 51.3mm on real hardware +# (tip at bottom of flat well plate, A1 nozzle to deck surface). +_DEFAULT_CLEARANCE_Z = 50.0 + + +class FlexDeck(Deck): + """Opentrons Flex deck — 16 slots with placement and collision detection. + + Each slot (the 12 standard A1–D3 slots plus the 4 staging slots A4–D4) + is modeled as a :class:`ResourceHolder` child assigned into this deck's + resource tree, so labware placed at a slot is properly parented — + ``resource.parent`` walks up through the slot holder to this deck, and + ``get_absolute_location()`` works through the standard PLR mechanism. + Labware is placed into a slot's holder with :meth:`assign_child_at_slot`. + + Example:: + + deck = FlexDeck() + deck.assign_child_at_slot(tip_rack, slot="C1") + print(deck.summary()) + deck.check_single_nozzle_clearance("C3", primary_nozzle="H1") + """ + + def __init__( + self, + with_trash_bin: bool = True, + name: str = "flex_deck", + ) -> None: + super().__init__(size_x=_DECK_SIZE_X, size_y=_DECK_SIZE_Y, size_z=0.0, name=name) + + self._slot_holders: Dict[str, ResourceHolder] = {} + for slot_id, loc in {**SLOT_LOCATIONS, **STAGING_LOCATIONS}.items(): + holder = ResourceHolder( + name=f"{self.name}_slot_{slot_id}", + size_x=SLOT_WIDTH, + size_y=SLOT_DEPTH, + size_z=0, + ) + self._slot_holders[slot_id] = holder + super().assign_child_resource(holder, location=Coordinate(x=loc["x"], y=loc["y"], z=loc["z"])) + + if with_trash_bin: + trash = Trash(name="trash", size_x=SLOT_WIDTH, size_y=SLOT_DEPTH, size_z=82.0) + self.assign_child_at_slot(trash, "A3") + + # --- Slot Validation --- + + @staticmethod + def _validate_slot(slot: str) -> str: + """Validate and normalize a slot identifier. Returns uppercase slot.""" + slot = slot.upper() + if _SLOT_PATTERN.match(slot): + return slot + + # Check if user passed an OT-2 integer slot + try: + ot2_slot = int(slot) + if 1 <= ot2_slot <= 12: + flex_slot = _OT2_TO_FLEX[ot2_slot] + raise ValueError( + f"'{slot}' looks like an OT-2 slot number. " + f"The Flex uses letter-number identifiers: " + f"slot {ot2_slot} on OT-2 is '{flex_slot}' on the Flex. " + f"Use deck.assign_child_at_slot(resource, slot='{flex_slot}')." + ) + except ValueError as e: + if "OT-2" in str(e): + raise + + raise ValueError( + f"Invalid slot identifier '{slot}'. " + f"Must be A1–D3 (standard) or A4–D4 (staging). " + f"Examples: 'C1', 'A3', 'B4'." + ) + + # --- Slot Access --- + + def get_slot_location(self, slot: str) -> Dict[str, float]: + """Get the XYZ coordinate for a slot.""" + slot = self._validate_slot(slot) + if slot in SLOT_LOCATIONS: + return SLOT_LOCATIONS[slot] + if slot in STAGING_LOCATIONS: + return STAGING_LOCATIONS[slot] + raise ValueError(f"Unknown slot '{slot}'.") + + def assign_child_at_slot(self, resource: Resource, slot: str) -> None: + """Place a resource at a named slot. + + Args: + resource: The resource (tip rack, plate, etc.) to place. + slot: Slot identifier, e.g., "C1", "A4". + + Raises: + ValueError: If slot is invalid or already occupied. + """ + slot = self._validate_slot(slot) + holder = self._slot_holders[slot] + if holder.resource is not None: + name = getattr(holder.resource, "name", str(holder.resource)) + raise ValueError(f"Slot {slot} is already occupied by '{name}'.") + holder.assign_child_resource(resource) + + def unassign_child_at_slot(self, slot: str) -> None: + """Remove a resource from a slot.""" + slot = self._validate_slot(slot) + holder = self._slot_holders[slot] + if holder.resource is not None: + holder.unassign_child_resource(holder.resource) + + def get_slot(self, resource: Resource) -> Optional[str]: + """Get the slot identifier for a placed resource, or None.""" + for slot_id, holder in self._slot_holders.items(): + if holder.resource is resource: + return slot_id + return None + + def get_resource_at_slot(self, slot: str) -> Optional[Resource]: + """Return the resource placed at a slot, or None.""" + slot = self._validate_slot(slot) + return self._slot_holders[slot].resource + + def get_trash_area(self) -> Trash: + """Return the trash resource (default at A3).""" + for holder in self._slot_holders.values(): + if isinstance(holder.resource, Trash): + return holder.resource + raise ValueError("No trash area configured on this deck.") + + # --- OT-2 Conversion --- + + @staticmethod + def ot2_slot_to_flex(ot2_slot: int) -> str: + """Convert an OT-2 slot number to the Flex equivalent. + + Useful for migrating protocols. E.g., 5 → "C2". + """ + if ot2_slot not in _OT2_TO_FLEX: + mapping = ", ".join(f"{k}→{v}" for k, v in sorted(_OT2_TO_FLEX.items())) + raise ValueError(f"OT-2 slot must be 1–12, got {ot2_slot}. Full mapping: {mapping}") + return _OT2_TO_FLEX[ot2_slot] + + # --- Collision Detection --- + + def check_single_nozzle_clearance( + self, + slot: str, + primary_nozzle: str = "H1", + operation_z: Optional[float] = None, + ) -> None: + """Check that adjacent slots are clear for single-nozzle operations. + + When an 8-channel pipette uses a single nozzle, the 7 inactive + nozzles extend ~63mm into the adjacent slot's airspace. Two rules: + + 1. TipRack in adjacent slot → always blocked (inactive nozzles + would physically engage tips). + 2. Other labware → blocked if taller than the operation Z + (the height the nozzle descends to). + + Args: + slot: Deck slot where the operation happens. + primary_nozzle: "H1" (front) or "A1" (rear). + operation_z: The Z height the nozzle descends to (mm). + If None, uses the default conservative threshold. + + Raises: + ValueError: If a collision risk is detected. + """ + from pylabrobot.resources.tip_rack import TipRack + + slot = self._validate_slot(slot) + row = slot[0] + col = slot[1] + row_idx = _ROW_ORDER.index(row) + + if primary_nozzle == "H1": + # Front nozzle → inactive extend toward rear + if row_idx + 1 < len(_ROW_ORDER): + danger_slot = f"{_ROW_ORDER[row_idx + 1]}{col}" + else: + return # Rearmost row (A), nothing behind + elif primary_nozzle == "A1": + # Rear nozzle → inactive extend toward front + if row_idx - 1 >= 0: + danger_slot = f"{_ROW_ORDER[row_idx - 1]}{col}" + else: + return # Frontmost row (D), nothing in front + else: + return # Other nozzle configs — skip for now + + resource = self._slot_holders[danger_slot].resource + if resource is None: + return # Slot empty, safe + + direction = "behind" if primary_nozzle == "H1" else "in front of" + name = getattr(resource, "name", str(resource)) + + # Rule 1: TipRack always blocked — nozzles would grab tips + if isinstance(resource, TipRack): + raise ValueError( + f"Collision risk: single-nozzle operation at {slot} " + f"with nozzle {primary_nozzle} — the 7 inactive nozzles " + f"extend into slot {danger_slot}, which contains tip rack " + f"'{name}'. Inactive nozzles would engage tips. " + f"Move the tip rack or use a different nozzle direction." + ) + + # Rule 2: Other labware — check against operation Z + if hasattr(resource, "get_size_z"): + resource_z = resource.get_size_z() + else: + resource_z = getattr(resource, "_size_z", 0) or getattr(resource, "size_z", 0) + + clearance_z = operation_z if operation_z is not None else _DEFAULT_CLEARANCE_Z + + if resource_z > clearance_z: + raise ValueError( + f"Collision risk: single-nozzle operation at {slot} " + f"with nozzle {primary_nozzle} — the 7 inactive nozzles " + f"extend into slot {danger_slot} at Z={clearance_z:.0f}mm, " + f"which contains '{name}' (height {resource_z:.0f}mm). " + f"Move '{name}' to a different slot, or use a slot with " + f"no tall labware {direction} it." + ) + + def check_deck_clearance(self, slot: str, operation: str = "move") -> None: + """Verify a slot has labware for an operation that requires it.""" + slot = self._validate_slot(slot) + resource = self._slot_holders[slot].resource + if resource is None and operation in ("pick_up_tips", "aspirate", "dispense"): + raise ValueError( + f"Cannot {operation} at slot {slot}: no labware assigned. " + f"Use deck.assign_child_at_slot(resource, slot='{slot}') first." + ) + + # --- Summary --- + + def summary(self) -> str: + """ASCII representation of the Flex deck. + + Example:: + + Flex Deck (855mm x 582mm) + + +----------+----------+----------+----------+ + | A1 | A2 | A3 | A4 | + | Empty | Empty | trash | (staging)| + +----------+----------+----------+----------+ + | B1 | B2 | B3 | B4 | + | Empty | Empty | Empty | (staging)| + +----------+----------+----------+----------+ + ... + """ + + def _slot_label(slot_id: str) -> str: + resource = self._slot_holders[slot_id].resource + if resource is None: + if slot_id.endswith("4"): + return "(staging)" + return "Empty" + name = getattr(resource, "name", str(resource)) + if len(name) > 8: + name = name[:6] + ".." + return name + + sep = "+----------+----------+----------+----------+" + lines = [ + f"Flex Deck ({self.get_absolute_size_x():g}mm x {self.get_absolute_size_y():g}mm)", + "", + sep, + ] + + for row_letter in "ABCD": + row_ids = [f"| {row_letter}{col} " for col in "1234"] + row_names = [f"| {_slot_label(f'{row_letter}{col}'):8s} " for col in "1234"] + lines.append("".join(row_ids) + "|") + lines.append("".join(row_names) + "|") + lines.append(sep) + + return "\n".join(lines) diff --git a/pylabrobot/resources/opentrons/flex_plates.py b/pylabrobot/resources/opentrons/flex_plates.py new file mode 100644 index 00000000000..454e824f0ba --- /dev/null +++ b/pylabrobot/resources/opentrons/flex_plates.py @@ -0,0 +1,111 @@ +"""Flex plate definitions — thin, name-based labware. + +Geometry here is **nominal**, not authoritative: a standard 96-position SBS +grid (127.76 x 85.48 mm footprint, 9 mm pitch) used only so PLR has named +``Well`` objects to hang volume-tracking state on. The *real* labware +definition lives on the Flex robot itself — when a plate is loaded, PLR sends +the robot its Opentrons load name (``ot_load_name``) and the robot resolves +the authoritative geometry. Do not treat the coordinates built here as +measured/precise; they exist for addressing and tracking only. + +``flex_plate(load_name, name, ...)`` builds a plate for ANY Opentrons plate +load name — pick the load name from the Opentrons Labware Library and PLR +will build a same-shaped nominal grid to track it. ``corning_96_wellplate_360ul_flat`` +is a convenience wrapper for the plate used in the hello-world notebook. +""" + +from __future__ import annotations + +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType + +# --- Nominal standard-96 SBS grid (addressing/tracking only) --- + +_FOOTPRINT_X = 127.76 # standard SBS microplate footprint +_FOOTPRINT_Y = 85.48 +_PITCH = 9.0 # center-to-center spacing, standard 96-well pitch + +_NUM_COLS = 12 +_NUM_ROWS = 8 + +# Symmetric nominal margins from the footprint edges to the A1 well center, +# derived from the standard footprint/pitch above (not measured). +_DX = (_FOOTPRINT_X - (_NUM_COLS - 1) * _PITCH) / 2 # 14.38 +_DY = (_FOOTPRINT_Y - (_NUM_ROWS - 1) * _PITCH) / 2 # 11.24 + +_PLATE_SIZE_Z = 14.5 # nominal plate height +_WELL_SIZE = 6.4 # nominal well footprint (round well diameter) +_WELL_SIZE_Z = 10.9 # nominal well depth + + +def flex_plate( + load_name: str, + name: str, + num_wells: int = 96, + well_volume: float = 360.0, +) -> Plate: + """Build a nominal, name-based Flex plate. + + Args: + load_name: the Opentrons labware load name (e.g. + ``"corning_96_wellplate_360ul_flat"``) sent to the Flex robot when this + plate is loaded — the robot resolves the authoritative geometry from + this name. Stored on the returned ``Plate`` as ``ot_load_name``. + name: the PLR resource name for this plate instance. + num_wells: number of wells; only the standard 96-well SBS grid (8 rows x + 12 columns) is supported today. + well_volume: nominal per-well max volume (uL), used for volume tracking. + + Returns: + A PLR ``Plate`` with a nominal 96-well grid (see module docstring) and + ``ot_load_name`` set to ``load_name``. + """ + if num_wells != 96: + raise ValueError( + f"flex_plate only supports the standard 96-well SBS grid today; got num_wells={num_wells}." + ) + + plate = Plate( + name=name, + size_x=_FOOTPRINT_X, + size_y=_FOOTPRINT_Y, + size_z=_PLATE_SIZE_Z, + model=load_name, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=_NUM_COLS, + num_items_y=_NUM_ROWS, + dx=_DX, + dy=_DY, + dz=0.0, + item_dx=_PITCH, + item_dy=_PITCH, + size_x=_WELL_SIZE, + size_y=_WELL_SIZE, + size_z=_WELL_SIZE_Z, + bottom_type=WellBottomType.FLAT, + max_volume=well_volume, + ), + ) + + # Flex-specific: Opentrons labware load name for JIT loading. The robot + # resolves the real geometry from this name; PLR's grid above is nominal. + plate.ot_load_name = load_name # type: ignore[attr-defined] + + return plate + + +def corning_96_wellplate_360ul_flat(name: str) -> Plate: + """Corning 96-well flat-bottom plate, 360 uL wells — Opentrons Labware Library name. + + Convenience wrapper around :func:`flex_plate` for + ``"corning_96_wellplate_360ul_flat"``, the plate used in the Flex + hello-world notebook. + """ + return flex_plate( + load_name="corning_96_wellplate_360ul_flat", + name=name, + num_wells=96, + well_volume=360.0, + ) diff --git a/pylabrobot/resources/opentrons/flex_tip_racks.py b/pylabrobot/resources/opentrons/flex_tip_racks.py new file mode 100644 index 00000000000..5a81319bf53 --- /dev/null +++ b/pylabrobot/resources/opentrons/flex_tip_racks.py @@ -0,0 +1,151 @@ +"""Flex tip rack definitions — thin, name-based labware. + +Geometry here is **nominal**, not authoritative: a standard 96-position SBS +grid (127.76 x 85.48 mm footprint, 9 mm pitch) used only so PLR has named +``TipSpot``/``Tip`` objects to hang tip- and volume-tracking state on. The +*real* labware definition lives on the Flex robot itself — when a rack is +loaded, PLR sends the robot its Opentrons load name (``ot_load_name``) and +the robot resolves the authoritative geometry. Do not treat the coordinates +built here as measured/precise; they exist for addressing and tracking only. + +Each factory function returns a PLR TipRack with: +- Standard TipSpots with TipTrackers for tip tracking and management +- Tips with VolumeTrackers for liquid volume tracking +- ``ot_load_name`` attribute for loading into the Flex robot's labware system +""" + +from __future__ import annotations + +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipRack, TipSpot +from pylabrobot.resources.utils import create_ordered_items_2d + +# --- Nominal standard-96 SBS grid (addressing/tracking only) --- + +_FOOTPRINT_X = 127.76 # standard SBS microplate footprint +_FOOTPRINT_Y = 85.48 +_PITCH = 9.0 # center-to-center spacing, standard 96-well pitch + +_NUM_COLS = 12 +_NUM_ROWS = 8 + +# Symmetric nominal margins from the footprint edges to the A1 tip-spot +# center, derived from the standard footprint/pitch above (not measured). +_DX = (_FOOTPRINT_X - (_NUM_COLS - 1) * _PITCH) / 2 # 14.38 +_DY = (_FOOTPRINT_Y - (_NUM_ROWS - 1) * _PITCH) / 2 # 11.24 + +_RACK_SIZE_Z = 99.0 # nominal rack height +_SPOT_SIZE = 5.5 # nominal tip-spot footprint + + +def _make_flex_tip_rack( + name: str, + ot_load_name: str, + tip_volume: float, + total_tip_length: float, + fitting_depth: float, + has_filter: bool = False, +) -> TipRack: + """Create a PLR TipRack with a nominal 96-position grid. + + Returns a standard PLR TipRack with an extra ``ot_load_name`` + attribute identifying the Opentrons labware definition for the Flex robot. + The grid geometry is nominal (see module docstring) — the Flex robot owns + the authoritative definition, loaded by ``ot_load_name``. + """ + + def make_tip(name: str) -> Tip: + return Tip( + name=name, + maximal_volume=tip_volume, + total_tip_length=total_tip_length, + fitting_depth=fitting_depth, + has_filter=has_filter, + ) + + rack = TipRack( + name=name, + size_x=_FOOTPRINT_X, + size_y=_FOOTPRINT_Y, + size_z=_RACK_SIZE_Z, + model=ot_load_name, + ordered_items=create_ordered_items_2d( + TipSpot, + num_items_x=_NUM_COLS, + num_items_y=_NUM_ROWS, + dx=_DX, + dy=_DY, + dz=0.0, + item_dx=_PITCH, + item_dy=_PITCH, + size_x=_SPOT_SIZE, + size_y=_SPOT_SIZE, + make_tip=make_tip, + ), + ) + + # Flex-specific: Opentrons labware load name for JIT loading. The robot + # resolves the real geometry from this name; PLR's grid above is nominal. + rack.ot_load_name = ot_load_name # type: ignore[attr-defined] + + return rack + + +# --- Tip Rack Factory Functions --- + + +def flex_96_tiprack_50ul(name: str = "flex_96_tiprack_50ul") -> TipRack: + """Opentrons Flex 96 Tip Rack 50 µL. + + Tip length 57.9mm, fitting depth 10.5mm (from Opentrons specs). + """ + return _make_flex_tip_rack( + name=name, + ot_load_name="opentrons_flex_96_tiprack_50ul", + tip_volume=50.0, + total_tip_length=57.9, + fitting_depth=10.5, + ) + + +def flex_96_filtertiprack_50ul( + name: str = "flex_96_filtertiprack_50ul", +) -> TipRack: + """Opentrons Flex 96 Filter Tip Rack 50 µL. + + Physically identical geometry to ``flex_96_tiprack_50ul`` (same 96-well + layout, tip length 57.9mm, fitting depth 10.5mm) — the only difference is the + aerosol filter, so it is the same rack with ``has_filter=True`` and the + Opentrons filter load name. Lets a protocol that uses filter tips resolve + against the resource model. + """ + return _make_flex_tip_rack( + name=name, + ot_load_name="opentrons_flex_96_filtertiprack_50ul", + tip_volume=50.0, + total_tip_length=57.9, + fitting_depth=10.5, + has_filter=True, + ) + + +def flex_96_tiprack_200ul(name: str = "flex_96_tiprack_200ul") -> TipRack: + """Opentrons Flex 96 Tip Rack 200 µL.""" + return _make_flex_tip_rack( + name=name, + ot_load_name="opentrons_flex_96_tiprack_200ul", + tip_volume=200.0, + total_tip_length=58.35, + fitting_depth=10.5, + ) + + +def flex_96_tiprack_1000ul(name: str = "flex_96_tiprack_1000ul") -> TipRack: + """Opentrons Flex 96 Tip Rack 1000 µL.""" + return _make_flex_tip_rack( + name=name, + ot_load_name="opentrons_flex_96_tiprack_1000ul", + tip_volume=1000.0, + total_tip_length=95.6, + fitting_depth=10.5, + ) From 891238c207bdfe7845a66e4ada91a76455083bbf Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 09:42:06 -0400 Subject: [PATCH 02/36] feat(opentrons): gripper capability + fine pipetting on Flex heads - 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 --- pylabrobot/opentrons/__init__.py | 2 + pylabrobot/opentrons/flex.py | 49 +- .../opentrons/flex_fine_pipetting_tests.py | 473 ++++++++++++++++++ pylabrobot/opentrons/flex_gripper.py | 104 ++++ pylabrobot/opentrons/flex_gripper_tests.py | 312 ++++++++++++ pylabrobot/opentrons/flex_head.py | 199 ++++++++ pylabrobot/opentrons/transport.py | 45 +- 7 files changed, 1172 insertions(+), 12 deletions(-) create mode 100644 pylabrobot/opentrons/flex_fine_pipetting_tests.py create mode 100644 pylabrobot/opentrons/flex_gripper.py create mode 100644 pylabrobot/opentrons/flex_gripper_tests.py diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py index 83fedf8826a..2dfa6465d8c 100644 --- a/pylabrobot/opentrons/__init__.py +++ b/pylabrobot/opentrons/__init__.py @@ -1,10 +1,12 @@ from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_gripper import FlexGripper from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96 from pylabrobot.opentrons.robot import OpentronsError, OpentronsRobot, PipetteInfo from pylabrobot.opentrons.transport import ChatterboxTransport, HttpxTransport, OpentronsTransport __all__ = [ "ChatterboxTransport", + "FlexGripper", "FlexHead1", "FlexHead8", "FlexHead96", diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index b9453be962b..1eb6b41b15f 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -1,7 +1,8 @@ import logging import uuid -from typing import Dict, List, Optional, Type, cast +from typing import Any, Dict, List, Optional, Type, cast +from pylabrobot.opentrons.flex_gripper import FlexGripper from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead from pylabrobot.opentrons.robot import OpentronsError, OpentronsRobot from pylabrobot.opentrons.transport import OpentronsTransport @@ -55,6 +56,7 @@ def __init__( self.left: Optional[_FlexHead] = None self.right: Optional[_FlexHead] = None self.head96: Optional[_FlexHead] = None + self.gripper: Optional[FlexGripper] = None self._heads: List[_FlexHead] = [] async def _model_setup(self) -> None: @@ -100,6 +102,25 @@ async def _model_setup(self) -> None: for head in self._heads: await head._on_setup() + # The gripper (extension mount) is optional: compose it when discovery + # reports one, leave ``self.gripper`` None otherwise. + gripper_model = self._parse_gripper(instruments_data) + if gripper_model is not None: + self.gripper = FlexGripper(self, gripper_model) + logger.info("Discovered gripper on the extension mount (model: %s)", gripper_model) + + def _parse_gripper(self, instruments_data: Dict[str, Any]) -> Optional[str]: + """Parse the /instruments response for a mounted gripper. + + Returns the gripper's model string, or ``None`` when none is mounted. + Separate from ``_parse_pipettes``, which filters to + ``instrumentType == 'pipette'`` and knows nothing about grippers. + """ + for instrument in instruments_data.get("data", []): + if instrument.get("instrumentType") == "gripper": + return cast(str, instrument.get("instrumentModel", "unknown")) + return None + async def stop(self) -> None: # Drop any mounted tips to the trash BEFORE parking/disconnecting, so the # robot is never left holding tips. A failure here must not block the @@ -162,6 +183,32 @@ async def _ensure_labware_loaded(self, resource: Resource) -> str: ) return labware_id + async def labware_moved_off_deck(self, resource: Resource) -> None: + """Tell the robot an EXTERNAL agent (human or lab transporter) removed labware. + + A logical move, not a gripper motion: ``moveLabware`` with strategy + ``manualMoveWithoutPause`` drops the labware from the robot-server's deck + model, freeing its slot. Without this the slot stays occupied server-side + and a later load into it fails with ``LocationIsOccupiedError``. The + PLR-side deck slot is freed too. No wire command is sent for labware that + was never loaded into the run; a re-add later loads fresh at its new slot. + """ + name = getattr(resource, "name", str(resource)) + if name in self._loaded_labware: + await self._execute_command( + "moveLabware", + { + "labwareId": self._loaded_labware[name], + "newLocation": "offDeck", + "strategy": "manualMoveWithoutPause", + }, + ) + del self._loaded_labware[name] + slot = self.deck.get_slot(resource) + if slot is not None: + self.deck.unassign_child_at_slot(slot) + logger.info("Labware '%s' marked moved off-deck", name) + @staticmethod def _ot_load_name(resource: Resource) -> str: """Resolve a PLR resource to its Opentrons labware load name.""" diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py new file mode 100644 index 00000000000..d5b88c317e4 --- /dev/null +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -0,0 +1,473 @@ +"""Tests for the fine-pipetting commands on the plain-class Flex heads. + +Covers ``blow_out`` (in-place plunger blow-out, all heads), ``touch_tip`` +(wall-touch, all heads) and ``liquid_probe``/``try_liquid_probe`` +(pressure-based liquid-level detection, mount heads only), driven through the +recording ``ChatterboxTransport`` -- the transport's ``liquid_probe_z`` kwarg +models the robot-server's found-liquid ``z_position`` result key, which is +OMITTED entirely (not null) when no liquid is found. +""" + +import asyncio +import unittest + +from pylabrobot.opentrons.flex_head import _DEFAULT_DISPENSE_FLOW_RATE +from pylabrobot.opentrons.flex_tests import _flex_head1, _flex_head8, _flex_head96 +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.resources import ( + biorad_384_wellplate_50uL_Vb, + cor_96_wellplate_360uL_Fb, + set_tip_tracking, + set_volume_tracking, +) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + +class TestBlowOut(unittest.TestCase): + """blow_out sends one blowOutInPlace command at the current position and + invalidates the plunger priming, so the NEXT aspirate re-sends + prepareToAspirate first.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_head8_sends_blow_out_in_place_with_dispense_default_flow_rate(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.blow_out()) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] + self.assertEqual(len(blow_cmds), 1) + self.assertEqual( + blow_cmds[0]["params"], + {"pipetteId": head.pipette_id, "flowRate": _DEFAULT_DISPENSE_FLOW_RATE}, + ) + finally: + asyncio.run(flex.stop()) + + def test_head8_accepts_flow_rate_override(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.blow_out(flow_rate=12.5)) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] + self.assertEqual(blow_cmds[0]["params"]["flowRate"], 12.5) + finally: + asyncio.run(flex.stop()) + + def test_head8_next_aspirate_reprimes_after_blow_out(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate, column=0, volume=10)) + asyncio.run(head.blow_out()) + asyncio.run(head.aspirate(plate, column=1, volume=10)) + + cmd_types = [c["commandType"] for c in transport.commands] + prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] + aspirate_indices = [i for i, t in enumerate(cmd_types) if t == "aspirate"] + self.assertEqual(len(prepare_indices), 2, "a blow-out must require a new prepare") + self.assertEqual(len(aspirate_indices), 2) + self.assertEqual(prepare_indices[1], aspirate_indices[1] - 1) + finally: + asyncio.run(flex.stop()) + + def test_head1_blow_out_and_reprime(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + well = plate.get_item("B3") + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.aspirate(well, volume=10)) + asyncio.run(head.blow_out()) + asyncio.run(head.aspirate(well, volume=10)) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] + self.assertEqual(len(blow_cmds), 1) + self.assertEqual( + blow_cmds[0]["params"], + {"pipetteId": head.pipette_id, "flowRate": _DEFAULT_DISPENSE_FLOW_RATE}, + ) + cmd_types = [c["commandType"] for c in transport.commands] + prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] + self.assertEqual(len(prepare_indices), 2, "a blow-out must require a new prepare") + finally: + asyncio.run(flex.stop()) + + def test_head96_blow_out_and_reprime(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack96") + plate = cor_96_wellplate_360uL_Fb(name="plate96") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.aspirate(plate, volume=10)) + asyncio.run(head.blow_out()) + asyncio.run(head.aspirate(plate, volume=10)) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] + self.assertEqual(len(blow_cmds), 1) + self.assertEqual(blow_cmds[0]["params"]["pipetteId"], head.pipette_id) + cmd_types = [c["commandType"] for c in transport.commands] + prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] + self.assertEqual(len(prepare_indices), 2, "a blow-out must require a new prepare") + finally: + asyncio.run(flex.stop()) + + +class TestTouchTipHead1(unittest.TestCase): + """FlexHead1.touch_tip sends one touchTip command naming the well, with the + radius and a bottom-origin wellLocation offset; a missing tip rejects + before any wire command.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_touch_tip_sends_one_command_with_radius_offset_and_well(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.touch_tip(plate.get_item("B3"), radius=0.75, offset=Coordinate(1, 2, 3))) + + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + self.assertEqual(len(touch_cmds), 1) + params = touch_cmds[0]["params"] + self.assertEqual(params["pipetteId"], head.pipette_id) + self.assertEqual(params["wellName"], "B3") + self.assertEqual(params["radius"], 0.75) + self.assertEqual( + params["wellLocation"], {"origin": "bottom", "offset": {"x": 1, "y": 2, "z": 3}} + ) + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_defaults_radius_one_and_zero_offset(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.touch_tip(plate.get_item("B3"))) + + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + params = touch_cmds[0]["params"] + self.assertEqual(params["radius"], 1.0) + self.assertEqual( + params["wellLocation"], {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 0}} + ) + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_without_tip_raises_and_sends_nothing(self): + flex, transport, head = _flex_head1() + try: + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.touch_tip(plate.get_item("B3"))) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestTouchTipHead8(unittest.TestCase): + """FlexHead8.touch_tip is column-addressed: one touchTip command anchored at + the column's A-row well; a missing tip rejects before any wire command.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_touch_tip_anchors_at_column_a_row_well(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.touch_tip(plate, column=2)) + + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + self.assertEqual(len(touch_cmds), 1) + self.assertEqual(touch_cmds[0]["params"]["wellName"], "A3") + self.assertEqual(touch_cmds[0]["params"]["radius"], 1.0) + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_without_tips_raises_and_sends_nothing(self): + flex, transport, head = _flex_head8() + try: + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.touch_tip(plate, column=0)) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestTouchTipHead96(unittest.TestCase): + """FlexHead96.touch_tip fans one touchTip command anchored at "A1" out to + all 96 channels; a non-96-position labware or a missing tip rejects before + any wire command.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_touch_tip_anchors_at_a1(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack96") + plate = cor_96_wellplate_360uL_Fb(name="plate96") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.touch_tip(plate)) + + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + self.assertEqual(len(touch_cmds), 1) + self.assertEqual(touch_cmds[0]["params"]["wellName"], "A1") + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_rejects_non_96_labware(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack96") + plate_384 = biorad_384_wellplate_50uL_Vb(name="plate384") + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate_384, "C3") + + asyncio.run(head.pick_up_tips(rack)) + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.touch_tip(plate_384)) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_without_tips_raises_and_sends_nothing(self): + flex, transport, head = _flex_head96() + try: + plate = cor_96_wellplate_360uL_Fb(name="plate96") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.touch_tip(plate)) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestLiquidProbeHead1(unittest.TestCase): + """FlexHead1 liquid probing: the found liquid z rides the command result's + ``z_position`` key, which the robot-server OMITS entirely (not null) when + no liquid is found -- liquid_probe raises on absence, try_liquid_probe + returns None.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self, **transport_kwargs): + flex, transport, head = _flex_head1(**transport_kwargs) + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + return flex, transport, head, rack, plate + + def test_liquid_probe_returns_configured_z_and_sends_probe_command(self): + flex, transport, head, rack, plate = self._bench(liquid_probe_z=12.5) + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + z = asyncio.run(head.liquid_probe(plate.get_item("B3"))) + + self.assertEqual(z, 12.5) + probe_cmds = [c for c in transport.commands if c["commandType"] == "liquidProbe"] + self.assertEqual(len(probe_cmds), 1) + params = probe_cmds[0]["params"] + self.assertEqual(params["pipetteId"], head.pipette_id) + self.assertEqual(params["wellName"], "B3") + self.assertEqual( + params["wellLocation"], {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 0}} + ) + finally: + asyncio.run(flex.stop()) + + def test_liquid_probe_raises_when_no_liquid_found(self): + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(OpentronsError): + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + + # The probe command WAS sent -- absence of z_position in its result is + # what raised, not a pre-wire guard. + probe_cmds = [c for c in transport.commands if c["commandType"] == "liquidProbe"] + self.assertEqual(len(probe_cmds), 1) + finally: + asyncio.run(flex.stop()) + + def test_try_liquid_probe_returns_none_when_no_liquid_found(self): + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + z = asyncio.run(head.try_liquid_probe(plate.get_item("B3"))) + + self.assertIsNone(z) + probe_cmds = [c for c in transport.commands if c["commandType"] == "tryLiquidProbe"] + self.assertEqual(len(probe_cmds), 1) + finally: + asyncio.run(flex.stop()) + + def test_try_liquid_probe_returns_configured_z(self): + flex, transport, head, rack, plate = self._bench(liquid_probe_z=4.75) + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + z = asyncio.run(head.try_liquid_probe(plate.get_item("B3"))) + self.assertEqual(z, 4.75) + finally: + asyncio.run(flex.stop()) + + def test_liquid_probe_without_tip_raises_and_sends_nothing(self): + flex, transport, head, _rack, plate = self._bench(liquid_probe_z=12.5) + try: + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestLiquidProbeHead8(unittest.TestCase): + """FlexHead8 liquid probing is column-addressed: one probe command anchored + at the column's A-row well.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self, **transport_kwargs): + flex, transport, head = _flex_head8(**transport_kwargs) + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + return flex, transport, head, rack, plate + + def test_liquid_probe_anchors_at_column_a_row_well_and_returns_z(self): + flex, transport, head, rack, plate = self._bench(liquid_probe_z=7.25) + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + z = asyncio.run(head.liquid_probe(plate, column=3)) + + self.assertEqual(z, 7.25) + probe_cmds = [c for c in transport.commands if c["commandType"] == "liquidProbe"] + self.assertEqual(len(probe_cmds), 1) + self.assertEqual(probe_cmds[0]["params"]["wellName"], "A4") + finally: + asyncio.run(flex.stop()) + + def test_try_liquid_probe_returns_none_when_no_liquid_found(self): + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + z = asyncio.run(head.try_liquid_probe(plate, column=3)) + + self.assertIsNone(z) + probe_cmds = [c for c in transport.commands if c["commandType"] == "tryLiquidProbe"] + self.assertEqual(len(probe_cmds), 1) + self.assertEqual(probe_cmds[0]["params"]["wellName"], "A4") + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_gripper.py b/pylabrobot/opentrons/flex_gripper.py new file mode 100644 index 00000000000..c69e7dfebd0 --- /dev/null +++ b/pylabrobot/opentrons/flex_gripper.py @@ -0,0 +1,104 @@ +"""Gripper sub-object for :class:`~pylabrobot.opentrons.flex.OpentronsFlex`. + +The Flex gripper rides the extension mount and moves labware between deck +slots. Like the heads (:mod:`pylabrobot.opentrons.flex_head`), it is a +plain-class sub-object: it holds a back-reference to the owning +``OpentronsFlex`` and issues commands through the shared transport via +``self.flex._execute_command``. It is composed by ``OpentronsFlex.setup()`` +when ``GET /instruments`` reports a gripper -- ``flex.gripper`` is ``None`` +on a Flex without one. + +The robot-server's ``moveLabware`` command is atomic (pick + travel + place +in one command), so a gripper move is a single wire call rather than a +pick/move/drop sequence. Grip geometry comes from the robot's own labware +definition for the loaded ``loadName``; PLR does not upload one. +""" + +import logging +from typing import TYPE_CHECKING + +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.resources.resource import Resource + +if TYPE_CHECKING: + from pylabrobot.opentrons.flex import OpentronsFlex + +logger = logging.getLogger(__name__) + +# Gripper moves are slow physical operations (pick + travel + place), so give +# them far more headroom than the 30s ``_execute_command`` default. +_MOVE_LABWARE_TIMEOUT = 120.0 + + +class FlexGripper: + """The Opentrons Flex gripper (extension mount). + + Constructed by ``OpentronsFlex._model_setup()`` when instrument discovery + reports a gripper; access it as ``flex.gripper``. + """ + + def __init__(self, flex: "OpentronsFlex", gripper_model: str) -> None: + self.flex = flex + self.gripper_model = gripper_model + + async def move_labware(self, resource: Resource, to_slot: str) -> None: + """Move ``resource`` from its current deck slot to ``to_slot`` with the gripper. + + Validates PLR-side first (resource on deck, destination a valid empty + slot), then sends ONE atomic ``moveLabware`` command. On wire success the + deck is re-parented to match; on wire failure the deck is left untouched + and the error propagates. + + Args: + resource: A resource currently placed on the deck. + to_slot: Destination slot, e.g. ``"C2"`` (standard) or ``"B4"`` (staging). + + Raises: + OpentronsError: If the resource is not on the deck, or ``to_slot`` is + invalid or occupied. Raised before any wire command is sent. + """ + deck = self.flex.deck + name = getattr(resource, "name", str(resource)) + + from_slot = deck.get_slot(resource) + if from_slot is None: + raise OpentronsError( + "Resource not on deck", + f"'{name}' is not on a deck slot. Use deck.assign_child_at_slot(resource, slot='C1').", + ) + + to_slot = to_slot.upper() + try: + occupant = deck.get_resource_at_slot(to_slot) + except ValueError as e: + raise OpentronsError("Invalid destination slot", str(e)) from e + if occupant is not None: + occupant_name = getattr(occupant, "name", str(occupant)) + raise OpentronsError( + "Destination slot occupied", + f"Slot {to_slot} is already occupied by '{occupant_name}'.", + ) + + labware_id = await self.flex._ensure_labware_loaded(resource) + await self.flex._execute_command( + "moveLabware", + { + "labwareId": labware_id, + "newLocation": {"slotName": to_slot}, + "strategy": "usingGripper", + }, + timeout=_MOVE_LABWARE_TIMEOUT, + ) + + deck.unassign_child_at_slot(from_slot) + deck.assign_child_at_slot(resource, to_slot) + logger.info("Gripper moved '%s' from %s to %s", name, from_slot, to_slot) + + async def ungrip(self) -> None: + """Open the gripper jaw (homing it) to release any held labware. + + Recovery command: after an interrupted ``moveLabware`` the gripper may + still be holding the labware; this releases it so the operator can + recover the plate by hand. + """ + await self.flex._execute_command("unsafe/ungripLabware", {}) diff --git a/pylabrobot/opentrons/flex_gripper_tests.py b/pylabrobot/opentrons/flex_gripper_tests.py new file mode 100644 index 00000000000..13ff020065e --- /dev/null +++ b/pylabrobot/opentrons/flex_gripper_tests.py @@ -0,0 +1,312 @@ +"""Tests for the Flex gripper capability (``flex.gripper``). + +Drives ``OpentronsFlex.setup()`` with an injected ``ChatterboxTransport`` +advertising a gripper on the extension mount, and asserts discovery attaches +a :class:`~pylabrobot.opentrons.flex_gripper.FlexGripper`; ``move_labware`` +follows the stage -> wire -> commit idiom (PLR-side validation before any +wire command, deck re-parent only on wire success); and +``labware_moved_off_deck`` releases a slot both server-side and PLR-side. +""" + +import asyncio +import unittest +from typing import Any, Dict, Optional, Tuple + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_gripper import FlexGripper +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import cor_96_wellplate_360uL_Fb +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.plate import Plate + + +def _flex_with_gripper(**transport_kwargs) -> Tuple[OpentronsFlex, ChatterboxTransport]: + """An ``OpentronsFlex`` whose transport advertises a gripper on the + extension mount (plus a single-channel pipette so setup() succeeds), + returning the transport too so a test can inspect recorded commands. + + ``transport_kwargs`` are forwarded to ``ChatterboxTransport``. + """ + transport = ChatterboxTransport( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], + gripper=True, + **transport_kwargs, + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + return flex, transport + + +def _plate(name: str = "plate") -> Plate: + plate = cor_96_wellplate_360uL_Fb(name=name) + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + return plate + + +class _FailingMoveTransport(ChatterboxTransport): + """Chatterbox whose ``moveLabware`` commands fail at the robot: the command + is accepted (recorded) but its status poll reports ``failed``, so + ``_execute_command`` raises the way a real robot-server failure would. + """ + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if path.endswith("/commands") and data.get("commandType") == "moveLabware": + cmd_data = result["data"] + cmd_data["status"] = "failed" + cmd_data["error"] = {"detail": "simulated gripper failure"} + return result + + +class TestGripperDiscovery(unittest.TestCase): + """setup() composes a FlexGripper iff /instruments reports a gripper.""" + + def test_gripper_attached_when_advertised(self): + flex, _transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + self.assertIsInstance(flex.gripper, FlexGripper) + assert flex.gripper is not None + self.assertEqual(flex.gripper.gripper_model, "gripperV1.3") + # Head composition is unaffected by the extra instrument entry. + self.assertIsNotNone(flex.right) + finally: + asyncio.run(flex.stop()) + + def test_gripper_none_when_absent(self): + transport = ChatterboxTransport(pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + self.assertIsNone(flex.gripper) + finally: + asyncio.run(flex.stop()) + + +class TestMoveLabware(unittest.TestCase): + """move_labware sends ONE atomic moveLabware command and re-parents the + deck only on wire success; the labware is JIT-loaded once.""" + + def test_happy_path_sends_exact_wire_params_and_reparents_deck(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(plate, "C2")) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1) + labware_id = flex._loaded_labware["plate"] + self.assertEqual( + move_cmds[0]["params"], + { + "labwareId": labware_id, + "newLocation": {"slotName": "C2"}, + "strategy": "usingGripper", + }, + ) + + self.assertEqual(flex.deck.get_slot(plate), "C2") + self.assertIsNone(flex.deck.get_resource_at_slot("C1")) + self.assertIs(flex.deck.get_resource_at_slot("C2"), plate) + finally: + asyncio.run(flex.stop()) + + def test_second_move_reuses_the_loaded_labware(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(plate, "C2")) + asyncio.run(gripper.move_labware(plate, "D3")) + + load_cmds = [c for c in transport.commands if c["commandType"] == "loadLabware"] + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(load_cmds), 1, "labware must be JIT-loaded exactly once") + self.assertEqual(len(move_cmds), 2) + self.assertEqual(flex.deck.get_slot(plate), "D3") + finally: + asyncio.run(flex.stop()) + + def test_move_to_staging_slot(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(plate, "B4")) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1) + self.assertEqual(move_cmds[0]["params"]["newLocation"], {"slotName": "B4"}) + self.assertEqual(flex.deck.get_slot(plate), "B4") + finally: + asyncio.run(flex.stop()) + + +class TestMoveLabwarePreWireRejections(unittest.TestCase): + """Invalid moves raise OpentronsError BEFORE any wire command is sent.""" + + def _assert_no_move_commands(self, transport: ChatterboxTransport) -> None: + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 0, "no moveLabware wire command may be sent") + + def test_labware_not_on_deck_raises(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() # never assigned to the deck + gripper = flex.gripper + assert gripper is not None + + with self.assertRaises(OpentronsError): + asyncio.run(gripper.move_labware(plate, "C2")) + + self._assert_no_move_commands(transport) + finally: + asyncio.run(flex.stop()) + + def test_occupied_destination_raises_and_deck_untouched(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + other = _plate(name="other") + flex.deck.assign_child_at_slot(plate, "C1") + flex.deck.assign_child_at_slot(other, "C2") + gripper = flex.gripper + assert gripper is not None + + with self.assertRaises(OpentronsError): + asyncio.run(gripper.move_labware(plate, "C2")) + + self._assert_no_move_commands(transport) + self.assertEqual(flex.deck.get_slot(plate), "C1") + self.assertEqual(flex.deck.get_slot(other), "C2") + finally: + asyncio.run(flex.stop()) + + def test_invalid_destination_slot_raises(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + with self.assertRaises(OpentronsError): + asyncio.run(gripper.move_labware(plate, "E5")) + + self._assert_no_move_commands(transport) + self.assertEqual(flex.deck.get_slot(plate), "C1") + finally: + asyncio.run(flex.stop()) + + +class TestMoveLabwareWireFailure(unittest.TestCase): + """A wire-level moveLabware failure re-raises and leaves the deck untouched.""" + + def test_failed_move_leaves_deck_untouched(self): + transport = _FailingMoveTransport( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], gripper=True + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + with self.assertRaises(RuntimeError): + asyncio.run(gripper.move_labware(plate, "C2")) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1, "the command reached the wire and failed there") + self.assertEqual(flex.deck.get_slot(plate), "C1") + self.assertIsNone(flex.deck.get_resource_at_slot("C2")) + finally: + asyncio.run(flex.stop()) + + +class TestLabwareMovedOffDeck(unittest.TestCase): + """labware_moved_off_deck releases the slot server-side (offDeck logical + move) for loaded labware, evicts the load cache, and frees the PLR slot.""" + + def test_loaded_labware_sends_offdeck_move_and_evicts_cache(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + labware_id = asyncio.run(flex._ensure_labware_loaded(plate)) + + asyncio.run(flex.labware_moved_off_deck(plate)) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1) + self.assertEqual( + move_cmds[0]["params"], + { + "labwareId": labware_id, + "newLocation": "offDeck", + "strategy": "manualMoveWithoutPause", + }, + ) + self.assertNotIn("plate", flex._loaded_labware) + self.assertIsNone(flex.deck.get_slot(plate)) + self.assertIsNone(flex.deck.get_resource_at_slot("C1")) + finally: + asyncio.run(flex.stop()) + + def test_never_loaded_labware_frees_slot_without_wire_command(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + + asyncio.run(flex.labware_moved_off_deck(plate)) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 0, "never-loaded labware needs no wire command") + self.assertIsNone(flex.deck.get_slot(plate)) + self.assertIsNone(flex.deck.get_resource_at_slot("C1")) + finally: + asyncio.run(flex.stop()) + + +class TestUngrip(unittest.TestCase): + """ungrip() sends the unsafe/ungripLabware recovery command.""" + + def test_ungrip_sends_unsafe_ungrip_labware(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.ungrip()) + + ungrip_cmds = [c for c in transport.commands if c["commandType"] == "unsafe/ungripLabware"] + self.assertEqual(len(ungrip_cmds), 1) + self.assertEqual(ungrip_cmds[0]["params"], {}) + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 3f2ce583716..9b21464d1e7 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -100,6 +100,21 @@ async def discard_tips(self, trash: Trash) -> None: """Discard all mounted tips into ``trash``. Implemented by each head.""" raise NotImplementedError + async def blow_out(self, flow_rate: Optional[float] = None) -> None: + """Blow out at the current position -- one ``blowOutInPlace`` command. + + Pushes the plunger past its dispense-bottom to expel residual liquid + from the tip(s) wherever the pipette currently is (no well addressing -- + position with a dispense/move first). ``flow_rate`` (uL/s) defaults to + the dispense default. Blowing out leaves the plunger at the blow-out + position, so the next aspirate is preceded by a fresh + ``prepareToAspirate`` (same priming rule as after a tip pickup). No + trackers are involved. + """ + rate = flow_rate if flow_rate is not None else _DEFAULT_BLOW_OUT_FLOW_RATE + await self._execute("blowOutInPlace", {"pipetteId": self.pipette_id, "flowRate": rate}) + self._prepared = False + async def has_tip_on_hardware(self) -> Optional[bool]: """Query the Flex's hardware tip-presence sensor for THIS head's pipette. @@ -265,6 +280,64 @@ async def _execute_trash_drop(self) -> None: ) await self._execute("dropTipInPlace", {"pipetteId": self.pipette_id}) + # --- Fine-pipetting shared helpers --- + + def _require_mounted_tip(self) -> None: + """Raise if no channel holds a tip -- pre-wire guard for tip-motion ops. + + ``touch_tip``/``liquid_probe`` move the mounted tip itself into the + well, so issuing them without a tip would drive the bare nozzle into + the labware. Checked before any wire command is sent. + """ + if all(tip is None for tip in self._channel_tips): + raise OpentronsError( + "NoTipError", + "No tip mounted; pick up a tip first.", + ) + + def _touch_tip_params( + self, + labware_id: str, + well_name: str, + radius: float, + offset: Optional[Coordinate], + ) -> Dict[str, Any]: + """Build the ``touchTip`` params dict shared by every head's ``touch_tip``. + + The ``wellLocation`` rides at origin "bottom" with a zero default + offset -- ``touchTip`` addresses the height of the wall-touch motion, + not a liquid position, so the liquid ops' +1mm clearance default does + not apply. ``radius`` is the fraction of the well radius the tip moves + toward (1.0 = the wall). + """ + o = offset if offset is not None else Coordinate.zero() + return { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "wellLocation": {"origin": "bottom", "offset": {"x": o.x, "y": o.y, "z": o.z}}, + "radius": radius, + } + + async def _probe_z(self, command_type: str, labware_id: str, well_name: str) -> Optional[float]: + """Send a ``liquidProbe``/``tryLiquidProbe`` command; return the found liquid z (mm). + + The robot-server OMITS ``z_position`` from the command result entirely + (rather than reporting null) when no liquid is detected, so absence is + read with ``.get()`` and surfaced as ``None`` -- callers decide whether + that raises (``liquid_probe``) or passes through (``try_liquid_probe``). + """ + result = await self._execute( + command_type, + { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "wellLocation": {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 0}}, + }, + ) + return cast(Optional[float], result.get("result", {}).get("z_position")) + async def _on_setup(self) -> None: """Hook for head-specific post-discovery setup. Default: no-op.""" @@ -333,6 +406,9 @@ def _well_location( _DEFAULT_ASPIRATE_FLOW_RATE = 35.0 _DEFAULT_DISPENSE_FLOW_RATE = 57.0 +# The p50_multi_v3.5's default blow-out rate equals its dispense rate. +_DEFAULT_BLOW_OUT_FLOW_RATE = _DEFAULT_DISPENSE_FLOW_RATE + # Default aspirate/dispense position: 1mm above the well bottom, matching the # Opentrons Python-API default. The raw Protocol-Engine /commands API defaults # an OMITTED wellLocation to origin "top" (the well rim -- above the liquid), @@ -533,6 +609,54 @@ async def dispense( await self._execute_liquid_op("dispense", params, staged_trackers) + async def touch_tip( + self, + well: Well, + radius: float = 1.0, + offset: Optional[Coordinate] = None, + ) -> None: + """Touch the mounted tip to the sides of ``well`` -- one ``touchTip`` command. + + ``radius`` is the fraction of the well radius the tip moves toward + (1.0 = the wall). Requires a mounted tip (checked before any wire + command). No trackers are involved. + """ + self._warn_untested_hardware() + self._require_mounted_tip() + parent = self._require_itemized_parent(well) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(well) + await self._execute("touchTip", self._touch_tip_params(labware_id, well_name, radius, offset)) + + async def liquid_probe(self, well: Well) -> float: + """Probe downward in ``well`` until the pressure sensor detects liquid; return its z (mm). + + One ``liquidProbe`` command naming ``well``. Requires a mounted tip + (checked before any wire command). Raises ``OpentronsError`` if no + liquid is found; use ``try_liquid_probe`` for the non-raising variant. + """ + self._warn_untested_hardware() + self._require_mounted_tip() + parent = self._require_itemized_parent(well) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(well) + z = await self._probe_z("liquidProbe", labware_id, well_name) + if z is None: + raise OpentronsError( + "LiquidNotFoundError", + f"liquid_probe found no liquid in well {well.name!r}.", + ) + return z + + async def try_liquid_probe(self, well: Well) -> Optional[float]: + """Like ``liquid_probe`` but return ``None`` instead of raising when no liquid is found.""" + self._warn_untested_hardware() + self._require_mounted_tip() + parent = self._require_itemized_parent(well) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(well) + return await self._probe_z("tryLiquidProbe", labware_id, well_name) + class FlexHead8(_FlexHead): """8-channel pipette head, column-addressed (anchor-well fan-out). @@ -798,6 +922,56 @@ async def dispense( await self._execute_liquid_op("dispense", params, staged_trackers) + async def touch_tip( + self, + plate: Plate, + column: int, + radius: float = 1.0, + offset: Optional[Coordinate] = None, + ) -> None: + """Touch the mounted tips to their well walls -- one ``touchTip`` command + anchored at the column's A-row well. + + ``radius`` is the fraction of the well radius each tip moves toward + (1.0 = the wall). Requires at least one mounted tip (checked before any + wire command) and ALL nozzle mode (reset first if a single-tip op left + the layout otherwise). No trackers are involved. + """ + self._require_mounted_tip() + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + well_name = _COLUMN_WELL_NAMES[column] + await self._execute("touchTip", self._touch_tip_params(labware_id, well_name, radius, offset)) + + async def liquid_probe(self, plate: Plate, column: int) -> float: + """Probe for liquid in a column -- one ``liquidProbe`` command anchored at + the A-row well; return the found liquid z (mm). + + Requires at least one mounted tip (checked before any wire command) and + ALL nozzle mode (reset first if a single-tip op left the layout + otherwise). Raises ``OpentronsError`` if no liquid is found; use + ``try_liquid_probe`` for the non-raising variant. + """ + self._require_mounted_tip() + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + well_name = _COLUMN_WELL_NAMES[column] + z = await self._probe_z("liquidProbe", labware_id, well_name) + if z is None: + raise OpentronsError( + "LiquidNotFoundError", + f"liquid_probe found no liquid in column {column} of {plate.name!r}.", + ) + return z + + async def try_liquid_probe(self, plate: Plate, column: int) -> Optional[float]: + """Like ``liquid_probe`` but return ``None`` instead of raising when no liquid is found.""" + self._require_mounted_tip() + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + well_name = _COLUMN_WELL_NAMES[column] + return await self._probe_z("tryLiquidProbe", labware_id, well_name) + # --- Single-tip cherry-pick --- @staticmethod @@ -989,6 +1163,10 @@ class FlexHead96(_FlexHead): machinery ``FlexHead8`` uses for its column ops, applied to the whole plate/rack instead of one column. + Liquid probing (``liquid_probe``/``try_liquid_probe``) is not implemented + on this head -- only the mount heads (``FlexHead1``/``FlexHead8``) + expose it. + Coded but **not yet verified on real 96-channel Flex hardware** -- Vincent's bench Flex carries an 8-channel pipette, not a 96-channel head. A one-time ``logger.warning`` fires on the first op issued by an instance, @@ -1202,3 +1380,24 @@ async def dispense( params["wellLocation"] = well_location await self._execute_liquid_op("dispense", params, staged_trackers) + + async def touch_tip( + self, + plate: Plate, + radius: float = 1.0, + offset: Optional[Coordinate] = None, + ) -> None: + """Touch the mounted tips to their well walls -- one ``touchTip`` command + anchored at "A1", fanned to all 96 channels. + + ``radius`` is the fraction of the well radius each tip moves toward + (1.0 = the wall). Requires at least one mounted tip and a 96-position + plate (both checked before any wire command). No trackers are involved. + """ + self._warn_untested_hardware() + self._require_mounted_tip() + self._check_full_coverage(plate) + labware_id = await self.flex._ensure_labware_loaded(plate) + await self._execute( + "touchTip", self._touch_tip_params(labware_id, self._ANCHOR_WELL_NAME, radius, offset) + ) diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index 9071ad3072e..681dd8ffe19 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -105,6 +105,8 @@ def __init__( log: Optional[Callable[..., None]] = None, simulate_failed_pickup: bool = False, simulate_stuck_tip: bool = False, + liquid_probe_z: Optional[float] = None, + gripper: bool = False, ) -> None: """Args: pipette: the simulated mounted pipette as ``(name, channels, min_vol, max_vol)``. @@ -129,12 +131,20 @@ def __init__( stuck to the nozzle after a drop, so ``_FlexHead._confirm_tips_cleared()`` sees ``tipDetected: True`` and logs a warning. Default False: a drop always clears the sensor (existing behavior). + liquid_probe_z: the liquid height (mm) a ``liquidProbe``/``tryLiquidProbe`` + command reports as ``z_position`` in its result. Default None: the key + is omitted from the result entirely (not set to null), matching the + real robot-server's shape when no liquid is found. + gripper: if True, ``/instruments`` also reports a gripper on the extension + mount, so tests can drive gripper discovery. Default False: no gripper + mounted (existing behavior). """ if pipettes is not None: self._pipettes: List[Tuple[str, int, float, float, str]] = list(pipettes) else: name, channels, min_v, max_v = pipette self._pipettes = [(name, channels, min_v, max_v, mount)] + self._gripper = gripper self._log = log or logger.info self._cmds: Dict[str, Dict[str, Any]] = {} # cmd_id -> full command data self._n = 0 @@ -143,6 +153,7 @@ def __init__( self.commands: List[Dict[str, Any]] = [] # every command, in send order: {commandType, params} self.simulate_failed_pickup = simulate_failed_pickup self.simulate_stuck_tip = simulate_stuck_tip + self.liquid_probe_z = liquid_probe_z # Per-mount simulated hardware tip-presence sensor state (Flex reports # ONE bool per pipette, not per nozzle -- see /instruments below). self._tip_detected: Dict[str, bool] = {mount: False for *_rest, mount in self._pipettes} @@ -154,19 +165,28 @@ async def get(self, path: str) -> Dict[str, Any]: if path == "/health": return {"api_version": "dry-run", "robot_model": "OT-3 Standard", "name": "chatterbox"} if path == "/instruments": - return { - "data": [ + instruments: List[Dict[str, Any]] = [ + { + "instrumentType": "pipette", + "mount": mount, + "instrumentName": name, + "instrumentModel": name, + "data": {"channels": channels, "min_volume": min_v, "max_volume": max_v}, + "state": {"tipDetected": self._tip_detected.get(mount, False)}, + } + for name, channels, min_v, max_v, mount in self._pipettes + ] + if self._gripper: + instruments.append( { - "instrumentType": "pipette", - "mount": mount, - "instrumentName": name, - "instrumentModel": name, - "data": {"channels": channels, "min_volume": min_v, "max_volume": max_v}, - "state": {"tipDetected": self._tip_detected.get(mount, False)}, + "instrumentType": "gripper", + "mount": "extension", + "instrumentName": "flexGripper", + "instrumentModel": "gripperV1.3", + "data": {"jawState": "unhomed"}, } - for name, channels, min_v, max_v, mount in self._pipettes - ] - } + ) + return {"data": instruments} if "/commands/" in path: # a poll for one command's status cmd_id = path.rsplit("/", 1)[-1] cmd_data = self._cmds.get( @@ -203,6 +223,9 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s mount = self._pipette_id_to_mount.get(params.get("pipetteId")) if mount is not None: self._tip_detected[mount] = self.simulate_stuck_tip + elif ctype in ("liquidProbe", "tryLiquidProbe"): + if self.liquid_probe_z is not None: + result = {"z_position": self.liquid_probe_z} cmd_data = {"id": cmd_id, "commandType": ctype, "status": "succeeded", "result": result} self._cmds[cmd_id] = cmd_data self._log("Chatterbox: %s %s", ctype, params) From daa7be455a63d649af0b5397969af5190c354f38 Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 10:06:07 -0400 Subject: [PATCH 03/36] feat(opentrons): container/reservoir ops + motion surface on Flex heads 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 --- pylabrobot/opentrons/flex_container_tests.py | 521 +++++++++++++++++++ pylabrobot/opentrons/flex_gripper.py | 113 +++- pylabrobot/opentrons/flex_head.py | 431 ++++++++++++--- pylabrobot/opentrons/flex_motion_tests.py | 358 +++++++++++++ pylabrobot/opentrons/transport.py | 32 +- 5 files changed, 1381 insertions(+), 74 deletions(-) create mode 100644 pylabrobot/opentrons/flex_container_tests.py create mode 100644 pylabrobot/opentrons/flex_motion_tests.py diff --git a/pylabrobot/opentrons/flex_container_tests.py b/pylabrobot/opentrons/flex_container_tests.py new file mode 100644 index 00000000000..9cb11bab9a4 --- /dev/null +++ b/pylabrobot/opentrons/flex_container_tests.py @@ -0,0 +1,521 @@ +"""Tests for container (trough/reservoir) ops on the Flex heads. + +A bare PLR ``Container`` is a single-cavity resource with ONE volume tracker; +robot-side single-cavity labware definitions expose exactly one well, named +"A1". These tests pin the wire shape (wellName "A1" plus the head-centering +``wellLocation`` math), the one-tracker staging semantics (each channel +holding a tip moves ``volume``; the summed delta commits/rolls back as one +op), the pre-wire rejections, and that the plate/column paths are unchanged. +""" + +import asyncio +import unittest +from typing import Any, Dict, Optional, Tuple, Type + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96 +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import ( + Container, + cor_96_wellplate_360uL_Fb, + set_tip_tracking, + set_volume_tracking, +) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + +class _FailingAspirateTransport(ChatterboxTransport): + """Chatterbox whose ``aspirate`` POST raises -- models a wire-level failure + AFTER trackers are staged, driving the rollback paths.""" + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if path.endswith("/commands") and (json or {}).get("data", {}).get("commandType") == "aspirate": + raise RuntimeError("simulated aspirate wire failure") + return await super().post(path, json) + + +def _make_trough( + name: str = "trough", + size_x: float = 107.0, + size_y: float = 71.0, + max_volume: float = 195000.0, +) -> Container: + """A single-cavity reservoir built directly with the PLR ``Container`` class, + mapped to a real Opentrons single-cavity load name. + """ + trough = Container(name=name, size_x=size_x, size_y=size_y, size_z=25.0, max_volume=max_volume) + trough.ot_load_name = "nest_1_reservoir_195ml" # type: ignore[attr-defined] + return trough + + +def _flex_head1( + transport_cls: Type[ChatterboxTransport] = ChatterboxTransport, +) -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead1]: + """An ``OpentronsFlex`` with a single-channel head on the right mount, plus + the transport (for command inspection) and the head itself. + """ + transport = transport_cls(pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.right + assert isinstance(head, FlexHead1) + return flex, transport, head + + +def _flex_head8( + transport_cls: Type[ChatterboxTransport] = ChatterboxTransport, +) -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead8]: + """An ``OpentronsFlex`` with an 8-channel head on the left mount, plus the + transport (for command inspection) and the head itself. + """ + transport = transport_cls(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, transport, head + + +def _flex_head96( + transport_cls: Type[ChatterboxTransport] = ChatterboxTransport, +) -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead96]: + """An ``OpentronsFlex`` with a 96-channel head, plus the transport (for + command inspection) and the head itself. + """ + transport = transport_cls(pipettes=[("p1000_96", 96, 1.0, 1000.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.head96 + assert isinstance(head, FlexHead96) + return flex, transport, head + + +class TestFlexHead1ContainerOps(unittest.TestCase): + """FlexHead1 aspirate/dispense accept a bare Container: the container is its + own robot-side labware addressed at its sole well "A1", and volume is + tracked against the container's single tracker. + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_aspirate_names_container_labware_at_well_a1(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(1000.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.aspirate(trough, volume=50)) + + load_cmds = [ + c + for c in transport.commands + if c["commandType"] == "loadLabware" and c["params"]["loadName"] == "nest_1_reservoir_195ml" + ] + self.assertEqual(len(load_cmds), 1, "the container itself must be loaded as labware") + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") + self.assertEqual(aspirate_cmds[0]["params"]["labwareId"], load_cmds[0]["params"]["labwareId"]) + # A single nozzle goes to the cavity center: no x/y centering offset, + # just the default bottom clearance. + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"], + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, + ) + + self.assertAlmostEqual(trough.tracker.volume, 950.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_adds_volume_to_container_tracker(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.dispense(trough, volume=30)) + + dispense_cmds = [c for c in transport.commands if c["commandType"] == "dispense"] + self.assertEqual(len(dispense_cmds), 1) + self.assertEqual(dispense_cmds[0]["params"]["wellName"], "A1") + self.assertAlmostEqual(trough.tracker.volume, 30.0) + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead8ContainerOps(unittest.TestCase): + """FlexHead8 aspirate_container/dispense_container fan all 8 nozzles into + one cavity: ONE command at well "A1" with a wellLocation centering the + 63 mm nozzle row, and the container's single tracker moves + volume * (channels holding tips) as one committed/rolled-back op. + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_aspirate_container_sends_one_centered_command_at_a1(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_container(trough, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + params = aspirate_cmds[0]["params"] + self.assertEqual(params["wellName"], "A1") + self.assertEqual(params["volume"], 50) + # The anchor (channel A) nozzle sits half the 63 mm row span back (+y) + # of the cavity center, so the row is centered front-to-back. + self.assertEqual( + params["wellLocation"], + {"origin": "bottom", "offset": {"x": 0.0, "y": 31.5, "z": 1.0}}, + ) + + cmd_types = [c["commandType"] for c in transport.commands] + prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] + aspirate_indices = [i for i, t in enumerate(cmd_types) if t == "aspirate"] + self.assertEqual(len(prepare_indices), 1, "prepareToAspirate must fire before the aspirate") + self.assertEqual(prepare_indices[0], aspirate_indices[0] - 1) + + # All 8 channels hold a tip, so the single tracker loses 8 * 50. + self.assertAlmostEqual(trough.tracker.volume, 9600.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 9600.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_tracker_scales_with_mounted_tips(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + # Leave only 3 tips in column 0 (rows A, D, H) before the pickup. + column_0_spots = rack.get_all_items()[0:8] + for i in (1, 2, 4, 5, 6): + column_0_spots[i].tracker.remove_tip(commit=True) + + asyncio.run(head.pick_up_tips(rack, column=0)) + self.assertEqual(sum(1 for t in head.get_mounted_tips() if t is not None), 3) + + asyncio.run(head.aspirate_container(trough, volume=50)) + + # Exactly the 3 tip-holding channels draw 50 each: -150, not -400. + self.assertAlmostEqual(trough.tracker.volume, 9850.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_container_adds_volume_per_mounted_tip(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.dispense_container(trough, volume=40)) + + dispense_cmds = [c for c in transport.commands if c["commandType"] == "dispense"] + self.assertEqual(len(dispense_cmds), 1) + params = dispense_cmds[0]["params"] + self.assertEqual(params["wellName"], "A1") + self.assertEqual( + params["wellLocation"], + {"origin": "bottom", "offset": {"x": 0.0, "y": 31.5, "z": 1.0}}, + ) + self.assertAlmostEqual(trough.tracker.volume, 320.0) + finally: + asyncio.run(flex.stop()) + + def test_container_well_location_merges_offset_and_liquid_height(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run( + head.aspirate_container( + trough, volume=10, offset=Coordinate(x=2, y=-1, z=0.5), liquid_height=3 + ) + ) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + # The caller's offset rides on top of the centering; liquid_height adds + # to z, replacing the default clearance. + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"], + {"origin": "bottom", "offset": {"x": 2.0, "y": 30.5, "z": 3.5}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_without_tip_rejects_before_any_wire_command(self): + flex, transport, head = _flex_head8() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate_container(trough, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 10000.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_rejects_cavity_narrower_than_nozzle_row(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + narrow = _make_trough(name="narrow", size_y=40.0, max_volume=50000.0) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(narrow, "C2") + narrow.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + + # 40 mm front-to-back cannot contain the 63 mm nozzle row. + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate_container(narrow, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(narrow.tracker.volume, 10000.0) + self.assertAlmostEqual(narrow.tracker.get_used_volume(), 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_wire_failure_rolls_back_container_tracker(self): + flex, _transport, head = _flex_head8(transport_cls=_FailingAspirateTransport) + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + with self.assertRaises(RuntimeError): + asyncio.run(head.aspirate_container(trough, volume=50)) + + # The staged 8 * 50 uL is rolled back in full: committed AND pending + # volume are untouched. + self.assertAlmostEqual(trough.tracker.volume, 10000.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_column_aspirate_on_plate_unchanged(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + wells = plate.get_all_items() + for well in wells: + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate, column=2, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A3") + + column_2 = set(wells[16:24]) + for well in wells: + expected = 50.0 if well in column_2 else 100.0 + self.assertAlmostEqual(well.tracker.volume, expected, msg=well.name) + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead96ContainerOps(unittest.TestCase): + """FlexHead96 aspirate/dispense accept a bare Container: ONE command at + well "A1" whose wellLocation puts the back-left (A1) anchor nozzle at + (-49.5, +31.5) from the cavity center so the 12x8 grid is centered, and + the container's single tracker moves volume * (channels holding tips). + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_aspirate_container_centers_grid_and_tracks_96_channels(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.aspirate(trough, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + params = aspirate_cmds[0]["params"] + self.assertEqual(params["wellName"], "A1") + # Back-left anchor nozzle at (-99/2, +63/2) from the cavity center + # centers the whole 12x8 grid. + self.assertEqual( + params["wellLocation"], + {"origin": "bottom", "offset": {"x": -49.5, "y": 31.5, "z": 1.0}}, + ) + + self.assertAlmostEqual(trough.tracker.volume, 100000.0 - 96 * 50.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 100000.0 - 96 * 50.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_container_centers_grid_and_adds_96x(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.dispense(trough, volume=20)) + + dispense_cmds = [c for c in transport.commands if c["commandType"] == "dispense"] + self.assertEqual(len(dispense_cmds), 1) + params = dispense_cmds[0]["params"] + self.assertEqual(params["wellName"], "A1") + self.assertEqual( + params["wellLocation"], + {"origin": "bottom", "offset": {"x": -49.5, "y": 31.5, "z": 1.0}}, + ) + self.assertAlmostEqual(trough.tracker.volume, 96 * 20.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_without_tip_rejects_before_any_wire_command(self): + flex, transport, head = _flex_head96() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(trough, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 100000.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_rejects_cavity_smaller_than_grid(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + narrow = _make_trough(name="narrow", size_x=90.0, max_volume=50000.0) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(narrow, "C2") + narrow.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack)) + + # 90 mm left-to-right cannot contain the grid's 99 mm x span. + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(narrow, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(narrow.tracker.volume, 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_wire_failure_rolls_back_container_tracker(self): + flex, _transport, head = _flex_head96(transport_cls=_FailingAspirateTransport) + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + with self.assertRaises(RuntimeError): + asyncio.run(head.aspirate(trough, volume=50)) + + # The staged 96 * 50 uL is rolled back in full: committed AND pending + # volume are untouched. + self.assertAlmostEqual(trough.tracker.volume, 100000.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 100000.0) + finally: + asyncio.run(flex.stop()) + + def test_plate_aspirate_unchanged(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.aspirate(plate, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") + for well in plate.get_all_items(): + self.assertAlmostEqual(well.tracker.volume, 50.0, msg=well.name) + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_gripper.py b/pylabrobot/opentrons/flex_gripper.py index c69e7dfebd0..b317b487e16 100644 --- a/pylabrobot/opentrons/flex_gripper.py +++ b/pylabrobot/opentrons/flex_gripper.py @@ -15,7 +15,7 @@ """ import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from pylabrobot.opentrons.robot import OpentronsError from pylabrobot.resources.resource import Resource @@ -29,12 +29,71 @@ # them far more headroom than the 30s ``_execute_command`` default. _MOVE_LABWARE_TIMEOUT = 120.0 +# The robot/* direct-motion command family (robot/moveTo, +# robot/openGripperJaw, robot/closeGripperJaw) landed in robot-server 8.2.0. +_ROBOT_COMMANDS_MIN_VERSION = "8.2.0" + +# Grip-force bounds (Newtons) the robot-server accepts for closeGripperJaw. +_GRIPPER_MIN_FORCE = 2.0 +_GRIPPER_MAX_FORCE = 30.0 + + +def _version_tuple(version: str) -> Tuple[int, ...]: + """Parse a dotted robot-software version into comparable integers. + + Comparing these as strings puts "10.0.0" below "7.1.0", so the version gate + compares numerically. Each dotted segment contributes its leading integer + ("0-beta" -> 0); a segment with no leading digit stops the parse. Only used + to gate at coarse major.minor granularity, where the exact handling of a + pre-release suffix does not change the outcome. + """ + parts: List[int] = [] + for part in version.split("."): + digits = "" + for char in part: + if not char.isdigit(): + break + digits += char + if digits == "": + break + parts.append(int(digits)) + return tuple(parts) + + +def _require_robot_commands(command: str, api_version: Optional[str]) -> None: + """Raise unless the robot's software supports the robot/* command family. + + ``api_version`` is the ``GET /health`` ``api_version`` the owning robot + stored at setup (``flex.api_version``). Released builds report a plain + numeric version and are gated against ``_ROBOT_COMMANDS_MIN_VERSION``; + dev/simulator builds ("0.0.0.dev0") and offline stand-in transports report + non-release strings but run current code, so they pass. + """ + if api_version is None: + raise OpentronsError( + "Robot version unknown", + f"{command} requires setup() to have run, to read the robot's version.", + ) + if "dev" in api_version: + return + version = _version_tuple(api_version) + if version and version < _version_tuple(_ROBOT_COMMANDS_MIN_VERSION): + raise OpentronsError( + "Robot software too old", + f"{command} requires Opentrons robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, " + f"but this robot reports {api_version}.", + ) + class FlexGripper: """The Opentrons Flex gripper (extension mount). Constructed by ``OpentronsFlex._model_setup()`` when instrument discovery reports a gripper; access it as ``flex.gripper``. + + The Flex gripper has NO rotation capability (a hardware limitation, not a + missing API): labware keeps its orientation through every gripper motion, + so a plate cannot be re-oriented between slots. """ def __init__(self, flex: "OpentronsFlex", gripper_model: str) -> None: @@ -102,3 +161,55 @@ async def ungrip(self) -> None: recover the plate by hand. """ await self.flex._execute_command("unsafe/ungripLabware", {}) + + # --- robot/*: direct gripper motion and jaw control --- + + async def move_to(self, x: float, y: float, z: float, speed: Optional[float] = None) -> None: + """Move the gripper to an absolute deck-frame position, in mm. + + Uses ``robot/moveTo`` with the extension mount rather than the + ``robot/moveAxes*`` family: those infer the mount from the axis map, and + the server's offset table has no gripper entry, so an ``extensionZ`` + target fails on the robot with ``KeyError: Mount.EXTENSION``. The gripper + also has a lower z ceiling than the pipette mounts, so a z a pipette + accepts can still be out of bounds. ``speed`` is in mm/s (robot default + if None). + """ + _require_robot_commands("robot/moveTo", self.flex.api_version) + # The robot/* commands take snake_case params, unlike the rest of the API. + params: Dict[str, Any] = {"mount": "extension", "destination": {"x": x, "y": y, "z": z}} + if speed is not None: + params["speed"] = speed + await self.flex._execute_command("robot/moveTo", params) + + async def grip(self, force: Optional[float] = None) -> None: + """Close the gripper jaw around whatever sits between its paddles. + + Args: + force: Grip force in Newtons, between 2.0 and 30.0. The robot applies + its own default when None. There is no jaw-width parameter; the jaw + closes until it grips. + + Raises: + OpentronsError: If ``force`` is outside the accepted range -- raised + before any wire command is sent. + """ + _require_robot_commands("robot/closeGripperJaw", self.flex.api_version) + params: Dict[str, Any] = {} + if force is not None: + if not _GRIPPER_MIN_FORCE <= force <= _GRIPPER_MAX_FORCE: + raise OpentronsError( + "Invalid grip force", + f"Grip force must be between {_GRIPPER_MIN_FORCE} and {_GRIPPER_MAX_FORCE} Newtons, " + f"got {force}.", + ) + params["force"] = force + await self.flex._execute_command("robot/closeGripperJaw", params) + + async def open_jaw(self) -> None: + """Open the gripper jaw -- the robot opens by HOMING the jaw to fully open. + + Releases anything held; there is no partial-open width parameter. + """ + _require_robot_commands("robot/openGripperJaw", self.flex.api_version) + await self.flex._execute_command("robot/openGripperJaw", {}) diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 9b21464d1e7..c4bb0bb2e30 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -25,10 +25,12 @@ from pylabrobot.opentrons.robot import OpentronsError from pylabrobot.resources import ( + Container, Plate, TipRack, TipSpot, Trash, + VolumeTracker, Well, does_tip_tracking, does_volume_tracking, @@ -388,6 +390,131 @@ def _well_location( return None return {"origin": origin, "offset": offset} + # --- Single-cavity container (trough/reservoir) shared helpers --- + + @staticmethod + def _container_well_location( + centering: Coordinate, + offset: Optional[Coordinate], + liquid_height: Optional[float], + ) -> Dict[str, Any]: + """Build the ``wellLocation`` for an op on a single-cavity container. + + ``centering`` is the head-geometry offset from the cavity center to + where the anchor nozzle must go (each head computes its own); the + caller's ``offset``/``liquid_height`` ride on top, with the same z + defaulting as ``_well_location`` (bottom clearance when neither is + given). Always returns a dict -- the centering must reach the wire + even when the caller passed nothing. + """ + o = offset if offset is not None else Coordinate.zero() + if offset is None and liquid_height is None: + z = _DEFAULT_WELL_BOTTOM_CLEARANCE + else: + z = o.z + (liquid_height if liquid_height is not None else 0.0) + return { + "origin": "bottom", + "offset": {"x": centering.x + o.x, "y": centering.y + o.y, "z": z}, + } + + @staticmethod + def _stage_container_aspirate(container: Container, total_volume: float) -> List[VolumeTracker]: + """Stage an aspirate's total volume against a container's single tracker. + + N channels drawing from one cavity share ONE tracker, whose pending + ops accumulate onto one pending volume and are flushed/discarded + together by a single ``commit()``/``rollback()``. So the summed volume + is staged as ONE ``remove_liquid`` and the tracker appears ONCE in the + returned staged list -- staging per channel would orphan the earlier + pending ops if a later channel's validation raised. + """ + staged_trackers: List[VolumeTracker] = [] + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.remove_liquid(volume=total_volume) # stages + validates + staged_trackers.append(container.tracker) + return staged_trackers + + @staticmethod + def _stage_container_dispense(container: Container, total_volume: float) -> List[VolumeTracker]: + """Stage a dispense's total volume against a container's single tracker. + + Same one-tracker rule as ``_stage_container_aspirate``, with + ``add_liquid`` staging the summed volume. + """ + staged_trackers: List[VolumeTracker] = [] + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.add_liquid(volume=total_volume) # stages + validates + staged_trackers.append(container.tracker) + return staged_trackers + + @staticmethod + def _require_span_fits_container(container: Container, x_span: float, y_span: float) -> None: + """Raise pre-wire if the centered nozzle array would overhang the cavity. + + The nozzles are rigid, so an op fanning one command into a single + cavity can only land every nozzle inside it if the cavity's footprint + contains the centered array's span on each axis. + """ + if x_span > container.get_size_x() or y_span > container.get_size_y(): + raise OpentronsError( + "Container too small", + f"The nozzle array spans {x_span} x {y_span} mm, which does not fit inside " + f"'{container.name}' ({container.get_size_x()} x {container.get_size_y()} mm). " + "Aim it at a container that holds the whole array.", + ) + + # --- Direct head motion (teaching / recovery jog) --- + + async def position(self) -> Coordinate: + """The head's current deck-frame position -- one ``savePosition`` query. + + Reports the pipette's critical point: the bottom of the mounted tip, or + the nozzle when no tip is mounted. The Flex's robot frame coincides with + the deck frame, so the reported position needs no conversion. + """ + result = await self._execute("savePosition", {"pipetteId": self.pipette_id}) + pos = result["result"]["position"] + return Coordinate(pos["x"], pos["y"], pos["z"]) + + async def move_to( + self, + x: Optional[float] = None, + y: Optional[float] = None, + z: Optional[float] = None, + speed: Optional[float] = None, + minimum_z_height: Optional[float] = None, + ) -> None: + """Move the head to an absolute deck-frame position, holding any axis left + unspecified -- ONE ``moveToCoordinates`` command. + + Axes left unspecified are filled from ``position()`` first, so a combined + move travels a single path instead of an axis-by-axis staircase (the read + is skipped when all three axes are given). A mounted tip is NOT required: + jogging is for teaching and recovery, and the target refers to the bottom + of the mounted tip, or the nozzle when none is mounted. + ``minimum_z_height`` (mm) defaults to the traversal height, so a lateral + jog arcs over deck labware; ``speed`` is in mm/s (robot default if None). + """ + if x is None and y is None and z is None: + raise ValueError("move_to: supply at least one of x, y, z.") + if x is None or y is None or z is None: + current = await self.position() + x = current.x if x is None else x + y = current.y if y is None else y + z = current.z if z is None else z + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "coordinates": {"x": x, "y": y, "z": z}, + "minimumZHeight": minimum_z_height if minimum_z_height is not None else _TRAVERSAL_HEIGHT, + } + if speed is not None: + params["speed"] = speed + await self._execute("moveToCoordinates", params) + + +# Default minimumZHeight (mm) for moveToCoordinates jogs: the head keeps at +# least this z while traveling, clearing any labware on the deck. +_TRAVERSAL_HEIGHT = 120.0 # Column index -> A-row well name (the Flex API's anchor well for 8-channel # ALL-mode column ops; the hardware fans a single command out to all 8 @@ -416,6 +543,18 @@ def _well_location( # bottom-referenced wellLocation for liquid ops. _DEFAULT_WELL_BOTTOM_CLEARANCE = 1.0 +# Opentrons single-cavity labware definitions (troughs/reservoirs) expose +# exactly one well, named "A1" -- container ops always address it. +_CONTAINER_WELL_NAME = "A1" + +# The 96-channel nozzle grid is 12 columns x 8 rows at 9 mm pitch: 99 mm +# A1->A12 in x, 63 mm A1->H1 in y. Used to center the head in a container. +_NINETY_SIX_HEAD_X_SPAN = (12 - 1) * 9.0 +_NINETY_SIX_HEAD_Y_SPAN = (8 - 1) * 9.0 + +# The 8-channel head's single nozzle row has the same 7 gaps front-to-back. +_EIGHT_CHANNEL_Y_SPAN = _NINETY_SIX_HEAD_Y_SPAN + class FlexHead1(_FlexHead): """Single-channel pipette head, well-addressed. @@ -531,31 +670,35 @@ async def discard_tips(self, trash: Trash) -> None: async def aspirate( self, - well: Well, + target: Union[Well, Container], volume: float, flow_rate: Optional[float] = None, offset: Optional[Coordinate] = None, liquid_height: Optional[float] = None, ) -> None: - """Aspirate from ``well`` -- one ``aspirate`` command naming it. - - Follows stage -> validate -> wire -> commit/rollback: ``well.tracker`` - (``remove_liquid``) is staged BEFORE the wire command, so an infeasible - aspirate raises before any hardware motion. A ``prepareToAspirate`` - command is sent first if this is the first aspirate since the last tip - pickup. + """Aspirate from a well or single-cavity container -- one ``aspirate`` command. + + A ``Well`` is addressed through its plate parent by well name. A bare + ``Container`` (trough/reservoir) is its own robot-side labware whose + single-cavity definition exposes exactly one well, named "A1", so the + command names the container's labware id and well "A1"; the volume is + tracked against the container's own tracker. Either way: stage -> + validate -> wire -> commit/rollback -- the tracker (``remove_liquid``) + is staged BEFORE the wire command, so an infeasible aspirate raises + before any hardware motion. A ``prepareToAspirate`` command is sent + first if this is the first aspirate since the last tip pickup. """ self._warn_untested_hardware() - parent = self._require_itemized_parent(well) - labware_id = await self.flex._ensure_labware_loaded(parent) - well_name = parent.get_child_identifier(well) + if isinstance(target, Well): + parent = self._require_itemized_parent(target) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(target) + else: + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = _CONTAINER_WELL_NAME rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE - tracking = does_volume_tracking() - staged_trackers: List[Any] = [] - if tracking and not well.tracker.is_disabled: - well.tracker.remove_liquid(volume=volume) # stages + validates - staged_trackers.append(well.tracker) + staged_trackers = self._stage_container_aspirate(target, volume) params: Dict[str, Any] = { "pipetteId": self.pipette_id, @@ -572,29 +715,32 @@ async def aspirate( async def dispense( self, - well: Well, + target: Union[Well, Container], volume: float, flow_rate: Optional[float] = None, offset: Optional[Coordinate] = None, liquid_height: Optional[float] = None, ) -> None: - """Dispense to ``well`` -- one ``dispense`` command naming it. - - Follows stage -> validate -> wire -> commit/rollback: ``well.tracker`` - (``add_liquid``) is staged BEFORE the wire command, so an infeasible - dispense raises before any hardware motion. + """Dispense to a well or single-cavity container -- one ``dispense`` command. + + A ``Well`` is addressed through its plate parent by well name; a bare + ``Container`` (trough/reservoir) is addressed as its own labware at + its sole robot-side well "A1" (see ``aspirate``). Either way: stage -> + validate -> wire -> commit/rollback -- the tracker (``add_liquid``) is + staged BEFORE the wire command, so an infeasible dispense raises + before any hardware motion. """ self._warn_untested_hardware() - parent = self._require_itemized_parent(well) - labware_id = await self.flex._ensure_labware_loaded(parent) - well_name = parent.get_child_identifier(well) + if isinstance(target, Well): + parent = self._require_itemized_parent(target) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(target) + else: + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = _CONTAINER_WELL_NAME rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE - tracking = does_volume_tracking() - staged_trackers: List[Any] = [] - if tracking and not well.tracker.is_disabled: - well.tracker.add_liquid(volume=volume) # stages + validates - staged_trackers.append(well.tracker) + staged_trackers = self._stage_container_dispense(target, volume) params: Dict[str, Any] = { "pipetteId": self.pipette_id, @@ -922,6 +1068,104 @@ async def dispense( await self._execute_liquid_op("dispense", params, staged_trackers) + # --- Single-cavity container (trough/reservoir) liquid handling --- + + @staticmethod + def _container_centering() -> Coordinate: + """Offset from the cavity center to where the anchor (channel A) nozzle goes. + + The 8 nozzles span 63 mm front-to-back at a 9 mm pitch, and the wire + command positions the A-row (rearmost) nozzle, so centering the row in + the cavity puts that anchor half a span back (+y) of the cavity + center. + """ + return Coordinate(y=_EIGHT_CHANNEL_Y_SPAN / 2) + + async def aspirate_container( + self, + container: Container, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Aspirate ``volume`` uL per channel from one single-cavity container. + + All 8 nozzles dip into the same cavity (trough/reservoir), which is + its own robot-side labware whose single-cavity definition exposes + exactly one well, named "A1": ONE ``aspirate`` command names that well + with a ``wellLocation`` that centers the nozzle row in the cavity + (``_container_centering``). Requires at least one mounted tip and a + cavity deep enough front-to-back to contain the 63 mm row -- both + checked before any wire command -- plus ALL nozzle mode (reset first + if a single-tip op left the layout otherwise). Each channel holding a + tip draws ``volume``, so the container's single tracker is staged with + ``volume * (channels holding tips)`` and committed/rolled back as one + op (stage -> validate -> wire -> commit/rollback). A + ``prepareToAspirate`` command is sent first if this is the first + aspirate since the last tip pickup. + """ + self._require_mounted_tip() + self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(container) + rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + + mounted = sum(1 for tip in self._channel_tips if tip is not None) + staged_trackers = self._stage_container_aspirate(container, volume * mounted) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": _CONTAINER_WELL_NAME, + "volume": volume, + "flowRate": rate, + "wellLocation": self._container_well_location( + self._container_centering(), offset, liquid_height + ), + } + + await self._execute_with_prepare("aspirate", params, staged_trackers) + + async def dispense_container( + self, + container: Container, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Dispense ``volume`` uL per channel into one single-cavity container. + + Mirrors ``aspirate_container``: ONE ``dispense`` command at the + container's sole robot-side well "A1", ``wellLocation`` centering the + nozzle row in the cavity, the same pre-wire guards (mounted tip, row + fits the cavity, ALL nozzle mode), and the container's single tracker + staged with ``volume * (channels holding tips)`` and committed/rolled + back as one op (stage -> validate -> wire -> commit/rollback). + """ + self._require_mounted_tip() + self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(container) + rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + + mounted = sum(1 for tip in self._channel_tips if tip is not None) + staged_trackers = self._stage_container_dispense(container, volume * mounted) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": _CONTAINER_WELL_NAME, + "volume": volume, + "flowRate": rate, + "wellLocation": self._container_well_location( + self._container_centering(), offset, liquid_height + ), + } + + await self._execute_liquid_op("dispense", params, staged_trackers) + async def touch_tip( self, plate: Plate, @@ -1295,45 +1539,76 @@ async def discard_tips(self, trash: Trash) -> None: """Discard the mounted 96 tips into the trash.""" await self.drop_tips(trash) + @staticmethod + def _container_centering() -> Coordinate: + """Offset from the cavity center to where the back-left (A1) anchor nozzle goes. + + The wire command positions the A1 nozzle -- the back-left corner of + the 12x8 grid -- so centering the grid in the cavity puts that anchor + back and left of the cavity center by half the grid's 99 x 63 mm + span; anchoring at the center instead would hang ~half the nozzles + off the cavity edge. + """ + return Coordinate(x=-_NINETY_SIX_HEAD_X_SPAN / 2, y=_NINETY_SIX_HEAD_Y_SPAN / 2) + async def aspirate( self, - plate: Plate, + target: Union[Plate, Container], volume: float, flow_rate: Optional[float] = None, offset: Optional[Coordinate] = None, liquid_height: Optional[float] = None, ) -> None: - """Aspirate the whole plate -- one ``aspirate`` command anchored at "A1". - - Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` - (``remove_liquid``) is staged for every well whose channel actually - holds a tip (None-skip) BEFORE the wire command, so an infeasible - aspirate raises before any hardware motion. A ``prepareToAspirate`` - command is sent first if this is the first aspirate since the last tip - pickup. + """Aspirate a whole plate or one single-cavity container -- one ``aspirate`` command. + + A ``Plate`` (which must have exactly 96 positions) is anchored at its + "A1" well and covered one-to-one: ``Well.tracker`` (``remove_liquid``) + is staged for every well whose channel actually holds a tip + (None-skip). A bare ``Container`` (trough/reservoir) is its own + robot-side labware whose single-cavity definition exposes exactly one + well, named "A1": the command's ``wellLocation`` centers the 12x8 + nozzle grid in the cavity (``_container_centering``), at least one + mounted tip and a cavity footprint containing the grid's 99 x 63 mm + span are required (checked before any wire command), and the + container's single tracker is staged with ``volume * (channels + holding tips)``. Either way: stage -> validate -> wire -> + commit/rollback, with an infeasible aspirate raising before any + hardware motion, and a ``prepareToAspirate`` command sent first if + this is the first aspirate since the last tip pickup. """ self._warn_untested_hardware() - wells = self._check_full_coverage(plate) - labware_id = await self.flex._ensure_labware_loaded(plate) rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE - - tracking = does_volume_tracking() staged_trackers: List[Any] = [] - if tracking: - for i, well in enumerate(wells): - if self._channel_tips[i] is None or well.tracker.is_disabled: - continue - well.tracker.remove_liquid(volume=volume) # stages + validates - staged_trackers.append(well.tracker) + well_location: Optional[Dict[str, Any]] + if isinstance(target, Plate): + wells = self._check_full_coverage(target) + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = self._ANCHOR_WELL_NAME + if does_volume_tracking(): + for i, well in enumerate(wells): + if self._channel_tips[i] is None or well.tracker.is_disabled: + continue + well.tracker.remove_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + well_location = self._well_location([offset], [liquid_height]) + else: + self._require_mounted_tip() + self._require_span_fits_container(target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN) + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = _CONTAINER_WELL_NAME + mounted = sum(1 for tip in self._channel_tips if tip is not None) + staged_trackers.extend(self._stage_container_aspirate(target, volume * mounted)) + well_location = self._container_well_location( + self._container_centering(), offset, liquid_height + ) params: Dict[str, Any] = { "pipetteId": self.pipette_id, "labwareId": labware_id, - "wellName": self._ANCHOR_WELL_NAME, + "wellName": well_name, "volume": volume, "flowRate": rate, } - well_location = self._well_location([offset], [liquid_height]) if well_location is not None: params["wellLocation"] = well_location @@ -1341,41 +1616,57 @@ async def aspirate( async def dispense( self, - plate: Plate, + target: Union[Plate, Container], volume: float, flow_rate: Optional[float] = None, offset: Optional[Coordinate] = None, liquid_height: Optional[float] = None, ) -> None: - """Dispense to the whole plate -- one ``dispense`` command anchored at "A1". - - Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` - (``add_liquid``) is staged for every well whose channel actually holds a - tip (None-skip) BEFORE the wire command, so an infeasible dispense - raises before any hardware motion. + """Dispense to a whole plate or one single-cavity container -- one ``dispense`` command. + + Mirrors ``aspirate``: a ``Plate`` is anchored at its "A1" well with + ``Well.tracker`` (``add_liquid``) staged per tip-holding channel + (None-skip); a bare ``Container`` is addressed at its sole robot-side + well "A1" with the nozzle grid centered in the cavity, the same + pre-wire guards (mounted tip, grid fits the cavity footprint), and + the container's single tracker staged with ``volume * (channels + holding tips)``. Either way: stage -> validate -> wire -> + commit/rollback, with an infeasible dispense raising before any + hardware motion. """ self._warn_untested_hardware() - wells = self._check_full_coverage(plate) - labware_id = await self.flex._ensure_labware_loaded(plate) rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE - - tracking = does_volume_tracking() staged_trackers: List[Any] = [] - if tracking: - for i, well in enumerate(wells): - if self._channel_tips[i] is None or well.tracker.is_disabled: - continue - well.tracker.add_liquid(volume=volume) # stages + validates - staged_trackers.append(well.tracker) + well_location: Optional[Dict[str, Any]] + if isinstance(target, Plate): + wells = self._check_full_coverage(target) + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = self._ANCHOR_WELL_NAME + if does_volume_tracking(): + for i, well in enumerate(wells): + if self._channel_tips[i] is None or well.tracker.is_disabled: + continue + well.tracker.add_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + well_location = self._well_location([offset], [liquid_height]) + else: + self._require_mounted_tip() + self._require_span_fits_container(target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN) + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = _CONTAINER_WELL_NAME + mounted = sum(1 for tip in self._channel_tips if tip is not None) + staged_trackers.extend(self._stage_container_dispense(target, volume * mounted)) + well_location = self._container_well_location( + self._container_centering(), offset, liquid_height + ) params: Dict[str, Any] = { "pipetteId": self.pipette_id, "labwareId": labware_id, - "wellName": self._ANCHOR_WELL_NAME, + "wellName": well_name, "volume": volume, "flowRate": rate, } - well_location = self._well_location([offset], [liquid_height]) if well_location is not None: params["wellLocation"] = well_location diff --git a/pylabrobot/opentrons/flex_motion_tests.py b/pylabrobot/opentrons/flex_motion_tests.py new file mode 100644 index 00000000000..a68f265ad3b --- /dev/null +++ b/pylabrobot/opentrons/flex_motion_tests.py @@ -0,0 +1,358 @@ +"""Tests for the Flex direct-motion surface: head jog + position read +(``_FlexHead.position``/``_FlexHead.move_to``) and gripper motion + jaw +control (``FlexGripper.move_to``/``grip``/``open_jaw``). + +Drives ``OpentronsFlex.setup()`` with an injected ``ChatterboxTransport`` and +asserts the exact wire commands: ``savePosition`` reads, ``moveToCoordinates`` +axis merging and ``minimumZHeight``/``speed`` handling, the ``robot/moveTo`` +extension-mount params, jaw force validation before any wire command, and the +robot-software version gate on the robot/* command family. +""" + +import asyncio +import unittest +from typing import Any, Dict, List, Optional, Tuple + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_gripper import FlexGripper, _require_robot_commands +from pylabrobot.opentrons.flex_head import _FlexHead +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.opentrons.flex_deck import FlexDeck + + +def _flex_with_gripper(**transport_kwargs) -> Tuple[OpentronsFlex, ChatterboxTransport]: + """An ``OpentronsFlex`` with a single-channel right-mount pipette and a + gripper, returning the transport too so a test can inspect recorded + commands. ``transport_kwargs`` are forwarded to ``ChatterboxTransport``. + """ + transport = ChatterboxTransport( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], + gripper=True, + **transport_kwargs, + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + return flex, transport + + +def _head(flex: OpentronsFlex) -> _FlexHead: + head = flex.right + assert head is not None + return head + + +def _gripper(flex: OpentronsFlex) -> FlexGripper: + gripper = flex.gripper + assert gripper is not None + return gripper + + +def _cmds(transport: ChatterboxTransport, command_type: str) -> List[Dict[str, Any]]: + return [c for c in transport.commands if c["commandType"] == command_type] + + +class _VersionedTransport(ChatterboxTransport): + """Chatterbox whose ``/health`` reports a caller-chosen robot software + version, so tests can drive the robot/* version gate. + """ + + def __init__(self, api_version: str, **kwargs) -> None: + super().__init__(**kwargs) + self._api_version = api_version + + async def get(self, path: str) -> Dict[str, Any]: + if path == "/health": + return { + "api_version": self._api_version, + "robot_model": "OT-3 Standard", + "name": "chatterbox", + } + return await super().get(path) + + +def _flex_with_version(api_version: str) -> Tuple[OpentronsFlex, ChatterboxTransport]: + transport = _VersionedTransport( + api_version, + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], + gripper=True, + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + return flex, transport + + +class TestHeadPosition(unittest.TestCase): + """position() reads the head's pose from a savePosition command result.""" + + def test_position_reads_save_position_result(self): + flex, transport = _flex_with_gripper(saved_position={"x": 10.0, "y": 20.0, "z": 30.5}) + asyncio.run(flex.setup()) + try: + head = _head(flex) + + position = asyncio.run(head.position()) + + self.assertEqual(position, Coordinate(10.0, 20.0, 30.5)) + save_cmds = _cmds(transport, "savePosition") + self.assertEqual(len(save_cmds), 1) + self.assertEqual(save_cmds[0]["params"], {"pipetteId": head.pipette_id}) + finally: + asyncio.run(flex.stop()) + + def test_position_default_saved_position(self): + flex, _transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + position = asyncio.run(_head(flex).position()) + self.assertEqual(position, Coordinate(100.0, 100.0, 100.0)) + finally: + asyncio.run(flex.stop()) + + +class TestHeadMoveTo(unittest.TestCase): + """move_to fills unspecified axes from the current position and sends ONE + moveToCoordinates command. No tip is mounted in any of these tests: jogging + is for teaching/recovery and must not require one. + """ + + def test_partial_axes_merge_saved_with_given(self): + flex, transport = _flex_with_gripper(saved_position={"x": 10.0, "y": 20.0, "z": 30.0}) + asyncio.run(flex.setup()) + try: + head = _head(flex) + + asyncio.run(head.move_to(x=50.0)) + + self.assertEqual(len(_cmds(transport, "savePosition")), 1) + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertEqual(len(move_cmds), 1) + self.assertEqual( + move_cmds[0]["params"], + { + "pipetteId": head.pipette_id, + "coordinates": {"x": 50.0, "y": 20.0, "z": 30.0}, + "minimumZHeight": 120.0, + }, + ) + finally: + asyncio.run(flex.stop()) + + def test_all_axes_given_skips_position_read(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0)) + + self.assertEqual(len(_cmds(transport, "savePosition")), 0) + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertEqual(len(move_cmds), 1) + self.assertEqual(move_cmds[0]["params"]["coordinates"], {"x": 1.0, "y": 2.0, "z": 3.0}) + finally: + asyncio.run(flex.stop()) + + def test_minimum_z_height_override(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0, minimum_z_height=35.0)) + + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertEqual(move_cmds[0]["params"]["minimumZHeight"], 35.0) + finally: + asyncio.run(flex.stop()) + + def test_speed_passthrough(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0, speed=40.0)) + + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertEqual(move_cmds[0]["params"]["speed"], 40.0) + finally: + asyncio.run(flex.stop()) + + def test_speed_omitted_by_default(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0)) + + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertNotIn("speed", move_cmds[0]["params"]) + finally: + asyncio.run(flex.stop()) + + def test_no_axes_raises_before_any_wire_command(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + with self.assertRaises(ValueError): + asyncio.run(_head(flex).move_to()) + + self.assertEqual(len(_cmds(transport, "savePosition")), 0) + self.assertEqual(len(_cmds(transport, "moveToCoordinates")), 0) + finally: + asyncio.run(flex.stop()) + + +class TestGripperMoveTo(unittest.TestCase): + """Gripper move_to sends robot/moveTo with the extension mount.""" + + def test_exact_wire_params(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).move_to(100.0, 50.0, 75.5)) + + move_cmds = _cmds(transport, "robot/moveTo") + self.assertEqual(len(move_cmds), 1) + self.assertEqual( + move_cmds[0]["params"], + {"mount": "extension", "destination": {"x": 100.0, "y": 50.0, "z": 75.5}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_speed_passthrough(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).move_to(1.0, 2.0, 3.0, speed=25.0)) + + move_cmds = _cmds(transport, "robot/moveTo") + self.assertEqual(move_cmds[0]["params"]["speed"], 25.0) + finally: + asyncio.run(flex.stop()) + + +class TestGripperJaw(unittest.TestCase): + """grip() validates force before the wire; open_jaw() homes the jaw open.""" + + def test_grip_without_force_sends_empty_params(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).grip()) + + close_cmds = _cmds(transport, "robot/closeGripperJaw") + self.assertEqual(len(close_cmds), 1) + self.assertEqual(close_cmds[0]["params"], {}) + finally: + asyncio.run(flex.stop()) + + def test_grip_force_boundaries_accepted(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + gripper = _gripper(flex) + + asyncio.run(gripper.grip(force=2.0)) + asyncio.run(gripper.grip(force=30.0)) + + close_cmds = _cmds(transport, "robot/closeGripperJaw") + self.assertEqual(len(close_cmds), 2) + self.assertEqual(close_cmds[0]["params"], {"force": 2.0}) + self.assertEqual(close_cmds[1]["params"], {"force": 30.0}) + finally: + asyncio.run(flex.stop()) + + def test_grip_force_out_of_range_raises_before_any_wire_command(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + gripper = _gripper(flex) + + for force in (1.9, 30.1, 0.0, -5.0): + with self.assertRaises(OpentronsError): + asyncio.run(gripper.grip(force=force)) + + self.assertEqual(len(_cmds(transport, "robot/closeGripperJaw")), 0) + finally: + asyncio.run(flex.stop()) + + def test_open_jaw_sends_open_gripper_jaw(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + + open_cmds = _cmds(transport, "robot/openGripperJaw") + self.assertEqual(len(open_cmds), 1) + self.assertEqual(open_cmds[0]["params"], {}) + finally: + asyncio.run(flex.stop()) + + +class TestRobotCommandsVersionGate(unittest.TestCase): + """robot/* commands require robot software 8.2.0+; dev builds and offline + stand-ins (non-release version strings) are exempt. Head motion + (savePosition/moveToCoordinates) is NOT gated -- it predates the robot/* + family. + """ + + def _assert_no_robot_commands(self, transport: ChatterboxTransport) -> None: + robot_cmds = [c for c in transport.commands if c["commandType"].startswith("robot/")] + self.assertEqual(len(robot_cmds), 0, "no robot/* wire command may be sent") + + def test_old_release_raises_and_sends_no_robot_commands(self): + flex, transport = _flex_with_version("8.1.0") + asyncio.run(flex.setup()) + try: + gripper = _gripper(flex) + + with self.assertRaises(OpentronsError) as ctx: + asyncio.run(gripper.move_to(1.0, 2.0, 3.0)) + self.assertIn("8.2.0", str(ctx.exception)) + with self.assertRaises(OpentronsError): + asyncio.run(gripper.grip()) + with self.assertRaises(OpentronsError): + asyncio.run(gripper.open_jaw()) + + self._assert_no_robot_commands(transport) + finally: + asyncio.run(flex.stop()) + + def test_minimum_release_passes(self): + flex, transport = _flex_with_version("8.2.0") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_dev_build_passes(self): + flex, transport = _flex_with_version("0.0.0.dev0") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_default_chatterbox_passes(self): + flex, transport = _flex_with_gripper() # /health reports "dry-run" + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_head_motion_is_not_gated(self): + flex, transport = _flex_with_version("8.1.0") + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0)) + self.assertEqual(len(_cmds(transport, "moveToCoordinates")), 1) + finally: + asyncio.run(flex.stop()) + + def test_unknown_version_raises(self): + with self.assertRaises(OpentronsError): + _require_robot_commands("robot/moveTo", None) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index 681dd8ffe19..7dbd5c1d2eb 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -86,9 +86,10 @@ class ChatterboxTransport: """Offline transport: logs commands, returns canned 'succeeded' responses. Instead of reaching a robot server it returns the fixed ``/health``, - ``/instruments``, ``/runs`` and ``/runs/{id}/commands`` shapes the - ``OpentronsRobot`` lifecycle (``setup()``: health check, create-run, - discover pipette) reads, so a caller can drive the robot with no network. + ``/instruments``, ``/runs``, ``/runs/{id}/commands`` and + ``/runs/{id}/labware_definitions`` shapes the ``OpentronsRobot`` lifecycle + (``setup()``: health check, create-run, discover pipette) and labware + loading read, so a caller can drive the robot with no network. Scope: this exercises PLR-native checks only. It does NOT reproduce the Opentrons Protocol Engine's *analysis* stage (deck-conflict, capacity, @@ -107,6 +108,7 @@ def __init__( simulate_stuck_tip: bool = False, liquid_probe_z: Optional[float] = None, gripper: bool = False, + saved_position: Optional[Dict[str, float]] = None, ) -> None: """Args: pipette: the simulated mounted pipette as ``(name, channels, min_vol, max_vol)``. @@ -138,6 +140,9 @@ def __init__( gripper: if True, ``/instruments`` also reports a gripper on the extension mount, so tests can drive gripper discovery. Default False: no gripper mounted (existing behavior). + saved_position: the position a ``savePosition`` command reports in its + result, as an ``{"x", "y", "z"}`` dict. Default None: report + ``{"x": 100.0, "y": 100.0, "z": 100.0}``. """ if pipettes is not None: self._pipettes: List[Tuple[str, int, float, float, str]] = list(pipettes) @@ -151,9 +156,11 @@ def __init__( self._pipette_load_count = 0 self.load_pipette_commands: List[Dict[str, Any]] = [] # recorded loadPipette params self.commands: List[Dict[str, Any]] = [] # every command, in send order: {commandType, params} + self.labware_definitions: List[Dict[str, Any]] = [] # recorded custom definition uploads self.simulate_failed_pickup = simulate_failed_pickup self.simulate_stuck_tip = simulate_stuck_tip self.liquid_probe_z = liquid_probe_z + self.saved_position = saved_position # Per-mount simulated hardware tip-presence sensor state (Flex reports # ONE bool per pipette, not per nozzle -- see /instruments below). self._tip_detected: Dict[str, bool] = {mount: False for *_rest, mount in self._pipettes} @@ -198,6 +205,21 @@ async def get(self, path: str) -> Dict[str, Any]: async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: if path == "/runs": return {"data": {"id": "chatterbox-run"}} + if path.endswith("/labware_definitions"): # custom labware definition upload + definition = (json or {}).get("data", {}) + self.labware_definitions.append(dict(definition)) + # The real robot-server answers with the stored definition's URI, which + # the caller parses to reference the definition in loadLabware. + uri = "/".join( + str(part) + for part in ( + definition.get("namespace"), + definition.get("parameters", {}).get("loadName"), + definition.get("version"), + ) + ) + self._log("Chatterbox: defineLabware %s", uri) + return {"data": {"definitionUri": uri}} if path.endswith("/commands"): data = (json or {}).get("data", {}) ctype = data.get("commandType", "?") @@ -205,6 +227,7 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s self._n += 1 cmd_id = f"cmd-{self._n}" self.commands.append({"commandType": ctype, "params": dict(params)}) + result: Dict[str, Any] = {} if ctype == "loadPipette": self._pipette_load_count += 1 pipette_id = f"chatterbox-pip-{self._pipette_load_count}" @@ -226,6 +249,9 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s elif ctype in ("liquidProbe", "tryLiquidProbe"): if self.liquid_probe_z is not None: result = {"z_position": self.liquid_probe_z} + elif ctype == "savePosition": + pos = self.saved_position or {"x": 100.0, "y": 100.0, "z": 100.0} + result = {"position": dict(pos)} cmd_data = {"id": cmd_id, "commandType": ctype, "status": "succeeded", "result": result} self._cmds[cmd_id] = cmd_data self._log("Chatterbox: %s %s", ctype, params) From 49aa7532ad936abf19a27f11a5f3aa4e03d28322 Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 10:06:07 -0400 Subject: [PATCH 04/36] feat(opentrons): custom labware definition upload for non-Opentrons labware 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 --- pylabrobot/opentrons/__init__.py | 10 + pylabrobot/opentrons/flex.py | 61 ++- pylabrobot/opentrons/labware_definitions.py | 257 ++++++++++++ .../opentrons/labware_definitions_tests.py | 386 ++++++++++++++++++ 4 files changed, 709 insertions(+), 5 deletions(-) create mode 100644 pylabrobot/opentrons/labware_definitions.py create mode 100644 pylabrobot/opentrons/labware_definitions_tests.py diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py index 2dfa6465d8c..7bd07b8d11f 100644 --- a/pylabrobot/opentrons/__init__.py +++ b/pylabrobot/opentrons/__init__.py @@ -1,6 +1,12 @@ from pylabrobot.opentrons.flex import OpentronsFlex from pylabrobot.opentrons.flex_gripper import FlexGripper from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96 +from pylabrobot.opentrons.labware_definitions import ( + build_container_definition, + build_movable_labware_definition, + build_plate_definition, + build_tip_rack_definition, +) from pylabrobot.opentrons.robot import OpentronsError, OpentronsRobot, PipetteInfo from pylabrobot.opentrons.transport import ChatterboxTransport, HttpxTransport, OpentronsTransport @@ -16,4 +22,8 @@ "OpentronsRobot", "OpentronsTransport", "PipetteInfo", + "build_container_definition", + "build_movable_labware_definition", + "build_plate_definition", + "build_tip_rack_definition", ] diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 1eb6b41b15f..39d0c2d8429 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -1,12 +1,17 @@ import logging import uuid -from typing import Any, Dict, List, Optional, Type, cast +from typing import Any, Dict, List, Optional, Tuple, Type, cast from pylabrobot.opentrons.flex_gripper import FlexGripper from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead +from pylabrobot.opentrons.labware_definitions import ( + build_container_definition, + build_plate_definition, + build_tip_rack_definition, +) from pylabrobot.opentrons.robot import OpentronsError, OpentronsRobot from pylabrobot.opentrons.transport import OpentronsTransport -from pylabrobot.resources import Resource +from pylabrobot.resources import Container, Plate, Resource, TipRack from pylabrobot.resources.opentrons.flex_deck import FlexDeck from pylabrobot.resources.trash import Trash @@ -53,6 +58,8 @@ def __init__( super().__init__(host=host, port=port, transport=transport) self.deck = deck self._loaded_labware: Dict[str, str] = {} + # resource.name -> (namespace, load_name, version) of an uploaded custom definition. + self._defined_labware: Dict[str, Tuple[str, str, int]] = {} self.left: Optional[_FlexHead] = None self.right: Optional[_FlexHead] = None self.head96: Optional[_FlexHead] = None @@ -157,7 +164,13 @@ async def _ensure_labware_loaded(self, resource: Resource) -> str: f"'{name}' is not on a deck slot. Use deck.assign_child_at_slot(resource, slot='C1').", ) - load_name = self._ot_load_name(resource) + try: + load_name = self._ot_load_name(resource) + namespace, version = _OT_NAMESPACE, _OT_VERSION + except OpentronsError: + # No official Opentrons definition: build one from the resource's PLR + # geometry, upload it, and load by the uploaded definition's identity. + namespace, load_name, version = await self._define_custom_labware(resource) labware_id = uuid.uuid4().hex[:12] result = await self._execute_command( @@ -165,8 +178,8 @@ async def _ensure_labware_loaded(self, resource: Resource) -> str: { "loadName": load_name, "location": {"slotName": slot}, - "namespace": _OT_NAMESPACE, - "version": _OT_VERSION, + "namespace": namespace, + "version": version, "labwareId": labware_id, "displayName": name, }, @@ -229,3 +242,41 @@ def _ot_load_name(resource: Resource) -> str: f"'{name_lower}' — set resource.ot_load_name = 'opentrons_flex_96_tiprack_50ul' " f"or use a standard Flex labware name.", ) + + async def _define_custom_labware(self, resource: Resource) -> Tuple[str, str, int]: + """Upload a geometry-derived definition for labware with no official Opentrons definition. + + Returns the uploaded definition's (namespace, load_name, version), parsed + from the robot-server's ``definitionUri`` so the subsequent ``loadLabware`` + references exactly what the server stored. Uploads once per resource per + run: the parsed identity is cached separately from ``_loaded_labware``, so + a re-load (e.g. after ``labware_moved_off_deck``) skips the re-upload. + """ + name = resource.name + if name in self._defined_labware: + return self._defined_labware[name] + + definition = self._build_labware_definition(resource) + assert self.run_id is not None, "No active run. Call setup() first." + data = await self._post(f"/runs/{self.run_id}/labware_definitions", {"data": definition}) + uri = cast(str, data["data"]["definitionUri"]) + namespace, load_name, version = uri.split("/") + self._defined_labware[name] = (namespace, load_name, int(version)) + logger.info("Uploaded custom labware definition for '%s': %s", name, uri) + return self._defined_labware[name] + + @staticmethod + def _build_labware_definition(resource: Resource) -> dict: + """Build the definition matching the resource's type, or raise for unbuildable labware.""" + if isinstance(resource, Plate): + return build_plate_definition(resource) + if isinstance(resource, TipRack): + return build_tip_rack_definition(resource) + if isinstance(resource, Container): + return build_container_definition(resource) + raise OpentronsError( + "Cannot build an Opentrons labware definition", + f"'{resource.name}' ({type(resource).__name__}) has no Opentrons load name, and a " + "definition can only be built from the geometry of a Plate, TipRack, or Container. " + "Set resource.ot_load_name to an official Opentrons load name.", + ) diff --git a/pylabrobot/opentrons/labware_definitions.py b/pylabrobot/opentrons/labware_definitions.py new file mode 100644 index 00000000000..e753d043ec1 --- /dev/null +++ b/pylabrobot/opentrons/labware_definitions.py @@ -0,0 +1,257 @@ +"""Builders for custom Opentrons labware definitions from PLR resource geometry. + +Labware without an official Opentrons definition (third-party plates, troughs, +lids) cannot ``loadLabware`` by name. The pure functions here (no I/O) build a +robot-server labware definition dict from the PLR resource's own geometry; +:class:`~pylabrobot.opentrons.flex.OpentronsFlex` uploads the dict to +``POST /runs/{run_id}/labware_definitions`` and then loads the labware by the +uploaded definition's ``namespace``/``loadName``/``version``. + +Frame conversion: PLR anchors labware at the front-left-bottom corner of a +slot while Opentrons anchors it at the back-left-bottom, so +``cornerOffsetFromSlot.y`` is ``86 - size_y`` (86 mm is the Opentrons slot +depth): labware shallower than the slot sits against the slot's back edge. +Well positions are front-left-bottom based in both frames and carry over +directly. +""" + +import re +from typing import Optional, cast + +from pylabrobot.resources import Container, Coordinate, Plate, Resource, TipRack +from pylabrobot.utils import reshape_2d + +_NAMESPACE = "pylabrobot" +_VERSION = 1 +_SCHEMA_VERSION = 2 +_OT_SLOT_SIZE_Y = 86 + + +def _definition_load_name(resource: Resource) -> str: + """Opentrons load names must match ``^[a-z0-9._]+$``; PLR names are unrestricted.""" + return re.sub(r"[^a-z0-9._]", "_", resource.name.lower()) + + +def build_plate_definition(plate: Plate, grip_distance_from_top: Optional[float] = None) -> dict: + """Build a robot-server wellPlate definition from a PLR plate's geometry. + + Wells carry their real depth and volume so well-referencing commands + (``touchTip``, ``liquidProbe``) get the true geometry. Wells are keyed by + their PLR child identifier ("A1" style), matching the ``wellName`` the + pipetting commands send. ``gripHeightFromLabwareBottom`` is included only + when ``grip_distance_from_top`` is given; without it the robot-server grips + at its default mid-height. + """ + well_names = [plate.get_child_identifier(well) for well in plate.get_all_items()] + definition: dict = { + "schemaVersion": _SCHEMA_VERSION, + "version": _VERSION, + "namespace": _NAMESPACE, + "metadata": { + "displayName": plate.name, + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + }, + "brand": {"brand": "unknown"}, + "parameters": { + "format": "irregular", + "isTiprack": False, + "loadName": _definition_load_name(plate), + "isMagneticModuleCompatible": False, + }, + "ordering": reshape_2d(well_names, (plate.num_items_x, plate.num_items_y)), + "cornerOffsetFromSlot": { + "x": 0, + "y": _OT_SLOT_SIZE_Y - plate.get_absolute_size_y(), + "z": 0, + }, + "dimensions": { + "xDimension": plate.get_absolute_size_x(), + "yDimension": plate.get_absolute_size_y(), + "zDimension": plate.get_absolute_size_z(), + }, + "wells": { + plate.get_child_identifier(well): { + "depth": well.get_absolute_size_z(), + "x": cast(Coordinate, well.location).x + well.get_absolute_size_x() / 2, + "y": cast(Coordinate, well.location).y + well.get_absolute_size_y() / 2, + "z": cast(Coordinate, well.location).z, + "shape": "circular", + "diameter": well.get_absolute_size_x(), + "totalLiquidVolume": well.max_volume, + } + for well in plate.get_all_items() + }, + "groups": [{"wells": well_names, "metadata": {"wellBottomShape": "flat"}}], + } + if grip_distance_from_top is not None: + definition["gripHeightFromLabwareBottom"] = max( + 0.0, plate.get_absolute_size_z() - grip_distance_from_top + ) + return definition + + +def build_tip_rack_definition( + tip_rack: TipRack, grip_distance_from_top: Optional[float] = None +) -> dict: + """Build a robot-server tipRack definition from a PLR tip rack's geometry. + + Tip length and overlap come from the rack's A1 prototype tip, so the robot + computes the same pickup z the PLR tip model implies. + """ + tip = tip_rack.get_item("A1").make_tip() + spot_names = [tip_rack.get_child_identifier(spot) for spot in tip_rack.get_all_items()] + definition: dict = { + "schemaVersion": _SCHEMA_VERSION, + "version": _VERSION, + "namespace": _NAMESPACE, + "metadata": { + "displayName": tip_rack.name, + "displayCategory": "tipRack", + "displayVolumeUnits": "µL", + }, + "brand": {"brand": "unknown"}, + "parameters": { + "format": "96Standard", + "isTiprack": True, + "tipLength": tip.total_tip_length, + "tipOverlap": tip.fitting_depth, + "loadName": _definition_load_name(tip_rack), + "isMagneticModuleCompatible": False, + }, + "ordering": reshape_2d(spot_names, (tip_rack.num_items_x, tip_rack.num_items_y)), + "cornerOffsetFromSlot": { + "x": 0, + "y": _OT_SLOT_SIZE_Y - tip_rack.get_absolute_size_y(), + "z": 0, + }, + "dimensions": { + "xDimension": tip_rack.get_absolute_size_x(), + "yDimension": tip_rack.get_absolute_size_y(), + "zDimension": tip_rack.get_absolute_size_z(), + }, + "wells": { + tip_rack.get_child_identifier(spot): { + "depth": spot.get_absolute_size_z(), + "x": cast(Coordinate, spot.location).x + spot.get_absolute_size_x() / 2, + "y": cast(Coordinate, spot.location).y + spot.get_absolute_size_y() / 2, + "z": cast(Coordinate, spot.location).z, + "shape": "circular", + "diameter": spot.get_absolute_size_x(), + "totalLiquidVolume": tip.maximal_volume, + } + for spot in tip_rack.get_all_items() + }, + "groups": [ + { + "wells": spot_names, + "metadata": { + "displayName": None, + "displayCategory": "tipRack", + "wellBottomShape": "flat", # required even for tip racks + }, + } + ], + } + if grip_distance_from_top is not None: + definition["gripHeightFromLabwareBottom"] = max( + 0.0, tip_rack.get_absolute_size_z() - grip_distance_from_top + ) + return definition + + +def build_container_definition(container: Container) -> dict: + """Build a robot-server reservoir definition from a PLR container's geometry. + + A container (e.g. a trough) is a single cavity, so the definition has one + well "A1" whose rectangular footprint spans the whole container, with depth + and volume from the container's geometry. + """ + size_x = container.get_absolute_size_x() + size_y = container.get_absolute_size_y() + size_z = container.get_absolute_size_z() + return { + "schemaVersion": _SCHEMA_VERSION, + "version": _VERSION, + "namespace": _NAMESPACE, + "metadata": { + "displayName": container.name, + "displayCategory": "reservoir", + "displayVolumeUnits": "µL", + }, + "brand": {"brand": "unknown"}, + "parameters": { + "format": "irregular", + "isTiprack": False, + "loadName": _definition_load_name(container), + "isMagneticModuleCompatible": False, + }, + "ordering": [["A1"]], + "cornerOffsetFromSlot": {"x": 0, "y": _OT_SLOT_SIZE_Y - size_y, "z": 0}, + "dimensions": {"xDimension": size_x, "yDimension": size_y, "zDimension": size_z}, + "wells": { + "A1": { + "depth": size_z, + "x": size_x / 2, + "y": size_y / 2, + "z": 0, + "shape": "rectangular", + "xDimension": size_x, + "yDimension": size_y, + "totalLiquidVolume": container.max_volume, + } + }, + "groups": [{"wells": ["A1"], "metadata": {"wellBottomShape": "flat"}}], + } + + +def build_movable_labware_definition(resource: Resource, grip_distance_from_top: float) -> dict: + """Build a minimal single-well stub definition for gripper moves of any resource. + + The stub is not pipettable (one fake well, zero depth and volume); it exists + so the robot-server can gripper-move labware it has no real definition for. + ``gripHeightFromLabwareBottom`` is load-bearing: without it the robot-server + grips at the z-midpoint and ignores the caller's requested grip distance. + """ + size_x = resource.get_absolute_size_x() + size_y = resource.get_absolute_size_y() + size_z = resource.get_absolute_size_z() + return { + "schemaVersion": _SCHEMA_VERSION, + "version": _VERSION, + "namespace": _NAMESPACE, + "metadata": { + "displayName": resource.name, + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + }, + "brand": {"brand": "unknown"}, + "parameters": { + "format": "irregular", + "isTiprack": False, + "loadName": _definition_load_name(resource), + "isMagneticModuleCompatible": False, + }, + "ordering": [["A1"]], + "cornerOffsetFromSlot": {"x": 0, "y": 0, "z": 0}, + "dimensions": {"xDimension": size_x, "yDimension": size_y, "zDimension": size_z}, + "wells": { + "A1": { + "depth": 0, + "x": size_x / 2, + "y": size_y / 2, + "z": 0, + "shape": "circular", + "diameter": 5, + "totalLiquidVolume": 0, + } + }, + "groups": [{"wells": ["A1"], "metadata": {"wellBottomShape": "flat"}}], + "gripHeightFromLabwareBottom": max(0.0, size_z - grip_distance_from_top), + "gripperOffsets": { + "default": { + "pickUpOffset": {"x": 0, "y": 0, "z": 0}, + "dropOffset": {"x": 0, "y": 0, "z": 0}, + } + }, + } diff --git a/pylabrobot/opentrons/labware_definitions_tests.py b/pylabrobot/opentrons/labware_definitions_tests.py new file mode 100644 index 00000000000..29150c6b029 --- /dev/null +++ b/pylabrobot/opentrons/labware_definitions_tests.py @@ -0,0 +1,386 @@ +"""Tests for custom Opentrons labware definition building and uploading. + +Builder-level tests pin the definition content produced from PLR geometry +(dimensions, well positions, the PLR front-left vs Opentrons back-left y-flip, +grip height). Flex-level tests drive ``OpentronsFlex._ensure_labware_loaded`` +with an injected ``ChatterboxTransport`` and assert labware without an +official Opentrons definition is uploaded once and then loaded by the +uploaded definition's namespace/loadName/version, while official-name labware +keeps loading with zero uploads. +""" + +import asyncio +import unittest +from typing import Tuple + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.labware_definitions import ( + build_container_definition, + build_movable_labware_definition, + build_plate_definition, + build_tip_rack_definition, +) +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import Plate, Resource, TipRack, TipSpot, Trough, Well +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.utils import create_ordered_items_2d + + +def _plate(name: str = "Black Plate-1") -> Plate: + """A 2x2-well plate with hand-picked geometry so expected numbers are exact.""" + return Plate( + name=name, + size_x=127.0, + size_y=80.0, + size_z=14.0, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=2, + num_items_y=2, + dx=10.0, + dy=8.0, + dz=1.0, + item_dx=9.0, + item_dy=9.0, + size_x=6.0, + size_y=6.0, + size_z=10.0, + max_volume=360.0, + ), + ) + + +def _tip_rack(name: str = "hamilton tips 300") -> TipRack: + """A 2x2-spot tip rack with hand-picked geometry and a pinned prototype tip.""" + + def make_tip(name: str) -> Tip: + return Tip( + has_filter=False, + total_tip_length=50.0, + maximal_volume=200.0, + fitting_depth=8.0, + name=name, + ) + + return TipRack( + name=name, + size_x=120.0, + size_y=82.0, + size_z=90.0, + ordered_items=create_ordered_items_2d( + TipSpot, + num_items_x=2, + num_items_y=2, + dx=10.0, + dy=8.0, + dz=0.0, + item_dx=9.0, + item_dy=9.0, + size_x=5.0, + size_y=5.0, + make_tip=make_tip, + ), + ) + + +def _trough(name: str = "hamilton trough") -> Trough: + return Trough(name=name, size_x=120.0, size_y=80.0, size_z=40.0, max_volume=290000.0) + + +class TestBuildPlateDefinition(unittest.TestCase): + """build_plate_definition maps PLR plate geometry into a wellPlate definition.""" + + def test_identity_and_dimensions(self): + definition = build_plate_definition(_plate()) + self.assertEqual(definition["namespace"], "pylabrobot") + self.assertEqual(definition["version"], 1) + self.assertEqual(definition["schemaVersion"], 2) + self.assertEqual(definition["metadata"]["displayCategory"], "wellPlate") + self.assertEqual(definition["metadata"]["displayName"], "Black Plate-1") + self.assertEqual(definition["parameters"]["loadName"], "black_plate_1") + self.assertFalse(definition["parameters"]["isTiprack"]) + self.assertEqual( + definition["dimensions"], + {"xDimension": 127.0, "yDimension": 80.0, "zDimension": 14.0}, + ) + + def test_ordering_is_column_major(self): + definition = build_plate_definition(_plate()) + self.assertEqual(definition["ordering"], [["A1", "B1"], ["A2", "B2"]]) + + def test_corner_offset_y_flip(self): + # PLR anchors at the slot's front-left, Opentrons at the back-left: an + # 80 mm-deep plate in an 86 mm-deep slot sits 6 mm toward the back. + definition = build_plate_definition(_plate()) + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 6.0, "z": 0}) + + def test_well_geometry_carries_depth_volume_and_centers(self): + definition = build_plate_definition(_plate()) + # A1 (back-left well): origin (10, 17, 1), 6 mm square, so center (13, 20). + self.assertEqual( + definition["wells"]["A1"], + { + "depth": 10.0, + "x": 13.0, + "y": 20.0, + "z": 1.0, + "shape": "circular", + "diameter": 6.0, + "totalLiquidVolume": 360.0, + }, + ) + # B1 is one 9 mm pitch toward the front: center y = 8 + 3 = 11. + self.assertEqual(definition["wells"]["B1"]["y"], 11.0) + self.assertEqual(definition["groups"][0]["wells"], ["A1", "B1", "A2", "B2"]) + + def test_grip_height_from_grip_distance(self): + self.assertNotIn("gripHeightFromLabwareBottom", build_plate_definition(_plate())) + definition = build_plate_definition(_plate(), grip_distance_from_top=4.0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 10.0) # 14 - 4 + clamped = build_plate_definition(_plate(), grip_distance_from_top=20.0) + self.assertEqual(clamped["gripHeightFromLabwareBottom"], 0.0) + + +class TestBuildTipRackDefinition(unittest.TestCase): + """build_tip_rack_definition maps rack geometry and the prototype tip.""" + + def test_tip_parameters_come_from_prototype_tip(self): + definition = build_tip_rack_definition(_tip_rack()) + self.assertEqual(definition["metadata"]["displayCategory"], "tipRack") + self.assertEqual(definition["parameters"]["format"], "96Standard") + self.assertTrue(definition["parameters"]["isTiprack"]) + self.assertEqual(definition["parameters"]["tipLength"], 50.0) + self.assertEqual(definition["parameters"]["tipOverlap"], 8.0) + self.assertEqual(definition["parameters"]["loadName"], "hamilton_tips_300") + + def test_spot_geometry_and_y_flip(self): + definition = build_tip_rack_definition(_tip_rack()) + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 4.0, "z": 0}) # 86 - 82 + self.assertEqual(definition["ordering"], [["A1", "B1"], ["A2", "B2"]]) + # A1 spot origin (10, 17, 0), 5 mm square: center (12.5, 19.5). + self.assertEqual( + definition["wells"]["A1"], + { + "depth": 0, + "x": 12.5, + "y": 19.5, + "z": 0.0, + "shape": "circular", + "diameter": 5.0, + "totalLiquidVolume": 200.0, + }, + ) + + def test_grip_height_from_grip_distance(self): + self.assertNotIn("gripHeightFromLabwareBottom", build_tip_rack_definition(_tip_rack())) + definition = build_tip_rack_definition(_tip_rack(), grip_distance_from_top=10.0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 80.0) # 90 - 10 + + +class TestBuildContainerDefinition(unittest.TestCase): + """build_container_definition maps a container to a single-cavity reservoir.""" + + def test_single_a1_cavity_spans_the_container(self): + definition = build_container_definition(_trough()) + self.assertEqual(definition["namespace"], "pylabrobot") + self.assertEqual(definition["metadata"]["displayCategory"], "reservoir") + self.assertEqual(definition["parameters"]["loadName"], "hamilton_trough") + self.assertEqual(definition["ordering"], [["A1"]]) + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 6.0, "z": 0}) # 86 - 80 + self.assertEqual( + definition["dimensions"], + {"xDimension": 120.0, "yDimension": 80.0, "zDimension": 40.0}, + ) + self.assertEqual( + definition["wells"], + { + "A1": { + "depth": 40.0, + "x": 60.0, + "y": 40.0, + "z": 0, + "shape": "rectangular", + "xDimension": 120.0, + "yDimension": 80.0, + "totalLiquidVolume": 290000.0, + } + }, + ) + self.assertEqual(definition["groups"][0]["wells"], ["A1"]) + + +class TestBuildMovableLabwareDefinition(unittest.TestCase): + """build_movable_labware_definition builds the minimal gripper-move stub.""" + + def test_stub_has_fake_well_and_grip_geometry(self): + resource = Resource(name="lid stack", size_x=100.0, size_y=90.0, size_z=20.0) + definition = build_movable_labware_definition(resource, grip_distance_from_top=5.0) + self.assertEqual(definition["namespace"], "pylabrobot") + self.assertEqual(definition["parameters"]["loadName"], "lid_stack") + self.assertEqual(definition["ordering"], [["A1"]]) + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) + self.assertEqual( + definition["wells"]["A1"], + { + "depth": 0, + "x": 50.0, + "y": 45.0, + "z": 0, + "shape": "circular", + "diameter": 5, + "totalLiquidVolume": 0, + }, + ) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 15.0) # 20 - 5 + self.assertEqual( + definition["gripperOffsets"], + { + "default": { + "pickUpOffset": {"x": 0, "y": 0, "z": 0}, + "dropOffset": {"x": 0, "y": 0, "z": 0}, + } + }, + ) + + def test_grip_height_clamped_at_labware_bottom(self): + resource = Resource(name="shim", size_x=10.0, size_y=10.0, size_z=3.0) + definition = build_movable_labware_definition(resource, grip_distance_from_top=7.0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 0.0) + + +def _flex_with_transport() -> Tuple[OpentronsFlex, ChatterboxTransport]: + transport = ChatterboxTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right") + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + return flex, transport + + +def _load_labware_commands(transport: ChatterboxTransport) -> list: + return [c for c in transport.commands if c["commandType"] == "loadLabware"] + + +class TestCustomLabwareLoadFlow(unittest.TestCase): + """_ensure_labware_loaded uploads a definition for labware with no official name.""" + + def test_plate_without_official_name_uploads_then_loads(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + asyncio.run(flex._ensure_labware_loaded(plate)) + + self.assertEqual(len(transport.labware_definitions), 1) + definition = transport.labware_definitions[0] + load_cmds = _load_labware_commands(transport) + self.assertEqual(len(load_cmds), 1) + params = load_cmds[0]["params"] + # loadLabware must reference exactly the uploaded definition's identity. + self.assertEqual(params["namespace"], definition["namespace"]) + self.assertEqual(params["loadName"], definition["parameters"]["loadName"]) + self.assertEqual(params["version"], definition["version"]) + self.assertEqual(params["namespace"], "pylabrobot") + self.assertEqual(params["loadName"], "black_plate_1") + self.assertEqual(params["version"], 1) + self.assertEqual(params["location"], {"slotName": "C1"}) + finally: + asyncio.run(flex.stop()) + + def test_second_use_hits_cache_no_second_upload_or_load(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + first = asyncio.run(flex._ensure_labware_loaded(plate)) + second = asyncio.run(flex._ensure_labware_loaded(plate)) + + self.assertEqual(first, second) + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(len(_load_labware_commands(transport)), 1) + finally: + asyncio.run(flex.stop()) + + def test_reload_after_off_deck_reuses_uploaded_definition(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + asyncio.run(flex._ensure_labware_loaded(plate)) + asyncio.run(flex.labware_moved_off_deck(plate)) + flex.deck.assign_child_at_slot(plate, "D2") + asyncio.run(flex._ensure_labware_loaded(plate)) + + # A fresh loadLabware at the new slot, but the definition uploads once per run. + self.assertEqual(len(transport.labware_definitions), 1) + load_cmds = _load_labware_commands(transport) + self.assertEqual(len(load_cmds), 2) + self.assertEqual(load_cmds[1]["params"]["location"], {"slotName": "D2"}) + finally: + asyncio.run(flex.stop()) + + def test_official_name_labware_loads_with_zero_uploads(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C1") + asyncio.run(flex._ensure_labware_loaded(plate)) + + self.assertEqual(len(transport.labware_definitions), 0) + load_cmds = _load_labware_commands(transport) + self.assertEqual(len(load_cmds), 1) + params = load_cmds[0]["params"] + self.assertEqual(params["namespace"], "opentrons") + self.assertEqual(params["loadName"], "corning_96_wellplate_360ul_flat") + self.assertEqual(params["version"], 1) + finally: + asyncio.run(flex.stop()) + + def test_container_uploads_single_cavity_definition(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + trough = _trough() + flex.deck.assign_child_at_slot(trough, "B1") + asyncio.run(flex._ensure_labware_loaded(trough)) + + self.assertEqual(len(transport.labware_definitions), 1) + definition = transport.labware_definitions[0] + self.assertEqual(list(definition["wells"]), ["A1"]) + params = _load_labware_commands(transport)[0]["params"] + self.assertEqual(params["namespace"], "pylabrobot") + self.assertEqual(params["loadName"], "hamilton_trough") + finally: + asyncio.run(flex.stop()) + + def test_tip_rack_without_official_name_uploads_tiprack_definition(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + rack = _tip_rack() + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex._ensure_labware_loaded(rack)) + + self.assertEqual(len(transport.labware_definitions), 1) + self.assertTrue(transport.labware_definitions[0]["parameters"]["isTiprack"]) + params = _load_labware_commands(transport)[0]["params"] + self.assertEqual(params["loadName"], "hamilton_tips_300") + finally: + asyncio.run(flex.stop()) + + def test_unbuildable_resource_still_raises_loudly(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + widget = Resource(name="widget", size_x=100.0, size_y=90.0, size_z=20.0) + flex.deck.assign_child_at_slot(widget, "C1") + with self.assertRaises(OpentronsError): + asyncio.run(flex._ensure_labware_loaded(widget)) + self.assertEqual(len(transport.labware_definitions), 0) + self.assertEqual(len(_load_labware_commands(transport)), 0) + finally: + asyncio.run(flex.stop()) From 3f3b89ca0e8d28bb566baab47b177bd19a577f30 Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 11:59:44 -0400 Subject: [PATCH 05/36] fix(opentrons): post-review fix pass on the Flex capability stack 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. --- pylabrobot/opentrons/__init__.py | 16 +- pylabrobot/opentrons/flex.py | 72 ++- pylabrobot/opentrons/flex_container_tests.py | 243 ++++++++-- .../opentrons/flex_fine_pipetting_tests.py | 191 +++++++- pylabrobot/opentrons/flex_gripper.py | 87 +++- pylabrobot/opentrons/flex_gripper_tests.py | 53 ++- pylabrobot/opentrons/flex_head.py | 421 +++++++++--------- pylabrobot/opentrons/flex_motion_tests.py | 132 +++++- pylabrobot/opentrons/labware_definitions.py | 138 ++++-- .../opentrons/labware_definitions_tests.py | 283 ++++++++++-- pylabrobot/opentrons/robot.py | 27 +- pylabrobot/opentrons/transport.py | 21 + 12 files changed, 1273 insertions(+), 411 deletions(-) diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py index 7bd07b8d11f..0316052685e 100644 --- a/pylabrobot/opentrons/__init__.py +++ b/pylabrobot/opentrons/__init__.py @@ -1,13 +1,12 @@ from pylabrobot.opentrons.flex import OpentronsFlex from pylabrobot.opentrons.flex_gripper import FlexGripper from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96 -from pylabrobot.opentrons.labware_definitions import ( - build_container_definition, - build_movable_labware_definition, - build_plate_definition, - build_tip_rack_definition, +from pylabrobot.opentrons.robot import ( + OpentronsCommandError, + OpentronsError, + OpentronsRobot, + PipetteInfo, ) -from pylabrobot.opentrons.robot import OpentronsError, OpentronsRobot, PipetteInfo from pylabrobot.opentrons.transport import ChatterboxTransport, HttpxTransport, OpentronsTransport __all__ = [ @@ -17,13 +16,10 @@ "FlexHead8", "FlexHead96", "HttpxTransport", + "OpentronsCommandError", "OpentronsError", "OpentronsFlex", "OpentronsRobot", "OpentronsTransport", "PipetteInfo", - "build_container_definition", - "build_movable_labware_definition", - "build_plate_definition", - "build_tip_rack_definition", ] diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 39d0c2d8429..25e78ac8a2a 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -2,10 +2,11 @@ import uuid from typing import Any, Dict, List, Optional, Tuple, Type, cast -from pylabrobot.opentrons.flex_gripper import FlexGripper +from pylabrobot.opentrons.flex_gripper import FlexGripper, _slot_wire_location from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead from pylabrobot.opentrons.labware_definitions import ( build_container_definition, + build_movable_labware_definition, build_plate_definition, build_tip_rack_definition, ) @@ -66,6 +67,14 @@ def __init__( self.gripper: Optional[FlexGripper] = None self._heads: List[_FlexHead] = [] + async def _create_run(self) -> str: + # labwareIds and uploaded definitions are both run-scoped server-side, so + # a new run must not serve cached identities from a previous one. + run_id = await super()._create_run() + self._loaded_labware.clear() + self._defined_labware.clear() + return run_id + async def _model_setup(self) -> None: await self.home() @@ -151,8 +160,16 @@ async def stop(self) -> None: await head._on_stop() await super().stop() # homes the gantry, then cancels the run + disconnects - async def _ensure_labware_loaded(self, resource: Resource) -> str: - """Load labware into the Flex run if not already loaded.""" + async def _ensure_labware_loaded( + self, resource: Resource, grip_distance_from_top: Optional[float] = None + ) -> str: + """Load labware into the Flex run if not already loaded. + + ``grip_distance_from_top`` feeds an uploaded custom definition's grip + height and is honored on the FIRST load only; a cache hit (already + loaded, or definition already uploaded) reuses the stored identity + unchanged. + """ name = getattr(resource, "name", str(resource)) if name in self._loaded_labware: return self._loaded_labware[name] @@ -170,14 +187,16 @@ async def _ensure_labware_loaded(self, resource: Resource) -> str: except OpentronsError: # No official Opentrons definition: build one from the resource's PLR # geometry, upload it, and load by the uploaded definition's identity. - namespace, load_name, version = await self._define_custom_labware(resource) + namespace, load_name, version = await self._define_custom_labware( + resource, grip_distance_from_top + ) labware_id = uuid.uuid4().hex[:12] result = await self._execute_command( "loadLabware", { "loadName": load_name, - "location": {"slotName": slot}, + "location": _slot_wire_location(slot), "namespace": namespace, "version": version, "labwareId": labware_id, @@ -204,7 +223,9 @@ async def labware_moved_off_deck(self, resource: Resource) -> None: model, freeing its slot. Without this the slot stays occupied server-side and a later load into it fails with ``LocationIsOccupiedError``. The PLR-side deck slot is freed too. No wire command is sent for labware that - was never loaded into the run; a re-add later loads fresh at its new slot. + was never loaded into the run; a re-add later loads fresh at its new slot + and re-uploads its definition -- a different same-named resource must not + inherit the departed labware's geometry. """ name = getattr(resource, "name", str(resource)) if name in self._loaded_labware: @@ -217,6 +238,7 @@ async def labware_moved_off_deck(self, resource: Resource) -> None: }, ) del self._loaded_labware[name] + self._defined_labware.pop(name, None) slot = self.deck.get_slot(resource) if slot is not None: self.deck.unassign_child_at_slot(slot) @@ -243,20 +265,24 @@ def _ot_load_name(resource: Resource) -> str: f"or use a standard Flex labware name.", ) - async def _define_custom_labware(self, resource: Resource) -> Tuple[str, str, int]: + async def _define_custom_labware( + self, resource: Resource, grip_distance_from_top: Optional[float] = None + ) -> Tuple[str, str, int]: """Upload a geometry-derived definition for labware with no official Opentrons definition. Returns the uploaded definition's (namespace, load_name, version), parsed from the robot-server's ``definitionUri`` so the subsequent ``loadLabware`` - references exactly what the server stored. Uploads once per resource per - run: the parsed identity is cached separately from ``_loaded_labware``, so - a re-load (e.g. after ``labware_moved_off_deck``) skips the re-upload. + references exactly what the server stored. The parsed identity is cached + (separately from ``_loaded_labware``) until the labware leaves the deck or + a new run starts, so repeat calls within a stay on deck skip the + re-upload -- which also means ``grip_distance_from_top`` only shapes the + FIRST upload. """ name = resource.name if name in self._defined_labware: return self._defined_labware[name] - definition = self._build_labware_definition(resource) + definition = self._build_labware_definition(resource, grip_distance_from_top) assert self.run_id is not None, "No active run. Call setup() first." data = await self._post(f"/runs/{self.run_id}/labware_definitions", {"data": definition}) uri = cast(str, data["data"]["definitionUri"]) @@ -266,17 +292,19 @@ async def _define_custom_labware(self, resource: Resource) -> Tuple[str, str, in return self._defined_labware[name] @staticmethod - def _build_labware_definition(resource: Resource) -> dict: - """Build the definition matching the resource's type, or raise for unbuildable labware.""" + def _build_labware_definition( + resource: Resource, grip_distance_from_top: Optional[float] = None + ) -> dict: + """Build the definition matching the resource's type. + + Pipettable types get real-geometry definitions; any other resource (lid, + adapter, ...) gets the non-pipettable movable stub so the gripper can + still move it. + """ if isinstance(resource, Plate): - return build_plate_definition(resource) + return build_plate_definition(resource, grip_distance_from_top) if isinstance(resource, TipRack): - return build_tip_rack_definition(resource) + return build_tip_rack_definition(resource, grip_distance_from_top) if isinstance(resource, Container): - return build_container_definition(resource) - raise OpentronsError( - "Cannot build an Opentrons labware definition", - f"'{resource.name}' ({type(resource).__name__}) has no Opentrons load name, and a " - "definition can only be built from the geometry of a Plate, TipRack, or Container. " - "Set resource.ot_load_name to an official Opentrons load name.", - ) + return build_container_definition(resource, grip_distance_from_top) + return build_movable_labware_definition(resource, grip_distance_from_top) diff --git a/pylabrobot/opentrons/flex_container_tests.py b/pylabrobot/opentrons/flex_container_tests.py index 9cb11bab9a4..43cf9e09eab 100644 --- a/pylabrobot/opentrons/flex_container_tests.py +++ b/pylabrobot/opentrons/flex_container_tests.py @@ -2,10 +2,13 @@ A bare PLR ``Container`` is a single-cavity resource with ONE volume tracker; robot-side single-cavity labware definitions expose exactly one well, named -"A1". These tests pin the wire shape (wellName "A1" plus the head-centering -``wellLocation`` math), the one-tracker staging semantics (each channel -holding a tip moves ``volume``; the summed delta commits/rolls back as one -op), the pre-wire rejections, and that the plate/column paths are unchanged. +"A1", and carry the ``centerMultichannelOnWells`` quirk, so the ENGINE +centers the nozzle array in the cavity. These tests pin the wire shape +(wellName "A1", no manual centering offsets -- only the caller's +offset/liquid_height ride the wire), the one-tracker staging semantics (each +channel holding a tip moves ``volume``; the summed delta commits/rolls back +as one op), and the pre-wire rejections (no tip, array or offset-shifted +array overhanging the cavity). """ import asyncio @@ -18,7 +21,6 @@ from pylabrobot.opentrons.transport import ChatterboxTransport from pylabrobot.resources import ( Container, - cor_96_wellplate_360uL_Fb, set_tip_tracking, set_volume_tracking, ) @@ -37,6 +39,15 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s return await super().post(path, json) +class _FailingDispenseTransport(ChatterboxTransport): + """Like ``_FailingAspirateTransport`` but for ``dispense`` commands.""" + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if path.endswith("/commands") and (json or {}).get("data", {}).get("commandType") == "dispense": + raise RuntimeError("simulated dispense wire failure") + return await super().post(path, json) + + def _make_trough( name: str = "trough", size_x: float = 107.0, @@ -159,11 +170,61 @@ def test_dispense_adds_volume_to_container_tracker(self): finally: asyncio.run(flex.stop()) + def test_aspirate_container_without_tip_rejects_before_any_wire_command(self): + flex, transport, head = _flex_head1() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(1000.0) + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(trough, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 1000.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_container_without_tip_rejects_before_any_wire_command(self): + flex, transport, head = _flex_head1() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.dispense(trough, volume=30)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 0.0) + finally: + asyncio.run(flex.stop()) + + def test_wire_failure_rolls_back_container_tracker(self): + flex, _transport, head = _flex_head1(transport_cls=_FailingAspirateTransport) + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(1000.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(RuntimeError): + asyncio.run(head.aspirate(trough, volume=50)) + + self.assertAlmostEqual(trough.tracker.volume, 1000.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 1000.0) + finally: + asyncio.run(flex.stop()) + class TestFlexHead8ContainerOps(unittest.TestCase): """FlexHead8 aspirate_container/dispense_container fan all 8 nozzles into - one cavity: ONE command at well "A1" with a wellLocation centering the - 63 mm nozzle row, and the container's single tracker moves + one cavity: ONE command at well "A1" with no manual centering (the + definition's centerMultichannelOnWells quirk makes the engine center the + 63 mm nozzle row), and the container's single tracker moves volume * (channels holding tips) as one committed/rolled-back op. """ @@ -175,7 +236,7 @@ def tearDown(self): set_tip_tracking(False) set_volume_tracking(False) - def test_aspirate_container_sends_one_centered_command_at_a1(self): + def test_aspirate_container_sends_one_uncentered_command_at_a1(self): flex, transport, head = _flex_head8() try: rack = flex_96_tiprack_50ul(name="rack") @@ -192,11 +253,12 @@ def test_aspirate_container_sends_one_centered_command_at_a1(self): params = aspirate_cmds[0]["params"] self.assertEqual(params["wellName"], "A1") self.assertEqual(params["volume"], 50) - # The anchor (channel A) nozzle sits half the 63 mm row span back (+y) - # of the cavity center, so the row is centered front-to-back. + # No manual x/y centering: the engine centers the nozzle row on the + # cavity itself (centerMultichannelOnWells). Only the default bottom + # clearance rides the wire. self.assertEqual( params["wellLocation"], - {"origin": "bottom", "offset": {"x": 0.0, "y": 31.5, "z": 1.0}}, + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, ) cmd_types = [c["commandType"] for c in transport.commands] @@ -252,13 +314,13 @@ def test_dispense_container_adds_volume_per_mounted_tip(self): self.assertEqual(params["wellName"], "A1") self.assertEqual( params["wellLocation"], - {"origin": "bottom", "offset": {"x": 0.0, "y": 31.5, "z": 1.0}}, + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, ) self.assertAlmostEqual(trough.tracker.volume, 320.0) finally: asyncio.run(flex.stop()) - def test_container_well_location_merges_offset_and_liquid_height(self): + def test_offset_and_liquid_height_merge_into_well_location(self): flex, transport, head = _flex_head8() try: rack = flex_96_tiprack_50ul(name="rack") @@ -275,11 +337,11 @@ def test_container_well_location_merges_offset_and_liquid_height(self): ) aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] - # The caller's offset rides on top of the centering; liquid_height adds - # to z, replacing the default clearance. + # Only the caller's offset rides the wire (centering is engine-side); + # liquid_height adds to z, replacing the default clearance. self.assertEqual( aspirate_cmds[0]["params"]["wellLocation"], - {"origin": "bottom", "offset": {"x": 2.0, "y": 30.5, "z": 3.5}}, + {"origin": "bottom", "offset": {"x": 2, "y": -1, "z": 3.5}}, ) finally: asyncio.run(flex.stop()) @@ -343,39 +405,85 @@ def test_wire_failure_rolls_back_container_tracker(self): finally: asyncio.run(flex.stop()) - def test_column_aspirate_on_plate_unchanged(self): + def test_dispense_container_without_tip_rejects_before_any_wire_command(self): flex, transport, head = _flex_head8() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.dispense_container(trough, volume=40)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 0.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_container_wire_failure_rolls_back_container_tracker(self): + flex, _transport, head = _flex_head8(transport_cls=_FailingDispenseTransport) try: rack = flex_96_tiprack_50ul(name="rack") - plate = cor_96_wellplate_360uL_Fb(name="plate") - plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + trough = _make_trough() flex.deck.assign_child_at_slot(rack, "C1") - flex.deck.assign_child_at_slot(plate, "C2") + flex.deck.assign_child_at_slot(trough, "C2") - wells = plate.get_all_items() - for well in wells: - well.tracker.set_volume(100.0) + asyncio.run(head.pick_up_tips(rack, column=0)) + with self.assertRaises(RuntimeError): + asyncio.run(head.dispense_container(trough, volume=40)) + + # The staged 8 * 40 uL is rolled back in full. + self.assertAlmostEqual(trough.tracker.volume, 0.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 0.0) + finally: + asyncio.run(flex.stop()) + + def test_offset_that_keeps_row_inside_cavity_passes(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() # 71 mm front-to-back; 63 mm row + 2*4 = 71 fits + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) asyncio.run(head.pick_up_tips(rack, column=0)) - asyncio.run(head.aspirate(plate, column=2, volume=50)) + asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate(y=4))) aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] self.assertEqual(len(aspirate_cmds), 1) - self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A3") + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"]["offset"], + {"x": 0, "y": 4, "z": 0.0}, + ) + finally: + asyncio.run(flex.stop()) - column_2 = set(wells[16:24]) - for well in wells: - expected = 50.0 if well in column_2 else 100.0 - self.assertAlmostEqual(well.tracker.volume, expected, msg=well.name) + def test_offset_that_shifts_row_past_cavity_wall_rejects_pre_wire(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() # 71 mm front-to-back; 63 mm row + 2*5 = 73 overhangs + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate(y=-5))) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 10000.0) finally: asyncio.run(flex.stop()) class TestFlexHead96ContainerOps(unittest.TestCase): """FlexHead96 aspirate/dispense accept a bare Container: ONE command at - well "A1" whose wellLocation puts the back-left (A1) anchor nozzle at - (-49.5, +31.5) from the cavity center so the 12x8 grid is centered, and - the container's single tracker moves volume * (channels holding tips). + well "A1" with no manual centering (the definition's + centerMultichannelOnWells quirk makes the engine center the 12x8 grid), + and the container's single tracker moves volume * (channels holding tips). """ def setUp(self): @@ -386,7 +494,7 @@ def tearDown(self): set_tip_tracking(False) set_volume_tracking(False) - def test_aspirate_container_centers_grid_and_tracks_96_channels(self): + def test_aspirate_container_no_manual_centering_and_tracks_96_channels(self): flex, transport, head = _flex_head96() try: rack = flex_96_tiprack_50ul(name="rack") @@ -402,11 +510,11 @@ def test_aspirate_container_centers_grid_and_tracks_96_channels(self): self.assertEqual(len(aspirate_cmds), 1) params = aspirate_cmds[0]["params"] self.assertEqual(params["wellName"], "A1") - # Back-left anchor nozzle at (-99/2, +63/2) from the cavity center - # centers the whole 12x8 grid. + # No manual x/y centering: the engine centers the 12x8 grid on the + # cavity itself (centerMultichannelOnWells). self.assertEqual( params["wellLocation"], - {"origin": "bottom", "offset": {"x": -49.5, "y": 31.5, "z": 1.0}}, + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, ) self.assertAlmostEqual(trough.tracker.volume, 100000.0 - 96 * 50.0) @@ -414,7 +522,7 @@ def test_aspirate_container_centers_grid_and_tracks_96_channels(self): finally: asyncio.run(flex.stop()) - def test_dispense_container_centers_grid_and_adds_96x(self): + def test_dispense_container_no_manual_centering_and_adds_96x(self): flex, transport, head = _flex_head96() try: rack = flex_96_tiprack_50ul(name="rack") @@ -431,7 +539,7 @@ def test_dispense_container_centers_grid_and_adds_96x(self): self.assertEqual(params["wellName"], "A1") self.assertEqual( params["wellLocation"], - {"origin": "bottom", "offset": {"x": -49.5, "y": 31.5, "z": 1.0}}, + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, ) self.assertAlmostEqual(trough.tracker.volume, 96 * 20.0) finally: @@ -494,25 +602,64 @@ def test_wire_failure_rolls_back_container_tracker(self): finally: asyncio.run(flex.stop()) - def test_plate_aspirate_unchanged(self): + def test_aspirate_container_rejects_cavity_shallower_than_grid_y_span(self): flex, transport, head = _flex_head96() try: rack = flex_96_tiprack_50ul(name="rack") - plate = cor_96_wellplate_360uL_Fb(name="plate") - plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + shallow = _make_trough(name="shallow", size_y=50.0, max_volume=50000.0) flex.deck.assign_child_at_slot(rack, "C1") - flex.deck.assign_child_at_slot(plate, "C2") - for well in plate.get_all_items(): - well.tracker.set_volume(100.0) + flex.deck.assign_child_at_slot(shallow, "C2") + shallow.tracker.set_volume(10000.0) asyncio.run(head.pick_up_tips(rack)) - asyncio.run(head.aspirate(plate, volume=50)) + + # 50 mm front-to-back cannot contain the grid's 63 mm y span. + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(shallow, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(shallow.tracker.volume, 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_offset_that_keeps_grid_inside_cavity_passes(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() # 107 x 71 mm; grid 99 x 63 + 2*4 on each axis fits + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.aspirate(trough, volume=10, offset=Coordinate(x=4, y=4))) aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] self.assertEqual(len(aspirate_cmds), 1) - self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") - for well in plate.get_all_items(): - self.assertAlmostEqual(well.tracker.volume, 50.0, msg=well.name) + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"]["offset"], + {"x": 4, "y": 4, "z": 0.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_offset_that_shifts_grid_past_cavity_wall_rejects_pre_wire(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() # 107 x 71 mm: x 99 + 2*4.5 = 108 and y 63 + 2*4.5 = 72 overhang + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + for offset in (Coordinate(x=4.5), Coordinate(y=4.5)): + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(trough, volume=10, offset=offset)) + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 100000.0) finally: asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index d5b88c317e4..b55cb1437fa 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -3,17 +3,22 @@ Covers ``blow_out`` (in-place plunger blow-out, all heads), ``touch_tip`` (wall-touch, all heads) and ``liquid_probe``/``try_liquid_probe`` (pressure-based liquid-level detection, mount heads only), driven through the -recording ``ChatterboxTransport`` -- the transport's ``liquid_probe_z`` kwarg -models the robot-server's found-liquid ``z_position`` result key, which is -OMITTED entirely (not null) when no liquid is found. +recording ``ChatterboxTransport``. Two no-liquid signals are modeled: the +``liquid_probe_z`` kwarg omits the ``z_position`` result key entirely (a +succeeding transport's shape), and ``simulate_liquid_probe_not_found`` fails +the ``liquidProbe`` command with the engine's defined "liquidNotFound" error +(the real-hardware behavior). """ import asyncio import unittest +from typing import Any, Dict, Optional -from pylabrobot.opentrons.flex_head import _DEFAULT_DISPENSE_FLOW_RATE +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import _DEFAULT_BLOW_OUT_FLOW_RATE, FlexHead1 from pylabrobot.opentrons.flex_tests import _flex_head1, _flex_head8, _flex_head96 -from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.robot import OpentronsCommandError, OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport from pylabrobot.resources import ( biorad_384_wellplate_50uL_Vb, cor_96_wellplate_360uL_Fb, @@ -21,6 +26,7 @@ set_volume_tracking, ) from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.opentrons.flex_deck import FlexDeck from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul @@ -50,7 +56,7 @@ def test_head8_sends_blow_out_in_place_with_dispense_default_flow_rate(self): self.assertEqual(len(blow_cmds), 1) self.assertEqual( blow_cmds[0]["params"], - {"pipetteId": head.pipette_id, "flowRate": _DEFAULT_DISPENSE_FLOW_RATE}, + {"pipetteId": head.pipette_id, "flowRate": _DEFAULT_BLOW_OUT_FLOW_RATE}, ) finally: asyncio.run(flex.stop()) @@ -114,7 +120,7 @@ def test_head1_blow_out_and_reprime(self): self.assertEqual(len(blow_cmds), 1) self.assertEqual( blow_cmds[0]["params"], - {"pipetteId": head.pipette_id, "flowRate": _DEFAULT_DISPENSE_FLOW_RATE}, + {"pipetteId": head.pipette_id, "flowRate": _DEFAULT_BLOW_OUT_FLOW_RATE}, ) cmd_types = [c["commandType"] for c in transport.commands] prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] @@ -150,8 +156,9 @@ def test_head96_blow_out_and_reprime(self): class TestTouchTipHead1(unittest.TestCase): """FlexHead1.touch_tip sends one touchTip command naming the well, with the - radius and a bottom-origin wellLocation offset; a missing tip rejects - before any wire command.""" + radius and a TOP-origin wellLocation (default 1 mm below the rim; a caller + offset replaces it, also top-relative); a missing tip rejects before any + wire command.""" def setUp(self): set_tip_tracking(True) @@ -180,12 +187,12 @@ def test_touch_tip_sends_one_command_with_radius_offset_and_well(self): self.assertEqual(params["wellName"], "B3") self.assertEqual(params["radius"], 0.75) self.assertEqual( - params["wellLocation"], {"origin": "bottom", "offset": {"x": 1, "y": 2, "z": 3}} + params["wellLocation"], {"origin": "top", "offset": {"x": 1, "y": 2, "z": 3}} ) finally: asyncio.run(flex.stop()) - def test_touch_tip_defaults_radius_one_and_zero_offset(self): + def test_touch_tip_defaults_radius_one_and_1mm_below_rim(self): flex, transport, head = _flex_head1() try: rack = flex_96_tiprack_50ul(name="rack1") @@ -201,7 +208,7 @@ def test_touch_tip_defaults_radius_one_and_zero_offset(self): params = touch_cmds[0]["params"] self.assertEqual(params["radius"], 1.0) self.assertEqual( - params["wellLocation"], {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 0}} + params["wellLocation"], {"origin": "top", "offset": {"x": 0, "y": 0, "z": -1.0}} ) finally: asyncio.run(flex.stop()) @@ -368,8 +375,10 @@ def test_liquid_probe_returns_configured_z_and_sends_probe_command(self): params = probe_cmds[0]["params"] self.assertEqual(params["pipetteId"], head.pipette_id) self.assertEqual(params["wellName"], "B3") + # The probe starts 2 mm above the well rim (the engine's own start + # offset) and descends; a bottom-origin start would begin at the floor. self.assertEqual( - params["wellLocation"], {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 0}} + params["wellLocation"], {"origin": "top", "offset": {"x": 0, "y": 0, "z": 2.0}} ) finally: asyncio.run(flex.stop()) @@ -406,6 +415,11 @@ def test_try_liquid_probe_returns_configured_z(self): asyncio.run(head.pick_up_tips(rack.get_item("A1"))) z = asyncio.run(head.try_liquid_probe(plate.get_item("B3"))) self.assertEqual(z, 4.75) + probe_cmds = [c for c in transport.commands if c["commandType"] == "tryLiquidProbe"] + self.assertEqual( + probe_cmds[0]["params"]["wellLocation"], + {"origin": "top", "offset": {"x": 0, "y": 0, "z": 2.0}}, + ) finally: asyncio.run(flex.stop()) @@ -420,6 +434,64 @@ def test_liquid_probe_without_tip_raises_and_sends_nothing(self): finally: asyncio.run(flex.stop()) + def test_liquid_probe_wire_failure_translates_to_liquid_not_found(self): + # Real hardware FAILS the liquidProbe command with the defined + # "liquidNotFound" error when no liquid is detected. + flex, transport, head, rack, plate = self._bench(simulate_liquid_probe_not_found=True) + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(OpentronsError) as ctx: + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + + self.assertEqual(ctx.exception.title, "LiquidNotFoundError") + probe_cmds = [c for c in transport.commands if c["commandType"] == "liquidProbe"] + self.assertEqual(len(probe_cmds), 1, "the probe reached the wire and failed there") + finally: + asyncio.run(flex.stop()) + + def test_try_liquid_probe_unaffected_by_liquid_probe_failure_mode(self): + # tryLiquidProbe genuinely succeeds with the z_position key absent. + flex, _transport, head, rack, plate = self._bench(simulate_liquid_probe_not_found=True) + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + self.assertIsNone(asyncio.run(head.try_liquid_probe(plate.get_item("B3")))) + finally: + asyncio.run(flex.stop()) + + def test_liquid_probe_other_wire_failure_reraises_untranslated(self): + class _OverpressureProbeTransport(ChatterboxTransport): + """Fails liquidProbe with a different defined error than liquidNotFound.""" + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if path.endswith("/commands") and data.get("commandType") == "liquidProbe": + cmd_data = result["data"] + cmd_data["status"] = "failed" + cmd_data["error"] = {"errorType": "overpressure", "detail": "clogged tip"} + return result + + transport = _OverpressureProbeTransport( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")] + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + head = flex.right + assert isinstance(head, FlexHead1) + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(OpentronsCommandError) as ctx: + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + self.assertEqual(ctx.exception.error_type, "overpressure") + finally: + asyncio.run(flex.stop()) + class TestLiquidProbeHead8(unittest.TestCase): """FlexHead8 liquid probing is column-addressed: one probe command anchored @@ -468,6 +540,99 @@ def test_try_liquid_probe_returns_none_when_no_liquid_found(self): finally: asyncio.run(flex.stop()) + def test_liquid_probe_without_tips_raises_and_sends_nothing(self): + flex, transport, head, _rack, plate = self._bench(liquid_probe_z=7.25) + try: + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.liquid_probe(plate, column=3)) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestHead8ColumnValidation(unittest.TestCase): + """Every FlexHead8 column op validates the column against the labware's + real grid BEFORE any wire command (including configureNozzleLayout and + loadLabware), so a rejected op ships nothing.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + return flex, transport, head, rack, plate + + def test_out_of_range_columns_reject_every_op_with_zero_wire_commands(self): + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) # the trio needs mounted tips + + for column in (-1, 12, 15): + ops = [ + lambda c=column: head.pick_up_tips(rack, column=c), + lambda c=column: head.drop_tips(rack, column=c), + lambda c=column: head.aspirate(plate, column=c, volume=10), + lambda c=column: head.dispense(plate, column=c, volume=10), + lambda c=column: head.touch_tip(plate, column=c), + lambda c=column: head.liquid_probe(plate, column=c), + lambda c=column: head.try_liquid_probe(plate, column=c), + ] + for op in ops: + n_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(op()) + self.assertEqual( + len(transport.commands), n_before, f"column {column} rejection must ship nothing" + ) + finally: + asyncio.run(flex.stop()) + + def test_column_minus_one_does_not_alias_to_the_last_column(self): + # Negative indexing must not silently address column 12's wells. + flex, transport, head, rack, _plate = self._bench() + try: + n_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(head.pick_up_tips(rack, column=-1)) + self.assertEqual(len(transport.commands), n_before) + for spot in rack.get_all_items(): + self.assertTrue(spot.has_tip(), spot.name) + finally: + asyncio.run(flex.stop()) + + def test_384_well_plate_rejects_column_ops_pre_wire(self): + # A 16-row plate cannot be column-addressed by the 8-channel head; the + # old fixed name table raised bare IndexError for column >= 12. + flex, transport, head, rack, _plate = self._bench() + try: + plate_384 = biorad_384_wellplate_50uL_Vb(name="plate384") + flex.deck.assign_child_at_slot(plate_384, "C3") + asyncio.run(head.pick_up_tips(rack, column=0)) + + n_before = len(transport.commands) + for op in ( + lambda: head.aspirate(plate_384, column=15, volume=10), + lambda: head.liquid_probe(plate_384, column=15), + lambda: head.touch_tip(plate_384, column=2), + ): + with self.assertRaises(ValueError): + asyncio.run(op()) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/opentrons/flex_gripper.py b/pylabrobot/opentrons/flex_gripper.py index b317b487e16..249e364d6f4 100644 --- a/pylabrobot/opentrons/flex_gripper.py +++ b/pylabrobot/opentrons/flex_gripper.py @@ -10,13 +10,15 @@ The robot-server's ``moveLabware`` command is atomic (pick + travel + place in one command), so a gripper move is a single wire call rather than a -pick/move/drop sequence. Grip geometry comes from the robot's own labware -definition for the loaded ``loadName``; PLR does not upload one. +pick/move/drop sequence. Grip geometry comes from the labware's definition: +the robot's own for official load names, or the uploaded custom definition +(optionally carrying a caller-chosen grip height) for everything else. """ import logging from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from pylabrobot.opentrons.flex_head import _UNTESTED_HARDWARE_WARNING from pylabrobot.opentrons.robot import OpentronsError from pylabrobot.resources.resource import Resource @@ -37,15 +39,28 @@ _GRIPPER_MIN_FORCE = 2.0 _GRIPPER_MAX_FORCE = 30.0 +# The robot-server's DeckSlotName covers only the A1-D3 grid; the column-4 +# staging slots are addressable areas and ride a different location key. +_STAGING_SLOT_NAMES = frozenset({"A4", "B4", "C4", "D4"}) + + +def _slot_wire_location(slot: str) -> Dict[str, str]: + """The ``loadLabware``/``moveLabware`` location for a Flex slot name.""" + if slot in _STAGING_SLOT_NAMES: + return {"addressableAreaName": slot} + return {"slotName": slot} + def _version_tuple(version: str) -> Tuple[int, ...]: """Parse a dotted robot-software version into comparable integers. Comparing these as strings puts "10.0.0" below "7.1.0", so the version gate compares numerically. Each dotted segment contributes its leading integer - ("0-beta" -> 0); a segment with no leading digit stops the parse. Only used - to gate at coarse major.minor granularity, where the exact handling of a - pre-release suffix does not change the outcome. + ("0-beta" -> 0); a segment with no leading digit stops the parse, and short + results pad with zeros so "8.2" compares equal to "8.2.0". + + Raises: + ValueError: If the version has no leading numeric segment at all. """ parts: List[int] = [] for part in version.split("."): @@ -57,6 +72,10 @@ def _version_tuple(version: str) -> Tuple[int, ...]: if digits == "": break parts.append(int(digits)) + if not parts: + raise ValueError(f"unparseable version string: {version!r}") + while len(parts) < 3: + parts.append(0) return tuple(parts) @@ -65,19 +84,27 @@ def _require_robot_commands(command: str, api_version: Optional[str]) -> None: ``api_version`` is the ``GET /health`` ``api_version`` the owning robot stored at setup (``flex.api_version``). Released builds report a plain - numeric version and are gated against ``_ROBOT_COMMANDS_MIN_VERSION``; - dev/simulator builds ("0.0.0.dev0") and offline stand-in transports report - non-release strings but run current code, so they pass. + numeric version and are gated against ``_ROBOT_COMMANDS_MIN_VERSION``. + Dev/simulator builds ("0.0.0.dev0") and the offline ChatterboxTransport + ("dry-run") run current code, so they pass; any other unparseable version + raises rather than silently passing the gate. """ if api_version is None: raise OpentronsError( "Robot version unknown", f"{command} requires setup() to have run, to read the robot's version.", ) - if "dev" in api_version: + if "dev" in api_version or api_version == "dry-run": return - version = _version_tuple(api_version) - if version and version < _version_tuple(_ROBOT_COMMANDS_MIN_VERSION): + try: + version = _version_tuple(api_version) + except ValueError: + raise OpentronsError( + "Robot version unrecognized", + f"{command} is gated on robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, but this " + f"robot reports the unrecognized version {api_version!r}.", + ) from None + if version < _version_tuple(_ROBOT_COMMANDS_MIN_VERSION): raise OpentronsError( "Robot software too old", f"{command} requires Opentrons robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, " @@ -99,23 +126,45 @@ class FlexGripper: def __init__(self, flex: "OpentronsFlex", gripper_model: str) -> None: self.flex = flex self.gripper_model = gripper_model - - async def move_labware(self, resource: Resource, to_slot: str) -> None: + self._untested_hardware_warned: bool = False + + def _warn_untested_hardware(self, op: str) -> None: + """Log a one-time notice that gripper ops are not yet verified on real hardware.""" + if self._untested_hardware_warned: + return + self._untested_hardware_warned = True + logger.warning(_UNTESTED_HARDWARE_WARNING, type(self).__name__, op) + + async def move_labware( + self, + resource: Resource, + to_slot: str, + grip_distance_from_top: Optional[float] = None, + ) -> None: """Move ``resource`` from its current deck slot to ``to_slot`` with the gripper. Validates PLR-side first (resource on deck, destination a valid empty slot), then sends ONE atomic ``moveLabware`` command. On wire success the deck is re-parented to match; on wire failure the deck is left untouched - and the error propagates. + and the error propagates. Standard slots ride ``slotName``; the column-4 + staging slots A4-D4 are addressable areas server-side and ride + ``addressableAreaName``. Args: resource: A resource currently placed on the deck. to_slot: Destination slot, e.g. ``"C2"`` (standard) or ``"B4"`` (staging). + grip_distance_from_top: How far below the labware's top the paddles + grab (mm), baked into an uploaded custom definition's grip height. + Honored on the labware's FIRST load in the run only; once the robot + holds a definition for it, later values are ignored. ``None`` keeps + the definition's grip height (the robot's mid-height default for + custom definitions built without one). Raises: OpentronsError: If the resource is not on the deck, or ``to_slot`` is invalid or occupied. Raised before any wire command is sent. """ + self._warn_untested_hardware("move_labware") deck = self.flex.deck name = getattr(resource, "name", str(resource)) @@ -138,12 +187,14 @@ async def move_labware(self, resource: Resource, to_slot: str) -> None: f"Slot {to_slot} is already occupied by '{occupant_name}'.", ) - labware_id = await self.flex._ensure_labware_loaded(resource) + labware_id = await self.flex._ensure_labware_loaded( + resource, grip_distance_from_top=grip_distance_from_top + ) await self.flex._execute_command( "moveLabware", { "labwareId": labware_id, - "newLocation": {"slotName": to_slot}, + "newLocation": _slot_wire_location(to_slot), "strategy": "usingGripper", }, timeout=_MOVE_LABWARE_TIMEOUT, @@ -160,6 +211,7 @@ async def ungrip(self) -> None: still be holding the labware; this releases it so the operator can recover the plate by hand. """ + self._warn_untested_hardware("ungrip") await self.flex._execute_command("unsafe/ungripLabware", {}) # --- robot/*: direct gripper motion and jaw control --- @@ -175,6 +227,7 @@ async def move_to(self, x: float, y: float, z: float, speed: Optional[float] = N accepts can still be out of bounds. ``speed`` is in mm/s (robot default if None). """ + self._warn_untested_hardware("move_to") _require_robot_commands("robot/moveTo", self.flex.api_version) # The robot/* commands take snake_case params, unlike the rest of the API. params: Dict[str, Any] = {"mount": "extension", "destination": {"x": x, "y": y, "z": z}} @@ -194,6 +247,7 @@ async def grip(self, force: Optional[float] = None) -> None: OpentronsError: If ``force`` is outside the accepted range -- raised before any wire command is sent. """ + self._warn_untested_hardware("grip") _require_robot_commands("robot/closeGripperJaw", self.flex.api_version) params: Dict[str, Any] = {} if force is not None: @@ -211,5 +265,6 @@ async def open_jaw(self) -> None: Releases anything held; there is no partial-open width parameter. """ + self._warn_untested_hardware("open_jaw") _require_robot_commands("robot/openGripperJaw", self.flex.api_version) await self.flex._execute_command("robot/openGripperJaw", {}) diff --git a/pylabrobot/opentrons/flex_gripper_tests.py b/pylabrobot/opentrons/flex_gripper_tests.py index 13ff020065e..c65d67564e7 100644 --- a/pylabrobot/opentrons/flex_gripper_tests.py +++ b/pylabrobot/opentrons/flex_gripper_tests.py @@ -16,7 +16,7 @@ from pylabrobot.opentrons.flex_gripper import FlexGripper from pylabrobot.opentrons.robot import OpentronsError from pylabrobot.opentrons.transport import ChatterboxTransport -from pylabrobot.resources import cor_96_wellplate_360uL_Fb +from pylabrobot.resources import Resource, cor_96_wellplate_360uL_Fb from pylabrobot.resources.opentrons.flex_deck import FlexDeck from pylabrobot.resources.plate import Plate @@ -137,7 +137,9 @@ def test_second_move_reuses_the_loaded_labware(self): finally: asyncio.run(flex.stop()) - def test_move_to_staging_slot(self): + def test_move_to_staging_slot_uses_addressable_area_form(self): + # The robot-server's DeckSlotName has no A4-D4: staging slots are + # addressable areas, so {"slotName": "B4"} would be rejected there. flex, transport = _flex_with_gripper() asyncio.run(flex.setup()) try: @@ -150,11 +152,56 @@ def test_move_to_staging_slot(self): move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] self.assertEqual(len(move_cmds), 1) - self.assertEqual(move_cmds[0]["params"]["newLocation"], {"slotName": "B4"}) + self.assertEqual(move_cmds[0]["params"]["newLocation"], {"addressableAreaName": "B4"}) self.assertEqual(flex.deck.get_slot(plate), "B4") finally: asyncio.run(flex.stop()) + def test_move_back_from_staging_slot_uses_slot_name_form(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "A4") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(plate, "C2")) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(move_cmds[0]["params"]["newLocation"], {"slotName": "C2"}) + # The load at the staging slot itself also needs the addressable-area form. + load_cmds = [c for c in transport.commands if c["commandType"] == "loadLabware"] + self.assertEqual(load_cmds[0]["params"]["location"], {"addressableAreaName": "A4"}) + finally: + asyncio.run(flex.stop()) + + def test_move_bare_resource_uploads_stub_with_grip_geometry(self): + # A resource with no pipettable geometry (lid, adapter) rides the movable + # stub definition; grip_distance_from_top shapes its grip height. + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + lid = Resource(name="lid stack", size_x=100.0, size_y=90.0, size_z=20.0) + flex.deck.assign_child_at_slot(lid, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(lid, "C2", grip_distance_from_top=5.0)) + + self.assertEqual(len(transport.labware_definitions), 1) + definition = transport.labware_definitions[0] + self.assertEqual(definition["ordering"], [["A1"]]) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 15.0) # 20 - 5 + load_cmds = [c for c in transport.commands if c["commandType"] == "loadLabware"] + self.assertEqual(load_cmds[0]["params"]["namespace"], "pylabrobot") + self.assertEqual(load_cmds[0]["params"]["loadName"], "lid_stack_2196eb") + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1) + self.assertEqual(flex.deck.get_slot(lid), "C2") + finally: + asyncio.run(flex.stop()) + class TestMoveLabwarePreWireRejections(unittest.TestCase): """Invalid moves raise OpentronsError BEFORE any wire command is sent.""" diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index c4bb0bb2e30..3493ad45342 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -21,9 +21,9 @@ """ import logging -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional, Tuple, Union, cast -from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.robot import OpentronsCommandError, OpentronsError from pylabrobot.resources import ( Container, Plate, @@ -45,6 +45,14 @@ logger = logging.getLogger(__name__) +# Shared by the heads and the gripper so the notice reads identically +# everywhere; each module logs it through its own logger. +_UNTESTED_HARDWARE_WARNING = ( + "%s.%s is coded but NOT YET VERIFIED on real Opentrons Flex hardware -- " + "tested only against ChatterboxTransport/simulated transport. Verify behavior " + "on real hardware before relying on it in a production protocol." +) + class _FlexHead: """Base class for a mount- (or 96-head-) addressed pipette on an ``OpentronsFlex``. @@ -55,6 +63,10 @@ class _FlexHead: need to build robot-server command params. """ + # Op names confirmed on real Flex hardware; every op outside this set + # triggers the one-time untested-hardware notice. + _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset() + def __init__(self, flex: "OpentronsFlex", mount: str, pipette_id: str, channels: int) -> None: self.flex = flex self.mount = mount @@ -68,23 +80,16 @@ def __init__(self, flex: "OpentronsFlex", mount: str, pipette_id: str, channels: self._prepared: bool = True self._untested_hardware_warned: bool = False - def _warn_untested_hardware(self) -> None: - """Log a one-time notice that this head is not yet verified on real hardware. + def _warn_untested_hardware(self, op: str) -> None: + """Log a one-time notice when an op has no real-hardware verification. - Called by ``FlexHead1``/``FlexHead96`` at the top of every op -- guarded - so only the FIRST call on a given instance actually logs. ``FlexHead8`` - does not call this (it has its own hardware-verification history); this - exists specifically for the hardware-unverified heads. + Coverage is op-scoped: ops in ``_HARDWARE_VERIFIED_OPS`` never log; the + first op outside that set logs once per instance. """ - if self._untested_hardware_warned: + if op in self._HARDWARE_VERIFIED_OPS or self._untested_hardware_warned: return self._untested_hardware_warned = True - logger.warning( - "%s ops are coded but NOT YET VERIFIED on real Opentrons Flex hardware -- " - "tested only against ChatterboxTransport/simulated transport. Verify behavior " - "on real hardware before relying on it in a production protocol.", - type(self).__name__, - ) + logger.warning(_UNTESTED_HARDWARE_WARNING, type(self).__name__, op) def get_mounted_tips(self) -> List[Optional[Tip]]: """Per-channel tip state (Case-2: no private-attribute peeking by consumers). @@ -113,6 +118,7 @@ async def blow_out(self, flow_rate: Optional[float] = None) -> None: ``prepareToAspirate`` (same priming rule as after a tip pickup). No trackers are involved. """ + self._warn_untested_hardware("blow_out") rate = flow_rate if flow_rate is not None else _DEFAULT_BLOW_OUT_FLOW_RATE await self._execute("blowOutInPlace", {"pipetteId": self.pipette_id, "flowRate": rate}) self._prepared = False @@ -306,28 +312,32 @@ def _touch_tip_params( ) -> Dict[str, Any]: """Build the ``touchTip`` params dict shared by every head's ``touch_tip``. - The ``wellLocation`` rides at origin "bottom" with a zero default - offset -- ``touchTip`` addresses the height of the wall-touch motion, - not a liquid position, so the liquid ops' +1mm clearance default does - not apply. ``radius`` is the fraction of the well radius the tip moves - toward (1.0 = the wall). + ``touchTip`` addresses the height of the wall-touch motion, not a liquid + position, so the ``wellLocation`` is TOP-relative: the default touches + 1 mm below the rim (the Opentrons Python API's ``v_offset`` default), and + a caller ``offset`` replaces it, also read against the well top. + ``radius`` is the fraction of the well radius the tip moves toward + (1.0 = the wall). """ - o = offset if offset is not None else Coordinate.zero() + o = offset if offset is not None else Coordinate(z=_DEFAULT_TOUCH_TIP_Z_OFFSET) return { "pipetteId": self.pipette_id, "labwareId": labware_id, "wellName": well_name, - "wellLocation": {"origin": "bottom", "offset": {"x": o.x, "y": o.y, "z": o.z}}, + "wellLocation": {"origin": "top", "offset": {"x": o.x, "y": o.y, "z": o.z}}, "radius": radius, } async def _probe_z(self, command_type: str, labware_id: str, well_name: str) -> Optional[float]: """Send a ``liquidProbe``/``tryLiquidProbe`` command; return the found liquid z (mm). - The robot-server OMITS ``z_position`` from the command result entirely - (rather than reporting null) when no liquid is detected, so absence is - read with ``.get()`` and surfaced as ``None`` -- callers decide whether - that raises (``liquid_probe``) or passes through (``try_liquid_probe``). + The probe starts just above the well rim (origin "top", +2 mm -- the + engine's own ``LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP``) and descends; + the engine reads the offset into the stroke length, so a bottom-origin + start would drive the tip from the well floor through the plate. The + robot-server OMITS ``z_position`` from a successful command result + entirely (rather than reporting null) when no liquid is detected, so + absence is read with ``.get()`` and surfaced as ``None``. """ result = await self._execute( command_type, @@ -335,11 +345,35 @@ async def _probe_z(self, command_type: str, labware_id: str, well_name: str) -> "pipetteId": self.pipette_id, "labwareId": labware_id, "wellName": well_name, - "wellLocation": {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 0}}, + "wellLocation": { + "origin": "top", + "offset": {"x": 0, "y": 0, "z": _LIQUID_PROBE_START_OFFSET_Z}, + }, }, ) return cast(Optional[float], result.get("result", {}).get("z_position")) + async def _liquid_probe_z(self, labware_id: str, well_name: str, where: str) -> float: + """``liquidProbe`` with both no-liquid signals mapped to ``LiquidNotFoundError``. + + On real hardware a no-liquid probe FAILS the command with the defined + "liquidNotFound" error, so the wire failure is caught and translated; + the absent-``z_position`` success path additionally covers transports + that succeed without a result (e.g. ``ChatterboxTransport``). Other wire + failures re-raise untranslated. + """ + try: + z = await self._probe_z("liquidProbe", labware_id, well_name) + except OpentronsCommandError as e: + if e.error_type == "liquidNotFound": + raise OpentronsError( + "LiquidNotFoundError", f"liquid_probe found no liquid in {where}." + ) from e + raise + if z is None: + raise OpentronsError("LiquidNotFoundError", f"liquid_probe found no liquid in {where}.") + return z + async def _on_setup(self) -> None: """Hook for head-specific post-discovery setup. Default: no-op.""" @@ -392,31 +426,6 @@ def _well_location( # --- Single-cavity container (trough/reservoir) shared helpers --- - @staticmethod - def _container_well_location( - centering: Coordinate, - offset: Optional[Coordinate], - liquid_height: Optional[float], - ) -> Dict[str, Any]: - """Build the ``wellLocation`` for an op on a single-cavity container. - - ``centering`` is the head-geometry offset from the cavity center to - where the anchor nozzle must go (each head computes its own); the - caller's ``offset``/``liquid_height`` ride on top, with the same z - defaulting as ``_well_location`` (bottom clearance when neither is - given). Always returns a dict -- the centering must reach the wire - even when the caller passed nothing. - """ - o = offset if offset is not None else Coordinate.zero() - if offset is None and liquid_height is None: - z = _DEFAULT_WELL_BOTTOM_CLEARANCE - else: - z = o.z + (liquid_height if liquid_height is not None else 0.0) - return { - "origin": "bottom", - "offset": {"x": centering.x + o.x, "y": centering.y + o.y, "z": z}, - } - @staticmethod def _stage_container_aspirate(container: Container, total_volume: float) -> List[VolumeTracker]: """Stage an aspirate's total volume against a container's single tracker. @@ -448,18 +457,28 @@ def _stage_container_dispense(container: Container, total_volume: float) -> List return staged_trackers @staticmethod - def _require_span_fits_container(container: Container, x_span: float, y_span: float) -> None: - """Raise pre-wire if the centered nozzle array would overhang the cavity. + def _require_span_fits_container( + container: Container, + x_span: float, + y_span: float, + offset: Optional[Coordinate], + ) -> None: + """Raise pre-wire if the nozzle array would overhang the cavity. - The nozzles are rigid, so an op fanning one command into a single - cavity can only land every nozzle inside it if the cavity's footprint - contains the centered array's span on each axis. + The engine centers the array on the cavity (the definition's + ``centerMultichannelOnWells`` quirk) and the caller's ``offset`` then + shifts it, so the shifted span must still fit inside the cavity's + footprint on each axis. """ - if x_span > container.get_size_x() or y_span > container.get_size_y(): + o = offset if offset is not None else Coordinate.zero() + required_x = x_span + 2 * abs(o.x) + required_y = y_span + 2 * abs(o.y) + if required_x > container.get_size_x() or required_y > container.get_size_y(): raise OpentronsError( "Container too small", - f"The nozzle array spans {x_span} x {y_span} mm, which does not fit inside " - f"'{container.name}' ({container.get_size_x()} x {container.get_size_y()} mm). " + f"The nozzle array spans {x_span} x {y_span} mm and the offset ({o.x}, {o.y}) shifts " + f"it off-center, which does not fit inside '{container.name}' " + f"({container.get_size_x()} x {container.get_size_y()} mm). " "Aim it at a container that holds the whole array.", ) @@ -472,6 +491,7 @@ async def position(self) -> Coordinate: the nozzle when no tip is mounted. The Flex's robot frame coincides with the deck frame, so the reported position needs no conversion. """ + self._warn_untested_hardware("position") result = await self._execute("savePosition", {"pipetteId": self.pipette_id}) pos = result["result"]["position"] return Coordinate(pos["x"], pos["y"], pos["z"]) @@ -495,6 +515,7 @@ async def move_to( ``minimum_z_height`` (mm) defaults to the traversal height, so a lateral jog arcs over deck labware; ``speed`` is in mm/s (robot default if None). """ + self._warn_untested_hardware("move_to") if x is None and y is None and z is None: raise ValueError("move_to: supply at least one of x, y, z.") if x is None or y is None or z is None: @@ -516,11 +537,6 @@ async def move_to( # least this z while traveling, clearing any labware on the deck. _TRAVERSAL_HEIGHT = 120.0 -# Column index -> A-row well name (the Flex API's anchor well for 8-channel -# ALL-mode column ops; the hardware fans a single command out to all 8 -# physical nozzles from there). -_COLUMN_WELL_NAMES = [f"A{c + 1}" for c in range(12)] - # Row letters front-to-back as the Flex API names single nozzles ("H1" is the # frontmost/primary nozzle, "A1" the rearmost). _ROW_LETTERS = "ABCDEFGH" @@ -543,6 +559,14 @@ async def move_to( # bottom-referenced wellLocation for liquid ops. _DEFAULT_WELL_BOTTOM_CLEARANCE = 1.0 +# touch_tip's default z: 1 mm below the well rim, matching the Opentrons +# Python API's v_offset default. +_DEFAULT_TOUCH_TIP_Z_OFFSET = -1.0 + +# Liquid probing starts just above the well rim and descends from there; +# matches the engine's LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP. +_LIQUID_PROBE_START_OFFSET_Z = 2.0 + # Opentrons single-cavity labware definitions (troughs/reservoirs) expose # exactly one well, named "A1" -- container ops always address it. _CONTAINER_WELL_NAME = "A1" @@ -591,7 +615,7 @@ async def pick_up_tips( committed only if that verification passes, rolled back (with no ``_channel_tips`` mutation) if the sensor reports a missed pickup. """ - self._warn_untested_hardware() + self._warn_untested_hardware("pick_up_tips") if self._channel_tips[0] is not None: raise OpentronsError( "HasTipError", @@ -635,7 +659,7 @@ async def drop_tips( commit, ``_confirm_tips_cleared()`` checks the hardware tip-presence sensor and logs a warning (does not raise) if it still reports a tip. """ - self._warn_untested_hardware() + self._warn_untested_hardware("drop_tips") if isinstance(target, Trash): await self._execute_trash_drop() @@ -682,18 +706,20 @@ async def aspirate( ``Container`` (trough/reservoir) is its own robot-side labware whose single-cavity definition exposes exactly one well, named "A1", so the command names the container's labware id and well "A1"; the volume is - tracked against the container's own tracker. Either way: stage -> + tracked against the container's own tracker, and a mounted tip is + required (checked before any wire command). Either way: stage -> validate -> wire -> commit/rollback -- the tracker (``remove_liquid``) is staged BEFORE the wire command, so an infeasible aspirate raises before any hardware motion. A ``prepareToAspirate`` command is sent first if this is the first aspirate since the last tip pickup. """ - self._warn_untested_hardware() + self._warn_untested_hardware("aspirate") if isinstance(target, Well): parent = self._require_itemized_parent(target) labware_id = await self.flex._ensure_labware_loaded(parent) well_name = parent.get_child_identifier(target) else: + self._require_mounted_tip() labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE @@ -725,17 +751,18 @@ async def dispense( A ``Well`` is addressed through its plate parent by well name; a bare ``Container`` (trough/reservoir) is addressed as its own labware at - its sole robot-side well "A1" (see ``aspirate``). Either way: stage -> - validate -> wire -> commit/rollback -- the tracker (``add_liquid``) is - staged BEFORE the wire command, so an infeasible dispense raises - before any hardware motion. + its sole robot-side well "A1" and requires a mounted tip (see + ``aspirate``). Either way: stage -> validate -> wire -> commit/rollback + -- the tracker (``add_liquid``) is staged BEFORE the wire command, so + an infeasible dispense raises before any hardware motion. """ - self._warn_untested_hardware() + self._warn_untested_hardware("dispense") if isinstance(target, Well): parent = self._require_itemized_parent(target) labware_id = await self.flex._ensure_labware_loaded(parent) well_name = parent.get_child_identifier(target) else: + self._require_mounted_tip() labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE @@ -767,7 +794,7 @@ async def touch_tip( (1.0 = the wall). Requires a mounted tip (checked before any wire command). No trackers are involved. """ - self._warn_untested_hardware() + self._warn_untested_hardware("touch_tip") self._require_mounted_tip() parent = self._require_itemized_parent(well) labware_id = await self.flex._ensure_labware_loaded(parent) @@ -781,22 +808,16 @@ async def liquid_probe(self, well: Well) -> float: (checked before any wire command). Raises ``OpentronsError`` if no liquid is found; use ``try_liquid_probe`` for the non-raising variant. """ - self._warn_untested_hardware() + self._warn_untested_hardware("liquid_probe") self._require_mounted_tip() parent = self._require_itemized_parent(well) labware_id = await self.flex._ensure_labware_loaded(parent) well_name = parent.get_child_identifier(well) - z = await self._probe_z("liquidProbe", labware_id, well_name) - if z is None: - raise OpentronsError( - "LiquidNotFoundError", - f"liquid_probe found no liquid in well {well.name!r}.", - ) - return z + return await self._liquid_probe_z(labware_id, well_name, f"well {well.name!r}") async def try_liquid_probe(self, well: Well) -> Optional[float]: """Like ``liquid_probe`` but return ``None`` instead of raising when no liquid is found.""" - self._warn_untested_hardware() + self._warn_untested_hardware("try_liquid_probe") self._require_mounted_tip() parent = self._require_itemized_parent(well) labware_id = await self.flex._ensure_labware_loaded(parent) @@ -822,9 +843,12 @@ class FlexHead8(_FlexHead): Verified on real 8-channel Flex hardware (Opentrons Flex, robot-server API 8.8): setup, homing, and column tip pickup confirmed against the - hardware tip-presence sensor. + hardware tip-presence sensor. Ops outside that verified lineage log the + one-time untested-hardware notice, same as the other heads. """ + _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset({"pick_up_tips"}) + def __init__(self, flex: "OpentronsFlex", mount: str, pipette_id: str, channels: int) -> None: super().__init__(flex, mount, pipette_id, channels) self._nozzle_layout: str = "ALL" # "ALL" | "SINGLE" @@ -850,21 +874,29 @@ async def _ensure_all_mode(self) -> None: # --- Column helpers --- @staticmethod - def _column_items(itemized: ItemizedResource, column: int) -> List[Any]: - """Return the 8 column resources (TipSpots or Wells), in row order A..H. - - Mirrors the column-major slice used throughout PLR's itemized resources: - item 0 is A1, item 1 is B1, ..., item 8 is A2, etc. -- so one column is - ``items[column * 8 : (column + 1) * 8]``. + def _column_anchor_and_items(itemized: ItemizedResource, column: int) -> Tuple[str, List[Any]]: + """Validate ``column`` against the labware's real grid; return the A-row + anchor well name plus the 8 column resources (row order A..H). + + Every column op calls this BEFORE any wire command (including + ``configureNozzleLayout`` and ``loadLabware``) so a rejected op ships + nothing. PLR itemized resources are column-major (item 0 is A1, item 1 + is B1, ...), and the anchor name comes from the resource itself rather + than a fixed name table, so any column count is addressed safely. """ - items = itemized.get_all_items() - num_columns = len(items) // _NUM_CHANNELS + if itemized.num_items_y != _NUM_CHANNELS: + raise ValueError( + f"'{itemized.name}' has {itemized.num_items_y} rows; 8-channel column ops " + f"require an 8-row layout." + ) + num_columns = itemized.num_items_x if not 0 <= column < num_columns: raise ValueError( f"Column {column} out of range for resource with {num_columns} columns " f"(0-{num_columns - 1})." ) - return items[column * _NUM_CHANNELS : (column + 1) * _NUM_CHANNELS] + column_items = itemized.get_all_items()[column * _NUM_CHANNELS : (column + 1) * _NUM_CHANNELS] + return itemized.get_child_identifier(column_items[0]), column_items # --- Column tip operations --- @@ -878,19 +910,18 @@ async def pick_up_tips( Anchored at the column's A-row well; the hardware fans the pickup motion out to all 8 physical nozzles. Follows stage -> validate -> wire -> - verify -> commit/rollback: tip trackers are staged (``commit=False``) - BEFORE the wire command -- so an already-occupied channel (fix #4) or an - invalid tracker state raises before any hardware motion -- then, after - the wire command succeeds, the hardware tip-presence sensor is checked + verify -> commit/rollback: the column index and the double-pickup guard + (fix #4) are validated before ANY wire command, tip trackers are staged + (``commit=False``) before the pickup command -- so an invalid tracker + state raises before any hardware motion -- then, after the wire command + succeeds, the hardware tip-presence sensor is checked (``_verify_tips_seated()``); trackers and ``_channel_tips`` are committed only if that verification passes, and rolled back (with no ``_channel_tips`` mutation) if the sensor reports a missed pickup. Only spots that actually had a tip are staged (None-skip). """ - await self._ensure_all_mode() - labware_id = await self.flex._ensure_labware_loaded(tip_rack) - well_name = _COLUMN_WELL_NAMES[column] - column_spots = self._column_items(tip_rack, column) + self._warn_untested_hardware("pick_up_tips") + well_name, column_spots = self._column_anchor_and_items(tip_rack, column) for i, spot in enumerate(column_spots): if spot.has_tip() and self._channel_tips[i] is not None: @@ -899,6 +930,9 @@ async def pick_up_tips( f"Channel {i} already holds a tip; drop it before picking up another.", ) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(tip_rack) + tracking = does_tip_tracking() staged_trackers: List[Any] = [] tips: List[Optional[Tip]] = [None] * len(column_spots) @@ -939,9 +973,10 @@ async def drop_tips( commit, ``_confirm_tips_cleared()`` checks the hardware tip-presence sensor and logs a warning (does not raise) if it still reports a tip. """ - await self._ensure_all_mode() + self._warn_untested_hardware("drop_tips") if isinstance(target, Trash): + await self._ensure_all_mode() await self._execute_trash_drop() self._channel_tips = [None] * self.channels await self._confirm_tips_cleared() @@ -950,9 +985,9 @@ async def drop_tips( if column is None: raise ValueError("column is required when dropping tips to a TipRack.") + well_name, column_spots = self._column_anchor_and_items(target, column) + await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(target) - well_name = _COLUMN_WELL_NAMES[column] - column_spots = self._column_items(target, column) tracking = does_tip_tracking() staged_trackers: List[Any] = [] @@ -998,15 +1033,16 @@ async def aspirate( motion. A ``prepareToAspirate`` command is sent first if this is the first aspirate since the last tip pickup. """ + self._warn_untested_hardware("aspirate") + well_name, column_wells = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) - well_name = _COLUMN_WELL_NAMES[column] rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE tracking = does_volume_tracking() staged_trackers: List[Any] = [] if tracking: - for i, well in enumerate(self._column_items(plate, column)): + for i, well in enumerate(column_wells): if self._channel_tips[i] is None or well.tracker.is_disabled: continue well.tracker.remove_liquid(volume=volume) # stages + validates @@ -1041,15 +1077,16 @@ async def dispense( a tip (None-skip) BEFORE the wire command, so an infeasible dispense (e.g. ``TooLittleVolumeError``) raises before any hardware motion. """ + self._warn_untested_hardware("dispense") + well_name, column_wells = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) - well_name = _COLUMN_WELL_NAMES[column] rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE tracking = does_volume_tracking() staged_trackers: List[Any] = [] if tracking: - for i, well in enumerate(self._column_items(plate, column)): + for i, well in enumerate(column_wells): if self._channel_tips[i] is None or well.tracker.is_disabled: continue well.tracker.add_liquid(volume=volume) # stages + validates @@ -1070,17 +1107,6 @@ async def dispense( # --- Single-cavity container (trough/reservoir) liquid handling --- - @staticmethod - def _container_centering() -> Coordinate: - """Offset from the cavity center to where the anchor (channel A) nozzle goes. - - The 8 nozzles span 63 mm front-to-back at a 9 mm pitch, and the wire - command positions the A-row (rearmost) nozzle, so centering the row in - the cavity puts that anchor half a span back (+y) of the cavity - center. - """ - return Coordinate(y=_EIGHT_CHANNEL_Y_SPAN / 2) - async def aspirate_container( self, container: Container, @@ -1093,20 +1119,23 @@ async def aspirate_container( All 8 nozzles dip into the same cavity (trough/reservoir), which is its own robot-side labware whose single-cavity definition exposes - exactly one well, named "A1": ONE ``aspirate`` command names that well - with a ``wellLocation`` that centers the nozzle row in the cavity - (``_container_centering``). Requires at least one mounted tip and a - cavity deep enough front-to-back to contain the 63 mm row -- both - checked before any wire command -- plus ALL nozzle mode (reset first - if a single-tip op left the layout otherwise). Each channel holding a - tip draws ``volume``, so the container's single tracker is staged with - ``volume * (channels holding tips)`` and committed/rolled back as one - op (stage -> validate -> wire -> commit/rollback). A - ``prepareToAspirate`` command is sent first if this is the first - aspirate since the last tip pickup. + exactly one well, named "A1": ONE ``aspirate`` command names that + well. The engine centers the nozzle row in the cavity itself (the + definition's ``centerMultichannelOnWells`` quirk, carried by every + single-cavity reservoir definition, uploaded ones included), so only + the caller's ``offset``/``liquid_height`` ride the wire. Requires at + least one mounted tip and a cavity that contains the 63 mm row even + after the offset shifts it -- both checked before any wire command -- + plus ALL nozzle mode (reset first if a single-tip op left the layout + otherwise). Each channel holding a tip draws ``volume``, so the + container's single tracker is staged with ``volume * (channels holding + tips)`` and committed/rolled back as one op (stage -> validate -> wire + -> commit/rollback). A ``prepareToAspirate`` command is sent first if + this is the first aspirate since the last tip pickup. """ + self._warn_untested_hardware("aspirate_container") self._require_mounted_tip() - self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN) + self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN, offset) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(container) rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE @@ -1120,10 +1149,10 @@ async def aspirate_container( "wellName": _CONTAINER_WELL_NAME, "volume": volume, "flowRate": rate, - "wellLocation": self._container_well_location( - self._container_centering(), offset, liquid_height - ), } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location await self._execute_with_prepare("aspirate", params, staged_trackers) @@ -1138,14 +1167,16 @@ async def dispense_container( """Dispense ``volume`` uL per channel into one single-cavity container. Mirrors ``aspirate_container``: ONE ``dispense`` command at the - container's sole robot-side well "A1", ``wellLocation`` centering the - nozzle row in the cavity, the same pre-wire guards (mounted tip, row - fits the cavity, ALL nozzle mode), and the container's single tracker - staged with ``volume * (channels holding tips)`` and committed/rolled - back as one op (stage -> validate -> wire -> commit/rollback). + container's sole robot-side well "A1", engine-side centering via the + definition's ``centerMultichannelOnWells`` quirk, the same pre-wire + guards (mounted tip, offset-shifted row fits the cavity, ALL nozzle + mode), and the container's single tracker staged with ``volume * + (channels holding tips)`` and committed/rolled back as one op (stage -> + validate -> wire -> commit/rollback). """ + self._warn_untested_hardware("dispense_container") self._require_mounted_tip() - self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN) + self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN, offset) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(container) rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE @@ -1159,10 +1190,10 @@ async def dispense_container( "wellName": _CONTAINER_WELL_NAME, "volume": volume, "flowRate": rate, - "wellLocation": self._container_well_location( - self._container_centering(), offset, liquid_height - ), } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location await self._execute_liquid_op("dispense", params, staged_trackers) @@ -1177,43 +1208,41 @@ async def touch_tip( anchored at the column's A-row well. ``radius`` is the fraction of the well radius each tip moves toward - (1.0 = the wall). Requires at least one mounted tip (checked before any - wire command) and ALL nozzle mode (reset first if a single-tip op left - the layout otherwise). No trackers are involved. + (1.0 = the wall). Requires at least one mounted tip and a valid column + (both checked before any wire command) and ALL nozzle mode (reset first + if a single-tip op left the layout otherwise). No trackers are involved. """ + self._warn_untested_hardware("touch_tip") self._require_mounted_tip() + well_name, _ = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) - well_name = _COLUMN_WELL_NAMES[column] await self._execute("touchTip", self._touch_tip_params(labware_id, well_name, radius, offset)) async def liquid_probe(self, plate: Plate, column: int) -> float: """Probe for liquid in a column -- one ``liquidProbe`` command anchored at the A-row well; return the found liquid z (mm). - Requires at least one mounted tip (checked before any wire command) and - ALL nozzle mode (reset first if a single-tip op left the layout - otherwise). Raises ``OpentronsError`` if no liquid is found; use - ``try_liquid_probe`` for the non-raising variant. + Requires at least one mounted tip and a valid column (both checked + before any wire command) and ALL nozzle mode (reset first if a + single-tip op left the layout otherwise). Raises ``OpentronsError`` if + no liquid is found; use ``try_liquid_probe`` for the non-raising + variant. """ + self._warn_untested_hardware("liquid_probe") self._require_mounted_tip() + well_name, _ = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) - well_name = _COLUMN_WELL_NAMES[column] - z = await self._probe_z("liquidProbe", labware_id, well_name) - if z is None: - raise OpentronsError( - "LiquidNotFoundError", - f"liquid_probe found no liquid in column {column} of {plate.name!r}.", - ) - return z + return await self._liquid_probe_z(labware_id, well_name, f"column {column} of {plate.name!r}") async def try_liquid_probe(self, plate: Plate, column: int) -> Optional[float]: """Like ``liquid_probe`` but return ``None`` instead of raising when no liquid is found.""" + self._warn_untested_hardware("try_liquid_probe") self._require_mounted_tip() + well_name, _ = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) - well_name = _COLUMN_WELL_NAMES[column] return await self._probe_z("tryLiquidProbe", labware_id, well_name) # --- Single-tip cherry-pick --- @@ -1263,6 +1292,7 @@ async def pick_up_single_tip( sensor reports a missed pickup (stage -> validate -> wire -> verify -> commit/rollback). """ + self._warn_untested_hardware("pick_up_single_tip") channel = self._channel_for_well(well) if self._channel_tips[channel] is not None: raise OpentronsError( @@ -1315,6 +1345,7 @@ async def aspirate_single( the last (single-tip) pickup. Follows stage -> validate -> wire -> commit/rollback for the well tracker, same as the column ``aspirate``. """ + self._warn_untested_hardware("aspirate_single") self._active_single_channel() labware_id = await self.flex._ensure_labware_loaded(plate) rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE @@ -1347,6 +1378,7 @@ async def dispense_single( flow_rate: Optional[float] = None, ) -> None: """Dispense to a single well with the currently mounted single tip.""" + self._warn_untested_hardware("dispense_single") self._active_single_channel() labware_id = await self.flex._ensure_labware_loaded(plate) rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE @@ -1378,6 +1410,7 @@ async def drop_single_tip(self, trash: Trash) -> None: checks the hardware tip-presence sensor and logs a warning (does not raise) if it still reports a tip. """ + self._warn_untested_hardware("drop_single_tip") channel = self._active_single_channel() await self._execute_trash_drop() self._channel_tips[channel] = None @@ -1450,7 +1483,7 @@ async def pick_up_tips( missed pickup. Only spots that actually had a tip are staged (None-skip). """ - self._warn_untested_hardware() + self._warn_untested_hardware("pick_up_tips") spots = self._check_full_coverage(tip_rack) for i, spot in enumerate(spots): @@ -1505,7 +1538,7 @@ async def drop_tips( checks the hardware tip-presence sensor and logs a warning (does not raise) if it still reports a tip. """ - self._warn_untested_hardware() + self._warn_untested_hardware("drop_tips") if isinstance(target, Trash): await self._execute_trash_drop() @@ -1539,18 +1572,6 @@ async def discard_tips(self, trash: Trash) -> None: """Discard the mounted 96 tips into the trash.""" await self.drop_tips(trash) - @staticmethod - def _container_centering() -> Coordinate: - """Offset from the cavity center to where the back-left (A1) anchor nozzle goes. - - The wire command positions the A1 nozzle -- the back-left corner of - the 12x8 grid -- so centering the grid in the cavity puts that anchor - back and left of the cavity center by half the grid's 99 x 63 mm - span; anchoring at the center instead would hang ~half the nozzles - off the cavity edge. - """ - return Coordinate(x=-_NINETY_SIX_HEAD_X_SPAN / 2, y=_NINETY_SIX_HEAD_Y_SPAN / 2) - async def aspirate( self, target: Union[Plate, Container], @@ -1566,20 +1587,20 @@ async def aspirate( is staged for every well whose channel actually holds a tip (None-skip). A bare ``Container`` (trough/reservoir) is its own robot-side labware whose single-cavity definition exposes exactly one - well, named "A1": the command's ``wellLocation`` centers the 12x8 - nozzle grid in the cavity (``_container_centering``), at least one - mounted tip and a cavity footprint containing the grid's 99 x 63 mm - span are required (checked before any wire command), and the - container's single tracker is staged with ``volume * (channels - holding tips)``. Either way: stage -> validate -> wire -> - commit/rollback, with an infeasible aspirate raising before any - hardware motion, and a ``prepareToAspirate`` command sent first if - this is the first aspirate since the last tip pickup. + well, named "A1": the engine centers the 12x8 nozzle grid in the + cavity itself (the definition's ``centerMultichannelOnWells`` quirk), + at least one mounted tip and a cavity footprint containing the grid's + 99 x 63 mm span even after the offset shifts it are required (checked + before any wire command), and the container's single tracker is staged + with ``volume * (channels holding tips)``. Either way: stage -> + validate -> wire -> commit/rollback, with an infeasible aspirate + raising before any hardware motion, and a ``prepareToAspirate`` + command sent first if this is the first aspirate since the last tip + pickup. """ - self._warn_untested_hardware() + self._warn_untested_hardware("aspirate") rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE staged_trackers: List[Any] = [] - well_location: Optional[Dict[str, Any]] if isinstance(target, Plate): wells = self._check_full_coverage(target) labware_id = await self.flex._ensure_labware_loaded(target) @@ -1590,17 +1611,16 @@ async def aspirate( continue well.tracker.remove_liquid(volume=volume) # stages + validates staged_trackers.append(well.tracker) - well_location = self._well_location([offset], [liquid_height]) else: self._require_mounted_tip() - self._require_span_fits_container(target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN) + self._require_span_fits_container( + target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN, offset + ) labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME mounted = sum(1 for tip in self._channel_tips if tip is not None) staged_trackers.extend(self._stage_container_aspirate(target, volume * mounted)) - well_location = self._container_well_location( - self._container_centering(), offset, liquid_height - ) + well_location = self._well_location([offset], [liquid_height]) params: Dict[str, Any] = { "pipetteId": self.pipette_id, @@ -1627,17 +1647,17 @@ async def dispense( Mirrors ``aspirate``: a ``Plate`` is anchored at its "A1" well with ``Well.tracker`` (``add_liquid``) staged per tip-holding channel (None-skip); a bare ``Container`` is addressed at its sole robot-side - well "A1" with the nozzle grid centered in the cavity, the same - pre-wire guards (mounted tip, grid fits the cavity footprint), and + well "A1" with the engine centering the nozzle grid in the cavity + (the ``centerMultichannelOnWells`` quirk), the same pre-wire guards + (mounted tip, offset-shifted grid fits the cavity footprint), and the container's single tracker staged with ``volume * (channels holding tips)``. Either way: stage -> validate -> wire -> commit/rollback, with an infeasible dispense raising before any hardware motion. """ - self._warn_untested_hardware() + self._warn_untested_hardware("dispense") rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE staged_trackers: List[Any] = [] - well_location: Optional[Dict[str, Any]] if isinstance(target, Plate): wells = self._check_full_coverage(target) labware_id = await self.flex._ensure_labware_loaded(target) @@ -1648,17 +1668,16 @@ async def dispense( continue well.tracker.add_liquid(volume=volume) # stages + validates staged_trackers.append(well.tracker) - well_location = self._well_location([offset], [liquid_height]) else: self._require_mounted_tip() - self._require_span_fits_container(target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN) + self._require_span_fits_container( + target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN, offset + ) labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME mounted = sum(1 for tip in self._channel_tips if tip is not None) staged_trackers.extend(self._stage_container_dispense(target, volume * mounted)) - well_location = self._container_well_location( - self._container_centering(), offset, liquid_height - ) + well_location = self._well_location([offset], [liquid_height]) params: Dict[str, Any] = { "pipetteId": self.pipette_id, @@ -1685,7 +1704,7 @@ async def touch_tip( (1.0 = the wall). Requires at least one mounted tip and a 96-position plate (both checked before any wire command). No trackers are involved. """ - self._warn_untested_hardware() + self._warn_untested_hardware("touch_tip") self._require_mounted_tip() self._check_full_coverage(plate) labware_id = await self.flex._ensure_labware_loaded(plate) diff --git a/pylabrobot/opentrons/flex_motion_tests.py b/pylabrobot/opentrons/flex_motion_tests.py index a68f265ad3b..c2dfc578449 100644 --- a/pylabrobot/opentrons/flex_motion_tests.py +++ b/pylabrobot/opentrons/flex_motion_tests.py @@ -11,15 +11,17 @@ import asyncio import unittest -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Tuple from pylabrobot.opentrons.flex import OpentronsFlex from pylabrobot.opentrons.flex_gripper import FlexGripper, _require_robot_commands -from pylabrobot.opentrons.flex_head import _FlexHead +from pylabrobot.opentrons.flex_head import FlexHead8, _FlexHead from pylabrobot.opentrons.robot import OpentronsError from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import set_tip_tracking from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul def _flex_with_gripper(**transport_kwargs) -> Tuple[OpentronsFlex, ChatterboxTransport]: @@ -99,15 +101,6 @@ def test_position_reads_save_position_result(self): finally: asyncio.run(flex.stop()) - def test_position_default_saved_position(self): - flex, _transport = _flex_with_gripper() - asyncio.run(flex.setup()) - try: - position = asyncio.run(_head(flex).position()) - self.assertEqual(position, Coordinate(100.0, 100.0, 100.0)) - finally: - asyncio.run(flex.stop()) - class TestHeadMoveTo(unittest.TestCase): """move_to fills unspecified axes from the current position and sends ONE @@ -322,6 +315,48 @@ def test_minimum_release_passes(self): finally: asyncio.run(flex.stop()) + def test_double_digit_major_passes(self): + # A lexicographic comparison would put "10.0.0" below "8.2.0". + flex, transport = _flex_with_version("10.0.0") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_two_part_version_passes(self): + # "8.2" pads to (8, 2, 0), equal to the minimum, not below it. + flex, transport = _flex_with_version("8.2") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_patch_release_below_minimum_rejected(self): + flex, transport = _flex_with_version("8.1.9") + asyncio.run(flex.setup()) + try: + with self.assertRaises(OpentronsError): + asyncio.run(_gripper(flex).open_jaw()) + self._assert_no_robot_commands(transport) + finally: + asyncio.run(flex.stop()) + + def test_unparseable_version_rejected(self): + # A version the gate cannot parse must raise, not silently pass. + flex, transport = _flex_with_version("unknown") + asyncio.run(flex.setup()) + try: + with self.assertRaises(OpentronsError) as ctx: + asyncio.run(_gripper(flex).open_jaw()) + self.assertIn("unknown", str(ctx.exception)) + self._assert_no_robot_commands(transport) + finally: + asyncio.run(flex.stop()) + def test_dev_build_passes(self): flex, transport = _flex_with_version("0.0.0.dev0") asyncio.run(flex.setup()) @@ -354,5 +389,80 @@ def test_unknown_version_raises(self): _require_robot_commands("robot/moveTo", None) +class TestUntestedHardwareWarnings(unittest.TestCase): + """Hardware-verification coverage is op-scoped: FlexHead8's verified + column-pickup lineage never warns; every other head or gripper op logs the + one-time untested-hardware notice, naming the op.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def _flex_head8(self) -> Tuple[OpentronsFlex, FlexHead8]: + transport = ChatterboxTransport(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, head + + def test_head8_verified_pickup_does_not_warn(self): + flex, head = self._flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + with self.assertRaises(AssertionError): + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING"): + asyncio.run(head.pick_up_tips(rack, column=0)) + finally: + asyncio.run(flex.stop()) + + def test_head8_op_outside_verified_lineage_warns_once(self): + flex, head = self._flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(head.pick_up_tips(rack, column=0)) + + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING") as log_ctx: + asyncio.run(head.blow_out()) + self.assertTrue(any("FlexHead8.blow_out" in msg for msg in log_ctx.output)) + self.assertTrue(any("not yet verified" in msg.lower() for msg in log_ctx.output)) + + # Only the FIRST unverified op on an instance logs. + with self.assertRaises(AssertionError): + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING"): + asyncio.run(head.blow_out()) + finally: + asyncio.run(flex.stop()) + + def test_base_motion_ops_warn_on_unverified_heads(self): + flex, _transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING") as log_ctx: + asyncio.run(_head(flex).position()) + self.assertTrue(any("FlexHead1.position" in msg for msg in log_ctx.output)) + finally: + asyncio.run(flex.stop()) + + def test_gripper_ops_warn_once(self): + flex, _transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + gripper = _gripper(flex) + with self.assertLogs("pylabrobot.opentrons.flex_gripper", level="WARNING") as log_ctx: + asyncio.run(gripper.move_to(1.0, 2.0, 3.0)) + self.assertTrue(any("FlexGripper.move_to" in msg for msg in log_ctx.output)) + + with self.assertRaises(AssertionError): + with self.assertLogs("pylabrobot.opentrons.flex_gripper", level="WARNING"): + asyncio.run(gripper.open_jaw()) + finally: + asyncio.run(flex.stop()) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/opentrons/labware_definitions.py b/pylabrobot/opentrons/labware_definitions.py index e753d043ec1..02ccdc50257 100644 --- a/pylabrobot/opentrons/labware_definitions.py +++ b/pylabrobot/opentrons/labware_definitions.py @@ -7,42 +7,84 @@ ``POST /runs/{run_id}/labware_definitions`` and then loads the labware by the uploaded definition's ``namespace``/``loadName``/``version``. -Frame conversion: PLR anchors labware at the front-left-bottom corner of a -slot while Opentrons anchors it at the back-left-bottom, so -``cornerOffsetFromSlot.y`` is ``86 - size_y`` (86 mm is the Opentrons slot -depth): labware shallower than the slot sits against the slot's back edge. -Well positions are front-left-bottom based in both frames and carry over -directly. +Frame note: PLR and Opentrons schema-2 definitions both anchor labware at the +front-left-bottom corner of the slot (every shipped Opentrons definition +carries ``cornerOffsetFromSlot`` of zero), so PLR geometry carries over +directly -- well positions included. """ +import hashlib import re from typing import Optional, cast -from pylabrobot.resources import Container, Coordinate, Plate, Resource, TipRack +from pylabrobot.resources import ( + Container, + Coordinate, + CrossSectionType, + Plate, + Resource, + TipRack, + Well, + WellBottomType, +) from pylabrobot.utils import reshape_2d _NAMESPACE = "pylabrobot" _VERSION = 1 _SCHEMA_VERSION = 2 -_OT_SLOT_SIZE_Y = 86 + +_WELL_BOTTOM_SHAPES = { + WellBottomType.FLAT: "flat", + WellBottomType.U: "u", + WellBottomType.V: "v", + WellBottomType.UNKNOWN: "flat", +} def _definition_load_name(resource: Resource) -> str: - """Opentrons load names must match ``^[a-z0-9._]+$``; PLR names are unrestricted.""" - return re.sub(r"[^a-z0-9._]", "_", resource.name.lower()) + """Sanitized resource name plus a short digest of the raw name. + + Opentrons load names must match ``^[a-z0-9._]+$`` while PLR names are + unrestricted, so distinct names can sanitize identically; the digest keeps + their definitions from silently sharing one ``definitionUri``. + """ + sanitized = re.sub(r"[^a-z0-9._]", "_", resource.name.lower()) + digest = hashlib.sha1(resource.name.encode()).hexdigest()[:6] + return f"{sanitized}_{digest}" + + +def _format_from_grid(num_items_x: int, num_items_y: int) -> str: + """The SBS formats the robot-server recognizes; anything else is irregular.""" + if (num_items_x, num_items_y) == (12, 8): + return "96Standard" + if (num_items_x, num_items_y) == (24, 16): + return "384Standard" + return "irregular" + + +def _well_shape(well: Well) -> dict: + if well.cross_section_type == CrossSectionType.RECTANGLE: + return { + "shape": "rectangular", + "xDimension": well.get_absolute_size_x(), + "yDimension": well.get_absolute_size_y(), + } + return {"shape": "circular", "diameter": well.get_absolute_size_x()} def build_plate_definition(plate: Plate, grip_distance_from_top: Optional[float] = None) -> dict: """Build a robot-server wellPlate definition from a PLR plate's geometry. - Wells carry their real depth and volume so well-referencing commands - (``touchTip``, ``liquidProbe``) get the true geometry. Wells are keyed by - their PLR child identifier ("A1" style), matching the ``wellName`` the - pipetting commands send. ``gripHeightFromLabwareBottom`` is included only - when ``grip_distance_from_top`` is given; without it the robot-server grips - at its default mid-height. + Wells carry their real depth, volume, cross-section (circular or + rectangular) and bottom shape so well-referencing commands (``touchTip``, + ``liquidProbe``) get the true geometry. Wells are keyed by their PLR child + identifier ("A1" style), matching the ``wellName`` the pipetting commands + send. ``gripHeightFromLabwareBottom`` is included only when + ``grip_distance_from_top`` is given; without it the robot-server grips at + its default mid-height. """ - well_names = [plate.get_child_identifier(well) for well in plate.get_all_items()] + wells = plate.get_all_items() + well_names = [plate.get_child_identifier(well) for well in wells] definition: dict = { "schemaVersion": _SCHEMA_VERSION, "version": _VERSION, @@ -54,17 +96,13 @@ def build_plate_definition(plate: Plate, grip_distance_from_top: Optional[float] }, "brand": {"brand": "unknown"}, "parameters": { - "format": "irregular", + "format": _format_from_grid(plate.num_items_x, plate.num_items_y), "isTiprack": False, "loadName": _definition_load_name(plate), "isMagneticModuleCompatible": False, }, "ordering": reshape_2d(well_names, (plate.num_items_x, plate.num_items_y)), - "cornerOffsetFromSlot": { - "x": 0, - "y": _OT_SLOT_SIZE_Y - plate.get_absolute_size_y(), - "z": 0, - }, + "cornerOffsetFromSlot": {"x": 0, "y": 0, "z": 0}, "dimensions": { "xDimension": plate.get_absolute_size_x(), "yDimension": plate.get_absolute_size_y(), @@ -76,13 +114,17 @@ def build_plate_definition(plate: Plate, grip_distance_from_top: Optional[float] "x": cast(Coordinate, well.location).x + well.get_absolute_size_x() / 2, "y": cast(Coordinate, well.location).y + well.get_absolute_size_y() / 2, "z": cast(Coordinate, well.location).z, - "shape": "circular", - "diameter": well.get_absolute_size_x(), "totalLiquidVolume": well.max_volume, + **_well_shape(well), } - for well in plate.get_all_items() + for well in wells }, - "groups": [{"wells": well_names, "metadata": {"wellBottomShape": "flat"}}], + "groups": [ + { + "wells": well_names, + "metadata": {"wellBottomShape": _WELL_BOTTOM_SHAPES[wells[0].bottom_type]}, + } + ], } if grip_distance_from_top is not None: definition["gripHeightFromLabwareBottom"] = max( @@ -112,7 +154,7 @@ def build_tip_rack_definition( }, "brand": {"brand": "unknown"}, "parameters": { - "format": "96Standard", + "format": _format_from_grid(tip_rack.num_items_x, tip_rack.num_items_y), "isTiprack": True, "tipLength": tip.total_tip_length, "tipOverlap": tip.fitting_depth, @@ -120,11 +162,7 @@ def build_tip_rack_definition( "isMagneticModuleCompatible": False, }, "ordering": reshape_2d(spot_names, (tip_rack.num_items_x, tip_rack.num_items_y)), - "cornerOffsetFromSlot": { - "x": 0, - "y": _OT_SLOT_SIZE_Y - tip_rack.get_absolute_size_y(), - "z": 0, - }, + "cornerOffsetFromSlot": {"x": 0, "y": 0, "z": 0}, "dimensions": { "xDimension": tip_rack.get_absolute_size_x(), "yDimension": tip_rack.get_absolute_size_y(), @@ -136,6 +174,8 @@ def build_tip_rack_definition( "x": cast(Coordinate, spot.location).x + spot.get_absolute_size_x() / 2, "y": cast(Coordinate, spot.location).y + spot.get_absolute_size_y() / 2, "z": cast(Coordinate, spot.location).z, + # Tip-rack wells stay circular regardless of the PLR cross-section: + # the engine rejects non-circular tip-rack wells (LabwareIsNotTipRackError). "shape": "circular", "diameter": spot.get_absolute_size_x(), "totalLiquidVolume": tip.maximal_volume, @@ -160,17 +200,22 @@ def build_tip_rack_definition( return definition -def build_container_definition(container: Container) -> dict: +def build_container_definition( + container: Container, grip_distance_from_top: Optional[float] = None +) -> dict: """Build a robot-server reservoir definition from a PLR container's geometry. A container (e.g. a trough) is a single cavity, so the definition has one well "A1" whose rectangular footprint spans the whole container, with depth - and volume from the container's geometry. + and volume from the container's geometry. The ``centerMultichannelOnWells`` + quirk matches every shipped Opentrons 1-well reservoir: the engine centers + a multi-channel nozzle array on the cavity itself, so ops send no manual + centering offsets. """ size_x = container.get_absolute_size_x() size_y = container.get_absolute_size_y() size_z = container.get_absolute_size_z() - return { + definition: dict = { "schemaVersion": _SCHEMA_VERSION, "version": _VERSION, "namespace": _NAMESPACE, @@ -182,12 +227,13 @@ def build_container_definition(container: Container) -> dict: "brand": {"brand": "unknown"}, "parameters": { "format": "irregular", + "quirks": ["centerMultichannelOnWells"], "isTiprack": False, "loadName": _definition_load_name(container), "isMagneticModuleCompatible": False, }, "ordering": [["A1"]], - "cornerOffsetFromSlot": {"x": 0, "y": _OT_SLOT_SIZE_Y - size_y, "z": 0}, + "cornerOffsetFromSlot": {"x": 0, "y": 0, "z": 0}, "dimensions": {"xDimension": size_x, "yDimension": size_y, "zDimension": size_z}, "wells": { "A1": { @@ -203,20 +249,26 @@ def build_container_definition(container: Container) -> dict: }, "groups": [{"wells": ["A1"], "metadata": {"wellBottomShape": "flat"}}], } + if grip_distance_from_top is not None: + definition["gripHeightFromLabwareBottom"] = max(0.0, size_z - grip_distance_from_top) + return definition -def build_movable_labware_definition(resource: Resource, grip_distance_from_top: float) -> dict: +def build_movable_labware_definition( + resource: Resource, grip_distance_from_top: Optional[float] = None +) -> dict: """Build a minimal single-well stub definition for gripper moves of any resource. The stub is not pipettable (one fake well, zero depth and volume); it exists so the robot-server can gripper-move labware it has no real definition for. - ``gripHeightFromLabwareBottom`` is load-bearing: without it the robot-server - grips at the z-midpoint and ignores the caller's requested grip distance. + ``gripHeightFromLabwareBottom`` is included only when + ``grip_distance_from_top`` is given; without it the robot-server grips at + its default mid-height. """ size_x = resource.get_absolute_size_x() size_y = resource.get_absolute_size_y() size_z = resource.get_absolute_size_z() - return { + definition: dict = { "schemaVersion": _SCHEMA_VERSION, "version": _VERSION, "namespace": _NAMESPACE, @@ -247,7 +299,6 @@ def build_movable_labware_definition(resource: Resource, grip_distance_from_top: } }, "groups": [{"wells": ["A1"], "metadata": {"wellBottomShape": "flat"}}], - "gripHeightFromLabwareBottom": max(0.0, size_z - grip_distance_from_top), "gripperOffsets": { "default": { "pickUpOffset": {"x": 0, "y": 0, "z": 0}, @@ -255,3 +306,6 @@ def build_movable_labware_definition(resource: Resource, grip_distance_from_top: } }, } + if grip_distance_from_top is not None: + definition["gripHeightFromLabwareBottom"] = max(0.0, size_z - grip_distance_from_top) + return definition diff --git a/pylabrobot/opentrons/labware_definitions_tests.py b/pylabrobot/opentrons/labware_definitions_tests.py index 29150c6b029..02ec28eb76f 100644 --- a/pylabrobot/opentrons/labware_definitions_tests.py +++ b/pylabrobot/opentrons/labware_definitions_tests.py @@ -1,17 +1,17 @@ """Tests for custom Opentrons labware definition building and uploading. Builder-level tests pin the definition content produced from PLR geometry -(dimensions, well positions, the PLR front-left vs Opentrons back-left y-flip, +(dimensions, well positions and shapes, the shared front-left slot anchoring, grip height). Flex-level tests drive ``OpentronsFlex._ensure_labware_loaded`` with an injected ``ChatterboxTransport`` and assert labware without an -official Opentrons definition is uploaded once and then loaded by the -uploaded definition's namespace/loadName/version, while official-name labware -keeps loading with zero uploads. +official Opentrons definition is uploaded and then loaded by the uploaded +definition's namespace/loadName/version, that the run-scoped caches reset +with the run, and that official-name labware keeps loading with zero uploads. """ import asyncio import unittest -from typing import Tuple +from typing import Any, Dict, Optional, Tuple from pylabrobot.opentrons.flex import OpentronsFlex from pylabrobot.opentrons.labware_definitions import ( @@ -20,16 +20,30 @@ build_plate_definition, build_tip_rack_definition, ) -from pylabrobot.opentrons.robot import OpentronsError from pylabrobot.opentrons.transport import ChatterboxTransport -from pylabrobot.resources import Plate, Resource, TipRack, TipSpot, Trough, Well +from pylabrobot.resources import ( + CrossSectionType, + Plate, + Resource, + TipRack, + TipSpot, + Trough, + Well, + WellBottomType, +) from pylabrobot.resources.opentrons.flex_deck import FlexDeck from pylabrobot.resources.tip import Tip from pylabrobot.resources.utils import create_ordered_items_2d -def _plate(name: str = "Black Plate-1") -> Plate: - """A 2x2-well plate with hand-picked geometry so expected numbers are exact.""" +def _plate( + name: str = "Black Plate-1", + num_items_x: int = 2, + num_items_y: int = 2, + cross_section_type: CrossSectionType = CrossSectionType.CIRCLE, + bottom_type: WellBottomType = WellBottomType.UNKNOWN, +) -> Plate: + """A plate with hand-picked geometry so expected numbers are exact.""" return Plate( name=name, size_x=127.0, @@ -37,8 +51,8 @@ def _plate(name: str = "Black Plate-1") -> Plate: size_z=14.0, ordered_items=create_ordered_items_2d( Well, - num_items_x=2, - num_items_y=2, + num_items_x=num_items_x, + num_items_y=num_items_y, dx=10.0, dy=8.0, dz=1.0, @@ -48,12 +62,16 @@ def _plate(name: str = "Black Plate-1") -> Plate: size_y=6.0, size_z=10.0, max_volume=360.0, + cross_section_type=cross_section_type, + bottom_type=bottom_type, ), ) -def _tip_rack(name: str = "hamilton tips 300") -> TipRack: - """A 2x2-spot tip rack with hand-picked geometry and a pinned prototype tip.""" +def _tip_rack( + name: str = "hamilton tips 300", num_items_x: int = 2, num_items_y: int = 2 +) -> TipRack: + """A tip rack with hand-picked geometry and a pinned prototype tip.""" def make_tip(name: str) -> Tip: return Tip( @@ -71,8 +89,8 @@ def make_tip(name: str) -> Tip: size_z=90.0, ordered_items=create_ordered_items_2d( TipSpot, - num_items_x=2, - num_items_y=2, + num_items_x=num_items_x, + num_items_y=num_items_y, dx=10.0, dy=8.0, dz=0.0, @@ -89,6 +107,24 @@ def _trough(name: str = "hamilton trough") -> Trough: return Trough(name=name, size_x=120.0, size_y=80.0, size_z=40.0, max_volume=290000.0) +class TestDefinitionLoadNames(unittest.TestCase): + """Load names are the sanitized PLR name plus a digest of the raw name, so + distinct names that sanitize identically never share a definitionUri.""" + + def test_load_name_is_sanitized_name_plus_digest(self): + definition = build_plate_definition(_plate()) + self.assertEqual(definition["parameters"]["loadName"], "black_plate_1_e2a464") + + def test_colliding_sanitized_names_get_distinct_load_names(self): + a = build_plate_definition(_plate(name="My Plate")) + b = build_plate_definition(_plate(name="my plate")) + self.assertEqual(a["parameters"]["loadName"], "my_plate_3f2f85") + self.assertEqual(b["parameters"]["loadName"], "my_plate_1aa3c7") + self.assertNotEqual(a["parameters"]["loadName"], b["parameters"]["loadName"]) + for definition in (a, b): + self.assertRegex(definition["parameters"]["loadName"], r"^[a-z0-9._]+$") + + class TestBuildPlateDefinition(unittest.TestCase): """build_plate_definition maps PLR plate geometry into a wellPlate definition.""" @@ -99,7 +135,7 @@ def test_identity_and_dimensions(self): self.assertEqual(definition["schemaVersion"], 2) self.assertEqual(definition["metadata"]["displayCategory"], "wellPlate") self.assertEqual(definition["metadata"]["displayName"], "Black Plate-1") - self.assertEqual(definition["parameters"]["loadName"], "black_plate_1") + self.assertEqual(definition["parameters"]["loadName"], "black_plate_1_e2a464") self.assertFalse(definition["parameters"]["isTiprack"]) self.assertEqual( definition["dimensions"], @@ -110,11 +146,11 @@ def test_ordering_is_column_major(self): definition = build_plate_definition(_plate()) self.assertEqual(definition["ordering"], [["A1", "B1"], ["A2", "B2"]]) - def test_corner_offset_y_flip(self): - # PLR anchors at the slot's front-left, Opentrons at the back-left: an - # 80 mm-deep plate in an 86 mm-deep slot sits 6 mm toward the back. + def test_corner_offset_is_zero_front_left_anchor(self): + # PLR and Opentrons schema-2 definitions both anchor labware at the + # slot's front-left-bottom corner, so no frame conversion applies. definition = build_plate_definition(_plate()) - self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 6.0, "z": 0}) + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) def test_well_geometry_carries_depth_volume_and_centers(self): definition = build_plate_definition(_plate()) @@ -135,6 +171,36 @@ def test_well_geometry_carries_depth_volume_and_centers(self): self.assertEqual(definition["wells"]["B1"]["y"], 11.0) self.assertEqual(definition["groups"][0]["wells"], ["A1", "B1", "A2", "B2"]) + def test_rectangular_wells_carry_x_y_dimensions(self): + definition = build_plate_definition(_plate(cross_section_type=CrossSectionType.RECTANGLE)) + well = definition["wells"]["A1"] + self.assertEqual(well["shape"], "rectangular") + self.assertEqual(well["xDimension"], 6.0) + self.assertEqual(well["yDimension"], 6.0) + self.assertNotIn("diameter", well) + + def test_well_bottom_shape_maps_from_plr_bottom_type(self): + self.assertEqual( + build_plate_definition(_plate(bottom_type=WellBottomType.V))["groups"][0]["metadata"], + {"wellBottomShape": "v"}, + ) + self.assertEqual( + build_plate_definition(_plate(bottom_type=WellBottomType.U))["groups"][0]["metadata"], + {"wellBottomShape": "u"}, + ) + # UNKNOWN falls back to flat, the schema's safest default. + self.assertEqual( + build_plate_definition(_plate())["groups"][0]["metadata"], + {"wellBottomShape": "flat"}, + ) + + def test_format_derives_from_grid(self): + self.assertEqual(build_plate_definition(_plate())["parameters"]["format"], "irregular") + plate_96 = _plate(num_items_x=12, num_items_y=8) + self.assertEqual(build_plate_definition(plate_96)["parameters"]["format"], "96Standard") + plate_384 = _plate(num_items_x=24, num_items_y=16) + self.assertEqual(build_plate_definition(plate_384)["parameters"]["format"], "384Standard") + def test_grip_height_from_grip_distance(self): self.assertNotIn("gripHeightFromLabwareBottom", build_plate_definition(_plate())) definition = build_plate_definition(_plate(), grip_distance_from_top=4.0) @@ -149,15 +215,19 @@ class TestBuildTipRackDefinition(unittest.TestCase): def test_tip_parameters_come_from_prototype_tip(self): definition = build_tip_rack_definition(_tip_rack()) self.assertEqual(definition["metadata"]["displayCategory"], "tipRack") - self.assertEqual(definition["parameters"]["format"], "96Standard") + self.assertEqual(definition["parameters"]["format"], "irregular") # 2x2 is not an SBS grid self.assertTrue(definition["parameters"]["isTiprack"]) self.assertEqual(definition["parameters"]["tipLength"], 50.0) self.assertEqual(definition["parameters"]["tipOverlap"], 8.0) - self.assertEqual(definition["parameters"]["loadName"], "hamilton_tips_300") + self.assertEqual(definition["parameters"]["loadName"], "hamilton_tips_300_0558ff") + + def test_full_rack_format_is_96standard(self): + definition = build_tip_rack_definition(_tip_rack(num_items_x=12, num_items_y=8)) + self.assertEqual(definition["parameters"]["format"], "96Standard") - def test_spot_geometry_and_y_flip(self): + def test_spot_geometry_and_zero_corner_offset(self): definition = build_tip_rack_definition(_tip_rack()) - self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 4.0, "z": 0}) # 86 - 82 + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) self.assertEqual(definition["ordering"], [["A1", "B1"], ["A2", "B2"]]) # A1 spot origin (10, 17, 0), 5 mm square: center (12.5, 19.5). self.assertEqual( @@ -186,9 +256,9 @@ def test_single_a1_cavity_spans_the_container(self): definition = build_container_definition(_trough()) self.assertEqual(definition["namespace"], "pylabrobot") self.assertEqual(definition["metadata"]["displayCategory"], "reservoir") - self.assertEqual(definition["parameters"]["loadName"], "hamilton_trough") + self.assertEqual(definition["parameters"]["loadName"], "hamilton_trough_9544de") self.assertEqual(definition["ordering"], [["A1"]]) - self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 6.0, "z": 0}) # 86 - 80 + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) self.assertEqual( definition["dimensions"], {"xDimension": 120.0, "yDimension": 80.0, "zDimension": 40.0}, @@ -210,6 +280,18 @@ def test_single_a1_cavity_spans_the_container(self): ) self.assertEqual(definition["groups"][0]["wells"], ["A1"]) + def test_center_multichannel_quirk_matches_shipped_reservoirs(self): + # Every shipped Opentrons 1-well reservoir carries this quirk; the engine + # centers a multi-channel nozzle array on the cavity because of it, so + # container ops send no manual centering offsets. + definition = build_container_definition(_trough()) + self.assertEqual(definition["parameters"]["quirks"], ["centerMultichannelOnWells"]) + + def test_grip_height_from_grip_distance(self): + self.assertNotIn("gripHeightFromLabwareBottom", build_container_definition(_trough())) + definition = build_container_definition(_trough(), grip_distance_from_top=10.0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 30.0) # 40 - 10 + class TestBuildMovableLabwareDefinition(unittest.TestCase): """build_movable_labware_definition builds the minimal gripper-move stub.""" @@ -218,7 +300,7 @@ def test_stub_has_fake_well_and_grip_geometry(self): resource = Resource(name="lid stack", size_x=100.0, size_y=90.0, size_z=20.0) definition = build_movable_labware_definition(resource, grip_distance_from_top=5.0) self.assertEqual(definition["namespace"], "pylabrobot") - self.assertEqual(definition["parameters"]["loadName"], "lid_stack") + self.assertEqual(definition["parameters"]["loadName"], "lid_stack_2196eb") self.assertEqual(definition["ordering"], [["A1"]]) self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) self.assertEqual( @@ -244,14 +326,23 @@ def test_stub_has_fake_well_and_grip_geometry(self): }, ) + def test_grip_height_omitted_without_grip_distance(self): + resource = Resource(name="lid stack", size_x=100.0, size_y=90.0, size_z=20.0) + definition = build_movable_labware_definition(resource) + self.assertNotIn("gripHeightFromLabwareBottom", definition) + def test_grip_height_clamped_at_labware_bottom(self): resource = Resource(name="shim", size_x=10.0, size_y=10.0, size_z=3.0) definition = build_movable_labware_definition(resource, grip_distance_from_top=7.0) self.assertEqual(definition["gripHeightFromLabwareBottom"], 0.0) -def _flex_with_transport() -> Tuple[OpentronsFlex, ChatterboxTransport]: - transport = ChatterboxTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right") +def _flex_with_transport( + transport: Optional[ChatterboxTransport] = None, +) -> Tuple[OpentronsFlex, ChatterboxTransport]: + transport = transport or ChatterboxTransport( + pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right" + ) flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) return flex, transport @@ -260,6 +351,42 @@ def _load_labware_commands(transport: ChatterboxTransport) -> list: return [c for c in transport.commands if c["commandType"] == "loadLabware"] +class _FailFirstUploadTransport(ChatterboxTransport): + """Chatterbox whose FIRST labware-definition upload raises; retries succeed.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._upload_failed_once = False + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if path.endswith("/labware_definitions") and not self._upload_failed_once: + self._upload_failed_once = True + raise RuntimeError("simulated definition upload failure") + return await super().post(path, json) + + +class _FailFirstLoadTransport(ChatterboxTransport): + """Chatterbox whose FIRST loadLabware command fails at the robot; retries succeed.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._load_failed_once = False + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if ( + path.endswith("/commands") + and data.get("commandType") == "loadLabware" + and not self._load_failed_once + ): + self._load_failed_once = True + cmd_data = result["data"] + cmd_data["status"] = "failed" + cmd_data["error"] = {"detail": "simulated loadLabware failure"} + return result + + class TestCustomLabwareLoadFlow(unittest.TestCase): """_ensure_labware_loaded uploads a definition for labware with no official name.""" @@ -281,7 +408,7 @@ def test_plate_without_official_name_uploads_then_loads(self): self.assertEqual(params["loadName"], definition["parameters"]["loadName"]) self.assertEqual(params["version"], definition["version"]) self.assertEqual(params["namespace"], "pylabrobot") - self.assertEqual(params["loadName"], "black_plate_1") + self.assertEqual(params["loadName"], "black_plate_1_e2a464") self.assertEqual(params["version"], 1) self.assertEqual(params["location"], {"slotName": "C1"}) finally: @@ -302,7 +429,10 @@ def test_second_use_hits_cache_no_second_upload_or_load(self): finally: asyncio.run(flex.stop()) - def test_reload_after_off_deck_reuses_uploaded_definition(self): + def test_reload_after_off_deck_reuploads_definition(self): + # The definition-identity cache is evicted with the departed labware: a + # different same-named resource re-added later must not inherit the old + # geometry, so the re-add re-uploads. flex, transport = _flex_with_transport() asyncio.run(flex.setup()) try: @@ -310,17 +440,82 @@ def test_reload_after_off_deck_reuses_uploaded_definition(self): flex.deck.assign_child_at_slot(plate, "C1") asyncio.run(flex._ensure_labware_loaded(plate)) asyncio.run(flex.labware_moved_off_deck(plate)) + self.assertNotIn(plate.name, flex._defined_labware) + flex.deck.assign_child_at_slot(plate, "D2") asyncio.run(flex._ensure_labware_loaded(plate)) - # A fresh loadLabware at the new slot, but the definition uploads once per run. - self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(len(transport.labware_definitions), 2) load_cmds = _load_labware_commands(transport) self.assertEqual(len(load_cmds), 2) self.assertEqual(load_cmds[1]["params"]["location"], {"slotName": "D2"}) finally: asyncio.run(flex.stop()) + def test_second_setup_clears_run_scoped_caches(self): + # labwareIds and uploaded definitions are both run-scoped server-side, so + # a new run (new setup) must re-upload and re-load. + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertEqual(len(transport.labware_definitions), 1) + + asyncio.run(flex.setup()) # new run + self.assertEqual(flex._loaded_labware, {}) + self.assertEqual(flex._defined_labware, {}) + + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertEqual(len(transport.labware_definitions), 2) + self.assertEqual(len(_load_labware_commands(transport)), 2) + finally: + asyncio.run(flex.stop()) + + def test_failed_upload_leaves_caches_clean_and_retry_works(self): + flex, transport = _flex_with_transport( + _FailFirstUploadTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right") + ) + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + + with self.assertRaises(RuntimeError): + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertNotIn(plate.name, flex._defined_labware) + self.assertNotIn(plate.name, flex._loaded_labware) + + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(len(_load_labware_commands(transport)), 1) + self.assertIn(plate.name, flex._loaded_labware) + finally: + asyncio.run(flex.stop()) + + def test_failed_load_leaves_no_labware_id_and_retry_reuses_definition(self): + flex, transport = _flex_with_transport( + _FailFirstLoadTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right") + ) + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + + with self.assertRaises(RuntimeError): + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertNotIn(plate.name, flex._loaded_labware) + + asyncio.run(flex._ensure_labware_loaded(plate)) + # The upload succeeded the first time, so the retry re-loads without a + # duplicate upload. + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(len(_load_labware_commands(transport)), 2) + self.assertIn(plate.name, flex._loaded_labware) + finally: + asyncio.run(flex.stop()) + def test_official_name_labware_loads_with_zero_uploads(self): flex, transport = _flex_with_transport() asyncio.run(flex.setup()) @@ -353,7 +548,7 @@ def test_container_uploads_single_cavity_definition(self): self.assertEqual(list(definition["wells"]), ["A1"]) params = _load_labware_commands(transport)[0]["params"] self.assertEqual(params["namespace"], "pylabrobot") - self.assertEqual(params["loadName"], "hamilton_trough") + self.assertEqual(params["loadName"], "hamilton_trough_9544de") finally: asyncio.run(flex.stop()) @@ -368,19 +563,27 @@ def test_tip_rack_without_official_name_uploads_tiprack_definition(self): self.assertEqual(len(transport.labware_definitions), 1) self.assertTrue(transport.labware_definitions[0]["parameters"]["isTiprack"]) params = _load_labware_commands(transport)[0]["params"] - self.assertEqual(params["loadName"], "hamilton_tips_300") + self.assertEqual(params["loadName"], "hamilton_tips_300_0558ff") finally: asyncio.run(flex.stop()) - def test_unbuildable_resource_still_raises_loudly(self): + def test_bare_resource_uploads_movable_stub(self): + # A resource that is not a Plate/TipRack/Container routes to the + # non-pipettable movable stub so the gripper can still move it. flex, transport = _flex_with_transport() asyncio.run(flex.setup()) try: widget = Resource(name="widget", size_x=100.0, size_y=90.0, size_z=20.0) flex.deck.assign_child_at_slot(widget, "C1") - with self.assertRaises(OpentronsError): - asyncio.run(flex._ensure_labware_loaded(widget)) - self.assertEqual(len(transport.labware_definitions), 0) - self.assertEqual(len(_load_labware_commands(transport)), 0) + asyncio.run(flex._ensure_labware_loaded(widget, grip_distance_from_top=5.0)) + + self.assertEqual(len(transport.labware_definitions), 1) + definition = transport.labware_definitions[0] + self.assertEqual(definition["ordering"], [["A1"]]) + self.assertEqual(definition["wells"]["A1"]["depth"], 0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 15.0) # 20 - 5 + params = _load_labware_commands(transport)[0]["params"] + self.assertEqual(params["namespace"], "pylabrobot") + self.assertEqual(params["loadName"], "widget_ff700e") finally: asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/robot.py b/pylabrobot/opentrons/robot.py index f8ab0f39343..b02811e04c3 100644 --- a/pylabrobot/opentrons/robot.py +++ b/pylabrobot/opentrons/robot.py @@ -16,6 +16,25 @@ def __init__(self, title: str, message: Optional[str] = None) -> None: super().__init__(f"{title}: {message}" if message else title) +class OpentronsCommandError(RuntimeError): + """A robot-server command completed with status "failed". + + Carries the command's error payload (the robot-server's ErrorOccurrence + dict) so callers can react to defined errors by ``error_type`` (e.g. + ``"liquidNotFound"``) instead of parsing the message string. + """ + + def __init__(self, command_type: str, error: Dict[str, Any]) -> None: + super().__init__(f"Opentrons command '{command_type}' failed: {error.get('detail', error)}") + self.command_type = command_type + self.error = error + + @property + def error_type(self) -> Optional[str]: + """The machine-readable error identifier, e.g. "liquidNotFound".""" + return cast(Optional[str], self.error.get("errorType")) + + @dataclass class PipetteInfo: mount: str @@ -185,7 +204,8 @@ async def _execute_command( The completed command data dict (includes "result" field). Raises: - RuntimeError: If the command fails or times out. + OpentronsCommandError: If the command reports status "failed". + RuntimeError: If the command times out. """ assert self.run_id is not None, "No active run. Call create_run() first." payload = { @@ -214,10 +234,7 @@ async def _execute_command( if status == "succeeded": return cmd_data elif status == "failed": - error = cmd_data.get("error", {}) - raise RuntimeError( - f"Opentrons command '{command_type}' failed: {error.get('detail', error)}" - ) + raise OpentronsCommandError(command_type, cmd_data.get("error", {})) await asyncio.sleep(0.2) raise RuntimeError(f"Opentrons command '{command_type}' timed out after {timeout}s") diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index 7dbd5c1d2eb..08b197a26f0 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -107,6 +107,7 @@ def __init__( simulate_failed_pickup: bool = False, simulate_stuck_tip: bool = False, liquid_probe_z: Optional[float] = None, + simulate_liquid_probe_not_found: bool = False, gripper: bool = False, saved_position: Optional[Dict[str, float]] = None, ) -> None: @@ -137,6 +138,11 @@ def __init__( command reports as ``z_position`` in its result. Default None: the key is omitted from the result entirely (not set to null), matching the real robot-server's shape when no liquid is found. + simulate_liquid_probe_not_found: if True, a ``liquidProbe`` command FAILS + with the engine's defined "liquidNotFound" error -- the real-hardware + behavior when no liquid is detected -- instead of succeeding with the + ``z_position`` key absent. ``tryLiquidProbe`` is unaffected: it + genuinely succeeds with the key absent. Default False. gripper: if True, ``/instruments`` also reports a gripper on the extension mount, so tests can drive gripper discovery. Default False: no gripper mounted (existing behavior). @@ -160,6 +166,7 @@ def __init__( self.simulate_failed_pickup = simulate_failed_pickup self.simulate_stuck_tip = simulate_stuck_tip self.liquid_probe_z = liquid_probe_z + self.simulate_liquid_probe_not_found = simulate_liquid_probe_not_found self.saved_position = saved_position # Per-mount simulated hardware tip-presence sensor state (Flex reports # ONE bool per pipette, not per nozzle -- see /instruments below). @@ -253,6 +260,20 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s pos = self.saved_position or {"x": 100.0, "y": 100.0, "z": 100.0} result = {"position": dict(pos)} cmd_data = {"id": cmd_id, "commandType": ctype, "status": "succeeded", "result": result} + if ctype == "liquidProbe" and self.simulate_liquid_probe_not_found: + # The real robot-server fails the command with a defined + # ErrorOccurrence when the probe finds no liquid. + cmd_data = { + "id": cmd_id, + "commandType": ctype, + "status": "failed", + "error": { + "errorType": "liquidNotFound", + "errorCode": "2017", + "detail": "No liquid detected during the liquid probe process.", + "isDefined": True, + }, + } self._cmds[cmd_id] = cmd_data self._log("Chatterbox: %s %s", ctype, params) return {"data": cmd_data} From e9538ea52f430a48d6e063e905749267c7902d75 Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 14:30:49 -0400 Subject: [PATCH 06/36] fix(opentrons): well-position clearance, rotated-cavity guard, and stub-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 --- pylabrobot/opentrons/flex.py | 100 +++++++++-- pylabrobot/opentrons/flex_container_tests.py | 142 ++++++++++++++- .../opentrons/flex_fine_pipetting_tests.py | 167 +++++++++++++++++- pylabrobot/opentrons/flex_gripper.py | 18 +- pylabrobot/opentrons/flex_gripper_tests.py | 49 +++++ pylabrobot/opentrons/flex_head.py | 126 ++++++++----- pylabrobot/opentrons/labware_definitions.py | 17 +- .../opentrons/labware_definitions_tests.py | 158 ++++++++++++++++- 8 files changed, 702 insertions(+), 75 deletions(-) diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 25e78ac8a2a..12a14f5c33d 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -1,6 +1,6 @@ import logging import uuid -from typing import Any, Dict, List, Optional, Tuple, Type, cast +from typing import Any, Dict, List, Optional, Set, Tuple, Type, cast from pylabrobot.opentrons.flex_gripper import FlexGripper, _slot_wire_location from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead @@ -40,6 +40,33 @@ } +def _has_pipettable_geometry(resource: Resource) -> bool: + """Whether a real, well-bearing definition can be built from this resource.""" + return isinstance(resource, (Plate, TipRack, Container)) + + +def _not_pipettable_error(resource: Resource) -> OpentronsError: + """The refusal for a resource no pipettable definition can be built from.""" + return OpentronsError( + "Cannot build an Opentrons labware definition", + f"'{resource.name}' ({type(resource).__name__}) has no Opentrons load name, and a " + "definition can only be built from the geometry of a Plate, TipRack, or Container. " + "Set resource.ot_load_name to an official Opentrons load name. The gripper can still " + "move it -- only pipetting needs real well geometry.", + ) + + +def _warn_grip_distance_discarded( + name: str, grip_distance_from_top: Optional[float], why: str +) -> None: + """Log a caller's grip distance being dropped, naming why it cannot apply.""" + if grip_distance_from_top is None: + return + logger.warning( + "grip_distance_from_top=%s ignored for '%s': %s.", grip_distance_from_top, name, why + ) + + class OpentronsFlex(OpentronsRobot): """Opentrons Flex liquid handler (plain class, post-#1180 architecture). @@ -61,6 +88,9 @@ def __init__( self._loaded_labware: Dict[str, str] = {} # resource.name -> (namespace, load_name, version) of an uploaded custom definition. self._defined_labware: Dict[str, Tuple[str, str, int]] = {} + # Names loaded under the non-pipettable movable stub. Tracked separately so + # a pipetting op refuses them even on a load-cache hit. + self._stub_labware: Set[str] = set() self.left: Optional[_FlexHead] = None self.right: Optional[_FlexHead] = None self.head96: Optional[_FlexHead] = None @@ -73,6 +103,7 @@ async def _create_run(self) -> str: run_id = await super()._create_run() self._loaded_labware.clear() self._defined_labware.clear() + self._stub_labware.clear() return run_id async def _model_setup(self) -> None: @@ -161,17 +192,39 @@ async def stop(self) -> None: await super().stop() # homes the gantry, then cancels the run + disconnects async def _ensure_labware_loaded( - self, resource: Resource, grip_distance_from_top: Optional[float] = None + self, + resource: Resource, + *, + allow_stub: bool = False, + grip_distance_from_top: Optional[float] = None, ) -> str: """Load labware into the Flex run if not already loaded. + ``allow_stub`` opts into the non-pipettable movable stub definition for a + resource no real definition can be built from (a lid, an adapter, a tube + rack). Only the gripper passes it: the stub's single fake well is + somewhere to grip, not somewhere to pipette, and a zero-depth well at the + labware's own bottom would put a tip on the deck. Every other caller gets + an ``OpentronsError`` for such a resource, before any wire command -- + including when the gripper already loaded it earlier in the run. + ``grip_distance_from_top`` feeds an uploaded custom definition's grip height and is honored on the FIRST load only; a cache hit (already loaded, or definition already uploaded) reuses the stored identity - unchanged. + unchanged, and labware resolving to an official Opentrons load name + ignores it entirely (the catalogue definition owns the grip height). + Every discard is logged. """ name = getattr(resource, "name", str(resource)) + if not allow_stub and name in self._stub_labware: + raise _not_pipettable_error(resource) if name in self._loaded_labware: + _warn_grip_distance_discarded( + name, + grip_distance_from_top, + "it is already loaded in this run, and the grip height rides the definition it " + "was loaded with", + ) return self._loaded_labware[name] slot = self.deck.get_slot(resource) @@ -183,12 +236,19 @@ async def _ensure_labware_loaded( try: load_name = self._ot_load_name(resource) - namespace, version = _OT_NAMESPACE, _OT_VERSION except OpentronsError: # No official Opentrons definition: build one from the resource's PLR # geometry, upload it, and load by the uploaded definition's identity. namespace, load_name, version = await self._define_custom_labware( - resource, grip_distance_from_top + resource, grip_distance_from_top, allow_stub + ) + else: + namespace, version = _OT_NAMESPACE, _OT_VERSION + _warn_grip_distance_discarded( + name, + grip_distance_from_top, + f"it loads the Opentrons catalogue definition '{load_name}', whose grip height the " + "robot owns", ) labware_id = uuid.uuid4().hex[:12] @@ -239,6 +299,7 @@ async def labware_moved_off_deck(self, resource: Resource) -> None: ) del self._loaded_labware[name] self._defined_labware.pop(name, None) + self._stub_labware.discard(name) slot = self.deck.get_slot(resource) if slot is not None: self.deck.unassign_child_at_slot(slot) @@ -266,7 +327,10 @@ def _ot_load_name(resource: Resource) -> str: ) async def _define_custom_labware( - self, resource: Resource, grip_distance_from_top: Optional[float] = None + self, + resource: Resource, + grip_distance_from_top: Optional[float] = None, + allow_stub: bool = False, ) -> Tuple[str, str, int]: """Upload a geometry-derived definition for labware with no official Opentrons definition. @@ -280,26 +344,36 @@ async def _define_custom_labware( """ name = resource.name if name in self._defined_labware: + _warn_grip_distance_discarded( + name, + grip_distance_from_top, + "its custom definition was already uploaded in this run, with the grip height it " + "carried then", + ) return self._defined_labware[name] - definition = self._build_labware_definition(resource, grip_distance_from_top) + definition = self._build_labware_definition(resource, grip_distance_from_top, allow_stub) assert self.run_id is not None, "No active run. Call setup() first." data = await self._post(f"/runs/{self.run_id}/labware_definitions", {"data": definition}) uri = cast(str, data["data"]["definitionUri"]) namespace, load_name, version = uri.split("/") self._defined_labware[name] = (namespace, load_name, int(version)) + if not _has_pipettable_geometry(resource): + self._stub_labware.add(name) logger.info("Uploaded custom labware definition for '%s': %s", name, uri) return self._defined_labware[name] @staticmethod def _build_labware_definition( - resource: Resource, grip_distance_from_top: Optional[float] = None + resource: Resource, + grip_distance_from_top: Optional[float] = None, + allow_stub: bool = False, ) -> dict: - """Build the definition matching the resource's type. + """Build the definition matching the resource's type, or raise for unbuildable labware. - Pipettable types get real-geometry definitions; any other resource (lid, - adapter, ...) gets the non-pipettable movable stub so the gripper can - still move it. + Pipettable types get real-geometry definitions. Any other resource (lid, + adapter, tube rack, ...) has no wells the robot can pipette, so it builds + only as the non-pipettable movable stub, which ``allow_stub`` opts into. """ if isinstance(resource, Plate): return build_plate_definition(resource, grip_distance_from_top) @@ -307,4 +381,6 @@ def _build_labware_definition( return build_tip_rack_definition(resource, grip_distance_from_top) if isinstance(resource, Container): return build_container_definition(resource, grip_distance_from_top) + if not allow_stub: + raise _not_pipettable_error(resource) return build_movable_labware_definition(resource, grip_distance_from_top) diff --git a/pylabrobot/opentrons/flex_container_tests.py b/pylabrobot/opentrons/flex_container_tests.py index 43cf9e09eab..84e96006ef2 100644 --- a/pylabrobot/opentrons/flex_container_tests.py +++ b/pylabrobot/opentrons/flex_container_tests.py @@ -27,6 +27,8 @@ from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.opentrons.flex_deck import FlexDeck from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.rotation import Rotation class _FailingAspirateTransport(ChatterboxTransport): @@ -452,13 +454,124 @@ def test_offset_that_keeps_row_inside_cavity_passes(self): aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] self.assertEqual(len(aspirate_cmds), 1) + # A lateral-only offset keeps the default bottom clearance: its z is 0 + # because the caller said nothing about z, not to ask for the floor. self.assertEqual( aspirate_cmds[0]["params"]["wellLocation"]["offset"], - {"x": 0, "y": 4, "z": 0.0}, + {"x": 0, "y": 4, "z": 1.0}, ) finally: asyncio.run(flex.stop()) + def test_zero_offset_keeps_the_default_bottom_clearance(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate.zero())) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"]["offset"], + {"x": 0, "y": 0, "z": 1.0}, + "a no-op offset must not move the tip to the cavity floor", + ) + finally: + asyncio.run(flex.stop()) + + def test_too_small_container_message_names_no_offset_when_none_was_passed(self): + flex, _transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + narrow = _make_trough(name="narrow", size_y=40.0, max_volume=50000.0) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(narrow, "C2") + narrow.tracker.set_volume(10000.0) + asyncio.run(head.pick_up_tips(rack, column=0)) + + with self.assertRaises(OpentronsError) as no_offset: + asyncio.run(head.aspirate_container(narrow, volume=50)) + self.assertNotIn("offset", str(no_offset.exception)) + + with self.assertRaises(OpentronsError) as with_offset: + asyncio.run(head.aspirate_container(narrow, volume=50, offset=Coordinate(y=3))) + self.assertIn("offset", str(with_offset.exception)) + finally: + asyncio.run(flex.stop()) + + def test_rotated_cavity_is_guarded_on_the_axis_the_robot_sees(self): + # 40 x 70 mm rotated a quarter turn is a 70 x 40 mm cavity to the robot, + # which is the footprint the uploaded definition carries. + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + rotated = _make_trough(name="rotated", size_x=40.0, size_y=70.0, max_volume=50000.0) + rotated.rotation = Rotation(z=90) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(rotated, "C2") + rotated.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate_container(rotated, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(rotated.tracker.volume, 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_rotated_cavity_deep_enough_for_the_row_is_accepted(self): + # The mirror case: 110 x 60 mm rotated presents 110 mm front-to-back, so + # the 63 mm row fits and reading the pre-rotation 60 mm would refuse it. + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + rotated = _make_trough(name="rotated", size_x=110.0, size_y=60.0, max_volume=50000.0) + rotated.rotation = Rotation(z=90) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(rotated, "C2") + rotated.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_container(rotated, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + finally: + asyncio.run(flex.stop()) + + def test_rotated_parent_rotates_the_cavity_too(self): + # The guard reads the COMPOSED rotation, so a plain container inside a + # rotated carrier is guarded on the same axis the robot will see. + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + carrier = Resource(name="carrier", size_x=70.0, size_y=40.0, size_z=25.0) + carrier.rotation = Rotation(z=90) + inner = _make_trough(name="inner", size_x=40.0, size_y=70.0, max_volume=50000.0) + carrier.assign_child_resource(inner, location=Coordinate.zero()) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(carrier, "C2") + inner.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError) as raised: + asyncio.run(head.aspirate_container(inner, volume=50)) + + # Name the guard: reading the container's own y (70 mm) would let this + # through, and it would then fail later for an unrelated reason. + self.assertEqual(raised.exception.title, "Container too small") + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + def test_offset_that_shifts_row_past_cavity_wall_rejects_pre_wire(self): flex, transport, head = _flex_head8() try: @@ -639,11 +752,36 @@ def test_offset_that_keeps_grid_inside_cavity_passes(self): self.assertEqual(len(aspirate_cmds), 1) self.assertEqual( aspirate_cmds[0]["params"]["wellLocation"]["offset"], - {"x": 4, "y": 4, "z": 0.0}, + {"x": 4, "y": 4, "z": 1.0}, ) finally: asyncio.run(flex.stop()) + def test_rotated_cavity_is_guarded_on_the_axis_the_robot_sees(self): + # 107 x 71 mm rotated a quarter turn is 71 mm left-to-right to the robot, + # too narrow for the grid's 99 mm x span, though unrotated it fits. + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + rotated = _make_trough(name="rotated") + rotated.rotation = Rotation(z=90) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(rotated, "C2") + rotated.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + for op in ( + lambda: head.aspirate(rotated, volume=50), + lambda: head.dispense(rotated, volume=50), + ): + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(op()) + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(rotated.tracker.volume, 100000.0) + finally: + asyncio.run(flex.stop()) + def test_offset_that_shifts_grid_past_cavity_wall_rejects_pre_wire(self): flex, transport, head = _flex_head96() try: diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index b55cb1437fa..925c6ce36ee 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -7,7 +7,10 @@ ``liquid_probe_z`` kwarg omits the ``z_position`` result key entirely (a succeeding transport's shape), and ``simulate_liquid_probe_not_found`` fails the ``liquidProbe`` command with the engine's defined "liquidNotFound" error -(the real-hardware behavior). +(the real-hardware behavior). Also pins the shared well-position rule the +liquid ops build on -- a caller offset shifts the head from the default +position rather than replacing it -- and the one place that deliberately +does not follow it, ``touch_tip``. """ import asyncio @@ -22,6 +25,7 @@ from pylabrobot.resources import ( biorad_384_wellplate_50uL_Vb, cor_96_wellplate_360uL_Fb, + cor_cos_24_wellplate_3470uL_Fb, set_tip_tracking, set_volume_tracking, ) @@ -612,9 +616,58 @@ def test_column_minus_one_does_not_alias_to_the_last_column(self): finally: asyncio.run(flex.stop()) - def test_384_well_plate_rejects_column_ops_pre_wire(self): - # A 16-row plate cannot be column-addressed by the 8-channel head; the - # old fixed name table raised bare IndexError for column >= 12. + def test_384_well_plate_addresses_two_interleaved_row_sets_per_physical_column(self): + # The nozzles hold their 9 mm pitch, so on a 16-row plate they cover + # every other row: two sets of 8 per physical column, indexed in turn. + flex, transport, head, rack, _plate = self._bench() + try: + plate_384 = biorad_384_wellplate_50uL_Vb(name="plate384") + flex.deck.assign_child_at_slot(plate_384, "C3") + asyncio.run(head.pick_up_tips(rack, column=0)) + + for column, expected in ((0, "A1"), (1, "B1"), (2, "A2"), (47, "B24")): + anchor, items = head._column_anchor_and_items(plate_384, column) + self.assertEqual(anchor, expected, f"column {column}") + self.assertEqual(len(items), 8) + # Column 0 covers the rear-row set the engine's own coverage math + # reports for a full 8-channel configuration anchored at A1. + _anchor, items = head._column_anchor_and_items(plate_384, 0) + self.assertEqual( + [plate_384.get_child_identifier(item) for item in items], + ["A1", "C1", "E1", "G1", "I1", "K1", "M1", "O1"], + ) + _anchor, items = head._column_anchor_and_items(plate_384, 1) + self.assertEqual( + [plate_384.get_child_identifier(item) for item in items], + ["B1", "D1", "F1", "H1", "J1", "L1", "N1", "P1"], + ) + finally: + asyncio.run(flex.stop()) + + def test_384_well_plate_column_op_reaches_the_wire_at_its_anchor_well(self): + flex, transport, head, rack, _plate = self._bench() + try: + plate_384 = biorad_384_wellplate_50uL_Vb(name="plate384") + flex.deck.assign_child_at_slot(plate_384, "C3") + for well in plate_384.get_all_items(): + well.tracker.set_volume(40.0) + asyncio.run(head.pick_up_tips(rack, column=0)) + + asyncio.run(head.aspirate(plate_384, column=3, volume=10)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "B2") + # Only the 8 wells the nozzles actually reach lose liquid. + touched = ["B2", "D2", "F2", "H2", "J2", "L2", "N2", "P2"] + for name in touched: + self.assertAlmostEqual(plate_384.get_item(name).tracker.volume, 30.0, msg=name) + self.assertAlmostEqual(plate_384.get_item("A2").tracker.volume, 40.0) + self.assertAlmostEqual(plate_384.get_item("C2").tracker.volume, 40.0) + finally: + asyncio.run(flex.stop()) + + def test_384_well_plate_rejects_out_of_range_column_pre_wire(self): flex, transport, head, rack, _plate = self._bench() try: plate_384 = biorad_384_wellplate_50uL_Vb(name="plate384") @@ -623,9 +676,9 @@ def test_384_well_plate_rejects_column_ops_pre_wire(self): n_before = len(transport.commands) for op in ( - lambda: head.aspirate(plate_384, column=15, volume=10), - lambda: head.liquid_probe(plate_384, column=15), - lambda: head.touch_tip(plate_384, column=2), + lambda: head.aspirate(plate_384, column=48, volume=10), + lambda: head.liquid_probe(plate_384, column=100), + lambda: head.touch_tip(plate_384, column=-1), ): with self.assertRaises(ValueError): asyncio.run(op()) @@ -633,6 +686,106 @@ def test_384_well_plate_rejects_column_ops_pre_wire(self): finally: asyncio.run(flex.stop()) + def test_row_count_that_is_not_a_multiple_of_eight_still_rejects_pre_wire(self): + # A 4-row (24-well) plate has no set of 8 evenly spaced rows, so the + # narrowing that protects hardware survives the 384 widening. + flex, transport, head, rack, _plate = self._bench() + try: + plate_24 = cor_cos_24_wellplate_3470uL_Fb(name="plate24") + flex.deck.assign_child_at_slot(plate_24, "C3") + asyncio.run(head.pick_up_tips(rack, column=0)) + + n_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(head.touch_tip(plate_24, column=0)) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestWellPositionOffsets(unittest.TestCase): + """A caller offset SHIFTS the head from the default liquid position (1 mm + above the well bottom, or ``liquid_height`` above it), never replaces it: + a Coordinate carries z=0 when the caller only meant to nudge x/y, so a + replacing offset would put the tip on the well floor.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + asyncio.run(head.pick_up_tips(rack, column=0)) + return flex, transport, head, plate + + def _aspirate_well_location(self, transport: ChatterboxTransport) -> dict: + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + well_location: dict = aspirate_cmds[0]["params"]["wellLocation"] + return well_location + + def test_lateral_offset_on_a_plate_column_keeps_the_bottom_clearance(self): + flex, transport, head, plate = self._bench() + try: + asyncio.run(head.aspirate(plate, column=0, volume=10, offset=Coordinate(x=1, y=2))) + self.assertEqual( + self._aspirate_well_location(transport), + {"origin": "bottom", "offset": {"x": 1, "y": 2, "z": 1.0}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_zero_offset_keeps_the_bottom_clearance(self): + flex, transport, head, plate = self._bench() + try: + asyncio.run(head.aspirate(plate, column=0, volume=10, offset=Coordinate.zero())) + self.assertEqual( + self._aspirate_well_location(transport), + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_liquid_height_is_the_base_the_offset_rides_on(self): + # liquid_height is already measured from the bottom, so it replaces the + # clearance as the base; the offset still adds on top of whichever base. + flex, transport, head, plate = self._bench() + try: + asyncio.run( + head.aspirate(plate, column=0, volume=10, offset=Coordinate(x=2, z=0.5), liquid_height=3) + ) + self.assertEqual( + self._aspirate_well_location(transport), + {"origin": "bottom", "offset": {"x": 2, "y": 0, "z": 3.5}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_offset_replaces_the_default_touch_height(self): + # touch_tip's z IS the touch height, mirroring the Opentrons Python API's + # absolute v_offset; dropping the default moves the tip up, not down. + flex, transport, head, plate = self._bench() + try: + asyncio.run(head.touch_tip(plate, column=0, offset=Coordinate(x=1))) + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + self.assertEqual( + touch_cmds[0]["params"]["wellLocation"], + {"origin": "top", "offset": {"x": 1, "y": 0, "z": 0}}, + ) + finally: + asyncio.run(flex.stop()) + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/opentrons/flex_gripper.py b/pylabrobot/opentrons/flex_gripper.py index 249e364d6f4..5a3c0d2b9f4 100644 --- a/pylabrobot/opentrons/flex_gripper.py +++ b/pylabrobot/opentrons/flex_gripper.py @@ -154,11 +154,17 @@ async def move_labware( resource: A resource currently placed on the deck. to_slot: Destination slot, e.g. ``"C2"`` (standard) or ``"B4"`` (staging). grip_distance_from_top: How far below the labware's top the paddles - grab (mm), baked into an uploaded custom definition's grip height. - Honored on the labware's FIRST load in the run only; once the robot - holds a definition for it, later values are ignored. ``None`` keeps - the definition's grip height (the robot's mid-height default for - custom definitions built without one). + grab (mm), baked into the grip height of a custom definition + pylabrobot uploads. It therefore applies ONLY to labware pylabrobot + uploads a definition for: a resource resolving to an official + Opentrons load name (``ot_load_name`` set, a standard tip-rack name, + or a name starting with "opentrons_") loads the catalogue definition + instead, which carries the vendor's own grip height, and this value + is ignored. Honored on the labware's FIRST load in the run only; + once the robot holds a definition for it, later values are ignored + too. Every ignored value is logged. ``None`` keeps the definition's + grip height (the robot's mid-height default for custom definitions + built without one). Raises: OpentronsError: If the resource is not on the deck, or ``to_slot`` is @@ -188,7 +194,7 @@ async def move_labware( ) labware_id = await self.flex._ensure_labware_loaded( - resource, grip_distance_from_top=grip_distance_from_top + resource, allow_stub=True, grip_distance_from_top=grip_distance_from_top ) await self.flex._execute_command( "moveLabware", diff --git a/pylabrobot/opentrons/flex_gripper_tests.py b/pylabrobot/opentrons/flex_gripper_tests.py index c65d67564e7..1551d0471ba 100644 --- a/pylabrobot/opentrons/flex_gripper_tests.py +++ b/pylabrobot/opentrons/flex_gripper_tests.py @@ -203,6 +203,55 @@ def test_move_bare_resource_uploads_stub_with_grip_geometry(self): asyncio.run(flex.stop()) +class TestGripDistanceDiscarded(unittest.TestCase): + """grip_distance_from_top only shapes a definition pylabrobot uploads, so + the paths that cannot honor it say so instead of dropping it silently.""" + + def test_catalogue_labware_logs_the_ignored_grip_distance(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() # resolves to an official Opentrons load name + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + with self.assertLogs("pylabrobot.opentrons.flex", level="WARNING") as logs: + asyncio.run(gripper.move_labware(plate, "C2", grip_distance_from_top=5.0)) + + # Nothing is uploaded, so there is nowhere to put the requested height: + # the robot grips at the catalogue definition's own. + self.assertEqual(len(transport.labware_definitions), 0) + load_cmds = [c for c in transport.commands if c["commandType"] == "loadLabware"] + self.assertEqual(load_cmds[0]["params"]["namespace"], "opentrons") + self.assertTrue(any("grip_distance_from_top=5.0" in line for line in logs.output)) + self.assertTrue( + any("corning_96_wellplate_360ul_flat" in line for line in logs.output), + logs.output, + ) + finally: + asyncio.run(flex.stop()) + + def test_second_move_logs_the_grip_distance_the_loaded_labware_ignores(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + lid = Resource(name="lid stack", size_x=100.0, size_y=90.0, size_z=20.0) + flex.deck.assign_child_at_slot(lid, "C1") + gripper = flex.gripper + assert gripper is not None + asyncio.run(gripper.move_labware(lid, "C2", grip_distance_from_top=5.0)) + + with self.assertLogs("pylabrobot.opentrons.flex", level="WARNING") as logs: + asyncio.run(gripper.move_labware(lid, "C3", grip_distance_from_top=9.0)) + + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(transport.labware_definitions[0]["gripHeightFromLabwareBottom"], 15.0) + self.assertTrue(any("grip_distance_from_top=9.0" in line for line in logs.output)) + finally: + asyncio.run(flex.stop()) + + class TestMoveLabwarePreWireRejections(unittest.TestCase): """Invalid moves raise OpentronsError BEFORE any wire command is sent.""" diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 3493ad45342..fbe6e7de354 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -23,6 +23,7 @@ import logging from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional, Tuple, Union, cast +from pylabrobot.opentrons.labware_definitions import container_cavity_footprint from pylabrobot.opentrons.robot import OpentronsCommandError, OpentronsError from pylabrobot.resources import ( Container, @@ -315,9 +316,17 @@ def _touch_tip_params( ``touchTip`` addresses the height of the wall-touch motion, not a liquid position, so the ``wellLocation`` is TOP-relative: the default touches 1 mm below the rim (the Opentrons Python API's ``v_offset`` default), and - a caller ``offset`` replaces it, also read against the well top. + a caller ``offset`` REPLACES it, also read against the well top. ``radius`` is the fraction of the well radius the tip moves toward (1.0 = the wall). + + Replacing rather than shifting is deliberate, unlike the liquid position + ``_well_location`` builds. This z IS the touch height (the Opentrons + Python API's own ``v_offset``, where a caller's number is likewise + absolute), so shifting would make the same argument mean a different + height here than in that API. And dropping this default moves the tip + UP toward the rim, away from the labware, where dropping the liquid + clearance moves it down onto the well floor. """ o = offset if offset is not None else Coordinate(z=_DEFAULT_TOUCH_TIP_Z_OFFSET) return { @@ -401,28 +410,32 @@ def _well_location( ) -> Optional[dict]: """Build the Flex ``wellLocation`` param from an offset and/or liquid height. - Merges an explicit x/y/z offset with ``liquid_height`` (added to z). + The default liquid position is ``_DEFAULT_WELL_BOTTOM_CLEARANCE`` above + the well bottom, or ``liquid_height`` above it when one is given. A + caller ``offset`` SHIFTS the head from that position rather than + replacing it: a ``Coordinate`` carries a z of 0 when the caller only + meant to nudge x/y, so a replacing offset would silently drop the + clearance and drive the tip onto the well floor. + ``origin`` defaults to ``"bottom"`` (aspirate/dispense); tip-pickup callers must pass ``origin="top"`` -- a tip-rack well's "bottom" is deep - inside the tip, not the pickup engagement point. Returns ``None`` if - neither offset nor liquid height is given. + inside the tip, not the pickup engagement point -- and the "top" origin + carries no clearance of its own, so there the offset is the whole + position. Returns ``None`` on a non-bottom origin when neither offset nor + liquid height is given. """ - offset = None - if offsets is not None and offsets[0] is not None: - o = offsets[0] - offset = {"x": o.x, "y": o.y, "z": o.z} - if liquid_height is not None and liquid_height[0] is not None: - offset = offset or {"x": 0, "y": 0, "z": 0} - offset["z"] += liquid_height[0] - if offset is None: - if origin == "bottom": - # No explicit position given: default to just above the well bottom - # rather than let the Protocol Engine fall back to origin "top" (the - # rim, above the liquid). Pickup callers (origin "top") keep None. - offset = {"x": 0, "y": 0, "z": _DEFAULT_WELL_BOTTOM_CLEARANCE} - else: - return None - return {"origin": origin, "offset": offset} + o = offsets[0] if offsets is not None else None + height = liquid_height[0] if liquid_height is not None else None + # A bottom origin always sends a position: an omitted wellLocation makes + # the Protocol Engine fall back to the rim, above the liquid. + if o is None and height is None and origin != "bottom": + return None + if height is not None: + base_z = height + else: + base_z = _DEFAULT_WELL_BOTTOM_CLEARANCE if origin == "bottom" else 0.0 + x, y, z = (o.x, o.y, o.z) if o is not None else (0.0, 0.0, 0.0) + return {"origin": origin, "offset": {"x": x, "y": y, "z": base_z + z}} # --- Single-cavity container (trough/reservoir) shared helpers --- @@ -468,17 +481,24 @@ def _require_span_fits_container( The engine centers the array on the cavity (the definition's ``centerMultichannelOnWells`` quirk) and the caller's ``offset`` then shifts it, so the shifted span must still fit inside the cavity's - footprint on each axis. + footprint on each axis. The footprint is the deck-frame one the uploaded + definition carries (``container_cavity_footprint``), not the container's + own x/y: a rotated container presents its axes to the robot swapped, and + guarding the pre-rotation axis passes an array that overhangs the real + cavity. """ o = offset if offset is not None else Coordinate.zero() + cavity_x, cavity_y = container_cavity_footprint(container) required_x = x_span + 2 * abs(o.x) required_y = y_span + 2 * abs(o.y) - if required_x > container.get_size_x() or required_y > container.get_size_y(): + if required_x > cavity_x or required_y > cavity_y: + detail = f"The nozzle array spans {x_span} x {y_span} mm" + if o.x or o.y: + detail += f" and the offset ({o.x}, {o.y}) shifts it off-center" raise OpentronsError( "Container too small", - f"The nozzle array spans {x_span} x {y_span} mm and the offset ({o.x}, {o.y}) shifts " - f"it off-center, which does not fit inside '{container.name}' " - f"({container.get_size_x()} x {container.get_size_y()} mm). " + f"{detail}, which does not fit inside '{container.name}' " + f"({cavity_x} x {cavity_y} mm as the robot sees it). " "Aim it at a container that holds the whole array.", ) @@ -828,12 +848,19 @@ async def try_liquid_probe(self, well: Well) -> Optional[float]: class FlexHead8(_FlexHead): """8-channel pipette head, column-addressed (anchor-well fan-out). - Every op sends exactly ONE robot-server command anchored at the column's - A-row well (e.g. column 2 -> wellName "A3"); the Flex hardware fans that - single command out to all 8 physical nozzles. Tip/volume trackers are - committed only for the channels/wells actually actuated, skipping ``None`` - (inactive) channels (None-skip) -- and only after the wire command - succeeds. + Every op sends exactly ONE robot-server command anchored at the rearmost + well the nozzle row covers (on a 96-format plate, column 2 -> wellName + "A3"); the Flex hardware fans that single command out to all 8 physical + nozzles. Tip/volume trackers are committed only for the channels/wells + actually actuated, skipping ``None`` (inactive) channels (None-skip) -- + and only after the wire command succeeds. + + ``column`` indexes the sets of 8 rows the nozzles can cover, which on a + 96-format plate is just the physical columns (0-11). A denser layout has + more than one such set per physical column, since the nozzles skip rows to + hold their 9 mm pitch: a 384 plate takes ``column`` 0-47, where 0 covers + A1/C1/E1/../O1 and 1 covers B1/D1/../P1. See + ``_column_anchor_and_items``. Single-tip cherry-pick (``pick_up_single_tip``/``aspirate_single``/ ``dispense_single``/``drop_single_tip``) switches the pipette to SINGLE @@ -875,27 +902,40 @@ async def _ensure_all_mode(self) -> None: @staticmethod def _column_anchor_and_items(itemized: ItemizedResource, column: int) -> Tuple[str, List[Any]]: - """Validate ``column`` against the labware's real grid; return the A-row - anchor well name plus the 8 column resources (row order A..H). + """Validate ``column`` against the labware's real grid; return the anchor + well name plus the 8 resources the nozzle row covers, rearmost first. Every column op calls this BEFORE any wire command (including ``configureNozzleLayout`` and ``loadLabware``) so a rejected op ships nothing. PLR itemized resources are column-major (item 0 is A1, item 1 is B1, ...), and the anchor name comes from the resource itself rather than a fixed name table, so any column count is addressed safely. + + The 8 nozzles sit at a 9 mm pitch, so on a denser layout they cover + every ``row_stride``-th row rather than adjacent rows: a 16-row (384) + plate has two interleaved sets of 8 (A,C,E,.. and B,D,F,..) per physical + column, matching the engine's own multi-channel coverage math. Those + sets are addressed as consecutive ``column`` indices, so a 384 plate + takes ``column`` 0-47 (physical column ``column // 2``, rear-row set + when even). A row count that is not a multiple of 8 has no such set and + is rejected. """ - if itemized.num_items_y != _NUM_CHANNELS: + rows = itemized.num_items_y + row_stride, remainder = divmod(rows, _NUM_CHANNELS) + if row_stride < 1 or remainder: raise ValueError( - f"'{itemized.name}' has {itemized.num_items_y} rows; 8-channel column ops " - f"require an 8-row layout." + f"'{itemized.name}' has {rows} rows; the 8 nozzles cover 8 evenly spaced rows, " + f"so column ops need a row count that is a multiple of {_NUM_CHANNELS}." ) - num_columns = itemized.num_items_x + num_columns = itemized.num_items_x * row_stride if not 0 <= column < num_columns: raise ValueError( f"Column {column} out of range for resource with {num_columns} columns " f"(0-{num_columns - 1})." ) - column_items = itemized.get_all_items()[column * _NUM_CHANNELS : (column + 1) * _NUM_CHANNELS] + physical_column, row_phase = divmod(column, row_stride) + start = physical_column * rows + row_phase + column_items = itemized.get_all_items()[start : start + rows : row_stride] return itemized.get_child_identifier(column_items[0]), column_items # --- Column tip operations --- @@ -908,7 +948,7 @@ async def pick_up_tips( ) -> None: """Pick up a full column (8 tips) with a single ``pickUpTip`` command. - Anchored at the column's A-row well; the hardware fans the pickup motion + Anchored at the column's rearmost spot; the hardware fans the pickup motion out to all 8 physical nozzles. Follows stage -> validate -> wire -> verify -> commit/rollback: the column index and the double-pickup guard (fix #4) are validated before ANY wire command, tip trackers are staged @@ -1023,7 +1063,7 @@ async def aspirate( offset: Optional[Coordinate] = None, liquid_height: Optional[float] = None, ) -> None: - """Aspirate a column -- one ``aspirate`` command anchored at the A-row well. + """Aspirate a column -- one ``aspirate`` command anchored at its rearmost well. Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` (``remove_liquid``) is staged for every well whose channel actually @@ -1070,7 +1110,7 @@ async def dispense( offset: Optional[Coordinate] = None, liquid_height: Optional[float] = None, ) -> None: - """Dispense a column -- one ``dispense`` command anchored at the A-row well. + """Dispense a column -- one ``dispense`` command anchored at its rearmost well. Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` (``add_liquid``) is staged for every well whose channel actually holds @@ -1205,7 +1245,7 @@ async def touch_tip( offset: Optional[Coordinate] = None, ) -> None: """Touch the mounted tips to their well walls -- one ``touchTip`` command - anchored at the column's A-row well. + anchored at the column's rearmost well. ``radius`` is the fraction of the well radius each tip moves toward (1.0 = the wall). Requires at least one mounted tip and a valid column @@ -1221,7 +1261,7 @@ async def touch_tip( async def liquid_probe(self, plate: Plate, column: int) -> float: """Probe for liquid in a column -- one ``liquidProbe`` command anchored at - the A-row well; return the found liquid z (mm). + its rearmost well; return the found liquid z (mm). Requires at least one mounted tip and a valid column (both checked before any wire command) and ALL nozzle mode (reset first if a diff --git a/pylabrobot/opentrons/labware_definitions.py b/pylabrobot/opentrons/labware_definitions.py index 02ccdc50257..b28d0a93879 100644 --- a/pylabrobot/opentrons/labware_definitions.py +++ b/pylabrobot/opentrons/labware_definitions.py @@ -15,7 +15,7 @@ import hashlib import re -from typing import Optional, cast +from typing import Optional, Tuple, cast from pylabrobot.resources import ( Container, @@ -62,6 +62,18 @@ def _format_from_grid(num_items_x: int, num_items_y: int) -> str: return "irregular" +def container_cavity_footprint(container: Container) -> Tuple[float, float]: + """The cavity's x/y footprint in the deck frame, as the robot sees it. + + Rotation-aware: a rotated container (or one under a rotated parent) presents + its bounding box to the robot, which has no notion of PLR's rotation. Ops + that reason about whether a nozzle array fits the cavity must read this + rather than the container's own ``get_size_x``/``get_size_y``, so the + guard and the uploaded definition below can never disagree. + """ + return container.get_absolute_size_x(), container.get_absolute_size_y() + + def _well_shape(well: Well) -> dict: if well.cross_section_type == CrossSectionType.RECTANGLE: return { @@ -212,8 +224,7 @@ def build_container_definition( a multi-channel nozzle array on the cavity itself, so ops send no manual centering offsets. """ - size_x = container.get_absolute_size_x() - size_y = container.get_absolute_size_y() + size_x, size_y = container_cavity_footprint(container) size_z = container.get_absolute_size_z() definition: dict = { "schemaVersion": _SCHEMA_VERSION, diff --git a/pylabrobot/opentrons/labware_definitions_tests.py b/pylabrobot/opentrons/labware_definitions_tests.py index 02ec28eb76f..45403f434da 100644 --- a/pylabrobot/opentrons/labware_definitions_tests.py +++ b/pylabrobot/opentrons/labware_definitions_tests.py @@ -14,24 +14,30 @@ from typing import Any, Dict, Optional, Tuple from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead8 from pylabrobot.opentrons.labware_definitions import ( build_container_definition, build_movable_labware_definition, build_plate_definition, build_tip_rack_definition, + container_cavity_footprint, ) +from pylabrobot.opentrons.robot import OpentronsError from pylabrobot.opentrons.transport import ChatterboxTransport from pylabrobot.resources import ( CrossSectionType, Plate, Resource, + ResourceHolder, TipRack, TipSpot, Trough, + TubeRack, Well, WellBottomType, ) from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.rotation import Rotation from pylabrobot.resources.tip import Tip from pylabrobot.resources.utils import create_ordered_items_2d @@ -107,6 +113,34 @@ def _trough(name: str = "hamilton trough") -> Trough: return Trough(name=name, size_x=120.0, size_y=80.0, size_z=40.0, max_volume=290000.0) +def _tube_rack(name: str = "tube rack") -> TubeRack: + """A 12x8 tube rack: deck-assignable and column-shaped, but not pipettable. + + The rack's holders are not wells, so no well-bearing definition can be + built from it -- the mainstream labware type that reaches the unbuildable + path through a pipetting op. + """ + return TubeRack( + name=name, + size_x=127.0, + size_y=86.0, + size_z=45.0, + ordered_items=create_ordered_items_2d( + ResourceHolder, + num_items_x=12, + num_items_y=8, + dx=10.0, + dy=8.0, + dz=0.0, + item_dx=9.0, + item_dy=9.0, + size_x=8.0, + size_y=8.0, + size_z=40.0, + ), + ) + + class TestDefinitionLoadNames(unittest.TestCase): """Load names are the sanitized PLR name plus a digest of the raw name, so distinct names that sanitize identically never share a definitionUri.""" @@ -280,6 +314,19 @@ def test_single_a1_cavity_spans_the_container(self): ) self.assertEqual(definition["groups"][0]["wells"], ["A1"]) + def test_rotated_cavity_uploads_the_shared_deck_frame_footprint(self): + # The uploaded rectangle and the ops' fit guard must read the same + # helper, or a rotated cavity is guarded on the wrong axis. + trough = _trough() + trough.rotation = Rotation(z=90) + definition = build_container_definition(trough) + cavity_x, cavity_y = container_cavity_footprint(trough) + self.assertEqual((cavity_x, cavity_y), (80.0, 120.0)) + self.assertEqual(definition["dimensions"]["xDimension"], cavity_x) + self.assertEqual(definition["dimensions"]["yDimension"], cavity_y) + self.assertEqual(definition["wells"]["A1"]["xDimension"], cavity_x) + self.assertEqual(definition["wells"]["A1"]["yDimension"], cavity_y) + def test_center_multichannel_quirk_matches_shipped_reservoirs(self): # Every shipped Opentrons 1-well reservoir carries this quirk; the engine # centers a multi-channel nozzle array on the cavity because of it, so @@ -347,6 +394,17 @@ def _flex_with_transport( return flex, transport +def _flex_head8_with_gripper() -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead8]: + """A set-up Flex with an 8-channel head AND a gripper, so one bench can + drive both the gripper-intent and pipetting-intent load paths.""" + transport = ChatterboxTransport(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")], gripper=True) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, transport, head + + def _load_labware_commands(transport: ChatterboxTransport) -> list: return [c for c in transport.commands if c["commandType"] == "loadLabware"] @@ -387,6 +445,80 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s return result +class TestUnbuildableLabwareGuard(unittest.TestCase): + """Labware no well-bearing definition can be built from is gripper-movable + but never pipettable: the movable stub's single fake well is zero-depth and + sits at the labware's own bottom, so pipetting it would drive a tip at the + deck. Pipetting callers are refused before any wire command.""" + + def test_pipetting_a_tube_rack_raises_before_any_wire_command(self): + flex, transport, head = _flex_head8_with_gripper() + try: + rack = _tube_rack() + flex.deck.assign_child_at_slot(rack, "C1") + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + # An untyped script can hand a rack to a Plate parameter; that is + # exactly the caller this guard exists for. + asyncio.run(head.aspirate(rack, column=0, volume=20)) # type: ignore[arg-type] + + self.assertEqual(len(transport.labware_definitions), 0) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_gripper_move_of_the_same_rack_still_works(self): + flex, transport, head = _flex_head8_with_gripper() + try: + rack = _tube_rack() + flex.deck.assign_child_at_slot(rack, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(rack, "C2")) + + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(transport.labware_definitions[0]["wells"]["A1"]["depth"], 0) + self.assertEqual(flex.deck.get_slot(rack), "C2") + finally: + asyncio.run(flex.stop()) + + def test_pipetting_after_a_gripper_move_still_raises_on_the_load_cache_hit(self): + # The load cache returns before any type dispatch, so the stub-loaded + # names are tracked separately for the pipetting refusal to see them. + flex, transport, head = _flex_head8_with_gripper() + try: + rack = _tube_rack() + flex.deck.assign_child_at_slot(rack, "C1") + gripper = flex.gripper + assert gripper is not None + asyncio.run(gripper.move_labware(rack, "C2")) + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(rack, column=0, volume=20)) # type: ignore[arg-type] + + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_off_deck_clears_the_stub_record(self): + flex, _transport, head = _flex_head8_with_gripper() + try: + rack = _tube_rack() + flex.deck.assign_child_at_slot(rack, "C1") + gripper = flex.gripper + assert gripper is not None + asyncio.run(gripper.move_labware(rack, "C2")) + self.assertIn(rack.name, flex._stub_labware) + + asyncio.run(flex.labware_moved_off_deck(rack)) + self.assertNotIn(rack.name, flex._stub_labware) + finally: + asyncio.run(flex.stop()) + + class TestCustomLabwareLoadFlow(unittest.TestCase): """_ensure_labware_loaded uploads a definition for labware with no official name.""" @@ -516,6 +648,28 @@ def test_failed_load_leaves_no_labware_id_and_retry_reuses_definition(self): finally: asyncio.run(flex.stop()) + def test_definition_cache_hit_logs_the_ignored_grip_distance(self): + # The upload survives a failed load, so the retry reuses the stored + # definition -- and the grip height it already carries. + flex, transport = _flex_with_transport( + _FailFirstLoadTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right") + ) + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + with self.assertRaises(RuntimeError): + asyncio.run(flex._ensure_labware_loaded(plate, grip_distance_from_top=4.0)) + + with self.assertLogs("pylabrobot.opentrons.flex", level="WARNING") as logs: + asyncio.run(flex._ensure_labware_loaded(plate, grip_distance_from_top=8.0)) + + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(transport.labware_definitions[0]["gripHeightFromLabwareBottom"], 10.0) + self.assertTrue(any("grip_distance_from_top=8.0" in line for line in logs.output)) + finally: + asyncio.run(flex.stop()) + def test_official_name_labware_loads_with_zero_uploads(self): flex, transport = _flex_with_transport() asyncio.run(flex.setup()) @@ -569,13 +723,13 @@ def test_tip_rack_without_official_name_uploads_tiprack_definition(self): def test_bare_resource_uploads_movable_stub(self): # A resource that is not a Plate/TipRack/Container routes to the - # non-pipettable movable stub so the gripper can still move it. + # non-pipettable movable stub, which only allow_stub callers may ask for. flex, transport = _flex_with_transport() asyncio.run(flex.setup()) try: widget = Resource(name="widget", size_x=100.0, size_y=90.0, size_z=20.0) flex.deck.assign_child_at_slot(widget, "C1") - asyncio.run(flex._ensure_labware_loaded(widget, grip_distance_from_top=5.0)) + asyncio.run(flex._ensure_labware_loaded(widget, allow_stub=True, grip_distance_from_top=5.0)) self.assertEqual(len(transport.labware_definitions), 1) definition = transport.labware_definitions[0] From f58d4a8bfe7291284a86d6cff70ccb64d5672f09 Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 20:04:10 -0400 Subject: [PATCH 07/36] fix(opentrons): anchor uploaded well geometry at the real cavity floor 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. --- docs/api/pylabrobot.opentrons.rst | 27 ++ pylabrobot/opentrons/flex.py | 99 +++++-- pylabrobot/opentrons/flex_container_tests.py | 103 ++++++- .../opentrons/flex_fine_pipetting_tests.py | 120 +++++++- pylabrobot/opentrons/flex_gripper.py | 46 ++- pylabrobot/opentrons/flex_gripper_tests.py | 3 - pylabrobot/opentrons/flex_head.py | 262 ++++++++++-------- pylabrobot/opentrons/flex_motion_tests.py | 51 ++-- pylabrobot/opentrons/flex_wire.py | 31 +++ pylabrobot/opentrons/labware_definitions.py | 221 ++++++++++++--- .../opentrons/labware_definitions_tests.py | 212 +++++++++++++- pylabrobot/opentrons/transport.py | 16 +- 12 files changed, 929 insertions(+), 262 deletions(-) create mode 100644 pylabrobot/opentrons/flex_wire.py diff --git a/docs/api/pylabrobot.opentrons.rst b/docs/api/pylabrobot.opentrons.rst index 5986f5315f0..7cf934683d7 100644 --- a/docs/api/pylabrobot.opentrons.rst +++ b/docs/api/pylabrobot.opentrons.rst @@ -14,4 +14,31 @@ Flex OpentronsRobot OpentronsFlex OpentronsError + OpentronsCommandError PipetteInfo + +Heads +----- + +.. currentmodule:: pylabrobot.opentrons.flex_head + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + FlexHead1 + FlexHead8 + FlexHead96 + +Gripper +------- + +.. currentmodule:: pylabrobot.opentrons.flex_gripper + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + FlexGripper diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 12a14f5c33d..6e40c24f32c 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -2,8 +2,9 @@ import uuid from typing import Any, Dict, List, Optional, Set, Tuple, Type, cast -from pylabrobot.opentrons.flex_gripper import FlexGripper, _slot_wire_location +from pylabrobot.opentrons.flex_gripper import FlexGripper from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead +from pylabrobot.opentrons.flex_wire import slot_wire_location from pylabrobot.opentrons.labware_definitions import ( build_container_definition, build_movable_labware_definition, @@ -19,7 +20,24 @@ logger = logging.getLogger(__name__) _OT_NAMESPACE = "opentrons" + +# Catalogue definition revision per load name -- see _ot_catalogue_identity. _OT_VERSION = 1 +_OT_CATALOGUE_VERSIONS = { + "appliedbiosystemsmicroamp_384_wellplate_40ul": 3, + "armadillo_96_wellplate_200ul_pcr_full_skirt": 2, + "biorad_384_wellplate_50ul": 2, + "biorad_96_wellplate_200ul_pcr": 2, + "corning_12_wellplate_6.9ml_flat": 2, + "corning_384_wellplate_112ul_flat": 2, + "corning_48_wellplate_1.6ml_flat": 2, + "corning_96_wellplate_360ul_flat": 2, + "nest_1_reservoir_195ml": 2, + "nest_96_wellplate_100ul_pcr_full_skirt": 2, + "nest_96_wellplate_200ul_flat": 2, + "nest_96_wellplate_2ml_deep": 2, + "opentrons_96_wellplate_200ul_pcr_full_skirt": 2, +} _TIP_RACK_MAP = { "flex_96_tiprack_50ul": "opentrons_flex_96_tiprack_50ul", @@ -235,7 +253,7 @@ async def _ensure_labware_loaded( ) try: - load_name = self._ot_load_name(resource) + load_name, version = self._ot_catalogue_identity(resource) except OpentronsError: # No official Opentrons definition: build one from the resource's PLR # geometry, upload it, and load by the uploaded definition's identity. @@ -243,12 +261,12 @@ async def _ensure_labware_loaded( resource, grip_distance_from_top, allow_stub ) else: - namespace, version = _OT_NAMESPACE, _OT_VERSION + namespace = _OT_NAMESPACE _warn_grip_distance_discarded( name, grip_distance_from_top, - f"it loads the Opentrons catalogue definition '{load_name}', whose grip height the " - "robot owns", + f"it loads the Opentrons catalogue definition '{load_name}', whose grip height is " + "the vendor's to state (the robot grips at mid-height when it states none)", ) labware_id = uuid.uuid4().hex[:12] @@ -256,7 +274,7 @@ async def _ensure_labware_loaded( "loadLabware", { "loadName": load_name, - "location": _slot_wire_location(slot), + "location": slot_wire_location(slot), "namespace": namespace, "version": version, "labwareId": labware_id, @@ -306,25 +324,42 @@ async def labware_moved_off_deck(self, resource: Resource) -> None: logger.info("Labware '%s' marked moved off-deck", name) @staticmethod - def _ot_load_name(resource: Resource) -> str: - """Resolve a PLR resource to its Opentrons labware load name.""" + def _ot_catalogue_identity(resource: Resource) -> Tuple[str, int]: + """Resolve a PLR resource to its Opentrons load name and definition version. + + Catalogue definitions are versioned per load name, and version 1 is the + OLDEST revision -- for much of the catalogue it predates the Flex and + declares no gripper grip height, so the robot grips at the labware's + mid-height rather than where the vendor says. ``_OT_CATALOGUE_VERSIONS`` + therefore pins the EARLIEST revision that states one. Earliest, not + newest: a robot only holds the revisions its own software shipped with, + and these have shipped since API 2.14, while their well geometry is + identical to version 1's -- so the bump changes the grip and nothing else. + Load names outside that map (every Flex tip rack among them, which ships + one revision) stay at 1. A resource can override the version for its own + load name by carrying an ``ot_version``. + """ if hasattr(resource, "ot_load_name"): - return cast(str, resource.ot_load_name) - - name_lower = getattr(resource, "name", "").lower() - - for key, ot_name in _TIP_RACK_MAP.items(): - if key in name_lower: - return ot_name - - if name_lower.startswith("opentrons_"): - return name_lower + load_name = cast(str, resource.ot_load_name) + else: + name_lower = getattr(resource, "name", "").lower() + for key, ot_name in _TIP_RACK_MAP.items(): + if key in name_lower: + load_name = ot_name + break + else: + if not name_lower.startswith("opentrons_"): + raise OpentronsError( + "Cannot determine Opentrons load name", + f"'{name_lower}' — set resource.ot_load_name = 'opentrons_flex_96_tiprack_50ul' " + f"or use a standard Flex labware name.", + ) + load_name = name_lower - raise OpentronsError( - "Cannot determine Opentrons load name", - f"'{name_lower}' — set resource.ot_load_name = 'opentrons_flex_96_tiprack_50ul' " - f"or use a standard Flex labware name.", - ) + version = getattr(resource, "ot_version", None) + if version is None: + version = _OT_CATALOGUE_VERSIONS.get(load_name, _OT_VERSION) + return load_name, cast(int, version) async def _define_custom_labware( self, @@ -374,13 +409,19 @@ def _build_labware_definition( Pipettable types get real-geometry definitions. Any other resource (lid, adapter, tube rack, ...) has no wells the robot can pipette, so it builds only as the non-pipettable movable stub, which ``allow_stub`` opts into. + Geometry an Opentrons definition cannot describe (a rotated plate, a + cavity floor the resource never declared) is refused here, before any + wire command, rather than uploaded for the robot to act on. """ - if isinstance(resource, Plate): - return build_plate_definition(resource, grip_distance_from_top) - if isinstance(resource, TipRack): - return build_tip_rack_definition(resource, grip_distance_from_top) - if isinstance(resource, Container): - return build_container_definition(resource, grip_distance_from_top) + try: + if isinstance(resource, Plate): + return build_plate_definition(resource, grip_distance_from_top) + if isinstance(resource, TipRack): + return build_tip_rack_definition(resource, grip_distance_from_top) + if isinstance(resource, Container): + return build_container_definition(resource, grip_distance_from_top) + except ValueError as e: + raise OpentronsError("Cannot build an Opentrons labware definition", str(e)) from e if not allow_stub: raise _not_pipettable_error(resource) return build_movable_labware_definition(resource, grip_distance_from_top) diff --git a/pylabrobot/opentrons/flex_container_tests.py b/pylabrobot/opentrons/flex_container_tests.py index 84e96006ef2..7a3e9c10703 100644 --- a/pylabrobot/opentrons/flex_container_tests.py +++ b/pylabrobot/opentrons/flex_container_tests.py @@ -21,6 +21,7 @@ from pylabrobot.opentrons.transport import ChatterboxTransport from pylabrobot.resources import ( Container, + cor_96_wellplate_360uL_Fb, set_tip_tracking, set_volume_tracking, ) @@ -52,14 +53,24 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s def _make_trough( name: str = "trough", - size_x: float = 107.0, - size_y: float = 71.0, + size_x: float = 127.76, + size_y: float = 85.48, max_volume: float = 195000.0, ) -> Container: """A single-cavity reservoir built directly with the PLR ``Container`` class, mapped to a real Opentrons single-cavity load name. + + The default size is that reservoir's OUTER footprint, the convention a PLR + container carries (its cavity is narrower, and PLR does not model it). """ - trough = Container(name=name, size_x=size_x, size_y=size_y, size_z=25.0, max_volume=max_volume) + trough = Container( + name=name, + size_x=size_x, + size_y=size_y, + size_z=31.4, + material_z_thickness=1.0, + max_volume=max_volume, + ) trough.ot_load_name = "nest_1_reservoir_195ml" # type: ignore[attr-defined] return trough @@ -444,13 +455,13 @@ def test_offset_that_keeps_row_inside_cavity_passes(self): flex, transport, head = _flex_head8() try: rack = flex_96_tiprack_50ul(name="rack") - trough = _make_trough() # 71 mm front-to-back; 63 mm row + 2*4 = 71 fits + trough = _make_trough() # 85.48 mm front-to-back; 63 mm row + 2*11.24 = 85.48 just fits flex.deck.assign_child_at_slot(rack, "C1") flex.deck.assign_child_at_slot(trough, "C2") trough.tracker.set_volume(10000.0) asyncio.run(head.pick_up_tips(rack, column=0)) - asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate(y=4))) + asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate(y=11.24))) aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] self.assertEqual(len(aspirate_cmds), 1) @@ -458,7 +469,7 @@ def test_offset_that_keeps_row_inside_cavity_passes(self): # because the caller said nothing about z, not to ask for the floor. self.assertEqual( aspirate_cmds[0]["params"]["wellLocation"]["offset"], - {"x": 0, "y": 4, "z": 1.0}, + {"x": 0, "y": 11.24, "z": 1.0}, ) finally: asyncio.run(flex.stop()) @@ -576,7 +587,7 @@ def test_offset_that_shifts_row_past_cavity_wall_rejects_pre_wire(self): flex, transport, head = _flex_head8() try: rack = flex_96_tiprack_50ul(name="rack") - trough = _make_trough() # 71 mm front-to-back; 63 mm row + 2*5 = 73 overhangs + trough = _make_trough() # 85.48 front-to-back; 63 mm row + 2*11.5 = 86 overhangs flex.deck.assign_child_at_slot(rack, "C1") flex.deck.assign_child_at_slot(trough, "C2") trough.tracker.set_volume(10000.0) @@ -584,7 +595,7 @@ def test_offset_that_shifts_row_past_cavity_wall_rejects_pre_wire(self): asyncio.run(head.pick_up_tips(rack, column=0)) commands_before = len(transport.commands) with self.assertRaises(OpentronsError): - asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate(y=-5))) + asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate(y=-11.5))) self.assertEqual(len(transport.commands), commands_before) self.assertAlmostEqual(trough.tracker.volume, 10000.0) @@ -740,19 +751,19 @@ def test_offset_that_keeps_grid_inside_cavity_passes(self): flex, transport, head = _flex_head96() try: rack = flex_96_tiprack_50ul(name="rack") - trough = _make_trough() # 107 x 71 mm; grid 99 x 63 + 2*4 on each axis fits + trough = _make_trough() # 127.76 x 85.48; grid 99 x 63 plus 2*14.38 / 2*11.24 just fits flex.deck.assign_child_at_slot(rack, "C1") flex.deck.assign_child_at_slot(trough, "C2") trough.tracker.set_volume(100000.0) asyncio.run(head.pick_up_tips(rack)) - asyncio.run(head.aspirate(trough, volume=10, offset=Coordinate(x=4, y=4))) + asyncio.run(head.aspirate(trough, volume=10, offset=Coordinate(x=14.38, y=11.24))) aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] self.assertEqual(len(aspirate_cmds), 1) self.assertEqual( aspirate_cmds[0]["params"]["wellLocation"]["offset"], - {"x": 4, "y": 4, "z": 1.0}, + {"x": 14.38, "y": 11.24, "z": 1.0}, ) finally: asyncio.run(flex.stop()) @@ -786,13 +797,13 @@ def test_offset_that_shifts_grid_past_cavity_wall_rejects_pre_wire(self): flex, transport, head = _flex_head96() try: rack = flex_96_tiprack_50ul(name="rack") - trough = _make_trough() # 107 x 71 mm: x 99 + 2*4.5 = 108 and y 63 + 2*4.5 = 72 overhang + trough = _make_trough() # 127.76 x 85.48: 99 + 2*14.5 and 63 + 2*11.5 both overhang flex.deck.assign_child_at_slot(rack, "C1") flex.deck.assign_child_at_slot(trough, "C2") trough.tracker.set_volume(100000.0) asyncio.run(head.pick_up_tips(rack)) - for offset in (Coordinate(x=4.5), Coordinate(y=4.5)): + for offset in (Coordinate(x=14.5), Coordinate(y=11.5)): commands_before = len(transport.commands) with self.assertRaises(OpentronsError): asyncio.run(head.aspirate(trough, volume=10, offset=offset)) @@ -802,5 +813,71 @@ def test_offset_that_shifts_grid_past_cavity_wall_rejects_pre_wire(self): asyncio.run(flex.stop()) +class TestLiquidOpsRequireAMountedTip(unittest.TestCase): + """Every liquid op requires a mounted tip, wells and plates as much as + containers: without one the command describes a tip that is not there and + the pipette drives its bare NOZZLE roughly a tip length lower. The engine + rejects a tipless op, but ``dispense`` moves to the well FIRST and checks + after, so the collision happens before the rejection.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + @staticmethod + def _plate(flex: OpentronsFlex): + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + return plate + + def _assert_refused_pre_wire(self, flex, transport, op) -> None: + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError) as caught: + asyncio.run(op()) + self.assertEqual(caught.exception.title, "NoTipError") + self.assertEqual(len(transport.commands), commands_before) + + def test_head1_well_ops_refuse_without_a_tip(self): + flex, transport, head = _flex_head1() + try: + well = self._plate(flex).get_item("B3") + self._assert_refused_pre_wire(flex, transport, lambda: head.aspirate(well, volume=10)) + self._assert_refused_pre_wire(flex, transport, lambda: head.dispense(well, volume=10)) + self.assertAlmostEqual(well.tracker.volume, 100.0) + finally: + asyncio.run(flex.stop()) + + def test_head8_column_ops_refuse_without_tips(self): + flex, transport, head = _flex_head8() + try: + plate = self._plate(flex) + self._assert_refused_pre_wire( + flex, transport, lambda: head.aspirate(plate, column=0, volume=10) + ) + self._assert_refused_pre_wire( + flex, transport, lambda: head.dispense(plate, column=0, volume=10) + ) + self.assertAlmostEqual(plate.get_item("A1").tracker.volume, 100.0) + finally: + asyncio.run(flex.stop()) + + def test_head96_plate_ops_refuse_without_tips(self): + flex, transport, head = _flex_head96() + try: + plate = self._plate(flex) + self._assert_refused_pre_wire(flex, transport, lambda: head.aspirate(plate, volume=10)) + self._assert_refused_pre_wire(flex, transport, lambda: head.dispense(plate, volume=10)) + self.assertAlmostEqual(plate.get_item("A1").tracker.volume, 100.0) + finally: + asyncio.run(flex.stop()) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index 925c6ce36ee..215f39d416e 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -10,7 +10,9 @@ (the real-hardware behavior). Also pins the shared well-position rule the liquid ops build on -- a caller offset shifts the head from the default position rather than replacing it -- and the one place that deliberately -does not follow it, ``touch_tip``. +does not follow it, ``touch_tip``; plus the two rules the single-nozzle +cherry-pick runs under: which nozzles an 8-channel Flex can anchor on, and +that no nozzle layout changes while a tip is mounted. """ import asyncio @@ -772,13 +774,19 @@ def test_liquid_height_is_the_base_the_offset_rides_on(self): finally: asyncio.run(flex.stop()) - def test_touch_tip_offset_replaces_the_default_touch_height(self): - # touch_tip's z IS the touch height, mirroring the Opentrons Python API's - # absolute v_offset; dropping the default moves the tip up, not down. + def test_touch_tip_offset_replaces_while_aspirate_offset_shifts(self): + # The same Coordinate(x=1) means different things on the two calls, which + # is why both public docstrings spell the rule out: touch_tip's z IS the + # touch height (the Opentrons Python API's absolute v_offset). flex, transport, head, plate = self._bench() try: + asyncio.run(head.aspirate(plate, column=0, volume=10, offset=Coordinate(x=1))) asyncio.run(head.touch_tip(plate, column=0, offset=Coordinate(x=1))) touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + self.assertEqual( + self._aspirate_well_location(transport), + {"origin": "bottom", "offset": {"x": 1, "y": 0, "z": 1.0}}, + ) self.assertEqual( touch_cmds[0]["params"]["wellLocation"], {"origin": "top", "offset": {"x": 1, "y": 0, "z": 0}}, @@ -787,5 +795,109 @@ def test_touch_tip_offset_replaces_the_default_touch_height(self): asyncio.run(flex.stop()) +class TestSingleNozzleLayout(unittest.TestCase): + """An 8-channel Flex can anchor a SINGLE nozzle layout on its "A1" or "H1" + nozzle and nothing else: the engine's primaryNozzle is a literal of those + (plus the 12-column ends a 96-head uses), and the pipette's own + validNozzleMaps carry only SingleA1/SingleH1. The engine also refuses ANY + nozzle reconfiguration, "ALL" included, while a tip is attached.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + return flex, transport, head, rack + + def _nozzle_params(self, transport: ChatterboxTransport) -> list: + return [ + c["params"]["configurationParams"] + for c in transport.commands + if c["commandType"] == "configureNozzleLayout" + ] + + def test_a_middle_row_well_needs_an_explicit_nozzle(self): + # Deriving the nozzle from the row letter builds "C1", which the + # robot-server rejects at body validation before the command exists. + flex, transport, head, rack = self._bench() + try: + commands_before = len(transport.commands) + with self.assertRaises(ValueError) as caught: + asyncio.run(head.pick_up_single_tip(rack, well="C3")) + self.assertIn("primary_nozzle", str(caught.exception)) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_explicit_nozzle_reaches_a_middle_row_well_on_its_own_channel(self): + # In a SINGLE layout the engine moves the chosen nozzle over whatever + # well is named, so the tip lands on that nozzle's channel, not the + # well's row. + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="C3", primary_nozzle="H1")) + + self.assertEqual(self._nozzle_params(transport)[-1]["primaryNozzle"], "H1") + pickups = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(pickups[-1]["params"]["wellName"], "C3") + self.assertIsNotNone(head.get_mounted_tips()[7]) + self.assertTrue(all(tip is None for i, tip in enumerate(head.get_mounted_tips()) if i != 7)) + finally: + asyncio.run(flex.stop()) + + def test_row_letter_default_picks_that_rows_nozzle(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="A2")) + self.assertEqual(self._nozzle_params(transport)[-1]["primaryNozzle"], "A1") + self.assertIsNotNone(head.get_mounted_tips()[0]) + finally: + asyncio.run(flex.stop()) + + def test_an_unanchorable_nozzle_is_rejected_pre_wire(self): + flex, transport, head, rack = self._bench() + try: + commands_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(head.pick_up_single_tip(rack, well="A1", primary_nozzle="B1")) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_reconfiguring_the_layout_while_a_tip_is_mounted_is_refused(self): + flex, transport, head, rack = self._bench() + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + try: + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + commands_before = len(transport.commands) + + # A column op resets the layout to ALL, which the robot refuses while + # the single tip is still on. + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.aspirate(plate, column=1, volume=10)) + self.assertIn("nozzle layout", str(caught.exception)) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_dropping_the_single_tip_restores_all_mode(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + asyncio.run(head.drop_single_tip(flex.deck.get_trash_area())) + + self.assertEqual(self._nozzle_params(transport)[-1], {"style": "ALL"}) + self.assertTrue(all(tip is None for tip in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/opentrons/flex_gripper.py b/pylabrobot/opentrons/flex_gripper.py index 5a3c0d2b9f4..43c025cabed 100644 --- a/pylabrobot/opentrons/flex_gripper.py +++ b/pylabrobot/opentrons/flex_gripper.py @@ -18,8 +18,9 @@ import logging from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -from pylabrobot.opentrons.flex_head import _UNTESTED_HARDWARE_WARNING +from pylabrobot.opentrons.flex_wire import UNTESTED_HARDWARE_WARNING, slot_wire_location from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import OFFLINE_API_VERSION from pylabrobot.resources.resource import Resource if TYPE_CHECKING: @@ -39,17 +40,6 @@ _GRIPPER_MIN_FORCE = 2.0 _GRIPPER_MAX_FORCE = 30.0 -# The robot-server's DeckSlotName covers only the A1-D3 grid; the column-4 -# staging slots are addressable areas and ride a different location key. -_STAGING_SLOT_NAMES = frozenset({"A4", "B4", "C4", "D4"}) - - -def _slot_wire_location(slot: str) -> Dict[str, str]: - """The ``loadLabware``/``moveLabware`` location for a Flex slot name.""" - if slot in _STAGING_SLOT_NAMES: - return {"addressableAreaName": slot} - return {"slotName": slot} - def _version_tuple(version: str) -> Tuple[int, ...]: """Parse a dotted robot-software version into comparable integers. @@ -85,16 +75,21 @@ def _require_robot_commands(command: str, api_version: Optional[str]) -> None: ``api_version`` is the ``GET /health`` ``api_version`` the owning robot stored at setup (``flex.api_version``). Released builds report a plain numeric version and are gated against ``_ROBOT_COMMANDS_MIN_VERSION``. - Dev/simulator builds ("0.0.0.dev0") and the offline ChatterboxTransport - ("dry-run") run current code, so they pass; any other unparseable version - raises rather than silently passing the gate. + + Two exemptions, both narrow. An UNTAGGED build reports "0.0.0.dev*" (the + version an unreleased source checkout carries, including the simulated + robot-server) and runs current code, so it passes -- but a build cut off a + real tag reports that tag plus a dev suffix ("8.1.0.dev5"), which is gated + on the tag like any release. ``ChatterboxTransport``'s offline "dry-run" + sentinel passes too; it reaches no robot at all. Any other unparseable + version raises rather than silently passing the gate. """ if api_version is None: raise OpentronsError( "Robot version unknown", f"{command} requires setup() to have run, to read the robot's version.", ) - if "dev" in api_version or api_version == "dry-run": + if api_version == OFFLINE_API_VERSION: return try: version = _version_tuple(api_version) @@ -104,6 +99,8 @@ def _require_robot_commands(command: str, api_version: Optional[str]) -> None: f"{command} is gated on robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, but this " f"robot reports the unrecognized version {api_version!r}.", ) from None + if version == (0, 0, 0) and "dev" in api_version: + return if version < _version_tuple(_ROBOT_COMMANDS_MIN_VERSION): raise OpentronsError( "Robot software too old", @@ -133,7 +130,7 @@ def _warn_untested_hardware(self, op: str) -> None: if self._untested_hardware_warned: return self._untested_hardware_warned = True - logger.warning(_UNTESTED_HARDWARE_WARNING, type(self).__name__, op) + logger.warning(UNTESTED_HARDWARE_WARNING, type(self).__name__, op) async def move_labware( self, @@ -159,12 +156,13 @@ async def move_labware( uploads a definition for: a resource resolving to an official Opentrons load name (``ot_load_name`` set, a standard tip-rack name, or a name starting with "opentrons_") loads the catalogue definition - instead, which carries the vendor's own grip height, and this value - is ignored. Honored on the labware's FIRST load in the run only; - once the robot holds a definition for it, later values are ignored - too. Every ignored value is logged. ``None`` keeps the definition's - grip height (the robot's mid-height default for custom definitions - built without one). + instead, whose grip height is the vendor's to state -- and when that + definition states none, the robot grips at the labware's mid-height + rather than at this value. Honored on the labware's FIRST load in the + run only; once the robot holds a definition for it, later values are + ignored too. Every ignored value is logged. ``None`` keeps the + definition's grip height (the robot's mid-height default for custom + definitions built without one). Raises: OpentronsError: If the resource is not on the deck, or ``to_slot`` is @@ -200,7 +198,7 @@ async def move_labware( "moveLabware", { "labwareId": labware_id, - "newLocation": _slot_wire_location(to_slot), + "newLocation": slot_wire_location(to_slot), "strategy": "usingGripper", }, timeout=_MOVE_LABWARE_TIMEOUT, diff --git a/pylabrobot/opentrons/flex_gripper_tests.py b/pylabrobot/opentrons/flex_gripper_tests.py index 1551d0471ba..6eefa1a3ea9 100644 --- a/pylabrobot/opentrons/flex_gripper_tests.py +++ b/pylabrobot/opentrons/flex_gripper_tests.py @@ -170,9 +170,6 @@ def test_move_back_from_staging_slot_uses_slot_name_form(self): move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] self.assertEqual(move_cmds[0]["params"]["newLocation"], {"slotName": "C2"}) - # The load at the staging slot itself also needs the addressable-area form. - load_cmds = [c for c in transport.commands if c["commandType"] == "loadLabware"] - self.assertEqual(load_cmds[0]["params"]["location"], {"addressableAreaName": "A4"}) finally: asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index fbe6e7de354..b8b017fe6c2 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -23,7 +23,8 @@ import logging from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional, Tuple, Union, cast -from pylabrobot.opentrons.labware_definitions import container_cavity_footprint +from pylabrobot.opentrons.flex_wire import UNTESTED_HARDWARE_WARNING +from pylabrobot.opentrons.labware_definitions import container_footprint from pylabrobot.opentrons.robot import OpentronsCommandError, OpentronsError from pylabrobot.resources import ( Container, @@ -46,14 +47,6 @@ logger = logging.getLogger(__name__) -# Shared by the heads and the gripper so the notice reads identically -# everywhere; each module logs it through its own logger. -_UNTESTED_HARDWARE_WARNING = ( - "%s.%s is coded but NOT YET VERIFIED on real Opentrons Flex hardware -- " - "tested only against ChatterboxTransport/simulated transport. Verify behavior " - "on real hardware before relying on it in a production protocol." -) - class _FlexHead: """Base class for a mount- (or 96-head-) addressed pipette on an ``OpentronsFlex``. @@ -90,7 +83,7 @@ def _warn_untested_hardware(self, op: str) -> None: if op in self._HARDWARE_VERIFIED_OPS or self._untested_hardware_warned: return self._untested_hardware_warned = True - logger.warning(_UNTESTED_HARDWARE_WARNING, type(self).__name__, op) + logger.warning(UNTESTED_HARDWARE_WARNING, type(self).__name__, op) def get_mounted_tips(self) -> List[Optional[Tip]]: """Per-channel tip state (Case-2: no private-attribute peeking by consumers). @@ -294,9 +287,12 @@ async def _execute_trash_drop(self) -> None: def _require_mounted_tip(self) -> None: """Raise if no channel holds a tip -- pre-wire guard for tip-motion ops. - ``touch_tip``/``liquid_probe`` move the mounted tip itself into the - well, so issuing them without a tip would drive the bare nozzle into - the labware. Checked before any wire command is sent. + Every liquid-handling op moves the mounted tip into the labware, so + issuing one without a tip would drive the bare NOZZLE there instead -- + roughly a tip length lower than the pose the command describes. The + engine rejects a tipless op, but ``dispense`` moves to the well first and + only then checks, so the collision happens before the rejection. Checked + before any wire command is sent. """ if all(tip is None for tip in self._channel_tips): raise OpentronsError( @@ -304,6 +300,27 @@ def _require_mounted_tip(self) -> None: "No tip mounted; pick up a tip first.", ) + async def _configure_nozzle_layout(self, configuration_params: Dict[str, Any]) -> None: + """Send ``configureNozzleLayout``, refusing while any channel holds a tip. + + The engine rejects EVERY nozzle reconfiguration, "ALL" included, while a + tip is attached ("Cannot configure nozzle layout ... while it has tips + attached"), because the layout decides which physical nozzles the pipette + drives. Refused here instead, so the caller reads which tips are in the + way rather than a mid-op command failure from the robot. + """ + if any(tip is not None for tip in self._channel_tips): + held = [i for i, tip in enumerate(self._channel_tips) if tip is not None] + raise OpentronsError( + "HasTipError", + f"The nozzle layout cannot change while channel(s) {held} hold a tip; the robot " + "refuses the reconfiguration. Drop the mounted tip(s) first.", + ) + await self._execute( + "configureNozzleLayout", + {"pipetteId": self.pipette_id, "configurationParams": configuration_params}, + ) + def _touch_tip_params( self, labware_id: str, @@ -476,19 +493,22 @@ def _require_span_fits_container( y_span: float, offset: Optional[Coordinate], ) -> None: - """Raise pre-wire if the nozzle array would overhang the cavity. + """Raise pre-wire if the nozzle array would overhang the container. The engine centers the array on the cavity (the definition's ``centerMultichannelOnWells`` quirk) and the caller's ``offset`` then - shifts it, so the shifted span must still fit inside the cavity's - footprint on each axis. The footprint is the deck-frame one the uploaded - definition carries (``container_cavity_footprint``), not the container's - own x/y: a rotated container presents its axes to the robot swapped, and - guarding the pre-rotation axis passes an array that overhangs the real - cavity. + shifts it, so the shifted span must still fit on each axis. The footprint + read here is the deck-frame one the uploaded definition carries + (``container_footprint``), not the container's own x/y: a rotated + container presents its axes to the robot swapped, and guarding the + pre-rotation axis passes an array that overhangs it. + + That footprint is the container's OUTER box, so this guard is as loose as + the definition is: PLR carries no cavity x/y, so an array that fits the + shell but not the cavity inside it passes both. """ o = offset if offset is not None else Coordinate.zero() - cavity_x, cavity_y = container_cavity_footprint(container) + cavity_x, cavity_y = container_footprint(container) required_x = x_span + 2 * abs(o.x) required_y = y_span + 2 * abs(o.y) if required_x > cavity_x or required_y > cavity_y: @@ -557,9 +577,10 @@ async def move_to( # least this z while traveling, clearing any labware on the deck. _TRAVERSAL_HEIGHT = 120.0 -# Row letters front-to-back as the Flex API names single nozzles ("H1" is the -# frontmost/primary nozzle, "A1" the rearmost). -_ROW_LETTERS = "ABCDEFGH" +# The only nozzles an 8-channel Flex can anchor a SINGLE layout on ("A1" is +# the rearmost, "H1" the frontmost), mapped to the channel each one is. +_SINGLE_NOZZLES = {"A1": 0, "H1": 7} +_SINGLE_NOZZLE_ROWS = frozenset(nozzle[0] for nozzle in _SINGLE_NOZZLES) _NUM_CHANNELS = 8 @@ -726,20 +747,20 @@ async def aspirate( ``Container`` (trough/reservoir) is its own robot-side labware whose single-cavity definition exposes exactly one well, named "A1", so the command names the container's labware id and well "A1"; the volume is - tracked against the container's own tracker, and a mounted tip is - required (checked before any wire command). Either way: stage -> - validate -> wire -> commit/rollback -- the tracker (``remove_liquid``) - is staged BEFORE the wire command, so an infeasible aspirate raises - before any hardware motion. A ``prepareToAspirate`` command is sent - first if this is the first aspirate since the last tip pickup. + tracked against the container's own tracker. Either way a mounted tip is + required (checked before any wire command), and: stage -> validate -> + wire -> commit/rollback -- the tracker (``remove_liquid``) is staged + BEFORE the wire command, so an infeasible aspirate raises before any + hardware motion. A ``prepareToAspirate`` command is sent first if this is + the first aspirate since the last tip pickup. """ self._warn_untested_hardware("aspirate") + self._require_mounted_tip() if isinstance(target, Well): parent = self._require_itemized_parent(target) labware_id = await self.flex._ensure_labware_loaded(parent) well_name = parent.get_child_identifier(target) else: - self._require_mounted_tip() labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE @@ -771,18 +792,18 @@ async def dispense( A ``Well`` is addressed through its plate parent by well name; a bare ``Container`` (trough/reservoir) is addressed as its own labware at - its sole robot-side well "A1" and requires a mounted tip (see - ``aspirate``). Either way: stage -> validate -> wire -> commit/rollback - -- the tracker (``add_liquid``) is staged BEFORE the wire command, so - an infeasible dispense raises before any hardware motion. + its sole robot-side well "A1". Either way a mounted tip is required (see + ``aspirate``), and: stage -> validate -> wire -> commit/rollback -- the + tracker (``add_liquid``) is staged BEFORE the wire command, so an + infeasible dispense raises before any hardware motion. """ self._warn_untested_hardware("dispense") + self._require_mounted_tip() if isinstance(target, Well): parent = self._require_itemized_parent(target) labware_id = await self.flex._ensure_labware_loaded(parent) well_name = parent.get_child_identifier(target) else: - self._require_mounted_tip() labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE @@ -811,8 +832,12 @@ async def touch_tip( """Touch the mounted tip to the sides of ``well`` -- one ``touchTip`` command. ``radius`` is the fraction of the well radius the tip moves toward - (1.0 = the wall). Requires a mounted tip (checked before any wire - command). No trackers are involved. + (1.0 = the wall). ``offset`` IS the touch position, read from the well + top (the Opentrons Python API's absolute ``v_offset``): it REPLACES the + 1 mm below the rim this touches at by default rather than shifting it the + way an ``aspirate``/``dispense`` offset does, so ``Coordinate(x=1)`` + touches level with the rim. Requires a mounted tip (checked before any + wire command). No trackers are involved. """ self._warn_untested_hardware("touch_tip") self._require_mounted_tip() @@ -888,14 +913,12 @@ async def _ensure_all_mode(self) -> None: A prior single-tip op may have left the pipette in SINGLE mode. Column ops always address all 8 physical channels, so they must not silently run under a stale single-nozzle configuration -- if the layout isn't - already ALL, reset it first. + already ALL, reset it first. The reset is refused while the single tip + from that op is still mounted, since the robot refuses it too. """ if self._nozzle_layout == "ALL": return - await self._execute( - "configureNozzleLayout", - {"pipetteId": self.pipette_id, "configurationParams": {"style": "ALL"}}, - ) + await self._configure_nozzle_layout({"style": "ALL"}) self._nozzle_layout = "ALL" # --- Column helpers --- @@ -1065,15 +1088,16 @@ async def aspirate( ) -> None: """Aspirate a column -- one ``aspirate`` command anchored at its rearmost well. - Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` - (``remove_liquid``) is staged for every well whose channel actually - holds a tip (None-skip; wells outside ``column`` are never touched -- - the Case-1 regression guard) BEFORE the wire command, so an infeasible - aspirate (e.g. ``TooLittleLiquidError``) raises before any hardware - motion. A ``prepareToAspirate`` command is sent first if this is the - first aspirate since the last tip pickup. + Requires at least one mounted tip. Follows stage -> validate -> wire -> + commit/rollback: ``Well.tracker`` (``remove_liquid``) is staged for + every well whose channel actually holds a tip (None-skip; wells outside + ``column`` are never touched -- the Case-1 regression guard) BEFORE the + wire command, so an infeasible aspirate (e.g. ``TooLittleLiquidError``) + raises before any hardware motion. A ``prepareToAspirate`` command is + sent first if this is the first aspirate since the last tip pickup. """ self._warn_untested_hardware("aspirate") + self._require_mounted_tip() well_name, column_wells = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) @@ -1112,12 +1136,14 @@ async def dispense( ) -> None: """Dispense a column -- one ``dispense`` command anchored at its rearmost well. - Follows stage -> validate -> wire -> commit/rollback: ``Well.tracker`` - (``add_liquid``) is staged for every well whose channel actually holds - a tip (None-skip) BEFORE the wire command, so an infeasible dispense - (e.g. ``TooLittleVolumeError``) raises before any hardware motion. + Requires at least one mounted tip. Follows stage -> validate -> wire -> + commit/rollback: ``Well.tracker`` (``add_liquid``) is staged for every + well whose channel actually holds a tip (None-skip) BEFORE the wire + command, so an infeasible dispense (e.g. ``TooLittleVolumeError``) + raises before any hardware motion. """ self._warn_untested_hardware("dispense") + self._require_mounted_tip() well_name, column_wells = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) @@ -1248,9 +1274,14 @@ async def touch_tip( anchored at the column's rearmost well. ``radius`` is the fraction of the well radius each tip moves toward - (1.0 = the wall). Requires at least one mounted tip and a valid column - (both checked before any wire command) and ALL nozzle mode (reset first - if a single-tip op left the layout otherwise). No trackers are involved. + (1.0 = the wall). ``offset`` IS the touch position, read from the well + top (the Opentrons Python API's absolute ``v_offset``): it REPLACES the + 1 mm below the rim this touches at by default rather than shifting it the + way an ``aspirate``/``dispense`` offset does, so ``Coordinate(x=1)`` + touches level with the rim. Requires at least one mounted tip and a valid + column (both checked before any wire command) and ALL nozzle mode (reset + first if a single-tip op left the layout otherwise). No trackers are + involved. """ self._warn_untested_hardware("touch_tip") self._require_mounted_tip() @@ -1288,13 +1319,23 @@ async def try_liquid_probe(self, plate: Plate, column: int) -> Optional[float]: # --- Single-tip cherry-pick --- @staticmethod - def _channel_for_well(well: str) -> int: - """Map a well name's row letter to its physical channel index (A=0..H=7).""" - row_letter = well[0].upper() - try: - return _ROW_LETTERS.index(row_letter) - except ValueError: - raise ValueError(f"'{well}' has no recognized row letter (expected A-H).") from None + def _nozzle_for_well_row(well: str) -> str: + """The single nozzle to anchor on when the caller named only a well. + + An 8-channel Flex can anchor a SINGLE layout on its A1 or H1 nozzle and + nothing else (the engine's ``primaryNozzle`` literal, and the pipette's + own ``validNozzleMaps``), so only the two rows those nozzles sit in have + an obvious anchor. Any other row needs the caller to say which end of the + row to reach with. + """ + row_letter = well[:1].upper() + if row_letter not in _SINGLE_NOZZLE_ROWS: + raise ValueError( + f"'{well}' is in row {row_letter or '?'}, and an 8-channel Flex can pick up single " + f"tips only with its {' or '.join(_SINGLE_NOZZLES)} nozzle. Pass " + 'primary_nozzle="A1" or "H1" to reach this well with that nozzle.' + ) + return f"{row_letter}1" def _active_single_channel(self) -> int: """Return the sole channel holding a tip in single-tip mode. @@ -1316,38 +1357,42 @@ async def pick_up_single_tip( tip_rack: TipRack, well: str, offset: Optional[Coordinate] = None, + primary_nozzle: Optional[str] = None, ) -> None: """Pick up one tip in SINGLE nozzle mode. Switches to SINGLE layout (``configureNozzleLayout``) before the - ``pickUpTip`` command. The physical nozzle engaged is the one whose row - matches ``well``'s row letter (e.g. well "H2" -> nozzle "H1" -> channel - 7); only that channel's tip state changes. Raises ``OpentronsError`` if - that channel already holds a tip (fix #4) -- checked before any wire - command. Tip tracker changes are staged (``commit=False``) before the - wire command, then, after the wire command succeeds, the hardware - tip-presence sensor is checked (``_verify_tips_seated()``) -- the - tracker and ``_channel_tips`` are committed only if that verification - passes, and rolled back (with no ``_channel_tips`` mutation) if the - sensor reports a missed pickup (stage -> validate -> wire -> verify -> - commit/rollback). + ``pickUpTip`` command. In that layout the pipette drives ONE nozzle, and + the engine moves it over whatever well is named -- so the nozzle, not the + well, decides which channel ends up holding the tip. An 8-channel Flex + can anchor on its "A1" or "H1" nozzle only (channel 0 or channel 7); + ``primary_nozzle`` picks between them, and defaults to the one sitting in + ``well``'s own row, which needs ``well`` to be in row A or H. Raises + ``OpentronsError`` if that channel already holds a tip -- checked, like + the nozzle itself, before any wire command. Tip tracker changes are + staged (``commit=False``) before the wire command, then, after the wire + command succeeds, the hardware tip-presence sensor is checked + (``_verify_tips_seated()``) -- the tracker and ``_channel_tips`` are + committed only if that verification passes, and rolled back (with no + ``_channel_tips`` mutation) if the sensor reports a missed pickup (stage + -> validate -> wire -> verify -> commit/rollback). """ self._warn_untested_hardware("pick_up_single_tip") - channel = self._channel_for_well(well) + if primary_nozzle is None: + primary_nozzle = self._nozzle_for_well_row(well) + elif primary_nozzle not in _SINGLE_NOZZLES: + raise ValueError( + f"primary_nozzle={primary_nozzle!r}: an 8-channel Flex can anchor a single-nozzle " + f"layout only on {' or '.join(_SINGLE_NOZZLES)}." + ) + channel = _SINGLE_NOZZLES[primary_nozzle] if self._channel_tips[channel] is not None: raise OpentronsError( "HasTipError", f"Channel {channel} already holds a tip; drop it before picking up another.", ) - primary_nozzle = f"{_ROW_LETTERS[channel]}1" - await self._execute( - "configureNozzleLayout", - { - "pipetteId": self.pipette_id, - "configurationParams": {"style": "SINGLE", "primaryNozzle": primary_nozzle}, - }, - ) + await self._configure_nozzle_layout({"style": "SINGLE", "primaryNozzle": primary_nozzle}) self._nozzle_layout = "SINGLE" labware_id = await self.flex._ensure_labware_loaded(tip_rack) @@ -1456,10 +1501,7 @@ async def drop_single_tip(self, trash: Trash) -> None: self._channel_tips[channel] = None await self._confirm_tips_cleared() - await self._execute( - "configureNozzleLayout", - {"pipetteId": self.pipette_id, "configurationParams": {"style": "ALL"}}, - ) + await self._configure_nozzle_layout({"style": "ALL"}) self._nozzle_layout = "ALL" @@ -1533,10 +1575,7 @@ async def pick_up_tips( f"Channel {i} already holds a tip; drop it before picking up another.", ) - await self._execute( - "configureNozzleLayout", - {"pipetteId": self.pipette_id, "configurationParams": {"style": "ALL"}}, - ) + await self._configure_nozzle_layout({"style": "ALL"}) labware_id = await self.flex._ensure_labware_loaded(tip_rack) tracking = does_tip_tracking() @@ -1628,17 +1667,18 @@ async def aspirate( (None-skip). A bare ``Container`` (trough/reservoir) is its own robot-side labware whose single-cavity definition exposes exactly one well, named "A1": the engine centers the 12x8 nozzle grid in the - cavity itself (the definition's ``centerMultichannelOnWells`` quirk), - at least one mounted tip and a cavity footprint containing the grid's - 99 x 63 mm span even after the offset shifts it are required (checked - before any wire command), and the container's single tracker is staged - with ``volume * (channels holding tips)``. Either way: stage -> - validate -> wire -> commit/rollback, with an infeasible aspirate - raising before any hardware motion, and a ``prepareToAspirate`` - command sent first if this is the first aspirate since the last tip - pickup. + cavity itself (the definition's ``centerMultichannelOnWells`` quirk), a + footprint containing the grid's 99 x 63 mm span even after the offset + shifts it is required, and the container's single tracker is staged + with ``volume * (channels holding tips)``. Either way at least one + mounted tip is required, and: stage -> validate -> wire -> + commit/rollback, with an infeasible aspirate raising before any + hardware motion (every check above runs before any wire command), and a + ``prepareToAspirate`` command sent first if this is the first aspirate + since the last tip pickup. """ self._warn_untested_hardware("aspirate") + self._require_mounted_tip() rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE staged_trackers: List[Any] = [] if isinstance(target, Plate): @@ -1652,7 +1692,6 @@ async def aspirate( well.tracker.remove_liquid(volume=volume) # stages + validates staged_trackers.append(well.tracker) else: - self._require_mounted_tip() self._require_span_fits_container( target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN, offset ) @@ -1688,14 +1727,15 @@ async def dispense( ``Well.tracker`` (``add_liquid``) staged per tip-holding channel (None-skip); a bare ``Container`` is addressed at its sole robot-side well "A1" with the engine centering the nozzle grid in the cavity - (the ``centerMultichannelOnWells`` quirk), the same pre-wire guards - (mounted tip, offset-shifted grid fits the cavity footprint), and - the container's single tracker staged with ``volume * (channels - holding tips)``. Either way: stage -> validate -> wire -> + (the ``centerMultichannelOnWells`` quirk), the same pre-wire footprint + guard (offset-shifted grid fits), and the container's single tracker + staged with ``volume * (channels holding tips)``. Either way at least + one mounted tip is required, and: stage -> validate -> wire -> commit/rollback, with an infeasible dispense raising before any hardware motion. """ self._warn_untested_hardware("dispense") + self._require_mounted_tip() rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE staged_trackers: List[Any] = [] if isinstance(target, Plate): @@ -1709,7 +1749,6 @@ async def dispense( well.tracker.add_liquid(volume=volume) # stages + validates staged_trackers.append(well.tracker) else: - self._require_mounted_tip() self._require_span_fits_container( target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN, offset ) @@ -1741,8 +1780,13 @@ async def touch_tip( anchored at "A1", fanned to all 96 channels. ``radius`` is the fraction of the well radius each tip moves toward - (1.0 = the wall). Requires at least one mounted tip and a 96-position - plate (both checked before any wire command). No trackers are involved. + (1.0 = the wall). ``offset`` IS the touch position, read from the well + top (the Opentrons Python API's absolute ``v_offset``): it REPLACES the + 1 mm below the rim this touches at by default rather than shifting it the + way an ``aspirate``/``dispense`` offset does, so ``Coordinate(x=1)`` + touches level with the rim. Requires at least one mounted tip and a + 96-position plate (both checked before any wire command). No trackers are + involved. """ self._warn_untested_hardware("touch_tip") self._require_mounted_tip() diff --git a/pylabrobot/opentrons/flex_motion_tests.py b/pylabrobot/opentrons/flex_motion_tests.py index c2dfc578449..b2fae5ce8fe 100644 --- a/pylabrobot/opentrons/flex_motion_tests.py +++ b/pylabrobot/opentrons/flex_motion_tests.py @@ -54,33 +54,10 @@ def _cmds(transport: ChatterboxTransport, command_type: str) -> List[Dict[str, A return [c for c in transport.commands if c["commandType"] == command_type] -class _VersionedTransport(ChatterboxTransport): - """Chatterbox whose ``/health`` reports a caller-chosen robot software - version, so tests can drive the robot/* version gate. - """ - - def __init__(self, api_version: str, **kwargs) -> None: - super().__init__(**kwargs) - self._api_version = api_version - - async def get(self, path: str) -> Dict[str, Any]: - if path == "/health": - return { - "api_version": self._api_version, - "robot_model": "OT-3 Standard", - "name": "chatterbox", - } - return await super().get(path) - - def _flex_with_version(api_version: str) -> Tuple[OpentronsFlex, ChatterboxTransport]: - transport = _VersionedTransport( - api_version, - pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], - gripper=True, - ) - flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) - return flex, transport + """A gripper-equipped Flex whose ``/health`` reports ``api_version``, so a + test can drive the robot/* version gate.""" + return _flex_with_gripper(api_version=api_version) class TestHeadPosition(unittest.TestCase): @@ -366,6 +343,28 @@ def test_dev_build_passes(self): finally: asyncio.run(flex.stop()) + def test_dev_build_cut_off_a_too_old_tag_is_still_gated(self): + # An untagged build reports 0.0.0.dev*; a build cut off a release tag + # reports that tag plus a dev suffix, and is as old as the tag says. + flex, transport = _flex_with_version("8.1.0.dev5") + asyncio.run(flex.setup()) + try: + with self.assertRaises(OpentronsError) as ctx: + asyncio.run(_gripper(flex).open_jaw()) + self.assertIn("8.2.0", str(ctx.exception)) + self._assert_no_robot_commands(transport) + finally: + asyncio.run(flex.stop()) + + def test_dev_build_cut_off_a_new_enough_tag_passes(self): + flex, transport = _flex_with_version("8.2.0.dev3") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + def test_default_chatterbox_passes(self): flex, transport = _flex_with_gripper() # /health reports "dry-run" asyncio.run(flex.setup()) diff --git a/pylabrobot/opentrons/flex_wire.py b/pylabrobot/opentrons/flex_wire.py new file mode 100644 index 00000000000..ca81ce3f0db --- /dev/null +++ b/pylabrobot/opentrons/flex_wire.py @@ -0,0 +1,31 @@ +"""Wire-level facts shared by the Flex device, its heads and its gripper. + +Small pieces that more than one of :mod:`~pylabrobot.opentrons.flex`, +:mod:`~pylabrobot.opentrons.flex_head` and +:mod:`~pylabrobot.opentrons.flex_gripper` needs, and that belong to none of +them: how a deck slot is spelled on the wire, and the notice every +not-yet-hardware-verified op logs. They live here so the always-present device +module does not have to reach into the optional gripper module (or the heads) +for them. +""" + +from typing import Dict, FrozenSet + +# Shared by the heads and the gripper so the notice reads identically +# everywhere; each module logs it through its own logger. +UNTESTED_HARDWARE_WARNING = ( + "%s.%s is coded but NOT YET VERIFIED on real Opentrons Flex hardware -- " + "tested only against ChatterboxTransport/simulated transport. Verify behavior " + "on real hardware before relying on it in a production protocol." +) + +# The robot-server's DeckSlotName covers only the A1-D3 grid; the column-4 +# staging slots are addressable areas and ride a different location key. +STAGING_SLOT_NAMES: FrozenSet[str] = frozenset({"A4", "B4", "C4", "D4"}) + + +def slot_wire_location(slot: str) -> Dict[str, str]: + """The ``loadLabware``/``moveLabware`` location for a Flex slot name.""" + if slot in STAGING_SLOT_NAMES: + return {"addressableAreaName": slot} + return {"slotName": slot} diff --git a/pylabrobot/opentrons/labware_definitions.py b/pylabrobot/opentrons/labware_definitions.py index b28d0a93879..fb2b780083c 100644 --- a/pylabrobot/opentrons/labware_definitions.py +++ b/pylabrobot/opentrons/labware_definitions.py @@ -9,8 +9,22 @@ Frame note: PLR and Opentrons schema-2 definitions both anchor labware at the front-left-bottom corner of the slot (every shipped Opentrons definition -carries ``cornerOffsetFromSlot`` of zero), so PLR geometry carries over -directly -- well positions included. +carries ``cornerOffsetFromSlot`` of zero), so PLR positions carry over +directly -- for UNROTATED labware. Schema 2 has no way to express a rotated +labware, so the plate and tip-rack builders refuse one rather than upload a +definition whose wells and footprint disagree. + +Height note: an Opentrons well's ``z`` is the CAVITY floor ("center-bottom of +well" in the schema), while a PLR well's/container's own origin is its OUTER +bottom. The two differ by the labware's wall thickness +(``Container.material_z_thickness``), so the pipettable builders add it -- +without that, the default 1 mm bottom clearance aims the tip INTO the plastic. +(The movable stub is exempt: its one fake well is somewhere to grip, not to +pipette, which is why pipetting ops refuse stub-loaded labware outright.) + +The builders are pure: they raise ``ValueError`` for geometry no Opentrons +definition can describe, and :class:`~pylabrobot.opentrons.flex.OpentronsFlex` +turns that into an ``OpentronsError`` before any wire command. """ import hashlib @@ -23,7 +37,11 @@ CrossSectionType, Plate, Resource, + Tip, TipRack, + TipSpot, + Trough, + TroughBottomType, Well, WellBottomType, ) @@ -40,6 +58,15 @@ WellBottomType.UNKNOWN: "flat", } +# The robot-server validates wellBottomShape as Literal["flat", "u", "v"], +# so the enum's own "U"/"V"/"unknown" values cannot be passed through. +_TROUGH_BOTTOM_SHAPES = { + TroughBottomType.FLAT: "flat", + TroughBottomType.U: "u", + TroughBottomType.V: "v", + TroughBottomType.UNKNOWN: "flat", +} + def _definition_load_name(resource: Resource) -> str: """Sanitized resource name plus a short digest of the raw name. @@ -62,26 +89,103 @@ def _format_from_grid(num_items_x: int, num_items_y: int) -> str: return "irregular" -def container_cavity_footprint(container: Container) -> Tuple[float, float]: - """The cavity's x/y footprint in the deck frame, as the robot sees it. +def container_footprint(container: Container) -> Tuple[float, float]: + """The container's x/y bounding box in the deck frame, as the robot sees it. + + This is the OUTER footprint, an upper bound on the cavity: PLR containers + carry their overall size, not their cavity's, so a trough's real cavity is + narrower than what this returns by the wall thickness on each side. Ops that + reason about whether a nozzle array fits, and the uploaded definition's own + well, both read this so they can never disagree. Rotation-aware: a rotated container (or one under a rotated parent) presents - its bounding box to the robot, which has no notion of PLR's rotation. Ops - that reason about whether a nozzle array fits the cavity must read this - rather than the container's own ``get_size_x``/``get_size_y``, so the - guard and the uploaded definition below can never disagree. + its bounding box to the robot, which has no notion of PLR's rotation, so + this must be read rather than the container's own + ``get_size_x``/``get_size_y``. """ return container.get_absolute_size_x(), container.get_absolute_size_y() +def _cavity_floor_z(container: Container) -> float: + """The height of the cavity floor above the container's own bottom. + + ``Container.material_z_thickness`` is optional in PLR and raises when it was + never declared. There is no safe default: falling back to zero would put the + Opentrons well floor at the labware's outer base and aim every default + aspirate a wall thickness INTO the plastic, so an undeclared thickness is + refused instead. + """ + try: + return container.material_z_thickness + except NotImplementedError as e: + raise ValueError( + f"'{container.name}' does not declare material_z_thickness, so the height of its " + "cavity floor above its base is unknown. An Opentrons definition anchors liquid " + "ops at that floor, so building one would aim them at the labware's outer base " + "instead. Give the resource a material_z_thickness, or an official Opentrons " + "load name." + ) from e + + +def _require_unrotated(resource: Resource) -> None: + """Refuse a rotated resource: an Opentrons definition cannot describe one. + + A schema-2 definition places its front-left-bottom corner at the slot's, and + positions every well from that corner, with no rotation anywhere. PLR + rotates about the resource's own origin without recentering, so baking the + rotation into the well coordinates would still leave the labware body and + the slot disagreeing. + """ + rotation = resource.get_absolute_rotation() + if (rotation.x, rotation.y, rotation.z) != (0, 0, 0): + raise ValueError( + f"'{resource.name}' has absolute rotation {rotation}. An Opentrons labware " + "definition anchors wells to the slot's front-left corner and cannot express a " + "rotated labware. Assign it unrotated, or give it an official Opentrons load name." + ) + + +def _container_bottom_shape(container: Container) -> str: + """The schema's ``wellBottomShape`` for any container the builders can see. + + Only troughs and wells carry a bottom shape; a plain ``Container``, + ``PetriDish``, ``Trash`` or ``Tube`` has none, so they get the schema's + safest default. + """ + if isinstance(container, Trough): + return _TROUGH_BOTTOM_SHAPES[container.bottom_type] + if isinstance(container, Well): + return _WELL_BOTTOM_SHAPES[container.bottom_type] + return "flat" + + def _well_shape(well: Well) -> dict: if well.cross_section_type == CrossSectionType.RECTANGLE: return { "shape": "rectangular", - "xDimension": well.get_absolute_size_x(), - "yDimension": well.get_absolute_size_y(), + "xDimension": well.get_size_x(), + "yDimension": well.get_size_y(), } - return {"shape": "circular", "diameter": well.get_absolute_size_x()} + return {"shape": "circular", "diameter": well.get_size_x()} + + +def _plate_well(well: Well) -> dict: + """One ``wells`` entry: the well's cavity floor, depth and footprint. + + ``depth`` is the well's own size_z, which in PLR already spans cavity floor + to rim, so ``z + depth`` lands on the plate's top the way every shipped + Opentrons plate definition does. + """ + _require_unrotated(well) + location = cast(Coordinate, well.location) + return { + "depth": well.get_size_z(), + "x": location.x + well.get_size_x() / 2, + "y": location.y + well.get_size_y() / 2, + "z": location.z + _cavity_floor_z(well), + "totalLiquidVolume": well.max_volume, + **_well_shape(well), + } def build_plate_definition(plate: Plate, grip_distance_from_top: Optional[float] = None) -> dict: @@ -91,10 +195,17 @@ def build_plate_definition(plate: Plate, grip_distance_from_top: Optional[float] rectangular) and bottom shape so well-referencing commands (``touchTip``, ``liquidProbe``) get the true geometry. Wells are keyed by their PLR child identifier ("A1" style), matching the ``wellName`` the pipetting commands - send. ``gripHeightFromLabwareBottom`` is included only when + send. Each well's ``z`` is its CAVITY floor -- its PLR origin plus its wall + thickness -- which puts the well top (``z + depth``) at the plate's real + rim. ``gripHeightFromLabwareBottom`` is included only when ``grip_distance_from_top`` is given; without it the robot-server grips at its default mid-height. + + Raises: + ValueError: If the plate (or a well) is rotated, or a well does not + declare the wall thickness its cavity floor is measured from. """ + _require_unrotated(plate) wells = plate.get_all_items() well_names = [plate.get_child_identifier(well) for well in wells] definition: dict = { @@ -120,17 +231,7 @@ def build_plate_definition(plate: Plate, grip_distance_from_top: Optional[float] "yDimension": plate.get_absolute_size_y(), "zDimension": plate.get_absolute_size_z(), }, - "wells": { - plate.get_child_identifier(well): { - "depth": well.get_absolute_size_z(), - "x": cast(Coordinate, well.location).x + well.get_absolute_size_x() / 2, - "y": cast(Coordinate, well.location).y + well.get_absolute_size_y() / 2, - "z": cast(Coordinate, well.location).z, - "totalLiquidVolume": well.max_volume, - **_well_shape(well), - } - for well in wells - }, + "wells": {plate.get_child_identifier(well): _plate_well(well) for well in wells}, "groups": [ { "wells": well_names, @@ -145,14 +246,53 @@ def build_plate_definition(plate: Plate, grip_distance_from_top: Optional[float] return definition +def _tip_rack_well(tip_rack: TipRack, spot: TipSpot, tip: Tip) -> dict: + """One ``wells`` entry describing the tip seated in ``spot``. + + A ``TipSpot`` has no height of its own (PLR leaves its size_z at zero), so + the depth comes from the prototype tip the rack's ``tipLength`` already + reports; using the spot's box would send a zero-depth well and collapse the + pickup target onto the rack's base. + """ + _require_unrotated(spot) + location = cast(Coordinate, spot.location) + if location.z < 0: + raise ValueError( + f"'{tip_rack.name}' seats its tips {-location.z} mm BELOW its own base (spot " + f"'{spot.name}'), which an Opentrons definition cannot express -- its well z must " + "be non-negative, and the robot-server rejects the upload outright. Re-anchor the " + "rack so its tip seats sit at or above its base, or give it an official Opentrons " + "load name." + ) + return { + "depth": tip.total_tip_length, + "x": location.x + spot.get_size_x() / 2, + "y": location.y + spot.get_size_y() / 2, + "z": location.z, + # Tip-rack wells stay circular regardless of the PLR cross-section: + # the engine rejects non-circular tip-rack wells (LabwareIsNotTipRackError). + "shape": "circular", + "diameter": spot.get_size_x(), + "totalLiquidVolume": tip.maximal_volume, + } + + def build_tip_rack_definition( tip_rack: TipRack, grip_distance_from_top: Optional[float] = None ) -> dict: """Build a robot-server tipRack definition from a PLR tip rack's geometry. Tip length and overlap come from the rack's A1 prototype tip, so the robot - computes the same pickup z the PLR tip model implies. + computes the same pickup z the PLR tip model implies. A tip-rack well + describes the seated TIP, not the spot's own box: ``z`` is where the tip + end sits and ``depth`` is the tip's length, so ``z + depth`` is the tip's + top -- the height a ``pickUpTip`` (top origin) descends to. + + Raises: + ValueError: If the rack (or a spot) is rotated, or its tips reach below + the rack's own base, which schema 2 cannot express. """ + _require_unrotated(tip_rack) tip = tip_rack.get_item("A1").make_tip() spot_names = [tip_rack.get_child_identifier(spot) for spot in tip_rack.get_all_items()] definition: dict = { @@ -181,17 +321,7 @@ def build_tip_rack_definition( "zDimension": tip_rack.get_absolute_size_z(), }, "wells": { - tip_rack.get_child_identifier(spot): { - "depth": spot.get_absolute_size_z(), - "x": cast(Coordinate, spot.location).x + spot.get_absolute_size_x() / 2, - "y": cast(Coordinate, spot.location).y + spot.get_absolute_size_y() / 2, - "z": cast(Coordinate, spot.location).z, - # Tip-rack wells stay circular regardless of the PLR cross-section: - # the engine rejects non-circular tip-rack wells (LabwareIsNotTipRackError). - "shape": "circular", - "diameter": spot.get_absolute_size_x(), - "totalLiquidVolume": tip.maximal_volume, - } + tip_rack.get_child_identifier(spot): _tip_rack_well(tip_rack, spot, tip) for spot in tip_rack.get_all_items() }, "groups": [ @@ -223,9 +353,20 @@ def build_container_definition( quirk matches every shipped Opentrons 1-well reservoir: the engine centers a multi-channel nozzle array on the cavity itself, so ops send no manual centering offsets. + + The cavity floor sits the container's wall thickness above its base, and the + cavity depth shrinks by the same amount, so the well's top stays at the + container's real rim (``z + depth == zDimension``, as on every shipped + Opentrons reservoir). The well's x/y footprint is the container's OUTER one + (see ``container_footprint``): PLR carries no cavity x/y to narrow it with. + + Raises: + ValueError: If the container does not declare the wall thickness its + cavity floor is measured from. """ - size_x, size_y = container_cavity_footprint(container) + size_x, size_y = container_footprint(container) size_z = container.get_absolute_size_z() + floor_z = _cavity_floor_z(container) definition: dict = { "schemaVersion": _SCHEMA_VERSION, "version": _VERSION, @@ -248,17 +389,19 @@ def build_container_definition( "dimensions": {"xDimension": size_x, "yDimension": size_y, "zDimension": size_z}, "wells": { "A1": { - "depth": size_z, + "depth": size_z - floor_z, "x": size_x / 2, "y": size_y / 2, - "z": 0, + "z": floor_z, "shape": "rectangular", "xDimension": size_x, "yDimension": size_y, "totalLiquidVolume": container.max_volume, } }, - "groups": [{"wells": ["A1"], "metadata": {"wellBottomShape": "flat"}}], + "groups": [ + {"wells": ["A1"], "metadata": {"wellBottomShape": _container_bottom_shape(container)}} + ], } if grip_distance_from_top is not None: definition["gripHeightFromLabwareBottom"] = max(0.0, size_z - grip_distance_from_top) diff --git a/pylabrobot/opentrons/labware_definitions_tests.py b/pylabrobot/opentrons/labware_definitions_tests.py index 45403f434da..e305efd0319 100644 --- a/pylabrobot/opentrons/labware_definitions_tests.py +++ b/pylabrobot/opentrons/labware_definitions_tests.py @@ -20,11 +20,12 @@ build_movable_labware_definition, build_plate_definition, build_tip_rack_definition, - container_cavity_footprint, + container_footprint, ) from pylabrobot.opentrons.robot import OpentronsError from pylabrobot.opentrons.transport import ChatterboxTransport from pylabrobot.resources import ( + Container, CrossSectionType, Plate, Resource, @@ -32,11 +33,17 @@ TipRack, TipSpot, Trough, + TroughBottomType, TubeRack, Well, WellBottomType, ) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.corning import cor_96_wellplate_360uL_Fb +from pylabrobot.resources.hamilton import hamilton_1_trough_60mL_Vb +from pylabrobot.resources.opentrons import opentrons_96_filtertiprack_200ul from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul from pylabrobot.resources.rotation import Rotation from pylabrobot.resources.tip import Tip from pylabrobot.resources.utils import create_ordered_items_2d @@ -48,8 +55,14 @@ def _plate( num_items_y: int = 2, cross_section_type: CrossSectionType = CrossSectionType.CIRCLE, bottom_type: WellBottomType = WellBottomType.UNKNOWN, + material_z_thickness: Optional[float] = 0.5, ) -> Plate: - """A plate with hand-picked geometry so expected numbers are exact.""" + """A plate with hand-picked geometry so expected numbers are exact. + + ``material_z_thickness`` is the wall between a well's own bottom and the + cavity floor liquid ops are anchored at; ``None`` builds the wells without + one, which is the shape the builder refuses. + """ return Plate( name=name, size_x=127.0, @@ -67,6 +80,7 @@ def _plate( size_x=6.0, size_y=6.0, size_z=10.0, + material_z_thickness=material_z_thickness, max_volume=360.0, cross_section_type=cross_section_type, bottom_type=bottom_type, @@ -75,9 +89,16 @@ def _plate( def _tip_rack( - name: str = "hamilton tips 300", num_items_x: int = 2, num_items_y: int = 2 + name: str = "hamilton tips 300", + num_items_x: int = 2, + num_items_y: int = 2, + dz: float = 0.0, ) -> TipRack: - """A tip rack with hand-picked geometry and a pinned prototype tip.""" + """A tip rack with hand-picked geometry and a pinned prototype tip. + + ``dz`` is where the seated tips' ends sit relative to the rack's own base; + a negative one models the Hamilton-style racks whose tips hang below it. + """ def make_tip(name: str) -> Tip: return Tip( @@ -99,7 +120,7 @@ def make_tip(name: str) -> Tip: num_items_y=num_items_y, dx=10.0, dy=8.0, - dz=0.0, + dz=dz, item_dx=9.0, item_dy=9.0, size_x=5.0, @@ -109,8 +130,21 @@ def make_tip(name: str) -> Tip: ) -def _trough(name: str = "hamilton trough") -> Trough: - return Trough(name=name, size_x=120.0, size_y=80.0, size_z=40.0, max_volume=290000.0) +def _trough( + name: str = "hamilton trough", + material_z_thickness: Optional[float] = 1.5, + bottom_type: TroughBottomType = TroughBottomType.UNKNOWN, +) -> Trough: + """A single-cavity trough; ``material_z_thickness`` is its floor thickness.""" + return Trough( + name=name, + size_x=120.0, + size_y=80.0, + size_z=40.0, + material_z_thickness=material_z_thickness, + bottom_type=bottom_type, + max_volume=290000.0, + ) def _tube_rack(name: str = "tube rack") -> TubeRack: @@ -189,13 +223,15 @@ def test_corner_offset_is_zero_front_left_anchor(self): def test_well_geometry_carries_depth_volume_and_centers(self): definition = build_plate_definition(_plate()) # A1 (back-left well): origin (10, 17, 1), 6 mm square, so center (13, 20). + # Its z is the CAVITY floor: the well's own bottom (1.0) plus the 0.5 mm + # of plastic beneath it, matching how Opentrons reads a well's z. self.assertEqual( definition["wells"]["A1"], { "depth": 10.0, "x": 13.0, "y": 20.0, - "z": 1.0, + "z": 1.5, "shape": "circular", "diameter": 6.0, "totalLiquidVolume": 360.0, @@ -205,6 +241,46 @@ def test_well_geometry_carries_depth_volume_and_centers(self): self.assertEqual(definition["wells"]["B1"]["y"], 11.0) self.assertEqual(definition["groups"][0]["wells"], ["A1", "B1", "A2", "B2"]) + def test_well_z_is_the_cavity_floor_not_the_wells_outer_bottom(self): + # The default liquid position is 1 mm above a well's z, so a z at the + # well's outer bottom aims it into the plastic. PLR's corning plate: A1 + # bottom 3.03 + 0.5 mm wall; Opentrons ships z = 3.55 for that plate. + definition = build_plate_definition(cor_96_wellplate_360uL_Fb(name="corning")) + self.assertAlmostEqual(definition["wells"]["A1"]["z"], 3.53) + self.assertAlmostEqual(definition["wells"]["A1"]["depth"], 10.67) + # z + depth is the real rim, which is what touchTip and liquidProbe ride. + self.assertAlmostEqual( + definition["wells"]["A1"]["z"] + definition["wells"]["A1"]["depth"], + definition["dimensions"]["zDimension"], + ) + + def test_plate_without_material_thickness_is_refused(self): + # No cavity floor to anchor liquid ops at, and no safe default: falling + # back to zero is what aims them at the plastic. + with self.assertRaises(ValueError) as caught: + build_plate_definition(_plate(material_z_thickness=None)) + self.assertIn("material_z_thickness", str(caught.exception)) + self.assertIn("Black Plate-1", str(caught.exception)) + + def test_rotated_plate_is_refused(self): + # An Opentrons definition positions wells from the slot's front-left + # corner and cannot express a rotation, so a rotated plate would upload + # unrotated well coordinates inside a rotated bounding box. + for angle in (90, 180): + plate = _plate() + plate.rotation = Rotation(z=angle) + with self.assertRaises(ValueError) as caught: + build_plate_definition(plate) + self.assertIn("rotation", str(caught.exception)) + + def test_plate_under_a_rotated_parent_is_refused(self): + holder = ResourceHolder(name="holder", size_x=130.0, size_y=90.0, size_z=1.0) + holder.rotation = Rotation(z=90) + plate = _plate() + holder.assign_child_resource(plate, location=Coordinate.zero()) + with self.assertRaises(ValueError): + build_plate_definition(plate) + def test_rectangular_wells_carry_x_y_dimensions(self): definition = build_plate_definition(_plate(cross_section_type=CrossSectionType.RECTANGLE)) well = definition["wells"]["A1"] @@ -263,11 +339,13 @@ def test_spot_geometry_and_zero_corner_offset(self): definition = build_tip_rack_definition(_tip_rack()) self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) self.assertEqual(definition["ordering"], [["A1", "B1"], ["A2", "B2"]]) - # A1 spot origin (10, 17, 0), 5 mm square: center (12.5, 19.5). + # A1 spot origin (10, 17, 0), 5 mm square: center (12.5, 19.5). The depth + # is the prototype tip's length: a TipSpot has no height of its own, and + # a pickUpTip descends to z + depth, the seated tip's top. self.assertEqual( definition["wells"]["A1"], { - "depth": 0, + "depth": 50.0, "x": 12.5, "y": 19.5, "z": 0.0, @@ -277,6 +355,29 @@ def test_spot_geometry_and_zero_corner_offset(self): }, ) + def test_well_depth_reproduces_a_shipped_opentrons_rack(self): + # PLR builds this rack BY loading Opentrons' own definition, so the + # rebuilt one must carry that file's numbers back: z 5.39, depth 59.3, + # tipLength 59.3. + definition = build_tip_rack_definition(opentrons_96_filtertiprack_200ul(name="ot rack")) + self.assertAlmostEqual(definition["wells"]["A1"]["z"], 5.39) + self.assertAlmostEqual(definition["wells"]["A1"]["depth"], 59.3) + self.assertAlmostEqual(definition["parameters"]["tipLength"], 59.3) + + def test_rack_whose_tips_hang_below_its_base_is_refused(self): + # The robot-server's schema declares well z non-negative, so this uploads + # as a 422 per well; refuse it here, naming the rack and the spot. + with self.assertRaises(ValueError) as caught: + build_tip_rack_definition(_tip_rack(dz=-50.5)) + self.assertIn("hamilton tips 300", str(caught.exception)) + self.assertIn("50.5 mm BELOW", str(caught.exception)) + + def test_rotated_tip_rack_is_refused(self): + rack = _tip_rack() + rack.rotation = Rotation(z=90) + with self.assertRaises(ValueError): + build_tip_rack_definition(rack) + def test_grip_height_from_grip_distance(self): self.assertNotIn("gripHeightFromLabwareBottom", build_tip_rack_definition(_tip_rack())) definition = build_tip_rack_definition(_tip_rack(), grip_distance_from_top=10.0) @@ -297,14 +398,16 @@ def test_single_a1_cavity_spans_the_container(self): definition["dimensions"], {"xDimension": 120.0, "yDimension": 80.0, "zDimension": 40.0}, ) + # The cavity floor sits on the 1.5 mm of plastic under it, and the depth + # loses the same, so the well's top stays at the container's real rim. self.assertEqual( definition["wells"], { "A1": { - "depth": 40.0, + "depth": 38.5, "x": 60.0, "y": 40.0, - "z": 0, + "z": 1.5, "shape": "rectangular", "xDimension": 120.0, "yDimension": 80.0, @@ -314,13 +417,47 @@ def test_single_a1_cavity_spans_the_container(self): ) self.assertEqual(definition["groups"][0]["wells"], ["A1"]) + def test_cavity_floor_sits_on_the_wall_thickness(self): + # Pinned against real labware: hamilton_1_trough_60mL_Vb is 65.5 mm tall + # with a 1.58 mm floor, and every shipped Opentrons reservoir likewise + # has z + depth == zDimension with a non-zero z. + definition = build_container_definition(hamilton_1_trough_60mL_Vb(name="trough")) + well = definition["wells"]["A1"] + self.assertAlmostEqual(well["z"], 1.58) + self.assertAlmostEqual(well["depth"], 63.92) + self.assertAlmostEqual(well["z"] + well["depth"], definition["dimensions"]["zDimension"]) + + def test_container_without_material_thickness_is_refused(self): + with self.assertRaises(ValueError) as caught: + build_container_definition(_trough(material_z_thickness=None)) + self.assertIn("material_z_thickness", str(caught.exception)) + self.assertIn("hamilton trough", str(caught.exception)) + + def test_well_bottom_shape_maps_from_the_troughs_bottom_type(self): + # The enum's own values are "U"/"V"/"unknown", which the robot-server's + # Literal["flat", "u", "v"] rejects, so the mapping must lower-case them. + for bottom_type, expected in ( + (TroughBottomType.V, "v"), + (TroughBottomType.U, "u"), + (TroughBottomType.FLAT, "flat"), + (TroughBottomType.UNKNOWN, "flat"), + ): + definition = build_container_definition(_trough(bottom_type=bottom_type)) + self.assertEqual(definition["groups"][0]["metadata"], {"wellBottomShape": expected}) + # A plain Container carries no bottom shape at all. + plain = Container(name="plain", size_x=10.0, size_y=10.0, size_z=10.0, material_z_thickness=1.0) + self.assertEqual( + build_container_definition(plain)["groups"][0]["metadata"], + {"wellBottomShape": "flat"}, + ) + def test_rotated_cavity_uploads_the_shared_deck_frame_footprint(self): # The uploaded rectangle and the ops' fit guard must read the same # helper, or a rotated cavity is guarded on the wrong axis. trough = _trough() trough.rotation = Rotation(z=90) definition = build_container_definition(trough) - cavity_x, cavity_y = container_cavity_footprint(trough) + cavity_x, cavity_y = container_footprint(trough) self.assertEqual((cavity_x, cavity_y), (80.0, 120.0)) self.assertEqual(definition["dimensions"]["xDimension"], cavity_x) self.assertEqual(definition["dimensions"]["yDimension"], cavity_y) @@ -409,6 +546,13 @@ def _load_labware_commands(transport: ChatterboxTransport) -> list: return [c for c in transport.commands if c["commandType"] == "loadLabware"] +def _mount_tips(flex: OpentronsFlex, head: FlexHead8) -> None: + """Pick up a column of tips, so a liquid op reaches past the mounted-tip guard.""" + rack = flex_96_tiprack_50ul(name=f"tips for {head.mount}") + flex.deck.assign_child_at_slot(rack, "D1") + asyncio.run(head.pick_up_tips(rack, column=0)) + + class _FailFirstUploadTransport(ChatterboxTransport): """Chatterbox whose FIRST labware-definition upload raises; retries succeed.""" @@ -456,6 +600,7 @@ def test_pipetting_a_tube_rack_raises_before_any_wire_command(self): try: rack = _tube_rack() flex.deck.assign_child_at_slot(rack, "C1") + _mount_tips(flex, head) commands_before = len(transport.commands) with self.assertRaises(OpentronsError): @@ -494,6 +639,7 @@ def test_pipetting_after_a_gripper_move_still_raises_on_the_load_cache_hit(self) gripper = flex.gripper assert gripper is not None asyncio.run(gripper.move_labware(rack, "C2")) + _mount_tips(flex, head) commands_before = len(transport.commands) with self.assertRaises(OpentronsError): @@ -546,6 +692,21 @@ def test_plate_without_official_name_uploads_then_loads(self): finally: asyncio.run(flex.stop()) + def test_load_into_a_staging_slot_uses_the_addressable_area_form(self): + # The robot-server's DeckSlotName covers only the A1-D3 grid, so the + # column-4 staging slots ride a different location key. + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "A4") + asyncio.run(flex._ensure_labware_loaded(plate)) + + params = _load_labware_commands(transport)[0]["params"] + self.assertEqual(params["location"], {"addressableAreaName": "A4"}) + finally: + asyncio.run(flex.stop()) + def test_second_use_hits_cache_no_second_upload_or_load(self): flex, transport = _flex_with_transport() asyncio.run(flex.setup()) @@ -685,7 +846,30 @@ def test_official_name_labware_loads_with_zero_uploads(self): params = load_cmds[0]["params"] self.assertEqual(params["namespace"], "opentrons") self.assertEqual(params["loadName"], "corning_96_wellplate_360ul_flat") - self.assertEqual(params["version"], 1) + # Revision 2, not 1: version 1 of this plate declares no gripper grip + # height, and 2 adds it without moving a single well. + self.assertEqual(params["version"], 2) + finally: + asyncio.run(flex.stop()) + + def test_catalogue_version_falls_back_to_1_and_a_resource_can_override_it(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + rack = _tip_rack() + # Flex tip racks ship one revision, so they stay at 1. + rack.ot_load_name = "opentrons_flex_96_tiprack_50ul" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex._ensure_labware_loaded(rack)) + + pinned = _plate(name="pinned plate") + pinned.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + pinned.ot_version = 4 # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(pinned, "C2") + asyncio.run(flex._ensure_labware_loaded(pinned)) + + versions = [c["params"]["version"] for c in _load_labware_commands(transport)] + self.assertEqual(versions, [1, 4]) finally: asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index 08b197a26f0..549e55b0b6b 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -28,6 +28,10 @@ logger = logging.getLogger(__name__) +# ChatterboxTransport's default /health version: deliberately not a version +# string, so a caller gating on robot software can tell offline from any robot. +OFFLINE_API_VERSION = "dry-run" + @runtime_checkable class OpentronsTransport(Protocol): @@ -110,6 +114,7 @@ def __init__( simulate_liquid_probe_not_found: bool = False, gripper: bool = False, saved_position: Optional[Dict[str, float]] = None, + api_version: str = OFFLINE_API_VERSION, ) -> None: """Args: pipette: the simulated mounted pipette as ``(name, channels, min_vol, max_vol)``. @@ -149,6 +154,10 @@ def __init__( saved_position: the position a ``savePosition`` command reports in its result, as an ``{"x", "y", "z"}`` dict. Default None: report ``{"x": 100.0, "y": 100.0, "z": 100.0}``. + api_version: the robot software version ``/health`` reports. Defaults to + the ``OFFLINE_API_VERSION`` sentinel, which no real robot returns; pass + a real version string (e.g. ``"8.1.0"``) to drive a caller's own + version gating without subclassing. """ if pipettes is not None: self._pipettes: List[Tuple[str, int, float, float, str]] = list(pipettes) @@ -156,6 +165,7 @@ def __init__( name, channels, min_v, max_v = pipette self._pipettes = [(name, channels, min_v, max_v, mount)] self._gripper = gripper + self.api_version = api_version self._log = log or logger.info self._cmds: Dict[str, Dict[str, Any]] = {} # cmd_id -> full command data self._n = 0 @@ -177,7 +187,11 @@ def __init__( async def get(self, path: str) -> Dict[str, Any]: if path == "/health": - return {"api_version": "dry-run", "robot_model": "OT-3 Standard", "name": "chatterbox"} + return { + "api_version": self.api_version, + "robot_model": "OT-3 Standard", + "name": "chatterbox", + } if path == "/instruments": instruments: List[Dict[str, Any]] = [ { From 8b5a59f17d0afc0e4e87b3cfc6797c78890eb7c4 Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 22:21:56 -0400 Subject: [PATCH 08/36] fix(opentrons): clear a cherry-picked tip, and anchor it where the robot 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. --- .../opentrons/flex_fine_pipetting_tests.py | 103 +++++++++++-- pylabrobot/opentrons/flex_head.py | 140 ++++++++++++++---- 2 files changed, 205 insertions(+), 38 deletions(-) diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index 215f39d416e..cc9421b07ca 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -808,10 +808,10 @@ def setUp(self): def tearDown(self): set_tip_tracking(False) - def _bench(self): + def _bench(self, slot="C1"): flex, transport, head = _flex_head8() rack = flex_96_tiprack_50ul(name="rack") - flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(rack, slot) return flex, transport, head, rack def _nozzle_params(self, transport: ChatterboxTransport) -> list: @@ -821,16 +821,16 @@ def _nozzle_params(self, transport: ChatterboxTransport) -> list: if c["commandType"] == "configureNozzleLayout" ] - def test_a_middle_row_well_needs_an_explicit_nozzle(self): - # Deriving the nozzle from the row letter builds "C1", which the - # robot-server rejects at body validation before the command exists. + def test_a_middle_row_well_anchors_on_a_nozzle_that_can_reach_it(self): + # The row letter names no anchor ("C1" is not a primaryNozzle the + # robot-server accepts), so the reachable end is chosen instead. flex, transport, head, rack = self._bench() try: - commands_before = len(transport.commands) - with self.assertRaises(ValueError) as caught: - asyncio.run(head.pick_up_single_tip(rack, well="C3")) - self.assertIn("primary_nozzle", str(caught.exception)) - self.assertEqual(len(transport.commands), commands_before) + asyncio.run(head.pick_up_single_tip(rack, well="C3")) + self.assertEqual(self._nozzle_params(transport)[-1]["primaryNozzle"], "A1") + pickups = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(pickups[-1]["params"]["wellName"], "C3") + self.assertIsNotNone(head.get_mounted_tips()[0]) finally: asyncio.run(flex.stop()) @@ -898,6 +898,89 @@ def test_dropping_the_single_tip_restores_all_mode(self): finally: asyncio.run(flex.stop()) + def test_discarding_a_cherry_picked_tip_drops_it_then_restores_all_mode(self): + # A trash drop addresses no channel, so it must not wait on a layout + # reset the robot refuses while the tip is still on. + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + asyncio.run(head.discard_tips(flex.deck.get_trash_area())) + + self.assertTrue(all(tip is None for tip in head.get_mounted_tips())) + sent = [c["commandType"] for c in transport.commands] + self.assertLess(sent.index("dropTipInPlace"), len(sent) - 1) + self.assertEqual(sent[-1], "configureNozzleLayout") + self.assertEqual(self._nozzle_params(transport)[-1], {"style": "ALL"}) + finally: + asyncio.run(flex.stop()) + + def test_returning_a_cherry_picked_tip_to_a_rack_column_names_the_op_that_works(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + commands_before = len(transport.commands) + + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.drop_tips(rack, column=0)) + self.assertIn("drop_single_tip", str(caught.exception)) + self.assertEqual(len(transport.commands), commands_before) + + asyncio.run(head.drop_single_tip(flex.deck.get_trash_area())) + finally: + asyncio.run(flex.stop()) + + def test_a_front_row_slot_puts_the_rear_anchor_out_of_reach(self): + # Anchoring A1 over D1's front rows leaves the mount ahead of the robot's + # own front limit: the pipette body reaches 95mm forward of it. + flex, _, head, rack = self._bench(slot="D1") + try: + self.assertEqual(head.reachable_single_nozzles(rack, "A1"), ("A1", "H1")) + self.assertEqual(head.reachable_single_nozzles(rack, "F1"), ("H1",)) + self.assertEqual(head.reachable_single_nozzles(rack, "H1"), ("H1",)) + finally: + asyncio.run(flex.stop()) + + def test_a_front_row_well_anchors_on_the_nozzle_that_reaches_it(self): + flex, transport, head, rack = self._bench(slot="D1") + try: + asyncio.run(head.pick_up_single_tip(rack, well="F1")) + self.assertEqual(self._nozzle_params(transport)[-1]["primaryNozzle"], "H1") + self.assertIsNotNone(head.get_mounted_tips()[7]) + finally: + asyncio.run(flex.stop()) + + def test_an_explicit_out_of_reach_anchor_is_refused_naming_the_one_that_works(self): + # An explicit choice is never silently swapped. + flex, transport, head, rack = self._bench(slot="D1") + try: + commands_before = len(transport.commands) + with self.assertRaises(ValueError) as caught: + asyncio.run(head.pick_up_single_tip(rack, well="F1", primary_nozzle="A1")) + self.assertIn("H1", str(caught.exception)) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_every_flex_slot_well_is_reachable_by_one_anchor_or_the_other(self): + # No slot the deck accepts leaves a well unpickable; A3 holds the trash. + for slot in [f"{row}{col}" for row in "ABCD" for col in "123" if f"{row}{col}" != "A3"]: + flex, _, head, rack = self._bench(slot=slot) + try: + for well_row in "ABCDEFGH": + reachable = head.reachable_single_nozzles(rack, f"{well_row}1") + self.assertTrue(reachable, f"{slot} {well_row}1 unreachable both ways") + finally: + asyncio.run(flex.stop()) + + def test_stop_leaves_no_cherry_picked_tip_on_the_pipette(self): + flex, transport, head, rack = self._bench() + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + + asyncio.run(flex.stop()) + + self.assertIn("dropTipInPlace", [c["commandType"] for c in transport.commands]) + self.assertTrue(all(tip is None for tip in head.get_mounted_tips())) + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index b8b017fe6c2..93cb86e9b80 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -580,7 +580,18 @@ async def move_to( # The only nozzles an 8-channel Flex can anchor a SINGLE layout on ("A1" is # the rearmost, "H1" the frontmost), mapped to the channel each one is. _SINGLE_NOZZLES = {"A1": 0, "H1": 7} -_SINGLE_NOZZLE_ROWS = frozenset(nozzle[0] for nozzle in _SINGLE_NOZZLES) +_SINGLE_NOZZLE_BY_CHANNEL = {channel: nozzle for nozzle, channel in _SINGLE_NOZZLES.items()} + +# Each anchor nozzle's y offset from the pipette mount, and the pipette body's own +# reach back/forward of it. shared-data pipette geometry v2, eight_channel p50 == p1000. +_SINGLE_NOZZLE_Y = {"A1": -16.0, "H1": -79.0} +_PIPETTE_BODY_BACK_Y = 0.0 +_PIPETTE_BODY_FRONT_Y = -95.0 + +# The deck y band that body has to stay inside. +# shared-data robot definition ot3.json: padding front 51.8 / rear -169.42, extents y 493.8. +_ROBOT_FRONT_LIMIT = 51.8 +_ROBOT_REAR_LIMIT = 493.8 - 169.42 _NUM_CHANNELS = 8 @@ -1035,16 +1046,28 @@ async def drop_tips( drops never return tips to a rack tracker. After the wire drop + tracker commit, ``_confirm_tips_cleared()`` checks the hardware tip-presence sensor and logs a warning (does not raise) if it still reports a tip. + + The trash drop resets the nozzle layout AFTER the drop, not before: it + addresses no channel, and the robot refuses every reconfiguration while + a tip is on -- so resetting first would make this op, the only one that + can clear a cherry-picked tip, need the tip already gone. """ self._warn_untested_hardware("drop_tips") if isinstance(target, Trash): - await self._ensure_all_mode() await self._execute_trash_drop() self._channel_tips = [None] * self.channels await self._confirm_tips_cleared() + await self._ensure_all_mode() return + if self._nozzle_layout == "SINGLE": + raise OpentronsError( + "NozzleLayoutError", + "A column drop fans out to all 8 nozzles, so a single cherry-picked tip cannot " + "be returned to a rack. Discard it with drop_single_tip(trash).", + ) + if column is None: raise ValueError("column is required when dropping tips to a TipRack.") @@ -1319,23 +1342,89 @@ async def try_liquid_probe(self, plate: Plate, column: int) -> Optional[float]: # --- Single-tip cherry-pick --- @staticmethod - def _nozzle_for_well_row(well: str) -> str: - """The single nozzle to anchor on when the caller named only a well. - - An 8-channel Flex can anchor a SINGLE layout on its A1 or H1 nozzle and - nothing else (the engine's ``primaryNozzle`` literal, and the pipette's - own ``validNozzleMaps``), so only the two rows those nozzles sit in have - an obvious anchor. Any other row needs the caller to say which end of the - row to reach with. + def _single_nozzle_reaches(labware: ItemizedResource, well: str, nozzle: str) -> bool: + """Whether anchoring ``nozzle`` over ``well`` keeps the pipette inside the robot. + + In a SINGLE layout the robot still carries the whole pipette: the body hangs + 95 mm forward of the mount, and the anchor nozzle sits 16 mm (A1) or 79 mm + (H1) forward of it. Anchoring A1 over a front-row slot therefore drives the + mount itself past the robot's front limit, and anchoring H1 at the very back + runs into the rear limit. The same arithmetic the engine runs in + ``pipette_movement_conflict``, which only guards Python-API protocols -- it + never sees the run commands this driver posts, so nothing downstream would + catch an out-of-extents anchor. + """ + well_y: float = labware.get_item(well).get_absolute_location(y="c").y + mount_y = well_y - _SINGLE_NOZZLE_Y[nozzle] + return ( + mount_y + _PIPETTE_BODY_BACK_Y >= _ROBOT_FRONT_LIMIT + and mount_y + _PIPETTE_BODY_FRONT_Y <= _ROBOT_REAR_LIMIT + ) + + @classmethod + def reachable_single_nozzles(cls, labware: ItemizedResource, well: str) -> Tuple[str, ...]: + """The anchor nozzles that can address ``well`` where the labware currently sits. + + Rearmost ("A1") first, since it keeps the most margin at the back of the + deck. Empty when the well is out of reach both ways. Depends on the deck + slot, not just the labware, so it is only meaningful once assigned. + """ + return tuple( + nozzle for nozzle in _SINGLE_NOZZLES if cls._single_nozzle_reaches(labware, well, nozzle) + ) + + @classmethod + def _anchor_for(cls, labware: ItemizedResource, well: str, primary_nozzle: Optional[str]) -> str: + """Settle which nozzle a single-tip op anchors on, refusing what cannot reach. + + A caller's explicit choice is never silently swapped -- an unreachable one + is refused, naming the nozzle that would work. Left to us, the well's own + row wins when it can reach (least surprise: the tip lands on the channel + whose row was asked for), otherwise whichever end reaches. """ - row_letter = well[:1].upper() - if row_letter not in _SINGLE_NOZZLE_ROWS: + reachable = cls.reachable_single_nozzles(labware, well) + if primary_nozzle is not None: + if primary_nozzle not in _SINGLE_NOZZLES: + raise ValueError( + f"primary_nozzle={primary_nozzle!r}: an 8-channel Flex can anchor a single-nozzle " + f"layout only on {' or '.join(_SINGLE_NOZZLES)}." + ) + if primary_nozzle not in reachable: + alternative = ( + f" Anchor on {reachable[0]} instead." + if reachable + else " Neither anchor reaches it; move the labware to another slot." + ) + raise ValueError( + f"Anchoring the {primary_nozzle} nozzle over '{labware.name}' well '{well}' would " + f"carry the pipette outside the robot's reach.{alternative}" + ) + return primary_nozzle + if not reachable: raise ValueError( - f"'{well}' is in row {row_letter or '?'}, and an 8-channel Flex can pick up single " - f"tips only with its {' or '.join(_SINGLE_NOZZLES)} nozzle. Pass " - 'primary_nozzle="A1" or "H1" to reach this well with that nozzle.' + f"'{labware.name}' well '{well}' is out of reach of both single-nozzle anchors " + f"({' and '.join(_SINGLE_NOZZLES)}); the pipette would leave the robot's extents " + f"either way. Move the labware to another slot." ) - return f"{row_letter}1" + row_nozzle = f"{well[:1].upper()}1" + return row_nozzle if row_nozzle in reachable else reachable[0] + + def _require_reach_in_single_layout(self, labware: ItemizedResource, well: str) -> None: + """Refuse a single-nozzle move to a well the mounted anchor cannot reach. + + The pickup chose an anchor that reached the RACK; a later well on a + front-row or back-row slot can still sit outside that same anchor's band. + """ + if self._nozzle_layout != "SINGLE": + return + nozzle = _SINGLE_NOZZLE_BY_CHANNEL.get(self._active_single_channel()) + if nozzle is None or self._single_nozzle_reaches(labware, well, nozzle): + return + raise ValueError( + f"The mounted tip is on the {nozzle} nozzle, and reaching '{labware.name}' well " + f"'{well}' with it would carry the pipette outside the robot's reach. Drop the tip " + f"and cherry-pick again from a rack the other anchor can reach." + ) def _active_single_channel(self) -> int: """Return the sole channel holding a tip in single-tip mode. @@ -1366,8 +1455,9 @@ async def pick_up_single_tip( the engine moves it over whatever well is named -- so the nozzle, not the well, decides which channel ends up holding the tip. An 8-channel Flex can anchor on its "A1" or "H1" nozzle only (channel 0 or channel 7); - ``primary_nozzle`` picks between them, and defaults to the one sitting in - ``well``'s own row, which needs ``well`` to be in row A or H. Raises + ``primary_nozzle`` picks between them, and left unset it is chosen for + you: the well's own row when that end can reach the slot the rack is on, + otherwise the end that can (see ``reachable_single_nozzles``). Raises ``OpentronsError`` if that channel already holds a tip -- checked, like the nozzle itself, before any wire command. Tip tracker changes are staged (``commit=False``) before the wire command, then, after the wire @@ -1378,13 +1468,7 @@ async def pick_up_single_tip( -> validate -> wire -> verify -> commit/rollback). """ self._warn_untested_hardware("pick_up_single_tip") - if primary_nozzle is None: - primary_nozzle = self._nozzle_for_well_row(well) - elif primary_nozzle not in _SINGLE_NOZZLES: - raise ValueError( - f"primary_nozzle={primary_nozzle!r}: an 8-channel Flex can anchor a single-nozzle " - f"layout only on {' or '.join(_SINGLE_NOZZLES)}." - ) + primary_nozzle = self._anchor_for(tip_rack, well, primary_nozzle) channel = _SINGLE_NOZZLES[primary_nozzle] if self._channel_tips[channel] is not None: raise OpentronsError( @@ -1432,6 +1516,7 @@ async def aspirate_single( """ self._warn_untested_hardware("aspirate_single") self._active_single_channel() + self._require_reach_in_single_layout(plate, well) labware_id = await self.flex._ensure_labware_loaded(plate) rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE params: Dict[str, Any] = { @@ -1465,6 +1550,7 @@ async def dispense_single( """Dispense to a single well with the currently mounted single tip.""" self._warn_untested_hardware("dispense_single") self._active_single_channel() + self._require_reach_in_single_layout(plate, well) labware_id = await self.flex._ensure_labware_loaded(plate) rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE params: Dict[str, Any] = { @@ -1500,9 +1586,7 @@ async def drop_single_tip(self, trash: Trash) -> None: await self._execute_trash_drop() self._channel_tips[channel] = None await self._confirm_tips_cleared() - - await self._configure_nozzle_layout({"style": "ALL"}) - self._nozzle_layout = "ALL" + await self._ensure_all_mode() class FlexHead96(_FlexHead): From de0f726b0d0f942c3bac0045ff2871fd8c3b7632 Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 22:49:56 -0400 Subject: [PATCH 09/36] build: declare httpx in the opentrons extra 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. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 06d86e3aef2..9df951e8672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ usb = ["pyusb", "libusb-package"] ftdi = ["pylibftdi", "pyusb"] hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] -opentrons = ["opentrons-http-api-client==0.2.1"] +opentrons = ["opentrons-http-api-client==0.2.1", "httpx"] sila = ["zeroconf>=0.131.0", "grpcio"] cytation-microscopy = ["numpy>=1.26", "opencv-python", "PyGObject"] pico = ["PyLabRobot[sila]", "opencv-python", "numpy"] From 947c5d39f4b56023621f7d55895142a6e6a2b579 Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 22:51:03 -0400 Subject: [PATCH 10/36] docs: drop a stranded copy of the channel-spreading comment in dispense 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. --- pylabrobot/legacy/liquid_handling/liquid_handler.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pylabrobot/legacy/liquid_handling/liquid_handler.py b/pylabrobot/legacy/liquid_handling/liquid_handler.py index a10242c5c95..afe0d886b25 100644 --- a/pylabrobot/legacy/liquid_handling/liquid_handler.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler.py @@ -1143,10 +1143,6 @@ async def dispense( blow_out_air_volume=blow_out_air_volume, ) - # If the user specified a single resource, but multiple channels to use, we will assume they - # want to space the channels evenly across the resource. Note that offsets are relative to the - # center of the resource. - self._check_containers(resources) use_channels = use_channels or self._default_use_channels or list(range(len(resources))) From 6ca4e6a9b453f60f45d02955cf66515ded3f1980 Mon Sep 17 00:00:00 2001 From: miike Date: Wed, 12 Aug 2026 15:52:19 -0400 Subject: [PATCH 11/36] Expose the lifecycle steps behind setup() and stop() 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. --- .../arms/precise_flex/precise_flex_backend.py | 27 ++++++- pylabrobot/opentrons/flex.py | 4 +- pylabrobot/opentrons/flex_tests.py | 74 +++++++++++++++++++ pylabrobot/opentrons/robot.py | 59 +++++++++++++-- 4 files changed, 152 insertions(+), 12 deletions(-) diff --git a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py index afba04dfe69..17dfa71eb98 100644 --- a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py @@ -91,16 +91,37 @@ def _convert_to_cartesian_array( return arr async def setup(self, skip_home: bool = False): - """Initialize the PreciseFlex backend.""" + """Bring the arm fully up: link, power, control, and (unless skipped) home.""" + await self.connect() + await self.initialize() + if not skip_home: + await self.home() + + async def connect(self): + """Open the link and agree the response protocol. Powers nothing, moves nothing.""" await self.io.setup() await self.set_response_mode("pc") + + async def initialize(self): + """Raise high power and take control, so the arm accepts commands. Moves nothing. + + Homing is ``home()``, deliberately separate: it sweeps the arm through its + whole envelope, which is not something to do just to read a position. + """ await self.power_on_robot() await self.attach(1) - if not skip_home: - await self.home() async def stop(self): """Stop the PreciseFlex backend.""" + await self.disconnect() + + async def disconnect(self): + """Hand the arm back, moving nothing. + + Drops high power (``hp 0``) as well as releasing the link, because + ``connect`` is what turned it on. Unlike the Flex there is nothing to park + first: this arm's teardown never moved it. + """ await self.detach() await self.power_off_robot() await self.exit() diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 6e40c24f32c..2b16cbefb48 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -125,8 +125,8 @@ async def _create_run(self) -> str: return run_id async def _model_setup(self) -> None: - await self.home() - + """Discover and compose heads. Homing is setup()'s own step, so that a + caller can ask what is mounted without moving the robot.""" # Discover ALL mounted pipettes (not just the first — _discover_pipette # only surfaces one) and compose the matching head per mount. The base # setup() no longer discovers/loads a pipette itself (that would double diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py index 9707fbcd34c..3d90290bc1c 100644 --- a/pylabrobot/opentrons/flex_tests.py +++ b/pylabrobot/opentrons/flex_tests.py @@ -42,6 +42,80 @@ def _flex_with_transport( return flex, transport +class TestLifecycleHalves(unittest.TestCase): + """``setup``/``stop`` are the two halves run together; each half is callable alone. + + The split exists because taking the robot and moving it are separate acts. An + operator reclaiming the touchscreen wants the session ended and nothing else; + homing an arm over a deck someone is reaching into is the thing to avoid. + """ + + def test_connect_opens_the_link_but_starts_no_run(self): + """The run is what the robot holds against its touchscreen, so taking the + robot is create_run's doing, not connect's.""" + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + + asyncio.run(flex.connect()) + try: + self.assertIsNone(flex.run_id) + self.assertEqual([c for c in transport.commands if c["commandType"] == "home"], []) + finally: + asyncio.run(flex.disconnect()) + + def test_initialize_discovers_without_moving(self): + """Asking what is mounted must not require moving the robot to find out.""" + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + asyncio.run(flex.connect()) + asyncio.run(flex.create_run()) + + asyncio.run(flex.initialize()) + try: + self.assertIsInstance(flex.left, FlexHead8) + self.assertEqual([c for c in transport.commands if c["commandType"] == "home"], []) + finally: + asyncio.run(flex.disconnect()) + + def test_cancel_run_frees_local_control_without_dropping_the_link(self): + """The operator's 'give me the robot back' action: end the session, stay connected.""" + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + asyncio.run(flex.setup()) + homes_from_setup = len([c for c in transport.commands if c["commandType"] == "home"]) + + asyncio.run(flex.cancel_run()) + try: + self.assertIsNone(flex.run_id) + self.assertEqual( + len([c for c in transport.commands if c["commandType"] == "home"]), homes_from_setup + ) + finally: + asyncio.run(flex.disconnect()) + + def test_disconnect_ends_the_run_without_homing(self): + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + asyncio.run(flex.setup()) + homes_from_setup = len([c for c in transport.commands if c["commandType"] == "home"]) + + asyncio.run(flex.disconnect()) + + self.assertIsNone(flex.run_id) + self.assertEqual( + len([c for c in transport.commands if c["commandType"] == "home"]), homes_from_setup + ) + + def test_stop_still_homes_before_releasing(self): + """The one-call teardown keeps parking the gantry; only disconnect skips it.""" + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + asyncio.run(flex.setup()) + homes_from_setup = len([c for c in transport.commands if c["commandType"] == "home"]) + + asyncio.run(flex.stop()) + + self.assertEqual( + len([c for c in transport.commands if c["commandType"] == "home"]), homes_from_setup + 1 + ) + self.assertIsNone(flex.run_id) + + class TestHeadDiscovery(unittest.TestCase): """setup() discovers mounted pipettes and composes the matching head per mount.""" diff --git a/pylabrobot/opentrons/robot.py b/pylabrobot/opentrons/robot.py index b02811e04c3..46df821f849 100644 --- a/pylabrobot/opentrons/robot.py +++ b/pylabrobot/opentrons/robot.py @@ -74,18 +74,63 @@ def __init__( self.robot_model: Optional[str] = None async def setup(self) -> None: - await self._connect() - await self._create_run() - await self._model_setup() + """Bring the robot fully up, PyLabRobot's usual one-call lifecycle entry. + + Composed of the steps below so a caller who needs only some of them (talk to + the robot without moving it, hand it back without homing it) can take them + one at a time. + """ + await self.connect() + await self.create_run() + await self.home() + await self.initialize() async def stop(self) -> None: - # Always home before releasing the robot so the gantry parks in a known - # pose. Done inside the run (before cancel); a failure here must not block - # disconnect. + """Park the gantry and release the robot, PyLabRobot's usual teardown.""" + # Home inside the run (before cancelling it) so the gantry parks in a known + # pose; a failure here must not block the release that follows. try: await self.home() except Exception: - logger.warning("home() before stop failed; continuing to disconnect", exc_info=True) + logger.warning("home() before stop failed; continuing to release", exc_info=True) + await self.cancel_run() + await self.disconnect() + + async def connect(self) -> None: + """Open the link and confirm the robot answers. Starts no run, moves nothing.""" + await self._connect() + + async def create_run(self) -> None: + """Start a control session. + + The robot reports itself as in use and refuses its own touchscreen for as + long as a run is current, so this is the step that takes it from the + operator, not ``connect``. + """ + await self._create_run() + + async def initialize(self) -> None: + """Discover what is mounted and get it ready to command. Moves nothing. + + Homing is ``home()``, deliberately not folded in here: a caller that wants + to know what is on the robot should not have to move it to find out. + """ + await self._model_setup() + + async def cancel_run(self) -> None: + """End the control session, handing the robot back to its own touchscreen. + + Safe with no run open. This, not ``disconnect``, is what frees local + control: the run is what the robot holds, and it outlives our link. + """ + await self._cancel_run() + + async def disconnect(self) -> None: + """Drop the link, moving nothing. + + Cancels an open run first, since a run left current keeps the robot locked + out of its own controls with nothing left to release it. + """ await self._cancel_run() await self._disconnect() From b04653d05185e6e92b85ab7e41b33086307d369f Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 14:42:49 -0400 Subject: [PATCH 12/36] Take flow-rate defaults from the robot's own pipette data 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) --- pylabrobot/opentrons/flex.py | 2 +- .../opentrons/flex_fine_pipetting_tests.py | 9 ++- pylabrobot/opentrons/flex_head.py | 68 ++++++++++------ pylabrobot/opentrons/pipette_defaults.py | 78 +++++++++++++++++++ .../opentrons/pipette_defaults_tests.py | 41 ++++++++++ pylabrobot/opentrons/transport.py | 13 +++- pyproject.toml | 2 +- 7 files changed, 184 insertions(+), 29 deletions(-) create mode 100644 pylabrobot/opentrons/pipette_defaults.py create mode 100644 pylabrobot/opentrons/pipette_defaults_tests.py diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 2b16cbefb48..b7bf53cd681 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -152,7 +152,7 @@ async def _model_setup(self) -> None: "Unsupported pipette channel count", f"{pip.channels} channels (mount '{pip.mount}') has no matching FlexHead.", ) - head = head_cls(self, pip.mount, pipette_id, pip.channels) + head = head_cls(self, pip.mount, pipette_id, pip.channels, pip.pipette_model) if pip.channels == 96: self.head96 = head diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index cc9421b07ca..ec3fe4318be 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -20,7 +20,7 @@ from typing import Any, Dict, Optional from pylabrobot.opentrons.flex import OpentronsFlex -from pylabrobot.opentrons.flex_head import _DEFAULT_BLOW_OUT_FLOW_RATE, FlexHead1 +from pylabrobot.opentrons.flex_head import FlexHead1 from pylabrobot.opentrons.flex_tests import _flex_head1, _flex_head8, _flex_head96 from pylabrobot.opentrons.robot import OpentronsCommandError, OpentronsError from pylabrobot.opentrons.transport import ChatterboxTransport @@ -60,9 +60,10 @@ def test_head8_sends_blow_out_in_place_with_dispense_default_flow_rate(self): blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] self.assertEqual(len(blow_cmds), 1) + # p50_multi_v3.5 on a 50uL tip, per Opentrons' shipped pipette data. self.assertEqual( blow_cmds[0]["params"], - {"pipetteId": head.pipette_id, "flowRate": _DEFAULT_BLOW_OUT_FLOW_RATE}, + {"pipetteId": head.pipette_id, "flowRate": 57.0}, ) finally: asyncio.run(flex.stop()) @@ -124,9 +125,11 @@ def test_head1_blow_out_and_reprime(self): blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] self.assertEqual(len(blow_cmds), 1) + # p1000_single_v3.5 on a 50uL tip: a different pipette blows out at its own + # rate, not the p50's 57. self.assertEqual( blow_cmds[0]["params"], - {"pipetteId": head.pipette_id, "flowRate": _DEFAULT_BLOW_OUT_FLOW_RATE}, + {"pipetteId": head.pipette_id, "flowRate": 478.0}, ) cmd_types = [c["commandType"] for c in transport.commands] prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 93cb86e9b80..bcf3d36b297 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -25,6 +25,7 @@ from pylabrobot.opentrons.flex_wire import UNTESTED_HARDWARE_WARNING from pylabrobot.opentrons.labware_definitions import container_footprint +from pylabrobot.opentrons.pipette_defaults import FlowRates, flow_rates from pylabrobot.opentrons.robot import OpentronsCommandError, OpentronsError from pylabrobot.resources import ( Container, @@ -61,11 +62,19 @@ class _FlexHead: # triggers the one-time untested-hardware notice. _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset() - def __init__(self, flex: "OpentronsFlex", mount: str, pipette_id: str, channels: int) -> None: + def __init__( + self, + flex: "OpentronsFlex", + mount: str, + pipette_id: str, + channels: int, + pipette_model: str, + ) -> None: self.flex = flex self.mount = mount self.pipette_id = pipette_id self.channels = channels + self.pipette_model = pipette_model self._channel_tips: List[Optional[Tip]] = [None] * channels # Whether the plunger has been prepared (primed) since the last tip # pickup. The Flex requires an explicit `prepareToAspirate` command @@ -97,6 +106,21 @@ def get_mounted_tips(self) -> List[Optional[Tip]]: """ return list(self._channel_tips) + def default_flow_rates(self) -> FlowRates: + """The robot's own defaults for this pipette and the tip currently on it. + + Tip-dependent, so it cannot be resolved before a pickup: the same p1000 + eight-channel defaults to 478 uL/s on a 50 uL tip and 716 on a 200. + """ + mounted = [tip for tip in self._channel_tips if tip is not None] + if not mounted: + raise OpentronsError( + "NoTipMounted", + f"'{self.mount}' has no tip, so its default flow rate is undefined. Pick up a " + "tip first, or pass an explicit flow_rate.", + ) + return flow_rates(self.pipette_model, mounted[0].maximal_volume) + async def discard_tips(self, trash: Trash) -> None: """Discard all mounted tips into ``trash``. Implemented by each head.""" raise NotImplementedError @@ -113,7 +137,7 @@ async def blow_out(self, flow_rate: Optional[float] = None) -> None: trackers are involved. """ self._warn_untested_hardware("blow_out") - rate = flow_rate if flow_rate is not None else _DEFAULT_BLOW_OUT_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().blow_out await self._execute("blowOutInPlace", {"pipetteId": self.pipette_id, "flowRate": rate}) self._prepared = False @@ -595,15 +619,6 @@ async def move_to( _NUM_CHANNELS = 8 -# Flex-managed positioning flow-rate defaults (uL/s), matching the -# p50_multi_v3.5 pipette defaults. Shared by FlexHead1/FlexHead8/FlexHead96 -- -# the Flex applies the same defaults regardless of channel count. -_DEFAULT_ASPIRATE_FLOW_RATE = 35.0 -_DEFAULT_DISPENSE_FLOW_RATE = 57.0 - -# The p50_multi_v3.5's default blow-out rate equals its dispense rate. -_DEFAULT_BLOW_OUT_FLOW_RATE = _DEFAULT_DISPENSE_FLOW_RATE - # Default aspirate/dispense position: 1mm above the well bottom, matching the # Opentrons Python-API default. The raw Protocol-Engine /commands API defaults # an OMITTED wellLocation to origin "top" (the well rim -- above the liquid), @@ -774,7 +789,7 @@ async def aspirate( else: labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME - rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate staged_trackers = self._stage_container_aspirate(target, volume) @@ -817,7 +832,7 @@ async def dispense( else: labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME - rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense staged_trackers = self._stage_container_dispense(target, volume) @@ -912,8 +927,15 @@ class FlexHead8(_FlexHead): _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset({"pick_up_tips"}) - def __init__(self, flex: "OpentronsFlex", mount: str, pipette_id: str, channels: int) -> None: - super().__init__(flex, mount, pipette_id, channels) + def __init__( + self, + flex: "OpentronsFlex", + mount: str, + pipette_id: str, + channels: int, + pipette_model: str, + ) -> None: + super().__init__(flex, mount, pipette_id, channels, pipette_model) self._nozzle_layout: str = "ALL" # "ALL" | "SINGLE" # --- Nozzle layout guard --- @@ -1124,7 +1146,7 @@ async def aspirate( well_name, column_wells = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) - rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate tracking = does_volume_tracking() staged_trackers: List[Any] = [] @@ -1170,7 +1192,7 @@ async def dispense( well_name, column_wells = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) - rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense tracking = does_volume_tracking() staged_trackers: List[Any] = [] @@ -1227,7 +1249,7 @@ async def aspirate_container( self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN, offset) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(container) - rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate mounted = sum(1 for tip in self._channel_tips if tip is not None) staged_trackers = self._stage_container_aspirate(container, volume * mounted) @@ -1268,7 +1290,7 @@ async def dispense_container( self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN, offset) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(container) - rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense mounted = sum(1 for tip in self._channel_tips if tip is not None) staged_trackers = self._stage_container_dispense(container, volume * mounted) @@ -1518,7 +1540,7 @@ async def aspirate_single( self._active_single_channel() self._require_reach_in_single_layout(plate, well) labware_id = await self.flex._ensure_labware_loaded(plate) - rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate params: Dict[str, Any] = { "pipetteId": self.pipette_id, "labwareId": labware_id, @@ -1552,7 +1574,7 @@ async def dispense_single( self._active_single_channel() self._require_reach_in_single_layout(plate, well) labware_id = await self.flex._ensure_labware_loaded(plate) - rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense params: Dict[str, Any] = { "pipetteId": self.pipette_id, "labwareId": labware_id, @@ -1763,7 +1785,7 @@ async def aspirate( """ self._warn_untested_hardware("aspirate") self._require_mounted_tip() - rate = flow_rate if flow_rate is not None else _DEFAULT_ASPIRATE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate staged_trackers: List[Any] = [] if isinstance(target, Plate): wells = self._check_full_coverage(target) @@ -1820,7 +1842,7 @@ async def dispense( """ self._warn_untested_hardware("dispense") self._require_mounted_tip() - rate = flow_rate if flow_rate is not None else _DEFAULT_DISPENSE_FLOW_RATE + rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense staged_trackers: List[Any] = [] if isinstance(target, Plate): wells = self._check_full_coverage(target) diff --git a/pylabrobot/opentrons/pipette_defaults.py b/pylabrobot/opentrons/pipette_defaults.py new file mode 100644 index 00000000000..ce31898ec68 --- /dev/null +++ b/pylabrobot/opentrons/pipette_defaults.py @@ -0,0 +1,78 @@ +"""The robot's own default flow rates, per pipette and per tip. + +Every Opentrons liquid-handling command carries a required ``flowRate``, and no +robot-server endpoint serves the defaults -- ``GET /instruments`` reports +channels and volume range only. They are read here from the same shared-data +package robot-server itself loads, so a caller who does not name a rate gets +what the robot would have used. The values differ by more than two orders of +magnitude across the Flex range (6 uL/s to 716), so a single constant is wrong +for almost every pipette. +""" + +from typing import Dict, NamedTuple, Tuple + +from opentrons_shared_data.pipette.load_data import load_liquid_model +from opentrons_shared_data.pipette.pipette_load_name_conversions import convert_pipette_model +from opentrons_shared_data.pipette.types import PipetteModel, PipetteOEMType + + +class FlowRates(NamedTuple): + """Default aspirate, dispense and blow-out rates in uL/s.""" + + aspirate: float + dispense: float + blow_out: float + + +_CACHE: Dict[str, Dict[str, FlowRates]] = {} + + +def _rates_by_tip(pipette_model: str) -> Dict[str, FlowRates]: + cached = _CACHE.get(pipette_model) + if cached is not None: + return cached + + if not pipette_model: + # An empty model resolves to a p1000 single-channel rather than raising, which + # would silently pipette a p50 at up to 716 uL/s. + raise ValueError("No pipette model given, so its default flow rates are unknown.") + + version = convert_pipette_model(PipetteModel(pipette_model)) + liquid_model = load_liquid_model( + version.pipette_type, version.pipette_channels, version.pipette_version, PipetteOEMType.OT + ) + rates = { + tip_type.name: FlowRates( + aspirate=tip.default_aspirate_flowrate.default, + dispense=tip.default_dispense_flowrate.default, + blow_out=tip.default_blowout_flowrate.default, + ) + for tip_type, tip in liquid_model["default"].supported_tips.items() + } + _CACHE[pipette_model] = rates + return rates + + +def flow_rates(pipette_model: str, tip_volume: float) -> FlowRates: + """Look up the defaults for a pipette model ("p50_multi_v3.5") and tip volume. + + Raises: + ValueError: If the pipette does not support a tip of that volume. There is + no near-enough tip to fall back to: the p1000 eight-channel alone spans + 478 uL/s on a 50 uL tip and 716 on a 200, so guessing would silently + pipette at the wrong speed. + """ + rates = _rates_by_tip(pipette_model) + tip_name = f"t{int(tip_volume)}" + if tip_name not in rates: + supported = ", ".join(sorted(rates)) + raise ValueError( + f"'{pipette_model}' publishes no default flow rate for a {tip_volume} uL tip " + f"(it supports {supported}). Pass an explicit flow_rate for this tip." + ) + return rates[tip_name] + + +def supported_tip_volumes(pipette_model: str) -> Tuple[float, ...]: + """The tip volumes this pipette publishes defaults for, ascending.""" + return tuple(sorted(float(name[1:]) for name in _rates_by_tip(pipette_model))) diff --git a/pylabrobot/opentrons/pipette_defaults_tests.py b/pylabrobot/opentrons/pipette_defaults_tests.py new file mode 100644 index 00000000000..6d0236b0881 --- /dev/null +++ b/pylabrobot/opentrons/pipette_defaults_tests.py @@ -0,0 +1,41 @@ +import unittest + +from pylabrobot.opentrons.pipette_defaults import flow_rates, supported_tip_volumes + + +class PipetteDefaultsTests(unittest.TestCase): + """Flow-rate defaults come from Opentrons' own shipped pipette data.""" + + def test_rates_differ_by_pipette_and_by_tip(self): + # The whole reason a single constant cannot serve: these span two orders of + # magnitude across pipettes the same robot can mount. + self.assertEqual(flow_rates("p50_multi_v3.5", 50), (35.0, 57.0, 57.0)) + self.assertEqual(flow_rates("p1000_multi_v3.5", 1000), (716.0, 716.0, 716.0)) + self.assertEqual(flow_rates("p1000_multi_v3.5", 50), (478.0, 478.0, 478.0)) + self.assertEqual(flow_rates("p200_96_v3.3", 20), (6.5, 6.5, 10.0)) + + def test_rates_differ_between_versions_of_one_pipette(self): + # Same pipette, same tip, different hardware revision: v3.0 runs a 50uL tip + # at 8 uL/s where v3.5 runs it at 35. + self.assertEqual(flow_rates("p50_multi_v3.0", 50).aspirate, 8.0) + self.assertEqual(flow_rates("p50_multi_v3.5", 50).aspirate, 35.0) + + def test_unsupported_tip_is_refused_and_names_what_is_supported(self): + with self.assertRaises(ValueError) as caught: + flow_rates("p50_multi_v3.5", 300) + self.assertIn("300", str(caught.exception)) + self.assertIn("t50", str(caught.exception)) + + def test_empty_model_is_refused_rather_than_resolving_to_a_p1000(self): + # Opentrons' own parser maps "" to a p1000 single-channel instead of raising, + # which would blow a p50 out at up to 716 uL/s. + with self.assertRaises(ValueError): + flow_rates("", 50) + + def test_supported_tip_volumes_are_ascending(self): + self.assertEqual(supported_tip_volumes("p50_multi_v3.5"), (20.0, 50.0)) + self.assertEqual(supported_tip_volumes("p1000_multi_v3.5"), (50.0, 200.0, 1000.0)) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index 549e55b0b6b..93cece9763a 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -32,6 +32,17 @@ # string, so a caller gating on robot software can tell offline from any robot. OFFLINE_API_VERSION = "dry-run" +# /instruments serves a versioned model, and flow-rate defaults differ between +# versions of the same pipette, so the fake cannot just echo the name back. +_MODELS_BY_NAME = { + "p50_single_flex": "p50_single_v3.5", + "p50_multi_flex": "p50_multi_v3.5", + "p1000_single_flex": "p1000_single_v3.5", + "p1000_multi_flex": "p1000_multi_v3.5", + "p1000_96": "p1000_96_v3.5", + "p200_96": "p200_96_v3.3", +} + @runtime_checkable class OpentronsTransport(Protocol): @@ -198,7 +209,7 @@ async def get(self, path: str) -> Dict[str, Any]: "instrumentType": "pipette", "mount": mount, "instrumentName": name, - "instrumentModel": name, + "instrumentModel": _MODELS_BY_NAME.get(name, name), "data": {"channels": channels, "min_volume": min_v, "max_volume": max_v}, "state": {"tipDetected": self._tip_detected.get(mount, False)}, } diff --git a/pyproject.toml b/pyproject.toml index 9df951e8672..0e0227a3dd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ usb = ["pyusb", "libusb-package"] ftdi = ["pylibftdi", "pyusb"] hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] -opentrons = ["opentrons-http-api-client==0.2.1", "httpx"] +opentrons = ["opentrons-http-api-client==0.2.1", "httpx", "opentrons-shared-data"] sila = ["zeroconf>=0.131.0", "grpcio"] cytation-microscopy = ["numpy>=1.26", "opencv-python", "PyGObject"] pico = ["PyLabRobot[sila]", "opencv-python", "numpy"] From a62435ae14e22a94e38dd7cdf62cda71d92edd85 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 14:54:44 -0400 Subject: [PATCH 13/36] Resolve labware by declared load name or model, and drop tips where the 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) --- pylabrobot/opentrons/catalogue.py | 22 ++++++++++ pylabrobot/opentrons/flex.py | 45 ++++++++----------- pylabrobot/opentrons/flex_head.py | 26 ++++++++--- pylabrobot/opentrons/flex_tests.py | 70 ++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 32 deletions(-) create mode 100644 pylabrobot/opentrons/catalogue.py diff --git a/pylabrobot/opentrons/catalogue.py b/pylabrobot/opentrons/catalogue.py new file mode 100644 index 00000000000..8b671d3516a --- /dev/null +++ b/pylabrobot/opentrons/catalogue.py @@ -0,0 +1,22 @@ +"""Which load names Opentrons' own labware catalogue defines. + +A ``loadLabware`` command names labware by load name and the robot resolves it +against this catalogue, so a name that is not in it fails on the robot with no +client-side warning. Checking here turns that into an error the caller can read. +""" + +from functools import lru_cache +from typing import FrozenSet + +from opentrons_shared_data.labware import list_definitions + + +@lru_cache(maxsize=1) +def catalogue_load_names() -> FrozenSet[str]: + """Every load name the shipped catalogue defines, across schema versions.""" + return frozenset(load_name for load_name, _version, _schema in list_definitions()) + + +def is_catalogue_labware(load_name: str) -> bool: + """Whether Opentrons ships a definition under this load name.""" + return load_name in catalogue_load_names() diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index b7bf53cd681..c13454d3af6 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple, Type, cast from pylabrobot.opentrons.flex_gripper import FlexGripper +from pylabrobot.opentrons.catalogue import is_catalogue_labware from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead from pylabrobot.opentrons.flex_wire import slot_wire_location from pylabrobot.opentrons.labware_definitions import ( @@ -39,17 +40,6 @@ "opentrons_96_wellplate_200ul_pcr_full_skirt": 2, } -_TIP_RACK_MAP = { - "flex_96_tiprack_50ul": "opentrons_flex_96_tiprack_50ul", - "flex_96_tiprack_200ul": "opentrons_flex_96_tiprack_200ul", - "flex_96_tiprack_1000ul": "opentrons_flex_96_tiprack_1000ul", - "flex_96_tiprack_20ul": "opentrons_flex_96_tiprack_20ul", - "flex_96_filtertiprack_50ul": "opentrons_flex_96_filtertiprack_50ul", - "flex_96_filtertiprack_200ul": "opentrons_flex_96_filtertiprack_200ul", - "flex_96_filtertiprack_1000ul": "opentrons_flex_96_filtertiprack_1000ul", - "flex_96_filtertiprack_20ul": "opentrons_flex_96_filtertiprack_20ul", -} - # Discovered pipette channel count -> matching head class. _CHANNELS_TO_HEAD: Dict[int, Type[_FlexHead]] = { 1: FlexHead1, @@ -339,22 +329,25 @@ def _ot_catalogue_identity(resource: Resource) -> Tuple[str, int]: one revision) stay at 1. A resource can override the version for its own load name by carrying an ``ot_version``. """ - if hasattr(resource, "ot_load_name"): - load_name = cast(str, resource.ot_load_name) + declared = getattr(resource, "ot_load_name", None) + if declared is not None: + load_name = cast(str, declared) + if not is_catalogue_labware(load_name): + # Not an OpentronsError: that is the caller's signal to synthesize, which + # would hide a typo behind geometry the operator never asked for. + raise ValueError( + f"'{resource.name}' declares ot_load_name '{load_name}', which Opentrons' labware " + "catalogue does not define. Correct the load name, or drop the attribute to have a " + "definition built from the resource's own geometry." + ) + elif resource.model is not None and is_catalogue_labware(resource.model): + load_name = resource.model else: - name_lower = getattr(resource, "name", "").lower() - for key, ot_name in _TIP_RACK_MAP.items(): - if key in name_lower: - load_name = ot_name - break - else: - if not name_lower.startswith("opentrons_"): - raise OpentronsError( - "Cannot determine Opentrons load name", - f"'{name_lower}' — set resource.ot_load_name = 'opentrons_flex_96_tiprack_50ul' " - f"or use a standard Flex labware name.", - ) - load_name = name_lower + raise OpentronsError( + "Cannot determine Opentrons load name", + f"'{resource.name}' has no ot_load_name, and its model " + f"{resource.model!r} is not in Opentrons' catalogue.", + ) version = getattr(resource, "ot_version", None) if version is None: diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index bcf3d36b297..8c08c549692 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -289,7 +289,18 @@ async def _execute_with_prepare( for tracker in staged_trackers: tracker.commit() - async def _execute_trash_drop(self) -> None: + def _trash_addressable_area(self, trash: Trash) -> str: + """The movable-trash addressable area for the slot this trash sits in.""" + slot = self.flex.deck.get_slot(trash) + if slot not in _MOVABLE_TRASH_SLOTS: + raise OpentronsError( + "Trash is not in a trash slot", + f"'{trash.name}' is in slot {slot!r}. A Flex accepts a movable trash only in " + f"{', '.join(sorted(_MOVABLE_TRASH_SLOTS))}.", + ) + return f"movableTrash{slot}" + + async def _execute_trash_drop(self, trash: Trash) -> None: """Send the two-command addressable-area trash-drop sequence. Shared by every ``discard_tips``/``drop_single_tip`` variant. No tracker @@ -300,7 +311,7 @@ async def _execute_trash_drop(self) -> None: "moveToAddressableAreaForDropTip", { "pipetteId": self.pipette_id, - "addressableAreaName": "movableTrashA3", + "addressableAreaName": self._trash_addressable_area(trash), "alternateDropLocation": True, }, ) @@ -619,6 +630,9 @@ async def move_to( _NUM_CHANNELS = 8 +# The slots a Flex accepts a movable trash in (shared-data ot3_standard.json). +_MOVABLE_TRASH_SLOTS = frozenset({"A1", "B1", "C1", "D1", "A3", "B3", "C3", "D3"}) + # Default aspirate/dispense position: 1mm above the well bottom, matching the # Opentrons Python-API default. The raw Protocol-Engine /commands API defaults # an OMITTED wellLocation to origin "top" (the well rim -- above the liquid), @@ -729,7 +743,7 @@ async def drop_tips( self._warn_untested_hardware("drop_tips") if isinstance(target, Trash): - await self._execute_trash_drop() + await self._execute_trash_drop(target) self._channel_tips[0] = None await self._confirm_tips_cleared() return @@ -1077,7 +1091,7 @@ async def drop_tips( self._warn_untested_hardware("drop_tips") if isinstance(target, Trash): - await self._execute_trash_drop() + await self._execute_trash_drop(target) self._channel_tips = [None] * self.channels await self._confirm_tips_cleared() await self._ensure_all_mode() @@ -1605,7 +1619,7 @@ async def drop_single_tip(self, trash: Trash) -> None: """ self._warn_untested_hardware("drop_single_tip") channel = self._active_single_channel() - await self._execute_trash_drop() + await self._execute_trash_drop(trash) self._channel_tips[channel] = None await self._confirm_tips_cleared() await self._ensure_all_mode() @@ -1726,7 +1740,7 @@ async def drop_tips( self._warn_untested_hardware("drop_tips") if isinstance(target, Trash): - await self._execute_trash_drop() + await self._execute_trash_drop(target) self._channel_tips = [None] * self.channels await self._confirm_tips_cleared() return diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py index 3d90290bc1c..cf1aeafd6a6 100644 --- a/pylabrobot/opentrons/flex_tests.py +++ b/pylabrobot/opentrons/flex_tests.py @@ -1131,5 +1131,75 @@ def test_docstring_does_not_claim_hardware_validation(self): self.assertNotIn("Validated on real", doc) +class TrashAddressableAreaTests(unittest.TestCase): + """A Flex takes a movable trash in any of eight slots, not just A3.""" + + def test_the_drop_follows_the_trash_to_another_slot(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + trash = flex.deck.get_trash_area() + flex.deck.unassign_child_at_slot("A3") + flex.deck.assign_child_at_slot(trash, "B3") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.discard_tips(trash)) + + move_cmd = next( + c for c in transport.commands if c["commandType"] == "moveToAddressableAreaForDropTip" + ) + self.assertEqual(move_cmd["params"]["addressableAreaName"], "movableTrashB3") + finally: + asyncio.run(flex.stop()) + + def test_a_trash_outside_a_trash_slot_is_refused(self): + flex, _transport, head = _flex_head8() + try: + trash = flex.deck.get_trash_area() + flex.deck.unassign_child_at_slot("A3") + flex.deck.assign_child_at_slot(trash, "C2") + with self.assertRaises(OpentronsError): + head._trash_addressable_area(trash) + finally: + asyncio.run(flex.stop()) + + +class CatalogueIdentityTests(unittest.TestCase): + """How a PLR resource resolves to an Opentrons load name.""" + + def test_a_resource_resolves_on_its_model(self): + # The model IS the load name on Opentrons' own factories, so nothing needs + # to be declared and the resource's instance name is irrelevant. + plate = cor_96_wellplate_360uL_Fb(name="anything at all") + plate.model = "corning_96_wellplate_360ul_flat" + load_name, _version = OpentronsFlex._ot_catalogue_identity(plate) + self.assertEqual(load_name, "corning_96_wellplate_360ul_flat") + + def test_a_declared_load_name_outside_the_catalogue_is_a_hard_error(self): + # Must NOT raise OpentronsError: the caller treats that as "synthesize one", + # which would silently ship geometry the operator never asked for. + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flatt" + with self.assertRaises(ValueError) as caught: + OpentronsFlex._ot_catalogue_identity(plate) + self.assertNotIsInstance(caught.exception, OpentronsError) + self.assertIn("corning_96_wellplate_360ul_flatt", str(caught.exception)) + + def test_an_unresolvable_resource_asks_for_a_synthesized_definition(self): + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.model = None + with self.assertRaises(OpentronsError): + OpentronsFlex._ot_catalogue_identity(plate) + + def test_the_instance_name_never_decides_the_load_name(self): + # It is a user-chosen label, so naming a plate after a tip rack must not + # load a tip rack. + plate = cor_96_wellplate_360uL_Fb(name="flex_96_tiprack_50ul") + plate.model = None + with self.assertRaises(OpentronsError): + OpentronsFlex._ot_catalogue_identity(plate) + + if __name__ == "__main__": unittest.main() From 8f58e58ab5f7f9af88685dbeeb894422cd753d73 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 14:59:02 -0400 Subject: [PATCH 14/36] `CaptureReader.done()`: end validation instead of re-arming it `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) --- pylabrobot/io/capture.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pylabrobot/io/capture.py b/pylabrobot/io/capture.py index 62244cb363e..f455f19b87c 100644 --- a/pylabrobot/io/capture.py +++ b/pylabrobot/io/capture.py @@ -102,6 +102,11 @@ def next_command(self) -> dict: return command def done(self): + """Assert the capture was fully consumed, then end validation. + + Clearing the flag is what lets the next validation build its io objects; + they are refused while a capture or validation is active. + """ if self._command_idx < len(self.commands): left = len(self.commands) - self._command_idx next_command = self.commands[self._command_idx] @@ -111,6 +116,9 @@ def done(self): print("Validation successful!") self.reset() + global _capture_or_validation_active + _capture_or_validation_active = False + def reset(self): self._command_idx = 0 From 53fc90b7f7f163c40cc19394a1ccbe0288952059 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 14:59:03 -0400 Subject: [PATCH 15/36] `io.http`: record and replay a request/response device API 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) --- pylabrobot/io/http.py | 256 ++++++++++++++++++++++++++++++ pylabrobot/io/http_tests.py | 182 +++++++++++++++++++++ pylabrobot/testing/http_server.py | 33 ++++ 3 files changed, 471 insertions(+) create mode 100644 pylabrobot/io/http_tests.py create mode 100644 pylabrobot/testing/http_server.py diff --git a/pylabrobot/io/http.py b/pylabrobot/io/http.py index e69de29bb2d..803a5eb5417 100644 --- a/pylabrobot/io/http.py +++ b/pylabrobot/io/http.py @@ -0,0 +1,256 @@ +"""HTTP io for devices that speak a request/response API, recorded through the capture log. + +A device driven over HTTP needs its traffic pinned just like a serial one: +record a known-good run with ``pylabrobot.start_capture()``, then replay it +through :class:`HTTPValidator` to re-run the same protocol with no device +present. Failed exchanges are recorded too, so a device's refusals replay as +the same exception the driver saw live. + +:class:`HTTP` is deliberately not an :class:`~pylabrobot.io.io.IOBase`. That +base's ``write`` and ``read`` model a byte stream whose halves are separately +meaningful, while a request and its response are one indivisible exchange, so +the pair is the unit that gets recorded and replayed. + +Replaying takes four steps in this order, because building a validator is +refused once validation is active:: + + cr = CaptureReader(path) + io = HTTPValidator(cr, base_url="http://robot:31950") + cr.start() + ... # drive the device through io + cr.done() # raises if the protocol stopped short of the recording +""" + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional, Union, cast + +from pylabrobot.io.capture import ( + CaptureReader, + Command, + capturer, + get_capture_or_validation_active, +) +from pylabrobot.io.errors import ValidationError +from pylabrobot.io.validation_utils import LOG_LEVEL_IO, align_sequences + +try: + import httpx # type: ignore[import-not-found] + + _HAS_HTTPX = True +except ImportError: + _HAS_HTTPX = False + +logger = logging.getLogger(__name__) + +# A parsed JSON body, or the raw text when the device did not answer in JSON. +Body = Union[Dict[str, Any], str] + + +@dataclass +class HTTPCommand(Command): + """One recorded exchange: what was asked, and what came back. + + ``status`` is kept so a non-2xx replays as the same refusal rather than + succeeding with an error body. + """ + + path: str + request: Optional[Dict[str, Any]] + response: Body + status: int + + def __init__( + self, + device_id: str, + action: str, + path: str, + response: Body, + status: int, + request: Optional[Dict[str, Any]] = None, + module: str = "http", + ): + super().__init__(module=module, device_id=device_id, action=action) + self.path = path + self.request = request + self.response = response + self.status = status + + +class HTTP: + """IO for a JSON-over-HTTP device API. + + The three verbs return the parsed body directly and raise on a non-2xx, so + callers never handle a response object. + """ + + def __init__( + self, + base_url: str, + timeout: float = 30.0, + headers: Optional[Dict[str, str]] = None, + ): + if not _HAS_HTTPX: + raise RuntimeError("httpx is required for HTTP io. Install with: pip install httpx") + self._base_url = base_url.rstrip("/") + self._timeout = timeout + self._headers = dict(headers or {}) + self._client: Optional["httpx.AsyncClient"] = None + + if get_capture_or_validation_active(): + raise RuntimeError("Cannot create a new HTTP object while capture or validation is active") + + @property + def base_url(self) -> str: + return self._base_url + + async def setup(self): + self._client = httpx.AsyncClient( + base_url=self._base_url, + timeout=self._timeout, + headers=self._headers, + ) + + async def stop(self): + if self._client is None: + return + await self._client.aclose() + self._client = None + + def serialize(self): + return { + "base_url": self._base_url, + "timeout": self._timeout, + "headers": self._headers, + "type": "HTTP", + } + + def _client_or_raise(self) -> "httpx.AsyncClient": + if self._client is None: + raise RuntimeError(f"HTTP io for '{self._base_url}' not set up; call setup() first") + return self._client + + async def get(self, path: str) -> Body: + return self._finish("get", path, None, await self._client_or_raise().get(path)) + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Body: + body = json or {} + return self._finish("post", path, body, await self._client_or_raise().post(path, json=body)) + + async def delete(self, path: str) -> Body: + return self._finish("delete", path, None, await self._client_or_raise().delete(path)) + + def _finish( + self, + action: str, + path: str, + request: Optional[Dict[str, Any]], + response: "httpx.Response", + ) -> Body: + """Record the exchange, then let a non-2xx raise. + + Recording precedes raising so a device's refusals land in the capture file; + they are the shapes a driver's error handling is written against. + """ + parsed = _parse_body(response) + logger.log( + LOG_LEVEL_IO, + "[%s] %s %s %s -> %s %s", + self._base_url, + action, + path, + request, + response.status_code, + parsed, + ) + capturer.record( + HTTPCommand( + device_id=self._base_url, + action=action, + path=path, + request=request, + response=parsed, + status=response.status_code, + ) + ) + if response.status_code >= 400: + raise _status_error(action, self._base_url, path, response.status_code, parsed) + return parsed + + +class HTTPValidator(HTTP): + """Replays a capture file instead of reaching the device.""" + + def __init__( + self, + cr: CaptureReader, + base_url: str, + timeout: float = 30.0, + headers: Optional[Dict[str, str]] = None, + ): + super().__init__(base_url=base_url, timeout=timeout, headers=headers) + self.cr = cr + + async def setup(self): + """No connection to open.""" + + async def stop(self): + """No connection to close.""" + + async def get(self, path: str) -> Body: + return self._replay("get", path, None) + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Body: + return self._replay("post", path, json or {}) + + async def delete(self, path: str) -> Body: + return self._replay("delete", path, None) + + def _replay(self, action: str, path: str, request: Optional[Dict[str, Any]]) -> Body: + recorded = HTTPCommand(**self.cr.next_command()) + if not ( + recorded.module == "http" + and recorded.device_id == self._base_url + and recorded.action == action + and recorded.path == path + ): + raise ValidationError( + f"Expected http {action} {path} to {self._base_url}, " + f"got {recorded.module} {recorded.action} {recorded.path} to {recorded.device_id}" + ) + if recorded.request != request: + align_sequences(expected=_pretty(recorded.request), actual=_pretty(request)) + raise ValidationError( + f"Request body mismatch on {action} {path}: difference was written to stdout." + ) + if recorded.status >= 400: + raise _status_error(action, self._base_url, path, recorded.status, recorded.response) + return recorded.response + + +def _parse_body(response: "httpx.Response") -> Body: + try: + return cast(Dict[str, Any], response.json()) + except ValueError: + return response.text + + +def _status_error( + action: str, base_url: str, path: str, status: int, body: Body +) -> "httpx.HTTPStatusError": + """The refusal a caller sees, built identically whether live or replayed.""" + rebuilt = ( + httpx.Response(status, text=body) + if isinstance(body, str) + else httpx.Response(status, json=body) + ) + return httpx.HTTPStatusError( + f"{status} from {action} {path}: {body}", + request=httpx.Request(action.upper(), base_url + path), + response=rebuilt, + ) + + +def _pretty(body: Optional[Dict[str, Any]]) -> str: + return json.dumps(body, indent=2, sort_keys=True, default=str) diff --git a/pylabrobot/io/http_tests.py b/pylabrobot/io/http_tests.py new file mode 100644 index 00000000000..e09c15caebf --- /dev/null +++ b/pylabrobot/io/http_tests.py @@ -0,0 +1,182 @@ +"""Tests for the HTTP io: what it records, and that a replay reproduces the run.""" + +import json +import tempfile +import unittest +from pathlib import Path +from typing import Any, Callable, Dict, List + +import httpx + +import pylabrobot +from pylabrobot.io.capture import CaptureReader +from pylabrobot.io.errors import ValidationError +from pylabrobot.io.http import HTTP, HTTPValidator +from pylabrobot.testing.http_server import serving as _serving + +BASE_URL = "http://robot.test:31950" + + +def _ok(body: Dict[str, Any]) -> Callable[[httpx.Request], httpx.Response]: + return lambda request: httpx.Response(200, json=body) + + +class HTTPCaptureTests(unittest.IsolatedAsyncioTestCase): + """A live run through HTTP lands in the capture file, verb by verb.""" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.capture_file = Path(self._dir.name) / "capture.json" + + def tearDown(self): + self._dir.cleanup() + + def _recorded(self) -> List[Dict[str, Any]]: + with open(self.capture_file, "r") as f: + return list(json.load(f)["commands"]) + + async def test_get_records_path_and_response(self): + io = HTTP(base_url=BASE_URL) + with _serving(_ok({"api_version": "8.8.0"})): + await io.setup() + pylabrobot.start_capture(self.capture_file) + self.assertEqual(await io.get("/health"), {"api_version": "8.8.0"}) + pylabrobot.stop_capture() + + (command,) = self._recorded() + self.assertEqual(command["module"], "http") + self.assertEqual(command["device_id"], BASE_URL) + self.assertEqual(command["action"], "get") + self.assertEqual(command["path"], "/health") + self.assertIsNone(command["request"]) + self.assertEqual(command["response"], {"api_version": "8.8.0"}) + self.assertEqual(command["status"], 200) + + async def test_post_records_the_request_body(self): + io = HTTP(base_url=BASE_URL) + sent = {"data": {"commandType": "home", "params": {}}} + with _serving(_ok({"data": {"id": "cmd-1", "status": "succeeded"}})): + await io.setup() + pylabrobot.start_capture(self.capture_file) + await io.post("/runs/r1/commands", json=sent) + pylabrobot.stop_capture() + + (command,) = self._recorded() + self.assertEqual(command["action"], "post") + self.assertEqual(command["request"], sent) + + async def test_a_refusal_is_recorded_before_it_raises(self): + """The error shapes are the point: a 4xx must reach the capture file.""" + io = HTTP(base_url=BASE_URL) + refusal = {"errors": [{"errorType": "LocationIsOccupiedError"}]} + with _serving(lambda request: httpx.Response(409, json=refusal)): + await io.setup() + pylabrobot.start_capture(self.capture_file) + with self.assertRaises(httpx.HTTPStatusError): + await io.post("/runs/r1/commands", json={"data": {}}) + pylabrobot.stop_capture() + + (command,) = self._recorded() + self.assertEqual(command["status"], 409) + self.assertEqual(command["response"], refusal) + + async def test_a_non_json_body_is_recorded_as_text(self): + io = HTTP(base_url=BASE_URL) + with _serving(lambda request: httpx.Response(200, text="not json")): + await io.setup() + pylabrobot.start_capture(self.capture_file) + self.assertEqual(await io.get("/health"), "not json") + pylabrobot.stop_capture() + + (command,) = self._recorded() + self.assertEqual(command["response"], "not json") + + async def test_use_before_setup_is_refused(self): + io = HTTP(base_url=BASE_URL) + with self.assertRaisesRegex(RuntimeError, "not set up"): + await io.get("/health") + + +class HTTPReplayTests(unittest.IsolatedAsyncioTestCase): + """A recording replays the same run with nothing on the network.""" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.capture_file = Path(self._dir.name) / "capture.json" + + def tearDown(self): + self._dir.cleanup() + + async def _record_two_exchanges(self): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/health": + return httpx.Response(200, json={"api_version": "8.8.0"}) + return httpx.Response(200, json={"data": {"id": "run-1"}}) + + io = HTTP(base_url=BASE_URL) + with _serving(handler): + await io.setup() + pylabrobot.start_capture(self.capture_file) + await io.get("/health") + await io.post("/runs", json={"data": {}}) + pylabrobot.stop_capture() + + def _validator(self) -> HTTPValidator: + return HTTPValidator(CaptureReader(path=str(self.capture_file)), base_url=BASE_URL) + + async def test_replay_returns_the_recorded_responses_in_order(self): + await self._record_two_exchanges() + replay = self._validator() + await replay.setup() + + self.assertEqual(await replay.get("/health"), {"api_version": "8.8.0"}) + self.assertEqual(await replay.post("/runs", json={"data": {}}), {"data": {"id": "run-1"}}) + replay.cr.done() + + async def test_a_different_path_fails_validation(self): + await self._record_two_exchanges() + replay = self._validator() + await replay.setup() + + with self.assertRaisesRegex(ValidationError, "Expected http get /instruments"): + await replay.get("/instruments") + + async def test_a_changed_request_body_fails_validation(self): + await self._record_two_exchanges() + replay = self._validator() + await replay.setup() + + await replay.get("/health") + with self.assertRaisesRegex(ValidationError, "Request body mismatch"): + await replay.post("/runs", json={"data": {"changed": True}}) + + async def test_stopping_short_of_the_recording_fails(self): + """The no-cassette guard, in reverse: a dropped command must not pass.""" + await self._record_two_exchanges() + replay = self._validator() + await replay.setup() + + await replay.get("/health") + with self.assertRaisesRegex(ValidationError, "not fully read"): + replay.cr.done() + + async def test_a_recorded_refusal_replays_as_the_same_error(self): + io = HTTP(base_url=BASE_URL) + refusal = {"errors": [{"errorType": "LocationIsOccupiedError"}]} + with _serving(lambda request: httpx.Response(409, json=refusal)): + await io.setup() + pylabrobot.start_capture(self.capture_file) + with self.assertRaises(httpx.HTTPStatusError): + await io.post("/runs/r1/commands", json={"data": {}}) + pylabrobot.stop_capture() + + replay = self._validator() + await replay.setup() + with self.assertRaises(httpx.HTTPStatusError) as caught: + await replay.post("/runs/r1/commands", json={"data": {}}) + self.assertEqual(caught.exception.response.status_code, 409) + self.assertEqual(caught.exception.response.json(), refusal) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/testing/http_server.py b/pylabrobot/testing/http_server.py new file mode 100644 index 00000000000..4b213a58572 --- /dev/null +++ b/pylabrobot/testing/http_server.py @@ -0,0 +1,33 @@ +"""Answer a device driver's HTTP calls from a handler instead of the network. + +Test support for drivers built on :class:`~pylabrobot.io.http.HTTP`: wrap the +part of a test that talks to the device, and every request reaches `handler` +rather than a socket. + + async def robot(request): + return httpx.Response(200, json={"api_version": "8.8.0"}) + + with serving(robot): + await io.setup() + await io.get("/health") +""" + +from typing import Any, Callable, Union +from unittest import mock + +import httpx + +Handler = Callable[[httpx.Request], Union[httpx.Response, Any]] + +# Bound before any patch: the patch target is the httpx module itself, so +# calling httpx.AsyncClient inside the factory would re-enter it. +_REAL_ASYNC_CLIENT = httpx.AsyncClient + + +def serving(handler: Handler): + """Patch the HTTP io's client to answer from `handler`. Sync or async handler.""" + + def make_client(**kwargs: Any) -> httpx.AsyncClient: + return _REAL_ASYNC_CLIENT(transport=httpx.MockTransport(handler), **kwargs) + + return mock.patch("pylabrobot.io.http.httpx.AsyncClient", make_client) From 054d8512cc8a665c32f5491555abfed371cc1bb5 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 14:59:27 -0400 Subject: [PATCH 16/36] `OpentronsRobot`: route the wire through `io.http`, add replay and `send_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) --- pylabrobot/opentrons/robot.py | 25 ++++++ pylabrobot/opentrons/transport.py | 105 ++++++++++++++++++------ pylabrobot/opentrons/transport_tests.py | 87 +++++++++++++++++++- 3 files changed, 191 insertions(+), 26 deletions(-) diff --git a/pylabrobot/opentrons/robot.py b/pylabrobot/opentrons/robot.py index 46df821f849..552d3a08596 100644 --- a/pylabrobot/opentrons/robot.py +++ b/pylabrobot/opentrons/robot.py @@ -157,6 +157,7 @@ async def _connect(self) -> None: """ if self._transport is None: self._transport = HttpxTransport(base_url=self.base_url) + await self._transport.setup() health = await self._get("/health") self.api_version = health.get("api_version") self.robot_model = health.get("robot_model", "") @@ -176,6 +177,30 @@ async def _disconnect(self) -> None: await self._transport.close() self._transport = None + async def send_command( + self, + command_type: str, + params: Optional[Dict[str, Any]] = None, + wait: bool = True, + timeout: float = 30.0, + ) -> Dict[str, Any]: + """Send any robot command by name, for the parts of the robot this class does not wrap. + + The robot accepts far more commands than this driver exposes as methods: + the module families (heater-shaker, thermocycler, temperature, magnetic, + absorbance reader, vacuum, Flex Stacker), calibration, and whatever a + later robot software release adds. ``command_type`` is the robot's own + command name (``"moveToWell"``, ``"heaterShaker/setTargetTemperature"``) + and ``params`` its payload; both are passed through untouched, and the + returned dict is the completed command including its ``result``. + + Prefer a typed method where one exists. This one validates nothing and + updates no resource-tree state, so a command that moves labware or changes + tip or liquid state leaves PyLabRobot's own trackers describing the state + before it ran. + """ + return await self._execute_command(command_type, params or {}, wait=wait, timeout=timeout) + # --- Low-Level Wire Calls --- async def _get(self, path: str) -> Dict[str, Any]: diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index 93cece9763a..1d4fe02f720 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -17,17 +17,29 @@ """ import logging -from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, cast, runtime_checkable +from pathlib import Path +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Protocol, + Tuple, + Union, + cast, + runtime_checkable, +) -try: - import httpx # type: ignore[import-not-found] - - _HAS_HTTPX = True -except ImportError: - _HAS_HTTPX = False +from pylabrobot.io.capture import CaptureReader +from pylabrobot.io.http import HTTP, HTTPValidator logger = logging.getLogger(__name__) +# The robot-server rejects a request that does not name the API version it +# should be read as. +DEFAULT_HEADERS = {"opentrons-version": "3"} + # ChatterboxTransport's default /health version: deliberately not a version # string, so a caller gating on robot software can tell offline from any robot. OFFLINE_API_VERSION = "dry-run" @@ -52,6 +64,8 @@ class OpentronsTransport(Protocol): raising for non-2xx status is the transport's job, not the robot's. """ + async def setup(self) -> None: ... + async def get(self, path: str) -> Dict[str, Any]: ... async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ... @@ -62,7 +76,12 @@ async def close(self) -> None: ... class HttpxTransport: - """Real transport: wraps an ``httpx.AsyncClient`` against the robot-server.""" + """Real transport, over the :class:`~pylabrobot.io.http.HTTP` io. + + Going through the io rather than a bare ``httpx.AsyncClient`` is what puts + every robot-server exchange in PyLabRobot's capture log, so a run wrapped in + ``start_capture()`` can later be replayed by :class:`ReplayTransport`. + """ def __init__( self, @@ -70,31 +89,64 @@ def __init__( timeout: float = 30.0, headers: Optional[Dict[str, str]] = None, ) -> None: - if not _HAS_HTTPX: - raise RuntimeError("httpx is required. Install with: pip install httpx") - self._client = httpx.AsyncClient( - base_url=base_url, - timeout=timeout, - headers=headers or {"opentrons-version": "3"}, - ) + self.io = HTTP(base_url=base_url, timeout=timeout, headers=headers or DEFAULT_HEADERS) + + async def setup(self) -> None: + await self.io.setup() + + async def get(self, path: str) -> Dict[str, Any]: + return cast(Dict[str, Any], await self.io.get(path)) + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return cast(Dict[str, Any], await self.io.post(path, json=json or {})) + + async def delete(self, path: str) -> Dict[str, Any]: + return cast(Dict[str, Any], await self.io.delete(path)) + + async def close(self) -> None: + await self.io.stop() + + +class ReplayTransport: + """Offline transport that replays a capture file recorded from a real robot. + + Where :class:`ChatterboxTransport` returns responses we wrote by hand, this + returns the ones an actual robot gave, in the order it gave them, and raises + the same refusal on an exchange that failed. Nothing reaches the network. + + Build the capture file by wrapping a live run in ``start_capture()`` / + ``stop_capture()``. Call :meth:`assert_fully_replayed` at the end of a test: + it fails when the protocol stopped short of the recording, which is what + catches a dropped command. + """ + + def __init__( + self, + capture_file: Union[str, Path], + base_url: str, + headers: Optional[Dict[str, str]] = None, + ) -> None: + self._cr = CaptureReader(path=str(capture_file)) + self.io = HTTPValidator(self._cr, base_url=base_url, headers=headers or DEFAULT_HEADERS) + + async def setup(self) -> None: + await self.io.setup() async def get(self, path: str) -> Dict[str, Any]: - response = await self._client.get(path) - response.raise_for_status() - return cast(Dict[str, Any], response.json()) + return cast(Dict[str, Any], await self.io.get(path)) async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - response = await self._client.post(path, json=json or {}) - response.raise_for_status() - return cast(Dict[str, Any], response.json()) + return cast(Dict[str, Any], await self.io.post(path, json=json or {})) async def delete(self, path: str) -> Dict[str, Any]: - response = await self._client.delete(path) - response.raise_for_status() - return cast(Dict[str, Any], response.json()) + return cast(Dict[str, Any], await self.io.delete(path)) async def close(self) -> None: - await self._client.aclose() + await self.io.stop() + + def assert_fully_replayed(self) -> None: + """Raise unless every recorded exchange was consumed.""" + self._cr.done() class ChatterboxTransport: @@ -196,6 +248,9 @@ def __init__( # pickUpTip/dropTip command's pipetteId can be resolved back to a mount. self._pipette_id_to_mount: Dict[str, str] = {} + async def setup(self) -> None: + """No connection to open.""" + async def get(self, path: str) -> Dict[str, Any]: if path == "/health": return { diff --git a/pylabrobot/opentrons/transport_tests.py b/pylabrobot/opentrons/transport_tests.py index f854fbea9ff..03387a26620 100644 --- a/pylabrobot/opentrons/transport_tests.py +++ b/pylabrobot/opentrons/transport_tests.py @@ -1,11 +1,26 @@ """Tests for the OpentronsRobot transport seam (Protocol + chatterbox).""" import asyncio +import json +import tempfile import unittest +from pathlib import Path from typing import Any, Dict, List +import httpx + +import pylabrobot +from pylabrobot.io.errors import ValidationError +from pylabrobot.opentrons.flex import OpentronsFlex from pylabrobot.opentrons.robot import OpentronsRobot -from pylabrobot.opentrons.transport import ChatterboxTransport, OpentronsTransport +from pylabrobot.opentrons.transport import ( + ChatterboxTransport, + HttpxTransport, + OpentronsTransport, + ReplayTransport, +) +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.testing.http_server import serving class _StubRobot(OpentronsRobot): @@ -162,5 +177,75 @@ async def _load_both() -> List[str]: self.assertEqual(len(transport.load_pipette_commands), 2) +class RecordAndReplayTests(unittest.IsolatedAsyncioTestCase): + """A recorded Flex lifecycle replays with nothing on the network. + + The recording is taken over ``HttpxTransport``, which is the path a real + robot uses, so what the replay proves is that the driver reaches the same + state from the capture file alone. + """ + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.capture_file = Path(self._dir.name) / "flex_setup.json" + + def tearDown(self): + self._dir.cleanup() + + async def _record_a_setup(self) -> None: + """Drive setup() over HTTP against a stand-in server, capturing as we go.""" + robot_server = ChatterboxTransport( + pipettes=[("p1000_multi_flex", 8, 5.0, 1000.0, "left")], + gripper=True, + ) + + async def answer(request: httpx.Request) -> httpx.Response: + path = request.url.path + if request.method == "GET": + body = await robot_server.get(path) + elif request.method == "DELETE": + body = await robot_server.delete(path) + else: + sent = json.loads(request.content) if request.content else {} + body = await robot_server.post(path, json=sent) + return httpx.Response(200, json=body) + + # The transport is built before capture starts: every pylabrobot io + # refuses construction while a capture is active. + transport = HttpxTransport(base_url="http://robot.test:31950") + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test", transport=transport) + with serving(answer): + pylabrobot.start_capture(self.capture_file) + try: + await flex.setup() + finally: + pylabrobot.stop_capture() + + async def test_replayed_setup_discovers_the_same_head(self): + await self._record_a_setup() + + replay = ReplayTransport(self.capture_file, base_url="http://robot.test:31950") + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test", transport=replay) + await flex.setup() + + left = flex.left + self.assertIsNotNone(left) + self.assertIsNone(flex.right) + assert left is not None + self.assertEqual(left.channels, 8) + replay.assert_fully_replayed() + + async def test_a_dropped_command_fails_the_replay(self): + """Skipping a step must fail, or a replay could pass while doing less.""" + await self._record_a_setup() + + replay = ReplayTransport(self.capture_file, base_url="http://robot.test:31950") + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test", transport=replay) + await flex.connect() + + with self.assertRaisesRegex(ValidationError, "not fully read"): + replay.assert_fully_replayed() + + if __name__ == "__main__": unittest.main() From 68546681b14428f7703cbe3795d5384989ca1b23 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 14:59:27 -0400 Subject: [PATCH 17/36] `OpentronsFlex`: name-based motion, in-place pipetting, and recovery 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) --- pylabrobot/opentrons/flex.py | 157 +++++++- pylabrobot/opentrons/flex_container_tests.py | 253 +++++++++++- .../opentrons/flex_fine_pipetting_tests.py | 360 ++++++++++++++++++ pylabrobot/opentrons/flex_gripper.py | 81 +--- pylabrobot/opentrons/flex_head.py | 234 ++++++++++++ pylabrobot/opentrons/flex_motion_tests.py | 170 +++++++++ pylabrobot/opentrons/flex_wire.py | 102 ++++- 7 files changed, 1272 insertions(+), 85 deletions(-) diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index c13454d3af6..04574b32d7c 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -1,11 +1,11 @@ import logging import uuid -from typing import Any, Dict, List, Optional, Set, Tuple, Type, cast +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, cast from pylabrobot.opentrons.flex_gripper import FlexGripper from pylabrobot.opentrons.catalogue import is_catalogue_labware from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead -from pylabrobot.opentrons.flex_wire import slot_wire_location +from pylabrobot.opentrons.flex_wire import ROBOT_AXES, _require_robot_commands, slot_wire_location from pylabrobot.opentrons.labware_definitions import ( build_container_definition, build_movable_labware_definition, @@ -40,6 +40,9 @@ "opentrons_96_wellplate_200ul_pcr_full_skirt": 2, } +# Seconds of polling a command gets on top of a wait it was told to perform. +_COMMAND_POLL_HEADROOM = 30.0 + # Discovered pipette channel count -> matching head class. _CHANNELS_TO_HEAD: Dict[int, Type[_FlexHead]] = { 1: FlexHead1, @@ -75,6 +78,37 @@ def _warn_grip_distance_discarded( ) +def _validate_axes(axes: Iterable[str]) -> None: + """Raise for any axis name the robot does not address.""" + unknown = sorted(set(axes) - ROBOT_AXES) + if unknown: + raise ValueError( + f"unknown axes: {', '.join(unknown)}. Valid axes are: {', '.join(sorted(ROBOT_AXES))}." + ) + + +def _reported_axis_position(command: Dict[str, Any]) -> Dict[str, float]: + """The axis positions a completed robot/moveAxes* command reports.""" + return cast(Dict[str, float], command.get("result", {}).get("position", {})) + + +def _axis_motion_params( + axis_map: Dict[str, float], + speed: Optional[float], + critical_point: Optional[Dict[str, float]] = None, +) -> Dict[str, Any]: + """The snake_case payload a robot/moveAxes* command takes (unlike the rest of + the API, which is camelCase), with every axis name validated first.""" + _validate_axes(axis_map) + params: Dict[str, Any] = {"axis_map": axis_map} + if critical_point is not None: + _validate_axes(critical_point) + params["critical_point"] = critical_point + if speed is not None: + params["speed"] = speed + return params + + class OpentronsFlex(OpentronsRobot): """Opentrons Flex liquid handler (plain class, post-#1180 architecture). @@ -283,6 +317,45 @@ async def _ensure_labware_loaded( ) return labware_id + async def sync_tips_to_robot(self, tip_rack: TipRack) -> None: + """Make the robot's tip-rack model agree with PyLabRobot's. + + The robot keeps its own record of which spots in a tip rack still hold a + tip, and it is only ever changed by pickups and drops it performed itself. + So anything that changes the rack outside a run -- a human taking tips, a + rack swapped for a partly-used one, state restored from a database -- + leaves the two records disagreeing, with no symptom until the robot picks + up from a spot it believes is full. + + This pushes PyLabRobot's record onto the robot in one ``setTipState`` per + state, taking the resource tree as the truth. Set the tip layout first + (``tip_rack.set_tip_state(...)``), then call this. + + There is no equivalent for liquid. ``loadLiquid`` is the robot's only way + in, and it refuses any liquid the run did not already define, which a run + created without a protocol never does. Well volumes therefore live only in + the resource tree. + """ + labware_id = await self._ensure_labware_loaded(tip_rack) + present: List[str] = [] + absent: List[str] = [] + for spot in tip_rack.get_all_items(): + (present if spot.has_tip() else absent).append(tip_rack.get_child_identifier(spot)) + + for well_names, state in ((present, "tipPresent"), (absent, "tipAbsent")): + if not well_names: + continue + await self._execute_command( + "setTipState", + {"labwareId": labware_id, "wellNames": well_names, "tipWellState": state}, + ) + logger.info( + "Synced '%s' tip state to the robot: %d present, %d absent", + tip_rack.name, + len(present), + len(absent), + ) + async def labware_moved_off_deck(self, resource: Resource) -> None: """Tell the robot an EXTERNAL agent (human or lab transporter) removed labware. @@ -313,6 +386,86 @@ async def labware_moved_off_deck(self, resource: Resource) -> None: self.deck.unassign_child_at_slot(slot) logger.info("Labware '%s' marked moved off-deck", name) + async def reload_labware(self, resource: Resource) -> None: + """Re-read the labware's position after a human moved it in its own slot. + + For labware nudged or re-seated where it already sits: the robot re-applies + its labware offset, keeping the slot. Use ``gripper.move_labware`` to move + it to another slot, and ``labware_moved_off_deck`` when it leaves the deck. + """ + labware_id = await self._ensure_labware_loaded(resource) + await self._execute_command("reloadLabware", {"labwareId": labware_id}) + + # --- Robot-level commands: axis motion, status surfaces, run log --- + + async def move_axes_to( + self, + axis_map: Dict[str, float], + critical_point: Optional[Dict[str, float]] = None, + speed: Optional[float] = None, + ) -> Dict[str, float]: + """Move the named axes to absolute deck-frame positions (mm), returning where they land. + + Every axis the map does not name holds its current position. + ``critical_point`` offsets the point on the mount that is driven to those + positions, also as a per-axis map. ``speed`` is in mm/s (robot default if + None). + + Raises: + ValueError: If any axis is not one the robot addresses. Raised before + any wire command is sent. + """ + _require_robot_commands("robot/moveAxesTo", self.api_version) + params = _axis_motion_params(axis_map, speed, critical_point) + return _reported_axis_position(await self._execute_command("robot/moveAxesTo", params)) + + async def move_axes_relative( + self, axis_map: Dict[str, float], speed: Optional[float] = None + ) -> Dict[str, float]: + """Jog the named axes by distances (mm) from where they are, returning where they land. + + Raises: + ValueError: If any axis is not one the robot addresses. Raised before + any wire command is sent. + """ + _require_robot_commands("robot/moveAxesRelative", self.api_version) + params = _axis_motion_params(axis_map, speed) + return _reported_axis_position(await self._execute_command("robot/moveAxesRelative", params)) + + async def retract_axis(self, axis: str) -> None: + """Retract ``axis`` to its home position, clearing the deck below it. + + Raises: + ValueError: If ``axis`` is not one the robot addresses. Raised before + any wire command is sent. + """ + _validate_axes([axis]) + await self._execute_command("retractAxis", {"axis": axis}) + + async def set_status_bar(self, animation: str) -> None: + """Play a built-in light-bar animation: "idle", "confirm", "updating", "disco" or "off". + + An animation is all the status bar takes: the robot owns the colours and + the timing, so there is nothing per-light to address. + """ + await self._execute_command("setStatusBar", {"animation": animation}) + + async def set_rail_lights(self, on: bool) -> None: + """Turn the deck rail lights on or off.""" + await self._execute_command("setRailLights", {"on": on}) + + async def add_comment(self, message: str) -> None: + """Record ``message`` in the run's command log; the robot does nothing else with it.""" + await self._execute_command("comment", {"message": message}) + + async def wait_for_duration(self, seconds: float) -> None: + """Hold the run for ``seconds`` before the next command runs.""" + # The command only completes once the robot finishes waiting, so the poll + # gets the wait itself plus the usual command headroom. + await self._execute_command( + "waitForDuration", {"seconds": seconds}, timeout=seconds + _COMMAND_POLL_HEADROOM + ) + @staticmethod def _ot_catalogue_identity(resource: Resource) -> Tuple[str, int]: """Resolve a PLR resource to its Opentrons load name and definition version. diff --git a/pylabrobot/opentrons/flex_container_tests.py b/pylabrobot/opentrons/flex_container_tests.py index 7a3e9c10703..d401fbc19d9 100644 --- a/pylabrobot/opentrons/flex_container_tests.py +++ b/pylabrobot/opentrons/flex_container_tests.py @@ -1,4 +1,5 @@ -"""Tests for container (trough/reservoir) ops on the Flex heads. +"""Tests for container (trough/reservoir) ops on the Flex heads, and for the +robot-level commands that belong to the device rather than to a head. A bare PLR ``Container`` is a single-cavity resource with ONE volume tracker; robot-side single-cavity labware definitions expose exactly one well, named @@ -9,16 +10,21 @@ channel holding a tip moves ``volume``; the summed delta commits/rolls back as one op), and the pre-wire rejections (no tip, array or offset-shifted array overhanging the cavity). + +The robot-level tests pin the wire shape of the device's own commands: the +snake_case params the robot/* family takes (unlike the rest of the API), the +axis-name and version-gate refusals that must send nothing, and the exact +params of the status, comment, wait and reload commands. """ import asyncio import unittest -from typing import Any, Dict, Optional, Tuple, Type +from typing import Any, Dict, List, Optional, Tuple, Type from pylabrobot.opentrons.flex import OpentronsFlex from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96 from pylabrobot.opentrons.robot import OpentronsError -from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.opentrons.transport import OFFLINE_API_VERSION, ChatterboxTransport from pylabrobot.resources import ( Container, cor_96_wellplate_360uL_Fb, @@ -879,5 +885,246 @@ def test_head96_plate_ops_refuse_without_tips(self): asyncio.run(flex.stop()) +class _AxisPositionTransport(ChatterboxTransport): + """Chatterbox whose robot/moveAxes* commands report the axis positions they + reached, as the real robot-server does. The dict it hands back is the one the + completion poll reads, so setting the result here is what the caller sees.""" + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + response = await super().post(path, json) + command_type = (json or {}).get("data", {}).get("commandType") + if command_type in ("robot/moveAxesTo", "robot/moveAxesRelative"): + response["data"]["result"] = {"position": {"x": 11.0, "y": 22.0, "leftZ": 33.0}} + return response + + +def _flex_device( + api_version: str = OFFLINE_API_VERSION, + transport_cls: Type[ChatterboxTransport] = ChatterboxTransport, +) -> Tuple[OpentronsFlex, ChatterboxTransport]: + """A set-up ``OpentronsFlex`` plus its transport, for the robot-level + commands that belong to the device rather than to a head. ``api_version`` is + what ``/health`` reports, which is what the robot/* version gate reads. + """ + transport = transport_cls( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], api_version=api_version + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + return flex, transport + + +def _cmds(transport: ChatterboxTransport, command_type: str) -> List[Dict[str, Any]]: + return [c for c in transport.commands if c["commandType"] == command_type] + + +class TestFlexAxisMotion(unittest.TestCase): + """move_axes_to/move_axes_relative send the robot/* family's snake_case + params and return the axis positions the command reports.""" + + def test_move_axes_to_sends_snake_case_axis_map_only(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.move_axes_to({"x": 100.0, "leftZ": 250.0})) + + (cmd,) = _cmds(transport, "robot/moveAxesTo") + self.assertEqual(cmd["params"], {"axis_map": {"x": 100.0, "leftZ": 250.0}}) + finally: + asyncio.run(flex.stop()) + + def test_move_axes_to_sends_critical_point_and_speed_snake_case(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.move_axes_to({"x": 1.0}, critical_point={"rightZ": 5.0}, speed=40.0)) + + (cmd,) = _cmds(transport, "robot/moveAxesTo") + self.assertEqual( + cmd["params"], + {"axis_map": {"x": 1.0}, "critical_point": {"rightZ": 5.0}, "speed": 40.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_move_axes_relative_sends_snake_case_axis_map_and_speed(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.move_axes_relative({"y": -5.0}, speed=20.0)) + + (cmd,) = _cmds(transport, "robot/moveAxesRelative") + self.assertEqual(cmd["params"], {"axis_map": {"y": -5.0}, "speed": 20.0}) + finally: + asyncio.run(flex.stop()) + + def test_speed_omitted_when_not_given(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.move_axes_relative({"y": -5.0})) + + (cmd,) = _cmds(transport, "robot/moveAxesRelative") + self.assertNotIn("speed", cmd["params"]) + finally: + asyncio.run(flex.stop()) + + def test_both_moves_return_the_reported_position(self): + flex, _transport = _flex_device(transport_cls=_AxisPositionTransport) + try: + reached = {"x": 11.0, "y": 22.0, "leftZ": 33.0} + self.assertEqual(asyncio.run(flex.move_axes_to({"x": 11.0})), reached) + self.assertEqual(asyncio.run(flex.move_axes_relative({"x": 1.0})), reached) + finally: + asyncio.run(flex.stop()) + + def test_unknown_axis_refused_before_any_wire_command(self): + flex, transport = _flex_device() + try: + for op in ( + lambda: flex.move_axes_to({"x": 1.0, "zed": 2.0}), + lambda: flex.move_axes_relative({"zed": 2.0}), + ): + commands_before = len(transport.commands) + with self.assertRaises(ValueError) as caught: + asyncio.run(op()) + # The refusal names what was wrong AND what would have been right. + self.assertIn("zed", str(caught.exception)) + self.assertIn("extensionJaw", str(caught.exception)) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_unknown_critical_point_axis_refused_before_any_wire_command(self): + flex, transport = _flex_device() + try: + commands_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(flex.move_axes_to({"x": 1.0}, critical_point={"zed": 2.0})) + + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_retract_axis_refuses_an_unknown_axis_before_the_wire(self): + flex, transport = _flex_device() + try: + commands_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(flex.retract_axis("zed")) + + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + +class TestFlexAxisMotionVersionGate(unittest.TestCase): + """The robot/* moveAxes commands need robot software 8.2.0+; retractAxis + predates that family and is not gated.""" + + def test_old_release_refuses_both_moves_and_sends_nothing(self): + flex, transport = _flex_device(api_version="8.1.0") + try: + for op in ( + lambda: flex.move_axes_to({"x": 1.0}), + lambda: flex.move_axes_relative({"x": 1.0}), + ): + with self.assertRaises(OpentronsError) as caught: + asyncio.run(op()) + self.assertIn("8.2.0", str(caught.exception)) + + robot_cmds = [c for c in transport.commands if c["commandType"].startswith("robot/")] + self.assertEqual(robot_cmds, []) + finally: + asyncio.run(flex.stop()) + + def test_retract_axis_is_not_gated(self): + flex, transport = _flex_device(api_version="8.1.0") + try: + asyncio.run(flex.retract_axis("leftZ")) + self.assertEqual(len(_cmds(transport, "retractAxis")), 1) + finally: + asyncio.run(flex.stop()) + + +class TestFlexRobotCommands(unittest.TestCase): + """The device's one-shot commands: exact params, no extras.""" + + def test_retract_axis(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.retract_axis("extensionJaw")) + + (cmd,) = _cmds(transport, "retractAxis") + self.assertEqual(cmd["params"], {"axis": "extensionJaw"}) + finally: + asyncio.run(flex.stop()) + + def test_set_status_bar(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.set_status_bar("disco")) + + (cmd,) = _cmds(transport, "setStatusBar") + self.assertEqual(cmd["params"], {"animation": "disco"}) + finally: + asyncio.run(flex.stop()) + + def test_set_rail_lights_carries_the_boolean_both_ways(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.set_rail_lights(True)) + asyncio.run(flex.set_rail_lights(False)) + + cmds = _cmds(transport, "setRailLights") + self.assertEqual([c["params"] for c in cmds], [{"on": True}, {"on": False}]) + finally: + asyncio.run(flex.stop()) + + def test_add_comment(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.add_comment("starting plate 3")) + + (cmd,) = _cmds(transport, "comment") + self.assertEqual(cmd["params"], {"message": "starting plate 3"}) + finally: + asyncio.run(flex.stop()) + + def test_wait_for_duration(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.wait_for_duration(2.5)) + + (cmd,) = _cmds(transport, "waitForDuration") + self.assertEqual(cmd["params"], {"seconds": 2.5}) + finally: + asyncio.run(flex.stop()) + + def test_reload_labware_names_the_id_the_labware_was_loaded_under(self): + flex, transport = _flex_device() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(flex.reload_labware(trough)) + + (load_cmd,) = _cmds(transport, "loadLabware") + (reload_cmd,) = _cmds(transport, "reloadLabware") + self.assertEqual(reload_cmd["params"], {"labwareId": load_cmd["params"]["labwareId"]}) + finally: + asyncio.run(flex.stop()) + + def test_reload_labware_of_loaded_labware_does_not_load_it_again(self): + flex, transport = _flex_device() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(flex.reload_labware(trough)) + asyncio.run(flex.reload_labware(trough)) + + self.assertEqual(len(_cmds(transport, "loadLabware")), 1) + self.assertEqual(len(_cmds(transport, "reloadLabware")), 2) + finally: + asyncio.run(flex.stop()) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index ec3fe4318be..5f9565b840a 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -13,6 +13,12 @@ does not follow it, ``touch_tip``; plus the two rules the single-nozzle cherry-pick runs under: which nozzles an 8-channel Flex can anchor on, and that no nozzle layout changes while a tip is mounted. + +Also covers the base-class ops every head inherits: the in-place liquid ops +(``aspirate_in_place``/``dispense_in_place``/``air_gap_in_place``), which name +no well and move no tracker; the two tip-presence commands +(``get_tip_presence`` reports, ``verify_tip_presence`` makes the robot fail on +a mismatch); ``configure_for_volume``; and the ``unsafe_*`` recovery pair. """ import asyncio @@ -985,5 +991,359 @@ def test_stop_leaves_no_cherry_picked_tip_on_the_pipette(self): self.assertTrue(all(tip is None for tip in head.get_mounted_tips())) +class TestInPlaceLiquidOps(unittest.TestCase): + """The in-place ops act where the head already is: one command each, naming + no labware and no well, carrying the flow-rate default of the motion they + are (aspirate for the air gap too). Each requires a mounted tip, refused + before the wire, and an unprimed plunger is primed first, since the robot + requires a prepareToAspirate before any aspirate, in place or not.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + return flex, transport, head, rack + + def _params(self, transport: ChatterboxTransport, command_type: str) -> dict: + cmds = [c for c in transport.commands if c["commandType"] == command_type] + self.assertEqual(len(cmds), 1) + params: dict = cmds[0]["params"] + return params + + def test_aspirate_in_place_sends_volume_and_the_aspirate_default_flow_rate(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + + self.assertEqual( + self._params(transport, "aspirateInPlace"), + {"pipetteId": head.pipette_id, "volume": 15, "flowRate": 35.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_in_place_accepts_a_flow_rate_override(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15, flow_rate=3.5)) + + self.assertEqual(self._params(transport, "aspirateInPlace")["flowRate"], 3.5) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_in_place_primes_the_unprimed_plunger_first_and_only_once(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.aspirate_in_place(volume=15)) + + sent = [c["commandType"] for c in transport.commands] + self.assertEqual(sent.count("prepareToAspirate"), 1) + self.assertEqual(sent.count("aspirateInPlace"), 2) + self.assertEqual(sent[sent.index("aspirateInPlace") - 1], "prepareToAspirate") + finally: + asyncio.run(flex.stop()) + + def test_dispense_in_place_sends_the_dispense_default_and_omits_push_out(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.dispense_in_place(volume=15)) + + self.assertEqual( + self._params(transport, "dispenseInPlace"), + {"pipetteId": head.pipette_id, "volume": 15, "flowRate": 57.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_dispense_in_place_carries_push_out_only_when_given(self): + # Omitted rather than sent as null, so the robot picks its own push-out + # for the mounted tip and volume. + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.dispense_in_place(volume=15, flow_rate=8.0, push_out=2.5)) + + self.assertEqual( + self._params(transport, "dispenseInPlace"), + {"pipetteId": head.pipette_id, "volume": 15, "flowRate": 8.0, "pushOut": 2.5}, + ) + finally: + asyncio.run(flex.stop()) + + def test_air_gap_in_place_uses_the_aspirate_default_flow_rate(self): + # The air gap IS an aspirate motion, so it takes the aspirate default, + # not the dispense one. + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.air_gap_in_place(volume=5)) + + self.assertEqual( + self._params(transport, "airGapInPlace"), + {"pipetteId": head.pipette_id, "volume": 5, "flowRate": 35.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_air_gap_in_place_accepts_a_flow_rate_override(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.air_gap_in_place(volume=5, flow_rate=1.25)) + + self.assertEqual(self._params(transport, "airGapInPlace")["flowRate"], 1.25) + finally: + asyncio.run(flex.stop()) + + def test_every_in_place_op_without_a_tip_raises_and_sends_nothing(self): + flex, transport, head, _rack = self._bench() + try: + for op in ( + lambda: head.aspirate_in_place(volume=15), + lambda: head.dispense_in_place(volume=15), + lambda: head.air_gap_in_place(volume=5), + ): + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(op()) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + def test_head1_and_head96_inherit_the_in_place_ops(self): + # They live on the base class, so every head issues them identically. + flex1, transport1, head1 = _flex_head1() + try: + rack1 = flex_96_tiprack_50ul(name="rack1") + flex1.deck.assign_child_at_slot(rack1, "C1") + asyncio.run(head1.pick_up_tips(rack1.get_item("A1"))) + asyncio.run(head1.aspirate_in_place(volume=15)) + asyncio.run(head1.dispense_in_place(volume=15)) + + # Each head carries its OWN pipette's default, not one shared number: + # p1000_single_v3.5 on a 50uL tip against the 96-head's 6.0 below. + self.assertEqual( + self._params(transport1, "aspirateInPlace"), + {"pipetteId": head1.pipette_id, "volume": 15, "flowRate": 478.0}, + ) + self.assertEqual(self._params(transport1, "dispenseInPlace")["volume"], 15) + finally: + asyncio.run(flex1.stop()) + + flex96, transport96, head96 = _flex_head96() + try: + rack96 = flex_96_tiprack_50ul(name="rack96") + flex96.deck.assign_child_at_slot(rack96, "C1") + asyncio.run(head96.pick_up_tips(rack96)) + asyncio.run(head96.air_gap_in_place(volume=5)) + + self.assertEqual( + self._params(transport96, "airGapInPlace"), + {"pipetteId": head96.pipette_id, "volume": 5, "flowRate": 6.0}, + ) + finally: + asyncio.run(flex96.stop()) + + +class _TipPresenceTransport(ChatterboxTransport): + """Answers getTipPresence with a fixed sensor reading.""" + + def __init__(self, status: str, **kwargs): + super().__init__(**kwargs) + self.status = status + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if path.endswith("/commands") and data.get("commandType") == "getTipPresence": + result["data"]["result"] = {"status": self.status} + return result + + +class TestTipPresenceCommands(unittest.TestCase): + """get_tip_presence returns the sensor reading from the command result and + leaves the judgement to the caller; verify_tip_presence hands the robot the + state to check against, and refuses a state that is neither "present" nor + "absent" before anything reaches the wire.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def _head1_with_status(self, status: str): + transport = _TipPresenceTransport( + status, pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")] + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.right + assert isinstance(head, FlexHead1) + return flex, transport, head + + def test_get_tip_presence_returns_the_reported_status(self): + flex, transport, head = self._head1_with_status("present") + try: + self.assertEqual(asyncio.run(head.get_tip_presence()), "present") + + presence_cmds = [c for c in transport.commands if c["commandType"] == "getTipPresence"] + self.assertEqual(len(presence_cmds), 1) + self.assertEqual(presence_cmds[0]["params"], {"pipetteId": head.pipette_id}) + finally: + asyncio.run(flex.stop()) + + def test_get_tip_presence_reports_absent_without_raising(self): + flex, _transport, head = self._head1_with_status("absent") + try: + self.assertEqual(asyncio.run(head.get_tip_presence()), "absent") + finally: + asyncio.run(flex.stop()) + + def test_verify_tip_presence_sends_the_expected_state(self): + flex, transport, head = _flex_head8() + try: + asyncio.run(head.verify_tip_presence("absent")) + asyncio.run(head.verify_tip_presence("present")) + + verify_cmds = [c for c in transport.commands if c["commandType"] == "verifyTipPresence"] + self.assertEqual( + [c["params"] for c in verify_cmds], + [ + {"pipetteId": head.pipette_id, "expectedState": "absent"}, + {"pipetteId": head.pipette_id, "expectedState": "present"}, + ], + ) + finally: + asyncio.run(flex.stop()) + + def test_verify_tip_presence_rejects_any_other_state_and_sends_nothing(self): + # "unknown" is a reading the sensor can return, not a state worth + # asserting, so it is refused with the rest. + flex, transport, head = _flex_head8() + try: + for state in ("unknown", "Present", "", "yes"): + n_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(head.verify_tip_presence(state)) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestConfigureForVolume(unittest.TestCase): + """configure_for_volume names the volume the pipette should be set up for. + It runs with no tip mounted on purpose: the robot refuses a mode change + while a tip is attached, so it belongs before the pickup.""" + + def test_configure_for_volume_sends_pipette_id_and_volume_before_any_pickup(self): + flex, transport, head = _flex_head8() + try: + asyncio.run(head.configure_for_volume(5.0)) + + configure_cmds = [c for c in transport.commands if c["commandType"] == "configureForVolume"] + self.assertEqual(len(configure_cmds), 1) + self.assertEqual(configure_cmds[0]["params"], {"pipetteId": head.pipette_id, "volume": 5.0}) + finally: + asyncio.run(flex.stop()) + + +class TestUnsafeRecoveryOps(unittest.TestCase): + """The unsafe/ ops are the recovery path: one command each, no labware, no + trackers. The drop clears this head's per-channel tip bookkeeping (the tip + goes back to no rack), and the blow-out leaves the plunger unprimed the way + the ordinary blow_out does. Both require a mounted tip.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + return flex, transport, head, rack + + def test_unsafe_drop_tip_in_place_sends_pipette_id_and_clears_mounted_tips(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.unsafe_drop_tip_in_place()) + + drop_cmds = [c for c in transport.commands if c["commandType"] == "unsafe/dropTipInPlace"] + self.assertEqual(len(drop_cmds), 1) + self.assertEqual(drop_cmds[0]["params"], {"pipetteId": head.pipette_id}) + self.assertTrue(all(tip is None for tip in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_unsafe_drop_tip_in_place_returns_no_tip_to_the_rack(self): + # The tip falls where the head is, so the spot it came from stays empty. + flex, _transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.unsafe_drop_tip_in_place()) + + self.assertFalse(rack.get_item("A1").has_tip()) + finally: + asyncio.run(flex.stop()) + + def test_unsafe_blow_out_in_place_sends_the_given_flow_rate(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.unsafe_blow_out_in_place(flow_rate=20.0)) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "unsafe/blowOutInPlace"] + self.assertEqual(len(blow_cmds), 1) + self.assertEqual(blow_cmds[0]["params"], {"pipetteId": head.pipette_id, "flowRate": 20.0}) + finally: + asyncio.run(flex.stop()) + + def test_next_aspirate_reprimes_after_an_unsafe_blow_out(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=10)) + asyncio.run(head.unsafe_blow_out_in_place(flow_rate=20.0)) + asyncio.run(head.aspirate_in_place(volume=10)) + + sent = [c["commandType"] for c in transport.commands] + self.assertEqual(sent.count("prepareToAspirate"), 2, "a blow-out must require a new prepare") + finally: + asyncio.run(flex.stop()) + + def test_unsafe_ops_without_a_tip_raise_and_send_nothing(self): + flex, transport, head, _rack = self._bench() + try: + for op in ( + head.unsafe_drop_tip_in_place, + lambda: head.unsafe_blow_out_in_place(flow_rate=20.0), + ): + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(op()) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/opentrons/flex_gripper.py b/pylabrobot/opentrons/flex_gripper.py index 43c025cabed..bf9542a089d 100644 --- a/pylabrobot/opentrons/flex_gripper.py +++ b/pylabrobot/opentrons/flex_gripper.py @@ -16,11 +16,14 @@ """ import logging -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, Optional -from pylabrobot.opentrons.flex_wire import UNTESTED_HARDWARE_WARNING, slot_wire_location +from pylabrobot.opentrons.flex_wire import ( + UNTESTED_HARDWARE_WARNING, + _require_robot_commands, + slot_wire_location, +) from pylabrobot.opentrons.robot import OpentronsError -from pylabrobot.opentrons.transport import OFFLINE_API_VERSION from pylabrobot.resources.resource import Resource if TYPE_CHECKING: @@ -32,83 +35,11 @@ # them far more headroom than the 30s ``_execute_command`` default. _MOVE_LABWARE_TIMEOUT = 120.0 -# The robot/* direct-motion command family (robot/moveTo, -# robot/openGripperJaw, robot/closeGripperJaw) landed in robot-server 8.2.0. -_ROBOT_COMMANDS_MIN_VERSION = "8.2.0" - # Grip-force bounds (Newtons) the robot-server accepts for closeGripperJaw. _GRIPPER_MIN_FORCE = 2.0 _GRIPPER_MAX_FORCE = 30.0 -def _version_tuple(version: str) -> Tuple[int, ...]: - """Parse a dotted robot-software version into comparable integers. - - Comparing these as strings puts "10.0.0" below "7.1.0", so the version gate - compares numerically. Each dotted segment contributes its leading integer - ("0-beta" -> 0); a segment with no leading digit stops the parse, and short - results pad with zeros so "8.2" compares equal to "8.2.0". - - Raises: - ValueError: If the version has no leading numeric segment at all. - """ - parts: List[int] = [] - for part in version.split("."): - digits = "" - for char in part: - if not char.isdigit(): - break - digits += char - if digits == "": - break - parts.append(int(digits)) - if not parts: - raise ValueError(f"unparseable version string: {version!r}") - while len(parts) < 3: - parts.append(0) - return tuple(parts) - - -def _require_robot_commands(command: str, api_version: Optional[str]) -> None: - """Raise unless the robot's software supports the robot/* command family. - - ``api_version`` is the ``GET /health`` ``api_version`` the owning robot - stored at setup (``flex.api_version``). Released builds report a plain - numeric version and are gated against ``_ROBOT_COMMANDS_MIN_VERSION``. - - Two exemptions, both narrow. An UNTAGGED build reports "0.0.0.dev*" (the - version an unreleased source checkout carries, including the simulated - robot-server) and runs current code, so it passes -- but a build cut off a - real tag reports that tag plus a dev suffix ("8.1.0.dev5"), which is gated - on the tag like any release. ``ChatterboxTransport``'s offline "dry-run" - sentinel passes too; it reaches no robot at all. Any other unparseable - version raises rather than silently passing the gate. - """ - if api_version is None: - raise OpentronsError( - "Robot version unknown", - f"{command} requires setup() to have run, to read the robot's version.", - ) - if api_version == OFFLINE_API_VERSION: - return - try: - version = _version_tuple(api_version) - except ValueError: - raise OpentronsError( - "Robot version unrecognized", - f"{command} is gated on robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, but this " - f"robot reports the unrecognized version {api_version!r}.", - ) from None - if version == (0, 0, 0) and "dev" in api_version: - return - if version < _version_tuple(_ROBOT_COMMANDS_MIN_VERSION): - raise OpentronsError( - "Robot software too old", - f"{command} requires Opentrons robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, " - f"but this robot reports {api_version}.", - ) - - class FlexGripper: """The Opentrons Flex gripper (extension mount). diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 8c08c549692..dfff1f84a6a 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -607,11 +607,245 @@ async def move_to( params["speed"] = speed await self._execute("moveToCoordinates", params) + async def move_to_well( + self, + target: Union[Well, Container], + offset: Optional[Coordinate] = None, + origin: str = "top", + minimum_z_height: Optional[float] = None, + speed: Optional[float] = None, + ) -> None: + """Move to a well, named rather than measured -- ONE ``moveToWell`` command. + + Prefer this over :meth:`move_to` for anything positioned relative to + labware. The robot owns the geometry, so naming the well lets it work out + where that is and refuse a move it cannot make, the same way it checks an + aspirate. ``move_to`` sends raw deck coordinates, which nothing on either + side bounds-checks. + + ``origin`` is where the offset is measured from: "top", "bottom", + "center", or "meniscus" (the last needs the robot to have a liquid level + for the well). So 10 mm above the well is ``origin="top"`` with + ``offset=Coordinate(z=10)``. + + No mounted tip is required: the target is the tip bottom when one is + mounted, the nozzle when none is. + """ + self._warn_untested_hardware("move_to_well") + if origin not in _WELL_ORIGINS: + raise ValueError(f"origin must be one of {sorted(_WELL_ORIGINS)}, got {origin!r}") + if isinstance(target, Well): + parent = self._require_itemized_parent(target) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(target) + else: + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = _CONTAINER_WELL_NAME + + o = offset or Coordinate(0, 0, 0) + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "wellLocation": {"origin": origin, "offset": {"x": o.x, "y": o.y, "z": o.z}}, + "minimumZHeight": minimum_z_height if minimum_z_height is not None else _TRAVERSAL_HEIGHT, + } + if speed is not None: + params["speed"] = speed + await self._execute("moveToWell", params) + + async def move_relative(self, axis: str, distance: float) -> None: + """Jog one axis by ``distance`` mm from wherever the head is now. + + ``axis`` is "x", "y" or "z". A negative distance moves the other way. + Relative to the head's current position, so unlike :meth:`move_to` it + needs no reading first. + """ + self._warn_untested_hardware("move_relative") + if axis not in _MOVE_AXES: + raise ValueError(f"axis must be one of {sorted(_MOVE_AXES)}, got {axis!r}") + await self._execute( + "moveRelative", + {"pipetteId": self.pipette_id, "axis": axis, "distance": distance}, + ) + + async def move_to_addressable_area( + self, + addressable_area_name: str, + offset: Optional[Coordinate] = None, + minimum_z_height: Optional[float] = None, + speed: Optional[float] = None, + stay_at_max_height: bool = False, + ) -> None: + """Move to a named fixture on the deck rather than to labware. + + An addressable area is somewhere the deck itself provides: a trash bin, a + waste chute, a staging slot. Named, so the robot resolves the position. + """ + self._warn_untested_hardware("move_to_addressable_area") + o = offset or Coordinate(0, 0, 0) + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "addressableAreaName": addressable_area_name, + "offset": {"x": o.x, "y": o.y, "z": o.z}, + "stayAtHighestPossibleZ": stay_at_max_height, + "minimumZHeight": minimum_z_height if minimum_z_height is not None else _TRAVERSAL_HEIGHT, + } + if speed is not None: + params["speed"] = speed + await self._execute("moveToAddressableArea", params) + + # --- In-place pipetting (acts where the head already is) --- + + async def aspirate_in_place(self, volume: float, flow_rate: Optional[float] = None) -> None: + """Aspirate ``volume`` uL where the head already is -- one ``aspirateInPlace`` command. + + Names no well, so no ``Well``/``Container`` tracker moves with it: + position the head first (``move_to_well``/``move_to``) and account for the + liquid yourself. ``flow_rate`` (uL/s) defaults to the aspirate default. A + ``prepareToAspirate`` command is sent first when the plunger is unprimed + (after a tip pickup or a blow-out), which the robot requires before any + aspirate, in place or not. + """ + self._warn_untested_hardware("aspirate_in_place") + self._require_mounted_tip() + rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate + await self._execute_with_prepare( + "aspirateInPlace", + {"pipetteId": self.pipette_id, "volume": volume, "flowRate": rate}, + [], + ) + + async def dispense_in_place( + self, + volume: float, + flow_rate: Optional[float] = None, + push_out: Optional[float] = None, + ) -> None: + """Dispense ``volume`` uL where the head already is -- one ``dispenseInPlace`` command. + + Names no well, so no tracker moves with it (see ``aspirate_in_place``). + ``push_out`` (uL) pushes the plunger past its dispense bottom to clear the + last drops; left out of the command entirely when None, so the robot + applies its own default for the mounted tip and volume. + """ + self._warn_untested_hardware("dispense_in_place") + self._require_mounted_tip() + rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "volume": volume, + "flowRate": rate, + } + if push_out is not None: + params["pushOut"] = push_out + await self._execute("dispenseInPlace", params) + + async def air_gap_in_place(self, volume: float, flow_rate: Optional[float] = None) -> None: + """Draw a ``volume`` uL air gap where the head already is -- one ``airGapInPlace`` command. + + The same plunger motion as ``aspirate_in_place``, but the robot books the + volume as air, so park the tip above the liquid first. ``flow_rate`` + (uL/s) defaults to the aspirate default, and the same priming rule + applies. No tracker is involved. + """ + self._warn_untested_hardware("air_gap_in_place") + self._require_mounted_tip() + rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate + await self._execute_with_prepare( + "airGapInPlace", + {"pipetteId": self.pipette_id, "volume": volume, "flowRate": rate}, + [], + ) + + # --- Tip-presence sensor (command form) --- + + async def get_tip_presence(self) -> Optional[str]: + """Read this head's tip sensor: "present", "absent" or "unknown". + + One reading per pipette, not per channel -- the same aggregate state + ``has_tip_on_hardware()`` reads from ``GET /instruments``, asked for as a + run command instead. ``None`` when the command reports no status. + """ + self._warn_untested_hardware("get_tip_presence") + result = await self._execute("getTipPresence", {"pipetteId": self.pipette_id}) + return cast(Optional[str], result.get("result", {}).get("status")) + + async def verify_tip_presence(self, expected_state: str) -> None: + """Have the robot fail the command unless its tip sensor reads ``expected_state``. + + ``expected_state`` is "present" or "absent". Where ``get_tip_presence`` + reports and leaves the judgement to the caller, this one raises the + mismatch from the robot side, so it reads as a checkpoint in a sequence. + """ + self._warn_untested_hardware("verify_tip_presence") + if expected_state not in _TIP_PRESENCE_STATES: + raise ValueError( + f"expected_state must be one of {sorted(_TIP_PRESENCE_STATES)}, got {expected_state!r}" + ) + await self._execute( + "verifyTipPresence", + {"pipetteId": self.pipette_id, "expectedState": expected_state}, + ) + + async def configure_for_volume(self, volume: float) -> None: + """Put the pipette in the volume mode that suits ``volume`` uL. + + A Flex pipette only reaches its stated accuracy at small volumes in its + low-volume mode, which this picks for the volume given. Call it before + picking up tips: the robot refuses a mode change while a tip is attached. + """ + self._warn_untested_hardware("configure_for_volume") + await self._execute("configureForVolume", {"pipetteId": self.pipette_id, "volume": volume}) + + # --- Recovery ops --- + + async def unsafe_drop_tip_in_place(self) -> None: + """Drop the mounted tip where the head is, skipping the engine's own checks. + + The "unsafe/" commands are the recovery path: they still run once the + engine has put the run into an error state, where the ordinary + ``dropTipInPlace`` is refused. The tip falls wherever the head happens to + be, so move somewhere it can be retrieved from first. Clears this head's + per-channel tip bookkeeping; no tip tracker is touched, since the tip + goes back to no rack. + """ + self._warn_untested_hardware("unsafe_drop_tip_in_place") + self._require_mounted_tip() + await self._execute("unsafe/dropTipInPlace", {"pipetteId": self.pipette_id}) + self._channel_tips = [None] * self.channels + + async def unsafe_blow_out_in_place(self, flow_rate: float) -> None: + """Blow out where the head is, skipping the engine's own checks. + + The recovery counterpart to ``blow_out`` (see ``unsafe_drop_tip_in_place`` + for what "unsafe/" buys). ``flow_rate`` is in uL/s and has no default + here, the recovery path being an explicit one. Leaves the plunger at the + blow-out position, so the next aspirate re-primes. + """ + self._warn_untested_hardware("unsafe_blow_out_in_place") + self._require_mounted_tip() + await self._execute( + "unsafe/blowOutInPlace", + {"pipetteId": self.pipette_id, "flowRate": flow_rate}, + ) + self._prepared = False + # Default minimumZHeight (mm) for moveToCoordinates jogs: the head keeps at # least this z while traveling, clearing any labware on the deck. _TRAVERSAL_HEIGHT = 120.0 +# Where a wellLocation offset is measured from. "meniscus" needs the robot to +# hold a liquid level for the well, which only a liquid probe gives it. +_WELL_ORIGINS = frozenset({"top", "bottom", "center", "meniscus"}) + +_MOVE_AXES = frozenset({"x", "y", "z"}) + +# What verify_tip_presence can assert. The sensor itself can also read +# "unknown", but that is a reading, not something to check against. +_TIP_PRESENCE_STATES = frozenset({"present", "absent"}) + # The only nozzles an 8-channel Flex can anchor a SINGLE layout on ("A1" is # the rearmost, "H1" the frontmost), mapped to the channel each one is. _SINGLE_NOZZLES = {"A1": 0, "H1": 7} diff --git a/pylabrobot/opentrons/flex_motion_tests.py b/pylabrobot/opentrons/flex_motion_tests.py index b2fae5ce8fe..421fde28894 100644 --- a/pylabrobot/opentrons/flex_motion_tests.py +++ b/pylabrobot/opentrons/flex_motion_tests.py @@ -21,6 +21,7 @@ from pylabrobot.resources import set_tip_tracking from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_plates import corning_96_wellplate_360ul_flat from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul @@ -465,3 +466,172 @@ def test_gripper_ops_warn_once(self): if __name__ == "__main__": unittest.main() + + +class TestMoveToWell(unittest.TestCase): + """move_to_well names the well and lets the robot resolve where that is.""" + + def _flex_with_plate(self): + flex, transport = _flex_with_gripper() + plate = corning_96_wellplate_360ul_flat(name="plate") + flex.deck.assign_child_at_slot(plate, "C1") + return flex, transport, plate + + def test_names_the_well_and_defaults_to_the_top_origin(self): + flex, transport, plate = self._flex_with_plate() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to_well(plate.get_item("D2"))) + + (cmd,) = _cmds(transport, "moveToWell") + self.assertEqual(cmd["params"]["wellName"], "D2") + self.assertEqual( + cmd["params"]["wellLocation"], + {"origin": "top", "offset": {"x": 0, "y": 0, "z": 0}}, + ) + self.assertNotIn("coordinates", cmd["params"]) + finally: + asyncio.run(flex.stop()) + + def test_offset_above_the_well_rides_the_top_origin(self): + """'10 mm above the D2 well' is an offset from the top, not a coordinate.""" + flex, transport, plate = self._flex_with_plate() + asyncio.run(flex.setup()) + try: + asyncio.run( + _head(flex).move_to_well(plate.get_item("D2"), offset=Coordinate(0, 0, 10), speed=50.0) + ) + + (cmd,) = _cmds(transport, "moveToWell") + self.assertEqual(cmd["params"]["wellLocation"]["origin"], "top") + self.assertEqual(cmd["params"]["wellLocation"]["offset"]["z"], 10) + self.assertEqual(cmd["params"]["speed"], 50.0) + finally: + asyncio.run(flex.stop()) + + def test_unknown_origin_is_refused_before_any_wire_command(self): + flex, transport, plate = self._flex_with_plate() + asyncio.run(flex.setup()) + try: + with self.assertRaisesRegex(ValueError, "origin must be one of"): + asyncio.run(_head(flex).move_to_well(plate.get_item("A1"), origin="sideways")) + self.assertEqual(_cmds(transport, "moveToWell"), []) + finally: + asyncio.run(flex.stop()) + + def test_no_mounted_tip_required(self): + """Jogging to a well is for teaching and recovery, so it must not need a tip.""" + flex, transport, plate = self._flex_with_plate() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to_well(plate.get_item("A1"))) + self.assertEqual(len(_cmds(transport, "moveToWell")), 1) + finally: + asyncio.run(flex.stop()) + + +class TestMoveRelative(unittest.TestCase): + """move_relative jogs one axis without reading the position first.""" + + def test_sends_axis_and_distance_only(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_relative("z", -5.0)) + + (cmd,) = _cmds(transport, "moveRelative") + self.assertEqual(cmd["params"]["axis"], "z") + self.assertEqual(cmd["params"]["distance"], -5.0) + self.assertEqual(_cmds(transport, "savePosition"), []) + finally: + asyncio.run(flex.stop()) + + def test_unknown_axis_is_refused_before_any_wire_command(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + with self.assertRaisesRegex(ValueError, "axis must be one of"): + asyncio.run(_head(flex).move_relative("w", 1.0)) + self.assertEqual(_cmds(transport, "moveRelative"), []) + finally: + asyncio.run(flex.stop()) + + +class TestMoveToAddressableArea(unittest.TestCase): + """move_to_addressable_area targets a deck fixture by name.""" + + def test_names_the_area_and_carries_the_offset(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run( + _head(flex).move_to_addressable_area("movableTrashA3", offset=Coordinate(0, 0, 5)) + ) + + (cmd,) = _cmds(transport, "moveToAddressableArea") + self.assertEqual(cmd["params"]["addressableAreaName"], "movableTrashA3") + self.assertEqual(cmd["params"]["offset"], {"x": 0, "y": 0, "z": 5}) + self.assertFalse(cmd["params"]["stayAtHighestPossibleZ"]) + finally: + asyncio.run(flex.stop()) + + +class TestSendCommandEscapeHatch(unittest.TestCase): + """send_command reaches commands the driver wraps no method around.""" + + def test_passes_command_type_and_params_through_untouched(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + params = {"moduleId": "abc", "celsius": 37.0} + asyncio.run(flex.send_command("heaterShaker/setTargetTemperature", params)) + + (cmd,) = _cmds(transport, "heaterShaker/setTargetTemperature") + self.assertEqual(cmd["params"], params) + finally: + asyncio.run(flex.stop()) + + def test_defaults_params_to_an_empty_payload(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(flex.send_command("unsafe/engageAxes")) + (cmd,) = _cmds(transport, "unsafe/engageAxes") + self.assertEqual(cmd["params"], {}) + finally: + asyncio.run(flex.stop()) + + +class TestSyncTipsToRobot(unittest.TestCase): + """sync_tips_to_robot pushes PyLabRobot's tip layout onto the robot.""" + + def test_splits_present_and_absent_into_one_command_each(self): + flex, transport = _flex_with_gripper() + rack = flex_96_tiprack_50ul(name="tips") + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex.setup()) + try: + rack.set_tip_state({spot.get_identifier(): False for spot in rack.get_all_items()}) + rack.set_tip_state({"A1": True, "B1": True}) + + asyncio.run(flex.sync_tips_to_robot(rack)) + + cmds = _cmds(transport, "setTipState") + by_state = {c["params"]["tipWellState"]: c["params"]["wellNames"] for c in cmds} + self.assertEqual(by_state["tipPresent"], ["A1", "B1"]) + self.assertEqual(len(by_state["tipAbsent"]), 94) + finally: + asyncio.run(flex.stop()) + + def test_a_uniform_rack_sends_only_the_state_it_has(self): + flex, transport = _flex_with_gripper() + rack = flex_96_tiprack_50ul(name="tips") + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex.setup()) + try: + asyncio.run(flex.sync_tips_to_robot(rack)) + + states = {c["params"]["tipWellState"] for c in _cmds(transport, "setTipState")} + self.assertEqual(states, {"tipPresent"}) + finally: + asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/flex_wire.py b/pylabrobot/opentrons/flex_wire.py index ca81ce3f0db..28804a9ab85 100644 --- a/pylabrobot/opentrons/flex_wire.py +++ b/pylabrobot/opentrons/flex_wire.py @@ -3,13 +3,17 @@ Small pieces that more than one of :mod:`~pylabrobot.opentrons.flex`, :mod:`~pylabrobot.opentrons.flex_head` and :mod:`~pylabrobot.opentrons.flex_gripper` needs, and that belong to none of -them: how a deck slot is spelled on the wire, and the notice every -not-yet-hardware-verified op logs. They live here so the always-present device -module does not have to reach into the optional gripper module (or the heads) -for them. +them: how a deck slot is spelled on the wire, which axes the robot addresses by +name, the software-version gate on the robot/* command family, and the notice +every not-yet-hardware-verified op logs. They live here so the always-present +device module does not have to reach into the optional gripper module (or the +heads) for them. """ -from typing import Dict, FrozenSet +from typing import Dict, FrozenSet, List, Optional, Tuple + +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import OFFLINE_API_VERSION # Shared by the heads and the gripper so the notice reads identically # everywhere; each module logs it through its own logger. @@ -23,9 +27,97 @@ # staging slots are addressable areas and ride a different location key. STAGING_SLOT_NAMES: FrozenSet[str] = frozenset({"A4", "B4", "C4", "D4"}) +# Every axis the robot addresses by name: gantry, each mount's z and plunger, +# the gripper's z and jaw (extensionZ/extensionJaw), the 96-channel head's cam. +ROBOT_AXES: FrozenSet[str] = frozenset( + { + "x", + "y", + "leftZ", + "rightZ", + "leftPlunger", + "rightPlunger", + "extensionZ", + "extensionJaw", + "axis96ChannelCam", + } +) + +# The robot/* direct-motion command family (moveTo, moveAxesTo, +# moveAxesRelative, openGripperJaw, closeGripperJaw) landed in robot-server 8.2.0. +_ROBOT_COMMANDS_MIN_VERSION = "8.2.0" + def slot_wire_location(slot: str) -> Dict[str, str]: """The ``loadLabware``/``moveLabware`` location for a Flex slot name.""" if slot in STAGING_SLOT_NAMES: return {"addressableAreaName": slot} return {"slotName": slot} + + +def _version_tuple(version: str) -> Tuple[int, ...]: + """Parse a dotted robot-software version into comparable integers. + + Comparing these as strings puts "10.0.0" below "7.1.0", so the version gate + compares numerically. Each dotted segment contributes its leading integer + ("0-beta" -> 0); a segment with no leading digit stops the parse, and short + results pad with zeros so "8.2" compares equal to "8.2.0". + + Raises: + ValueError: If the version has no leading numeric segment at all. + """ + parts: List[int] = [] + for part in version.split("."): + digits = "" + for char in part: + if not char.isdigit(): + break + digits += char + if digits == "": + break + parts.append(int(digits)) + if not parts: + raise ValueError(f"unparseable version string: {version!r}") + while len(parts) < 3: + parts.append(0) + return tuple(parts) + + +def _require_robot_commands(command: str, api_version: Optional[str]) -> None: + """Raise unless the robot's software supports the robot/* command family. + + ``api_version`` is the ``GET /health`` ``api_version`` the owning robot + stored at setup (``flex.api_version``). Released builds report a plain + numeric version and are gated against ``_ROBOT_COMMANDS_MIN_VERSION``. + + Two exemptions, both narrow. An UNTAGGED build reports "0.0.0.dev*" (the + version an unreleased source checkout carries, including the simulated + robot-server) and runs current code, so it passes -- but a build cut off a + real tag reports that tag plus a dev suffix ("8.1.0.dev5"), which is gated + on the tag like any release. ``ChatterboxTransport``'s offline "dry-run" + sentinel passes too; it reaches no robot at all. Any other unparseable + version raises rather than silently passing the gate. + """ + if api_version is None: + raise OpentronsError( + "Robot version unknown", + f"{command} requires setup() to have run, to read the robot's version.", + ) + if api_version == OFFLINE_API_VERSION: + return + try: + version = _version_tuple(api_version) + except ValueError: + raise OpentronsError( + "Robot version unrecognized", + f"{command} is gated on robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, but this " + f"robot reports the unrecognized version {api_version!r}.", + ) from None + if version == (0, 0, 0) and "dev" in api_version: + return + if version < _version_tuple(_ROBOT_COMMANDS_MIN_VERSION): + raise OpentronsError( + "Robot software too old", + f"{command} requires Opentrons robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, " + f"but this robot reports {api_version}.", + ) From 252056bd2852ba8f6d1c3e672646c7f96fee3747 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 15:30:48 -0400 Subject: [PATCH 18/36] Read tip presence as a run command, not over GET /instruments 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) --- .../opentrons/flex_fine_pipetting_tests.py | 6 +++ pylabrobot/opentrons/flex_head.py | 46 ++++++++++--------- pylabrobot/opentrons/flex_tests.py | 22 +++++++++ pylabrobot/opentrons/transport.py | 36 +++++++++++++-- 4 files changed, 85 insertions(+), 25 deletions(-) diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index 5f9565b840a..ce888a0f934 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -1216,7 +1216,13 @@ def test_get_tip_presence_reports_absent_without_raising(self): def test_verify_tip_presence_sends_the_expected_state(self): flex, transport, head = _flex_head8() try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + # Each state is asserted while it actually holds: the robot fails the + # command on a mismatch, so a bare pair would be checking a lie. asyncio.run(head.verify_tip_presence("absent")) + asyncio.run(head.pick_up_tips(rack, column=0)) asyncio.run(head.verify_tip_presence("present")) verify_cmds = [c for c in transport.commands if c["commandType"] == "verifyTipPresence"] diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index dfff1f84a6a..b4d41ec0fde 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -144,27 +144,33 @@ async def blow_out(self, flow_rate: Optional[float] = None) -> None: async def has_tip_on_hardware(self) -> Optional[bool]: """Query the Flex's hardware tip-presence sensor for THIS head's pipette. - The Flex reports tip presence as one boolean per pipette (mount), not - per nozzle/channel: ``GET /instruments`` -> ``data[i].state.tipDetected``. - This is the aggregate hardware ground truth, used to verify/reconcile - the per-channel ``_channel_tips`` bookkeeping -- it cannot tell you - *which* channel(s) hold a tip. + The Flex reports tip presence as one reading per pipette (mount), not per + nozzle/channel. This is the aggregate hardware ground truth, used to + verify/reconcile the per-channel ``_channel_tips`` bookkeeping -- it + cannot tell you *which* channel(s) hold a tip. + + Asked for with the ``getTipPresence`` run command rather than the + ``GET /instruments`` REST read, which reports the same bit but re-caches + the attached instruments as a side effect. On a Flex that re-cache + clears the run's record of the attached tip, so reading the sensor over + REST between a pickup and the next pipetting command makes that command + fail with "cannot perform PREPARE_ASPIRATE without a tip attached". Returns: - ``True``/``False`` if a pipette is found on ``self.mount`` and reports - a tip-detection state, ``None`` if unknown (no ``state`` field) or no - pipette is found on this mount. + ``True``/``False`` when the sensor reads present/absent, ``None`` when + it reads unknown or reports no status. """ - instruments_data = await self.flex._get_instruments() - for instrument in instruments_data.get("data", []): - if instrument.get("instrumentType") != "pipette": - continue - if instrument.get("mount") != self.mount: - continue - state = instrument.get("state", {}) - return cast(Optional[bool], state.get("tipDetected")) + status = await self._read_tip_presence() + if status == "present": + return True + if status == "absent": + return False return None + async def _read_tip_presence(self) -> Optional[str]: + result = await self._execute("getTipPresence", {"pipetteId": self.pipette_id}) + return cast(Optional[str], result.get("result", {}).get("status")) + async def _verify_tips_seated(self) -> None: """Raise if the hardware tip-presence sensor reports no tip after a pickup. @@ -763,13 +769,11 @@ async def air_gap_in_place(self, volume: float, flow_rate: Optional[float] = Non async def get_tip_presence(self) -> Optional[str]: """Read this head's tip sensor: "present", "absent" or "unknown". - One reading per pipette, not per channel -- the same aggregate state - ``has_tip_on_hardware()`` reads from ``GET /instruments``, asked for as a - run command instead. ``None`` when the command reports no status. + One reading per pipette, not per channel. ``has_tip_on_hardware()`` is + the same reading as a bool. ``None`` when the command reports no status. """ self._warn_untested_hardware("get_tip_presence") - result = await self._execute("getTipPresence", {"pipetteId": self.pipette_id}) - return cast(Optional[str], result.get("result", {}).get("status")) + return await self._read_tip_presence() async def verify_tip_presence(self, expected_state: str) -> None: """Have the robot fail the command unless its tip sensor reads ``expected_state``. diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py index cf1aeafd6a6..fe7c4c5de40 100644 --- a/pylabrobot/opentrons/flex_tests.py +++ b/pylabrobot/opentrons/flex_tests.py @@ -839,6 +839,28 @@ def test_has_tip_on_hardware_false_after_discard_tips(self): finally: asyncio.run(flex.stop()) + def test_tip_presence_is_read_as_a_run_command_never_over_the_instruments_route(self): + """The tip check must not touch ``GET /instruments`` once a run is live. + + That REST read re-caches the attached pipettes, which clears the run's + record of the attached tip, so the next pipetting command fails with + "cannot perform PREPARE_ASPIRATE without a tip attached". Reproduced + against the Opentrons robot-server simulator. + """ + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + reads_after_setup = transport.instrument_reads + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.drop_tips(rack, column=0)) + + self.assertEqual(transport.instrument_reads, reads_after_setup) + self.assertIn("getTipPresence", [c["commandType"] for c in transport.commands]) + finally: + asyncio.run(flex.stop()) + def test_simulated_stuck_tip_after_drop_logs_warning(self): flex, _transport, head = _flex_head8(simulate_stuck_tip=True) try: diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index 1d4fe02f720..f39e4cbf7b7 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -195,13 +195,13 @@ def __init__( simulate_failed_pickup: if True, a ``pickUpTip`` command does NOT flip the issuing pipette's simulated tip-presence sensor to detected -- models a hardware pickup that moved through the motion but never seated a tip, - so ``_FlexHead._verify_tips_seated()`` sees ``tipDetected: False`` and - raises. Default False: a pickup always seats a tip (existing behavior). + so ``_FlexHead._verify_tips_seated()`` reads "absent" and raises. + Default False: a pickup always seats a tip (existing behavior). simulate_stuck_tip: if True, ``dropTip``/``dropTipInPlace`` do NOT clear the issuing pipette's simulated tip-presence sensor -- models a tip stuck to the nozzle after a drop, so ``_FlexHead._confirm_tips_cleared()`` - sees ``tipDetected: True`` and logs a warning. Default False: a drop - always clears the sensor (existing behavior). + reads "present" and logs a warning. Default False: a drop always clears + the sensor (existing behavior). liquid_probe_z: the liquid height (mm) a ``liquidProbe``/``tryLiquidProbe`` command reports as ``z_position`` in its result. Default None: the key is omitted from the result entirely (not set to null), matching the @@ -233,9 +233,14 @@ def __init__( self._cmds: Dict[str, Dict[str, Any]] = {} # cmd_id -> full command data self._n = 0 self._pipette_load_count = 0 + self._labware_load_count = 0 self.load_pipette_commands: List[Dict[str, Any]] = [] # recorded loadPipette params self.commands: List[Dict[str, Any]] = [] # every command, in send order: {commandType, params} + # Reading GET /instruments re-caches the pipettes, which clears the run's + # record of the attached tip, so it must not happen mid-run. + self.instrument_reads = 0 self.labware_definitions: List[Dict[str, Any]] = [] # recorded custom definition uploads + self.labware_ids: Dict[str, str] = {} # displayName -> the id this transport assigned it self.simulate_failed_pickup = simulate_failed_pickup self.simulate_stuck_tip = simulate_stuck_tip self.liquid_probe_z = liquid_probe_z @@ -259,6 +264,7 @@ async def get(self, path: str) -> Dict[str, Any]: "name": "chatterbox", } if path == "/instruments": + self.instrument_reads += 1 instruments: List[Dict[str, Any]] = [ { "instrumentType": "pipette", @@ -323,6 +329,11 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s mount = params.get("mount") if mount is not None: self._pipette_id_to_mount[pipette_id] = mount + elif ctype == "loadLabware": + self._labware_load_count += 1 + labware_id = f"chatterbox-labware-{self._labware_load_count}" + result = {"labwareId": labware_id} + self.labware_ids[str(params.get("displayName"))] = labware_id else: result = {} if ctype == "pickUpTip": @@ -333,6 +344,10 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s mount = self._pipette_id_to_mount.get(params.get("pipetteId")) if mount is not None: self._tip_detected[mount] = self.simulate_stuck_tip + elif ctype in ("getTipPresence", "verifyTipPresence"): + mount = self._pipette_id_to_mount.get(params.get("pipetteId")) + detected = self._tip_detected.get(mount, False) if mount is not None else False + result = {"status": "present" if detected else "absent"} elif ctype in ("liquidProbe", "tryLiquidProbe"): if self.liquid_probe_z is not None: result = {"z_position": self.liquid_probe_z} @@ -354,6 +369,19 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s "isDefined": True, }, } + if ctype == "verifyTipPresence" and result.get("status") != params.get("expectedState"): + # The point of verifyTipPresence over getTipPresence: the robot, not + # the caller, refuses the mismatch. + cmd_data = { + "id": cmd_id, + "commandType": ctype, + "status": "failed", + "error": { + "errorType": "tipPresenceMismatch", + "detail": f"Expected tip to be {params.get('expectedState')}.", + "isDefined": True, + }, + } self._cmds[cmd_id] = cmd_data self._log("Chatterbox: %s %s", ctype, params) return {"data": cmd_data} From dd257574d20f541f20ef7839ccd1257fe4388844 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 15:31:54 -0400 Subject: [PATCH 19/36] Let the robot assign labware ids, so a capture can be replayed _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) --- pylabrobot/opentrons/flex.py | 13 ++++++++----- pylabrobot/opentrons/flex_container_tests.py | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 04574b32d7c..dfba84f4dc7 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -1,5 +1,4 @@ import logging -import uuid from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, cast from pylabrobot.opentrons.flex_gripper import FlexGripper @@ -292,8 +291,8 @@ async def _ensure_labware_loaded( f"it loads the Opentrons catalogue definition '{load_name}', whose grip height is " "the vendor's to state (the robot grips at mid-height when it states none)", ) - labware_id = uuid.uuid4().hex[:12] - + # The robot assigns the id. Proposing one here would only make the + # request body differ run to run, which is what breaks a replayed capture. result = await self._execute_command( "loadLabware", { @@ -301,11 +300,15 @@ async def _ensure_labware_loaded( "location": slot_wire_location(slot), "namespace": namespace, "version": version, - "labwareId": labware_id, "displayName": name, }, ) - labware_id = cast(str, result.get("result", {}).get("labwareId", labware_id)) + labware_id = result.get("result", {}).get("labwareId") + if not isinstance(labware_id, str): + raise OpentronsError( + "Labware load returned no id", + f"loadLabware for '{name}' succeeded but reported no labwareId to address it by.", + ) self._loaded_labware[name] = labware_id logger.info( diff --git a/pylabrobot/opentrons/flex_container_tests.py b/pylabrobot/opentrons/flex_container_tests.py index d401fbc19d9..af930d27804 100644 --- a/pylabrobot/opentrons/flex_container_tests.py +++ b/pylabrobot/opentrons/flex_container_tests.py @@ -159,7 +159,7 @@ def test_aspirate_names_container_labware_at_well_a1(self): aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] self.assertEqual(len(aspirate_cmds), 1) self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") - self.assertEqual(aspirate_cmds[0]["params"]["labwareId"], load_cmds[0]["params"]["labwareId"]) + self.assertEqual(aspirate_cmds[0]["params"]["labwareId"], transport.labware_ids["trough"]) # A single nozzle goes to the cavity center: no x/y centering offset, # just the default bottom clearance. self.assertEqual( @@ -1105,9 +1105,9 @@ def test_reload_labware_names_the_id_the_labware_was_loaded_under(self): asyncio.run(flex.reload_labware(trough)) - (load_cmd,) = _cmds(transport, "loadLabware") + (_load_cmd,) = _cmds(transport, "loadLabware") (reload_cmd,) = _cmds(transport, "reloadLabware") - self.assertEqual(reload_cmd["params"], {"labwareId": load_cmd["params"]["labwareId"]}) + self.assertEqual(reload_cmd["params"], {"labwareId": transport.labware_ids["trough"]}) finally: asyncio.run(flex.stop()) From cbee07fa17536a197ae6488b7f06286cbfc0eb56 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 16:56:47 -0400 Subject: [PATCH 20/36] Fix two engine-contract bugs the simulator sweep found 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) --- pylabrobot/opentrons/flex.py | 16 +++++---- .../opentrons/flex_fine_pipetting_tests.py | 34 +++++++++++++++++++ pylabrobot/opentrons/flex_head.py | 17 ++++++---- pylabrobot/opentrons/flex_motion_tests.py | 6 ++-- pylabrobot/opentrons/transport.py | 24 +++++++++++++ 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index dfba84f4dc7..f115b38716a 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -340,12 +340,14 @@ async def sync_tips_to_robot(self, tip_rack: TipRack) -> None: the resource tree. """ labware_id = await self._ensure_labware_loaded(tip_rack) - present: List[str] = [] - absent: List[str] = [] + # The robot grades a tip-rack well "clean"/"used"/"empty". PyLabRobot only + # records whether a tip is there, so an occupied spot maps to "clean". + occupied: List[str] = [] + empty: List[str] = [] for spot in tip_rack.get_all_items(): - (present if spot.has_tip() else absent).append(tip_rack.get_child_identifier(spot)) + (occupied if spot.has_tip() else empty).append(tip_rack.get_child_identifier(spot)) - for well_names, state in ((present, "tipPresent"), (absent, "tipAbsent")): + for well_names, state in ((occupied, "clean"), (empty, "empty")): if not well_names: continue await self._execute_command( @@ -353,10 +355,10 @@ async def sync_tips_to_robot(self, tip_rack: TipRack) -> None: {"labwareId": labware_id, "wellNames": well_names, "tipWellState": state}, ) logger.info( - "Synced '%s' tip state to the robot: %d present, %d absent", + "Synced '%s' tip state to the robot: %d with a tip, %d empty", tip_rack.name, - len(present), - len(absent), + len(occupied), + len(empty), ) async def labware_moved_off_deck(self, resource: Resource) -> None: diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index ce888a0f934..a2f5e63b4b3 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -1055,6 +1055,40 @@ def test_aspirate_in_place_primes_the_unprimed_plunger_first_and_only_once(self) finally: asyncio.run(flex.stop()) + def test_a_dispense_unprimes_the_plunger_so_the_next_aspirate_primes_again(self): + """A dispense drives the plunger past its bottom, so the robot refuses the + next aspirate until a prepareToAspirate resets it. Without this the ordinary + aspirate/dispense/aspirate transfer loop fails on its second aspirate. + Confirmed against the Opentrons robot-server simulator. + """ + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.dispense_in_place(volume=15)) + asyncio.run(head.aspirate_in_place(volume=15)) + + sent = [c["commandType"] for c in transport.commands] + self.assertEqual(sent.count("prepareToAspirate"), 2) + last_aspirate = len(sent) - 1 - sent[::-1].index("aspirateInPlace") + self.assertEqual(sent[last_aspirate - 1], "prepareToAspirate") + finally: + asyncio.run(flex.stop()) + + def test_a_blow_out_unprimes_the_plunger_too(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.blow_out()) + asyncio.run(head.aspirate_in_place(volume=15)) + + self.assertEqual( + [c["commandType"] for c in transport.commands].count("prepareToAspirate"), 2 + ) + finally: + asyncio.run(flex.stop()) + def test_dispense_in_place_sends_the_dispense_default_and_omits_push_out(self): flex, transport, head, rack = self._bench() try: diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index b4d41ec0fde..e0d72e284ae 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -139,7 +139,6 @@ async def blow_out(self, flow_rate: Optional[float] = None) -> None: self._warn_untested_hardware("blow_out") rate = flow_rate if flow_rate is not None else self.default_flow_rates().blow_out await self._execute("blowOutInPlace", {"pipetteId": self.pipette_id, "flowRate": rate}) - self._prepared = False async def has_tip_on_hardware(self) -> Optional[bool]: """Query the Flex's hardware tip-presence sensor for THIS head's pipette. @@ -449,7 +448,10 @@ async def _on_stop(self) -> None: async def _execute(self, command_type: str, params: Dict[str, Any]) -> Dict[str, Any]: """Issue a robot-server command through the owning device's shared transport.""" - return await self.flex._execute_command(command_type, params) + result = await self.flex._execute_command(command_type, params) + if command_type in _UNPRIMING_COMMANDS: + self._prepared = False + return result @staticmethod def _require_itemized_parent(item: Resource) -> ItemizedResource: @@ -833,7 +835,6 @@ async def unsafe_blow_out_in_place(self, flow_rate: float) -> None: "unsafe/blowOutInPlace", {"pipetteId": self.pipette_id, "flowRate": flow_rate}, ) - self._prepared = False # Default minimumZHeight (mm) for moveToCoordinates jogs: the head keeps at @@ -846,6 +847,12 @@ async def unsafe_blow_out_in_place(self, flow_rate: float) -> None: _MOVE_AXES = frozenset({"x", "y", "z"}) +# After these the plunger is unprimed and the robot refuses the next aspirate: +# a dispense or blow-out drives it past its bottom, a fresh tip never primed. +_UNPRIMING_COMMANDS = frozenset( + {"dispense", "dispenseInPlace", "blowOutInPlace", "unsafe/blowOutInPlace", "pickUpTip"} +) + # What verify_tip_presence can assert. The sensor itself can also read # "unknown", but that is a reading, not something to check against. _TIP_PRESENCE_STATES = frozenset({"present", "absent"}) @@ -963,7 +970,6 @@ async def pick_up_tips( await self._execute_pickup("pickUpTip", params, staged_trackers) self._channel_tips[0] = tip - self._prepared = False async def drop_tips( self, @@ -1304,7 +1310,6 @@ async def pick_up_tips( await self._execute_pickup("pickUpTip", params, staged_trackers) for i, tip in enumerate(tips): self._channel_tips[i] = tip - self._prepared = False async def drop_tips( self, @@ -1773,7 +1778,6 @@ async def pick_up_single_tip( await self._execute_pickup("pickUpTip", params, staged_trackers) self._channel_tips[channel] = tip - self._prepared = False async def aspirate_single( self, @@ -1959,7 +1963,6 @@ async def pick_up_tips( await self._execute_pickup("pickUpTip", params, staged_trackers) for i, tip in enumerate(tips): self._channel_tips[i] = tip - self._prepared = False async def drop_tips( self, diff --git a/pylabrobot/opentrons/flex_motion_tests.py b/pylabrobot/opentrons/flex_motion_tests.py index 421fde28894..9bdb9ea45fc 100644 --- a/pylabrobot/opentrons/flex_motion_tests.py +++ b/pylabrobot/opentrons/flex_motion_tests.py @@ -618,8 +618,8 @@ def test_splits_present_and_absent_into_one_command_each(self): cmds = _cmds(transport, "setTipState") by_state = {c["params"]["tipWellState"]: c["params"]["wellNames"] for c in cmds} - self.assertEqual(by_state["tipPresent"], ["A1", "B1"]) - self.assertEqual(len(by_state["tipAbsent"]), 94) + self.assertEqual(by_state["clean"], ["A1", "B1"]) + self.assertEqual(len(by_state["empty"]), 94) finally: asyncio.run(flex.stop()) @@ -632,6 +632,6 @@ def test_a_uniform_rack_sends_only_the_state_it_has(self): asyncio.run(flex.sync_tips_to_robot(rack)) states = {c["params"]["tipWellState"] for c in _cmds(transport, "setTipState")} - self.assertEqual(states, {"tipPresent"}) + self.assertEqual(states, {"clean"}) finally: asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index f39e4cbf7b7..831e80f8d07 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -56,6 +56,29 @@ } +# Enum-valued command params, with what the robot-server accepts. A fake that +# takes any string lets a driver ship a value the real robot answers 422 to. +_COMMAND_ENUMS = { + ("setTipState", "tipWellState"): frozenset({"clean", "used", "empty"}), + ("verifyTipPresence", "expectedState"): frozenset({"present", "absent"}), + ("moveLabware", "strategy"): frozenset( + {"usingGripper", "manualMoveWithPause", "manualMoveWithoutPause"} + ), +} + + +def _reject_unknown_enum_values(command_type: str, params: Dict[str, Any]) -> None: + """Refuse a param value the real robot-server would reject.""" + for (ctype, key), allowed in _COMMAND_ENUMS.items(): + if ctype != command_type or key not in params: + continue + if params[key] not in allowed: + raise ValueError( + f"{command_type} param {key}={params[key]!r} is not one of {sorted(allowed)}; " + "the robot-server answers 422 to this." + ) + + @runtime_checkable class OpentronsTransport(Protocol): """Wire-level seam: the subset of HTTP that ``OpentronsRobot`` needs. @@ -317,6 +340,7 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s data = (json or {}).get("data", {}) ctype = data.get("commandType", "?") params = data.get("params", {}) + _reject_unknown_enum_values(ctype, params) self._n += 1 cmd_id = f"cmd-{self._n}" self.commands.append({"commandType": ctype, "params": dict(params)}) From 8ad7ef63e851a4b529de0155386363c899ed73e0 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 17:16:36 -0400 Subject: [PATCH 21/36] Address the driver review: capture recipe, optional extra, lifecycle 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) --- pylabrobot/io/capture.py | 26 +++++++------ .../arms/precise_flex/precise_flex_backend.py | 2 +- pylabrobot/opentrons/__init__.py | 8 +++- pylabrobot/opentrons/catalogue.py | 5 ++- pylabrobot/opentrons/flex.py | 13 ++++--- .../opentrons/flex_fine_pipetting_tests.py | 16 ++++++++ pylabrobot/opentrons/flex_head.py | 31 ++++++++++----- pylabrobot/opentrons/pipette_defaults.py | 9 +++-- pylabrobot/opentrons/robot.py | 38 +++++++++++++------ pylabrobot/opentrons/shared_data.py | 24 ++++++++++++ pylabrobot/opentrons/transport.py | 16 ++++++-- pylabrobot/opentrons/transport_tests.py | 24 ++++++++++++ 12 files changed, 165 insertions(+), 47 deletions(-) create mode 100644 pylabrobot/opentrons/shared_data.py diff --git a/pylabrobot/io/capture.py b/pylabrobot/io/capture.py index f455f19b87c..6eacf0763b7 100644 --- a/pylabrobot/io/capture.py +++ b/pylabrobot/io/capture.py @@ -104,20 +104,22 @@ def next_command(self) -> dict: def done(self): """Assert the capture was fully consumed, then end validation. - Clearing the flag is what lets the next validation build its io objects; - they are refused while a capture or validation is active. + Ending it is what lets the next validation build its io objects; they are + refused while a capture or validation is active. It happens even when the + assertion fails, so one short replay cannot strand every io after it. """ - if self._command_idx < len(self.commands): - left = len(self.commands) - self._command_idx - next_command = self.commands[self._command_idx] - raise ValidationError( - f"Log file not fully read, {left} lines left. First command: {next_command}" - ) - print("Validation successful!") - self.reset() - global _capture_or_validation_active - _capture_or_validation_active = False + try: + if self._command_idx < len(self.commands): + left = len(self.commands) - self._command_idx + next_command = self.commands[self._command_idx] + raise ValidationError( + f"Log file not fully read, {left} lines left. First command: {next_command}" + ) + print("Validation successful!") + finally: + self._command_idx = 0 + _capture_or_validation_active = False def reset(self): self._command_idx = 0 diff --git a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py index 17dfa71eb98..f57575b229f 100644 --- a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py @@ -119,7 +119,7 @@ async def disconnect(self): """Hand the arm back, moving nothing. Drops high power (``hp 0``) as well as releasing the link, because - ``connect`` is what turned it on. Unlike the Flex there is nothing to park + ``initialize`` is what raised it. Unlike the Flex there is nothing to park first: this arm's teardown never moved it. """ await self.detach() diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py index 0316052685e..fe82d47a25d 100644 --- a/pylabrobot/opentrons/__init__.py +++ b/pylabrobot/opentrons/__init__.py @@ -7,7 +7,12 @@ OpentronsRobot, PipetteInfo, ) -from pylabrobot.opentrons.transport import ChatterboxTransport, HttpxTransport, OpentronsTransport +from pylabrobot.opentrons.transport import ( + ChatterboxTransport, + HttpxTransport, + OpentronsTransport, + ReplayTransport, +) __all__ = [ "ChatterboxTransport", @@ -17,6 +22,7 @@ "FlexHead96", "HttpxTransport", "OpentronsCommandError", + "ReplayTransport", "OpentronsError", "OpentronsFlex", "OpentronsRobot", diff --git a/pylabrobot/opentrons/catalogue.py b/pylabrobot/opentrons/catalogue.py index 8b671d3516a..c35a2f42c5b 100644 --- a/pylabrobot/opentrons/catalogue.py +++ b/pylabrobot/opentrons/catalogue.py @@ -8,12 +8,15 @@ from functools import lru_cache from typing import FrozenSet -from opentrons_shared_data.labware import list_definitions +from pylabrobot.opentrons.shared_data import require_shared_data @lru_cache(maxsize=1) def catalogue_load_names() -> FrozenSet[str]: """Every load name the shipped catalogue defines, across schema versions.""" + require_shared_data() + from opentrons_shared_data.labware import list_definitions + return frozenset(load_name for load_name, _version, _schema in list_definitions()) diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index f115b38716a..bee418a13d9 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -11,7 +11,7 @@ build_plate_definition, build_tip_rack_definition, ) -from pylabrobot.opentrons.robot import OpentronsError, OpentronsRobot +from pylabrobot.opentrons.robot import COMMAND_POLL_HEADROOM, OpentronsError, OpentronsRobot from pylabrobot.opentrons.transport import OpentronsTransport from pylabrobot.resources import Container, Plate, Resource, TipRack from pylabrobot.resources.opentrons.flex_deck import FlexDeck @@ -39,9 +39,6 @@ "opentrons_96_wellplate_200ul_pcr_full_skirt": 2, } -# Seconds of polling a command gets on top of a wait it was told to perform. -_COMMAND_POLL_HEADROOM = 30.0 - # Discovered pipette channel count -> matching head class. _CHANNELS_TO_HEAD: Dict[int, Type[_FlexHead]] = { 1: FlexHead1, @@ -155,6 +152,12 @@ async def _model_setup(self) -> None: # setup() no longer discovers/loads a pipette itself (that would double # `loadPipette` the first mount), so this is the only place a Flex loads # its pipettes. + # Discovery is re-runnable: drop whatever a previous setup composed rather + # than stacking a second set of heads onto dead pipette ids. + self.left = self.right = self.head96 = None + self.gripper = None + self._heads.clear() + instruments_data = await self._get_instruments() pipettes = self._parse_pipettes(instruments_data) @@ -468,7 +471,7 @@ async def wait_for_duration(self, seconds: float) -> None: # The command only completes once the robot finishes waiting, so the poll # gets the wait itself plus the usual command headroom. await self._execute_command( - "waitForDuration", {"seconds": seconds}, timeout=seconds + _COMMAND_POLL_HEADROOM + "waitForDuration", {"seconds": seconds}, timeout=seconds + COMMAND_POLL_HEADROOM ) @staticmethod diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index a2f5e63b4b3..a79025c3609 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -1075,6 +1075,22 @@ def test_a_dispense_unprimes_the_plunger_so_the_next_aspirate_primes_again(self) finally: asyncio.run(flex.stop()) + def test_configure_for_volume_unprimes_the_plunger(self): + """Switching volume mode resets the robot's ready-to-aspirate flag, so the + next aspirate needs a fresh prepare. Confirmed against the simulator.""" + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.configure_for_volume(10.0)) + asyncio.run(head.aspirate_in_place(volume=5)) + + self.assertEqual( + [c["commandType"] for c in transport.commands].count("prepareToAspirate"), 2 + ) + finally: + asyncio.run(flex.stop()) + def test_a_blow_out_unprimes_the_plunger_too(self): flex, transport, head, rack = self._bench() try: diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index e0d72e284ae..3a0ab5ed642 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -274,17 +274,13 @@ async def _execute_with_prepare( """``prepareToAspirate`` (if pending) -> wire -> commit/rollback. Shared by every ``aspirate``/``aspirate_single`` variant. Sends - ``prepareToAspirate`` first if this is the first aspirate since the last - tip pickup (``self._prepared`` False), then the aspirate command itself. - A successful prepare sets ``self._prepared = True`` immediately -- even - if the following aspirate then fails and trackers roll back -- since - priming is physical plunger state, not tracker state, and is not - reversed by a tracker rollback. + ``prepareToAspirate`` first when the plunger is not primed, then the + aspirate itself. Priming is physical plunger state, so a tracker rollback + on a failed aspirate does not undo it: ``_execute`` records it. """ try: if not self._prepared: await self._execute("prepareToAspirate", {"pipetteId": self.pipette_id}) - self._prepared = True await self._execute(command_type, params) except Exception: for tracker in staged_trackers: @@ -451,6 +447,8 @@ async def _execute(self, command_type: str, params: Dict[str, Any]) -> Dict[str, result = await self.flex._execute_command(command_type, params) if command_type in _UNPRIMING_COMMANDS: self._prepared = False + elif command_type in _PRIMING_COMMANDS: + self._prepared = True return result @staticmethod @@ -847,11 +845,19 @@ async def unsafe_blow_out_in_place(self, flow_rate: float) -> None: _MOVE_AXES = frozenset({"x", "y", "z"}) -# After these the plunger is unprimed and the robot refuses the next aspirate: -# a dispense or blow-out drives it past its bottom, a fresh tip never primed. +# Wider than the robot's own rule (it primes on pickUpTip, and a dispense +# unprimes only when it pushes out or empties): a redundant prepare is accepted. _UNPRIMING_COMMANDS = frozenset( - {"dispense", "dispenseInPlace", "blowOutInPlace", "unsafe/blowOutInPlace", "pickUpTip"} + { + "dispense", + "dispenseInPlace", + "blowOutInPlace", + "unsafe/blowOutInPlace", + "configureForVolume", + "pickUpTip", + } ) +_PRIMING_COMMANDS = frozenset({"prepareToAspirate"}) # What verify_tip_presence can assert. The sensor itself can also read # "unknown", but that is a reading, not something to check against. @@ -1748,6 +1754,11 @@ async def pick_up_single_tip( """ self._warn_untested_hardware("pick_up_single_tip") primary_nozzle = self._anchor_for(tip_rack, well, primary_nozzle) + # The anchor only settles that the pipette stays inside the robot. The 7 + # idle nozzles still hang over the neighbouring slot, which is a crash. + slot = self.flex.deck.get_slot(tip_rack) + if slot is not None: + self.flex.deck.check_single_nozzle_clearance(slot, primary_nozzle) channel = _SINGLE_NOZZLES[primary_nozzle] if self._channel_tips[channel] is not None: raise OpentronsError( diff --git a/pylabrobot/opentrons/pipette_defaults.py b/pylabrobot/opentrons/pipette_defaults.py index ce31898ec68..4be65186814 100644 --- a/pylabrobot/opentrons/pipette_defaults.py +++ b/pylabrobot/opentrons/pipette_defaults.py @@ -11,9 +11,7 @@ from typing import Dict, NamedTuple, Tuple -from opentrons_shared_data.pipette.load_data import load_liquid_model -from opentrons_shared_data.pipette.pipette_load_name_conversions import convert_pipette_model -from opentrons_shared_data.pipette.types import PipetteModel, PipetteOEMType +from pylabrobot.opentrons.shared_data import require_shared_data class FlowRates(NamedTuple): @@ -37,6 +35,11 @@ def _rates_by_tip(pipette_model: str) -> Dict[str, FlowRates]: # would silently pipette a p50 at up to 716 uL/s. raise ValueError("No pipette model given, so its default flow rates are unknown.") + require_shared_data() + from opentrons_shared_data.pipette.load_data import load_liquid_model + from opentrons_shared_data.pipette.pipette_load_name_conversions import convert_pipette_model + from opentrons_shared_data.pipette.types import PipetteModel, PipetteOEMType + version = convert_pipette_model(PipetteModel(pipette_model)) liquid_model = load_liquid_model( version.pipette_type, version.pipette_channels, version.pipette_version, PipetteOEMType.OT diff --git a/pylabrobot/opentrons/robot.py b/pylabrobot/opentrons/robot.py index 552d3a08596..df8a6abb38f 100644 --- a/pylabrobot/opentrons/robot.py +++ b/pylabrobot/opentrons/robot.py @@ -9,6 +9,22 @@ logger = logging.getLogger(__name__) +# Seconds of polling a command gets on top of however long its own motion takes. +COMMAND_POLL_HEADROOM = 30.0 + + +def _plunger_seconds(params: Dict[str, Any]) -> float: + """How long a command's own plunger travel takes, from its volume and rate. + + A 1000 uL aspirate at a viscous-liquid flow rate outlasts any fixed poll + timeout, and giving up mid-command rolls the trackers back while the robot + keeps pipetting. + """ + volume, flow_rate = params.get("volume"), params.get("flowRate") + if not isinstance(volume, (int, float)) or not isinstance(flow_rate, (int, float)): + return 0.0 + return abs(volume) / flow_rate if flow_rate > 0 else 0.0 + class OpentronsError(Exception): def __init__(self, title: str, message: Optional[str] = None) -> None: @@ -67,7 +83,9 @@ def __init__( ) -> None: self.host, self.port = host, port self.base_url = f"http://{host}:{port}" - self._transport: Optional[OpentronsTransport] = transport + # Built here rather than on connect: a pylabrobot io refuses construction + # once a capture is armed, so a robot built first can still be recorded. + self._transport: OpentronsTransport = transport or HttpxTransport(base_url=self.base_url) self.run_id: Optional[str] = None self.pipette: Optional[PipetteInfo] = None self.api_version: Optional[str] = None @@ -150,13 +168,11 @@ async def _model_setup(self) -> None: # --- Connection Lifecycle --- async def _connect(self) -> None: - """Create the transport (unless one was injected) and verify connectivity. + """Open the transport and verify connectivity. Sends a health check to confirm the robot is reachable and the robot server is running (not in Jupyter/Python API mode). """ - if self._transport is None: - self._transport = HttpxTransport(base_url=self.base_url) await self._transport.setup() health = await self._get("/health") self.api_version = health.get("api_version") @@ -172,10 +188,12 @@ async def _connect(self) -> None: ) async def _disconnect(self) -> None: - """Close the transport.""" - if self._transport is not None: - await self._transport.close() - self._transport = None + """Close the transport, keeping the object so a reconnect can reopen it. + + Discarding it here would mean rebuilding an io on the next connect, which + a capture armed in the meantime refuses. + """ + await self._transport.close() async def send_command( self, @@ -205,17 +223,14 @@ async def send_command( async def _get(self, path: str) -> Dict[str, Any]: """Wire GET, return parsed JSON.""" - assert self._transport is not None, "Not connected. Call connect() first." return await self._transport.get(path) async def _post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Wire POST, return parsed JSON.""" - assert self._transport is not None, "Not connected. Call connect() first." return await self._transport.post(path, json=data or {}) async def _delete(self, path: str) -> Dict[str, Any]: """Wire DELETE, return parsed JSON.""" - assert self._transport is not None, "Not connected. Call connect() first." return await self._transport.delete(path) # --- Run Management --- @@ -278,6 +293,7 @@ async def _execute_command( RuntimeError: If the command times out. """ assert self.run_id is not None, "No active run. Call create_run() first." + timeout = max(timeout, _plunger_seconds(params) + COMMAND_POLL_HEADROOM) payload = { "data": { "commandType": command_type, diff --git a/pylabrobot/opentrons/shared_data.py b/pylabrobot/opentrons/shared_data.py new file mode 100644 index 00000000000..4cc40d9c74d --- /dev/null +++ b/pylabrobot/opentrons/shared_data.py @@ -0,0 +1,24 @@ +"""Opentrons' own data package, imported only where it is used. + +``opentrons-shared-data`` ships the labware catalogue and the pipette +definitions the robot itself loads, so reading them beats vendoring numbers +that drift. It is an optional extra, and importing it at module scope would +make ``import pylabrobot.opentrons`` fail for anyone who installed PyLabRobot +without it. +""" + +from importlib.util import find_spec + + +def has_shared_data() -> bool: + """Whether Opentrons' data package is installed.""" + return find_spec("opentrons_shared_data") is not None + + +def require_shared_data() -> None: + """Raise unless Opentrons' data package is importable.""" + if not has_shared_data(): + raise RuntimeError( + "opentrons-shared-data is required for Opentrons labware and pipette data. " + 'Install with: pip install "pylabrobot[opentrons]"' + ) diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index 831e80f8d07..bc93c8ab5bd 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -138,9 +138,19 @@ class ReplayTransport: the same refusal on an exchange that failed. Nothing reaches the network. Build the capture file by wrapping a live run in ``start_capture()`` / - ``stop_capture()``. Call :meth:`assert_fully_replayed` at the end of a test: - it fails when the protocol stopped short of the recording, which is what - catches a dropped command. + ``stop_capture()``. Construct the robot BEFORE arming the capture: every + pylabrobot io refuses construction while one is active, and the robot builds + its transport in ``__init__`` for exactly that reason. + + flex = OpentronsFlex(deck=deck, host=host) # transport built here + pylabrobot.start_capture(path) + await flex.setup() + ... + pylabrobot.stop_capture() + + Call :meth:`assert_fully_replayed` at the end of a test: it fails when the + protocol stopped short of the recording, which is what catches a dropped + command. """ def __init__( diff --git a/pylabrobot/opentrons/transport_tests.py b/pylabrobot/opentrons/transport_tests.py index 03387a26620..b2cb3e22882 100644 --- a/pylabrobot/opentrons/transport_tests.py +++ b/pylabrobot/opentrons/transport_tests.py @@ -177,6 +177,30 @@ async def _load_both() -> List[str]: self.assertEqual(len(transport.load_pipette_commands), 2) +class TestTransportIsBuiltBeforeCaptureCanBeArmed(unittest.TestCase): + """A robot builds its transport in __init__, not on connect. + + Every pylabrobot io refuses construction while a capture is active, so + building it on connect made the documented recording recipe (construct, + start_capture, setup) die with "Cannot create a new HTTP object while + capture or validation is active" unless the caller passed a transport in. + """ + + def test_a_robot_built_with_no_transport_still_has_one(self): + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test") + self.assertIsInstance(flex._transport, HttpxTransport) + + def test_arming_a_capture_after_construction_does_not_refuse_the_io(self): + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test") + with tempfile.TemporaryDirectory() as tmp: + pylabrobot.start_capture(Path(tmp) / "c.json") + try: + # The io already exists, so nothing here needs to construct one. + self.assertIsInstance(flex._transport, HttpxTransport) + finally: + pylabrobot.stop_capture() + + class RecordAndReplayTests(unittest.IsolatedAsyncioTestCase): """A recorded Flex lifecycle replays with nothing on the network. From f93c9de8c186d820ba25d2c8fa59e5dfad690bc2 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 17:07:05 -0400 Subject: [PATCH 22/36] Carry the slot-deck reads and OT-2 channel moves onto the plain-class 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. --- .../backends/opentrons_backend.py | 30 +++++++ pylabrobot/resources/opentrons/flex_deck.py | 83 ++++++++++++++++++- .../resources/opentrons/flex_deck_tests.py | 71 ++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 pylabrobot/resources/opentrons/flex_deck_tests.py diff --git a/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py index 31327da546d..e7b2a24a2ea 100644 --- a/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py @@ -668,6 +668,36 @@ async def prepare_for_manual_channel_operation(self, channel: int): _ = self._pipette_id_for_channel(channel) + async def get_channel_position(self, channel: int) -> Coordinate: + """Where a channel is right now, in deck coordinates.""" + + _, current = self._current_channel_position(channel) + return current + + async def move_channel_to( + self, + channel: int, + x: Optional[float] = None, + y: Optional[float] = None, + z: Optional[float] = None, + ): + """Move a channel to an absolute position, holding the axes left out. + + One coordinated move rather than the per-axis calls chained: the robot lifts to the traversal + height and travels once, where three separate moves each descend and can clip labware between + them. + """ + + pipette_id, current = self._current_channel_position(channel) + target = Coordinate( + x=current.x if x is None else x, + y=current.y if y is None else y, + z=current.z if z is None else z, + ) + await self.move_pipette_head( + location=target, minimum_z_height=self.traversal_height, pipette_id=pipette_id + ) + async def move_channel_x(self, channel: int, x: float): """Move a channel to an absolute x coordinate using savePosition to seed pose.""" diff --git a/pylabrobot/resources/opentrons/flex_deck.py b/pylabrobot/resources/opentrons/flex_deck.py index 4cd847bc2a7..323fe410f6a 100644 --- a/pylabrobot/resources/opentrons/flex_deck.py +++ b/pylabrobot/resources/opentrons/flex_deck.py @@ -15,7 +15,7 @@ from __future__ import annotations import re -from typing import Dict, Optional +from typing import Dict, Optional, cast from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.deck import Deck @@ -156,6 +156,36 @@ def _validate_slot(slot: str) -> str: # --- Slot Access --- + @property + def slots(self) -> Dict[str, Optional[Resource]]: + """The labware in each slot (standard and staging), or ``None`` for empty slots. + + The Flex counterpart of :attr:`OTDeck.slots`, keyed by slot name rather than by position. + """ + return {slot: holder.resource for slot, holder in self._slot_holders.items()} + + @property + def slot_locations(self) -> Dict[str, Coordinate]: + """The front-left corner of each slot in the robot frame, keyed by slot name.""" + return {slot: cast(Coordinate, holder.location) for slot, holder in self._slot_holders.items()} + + def get_slot_holder(self, slot: str) -> ResourceHolder: + """The ResourceHolder for a slot, e.g. as a ``move_plate`` gripper destination.""" + slot = self._validate_slot(slot) + return self._slot_holders[slot] + + def get_slot_at_location(self, location: Coordinate, tolerance: float = 2.0) -> Optional[str]: + """The slot whose front-left corner matches ``location`` (x/y within ``tolerance`` mm). + + The gripper receives a destination as a deck-frame coordinate (the LFB of the moved labware), + never a slot name, so a move resolves its target slot by matching that corner here. + """ + for slot, holder in self._slot_holders.items(): + corner = cast(Coordinate, holder.location) + if abs(corner.x - location.x) <= tolerance and abs(corner.y - location.y) <= tolerance: + return slot + return None + def get_slot_location(self, slot: str) -> Dict[str, float]: """Get the XYZ coordinate for a slot.""" slot = self._validate_slot(slot) @@ -165,6 +195,53 @@ def get_slot_location(self, slot: str) -> Dict[str, float]: return STAGING_LOCATIONS[slot] raise ValueError(f"Unknown slot '{slot}'.") + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True, + ) -> None: + """Assign a slot holder to the deck, or route labware into its slot. + + The deck's direct children are the slot holders created in ``__init__``; deserialization + re-assigns those holders by name, replacing the placeholder with the loaded one. Labware is + normally placed with :meth:`assign_child_at_slot`. A gripper ``move_plate`` to a bare + :class:`~pylabrobot.resources.Coordinate` also lands here, because the liquid handler assigns + the moved plate to the deck at the destination coordinate; such a resource is routed into the + slot whose corner matches that coordinate, keeping the robot and the resource tree in sync. + """ + + existing = next((child for child in self.children if child.name == resource.name), None) + if existing is not None: + if not reassign: + raise ValueError(f"Resource '{resource.name}' already assigned to deck") + super().unassign_child_resource(existing) + for occupied_slot, holder in self._slot_holders.items(): + if holder is existing: + self._slot_holders[occupied_slot] = cast(ResourceHolder, resource) + break + super().assign_child_resource(resource, location=location, reassign=reassign) + return + + if isinstance(resource, ResourceHolder): + super().assign_child_resource(resource, location=location, reassign=reassign) + return + + slot = self.get_slot_at_location(location) if location is not None else None + if slot is None: + raise ValueError( + f"Cannot assign '{resource.name}' to the deck at {location}: it matches no Flex slot. " + "Place labware with assign_child_at_slot, or move it to a slot holder or slot location." + ) + self.assign_child_at_slot(resource, slot) + + def unassign_child_resource(self, resource: Resource) -> None: + for holder in self._slot_holders.values(): + if holder.resource is resource: + holder.unassign_child_resource(resource) + return + super().unassign_child_resource(resource) + def assign_child_at_slot(self, resource: Resource, slot: str) -> None: """Place a resource at a named slot. @@ -208,6 +285,10 @@ def get_trash_area(self) -> Trash: return holder.resource raise ValueError("No trash area configured on this deck.") + def get_trash_area96(self) -> Trash: + # The Flex has one movable trash bin; the 96 head discards into the same bin as the others. + return self.get_trash_area() + # --- OT-2 Conversion --- @staticmethod diff --git a/pylabrobot/resources/opentrons/flex_deck_tests.py b/pylabrobot/resources/opentrons/flex_deck_tests.py new file mode 100644 index 00000000000..fcb7eb73621 --- /dev/null +++ b/pylabrobot/resources/opentrons/flex_deck_tests.py @@ -0,0 +1,71 @@ +import unittest + +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.opentrons import FlexDeck + + +class FlexDeckTests(unittest.TestCase): + def test_has_16_slots_with_trash_at_a3(self): + # 12 standard slots A1-D3 plus the 4 column-4 staging slots. + deck = FlexDeck() + self.assertEqual(len(deck.slots), 16) + trash = deck.slots["A3"] + assert trash is not None + self.assertEqual(trash.name, "trash") + self.assertIsNone(deck.slots["A1"]) + + def test_with_trash_false_leaves_a3_empty(self): + deck = FlexDeck(with_trash_bin=False) + self.assertIsNone(deck.slots["A3"]) + + def test_trash_area_96_is_the_same_movable_bin(self): + # The 96 head discards into the one movable trash bin, so the frontend's discard_tips96 + # path (which asks the deck for a 96 trash area) resolves to the same trash. + deck = FlexDeck() + self.assertIs(deck.get_trash_area96(), deck.get_trash_area()) + + def test_d1_is_the_robot_origin(self): + deck = FlexDeck() + self.assertEqual(deck.slot_locations["D1"], Coordinate(0.0, 0.0, 0.0)) + self.assertEqual(deck.slot_locations["A1"], Coordinate(0.0, 321.0, 0.0)) + self.assertEqual(deck.slot_locations["D3"], Coordinate(328.0, 0.0, 0.0)) + + def test_assign_and_get_slot(self): + deck = FlexDeck() + plate = Resource(name="plate", size_x=127.0, size_y=85.0, size_z=14.0) + deck.assign_child_at_slot(plate, "C2") + self.assertIs(deck.slots["C2"], plate) + self.assertEqual(deck.get_slot(plate), "C2") + + def test_occupied_slot_raises(self): + deck = FlexDeck() + deck.assign_child_at_slot(Resource(name="a", size_x=1, size_y=1, size_z=1), "B1") + with self.assertRaises(ValueError): + deck.assign_child_at_slot(Resource(name="b", size_x=1, size_y=1, size_z=1), "B1") + + def test_unknown_slot_raises(self): + deck = FlexDeck() + with self.assertRaises(ValueError): + deck.assign_child_at_slot(Resource(name="x", size_x=1, size_y=1, size_z=1), "E9") + + def test_get_slot_at_location_reverse_lookup(self): + deck = FlexDeck() + # the gripper resolves a destination coordinate (the LFB corner) back to a slot name + self.assertEqual(deck.get_slot_at_location(Coordinate(328.0, 214.0, 0.0)), "B3") + self.assertEqual(deck.get_slot_at_location(Coordinate(328.5, 214.3, 0.0)), "B3") # tolerance + self.assertIsNone(deck.get_slot_at_location(Coordinate(50.0, 50.0, 0.0))) + + def test_coordinate_destination_routes_into_matching_slot(self): + # a gripper move to a bare Coordinate (the slot's corner) must land the plate in that slot's + # holder, matching how the backend resolves the same coordinate, so robot and tree stay in sync + deck = FlexDeck() + plate = Resource(name="plate", size_x=127.0, size_y=85.0, size_z=14.0) + deck.assign_child_resource(plate, location=deck.slot_locations["C2"]) + self.assertIs(deck.slots["C2"], plate) + self.assertEqual(deck.get_slot(plate), "C2") + + def test_offgrid_coordinate_destination_raises(self): + deck = FlexDeck() + plate = Resource(name="plate", size_x=127.0, size_y=85.0, size_z=14.0) + with self.assertRaises(ValueError): + deck.assign_child_resource(plate, location=Coordinate(500.0, 500.0, 0.0)) From a600d3085223e6407a0f9b8bc014f4f782a97c71 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 17:35:35 -0400 Subject: [PATCH 23/36] move_to_well: accept a tip spot, not just a well 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) --- pylabrobot/opentrons/flex_head.py | 8 ++++++-- pylabrobot/opentrons/flex_motion_tests.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 3a0ab5ed642..dbe515e16e5 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -615,7 +615,7 @@ async def move_to( async def move_to_well( self, - target: Union[Well, Container], + target: Union[Well, TipSpot, Container], offset: Optional[Coordinate] = None, origin: str = "top", minimum_z_height: Optional[float] = None, @@ -636,11 +636,15 @@ async def move_to_well( No mounted tip is required: the target is the tip bottom when one is mounted, the nozzle when none is. + + A tip spot is a valid target: the robot addresses a tip rack's wells by + the same names, so this is how you look at where a pickup would descend + before committing to it. """ self._warn_untested_hardware("move_to_well") if origin not in _WELL_ORIGINS: raise ValueError(f"origin must be one of {sorted(_WELL_ORIGINS)}, got {origin!r}") - if isinstance(target, Well): + if isinstance(target, (Well, TipSpot)): parent = self._require_itemized_parent(target) labware_id = await self.flex._ensure_labware_loaded(parent) well_name = parent.get_child_identifier(target) diff --git a/pylabrobot/opentrons/flex_motion_tests.py b/pylabrobot/opentrons/flex_motion_tests.py index 9bdb9ea45fc..83eb29bad41 100644 --- a/pylabrobot/opentrons/flex_motion_tests.py +++ b/pylabrobot/opentrons/flex_motion_tests.py @@ -477,6 +477,23 @@ def _flex_with_plate(self): flex.deck.assign_child_at_slot(plate, "C1") return flex, transport, plate + def test_a_tip_spot_is_a_valid_target(self): + """Looking at where a pickup would descend, before committing to it. The + robot addresses a tip rack's wells by the same names a plate's use.""" + flex, transport = _flex_with_gripper() + rack = flex_96_tiprack_50ul(name="tips") + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to_well(rack.get_item("A1"), offset=Coordinate(0, 0, 20))) + + (cmd,) = _cmds(transport, "moveToWell") + self.assertEqual(cmd["params"]["wellName"], "A1") + self.assertEqual(cmd["params"]["labwareId"], transport.labware_ids["tips"]) + self.assertEqual(cmd["params"]["wellLocation"]["offset"]["z"], 20) + finally: + asyncio.run(flex.stop()) + def test_names_the_well_and_defaults_to_the_top_origin(self): flex, transport, plate = self._flex_with_plate() asyncio.run(flex.setup()) From d6f23ce7aecb8a34fbe7e26ae81f5c78d5b2d472 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 15:06:44 -0400 Subject: [PATCH 24/36] Let a deck attach to a live robot, and stop create_run stranding the 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. --- pylabrobot/opentrons/flex.py | 12 ++++++++++++ pylabrobot/opentrons/robot.py | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index bee418a13d9..667d84d55cf 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -135,6 +135,18 @@ def __init__( self.gripper: Optional[FlexGripper] = None self._heads: List[_FlexHead] = [] + def attach_deck(self, deck: FlexDeck) -> None: + """Swap in a new deck, so a caller can describe the deck without rebuilding + the robot (and losing the link and the run with it). + + Clears the labware caches: their ids describe the deck being replaced, and + serving one for the new deck would address the wrong slot. + """ + self.deck = deck + self._loaded_labware.clear() + self._defined_labware.clear() + self._stub_labware.clear() + async def _create_run(self) -> str: # labwareIds and uploaded definitions are both run-scoped server-side, so # a new run must not serve cached identities from a previous one. diff --git a/pylabrobot/opentrons/robot.py b/pylabrobot/opentrons/robot.py index df8a6abb38f..41de8eabdab 100644 --- a/pylabrobot/opentrons/robot.py +++ b/pylabrobot/opentrons/robot.py @@ -124,7 +124,13 @@ async def create_run(self) -> None: The robot reports itself as in use and refuses its own touchscreen for as long as a run is current, so this is the step that takes it from the operator, not ``connect``. + + Cancels a run this object already holds before opening the next one. + ``run_id`` is the only handle we have on a run, so overwriting it strands + the old one on the robot, still current, with nothing left able to release + it: the touchscreen stays locked and a power cycle is the only way out. """ + await self._cancel_run() await self._create_run() async def initialize(self) -> None: From cc14c7232d72243e8c9ffc1361cf2c14fd5e69d9 Mon Sep 17 00:00:00 2001 From: miike Date: Thu, 13 Aug 2026 19:34:42 -0400 Subject: [PATCH 25/36] A Flex head carries its own pipette capacity 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. --- pylabrobot/opentrons/flex.py | 4 +++- pylabrobot/opentrons/flex_head.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 667d84d55cf..93e8b0777f0 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -190,7 +190,9 @@ async def _model_setup(self) -> None: "Unsupported pipette channel count", f"{pip.channels} channels (mount '{pip.mount}') has no matching FlexHead.", ) - head = head_cls(self, pip.mount, pipette_id, pip.channels, pip.pipette_model) + head = head_cls( + self, pip.mount, pipette_id, pip.channels, pip.pipette_model, pip.max_volume + ) if pip.channels == 96: self.head96 = head diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index dbe515e16e5..6a2caa541d8 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -69,12 +69,16 @@ def __init__( pipette_id: str, channels: int, pipette_model: str, + max_volume: float, ) -> None: self.flex = flex self.mount = mount self.pipette_id = pipette_id self.channels = channels self.pipette_model = pipette_model + # The pipette's own capacity, not the mounted tip's. Carried here so a caller + # describing the head does not have to re-read /instruments to get it. + self.max_volume = max_volume self._channel_tips: List[Optional[Tip]] = [None] * channels # Whether the plunger has been prepared (primed) since the last tip # pickup. The Flex requires an explicit `prepareToAspirate` command @@ -1202,8 +1206,9 @@ def __init__( pipette_id: str, channels: int, pipette_model: str, + max_volume: float, ) -> None: - super().__init__(flex, mount, pipette_id, channels, pipette_model) + super().__init__(flex, mount, pipette_id, channels, pipette_model, max_volume) self._nozzle_layout: str = "ALL" # "ALL" | "SINGLE" # --- Nozzle layout guard --- From 74e1baafd3772f107c4825ade50b47ed7807a3b7 Mon Sep 17 00:00:00 2001 From: miike Date: Sun, 16 Aug 2026 20:17:29 -0400 Subject: [PATCH 26/36] Fold the repeated aspirate/dispense plumbing into one helper 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) --- pylabrobot/opentrons/flex_head.py | 627 +++++++++++------------------- 1 file changed, 230 insertions(+), 397 deletions(-) diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 6a2caa541d8..db774abd60d 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -153,11 +153,8 @@ async def has_tip_on_hardware(self) -> Optional[bool]: cannot tell you *which* channel(s) hold a tip. Asked for with the ``getTipPresence`` run command rather than the - ``GET /instruments`` REST read, which reports the same bit but re-caches - the attached instruments as a side effect. On a Flex that re-cache - clears the run's record of the attached tip, so reading the sensor over - REST between a pickup and the next pipetting command makes that command - fail with "cannot perform PREPARE_ASPIRATE without a tip attached". + ``GET /instruments`` REST read: same bit, but the REST path re-caches the + attached instruments as a side effect. Returns: ``True``/``False`` when the sensor reads present/absent, ``None`` when @@ -324,15 +321,17 @@ async def _execute_trash_drop(self, trash: Trash) -> None: # --- Fine-pipetting shared helpers --- + def _mounted_count(self) -> int: + """How many channels currently hold a tip.""" + return sum(1 for tip in self._channel_tips if tip is not None) + def _require_mounted_tip(self) -> None: """Raise if no channel holds a tip -- pre-wire guard for tip-motion ops. - Every liquid-handling op moves the mounted tip into the labware, so - issuing one without a tip would drive the bare NOZZLE there instead -- - roughly a tip length lower than the pose the command describes. The - engine rejects a tipless op, but ``dispense`` moves to the well first and - only then checks, so the collision happens before the rejection. Checked - before any wire command is sent. + Without a tip the same command drives the bare NOZZLE into the labware, + about a tip length lower than the pose it describes. The engine does + reject a tipless op, but ``dispense`` moves to the well before it checks, + so the crash lands first. """ if all(tip is None for tip in self._channel_tips): raise OpentronsError( @@ -340,6 +339,49 @@ def _require_mounted_tip(self) -> None: "No tip mounted; pick up a tip first.", ) + async def _well_target(self, target: Union[Well, TipSpot, Container]) -> Tuple[str, str]: + """Labware id and well name for a well or tip spot, or a container's sole well.""" + if isinstance(target, (Well, TipSpot)): + parent = self._require_itemized_parent(target) + loaded = await self.flex._ensure_labware_loaded(parent) + return loaded, parent.get_child_identifier(target) + return await self.flex._ensure_labware_loaded(target), _CONTAINER_WELL_NAME + + async def _pipette( + self, + verb: str, + labware_id: str, + well_name: str, + volume: float, + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + staged_trackers: List[VolumeTracker], + ) -> None: + """Send one ``aspirate``/``dispense`` at a named well and settle the trackers. + + ``flow_rate`` defaults to the robot's own for the mounted tip, so it is + resolved only when the caller left it out (asking with no tip raises). + An aspirate is primed first when a prepare is pending. + """ + if flow_rate is None: + rates = self.default_flow_rates() + flow_rate = rates.aspirate if verb == "aspirate" else rates.dispense + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "volume": volume, + "flowRate": flow_rate, + } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location + if verb == "aspirate": + await self._execute_with_prepare(verb, params, staged_trackers) + else: + await self._execute_liquid_op(verb, params, staged_trackers) + async def _configure_nozzle_layout(self, configuration_params: Dict[str, Any]) -> None: """Send ``configureNozzleLayout``, refusing while any channel holds a tip. @@ -370,20 +412,11 @@ def _touch_tip_params( ) -> Dict[str, Any]: """Build the ``touchTip`` params dict shared by every head's ``touch_tip``. - ``touchTip`` addresses the height of the wall-touch motion, not a liquid - position, so the ``wellLocation`` is TOP-relative: the default touches - 1 mm below the rim (the Opentrons Python API's ``v_offset`` default), and - a caller ``offset`` REPLACES it, also read against the well top. - ``radius`` is the fraction of the well radius the tip moves toward - (1.0 = the wall). - - Replacing rather than shifting is deliberate, unlike the liquid position - ``_well_location`` builds. This z IS the touch height (the Opentrons - Python API's own ``v_offset``, where a caller's number is likewise - absolute), so shifting would make the same argument mean a different - height here than in that API. And dropping this default moves the tip - UP toward the rim, away from the labware, where dropping the liquid - clearance moves it down onto the well floor. + The ``wellLocation`` is TOP-relative and the default touches 1 mm below + the rim. A caller ``offset`` REPLACES it rather than shifting it, unlike + the liquid position ``_well_location`` builds: this z IS the touch height + (the Opentrons Python API's absolute ``v_offset``), and dropping the + default here moves the tip UP toward the rim, away from the labware. """ o = offset if offset is not None else Coordinate(z=_DEFAULT_TOUCH_TIP_Z_OFFSET) return { @@ -505,12 +538,10 @@ def _well_location( def _stage_container_aspirate(container: Container, total_volume: float) -> List[VolumeTracker]: """Stage an aspirate's total volume against a container's single tracker. - N channels drawing from one cavity share ONE tracker, whose pending - ops accumulate onto one pending volume and are flushed/discarded - together by a single ``commit()``/``rollback()``. So the summed volume - is staged as ONE ``remove_liquid`` and the tracker appears ONCE in the - returned staged list -- staging per channel would orphan the earlier - pending ops if a later channel's validation raised. + N channels drawing from one cavity share ONE tracker, so the summed + volume is staged as one ``remove_liquid`` and the tracker appears once in + the returned list. Staging per channel would orphan the earlier pending + ops if a later channel's validation raised. """ staged_trackers: List[VolumeTracker] = [] if does_volume_tracking() and not container.tracker.is_disabled: @@ -522,8 +553,7 @@ def _stage_container_aspirate(container: Container, total_volume: float) -> List def _stage_container_dispense(container: Container, total_volume: float) -> List[VolumeTracker]: """Stage a dispense's total volume against a container's single tracker. - Same one-tracker rule as ``_stage_container_aspirate``, with - ``add_liquid`` staging the summed volume. + Same one-tracker rule as ``_stage_container_aspirate``. """ staged_trackers: List[VolumeTracker] = [] if does_volume_tracking() and not container.tracker.is_disabled: @@ -531,6 +561,26 @@ def _stage_container_dispense(container: Container, total_volume: float) -> List staged_trackers.append(container.tracker) return staged_trackers + def _stage_wells_aspirate(self, wells: List[Well], volume: float) -> List[VolumeTracker]: + """Stage ``remove_liquid`` on each well whose channel holds a tip.""" + staged_trackers: List[VolumeTracker] = [] + if does_volume_tracking(): + for i, well in enumerate(wells): + if self._channel_tips[i] is not None and not well.tracker.is_disabled: + well.tracker.remove_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + return staged_trackers + + def _stage_wells_dispense(self, wells: List[Well], volume: float) -> List[VolumeTracker]: + """Stage ``add_liquid`` on each well whose channel holds a tip.""" + staged_trackers: List[VolumeTracker] = [] + if does_volume_tracking(): + for i, well in enumerate(wells): + if self._channel_tips[i] is not None and not well.tracker.is_disabled: + well.tracker.add_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + return staged_trackers + @staticmethod def _require_span_fits_container( container: Container, @@ -540,17 +590,12 @@ def _require_span_fits_container( ) -> None: """Raise pre-wire if the nozzle array would overhang the container. - The engine centers the array on the cavity (the definition's - ``centerMultichannelOnWells`` quirk) and the caller's ``offset`` then + The engine centers the array on the cavity and the caller's ``offset`` shifts it, so the shifted span must still fit on each axis. The footprint - read here is the deck-frame one the uploaded definition carries - (``container_footprint``), not the container's own x/y: a rotated - container presents its axes to the robot swapped, and guarding the - pre-rotation axis passes an array that overhangs it. - - That footprint is the container's OUTER box, so this guard is as loose as - the definition is: PLR carries no cavity x/y, so an array that fits the - shell but not the cavity inside it passes both. + is the deck-frame one the definition carries, not the container's own + x/y: a rotated container presents its axes to the robot swapped. It is + the OUTER box, so an array that fits the shell but not the cavity inside + passes -- PLR carries no cavity x/y to check against. """ o = offset if offset is not None else Coordinate.zero() cavity_x, cavity_y = container_footprint(container) @@ -628,33 +673,19 @@ async def move_to_well( """Move to a well, named rather than measured -- ONE ``moveToWell`` command. Prefer this over :meth:`move_to` for anything positioned relative to - labware. The robot owns the geometry, so naming the well lets it work out - where that is and refuse a move it cannot make, the same way it checks an - aspirate. ``move_to`` sends raw deck coordinates, which nothing on either - side bounds-checks. - - ``origin`` is where the offset is measured from: "top", "bottom", - "center", or "meniscus" (the last needs the robot to have a liquid level - for the well). So 10 mm above the well is ``origin="top"`` with - ``offset=Coordinate(z=10)``. - - No mounted tip is required: the target is the tip bottom when one is - mounted, the nozzle when none is. - - A tip spot is a valid target: the robot addresses a tip rack's wells by - the same names, so this is how you look at where a pickup would descend - before committing to it. + labware: naming the well lets the robot work out where that is and refuse + a move it cannot make, where ``move_to`` sends raw coordinates nothing + bounds-checks. ``origin`` is where the offset is measured from ("top", + "bottom", "center", or "meniscus", the last needing a probed liquid + level), so 10 mm above the well is ``origin="top"`` with + ``offset=Coordinate(z=10)``. A tip spot is a valid target. No mounted tip + is required: the target is the tip bottom when one is mounted, the nozzle + when none is. """ self._warn_untested_hardware("move_to_well") if origin not in _WELL_ORIGINS: raise ValueError(f"origin must be one of {sorted(_WELL_ORIGINS)}, got {origin!r}") - if isinstance(target, (Well, TipSpot)): - parent = self._require_itemized_parent(target) - labware_id = await self.flex._ensure_labware_loaded(parent) - well_name = parent.get_child_identifier(target) - else: - labware_id = await self.flex._ensure_labware_loaded(target) - well_name = _CONTAINER_WELL_NAME + labware_id, well_name = await self._well_target(target) o = offset or Coordinate(0, 0, 0) params: Dict[str, Any] = { @@ -1041,42 +1072,21 @@ async def aspirate( ) -> None: """Aspirate from a well or single-cavity container -- one ``aspirate`` command. - A ``Well`` is addressed through its plate parent by well name. A bare - ``Container`` (trough/reservoir) is its own robot-side labware whose - single-cavity definition exposes exactly one well, named "A1", so the - command names the container's labware id and well "A1"; the volume is - tracked against the container's own tracker. Either way a mounted tip is - required (checked before any wire command), and: stage -> validate -> - wire -> commit/rollback -- the tracker (``remove_liquid``) is staged - BEFORE the wire command, so an infeasible aspirate raises before any - hardware motion. A ``prepareToAspirate`` command is sent first if this is - the first aspirate since the last tip pickup. + A ``Well`` is addressed through its plate parent by well name; a bare + ``Container`` (trough/reservoir) at its own sole well. Requires a mounted + tip. Follows stage -> validate -> wire -> commit/rollback: the tracker + (``remove_liquid``) is staged BEFORE the wire command, so an infeasible + aspirate raises before any hardware motion. A ``prepareToAspirate`` + command is sent first if this is the first aspirate since the last tip + pickup. """ self._warn_untested_hardware("aspirate") self._require_mounted_tip() - if isinstance(target, Well): - parent = self._require_itemized_parent(target) - labware_id = await self.flex._ensure_labware_loaded(parent) - well_name = parent.get_child_identifier(target) - else: - labware_id = await self.flex._ensure_labware_loaded(target) - well_name = _CONTAINER_WELL_NAME - rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate - + labware_id, well_name = await self._well_target(target) staged_trackers = self._stage_container_aspirate(target, volume) - - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": well_name, - "volume": volume, - "flowRate": rate, - } - well_location = self._well_location([offset], [liquid_height]) - if well_location is not None: - params["wellLocation"] = well_location - - await self._execute_with_prepare("aspirate", params, staged_trackers) + await self._pipette( + "aspirate", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) async def dispense( self, @@ -1088,38 +1098,17 @@ async def dispense( ) -> None: """Dispense to a well or single-cavity container -- one ``dispense`` command. - A ``Well`` is addressed through its plate parent by well name; a bare - ``Container`` (trough/reservoir) is addressed as its own labware at - its sole robot-side well "A1". Either way a mounted tip is required (see - ``aspirate``), and: stage -> validate -> wire -> commit/rollback -- the - tracker (``add_liquid``) is staged BEFORE the wire command, so an - infeasible dispense raises before any hardware motion. + Mirrors ``aspirate``: same addressing, same mounted-tip requirement, and + stage -> validate -> wire -> commit/rollback with ``add_liquid`` staged + BEFORE the wire command. """ self._warn_untested_hardware("dispense") self._require_mounted_tip() - if isinstance(target, Well): - parent = self._require_itemized_parent(target) - labware_id = await self.flex._ensure_labware_loaded(parent) - well_name = parent.get_child_identifier(target) - else: - labware_id = await self.flex._ensure_labware_loaded(target) - well_name = _CONTAINER_WELL_NAME - rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense - + labware_id, well_name = await self._well_target(target) staged_trackers = self._stage_container_dispense(target, volume) - - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": well_name, - "volume": volume, - "flowRate": rate, - } - well_location = self._well_location([offset], [liquid_height]) - if well_location is not None: - params["wellLocation"] = well_location - - await self._execute_liquid_op("dispense", params, staged_trackers) + await self._pipette( + "dispense", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) async def touch_tip( self, @@ -1130,18 +1119,13 @@ async def touch_tip( """Touch the mounted tip to the sides of ``well`` -- one ``touchTip`` command. ``radius`` is the fraction of the well radius the tip moves toward - (1.0 = the wall). ``offset`` IS the touch position, read from the well - top (the Opentrons Python API's absolute ``v_offset``): it REPLACES the - 1 mm below the rim this touches at by default rather than shifting it the - way an ``aspirate``/``dispense`` offset does, so ``Coordinate(x=1)`` - touches level with the rim. Requires a mounted tip (checked before any - wire command). No trackers are involved. + (1.0 = the wall); ``offset`` IS the touch position, not a shift (see + ``_touch_tip_params``). Requires a mounted tip, checked before any wire + command. No trackers are involved. """ self._warn_untested_hardware("touch_tip") self._require_mounted_tip() - parent = self._require_itemized_parent(well) - labware_id = await self.flex._ensure_labware_loaded(parent) - well_name = parent.get_child_identifier(well) + labware_id, well_name = await self._well_target(well) await self._execute("touchTip", self._touch_tip_params(labware_id, well_name, radius, offset)) async def liquid_probe(self, well: Well) -> float: @@ -1197,7 +1181,26 @@ class FlexHead8(_FlexHead): one-time untested-hardware notice, same as the other heads. """ - _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset({"pick_up_tips"}) + # Confirmed on a p50 single-channel Flex. Base-class ops are listed here + # rather than on _FlexHead because only this head has been on hardware. + _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset( + { + "aspirate", + "configure_for_volume", + "dispense", + "drop_tips", + "get_tip_presence", + "liquid_probe", + "move_relative", + "move_to", + "move_to_addressable_area", + "move_to_well", + "pick_up_tips", + "position", + "try_liquid_probe", + "verify_tip_presence", + } + ) def __init__( self, @@ -1234,20 +1237,15 @@ def _column_anchor_and_items(itemized: ItemizedResource, column: int) -> Tuple[s """Validate ``column`` against the labware's real grid; return the anchor well name plus the 8 resources the nozzle row covers, rearmost first. - Every column op calls this BEFORE any wire command (including - ``configureNozzleLayout`` and ``loadLabware``) so a rejected op ships - nothing. PLR itemized resources are column-major (item 0 is A1, item 1 - is B1, ...), and the anchor name comes from the resource itself rather - than a fixed name table, so any column count is addressed safely. - - The 8 nozzles sit at a 9 mm pitch, so on a denser layout they cover - every ``row_stride``-th row rather than adjacent rows: a 16-row (384) - plate has two interleaved sets of 8 (A,C,E,.. and B,D,F,..) per physical - column, matching the engine's own multi-channel coverage math. Those - sets are addressed as consecutive ``column`` indices, so a 384 plate - takes ``column`` 0-47 (physical column ``column // 2``, rear-row set - when even). A row count that is not a multiple of 8 has no such set and - is rejected. + Every column op calls this BEFORE any wire command so a rejected op ships + nothing. + + The 8 nozzles sit at a 9 mm pitch, so on a denser layout they cover every + ``row_stride``-th row rather than adjacent rows: a 384 plate has two + interleaved sets of 8 per physical column, addressed as consecutive + ``column`` indices (so 0-47, physical column ``column // 2``, rear-row set + when even). A row count that is not a multiple of 8 has no such set and is + rejected. """ rows = itemized.num_items_y row_stride, remainder = divmod(rows, _NUM_CHANNELS) @@ -1418,29 +1416,10 @@ async def aspirate( well_name, column_wells = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) - rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate - - tracking = does_volume_tracking() - staged_trackers: List[Any] = [] - if tracking: - for i, well in enumerate(column_wells): - if self._channel_tips[i] is None or well.tracker.is_disabled: - continue - well.tracker.remove_liquid(volume=volume) # stages + validates - staged_trackers.append(well.tracker) - - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": well_name, - "volume": volume, - "flowRate": rate, - } - well_location = self._well_location([offset], [liquid_height]) - if well_location is not None: - params["wellLocation"] = well_location - - await self._execute_with_prepare("aspirate", params, staged_trackers) + staged_trackers = self._stage_wells_aspirate(column_wells, volume) + await self._pipette( + "aspirate", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) async def dispense( self, @@ -1464,29 +1443,10 @@ async def dispense( well_name, column_wells = self._column_anchor_and_items(plate, column) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(plate) - rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense - - tracking = does_volume_tracking() - staged_trackers: List[Any] = [] - if tracking: - for i, well in enumerate(column_wells): - if self._channel_tips[i] is None or well.tracker.is_disabled: - continue - well.tracker.add_liquid(volume=volume) # stages + validates - staged_trackers.append(well.tracker) - - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": well_name, - "volume": volume, - "flowRate": rate, - } - well_location = self._well_location([offset], [liquid_height]) - if well_location is not None: - params["wellLocation"] = well_location - - await self._execute_liquid_op("dispense", params, staged_trackers) + staged_trackers = self._stage_wells_dispense(column_wells, volume) + await self._pipette( + "dispense", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) # --- Single-cavity container (trough/reservoir) liquid handling --- @@ -1500,44 +1460,30 @@ async def aspirate_container( ) -> None: """Aspirate ``volume`` uL per channel from one single-cavity container. - All 8 nozzles dip into the same cavity (trough/reservoir), which is - its own robot-side labware whose single-cavity definition exposes - exactly one well, named "A1": ONE ``aspirate`` command names that - well. The engine centers the nozzle row in the cavity itself (the - definition's ``centerMultichannelOnWells`` quirk, carried by every - single-cavity reservoir definition, uploaded ones included), so only - the caller's ``offset``/``liquid_height`` ride the wire. Requires at - least one mounted tip and a cavity that contains the 63 mm row even - after the offset shifts it -- both checked before any wire command -- - plus ALL nozzle mode (reset first if a single-tip op left the layout - otherwise). Each channel holding a tip draws ``volume``, so the - container's single tracker is staged with ``volume * (channels holding - tips)`` and committed/rolled back as one op (stage -> validate -> wire - -> commit/rollback). A ``prepareToAspirate`` command is sent first if - this is the first aspirate since the last tip pickup. + All 8 nozzles dip into the same cavity (trough/reservoir) and ONE + ``aspirate`` command names its sole well. The engine centers the nozzle + row in the cavity itself, so only the caller's ``offset``/ + ``liquid_height`` ride the wire. Requires a mounted tip, a cavity the + offset-shifted row still fits, and ALL nozzle mode. Each channel holding + a tip draws ``volume``, so the container's single tracker is staged with + the total and settled as one op. """ self._warn_untested_hardware("aspirate_container") self._require_mounted_tip() self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN, offset) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(container) - rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate - - mounted = sum(1 for tip in self._channel_tips if tip is not None) - staged_trackers = self._stage_container_aspirate(container, volume * mounted) - - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": _CONTAINER_WELL_NAME, - "volume": volume, - "flowRate": rate, - } - well_location = self._well_location([offset], [liquid_height]) - if well_location is not None: - params["wellLocation"] = well_location - - await self._execute_with_prepare("aspirate", params, staged_trackers) + staged_trackers = self._stage_container_aspirate(container, volume * self._mounted_count()) + await self._pipette( + "aspirate", + labware_id, + _CONTAINER_WELL_NAME, + volume, + flow_rate, + offset, + liquid_height, + staged_trackers, + ) async def dispense_container( self, @@ -1549,36 +1495,25 @@ async def dispense_container( ) -> None: """Dispense ``volume`` uL per channel into one single-cavity container. - Mirrors ``aspirate_container``: ONE ``dispense`` command at the - container's sole robot-side well "A1", engine-side centering via the - definition's ``centerMultichannelOnWells`` quirk, the same pre-wire - guards (mounted tip, offset-shifted row fits the cavity, ALL nozzle - mode), and the container's single tracker staged with ``volume * - (channels holding tips)`` and committed/rolled back as one op (stage -> - validate -> wire -> commit/rollback). + Mirrors ``aspirate_container``: same addressing, same pre-wire guards, + and the container's single tracker staged with the total. """ self._warn_untested_hardware("dispense_container") self._require_mounted_tip() self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN, offset) await self._ensure_all_mode() labware_id = await self.flex._ensure_labware_loaded(container) - rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense - - mounted = sum(1 for tip in self._channel_tips if tip is not None) - staged_trackers = self._stage_container_dispense(container, volume * mounted) - - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": _CONTAINER_WELL_NAME, - "volume": volume, - "flowRate": rate, - } - well_location = self._well_location([offset], [liquid_height]) - if well_location is not None: - params["wellLocation"] = well_location - - await self._execute_liquid_op("dispense", params, staged_trackers) + staged_trackers = self._stage_container_dispense(container, volume * self._mounted_count()) + await self._pipette( + "dispense", + labware_id, + _CONTAINER_WELL_NAME, + volume, + flow_rate, + offset, + liquid_height, + staged_trackers, + ) async def touch_tip( self, @@ -1590,15 +1525,9 @@ async def touch_tip( """Touch the mounted tips to their well walls -- one ``touchTip`` command anchored at the column's rearmost well. - ``radius`` is the fraction of the well radius each tip moves toward - (1.0 = the wall). ``offset`` IS the touch position, read from the well - top (the Opentrons Python API's absolute ``v_offset``): it REPLACES the - 1 mm below the rim this touches at by default rather than shifting it the - way an ``aspirate``/``dispense`` offset does, so ``Coordinate(x=1)`` - touches level with the rim. Requires at least one mounted tip and a valid - column (both checked before any wire command) and ALL nozzle mode (reset - first if a single-tip op left the layout otherwise). No trackers are - involved. + ``radius`` and ``offset`` behave as in ``FlexHead1.touch_tip``. Requires + a mounted tip and a valid column, both checked before any wire command, + plus ALL nozzle mode. No trackers are involved. """ self._warn_untested_hardware("touch_tip") self._require_mounted_tip() @@ -1745,21 +1674,16 @@ async def pick_up_single_tip( """Pick up one tip in SINGLE nozzle mode. Switches to SINGLE layout (``configureNozzleLayout``) before the - ``pickUpTip`` command. In that layout the pipette drives ONE nozzle, and - the engine moves it over whatever well is named -- so the nozzle, not the + ``pickUpTip`` command. In that layout the pipette drives ONE nozzle and + the engine moves it over whatever well is named, so the nozzle, not the well, decides which channel ends up holding the tip. An 8-channel Flex - can anchor on its "A1" or "H1" nozzle only (channel 0 or channel 7); - ``primary_nozzle`` picks between them, and left unset it is chosen for - you: the well's own row when that end can reach the slot the rack is on, - otherwise the end that can (see ``reachable_single_nozzles``). Raises - ``OpentronsError`` if that channel already holds a tip -- checked, like - the nozzle itself, before any wire command. Tip tracker changes are - staged (``commit=False``) before the wire command, then, after the wire - command succeeds, the hardware tip-presence sensor is checked - (``_verify_tips_seated()``) -- the tracker and ``_channel_tips`` are - committed only if that verification passes, and rolled back (with no - ``_channel_tips`` mutation) if the sensor reports a missed pickup (stage - -> validate -> wire -> verify -> commit/rollback). + can anchor on its "A1" or "H1" nozzle only; ``primary_nozzle`` picks + between them, and left unset it is the well's own row when that end can + reach the rack's slot, otherwise the end that can (see + ``reachable_single_nozzles``). Raises ``OpentronsError`` if that channel + already holds a tip -- checked, like the nozzle itself, before any wire + command. Then stage -> validate -> wire -> verify -> commit/rollback, as + in ``pick_up_tips``. """ self._warn_untested_hardware("pick_up_single_tip") primary_nozzle = self._anchor_for(tip_rack, well, primary_nozzle) @@ -1816,27 +1740,10 @@ async def aspirate_single( self._active_single_channel() self._require_reach_in_single_layout(plate, well) labware_id = await self.flex._ensure_labware_loaded(plate) - rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": well, - "volume": volume, - "flowRate": rate, - "wellLocation": { - "origin": "bottom", - "offset": {"x": 0, "y": 0, "z": _DEFAULT_WELL_BOTTOM_CLEARANCE}, - }, - } - - target = plate.get_item(well) - tracking = does_volume_tracking() - staged_trackers: List[Any] = [] - if tracking and not target.tracker.is_disabled: - target.tracker.remove_liquid(volume=volume) # stages + validates - staged_trackers.append(target.tracker) - - await self._execute_with_prepare("aspirate", params, staged_trackers) + staged_trackers = self._stage_container_aspirate(plate.get_item(well), volume) + await self._pipette( + "aspirate", labware_id, well, volume, flow_rate, None, None, staged_trackers + ) async def dispense_single( self, @@ -1850,27 +1757,10 @@ async def dispense_single( self._active_single_channel() self._require_reach_in_single_layout(plate, well) labware_id = await self.flex._ensure_labware_loaded(plate) - rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": well, - "volume": volume, - "flowRate": rate, - "wellLocation": { - "origin": "bottom", - "offset": {"x": 0, "y": 0, "z": _DEFAULT_WELL_BOTTOM_CLEARANCE}, - }, - } - - target = plate.get_item(well) - tracking = does_volume_tracking() - staged_trackers: List[Any] = [] - if tracking and not target.tracker.is_disabled: - target.tracker.add_liquid(volume=volume) # stages + validates - staged_trackers.append(target.tracker) - - await self._execute_liquid_op("dispense", params, staged_trackers) + staged_trackers = self._stage_container_dispense(plate.get_item(well), volume) + await self._pipette( + "dispense", labware_id, well, volume, flow_rate, None, None, staged_trackers + ) async def drop_single_tip(self, trash: Trash) -> None: """Drop the single mounted tip to trash and restore ALL nozzle mode. @@ -2043,56 +1933,30 @@ async def aspirate( """Aspirate a whole plate or one single-cavity container -- one ``aspirate`` command. A ``Plate`` (which must have exactly 96 positions) is anchored at its - "A1" well and covered one-to-one: ``Well.tracker`` (``remove_liquid``) - is staged for every well whose channel actually holds a tip - (None-skip). A bare ``Container`` (trough/reservoir) is its own - robot-side labware whose single-cavity definition exposes exactly one - well, named "A1": the engine centers the 12x8 nozzle grid in the - cavity itself (the definition's ``centerMultichannelOnWells`` quirk), a - footprint containing the grid's 99 x 63 mm span even after the offset - shifts it is required, and the container's single tracker is staged - with ``volume * (channels holding tips)``. Either way at least one - mounted tip is required, and: stage -> validate -> wire -> - commit/rollback, with an infeasible aspirate raising before any - hardware motion (every check above runs before any wire command), and a - ``prepareToAspirate`` command sent first if this is the first aspirate - since the last tip pickup. + "A1" well and covered one-to-one, staging ``remove_liquid`` per well + whose channel holds a tip. A bare ``Container`` (trough/reservoir) is + addressed at its sole well, with the engine centering the nozzle grid in + the cavity and the container's single tracker staged with the total. + Either way a mounted tip is required and every check runs before any wire + command. A ``prepareToAspirate`` command is sent first if this is the + first aspirate since the last tip pickup. """ self._warn_untested_hardware("aspirate") self._require_mounted_tip() - rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate - staged_trackers: List[Any] = [] if isinstance(target, Plate): - wells = self._check_full_coverage(target) labware_id = await self.flex._ensure_labware_loaded(target) well_name = self._ANCHOR_WELL_NAME - if does_volume_tracking(): - for i, well in enumerate(wells): - if self._channel_tips[i] is None or well.tracker.is_disabled: - continue - well.tracker.remove_liquid(volume=volume) # stages + validates - staged_trackers.append(well.tracker) + staged_trackers = self._stage_wells_aspirate(self._check_full_coverage(target), volume) else: self._require_span_fits_container( target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN, offset ) labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME - mounted = sum(1 for tip in self._channel_tips if tip is not None) - staged_trackers.extend(self._stage_container_aspirate(target, volume * mounted)) - well_location = self._well_location([offset], [liquid_height]) - - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": well_name, - "volume": volume, - "flowRate": rate, - } - if well_location is not None: - params["wellLocation"] = well_location - - await self._execute_with_prepare("aspirate", params, staged_trackers) + staged_trackers = self._stage_container_aspirate(target, volume * self._mounted_count()) + await self._pipette( + "aspirate", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) async def dispense( self, @@ -2104,52 +1968,26 @@ async def dispense( ) -> None: """Dispense to a whole plate or one single-cavity container -- one ``dispense`` command. - Mirrors ``aspirate``: a ``Plate`` is anchored at its "A1" well with - ``Well.tracker`` (``add_liquid``) staged per tip-holding channel - (None-skip); a bare ``Container`` is addressed at its sole robot-side - well "A1" with the engine centering the nozzle grid in the cavity - (the ``centerMultichannelOnWells`` quirk), the same pre-wire footprint - guard (offset-shifted grid fits), and the container's single tracker - staged with ``volume * (channels holding tips)``. Either way at least - one mounted tip is required, and: stage -> validate -> wire -> - commit/rollback, with an infeasible dispense raising before any - hardware motion. + Mirrors ``aspirate``: same addressing, same pre-wire guards, with + ``add_liquid`` staged per tip-holding channel for a plate and the total + staged against a container's single tracker. """ self._warn_untested_hardware("dispense") self._require_mounted_tip() - rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense - staged_trackers: List[Any] = [] if isinstance(target, Plate): - wells = self._check_full_coverage(target) labware_id = await self.flex._ensure_labware_loaded(target) well_name = self._ANCHOR_WELL_NAME - if does_volume_tracking(): - for i, well in enumerate(wells): - if self._channel_tips[i] is None or well.tracker.is_disabled: - continue - well.tracker.add_liquid(volume=volume) # stages + validates - staged_trackers.append(well.tracker) + staged_trackers = self._stage_wells_dispense(self._check_full_coverage(target), volume) else: self._require_span_fits_container( target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN, offset ) labware_id = await self.flex._ensure_labware_loaded(target) well_name = _CONTAINER_WELL_NAME - mounted = sum(1 for tip in self._channel_tips if tip is not None) - staged_trackers.extend(self._stage_container_dispense(target, volume * mounted)) - well_location = self._well_location([offset], [liquid_height]) - - params: Dict[str, Any] = { - "pipetteId": self.pipette_id, - "labwareId": labware_id, - "wellName": well_name, - "volume": volume, - "flowRate": rate, - } - if well_location is not None: - params["wellLocation"] = well_location - - await self._execute_liquid_op("dispense", params, staged_trackers) + staged_trackers = self._stage_container_dispense(target, volume * self._mounted_count()) + await self._pipette( + "dispense", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) async def touch_tip( self, @@ -2160,14 +1998,9 @@ async def touch_tip( """Touch the mounted tips to their well walls -- one ``touchTip`` command anchored at "A1", fanned to all 96 channels. - ``radius`` is the fraction of the well radius each tip moves toward - (1.0 = the wall). ``offset`` IS the touch position, read from the well - top (the Opentrons Python API's absolute ``v_offset``): it REPLACES the - 1 mm below the rim this touches at by default rather than shifting it the - way an ``aspirate``/``dispense`` offset does, so ``Coordinate(x=1)`` - touches level with the rim. Requires at least one mounted tip and a - 96-position plate (both checked before any wire command). No trackers are - involved. + ``radius`` and ``offset`` behave as in ``FlexHead1.touch_tip``. Requires + a mounted tip and a 96-position plate, both checked before any wire + command. No trackers are involved. """ self._warn_untested_hardware("touch_tip") self._require_mounted_tip() From e4f312fe38c37ff3655a4d3377f76a2d423f5d69 Mon Sep 17 00:00:00 2001 From: miike Date: Mon, 17 Aug 2026 11:11:19 -0400 Subject: [PATCH 27/36] Say which head was verified on hardware, per op 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) --- pylabrobot/opentrons/flex.py | 4 +- .../opentrons/flex_fine_pipetting_tests.py | 8 +-- pylabrobot/opentrons/flex_head.py | 50 ++++++++++--------- pylabrobot/opentrons/flex_motion_tests.py | 26 ++++++++-- pylabrobot/opentrons/flex_tests.py | 11 ++-- 5 files changed, 54 insertions(+), 45 deletions(-) diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 93e8b0777f0..54ddc60be78 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -190,9 +190,7 @@ async def _model_setup(self) -> None: "Unsupported pipette channel count", f"{pip.channels} channels (mount '{pip.mount}') has no matching FlexHead.", ) - head = head_cls( - self, pip.mount, pipette_id, pip.channels, pip.pipette_model, pip.max_volume - ) + head = head_cls(self, pip.mount, pipette_id, pip.channels, pip.pipette_model, pip.max_volume) if pip.channels == 96: self.head96 = head diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index a79025c3609..a25f067d0b1 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -1085,9 +1085,7 @@ def test_configure_for_volume_unprimes_the_plunger(self): asyncio.run(head.configure_for_volume(10.0)) asyncio.run(head.aspirate_in_place(volume=5)) - self.assertEqual( - [c["commandType"] for c in transport.commands].count("prepareToAspirate"), 2 - ) + self.assertEqual([c["commandType"] for c in transport.commands].count("prepareToAspirate"), 2) finally: asyncio.run(flex.stop()) @@ -1099,9 +1097,7 @@ def test_a_blow_out_unprimes_the_plunger_too(self): asyncio.run(head.blow_out()) asyncio.run(head.aspirate_in_place(volume=15)) - self.assertEqual( - [c["commandType"] for c in transport.commands].count("prepareToAspirate"), 2 - ) + self.assertEqual([c["commandType"] for c in transport.commands].count("prepareToAspirate"), 2) finally: asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index db774abd60d..4dc5f2cf6b9 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -965,12 +965,33 @@ class FlexHead1(_FlexHead): ``prepareToAspirate`` priming -- the same machinery ``FlexHead8`` uses for its column ops, applied to a single well instead of a column. - Coded but **not yet verified on real single-channel Flex hardware** -- - Vincent's bench Flex carries an 8-channel pipette, not a single-channel - one. A one-time ``logger.warning`` fires on the first op issued by an - instance, and this docstring makes no "validated on hardware" claim. + Verified on a real single-channel Flex (p50, robot-server API 9.1.1): + motion, tip pickup and drop, liquid probe, aspirate/dispense and volume + mode, all against the hardware tip-presence sensor. Ops outside + ``_HARDWARE_VERIFIED_OPS`` still log the one-time untested-hardware notice. """ + # Confirmed on a p50 single channel. Base-class ops are listed here rather + # than on _FlexHead because the other heads have not been run on hardware. + _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset( + { + "aspirate", + "configure_for_volume", + "dispense", + "drop_tips", + "get_tip_presence", + "liquid_probe", + "move_relative", + "move_to", + "move_to_addressable_area", + "move_to_well", + "pick_up_tips", + "position", + "try_liquid_probe", + "verify_tip_presence", + } + ) + async def pick_up_tips( self, tip_spot: TipSpot, @@ -1181,26 +1202,7 @@ class FlexHead8(_FlexHead): one-time untested-hardware notice, same as the other heads. """ - # Confirmed on a p50 single-channel Flex. Base-class ops are listed here - # rather than on _FlexHead because only this head has been on hardware. - _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset( - { - "aspirate", - "configure_for_volume", - "dispense", - "drop_tips", - "get_tip_presence", - "liquid_probe", - "move_relative", - "move_to", - "move_to_addressable_area", - "move_to_well", - "pick_up_tips", - "position", - "try_liquid_probe", - "verify_tip_presence", - } - ) + _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset({"pick_up_tips"}) def __init__( self, diff --git a/pylabrobot/opentrons/flex_motion_tests.py b/pylabrobot/opentrons/flex_motion_tests.py index 83eb29bad41..2adee99da6c 100644 --- a/pylabrobot/opentrons/flex_motion_tests.py +++ b/pylabrobot/opentrons/flex_motion_tests.py @@ -439,12 +439,30 @@ def test_head8_op_outside_verified_lineage_warns_once(self): asyncio.run(flex.stop()) def test_base_motion_ops_warn_on_unverified_heads(self): - flex, _transport = _flex_with_gripper() - asyncio.run(flex.setup()) + """A base-class op warns unless THIS head's verified set names it. + + Uses FlexHead8, whose verified lineage is column pickup only, so a motion + op it never covered still warns. + """ + flex, head = self._flex_head8() try: with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING") as log_ctx: - asyncio.run(_head(flex).position()) - self.assertTrue(any("FlexHead1.position" in msg for msg in log_ctx.output)) + asyncio.run(head.position()) + self.assertTrue(any("FlexHead8.position" in msg for msg in log_ctx.output)) + finally: + asyncio.run(flex.stop()) + + def test_head1_hardware_verified_ops_do_not_warn(self): + """FlexHead1's ops were confirmed on a p50 single channel, so they stay quiet.""" + transport = ChatterboxTransport(pipettes=[("p50_single_flex", 1, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + head = flex.left + assert head is not None + with self.assertRaises(AssertionError): + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING"): + asyncio.run(head.position()) finally: asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py index fe7c4c5de40..2feaa3a5930 100644 --- a/pylabrobot/opentrons/flex_tests.py +++ b/pylabrobot/opentrons/flex_tests.py @@ -928,7 +928,7 @@ def tearDown(self): set_tip_tracking(False) set_volume_tracking(False) - def test_pick_up_tips_and_aspirate_emit_one_command_each_and_warn_untested(self): + def test_pick_up_tips_and_aspirate_emit_one_command_each(self): flex, transport, head = _flex_head1() try: rack = flex_96_tiprack_50ul(name="rack1") @@ -940,9 +940,7 @@ def test_pick_up_tips_and_aspirate_emit_one_command_each_and_warn_untested(self) target_well = plate.get_item("B3") target_well.tracker.set_volume(100.0) - with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING") as log_ctx: - asyncio.run(head.pick_up_tips(rack.get_item("A1"))) - self.assertTrue(any("not yet verified" in msg.lower() for msg in log_ctx.output)) + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] self.assertEqual(len(pickup_cmds), 1) @@ -950,10 +948,7 @@ def test_pick_up_tips_and_aspirate_emit_one_command_each_and_warn_untested(self) self.assertIsNotNone(head.get_mounted_tips()[0]) self.assertEqual(len(head.get_mounted_tips()), 1) - # A 2nd warning call must be a no-op (only the FIRST op logs). - with self.assertRaises(AssertionError): - with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING"): - asyncio.run(head.aspirate(target_well, volume=10)) + asyncio.run(head.aspirate(target_well, volume=10)) cmd_types = [c["commandType"] for c in transport.commands] prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] From 14017549e0a196329c746810198bb091d5eaf129 Mon Sep 17 00:00:00 2001 From: miike Date: Mon, 17 Aug 2026 15:52:06 -0400 Subject: [PATCH 28/36] Let the robot own plunger priming 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) --- pylabrobot/opentrons/flex_container_tests.py | 9 +- .../opentrons/flex_fine_pipetting_tests.py | 177 +++++++++++++---- pylabrobot/opentrons/flex_head.py | 182 +++++++++--------- pylabrobot/opentrons/flex_tests.py | 94 +++++---- pylabrobot/opentrons/transport.py | 98 ++++++++++ 5 files changed, 388 insertions(+), 172 deletions(-) diff --git a/pylabrobot/opentrons/flex_container_tests.py b/pylabrobot/opentrons/flex_container_tests.py index af930d27804..cb9488d6bee 100644 --- a/pylabrobot/opentrons/flex_container_tests.py +++ b/pylabrobot/opentrons/flex_container_tests.py @@ -281,10 +281,11 @@ def test_aspirate_container_sends_one_uncentered_command_at_a1(self): ) cmd_types = [c["commandType"] for c in transport.commands] - prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] - aspirate_indices = [i for i, t in enumerate(cmd_types) if t == "aspirate"] - self.assertEqual(len(prepare_indices), 1, "prepareToAspirate must fire before the aspirate") - self.assertEqual(prepare_indices[0], aspirate_indices[0] - 1) + self.assertNotIn( + "prepareToAspirate", + cmd_types, + "naming the well leaves priming to the robot, which does it at the well top", + ) # All 8 channels hold a tip, so the single tracker loses 8 * 50. self.assertAlmostEqual(trough.tracker.volume, 9600.0) diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py index a25f067d0b1..e9185b1bb80 100644 --- a/pylabrobot/opentrons/flex_fine_pipetting_tests.py +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -43,9 +43,11 @@ class TestBlowOut(unittest.TestCase): - """blow_out sends one blowOutInPlace command at the current position and - invalidates the plunger priming, so the NEXT aspirate re-sends - prepareToAspirate first.""" + """blow_out sends one blowOutInPlace command at the current position. + + It leaves the plunger past its dispense bottom, but the driver sends no + prepareToAspirate to fix that: a following well-addressed aspirate names a + well, so the robot primes at the well top and descends by itself.""" def setUp(self): set_tip_tracking(True) @@ -88,7 +90,7 @@ def test_head8_accepts_flow_rate_override(self): finally: asyncio.run(flex.stop()) - def test_head8_next_aspirate_reprimes_after_blow_out(self): + def test_head8_next_aspirate_after_blow_out_sends_no_prepare(self): flex, transport, head = _flex_head8() try: rack = flex_96_tiprack_50ul(name="rack") @@ -105,15 +107,12 @@ def test_head8_next_aspirate_reprimes_after_blow_out(self): asyncio.run(head.aspirate(plate, column=1, volume=10)) cmd_types = [c["commandType"] for c in transport.commands] - prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] - aspirate_indices = [i for i, t in enumerate(cmd_types) if t == "aspirate"] - self.assertEqual(len(prepare_indices), 2, "a blow-out must require a new prepare") - self.assertEqual(len(aspirate_indices), 2) - self.assertEqual(prepare_indices[1], aspirate_indices[1] - 1) + self.assertEqual(cmd_types.count("aspirate"), 2) + self.assertNotIn("prepareToAspirate", cmd_types) finally: asyncio.run(flex.stop()) - def test_head1_blow_out_and_reprime(self): + def test_head1_blow_out_then_aspirate_sends_no_prepare(self): flex, transport, head = _flex_head1() try: rack = flex_96_tiprack_50ul(name="rack1") @@ -138,12 +137,12 @@ def test_head1_blow_out_and_reprime(self): {"pipetteId": head.pipette_id, "flowRate": 478.0}, ) cmd_types = [c["commandType"] for c in transport.commands] - prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] - self.assertEqual(len(prepare_indices), 2, "a blow-out must require a new prepare") + self.assertEqual(cmd_types.count("aspirate"), 2) + self.assertNotIn("prepareToAspirate", cmd_types) finally: asyncio.run(flex.stop()) - def test_head96_blow_out_and_reprime(self): + def test_head96_blow_out_then_aspirate_sends_no_prepare(self): flex, transport, head = _flex_head96() try: rack = flex_96_tiprack_50ul(name="rack96") @@ -163,8 +162,8 @@ def test_head96_blow_out_and_reprime(self): self.assertEqual(len(blow_cmds), 1) self.assertEqual(blow_cmds[0]["params"]["pipetteId"], head.pipette_id) cmd_types = [c["commandType"] for c in transport.commands] - prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] - self.assertEqual(len(prepare_indices), 2, "a blow-out must require a new prepare") + self.assertEqual(cmd_types.count("aspirate"), 2) + self.assertNotIn("prepareToAspirate", cmd_types) finally: asyncio.run(flex.stop()) @@ -507,6 +506,46 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s finally: asyncio.run(flex.stop()) + def test_a_probe_refused_for_an_unprimed_plunger_carries_the_priming_remedy(self): + """A probe pushes the plunger, so the robot refuses it on an unprimed one + even though a well IS named: getting there means the tip has held liquid, + and a probe wants a dry tip. The driver translates it like an in-place + draw rather than letting the raw wire error through.""" + + class _UnprimedProbeTransport(ChatterboxTransport): + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if path.endswith("/commands") and data.get("commandType") == "liquidProbe": + cmd_data = result["data"] + cmd_data["status"] = "failed" + cmd_data["error"] = { + "errorType": "PipetteNotReadyToAspirateError", + "detail": "The pipette cannot probe liquid because a previous dispense or " + "blowout pushed the plunger beyond the bottom position.", + } + return result + + transport = _UnprimedProbeTransport(pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + head = flex.right + assert isinstance(head, FlexHead1) + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + self.assertEqual(caught.exception.title, "NotReadyToAspirateError") + self.assertIn("prepare_to_aspirate()", str(caught.exception)) + finally: + asyncio.run(flex.stop()) + class TestLiquidProbeHead8(unittest.TestCase): """FlexHead8 liquid probing is column-addressed: one probe command anchored @@ -995,8 +1034,13 @@ class TestInPlaceLiquidOps(unittest.TestCase): """The in-place ops act where the head already is: one command each, naming no labware and no well, carrying the flow-rate default of the motion they are (aspirate for the air gap too). Each requires a mounted tip, refused - before the wire, and an unprimed plunger is primed first, since the robot - requires a prepareToAspirate before any aspirate, in place or not.""" + before the wire. + + Naming no well is also why the in-place DRAWS are the only ops that make the + caller deal with plunger priming. The robot primes a well-addressed aspirate + itself, at the well top where the tip is in open air; with no well it has no + safe height to do that at, so it refuses instead of guessing, and the driver + passes the refusal on rather than priming blind.""" def setUp(self): set_tip_tracking(True) @@ -1041,7 +1085,9 @@ def test_aspirate_in_place_accepts_a_flow_rate_override(self): finally: asyncio.run(flex.stop()) - def test_aspirate_in_place_primes_the_unprimed_plunger_first_and_only_once(self): + def test_a_pickup_leaves_the_plunger_ready_so_in_place_draws_need_no_prepare(self): + """The robot primes as part of a tip pickup, so back-to-back in-place + aspirates straight off a fresh tip send no prepareToAspirate at all.""" flex, transport, head, rack = self._bench() try: asyncio.run(head.pick_up_tips(rack, column=0)) @@ -1049,55 +1095,102 @@ def test_aspirate_in_place_primes_the_unprimed_plunger_first_and_only_once(self) asyncio.run(head.aspirate_in_place(volume=15)) sent = [c["commandType"] for c in transport.commands] - self.assertEqual(sent.count("prepareToAspirate"), 1) self.assertEqual(sent.count("aspirateInPlace"), 2) - self.assertEqual(sent[sent.index("aspirateInPlace") - 1], "prepareToAspirate") + self.assertNotIn("prepareToAspirate", sent) finally: asyncio.run(flex.stop()) - def test_a_dispense_unprimes_the_plunger_so_the_next_aspirate_primes_again(self): - """A dispense drives the plunger past its bottom, so the robot refuses the - next aspirate until a prepareToAspirate resets it. Without this the ordinary - aspirate/dispense/aspirate transfer loop fails on its second aspirate. - Confirmed against the Opentrons robot-server simulator. - """ + def _assert_in_place_draw_is_refused(self, head, transport): + """The robot refuses, and the driver hands back a message naming both remedies.""" + n_before = len(transport.commands) + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.aspirate_in_place(volume=15)) + self.assertEqual(caught.exception.title, "NotReadyToAspirateError") + self.assertIn("prepare_to_aspirate()", str(caught.exception)) + self.assertIn("aspirate from a well", str(caught.exception)) + self.assertEqual(len(transport.commands), n_before + 1, "the refusal comes from the robot") + + def test_a_dispense_that_empties_the_tip_makes_the_next_in_place_draw_refuse(self): + """The ordinary transfer loop's second draw: emptying the tip leaves the + plunger past its bottom, and with no well named the robot will not fix it.""" flex, transport, head, rack = self._bench() try: asyncio.run(head.pick_up_tips(rack, column=0)) asyncio.run(head.aspirate_in_place(volume=15)) asyncio.run(head.dispense_in_place(volume=15)) + + self._assert_in_place_draw_is_refused(head, transport) + finally: + asyncio.run(flex.stop()) + + def test_a_partial_dispense_leaves_the_plunger_ready(self): + """Only a dispense that EMPTIES the tip un-primes it, because only that one + gets a push-out. Refusing after every dispense would refuse work the robot + accepts.""" + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.dispense_in_place(volume=5)) + asyncio.run(head.aspirate_in_place(volume=5)) sent = [c["commandType"] for c in transport.commands] - self.assertEqual(sent.count("prepareToAspirate"), 2) - last_aspirate = len(sent) - 1 - sent[::-1].index("aspirateInPlace") - self.assertEqual(sent[last_aspirate - 1], "prepareToAspirate") + self.assertEqual(sent.count("aspirateInPlace"), 2) + self.assertNotIn("prepareToAspirate", sent) finally: asyncio.run(flex.stop()) - def test_configure_for_volume_unprimes_the_plunger(self): - """Switching volume mode resets the robot's ready-to-aspirate flag, so the - next aspirate needs a fresh prepare. Confirmed against the simulator.""" + def test_lifting_clear_and_priming_by_hand_clears_the_refusal(self): + """The documented remedy, end to end: the caller moves the tip out of the + liquid, primes, and the in-place draw goes through.""" flex, transport, head, rack = self._bench() try: asyncio.run(head.pick_up_tips(rack, column=0)) asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.dispense_in_place(volume=15)) + self._assert_in_place_draw_is_refused(head, transport) + + asyncio.run(head.prepare_to_aspirate()) + asyncio.run(head.aspirate_in_place(volume=15)) + + sent = [c["commandType"] for c in transport.commands] + self.assertEqual(sent[-1], "aspirateInPlace") + self.assertEqual(sent.count("prepareToAspirate"), 1) + finally: + asyncio.run(flex.stop()) + + def test_configure_for_volume_makes_the_next_in_place_draw_refuse(self): + """Switching volume mode moves where the plunger bottom IS, so the robot + drops its ready flag.""" + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) asyncio.run(head.configure_for_volume(10.0)) - asyncio.run(head.aspirate_in_place(volume=5)) - self.assertEqual([c["commandType"] for c in transport.commands].count("prepareToAspirate"), 2) + self._assert_in_place_draw_is_refused(head, transport) finally: asyncio.run(flex.stop()) - def test_a_blow_out_unprimes_the_plunger_too(self): + def test_a_blow_out_makes_the_next_in_place_draw_refuse(self): flex, transport, head, rack = self._bench() try: asyncio.run(head.pick_up_tips(rack, column=0)) asyncio.run(head.aspirate_in_place(volume=15)) asyncio.run(head.blow_out()) - asyncio.run(head.aspirate_in_place(volume=15)) - self.assertEqual([c["commandType"] for c in transport.commands].count("prepareToAspirate"), 2) + self._assert_in_place_draw_is_refused(head, transport) + finally: + asyncio.run(flex.stop()) + + def test_an_air_gap_in_place_refuses_the_same_way(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.blow_out()) + + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.air_gap_in_place(volume=10)) + self.assertEqual(caught.exception.title, "NotReadyToAspirateError") finally: asyncio.run(flex.stop()) @@ -1369,16 +1462,18 @@ def test_unsafe_blow_out_in_place_sends_the_given_flow_rate(self): finally: asyncio.run(flex.stop()) - def test_next_aspirate_reprimes_after_an_unsafe_blow_out(self): + def test_an_unsafe_blow_out_makes_the_next_in_place_draw_refuse(self): + """The recovery blow-out leaves the plunger where the ordinary one does, + so the next in-place draw needs the same manual prime.""" flex, transport, head, rack = self._bench() try: asyncio.run(head.pick_up_tips(rack, column=0)) asyncio.run(head.aspirate_in_place(volume=10)) asyncio.run(head.unsafe_blow_out_in_place(flow_rate=20.0)) - asyncio.run(head.aspirate_in_place(volume=10)) - sent = [c["commandType"] for c in transport.commands] - self.assertEqual(sent.count("prepareToAspirate"), 2, "a blow-out must require a new prepare") + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.aspirate_in_place(volume=10)) + self.assertEqual(caught.exception.title, "NotReadyToAspirateError") finally: asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 4dc5f2cf6b9..1f2c2afdf0f 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -11,13 +11,16 @@ This module holds the ``_FlexHead`` base plus ``FlexHead1`` (single-channel, well-addressed), ``FlexHead8`` (column-addressed, anchor-well fan-out) and ``FlexHead96`` (96 fixed nozzles, whole-plate-addressed). The transactional -stage->wire->verify->commit/rollback flow, hardware tip-presence -verification, and ``prepareToAspirate`` priming are factored onto the -``_FlexHead`` base (``_execute_pickup``/``_execute_liquid_op``/ -``_execute_with_prepare``/``_execute_trash_drop``) so ``FlexHead1`` and -``FlexHead96`` reuse the exact machinery ``FlexHead8`` established -- only -the addressing (single well vs. column vs. whole-plate anchor) and nozzle -layout differ per head. +stage->wire->verify->commit/rollback flow and hardware tip-presence +verification are factored onto the ``_FlexHead`` base +(``_execute_pickup``/``_execute_liquid_op``/``_execute_draw``/ +``_execute_trash_drop``) so ``FlexHead1`` and ``FlexHead96`` reuse the exact +machinery ``FlexHead8`` established -- only the addressing (single well vs. +column vs. whole-plate anchor) and nozzle layout differ per head. + +Plunger priming (``prepareToAspirate``) is the robot's business, not this +driver's: see ``prepare_to_aspirate`` for the whole rule and why nothing here +tracks it. """ import logging @@ -80,11 +83,6 @@ def __init__( # describing the head does not have to re-read /instruments to get it. self.max_volume = max_volume self._channel_tips: List[Optional[Tip]] = [None] * channels - # Whether the plunger has been prepared (primed) since the last tip - # pickup. The Flex requires an explicit `prepareToAspirate` command - # before the FIRST aspirate after a pickup (implicit on the STAR, - # explicit on the Flex) -- True means no prepare is currently pending. - self._prepared: bool = True self._untested_hardware_warned: bool = False def _warn_untested_hardware(self, op: str) -> None: @@ -135,9 +133,8 @@ async def blow_out(self, flow_rate: Optional[float] = None) -> None: Pushes the plunger past its dispense-bottom to expel residual liquid from the tip(s) wherever the pipette currently is (no well addressing -- position with a dispense/move first). ``flow_rate`` (uL/s) defaults to - the dispense default. Blowing out leaves the plunger at the blow-out - position, so the next aspirate is preceded by a fresh - ``prepareToAspirate`` (same priming rule as after a tip pickup). No + the dispense default. Blowing out leaves the plunger past its dispense + bottom, so the next draw needs priming: see ``prepare_to_aspirate``. No trackers are involved. """ self._warn_untested_hardware("blow_out") @@ -224,7 +221,7 @@ async def _execute_pickup( fails, or if it succeeds but ``_verify_tips_seated()`` reports no tip seated; commits only once both the wire command and hardware verification succeed. Callers are responsible for updating - ``_channel_tips`` and ``_prepared`` AFTER this returns successfully. + ``_channel_tips`` AFTER this returns successfully. """ try: await self._execute(command_type, params) @@ -266,30 +263,46 @@ async def _execute_liquid_op( for tracker in staged_trackers: tracker.commit() - async def _execute_with_prepare( - self, - command_type: str, - params: Dict[str, Any], - staged_trackers: List[Any], - ) -> None: - """``prepareToAspirate`` (if pending) -> wire -> commit/rollback. + async def _execute_draw(self, command_type: str, params: Dict[str, Any]) -> Dict[str, Any]: + """``_execute`` for a draw, restating the robot's "plunger not primed" refusal. - Shared by every ``aspirate``/``aspirate_single`` variant. Sends - ``prepareToAspirate`` first when the plunger is not primed, then the - aspirate itself. Priming is physical plunger state, so a tracker rollback - on a failed aspirate does not undo it: ``_execute`` records it. + The robot refuses a draw that names no well while the plunger sits past + its dispense bottom: fixing that means moving the plunger, and only the + caller knows whether the tip is in liquid. The robot's own message offers + one remedy (aspirate from a well instead); this adds the other one, which + is to lift clear and prime by hand. """ try: - if not self._prepared: - await self._execute("prepareToAspirate", {"pipetteId": self.pipette_id}) - await self._execute(command_type, params) - except Exception: - for tracker in staged_trackers: - tracker.rollback() - raise - else: - for tracker in staged_trackers: - tracker.commit() + return await self._execute(command_type, params) + except OpentronsCommandError as e: + if e.error_type != _NOT_READY_TO_ASPIRATE: + raise + raise OpentronsError("NotReadyToAspirateError", _NOT_PRIMED_REMEDY) from e + + async def prepare_to_aspirate(self) -> None: + """Move the plunger to where an aspirate starts from ("priming"). Tip OUT of the liquid. + + The plunger sits past its dispense bottom after a dispense that emptied + the tip, a blow out, or a volume-mode change, and the robot will not draw + from there. Priming lifts it back, which with the tip submerged draws + that much of the well in, unmeasured: about 4 uL on a p50 in its normal + mode, 12 uL in low-volume mode, 80 uL on a p1000. Move the tip above the + liquid first (``move_to_well`` with a "top" origin) and prime there. + + Usually you do not need this at all. A well-addressed ``aspirate`` primes + itself, at the well top in open air, then descends and draws. Only the + in-place draws need it, because they name no well and so the robot cannot + pick a safe height to prime at. Sending it when the plunger is already in + place does nothing, so it is safe to send defensively. + + Nothing here tracks whether a prime is pending. The robot owns that flag + and answers with it on every draw; a copy in this process would go stale + the first time anything moved the plunger without going through this + driver. + """ + self._warn_untested_hardware("prepare_to_aspirate") + self._require_mounted_tip() + await self._execute("prepareToAspirate", {"pipetteId": self.pipette_id}) def _trash_addressable_area(self, trash: Trash) -> str: """The movable-trash addressable area for the slot this trash sits in.""" @@ -362,7 +375,8 @@ async def _pipette( ``flow_rate`` defaults to the robot's own for the mounted tip, so it is resolved only when the caller left it out (asking with no tip raises). - An aspirate is primed first when a prepare is pending. + Naming the well is what lets the robot prime itself when it needs to, so + no ``prepareToAspirate`` is sent here: see ``prepare_to_aspirate``. """ if flow_rate is None: rates = self.default_flow_rates() @@ -377,10 +391,7 @@ async def _pipette( well_location = self._well_location([offset], [liquid_height]) if well_location is not None: params["wellLocation"] = well_location - if verb == "aspirate": - await self._execute_with_prepare(verb, params, staged_trackers) - else: - await self._execute_liquid_op(verb, params, staged_trackers) + await self._execute_liquid_op(verb, params, staged_trackers) async def _configure_nozzle_layout(self, configuration_params: Dict[str, Any]) -> None: """Send ``configureNozzleLayout``, refusing while any channel holds a tip. @@ -437,8 +448,13 @@ async def _probe_z(self, command_type: str, labware_id: str, well_name: str) -> robot-server OMITS ``z_position`` from a successful command result entirely (rather than reporting null) when no liquid is detected, so absence is read with ``.get()`` and surfaced as ``None``. + + A probe pushes the plunger, so it is refused on an unprimed one exactly + like an in-place draw, even though it does name a well: the robot + deliberately does not prime for it, because reaching this state means the + tip has held liquid and a probe wants a dry one. """ - result = await self._execute( + result = await self._execute_draw( command_type, { "pipetteId": self.pipette_id, @@ -481,12 +497,7 @@ async def _on_stop(self) -> None: async def _execute(self, command_type: str, params: Dict[str, Any]) -> Dict[str, Any]: """Issue a robot-server command through the owning device's shared transport.""" - result = await self.flex._execute_command(command_type, params) - if command_type in _UNPRIMING_COMMANDS: - self._prepared = False - elif command_type in _PRIMING_COMMANDS: - self._prepared = True - return result + return await self.flex._execute_command(command_type, params) @staticmethod def _require_itemized_parent(item: Resource) -> ItemizedResource: @@ -747,18 +758,18 @@ async def aspirate_in_place(self, volume: float, flow_rate: Optional[float] = No Names no well, so no ``Well``/``Container`` tracker moves with it: position the head first (``move_to_well``/``move_to``) and account for the - liquid yourself. ``flow_rate`` (uL/s) defaults to the aspirate default. A - ``prepareToAspirate`` command is sent first when the plunger is unprimed - (after a tip pickup or a blow-out), which the robot requires before any - aspirate, in place or not. + liquid yourself. ``flow_rate`` (uL/s) defaults to the aspirate default. + + Raises ``NotReadyToAspirateError`` when the plunger needs priming, since + naming no well leaves the robot no safe height to prime at. See + ``prepare_to_aspirate``. """ self._warn_untested_hardware("aspirate_in_place") self._require_mounted_tip() rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate - await self._execute_with_prepare( + await self._execute_draw( "aspirateInPlace", {"pipetteId": self.pipette_id, "volume": volume, "flowRate": rate}, - [], ) async def dispense_in_place( @@ -791,16 +802,15 @@ async def air_gap_in_place(self, volume: float, flow_rate: Optional[float] = Non The same plunger motion as ``aspirate_in_place``, but the robot books the volume as air, so park the tip above the liquid first. ``flow_rate`` - (uL/s) defaults to the aspirate default, and the same priming rule - applies. No tracker is involved. + (uL/s) defaults to the aspirate default. Refuses an unprimed plunger the + same way ``aspirate_in_place`` does. No tracker is involved. """ self._warn_untested_hardware("air_gap_in_place") self._require_mounted_tip() rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate - await self._execute_with_prepare( + await self._execute_draw( "airGapInPlace", {"pipetteId": self.pipette_id, "volume": volume, "flowRate": rate}, - [], ) # --- Tip-presence sensor (command form) --- @@ -863,8 +873,8 @@ async def unsafe_blow_out_in_place(self, flow_rate: float) -> None: The recovery counterpart to ``blow_out`` (see ``unsafe_drop_tip_in_place`` for what "unsafe/" buys). ``flow_rate`` is in uL/s and has no default - here, the recovery path being an explicit one. Leaves the plunger at the - blow-out position, so the next aspirate re-primes. + here, the recovery path being an explicit one. Leaves the plunger past + its dispense bottom, so the next draw needs priming. """ self._warn_untested_hardware("unsafe_blow_out_in_place") self._require_mounted_tip() @@ -884,19 +894,16 @@ async def unsafe_blow_out_in_place(self, flow_rate: float) -> None: _MOVE_AXES = frozenset({"x", "y", "z"}) -# Wider than the robot's own rule (it primes on pickUpTip, and a dispense -# unprimes only when it pushes out or empties): a redundant prepare is accepted. -_UNPRIMING_COMMANDS = frozenset( - { - "dispense", - "dispenseInPlace", - "blowOutInPlace", - "unsafe/blowOutInPlace", - "configureForVolume", - "pickUpTip", - } +# What the robot calls its refusal to draw with the plunger past its dispense +# bottom. Raised undefined, so the wire carries the exception's class name. +_NOT_READY_TO_ASPIRATE = "PipetteNotReadyToAspirateError" + +_NOT_PRIMED_REMEDY = ( + "The plunger sits past its dispense bottom, so the robot will not draw from where it " + "is. Either aspirate from a well by name, which primes itself at the well top and then " + "descends, or lift the tip clear of the liquid and call prepare_to_aspirate() before " + "drawing in place. Priming while submerged draws several uL of the well into the tip." ) -_PRIMING_COMMANDS = frozenset({"prepareToAspirate"}) # What verify_tip_presence can assert. The sensor itself can also read # "unknown", but that is a reading, not something to check against. @@ -960,10 +967,10 @@ class FlexHead1(_FlexHead): length 1; the sole channel is index 0. Reuses the ``_FlexHead`` base's transactional stage -> wire -> verify -> - commit/rollback flow, hardware tip-presence verification - (``_verify_tips_seated``/``_confirm_tips_cleared``), and - ``prepareToAspirate`` priming -- the same machinery ``FlexHead8`` uses for - its column ops, applied to a single well instead of a column. + commit/rollback flow and hardware tip-presence verification + (``_verify_tips_seated``/``_confirm_tips_cleared``) -- the same machinery + ``FlexHead8`` uses for its column ops, applied to a single well instead of + a column. Verified on a real single-channel Flex (p50, robot-server API 9.1.1): motion, tip pickup and drop, liquid probe, aspirate/dispense and volume @@ -1097,9 +1104,9 @@ async def aspirate( ``Container`` (trough/reservoir) at its own sole well. Requires a mounted tip. Follows stage -> validate -> wire -> commit/rollback: the tracker (``remove_liquid``) is staged BEFORE the wire command, so an infeasible - aspirate raises before any hardware motion. A ``prepareToAspirate`` - command is sent first if this is the first aspirate since the last tip - pickup. + aspirate raises before any hardware motion. Naming the well means the + robot primes the plunger itself when it needs to, at the well top and + then descending, so nothing here has to. """ self._warn_untested_hardware("aspirate") self._require_mounted_tip() @@ -1410,8 +1417,9 @@ async def aspirate( every well whose channel actually holds a tip (None-skip; wells outside ``column`` are never touched -- the Case-1 regression guard) BEFORE the wire command, so an infeasible aspirate (e.g. ``TooLittleLiquidError``) - raises before any hardware motion. A ``prepareToAspirate`` command is - sent first if this is the first aspirate since the last tip pickup. + raises before any hardware motion. Naming the wells means the robot + primes the plunger itself when it needs to, at the well top and then + descending, so nothing here has to. """ self._warn_untested_hardware("aspirate") self._require_mounted_tip() @@ -1734,9 +1742,9 @@ async def aspirate_single( ) -> None: """Aspirate a single well with the currently mounted single tip. - Sends ``prepareToAspirate`` first if this is the first aspirate since - the last (single-tip) pickup. Follows stage -> validate -> wire -> - commit/rollback for the well tracker, same as the column ``aspirate``. + Follows stage -> validate -> wire -> commit/rollback for the well + tracker, same as the column ``aspirate``, and leaves any plunger priming + to the robot for the same reason. """ self._warn_untested_hardware("aspirate_single") self._active_single_channel() @@ -1940,8 +1948,8 @@ async def aspirate( addressed at its sole well, with the engine centering the nozzle grid in the cavity and the container's single tracker staged with the total. Either way a mounted tip is required and every check runs before any wire - command. A ``prepareToAspirate`` command is sent first if this is the - first aspirate since the last tip pickup. + command. Naming the wells means the robot primes the plunger itself when + it needs to, at the well top and then descending, so nothing here has to. """ self._warn_untested_hardware("aspirate") self._require_mounted_tip() diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py index 2feaa3a5930..9529ab512dc 100644 --- a/pylabrobot/opentrons/flex_tests.py +++ b/pylabrobot/opentrons/flex_tests.py @@ -467,8 +467,14 @@ def test_single_tip_aspirate_dispense_and_drop_round_trip(self): class TestFlexHead8PrepareToAspirate(unittest.TestCase): - """Task 3 fix #1: `prepareToAspirate` must fire once, immediately before the - FIRST aspirate after a tip pickup, and NOT before subsequent aspirates.""" + """Priming a well-addressed aspirate is the robot's job, never the driver's. + + The robot moves to the well TOP, primes there in open air and then descends, + which is the only way to prime safely: the plunger travels several uL worth, + so priming with the tip already in the liquid would draw an unmeasured slug + of the well into the tip. A prepareToAspirate the driver sends first sets the + robot's ready flag and so skips that safe handling. `prepare_to_aspirate` is + the caller's escape hatch for the in-place ops, which name no well.""" def setUp(self): set_tip_tracking(True) @@ -478,54 +484,65 @@ def tearDown(self): set_tip_tracking(False) set_volume_tracking(False) - def test_prepare_to_aspirate_sent_before_first_aspirate_only(self): + def _bench(self): flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + return flex, transport, head, rack, plate + + def test_no_prepare_is_sent_around_a_transfer_loop(self): + flex, transport, head, rack, plate = self._bench() try: - rack = flex_96_tiprack_50ul(name="rack") - plate = cor_96_wellplate_360uL_Fb(name="plate") - plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] - flex.deck.assign_child_at_slot(rack, "C1") - flex.deck.assign_child_at_slot(plate, "C2") - for well in plate.get_all_items(): - well.tracker.set_volume(100.0) - asyncio.run(head.pick_up_tips(rack, column=0)) asyncio.run(head.aspirate(plate, column=0, volume=10)) + asyncio.run(head.dispense(plate, column=1, volume=10)) asyncio.run(head.aspirate(plate, column=1, volume=10)) cmd_types = [c["commandType"] for c in transport.commands] - prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] - aspirate_indices = [i for i, t in enumerate(cmd_types) if t == "aspirate"] - - self.assertEqual(len(prepare_indices), 1, "prepareToAspirate must fire exactly once") - self.assertEqual(len(aspirate_indices), 2) - self.assertEqual(prepare_indices[0], aspirate_indices[0] - 1) - - prepare_cmd = transport.commands[prepare_indices[0]] - self.assertEqual(prepare_cmd["params"], {"pipetteId": head.pipette_id}) + self.assertEqual(cmd_types.count("aspirate"), 2) + self.assertNotIn("prepareToAspirate", cmd_types) finally: asyncio.run(flex.stop()) - def test_prepare_to_aspirate_refires_after_a_new_pickup(self): - flex, transport, head = _flex_head8() + def test_a_well_addressed_aspirate_still_works_with_the_plunger_pushed_past_bottom(self): + """The whole reason the driver can stay out of it: after a blow-out the + robot primes the aspirate itself rather than refusing it.""" + flex, transport, head, rack, plate = self._bench() try: - rack = flex_96_tiprack_50ul(name="rack") - plate = cor_96_wellplate_360uL_Fb(name="plate") - plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] - flex.deck.assign_child_at_slot(rack, "C1") - flex.deck.assign_child_at_slot(plate, "C2") - for well in plate.get_all_items(): - well.tracker.set_volume(100.0) - asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.blow_out()) asyncio.run(head.aspirate(plate, column=0, volume=10)) - asyncio.run(head.drop_tips(rack, column=0)) - asyncio.run(head.pick_up_tips(rack, column=1)) - asyncio.run(head.aspirate(plate, column=1, volume=10)) cmd_types = [c["commandType"] for c in transport.commands] - prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] - self.assertEqual(len(prepare_indices), 2, "a new pickup must require a new prepare") + self.assertEqual(cmd_types.count("aspirate"), 1) + self.assertNotIn("prepareToAspirate", cmd_types) + finally: + asyncio.run(flex.stop()) + + def test_prepare_to_aspirate_sends_one_command_carrying_only_the_pipette(self): + flex, transport, head, rack, _plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.prepare_to_aspirate()) + + prepares = [c for c in transport.commands if c["commandType"] == "prepareToAspirate"] + self.assertEqual(len(prepares), 1) + self.assertEqual(prepares[0]["params"], {"pipetteId": head.pipette_id}) + finally: + asyncio.run(flex.stop()) + + def test_prepare_to_aspirate_without_a_tip_raises_before_the_wire(self): + flex, transport, head, _rack, _plate = self._bench() + try: + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.prepare_to_aspirate()) + self.assertEqual(len(transport.commands), n_before) finally: asyncio.run(flex.stop()) @@ -951,11 +968,9 @@ def test_pick_up_tips_and_aspirate_emit_one_command_each(self): asyncio.run(head.aspirate(target_well, volume=10)) cmd_types = [c["commandType"] for c in transport.commands] - prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] aspirate_indices = [i for i, t in enumerate(cmd_types) if t == "aspirate"] - self.assertEqual(len(prepare_indices), 1, "prepareToAspirate must fire exactly once") self.assertEqual(len(aspirate_indices), 1) - self.assertEqual(prepare_indices[0], aspirate_indices[0] - 1) + self.assertNotIn("prepareToAspirate", cmd_types, "the robot primes for a named well") self.assertEqual(transport.commands[aspirate_indices[0]]["params"]["wellName"], "B3") # Exactly 1 Well tracked -- every other well on the plate is untouched. @@ -1084,10 +1099,9 @@ def test_aspirate_emits_one_command_and_tracks_all_96_wells(self): cmd_types = [c["commandType"] for c in transport.commands] aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] - prepare_indices = [i for i, t in enumerate(cmd_types) if t == "prepareToAspirate"] self.assertEqual(len(aspirate_cmds), 1) self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") - self.assertEqual(len(prepare_indices), 1, "prepareToAspirate must fire before the aspirate") + self.assertNotIn("prepareToAspirate", cmd_types, "the robot primes for a named well") wells = plate.get_all_items() self.assertEqual(len(wells), 96) diff --git a/pylabrobot/opentrons/transport.py b/pylabrobot/opentrons/transport.py index bc93c8ab5bd..15a29b3744a 100644 --- a/pylabrobot/opentrons/transport.py +++ b/pylabrobot/opentrons/transport.py @@ -196,6 +196,10 @@ class ChatterboxTransport: partial-tip extents) — that is protocol-file based (``opentrons_simulate`` against a virtual Protocol Engine) and needs the ``opentrons`` package, which an HTTP transport cannot reach. + + It does track the engine's plunger-priming rule (see ``_track_plunger``), + so a draw the real robot would refuse fails here too. That rule cost a + round of tests that passed on wire traffic hardware rejects. """ def __init__( @@ -285,6 +289,10 @@ def __init__( # pipetteId (as returned by loadPipette) -> mount, so a later # pickUpTip/dropTip command's pipetteId can be resolved back to a mount. self._pipette_id_to_mount: Dict[str, str] = {} + # Simulated plunger state per pipetteId, so a draw the real robot would + # refuse is refused here too. See _plunger_is_ready. + self._plunger_primed: Dict[str, bool] = {} + self._tip_volume: Dict[str, Optional[float]] = {} async def setup(self) -> None: """No connection to open.""" @@ -328,6 +336,77 @@ async def get(self, path: str) -> Dict[str, Any]: return {"data": cmd_data} return {"data": {}} + def _plunger_is_ready(self, pipette_id: str) -> bool: + """Whether the robot would let this pipette draw where it stands. + + Mirrors ``HardwarePipettingHandler.get_is_ready_to_aspirate``: the + plunger must be at its dispense bottom AND the robot must still know how + much the tip holds. Dropping a tip makes the contents unknown, which is + why a drop leaves the pipette not ready even though nothing pushed the + plunger down. + """ + return self._tip_volume.get(pipette_id) is not None and self._plunger_primed.get( + pipette_id, False + ) + + def _track_plunger(self, ctype: str, params: Dict[str, Any]) -> None: + """Apply one command's effect on the simulated plunger and tip contents. + + Straight from the Protocol Engine's own state updates. The two that + surprise people: a tip pickup PRIMES the plunger (the engine primes it as + part of the pickup), and a dispense only un-primes when it empties the + tip, because that is the one the robot follows with a push-out. + """ + pid = params.get("pipetteId") + if not isinstance(pid, str): + return + volume = float(params.get("volume", 0.0) or 0.0) + held = self._tip_volume.get(pid) + if ctype == "configureForVolume": + self._plunger_primed[pid] = False + elif ctype in ("pickUpTip", "prepareToAspirate", "liquidProbe", "tryLiquidProbe"): + self._plunger_primed[pid] = True + self._tip_volume[pid] = 0.0 + elif ctype in ("aspirate", "aspirateInPlace", "airGapInPlace"): + # A well-addressed aspirate primes itself at the well top when it has to. + self._plunger_primed[pid] = True + self._tip_volume[pid] = (held or 0.0) + volume + elif ctype in ("dispense", "dispenseInPlace"): + left = (held or 0.0) - volume + self._tip_volume[pid] = left + push_out = params.get("pushOut") + self._plunger_primed[pid] = push_out == 0 if push_out is not None else abs(left) > 1e-9 + elif ctype in ("blowOut", "blowOutInPlace", "unsafe/blowOutInPlace"): + self._plunger_primed[pid] = False + self._tip_volume[pid] = 0.0 + elif ctype in ("dropTip", "dropTipInPlace", "unsafe/dropTipInPlace"): + self._tip_volume[pid] = None + + def _plunger_refusal(self, ctype: str, params: Dict[str, Any]) -> Optional[str]: + """The robot's own message when a command needs a primed plunger and finds none. + + Only commands that name no well refuse: a well-addressed ``aspirate`` + primes itself at the well top instead. A liquid probe refuses despite + naming a well, deliberately, because getting there means the tip has held + liquid and a probe wants a dry one. + """ + pid = params.get("pipetteId") + if not isinstance(pid, str): + return None + if ctype in ("aspirateInPlace", "airGapInPlace") and not self._plunger_is_ready(pid): + return ( + "Pipette cannot aspirate in place because a previous dispense or blowout pushed " + "the plunger beyond the bottom position. The subsequent aspirate must be from a " + "specific well so the plunger can be reset in a known safe position." + ) + if ctype in ("liquidProbe", "tryLiquidProbe") and self._tip_volume.get(pid) is None: + return ( + "The pipette cannot probe liquid because a previous dispense or blowout pushed " + "the plunger beyond the bottom position. The plunger must be reset while the tip " + "is somewhere away from liquid." + ) + return None + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: if path == "/runs": return {"data": {"id": "chatterbox-run"}} @@ -355,6 +434,23 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s cmd_id = f"cmd-{self._n}" self.commands.append({"commandType": ctype, "params": dict(params)}) result: Dict[str, Any] = {} + refusal = self._plunger_refusal(ctype, params) + if refusal is not None: + # Raised undefined by the engine, so the wire carries the exception's + # class name rather than a defined error code. + cmd_data = { + "id": cmd_id, + "commandType": ctype, + "status": "failed", + "error": { + "errorType": "PipetteNotReadyToAspirateError", + "errorCode": "4000", + "detail": refusal, + }, + } + self._cmds[cmd_id] = cmd_data + self._log("Chatterbox: %s %s REFUSED (plunger not primed)", ctype, params) + return {"data": cmd_data} if ctype == "loadPipette": self._pipette_load_count += 1 pipette_id = f"chatterbox-pip-{self._pipette_load_count}" @@ -416,6 +512,8 @@ async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[s "isDefined": True, }, } + if cmd_data["status"] == "succeeded": + self._track_plunger(ctype, params) self._cmds[cmd_id] = cmd_data self._log("Chatterbox: %s %s", ctype, params) return {"data": cmd_data} From 8e0a02e7eaa1fad12b9be106e362b313f6ca31d7 Mon Sep 17 00:00:00 2001 From: miike Date: Mon, 17 Aug 2026 16:55:39 -0400 Subject: [PATCH 29/36] Record the in-place ops as hardware verified, and say where a tip belongs 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) --- pylabrobot/opentrons/flex_head.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 1f2c2afdf0f..69691763f1c 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -758,7 +758,9 @@ async def aspirate_in_place(self, volume: float, flow_rate: Optional[float] = No Names no well, so no ``Well``/``Container`` tracker moves with it: position the head first (``move_to_well``/``move_to``) and account for the - liquid yourself. ``flow_rate`` (uL/s) defaults to the aspirate default. + liquid yourself. Draw with the tip UNDER the surface and far enough off the + floor not to seal against it; drawing from above the liquid takes air. + ``flow_rate`` (uL/s) defaults to the aspirate default. Raises ``NotReadyToAspirateError`` when the plunger needs priming, since naming no well leaves the robot no safe height to prime at. See @@ -784,6 +786,11 @@ async def dispense_in_place( ``push_out`` (uL) pushes the plunger past its dispense bottom to clear the last drops; left out of the command entirely when None, so the robot applies its own default for the mounted tip and volume. + + Put the tip AT THE LIQUID SURFACE first, not above it. Dispensing from + height splashes, aerosolises, and leaves volume hanging in the tip. "The + caller owns the position" means the caller owes it a good one, not that + any position will do. """ self._warn_untested_hardware("dispense_in_place") self._require_mounted_tip() @@ -982,9 +989,12 @@ class FlexHead1(_FlexHead): # than on _FlexHead because the other heads have not been run on hardware. _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset( { + "air_gap_in_place", "aspirate", + "aspirate_in_place", "configure_for_volume", "dispense", + "dispense_in_place", "drop_tips", "get_tip_presence", "liquid_probe", From c93a3069d101a7abbe61c8b39670bcd2e95605e4 Mon Sep 17 00:00:00 2001 From: miike Date: Mon, 17 Aug 2026 20:19:59 -0400 Subject: [PATCH 30/36] Take the legacy backend changes out of this PR 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) --- .../arms/precise_flex/precise_flex_backend.py | 27 ++--------------- .../backends/opentrons_backend.py | 30 ------------------- .../legacy/liquid_handling/liquid_handler.py | 4 +++ 3 files changed, 7 insertions(+), 54 deletions(-) diff --git a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py index f57575b229f..afba04dfe69 100644 --- a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py @@ -91,37 +91,16 @@ def _convert_to_cartesian_array( return arr async def setup(self, skip_home: bool = False): - """Bring the arm fully up: link, power, control, and (unless skipped) home.""" - await self.connect() - await self.initialize() - if not skip_home: - await self.home() - - async def connect(self): - """Open the link and agree the response protocol. Powers nothing, moves nothing.""" + """Initialize the PreciseFlex backend.""" await self.io.setup() await self.set_response_mode("pc") - - async def initialize(self): - """Raise high power and take control, so the arm accepts commands. Moves nothing. - - Homing is ``home()``, deliberately separate: it sweeps the arm through its - whole envelope, which is not something to do just to read a position. - """ await self.power_on_robot() await self.attach(1) + if not skip_home: + await self.home() async def stop(self): """Stop the PreciseFlex backend.""" - await self.disconnect() - - async def disconnect(self): - """Hand the arm back, moving nothing. - - Drops high power (``hp 0``) as well as releasing the link, because - ``initialize`` is what raised it. Unlike the Flex there is nothing to park - first: this arm's teardown never moved it. - """ await self.detach() await self.power_off_robot() await self.exit() diff --git a/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py index e7b2a24a2ea..31327da546d 100644 --- a/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py @@ -668,36 +668,6 @@ async def prepare_for_manual_channel_operation(self, channel: int): _ = self._pipette_id_for_channel(channel) - async def get_channel_position(self, channel: int) -> Coordinate: - """Where a channel is right now, in deck coordinates.""" - - _, current = self._current_channel_position(channel) - return current - - async def move_channel_to( - self, - channel: int, - x: Optional[float] = None, - y: Optional[float] = None, - z: Optional[float] = None, - ): - """Move a channel to an absolute position, holding the axes left out. - - One coordinated move rather than the per-axis calls chained: the robot lifts to the traversal - height and travels once, where three separate moves each descend and can clip labware between - them. - """ - - pipette_id, current = self._current_channel_position(channel) - target = Coordinate( - x=current.x if x is None else x, - y=current.y if y is None else y, - z=current.z if z is None else z, - ) - await self.move_pipette_head( - location=target, minimum_z_height=self.traversal_height, pipette_id=pipette_id - ) - async def move_channel_x(self, channel: int, x: float): """Move a channel to an absolute x coordinate using savePosition to seed pose.""" diff --git a/pylabrobot/legacy/liquid_handling/liquid_handler.py b/pylabrobot/legacy/liquid_handling/liquid_handler.py index afe0d886b25..a10242c5c95 100644 --- a/pylabrobot/legacy/liquid_handling/liquid_handler.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler.py @@ -1143,6 +1143,10 @@ async def dispense( blow_out_air_volume=blow_out_air_volume, ) + # If the user specified a single resource, but multiple channels to use, we will assume they + # want to space the channels evenly across the resource. Note that offsets are relative to the + # center of the resource. + self._check_containers(resources) use_channels = use_channels or self._default_use_channels or list(range(len(resources))) From 52704e70cf96775680096e883a0f1bd75a287ee6 Mon Sep 17 00:00:00 2001 From: miike Date: Mon, 17 Aug 2026 20:30:20 -0400 Subject: [PATCH 31/36] Drop the opentrons-shared-data dependency 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) --- pylabrobot/opentrons/catalogue.py | 179 +++++++++++++++++- pylabrobot/opentrons/catalogue_tests.py | 37 ++++ pylabrobot/opentrons/pipette_defaults.py | 128 +++++++++---- .../opentrons/pipette_defaults_tests.py | 49 ++++- pylabrobot/opentrons/shared_data.py | 24 --- pyproject.toml | 2 +- 6 files changed, 351 insertions(+), 68 deletions(-) create mode 100644 pylabrobot/opentrons/catalogue_tests.py delete mode 100644 pylabrobot/opentrons/shared_data.py diff --git a/pylabrobot/opentrons/catalogue.py b/pylabrobot/opentrons/catalogue.py index c35a2f42c5b..d68c14c309d 100644 --- a/pylabrobot/opentrons/catalogue.py +++ b/pylabrobot/opentrons/catalogue.py @@ -2,24 +2,183 @@ A ``loadLabware`` command names labware by load name and the robot resolves it against this catalogue, so a name that is not in it fails on the robot with no -client-side warning. Checking here turns that into an error the caller can read. +client-side warning. Checking here turns that into an error the caller can read, +and it is also what decides whether a resource gets the vendor's definition or +one synthesized from its own geometry. + +Transcribed from Opentrons' own labware definitions +(``shared-data/labware/definitions``) at version 9.1.2. ``catalogue_tests`` +re-reads those definitions and fails on any drift, whenever +``opentrons-shared-data`` happens to be installed. A robot only resolves the +names its own software shipped with, so a newer robot may know names this list +does not; add them here when that happens. """ -from functools import lru_cache from typing import FrozenSet -from pylabrobot.opentrons.shared_data import require_shared_data +SHARED_DATA_VERSION = "9.1.2" +_LOAD_NAMES = ( + "agilent_1_reservoir_290ml", + "appliedbiosystemsmicroamp_384_wellplate_40ul", + "armadillo_96_wellplate_200ul_pcr_full_skirt", + "axygen_1_reservoir_90ml", + "axygen_96_wellplate_500ul", + "biorad_384_wellplate_50ul", + "biorad_96_wellplate_200ul_pcr", + "black_96_well_microtiter_plate_lid", + "corning_12_wellplate_6.9ml_flat", + "corning_24_wellplate_3.4ml_flat", + "corning_384_wellplate_112ul_flat", + "corning_48_wellplate_1.6ml_flat", + "corning_6_wellplate_16.8ml_flat", + "corning_96_wellplate_330ul", + "corning_96_wellplate_360ul_flat", + "corning_96_wellplate_360ul_lid", + "corning_falcon_384_wellplate_130ul_flat", + "corning_falcon_384_wellplate_130ul_flat_lid", + "costar_96_wellplate_2.2ml", + "eppendorf_384_wellplate_45ul", + "eppendorf_96_tiprack_1000ul_eptips", + "eppendorf_96_tiprack_10ul_eptips", + "eppendorf_96_wellplate_1000ul", + "eppendorf_96_wellplate_150ul", + "eppendorf_96_wellplate_2000ul", + "eppendorf_96_wellplate_2000ul_lobind", + "eppendorf_96_wellplate_350ul_lobind", + "eppendorf_96_wellplate_500ul", + "eppendorf_96_wellplate_500ul_lobind", + "ev_resin_tips_flex_96_labware", + "ev_resin_tips_flex_96_tiprack_adapter", + "ev_resin_tips_flex_short_adapter", + "ev_resin_tips_flex_tall_adapter", + "geb_96_tiprack_1000ul", + "geb_96_tiprack_10ul", + "greiner_384_wellplate_240ul", + "greiner_96_wellplate_323ul", + "greiner_96_wellplate_340ul_chimney", + "greiner_96_wellplate_382ul", + "ibidi_96_square_well_plate_300ul", + "ibidi_96_square_well_plate_300ul_lid", + "milliplex_r_96_well_microtiter_plate", + "nest_12_reservoir_15ml", + "nest_12_reservoir_22ml", + "nest_1_reservoir_195ml", + "nest_1_reservoir_290ml", + "nest_24_wellplate_10.4ml", + "nest_8_reservoir_22ml", + "nest_96_wellplate_100ul_pcr_full_skirt", + "nest_96_wellplate_200ul_flat", + "nest_96_wellplate_2ml_deep", + "nunc_384_wellplate_100ul", + "nunc_96_wellplate_450ul", + "opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical", + "opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical_acrylic", + "opentrons_10_tuberack_nest_4x50ml_6x15ml_conical", + "opentrons_12_well_aluminumblock_tough_22ml", + "opentrons_15_tuberack_eppendorf_15ml_conical", + "opentrons_15_tuberack_falcon_15ml_conical", + "opentrons_15_tuberack_nest_15ml_conical", + "opentrons_1_trash_1100ml_fixed", + "opentrons_1_trash_3200ml_fixed", + "opentrons_1_trash_850ml_fixed", + "opentrons_1_well_aluminumblock_tough_300ml", + "opentrons_24_aluminumblock_generic_2ml_screwcap", + "opentrons_24_aluminumblock_nest_0.5ml_screwcap", + "opentrons_24_aluminumblock_nest_1.5ml_screwcap", + "opentrons_24_aluminumblock_nest_1.5ml_snapcap", + "opentrons_24_aluminumblock_nest_2ml_screwcap", + "opentrons_24_aluminumblock_nest_2ml_snapcap", + "opentrons_24_tuberack_eppendorf_1.5ml_safelock_snapcap", + "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap", + "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap_acrylic", + "opentrons_24_tuberack_generic_0.75ml_snapcap_acrylic", + "opentrons_24_tuberack_generic_2ml_screwcap", + "opentrons_24_tuberack_nest_0.5ml_screwcap", + "opentrons_24_tuberack_nest_1.5ml_screwcap", + "opentrons_24_tuberack_nest_1.5ml_snapcap", + "opentrons_24_tuberack_nest_2ml_screwcap", + "opentrons_24_tuberack_nest_2ml_snapcap", + "opentrons_40_aluminumblock_eppendorf_24x2ml_safelock_snapcap_generic_16x0.2ml_pcr_strip", + "opentrons_4_well_aluminumblock_tough_72ml", + "opentrons_6_tuberack_falcon_50ml_conical", + "opentrons_6_tuberack_nest_50ml_conical", + "opentrons_96_aluminumblock_biorad_wellplate_200ul", + "opentrons_96_aluminumblock_generic_pcr_strip_200ul", + "opentrons_96_aluminumblock_nest_wellplate_100ul", + "opentrons_96_deep_well_adapter", + "opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep", + "opentrons_96_deep_well_temp_mod_adapter", + "opentrons_96_filtertiprack_1000ul", + "opentrons_96_filtertiprack_10ul", + "opentrons_96_filtertiprack_200ul", + "opentrons_96_filtertiprack_20ul", + "opentrons_96_flat_bottom_adapter", + "opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat", + "opentrons_96_pcr_adapter", + "opentrons_96_pcr_adapter_armadillo_wellplate_200ul", + "opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt", + "opentrons_96_tiprack_1000ul", + "opentrons_96_tiprack_10ul", + "opentrons_96_tiprack_20ul", + "opentrons_96_tiprack_300ul", + "opentrons_96_well_aluminum_block", + "opentrons_96_wellplate_200ul_pcr_full_skirt", + "opentrons_aluminum_flat_bottom_plate", + "opentrons_calibration_adapter_heatershaker_module", + "opentrons_calibration_adapter_temperature_module", + "opentrons_calibration_adapter_thermocycler_module", + "opentrons_calibrationblock_short_side_left", + "opentrons_calibrationblock_short_side_right", + "opentrons_flex_96_filtertiprack_1000ul", + "opentrons_flex_96_filtertiprack_200ul", + "opentrons_flex_96_filtertiprack_20ul", + "opentrons_flex_96_filtertiprack_50ul", + "opentrons_flex_96_tiprack_1000ul", + "opentrons_flex_96_tiprack_200ul", + "opentrons_flex_96_tiprack_20ul", + "opentrons_flex_96_tiprack_50ul", + "opentrons_flex_96_tiprack_adapter", + "opentrons_flex_deck_riser", + "opentrons_flex_lid_absorbance_plate_reader_module", + "opentrons_flex_tiprack_lid", + "opentrons_tough_12_reservoir_22ml", + "opentrons_tough_1_reservoir_300ml", + "opentrons_tough_4_reservoir_72ml", + "opentrons_tough_pcr_auto_sealing_lid", + "opentrons_tough_universal_lid", + "opentrons_universal_flat_adapter", + "opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat", + "opentrons_universal_flat_adapter_type_b", + "protocol_engine_lid_stack_object", + "schema3test_96_well_aluminum_block", + "schema3test_96_wellplate_200ul_pcr_full_skirt", + "schema3test_96_wellplate_360ul_flat", + "schema3test_aluminum_flat_bottom_plate", + "schema3test_flex_96_tiprack_200ul", + "schema3test_flex_96_tiprack_adapter", + "schema3test_flex_tiprack_lid", + "schema3test_tough_pcr_auto_sealing_lid", + "schema3test_universal_flat_adapter", + "smc_384_read_plate", + "thermofisher_nunc_maxisorp_lockwell_elisa", + "thermoscientific_96_wellplate_800ul", + "thermoscientific_abgene_96_wellplate_1.2ml", + "thermoscientificnunc_96_wellplate_1300ul", + "thermoscientificnunc_96_wellplate_2000ul", + "tipone_96_tiprack_200ul", + "usascientific_12_reservoir_22ml", + "usascientific_96_wellplate_2.4ml_deep", +) -@lru_cache(maxsize=1) -def catalogue_load_names() -> FrozenSet[str]: - """Every load name the shipped catalogue defines, across schema versions.""" - require_shared_data() - from opentrons_shared_data.labware import list_definitions +CATALOGUE_LOAD_NAMES: FrozenSet[str] = frozenset(_LOAD_NAMES) - return frozenset(load_name for load_name, _version, _schema in list_definitions()) + +def catalogue_load_names() -> FrozenSet[str]: + """Every load name the shipped catalogue defines.""" + return CATALOGUE_LOAD_NAMES def is_catalogue_labware(load_name: str) -> bool: """Whether Opentrons ships a definition under this load name.""" - return load_name in catalogue_load_names() + return load_name in CATALOGUE_LOAD_NAMES diff --git a/pylabrobot/opentrons/catalogue_tests.py b/pylabrobot/opentrons/catalogue_tests.py new file mode 100644 index 00000000000..405eb928262 --- /dev/null +++ b/pylabrobot/opentrons/catalogue_tests.py @@ -0,0 +1,37 @@ +import unittest + +import pytest + +from pylabrobot.opentrons.catalogue import CATALOGUE_LOAD_NAMES, is_catalogue_labware + + +class CatalogueTests(unittest.TestCase): + def test_a_shipped_name_is_recognised(self): + self.assertTrue(is_catalogue_labware("opentrons_96_wellplate_200ul_pcr_full_skirt")) + + def test_a_name_outside_the_catalogue_is_not(self): + self.assertFalse(is_catalogue_labware("my_custom_plate")) + + def test_the_list_is_not_accidentally_empty(self): + # An empty catalogue would silently route every resource to a synthesized + # definition instead of the vendor's, which changes gripper grip heights. + self.assertGreater(len(CATALOGUE_LOAD_NAMES), 100) + + +class CatalogueMatchesOpentronsTests(unittest.TestCase): + """The list is a copy, so prove it still matches what it was copied from. + + Skipped unless ``opentrons-shared-data`` is installed; PyLabRobot does not + depend on it. + """ + + def test_the_load_names_match_opentrons_own_definitions(self): + pytest.importorskip("opentrons_shared_data") + from opentrons_shared_data.labware import list_definitions + + theirs = {load_name for load_name, _version, _schema in list_definitions()} + self.assertEqual(CATALOGUE_LOAD_NAMES, theirs) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/pipette_defaults.py b/pylabrobot/opentrons/pipette_defaults.py index 4be65186814..10628ff68f5 100644 --- a/pylabrobot/opentrons/pipette_defaults.py +++ b/pylabrobot/opentrons/pipette_defaults.py @@ -1,17 +1,27 @@ -"""The robot's own default flow rates, per pipette and per tip. +"""Opentrons' own default flow rates for the Flex pipettes, per model and tip. Every Opentrons liquid-handling command carries a required ``flowRate``, and no -robot-server endpoint serves the defaults -- ``GET /instruments`` reports -channels and volume range only. They are read here from the same shared-data -package robot-server itself loads, so a caller who does not name a rate gets -what the robot would have used. The values differ by more than two orders of -magnitude across the Flex range (6 uL/s to 716), so a single constant is wrong -for almost every pipette. +robot-server endpoint serves the defaults: ``GET /instruments`` reports channels +and volume range only, and the protocol engine rejects a command that omits the +field rather than filling one in. The numbers therefore have to live on the +client, the same way the Hamilton backends carry their liquid classes. + +They are transcribed from Opentrons' own pipette definitions +(``shared-data/pipette/definitions/2/liquid``) at version 9.1.2. +``pipette_defaults_tests`` re-reads those definitions and fails on any drift, +whenever ``opentrons-shared-data`` happens to be installed. + +Rates span more than two orders of magnitude across the range, 6 uL/s to 716, so +one constant is wrong for almost every pipette. They also change between +VERSIONS of the same model: a p1000 on a 50 uL tip is 6 uL/s at v3.3 and 478 at +v3.4. Models are therefore keyed by full version string, and one this table does +not name raises instead of falling back, because no neighbour is near enough to +guess with. """ from typing import Dict, NamedTuple, Tuple -from pylabrobot.opentrons.shared_data import require_shared_data +SHARED_DATA_VERSION = "9.1.2" class FlowRates(NamedTuple): @@ -22,37 +32,91 @@ class FlowRates(NamedTuple): blow_out: float -_CACHE: Dict[str, Dict[str, FlowRates]] = {} +_P1000_ORIGINAL = { + "t50": FlowRates(6, 6, 80), + "t200": FlowRates(80, 80, 80), + "t1000": FlowRates(160, 160, 80), +} + +_P1000_FAST = { + "t50": FlowRates(478, 478, 478), + "t200": FlowRates(716, 716, 716), + "t1000": FlowRates(716, 716, 716), +} + +_P200_96_ORIGINAL = { + "t20": FlowRates(6.5, 6.5, 10), + "t50": FlowRates(6, 6, 10), + "t200": FlowRates(10, 10, 10), +} + +_P200_96_REVISED = { + "t20": FlowRates(6.5, 6.5, 10), + "t50": FlowRates(22, 22, 22), + "t200": FlowRates(15, 15, 10), +} + +_P50_SLOW_ON_50 = { + "t20": FlowRates(35, 57, 57), + "t50": FlowRates(8, 8, 4), +} + +_P50_FAST_ON_50 = { + "t20": FlowRates(35, 57, 57), + "t50": FlowRates(35, 57, 57), +} + +_P50_SLOW_ON_20 = { + "t20": FlowRates(22, 22, 57), + "t50": FlowRates(35, 57, 57), +} + +_DEFAULTS: Dict[str, Dict[str, FlowRates]] = { + "p1000_96_v3.0": _P1000_ORIGINAL, + "p1000_96_v3.3": _P1000_ORIGINAL, + "p1000_96_v3.4": _P1000_ORIGINAL, + "p1000_96_v3.5": _P1000_ORIGINAL, + "p1000_96_v3.6": _P1000_ORIGINAL, + "p1000_96_v3.7": _P1000_ORIGINAL, + "p1000_multi_v3.0": _P1000_ORIGINAL, + "p1000_multi_v3.3": _P1000_ORIGINAL, + "p1000_multi_v3.4": _P1000_FAST, + "p1000_multi_v3.5": _P1000_FAST, + "p1000_multi_v3.6": _P1000_FAST, + "p1000_single_v3.0": _P1000_ORIGINAL, + "p1000_single_v3.3": _P1000_ORIGINAL, + "p1000_single_v3.4": _P1000_FAST, + "p1000_single_v3.5": _P1000_FAST, + "p1000_single_v3.6": _P1000_FAST, + "p1000_single_v3.7": _P1000_FAST, + "p200_96_v3.0": _P200_96_ORIGINAL, + "p200_96_v3.1": _P200_96_REVISED, + "p200_96_v3.2": _P200_96_REVISED, + "p200_96_v3.3": _P200_96_REVISED, + "p50_multi_v3.0": _P50_SLOW_ON_50, + "p50_multi_v3.3": _P50_SLOW_ON_50, + "p50_multi_v3.4": _P50_FAST_ON_50, + "p50_multi_v3.5": _P50_FAST_ON_50, + "p50_single_v3.0": _P50_SLOW_ON_50, + "p50_single_v3.3": _P50_SLOW_ON_50, + "p50_single_v3.4": _P50_FAST_ON_50, + "p50_single_v3.5": _P50_FAST_ON_50, + "p50_single_v3.6": _P50_SLOW_ON_20, + "p50_single_v3.7": _P50_SLOW_ON_20, +} def _rates_by_tip(pipette_model: str) -> Dict[str, FlowRates]: - cached = _CACHE.get(pipette_model) - if cached is not None: - return cached - if not pipette_model: - # An empty model resolves to a p1000 single-channel rather than raising, which - # would silently pipette a p50 at up to 716 uL/s. raise ValueError("No pipette model given, so its default flow rates are unknown.") - require_shared_data() - from opentrons_shared_data.pipette.load_data import load_liquid_model - from opentrons_shared_data.pipette.pipette_load_name_conversions import convert_pipette_model - from opentrons_shared_data.pipette.types import PipetteModel, PipetteOEMType - - version = convert_pipette_model(PipetteModel(pipette_model)) - liquid_model = load_liquid_model( - version.pipette_type, version.pipette_channels, version.pipette_version, PipetteOEMType.OT - ) - rates = { - tip_type.name: FlowRates( - aspirate=tip.default_aspirate_flowrate.default, - dispense=tip.default_dispense_flowrate.default, - blow_out=tip.default_blowout_flowrate.default, + rates = _DEFAULTS.get(pipette_model) + if rates is None: + raise ValueError( + f"No default flow rates are recorded for pipette '{pipette_model}'. Pass an explicit " + "flow_rate, or add the model to pylabrobot.opentrons.pipette_defaults. Rates change " + "between versions of the same pipette, so the nearest version is not a safe stand-in." ) - for tip_type, tip in liquid_model["default"].supported_tips.items() - } - _CACHE[pipette_model] = rates return rates diff --git a/pylabrobot/opentrons/pipette_defaults_tests.py b/pylabrobot/opentrons/pipette_defaults_tests.py index 6d0236b0881..07f7d286e98 100644 --- a/pylabrobot/opentrons/pipette_defaults_tests.py +++ b/pylabrobot/opentrons/pipette_defaults_tests.py @@ -1,6 +1,12 @@ import unittest -from pylabrobot.opentrons.pipette_defaults import flow_rates, supported_tip_volumes +import pytest + +from pylabrobot.opentrons.pipette_defaults import ( + _DEFAULTS, + flow_rates, + supported_tip_volumes, +) class PipetteDefaultsTests(unittest.TestCase): @@ -36,6 +42,47 @@ def test_supported_tip_volumes_are_ascending(self): self.assertEqual(supported_tip_volumes("p50_multi_v3.5"), (20.0, 50.0)) self.assertEqual(supported_tip_volumes("p1000_multi_v3.5"), (50.0, 200.0, 1000.0)) + def test_an_unrecorded_model_is_refused_rather_than_run_at_a_neighbour_s_rate(self): + with self.assertRaises(ValueError) as caught: + flow_rates("p50_single_v9.9", 50) + self.assertIn("p50_single_v9.9", str(caught.exception)) + self.assertIn("flow_rate", str(caught.exception)) + + +class PipetteDefaultsMatchOpentronsTests(unittest.TestCase): + """The vendored table is a copy, so prove it still matches what it was copied from. + + Skipped unless ``opentrons-shared-data`` is installed; PyLabRobot does not + depend on it. + """ + + def test_every_recorded_model_matches_opentrons_own_definition(self): + pytest.importorskip("opentrons_shared_data") + from opentrons_shared_data.pipette.load_data import load_liquid_model + from opentrons_shared_data.pipette.pipette_load_name_conversions import ( + convert_pipette_model, + ) + from opentrons_shared_data.pipette.types import PipetteModel, PipetteOEMType + + for model, recorded in _DEFAULTS.items(): + with self.subTest(model=model): + version = convert_pipette_model(PipetteModel(model)) + liquid_model = load_liquid_model( + version.pipette_type, + version.pipette_channels, + version.pipette_version, + PipetteOEMType.OT, + ) + theirs = { + tip_type.name: ( + tip.default_aspirate_flowrate.default, + tip.default_dispense_flowrate.default, + tip.default_blowout_flowrate.default, + ) + for tip_type, tip in liquid_model["default"].supported_tips.items() + } + self.assertEqual({name: tuple(r) for name, r in recorded.items()}, theirs) + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/opentrons/shared_data.py b/pylabrobot/opentrons/shared_data.py deleted file mode 100644 index 4cc40d9c74d..00000000000 --- a/pylabrobot/opentrons/shared_data.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Opentrons' own data package, imported only where it is used. - -``opentrons-shared-data`` ships the labware catalogue and the pipette -definitions the robot itself loads, so reading them beats vendoring numbers -that drift. It is an optional extra, and importing it at module scope would -make ``import pylabrobot.opentrons`` fail for anyone who installed PyLabRobot -without it. -""" - -from importlib.util import find_spec - - -def has_shared_data() -> bool: - """Whether Opentrons' data package is installed.""" - return find_spec("opentrons_shared_data") is not None - - -def require_shared_data() -> None: - """Raise unless Opentrons' data package is importable.""" - if not has_shared_data(): - raise RuntimeError( - "opentrons-shared-data is required for Opentrons labware and pipette data. " - 'Install with: pip install "pylabrobot[opentrons]"' - ) diff --git a/pyproject.toml b/pyproject.toml index 0e0227a3dd4..9df951e8672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ usb = ["pyusb", "libusb-package"] ftdi = ["pylibftdi", "pyusb"] hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] -opentrons = ["opentrons-http-api-client==0.2.1", "httpx", "opentrons-shared-data"] +opentrons = ["opentrons-http-api-client==0.2.1", "httpx"] sila = ["zeroconf>=0.131.0", "grpcio"] cytation-microscopy = ["numpy>=1.26", "opencv-python", "PyGObject"] pico = ["PyLabRobot[sila]", "opencv-python", "numpy"] From 519215d18f87f22b4ea1ce6ae20f26ce067272b8 Mon Sep 17 00:00:00 2001 From: miike Date: Mon, 17 Aug 2026 23:52:20 -0400 Subject: [PATCH 32/36] Let the robot say what labware names it knows, instead of guessing here 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) --- pylabrobot/opentrons/catalogue.py | 184 ------------------------ pylabrobot/opentrons/catalogue_tests.py | 37 ----- pylabrobot/opentrons/flex.py | 61 ++++---- pylabrobot/opentrons/flex_tests.py | 39 ++--- 4 files changed, 50 insertions(+), 271 deletions(-) delete mode 100644 pylabrobot/opentrons/catalogue.py delete mode 100644 pylabrobot/opentrons/catalogue_tests.py diff --git a/pylabrobot/opentrons/catalogue.py b/pylabrobot/opentrons/catalogue.py deleted file mode 100644 index d68c14c309d..00000000000 --- a/pylabrobot/opentrons/catalogue.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Which load names Opentrons' own labware catalogue defines. - -A ``loadLabware`` command names labware by load name and the robot resolves it -against this catalogue, so a name that is not in it fails on the robot with no -client-side warning. Checking here turns that into an error the caller can read, -and it is also what decides whether a resource gets the vendor's definition or -one synthesized from its own geometry. - -Transcribed from Opentrons' own labware definitions -(``shared-data/labware/definitions``) at version 9.1.2. ``catalogue_tests`` -re-reads those definitions and fails on any drift, whenever -``opentrons-shared-data`` happens to be installed. A robot only resolves the -names its own software shipped with, so a newer robot may know names this list -does not; add them here when that happens. -""" - -from typing import FrozenSet - -SHARED_DATA_VERSION = "9.1.2" - -_LOAD_NAMES = ( - "agilent_1_reservoir_290ml", - "appliedbiosystemsmicroamp_384_wellplate_40ul", - "armadillo_96_wellplate_200ul_pcr_full_skirt", - "axygen_1_reservoir_90ml", - "axygen_96_wellplate_500ul", - "biorad_384_wellplate_50ul", - "biorad_96_wellplate_200ul_pcr", - "black_96_well_microtiter_plate_lid", - "corning_12_wellplate_6.9ml_flat", - "corning_24_wellplate_3.4ml_flat", - "corning_384_wellplate_112ul_flat", - "corning_48_wellplate_1.6ml_flat", - "corning_6_wellplate_16.8ml_flat", - "corning_96_wellplate_330ul", - "corning_96_wellplate_360ul_flat", - "corning_96_wellplate_360ul_lid", - "corning_falcon_384_wellplate_130ul_flat", - "corning_falcon_384_wellplate_130ul_flat_lid", - "costar_96_wellplate_2.2ml", - "eppendorf_384_wellplate_45ul", - "eppendorf_96_tiprack_1000ul_eptips", - "eppendorf_96_tiprack_10ul_eptips", - "eppendorf_96_wellplate_1000ul", - "eppendorf_96_wellplate_150ul", - "eppendorf_96_wellplate_2000ul", - "eppendorf_96_wellplate_2000ul_lobind", - "eppendorf_96_wellplate_350ul_lobind", - "eppendorf_96_wellplate_500ul", - "eppendorf_96_wellplate_500ul_lobind", - "ev_resin_tips_flex_96_labware", - "ev_resin_tips_flex_96_tiprack_adapter", - "ev_resin_tips_flex_short_adapter", - "ev_resin_tips_flex_tall_adapter", - "geb_96_tiprack_1000ul", - "geb_96_tiprack_10ul", - "greiner_384_wellplate_240ul", - "greiner_96_wellplate_323ul", - "greiner_96_wellplate_340ul_chimney", - "greiner_96_wellplate_382ul", - "ibidi_96_square_well_plate_300ul", - "ibidi_96_square_well_plate_300ul_lid", - "milliplex_r_96_well_microtiter_plate", - "nest_12_reservoir_15ml", - "nest_12_reservoir_22ml", - "nest_1_reservoir_195ml", - "nest_1_reservoir_290ml", - "nest_24_wellplate_10.4ml", - "nest_8_reservoir_22ml", - "nest_96_wellplate_100ul_pcr_full_skirt", - "nest_96_wellplate_200ul_flat", - "nest_96_wellplate_2ml_deep", - "nunc_384_wellplate_100ul", - "nunc_96_wellplate_450ul", - "opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical", - "opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical_acrylic", - "opentrons_10_tuberack_nest_4x50ml_6x15ml_conical", - "opentrons_12_well_aluminumblock_tough_22ml", - "opentrons_15_tuberack_eppendorf_15ml_conical", - "opentrons_15_tuberack_falcon_15ml_conical", - "opentrons_15_tuberack_nest_15ml_conical", - "opentrons_1_trash_1100ml_fixed", - "opentrons_1_trash_3200ml_fixed", - "opentrons_1_trash_850ml_fixed", - "opentrons_1_well_aluminumblock_tough_300ml", - "opentrons_24_aluminumblock_generic_2ml_screwcap", - "opentrons_24_aluminumblock_nest_0.5ml_screwcap", - "opentrons_24_aluminumblock_nest_1.5ml_screwcap", - "opentrons_24_aluminumblock_nest_1.5ml_snapcap", - "opentrons_24_aluminumblock_nest_2ml_screwcap", - "opentrons_24_aluminumblock_nest_2ml_snapcap", - "opentrons_24_tuberack_eppendorf_1.5ml_safelock_snapcap", - "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap", - "opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap_acrylic", - "opentrons_24_tuberack_generic_0.75ml_snapcap_acrylic", - "opentrons_24_tuberack_generic_2ml_screwcap", - "opentrons_24_tuberack_nest_0.5ml_screwcap", - "opentrons_24_tuberack_nest_1.5ml_screwcap", - "opentrons_24_tuberack_nest_1.5ml_snapcap", - "opentrons_24_tuberack_nest_2ml_screwcap", - "opentrons_24_tuberack_nest_2ml_snapcap", - "opentrons_40_aluminumblock_eppendorf_24x2ml_safelock_snapcap_generic_16x0.2ml_pcr_strip", - "opentrons_4_well_aluminumblock_tough_72ml", - "opentrons_6_tuberack_falcon_50ml_conical", - "opentrons_6_tuberack_nest_50ml_conical", - "opentrons_96_aluminumblock_biorad_wellplate_200ul", - "opentrons_96_aluminumblock_generic_pcr_strip_200ul", - "opentrons_96_aluminumblock_nest_wellplate_100ul", - "opentrons_96_deep_well_adapter", - "opentrons_96_deep_well_adapter_nest_wellplate_2ml_deep", - "opentrons_96_deep_well_temp_mod_adapter", - "opentrons_96_filtertiprack_1000ul", - "opentrons_96_filtertiprack_10ul", - "opentrons_96_filtertiprack_200ul", - "opentrons_96_filtertiprack_20ul", - "opentrons_96_flat_bottom_adapter", - "opentrons_96_flat_bottom_adapter_nest_wellplate_200ul_flat", - "opentrons_96_pcr_adapter", - "opentrons_96_pcr_adapter_armadillo_wellplate_200ul", - "opentrons_96_pcr_adapter_nest_wellplate_100ul_pcr_full_skirt", - "opentrons_96_tiprack_1000ul", - "opentrons_96_tiprack_10ul", - "opentrons_96_tiprack_20ul", - "opentrons_96_tiprack_300ul", - "opentrons_96_well_aluminum_block", - "opentrons_96_wellplate_200ul_pcr_full_skirt", - "opentrons_aluminum_flat_bottom_plate", - "opentrons_calibration_adapter_heatershaker_module", - "opentrons_calibration_adapter_temperature_module", - "opentrons_calibration_adapter_thermocycler_module", - "opentrons_calibrationblock_short_side_left", - "opentrons_calibrationblock_short_side_right", - "opentrons_flex_96_filtertiprack_1000ul", - "opentrons_flex_96_filtertiprack_200ul", - "opentrons_flex_96_filtertiprack_20ul", - "opentrons_flex_96_filtertiprack_50ul", - "opentrons_flex_96_tiprack_1000ul", - "opentrons_flex_96_tiprack_200ul", - "opentrons_flex_96_tiprack_20ul", - "opentrons_flex_96_tiprack_50ul", - "opentrons_flex_96_tiprack_adapter", - "opentrons_flex_deck_riser", - "opentrons_flex_lid_absorbance_plate_reader_module", - "opentrons_flex_tiprack_lid", - "opentrons_tough_12_reservoir_22ml", - "opentrons_tough_1_reservoir_300ml", - "opentrons_tough_4_reservoir_72ml", - "opentrons_tough_pcr_auto_sealing_lid", - "opentrons_tough_universal_lid", - "opentrons_universal_flat_adapter", - "opentrons_universal_flat_adapter_corning_384_wellplate_112ul_flat", - "opentrons_universal_flat_adapter_type_b", - "protocol_engine_lid_stack_object", - "schema3test_96_well_aluminum_block", - "schema3test_96_wellplate_200ul_pcr_full_skirt", - "schema3test_96_wellplate_360ul_flat", - "schema3test_aluminum_flat_bottom_plate", - "schema3test_flex_96_tiprack_200ul", - "schema3test_flex_96_tiprack_adapter", - "schema3test_flex_tiprack_lid", - "schema3test_tough_pcr_auto_sealing_lid", - "schema3test_universal_flat_adapter", - "smc_384_read_plate", - "thermofisher_nunc_maxisorp_lockwell_elisa", - "thermoscientific_96_wellplate_800ul", - "thermoscientific_abgene_96_wellplate_1.2ml", - "thermoscientificnunc_96_wellplate_1300ul", - "thermoscientificnunc_96_wellplate_2000ul", - "tipone_96_tiprack_200ul", - "usascientific_12_reservoir_22ml", - "usascientific_96_wellplate_2.4ml_deep", -) - -CATALOGUE_LOAD_NAMES: FrozenSet[str] = frozenset(_LOAD_NAMES) - - -def catalogue_load_names() -> FrozenSet[str]: - """Every load name the shipped catalogue defines.""" - return CATALOGUE_LOAD_NAMES - - -def is_catalogue_labware(load_name: str) -> bool: - """Whether Opentrons ships a definition under this load name.""" - return load_name in CATALOGUE_LOAD_NAMES diff --git a/pylabrobot/opentrons/catalogue_tests.py b/pylabrobot/opentrons/catalogue_tests.py deleted file mode 100644 index 405eb928262..00000000000 --- a/pylabrobot/opentrons/catalogue_tests.py +++ /dev/null @@ -1,37 +0,0 @@ -import unittest - -import pytest - -from pylabrobot.opentrons.catalogue import CATALOGUE_LOAD_NAMES, is_catalogue_labware - - -class CatalogueTests(unittest.TestCase): - def test_a_shipped_name_is_recognised(self): - self.assertTrue(is_catalogue_labware("opentrons_96_wellplate_200ul_pcr_full_skirt")) - - def test_a_name_outside_the_catalogue_is_not(self): - self.assertFalse(is_catalogue_labware("my_custom_plate")) - - def test_the_list_is_not_accidentally_empty(self): - # An empty catalogue would silently route every resource to a synthesized - # definition instead of the vendor's, which changes gripper grip heights. - self.assertGreater(len(CATALOGUE_LOAD_NAMES), 100) - - -class CatalogueMatchesOpentronsTests(unittest.TestCase): - """The list is a copy, so prove it still matches what it was copied from. - - Skipped unless ``opentrons-shared-data`` is installed; PyLabRobot does not - depend on it. - """ - - def test_the_load_names_match_opentrons_own_definitions(self): - pytest.importorskip("opentrons_shared_data") - from opentrons_shared_data.labware import list_definitions - - theirs = {load_name for load_name, _version, _schema in list_definitions()} - self.assertEqual(CATALOGUE_LOAD_NAMES, theirs) - - -if __name__ == "__main__": - unittest.main() diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index 54ddc60be78..ba31eeb3810 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -2,7 +2,6 @@ from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, cast from pylabrobot.opentrons.flex_gripper import FlexGripper -from pylabrobot.opentrons.catalogue import is_catalogue_labware from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead from pylabrobot.opentrons.flex_wire import ROBOT_AXES, _require_robot_commands, slot_wire_location from pylabrobot.opentrons.labware_definitions import ( @@ -21,7 +20,7 @@ _OT_NAMESPACE = "opentrons" -# Catalogue definition revision per load name -- see _ot_catalogue_identity. +# Definition revision per load name -- see _ot_declared_identity. _OT_VERSION = 1 _OT_CATALOGUE_VERSIONS = { "appliedbiosystemsmicroamp_384_wellplate_40ul": 3, @@ -268,7 +267,7 @@ async def _ensure_labware_loaded( height and is honored on the FIRST load only; a cache hit (already loaded, or definition already uploaded) reuses the stored identity unchanged, and labware resolving to an official Opentrons load name - ignores it entirely (the catalogue definition owns the grip height). + ignores it entirely (the robot's own definition owns the grip height). Every discard is logged. """ name = getattr(resource, "name", str(resource)) @@ -291,7 +290,7 @@ async def _ensure_labware_loaded( ) try: - load_name, version = self._ot_catalogue_identity(resource) + load_name, version = self._ot_declared_identity(resource) except OpentronsError: # No official Opentrons definition: build one from the resource's PLR # geometry, upload it, and load by the uploaded definition's identity. @@ -303,7 +302,7 @@ async def _ensure_labware_loaded( _warn_grip_distance_discarded( name, grip_distance_from_top, - f"it loads the Opentrons catalogue definition '{load_name}', whose grip height is " + f"it loads the robot's own definition for '{load_name}', whose grip height is " "the vendor's to state (the robot grips at mid-height when it states none)", ) # The robot assigns the id. Proposing one here would only make the @@ -487,41 +486,37 @@ async def wait_for_duration(self, seconds: float) -> None: ) @staticmethod - def _ot_catalogue_identity(resource: Resource) -> Tuple[str, int]: - """Resolve a PLR resource to its Opentrons load name and definition version. - - Catalogue definitions are versioned per load name, and version 1 is the - OLDEST revision -- for much of the catalogue it predates the Flex and + def _ot_declared_identity(resource: Resource) -> Tuple[str, int]: + """The load name and version to load a resource by, if it declares one. + + Declaring ``ot_load_name`` is the whole rule: a resource that does one is + loaded by name, and a resource that does not gets a definition built from + its own geometry and uploaded. Whether the robot can actually resolve a + declared name is the ROBOT's to answer, not ours. It looks in the + definitions its own software shipped with AND in the custom labware a lab + has added to it, so no list on this side can be right for every robot. A + name it cannot resolve fails the ``loadLabware``, which surfaces where a + caller expects it: at the point the labware goes onto the deck. + + A definition is versioned per load name, and version 1 is the OLDEST + revision -- for much of Opentrons' own catalogue it predates the Flex and declares no gripper grip height, so the robot grips at the labware's mid-height rather than where the vendor says. ``_OT_CATALOGUE_VERSIONS`` - therefore pins the EARLIEST revision that states one. Earliest, not - newest: a robot only holds the revisions its own software shipped with, - and these have shipped since API 2.14, while their well geometry is - identical to version 1's -- so the bump changes the grip and nothing else. - Load names outside that map (every Flex tip rack among them, which ships - one revision) stay at 1. A resource can override the version for its own - load name by carrying an ``ot_version``. + therefore pins the EARLIEST revision that states one. Earliest, not newest: + a robot only holds the revisions its own software shipped with, and these + have shipped since API 2.14, while their well geometry is identical to + version 1's -- so the bump changes the grip and nothing else. Load names + outside that map (every Flex tip rack among them, which ships one revision) + stay at 1. A resource can override the version by carrying an ``ot_version``. """ declared = getattr(resource, "ot_load_name", None) - if declared is not None: - load_name = cast(str, declared) - if not is_catalogue_labware(load_name): - # Not an OpentronsError: that is the caller's signal to synthesize, which - # would hide a typo behind geometry the operator never asked for. - raise ValueError( - f"'{resource.name}' declares ot_load_name '{load_name}', which Opentrons' labware " - "catalogue does not define. Correct the load name, or drop the attribute to have a " - "definition built from the resource's own geometry." - ) - elif resource.model is not None and is_catalogue_labware(resource.model): - load_name = resource.model - else: + if declared is None: raise OpentronsError( - "Cannot determine Opentrons load name", - f"'{resource.name}' has no ot_load_name, and its model " - f"{resource.model!r} is not in Opentrons' catalogue.", + "No Opentrons load name declared", + f"'{resource.name}' carries no ot_load_name, so there is no name to load it by.", ) + load_name = cast(str, declared) version = getattr(resource, "ot_version", None) if version is None: version = _OT_CATALOGUE_VERSIONS.get(load_name, _OT_VERSION) diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py index 9529ab512dc..a4af35294b8 100644 --- a/pylabrobot/opentrons/flex_tests.py +++ b/pylabrobot/opentrons/flex_tests.py @@ -1199,29 +1199,34 @@ def test_a_trash_outside_a_trash_slot_is_refused(self): class CatalogueIdentityTests(unittest.TestCase): """How a PLR resource resolves to an Opentrons load name.""" - def test_a_resource_resolves_on_its_model(self): - # The model IS the load name on Opentrons' own factories, so nothing needs - # to be declared and the resource's instance name is irrelevant. + def test_a_declared_load_name_is_what_the_resource_loads_by(self): plate = cor_96_wellplate_360uL_Fb(name="anything at all") - plate.model = "corning_96_wellplate_360ul_flat" - load_name, _version = OpentronsFlex._ot_catalogue_identity(plate) + plate.ot_load_name = "corning_96_wellplate_360ul_flat" + load_name, version = OpentronsFlex._ot_declared_identity(plate) self.assertEqual(load_name, "corning_96_wellplate_360ul_flat") + self.assertEqual(version, 2) - def test_a_declared_load_name_outside_the_catalogue_is_a_hard_error(self): - # Must NOT raise OpentronsError: the caller treats that as "synthesize one", - # which would silently ship geometry the operator never asked for. + def test_a_declared_name_is_passed_through_rather_than_checked_against_a_list(self): + # The robot resolves against its own shipped definitions AND a lab's own + # uploads, so any list here would be wrong for somebody's robot. plate = cor_96_wellplate_360uL_Fb(name="plate") - plate.ot_load_name = "corning_96_wellplate_360ul_flatt" - with self.assertRaises(ValueError) as caught: - OpentronsFlex._ot_catalogue_identity(plate) - self.assertNotIsInstance(caught.exception, OpentronsError) - self.assertIn("corning_96_wellplate_360ul_flatt", str(caught.exception)) + plate.ot_load_name = "a_lab_uploaded_this_one_themselves" + load_name, version = OpentronsFlex._ot_declared_identity(plate) + self.assertEqual(load_name, "a_lab_uploaded_this_one_themselves") + self.assertEqual(version, 1) - def test_an_unresolvable_resource_asks_for_a_synthesized_definition(self): + def test_a_resource_declaring_nothing_asks_for_a_synthesized_definition(self): plate = cor_96_wellplate_360uL_Fb(name="plate") - plate.model = None with self.assertRaises(OpentronsError): - OpentronsFlex._ot_catalogue_identity(plate) + OpentronsFlex._ot_declared_identity(plate) + + def test_the_model_never_decides_the_load_name(self): + # A PLR model is not an Opentrons load name, and sending one that merely + # looks like a load name would load the wrong labware. Declaring is the rule. + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.model = "corning_96_wellplate_360ul_flat" + with self.assertRaises(OpentronsError): + OpentronsFlex._ot_declared_identity(plate) def test_the_instance_name_never_decides_the_load_name(self): # It is a user-chosen label, so naming a plate after a tip rack must not @@ -1229,7 +1234,7 @@ def test_the_instance_name_never_decides_the_load_name(self): plate = cor_96_wellplate_360uL_Fb(name="flex_96_tiprack_50ul") plate.model = None with self.assertRaises(OpentronsError): - OpentronsFlex._ot_catalogue_identity(plate) + OpentronsFlex._ot_declared_identity(plate) if __name__ == "__main__": From cbe884e430cf4e0a2dde23c070b9288cec1ec15d Mon Sep 17 00:00:00 2001 From: miike Date: Tue, 18 Aug 2026 00:11:52 -0400 Subject: [PATCH 33/36] Let the resource say which definition revision to load 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) --- pylabrobot/opentrons/flex.py | 39 ++++++------------- pylabrobot/opentrons/flex_tests.py | 12 +++++- .../opentrons/labware_definitions_tests.py | 9 ++--- pylabrobot/opentrons/pipette_defaults.py | 2 - pylabrobot/resources/opentrons/flex_plates.py | 14 +++++++ 5 files changed, 40 insertions(+), 36 deletions(-) diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py index ba31eeb3810..462d228eb68 100644 --- a/pylabrobot/opentrons/flex.py +++ b/pylabrobot/opentrons/flex.py @@ -20,23 +20,8 @@ _OT_NAMESPACE = "opentrons" -# Definition revision per load name -- see _ot_declared_identity. +# Every definition has a revision 1, so it is the only version safe to assume. _OT_VERSION = 1 -_OT_CATALOGUE_VERSIONS = { - "appliedbiosystemsmicroamp_384_wellplate_40ul": 3, - "armadillo_96_wellplate_200ul_pcr_full_skirt": 2, - "biorad_384_wellplate_50ul": 2, - "biorad_96_wellplate_200ul_pcr": 2, - "corning_12_wellplate_6.9ml_flat": 2, - "corning_384_wellplate_112ul_flat": 2, - "corning_48_wellplate_1.6ml_flat": 2, - "corning_96_wellplate_360ul_flat": 2, - "nest_1_reservoir_195ml": 2, - "nest_96_wellplate_100ul_pcr_full_skirt": 2, - "nest_96_wellplate_200ul_flat": 2, - "nest_96_wellplate_2ml_deep": 2, - "opentrons_96_wellplate_200ul_pcr_full_skirt": 2, -} # Discovered pipette channel count -> matching head class. _CHANNELS_TO_HEAD: Dict[int, Type[_FlexHead]] = { @@ -498,16 +483,16 @@ def _ot_declared_identity(resource: Resource) -> Tuple[str, int]: name it cannot resolve fails the ``loadLabware``, which surfaces where a caller expects it: at the point the labware goes onto the deck. - A definition is versioned per load name, and version 1 is the OLDEST - revision -- for much of Opentrons' own catalogue it predates the Flex and - declares no gripper grip height, so the robot grips at the labware's - mid-height rather than where the vendor says. ``_OT_CATALOGUE_VERSIONS`` - therefore pins the EARLIEST revision that states one. Earliest, not newest: - a robot only holds the revisions its own software shipped with, and these - have shipped since API 2.14, while their well geometry is identical to - version 1's -- so the bump changes the grip and nothing else. Load names - outside that map (every Flex tip rack among them, which ships one revision) - stay at 1. A resource can override the version by carrying an ``ot_version``. + ``ot_version`` picks the revision, and which revisions a robot holds is the + robot's business too, so this defaults to 1 rather than guessing higher. + Revision 1 is the one every definition has. It is also the OLDEST, and for + much of Opentrons' own catalogue it predates the Flex and states no gripper + grip height, so the robot grips at the labware's mid-height rather than + where the vendor says. A later revision usually fixes that while leaving the + well geometry alone, which is why the resources PyLabRobot ships for those + plates declare one. Naming a revision the robot does not hold fails the + load, so raising the default here would break older robots to grip better on + newer ones. """ declared = getattr(resource, "ot_load_name", None) if declared is None: @@ -519,7 +504,7 @@ def _ot_declared_identity(resource: Resource) -> Tuple[str, int]: load_name = cast(str, declared) version = getattr(resource, "ot_version", None) if version is None: - version = _OT_CATALOGUE_VERSIONS.get(load_name, _OT_VERSION) + version = _OT_VERSION return load_name, cast(int, version) async def _define_custom_labware( diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py index a4af35294b8..fa1fcd43197 100644 --- a/pylabrobot/opentrons/flex_tests.py +++ b/pylabrobot/opentrons/flex_tests.py @@ -1196,7 +1196,7 @@ def test_a_trash_outside_a_trash_slot_is_refused(self): asyncio.run(flex.stop()) -class CatalogueIdentityTests(unittest.TestCase): +class DeclaredIdentityTests(unittest.TestCase): """How a PLR resource resolves to an Opentrons load name.""" def test_a_declared_load_name_is_what_the_resource_loads_by(self): @@ -1204,7 +1204,15 @@ def test_a_declared_load_name_is_what_the_resource_loads_by(self): plate.ot_load_name = "corning_96_wellplate_360ul_flat" load_name, version = OpentronsFlex._ot_declared_identity(plate) self.assertEqual(load_name, "corning_96_wellplate_360ul_flat") - self.assertEqual(version, 2) + self.assertEqual(version, 1) + + def test_the_revision_is_the_resource_s_to_declare_too(self): + # Revision 1 is the only one every robot holds, so a caller who wants a + # later one (for its gripper grip height) has to say so. + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" + plate.ot_version = 2 + self.assertEqual(OpentronsFlex._ot_declared_identity(plate), ("corning_96_wellplate_360ul_flat", 2)) def test_a_declared_name_is_passed_through_rather_than_checked_against_a_list(self): # The robot resolves against its own shipped definitions AND a lab's own diff --git a/pylabrobot/opentrons/labware_definitions_tests.py b/pylabrobot/opentrons/labware_definitions_tests.py index e305efd0319..3c38fb50aa5 100644 --- a/pylabrobot/opentrons/labware_definitions_tests.py +++ b/pylabrobot/opentrons/labware_definitions_tests.py @@ -846,18 +846,17 @@ def test_official_name_labware_loads_with_zero_uploads(self): params = load_cmds[0]["params"] self.assertEqual(params["namespace"], "opentrons") self.assertEqual(params["loadName"], "corning_96_wellplate_360ul_flat") - # Revision 2, not 1: version 1 of this plate declares no gripper grip - # height, and 2 adds it without moving a single well. - self.assertEqual(params["version"], 2) + # This resource declares no ot_version, and revision 1 is the only one + # every robot is guaranteed to hold, so that is what goes out. + self.assertEqual(params["version"], 1) finally: asyncio.run(flex.stop()) - def test_catalogue_version_falls_back_to_1_and_a_resource_can_override_it(self): + def test_version_falls_back_to_1_and_a_resource_can_override_it(self): flex, transport = _flex_with_transport() asyncio.run(flex.setup()) try: rack = _tip_rack() - # Flex tip racks ship one revision, so they stay at 1. rack.ot_load_name = "opentrons_flex_96_tiprack_50ul" # type: ignore[attr-defined] flex.deck.assign_child_at_slot(rack, "C1") asyncio.run(flex._ensure_labware_loaded(rack)) diff --git a/pylabrobot/opentrons/pipette_defaults.py b/pylabrobot/opentrons/pipette_defaults.py index 10628ff68f5..4e54adbbcdc 100644 --- a/pylabrobot/opentrons/pipette_defaults.py +++ b/pylabrobot/opentrons/pipette_defaults.py @@ -21,8 +21,6 @@ from typing import Dict, NamedTuple, Tuple -SHARED_DATA_VERSION = "9.1.2" - class FlowRates(NamedTuple): """Default aspirate, dispense and blow-out rates in uL/s.""" diff --git a/pylabrobot/resources/opentrons/flex_plates.py b/pylabrobot/resources/opentrons/flex_plates.py index 454e824f0ba..d858cb1ceaf 100644 --- a/pylabrobot/resources/opentrons/flex_plates.py +++ b/pylabrobot/resources/opentrons/flex_plates.py @@ -44,6 +44,7 @@ def flex_plate( name: str, num_wells: int = 96, well_volume: float = 360.0, + version: int = 1, ) -> Plate: """Build a nominal, name-based Flex plate. @@ -56,6 +57,13 @@ def flex_plate( num_wells: number of wells; only the standard 96-well SBS grid (8 rows x 12 columns) is supported today. well_volume: nominal per-well max volume (uL), used for volume tracking. + version: which revision of that definition to load. Stored as + ``ot_version``. Revision 1 is the one every definition has, and the only + one safe to assume, but for much of Opentrons' catalogue it predates the + Flex and states no gripper grip height, so the robot grips at the plate's + mid-height. A later revision usually fixes that and leaves the well + geometry alone. Naming a revision the robot does not hold fails the load, + so raise this only for a plate you know the robot has. Returns: A PLR ``Plate`` with a nominal 96-well grid (see module docstring) and @@ -92,6 +100,7 @@ def flex_plate( # Flex-specific: Opentrons labware load name for JIT loading. The robot # resolves the real geometry from this name; PLR's grid above is nominal. plate.ot_load_name = load_name # type: ignore[attr-defined] + plate.ot_version = version # type: ignore[attr-defined] return plate @@ -102,10 +111,15 @@ def corning_96_wellplate_360ul_flat(name: str) -> Plate: Convenience wrapper around :func:`flex_plate` for ``"corning_96_wellplate_360ul_flat"``, the plate used in the Flex hello-world notebook. + + Loads revision 2, the earliest that states a gripper grip height (12.2 mm); + revision 1 states none and the robot grips at the plate's mid-height instead. + The well geometry is the same in both. """ return flex_plate( load_name="corning_96_wellplate_360ul_flat", name=name, num_wells=96, well_volume=360.0, + version=2, ) From b34c88126584c26769703b6dd4c6803f25c53eb4 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Tue, 18 Aug 2026 18:25:38 +0200 Subject: [PATCH 34/36] Unify FlexHead8 around per-call use_channels, and always travel above 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 --- .../opentrons/flex/use_channels_smoke.ipynb | 208 +++++++ pylabrobot/opentrons/checks.py | 129 ++++ pylabrobot/opentrons/envelope.py | 169 ++++++ pylabrobot/opentrons/flex_envelope_tests.py | 180 ++++++ pylabrobot/opentrons/flex_head.py | 558 +++++++++++++++++- .../opentrons/flex_head_use_channels_tests.py | 402 +++++++++++++ pylabrobot/opentrons/flex_motion_tests.py | 5 +- 7 files changed, 1626 insertions(+), 25 deletions(-) create mode 100644 docs/user_guide/opentrons/flex/use_channels_smoke.ipynb create mode 100644 pylabrobot/opentrons/checks.py create mode 100644 pylabrobot/opentrons/envelope.py create mode 100644 pylabrobot/opentrons/flex_envelope_tests.py create mode 100644 pylabrobot/opentrons/flex_head_use_channels_tests.py diff --git a/docs/user_guide/opentrons/flex/use_channels_smoke.ipynb b/docs/user_guide/opentrons/flex/use_channels_smoke.ipynb new file mode 100644 index 00000000000..de7d218365e --- /dev/null +++ b/docs/user_guide/opentrons/flex/use_channels_smoke.ipynb @@ -0,0 +1,208 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title", + "metadata": {}, + "source": [ + "# Flex `use_channels` redesign — hardware smoke test\n", + "\n", + "Drives the **new unified surface** on a real Flex: PLR-native `plate.column(c)` column\n", + "ops, single-tip `pick_up_tips(spot, use_channels=[7])` (the H1 front nozzle), and a\n", + "front-anchored **partial** column.\n", + "\n", + "**Structure:** run the **setup** cells top-to-bottom once (deck → connect → home → trash).\n", + "After that, the three **pipetting** cells (full column, single H1 tip, front-4 partial) are\n", + "each self-contained — they pick their own tips, pipette, discard, and return the gantry to\n", + "home — so you can run them in **any order**. Each uses a different tip-rack column, so they\n", + "don't collide.\n", + "\n", + "**This moves the robot.** Tip rack in **D1**, plate in **B1**, rows **A and C empty** (see\n", + "the deck rule below). Keep the e-stop in reach. Put liquid in the plate for real draws —\n", + "otherwise it aspirates air, fine for a motion check." + ] + }, + { + "cell_type": "markdown", + "id": "deck-rule", + "metadata": {}, + "source": [ + "## Deck-layout rule for single / partial tip pickup\n", + "\n", + "In a single- or partial-nozzle config the idle nozzles trail off one end of the head, so\n", + "keep the adjacent rows clear:\n", + "\n", + "| pickup nozzle | idle nozzles trail toward | labware allowed in | keep EMPTY |\n", + "|---|---|---|---|\n", + "| **H1** (front) | rear | rows **B** and **D** | rows **A** and **C** |\n", + "| **A1** (rear) | front | rows **A** and **C** | rows **B** and **D** |\n", + "\n", + "This notebook uses the **H1** convention: tip rack in **D1**, plate in **B1**, rows A and C\n", + "empty." + ] + }, + { + "cell_type": "markdown", + "id": "setup-header", + "metadata": {}, + "source": [ + "## Setup — run these once, top to bottom" + ] + }, + { + "cell_type": "markdown", + "id": "en8-note", + "metadata": {}, + "source": "Set `FLEX_HOST` below to your Flex's IP or USB address (the same address the Opentrons App uses; port 31950). On a link-local connection (`169.254.x.x`) you may need to wake the interface first, e.g.:\n\n```bash\nping -c2 -b 169.254.255.255 # wake the link, then plain HTTP routes\n```" + }, + { + "cell_type": "code", + "id": "imports", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "FLEX_HOST = \"169.254.1.1\" # <-- SET to your Flex's IP / USB address\n\nfrom pylabrobot.opentrons import FlexHead8, OpentronsFlex\nfrom pylabrobot.resources.opentrons import (\n FlexDeck,\n corning_96_wellplate_360ul_flat,\n flex_96_tiprack_50ul,\n)" + }, + { + "cell_type": "code", + "id": "deck", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "deck = FlexDeck()\ntip_rack = flex_96_tiprack_50ul(name=\"tips_01\")\nplate = corning_96_wellplate_360ul_flat(name=\"plate_01\")\n# H1 convention: labware only in rows B and D; rows A and C empty (see the deck rule).\ndeck.assign_child_at_slot(tip_rack, \"D1\")\ndeck.assign_child_at_slot(plate, \"B1\")\n# The trash is a deck feature; grab its handle now that the slots are populated.\ntrash = deck.get_trash_area()" + }, + { + "cell_type": "code", + "id": "connect", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "flex = OpentronsFlex(deck, host=FLEX_HOST)\n", + "await flex.setup()\n", + "\n", + "print(\"api_version:\", flex.api_version)\n", + "print(\"robot_model:\", flex.robot_model)\n", + "head = flex.left or flex.right\n", + "assert isinstance(head, FlexHead8), f\"expected FlexHead8, got {type(head)}\"\n", + "print(\"head:\", head, \"| mounted tips:\", head.get_mounted_tips())" + ] + }, + { + "cell_type": "code", + "id": "home-trash", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Finish setup: home the gantry. After this, the pipetting cells below are\n# self-contained and can be run in any order.\nawait flex.home()" + }, + { + "cell_type": "markdown", + "id": "pipetting-header", + "metadata": {}, + "source": [ + "## Pipetting — run these in any order\n", + "\n", + "Each cell picks its own tips (a different column each), pipettes, discards to the trash,\n", + "and homes. Re-running a cell tries to pick the same tips again, so run each once per pass." + ] + }, + { + "cell_type": "markdown", + "id": "column-md", + "metadata": {}, + "source": [ + "### Full column — `plate.column(c)` (ALL layout)" + ] + }, + { + "cell_type": "code", + "id": "column-cell", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# PLR-native: pass the list of wells, not (rack, column).\n", + "await head.pick_up_tips(tip_rack.column(0))\n", + "print(\"mounted:\", sum(1 for t in head.get_mounted_tips() if t is not None), \"tips\")\n", + "await head.aspirate(plate.column(0), volume=50)\n", + "await head.dispense(plate.column(0), volume=50)\n", + "await head.discard_tips(trash)\n", + "await flex.home() # park at home after the trash drop\n", + "print(\"full column done; mounted:\", sum(1 for t in head.get_mounted_tips() if t is not None))" + ] + }, + { + "cell_type": "markdown", + "id": "single-md", + "metadata": {}, + "source": [ + "### Single H1 tip — `use_channels=[7]` (SINGLE layout)\n", + "\n", + "`use_channels=[7]` configures the **H1** front nozzle; the single-nozzle liquid op is\n", + "guarded by the same idle-nozzle clearance check as pickup. Plate is in **B1** with the row\n", + "behind it (**A1**) empty, so H1 clears — the aspirate/dispense is allowed." + ] + }, + { + "cell_type": "code", + "id": "single-cell", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Picks from column 1 (well A2); lands the tip on channel 7.\n", + "await head.pick_up_tips(tip_rack.get_item(\"A2\"), use_channels=[7])\n", + "await head.aspirate(plate.get_item(\"A1\"), volume=20)\n", + "await head.dispense(plate.get_item(\"A1\"), volume=20)\n", + "await head.drop_single_tip(trash)\n", + "await flex.home() # park at home after the trash drop\n", + "print(\"single done; mounted:\", sum(1 for t in head.get_mounted_tips() if t is not None))" + ] + }, + { + "cell_type": "markdown", + "id": "partial-md", + "metadata": {}, + "source": "### Partial column — `use_channels=[4,5,6,7]` (QUADRANT layout)\n\nFront nozzles **E,F,G,H** (channels 4-7) pick the **first 4 rows (A-D)** of column 4. This emits Opentrons' own QUADRANT config (`primaryNozzle = frontRightNozzle = \"H1\"`, `backLeftNozzle = \"E1\"`), anchored at the D-row tip.\n\n**Why the first 4 and not the last 4:** all 8 nozzles descend to the same height, so the 4 *inactive* nozzles must be over empty space. Picking the first 4 rows puts the inactive rear nozzles **off the back of the rack** (behind row A); picking `[4:8]` (rows E-H) would put them over the A-D tips still in the column — a crash. The idle nozzles also trail into the empty **C1** row (rack in D1), per the deck rule.\n\n**Bench-first:** this exact partial-column config has not been run on hardware — watch it, e-stop ready." + }, + { + "cell_type": "code", + "id": "partial-cell", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Front nozzles E,F,G,H (channels 4-7) pick the FIRST 4 rows (A-D) of column 4.\n# Picking the first 4 keeps the inactive rear nozzles OFF the back of the rack (behind\n# row A), clear of the E-H tips still in that column. (Picking the LAST 4 [4:8] would put\n# the inactive nozzles over the A-D tips -> collision.)\nawait head.pick_up_tips(tip_rack.column(3)[0:4], use_channels=[4, 5, 6, 7])\nprint(\"partial channels with tips:\",\n [i for i, t in enumerate(head.get_mounted_tips()) if t is not None])\nawait head.discard_tips(trash)\nawait flex.home() # park at home after the trash drop\nprint(\"partial done; mounted:\", sum(1 for t in head.get_mounted_tips() if t is not None))" + }, + { + "cell_type": "markdown", + "id": "teardown-md", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "id": "stop", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await flex.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/pylabrobot/opentrons/checks.py b/pylabrobot/opentrons/checks.py new file mode 100644 index 00000000000..02d863d6305 --- /dev/null +++ b/pylabrobot/opentrons/checks.py @@ -0,0 +1,129 @@ +"""Native pre-dispatch verification checks for the Opentrons Flex backend. + +The Flex backend drives the robot-server Protocol Engine over HTTP, which runs its +own analysis pass. These checks rebuild the equivalent verification IN PLR — the +Hamilton STAR pattern (``hamilton/STAR_backend.py``) — so a call is refused *before* +dispatch, and PLR owns the check rather than delegating it to the vendor. +""" + +from typing import List, Optional + +from pylabrobot.opentrons.envelope import ( + FLEX_8CH_NOZZLE_A1_Y, + FLEX_8CH_NOZZLE_H1_Y as FLEX_8CH_NOZZLE_H1_Y, # re-exported for callers importing from checks + FLEX_ENVELOPE, + nozzle_offset_y, +) +from pylabrobot.opentrons.robot import PipetteInfo +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.errors import NoLocationError +from pylabrobot.resources.tip import Tip + +# OT-3 DEFAULT_GENERAL_ARC_Z_MARGIN (motion_planning/waypoints.py:12). +_DEFAULT_ARC_MARGIN = 10.0 + +# Every Flex tip rack (flex_96_tiprack_50ul / 200ul / 1000ul, and the filter rack) +# models to this z (tips included). The travel plane never drops below a tip rack, +# even on a deck the model shows as empty -- a rack physically present but not in +# the model (or not yet loaded on the robot) must still be cleared. +_FLEX_TIPRACK_HEIGHT = 99.0 + + +def check_volume_bounds(volume: float, pipette: PipetteInfo, tip: Optional[Tip] = None) -> None: + """Refuse a liquid-handling volume the pipette or tip cannot handle. + + Mirrors STAR's firmware-range asserts (``STAR_backend.py:4161``): the pipette has an + accurate ``[min_volume, max_volume]`` range and a mounted tip has a ``maximal_volume`` + capacity; a requested volume outside those is refused before dispatch. + + Args: + volume: the requested aspirate/dispense volume, in µL. + pipette: the mounted pipette (its ``min_volume``/``max_volume`` bound the range). + tip: the tip on the channel, if known (its ``maximal_volume`` is the capacity). + When ``None`` the tip-capacity check is skipped. + + Raises: + ValueError: if ``volume`` exceeds the pipette max, is below the pipette min (for a + non-zero volume), or exceeds the tip capacity. + """ + if volume > pipette.max_volume: + raise ValueError( + f"volume {volume}µL exceeds pipette max {pipette.max_volume}µL ({pipette.pipette_name})" + ) + if 0 < volume < pipette.min_volume: + raise ValueError( + f"volume {volume}µL is below pipette min {pipette.min_volume}µL ({pipette.pipette_name})" + ) + if tip is not None and volume > tip.maximal_volume: + raise ValueError(f"volume {volume}µL exceeds tip capacity {tip.maximal_volume}µL") + + +def traversal_z(deck: Resource, arc_margin: float = _DEFAULT_ARC_MARGIN) -> float: + """The tip-safe travel plane (tip-end / critical-point frame), from the resource model. + + The higher of: the tallest labware top on the deck plus ``arc_margin``, and an + unconditional tip-rack floor (``_FLEX_TIPRACK_HEIGHT`` + ``arc_margin``). The floor + means travel is always above a tip rack even when the deck model shows none -- a + rack physically present but unmodeled, or not yet loaded on the robot, is still + cleared. Pure function of the resource tree; no server round-trip. + """ + tops = [] + for child in deck.get_all_children(): + try: + tops.append(child.get_absolute_location(z="t").z) + except NoLocationError: + continue + computed = (max(tops) if tops else 0.0) + arc_margin + return max(computed, _FLEX_TIPRACK_HEIGHT + arc_margin) + + +def check_position_legal(coordinate: Coordinate, deck: Optional[Resource] = None) -> None: + """Refuse a nozzle target outside the Flex's addressable deck extents (coarse bound). + + Delegates to :data:`FLEX_ENVELOPE`. ``deck`` is accepted for call-site compatibility + but the envelope is the machine's, not the deck resource's. + """ + FLEX_ENVELOPE.check_bounds(coordinate) + + +def check_eight_channel_extents( + nozzle_target: Coordinate, + partial: bool, + active_nozzle_offset_y: float = FLEX_8CH_NOZZLE_A1_Y, +) -> None: + """Refuse an 8-channel single/partial move whose head casing exceeds the reach caps. + + Delegates to :meth:`FlexEnvelope.check_eight_channel`. + """ + FLEX_ENVELOPE.check_eight_channel(nozzle_target, partial, active_nozzle_offset_y) + + +def check_target_on_deck( + resource: Resource, + deck: Optional[Resource] = None, + pipette: Optional[PipetteInfo] = None, + use_channels: Optional[List[int]] = None, +) -> None: + """Refuse if ``resource``'s absolute location is unreachable for the pipette. + + Delegates to :meth:`FlexEnvelope.check_point`. A no-op when the resource has no + location yet (caught by the parent/labware checks, not here). + + For an 8-channel single/partial move the *active nozzle* sets the rear reach cap, so + the reference channel (``use_channels[0]``, paired with ``resource``) is threaded + through as its nozzle Y offset — a front (H1) nozzle at a rear row is refused where a + back (A1) nozzle would be allowed. Without this the permissive A1 default would let an + H1 partial move drive the head casing past the rear cap into the back panel. + """ + try: + coordinate = resource.get_absolute_location(x="c", y="c", z="b") + except NoLocationError: + return + channels = pipette.channels if pipette is not None else 1 + n_active = len(use_channels) if use_channels is not None else channels + offset = ( + nozzle_offset_y(use_channels[0]) if channels == 8 and use_channels else FLEX_8CH_NOZZLE_A1_Y + ) + FLEX_ENVELOPE.check_point( + coordinate, channels=channels, n_active=n_active, active_nozzle_offset_y=offset + ) diff --git a/pylabrobot/opentrons/envelope.py b/pylabrobot/opentrons/envelope.py new file mode 100644 index 00000000000..8011cb8818c --- /dev/null +++ b/pylabrobot/opentrons/envelope.py @@ -0,0 +1,169 @@ +"""The grounded OT-3 (Flex) operating envelope — one source of truth for reach. + +Every reachability cap the Flex native checks use is a *derived property* of the +primitive OT-3 values below, each cited to its source in the installed ``opentrons`` +/ ``opentrons_shared_data`` package. Deriving the caps (rather than hardcoding e.g. +324.38) makes the source-to-cap relationship a rule in code that cannot silently +drift when a primitive changes -- exactly the drift the ``padding_rear`` value saw +between opentrons 8.3.0 (-177.42) and 8.8.1 (-169.42). +""" + +from dataclasses import dataclass +from typing import List, Tuple, TYPE_CHECKING + +from pylabrobot.resources import Coordinate + +if TYPE_CHECKING: + from pylabrobot.resources.opentrons.flex_deck import FlexDeck + +# 8-channel single-config active-nozzle Y offsets (9 mm pitch, A1 back .. H1 front), +# relative to the mount reference. pipette/.../eight_channel/p1000/3_5.json. +FLEX_8CH_NOZZLE_A1_Y = -16.0 +FLEX_8CH_NOZZLE_H1_Y = -79.0 +FLEX_8CH_NOZZLE_PITCH_Y = -9.0 # 9 mm pitch between adjacent nozzles (channel 0=A .. 7=H) + + +def nozzle_offset_y(channel: int) -> float: + """Y offset (from the mount reference) of an 8-channel nozzle by channel index. + + Channel 0 = A1 (rearmost, −16), channel 7 = H1 (frontmost, −79); linear at the 9 mm + pitch. The frontmost active nozzle (largest ``|offset|``) drives the head casing + furthest rearward, so this is what binds the rear reach cap for a single/partial move. + """ + return FLEX_8CH_NOZZLE_A1_Y + FLEX_8CH_NOZZLE_PITCH_Y * channel + + +@dataclass(frozen=True) +class FlexEnvelope: + """The OT-3 operating envelope as primitive values + derived caps. + + Frozen so an instance is a value object; use ``dataclasses.replace`` to explore a + perturbed envelope (the grounding test does this). + """ + + # --- primitives (grounded; do not pre-compute anything here) --- + deck_extent_x: float = 477.2 # robot/definitions/1/ot3.json extents[0] + deck_extent_y: float = 493.8 # ot3.json extents[1] (rear/home limit) + z_max: float = 300.0 # ot3controller.py axis_bounds Z_L/Z_R + carriage_offset_x: float = 477.20 # defaults_ot3.py:72 DEFAULT_CARRIAGE_OFFSET + carriage_offset_y: float = 493.8 + carriage_offset_z: float = 253.475 + padding_rear: float = -169.42 # ot3.json paddingOffsets.rear @ 8.8.1 (8.3.0 had -177.42) + padding_front: float = 51.8 # paddingOffsets.front + padding_left: float = 31.88 # paddingOffsets.leftSide + padding_right: float = -80.32 # paddingOffsets.rightSide + casing_depth_y: float = 95.0 # 8-channel head casing depth (mount ref .. front corner) + + # --- derived caps (never literals) --- + @property + def rear_cap_y(self) -> float: + """8-channel rear reach cap: deck extent plus the (negative) rear padding = 324.38.""" + return self.deck_extent_y + self.padding_rear + + @property + def front_cap_y(self) -> float: + """8-channel front reach cap = front padding = 51.8.""" + return self.padding_front + + @property + def home_xy(self) -> Tuple[float, float]: + """Home carriage reference in the deck frame (rear-right corner) = (477.2, 493.8).""" + return (self.carriage_offset_x, self.carriage_offset_y) + + def reach_y(self, active_nozzle_offset_y: float) -> Tuple[float, float]: + """Per-config nozzle-Y reach for an 8-channel single/partial move: ``(min_y, max_y)``. + + The 95 mm casing binds the reach: the casing front corner (mount_ref − casing_depth) + must stay <= rear_cap_y, and the casing back corner (mount_ref) must stay >= front_cap_y, + where mount_ref = nozzle_y − active_nozzle_offset_y. Solving for nozzle_y gives A1 (−16) + -> max 403.38, H1 (−79) -> max 340.38. + """ + max_y = self.rear_cap_y + self.casing_depth_y + active_nozzle_offset_y + min_y = self.front_cap_y + active_nozzle_offset_y + return (min_y, max_y) + + def check_bounds(self, coordinate: Coordinate) -> None: + """Refuse a target outside the coarse addressable deck envelope (per-axis).""" + if not 0.0 <= coordinate.x <= self.deck_extent_x: + raise ValueError(f"target x {coordinate.x:.1f} outside Flex reach [0, {self.deck_extent_x}]") + if not 0.0 <= coordinate.y <= self.deck_extent_y: + raise ValueError(f"target y {coordinate.y:.1f} outside Flex reach [0, {self.deck_extent_y}]") + if not 0.0 <= coordinate.z <= self.z_max: + raise ValueError(f"target z {coordinate.z:.1f} outside Flex Z [0, {self.z_max}]") + + def check_eight_channel( + self, + nozzle_target: Coordinate, + partial: bool, + active_nozzle_offset_y: float = FLEX_8CH_NOZZLE_A1_Y, + ) -> None: + """Refuse an 8-channel single/partial move whose 95 mm casing exceeds the reach caps. + + Full column (``partial=False``) is not extent-checked (mirrors Opentrons' + ``_is_within_pipette_extents`` 8-ch branch, which only bounds single/partial configs). + """ + if not partial: + return + mount_ref_y = nozzle_target.y - active_nozzle_offset_y + casing_front_y = mount_ref_y - self.casing_depth_y + casing_back_y = mount_ref_y + if casing_front_y > self.rear_cap_y: + _, reach = self.reach_y(active_nozzle_offset_y) + raise ValueError( + f"8-channel single/partial move: nozzle Y {nozzle_target.y:.1f} drives the casing to " + f"Y {casing_front_y:.1f} > rear cap {self.rear_cap_y:.2f} — would hit the back panel " + f"(rear reach for this nozzle is Y <= {reach:.2f})" + ) + if casing_back_y < self.front_cap_y: + raise ValueError( + f"8-channel single/partial move: nozzle Y {nozzle_target.y:.1f} puts the casing at " + f"Y {casing_back_y:.1f} < front cap {self.front_cap_y:.2f}" + ) + + def check_point( + self, + coordinate: Coordinate, + channels: int, + n_active: int, + active_nozzle_offset_y: float = FLEX_8CH_NOZZLE_A1_Y, + ) -> None: + """The single check surface: coarse bounds, plus the 8-ch casing caps when partial.""" + self.check_bounds(coordinate) + if channels == 8 and n_active < 8: + self.check_eight_channel( + coordinate, partial=True, active_nozzle_offset_y=active_nozzle_offset_y + ) + + def unreachable_slots( + self, + deck: "FlexDeck", + channels: int, + n_active: int, + active_nozzle_offset_y: float = FLEX_8CH_NOZZLE_A1_Y, + ) -> List[str]: + """The standard slots (A1–D3) whose center this pipette config cannot reach. + + Combines the deck-grid geometry (slot centers) with the per-config reach caps: a + slot is unreachable when :meth:`check_point` refuses its center. Staging slots + (A4–D4) are excluded — they are gripper-only storage, not pipette targets. This is + the executable form of "a labware assigned to a rear slot is not necessarily + reachable by this pipette config." + """ + from pylabrobot.resources.opentrons.flex_deck import ( + SLOT_DEPTH, + SLOT_LOCATIONS, + SLOT_WIDTH, + ) + + out: List[str] = [] + for slot in sorted(SLOT_LOCATIONS): + loc = deck.get_slot_location(slot) + center = Coordinate(loc["x"] + SLOT_WIDTH / 2, loc["y"] + SLOT_DEPTH / 2, loc["z"]) + try: + self.check_point(center, channels, n_active, active_nozzle_offset_y) + except ValueError: + out.append(slot) + return out + + +FLEX_ENVELOPE = FlexEnvelope() diff --git a/pylabrobot/opentrons/flex_envelope_tests.py b/pylabrobot/opentrons/flex_envelope_tests.py new file mode 100644 index 00000000000..8f8b1c676be --- /dev/null +++ b/pylabrobot/opentrons/flex_envelope_tests.py @@ -0,0 +1,180 @@ +"""Tests for the native Flex operating envelope + its wiring into the heads. + +``envelope.py`` derives every reach cap from grounded OT-3 primitives; ``checks.py`` +is the pre-dispatch verification surface. These tests pin the derived caps to the +opentrons **8.8.1** shared-data the robot actually runs, and assert the computed +traversal plane replaces the hardcoded magic number. +""" + +import asyncio +import unittest +from typing import List, Tuple + +from pylabrobot.opentrons.checks import traversal_z +from pylabrobot.opentrons.envelope import FLEX_ENVELOPE +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead8 +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import cor_96_wellplate_360uL_Fb +from pylabrobot.resources.opentrons.flex_deck import FlexDeck + + +def _flex_head8() -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead8]: + transport = ChatterboxTransport(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, transport, head + + +def _commands_of(transport: ChatterboxTransport, command_type: str) -> List[dict]: + return [c for c in transport.commands if c["commandType"] == command_type] + + +class TestRearCapGroundedTo881(unittest.TestCase): + """The 8-channel rear reach cap is ``deck_extent_y + paddingOffsets.rear``. + + The rear padding is version-specific: opentrons 8.3.0 = -177.42, but the robot + runs **8.8.1** where it is -169.42, giving a rear cap of 324.38. The stale + 8.3.0 value (316.38) must not be what the envelope carries. + """ + + def test_rear_cap_matches_881_shared_data(self): + self.assertAlmostEqual(FLEX_ENVELOPE.padding_rear, -169.42) + self.assertAlmostEqual(FLEX_ENVELOPE.rear_cap_y, 324.38) + + +class TestUnconditionalTiprackFloor(unittest.TestCase): + """The travel plane never drops below a tip rack (99 + 10 margin = 109), even on + a deck the model shows as empty or holding only short labware -- a rack that is + present but unmodeled must still be cleared.""" + + def test_empty_deck_still_clears_a_tiprack(self): + self.assertAlmostEqual(traversal_z(FlexDeck()), 109.0) + + def test_short_labware_does_not_lower_the_floor(self): + deck = FlexDeck() + plate = cor_96_wellplate_360uL_Fb(name="plate") # ~14 mm tall, well below a rack + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + deck.assign_child_at_slot(plate, "C2") + self.assertAlmostEqual(traversal_z(deck), 109.0) + + +class TestComputedTraversalPlane(unittest.TestCase): + """A lateral jog defaults its ``minimumZHeight`` to the COMPUTED tip-safe plane + (tallest labware top + arc margin), not a hardcoded 120.0 magic number.""" + + def test_move_to_uses_computed_traversal_not_120(self): + flex, transport, head = _flex_head8() + try: + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + + expected = traversal_z(flex.deck) + self.assertNotAlmostEqual(expected, 120.0, msg="test needs labware whose plane != 120") + + asyncio.run(head.move_to(x=100.0, y=100.0, z=50.0)) + + move_cmds = _commands_of(transport, "moveToCoordinates") + self.assertEqual(len(move_cmds), 1) + self.assertAlmostEqual(move_cmds[0]["params"]["minimumZHeight"], expected) + finally: + asyncio.run(flex.stop()) + + +class TestTrashDropArcsHighEnough(unittest.TestCase): + """The move to the trash after a dispense must arc at the computed traversal + plane, not the engine's default -- otherwise it can travel too low and clip + labware the robot was never told about.""" + + def test_discard_tips_move_carries_computed_minimum_z_height(self): + from pylabrobot.resources import cor_96_wellplate_360uL_Fb + from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + trash = flex.deck.get_trash_area() + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.discard_tips(trash)) + + move = next( + c for c in transport.commands if c["commandType"] == "moveToAddressableAreaForDropTip" + ) + self.assertAlmostEqual(move["params"]["minimumZHeight"], traversal_z(flex.deck)) + finally: + asyncio.run(flex.stop()) + + +class TestBetweenSlotArcGuard(unittest.TestCase): + """A pipetting move that crosses to a different slot is prefixed with a safe + high moveToWell (>= the tip-rack floor); a move within the same labware is not.""" + + def setUp(self): + from pylabrobot.resources import set_tip_tracking, set_volume_tracking + + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + from pylabrobot.resources import set_tip_tracking, set_volume_tracking + + set_tip_tracking(False) + set_volume_tracking(False) + + def _setup(self): + from pylabrobot.resources import cor_96_wellplate_360uL_Fb + from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for w in plate.get_all_items(): + w.tracker.set_volume(100.0) + return flex, transport, head, rack, plate + + def _move_to_wells(self, transport): + return [c for c in transport.commands if c["commandType"] == "moveToWell"] + + def test_crossing_to_a_new_slot_arcs_high_first(self): + flex, transport, head, rack, plate = self._setup() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) # over the rack (C1) + before = len(self._move_to_wells(transport)) + asyncio.run(head.aspirate(plate.column(0), volume=50)) # -> plate (C2), a new slot + + moves = self._move_to_wells(transport) + self.assertEqual( + len(moves), before + 1, "one safe move should precede the cross-slot aspirate" + ) + self.assertAlmostEqual(moves[-1]["params"]["minimumZHeight"], traversal_z(flex.deck)) + # the safe move comes immediately before the aspirate + types = [c["commandType"] for c in transport.commands] + self.assertEqual(types[types.index("aspirate") - 1], "moveToWell") + finally: + asyncio.run(flex.stop()) + + def test_moving_within_the_same_labware_does_not_arc_high(self): + flex, transport, head, rack, plate = self._setup() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate.column(0), volume=50)) # cross-slot -> one safe move + n = len(self._move_to_wells(transport)) + asyncio.run(head.dispense(plate.column(1), volume=50)) # same plate -> no new safe move + self.assertEqual(len(self._move_to_wells(transport)), n, "within-slot move must not arc high") + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 69691763f1c..849db3ec8b3 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -24,8 +24,9 @@ """ import logging -from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional, Sequence, Tuple, Union, cast +from pylabrobot.opentrons.checks import traversal_z from pylabrobot.opentrons.flex_wire import UNTESTED_HARDWARE_WARNING from pylabrobot.opentrons.labware_definitions import container_footprint from pylabrobot.opentrons.pipette_defaults import FlowRates, flow_rates @@ -84,6 +85,40 @@ def __init__( self.max_volume = max_volume self._channel_tips: List[Optional[Tip]] = [None] * channels self._untested_hardware_warned: bool = False + # The labware id the pipette last pipetted over, or None when its position is + # unknown (start of run, after a jog or a trash drop). Used to arc high only + # when a pipetting move crosses to a different slot -- see _travel_guard. + self._current_labware_id: Optional[str] = None + + async def _travel_guard(self, params: Dict[str, Any]) -> None: + """Arc to a new slot's well at the safe travel plane before pipetting there. + + A pipetting move to a well on a DIFFERENT labware than the pipette last + worked over crosses deck slots, so it is prefixed with a ``moveToWell`` at + the computed traversal plane (never below a tip rack, see + ``checks.traversal_z``) -- the ``aspirate``/``dispense``/``pickUpTip``/ + ``dropTip`` commands cannot carry a ``minimumZHeight`` themselves. A move + WITHIN the same labware never crosses another slot, so it is left to the + engine's own low arc. ``minimumZHeight`` is a mid-travel floor only; it does + not clamp the descent, so the following op still reaches the well. + """ + labware_id = params.get("labwareId") + well_name = params.get("wellName") + if not isinstance(labware_id, str) or not isinstance(well_name, str): + return + if labware_id == self._current_labware_id: + return + await self._execute( + "moveToWell", + { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "wellLocation": {"origin": "top", "offset": {"x": 0, "y": 0, "z": 0}}, + "minimumZHeight": self._traversal_height(), + }, + ) + self._current_labware_id = labware_id def _warn_untested_hardware(self, op: str) -> None: """Log a one-time notice when an op has no real-hardware verification. @@ -224,6 +259,7 @@ async def _execute_pickup( ``_channel_tips`` AFTER this returns successfully. """ try: + await self._travel_guard(params) await self._execute(command_type, params) except Exception: for tracker in staged_trackers: @@ -254,6 +290,7 @@ async def _execute_liquid_op( calling this. """ try: + await self._travel_guard(params) await self._execute(command_type, params) except Exception: for tracker in staged_trackers: @@ -321,6 +358,11 @@ async def _execute_trash_drop(self, trash: Trash) -> None: Shared by every ``discard_tips``/``drop_single_tip`` variant. No tracker involvement (trash has none); callers update ``_channel_tips`` and call ``_confirm_tips_cleared()`` themselves after this returns. + + ``minimumZHeight`` is set to the computed traversal plane so the travel to + the trash arcs over every labware on the deck. Without it the engine picks + its own arc height from only the labware it has been told is loaded, which + can travel too low and clip a rack the robot was never told about. """ await self._execute( "moveToAddressableAreaForDropTip", @@ -328,9 +370,12 @@ async def _execute_trash_drop(self, trash: Trash) -> None: "pipetteId": self.pipette_id, "addressableAreaName": self._trash_addressable_area(trash), "alternateDropLocation": True, + "minimumZHeight": self._traversal_height(), }, ) await self._execute("dropTipInPlace", {"pipetteId": self.pipette_id}) + # Now over the trash, not a slot's labware: the next pipetting move arcs high. + self._current_labware_id = None # --- Fine-pipetting shared helpers --- @@ -625,6 +670,17 @@ def _require_span_fits_container( # --- Direct head motion (teaching / recovery jog) --- + def _traversal_height(self) -> float: + """The computed tip-safe travel plane for a lateral jog over this deck. + + ``max(labware tops) + arc margin`` (``checks.traversal_z``), tip-end framed + (the frame ``minimumZHeight`` already uses), computed from the resource model + -- so the arc adapts to what is actually on the deck instead of a fixed magic + number that is both wasteful over short labware and unsafe under anything + taller. + """ + return traversal_z(self.flex.deck) + async def position(self) -> Coordinate: """The head's current deck-frame position -- one ``savePosition`` query. @@ -667,11 +723,16 @@ async def move_to( params: Dict[str, Any] = { "pipetteId": self.pipette_id, "coordinates": {"x": x, "y": y, "z": z}, - "minimumZHeight": minimum_z_height if minimum_z_height is not None else _TRAVERSAL_HEIGHT, + "minimumZHeight": ( + minimum_z_height if minimum_z_height is not None else self._traversal_height() + ), } if speed is not None: params["speed"] = speed await self._execute("moveToCoordinates", params) + # A raw jog leaves the pipette at an arbitrary point: the next pipetting move + # can no longer assume it is over its last labware, so make it arc high. + self._current_labware_id = None async def move_to_well( self, @@ -704,7 +765,9 @@ async def move_to_well( "labwareId": labware_id, "wellName": well_name, "wellLocation": {"origin": origin, "offset": {"x": o.x, "y": o.y, "z": o.z}}, - "minimumZHeight": minimum_z_height if minimum_z_height is not None else _TRAVERSAL_HEIGHT, + "minimumZHeight": ( + minimum_z_height if minimum_z_height is not None else self._traversal_height() + ), } if speed is not None: params["speed"] = speed @@ -745,7 +808,9 @@ async def move_to_addressable_area( "addressableAreaName": addressable_area_name, "offset": {"x": o.x, "y": o.y, "z": o.z}, "stayAtHighestPossibleZ": stay_at_max_height, - "minimumZHeight": minimum_z_height if minimum_z_height is not None else _TRAVERSAL_HEIGHT, + "minimumZHeight": ( + minimum_z_height if minimum_z_height is not None else self._traversal_height() + ), } if speed is not None: params["speed"] = speed @@ -891,10 +956,6 @@ async def unsafe_blow_out_in_place(self, flow_rate: float) -> None: ) -# Default minimumZHeight (mm) for moveToCoordinates jogs: the head keeps at -# least this z while traveling, clearing any labware on the deck. -_TRAVERSAL_HEIGHT = 120.0 - # Where a wellLocation offset is measured from. "meniscus" needs the robot to # hold a liquid level for the well, which only a liquid probe gives it. _WELL_ORIGINS = frozenset({"top", "bottom", "center", "meniscus"}) @@ -916,6 +977,10 @@ async def unsafe_blow_out_in_place(self, flow_rate: float) -> None: # "unknown", but that is a reading, not something to check against. _TIP_PRESENCE_STATES = frozenset({"present", "absent"}) +# The 8-channel head's rows front-to-back: channel 0 = "A" (rearmost) .. 7 = "H" +# (frontmost). Used to name the corner nozzles of a partial (QUADRANT) column. +_ROW_LETTERS = "ABCDEFGH" + # The only nozzles an 8-channel Flex can anchor a SINGLE layout on ("A1" is # the rearmost, "H1" the frontmost), mapped to the channel each one is. _SINGLE_NOZZLES = {"A1": 0, "H1": 7} @@ -1287,29 +1352,234 @@ def _column_anchor_and_items(itemized: ItemizedResource, column: int) -> Tuple[s # --- Column tip operations --- async def pick_up_tips( + self, + target: Union[TipRack, Sequence[TipSpot], TipSpot], + *, + column: Optional[int] = None, + use_channels: Optional[Sequence[int]] = None, + offset: Optional[Coordinate] = None, + primary_nozzle: Optional[str] = None, + ) -> None: + """Pick up tip(s), choosing the nozzle layout for this call. + + Pickup is where per-call nozzle configuration lives -- the engine only lets + the layout change while no tip is on -- so this is the method that emits the + ``configureNozzleLayout``. The ``target`` *type* selects the layout: + + - a ``Sequence[TipSpot]`` (a ``rack.column(c)``) -> ALL, a full column; + - a single ``TipSpot`` -> SINGLE, one cherry-picked tip. + + ``use_channels`` names the channels to fill: ``None``/all 8 -> ALL; ``[0]`` + or ``[7]`` -> SINGLE on the A1 or H1 nozzle (the only two an 8-channel Flex + can single-anchor). The ALL configuration is emitted only when a prior + single-tip op left the layout otherwise (``_ensure_all_mode``); a SINGLE + pickup always emits its ``configureNozzleLayout``. ``column`` is the + transitional bridge for the old ``pick_up_tips(rack, column=c)`` call; + prefer ``rack.column(c)``. + """ + if column is not None: + await self._pick_up_column(cast(TipRack, target), column, offset) + return + if isinstance(target, (list, tuple)): + await self._pick_up_spots(list(target), use_channels, offset) + return + if isinstance(target, TipSpot): + await self._pick_up_single_spot(target, use_channels, offset, primary_nozzle) + return + raise TypeError( + f"pick_up_tips target must be a column (Sequence[TipSpot]) or a single TipSpot; " + f"got {type(target).__name__}." + ) + + async def _pick_up_single_spot( + self, + spot: TipSpot, + use_channels: Optional[Sequence[int]], + offset: Optional[Coordinate], + primary_nozzle: Optional[str], + ) -> None: + """Cherry-pick one tip (SINGLE layout), resolving the anchor nozzle. + + ``use_channels`` may name only the single-anchor channels (0 -> A1, 7 -> H1); + any other channel is refused, since an 8-channel Flex cannot anchor a + single-nozzle layout elsewhere. + """ + parent = self._require_itemized_parent(spot) + well_name = parent.get_child_identifier(spot) + if use_channels is not None: + if len(use_channels) != 1 or use_channels[0] not in _SINGLE_NOZZLE_BY_CHANNEL: + raise OpentronsError( + "NozzleConfigError", + f"An 8-channel Flex can single-anchor only on channels " + f"{sorted(_SINGLE_NOZZLE_BY_CHANNEL)} (A1/H1); got use_channels={list(use_channels)}.", + ) + resolved = _SINGLE_NOZZLE_BY_CHANNEL[use_channels[0]] + if primary_nozzle is not None and primary_nozzle != resolved: + raise OpentronsError( + "NozzleConfigError", + f"use_channels={list(use_channels)} names nozzle {resolved}, but " + f"primary_nozzle={primary_nozzle!r} was also given.", + ) + primary_nozzle = resolved + await self.pick_up_single_tip( + cast(TipRack, parent), well_name, offset=offset, primary_nozzle=primary_nozzle + ) + + async def _pick_up_spots( + self, + spots: List[TipSpot], + use_channels: Optional[Sequence[int]], + offset: Optional[Coordinate], + ) -> None: + """Pick up a full or partial column given as a list of tip spots. + + ``use_channels`` names the channel each spot fills, in order. The full + 8-channel set (or ``None`` for a full column) uses the ALL layout; a + contiguous run from the front (incl. H1/ch7) or rear (incl. A1/ch0) end uses + a QUADRANT partial layout. + """ + if not spots: + raise ValueError("pick_up_tips: the target spot sequence is empty.") + n = len(spots) + if use_channels is None: + if n != self.channels: + raise OpentronsError( + "NozzleConfigError", + f"{n} spots given without use_channels; pass use_channels to name which " + f"nozzles a partial column fills, or give a full column of {self.channels}.", + ) + use_channels = list(range(self.channels)) + uc = list(use_channels) + if len(uc) != n: + raise OpentronsError( + "NozzleConfigError", + f"use_channels has {len(uc)} entries but {n} spots were given; one per spot.", + ) + ordered = sorted(uc) + if ordered != list(range(ordered[0], ordered[-1] + 1)): + raise OpentronsError( + "NozzleConfigError", + f"a partial column must be a contiguous run of channels; got use_channels={uc}.", + ) + if ordered == list(range(self.channels)): + parent = cast(TipRack, self._require_itemized_parent(spots[0])) + anchor = parent.get_child_identifier(spots[0]) + await self._pick_up_spots_core(parent, anchor, spots, offset) + return + await self._pick_up_partial(spots, uc, offset) + + async def _pick_up_partial( + self, + spots: List[TipSpot], + use_channels: List[int], + offset: Optional[Coordinate], + ) -> None: + """Pick up a contiguous partial column in a QUADRANT nozzle layout. + + The layout matches what Opentrons' own ``configure_nozzle_layout`` emits for a + ``PARTIAL_COLUMN``: a front-anchored run (including H1/ch7) sets + ``primaryNozzle = frontRightNozzle = "H1"`` with ``backLeftNozzle`` the rear-most + active nozzle; a rear-anchored run (including A1/ch0) mirrors it on A1. The + ``configureNozzleLayout`` is emitted here (no tip is on yet, so the engine + accepts it); the ``pickUpTip`` is anchored at the well under the primary nozzle. + """ + ordered = sorted(use_channels) + top = self.channels - 1 + if ordered[-1] == top: # front-anchored partial (includes H1) + primary_channel = top + primary = front_right = "H1" + back_left = f"{_ROW_LETTERS[ordered[0]]}1" + elif ordered[0] == 0: # rear-anchored partial (includes A1) + primary_channel = 0 + primary = back_left = "A1" + front_right = f"{_ROW_LETTERS[ordered[-1]]}1" + else: + raise OpentronsError( + "NozzleConfigError", + "an 8-channel partial column must run from the front (incl. H1) or the rear " + f"(incl. A1) end; got use_channels={use_channels}.", + ) + + spot_by_channel = {use_channels[i]: spots[i] for i in range(len(spots))} + for ch in ordered: + if spot_by_channel[ch].has_tip() and self._channel_tips[ch] is not None: + raise OpentronsError( + "HasTipError", + f"Channel {ch} already holds a tip; drop it before picking up another.", + ) + + await self._configure_nozzle_layout( + { + "style": "QUADRANT", + "primaryNozzle": primary, + "frontRightNozzle": front_right, + "backLeftNozzle": back_left, + } + ) + self._nozzle_layout = "PARTIAL" # non-ALL: a later column op resets via _ensure_all_mode + + primary_spot = spot_by_channel[primary_channel] + parent = cast(TipRack, self._require_itemized_parent(primary_spot)) + labware_id = await self.flex._ensure_labware_loaded(parent) + anchor = parent.get_child_identifier(primary_spot) + + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + tips: Dict[int, Tip] = {} + for ch in ordered: + spot = spot_by_channel[ch] + if not spot.has_tip(): + continue + tips[ch] = spot.get_tip() + if tracking and not spot.tracker.is_disabled: + spot.tracker.remove_tip() # commit=False: stages + validates + staged_trackers.append(spot.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": anchor, + } + well_location = self._well_location([offset], [None], origin="top") + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_pickup("pickUpTip", params, staged_trackers) + for ch, tip in tips.items(): + self._channel_tips[ch] = tip + + async def _pick_up_column( self, tip_rack: TipRack, column: int, offset: Optional[Coordinate] = None, ) -> None: - """Pick up a full column (8 tips) with a single ``pickUpTip`` command. + """Pick up a full column (8 tips) by column index (transitional bridge).""" + well_name, column_spots = self._column_anchor_and_items(tip_rack, column) + await self._pick_up_spots_core(tip_rack, well_name, column_spots, offset) + + async def _pick_up_spots_core( + self, + tip_rack: TipRack, + anchor_name: str, + spots: List[Any], + offset: Optional[Coordinate], + ) -> None: + """Shared ALL-layout column pickup with a single ``pickUpTip`` command. Anchored at the column's rearmost spot; the hardware fans the pickup motion out to all 8 physical nozzles. Follows stage -> validate -> wire -> - verify -> commit/rollback: the column index and the double-pickup guard - (fix #4) are validated before ANY wire command, tip trackers are staged - (``commit=False``) before the pickup command -- so an invalid tracker - state raises before any hardware motion -- then, after the wire command - succeeds, the hardware tip-presence sensor is checked - (``_verify_tips_seated()``); trackers and ``_channel_tips`` are committed - only if that verification passes, and rolled back (with no - ``_channel_tips`` mutation) if the sensor reports a missed pickup. Only - spots that actually had a tip are staged (None-skip). + verify -> commit/rollback: the double-pickup guard is validated before ANY + wire command, tip trackers are staged (``commit=False``) before the pickup + command, then, after the wire command succeeds, the hardware tip-presence + sensor is checked (``_verify_tips_seated()``); trackers and ``_channel_tips`` + are committed only if that verification passes, and rolled back (with no + ``_channel_tips`` mutation) if the sensor reports a missed pickup. Only spots + that actually had a tip are staged (None-skip). """ self._warn_untested_hardware("pick_up_tips") - well_name, column_spots = self._column_anchor_and_items(tip_rack, column) - for i, spot in enumerate(column_spots): + for i, spot in enumerate(spots): if spot.has_tip() and self._channel_tips[i] is not None: raise OpentronsError( "HasTipError", @@ -1321,8 +1591,8 @@ async def pick_up_tips( tracking = does_tip_tracking() staged_trackers: List[Any] = [] - tips: List[Optional[Tip]] = [None] * len(column_spots) - for i, spot in enumerate(column_spots): + tips: List[Optional[Tip]] = [None] * len(spots) + for i, spot in enumerate(spots): if not spot.has_tip(): continue tips[i] = spot.get_tip() @@ -1333,7 +1603,7 @@ async def pick_up_tips( params: Dict[str, Any] = { "pipetteId": self.pipette_id, "labwareId": labware_id, - "wellName": well_name, + "wellName": anchor_name, } well_location = self._well_location([offset], [None], origin="top") if well_location is not None: @@ -1409,9 +1679,158 @@ async def discard_tips(self, trash: Trash) -> None: """Discard the mounted column of tips into the trash.""" await self.drop_tips(trash) - # --- Column liquid handling --- + # --- Unified liquid handling (per-call nozzle configuration) --- + + def _require_use_channels_match_mounted(self, use_channels: Optional[Sequence[int]]) -> None: + """Refuse a ``use_channels`` that is not exactly the mounted channels. + + ``use_channels`` names the active nozzles for the call, but the layout is + fixed at pickup (the engine refuses to reconfigure while a tip is on) and + one fanned command actuates every mounted nozzle -- so it cannot address a + subset. An explicit ``use_channels`` must therefore equal the channels that + currently hold tips; left ``None`` the mounted set is used implicitly. The + reference channel it names is also what the reachability check reads to pick + the active nozzle, so a mismatch here would misaim that guard. + """ + if use_channels is None: + return + mounted = {i for i, tip in enumerate(self._channel_tips) if tip is not None} + requested = set(use_channels) + if requested != mounted: + raise OpentronsError( + "NozzleConfigMismatch", + f"use_channels={sorted(requested)} does not match the mounted channels " + f"{sorted(mounted)}. The nozzle layout is fixed at pickup and one command fans to " + "every mounted nozzle, so use_channels must name exactly the channels holding tips.", + ) async def aspirate( + self, + target: Union[Plate, Well, Sequence[Well], Container], + volume: float, + *, + column: Optional[int] = None, + use_channels: Optional[Sequence[int]] = None, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Aspirate ``volume`` uL from ``target`` -- one ``aspirate`` command. + + ONE method for every 8-channel aspirate; the ``target`` *type* selects the + addressing and ``use_channels`` names the active nozzles for this call: + + - a ``Sequence[Well]`` (a ``plate.column(c)``) -> that column, anchored at + its rearmost well, one ``Well.tracker`` per active channel (None-skip); + - a single ``Well`` -> the mounted single nozzle draws from it; + - a ``Container`` (trough/reservoir) -> every active nozzle dips into the + one cavity, its single tracker staged with ``volume x active-channels``. + + The nozzle LAYOUT is fixed at pickup, not here: the engine refuses a + reconfiguration while a tip is attached, and an aspirate always has a tip, + so no ``configureNozzleLayout`` is ever emitted by an aspirate. This method + only names which mounted nozzles it drives and refuses a request the mount + cannot satisfy, before any wire command. + + ``column`` is the transitional bridge for the old ``aspirate(plate, + column=c)`` call and takes a ``Plate`` target; prefer ``plate.column(c)``. + """ + self._require_use_channels_match_mounted(use_channels) + if column is not None: + await self._aspirate_column( + cast(Plate, target), column, volume, flow_rate, offset, liquid_height + ) + return + if isinstance(target, (list, tuple)): + await self._aspirate_wells( + list(target), volume, use_channels, flow_rate, offset, liquid_height + ) + return + if isinstance(target, Well): # Well is a Container, so check it first + await self._aspirate_single_well(target, volume, flow_rate, offset, liquid_height) + return + if isinstance(target, Container): + await self.aspirate_container( + target, volume, flow_rate=flow_rate, offset=offset, liquid_height=liquid_height + ) + return + raise TypeError( + f"aspirate target must be a Plate column (Sequence[Well]), a single Well, or a " + f"Container; got {type(target).__name__}." + ) + + def _require_single_nozzle_clearance(self, labware: ItemizedResource) -> None: + """Refuse a single-nozzle liquid op whose idle nozzles would overhang the + adjacent slot's labware. + + In SINGLE layout the 7 idle nozzles trail off one end of the head, so a + liquid op over a slot with a tip rack (or taller labware) in the trailing + slot would drive them into it. The engine does not check this for a raw + command, so it is refused here -- the same clearance guard + ``pick_up_single_tip`` runs at pickup, applied to the liquid op. + """ + nozzle = _SINGLE_NOZZLE_BY_CHANNEL.get(self._active_single_channel()) + slot = self.flex.deck.get_slot(labware) + if nozzle is not None and slot is not None: + self.flex.deck.check_single_nozzle_clearance(slot, nozzle) + + async def _aspirate_single_well( + self, + well: Well, + volume: float, + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + ) -> None: + """Aspirate one well with the mounted single nozzle -- one ``aspirate`` at it. + + Requires exactly one mounted tip (a SINGLE-layout cherry-pick); refuses a + well the mounted anchor cannot reach, and a target whose idle nozzles would + overhang the adjacent slot's labware -- all before any wire command. The + well's own tracker is staged with ``volume``. + """ + self._warn_untested_hardware("aspirate") + self._active_single_channel() + parent = self._require_itemized_parent(well) + well_name = parent.get_child_identifier(well) + self._require_reach_in_single_layout(parent, well_name) + self._require_single_nozzle_clearance(parent) + labware_id = await self.flex._ensure_labware_loaded(parent) + staged_trackers = self._stage_container_aspirate(well, volume) + await self._pipette( + "aspirate", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def _aspirate_wells( + self, + wells: List[Well], + volume: float, + use_channels: Optional[Sequence[int]], + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + ) -> None: + """Aspirate a PLR-native column (a list of wells) -- one anchored ``aspirate``. + + The wells are the resources the nozzle row covers, rearmost first (as + ``plate.column(c)`` returns them); the command anchors at the first and the + hardware fans it to all 8 nozzles. One ``Well.tracker`` is staged per well + whose channel holds a tip (None-skip), before the wire command. + """ + self._warn_untested_hardware("aspirate") + self._require_mounted_tip() + if not wells: + raise ValueError("aspirate: the target well sequence is empty.") + parent = self._require_itemized_parent(wells[0]) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(parent) + anchor = parent.get_child_identifier(wells[0]) + staged_trackers = self._stage_wells_aspirate(wells, volume) + await self._pipette( + "aspirate", labware_id, anchor, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def _aspirate_column( self, plate: Plate, column: int, @@ -1442,6 +1861,95 @@ async def aspirate( ) async def dispense( + self, + target: Union[Plate, Well, Sequence[Well], Container], + volume: float, + *, + column: Optional[int] = None, + use_channels: Optional[Sequence[int]] = None, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Dispense ``volume`` uL into ``target`` -- one ``dispense`` command. + + The ``dispense`` mirror of :meth:`aspirate`: the ``target`` type selects the + addressing (a ``plate.column(c)`` sequence, a single ``Well``, or a + ``Container`` cavity) and ``use_channels`` names the active nozzles. No + ``configureNozzleLayout`` is emitted (the layout is fixed at pickup). See + :meth:`aspirate` for the full contract; ``column`` is the same transitional + bridge for the old ``dispense(plate, column=c)`` call. + """ + self._require_use_channels_match_mounted(use_channels) + if column is not None: + await self._dispense_column( + cast(Plate, target), column, volume, flow_rate, offset, liquid_height + ) + return + if isinstance(target, (list, tuple)): + await self._dispense_wells( + list(target), volume, use_channels, flow_rate, offset, liquid_height + ) + return + if isinstance(target, Well): # Well is a Container, so check it first + await self._dispense_single_well(target, volume, flow_rate, offset, liquid_height) + return + if isinstance(target, Container): + await self.dispense_container( + target, volume, flow_rate=flow_rate, offset=offset, liquid_height=liquid_height + ) + return + raise TypeError( + f"dispense target must be a Plate column (Sequence[Well]), a single Well, or a " + f"Container; got {type(target).__name__}." + ) + + async def _dispense_single_well( + self, + well: Well, + volume: float, + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + ) -> None: + """Dispense to one well with the mounted single nozzle -- the mirror of + :meth:`_aspirate_single_well`.""" + self._warn_untested_hardware("dispense") + self._active_single_channel() + parent = self._require_itemized_parent(well) + well_name = parent.get_child_identifier(well) + self._require_reach_in_single_layout(parent, well_name) + self._require_single_nozzle_clearance(parent) + labware_id = await self.flex._ensure_labware_loaded(parent) + staged_trackers = self._stage_container_dispense(well, volume) + await self._pipette( + "dispense", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def _dispense_wells( + self, + wells: List[Well], + volume: float, + use_channels: Optional[Sequence[int]], + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + ) -> None: + """Dispense a PLR-native column (a list of wells) -- one anchored ``dispense``.""" + self._warn_untested_hardware("dispense") + self._require_mounted_tip() + if not wells: + raise ValueError("dispense: the target well sequence is empty.") + parent = self._require_itemized_parent(wells[0]) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(parent) + anchor = parent.get_child_identifier(wells[0]) + staged_trackers = self._stage_wells_dispense(wells, volume) + await self._pipette( + "dispense", labware_id, anchor, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def _dispense_column( self, plate: Plate, column: int, @@ -1759,6 +2267,7 @@ async def aspirate_single( self._warn_untested_hardware("aspirate_single") self._active_single_channel() self._require_reach_in_single_layout(plate, well) + self._require_single_nozzle_clearance(plate) labware_id = await self.flex._ensure_labware_loaded(plate) staged_trackers = self._stage_container_aspirate(plate.get_item(well), volume) await self._pipette( @@ -1776,6 +2285,7 @@ async def dispense_single( self._warn_untested_hardware("dispense_single") self._active_single_channel() self._require_reach_in_single_layout(plate, well) + self._require_single_nozzle_clearance(plate) labware_id = await self.flex._ensure_labware_loaded(plate) staged_trackers = self._stage_container_dispense(plate.get_item(well), volume) await self._pipette( diff --git a/pylabrobot/opentrons/flex_head_use_channels_tests.py b/pylabrobot/opentrons/flex_head_use_channels_tests.py new file mode 100644 index 00000000000..25d95015383 --- /dev/null +++ b/pylabrobot/opentrons/flex_head_use_channels_tests.py @@ -0,0 +1,402 @@ +"""Tests for the unified ``FlexHead8.aspirate``/``dispense`` (per-call nozzle config). + +Collapses the three per-config methods -- ``aspirate`` (column), ``aspirate_single`` +(one well), ``aspirate_container`` (trough) -- into ONE +``aspirate(target, volume, *, use_channels=...)`` and the same for ``dispense``. +The target *type* drives addressing; ``use_channels`` names/validates the active +nozzles for that call. The layout itself is fixed at pickup (the engine refuses a +nozzle reconfiguration while tips are attached), so a liquid op emits NO +``configureNozzleLayout`` -- only the ``aspirate``/``dispense`` command. + +Wire payloads are inspected through the injected ``ChatterboxTransport``. +""" + +import asyncio +import unittest +from typing import List, Tuple + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead8 +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import ( + Container, + cor_96_wellplate_360uL_Fb, + set_tip_tracking, + set_volume_tracking, +) +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + +def _make_trough(name: str = "trough") -> Container: + """A single-cavity reservoir (PLR ``Container``) mapped to a real Opentrons load name.""" + trough = Container( + name=name, + size_x=127.76, + size_y=85.48, + size_z=31.4, + material_z_thickness=1.0, + max_volume=195000.0, + ) + trough.ot_load_name = "nest_1_reservoir_195ml" # type: ignore[attr-defined] + return trough + + +def _flex_head8() -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead8]: + transport = ChatterboxTransport(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, transport, head + + +def _plate_on(flex: OpentronsFlex, slot: str = "C2"): + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, slot) + return plate + + +def _rack_on(flex: OpentronsFlex, slot: str = "C1"): + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, slot) + return rack + + +def _commands_of(transport: ChatterboxTransport, command_type: str) -> List[dict]: + return [c for c in transport.commands if c["commandType"] == command_type] + + +class TestUnifiedAspirateColumn(unittest.TestCase): + """A ``Sequence[Well]`` target (a ``plate.column(c)``) aspirates that column + with ONE ``aspirate`` anchored at its rearmost well, per-well trackers, and no + ``configureNozzleLayout`` emitted at aspirate time.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_column_target_emits_one_anchored_aspirate_and_tracks_only_that_column(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + plate = _plate_on(flex) + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + commands_before = len(transport.commands) + + asyncio.run(head.aspirate(plate.column(2), volume=50)) + + aspirate_cmds = _commands_of(transport, "aspirate") + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A3") + + # No configureNozzleLayout may be emitted by the aspirate itself: the + # engine refuses a reconfiguration while tips are attached. + new_cmds = transport.commands[commands_before:] + self.assertNotIn( + "configureNozzleLayout", + [c["commandType"] for c in new_cmds], + ) + + wells = plate.get_all_items() + column_2 = set(wells[16:24]) + for well in wells: + expected = 50.0 if well in column_2 else 100.0 + self.assertAlmostEqual(well.tracker.volume, expected, msg=well.name) + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedAspirateSingleWell(unittest.TestCase): + """A bare ``Well`` target aspirates it with the mounted single nozzle -- one + ``aspirate`` at that well, its one tracker, no ``configureNozzleLayout``.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_bare_well_target_aspirates_with_single_nozzle(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + plate = _plate_on(flex) + target = plate.get_item("B3") + target.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + commands_before = len(transport.commands) + + asyncio.run(head.aspirate(target, volume=20)) + + aspirate_cmds = _commands_of(transport, "aspirate") + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "B3") + + new_cmds = [c["commandType"] for c in transport.commands[commands_before:]] + self.assertNotIn("configureNozzleLayout", new_cmds) + + self.assertAlmostEqual(target.tracker.volume, 80.0) + for well in plate.get_all_items(): + if well is target: + continue + self.assertAlmostEqual(well.tracker.volume, 0.0, msg=well.name) + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedAspirateContainer(unittest.TestCase): + """A ``Container`` (trough) target fans every mounted nozzle into the one + cavity -- one ``aspirate`` at the container well, its single tracker staged + with ``volume x active-channels``.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_container_target_stages_total_and_emits_one_aspirate(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(1000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) # 8 tips, ALL layout + commands_before = len(transport.commands) + + asyncio.run(head.aspirate(trough, volume=10)) + + aspirate_cmds = _commands_of(transport, "aspirate") + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") + + new_cmds = [c["commandType"] for c in transport.commands[commands_before:]] + self.assertNotIn("configureNozzleLayout", new_cmds) + + # 8 channels x 10 uL = 80 uL removed from the one cavity. + self.assertAlmostEqual(trough.tracker.volume, 920.0) + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedUseChannelsValidation(unittest.TestCase): + """``use_channels`` names the active nozzles for the call and, since the + layout is fixed at pickup, must match exactly the channels holding tips -- + a mismatch is refused before any wire command.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_use_channels_not_matching_mounted_tips_refuses_before_wire(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + plate = _plate_on(flex) + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) # all 8 channels hold tips + commands_before = len(transport.commands) + + # Only 3 channels named, but 8 are mounted: the fanned command cannot + # actuate a subset, so this is refused. + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(plate.column(2), volume=20, use_channels=[0, 1, 2])) + + self.assertEqual(len(transport.commands), commands_before, "no wire command may be sent") + finally: + asyncio.run(flex.stop()) + + def test_use_channels_matching_all_mounted_is_accepted(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + plate = _plate_on(flex) + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate.column(2), volume=20, use_channels=list(range(8)))) + + aspirate_cmds = _commands_of(transport, "aspirate") + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A3") + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedPickUpTips(unittest.TestCase): + """One ``pick_up_tips(target, *, use_channels)`` chooses the layout and emits the + ``configureNozzleLayout`` -- pickup is where per-call nozzle configuration lives. + A ``Sequence[TipSpot]`` (a ``rack.column(c)``) -> ALL; a single ``TipSpot`` -> SINGLE.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def test_column_of_spots_picks_up_eight_in_all_layout(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + + asyncio.run(head.pick_up_tips(rack.column(0))) + + pickup = _commands_of(transport, "pickUpTip") + self.assertEqual(len(pickup), 1) + self.assertEqual(pickup[0]["params"]["wellName"], "A1") + self.assertEqual(sum(1 for t in head.get_mounted_tips() if t is not None), 8) + # A fresh head is already ALL, so no SINGLE reconfiguration is emitted. + styles = [ + c["params"]["configurationParams"]["style"] + for c in _commands_of(transport, "configureNozzleLayout") + ] + self.assertNotIn("SINGLE", styles) + finally: + asyncio.run(flex.stop()) + + def test_single_spot_with_use_channels_configures_single_primary_nozzle(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"), use_channels=[0])) + + configure = _commands_of(transport, "configureNozzleLayout") + cfg = configure[-1]["params"]["configurationParams"] + self.assertEqual(cfg["style"], "SINGLE") + self.assertEqual(cfg["primaryNozzle"], "A1") + pickup = _commands_of(transport, "pickUpTip") + self.assertEqual(pickup[-1]["params"]["wellName"], "A1") + tips = head.get_mounted_tips() + self.assertIsNotNone(tips[0]) + self.assertEqual(sum(1 for t in tips if t is not None), 1) + finally: + asyncio.run(flex.stop()) + + def test_single_spot_use_channels_off_anchor_refuses(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + # An 8-channel Flex can single-anchor only on A1 (ch 0) or H1 (ch 7). + with self.assertRaises((ValueError, OpentronsError)): + asyncio.run(head.pick_up_tips(rack.get_item("A1"), use_channels=[3])) + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedPartialPickUp(unittest.TestCase): + """A contiguous partial ``use_channels`` from the front (H1) end emits the QUADRANT + config Opentrons' own protocol_api produces (start=H1 -> primary=frontRight=H1, + backLeft=rear-most active nozzle) and picks up that partial column.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def test_front_partial_emits_quadrant_and_picks_the_front_channels(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + + # Front 4 nozzles E,F,G,H = channels 4..7; spots in channel order. + await_spots = rack.column(0)[4:8] + asyncio.run(head.pick_up_tips(await_spots, use_channels=[4, 5, 6, 7])) + + cfg = _commands_of(transport, "configureNozzleLayout")[-1]["params"]["configurationParams"] + self.assertEqual(cfg["style"], "QUADRANT") + self.assertEqual(cfg["primaryNozzle"], "H1") + self.assertEqual(cfg["frontRightNozzle"], "H1") + self.assertEqual(cfg["backLeftNozzle"], "E1") # rear-most of the front-4 + + pickup = _commands_of(transport, "pickUpTip") + self.assertEqual(pickup[-1]["params"]["wellName"], "H1") # anchor = primary nozzle's well + + tips = head.get_mounted_tips() + self.assertEqual([i for i, t in enumerate(tips) if t is not None], [4, 5, 6, 7]) + finally: + asyncio.run(flex.stop()) + + def test_non_contiguous_partial_refuses(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + spots = [rack.get_item("A1"), rack.get_item("C1"), rack.get_item("E1")] + with self.assertRaises((ValueError, OpentronsError)): + asyncio.run(head.pick_up_tips(spots, use_channels=[0, 2, 4])) + finally: + asyncio.run(flex.stop()) + + +class TestSingleNozzleLiquidClearance(unittest.TestCase): + """The single-nozzle aspirate/dispense refuses when the idle nozzles would + overhang the adjacent slot's labware (the same guard pickup runs), and allows + it when the trailing row is empty.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_single_aspirate_refuses_when_idle_nozzles_overhang_a_tip_rack(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + plate = _plate_on(flex, "D1") # D1 front; C1 (a tip rack) is directly behind it + plate.get_item("A1").tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"), use_channels=[7])) # H1 single + with self.assertRaises((ValueError, OpentronsError)): + asyncio.run(head.aspirate(plate.get_item("A1"), volume=20)) + + self.assertEqual(len(_commands_of(transport, "aspirate")), 0, "no aspirate may be sent") + finally: + asyncio.run(flex.stop()) + + def test_single_aspirate_allowed_when_trailing_row_empty(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "D1") # pick from D1 (behind it, C1, is empty) + plate = _plate_on(flex, "B1") # aspirate B1; behind it, A1, is empty + plate.get_item("A1").tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"), use_channels=[7])) + asyncio.run(head.aspirate(plate.get_item("A1"), volume=20)) + + self.assertEqual(len(_commands_of(transport, "aspirate")), 1) + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_motion_tests.py b/pylabrobot/opentrons/flex_motion_tests.py index 2adee99da6c..328e9ecb0bc 100644 --- a/pylabrobot/opentrons/flex_motion_tests.py +++ b/pylabrobot/opentrons/flex_motion_tests.py @@ -13,6 +13,7 @@ import unittest from typing import Any, Dict, List, Tuple +from pylabrobot.opentrons.checks import traversal_z from pylabrobot.opentrons.flex import OpentronsFlex from pylabrobot.opentrons.flex_gripper import FlexGripper, _require_robot_commands from pylabrobot.opentrons.flex_head import FlexHead8, _FlexHead @@ -102,7 +103,9 @@ def test_partial_axes_merge_saved_with_given(self): { "pipetteId": head.pipette_id, "coordinates": {"x": 50.0, "y": 20.0, "z": 30.0}, - "minimumZHeight": 120.0, + # Default minimumZHeight is now the computed tip-safe plane (tallest + # labware top + arc margin), not a hardcoded 120.0 magic number. + "minimumZHeight": traversal_z(flex.deck), }, ) finally: From abba480f49ac479507d07214b3ab24ebf7e35aee Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Thu, 20 Aug 2026 09:04:46 +0200 Subject: [PATCH 35/36] fix(opentrons): record FlexHead8 hardware-verified ops accurately 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 --- pylabrobot/opentrons/flex_head.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 849db3ec8b3..643dcce16e4 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -1279,12 +1279,21 @@ class FlexHead8(_FlexHead): (``_ensure_all_mode``). Verified on real 8-channel Flex hardware (Opentrons Flex, robot-server - API 8.8): setup, homing, and column tip pickup confirmed against the - hardware tip-presence sensor. Ops outside that verified lineage log the + API 8.8) by the ``docs/user_guide/opentrons/flex/hello-world.ipynb`` and + ``use_channels_smoke.ipynb`` runs: setup and homing; tip pickup in + full-column (ALL), single-nozzle (SINGLE, H1), and partial-column (QUADRANT, + front four) layouts, and tip drop in the full-column and single-nozzle + layouts, confirmed against the hardware tip-presence sensor; and + aspirate/dispense into a plate in the full-column and single-nozzle layouts. + Ops outside that set -- container/reservoir ops, touch_tip, liquid_probe, and + the motion surface -- are coded but not yet hardware-verified and log the one-time untested-hardware notice, same as the other heads. """ - _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset({"pick_up_tips"}) + _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset( + {"pick_up_tips", "pick_up_single_tip", "pick_up_partial", "drop_tips", "drop_single_tip", + "aspirate", "dispense"} + ) def __init__( self, @@ -1483,6 +1492,7 @@ async def _pick_up_partial( ``configureNozzleLayout`` is emitted here (no tip is on yet, so the engine accepts it); the ``pickUpTip`` is anchored at the well under the primary nozzle. """ + self._warn_untested_hardware("pick_up_partial") ordered = sorted(use_channels) top = self.channels - 1 if ordered[-1] == top: # front-anchored partial (includes H1) From b5e841d7c59d33f114076e52000ff9a905068f73 Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Thu, 20 Aug 2026 18:59:57 +0200 Subject: [PATCH 36/36] chore(opentrons): green the Flex-stack CI checks - 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 --- _typos.toml | 3 +++ docs/user_guide/opentrons/index.md | 1 + pylabrobot.code-workspace | 8 ++++++++ pylabrobot/io/http_tests.py | 12 +++++++++--- pylabrobot/opentrons/checks.py | 4 +++- pylabrobot/opentrons/envelope.py | 2 +- pylabrobot/opentrons/flex_gripper.py | 2 +- pylabrobot/opentrons/flex_head.py | 11 +++++++++-- pylabrobot/opentrons/flex_tests.py | 12 +++++++----- pylabrobot/opentrons/transport_tests.py | 12 +++++++++--- 10 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 pylabrobot.code-workspace diff --git a/_typos.toml b/_typos.toml index 9a066135d51..f7e9cfbc2ff 100644 --- a/_typos.toml +++ b/_typos.toml @@ -46,6 +46,9 @@ LOK = "LOK" ouput = "ouput" hegiht = "hegiht" +# "unparseable" is an accepted English variant used by the Opentrons version parser. +unparseable = "unparseable" + [files] extend-exclude = [ "*.ipynb" diff --git a/docs/user_guide/opentrons/index.md b/docs/user_guide/opentrons/index.md index c9accb591e6..a65ebb9ac52 100644 --- a/docs/user_guide/opentrons/index.md +++ b/docs/user_guide/opentrons/index.md @@ -4,4 +4,5 @@ :maxdepth: 1 flex/hello-world +flex/use_channels_smoke ``` diff --git a/pylabrobot.code-workspace b/pylabrobot.code-workspace new file mode 100644 index 00000000000..876a1499c09 --- /dev/null +++ b/pylabrobot.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/pylabrobot/io/http_tests.py b/pylabrobot/io/http_tests.py index e09c15caebf..3572f17933e 100644 --- a/pylabrobot/io/http_tests.py +++ b/pylabrobot/io/http_tests.py @@ -4,15 +4,21 @@ import tempfile import unittest from pathlib import Path -from typing import Any, Callable, Dict, List +from typing import TYPE_CHECKING, Any, Callable, Dict, List -import httpx +import pytest import pylabrobot from pylabrobot.io.capture import CaptureReader from pylabrobot.io.errors import ValidationError from pylabrobot.io.http import HTTP, HTTPValidator -from pylabrobot.testing.http_server import serving as _serving + +if TYPE_CHECKING: + import httpx +else: + httpx = pytest.importorskip("httpx") + +from pylabrobot.testing.http_server import serving as _serving # noqa: E402 BASE_URL = "http://robot.test:31950" diff --git a/pylabrobot/opentrons/checks.py b/pylabrobot/opentrons/checks.py index 02d863d6305..3a4abe906a2 100644 --- a/pylabrobot/opentrons/checks.py +++ b/pylabrobot/opentrons/checks.py @@ -10,10 +10,12 @@ from pylabrobot.opentrons.envelope import ( FLEX_8CH_NOZZLE_A1_Y, - FLEX_8CH_NOZZLE_H1_Y as FLEX_8CH_NOZZLE_H1_Y, # re-exported for callers importing from checks FLEX_ENVELOPE, nozzle_offset_y, ) +from pylabrobot.opentrons.envelope import ( + FLEX_8CH_NOZZLE_H1_Y as FLEX_8CH_NOZZLE_H1_Y, # re-exported for callers importing from checks +) from pylabrobot.opentrons.robot import PipetteInfo from pylabrobot.resources import Coordinate, Resource from pylabrobot.resources.errors import NoLocationError diff --git a/pylabrobot/opentrons/envelope.py b/pylabrobot/opentrons/envelope.py index 8011cb8818c..762a585f113 100644 --- a/pylabrobot/opentrons/envelope.py +++ b/pylabrobot/opentrons/envelope.py @@ -9,7 +9,7 @@ """ from dataclasses import dataclass -from typing import List, Tuple, TYPE_CHECKING +from typing import TYPE_CHECKING, List, Tuple from pylabrobot.resources import Coordinate diff --git a/pylabrobot/opentrons/flex_gripper.py b/pylabrobot/opentrons/flex_gripper.py index bf9542a089d..24af0e80738 100644 --- a/pylabrobot/opentrons/flex_gripper.py +++ b/pylabrobot/opentrons/flex_gripper.py @@ -86,7 +86,7 @@ async def move_labware( pylabrobot uploads. It therefore applies ONLY to labware pylabrobot uploads a definition for: a resource resolving to an official Opentrons load name (``ot_load_name`` set, a standard tip-rack name, - or a name starting with "opentrons_") loads the catalogue definition + or a name starting with ``opentrons_``) loads the catalogue definition instead, whose grip height is the vendor's to state -- and when that definition states none, the robot grips at the labware's mid-height rather than at this value. Honored on the labware's FIRST load in the diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py index 643dcce16e4..a0d784db00b 100644 --- a/pylabrobot/opentrons/flex_head.py +++ b/pylabrobot/opentrons/flex_head.py @@ -1291,8 +1291,15 @@ class FlexHead8(_FlexHead): """ _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset( - {"pick_up_tips", "pick_up_single_tip", "pick_up_partial", "drop_tips", "drop_single_tip", - "aspirate", "dispense"} + { + "pick_up_tips", + "pick_up_single_tip", + "pick_up_partial", + "drop_tips", + "drop_single_tip", + "aspirate", + "dispense", + } ) def __init__( diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py index fa1fcd43197..9514c1ea83c 100644 --- a/pylabrobot/opentrons/flex_tests.py +++ b/pylabrobot/opentrons/flex_tests.py @@ -1201,7 +1201,7 @@ class DeclaredIdentityTests(unittest.TestCase): def test_a_declared_load_name_is_what_the_resource_loads_by(self): plate = cor_96_wellplate_360uL_Fb(name="anything at all") - plate.ot_load_name = "corning_96_wellplate_360ul_flat" + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] load_name, version = OpentronsFlex._ot_declared_identity(plate) self.assertEqual(load_name, "corning_96_wellplate_360ul_flat") self.assertEqual(version, 1) @@ -1210,15 +1210,17 @@ def test_the_revision_is_the_resource_s_to_declare_too(self): # Revision 1 is the only one every robot holds, so a caller who wants a # later one (for its gripper grip height) has to say so. plate = cor_96_wellplate_360uL_Fb(name="plate") - plate.ot_load_name = "corning_96_wellplate_360ul_flat" - plate.ot_version = 2 - self.assertEqual(OpentronsFlex._ot_declared_identity(plate), ("corning_96_wellplate_360ul_flat", 2)) + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + plate.ot_version = 2 # type: ignore[attr-defined] + self.assertEqual( + OpentronsFlex._ot_declared_identity(plate), ("corning_96_wellplate_360ul_flat", 2) + ) def test_a_declared_name_is_passed_through_rather_than_checked_against_a_list(self): # The robot resolves against its own shipped definitions AND a lab's own # uploads, so any list here would be wrong for somebody's robot. plate = cor_96_wellplate_360uL_Fb(name="plate") - plate.ot_load_name = "a_lab_uploaded_this_one_themselves" + plate.ot_load_name = "a_lab_uploaded_this_one_themselves" # type: ignore[attr-defined] load_name, version = OpentronsFlex._ot_declared_identity(plate) self.assertEqual(load_name, "a_lab_uploaded_this_one_themselves") self.assertEqual(version, 1) diff --git a/pylabrobot/opentrons/transport_tests.py b/pylabrobot/opentrons/transport_tests.py index b2cb3e22882..d299306e9c0 100644 --- a/pylabrobot/opentrons/transport_tests.py +++ b/pylabrobot/opentrons/transport_tests.py @@ -5,9 +5,9 @@ import tempfile import unittest from pathlib import Path -from typing import Any, Dict, List +from typing import TYPE_CHECKING, Any, Dict, List -import httpx +import pytest import pylabrobot from pylabrobot.io.errors import ValidationError @@ -20,7 +20,13 @@ ReplayTransport, ) from pylabrobot.resources.opentrons.flex_deck import FlexDeck -from pylabrobot.testing.http_server import serving + +if TYPE_CHECKING: + import httpx +else: + httpx = pytest.importorskip("httpx") + +from pylabrobot.testing.http_server import serving # noqa: E402 class _StubRobot(OpentronsRobot):