From 5f3869c0941004579f1656e1d5136488ef4c81db Mon Sep 17 00:00:00 2001 From: vcjdeboer Date: Wed, 5 Aug 2026 15:36:39 +0200 Subject: [PATCH] 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, + )