diff --git a/_typos.toml b/_typos.toml index 9a066135d51..f7e9cfbc2ff 100644 --- a/_typos.toml +++ b/_typos.toml @@ -46,6 +46,9 @@ LOK = "LOK" ouput = "ouput" hegiht = "hegiht" +# "unparseable" is an accepted English variant used by the Opentrons version parser. +unparseable = "unparseable" + [files] extend-exclude = [ "*.ipynb" diff --git a/docs/api/pylabrobot.opentrons.rst b/docs/api/pylabrobot.opentrons.rst new file mode 100644 index 00000000000..7cf934683d7 --- /dev/null +++ b/docs/api/pylabrobot.opentrons.rst @@ -0,0 +1,44 @@ +.. currentmodule:: pylabrobot.opentrons + +pylabrobot.opentrons package +============================ + +Flex +---- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + OpentronsRobot + OpentronsFlex + OpentronsError + OpentronsCommandError + PipetteInfo + +Heads +----- + +.. currentmodule:: pylabrobot.opentrons.flex_head + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + FlexHead1 + FlexHead8 + FlexHead96 + +Gripper +------- + +.. currentmodule:: pylabrobot.opentrons.flex_gripper + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + FlexGripper diff --git a/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/flex/use_channels_smoke.ipynb b/docs/user_guide/opentrons/flex/use_channels_smoke.ipynb new file mode 100644 index 00000000000..de7d218365e --- /dev/null +++ b/docs/user_guide/opentrons/flex/use_channels_smoke.ipynb @@ -0,0 +1,208 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title", + "metadata": {}, + "source": [ + "# Flex `use_channels` redesign — hardware smoke test\n", + "\n", + "Drives the **new unified surface** on a real Flex: PLR-native `plate.column(c)` column\n", + "ops, single-tip `pick_up_tips(spot, use_channels=[7])` (the H1 front nozzle), and a\n", + "front-anchored **partial** column.\n", + "\n", + "**Structure:** run the **setup** cells top-to-bottom once (deck → connect → home → trash).\n", + "After that, the three **pipetting** cells (full column, single H1 tip, front-4 partial) are\n", + "each self-contained — they pick their own tips, pipette, discard, and return the gantry to\n", + "home — so you can run them in **any order**. Each uses a different tip-rack column, so they\n", + "don't collide.\n", + "\n", + "**This moves the robot.** Tip rack in **D1**, plate in **B1**, rows **A and C empty** (see\n", + "the deck rule below). Keep the e-stop in reach. Put liquid in the plate for real draws —\n", + "otherwise it aspirates air, fine for a motion check." + ] + }, + { + "cell_type": "markdown", + "id": "deck-rule", + "metadata": {}, + "source": [ + "## Deck-layout rule for single / partial tip pickup\n", + "\n", + "In a single- or partial-nozzle config the idle nozzles trail off one end of the head, so\n", + "keep the adjacent rows clear:\n", + "\n", + "| pickup nozzle | idle nozzles trail toward | labware allowed in | keep EMPTY |\n", + "|---|---|---|---|\n", + "| **H1** (front) | rear | rows **B** and **D** | rows **A** and **C** |\n", + "| **A1** (rear) | front | rows **A** and **C** | rows **B** and **D** |\n", + "\n", + "This notebook uses the **H1** convention: tip rack in **D1**, plate in **B1**, rows A and C\n", + "empty." + ] + }, + { + "cell_type": "markdown", + "id": "setup-header", + "metadata": {}, + "source": [ + "## Setup — run these once, top to bottom" + ] + }, + { + "cell_type": "markdown", + "id": "en8-note", + "metadata": {}, + "source": "Set `FLEX_HOST` below to your Flex's IP or USB address (the same address the Opentrons App uses; port 31950). On a link-local connection (`169.254.x.x`) you may need to wake the interface first, e.g.:\n\n```bash\nping -c2 -b 169.254.255.255 # wake the link, then plain HTTP routes\n```" + }, + { + "cell_type": "code", + "id": "imports", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "FLEX_HOST = \"169.254.1.1\" # <-- SET to your Flex's IP / USB address\n\nfrom pylabrobot.opentrons import FlexHead8, OpentronsFlex\nfrom pylabrobot.resources.opentrons import (\n FlexDeck,\n corning_96_wellplate_360ul_flat,\n flex_96_tiprack_50ul,\n)" + }, + { + "cell_type": "code", + "id": "deck", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "deck = FlexDeck()\ntip_rack = flex_96_tiprack_50ul(name=\"tips_01\")\nplate = corning_96_wellplate_360ul_flat(name=\"plate_01\")\n# H1 convention: labware only in rows B and D; rows A and C empty (see the deck rule).\ndeck.assign_child_at_slot(tip_rack, \"D1\")\ndeck.assign_child_at_slot(plate, \"B1\")\n# The trash is a deck feature; grab its handle now that the slots are populated.\ntrash = deck.get_trash_area()" + }, + { + "cell_type": "code", + "id": "connect", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "flex = OpentronsFlex(deck, host=FLEX_HOST)\n", + "await flex.setup()\n", + "\n", + "print(\"api_version:\", flex.api_version)\n", + "print(\"robot_model:\", flex.robot_model)\n", + "head = flex.left or flex.right\n", + "assert isinstance(head, FlexHead8), f\"expected FlexHead8, got {type(head)}\"\n", + "print(\"head:\", head, \"| mounted tips:\", head.get_mounted_tips())" + ] + }, + { + "cell_type": "code", + "id": "home-trash", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Finish setup: home the gantry. After this, the pipetting cells below are\n# self-contained and can be run in any order.\nawait flex.home()" + }, + { + "cell_type": "markdown", + "id": "pipetting-header", + "metadata": {}, + "source": [ + "## Pipetting — run these in any order\n", + "\n", + "Each cell picks its own tips (a different column each), pipettes, discards to the trash,\n", + "and homes. Re-running a cell tries to pick the same tips again, so run each once per pass." + ] + }, + { + "cell_type": "markdown", + "id": "column-md", + "metadata": {}, + "source": [ + "### Full column — `plate.column(c)` (ALL layout)" + ] + }, + { + "cell_type": "code", + "id": "column-cell", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# PLR-native: pass the list of wells, not (rack, column).\n", + "await head.pick_up_tips(tip_rack.column(0))\n", + "print(\"mounted:\", sum(1 for t in head.get_mounted_tips() if t is not None), \"tips\")\n", + "await head.aspirate(plate.column(0), volume=50)\n", + "await head.dispense(plate.column(0), volume=50)\n", + "await head.discard_tips(trash)\n", + "await flex.home() # park at home after the trash drop\n", + "print(\"full column done; mounted:\", sum(1 for t in head.get_mounted_tips() if t is not None))" + ] + }, + { + "cell_type": "markdown", + "id": "single-md", + "metadata": {}, + "source": [ + "### Single H1 tip — `use_channels=[7]` (SINGLE layout)\n", + "\n", + "`use_channels=[7]` configures the **H1** front nozzle; the single-nozzle liquid op is\n", + "guarded by the same idle-nozzle clearance check as pickup. Plate is in **B1** with the row\n", + "behind it (**A1**) empty, so H1 clears — the aspirate/dispense is allowed." + ] + }, + { + "cell_type": "code", + "id": "single-cell", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Picks from column 1 (well A2); lands the tip on channel 7.\n", + "await head.pick_up_tips(tip_rack.get_item(\"A2\"), use_channels=[7])\n", + "await head.aspirate(plate.get_item(\"A1\"), volume=20)\n", + "await head.dispense(plate.get_item(\"A1\"), volume=20)\n", + "await head.drop_single_tip(trash)\n", + "await flex.home() # park at home after the trash drop\n", + "print(\"single done; mounted:\", sum(1 for t in head.get_mounted_tips() if t is not None))" + ] + }, + { + "cell_type": "markdown", + "id": "partial-md", + "metadata": {}, + "source": "### Partial column — `use_channels=[4,5,6,7]` (QUADRANT layout)\n\nFront nozzles **E,F,G,H** (channels 4-7) pick the **first 4 rows (A-D)** of column 4. This emits Opentrons' own QUADRANT config (`primaryNozzle = frontRightNozzle = \"H1\"`, `backLeftNozzle = \"E1\"`), anchored at the D-row tip.\n\n**Why the first 4 and not the last 4:** all 8 nozzles descend to the same height, so the 4 *inactive* nozzles must be over empty space. Picking the first 4 rows puts the inactive rear nozzles **off the back of the rack** (behind row A); picking `[4:8]` (rows E-H) would put them over the A-D tips still in the column — a crash. The idle nozzles also trail into the empty **C1** row (rack in D1), per the deck rule.\n\n**Bench-first:** this exact partial-column config has not been run on hardware — watch it, e-stop ready." + }, + { + "cell_type": "code", + "id": "partial-cell", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "# Front nozzles E,F,G,H (channels 4-7) pick the FIRST 4 rows (A-D) of column 4.\n# Picking the first 4 keeps the inactive rear nozzles OFF the back of the rack (behind\n# row A), clear of the E-H tips still in that column. (Picking the LAST 4 [4:8] would put\n# the inactive nozzles over the A-D tips -> collision.)\nawait head.pick_up_tips(tip_rack.column(3)[0:4], use_channels=[4, 5, 6, 7])\nprint(\"partial channels with tips:\",\n [i for i, t in enumerate(head.get_mounted_tips()) if t is not None])\nawait head.discard_tips(trash)\nawait flex.home() # park at home after the trash drop\nprint(\"partial done; mounted:\", sum(1 for t in head.get_mounted_tips() if t is not None))" + }, + { + "cell_type": "markdown", + "id": "teardown-md", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "id": "stop", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await flex.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/opentrons/index.md b/docs/user_guide/opentrons/index.md new file mode 100644 index 00000000000..a65ebb9ac52 --- /dev/null +++ b/docs/user_guide/opentrons/index.md @@ -0,0 +1,8 @@ +# Opentrons + +```{toctree} +:maxdepth: 1 + +flex/hello-world +flex/use_channels_smoke +``` diff --git a/pylabrobot.code-workspace b/pylabrobot.code-workspace new file mode 100644 index 00000000000..876a1499c09 --- /dev/null +++ b/pylabrobot.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/pylabrobot/io/capture.py b/pylabrobot/io/capture.py index 62244cb363e..6eacf0763b7 100644 --- a/pylabrobot/io/capture.py +++ b/pylabrobot/io/capture.py @@ -102,14 +102,24 @@ def next_command(self) -> dict: return command def done(self): - if self._command_idx < len(self.commands): - left = len(self.commands) - self._command_idx - next_command = self.commands[self._command_idx] - raise ValidationError( - f"Log file not fully read, {left} lines left. First command: {next_command}" - ) - print("Validation successful!") - self.reset() + """Assert the capture was fully consumed, then end validation. + + Ending it is what lets the next validation build its io objects; they are + refused while a capture or validation is active. It happens even when the + assertion fails, so one short replay cannot strand every io after it. + """ + global _capture_or_validation_active + try: + if self._command_idx < len(self.commands): + left = len(self.commands) - self._command_idx + next_command = self.commands[self._command_idx] + raise ValidationError( + f"Log file not fully read, {left} lines left. First command: {next_command}" + ) + print("Validation successful!") + finally: + self._command_idx = 0 + _capture_or_validation_active = False def reset(self): self._command_idx = 0 diff --git a/pylabrobot/io/http.py b/pylabrobot/io/http.py index e69de29bb2d..803a5eb5417 100644 --- a/pylabrobot/io/http.py +++ b/pylabrobot/io/http.py @@ -0,0 +1,256 @@ +"""HTTP io for devices that speak a request/response API, recorded through the capture log. + +A device driven over HTTP needs its traffic pinned just like a serial one: +record a known-good run with ``pylabrobot.start_capture()``, then replay it +through :class:`HTTPValidator` to re-run the same protocol with no device +present. Failed exchanges are recorded too, so a device's refusals replay as +the same exception the driver saw live. + +:class:`HTTP` is deliberately not an :class:`~pylabrobot.io.io.IOBase`. That +base's ``write`` and ``read`` model a byte stream whose halves are separately +meaningful, while a request and its response are one indivisible exchange, so +the pair is the unit that gets recorded and replayed. + +Replaying takes four steps in this order, because building a validator is +refused once validation is active:: + + cr = CaptureReader(path) + io = HTTPValidator(cr, base_url="http://robot:31950") + cr.start() + ... # drive the device through io + cr.done() # raises if the protocol stopped short of the recording +""" + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, Optional, Union, cast + +from pylabrobot.io.capture import ( + CaptureReader, + Command, + capturer, + get_capture_or_validation_active, +) +from pylabrobot.io.errors import ValidationError +from pylabrobot.io.validation_utils import LOG_LEVEL_IO, align_sequences + +try: + import httpx # type: ignore[import-not-found] + + _HAS_HTTPX = True +except ImportError: + _HAS_HTTPX = False + +logger = logging.getLogger(__name__) + +# A parsed JSON body, or the raw text when the device did not answer in JSON. +Body = Union[Dict[str, Any], str] + + +@dataclass +class HTTPCommand(Command): + """One recorded exchange: what was asked, and what came back. + + ``status`` is kept so a non-2xx replays as the same refusal rather than + succeeding with an error body. + """ + + path: str + request: Optional[Dict[str, Any]] + response: Body + status: int + + def __init__( + self, + device_id: str, + action: str, + path: str, + response: Body, + status: int, + request: Optional[Dict[str, Any]] = None, + module: str = "http", + ): + super().__init__(module=module, device_id=device_id, action=action) + self.path = path + self.request = request + self.response = response + self.status = status + + +class HTTP: + """IO for a JSON-over-HTTP device API. + + The three verbs return the parsed body directly and raise on a non-2xx, so + callers never handle a response object. + """ + + def __init__( + self, + base_url: str, + timeout: float = 30.0, + headers: Optional[Dict[str, str]] = None, + ): + if not _HAS_HTTPX: + raise RuntimeError("httpx is required for HTTP io. Install with: pip install httpx") + self._base_url = base_url.rstrip("/") + self._timeout = timeout + self._headers = dict(headers or {}) + self._client: Optional["httpx.AsyncClient"] = None + + if get_capture_or_validation_active(): + raise RuntimeError("Cannot create a new HTTP object while capture or validation is active") + + @property + def base_url(self) -> str: + return self._base_url + + async def setup(self): + self._client = httpx.AsyncClient( + base_url=self._base_url, + timeout=self._timeout, + headers=self._headers, + ) + + async def stop(self): + if self._client is None: + return + await self._client.aclose() + self._client = None + + def serialize(self): + return { + "base_url": self._base_url, + "timeout": self._timeout, + "headers": self._headers, + "type": "HTTP", + } + + def _client_or_raise(self) -> "httpx.AsyncClient": + if self._client is None: + raise RuntimeError(f"HTTP io for '{self._base_url}' not set up; call setup() first") + return self._client + + async def get(self, path: str) -> Body: + return self._finish("get", path, None, await self._client_or_raise().get(path)) + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Body: + body = json or {} + return self._finish("post", path, body, await self._client_or_raise().post(path, json=body)) + + async def delete(self, path: str) -> Body: + return self._finish("delete", path, None, await self._client_or_raise().delete(path)) + + def _finish( + self, + action: str, + path: str, + request: Optional[Dict[str, Any]], + response: "httpx.Response", + ) -> Body: + """Record the exchange, then let a non-2xx raise. + + Recording precedes raising so a device's refusals land in the capture file; + they are the shapes a driver's error handling is written against. + """ + parsed = _parse_body(response) + logger.log( + LOG_LEVEL_IO, + "[%s] %s %s %s -> %s %s", + self._base_url, + action, + path, + request, + response.status_code, + parsed, + ) + capturer.record( + HTTPCommand( + device_id=self._base_url, + action=action, + path=path, + request=request, + response=parsed, + status=response.status_code, + ) + ) + if response.status_code >= 400: + raise _status_error(action, self._base_url, path, response.status_code, parsed) + return parsed + + +class HTTPValidator(HTTP): + """Replays a capture file instead of reaching the device.""" + + def __init__( + self, + cr: CaptureReader, + base_url: str, + timeout: float = 30.0, + headers: Optional[Dict[str, str]] = None, + ): + super().__init__(base_url=base_url, timeout=timeout, headers=headers) + self.cr = cr + + async def setup(self): + """No connection to open.""" + + async def stop(self): + """No connection to close.""" + + async def get(self, path: str) -> Body: + return self._replay("get", path, None) + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Body: + return self._replay("post", path, json or {}) + + async def delete(self, path: str) -> Body: + return self._replay("delete", path, None) + + def _replay(self, action: str, path: str, request: Optional[Dict[str, Any]]) -> Body: + recorded = HTTPCommand(**self.cr.next_command()) + if not ( + recorded.module == "http" + and recorded.device_id == self._base_url + and recorded.action == action + and recorded.path == path + ): + raise ValidationError( + f"Expected http {action} {path} to {self._base_url}, " + f"got {recorded.module} {recorded.action} {recorded.path} to {recorded.device_id}" + ) + if recorded.request != request: + align_sequences(expected=_pretty(recorded.request), actual=_pretty(request)) + raise ValidationError( + f"Request body mismatch on {action} {path}: difference was written to stdout." + ) + if recorded.status >= 400: + raise _status_error(action, self._base_url, path, recorded.status, recorded.response) + return recorded.response + + +def _parse_body(response: "httpx.Response") -> Body: + try: + return cast(Dict[str, Any], response.json()) + except ValueError: + return response.text + + +def _status_error( + action: str, base_url: str, path: str, status: int, body: Body +) -> "httpx.HTTPStatusError": + """The refusal a caller sees, built identically whether live or replayed.""" + rebuilt = ( + httpx.Response(status, text=body) + if isinstance(body, str) + else httpx.Response(status, json=body) + ) + return httpx.HTTPStatusError( + f"{status} from {action} {path}: {body}", + request=httpx.Request(action.upper(), base_url + path), + response=rebuilt, + ) + + +def _pretty(body: Optional[Dict[str, Any]]) -> str: + return json.dumps(body, indent=2, sort_keys=True, default=str) diff --git a/pylabrobot/io/http_tests.py b/pylabrobot/io/http_tests.py new file mode 100644 index 00000000000..3572f17933e --- /dev/null +++ b/pylabrobot/io/http_tests.py @@ -0,0 +1,188 @@ +"""Tests for the HTTP io: what it records, and that a replay reproduces the run.""" + +import json +import tempfile +import unittest +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Dict, List + +import pytest + +import pylabrobot +from pylabrobot.io.capture import CaptureReader +from pylabrobot.io.errors import ValidationError +from pylabrobot.io.http import HTTP, HTTPValidator + +if TYPE_CHECKING: + import httpx +else: + httpx = pytest.importorskip("httpx") + +from pylabrobot.testing.http_server import serving as _serving # noqa: E402 + +BASE_URL = "http://robot.test:31950" + + +def _ok(body: Dict[str, Any]) -> Callable[[httpx.Request], httpx.Response]: + return lambda request: httpx.Response(200, json=body) + + +class HTTPCaptureTests(unittest.IsolatedAsyncioTestCase): + """A live run through HTTP lands in the capture file, verb by verb.""" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.capture_file = Path(self._dir.name) / "capture.json" + + def tearDown(self): + self._dir.cleanup() + + def _recorded(self) -> List[Dict[str, Any]]: + with open(self.capture_file, "r") as f: + return list(json.load(f)["commands"]) + + async def test_get_records_path_and_response(self): + io = HTTP(base_url=BASE_URL) + with _serving(_ok({"api_version": "8.8.0"})): + await io.setup() + pylabrobot.start_capture(self.capture_file) + self.assertEqual(await io.get("/health"), {"api_version": "8.8.0"}) + pylabrobot.stop_capture() + + (command,) = self._recorded() + self.assertEqual(command["module"], "http") + self.assertEqual(command["device_id"], BASE_URL) + self.assertEqual(command["action"], "get") + self.assertEqual(command["path"], "/health") + self.assertIsNone(command["request"]) + self.assertEqual(command["response"], {"api_version": "8.8.0"}) + self.assertEqual(command["status"], 200) + + async def test_post_records_the_request_body(self): + io = HTTP(base_url=BASE_URL) + sent = {"data": {"commandType": "home", "params": {}}} + with _serving(_ok({"data": {"id": "cmd-1", "status": "succeeded"}})): + await io.setup() + pylabrobot.start_capture(self.capture_file) + await io.post("/runs/r1/commands", json=sent) + pylabrobot.stop_capture() + + (command,) = self._recorded() + self.assertEqual(command["action"], "post") + self.assertEqual(command["request"], sent) + + async def test_a_refusal_is_recorded_before_it_raises(self): + """The error shapes are the point: a 4xx must reach the capture file.""" + io = HTTP(base_url=BASE_URL) + refusal = {"errors": [{"errorType": "LocationIsOccupiedError"}]} + with _serving(lambda request: httpx.Response(409, json=refusal)): + await io.setup() + pylabrobot.start_capture(self.capture_file) + with self.assertRaises(httpx.HTTPStatusError): + await io.post("/runs/r1/commands", json={"data": {}}) + pylabrobot.stop_capture() + + (command,) = self._recorded() + self.assertEqual(command["status"], 409) + self.assertEqual(command["response"], refusal) + + async def test_a_non_json_body_is_recorded_as_text(self): + io = HTTP(base_url=BASE_URL) + with _serving(lambda request: httpx.Response(200, text="not json")): + await io.setup() + pylabrobot.start_capture(self.capture_file) + self.assertEqual(await io.get("/health"), "not json") + pylabrobot.stop_capture() + + (command,) = self._recorded() + self.assertEqual(command["response"], "not json") + + async def test_use_before_setup_is_refused(self): + io = HTTP(base_url=BASE_URL) + with self.assertRaisesRegex(RuntimeError, "not set up"): + await io.get("/health") + + +class HTTPReplayTests(unittest.IsolatedAsyncioTestCase): + """A recording replays the same run with nothing on the network.""" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.capture_file = Path(self._dir.name) / "capture.json" + + def tearDown(self): + self._dir.cleanup() + + async def _record_two_exchanges(self): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/health": + return httpx.Response(200, json={"api_version": "8.8.0"}) + return httpx.Response(200, json={"data": {"id": "run-1"}}) + + io = HTTP(base_url=BASE_URL) + with _serving(handler): + await io.setup() + pylabrobot.start_capture(self.capture_file) + await io.get("/health") + await io.post("/runs", json={"data": {}}) + pylabrobot.stop_capture() + + def _validator(self) -> HTTPValidator: + return HTTPValidator(CaptureReader(path=str(self.capture_file)), base_url=BASE_URL) + + async def test_replay_returns_the_recorded_responses_in_order(self): + await self._record_two_exchanges() + replay = self._validator() + await replay.setup() + + self.assertEqual(await replay.get("/health"), {"api_version": "8.8.0"}) + self.assertEqual(await replay.post("/runs", json={"data": {}}), {"data": {"id": "run-1"}}) + replay.cr.done() + + async def test_a_different_path_fails_validation(self): + await self._record_two_exchanges() + replay = self._validator() + await replay.setup() + + with self.assertRaisesRegex(ValidationError, "Expected http get /instruments"): + await replay.get("/instruments") + + async def test_a_changed_request_body_fails_validation(self): + await self._record_two_exchanges() + replay = self._validator() + await replay.setup() + + await replay.get("/health") + with self.assertRaisesRegex(ValidationError, "Request body mismatch"): + await replay.post("/runs", json={"data": {"changed": True}}) + + async def test_stopping_short_of_the_recording_fails(self): + """The no-cassette guard, in reverse: a dropped command must not pass.""" + await self._record_two_exchanges() + replay = self._validator() + await replay.setup() + + await replay.get("/health") + with self.assertRaisesRegex(ValidationError, "not fully read"): + replay.cr.done() + + async def test_a_recorded_refusal_replays_as_the_same_error(self): + io = HTTP(base_url=BASE_URL) + refusal = {"errors": [{"errorType": "LocationIsOccupiedError"}]} + with _serving(lambda request: httpx.Response(409, json=refusal)): + await io.setup() + pylabrobot.start_capture(self.capture_file) + with self.assertRaises(httpx.HTTPStatusError): + await io.post("/runs/r1/commands", json={"data": {}}) + pylabrobot.stop_capture() + + replay = self._validator() + await replay.setup() + with self.assertRaises(httpx.HTTPStatusError) as caught: + await replay.post("/runs/r1/commands", json={"data": {}}) + self.assertEqual(caught.exception.response.status_code, 409) + self.assertEqual(caught.exception.response.json(), refusal) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py new file mode 100644 index 00000000000..fe82d47a25d --- /dev/null +++ b/pylabrobot/opentrons/__init__.py @@ -0,0 +1,31 @@ +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_gripper import FlexGripper +from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96 +from pylabrobot.opentrons.robot import ( + OpentronsCommandError, + OpentronsError, + OpentronsRobot, + PipetteInfo, +) +from pylabrobot.opentrons.transport import ( + ChatterboxTransport, + HttpxTransport, + OpentronsTransport, + ReplayTransport, +) + +__all__ = [ + "ChatterboxTransport", + "FlexGripper", + "FlexHead1", + "FlexHead8", + "FlexHead96", + "HttpxTransport", + "OpentronsCommandError", + "ReplayTransport", + "OpentronsError", + "OpentronsFlex", + "OpentronsRobot", + "OpentronsTransport", + "PipetteInfo", +] diff --git a/pylabrobot/opentrons/checks.py b/pylabrobot/opentrons/checks.py new file mode 100644 index 00000000000..3a4abe906a2 --- /dev/null +++ b/pylabrobot/opentrons/checks.py @@ -0,0 +1,131 @@ +"""Native pre-dispatch verification checks for the Opentrons Flex backend. + +The Flex backend drives the robot-server Protocol Engine over HTTP, which runs its +own analysis pass. These checks rebuild the equivalent verification IN PLR — the +Hamilton STAR pattern (``hamilton/STAR_backend.py``) — so a call is refused *before* +dispatch, and PLR owns the check rather than delegating it to the vendor. +""" + +from typing import List, Optional + +from pylabrobot.opentrons.envelope import ( + FLEX_8CH_NOZZLE_A1_Y, + FLEX_ENVELOPE, + nozzle_offset_y, +) +from pylabrobot.opentrons.envelope import ( + FLEX_8CH_NOZZLE_H1_Y as FLEX_8CH_NOZZLE_H1_Y, # re-exported for callers importing from checks +) +from pylabrobot.opentrons.robot import PipetteInfo +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.errors import NoLocationError +from pylabrobot.resources.tip import Tip + +# OT-3 DEFAULT_GENERAL_ARC_Z_MARGIN (motion_planning/waypoints.py:12). +_DEFAULT_ARC_MARGIN = 10.0 + +# Every Flex tip rack (flex_96_tiprack_50ul / 200ul / 1000ul, and the filter rack) +# models to this z (tips included). The travel plane never drops below a tip rack, +# even on a deck the model shows as empty -- a rack physically present but not in +# the model (or not yet loaded on the robot) must still be cleared. +_FLEX_TIPRACK_HEIGHT = 99.0 + + +def check_volume_bounds(volume: float, pipette: PipetteInfo, tip: Optional[Tip] = None) -> None: + """Refuse a liquid-handling volume the pipette or tip cannot handle. + + Mirrors STAR's firmware-range asserts (``STAR_backend.py:4161``): the pipette has an + accurate ``[min_volume, max_volume]`` range and a mounted tip has a ``maximal_volume`` + capacity; a requested volume outside those is refused before dispatch. + + Args: + volume: the requested aspirate/dispense volume, in µL. + pipette: the mounted pipette (its ``min_volume``/``max_volume`` bound the range). + tip: the tip on the channel, if known (its ``maximal_volume`` is the capacity). + When ``None`` the tip-capacity check is skipped. + + Raises: + ValueError: if ``volume`` exceeds the pipette max, is below the pipette min (for a + non-zero volume), or exceeds the tip capacity. + """ + if volume > pipette.max_volume: + raise ValueError( + f"volume {volume}µL exceeds pipette max {pipette.max_volume}µL ({pipette.pipette_name})" + ) + if 0 < volume < pipette.min_volume: + raise ValueError( + f"volume {volume}µL is below pipette min {pipette.min_volume}µL ({pipette.pipette_name})" + ) + if tip is not None and volume > tip.maximal_volume: + raise ValueError(f"volume {volume}µL exceeds tip capacity {tip.maximal_volume}µL") + + +def traversal_z(deck: Resource, arc_margin: float = _DEFAULT_ARC_MARGIN) -> float: + """The tip-safe travel plane (tip-end / critical-point frame), from the resource model. + + The higher of: the tallest labware top on the deck plus ``arc_margin``, and an + unconditional tip-rack floor (``_FLEX_TIPRACK_HEIGHT`` + ``arc_margin``). The floor + means travel is always above a tip rack even when the deck model shows none -- a + rack physically present but unmodeled, or not yet loaded on the robot, is still + cleared. Pure function of the resource tree; no server round-trip. + """ + tops = [] + for child in deck.get_all_children(): + try: + tops.append(child.get_absolute_location(z="t").z) + except NoLocationError: + continue + computed = (max(tops) if tops else 0.0) + arc_margin + return max(computed, _FLEX_TIPRACK_HEIGHT + arc_margin) + + +def check_position_legal(coordinate: Coordinate, deck: Optional[Resource] = None) -> None: + """Refuse a nozzle target outside the Flex's addressable deck extents (coarse bound). + + Delegates to :data:`FLEX_ENVELOPE`. ``deck`` is accepted for call-site compatibility + but the envelope is the machine's, not the deck resource's. + """ + FLEX_ENVELOPE.check_bounds(coordinate) + + +def check_eight_channel_extents( + nozzle_target: Coordinate, + partial: bool, + active_nozzle_offset_y: float = FLEX_8CH_NOZZLE_A1_Y, +) -> None: + """Refuse an 8-channel single/partial move whose head casing exceeds the reach caps. + + Delegates to :meth:`FlexEnvelope.check_eight_channel`. + """ + FLEX_ENVELOPE.check_eight_channel(nozzle_target, partial, active_nozzle_offset_y) + + +def check_target_on_deck( + resource: Resource, + deck: Optional[Resource] = None, + pipette: Optional[PipetteInfo] = None, + use_channels: Optional[List[int]] = None, +) -> None: + """Refuse if ``resource``'s absolute location is unreachable for the pipette. + + Delegates to :meth:`FlexEnvelope.check_point`. A no-op when the resource has no + location yet (caught by the parent/labware checks, not here). + + For an 8-channel single/partial move the *active nozzle* sets the rear reach cap, so + the reference channel (``use_channels[0]``, paired with ``resource``) is threaded + through as its nozzle Y offset — a front (H1) nozzle at a rear row is refused where a + back (A1) nozzle would be allowed. Without this the permissive A1 default would let an + H1 partial move drive the head casing past the rear cap into the back panel. + """ + try: + coordinate = resource.get_absolute_location(x="c", y="c", z="b") + except NoLocationError: + return + channels = pipette.channels if pipette is not None else 1 + n_active = len(use_channels) if use_channels is not None else channels + offset = ( + nozzle_offset_y(use_channels[0]) if channels == 8 and use_channels else FLEX_8CH_NOZZLE_A1_Y + ) + FLEX_ENVELOPE.check_point( + coordinate, channels=channels, n_active=n_active, active_nozzle_offset_y=offset + ) diff --git a/pylabrobot/opentrons/envelope.py b/pylabrobot/opentrons/envelope.py new file mode 100644 index 00000000000..762a585f113 --- /dev/null +++ b/pylabrobot/opentrons/envelope.py @@ -0,0 +1,169 @@ +"""The grounded OT-3 (Flex) operating envelope — one source of truth for reach. + +Every reachability cap the Flex native checks use is a *derived property* of the +primitive OT-3 values below, each cited to its source in the installed ``opentrons`` +/ ``opentrons_shared_data`` package. Deriving the caps (rather than hardcoding e.g. +324.38) makes the source-to-cap relationship a rule in code that cannot silently +drift when a primitive changes -- exactly the drift the ``padding_rear`` value saw +between opentrons 8.3.0 (-177.42) and 8.8.1 (-169.42). +""" + +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Tuple + +from pylabrobot.resources import Coordinate + +if TYPE_CHECKING: + from pylabrobot.resources.opentrons.flex_deck import FlexDeck + +# 8-channel single-config active-nozzle Y offsets (9 mm pitch, A1 back .. H1 front), +# relative to the mount reference. pipette/.../eight_channel/p1000/3_5.json. +FLEX_8CH_NOZZLE_A1_Y = -16.0 +FLEX_8CH_NOZZLE_H1_Y = -79.0 +FLEX_8CH_NOZZLE_PITCH_Y = -9.0 # 9 mm pitch between adjacent nozzles (channel 0=A .. 7=H) + + +def nozzle_offset_y(channel: int) -> float: + """Y offset (from the mount reference) of an 8-channel nozzle by channel index. + + Channel 0 = A1 (rearmost, −16), channel 7 = H1 (frontmost, −79); linear at the 9 mm + pitch. The frontmost active nozzle (largest ``|offset|``) drives the head casing + furthest rearward, so this is what binds the rear reach cap for a single/partial move. + """ + return FLEX_8CH_NOZZLE_A1_Y + FLEX_8CH_NOZZLE_PITCH_Y * channel + + +@dataclass(frozen=True) +class FlexEnvelope: + """The OT-3 operating envelope as primitive values + derived caps. + + Frozen so an instance is a value object; use ``dataclasses.replace`` to explore a + perturbed envelope (the grounding test does this). + """ + + # --- primitives (grounded; do not pre-compute anything here) --- + deck_extent_x: float = 477.2 # robot/definitions/1/ot3.json extents[0] + deck_extent_y: float = 493.8 # ot3.json extents[1] (rear/home limit) + z_max: float = 300.0 # ot3controller.py axis_bounds Z_L/Z_R + carriage_offset_x: float = 477.20 # defaults_ot3.py:72 DEFAULT_CARRIAGE_OFFSET + carriage_offset_y: float = 493.8 + carriage_offset_z: float = 253.475 + padding_rear: float = -169.42 # ot3.json paddingOffsets.rear @ 8.8.1 (8.3.0 had -177.42) + padding_front: float = 51.8 # paddingOffsets.front + padding_left: float = 31.88 # paddingOffsets.leftSide + padding_right: float = -80.32 # paddingOffsets.rightSide + casing_depth_y: float = 95.0 # 8-channel head casing depth (mount ref .. front corner) + + # --- derived caps (never literals) --- + @property + def rear_cap_y(self) -> float: + """8-channel rear reach cap: deck extent plus the (negative) rear padding = 324.38.""" + return self.deck_extent_y + self.padding_rear + + @property + def front_cap_y(self) -> float: + """8-channel front reach cap = front padding = 51.8.""" + return self.padding_front + + @property + def home_xy(self) -> Tuple[float, float]: + """Home carriage reference in the deck frame (rear-right corner) = (477.2, 493.8).""" + return (self.carriage_offset_x, self.carriage_offset_y) + + def reach_y(self, active_nozzle_offset_y: float) -> Tuple[float, float]: + """Per-config nozzle-Y reach for an 8-channel single/partial move: ``(min_y, max_y)``. + + The 95 mm casing binds the reach: the casing front corner (mount_ref − casing_depth) + must stay <= rear_cap_y, and the casing back corner (mount_ref) must stay >= front_cap_y, + where mount_ref = nozzle_y − active_nozzle_offset_y. Solving for nozzle_y gives A1 (−16) + -> max 403.38, H1 (−79) -> max 340.38. + """ + max_y = self.rear_cap_y + self.casing_depth_y + active_nozzle_offset_y + min_y = self.front_cap_y + active_nozzle_offset_y + return (min_y, max_y) + + def check_bounds(self, coordinate: Coordinate) -> None: + """Refuse a target outside the coarse addressable deck envelope (per-axis).""" + if not 0.0 <= coordinate.x <= self.deck_extent_x: + raise ValueError(f"target x {coordinate.x:.1f} outside Flex reach [0, {self.deck_extent_x}]") + if not 0.0 <= coordinate.y <= self.deck_extent_y: + raise ValueError(f"target y {coordinate.y:.1f} outside Flex reach [0, {self.deck_extent_y}]") + if not 0.0 <= coordinate.z <= self.z_max: + raise ValueError(f"target z {coordinate.z:.1f} outside Flex Z [0, {self.z_max}]") + + def check_eight_channel( + self, + nozzle_target: Coordinate, + partial: bool, + active_nozzle_offset_y: float = FLEX_8CH_NOZZLE_A1_Y, + ) -> None: + """Refuse an 8-channel single/partial move whose 95 mm casing exceeds the reach caps. + + Full column (``partial=False``) is not extent-checked (mirrors Opentrons' + ``_is_within_pipette_extents`` 8-ch branch, which only bounds single/partial configs). + """ + if not partial: + return + mount_ref_y = nozzle_target.y - active_nozzle_offset_y + casing_front_y = mount_ref_y - self.casing_depth_y + casing_back_y = mount_ref_y + if casing_front_y > self.rear_cap_y: + _, reach = self.reach_y(active_nozzle_offset_y) + raise ValueError( + f"8-channel single/partial move: nozzle Y {nozzle_target.y:.1f} drives the casing to " + f"Y {casing_front_y:.1f} > rear cap {self.rear_cap_y:.2f} — would hit the back panel " + f"(rear reach for this nozzle is Y <= {reach:.2f})" + ) + if casing_back_y < self.front_cap_y: + raise ValueError( + f"8-channel single/partial move: nozzle Y {nozzle_target.y:.1f} puts the casing at " + f"Y {casing_back_y:.1f} < front cap {self.front_cap_y:.2f}" + ) + + def check_point( + self, + coordinate: Coordinate, + channels: int, + n_active: int, + active_nozzle_offset_y: float = FLEX_8CH_NOZZLE_A1_Y, + ) -> None: + """The single check surface: coarse bounds, plus the 8-ch casing caps when partial.""" + self.check_bounds(coordinate) + if channels == 8 and n_active < 8: + self.check_eight_channel( + coordinate, partial=True, active_nozzle_offset_y=active_nozzle_offset_y + ) + + def unreachable_slots( + self, + deck: "FlexDeck", + channels: int, + n_active: int, + active_nozzle_offset_y: float = FLEX_8CH_NOZZLE_A1_Y, + ) -> List[str]: + """The standard slots (A1–D3) whose center this pipette config cannot reach. + + Combines the deck-grid geometry (slot centers) with the per-config reach caps: a + slot is unreachable when :meth:`check_point` refuses its center. Staging slots + (A4–D4) are excluded — they are gripper-only storage, not pipette targets. This is + the executable form of "a labware assigned to a rear slot is not necessarily + reachable by this pipette config." + """ + from pylabrobot.resources.opentrons.flex_deck import ( + SLOT_DEPTH, + SLOT_LOCATIONS, + SLOT_WIDTH, + ) + + out: List[str] = [] + for slot in sorted(SLOT_LOCATIONS): + loc = deck.get_slot_location(slot) + center = Coordinate(loc["x"] + SLOT_WIDTH / 2, loc["y"] + SLOT_DEPTH / 2, loc["z"]) + try: + self.check_point(center, channels, n_active, active_nozzle_offset_y) + except ValueError: + out.append(slot) + return out + + +FLEX_ENVELOPE = FlexEnvelope() diff --git a/pylabrobot/opentrons/flex.py b/pylabrobot/opentrons/flex.py new file mode 100644 index 00000000000..462d228eb68 --- /dev/null +++ b/pylabrobot/opentrons/flex.py @@ -0,0 +1,573 @@ +import logging +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, cast + +from pylabrobot.opentrons.flex_gripper import FlexGripper +from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96, _FlexHead +from pylabrobot.opentrons.flex_wire import ROBOT_AXES, _require_robot_commands, slot_wire_location +from pylabrobot.opentrons.labware_definitions import ( + build_container_definition, + build_movable_labware_definition, + build_plate_definition, + build_tip_rack_definition, +) +from pylabrobot.opentrons.robot import COMMAND_POLL_HEADROOM, OpentronsError, OpentronsRobot +from pylabrobot.opentrons.transport import OpentronsTransport +from pylabrobot.resources import Container, Plate, Resource, TipRack +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.trash import Trash + +logger = logging.getLogger(__name__) + +_OT_NAMESPACE = "opentrons" + +# Every definition has a revision 1, so it is the only version safe to assume. +_OT_VERSION = 1 + +# Discovered pipette channel count -> matching head class. +_CHANNELS_TO_HEAD: Dict[int, Type[_FlexHead]] = { + 1: FlexHead1, + 8: FlexHead8, + 96: FlexHead96, +} + + +def _has_pipettable_geometry(resource: Resource) -> bool: + """Whether a real, well-bearing definition can be built from this resource.""" + return isinstance(resource, (Plate, TipRack, Container)) + + +def _not_pipettable_error(resource: Resource) -> OpentronsError: + """The refusal for a resource no pipettable definition can be built from.""" + return OpentronsError( + "Cannot build an Opentrons labware definition", + f"'{resource.name}' ({type(resource).__name__}) has no Opentrons load name, and a " + "definition can only be built from the geometry of a Plate, TipRack, or Container. " + "Set resource.ot_load_name to an official Opentrons load name. The gripper can still " + "move it -- only pipetting needs real well geometry.", + ) + + +def _warn_grip_distance_discarded( + name: str, grip_distance_from_top: Optional[float], why: str +) -> None: + """Log a caller's grip distance being dropped, naming why it cannot apply.""" + if grip_distance_from_top is None: + return + logger.warning( + "grip_distance_from_top=%s ignored for '%s': %s.", grip_distance_from_top, name, why + ) + + +def _validate_axes(axes: Iterable[str]) -> None: + """Raise for any axis name the robot does not address.""" + unknown = sorted(set(axes) - ROBOT_AXES) + if unknown: + raise ValueError( + f"unknown axes: {', '.join(unknown)}. Valid axes are: {', '.join(sorted(ROBOT_AXES))}." + ) + + +def _reported_axis_position(command: Dict[str, Any]) -> Dict[str, float]: + """The axis positions a completed robot/moveAxes* command reports.""" + return cast(Dict[str, float], command.get("result", {}).get("position", {})) + + +def _axis_motion_params( + axis_map: Dict[str, float], + speed: Optional[float], + critical_point: Optional[Dict[str, float]] = None, +) -> Dict[str, Any]: + """The snake_case payload a robot/moveAxes* command takes (unlike the rest of + the API, which is camelCase), with every axis name validated first.""" + _validate_axes(axis_map) + params: Dict[str, Any] = {"axis_map": axis_map} + if critical_point is not None: + _validate_axes(critical_point) + params["critical_point"] = critical_point + if speed is not None: + params["speed"] = speed + return params + + +class OpentronsFlex(OpentronsRobot): + """Opentrons Flex liquid handler (plain class, post-#1180 architecture). + + 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] = {} + # resource.name -> (namespace, load_name, version) of an uploaded custom definition. + self._defined_labware: Dict[str, Tuple[str, str, int]] = {} + # Names loaded under the non-pipettable movable stub. Tracked separately so + # a pipetting op refuses them even on a load-cache hit. + self._stub_labware: Set[str] = set() + self.left: Optional[_FlexHead] = None + self.right: Optional[_FlexHead] = None + self.head96: Optional[_FlexHead] = None + self.gripper: Optional[FlexGripper] = None + self._heads: List[_FlexHead] = [] + + def attach_deck(self, deck: FlexDeck) -> None: + """Swap in a new deck, so a caller can describe the deck without rebuilding + the robot (and losing the link and the run with it). + + Clears the labware caches: their ids describe the deck being replaced, and + serving one for the new deck would address the wrong slot. + """ + self.deck = deck + self._loaded_labware.clear() + self._defined_labware.clear() + self._stub_labware.clear() + + async def _create_run(self) -> str: + # labwareIds and uploaded definitions are both run-scoped server-side, so + # a new run must not serve cached identities from a previous one. + run_id = await super()._create_run() + self._loaded_labware.clear() + self._defined_labware.clear() + self._stub_labware.clear() + return run_id + + async def _model_setup(self) -> None: + """Discover and compose heads. Homing is setup()'s own step, so that a + caller can ask what is mounted without moving the robot.""" + # Discover ALL mounted pipettes (not just the first — _discover_pipette + # only surfaces one) and compose the matching head per mount. The base + # setup() no longer discovers/loads a pipette itself (that would double + # `loadPipette` the first mount), so this is the only place a Flex loads + # its pipettes. + # Discovery is re-runnable: drop whatever a previous setup composed rather + # than stacking a second set of heads onto dead pipette ids. + self.left = self.right = self.head96 = None + self.gripper = None + self._heads.clear() + + instruments_data = await self._get_instruments() + pipettes = self._parse_pipettes(instruments_data) + + 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, pip.pipette_model, pip.max_volume) + + 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() + + # The gripper (extension mount) is optional: compose it when discovery + # reports one, leave ``self.gripper`` None otherwise. + gripper_model = self._parse_gripper(instruments_data) + if gripper_model is not None: + self.gripper = FlexGripper(self, gripper_model) + logger.info("Discovered gripper on the extension mount (model: %s)", gripper_model) + + def _parse_gripper(self, instruments_data: Dict[str, Any]) -> Optional[str]: + """Parse the /instruments response for a mounted gripper. + + Returns the gripper's model string, or ``None`` when none is mounted. + Separate from ``_parse_pipettes``, which filters to + ``instrumentType == 'pipette'`` and knows nothing about grippers. + """ + for instrument in instruments_data.get("data", []): + if instrument.get("instrumentType") == "gripper": + return cast(str, instrument.get("instrumentModel", "unknown")) + return None + + async def stop(self) -> None: + # Drop any mounted tips to the trash BEFORE parking/disconnecting, so the + # robot is never left holding tips. A failure here must not block the + # 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, + *, + allow_stub: bool = False, + grip_distance_from_top: Optional[float] = None, + ) -> str: + """Load labware into the Flex run if not already loaded. + + ``allow_stub`` opts into the non-pipettable movable stub definition for a + resource no real definition can be built from (a lid, an adapter, a tube + rack). Only the gripper passes it: the stub's single fake well is + somewhere to grip, not somewhere to pipette, and a zero-depth well at the + labware's own bottom would put a tip on the deck. Every other caller gets + an ``OpentronsError`` for such a resource, before any wire command -- + including when the gripper already loaded it earlier in the run. + + ``grip_distance_from_top`` feeds an uploaded custom definition's grip + height and is honored on the FIRST load only; a cache hit (already + loaded, or definition already uploaded) reuses the stored identity + unchanged, and labware resolving to an official Opentrons load name + ignores it entirely (the robot's own definition owns the grip height). + Every discard is logged. + """ + name = getattr(resource, "name", str(resource)) + if not allow_stub and name in self._stub_labware: + raise _not_pipettable_error(resource) + if name in self._loaded_labware: + _warn_grip_distance_discarded( + name, + grip_distance_from_top, + "it is already loaded in this run, and the grip height rides the definition it " + "was loaded with", + ) + return self._loaded_labware[name] + + slot = self.deck.get_slot(resource) + 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').", + ) + + try: + load_name, version = self._ot_declared_identity(resource) + except OpentronsError: + # No official Opentrons definition: build one from the resource's PLR + # geometry, upload it, and load by the uploaded definition's identity. + namespace, load_name, version = await self._define_custom_labware( + resource, grip_distance_from_top, allow_stub + ) + else: + namespace = _OT_NAMESPACE + _warn_grip_distance_discarded( + name, + grip_distance_from_top, + f"it loads the robot's own definition for '{load_name}', whose grip height is " + "the vendor's to state (the robot grips at mid-height when it states none)", + ) + # The robot assigns the id. Proposing one here would only make the + # request body differ run to run, which is what breaks a replayed capture. + result = await self._execute_command( + "loadLabware", + { + "loadName": load_name, + "location": slot_wire_location(slot), + "namespace": namespace, + "version": version, + "displayName": name, + }, + ) + labware_id = result.get("result", {}).get("labwareId") + if not isinstance(labware_id, str): + raise OpentronsError( + "Labware load returned no id", + f"loadLabware for '{name}' succeeded but reported no labwareId to address it by.", + ) + + self._loaded_labware[name] = labware_id + logger.info( + "Loaded labware '%s' at slot %s -> ID: %s (OT: %s)", + name, + slot, + labware_id, + load_name, + ) + return labware_id + + async def sync_tips_to_robot(self, tip_rack: TipRack) -> None: + """Make the robot's tip-rack model agree with PyLabRobot's. + + The robot keeps its own record of which spots in a tip rack still hold a + tip, and it is only ever changed by pickups and drops it performed itself. + So anything that changes the rack outside a run -- a human taking tips, a + rack swapped for a partly-used one, state restored from a database -- + leaves the two records disagreeing, with no symptom until the robot picks + up from a spot it believes is full. + + This pushes PyLabRobot's record onto the robot in one ``setTipState`` per + state, taking the resource tree as the truth. Set the tip layout first + (``tip_rack.set_tip_state(...)``), then call this. + + There is no equivalent for liquid. ``loadLiquid`` is the robot's only way + in, and it refuses any liquid the run did not already define, which a run + created without a protocol never does. Well volumes therefore live only in + the resource tree. + """ + labware_id = await self._ensure_labware_loaded(tip_rack) + # The robot grades a tip-rack well "clean"/"used"/"empty". PyLabRobot only + # records whether a tip is there, so an occupied spot maps to "clean". + occupied: List[str] = [] + empty: List[str] = [] + for spot in tip_rack.get_all_items(): + (occupied if spot.has_tip() else empty).append(tip_rack.get_child_identifier(spot)) + + for well_names, state in ((occupied, "clean"), (empty, "empty")): + if not well_names: + continue + await self._execute_command( + "setTipState", + {"labwareId": labware_id, "wellNames": well_names, "tipWellState": state}, + ) + logger.info( + "Synced '%s' tip state to the robot: %d with a tip, %d empty", + tip_rack.name, + len(occupied), + len(empty), + ) + + async def labware_moved_off_deck(self, resource: Resource) -> None: + """Tell the robot an EXTERNAL agent (human or lab transporter) removed labware. + + A logical move, not a gripper motion: ``moveLabware`` with strategy + ``manualMoveWithoutPause`` drops the labware from the robot-server's deck + model, freeing its slot. Without this the slot stays occupied server-side + and a later load into it fails with ``LocationIsOccupiedError``. The + PLR-side deck slot is freed too. No wire command is sent for labware that + was never loaded into the run; a re-add later loads fresh at its new slot + and re-uploads its definition -- a different same-named resource must not + inherit the departed labware's geometry. + """ + name = getattr(resource, "name", str(resource)) + if name in self._loaded_labware: + await self._execute_command( + "moveLabware", + { + "labwareId": self._loaded_labware[name], + "newLocation": "offDeck", + "strategy": "manualMoveWithoutPause", + }, + ) + del self._loaded_labware[name] + self._defined_labware.pop(name, None) + self._stub_labware.discard(name) + slot = self.deck.get_slot(resource) + if slot is not None: + self.deck.unassign_child_at_slot(slot) + logger.info("Labware '%s' marked moved off-deck", name) + + async def reload_labware(self, resource: Resource) -> None: + """Re-read the labware's position after a human moved it in its own slot. + + For labware nudged or re-seated where it already sits: the robot re-applies + its labware offset, keeping the slot. Use ``gripper.move_labware`` to move + it to another slot, and ``labware_moved_off_deck`` when it leaves the deck. + """ + labware_id = await self._ensure_labware_loaded(resource) + await self._execute_command("reloadLabware", {"labwareId": labware_id}) + + # --- Robot-level commands: axis motion, status surfaces, run log --- + + async def move_axes_to( + self, + axis_map: Dict[str, float], + critical_point: Optional[Dict[str, float]] = None, + speed: Optional[float] = None, + ) -> Dict[str, float]: + """Move the named axes to absolute deck-frame positions (mm), returning where they land. + + Every axis the map does not name holds its current position. + ``critical_point`` offsets the point on the mount that is driven to those + positions, also as a per-axis map. ``speed`` is in mm/s (robot default if + None). + + Raises: + ValueError: If any axis is not one the robot addresses. Raised before + any wire command is sent. + """ + _require_robot_commands("robot/moveAxesTo", self.api_version) + params = _axis_motion_params(axis_map, speed, critical_point) + return _reported_axis_position(await self._execute_command("robot/moveAxesTo", params)) + + async def move_axes_relative( + self, axis_map: Dict[str, float], speed: Optional[float] = None + ) -> Dict[str, float]: + """Jog the named axes by distances (mm) from where they are, returning where they land. + + Raises: + ValueError: If any axis is not one the robot addresses. Raised before + any wire command is sent. + """ + _require_robot_commands("robot/moveAxesRelative", self.api_version) + params = _axis_motion_params(axis_map, speed) + return _reported_axis_position(await self._execute_command("robot/moveAxesRelative", params)) + + async def retract_axis(self, axis: str) -> None: + """Retract ``axis`` to its home position, clearing the deck below it. + + Raises: + ValueError: If ``axis`` is not one the robot addresses. Raised before + any wire command is sent. + """ + _validate_axes([axis]) + await self._execute_command("retractAxis", {"axis": axis}) + + async def set_status_bar(self, animation: str) -> None: + """Play a built-in light-bar animation: "idle", "confirm", "updating", "disco" or "off". + + An animation is all the status bar takes: the robot owns the colours and + the timing, so there is nothing per-light to address. + """ + await self._execute_command("setStatusBar", {"animation": animation}) + + async def set_rail_lights(self, on: bool) -> None: + """Turn the deck rail lights on or off.""" + await self._execute_command("setRailLights", {"on": on}) + + async def add_comment(self, message: str) -> None: + """Record ``message`` in the run's command log; the robot does nothing else with it.""" + await self._execute_command("comment", {"message": message}) + + async def wait_for_duration(self, seconds: float) -> None: + """Hold the run for ``seconds`` before the next command runs.""" + # The command only completes once the robot finishes waiting, so the poll + # gets the wait itself plus the usual command headroom. + await self._execute_command( + "waitForDuration", {"seconds": seconds}, timeout=seconds + COMMAND_POLL_HEADROOM + ) + + @staticmethod + def _ot_declared_identity(resource: Resource) -> Tuple[str, int]: + """The load name and version to load a resource by, if it declares one. + + Declaring ``ot_load_name`` is the whole rule: a resource that does one is + loaded by name, and a resource that does not gets a definition built from + its own geometry and uploaded. Whether the robot can actually resolve a + declared name is the ROBOT's to answer, not ours. It looks in the + definitions its own software shipped with AND in the custom labware a lab + has added to it, so no list on this side can be right for every robot. A + name it cannot resolve fails the ``loadLabware``, which surfaces where a + caller expects it: at the point the labware goes onto the deck. + + ``ot_version`` picks the revision, and which revisions a robot holds is the + robot's business too, so this defaults to 1 rather than guessing higher. + Revision 1 is the one every definition has. It is also the OLDEST, and for + much of Opentrons' own catalogue it predates the Flex and states no gripper + grip height, so the robot grips at the labware's mid-height rather than + where the vendor says. A later revision usually fixes that while leaving the + well geometry alone, which is why the resources PyLabRobot ships for those + plates declare one. Naming a revision the robot does not hold fails the + load, so raising the default here would break older robots to grip better on + newer ones. + """ + declared = getattr(resource, "ot_load_name", None) + if declared is None: + raise OpentronsError( + "No Opentrons load name declared", + f"'{resource.name}' carries no ot_load_name, so there is no name to load it by.", + ) + + load_name = cast(str, declared) + version = getattr(resource, "ot_version", None) + if version is None: + version = _OT_VERSION + return load_name, cast(int, version) + + async def _define_custom_labware( + self, + resource: Resource, + grip_distance_from_top: Optional[float] = None, + allow_stub: bool = False, + ) -> Tuple[str, str, int]: + """Upload a geometry-derived definition for labware with no official Opentrons definition. + + Returns the uploaded definition's (namespace, load_name, version), parsed + from the robot-server's ``definitionUri`` so the subsequent ``loadLabware`` + references exactly what the server stored. The parsed identity is cached + (separately from ``_loaded_labware``) until the labware leaves the deck or + a new run starts, so repeat calls within a stay on deck skip the + re-upload -- which also means ``grip_distance_from_top`` only shapes the + FIRST upload. + """ + name = resource.name + if name in self._defined_labware: + _warn_grip_distance_discarded( + name, + grip_distance_from_top, + "its custom definition was already uploaded in this run, with the grip height it " + "carried then", + ) + return self._defined_labware[name] + + definition = self._build_labware_definition(resource, grip_distance_from_top, allow_stub) + assert self.run_id is not None, "No active run. Call setup() first." + data = await self._post(f"/runs/{self.run_id}/labware_definitions", {"data": definition}) + uri = cast(str, data["data"]["definitionUri"]) + namespace, load_name, version = uri.split("/") + self._defined_labware[name] = (namespace, load_name, int(version)) + if not _has_pipettable_geometry(resource): + self._stub_labware.add(name) + logger.info("Uploaded custom labware definition for '%s': %s", name, uri) + return self._defined_labware[name] + + @staticmethod + def _build_labware_definition( + resource: Resource, + grip_distance_from_top: Optional[float] = None, + allow_stub: bool = False, + ) -> dict: + """Build the definition matching the resource's type, or raise for unbuildable labware. + + Pipettable types get real-geometry definitions. Any other resource (lid, + adapter, tube rack, ...) has no wells the robot can pipette, so it builds + only as the non-pipettable movable stub, which ``allow_stub`` opts into. + Geometry an Opentrons definition cannot describe (a rotated plate, a + cavity floor the resource never declared) is refused here, before any + wire command, rather than uploaded for the robot to act on. + """ + try: + if isinstance(resource, Plate): + return build_plate_definition(resource, grip_distance_from_top) + if isinstance(resource, TipRack): + return build_tip_rack_definition(resource, grip_distance_from_top) + if isinstance(resource, Container): + return build_container_definition(resource, grip_distance_from_top) + except ValueError as e: + raise OpentronsError("Cannot build an Opentrons labware definition", str(e)) from e + if not allow_stub: + raise _not_pipettable_error(resource) + return build_movable_labware_definition(resource, grip_distance_from_top) diff --git a/pylabrobot/opentrons/flex_container_tests.py b/pylabrobot/opentrons/flex_container_tests.py new file mode 100644 index 00000000000..cb9488d6bee --- /dev/null +++ b/pylabrobot/opentrons/flex_container_tests.py @@ -0,0 +1,1131 @@ +"""Tests for container (trough/reservoir) ops on the Flex heads, and for the +robot-level commands that belong to the device rather than to a head. + +A bare PLR ``Container`` is a single-cavity resource with ONE volume tracker; +robot-side single-cavity labware definitions expose exactly one well, named +"A1", and carry the ``centerMultichannelOnWells`` quirk, so the ENGINE +centers the nozzle array in the cavity. These tests pin the wire shape +(wellName "A1", no manual centering offsets -- only the caller's +offset/liquid_height ride the wire), the one-tracker staging semantics (each +channel holding a tip moves ``volume``; the summed delta commits/rolls back +as one op), and the pre-wire rejections (no tip, array or offset-shifted +array overhanging the cavity). + +The robot-level tests pin the wire shape of the device's own commands: the +snake_case params the robot/* family takes (unlike the rest of the API), the +axis-name and version-gate refusals that must send nothing, and the exact +params of the status, comment, wait and reload commands. +""" + +import asyncio +import unittest +from typing import Any, Dict, List, Optional, Tuple, Type + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead1, FlexHead8, FlexHead96 +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import OFFLINE_API_VERSION, ChatterboxTransport +from pylabrobot.resources import ( + Container, + cor_96_wellplate_360uL_Fb, + set_tip_tracking, + set_volume_tracking, +) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.rotation import Rotation + + +class _FailingAspirateTransport(ChatterboxTransport): + """Chatterbox whose ``aspirate`` POST raises -- models a wire-level failure + AFTER trackers are staged, driving the rollback paths.""" + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if path.endswith("/commands") and (json or {}).get("data", {}).get("commandType") == "aspirate": + raise RuntimeError("simulated aspirate wire failure") + return await super().post(path, json) + + +class _FailingDispenseTransport(ChatterboxTransport): + """Like ``_FailingAspirateTransport`` but for ``dispense`` commands.""" + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if path.endswith("/commands") and (json or {}).get("data", {}).get("commandType") == "dispense": + raise RuntimeError("simulated dispense wire failure") + return await super().post(path, json) + + +def _make_trough( + name: str = "trough", + size_x: float = 127.76, + size_y: float = 85.48, + max_volume: float = 195000.0, +) -> Container: + """A single-cavity reservoir built directly with the PLR ``Container`` class, + mapped to a real Opentrons single-cavity load name. + + The default size is that reservoir's OUTER footprint, the convention a PLR + container carries (its cavity is narrower, and PLR does not model it). + """ + trough = Container( + name=name, + size_x=size_x, + size_y=size_y, + size_z=31.4, + material_z_thickness=1.0, + max_volume=max_volume, + ) + trough.ot_load_name = "nest_1_reservoir_195ml" # type: ignore[attr-defined] + return trough + + +def _flex_head1( + transport_cls: Type[ChatterboxTransport] = ChatterboxTransport, +) -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead1]: + """An ``OpentronsFlex`` with a single-channel head on the right mount, plus + the transport (for command inspection) and the head itself. + """ + transport = transport_cls(pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.right + assert isinstance(head, FlexHead1) + return flex, transport, head + + +def _flex_head8( + transport_cls: Type[ChatterboxTransport] = ChatterboxTransport, +) -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead8]: + """An ``OpentronsFlex`` with an 8-channel head on the left mount, plus the + transport (for command inspection) and the head itself. + """ + transport = transport_cls(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, transport, head + + +def _flex_head96( + transport_cls: Type[ChatterboxTransport] = ChatterboxTransport, +) -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead96]: + """An ``OpentronsFlex`` with a 96-channel head, plus the transport (for + command inspection) and the head itself. + """ + transport = transport_cls(pipettes=[("p1000_96", 96, 1.0, 1000.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.head96 + assert isinstance(head, FlexHead96) + return flex, transport, head + + +class TestFlexHead1ContainerOps(unittest.TestCase): + """FlexHead1 aspirate/dispense accept a bare Container: the container is its + own robot-side labware addressed at its sole well "A1", and volume is + tracked against the container's single tracker. + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_aspirate_names_container_labware_at_well_a1(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(1000.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.aspirate(trough, volume=50)) + + load_cmds = [ + c + for c in transport.commands + if c["commandType"] == "loadLabware" and c["params"]["loadName"] == "nest_1_reservoir_195ml" + ] + self.assertEqual(len(load_cmds), 1, "the container itself must be loaded as labware") + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") + self.assertEqual(aspirate_cmds[0]["params"]["labwareId"], transport.labware_ids["trough"]) + # A single nozzle goes to the cavity center: no x/y centering offset, + # just the default bottom clearance. + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"], + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, + ) + + self.assertAlmostEqual(trough.tracker.volume, 950.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_adds_volume_to_container_tracker(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.dispense(trough, volume=30)) + + dispense_cmds = [c for c in transport.commands if c["commandType"] == "dispense"] + self.assertEqual(len(dispense_cmds), 1) + self.assertEqual(dispense_cmds[0]["params"]["wellName"], "A1") + self.assertAlmostEqual(trough.tracker.volume, 30.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_without_tip_rejects_before_any_wire_command(self): + flex, transport, head = _flex_head1() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(1000.0) + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(trough, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 1000.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_container_without_tip_rejects_before_any_wire_command(self): + flex, transport, head = _flex_head1() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.dispense(trough, volume=30)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 0.0) + finally: + asyncio.run(flex.stop()) + + def test_wire_failure_rolls_back_container_tracker(self): + flex, _transport, head = _flex_head1(transport_cls=_FailingAspirateTransport) + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(1000.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(RuntimeError): + asyncio.run(head.aspirate(trough, volume=50)) + + self.assertAlmostEqual(trough.tracker.volume, 1000.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 1000.0) + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead8ContainerOps(unittest.TestCase): + """FlexHead8 aspirate_container/dispense_container fan all 8 nozzles into + one cavity: ONE command at well "A1" with no manual centering (the + definition's centerMultichannelOnWells quirk makes the engine center the + 63 mm nozzle row), and the container's single tracker moves + volume * (channels holding tips) as one committed/rolled-back op. + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_aspirate_container_sends_one_uncentered_command_at_a1(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_container(trough, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + params = aspirate_cmds[0]["params"] + self.assertEqual(params["wellName"], "A1") + self.assertEqual(params["volume"], 50) + # No manual x/y centering: the engine centers the nozzle row on the + # cavity itself (centerMultichannelOnWells). Only the default bottom + # clearance rides the wire. + self.assertEqual( + params["wellLocation"], + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, + ) + + cmd_types = [c["commandType"] for c in transport.commands] + self.assertNotIn( + "prepareToAspirate", + cmd_types, + "naming the well leaves priming to the robot, which does it at the well top", + ) + + # All 8 channels hold a tip, so the single tracker loses 8 * 50. + self.assertAlmostEqual(trough.tracker.volume, 9600.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 9600.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_tracker_scales_with_mounted_tips(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + # Leave only 3 tips in column 0 (rows A, D, H) before the pickup. + column_0_spots = rack.get_all_items()[0:8] + for i in (1, 2, 4, 5, 6): + column_0_spots[i].tracker.remove_tip(commit=True) + + asyncio.run(head.pick_up_tips(rack, column=0)) + self.assertEqual(sum(1 for t in head.get_mounted_tips() if t is not None), 3) + + asyncio.run(head.aspirate_container(trough, volume=50)) + + # Exactly the 3 tip-holding channels draw 50 each: -150, not -400. + self.assertAlmostEqual(trough.tracker.volume, 9850.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_container_adds_volume_per_mounted_tip(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.dispense_container(trough, volume=40)) + + dispense_cmds = [c for c in transport.commands if c["commandType"] == "dispense"] + self.assertEqual(len(dispense_cmds), 1) + params = dispense_cmds[0]["params"] + self.assertEqual(params["wellName"], "A1") + self.assertEqual( + params["wellLocation"], + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, + ) + self.assertAlmostEqual(trough.tracker.volume, 320.0) + finally: + asyncio.run(flex.stop()) + + def test_offset_and_liquid_height_merge_into_well_location(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run( + head.aspirate_container( + trough, volume=10, offset=Coordinate(x=2, y=-1, z=0.5), liquid_height=3 + ) + ) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + # Only the caller's offset rides the wire (centering is engine-side); + # liquid_height adds to z, replacing the default clearance. + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"], + {"origin": "bottom", "offset": {"x": 2, "y": -1, "z": 3.5}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_without_tip_rejects_before_any_wire_command(self): + flex, transport, head = _flex_head8() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate_container(trough, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 10000.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_rejects_cavity_narrower_than_nozzle_row(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + narrow = _make_trough(name="narrow", size_y=40.0, max_volume=50000.0) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(narrow, "C2") + narrow.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + + # 40 mm front-to-back cannot contain the 63 mm nozzle row. + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate_container(narrow, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(narrow.tracker.volume, 10000.0) + self.assertAlmostEqual(narrow.tracker.get_used_volume(), 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_wire_failure_rolls_back_container_tracker(self): + flex, _transport, head = _flex_head8(transport_cls=_FailingAspirateTransport) + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + with self.assertRaises(RuntimeError): + asyncio.run(head.aspirate_container(trough, volume=50)) + + # The staged 8 * 50 uL is rolled back in full: committed AND pending + # volume are untouched. + self.assertAlmostEqual(trough.tracker.volume, 10000.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_container_without_tip_rejects_before_any_wire_command(self): + flex, transport, head = _flex_head8() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.dispense_container(trough, volume=40)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 0.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_container_wire_failure_rolls_back_container_tracker(self): + flex, _transport, head = _flex_head8(transport_cls=_FailingDispenseTransport) + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(head.pick_up_tips(rack, column=0)) + with self.assertRaises(RuntimeError): + asyncio.run(head.dispense_container(trough, volume=40)) + + # The staged 8 * 40 uL is rolled back in full. + self.assertAlmostEqual(trough.tracker.volume, 0.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 0.0) + finally: + asyncio.run(flex.stop()) + + def test_offset_that_keeps_row_inside_cavity_passes(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() # 85.48 mm front-to-back; 63 mm row + 2*11.24 = 85.48 just fits + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate(y=11.24))) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + # A lateral-only offset keeps the default bottom clearance: its z is 0 + # because the caller said nothing about z, not to ask for the floor. + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"]["offset"], + {"x": 0, "y": 11.24, "z": 1.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_zero_offset_keeps_the_default_bottom_clearance(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate.zero())) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"]["offset"], + {"x": 0, "y": 0, "z": 1.0}, + "a no-op offset must not move the tip to the cavity floor", + ) + finally: + asyncio.run(flex.stop()) + + def test_too_small_container_message_names_no_offset_when_none_was_passed(self): + flex, _transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + narrow = _make_trough(name="narrow", size_y=40.0, max_volume=50000.0) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(narrow, "C2") + narrow.tracker.set_volume(10000.0) + asyncio.run(head.pick_up_tips(rack, column=0)) + + with self.assertRaises(OpentronsError) as no_offset: + asyncio.run(head.aspirate_container(narrow, volume=50)) + self.assertNotIn("offset", str(no_offset.exception)) + + with self.assertRaises(OpentronsError) as with_offset: + asyncio.run(head.aspirate_container(narrow, volume=50, offset=Coordinate(y=3))) + self.assertIn("offset", str(with_offset.exception)) + finally: + asyncio.run(flex.stop()) + + def test_rotated_cavity_is_guarded_on_the_axis_the_robot_sees(self): + # 40 x 70 mm rotated a quarter turn is a 70 x 40 mm cavity to the robot, + # which is the footprint the uploaded definition carries. + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + rotated = _make_trough(name="rotated", size_x=40.0, size_y=70.0, max_volume=50000.0) + rotated.rotation = Rotation(z=90) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(rotated, "C2") + rotated.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate_container(rotated, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(rotated.tracker.volume, 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_rotated_cavity_deep_enough_for_the_row_is_accepted(self): + # The mirror case: 110 x 60 mm rotated presents 110 mm front-to-back, so + # the 63 mm row fits and reading the pre-rotation 60 mm would refuse it. + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + rotated = _make_trough(name="rotated", size_x=110.0, size_y=60.0, max_volume=50000.0) + rotated.rotation = Rotation(z=90) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(rotated, "C2") + rotated.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_container(rotated, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + finally: + asyncio.run(flex.stop()) + + def test_rotated_parent_rotates_the_cavity_too(self): + # The guard reads the COMPOSED rotation, so a plain container inside a + # rotated carrier is guarded on the same axis the robot will see. + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + carrier = Resource(name="carrier", size_x=70.0, size_y=40.0, size_z=25.0) + carrier.rotation = Rotation(z=90) + inner = _make_trough(name="inner", size_x=40.0, size_y=70.0, max_volume=50000.0) + carrier.assign_child_resource(inner, location=Coordinate.zero()) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(carrier, "C2") + inner.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError) as raised: + asyncio.run(head.aspirate_container(inner, volume=50)) + + # Name the guard: reading the container's own y (70 mm) would let this + # through, and it would then fail later for an unrelated reason. + self.assertEqual(raised.exception.title, "Container too small") + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_offset_that_shifts_row_past_cavity_wall_rejects_pre_wire(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() # 85.48 front-to-back; 63 mm row + 2*11.5 = 86 overhangs + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate_container(trough, volume=10, offset=Coordinate(y=-11.5))) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 10000.0) + finally: + asyncio.run(flex.stop()) + + +class TestFlexHead96ContainerOps(unittest.TestCase): + """FlexHead96 aspirate/dispense accept a bare Container: ONE command at + well "A1" with no manual centering (the definition's + centerMultichannelOnWells quirk makes the engine center the 12x8 grid), + and the container's single tracker moves volume * (channels holding tips). + """ + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_aspirate_container_no_manual_centering_and_tracks_96_channels(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.aspirate(trough, volume=50)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + params = aspirate_cmds[0]["params"] + self.assertEqual(params["wellName"], "A1") + # No manual x/y centering: the engine centers the 12x8 grid on the + # cavity itself (centerMultichannelOnWells). + self.assertEqual( + params["wellLocation"], + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, + ) + + self.assertAlmostEqual(trough.tracker.volume, 100000.0 - 96 * 50.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 100000.0 - 96 * 50.0) + finally: + asyncio.run(flex.stop()) + + def test_dispense_container_no_manual_centering_and_adds_96x(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.dispense(trough, volume=20)) + + dispense_cmds = [c for c in transport.commands if c["commandType"] == "dispense"] + self.assertEqual(len(dispense_cmds), 1) + params = dispense_cmds[0]["params"] + self.assertEqual(params["wellName"], "A1") + self.assertEqual( + params["wellLocation"], + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, + ) + self.assertAlmostEqual(trough.tracker.volume, 96 * 20.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_without_tip_rejects_before_any_wire_command(self): + flex, transport, head = _flex_head96() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(trough, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 100000.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_rejects_cavity_smaller_than_grid(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + narrow = _make_trough(name="narrow", size_x=90.0, max_volume=50000.0) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(narrow, "C2") + narrow.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack)) + + # 90 mm left-to-right cannot contain the grid's 99 mm x span. + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(narrow, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(narrow.tracker.volume, 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_wire_failure_rolls_back_container_tracker(self): + flex, _transport, head = _flex_head96(transport_cls=_FailingAspirateTransport) + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + with self.assertRaises(RuntimeError): + asyncio.run(head.aspirate(trough, volume=50)) + + # The staged 96 * 50 uL is rolled back in full: committed AND pending + # volume are untouched. + self.assertAlmostEqual(trough.tracker.volume, 100000.0) + self.assertAlmostEqual(trough.tracker.get_used_volume(), 100000.0) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_container_rejects_cavity_shallower_than_grid_y_span(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + shallow = _make_trough(name="shallow", size_y=50.0, max_volume=50000.0) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(shallow, "C2") + shallow.tracker.set_volume(10000.0) + + asyncio.run(head.pick_up_tips(rack)) + + # 50 mm front-to-back cannot contain the grid's 63 mm y span. + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(shallow, volume=50)) + + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(shallow.tracker.volume, 10000.0) + finally: + asyncio.run(flex.stop()) + + def test_offset_that_keeps_grid_inside_cavity_passes(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() # 127.76 x 85.48; grid 99 x 63 plus 2*14.38 / 2*11.24 just fits + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.aspirate(trough, volume=10, offset=Coordinate(x=14.38, y=11.24))) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual( + aspirate_cmds[0]["params"]["wellLocation"]["offset"], + {"x": 14.38, "y": 11.24, "z": 1.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_rotated_cavity_is_guarded_on_the_axis_the_robot_sees(self): + # 107 x 71 mm rotated a quarter turn is 71 mm left-to-right to the robot, + # too narrow for the grid's 99 mm x span, though unrotated it fits. + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + rotated = _make_trough(name="rotated") + rotated.rotation = Rotation(z=90) + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(rotated, "C2") + rotated.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + for op in ( + lambda: head.aspirate(rotated, volume=50), + lambda: head.dispense(rotated, volume=50), + ): + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(op()) + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(rotated.tracker.volume, 100000.0) + finally: + asyncio.run(flex.stop()) + + def test_offset_that_shifts_grid_past_cavity_wall_rejects_pre_wire(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack") + trough = _make_trough() # 127.76 x 85.48: 99 + 2*14.5 and 63 + 2*11.5 both overhang + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(100000.0) + + asyncio.run(head.pick_up_tips(rack)) + for offset in (Coordinate(x=14.5), Coordinate(y=11.5)): + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(trough, volume=10, offset=offset)) + self.assertEqual(len(transport.commands), commands_before) + self.assertAlmostEqual(trough.tracker.volume, 100000.0) + finally: + asyncio.run(flex.stop()) + + +class TestLiquidOpsRequireAMountedTip(unittest.TestCase): + """Every liquid op requires a mounted tip, wells and plates as much as + containers: without one the command describes a tip that is not there and + the pipette drives its bare NOZZLE roughly a tip length lower. The engine + rejects a tipless op, but ``dispense`` moves to the well FIRST and checks + after, so the collision happens before the rejection.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + @staticmethod + def _plate(flex: OpentronsFlex): + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + return plate + + def _assert_refused_pre_wire(self, flex, transport, op) -> None: + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError) as caught: + asyncio.run(op()) + self.assertEqual(caught.exception.title, "NoTipError") + self.assertEqual(len(transport.commands), commands_before) + + def test_head1_well_ops_refuse_without_a_tip(self): + flex, transport, head = _flex_head1() + try: + well = self._plate(flex).get_item("B3") + self._assert_refused_pre_wire(flex, transport, lambda: head.aspirate(well, volume=10)) + self._assert_refused_pre_wire(flex, transport, lambda: head.dispense(well, volume=10)) + self.assertAlmostEqual(well.tracker.volume, 100.0) + finally: + asyncio.run(flex.stop()) + + def test_head8_column_ops_refuse_without_tips(self): + flex, transport, head = _flex_head8() + try: + plate = self._plate(flex) + self._assert_refused_pre_wire( + flex, transport, lambda: head.aspirate(plate, column=0, volume=10) + ) + self._assert_refused_pre_wire( + flex, transport, lambda: head.dispense(plate, column=0, volume=10) + ) + self.assertAlmostEqual(plate.get_item("A1").tracker.volume, 100.0) + finally: + asyncio.run(flex.stop()) + + def test_head96_plate_ops_refuse_without_tips(self): + flex, transport, head = _flex_head96() + try: + plate = self._plate(flex) + self._assert_refused_pre_wire(flex, transport, lambda: head.aspirate(plate, volume=10)) + self._assert_refused_pre_wire(flex, transport, lambda: head.dispense(plate, volume=10)) + self.assertAlmostEqual(plate.get_item("A1").tracker.volume, 100.0) + finally: + asyncio.run(flex.stop()) + + +class _AxisPositionTransport(ChatterboxTransport): + """Chatterbox whose robot/moveAxes* commands report the axis positions they + reached, as the real robot-server does. The dict it hands back is the one the + completion poll reads, so setting the result here is what the caller sees.""" + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + response = await super().post(path, json) + command_type = (json or {}).get("data", {}).get("commandType") + if command_type in ("robot/moveAxesTo", "robot/moveAxesRelative"): + response["data"]["result"] = {"position": {"x": 11.0, "y": 22.0, "leftZ": 33.0}} + return response + + +def _flex_device( + api_version: str = OFFLINE_API_VERSION, + transport_cls: Type[ChatterboxTransport] = ChatterboxTransport, +) -> Tuple[OpentronsFlex, ChatterboxTransport]: + """A set-up ``OpentronsFlex`` plus its transport, for the robot-level + commands that belong to the device rather than to a head. ``api_version`` is + what ``/health`` reports, which is what the robot/* version gate reads. + """ + transport = transport_cls( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], api_version=api_version + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + return flex, transport + + +def _cmds(transport: ChatterboxTransport, command_type: str) -> List[Dict[str, Any]]: + return [c for c in transport.commands if c["commandType"] == command_type] + + +class TestFlexAxisMotion(unittest.TestCase): + """move_axes_to/move_axes_relative send the robot/* family's snake_case + params and return the axis positions the command reports.""" + + def test_move_axes_to_sends_snake_case_axis_map_only(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.move_axes_to({"x": 100.0, "leftZ": 250.0})) + + (cmd,) = _cmds(transport, "robot/moveAxesTo") + self.assertEqual(cmd["params"], {"axis_map": {"x": 100.0, "leftZ": 250.0}}) + finally: + asyncio.run(flex.stop()) + + def test_move_axes_to_sends_critical_point_and_speed_snake_case(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.move_axes_to({"x": 1.0}, critical_point={"rightZ": 5.0}, speed=40.0)) + + (cmd,) = _cmds(transport, "robot/moveAxesTo") + self.assertEqual( + cmd["params"], + {"axis_map": {"x": 1.0}, "critical_point": {"rightZ": 5.0}, "speed": 40.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_move_axes_relative_sends_snake_case_axis_map_and_speed(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.move_axes_relative({"y": -5.0}, speed=20.0)) + + (cmd,) = _cmds(transport, "robot/moveAxesRelative") + self.assertEqual(cmd["params"], {"axis_map": {"y": -5.0}, "speed": 20.0}) + finally: + asyncio.run(flex.stop()) + + def test_speed_omitted_when_not_given(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.move_axes_relative({"y": -5.0})) + + (cmd,) = _cmds(transport, "robot/moveAxesRelative") + self.assertNotIn("speed", cmd["params"]) + finally: + asyncio.run(flex.stop()) + + def test_both_moves_return_the_reported_position(self): + flex, _transport = _flex_device(transport_cls=_AxisPositionTransport) + try: + reached = {"x": 11.0, "y": 22.0, "leftZ": 33.0} + self.assertEqual(asyncio.run(flex.move_axes_to({"x": 11.0})), reached) + self.assertEqual(asyncio.run(flex.move_axes_relative({"x": 1.0})), reached) + finally: + asyncio.run(flex.stop()) + + def test_unknown_axis_refused_before_any_wire_command(self): + flex, transport = _flex_device() + try: + for op in ( + lambda: flex.move_axes_to({"x": 1.0, "zed": 2.0}), + lambda: flex.move_axes_relative({"zed": 2.0}), + ): + commands_before = len(transport.commands) + with self.assertRaises(ValueError) as caught: + asyncio.run(op()) + # The refusal names what was wrong AND what would have been right. + self.assertIn("zed", str(caught.exception)) + self.assertIn("extensionJaw", str(caught.exception)) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_unknown_critical_point_axis_refused_before_any_wire_command(self): + flex, transport = _flex_device() + try: + commands_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(flex.move_axes_to({"x": 1.0}, critical_point={"zed": 2.0})) + + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_retract_axis_refuses_an_unknown_axis_before_the_wire(self): + flex, transport = _flex_device() + try: + commands_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(flex.retract_axis("zed")) + + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + +class TestFlexAxisMotionVersionGate(unittest.TestCase): + """The robot/* moveAxes commands need robot software 8.2.0+; retractAxis + predates that family and is not gated.""" + + def test_old_release_refuses_both_moves_and_sends_nothing(self): + flex, transport = _flex_device(api_version="8.1.0") + try: + for op in ( + lambda: flex.move_axes_to({"x": 1.0}), + lambda: flex.move_axes_relative({"x": 1.0}), + ): + with self.assertRaises(OpentronsError) as caught: + asyncio.run(op()) + self.assertIn("8.2.0", str(caught.exception)) + + robot_cmds = [c for c in transport.commands if c["commandType"].startswith("robot/")] + self.assertEqual(robot_cmds, []) + finally: + asyncio.run(flex.stop()) + + def test_retract_axis_is_not_gated(self): + flex, transport = _flex_device(api_version="8.1.0") + try: + asyncio.run(flex.retract_axis("leftZ")) + self.assertEqual(len(_cmds(transport, "retractAxis")), 1) + finally: + asyncio.run(flex.stop()) + + +class TestFlexRobotCommands(unittest.TestCase): + """The device's one-shot commands: exact params, no extras.""" + + def test_retract_axis(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.retract_axis("extensionJaw")) + + (cmd,) = _cmds(transport, "retractAxis") + self.assertEqual(cmd["params"], {"axis": "extensionJaw"}) + finally: + asyncio.run(flex.stop()) + + def test_set_status_bar(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.set_status_bar("disco")) + + (cmd,) = _cmds(transport, "setStatusBar") + self.assertEqual(cmd["params"], {"animation": "disco"}) + finally: + asyncio.run(flex.stop()) + + def test_set_rail_lights_carries_the_boolean_both_ways(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.set_rail_lights(True)) + asyncio.run(flex.set_rail_lights(False)) + + cmds = _cmds(transport, "setRailLights") + self.assertEqual([c["params"] for c in cmds], [{"on": True}, {"on": False}]) + finally: + asyncio.run(flex.stop()) + + def test_add_comment(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.add_comment("starting plate 3")) + + (cmd,) = _cmds(transport, "comment") + self.assertEqual(cmd["params"], {"message": "starting plate 3"}) + finally: + asyncio.run(flex.stop()) + + def test_wait_for_duration(self): + flex, transport = _flex_device() + try: + asyncio.run(flex.wait_for_duration(2.5)) + + (cmd,) = _cmds(transport, "waitForDuration") + self.assertEqual(cmd["params"], {"seconds": 2.5}) + finally: + asyncio.run(flex.stop()) + + def test_reload_labware_names_the_id_the_labware_was_loaded_under(self): + flex, transport = _flex_device() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(flex.reload_labware(trough)) + + (_load_cmd,) = _cmds(transport, "loadLabware") + (reload_cmd,) = _cmds(transport, "reloadLabware") + self.assertEqual(reload_cmd["params"], {"labwareId": transport.labware_ids["trough"]}) + finally: + asyncio.run(flex.stop()) + + def test_reload_labware_of_loaded_labware_does_not_load_it_again(self): + flex, transport = _flex_device() + try: + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + + asyncio.run(flex.reload_labware(trough)) + asyncio.run(flex.reload_labware(trough)) + + self.assertEqual(len(_cmds(transport, "loadLabware")), 1) + self.assertEqual(len(_cmds(transport, "reloadLabware")), 2) + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_envelope_tests.py b/pylabrobot/opentrons/flex_envelope_tests.py new file mode 100644 index 00000000000..8f8b1c676be --- /dev/null +++ b/pylabrobot/opentrons/flex_envelope_tests.py @@ -0,0 +1,180 @@ +"""Tests for the native Flex operating envelope + its wiring into the heads. + +``envelope.py`` derives every reach cap from grounded OT-3 primitives; ``checks.py`` +is the pre-dispatch verification surface. These tests pin the derived caps to the +opentrons **8.8.1** shared-data the robot actually runs, and assert the computed +traversal plane replaces the hardcoded magic number. +""" + +import asyncio +import unittest +from typing import List, Tuple + +from pylabrobot.opentrons.checks import traversal_z +from pylabrobot.opentrons.envelope import FLEX_ENVELOPE +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead8 +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import cor_96_wellplate_360uL_Fb +from pylabrobot.resources.opentrons.flex_deck import FlexDeck + + +def _flex_head8() -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead8]: + transport = ChatterboxTransport(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, transport, head + + +def _commands_of(transport: ChatterboxTransport, command_type: str) -> List[dict]: + return [c for c in transport.commands if c["commandType"] == command_type] + + +class TestRearCapGroundedTo881(unittest.TestCase): + """The 8-channel rear reach cap is ``deck_extent_y + paddingOffsets.rear``. + + The rear padding is version-specific: opentrons 8.3.0 = -177.42, but the robot + runs **8.8.1** where it is -169.42, giving a rear cap of 324.38. The stale + 8.3.0 value (316.38) must not be what the envelope carries. + """ + + def test_rear_cap_matches_881_shared_data(self): + self.assertAlmostEqual(FLEX_ENVELOPE.padding_rear, -169.42) + self.assertAlmostEqual(FLEX_ENVELOPE.rear_cap_y, 324.38) + + +class TestUnconditionalTiprackFloor(unittest.TestCase): + """The travel plane never drops below a tip rack (99 + 10 margin = 109), even on + a deck the model shows as empty or holding only short labware -- a rack that is + present but unmodeled must still be cleared.""" + + def test_empty_deck_still_clears_a_tiprack(self): + self.assertAlmostEqual(traversal_z(FlexDeck()), 109.0) + + def test_short_labware_does_not_lower_the_floor(self): + deck = FlexDeck() + plate = cor_96_wellplate_360uL_Fb(name="plate") # ~14 mm tall, well below a rack + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + deck.assign_child_at_slot(plate, "C2") + self.assertAlmostEqual(traversal_z(deck), 109.0) + + +class TestComputedTraversalPlane(unittest.TestCase): + """A lateral jog defaults its ``minimumZHeight`` to the COMPUTED tip-safe plane + (tallest labware top + arc margin), not a hardcoded 120.0 magic number.""" + + def test_move_to_uses_computed_traversal_not_120(self): + flex, transport, head = _flex_head8() + try: + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + + expected = traversal_z(flex.deck) + self.assertNotAlmostEqual(expected, 120.0, msg="test needs labware whose plane != 120") + + asyncio.run(head.move_to(x=100.0, y=100.0, z=50.0)) + + move_cmds = _commands_of(transport, "moveToCoordinates") + self.assertEqual(len(move_cmds), 1) + self.assertAlmostEqual(move_cmds[0]["params"]["minimumZHeight"], expected) + finally: + asyncio.run(flex.stop()) + + +class TestTrashDropArcsHighEnough(unittest.TestCase): + """The move to the trash after a dispense must arc at the computed traversal + plane, not the engine's default -- otherwise it can travel too low and clip + labware the robot was never told about.""" + + def test_discard_tips_move_carries_computed_minimum_z_height(self): + from pylabrobot.resources import cor_96_wellplate_360uL_Fb + from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + trash = flex.deck.get_trash_area() + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.discard_tips(trash)) + + move = next( + c for c in transport.commands if c["commandType"] == "moveToAddressableAreaForDropTip" + ) + self.assertAlmostEqual(move["params"]["minimumZHeight"], traversal_z(flex.deck)) + finally: + asyncio.run(flex.stop()) + + +class TestBetweenSlotArcGuard(unittest.TestCase): + """A pipetting move that crosses to a different slot is prefixed with a safe + high moveToWell (>= the tip-rack floor); a move within the same labware is not.""" + + def setUp(self): + from pylabrobot.resources import set_tip_tracking, set_volume_tracking + + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + from pylabrobot.resources import set_tip_tracking, set_volume_tracking + + set_tip_tracking(False) + set_volume_tracking(False) + + def _setup(self): + from pylabrobot.resources import cor_96_wellplate_360uL_Fb + from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for w in plate.get_all_items(): + w.tracker.set_volume(100.0) + return flex, transport, head, rack, plate + + def _move_to_wells(self, transport): + return [c for c in transport.commands if c["commandType"] == "moveToWell"] + + def test_crossing_to_a_new_slot_arcs_high_first(self): + flex, transport, head, rack, plate = self._setup() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) # over the rack (C1) + before = len(self._move_to_wells(transport)) + asyncio.run(head.aspirate(plate.column(0), volume=50)) # -> plate (C2), a new slot + + moves = self._move_to_wells(transport) + self.assertEqual( + len(moves), before + 1, "one safe move should precede the cross-slot aspirate" + ) + self.assertAlmostEqual(moves[-1]["params"]["minimumZHeight"], traversal_z(flex.deck)) + # the safe move comes immediately before the aspirate + types = [c["commandType"] for c in transport.commands] + self.assertEqual(types[types.index("aspirate") - 1], "moveToWell") + finally: + asyncio.run(flex.stop()) + + def test_moving_within_the_same_labware_does_not_arc_high(self): + flex, transport, head, rack, plate = self._setup() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate.column(0), volume=50)) # cross-slot -> one safe move + n = len(self._move_to_wells(transport)) + asyncio.run(head.dispense(plate.column(1), volume=50)) # same plate -> no new safe move + self.assertEqual(len(self._move_to_wells(transport)), n, "within-slot move must not arc high") + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_fine_pipetting_tests.py b/pylabrobot/opentrons/flex_fine_pipetting_tests.py new file mode 100644 index 00000000000..e9185b1bb80 --- /dev/null +++ b/pylabrobot/opentrons/flex_fine_pipetting_tests.py @@ -0,0 +1,1496 @@ +"""Tests for the fine-pipetting commands on the plain-class Flex heads. + +Covers ``blow_out`` (in-place plunger blow-out, all heads), ``touch_tip`` +(wall-touch, all heads) and ``liquid_probe``/``try_liquid_probe`` +(pressure-based liquid-level detection, mount heads only), driven through the +recording ``ChatterboxTransport``. Two no-liquid signals are modeled: the +``liquid_probe_z`` kwarg omits the ``z_position`` result key entirely (a +succeeding transport's shape), and ``simulate_liquid_probe_not_found`` fails +the ``liquidProbe`` command with the engine's defined "liquidNotFound" error +(the real-hardware behavior). Also pins the shared well-position rule the +liquid ops build on -- a caller offset shifts the head from the default +position rather than replacing it -- and the one place that deliberately +does not follow it, ``touch_tip``; plus the two rules the single-nozzle +cherry-pick runs under: which nozzles an 8-channel Flex can anchor on, and +that no nozzle layout changes while a tip is mounted. + +Also covers the base-class ops every head inherits: the in-place liquid ops +(``aspirate_in_place``/``dispense_in_place``/``air_gap_in_place``), which name +no well and move no tracker; the two tip-presence commands +(``get_tip_presence`` reports, ``verify_tip_presence`` makes the robot fail on +a mismatch); ``configure_for_volume``; and the ``unsafe_*`` recovery pair. +""" + +import asyncio +import unittest +from typing import Any, Dict, Optional + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead1 +from pylabrobot.opentrons.flex_tests import _flex_head1, _flex_head8, _flex_head96 +from pylabrobot.opentrons.robot import OpentronsCommandError, OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import ( + biorad_384_wellplate_50uL_Vb, + cor_96_wellplate_360uL_Fb, + cor_cos_24_wellplate_3470uL_Fb, + set_tip_tracking, + set_volume_tracking, +) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + +class TestBlowOut(unittest.TestCase): + """blow_out sends one blowOutInPlace command at the current position. + + It leaves the plunger past its dispense bottom, but the driver sends no + prepareToAspirate to fix that: a following well-addressed aspirate names a + well, so the robot primes at the well top and descends by itself.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_head8_sends_blow_out_in_place_with_dispense_default_flow_rate(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.blow_out()) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] + self.assertEqual(len(blow_cmds), 1) + # p50_multi_v3.5 on a 50uL tip, per Opentrons' shipped pipette data. + self.assertEqual( + blow_cmds[0]["params"], + {"pipetteId": head.pipette_id, "flowRate": 57.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_head8_accepts_flow_rate_override(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.blow_out(flow_rate=12.5)) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] + self.assertEqual(blow_cmds[0]["params"]["flowRate"], 12.5) + finally: + asyncio.run(flex.stop()) + + def test_head8_next_aspirate_after_blow_out_sends_no_prepare(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate, column=0, volume=10)) + asyncio.run(head.blow_out()) + asyncio.run(head.aspirate(plate, column=1, volume=10)) + + cmd_types = [c["commandType"] for c in transport.commands] + self.assertEqual(cmd_types.count("aspirate"), 2) + self.assertNotIn("prepareToAspirate", cmd_types) + finally: + asyncio.run(flex.stop()) + + def test_head1_blow_out_then_aspirate_sends_no_prepare(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + well = plate.get_item("B3") + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.aspirate(well, volume=10)) + asyncio.run(head.blow_out()) + asyncio.run(head.aspirate(well, volume=10)) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] + self.assertEqual(len(blow_cmds), 1) + # p1000_single_v3.5 on a 50uL tip: a different pipette blows out at its own + # rate, not the p50's 57. + self.assertEqual( + blow_cmds[0]["params"], + {"pipetteId": head.pipette_id, "flowRate": 478.0}, + ) + cmd_types = [c["commandType"] for c in transport.commands] + self.assertEqual(cmd_types.count("aspirate"), 2) + self.assertNotIn("prepareToAspirate", cmd_types) + finally: + asyncio.run(flex.stop()) + + def test_head96_blow_out_then_aspirate_sends_no_prepare(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack96") + plate = cor_96_wellplate_360uL_Fb(name="plate96") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.aspirate(plate, volume=10)) + asyncio.run(head.blow_out()) + asyncio.run(head.aspirate(plate, volume=10)) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "blowOutInPlace"] + self.assertEqual(len(blow_cmds), 1) + self.assertEqual(blow_cmds[0]["params"]["pipetteId"], head.pipette_id) + cmd_types = [c["commandType"] for c in transport.commands] + self.assertEqual(cmd_types.count("aspirate"), 2) + self.assertNotIn("prepareToAspirate", cmd_types) + finally: + asyncio.run(flex.stop()) + + +class TestTouchTipHead1(unittest.TestCase): + """FlexHead1.touch_tip sends one touchTip command naming the well, with the + radius and a TOP-origin wellLocation (default 1 mm below the rim; a caller + offset replaces it, also top-relative); a missing tip rejects before any + wire command.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_touch_tip_sends_one_command_with_radius_offset_and_well(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.touch_tip(plate.get_item("B3"), radius=0.75, offset=Coordinate(1, 2, 3))) + + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + self.assertEqual(len(touch_cmds), 1) + params = touch_cmds[0]["params"] + self.assertEqual(params["pipetteId"], head.pipette_id) + self.assertEqual(params["wellName"], "B3") + self.assertEqual(params["radius"], 0.75) + self.assertEqual( + params["wellLocation"], {"origin": "top", "offset": {"x": 1, "y": 2, "z": 3}} + ) + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_defaults_radius_one_and_1mm_below_rim(self): + flex, transport, head = _flex_head1() + try: + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + asyncio.run(head.touch_tip(plate.get_item("B3"))) + + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + params = touch_cmds[0]["params"] + self.assertEqual(params["radius"], 1.0) + self.assertEqual( + params["wellLocation"], {"origin": "top", "offset": {"x": 0, "y": 0, "z": -1.0}} + ) + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_without_tip_raises_and_sends_nothing(self): + flex, transport, head = _flex_head1() + try: + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.touch_tip(plate.get_item("B3"))) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestTouchTipHead8(unittest.TestCase): + """FlexHead8.touch_tip is column-addressed: one touchTip command anchored at + the column's A-row well; a missing tip rejects before any wire command.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_touch_tip_anchors_at_column_a_row_well(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.touch_tip(plate, column=2)) + + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + self.assertEqual(len(touch_cmds), 1) + self.assertEqual(touch_cmds[0]["params"]["wellName"], "A3") + self.assertEqual(touch_cmds[0]["params"]["radius"], 1.0) + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_without_tips_raises_and_sends_nothing(self): + flex, transport, head = _flex_head8() + try: + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.touch_tip(plate, column=0)) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestTouchTipHead96(unittest.TestCase): + """FlexHead96.touch_tip fans one touchTip command anchored at "A1" out to + all 96 channels; a non-96-position labware or a missing tip rejects before + any wire command.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_touch_tip_anchors_at_a1(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack96") + plate = cor_96_wellplate_360uL_Fb(name="plate96") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack)) + asyncio.run(head.touch_tip(plate)) + + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + self.assertEqual(len(touch_cmds), 1) + self.assertEqual(touch_cmds[0]["params"]["wellName"], "A1") + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_rejects_non_96_labware(self): + flex, transport, head = _flex_head96() + try: + rack = flex_96_tiprack_50ul(name="rack96") + plate_384 = biorad_384_wellplate_50uL_Vb(name="plate384") + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate_384, "C3") + + asyncio.run(head.pick_up_tips(rack)) + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.touch_tip(plate_384)) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_without_tips_raises_and_sends_nothing(self): + flex, transport, head = _flex_head96() + try: + plate = cor_96_wellplate_360uL_Fb(name="plate96") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.touch_tip(plate)) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestLiquidProbeHead1(unittest.TestCase): + """FlexHead1 liquid probing: the found liquid z rides the command result's + ``z_position`` key, which the robot-server OMITS entirely (not null) when + no liquid is found -- liquid_probe raises on absence, try_liquid_probe + returns None.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self, **transport_kwargs): + flex, transport, head = _flex_head1(**transport_kwargs) + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + return flex, transport, head, rack, plate + + def test_liquid_probe_returns_configured_z_and_sends_probe_command(self): + flex, transport, head, rack, plate = self._bench(liquid_probe_z=12.5) + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + z = asyncio.run(head.liquid_probe(plate.get_item("B3"))) + + self.assertEqual(z, 12.5) + probe_cmds = [c for c in transport.commands if c["commandType"] == "liquidProbe"] + self.assertEqual(len(probe_cmds), 1) + params = probe_cmds[0]["params"] + self.assertEqual(params["pipetteId"], head.pipette_id) + self.assertEqual(params["wellName"], "B3") + # The probe starts 2 mm above the well rim (the engine's own start + # offset) and descends; a bottom-origin start would begin at the floor. + self.assertEqual( + params["wellLocation"], {"origin": "top", "offset": {"x": 0, "y": 0, "z": 2.0}} + ) + finally: + asyncio.run(flex.stop()) + + def test_liquid_probe_raises_when_no_liquid_found(self): + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(OpentronsError): + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + + # The probe command WAS sent -- absence of z_position in its result is + # what raised, not a pre-wire guard. + probe_cmds = [c for c in transport.commands if c["commandType"] == "liquidProbe"] + self.assertEqual(len(probe_cmds), 1) + finally: + asyncio.run(flex.stop()) + + def test_try_liquid_probe_returns_none_when_no_liquid_found(self): + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + z = asyncio.run(head.try_liquid_probe(plate.get_item("B3"))) + + self.assertIsNone(z) + probe_cmds = [c for c in transport.commands if c["commandType"] == "tryLiquidProbe"] + self.assertEqual(len(probe_cmds), 1) + finally: + asyncio.run(flex.stop()) + + def test_try_liquid_probe_returns_configured_z(self): + flex, transport, head, rack, plate = self._bench(liquid_probe_z=4.75) + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + z = asyncio.run(head.try_liquid_probe(plate.get_item("B3"))) + self.assertEqual(z, 4.75) + probe_cmds = [c for c in transport.commands if c["commandType"] == "tryLiquidProbe"] + self.assertEqual( + probe_cmds[0]["params"]["wellLocation"], + {"origin": "top", "offset": {"x": 0, "y": 0, "z": 2.0}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_liquid_probe_without_tip_raises_and_sends_nothing(self): + flex, transport, head, _rack, plate = self._bench(liquid_probe_z=12.5) + try: + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + def test_liquid_probe_wire_failure_translates_to_liquid_not_found(self): + # Real hardware FAILS the liquidProbe command with the defined + # "liquidNotFound" error when no liquid is detected. + flex, transport, head, rack, plate = self._bench(simulate_liquid_probe_not_found=True) + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(OpentronsError) as ctx: + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + + self.assertEqual(ctx.exception.title, "LiquidNotFoundError") + probe_cmds = [c for c in transport.commands if c["commandType"] == "liquidProbe"] + self.assertEqual(len(probe_cmds), 1, "the probe reached the wire and failed there") + finally: + asyncio.run(flex.stop()) + + def test_try_liquid_probe_unaffected_by_liquid_probe_failure_mode(self): + # tryLiquidProbe genuinely succeeds with the z_position key absent. + flex, _transport, head, rack, plate = self._bench(simulate_liquid_probe_not_found=True) + try: + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + self.assertIsNone(asyncio.run(head.try_liquid_probe(plate.get_item("B3")))) + finally: + asyncio.run(flex.stop()) + + def test_liquid_probe_other_wire_failure_reraises_untranslated(self): + class _OverpressureProbeTransport(ChatterboxTransport): + """Fails liquidProbe with a different defined error than liquidNotFound.""" + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if path.endswith("/commands") and data.get("commandType") == "liquidProbe": + cmd_data = result["data"] + cmd_data["status"] = "failed" + cmd_data["error"] = {"errorType": "overpressure", "detail": "clogged tip"} + return result + + transport = _OverpressureProbeTransport( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")] + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + head = flex.right + assert isinstance(head, FlexHead1) + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(OpentronsCommandError) as ctx: + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + self.assertEqual(ctx.exception.error_type, "overpressure") + finally: + asyncio.run(flex.stop()) + + def test_a_probe_refused_for_an_unprimed_plunger_carries_the_priming_remedy(self): + """A probe pushes the plunger, so the robot refuses it on an unprimed one + even though a well IS named: getting there means the tip has held liquid, + and a probe wants a dry tip. The driver translates it like an in-place + draw rather than letting the raw wire error through.""" + + class _UnprimedProbeTransport(ChatterboxTransport): + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if path.endswith("/commands") and data.get("commandType") == "liquidProbe": + cmd_data = result["data"] + cmd_data["status"] = "failed" + cmd_data["error"] = { + "errorType": "PipetteNotReadyToAspirateError", + "detail": "The pipette cannot probe liquid because a previous dispense or " + "blowout pushed the plunger beyond the bottom position.", + } + return result + + transport = _UnprimedProbeTransport(pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + head = flex.right + assert isinstance(head, FlexHead1) + rack = flex_96_tiprack_50ul(name="rack1") + plate = cor_96_wellplate_360uL_Fb(name="plate1") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.liquid_probe(plate.get_item("B3"))) + self.assertEqual(caught.exception.title, "NotReadyToAspirateError") + self.assertIn("prepare_to_aspirate()", str(caught.exception)) + finally: + asyncio.run(flex.stop()) + + +class TestLiquidProbeHead8(unittest.TestCase): + """FlexHead8 liquid probing is column-addressed: one probe command anchored + at the column's A-row well.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self, **transport_kwargs): + flex, transport, head = _flex_head8(**transport_kwargs) + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + return flex, transport, head, rack, plate + + def test_liquid_probe_anchors_at_column_a_row_well_and_returns_z(self): + flex, transport, head, rack, plate = self._bench(liquid_probe_z=7.25) + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + z = asyncio.run(head.liquid_probe(plate, column=3)) + + self.assertEqual(z, 7.25) + probe_cmds = [c for c in transport.commands if c["commandType"] == "liquidProbe"] + self.assertEqual(len(probe_cmds), 1) + self.assertEqual(probe_cmds[0]["params"]["wellName"], "A4") + finally: + asyncio.run(flex.stop()) + + def test_try_liquid_probe_returns_none_when_no_liquid_found(self): + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + z = asyncio.run(head.try_liquid_probe(plate, column=3)) + + self.assertIsNone(z) + probe_cmds = [c for c in transport.commands if c["commandType"] == "tryLiquidProbe"] + self.assertEqual(len(probe_cmds), 1) + self.assertEqual(probe_cmds[0]["params"]["wellName"], "A4") + finally: + asyncio.run(flex.stop()) + + def test_liquid_probe_without_tips_raises_and_sends_nothing(self): + flex, transport, head, _rack, plate = self._bench(liquid_probe_z=7.25) + try: + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.liquid_probe(plate, column=3)) + + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestHead8ColumnValidation(unittest.TestCase): + """Every FlexHead8 column op validates the column against the labware's + real grid BEFORE any wire command (including configureNozzleLayout and + loadLabware), so a rejected op ships nothing.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + return flex, transport, head, rack, plate + + def test_out_of_range_columns_reject_every_op_with_zero_wire_commands(self): + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) # the trio needs mounted tips + + for column in (-1, 12, 15): + ops = [ + lambda c=column: head.pick_up_tips(rack, column=c), + lambda c=column: head.drop_tips(rack, column=c), + lambda c=column: head.aspirate(plate, column=c, volume=10), + lambda c=column: head.dispense(plate, column=c, volume=10), + lambda c=column: head.touch_tip(plate, column=c), + lambda c=column: head.liquid_probe(plate, column=c), + lambda c=column: head.try_liquid_probe(plate, column=c), + ] + for op in ops: + n_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(op()) + self.assertEqual( + len(transport.commands), n_before, f"column {column} rejection must ship nothing" + ) + finally: + asyncio.run(flex.stop()) + + def test_column_minus_one_does_not_alias_to_the_last_column(self): + # Negative indexing must not silently address column 12's wells. + flex, transport, head, rack, _plate = self._bench() + try: + n_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(head.pick_up_tips(rack, column=-1)) + self.assertEqual(len(transport.commands), n_before) + for spot in rack.get_all_items(): + self.assertTrue(spot.has_tip(), spot.name) + finally: + asyncio.run(flex.stop()) + + def test_384_well_plate_addresses_two_interleaved_row_sets_per_physical_column(self): + # The nozzles hold their 9 mm pitch, so on a 16-row plate they cover + # every other row: two sets of 8 per physical column, indexed in turn. + flex, transport, head, rack, _plate = self._bench() + try: + plate_384 = biorad_384_wellplate_50uL_Vb(name="plate384") + flex.deck.assign_child_at_slot(plate_384, "C3") + asyncio.run(head.pick_up_tips(rack, column=0)) + + for column, expected in ((0, "A1"), (1, "B1"), (2, "A2"), (47, "B24")): + anchor, items = head._column_anchor_and_items(plate_384, column) + self.assertEqual(anchor, expected, f"column {column}") + self.assertEqual(len(items), 8) + # Column 0 covers the rear-row set the engine's own coverage math + # reports for a full 8-channel configuration anchored at A1. + _anchor, items = head._column_anchor_and_items(plate_384, 0) + self.assertEqual( + [plate_384.get_child_identifier(item) for item in items], + ["A1", "C1", "E1", "G1", "I1", "K1", "M1", "O1"], + ) + _anchor, items = head._column_anchor_and_items(plate_384, 1) + self.assertEqual( + [plate_384.get_child_identifier(item) for item in items], + ["B1", "D1", "F1", "H1", "J1", "L1", "N1", "P1"], + ) + finally: + asyncio.run(flex.stop()) + + def test_384_well_plate_column_op_reaches_the_wire_at_its_anchor_well(self): + flex, transport, head, rack, _plate = self._bench() + try: + plate_384 = biorad_384_wellplate_50uL_Vb(name="plate384") + flex.deck.assign_child_at_slot(plate_384, "C3") + for well in plate_384.get_all_items(): + well.tracker.set_volume(40.0) + asyncio.run(head.pick_up_tips(rack, column=0)) + + asyncio.run(head.aspirate(plate_384, column=3, volume=10)) + + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "B2") + # Only the 8 wells the nozzles actually reach lose liquid. + touched = ["B2", "D2", "F2", "H2", "J2", "L2", "N2", "P2"] + for name in touched: + self.assertAlmostEqual(plate_384.get_item(name).tracker.volume, 30.0, msg=name) + self.assertAlmostEqual(plate_384.get_item("A2").tracker.volume, 40.0) + self.assertAlmostEqual(plate_384.get_item("C2").tracker.volume, 40.0) + finally: + asyncio.run(flex.stop()) + + def test_384_well_plate_rejects_out_of_range_column_pre_wire(self): + flex, transport, head, rack, _plate = self._bench() + try: + plate_384 = biorad_384_wellplate_50uL_Vb(name="plate384") + flex.deck.assign_child_at_slot(plate_384, "C3") + asyncio.run(head.pick_up_tips(rack, column=0)) + + n_before = len(transport.commands) + for op in ( + lambda: head.aspirate(plate_384, column=48, volume=10), + lambda: head.liquid_probe(plate_384, column=100), + lambda: head.touch_tip(plate_384, column=-1), + ): + with self.assertRaises(ValueError): + asyncio.run(op()) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + def test_row_count_that_is_not_a_multiple_of_eight_still_rejects_pre_wire(self): + # A 4-row (24-well) plate has no set of 8 evenly spaced rows, so the + # narrowing that protects hardware survives the 384 widening. + flex, transport, head, rack, _plate = self._bench() + try: + plate_24 = cor_cos_24_wellplate_3470uL_Fb(name="plate24") + flex.deck.assign_child_at_slot(plate_24, "C3") + asyncio.run(head.pick_up_tips(rack, column=0)) + + n_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(head.touch_tip(plate_24, column=0)) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestWellPositionOffsets(unittest.TestCase): + """A caller offset SHIFTS the head from the default liquid position (1 mm + above the well bottom, or ``liquid_height`` above it), never replaces it: + a Coordinate carries z=0 when the caller only meant to nudge x/y, so a + replacing offset would put the tip on the well floor.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + asyncio.run(head.pick_up_tips(rack, column=0)) + return flex, transport, head, plate + + def _aspirate_well_location(self, transport: ChatterboxTransport) -> dict: + aspirate_cmds = [c for c in transport.commands if c["commandType"] == "aspirate"] + self.assertEqual(len(aspirate_cmds), 1) + well_location: dict = aspirate_cmds[0]["params"]["wellLocation"] + return well_location + + def test_lateral_offset_on_a_plate_column_keeps_the_bottom_clearance(self): + flex, transport, head, plate = self._bench() + try: + asyncio.run(head.aspirate(plate, column=0, volume=10, offset=Coordinate(x=1, y=2))) + self.assertEqual( + self._aspirate_well_location(transport), + {"origin": "bottom", "offset": {"x": 1, "y": 2, "z": 1.0}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_zero_offset_keeps_the_bottom_clearance(self): + flex, transport, head, plate = self._bench() + try: + asyncio.run(head.aspirate(plate, column=0, volume=10, offset=Coordinate.zero())) + self.assertEqual( + self._aspirate_well_location(transport), + {"origin": "bottom", "offset": {"x": 0, "y": 0, "z": 1.0}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_liquid_height_is_the_base_the_offset_rides_on(self): + # liquid_height is already measured from the bottom, so it replaces the + # clearance as the base; the offset still adds on top of whichever base. + flex, transport, head, plate = self._bench() + try: + asyncio.run( + head.aspirate(plate, column=0, volume=10, offset=Coordinate(x=2, z=0.5), liquid_height=3) + ) + self.assertEqual( + self._aspirate_well_location(transport), + {"origin": "bottom", "offset": {"x": 2, "y": 0, "z": 3.5}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_touch_tip_offset_replaces_while_aspirate_offset_shifts(self): + # The same Coordinate(x=1) means different things on the two calls, which + # is why both public docstrings spell the rule out: touch_tip's z IS the + # touch height (the Opentrons Python API's absolute v_offset). + flex, transport, head, plate = self._bench() + try: + asyncio.run(head.aspirate(plate, column=0, volume=10, offset=Coordinate(x=1))) + asyncio.run(head.touch_tip(plate, column=0, offset=Coordinate(x=1))) + touch_cmds = [c for c in transport.commands if c["commandType"] == "touchTip"] + self.assertEqual( + self._aspirate_well_location(transport), + {"origin": "bottom", "offset": {"x": 1, "y": 0, "z": 1.0}}, + ) + self.assertEqual( + touch_cmds[0]["params"]["wellLocation"], + {"origin": "top", "offset": {"x": 1, "y": 0, "z": 0}}, + ) + finally: + asyncio.run(flex.stop()) + + +class TestSingleNozzleLayout(unittest.TestCase): + """An 8-channel Flex can anchor a SINGLE nozzle layout on its "A1" or "H1" + nozzle and nothing else: the engine's primaryNozzle is a literal of those + (plus the 12-column ends a 96-head uses), and the pipette's own + validNozzleMaps carry only SingleA1/SingleH1. The engine also refuses ANY + nozzle reconfiguration, "ALL" included, while a tip is attached.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def _bench(self, slot="C1"): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, slot) + return flex, transport, head, rack + + def _nozzle_params(self, transport: ChatterboxTransport) -> list: + return [ + c["params"]["configurationParams"] + for c in transport.commands + if c["commandType"] == "configureNozzleLayout" + ] + + def test_a_middle_row_well_anchors_on_a_nozzle_that_can_reach_it(self): + # The row letter names no anchor ("C1" is not a primaryNozzle the + # robot-server accepts), so the reachable end is chosen instead. + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="C3")) + self.assertEqual(self._nozzle_params(transport)[-1]["primaryNozzle"], "A1") + pickups = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(pickups[-1]["params"]["wellName"], "C3") + self.assertIsNotNone(head.get_mounted_tips()[0]) + finally: + asyncio.run(flex.stop()) + + def test_explicit_nozzle_reaches_a_middle_row_well_on_its_own_channel(self): + # In a SINGLE layout the engine moves the chosen nozzle over whatever + # well is named, so the tip lands on that nozzle's channel, not the + # well's row. + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="C3", primary_nozzle="H1")) + + self.assertEqual(self._nozzle_params(transport)[-1]["primaryNozzle"], "H1") + pickups = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(pickups[-1]["params"]["wellName"], "C3") + self.assertIsNotNone(head.get_mounted_tips()[7]) + self.assertTrue(all(tip is None for i, tip in enumerate(head.get_mounted_tips()) if i != 7)) + finally: + asyncio.run(flex.stop()) + + def test_row_letter_default_picks_that_rows_nozzle(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="A2")) + self.assertEqual(self._nozzle_params(transport)[-1]["primaryNozzle"], "A1") + self.assertIsNotNone(head.get_mounted_tips()[0]) + finally: + asyncio.run(flex.stop()) + + def test_an_unanchorable_nozzle_is_rejected_pre_wire(self): + flex, transport, head, rack = self._bench() + try: + commands_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(head.pick_up_single_tip(rack, well="A1", primary_nozzle="B1")) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_reconfiguring_the_layout_while_a_tip_is_mounted_is_refused(self): + flex, transport, head, rack = self._bench() + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C2") + try: + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + commands_before = len(transport.commands) + + # A column op resets the layout to ALL, which the robot refuses while + # the single tip is still on. + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.aspirate(plate, column=1, volume=10)) + self.assertIn("nozzle layout", str(caught.exception)) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_dropping_the_single_tip_restores_all_mode(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + asyncio.run(head.drop_single_tip(flex.deck.get_trash_area())) + + self.assertEqual(self._nozzle_params(transport)[-1], {"style": "ALL"}) + self.assertTrue(all(tip is None for tip in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_discarding_a_cherry_picked_tip_drops_it_then_restores_all_mode(self): + # A trash drop addresses no channel, so it must not wait on a layout + # reset the robot refuses while the tip is still on. + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + asyncio.run(head.discard_tips(flex.deck.get_trash_area())) + + self.assertTrue(all(tip is None for tip in head.get_mounted_tips())) + sent = [c["commandType"] for c in transport.commands] + self.assertLess(sent.index("dropTipInPlace"), len(sent) - 1) + self.assertEqual(sent[-1], "configureNozzleLayout") + self.assertEqual(self._nozzle_params(transport)[-1], {"style": "ALL"}) + finally: + asyncio.run(flex.stop()) + + def test_returning_a_cherry_picked_tip_to_a_rack_column_names_the_op_that_works(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + commands_before = len(transport.commands) + + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.drop_tips(rack, column=0)) + self.assertIn("drop_single_tip", str(caught.exception)) + self.assertEqual(len(transport.commands), commands_before) + + asyncio.run(head.drop_single_tip(flex.deck.get_trash_area())) + finally: + asyncio.run(flex.stop()) + + def test_a_front_row_slot_puts_the_rear_anchor_out_of_reach(self): + # Anchoring A1 over D1's front rows leaves the mount ahead of the robot's + # own front limit: the pipette body reaches 95mm forward of it. + flex, _, head, rack = self._bench(slot="D1") + try: + self.assertEqual(head.reachable_single_nozzles(rack, "A1"), ("A1", "H1")) + self.assertEqual(head.reachable_single_nozzles(rack, "F1"), ("H1",)) + self.assertEqual(head.reachable_single_nozzles(rack, "H1"), ("H1",)) + finally: + asyncio.run(flex.stop()) + + def test_a_front_row_well_anchors_on_the_nozzle_that_reaches_it(self): + flex, transport, head, rack = self._bench(slot="D1") + try: + asyncio.run(head.pick_up_single_tip(rack, well="F1")) + self.assertEqual(self._nozzle_params(transport)[-1]["primaryNozzle"], "H1") + self.assertIsNotNone(head.get_mounted_tips()[7]) + finally: + asyncio.run(flex.stop()) + + def test_an_explicit_out_of_reach_anchor_is_refused_naming_the_one_that_works(self): + # An explicit choice is never silently swapped. + flex, transport, head, rack = self._bench(slot="D1") + try: + commands_before = len(transport.commands) + with self.assertRaises(ValueError) as caught: + asyncio.run(head.pick_up_single_tip(rack, well="F1", primary_nozzle="A1")) + self.assertIn("H1", str(caught.exception)) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_every_flex_slot_well_is_reachable_by_one_anchor_or_the_other(self): + # No slot the deck accepts leaves a well unpickable; A3 holds the trash. + for slot in [f"{row}{col}" for row in "ABCD" for col in "123" if f"{row}{col}" != "A3"]: + flex, _, head, rack = self._bench(slot=slot) + try: + for well_row in "ABCDEFGH": + reachable = head.reachable_single_nozzles(rack, f"{well_row}1") + self.assertTrue(reachable, f"{slot} {well_row}1 unreachable both ways") + finally: + asyncio.run(flex.stop()) + + def test_stop_leaves_no_cherry_picked_tip_on_the_pipette(self): + flex, transport, head, rack = self._bench() + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + + asyncio.run(flex.stop()) + + self.assertIn("dropTipInPlace", [c["commandType"] for c in transport.commands]) + self.assertTrue(all(tip is None for tip in head.get_mounted_tips())) + + +class TestInPlaceLiquidOps(unittest.TestCase): + """The in-place ops act where the head already is: one command each, naming + no labware and no well, carrying the flow-rate default of the motion they + are (aspirate for the air gap too). Each requires a mounted tip, refused + before the wire. + + Naming no well is also why the in-place DRAWS are the only ops that make the + caller deal with plunger priming. The robot primes a well-addressed aspirate + itself, at the well top where the tip is in open air; with no well it has no + safe height to do that at, so it refuses instead of guessing, and the driver + passes the refusal on rather than priming blind.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + return flex, transport, head, rack + + def _params(self, transport: ChatterboxTransport, command_type: str) -> dict: + cmds = [c for c in transport.commands if c["commandType"] == command_type] + self.assertEqual(len(cmds), 1) + params: dict = cmds[0]["params"] + return params + + def test_aspirate_in_place_sends_volume_and_the_aspirate_default_flow_rate(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + + self.assertEqual( + self._params(transport, "aspirateInPlace"), + {"pipetteId": head.pipette_id, "volume": 15, "flowRate": 35.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_aspirate_in_place_accepts_a_flow_rate_override(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15, flow_rate=3.5)) + + self.assertEqual(self._params(transport, "aspirateInPlace")["flowRate"], 3.5) + finally: + asyncio.run(flex.stop()) + + def test_a_pickup_leaves_the_plunger_ready_so_in_place_draws_need_no_prepare(self): + """The robot primes as part of a tip pickup, so back-to-back in-place + aspirates straight off a fresh tip send no prepareToAspirate at all.""" + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.aspirate_in_place(volume=15)) + + sent = [c["commandType"] for c in transport.commands] + self.assertEqual(sent.count("aspirateInPlace"), 2) + self.assertNotIn("prepareToAspirate", sent) + finally: + asyncio.run(flex.stop()) + + def _assert_in_place_draw_is_refused(self, head, transport): + """The robot refuses, and the driver hands back a message naming both remedies.""" + n_before = len(transport.commands) + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.aspirate_in_place(volume=15)) + self.assertEqual(caught.exception.title, "NotReadyToAspirateError") + self.assertIn("prepare_to_aspirate()", str(caught.exception)) + self.assertIn("aspirate from a well", str(caught.exception)) + self.assertEqual(len(transport.commands), n_before + 1, "the refusal comes from the robot") + + def test_a_dispense_that_empties_the_tip_makes_the_next_in_place_draw_refuse(self): + """The ordinary transfer loop's second draw: emptying the tip leaves the + plunger past its bottom, and with no well named the robot will not fix it.""" + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.dispense_in_place(volume=15)) + + self._assert_in_place_draw_is_refused(head, transport) + finally: + asyncio.run(flex.stop()) + + def test_a_partial_dispense_leaves_the_plunger_ready(self): + """Only a dispense that EMPTIES the tip un-primes it, because only that one + gets a push-out. Refusing after every dispense would refuse work the robot + accepts.""" + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.dispense_in_place(volume=5)) + asyncio.run(head.aspirate_in_place(volume=5)) + + sent = [c["commandType"] for c in transport.commands] + self.assertEqual(sent.count("aspirateInPlace"), 2) + self.assertNotIn("prepareToAspirate", sent) + finally: + asyncio.run(flex.stop()) + + def test_lifting_clear_and_priming_by_hand_clears_the_refusal(self): + """The documented remedy, end to end: the caller moves the tip out of the + liquid, primes, and the in-place draw goes through.""" + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.dispense_in_place(volume=15)) + self._assert_in_place_draw_is_refused(head, transport) + + asyncio.run(head.prepare_to_aspirate()) + asyncio.run(head.aspirate_in_place(volume=15)) + + sent = [c["commandType"] for c in transport.commands] + self.assertEqual(sent[-1], "aspirateInPlace") + self.assertEqual(sent.count("prepareToAspirate"), 1) + finally: + asyncio.run(flex.stop()) + + def test_configure_for_volume_makes_the_next_in_place_draw_refuse(self): + """Switching volume mode moves where the plunger bottom IS, so the robot + drops its ready flag.""" + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.configure_for_volume(10.0)) + + self._assert_in_place_draw_is_refused(head, transport) + finally: + asyncio.run(flex.stop()) + + def test_a_blow_out_makes_the_next_in_place_draw_refuse(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=15)) + asyncio.run(head.blow_out()) + + self._assert_in_place_draw_is_refused(head, transport) + finally: + asyncio.run(flex.stop()) + + def test_an_air_gap_in_place_refuses_the_same_way(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.blow_out()) + + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.air_gap_in_place(volume=10)) + self.assertEqual(caught.exception.title, "NotReadyToAspirateError") + finally: + asyncio.run(flex.stop()) + + def test_dispense_in_place_sends_the_dispense_default_and_omits_push_out(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.dispense_in_place(volume=15)) + + self.assertEqual( + self._params(transport, "dispenseInPlace"), + {"pipetteId": head.pipette_id, "volume": 15, "flowRate": 57.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_dispense_in_place_carries_push_out_only_when_given(self): + # Omitted rather than sent as null, so the robot picks its own push-out + # for the mounted tip and volume. + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.dispense_in_place(volume=15, flow_rate=8.0, push_out=2.5)) + + self.assertEqual( + self._params(transport, "dispenseInPlace"), + {"pipetteId": head.pipette_id, "volume": 15, "flowRate": 8.0, "pushOut": 2.5}, + ) + finally: + asyncio.run(flex.stop()) + + def test_air_gap_in_place_uses_the_aspirate_default_flow_rate(self): + # The air gap IS an aspirate motion, so it takes the aspirate default, + # not the dispense one. + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.air_gap_in_place(volume=5)) + + self.assertEqual( + self._params(transport, "airGapInPlace"), + {"pipetteId": head.pipette_id, "volume": 5, "flowRate": 35.0}, + ) + finally: + asyncio.run(flex.stop()) + + def test_air_gap_in_place_accepts_a_flow_rate_override(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.air_gap_in_place(volume=5, flow_rate=1.25)) + + self.assertEqual(self._params(transport, "airGapInPlace")["flowRate"], 1.25) + finally: + asyncio.run(flex.stop()) + + def test_every_in_place_op_without_a_tip_raises_and_sends_nothing(self): + flex, transport, head, _rack = self._bench() + try: + for op in ( + lambda: head.aspirate_in_place(volume=15), + lambda: head.dispense_in_place(volume=15), + lambda: head.air_gap_in_place(volume=5), + ): + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(op()) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + def test_head1_and_head96_inherit_the_in_place_ops(self): + # They live on the base class, so every head issues them identically. + flex1, transport1, head1 = _flex_head1() + try: + rack1 = flex_96_tiprack_50ul(name="rack1") + flex1.deck.assign_child_at_slot(rack1, "C1") + asyncio.run(head1.pick_up_tips(rack1.get_item("A1"))) + asyncio.run(head1.aspirate_in_place(volume=15)) + asyncio.run(head1.dispense_in_place(volume=15)) + + # Each head carries its OWN pipette's default, not one shared number: + # p1000_single_v3.5 on a 50uL tip against the 96-head's 6.0 below. + self.assertEqual( + self._params(transport1, "aspirateInPlace"), + {"pipetteId": head1.pipette_id, "volume": 15, "flowRate": 478.0}, + ) + self.assertEqual(self._params(transport1, "dispenseInPlace")["volume"], 15) + finally: + asyncio.run(flex1.stop()) + + flex96, transport96, head96 = _flex_head96() + try: + rack96 = flex_96_tiprack_50ul(name="rack96") + flex96.deck.assign_child_at_slot(rack96, "C1") + asyncio.run(head96.pick_up_tips(rack96)) + asyncio.run(head96.air_gap_in_place(volume=5)) + + self.assertEqual( + self._params(transport96, "airGapInPlace"), + {"pipetteId": head96.pipette_id, "volume": 5, "flowRate": 6.0}, + ) + finally: + asyncio.run(flex96.stop()) + + +class _TipPresenceTransport(ChatterboxTransport): + """Answers getTipPresence with a fixed sensor reading.""" + + def __init__(self, status: str, **kwargs): + super().__init__(**kwargs) + self.status = status + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if path.endswith("/commands") and data.get("commandType") == "getTipPresence": + result["data"]["result"] = {"status": self.status} + return result + + +class TestTipPresenceCommands(unittest.TestCase): + """get_tip_presence returns the sensor reading from the command result and + leaves the judgement to the caller; verify_tip_presence hands the robot the + state to check against, and refuses a state that is neither "present" nor + "absent" before anything reaches the wire.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def _head1_with_status(self, status: str): + transport = _TipPresenceTransport( + status, pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")] + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.right + assert isinstance(head, FlexHead1) + return flex, transport, head + + def test_get_tip_presence_returns_the_reported_status(self): + flex, transport, head = self._head1_with_status("present") + try: + self.assertEqual(asyncio.run(head.get_tip_presence()), "present") + + presence_cmds = [c for c in transport.commands if c["commandType"] == "getTipPresence"] + self.assertEqual(len(presence_cmds), 1) + self.assertEqual(presence_cmds[0]["params"], {"pipetteId": head.pipette_id}) + finally: + asyncio.run(flex.stop()) + + def test_get_tip_presence_reports_absent_without_raising(self): + flex, _transport, head = self._head1_with_status("absent") + try: + self.assertEqual(asyncio.run(head.get_tip_presence()), "absent") + finally: + asyncio.run(flex.stop()) + + def test_verify_tip_presence_sends_the_expected_state(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + + # Each state is asserted while it actually holds: the robot fails the + # command on a mismatch, so a bare pair would be checking a lie. + asyncio.run(head.verify_tip_presence("absent")) + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.verify_tip_presence("present")) + + verify_cmds = [c for c in transport.commands if c["commandType"] == "verifyTipPresence"] + self.assertEqual( + [c["params"] for c in verify_cmds], + [ + {"pipetteId": head.pipette_id, "expectedState": "absent"}, + {"pipetteId": head.pipette_id, "expectedState": "present"}, + ], + ) + finally: + asyncio.run(flex.stop()) + + def test_verify_tip_presence_rejects_any_other_state_and_sends_nothing(self): + # "unknown" is a reading the sensor can return, not a state worth + # asserting, so it is refused with the rest. + flex, transport, head = _flex_head8() + try: + for state in ("unknown", "Present", "", "yes"): + n_before = len(transport.commands) + with self.assertRaises(ValueError): + asyncio.run(head.verify_tip_presence(state)) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +class TestConfigureForVolume(unittest.TestCase): + """configure_for_volume names the volume the pipette should be set up for. + It runs with no tip mounted on purpose: the robot refuses a mode change + while a tip is attached, so it belongs before the pickup.""" + + def test_configure_for_volume_sends_pipette_id_and_volume_before_any_pickup(self): + flex, transport, head = _flex_head8() + try: + asyncio.run(head.configure_for_volume(5.0)) + + configure_cmds = [c for c in transport.commands if c["commandType"] == "configureForVolume"] + self.assertEqual(len(configure_cmds), 1) + self.assertEqual(configure_cmds[0]["params"], {"pipetteId": head.pipette_id, "volume": 5.0}) + finally: + asyncio.run(flex.stop()) + + +class TestUnsafeRecoveryOps(unittest.TestCase): + """The unsafe/ ops are the recovery path: one command each, no labware, no + trackers. The drop clears this head's per-channel tip bookkeeping (the tip + goes back to no rack), and the blow-out leaves the plunger unprimed the way + the ordinary blow_out does. Both require a mounted tip.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + return flex, transport, head, rack + + def test_unsafe_drop_tip_in_place_sends_pipette_id_and_clears_mounted_tips(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.unsafe_drop_tip_in_place()) + + drop_cmds = [c for c in transport.commands if c["commandType"] == "unsafe/dropTipInPlace"] + self.assertEqual(len(drop_cmds), 1) + self.assertEqual(drop_cmds[0]["params"], {"pipetteId": head.pipette_id}) + self.assertTrue(all(tip is None for tip in head.get_mounted_tips())) + finally: + asyncio.run(flex.stop()) + + def test_unsafe_drop_tip_in_place_returns_no_tip_to_the_rack(self): + # The tip falls where the head is, so the spot it came from stays empty. + flex, _transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.unsafe_drop_tip_in_place()) + + self.assertFalse(rack.get_item("A1").has_tip()) + finally: + asyncio.run(flex.stop()) + + def test_unsafe_blow_out_in_place_sends_the_given_flow_rate(self): + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.unsafe_blow_out_in_place(flow_rate=20.0)) + + blow_cmds = [c for c in transport.commands if c["commandType"] == "unsafe/blowOutInPlace"] + self.assertEqual(len(blow_cmds), 1) + self.assertEqual(blow_cmds[0]["params"], {"pipetteId": head.pipette_id, "flowRate": 20.0}) + finally: + asyncio.run(flex.stop()) + + def test_an_unsafe_blow_out_makes_the_next_in_place_draw_refuse(self): + """The recovery blow-out leaves the plunger where the ordinary one does, + so the next in-place draw needs the same manual prime.""" + flex, transport, head, rack = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate_in_place(volume=10)) + asyncio.run(head.unsafe_blow_out_in_place(flow_rate=20.0)) + + with self.assertRaises(OpentronsError) as caught: + asyncio.run(head.aspirate_in_place(volume=10)) + self.assertEqual(caught.exception.title, "NotReadyToAspirateError") + finally: + asyncio.run(flex.stop()) + + def test_unsafe_ops_without_a_tip_raise_and_send_nothing(self): + flex, transport, head, _rack = self._bench() + try: + for op in ( + head.unsafe_drop_tip_in_place, + lambda: head.unsafe_blow_out_in_place(flow_rate=20.0), + ): + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(op()) + self.assertEqual(len(transport.commands), n_before, "rejection must not reach the wire") + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_gripper.py b/pylabrobot/opentrons/flex_gripper.py new file mode 100644 index 00000000000..24af0e80738 --- /dev/null +++ b/pylabrobot/opentrons/flex_gripper.py @@ -0,0 +1,205 @@ +"""Gripper sub-object for :class:`~pylabrobot.opentrons.flex.OpentronsFlex`. + +The Flex gripper rides the extension mount and moves labware between deck +slots. Like the heads (:mod:`pylabrobot.opentrons.flex_head`), it is a +plain-class sub-object: it holds a back-reference to the owning +``OpentronsFlex`` and issues commands through the shared transport via +``self.flex._execute_command``. It is composed by ``OpentronsFlex.setup()`` +when ``GET /instruments`` reports a gripper -- ``flex.gripper`` is ``None`` +on a Flex without one. + +The robot-server's ``moveLabware`` command is atomic (pick + travel + place +in one command), so a gripper move is a single wire call rather than a +pick/move/drop sequence. Grip geometry comes from the labware's definition: +the robot's own for official load names, or the uploaded custom definition +(optionally carrying a caller-chosen grip height) for everything else. +""" + +import logging +from typing import TYPE_CHECKING, Any, Dict, Optional + +from pylabrobot.opentrons.flex_wire import ( + UNTESTED_HARDWARE_WARNING, + _require_robot_commands, + slot_wire_location, +) +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.resources.resource import Resource + +if TYPE_CHECKING: + from pylabrobot.opentrons.flex import OpentronsFlex + +logger = logging.getLogger(__name__) + +# Gripper moves are slow physical operations (pick + travel + place), so give +# them far more headroom than the 30s ``_execute_command`` default. +_MOVE_LABWARE_TIMEOUT = 120.0 + +# Grip-force bounds (Newtons) the robot-server accepts for closeGripperJaw. +_GRIPPER_MIN_FORCE = 2.0 +_GRIPPER_MAX_FORCE = 30.0 + + +class FlexGripper: + """The Opentrons Flex gripper (extension mount). + + Constructed by ``OpentronsFlex._model_setup()`` when instrument discovery + reports a gripper; access it as ``flex.gripper``. + + The Flex gripper has NO rotation capability (a hardware limitation, not a + missing API): labware keeps its orientation through every gripper motion, + so a plate cannot be re-oriented between slots. + """ + + def __init__(self, flex: "OpentronsFlex", gripper_model: str) -> None: + self.flex = flex + self.gripper_model = gripper_model + self._untested_hardware_warned: bool = False + + def _warn_untested_hardware(self, op: str) -> None: + """Log a one-time notice that gripper ops are not yet verified on real hardware.""" + if self._untested_hardware_warned: + return + self._untested_hardware_warned = True + logger.warning(UNTESTED_HARDWARE_WARNING, type(self).__name__, op) + + async def move_labware( + self, + resource: Resource, + to_slot: str, + grip_distance_from_top: Optional[float] = None, + ) -> None: + """Move ``resource`` from its current deck slot to ``to_slot`` with the gripper. + + Validates PLR-side first (resource on deck, destination a valid empty + slot), then sends ONE atomic ``moveLabware`` command. On wire success the + deck is re-parented to match; on wire failure the deck is left untouched + and the error propagates. Standard slots ride ``slotName``; the column-4 + staging slots A4-D4 are addressable areas server-side and ride + ``addressableAreaName``. + + Args: + resource: A resource currently placed on the deck. + to_slot: Destination slot, e.g. ``"C2"`` (standard) or ``"B4"`` (staging). + grip_distance_from_top: How far below the labware's top the paddles + grab (mm), baked into the grip height of a custom definition + pylabrobot uploads. It therefore applies ONLY to labware pylabrobot + uploads a definition for: a resource resolving to an official + Opentrons load name (``ot_load_name`` set, a standard tip-rack name, + or a name starting with ``opentrons_``) loads the catalogue definition + instead, whose grip height is the vendor's to state -- and when that + definition states none, the robot grips at the labware's mid-height + rather than at this value. Honored on the labware's FIRST load in the + run only; once the robot holds a definition for it, later values are + ignored too. Every ignored value is logged. ``None`` keeps the + definition's grip height (the robot's mid-height default for custom + definitions built without one). + + Raises: + OpentronsError: If the resource is not on the deck, or ``to_slot`` is + invalid or occupied. Raised before any wire command is sent. + """ + self._warn_untested_hardware("move_labware") + deck = self.flex.deck + name = getattr(resource, "name", str(resource)) + + from_slot = deck.get_slot(resource) + if from_slot is None: + raise OpentronsError( + "Resource not on deck", + f"'{name}' is not on a deck slot. Use deck.assign_child_at_slot(resource, slot='C1').", + ) + + to_slot = to_slot.upper() + try: + occupant = deck.get_resource_at_slot(to_slot) + except ValueError as e: + raise OpentronsError("Invalid destination slot", str(e)) from e + if occupant is not None: + occupant_name = getattr(occupant, "name", str(occupant)) + raise OpentronsError( + "Destination slot occupied", + f"Slot {to_slot} is already occupied by '{occupant_name}'.", + ) + + labware_id = await self.flex._ensure_labware_loaded( + resource, allow_stub=True, grip_distance_from_top=grip_distance_from_top + ) + await self.flex._execute_command( + "moveLabware", + { + "labwareId": labware_id, + "newLocation": slot_wire_location(to_slot), + "strategy": "usingGripper", + }, + timeout=_MOVE_LABWARE_TIMEOUT, + ) + + deck.unassign_child_at_slot(from_slot) + deck.assign_child_at_slot(resource, to_slot) + logger.info("Gripper moved '%s' from %s to %s", name, from_slot, to_slot) + + async def ungrip(self) -> None: + """Open the gripper jaw (homing it) to release any held labware. + + Recovery command: after an interrupted ``moveLabware`` the gripper may + still be holding the labware; this releases it so the operator can + recover the plate by hand. + """ + self._warn_untested_hardware("ungrip") + await self.flex._execute_command("unsafe/ungripLabware", {}) + + # --- robot/*: direct gripper motion and jaw control --- + + async def move_to(self, x: float, y: float, z: float, speed: Optional[float] = None) -> None: + """Move the gripper to an absolute deck-frame position, in mm. + + Uses ``robot/moveTo`` with the extension mount rather than the + ``robot/moveAxes*`` family: those infer the mount from the axis map, and + the server's offset table has no gripper entry, so an ``extensionZ`` + target fails on the robot with ``KeyError: Mount.EXTENSION``. The gripper + also has a lower z ceiling than the pipette mounts, so a z a pipette + accepts can still be out of bounds. ``speed`` is in mm/s (robot default + if None). + """ + self._warn_untested_hardware("move_to") + _require_robot_commands("robot/moveTo", self.flex.api_version) + # The robot/* commands take snake_case params, unlike the rest of the API. + params: Dict[str, Any] = {"mount": "extension", "destination": {"x": x, "y": y, "z": z}} + if speed is not None: + params["speed"] = speed + await self.flex._execute_command("robot/moveTo", params) + + async def grip(self, force: Optional[float] = None) -> None: + """Close the gripper jaw around whatever sits between its paddles. + + Args: + force: Grip force in Newtons, between 2.0 and 30.0. The robot applies + its own default when None. There is no jaw-width parameter; the jaw + closes until it grips. + + Raises: + OpentronsError: If ``force`` is outside the accepted range -- raised + before any wire command is sent. + """ + self._warn_untested_hardware("grip") + _require_robot_commands("robot/closeGripperJaw", self.flex.api_version) + params: Dict[str, Any] = {} + if force is not None: + if not _GRIPPER_MIN_FORCE <= force <= _GRIPPER_MAX_FORCE: + raise OpentronsError( + "Invalid grip force", + f"Grip force must be between {_GRIPPER_MIN_FORCE} and {_GRIPPER_MAX_FORCE} Newtons, " + f"got {force}.", + ) + params["force"] = force + await self.flex._execute_command("robot/closeGripperJaw", params) + + async def open_jaw(self) -> None: + """Open the gripper jaw -- the robot opens by HOMING the jaw to fully open. + + Releases anything held; there is no partial-open width parameter. + """ + self._warn_untested_hardware("open_jaw") + _require_robot_commands("robot/openGripperJaw", self.flex.api_version) + await self.flex._execute_command("robot/openGripperJaw", {}) diff --git a/pylabrobot/opentrons/flex_gripper_tests.py b/pylabrobot/opentrons/flex_gripper_tests.py new file mode 100644 index 00000000000..6eefa1a3ea9 --- /dev/null +++ b/pylabrobot/opentrons/flex_gripper_tests.py @@ -0,0 +1,405 @@ +"""Tests for the Flex gripper capability (``flex.gripper``). + +Drives ``OpentronsFlex.setup()`` with an injected ``ChatterboxTransport`` +advertising a gripper on the extension mount, and asserts discovery attaches +a :class:`~pylabrobot.opentrons.flex_gripper.FlexGripper`; ``move_labware`` +follows the stage -> wire -> commit idiom (PLR-side validation before any +wire command, deck re-parent only on wire success); and +``labware_moved_off_deck`` releases a slot both server-side and PLR-side. +""" + +import asyncio +import unittest +from typing import Any, Dict, Optional, Tuple + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_gripper import FlexGripper +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import Resource, cor_96_wellplate_360uL_Fb +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.plate import Plate + + +def _flex_with_gripper(**transport_kwargs) -> Tuple[OpentronsFlex, ChatterboxTransport]: + """An ``OpentronsFlex`` whose transport advertises a gripper on the + extension mount (plus a single-channel pipette so setup() succeeds), + returning the transport too so a test can inspect recorded commands. + + ``transport_kwargs`` are forwarded to ``ChatterboxTransport``. + """ + transport = ChatterboxTransport( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], + gripper=True, + **transport_kwargs, + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + return flex, transport + + +def _plate(name: str = "plate") -> Plate: + plate = cor_96_wellplate_360uL_Fb(name=name) + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + return plate + + +class _FailingMoveTransport(ChatterboxTransport): + """Chatterbox whose ``moveLabware`` commands fail at the robot: the command + is accepted (recorded) but its status poll reports ``failed``, so + ``_execute_command`` raises the way a real robot-server failure would. + """ + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if path.endswith("/commands") and data.get("commandType") == "moveLabware": + cmd_data = result["data"] + cmd_data["status"] = "failed" + cmd_data["error"] = {"detail": "simulated gripper failure"} + return result + + +class TestGripperDiscovery(unittest.TestCase): + """setup() composes a FlexGripper iff /instruments reports a gripper.""" + + def test_gripper_attached_when_advertised(self): + flex, _transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + self.assertIsInstance(flex.gripper, FlexGripper) + assert flex.gripper is not None + self.assertEqual(flex.gripper.gripper_model, "gripperV1.3") + # Head composition is unaffected by the extra instrument entry. + self.assertIsNotNone(flex.right) + finally: + asyncio.run(flex.stop()) + + def test_gripper_none_when_absent(self): + transport = ChatterboxTransport(pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + self.assertIsNone(flex.gripper) + finally: + asyncio.run(flex.stop()) + + +class TestMoveLabware(unittest.TestCase): + """move_labware sends ONE atomic moveLabware command and re-parents the + deck only on wire success; the labware is JIT-loaded once.""" + + def test_happy_path_sends_exact_wire_params_and_reparents_deck(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(plate, "C2")) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1) + labware_id = flex._loaded_labware["plate"] + self.assertEqual( + move_cmds[0]["params"], + { + "labwareId": labware_id, + "newLocation": {"slotName": "C2"}, + "strategy": "usingGripper", + }, + ) + + self.assertEqual(flex.deck.get_slot(plate), "C2") + self.assertIsNone(flex.deck.get_resource_at_slot("C1")) + self.assertIs(flex.deck.get_resource_at_slot("C2"), plate) + finally: + asyncio.run(flex.stop()) + + def test_second_move_reuses_the_loaded_labware(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(plate, "C2")) + asyncio.run(gripper.move_labware(plate, "D3")) + + load_cmds = [c for c in transport.commands if c["commandType"] == "loadLabware"] + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(load_cmds), 1, "labware must be JIT-loaded exactly once") + self.assertEqual(len(move_cmds), 2) + self.assertEqual(flex.deck.get_slot(plate), "D3") + finally: + asyncio.run(flex.stop()) + + def test_move_to_staging_slot_uses_addressable_area_form(self): + # The robot-server's DeckSlotName has no A4-D4: staging slots are + # addressable areas, so {"slotName": "B4"} would be rejected there. + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(plate, "B4")) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1) + self.assertEqual(move_cmds[0]["params"]["newLocation"], {"addressableAreaName": "B4"}) + self.assertEqual(flex.deck.get_slot(plate), "B4") + finally: + asyncio.run(flex.stop()) + + def test_move_back_from_staging_slot_uses_slot_name_form(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "A4") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(plate, "C2")) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(move_cmds[0]["params"]["newLocation"], {"slotName": "C2"}) + finally: + asyncio.run(flex.stop()) + + def test_move_bare_resource_uploads_stub_with_grip_geometry(self): + # A resource with no pipettable geometry (lid, adapter) rides the movable + # stub definition; grip_distance_from_top shapes its grip height. + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + lid = Resource(name="lid stack", size_x=100.0, size_y=90.0, size_z=20.0) + flex.deck.assign_child_at_slot(lid, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(lid, "C2", grip_distance_from_top=5.0)) + + self.assertEqual(len(transport.labware_definitions), 1) + definition = transport.labware_definitions[0] + self.assertEqual(definition["ordering"], [["A1"]]) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 15.0) # 20 - 5 + load_cmds = [c for c in transport.commands if c["commandType"] == "loadLabware"] + self.assertEqual(load_cmds[0]["params"]["namespace"], "pylabrobot") + self.assertEqual(load_cmds[0]["params"]["loadName"], "lid_stack_2196eb") + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1) + self.assertEqual(flex.deck.get_slot(lid), "C2") + finally: + asyncio.run(flex.stop()) + + +class TestGripDistanceDiscarded(unittest.TestCase): + """grip_distance_from_top only shapes a definition pylabrobot uploads, so + the paths that cannot honor it say so instead of dropping it silently.""" + + def test_catalogue_labware_logs_the_ignored_grip_distance(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() # resolves to an official Opentrons load name + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + with self.assertLogs("pylabrobot.opentrons.flex", level="WARNING") as logs: + asyncio.run(gripper.move_labware(plate, "C2", grip_distance_from_top=5.0)) + + # Nothing is uploaded, so there is nowhere to put the requested height: + # the robot grips at the catalogue definition's own. + self.assertEqual(len(transport.labware_definitions), 0) + load_cmds = [c for c in transport.commands if c["commandType"] == "loadLabware"] + self.assertEqual(load_cmds[0]["params"]["namespace"], "opentrons") + self.assertTrue(any("grip_distance_from_top=5.0" in line for line in logs.output)) + self.assertTrue( + any("corning_96_wellplate_360ul_flat" in line for line in logs.output), + logs.output, + ) + finally: + asyncio.run(flex.stop()) + + def test_second_move_logs_the_grip_distance_the_loaded_labware_ignores(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + lid = Resource(name="lid stack", size_x=100.0, size_y=90.0, size_z=20.0) + flex.deck.assign_child_at_slot(lid, "C1") + gripper = flex.gripper + assert gripper is not None + asyncio.run(gripper.move_labware(lid, "C2", grip_distance_from_top=5.0)) + + with self.assertLogs("pylabrobot.opentrons.flex", level="WARNING") as logs: + asyncio.run(gripper.move_labware(lid, "C3", grip_distance_from_top=9.0)) + + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(transport.labware_definitions[0]["gripHeightFromLabwareBottom"], 15.0) + self.assertTrue(any("grip_distance_from_top=9.0" in line for line in logs.output)) + finally: + asyncio.run(flex.stop()) + + +class TestMoveLabwarePreWireRejections(unittest.TestCase): + """Invalid moves raise OpentronsError BEFORE any wire command is sent.""" + + def _assert_no_move_commands(self, transport: ChatterboxTransport) -> None: + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 0, "no moveLabware wire command may be sent") + + def test_labware_not_on_deck_raises(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() # never assigned to the deck + gripper = flex.gripper + assert gripper is not None + + with self.assertRaises(OpentronsError): + asyncio.run(gripper.move_labware(plate, "C2")) + + self._assert_no_move_commands(transport) + finally: + asyncio.run(flex.stop()) + + def test_occupied_destination_raises_and_deck_untouched(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + other = _plate(name="other") + flex.deck.assign_child_at_slot(plate, "C1") + flex.deck.assign_child_at_slot(other, "C2") + gripper = flex.gripper + assert gripper is not None + + with self.assertRaises(OpentronsError): + asyncio.run(gripper.move_labware(plate, "C2")) + + self._assert_no_move_commands(transport) + self.assertEqual(flex.deck.get_slot(plate), "C1") + self.assertEqual(flex.deck.get_slot(other), "C2") + finally: + asyncio.run(flex.stop()) + + def test_invalid_destination_slot_raises(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + with self.assertRaises(OpentronsError): + asyncio.run(gripper.move_labware(plate, "E5")) + + self._assert_no_move_commands(transport) + self.assertEqual(flex.deck.get_slot(plate), "C1") + finally: + asyncio.run(flex.stop()) + + +class TestMoveLabwareWireFailure(unittest.TestCase): + """A wire-level moveLabware failure re-raises and leaves the deck untouched.""" + + def test_failed_move_leaves_deck_untouched(self): + transport = _FailingMoveTransport( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], gripper=True + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + gripper = flex.gripper + assert gripper is not None + + with self.assertRaises(RuntimeError): + asyncio.run(gripper.move_labware(plate, "C2")) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1, "the command reached the wire and failed there") + self.assertEqual(flex.deck.get_slot(plate), "C1") + self.assertIsNone(flex.deck.get_resource_at_slot("C2")) + finally: + asyncio.run(flex.stop()) + + +class TestLabwareMovedOffDeck(unittest.TestCase): + """labware_moved_off_deck releases the slot server-side (offDeck logical + move) for loaded labware, evicts the load cache, and frees the PLR slot.""" + + def test_loaded_labware_sends_offdeck_move_and_evicts_cache(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + labware_id = asyncio.run(flex._ensure_labware_loaded(plate)) + + asyncio.run(flex.labware_moved_off_deck(plate)) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 1) + self.assertEqual( + move_cmds[0]["params"], + { + "labwareId": labware_id, + "newLocation": "offDeck", + "strategy": "manualMoveWithoutPause", + }, + ) + self.assertNotIn("plate", flex._loaded_labware) + self.assertIsNone(flex.deck.get_slot(plate)) + self.assertIsNone(flex.deck.get_resource_at_slot("C1")) + finally: + asyncio.run(flex.stop()) + + def test_never_loaded_labware_frees_slot_without_wire_command(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + + asyncio.run(flex.labware_moved_off_deck(plate)) + + move_cmds = [c for c in transport.commands if c["commandType"] == "moveLabware"] + self.assertEqual(len(move_cmds), 0, "never-loaded labware needs no wire command") + self.assertIsNone(flex.deck.get_slot(plate)) + self.assertIsNone(flex.deck.get_resource_at_slot("C1")) + finally: + asyncio.run(flex.stop()) + + +class TestUngrip(unittest.TestCase): + """ungrip() sends the unsafe/ungripLabware recovery command.""" + + def test_ungrip_sends_unsafe_ungrip_labware(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.ungrip()) + + ungrip_cmds = [c for c in transport.commands if c["commandType"] == "unsafe/ungripLabware"] + self.assertEqual(len(ungrip_cmds), 1) + self.assertEqual(ungrip_cmds[0]["params"], {}) + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_head.py b/pylabrobot/opentrons/flex_head.py new file mode 100644 index 00000000000..a0d784db00b --- /dev/null +++ b/pylabrobot/opentrons/flex_head.py @@ -0,0 +1,2558 @@ +"""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 and hardware tip-presence +verification are factored onto the ``_FlexHead`` base +(``_execute_pickup``/``_execute_liquid_op``/``_execute_draw``/ +``_execute_trash_drop``) so ``FlexHead1`` and ``FlexHead96`` reuse the exact +machinery ``FlexHead8`` established -- only the addressing (single well vs. +column vs. whole-plate anchor) and nozzle layout differ per head. + +Plunger priming (``prepareToAspirate``) is the robot's business, not this +driver's: see ``prepare_to_aspirate`` for the whole rule and why nothing here +tracks it. +""" + +import logging +from typing import TYPE_CHECKING, Any, Dict, FrozenSet, List, Optional, Sequence, Tuple, Union, cast + +from pylabrobot.opentrons.checks import traversal_z +from pylabrobot.opentrons.flex_wire import UNTESTED_HARDWARE_WARNING +from pylabrobot.opentrons.labware_definitions import container_footprint +from pylabrobot.opentrons.pipette_defaults import FlowRates, flow_rates +from pylabrobot.opentrons.robot import OpentronsCommandError, OpentronsError +from pylabrobot.resources import ( + Container, + Plate, + TipRack, + TipSpot, + Trash, + VolumeTracker, + 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. + """ + + # Op names confirmed on real Flex hardware; every op outside this set + # triggers the one-time untested-hardware notice. + _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset() + + def __init__( + self, + flex: "OpentronsFlex", + mount: str, + pipette_id: str, + channels: int, + pipette_model: str, + max_volume: float, + ) -> None: + self.flex = flex + self.mount = mount + self.pipette_id = pipette_id + self.channels = channels + self.pipette_model = pipette_model + # The pipette's own capacity, not the mounted tip's. Carried here so a caller + # describing the head does not have to re-read /instruments to get it. + self.max_volume = max_volume + self._channel_tips: List[Optional[Tip]] = [None] * channels + self._untested_hardware_warned: bool = False + # The labware id the pipette last pipetted over, or None when its position is + # unknown (start of run, after a jog or a trash drop). Used to arc high only + # when a pipetting move crosses to a different slot -- see _travel_guard. + self._current_labware_id: Optional[str] = None + + async def _travel_guard(self, params: Dict[str, Any]) -> None: + """Arc to a new slot's well at the safe travel plane before pipetting there. + + A pipetting move to a well on a DIFFERENT labware than the pipette last + worked over crosses deck slots, so it is prefixed with a ``moveToWell`` at + the computed traversal plane (never below a tip rack, see + ``checks.traversal_z``) -- the ``aspirate``/``dispense``/``pickUpTip``/ + ``dropTip`` commands cannot carry a ``minimumZHeight`` themselves. A move + WITHIN the same labware never crosses another slot, so it is left to the + engine's own low arc. ``minimumZHeight`` is a mid-travel floor only; it does + not clamp the descent, so the following op still reaches the well. + """ + labware_id = params.get("labwareId") + well_name = params.get("wellName") + if not isinstance(labware_id, str) or not isinstance(well_name, str): + return + if labware_id == self._current_labware_id: + return + await self._execute( + "moveToWell", + { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "wellLocation": {"origin": "top", "offset": {"x": 0, "y": 0, "z": 0}}, + "minimumZHeight": self._traversal_height(), + }, + ) + self._current_labware_id = labware_id + + def _warn_untested_hardware(self, op: str) -> None: + """Log a one-time notice when an op has no real-hardware verification. + + Coverage is op-scoped: ops in ``_HARDWARE_VERIFIED_OPS`` never log; the + first op outside that set logs once per instance. + """ + if op in self._HARDWARE_VERIFIED_OPS or self._untested_hardware_warned: + return + self._untested_hardware_warned = True + logger.warning(UNTESTED_HARDWARE_WARNING, type(self).__name__, op) + + 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) + + def default_flow_rates(self) -> FlowRates: + """The robot's own defaults for this pipette and the tip currently on it. + + Tip-dependent, so it cannot be resolved before a pickup: the same p1000 + eight-channel defaults to 478 uL/s on a 50 uL tip and 716 on a 200. + """ + mounted = [tip for tip in self._channel_tips if tip is not None] + if not mounted: + raise OpentronsError( + "NoTipMounted", + f"'{self.mount}' has no tip, so its default flow rate is undefined. Pick up a " + "tip first, or pass an explicit flow_rate.", + ) + return flow_rates(self.pipette_model, mounted[0].maximal_volume) + + async def discard_tips(self, trash: Trash) -> None: + """Discard all mounted tips into ``trash``. Implemented by each head.""" + raise NotImplementedError + + async def blow_out(self, flow_rate: Optional[float] = None) -> None: + """Blow out at the current position -- one ``blowOutInPlace`` command. + + Pushes the plunger past its dispense-bottom to expel residual liquid + from the tip(s) wherever the pipette currently is (no well addressing -- + position with a dispense/move first). ``flow_rate`` (uL/s) defaults to + the dispense default. Blowing out leaves the plunger past its dispense + bottom, so the next draw needs priming: see ``prepare_to_aspirate``. No + trackers are involved. + """ + self._warn_untested_hardware("blow_out") + rate = flow_rate if flow_rate is not None else self.default_flow_rates().blow_out + await self._execute("blowOutInPlace", {"pipetteId": self.pipette_id, "flowRate": rate}) + + 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 reading per pipette (mount), not per + nozzle/channel. This is the aggregate hardware ground truth, used to + verify/reconcile the per-channel ``_channel_tips`` bookkeeping -- it + cannot tell you *which* channel(s) hold a tip. + + Asked for with the ``getTipPresence`` run command rather than the + ``GET /instruments`` REST read: same bit, but the REST path re-caches the + attached instruments as a side effect. + + Returns: + ``True``/``False`` when the sensor reads present/absent, ``None`` when + it reads unknown or reports no status. + """ + status = await self._read_tip_presence() + if status == "present": + return True + if status == "absent": + return False + return None + + async def _read_tip_presence(self) -> Optional[str]: + result = await self._execute("getTipPresence", {"pipetteId": self.pipette_id}) + return cast(Optional[str], result.get("result", {}).get("status")) + + async def _verify_tips_seated(self) -> None: + """Raise if the hardware tip-presence sensor reports no tip after a pickup. + + 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`` AFTER this returns successfully. + """ + try: + await self._travel_guard(params) + 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._travel_guard(params) + 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_draw(self, command_type: str, params: Dict[str, Any]) -> Dict[str, Any]: + """``_execute`` for a draw, restating the robot's "plunger not primed" refusal. + + The robot refuses a draw that names no well while the plunger sits past + its dispense bottom: fixing that means moving the plunger, and only the + caller knows whether the tip is in liquid. The robot's own message offers + one remedy (aspirate from a well instead); this adds the other one, which + is to lift clear and prime by hand. + """ + try: + return await self._execute(command_type, params) + except OpentronsCommandError as e: + if e.error_type != _NOT_READY_TO_ASPIRATE: + raise + raise OpentronsError("NotReadyToAspirateError", _NOT_PRIMED_REMEDY) from e + + async def prepare_to_aspirate(self) -> None: + """Move the plunger to where an aspirate starts from ("priming"). Tip OUT of the liquid. + + The plunger sits past its dispense bottom after a dispense that emptied + the tip, a blow out, or a volume-mode change, and the robot will not draw + from there. Priming lifts it back, which with the tip submerged draws + that much of the well in, unmeasured: about 4 uL on a p50 in its normal + mode, 12 uL in low-volume mode, 80 uL on a p1000. Move the tip above the + liquid first (``move_to_well`` with a "top" origin) and prime there. + + Usually you do not need this at all. A well-addressed ``aspirate`` primes + itself, at the well top in open air, then descends and draws. Only the + in-place draws need it, because they name no well and so the robot cannot + pick a safe height to prime at. Sending it when the plunger is already in + place does nothing, so it is safe to send defensively. + + Nothing here tracks whether a prime is pending. The robot owns that flag + and answers with it on every draw; a copy in this process would go stale + the first time anything moved the plunger without going through this + driver. + """ + self._warn_untested_hardware("prepare_to_aspirate") + self._require_mounted_tip() + await self._execute("prepareToAspirate", {"pipetteId": self.pipette_id}) + + def _trash_addressable_area(self, trash: Trash) -> str: + """The movable-trash addressable area for the slot this trash sits in.""" + slot = self.flex.deck.get_slot(trash) + if slot not in _MOVABLE_TRASH_SLOTS: + raise OpentronsError( + "Trash is not in a trash slot", + f"'{trash.name}' is in slot {slot!r}. A Flex accepts a movable trash only in " + f"{', '.join(sorted(_MOVABLE_TRASH_SLOTS))}.", + ) + return f"movableTrash{slot}" + + async def _execute_trash_drop(self, trash: Trash) -> None: + """Send the two-command addressable-area trash-drop sequence. + + Shared by every ``discard_tips``/``drop_single_tip`` variant. No tracker + involvement (trash has none); callers update ``_channel_tips`` and call + ``_confirm_tips_cleared()`` themselves after this returns. + + ``minimumZHeight`` is set to the computed traversal plane so the travel to + the trash arcs over every labware on the deck. Without it the engine picks + its own arc height from only the labware it has been told is loaded, which + can travel too low and clip a rack the robot was never told about. + """ + await self._execute( + "moveToAddressableAreaForDropTip", + { + "pipetteId": self.pipette_id, + "addressableAreaName": self._trash_addressable_area(trash), + "alternateDropLocation": True, + "minimumZHeight": self._traversal_height(), + }, + ) + await self._execute("dropTipInPlace", {"pipetteId": self.pipette_id}) + # Now over the trash, not a slot's labware: the next pipetting move arcs high. + self._current_labware_id = None + + # --- Fine-pipetting shared helpers --- + + def _mounted_count(self) -> int: + """How many channels currently hold a tip.""" + return sum(1 for tip in self._channel_tips if tip is not None) + + def _require_mounted_tip(self) -> None: + """Raise if no channel holds a tip -- pre-wire guard for tip-motion ops. + + Without a tip the same command drives the bare NOZZLE into the labware, + about a tip length lower than the pose it describes. The engine does + reject a tipless op, but ``dispense`` moves to the well before it checks, + so the crash lands first. + """ + if all(tip is None for tip in self._channel_tips): + raise OpentronsError( + "NoTipError", + "No tip mounted; pick up a tip first.", + ) + + async def _well_target(self, target: Union[Well, TipSpot, Container]) -> Tuple[str, str]: + """Labware id and well name for a well or tip spot, or a container's sole well.""" + if isinstance(target, (Well, TipSpot)): + parent = self._require_itemized_parent(target) + loaded = await self.flex._ensure_labware_loaded(parent) + return loaded, parent.get_child_identifier(target) + return await self.flex._ensure_labware_loaded(target), _CONTAINER_WELL_NAME + + async def _pipette( + self, + verb: str, + labware_id: str, + well_name: str, + volume: float, + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + staged_trackers: List[VolumeTracker], + ) -> None: + """Send one ``aspirate``/``dispense`` at a named well and settle the trackers. + + ``flow_rate`` defaults to the robot's own for the mounted tip, so it is + resolved only when the caller left it out (asking with no tip raises). + Naming the well is what lets the robot prime itself when it needs to, so + no ``prepareToAspirate`` is sent here: see ``prepare_to_aspirate``. + """ + if flow_rate is None: + rates = self.default_flow_rates() + flow_rate = rates.aspirate if verb == "aspirate" else rates.dispense + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "volume": volume, + "flowRate": flow_rate, + } + well_location = self._well_location([offset], [liquid_height]) + if well_location is not None: + params["wellLocation"] = well_location + await self._execute_liquid_op(verb, params, staged_trackers) + + async def _configure_nozzle_layout(self, configuration_params: Dict[str, Any]) -> None: + """Send ``configureNozzleLayout``, refusing while any channel holds a tip. + + The engine rejects EVERY nozzle reconfiguration, "ALL" included, while a + tip is attached ("Cannot configure nozzle layout ... while it has tips + attached"), because the layout decides which physical nozzles the pipette + drives. Refused here instead, so the caller reads which tips are in the + way rather than a mid-op command failure from the robot. + """ + if any(tip is not None for tip in self._channel_tips): + held = [i for i, tip in enumerate(self._channel_tips) if tip is not None] + raise OpentronsError( + "HasTipError", + f"The nozzle layout cannot change while channel(s) {held} hold a tip; the robot " + "refuses the reconfiguration. Drop the mounted tip(s) first.", + ) + await self._execute( + "configureNozzleLayout", + {"pipetteId": self.pipette_id, "configurationParams": configuration_params}, + ) + + def _touch_tip_params( + self, + labware_id: str, + well_name: str, + radius: float, + offset: Optional[Coordinate], + ) -> Dict[str, Any]: + """Build the ``touchTip`` params dict shared by every head's ``touch_tip``. + + The ``wellLocation`` is TOP-relative and the default touches 1 mm below + the rim. A caller ``offset`` REPLACES it rather than shifting it, unlike + the liquid position ``_well_location`` builds: this z IS the touch height + (the Opentrons Python API's absolute ``v_offset``), and dropping the + default here moves the tip UP toward the rim, away from the labware. + """ + o = offset if offset is not None else Coordinate(z=_DEFAULT_TOUCH_TIP_Z_OFFSET) + return { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "wellLocation": {"origin": "top", "offset": {"x": o.x, "y": o.y, "z": o.z}}, + "radius": radius, + } + + async def _probe_z(self, command_type: str, labware_id: str, well_name: str) -> Optional[float]: + """Send a ``liquidProbe``/``tryLiquidProbe`` command; return the found liquid z (mm). + + The probe starts just above the well rim (origin "top", +2 mm -- the + engine's own ``LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP``) and descends; + the engine reads the offset into the stroke length, so a bottom-origin + start would drive the tip from the well floor through the plate. The + robot-server OMITS ``z_position`` from a successful command result + entirely (rather than reporting null) when no liquid is detected, so + absence is read with ``.get()`` and surfaced as ``None``. + + A probe pushes the plunger, so it is refused on an unprimed one exactly + like an in-place draw, even though it does name a well: the robot + deliberately does not prime for it, because reaching this state means the + tip has held liquid and a probe wants a dry one. + """ + result = await self._execute_draw( + command_type, + { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "wellLocation": { + "origin": "top", + "offset": {"x": 0, "y": 0, "z": _LIQUID_PROBE_START_OFFSET_Z}, + }, + }, + ) + return cast(Optional[float], result.get("result", {}).get("z_position")) + + async def _liquid_probe_z(self, labware_id: str, well_name: str, where: str) -> float: + """``liquidProbe`` with both no-liquid signals mapped to ``LiquidNotFoundError``. + + On real hardware a no-liquid probe FAILS the command with the defined + "liquidNotFound" error, so the wire failure is caught and translated; + the absent-``z_position`` success path additionally covers transports + that succeed without a result (e.g. ``ChatterboxTransport``). Other wire + failures re-raise untranslated. + """ + try: + z = await self._probe_z("liquidProbe", labware_id, well_name) + except OpentronsCommandError as e: + if e.error_type == "liquidNotFound": + raise OpentronsError( + "LiquidNotFoundError", f"liquid_probe found no liquid in {where}." + ) from e + raise + if z is None: + raise OpentronsError("LiquidNotFoundError", f"liquid_probe found no liquid in {where}.") + return z + + async def _on_setup(self) -> None: + """Hook for head-specific post-discovery setup. Default: no-op.""" + + 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. + + The default liquid position is ``_DEFAULT_WELL_BOTTOM_CLEARANCE`` above + the well bottom, or ``liquid_height`` above it when one is given. A + caller ``offset`` SHIFTS the head from that position rather than + replacing it: a ``Coordinate`` carries a z of 0 when the caller only + meant to nudge x/y, so a replacing offset would silently drop the + clearance and drive the tip onto the well floor. + + ``origin`` defaults to ``"bottom"`` (aspirate/dispense); tip-pickup + callers must pass ``origin="top"`` -- a tip-rack well's "bottom" is deep + inside the tip, not the pickup engagement point -- and the "top" origin + carries no clearance of its own, so there the offset is the whole + position. Returns ``None`` on a non-bottom origin when neither offset nor + liquid height is given. + """ + o = offsets[0] if offsets is not None else None + height = liquid_height[0] if liquid_height is not None else None + # A bottom origin always sends a position: an omitted wellLocation makes + # the Protocol Engine fall back to the rim, above the liquid. + if o is None and height is None and origin != "bottom": + return None + if height is not None: + base_z = height + else: + base_z = _DEFAULT_WELL_BOTTOM_CLEARANCE if origin == "bottom" else 0.0 + x, y, z = (o.x, o.y, o.z) if o is not None else (0.0, 0.0, 0.0) + return {"origin": origin, "offset": {"x": x, "y": y, "z": base_z + z}} + + # --- Single-cavity container (trough/reservoir) shared helpers --- + + @staticmethod + def _stage_container_aspirate(container: Container, total_volume: float) -> List[VolumeTracker]: + """Stage an aspirate's total volume against a container's single tracker. + + N channels drawing from one cavity share ONE tracker, so the summed + volume is staged as one ``remove_liquid`` and the tracker appears once in + the returned list. Staging per channel would orphan the earlier pending + ops if a later channel's validation raised. + """ + staged_trackers: List[VolumeTracker] = [] + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.remove_liquid(volume=total_volume) # stages + validates + staged_trackers.append(container.tracker) + return staged_trackers + + @staticmethod + def _stage_container_dispense(container: Container, total_volume: float) -> List[VolumeTracker]: + """Stage a dispense's total volume against a container's single tracker. + + Same one-tracker rule as ``_stage_container_aspirate``. + """ + staged_trackers: List[VolumeTracker] = [] + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.add_liquid(volume=total_volume) # stages + validates + staged_trackers.append(container.tracker) + return staged_trackers + + def _stage_wells_aspirate(self, wells: List[Well], volume: float) -> List[VolumeTracker]: + """Stage ``remove_liquid`` on each well whose channel holds a tip.""" + staged_trackers: List[VolumeTracker] = [] + if does_volume_tracking(): + for i, well in enumerate(wells): + if self._channel_tips[i] is not None and not well.tracker.is_disabled: + well.tracker.remove_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + return staged_trackers + + def _stage_wells_dispense(self, wells: List[Well], volume: float) -> List[VolumeTracker]: + """Stage ``add_liquid`` on each well whose channel holds a tip.""" + staged_trackers: List[VolumeTracker] = [] + if does_volume_tracking(): + for i, well in enumerate(wells): + if self._channel_tips[i] is not None and not well.tracker.is_disabled: + well.tracker.add_liquid(volume=volume) # stages + validates + staged_trackers.append(well.tracker) + return staged_trackers + + @staticmethod + def _require_span_fits_container( + container: Container, + x_span: float, + y_span: float, + offset: Optional[Coordinate], + ) -> None: + """Raise pre-wire if the nozzle array would overhang the container. + + The engine centers the array on the cavity and the caller's ``offset`` + shifts it, so the shifted span must still fit on each axis. The footprint + is the deck-frame one the definition carries, not the container's own + x/y: a rotated container presents its axes to the robot swapped. It is + the OUTER box, so an array that fits the shell but not the cavity inside + passes -- PLR carries no cavity x/y to check against. + """ + o = offset if offset is not None else Coordinate.zero() + cavity_x, cavity_y = container_footprint(container) + required_x = x_span + 2 * abs(o.x) + required_y = y_span + 2 * abs(o.y) + if required_x > cavity_x or required_y > cavity_y: + detail = f"The nozzle array spans {x_span} x {y_span} mm" + if o.x or o.y: + detail += f" and the offset ({o.x}, {o.y}) shifts it off-center" + raise OpentronsError( + "Container too small", + f"{detail}, which does not fit inside '{container.name}' " + f"({cavity_x} x {cavity_y} mm as the robot sees it). " + "Aim it at a container that holds the whole array.", + ) + + # --- Direct head motion (teaching / recovery jog) --- + + def _traversal_height(self) -> float: + """The computed tip-safe travel plane for a lateral jog over this deck. + + ``max(labware tops) + arc margin`` (``checks.traversal_z``), tip-end framed + (the frame ``minimumZHeight`` already uses), computed from the resource model + -- so the arc adapts to what is actually on the deck instead of a fixed magic + number that is both wasteful over short labware and unsafe under anything + taller. + """ + return traversal_z(self.flex.deck) + + async def position(self) -> Coordinate: + """The head's current deck-frame position -- one ``savePosition`` query. + + Reports the pipette's critical point: the bottom of the mounted tip, or + the nozzle when no tip is mounted. The Flex's robot frame coincides with + the deck frame, so the reported position needs no conversion. + """ + self._warn_untested_hardware("position") + result = await self._execute("savePosition", {"pipetteId": self.pipette_id}) + pos = result["result"]["position"] + return Coordinate(pos["x"], pos["y"], pos["z"]) + + async def move_to( + self, + x: Optional[float] = None, + y: Optional[float] = None, + z: Optional[float] = None, + speed: Optional[float] = None, + minimum_z_height: Optional[float] = None, + ) -> None: + """Move the head to an absolute deck-frame position, holding any axis left + unspecified -- ONE ``moveToCoordinates`` command. + + Axes left unspecified are filled from ``position()`` first, so a combined + move travels a single path instead of an axis-by-axis staircase (the read + is skipped when all three axes are given). A mounted tip is NOT required: + jogging is for teaching and recovery, and the target refers to the bottom + of the mounted tip, or the nozzle when none is mounted. + ``minimum_z_height`` (mm) defaults to the traversal height, so a lateral + jog arcs over deck labware; ``speed`` is in mm/s (robot default if None). + """ + self._warn_untested_hardware("move_to") + if x is None and y is None and z is None: + raise ValueError("move_to: supply at least one of x, y, z.") + if x is None or y is None or z is None: + current = await self.position() + x = current.x if x is None else x + y = current.y if y is None else y + z = current.z if z is None else z + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "coordinates": {"x": x, "y": y, "z": z}, + "minimumZHeight": ( + minimum_z_height if minimum_z_height is not None else self._traversal_height() + ), + } + if speed is not None: + params["speed"] = speed + await self._execute("moveToCoordinates", params) + # A raw jog leaves the pipette at an arbitrary point: the next pipetting move + # can no longer assume it is over its last labware, so make it arc high. + self._current_labware_id = None + + async def move_to_well( + self, + target: Union[Well, TipSpot, Container], + offset: Optional[Coordinate] = None, + origin: str = "top", + minimum_z_height: Optional[float] = None, + speed: Optional[float] = None, + ) -> None: + """Move to a well, named rather than measured -- ONE ``moveToWell`` command. + + Prefer this over :meth:`move_to` for anything positioned relative to + labware: naming the well lets the robot work out where that is and refuse + a move it cannot make, where ``move_to`` sends raw coordinates nothing + bounds-checks. ``origin`` is where the offset is measured from ("top", + "bottom", "center", or "meniscus", the last needing a probed liquid + level), so 10 mm above the well is ``origin="top"`` with + ``offset=Coordinate(z=10)``. A tip spot is a valid target. No mounted tip + is required: the target is the tip bottom when one is mounted, the nozzle + when none is. + """ + self._warn_untested_hardware("move_to_well") + if origin not in _WELL_ORIGINS: + raise ValueError(f"origin must be one of {sorted(_WELL_ORIGINS)}, got {origin!r}") + labware_id, well_name = await self._well_target(target) + + o = offset or Coordinate(0, 0, 0) + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": well_name, + "wellLocation": {"origin": origin, "offset": {"x": o.x, "y": o.y, "z": o.z}}, + "minimumZHeight": ( + minimum_z_height if minimum_z_height is not None else self._traversal_height() + ), + } + if speed is not None: + params["speed"] = speed + await self._execute("moveToWell", params) + + async def move_relative(self, axis: str, distance: float) -> None: + """Jog one axis by ``distance`` mm from wherever the head is now. + + ``axis`` is "x", "y" or "z". A negative distance moves the other way. + Relative to the head's current position, so unlike :meth:`move_to` it + needs no reading first. + """ + self._warn_untested_hardware("move_relative") + if axis not in _MOVE_AXES: + raise ValueError(f"axis must be one of {sorted(_MOVE_AXES)}, got {axis!r}") + await self._execute( + "moveRelative", + {"pipetteId": self.pipette_id, "axis": axis, "distance": distance}, + ) + + async def move_to_addressable_area( + self, + addressable_area_name: str, + offset: Optional[Coordinate] = None, + minimum_z_height: Optional[float] = None, + speed: Optional[float] = None, + stay_at_max_height: bool = False, + ) -> None: + """Move to a named fixture on the deck rather than to labware. + + An addressable area is somewhere the deck itself provides: a trash bin, a + waste chute, a staging slot. Named, so the robot resolves the position. + """ + self._warn_untested_hardware("move_to_addressable_area") + o = offset or Coordinate(0, 0, 0) + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "addressableAreaName": addressable_area_name, + "offset": {"x": o.x, "y": o.y, "z": o.z}, + "stayAtHighestPossibleZ": stay_at_max_height, + "minimumZHeight": ( + minimum_z_height if minimum_z_height is not None else self._traversal_height() + ), + } + if speed is not None: + params["speed"] = speed + await self._execute("moveToAddressableArea", params) + + # --- In-place pipetting (acts where the head already is) --- + + async def aspirate_in_place(self, volume: float, flow_rate: Optional[float] = None) -> None: + """Aspirate ``volume`` uL where the head already is -- one ``aspirateInPlace`` command. + + Names no well, so no ``Well``/``Container`` tracker moves with it: + position the head first (``move_to_well``/``move_to``) and account for the + liquid yourself. Draw with the tip UNDER the surface and far enough off the + floor not to seal against it; drawing from above the liquid takes air. + ``flow_rate`` (uL/s) defaults to the aspirate default. + + Raises ``NotReadyToAspirateError`` when the plunger needs priming, since + naming no well leaves the robot no safe height to prime at. See + ``prepare_to_aspirate``. + """ + self._warn_untested_hardware("aspirate_in_place") + self._require_mounted_tip() + rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate + await self._execute_draw( + "aspirateInPlace", + {"pipetteId": self.pipette_id, "volume": volume, "flowRate": rate}, + ) + + async def dispense_in_place( + self, + volume: float, + flow_rate: Optional[float] = None, + push_out: Optional[float] = None, + ) -> None: + """Dispense ``volume`` uL where the head already is -- one ``dispenseInPlace`` command. + + Names no well, so no tracker moves with it (see ``aspirate_in_place``). + ``push_out`` (uL) pushes the plunger past its dispense bottom to clear the + last drops; left out of the command entirely when None, so the robot + applies its own default for the mounted tip and volume. + + Put the tip AT THE LIQUID SURFACE first, not above it. Dispensing from + height splashes, aerosolises, and leaves volume hanging in the tip. "The + caller owns the position" means the caller owes it a good one, not that + any position will do. + """ + self._warn_untested_hardware("dispense_in_place") + self._require_mounted_tip() + rate = flow_rate if flow_rate is not None else self.default_flow_rates().dispense + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "volume": volume, + "flowRate": rate, + } + if push_out is not None: + params["pushOut"] = push_out + await self._execute("dispenseInPlace", params) + + async def air_gap_in_place(self, volume: float, flow_rate: Optional[float] = None) -> None: + """Draw a ``volume`` uL air gap where the head already is -- one ``airGapInPlace`` command. + + The same plunger motion as ``aspirate_in_place``, but the robot books the + volume as air, so park the tip above the liquid first. ``flow_rate`` + (uL/s) defaults to the aspirate default. Refuses an unprimed plunger the + same way ``aspirate_in_place`` does. No tracker is involved. + """ + self._warn_untested_hardware("air_gap_in_place") + self._require_mounted_tip() + rate = flow_rate if flow_rate is not None else self.default_flow_rates().aspirate + await self._execute_draw( + "airGapInPlace", + {"pipetteId": self.pipette_id, "volume": volume, "flowRate": rate}, + ) + + # --- Tip-presence sensor (command form) --- + + async def get_tip_presence(self) -> Optional[str]: + """Read this head's tip sensor: "present", "absent" or "unknown". + + One reading per pipette, not per channel. ``has_tip_on_hardware()`` is + the same reading as a bool. ``None`` when the command reports no status. + """ + self._warn_untested_hardware("get_tip_presence") + return await self._read_tip_presence() + + async def verify_tip_presence(self, expected_state: str) -> None: + """Have the robot fail the command unless its tip sensor reads ``expected_state``. + + ``expected_state`` is "present" or "absent". Where ``get_tip_presence`` + reports and leaves the judgement to the caller, this one raises the + mismatch from the robot side, so it reads as a checkpoint in a sequence. + """ + self._warn_untested_hardware("verify_tip_presence") + if expected_state not in _TIP_PRESENCE_STATES: + raise ValueError( + f"expected_state must be one of {sorted(_TIP_PRESENCE_STATES)}, got {expected_state!r}" + ) + await self._execute( + "verifyTipPresence", + {"pipetteId": self.pipette_id, "expectedState": expected_state}, + ) + + async def configure_for_volume(self, volume: float) -> None: + """Put the pipette in the volume mode that suits ``volume`` uL. + + A Flex pipette only reaches its stated accuracy at small volumes in its + low-volume mode, which this picks for the volume given. Call it before + picking up tips: the robot refuses a mode change while a tip is attached. + """ + self._warn_untested_hardware("configure_for_volume") + await self._execute("configureForVolume", {"pipetteId": self.pipette_id, "volume": volume}) + + # --- Recovery ops --- + + async def unsafe_drop_tip_in_place(self) -> None: + """Drop the mounted tip where the head is, skipping the engine's own checks. + + The "unsafe/" commands are the recovery path: they still run once the + engine has put the run into an error state, where the ordinary + ``dropTipInPlace`` is refused. The tip falls wherever the head happens to + be, so move somewhere it can be retrieved from first. Clears this head's + per-channel tip bookkeeping; no tip tracker is touched, since the tip + goes back to no rack. + """ + self._warn_untested_hardware("unsafe_drop_tip_in_place") + self._require_mounted_tip() + await self._execute("unsafe/dropTipInPlace", {"pipetteId": self.pipette_id}) + self._channel_tips = [None] * self.channels + + async def unsafe_blow_out_in_place(self, flow_rate: float) -> None: + """Blow out where the head is, skipping the engine's own checks. + + The recovery counterpart to ``blow_out`` (see ``unsafe_drop_tip_in_place`` + for what "unsafe/" buys). ``flow_rate`` is in uL/s and has no default + here, the recovery path being an explicit one. Leaves the plunger past + its dispense bottom, so the next draw needs priming. + """ + self._warn_untested_hardware("unsafe_blow_out_in_place") + self._require_mounted_tip() + await self._execute( + "unsafe/blowOutInPlace", + {"pipetteId": self.pipette_id, "flowRate": flow_rate}, + ) + + +# Where a wellLocation offset is measured from. "meniscus" needs the robot to +# hold a liquid level for the well, which only a liquid probe gives it. +_WELL_ORIGINS = frozenset({"top", "bottom", "center", "meniscus"}) + +_MOVE_AXES = frozenset({"x", "y", "z"}) + +# What the robot calls its refusal to draw with the plunger past its dispense +# bottom. Raised undefined, so the wire carries the exception's class name. +_NOT_READY_TO_ASPIRATE = "PipetteNotReadyToAspirateError" + +_NOT_PRIMED_REMEDY = ( + "The plunger sits past its dispense bottom, so the robot will not draw from where it " + "is. Either aspirate from a well by name, which primes itself at the well top and then " + "descends, or lift the tip clear of the liquid and call prepare_to_aspirate() before " + "drawing in place. Priming while submerged draws several uL of the well into the tip." +) + +# What verify_tip_presence can assert. The sensor itself can also read +# "unknown", but that is a reading, not something to check against. +_TIP_PRESENCE_STATES = frozenset({"present", "absent"}) + +# The 8-channel head's rows front-to-back: channel 0 = "A" (rearmost) .. 7 = "H" +# (frontmost). Used to name the corner nozzles of a partial (QUADRANT) column. +_ROW_LETTERS = "ABCDEFGH" + +# The only nozzles an 8-channel Flex can anchor a SINGLE layout on ("A1" is +# the rearmost, "H1" the frontmost), mapped to the channel each one is. +_SINGLE_NOZZLES = {"A1": 0, "H1": 7} +_SINGLE_NOZZLE_BY_CHANNEL = {channel: nozzle for nozzle, channel in _SINGLE_NOZZLES.items()} + +# Each anchor nozzle's y offset from the pipette mount, and the pipette body's own +# reach back/forward of it. shared-data pipette geometry v2, eight_channel p50 == p1000. +_SINGLE_NOZZLE_Y = {"A1": -16.0, "H1": -79.0} +_PIPETTE_BODY_BACK_Y = 0.0 +_PIPETTE_BODY_FRONT_Y = -95.0 + +# The deck y band that body has to stay inside. +# shared-data robot definition ot3.json: padding front 51.8 / rear -169.42, extents y 493.8. +_ROBOT_FRONT_LIMIT = 51.8 +_ROBOT_REAR_LIMIT = 493.8 - 169.42 + +_NUM_CHANNELS = 8 + +# The slots a Flex accepts a movable trash in (shared-data ot3_standard.json). +_MOVABLE_TRASH_SLOTS = frozenset({"A1", "B1", "C1", "D1", "A3", "B3", "C3", "D3"}) + +# Default aspirate/dispense position: 1mm above the well bottom, matching the +# Opentrons Python-API default. The raw Protocol-Engine /commands API defaults +# an OMITTED wellLocation to origin "top" (the well rim -- above the liquid), +# 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 + +# touch_tip's default z: 1 mm below the well rim, matching the Opentrons +# Python API's v_offset default. +_DEFAULT_TOUCH_TIP_Z_OFFSET = -1.0 + +# Liquid probing starts just above the well rim and descends from there; +# matches the engine's LIQUID_PROBE_START_OFFSET_FROM_WELL_TOP. +_LIQUID_PROBE_START_OFFSET_Z = 2.0 + +# Opentrons single-cavity labware definitions (troughs/reservoirs) expose +# exactly one well, named "A1" -- container ops always address it. +_CONTAINER_WELL_NAME = "A1" + +# The 96-channel nozzle grid is 12 columns x 8 rows at 9 mm pitch: 99 mm +# A1->A12 in x, 63 mm A1->H1 in y. Used to center the head in a container. +_NINETY_SIX_HEAD_X_SPAN = (12 - 1) * 9.0 +_NINETY_SIX_HEAD_Y_SPAN = (8 - 1) * 9.0 + +# The 8-channel head's single nozzle row has the same 7 gaps front-to-back. +_EIGHT_CHANNEL_Y_SPAN = _NINETY_SIX_HEAD_Y_SPAN + + +class FlexHead1(_FlexHead): + """Single-channel pipette head, well-addressed. + + 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 and hardware tip-presence verification + (``_verify_tips_seated``/``_confirm_tips_cleared``) -- the same machinery + ``FlexHead8`` uses for its column ops, applied to a single well instead of + a column. + + Verified on a real single-channel Flex (p50, robot-server API 9.1.1): + motion, tip pickup and drop, liquid probe, aspirate/dispense and volume + mode, all against the hardware tip-presence sensor. Ops outside + ``_HARDWARE_VERIFIED_OPS`` still log the one-time untested-hardware notice. + """ + + # Confirmed on a p50 single channel. Base-class ops are listed here rather + # than on _FlexHead because the other heads have not been run on hardware. + _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset( + { + "air_gap_in_place", + "aspirate", + "aspirate_in_place", + "configure_for_volume", + "dispense", + "dispense_in_place", + "drop_tips", + "get_tip_presence", + "liquid_probe", + "move_relative", + "move_to", + "move_to_addressable_area", + "move_to_well", + "pick_up_tips", + "position", + "try_liquid_probe", + "verify_tip_presence", + } + ) + + async def pick_up_tips( + self, + tip_spot: TipSpot, + 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("pick_up_tips") + 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 + + 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("drop_tips") + + if isinstance(target, Trash): + await self._execute_trash_drop(target) + 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, + target: Union[Well, Container], + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Aspirate from a well or single-cavity container -- one ``aspirate`` command. + + A ``Well`` is addressed through its plate parent by well name; a bare + ``Container`` (trough/reservoir) at its own sole well. Requires a mounted + tip. Follows stage -> validate -> wire -> commit/rollback: the tracker + (``remove_liquid``) is staged BEFORE the wire command, so an infeasible + aspirate raises before any hardware motion. Naming the well means the + robot primes the plunger itself when it needs to, at the well top and + then descending, so nothing here has to. + """ + self._warn_untested_hardware("aspirate") + self._require_mounted_tip() + labware_id, well_name = await self._well_target(target) + staged_trackers = self._stage_container_aspirate(target, volume) + await self._pipette( + "aspirate", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def dispense( + self, + target: Union[Well, Container], + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Dispense to a well or single-cavity container -- one ``dispense`` command. + + Mirrors ``aspirate``: same addressing, same mounted-tip requirement, and + stage -> validate -> wire -> commit/rollback with ``add_liquid`` staged + BEFORE the wire command. + """ + self._warn_untested_hardware("dispense") + self._require_mounted_tip() + labware_id, well_name = await self._well_target(target) + staged_trackers = self._stage_container_dispense(target, volume) + await self._pipette( + "dispense", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def touch_tip( + self, + well: Well, + radius: float = 1.0, + offset: Optional[Coordinate] = None, + ) -> None: + """Touch the mounted tip to the sides of ``well`` -- one ``touchTip`` command. + + ``radius`` is the fraction of the well radius the tip moves toward + (1.0 = the wall); ``offset`` IS the touch position, not a shift (see + ``_touch_tip_params``). Requires a mounted tip, checked before any wire + command. No trackers are involved. + """ + self._warn_untested_hardware("touch_tip") + self._require_mounted_tip() + labware_id, well_name = await self._well_target(well) + await self._execute("touchTip", self._touch_tip_params(labware_id, well_name, radius, offset)) + + async def liquid_probe(self, well: Well) -> float: + """Probe downward in ``well`` until the pressure sensor detects liquid; return its z (mm). + + One ``liquidProbe`` command naming ``well``. Requires a mounted tip + (checked before any wire command). Raises ``OpentronsError`` if no + liquid is found; use ``try_liquid_probe`` for the non-raising variant. + """ + self._warn_untested_hardware("liquid_probe") + self._require_mounted_tip() + parent = self._require_itemized_parent(well) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(well) + return await self._liquid_probe_z(labware_id, well_name, f"well {well.name!r}") + + async def try_liquid_probe(self, well: Well) -> Optional[float]: + """Like ``liquid_probe`` but return ``None`` instead of raising when no liquid is found.""" + self._warn_untested_hardware("try_liquid_probe") + self._require_mounted_tip() + parent = self._require_itemized_parent(well) + labware_id = await self.flex._ensure_labware_loaded(parent) + well_name = parent.get_child_identifier(well) + return await self._probe_z("tryLiquidProbe", labware_id, well_name) + + +class FlexHead8(_FlexHead): + """8-channel pipette head, column-addressed (anchor-well fan-out). + + Every op sends exactly ONE robot-server command anchored at the rearmost + well the nozzle row covers (on a 96-format plate, column 2 -> wellName + "A3"); the Flex hardware fans that single command out to all 8 physical + nozzles. Tip/volume trackers are committed only for the channels/wells + actually actuated, skipping ``None`` (inactive) channels (None-skip) -- + and only after the wire command succeeds. + + ``column`` indexes the sets of 8 rows the nozzles can cover, which on a + 96-format plate is just the physical columns (0-11). A denser layout has + more than one such set per physical column, since the nozzles skip rows to + hold their 9 mm pitch: a 384 plate takes ``column`` 0-47, where 0 covers + A1/C1/E1/../O1 and 1 covers B1/D1/../P1. See + ``_column_anchor_and_items``. + + Single-tip cherry-pick (``pick_up_single_tip``/``aspirate_single``/ + ``dispense_single``/``drop_single_tip``) switches the pipette to SINGLE + 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) by the ``docs/user_guide/opentrons/flex/hello-world.ipynb`` and + ``use_channels_smoke.ipynb`` runs: setup and homing; tip pickup in + full-column (ALL), single-nozzle (SINGLE, H1), and partial-column (QUADRANT, + front four) layouts, and tip drop in the full-column and single-nozzle + layouts, confirmed against the hardware tip-presence sensor; and + aspirate/dispense into a plate in the full-column and single-nozzle layouts. + Ops outside that set -- container/reservoir ops, touch_tip, liquid_probe, and + the motion surface -- are coded but not yet hardware-verified and log the + one-time untested-hardware notice, same as the other heads. + """ + + _HARDWARE_VERIFIED_OPS: FrozenSet[str] = frozenset( + { + "pick_up_tips", + "pick_up_single_tip", + "pick_up_partial", + "drop_tips", + "drop_single_tip", + "aspirate", + "dispense", + } + ) + + def __init__( + self, + flex: "OpentronsFlex", + mount: str, + pipette_id: str, + channels: int, + pipette_model: str, + max_volume: float, + ) -> None: + super().__init__(flex, mount, pipette_id, channels, pipette_model, max_volume) + 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. The reset is refused while the single tip + from that op is still mounted, since the robot refuses it too. + """ + if self._nozzle_layout == "ALL": + return + await self._configure_nozzle_layout({"style": "ALL"}) + self._nozzle_layout = "ALL" + + # --- Column helpers --- + + @staticmethod + def _column_anchor_and_items(itemized: ItemizedResource, column: int) -> Tuple[str, List[Any]]: + """Validate ``column`` against the labware's real grid; return the anchor + well name plus the 8 resources the nozzle row covers, rearmost first. + + Every column op calls this BEFORE any wire command so a rejected op ships + nothing. + + The 8 nozzles sit at a 9 mm pitch, so on a denser layout they cover every + ``row_stride``-th row rather than adjacent rows: a 384 plate has two + interleaved sets of 8 per physical column, addressed as consecutive + ``column`` indices (so 0-47, physical column ``column // 2``, rear-row set + when even). A row count that is not a multiple of 8 has no such set and is + rejected. + """ + rows = itemized.num_items_y + row_stride, remainder = divmod(rows, _NUM_CHANNELS) + if row_stride < 1 or remainder: + raise ValueError( + f"'{itemized.name}' has {rows} rows; the 8 nozzles cover 8 evenly spaced rows, " + f"so column ops need a row count that is a multiple of {_NUM_CHANNELS}." + ) + num_columns = itemized.num_items_x * row_stride + if not 0 <= column < num_columns: + raise ValueError( + f"Column {column} out of range for resource with {num_columns} columns " + f"(0-{num_columns - 1})." + ) + physical_column, row_phase = divmod(column, row_stride) + start = physical_column * rows + row_phase + column_items = itemized.get_all_items()[start : start + rows : row_stride] + return itemized.get_child_identifier(column_items[0]), column_items + + # --- Column tip operations --- + + async def pick_up_tips( + self, + target: Union[TipRack, Sequence[TipSpot], TipSpot], + *, + column: Optional[int] = None, + use_channels: Optional[Sequence[int]] = None, + offset: Optional[Coordinate] = None, + primary_nozzle: Optional[str] = None, + ) -> None: + """Pick up tip(s), choosing the nozzle layout for this call. + + Pickup is where per-call nozzle configuration lives -- the engine only lets + the layout change while no tip is on -- so this is the method that emits the + ``configureNozzleLayout``. The ``target`` *type* selects the layout: + + - a ``Sequence[TipSpot]`` (a ``rack.column(c)``) -> ALL, a full column; + - a single ``TipSpot`` -> SINGLE, one cherry-picked tip. + + ``use_channels`` names the channels to fill: ``None``/all 8 -> ALL; ``[0]`` + or ``[7]`` -> SINGLE on the A1 or H1 nozzle (the only two an 8-channel Flex + can single-anchor). The ALL configuration is emitted only when a prior + single-tip op left the layout otherwise (``_ensure_all_mode``); a SINGLE + pickup always emits its ``configureNozzleLayout``. ``column`` is the + transitional bridge for the old ``pick_up_tips(rack, column=c)`` call; + prefer ``rack.column(c)``. + """ + if column is not None: + await self._pick_up_column(cast(TipRack, target), column, offset) + return + if isinstance(target, (list, tuple)): + await self._pick_up_spots(list(target), use_channels, offset) + return + if isinstance(target, TipSpot): + await self._pick_up_single_spot(target, use_channels, offset, primary_nozzle) + return + raise TypeError( + f"pick_up_tips target must be a column (Sequence[TipSpot]) or a single TipSpot; " + f"got {type(target).__name__}." + ) + + async def _pick_up_single_spot( + self, + spot: TipSpot, + use_channels: Optional[Sequence[int]], + offset: Optional[Coordinate], + primary_nozzle: Optional[str], + ) -> None: + """Cherry-pick one tip (SINGLE layout), resolving the anchor nozzle. + + ``use_channels`` may name only the single-anchor channels (0 -> A1, 7 -> H1); + any other channel is refused, since an 8-channel Flex cannot anchor a + single-nozzle layout elsewhere. + """ + parent = self._require_itemized_parent(spot) + well_name = parent.get_child_identifier(spot) + if use_channels is not None: + if len(use_channels) != 1 or use_channels[0] not in _SINGLE_NOZZLE_BY_CHANNEL: + raise OpentronsError( + "NozzleConfigError", + f"An 8-channel Flex can single-anchor only on channels " + f"{sorted(_SINGLE_NOZZLE_BY_CHANNEL)} (A1/H1); got use_channels={list(use_channels)}.", + ) + resolved = _SINGLE_NOZZLE_BY_CHANNEL[use_channels[0]] + if primary_nozzle is not None and primary_nozzle != resolved: + raise OpentronsError( + "NozzleConfigError", + f"use_channels={list(use_channels)} names nozzle {resolved}, but " + f"primary_nozzle={primary_nozzle!r} was also given.", + ) + primary_nozzle = resolved + await self.pick_up_single_tip( + cast(TipRack, parent), well_name, offset=offset, primary_nozzle=primary_nozzle + ) + + async def _pick_up_spots( + self, + spots: List[TipSpot], + use_channels: Optional[Sequence[int]], + offset: Optional[Coordinate], + ) -> None: + """Pick up a full or partial column given as a list of tip spots. + + ``use_channels`` names the channel each spot fills, in order. The full + 8-channel set (or ``None`` for a full column) uses the ALL layout; a + contiguous run from the front (incl. H1/ch7) or rear (incl. A1/ch0) end uses + a QUADRANT partial layout. + """ + if not spots: + raise ValueError("pick_up_tips: the target spot sequence is empty.") + n = len(spots) + if use_channels is None: + if n != self.channels: + raise OpentronsError( + "NozzleConfigError", + f"{n} spots given without use_channels; pass use_channels to name which " + f"nozzles a partial column fills, or give a full column of {self.channels}.", + ) + use_channels = list(range(self.channels)) + uc = list(use_channels) + if len(uc) != n: + raise OpentronsError( + "NozzleConfigError", + f"use_channels has {len(uc)} entries but {n} spots were given; one per spot.", + ) + ordered = sorted(uc) + if ordered != list(range(ordered[0], ordered[-1] + 1)): + raise OpentronsError( + "NozzleConfigError", + f"a partial column must be a contiguous run of channels; got use_channels={uc}.", + ) + if ordered == list(range(self.channels)): + parent = cast(TipRack, self._require_itemized_parent(spots[0])) + anchor = parent.get_child_identifier(spots[0]) + await self._pick_up_spots_core(parent, anchor, spots, offset) + return + await self._pick_up_partial(spots, uc, offset) + + async def _pick_up_partial( + self, + spots: List[TipSpot], + use_channels: List[int], + offset: Optional[Coordinate], + ) -> None: + """Pick up a contiguous partial column in a QUADRANT nozzle layout. + + The layout matches what Opentrons' own ``configure_nozzle_layout`` emits for a + ``PARTIAL_COLUMN``: a front-anchored run (including H1/ch7) sets + ``primaryNozzle = frontRightNozzle = "H1"`` with ``backLeftNozzle`` the rear-most + active nozzle; a rear-anchored run (including A1/ch0) mirrors it on A1. The + ``configureNozzleLayout`` is emitted here (no tip is on yet, so the engine + accepts it); the ``pickUpTip`` is anchored at the well under the primary nozzle. + """ + self._warn_untested_hardware("pick_up_partial") + ordered = sorted(use_channels) + top = self.channels - 1 + if ordered[-1] == top: # front-anchored partial (includes H1) + primary_channel = top + primary = front_right = "H1" + back_left = f"{_ROW_LETTERS[ordered[0]]}1" + elif ordered[0] == 0: # rear-anchored partial (includes A1) + primary_channel = 0 + primary = back_left = "A1" + front_right = f"{_ROW_LETTERS[ordered[-1]]}1" + else: + raise OpentronsError( + "NozzleConfigError", + "an 8-channel partial column must run from the front (incl. H1) or the rear " + f"(incl. A1) end; got use_channels={use_channels}.", + ) + + spot_by_channel = {use_channels[i]: spots[i] for i in range(len(spots))} + for ch in ordered: + if spot_by_channel[ch].has_tip() and self._channel_tips[ch] is not None: + raise OpentronsError( + "HasTipError", + f"Channel {ch} already holds a tip; drop it before picking up another.", + ) + + await self._configure_nozzle_layout( + { + "style": "QUADRANT", + "primaryNozzle": primary, + "frontRightNozzle": front_right, + "backLeftNozzle": back_left, + } + ) + self._nozzle_layout = "PARTIAL" # non-ALL: a later column op resets via _ensure_all_mode + + primary_spot = spot_by_channel[primary_channel] + parent = cast(TipRack, self._require_itemized_parent(primary_spot)) + labware_id = await self.flex._ensure_labware_loaded(parent) + anchor = parent.get_child_identifier(primary_spot) + + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + tips: Dict[int, Tip] = {} + for ch in ordered: + spot = spot_by_channel[ch] + if not spot.has_tip(): + continue + tips[ch] = spot.get_tip() + if tracking and not spot.tracker.is_disabled: + spot.tracker.remove_tip() # commit=False: stages + validates + staged_trackers.append(spot.tracker) + + params: Dict[str, Any] = { + "pipetteId": self.pipette_id, + "labwareId": labware_id, + "wellName": anchor, + } + well_location = self._well_location([offset], [None], origin="top") + if well_location is not None: + params["wellLocation"] = well_location + + await self._execute_pickup("pickUpTip", params, staged_trackers) + for ch, tip in tips.items(): + self._channel_tips[ch] = tip + + async def _pick_up_column( + self, + tip_rack: TipRack, + column: int, + offset: Optional[Coordinate] = None, + ) -> None: + """Pick up a full column (8 tips) by column index (transitional bridge).""" + well_name, column_spots = self._column_anchor_and_items(tip_rack, column) + await self._pick_up_spots_core(tip_rack, well_name, column_spots, offset) + + async def _pick_up_spots_core( + self, + tip_rack: TipRack, + anchor_name: str, + spots: List[Any], + offset: Optional[Coordinate], + ) -> None: + """Shared ALL-layout column pickup with a single ``pickUpTip`` command. + + Anchored at the column's rearmost spot; the hardware fans the pickup motion + out to all 8 physical nozzles. Follows stage -> validate -> wire -> + verify -> commit/rollback: the double-pickup guard is validated before ANY + wire command, tip trackers are staged (``commit=False``) before the pickup + command, then, after the wire command succeeds, the hardware tip-presence + sensor is checked (``_verify_tips_seated()``); trackers and ``_channel_tips`` + are committed only if that verification passes, and rolled back (with no + ``_channel_tips`` mutation) if the sensor reports a missed pickup. Only spots + that actually had a tip are staged (None-skip). + """ + self._warn_untested_hardware("pick_up_tips") + + 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._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(tip_rack) + + tracking = does_tip_tracking() + staged_trackers: List[Any] = [] + tips: List[Optional[Tip]] = [None] * len(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": anchor_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 + + 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. + + The trash drop resets the nozzle layout AFTER the drop, not before: it + addresses no channel, and the robot refuses every reconfiguration while + a tip is on -- so resetting first would make this op, the only one that + can clear a cherry-picked tip, need the tip already gone. + """ + self._warn_untested_hardware("drop_tips") + + if isinstance(target, Trash): + await self._execute_trash_drop(target) + self._channel_tips = [None] * self.channels + await self._confirm_tips_cleared() + await self._ensure_all_mode() + return + + if self._nozzle_layout == "SINGLE": + raise OpentronsError( + "NozzleLayoutError", + "A column drop fans out to all 8 nozzles, so a single cherry-picked tip cannot " + "be returned to a rack. Discard it with drop_single_tip(trash).", + ) + + if column is None: + raise ValueError("column is required when dropping tips to a TipRack.") + + well_name, column_spots = self._column_anchor_and_items(target, column) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(target) + + 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) + + # --- Unified liquid handling (per-call nozzle configuration) --- + + def _require_use_channels_match_mounted(self, use_channels: Optional[Sequence[int]]) -> None: + """Refuse a ``use_channels`` that is not exactly the mounted channels. + + ``use_channels`` names the active nozzles for the call, but the layout is + fixed at pickup (the engine refuses to reconfigure while a tip is on) and + one fanned command actuates every mounted nozzle -- so it cannot address a + subset. An explicit ``use_channels`` must therefore equal the channels that + currently hold tips; left ``None`` the mounted set is used implicitly. The + reference channel it names is also what the reachability check reads to pick + the active nozzle, so a mismatch here would misaim that guard. + """ + if use_channels is None: + return + mounted = {i for i, tip in enumerate(self._channel_tips) if tip is not None} + requested = set(use_channels) + if requested != mounted: + raise OpentronsError( + "NozzleConfigMismatch", + f"use_channels={sorted(requested)} does not match the mounted channels " + f"{sorted(mounted)}. The nozzle layout is fixed at pickup and one command fans to " + "every mounted nozzle, so use_channels must name exactly the channels holding tips.", + ) + + async def aspirate( + self, + target: Union[Plate, Well, Sequence[Well], Container], + volume: float, + *, + column: Optional[int] = None, + use_channels: Optional[Sequence[int]] = None, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Aspirate ``volume`` uL from ``target`` -- one ``aspirate`` command. + + ONE method for every 8-channel aspirate; the ``target`` *type* selects the + addressing and ``use_channels`` names the active nozzles for this call: + + - a ``Sequence[Well]`` (a ``plate.column(c)``) -> that column, anchored at + its rearmost well, one ``Well.tracker`` per active channel (None-skip); + - a single ``Well`` -> the mounted single nozzle draws from it; + - a ``Container`` (trough/reservoir) -> every active nozzle dips into the + one cavity, its single tracker staged with ``volume x active-channels``. + + The nozzle LAYOUT is fixed at pickup, not here: the engine refuses a + reconfiguration while a tip is attached, and an aspirate always has a tip, + so no ``configureNozzleLayout`` is ever emitted by an aspirate. This method + only names which mounted nozzles it drives and refuses a request the mount + cannot satisfy, before any wire command. + + ``column`` is the transitional bridge for the old ``aspirate(plate, + column=c)`` call and takes a ``Plate`` target; prefer ``plate.column(c)``. + """ + self._require_use_channels_match_mounted(use_channels) + if column is not None: + await self._aspirate_column( + cast(Plate, target), column, volume, flow_rate, offset, liquid_height + ) + return + if isinstance(target, (list, tuple)): + await self._aspirate_wells( + list(target), volume, use_channels, flow_rate, offset, liquid_height + ) + return + if isinstance(target, Well): # Well is a Container, so check it first + await self._aspirate_single_well(target, volume, flow_rate, offset, liquid_height) + return + if isinstance(target, Container): + await self.aspirate_container( + target, volume, flow_rate=flow_rate, offset=offset, liquid_height=liquid_height + ) + return + raise TypeError( + f"aspirate target must be a Plate column (Sequence[Well]), a single Well, or a " + f"Container; got {type(target).__name__}." + ) + + def _require_single_nozzle_clearance(self, labware: ItemizedResource) -> None: + """Refuse a single-nozzle liquid op whose idle nozzles would overhang the + adjacent slot's labware. + + In SINGLE layout the 7 idle nozzles trail off one end of the head, so a + liquid op over a slot with a tip rack (or taller labware) in the trailing + slot would drive them into it. The engine does not check this for a raw + command, so it is refused here -- the same clearance guard + ``pick_up_single_tip`` runs at pickup, applied to the liquid op. + """ + nozzle = _SINGLE_NOZZLE_BY_CHANNEL.get(self._active_single_channel()) + slot = self.flex.deck.get_slot(labware) + if nozzle is not None and slot is not None: + self.flex.deck.check_single_nozzle_clearance(slot, nozzle) + + async def _aspirate_single_well( + self, + well: Well, + volume: float, + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + ) -> None: + """Aspirate one well with the mounted single nozzle -- one ``aspirate`` at it. + + Requires exactly one mounted tip (a SINGLE-layout cherry-pick); refuses a + well the mounted anchor cannot reach, and a target whose idle nozzles would + overhang the adjacent slot's labware -- all before any wire command. The + well's own tracker is staged with ``volume``. + """ + self._warn_untested_hardware("aspirate") + self._active_single_channel() + parent = self._require_itemized_parent(well) + well_name = parent.get_child_identifier(well) + self._require_reach_in_single_layout(parent, well_name) + self._require_single_nozzle_clearance(parent) + labware_id = await self.flex._ensure_labware_loaded(parent) + staged_trackers = self._stage_container_aspirate(well, volume) + await self._pipette( + "aspirate", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def _aspirate_wells( + self, + wells: List[Well], + volume: float, + use_channels: Optional[Sequence[int]], + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + ) -> None: + """Aspirate a PLR-native column (a list of wells) -- one anchored ``aspirate``. + + The wells are the resources the nozzle row covers, rearmost first (as + ``plate.column(c)`` returns them); the command anchors at the first and the + hardware fans it to all 8 nozzles. One ``Well.tracker`` is staged per well + whose channel holds a tip (None-skip), before the wire command. + """ + self._warn_untested_hardware("aspirate") + self._require_mounted_tip() + if not wells: + raise ValueError("aspirate: the target well sequence is empty.") + parent = self._require_itemized_parent(wells[0]) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(parent) + anchor = parent.get_child_identifier(wells[0]) + staged_trackers = self._stage_wells_aspirate(wells, volume) + await self._pipette( + "aspirate", labware_id, anchor, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def _aspirate_column( + self, + plate: Plate, + column: int, + 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 its rearmost well. + + Requires at least one mounted tip. Follows stage -> validate -> wire -> + commit/rollback: ``Well.tracker`` (``remove_liquid``) is staged for + every well whose channel actually holds a tip (None-skip; wells outside + ``column`` are never touched -- the Case-1 regression guard) BEFORE the + wire command, so an infeasible aspirate (e.g. ``TooLittleLiquidError``) + raises before any hardware motion. Naming the wells means the robot + primes the plunger itself when it needs to, at the well top and then + descending, so nothing here has to. + """ + self._warn_untested_hardware("aspirate") + self._require_mounted_tip() + well_name, column_wells = self._column_anchor_and_items(plate, column) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + staged_trackers = self._stage_wells_aspirate(column_wells, volume) + await self._pipette( + "aspirate", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def dispense( + self, + target: Union[Plate, Well, Sequence[Well], Container], + volume: float, + *, + column: Optional[int] = None, + use_channels: Optional[Sequence[int]] = None, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Dispense ``volume`` uL into ``target`` -- one ``dispense`` command. + + The ``dispense`` mirror of :meth:`aspirate`: the ``target`` type selects the + addressing (a ``plate.column(c)`` sequence, a single ``Well``, or a + ``Container`` cavity) and ``use_channels`` names the active nozzles. No + ``configureNozzleLayout`` is emitted (the layout is fixed at pickup). See + :meth:`aspirate` for the full contract; ``column`` is the same transitional + bridge for the old ``dispense(plate, column=c)`` call. + """ + self._require_use_channels_match_mounted(use_channels) + if column is not None: + await self._dispense_column( + cast(Plate, target), column, volume, flow_rate, offset, liquid_height + ) + return + if isinstance(target, (list, tuple)): + await self._dispense_wells( + list(target), volume, use_channels, flow_rate, offset, liquid_height + ) + return + if isinstance(target, Well): # Well is a Container, so check it first + await self._dispense_single_well(target, volume, flow_rate, offset, liquid_height) + return + if isinstance(target, Container): + await self.dispense_container( + target, volume, flow_rate=flow_rate, offset=offset, liquid_height=liquid_height + ) + return + raise TypeError( + f"dispense target must be a Plate column (Sequence[Well]), a single Well, or a " + f"Container; got {type(target).__name__}." + ) + + async def _dispense_single_well( + self, + well: Well, + volume: float, + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + ) -> None: + """Dispense to one well with the mounted single nozzle -- the mirror of + :meth:`_aspirate_single_well`.""" + self._warn_untested_hardware("dispense") + self._active_single_channel() + parent = self._require_itemized_parent(well) + well_name = parent.get_child_identifier(well) + self._require_reach_in_single_layout(parent, well_name) + self._require_single_nozzle_clearance(parent) + labware_id = await self.flex._ensure_labware_loaded(parent) + staged_trackers = self._stage_container_dispense(well, volume) + await self._pipette( + "dispense", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def _dispense_wells( + self, + wells: List[Well], + volume: float, + use_channels: Optional[Sequence[int]], + flow_rate: Optional[float], + offset: Optional[Coordinate], + liquid_height: Optional[float], + ) -> None: + """Dispense a PLR-native column (a list of wells) -- one anchored ``dispense``.""" + self._warn_untested_hardware("dispense") + self._require_mounted_tip() + if not wells: + raise ValueError("dispense: the target well sequence is empty.") + parent = self._require_itemized_parent(wells[0]) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(parent) + anchor = parent.get_child_identifier(wells[0]) + staged_trackers = self._stage_wells_dispense(wells, volume) + await self._pipette( + "dispense", labware_id, anchor, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def _dispense_column( + self, + plate: Plate, + column: int, + 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 its rearmost well. + + Requires at least one mounted tip. Follows stage -> validate -> wire -> + commit/rollback: ``Well.tracker`` (``add_liquid``) is staged for every + well whose channel actually holds a tip (None-skip) BEFORE the wire + command, so an infeasible dispense (e.g. ``TooLittleVolumeError``) + raises before any hardware motion. + """ + self._warn_untested_hardware("dispense") + self._require_mounted_tip() + well_name, column_wells = self._column_anchor_and_items(plate, column) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + staged_trackers = self._stage_wells_dispense(column_wells, volume) + await self._pipette( + "dispense", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + # --- Single-cavity container (trough/reservoir) liquid handling --- + + async def aspirate_container( + self, + container: Container, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Aspirate ``volume`` uL per channel from one single-cavity container. + + All 8 nozzles dip into the same cavity (trough/reservoir) and ONE + ``aspirate`` command names its sole well. The engine centers the nozzle + row in the cavity itself, so only the caller's ``offset``/ + ``liquid_height`` ride the wire. Requires a mounted tip, a cavity the + offset-shifted row still fits, and ALL nozzle mode. Each channel holding + a tip draws ``volume``, so the container's single tracker is staged with + the total and settled as one op. + """ + self._warn_untested_hardware("aspirate_container") + self._require_mounted_tip() + self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN, offset) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(container) + staged_trackers = self._stage_container_aspirate(container, volume * self._mounted_count()) + await self._pipette( + "aspirate", + labware_id, + _CONTAINER_WELL_NAME, + volume, + flow_rate, + offset, + liquid_height, + staged_trackers, + ) + + async def dispense_container( + self, + container: Container, + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Dispense ``volume`` uL per channel into one single-cavity container. + + Mirrors ``aspirate_container``: same addressing, same pre-wire guards, + and the container's single tracker staged with the total. + """ + self._warn_untested_hardware("dispense_container") + self._require_mounted_tip() + self._require_span_fits_container(container, 0.0, _EIGHT_CHANNEL_Y_SPAN, offset) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(container) + staged_trackers = self._stage_container_dispense(container, volume * self._mounted_count()) + await self._pipette( + "dispense", + labware_id, + _CONTAINER_WELL_NAME, + volume, + flow_rate, + offset, + liquid_height, + staged_trackers, + ) + + async def touch_tip( + self, + plate: Plate, + column: int, + radius: float = 1.0, + offset: Optional[Coordinate] = None, + ) -> None: + """Touch the mounted tips to their well walls -- one ``touchTip`` command + anchored at the column's rearmost well. + + ``radius`` and ``offset`` behave as in ``FlexHead1.touch_tip``. Requires + a mounted tip and a valid column, both checked before any wire command, + plus ALL nozzle mode. No trackers are involved. + """ + self._warn_untested_hardware("touch_tip") + self._require_mounted_tip() + well_name, _ = self._column_anchor_and_items(plate, column) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + await self._execute("touchTip", self._touch_tip_params(labware_id, well_name, radius, offset)) + + async def liquid_probe(self, plate: Plate, column: int) -> float: + """Probe for liquid in a column -- one ``liquidProbe`` command anchored at + its rearmost well; return the found liquid z (mm). + + Requires at least one mounted tip and a valid column (both checked + before any wire command) and ALL nozzle mode (reset first if a + single-tip op left the layout otherwise). Raises ``OpentronsError`` if + no liquid is found; use ``try_liquid_probe`` for the non-raising + variant. + """ + self._warn_untested_hardware("liquid_probe") + self._require_mounted_tip() + well_name, _ = self._column_anchor_and_items(plate, column) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + return await self._liquid_probe_z(labware_id, well_name, f"column {column} of {plate.name!r}") + + async def try_liquid_probe(self, plate: Plate, column: int) -> Optional[float]: + """Like ``liquid_probe`` but return ``None`` instead of raising when no liquid is found.""" + self._warn_untested_hardware("try_liquid_probe") + self._require_mounted_tip() + well_name, _ = self._column_anchor_and_items(plate, column) + await self._ensure_all_mode() + labware_id = await self.flex._ensure_labware_loaded(plate) + return await self._probe_z("tryLiquidProbe", labware_id, well_name) + + # --- Single-tip cherry-pick --- + + @staticmethod + def _single_nozzle_reaches(labware: ItemizedResource, well: str, nozzle: str) -> bool: + """Whether anchoring ``nozzle`` over ``well`` keeps the pipette inside the robot. + + In a SINGLE layout the robot still carries the whole pipette: the body hangs + 95 mm forward of the mount, and the anchor nozzle sits 16 mm (A1) or 79 mm + (H1) forward of it. Anchoring A1 over a front-row slot therefore drives the + mount itself past the robot's front limit, and anchoring H1 at the very back + runs into the rear limit. The same arithmetic the engine runs in + ``pipette_movement_conflict``, which only guards Python-API protocols -- it + never sees the run commands this driver posts, so nothing downstream would + catch an out-of-extents anchor. + """ + well_y: float = labware.get_item(well).get_absolute_location(y="c").y + mount_y = well_y - _SINGLE_NOZZLE_Y[nozzle] + return ( + mount_y + _PIPETTE_BODY_BACK_Y >= _ROBOT_FRONT_LIMIT + and mount_y + _PIPETTE_BODY_FRONT_Y <= _ROBOT_REAR_LIMIT + ) + + @classmethod + def reachable_single_nozzles(cls, labware: ItemizedResource, well: str) -> Tuple[str, ...]: + """The anchor nozzles that can address ``well`` where the labware currently sits. + + Rearmost ("A1") first, since it keeps the most margin at the back of the + deck. Empty when the well is out of reach both ways. Depends on the deck + slot, not just the labware, so it is only meaningful once assigned. + """ + return tuple( + nozzle for nozzle in _SINGLE_NOZZLES if cls._single_nozzle_reaches(labware, well, nozzle) + ) + + @classmethod + def _anchor_for(cls, labware: ItemizedResource, well: str, primary_nozzle: Optional[str]) -> str: + """Settle which nozzle a single-tip op anchors on, refusing what cannot reach. + + A caller's explicit choice is never silently swapped -- an unreachable one + is refused, naming the nozzle that would work. Left to us, the well's own + row wins when it can reach (least surprise: the tip lands on the channel + whose row was asked for), otherwise whichever end reaches. + """ + reachable = cls.reachable_single_nozzles(labware, well) + if primary_nozzle is not None: + if primary_nozzle not in _SINGLE_NOZZLES: + raise ValueError( + f"primary_nozzle={primary_nozzle!r}: an 8-channel Flex can anchor a single-nozzle " + f"layout only on {' or '.join(_SINGLE_NOZZLES)}." + ) + if primary_nozzle not in reachable: + alternative = ( + f" Anchor on {reachable[0]} instead." + if reachable + else " Neither anchor reaches it; move the labware to another slot." + ) + raise ValueError( + f"Anchoring the {primary_nozzle} nozzle over '{labware.name}' well '{well}' would " + f"carry the pipette outside the robot's reach.{alternative}" + ) + return primary_nozzle + if not reachable: + raise ValueError( + f"'{labware.name}' well '{well}' is out of reach of both single-nozzle anchors " + f"({' and '.join(_SINGLE_NOZZLES)}); the pipette would leave the robot's extents " + f"either way. Move the labware to another slot." + ) + row_nozzle = f"{well[:1].upper()}1" + return row_nozzle if row_nozzle in reachable else reachable[0] + + def _require_reach_in_single_layout(self, labware: ItemizedResource, well: str) -> None: + """Refuse a single-nozzle move to a well the mounted anchor cannot reach. + + The pickup chose an anchor that reached the RACK; a later well on a + front-row or back-row slot can still sit outside that same anchor's band. + """ + if self._nozzle_layout != "SINGLE": + return + nozzle = _SINGLE_NOZZLE_BY_CHANNEL.get(self._active_single_channel()) + if nozzle is None or self._single_nozzle_reaches(labware, well, nozzle): + return + raise ValueError( + f"The mounted tip is on the {nozzle} nozzle, and reaching '{labware.name}' well " + f"'{well}' with it would carry the pipette outside the robot's reach. Drop the tip " + f"and cherry-pick again from a rack the other anchor can reach." + ) + + def _active_single_channel(self) -> int: + """Return the sole channel holding a tip in single-tip mode. + + 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, + primary_nozzle: Optional[str] = None, + ) -> None: + """Pick up one tip in SINGLE nozzle mode. + + Switches to SINGLE layout (``configureNozzleLayout``) before the + ``pickUpTip`` command. In that layout the pipette drives ONE nozzle and + the engine moves it over whatever well is named, so the nozzle, not the + well, decides which channel ends up holding the tip. An 8-channel Flex + can anchor on its "A1" or "H1" nozzle only; ``primary_nozzle`` picks + between them, and left unset it is the well's own row when that end can + reach the rack's slot, otherwise the end that can (see + ``reachable_single_nozzles``). Raises ``OpentronsError`` if that channel + already holds a tip -- checked, like the nozzle itself, before any wire + command. Then stage -> validate -> wire -> verify -> commit/rollback, as + in ``pick_up_tips``. + """ + self._warn_untested_hardware("pick_up_single_tip") + primary_nozzle = self._anchor_for(tip_rack, well, primary_nozzle) + # The anchor only settles that the pipette stays inside the robot. The 7 + # idle nozzles still hang over the neighbouring slot, which is a crash. + slot = self.flex.deck.get_slot(tip_rack) + if slot is not None: + self.flex.deck.check_single_nozzle_clearance(slot, primary_nozzle) + channel = _SINGLE_NOZZLES[primary_nozzle] + if self._channel_tips[channel] is not None: + raise OpentronsError( + "HasTipError", + f"Channel {channel} already holds a tip; drop it before picking up another.", + ) + + await self._configure_nozzle_layout({"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 + + 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. + + Follows stage -> validate -> wire -> commit/rollback for the well + tracker, same as the column ``aspirate``, and leaves any plunger priming + to the robot for the same reason. + """ + self._warn_untested_hardware("aspirate_single") + self._active_single_channel() + self._require_reach_in_single_layout(plate, well) + self._require_single_nozzle_clearance(plate) + labware_id = await self.flex._ensure_labware_loaded(plate) + staged_trackers = self._stage_container_aspirate(plate.get_item(well), volume) + await self._pipette( + "aspirate", labware_id, well, volume, flow_rate, None, None, 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._warn_untested_hardware("dispense_single") + self._active_single_channel() + self._require_reach_in_single_layout(plate, well) + self._require_single_nozzle_clearance(plate) + labware_id = await self.flex._ensure_labware_loaded(plate) + staged_trackers = self._stage_container_dispense(plate.get_item(well), volume) + await self._pipette( + "dispense", labware_id, well, volume, flow_rate, None, None, staged_trackers + ) + + async def drop_single_tip(self, trash: Trash) -> None: + """Drop the single mounted tip to trash and restore ALL nozzle mode. + + 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. + """ + self._warn_untested_hardware("drop_single_tip") + channel = self._active_single_channel() + await self._execute_trash_drop(trash) + self._channel_tips[channel] = None + await self._confirm_tips_cleared() + await self._ensure_all_mode() + + +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. + + Liquid probing (``liquid_probe``/``try_liquid_probe``) is not implemented + on this head -- only the mount heads (``FlexHead1``/``FlexHead8``) + expose it. + + Coded but **not yet verified on real 96-channel Flex hardware** -- + Vincent's bench Flex carries an 8-channel pipette, not a 96-channel head. + A one-time ``logger.warning`` fires on the first op issued by an instance, + 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("pick_up_tips") + 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._configure_nozzle_layout({"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 + + 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("drop_tips") + + if isinstance(target, Trash): + await self._execute_trash_drop(target) + 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, + target: Union[Plate, Container], + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Aspirate a whole plate or one single-cavity container -- one ``aspirate`` command. + + A ``Plate`` (which must have exactly 96 positions) is anchored at its + "A1" well and covered one-to-one, staging ``remove_liquid`` per well + whose channel holds a tip. A bare ``Container`` (trough/reservoir) is + addressed at its sole well, with the engine centering the nozzle grid in + the cavity and the container's single tracker staged with the total. + Either way a mounted tip is required and every check runs before any wire + command. Naming the wells means the robot primes the plunger itself when + it needs to, at the well top and then descending, so nothing here has to. + """ + self._warn_untested_hardware("aspirate") + self._require_mounted_tip() + if isinstance(target, Plate): + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = self._ANCHOR_WELL_NAME + staged_trackers = self._stage_wells_aspirate(self._check_full_coverage(target), volume) + else: + self._require_span_fits_container( + target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN, offset + ) + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = _CONTAINER_WELL_NAME + staged_trackers = self._stage_container_aspirate(target, volume * self._mounted_count()) + await self._pipette( + "aspirate", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def dispense( + self, + target: Union[Plate, Container], + volume: float, + flow_rate: Optional[float] = None, + offset: Optional[Coordinate] = None, + liquid_height: Optional[float] = None, + ) -> None: + """Dispense to a whole plate or one single-cavity container -- one ``dispense`` command. + + Mirrors ``aspirate``: same addressing, same pre-wire guards, with + ``add_liquid`` staged per tip-holding channel for a plate and the total + staged against a container's single tracker. + """ + self._warn_untested_hardware("dispense") + self._require_mounted_tip() + if isinstance(target, Plate): + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = self._ANCHOR_WELL_NAME + staged_trackers = self._stage_wells_dispense(self._check_full_coverage(target), volume) + else: + self._require_span_fits_container( + target, _NINETY_SIX_HEAD_X_SPAN, _NINETY_SIX_HEAD_Y_SPAN, offset + ) + labware_id = await self.flex._ensure_labware_loaded(target) + well_name = _CONTAINER_WELL_NAME + staged_trackers = self._stage_container_dispense(target, volume * self._mounted_count()) + await self._pipette( + "dispense", labware_id, well_name, volume, flow_rate, offset, liquid_height, staged_trackers + ) + + async def touch_tip( + self, + plate: Plate, + radius: float = 1.0, + offset: Optional[Coordinate] = None, + ) -> None: + """Touch the mounted tips to their well walls -- one ``touchTip`` command + anchored at "A1", fanned to all 96 channels. + + ``radius`` and ``offset`` behave as in ``FlexHead1.touch_tip``. Requires + a mounted tip and a 96-position plate, both checked before any wire + command. No trackers are involved. + """ + self._warn_untested_hardware("touch_tip") + self._require_mounted_tip() + self._check_full_coverage(plate) + labware_id = await self.flex._ensure_labware_loaded(plate) + await self._execute( + "touchTip", self._touch_tip_params(labware_id, self._ANCHOR_WELL_NAME, radius, offset) + ) diff --git a/pylabrobot/opentrons/flex_head_use_channels_tests.py b/pylabrobot/opentrons/flex_head_use_channels_tests.py new file mode 100644 index 00000000000..25d95015383 --- /dev/null +++ b/pylabrobot/opentrons/flex_head_use_channels_tests.py @@ -0,0 +1,402 @@ +"""Tests for the unified ``FlexHead8.aspirate``/``dispense`` (per-call nozzle config). + +Collapses the three per-config methods -- ``aspirate`` (column), ``aspirate_single`` +(one well), ``aspirate_container`` (trough) -- into ONE +``aspirate(target, volume, *, use_channels=...)`` and the same for ``dispense``. +The target *type* drives addressing; ``use_channels`` names/validates the active +nozzles for that call. The layout itself is fixed at pickup (the engine refuses a +nozzle reconfiguration while tips are attached), so a liquid op emits NO +``configureNozzleLayout`` -- only the ``aspirate``/``dispense`` command. + +Wire payloads are inspected through the injected ``ChatterboxTransport``. +""" + +import asyncio +import unittest +from typing import List, Tuple + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead8 +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import ( + Container, + cor_96_wellplate_360uL_Fb, + set_tip_tracking, + set_volume_tracking, +) +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + +def _make_trough(name: str = "trough") -> Container: + """A single-cavity reservoir (PLR ``Container``) mapped to a real Opentrons load name.""" + trough = Container( + name=name, + size_x=127.76, + size_y=85.48, + size_z=31.4, + material_z_thickness=1.0, + max_volume=195000.0, + ) + trough.ot_load_name = "nest_1_reservoir_195ml" # type: ignore[attr-defined] + return trough + + +def _flex_head8() -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead8]: + transport = ChatterboxTransport(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, transport, head + + +def _plate_on(flex: OpentronsFlex, slot: str = "C2"): + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, slot) + return plate + + +def _rack_on(flex: OpentronsFlex, slot: str = "C1"): + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, slot) + return rack + + +def _commands_of(transport: ChatterboxTransport, command_type: str) -> List[dict]: + return [c for c in transport.commands if c["commandType"] == command_type] + + +class TestUnifiedAspirateColumn(unittest.TestCase): + """A ``Sequence[Well]`` target (a ``plate.column(c)``) aspirates that column + with ONE ``aspirate`` anchored at its rearmost well, per-well trackers, and no + ``configureNozzleLayout`` emitted at aspirate time.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_column_target_emits_one_anchored_aspirate_and_tracks_only_that_column(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + plate = _plate_on(flex) + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + commands_before = len(transport.commands) + + asyncio.run(head.aspirate(plate.column(2), volume=50)) + + aspirate_cmds = _commands_of(transport, "aspirate") + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A3") + + # No configureNozzleLayout may be emitted by the aspirate itself: the + # engine refuses a reconfiguration while tips are attached. + new_cmds = transport.commands[commands_before:] + self.assertNotIn( + "configureNozzleLayout", + [c["commandType"] for c in new_cmds], + ) + + wells = plate.get_all_items() + column_2 = set(wells[16:24]) + for well in wells: + expected = 50.0 if well in column_2 else 100.0 + self.assertAlmostEqual(well.tracker.volume, expected, msg=well.name) + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedAspirateSingleWell(unittest.TestCase): + """A bare ``Well`` target aspirates it with the mounted single nozzle -- one + ``aspirate`` at that well, its one tracker, no ``configureNozzleLayout``.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_bare_well_target_aspirates_with_single_nozzle(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + plate = _plate_on(flex) + target = plate.get_item("B3") + target.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_single_tip(rack, well="A1")) + commands_before = len(transport.commands) + + asyncio.run(head.aspirate(target, volume=20)) + + aspirate_cmds = _commands_of(transport, "aspirate") + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "B3") + + new_cmds = [c["commandType"] for c in transport.commands[commands_before:]] + self.assertNotIn("configureNozzleLayout", new_cmds) + + self.assertAlmostEqual(target.tracker.volume, 80.0) + for well in plate.get_all_items(): + if well is target: + continue + self.assertAlmostEqual(well.tracker.volume, 0.0, msg=well.name) + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedAspirateContainer(unittest.TestCase): + """A ``Container`` (trough) target fans every mounted nozzle into the one + cavity -- one ``aspirate`` at the container well, its single tracker staged + with ``volume x active-channels``.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_container_target_stages_total_and_emits_one_aspirate(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + trough = _make_trough() + flex.deck.assign_child_at_slot(trough, "C2") + trough.tracker.set_volume(1000.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) # 8 tips, ALL layout + commands_before = len(transport.commands) + + asyncio.run(head.aspirate(trough, volume=10)) + + aspirate_cmds = _commands_of(transport, "aspirate") + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") + + new_cmds = [c["commandType"] for c in transport.commands[commands_before:]] + self.assertNotIn("configureNozzleLayout", new_cmds) + + # 8 channels x 10 uL = 80 uL removed from the one cavity. + self.assertAlmostEqual(trough.tracker.volume, 920.0) + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedUseChannelsValidation(unittest.TestCase): + """``use_channels`` names the active nozzles for the call and, since the + layout is fixed at pickup, must match exactly the channels holding tips -- + a mismatch is refused before any wire command.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_use_channels_not_matching_mounted_tips_refuses_before_wire(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + plate = _plate_on(flex) + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) # all 8 channels hold tips + commands_before = len(transport.commands) + + # Only 3 channels named, but 8 are mounted: the fanned command cannot + # actuate a subset, so this is refused. + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(plate.column(2), volume=20, use_channels=[0, 1, 2])) + + self.assertEqual(len(transport.commands), commands_before, "no wire command may be sent") + finally: + asyncio.run(flex.stop()) + + def test_use_channels_matching_all_mounted_is_accepted(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + plate = _plate_on(flex) + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate.column(2), volume=20, use_channels=list(range(8)))) + + aspirate_cmds = _commands_of(transport, "aspirate") + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A3") + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedPickUpTips(unittest.TestCase): + """One ``pick_up_tips(target, *, use_channels)`` chooses the layout and emits the + ``configureNozzleLayout`` -- pickup is where per-call nozzle configuration lives. + A ``Sequence[TipSpot]`` (a ``rack.column(c)``) -> ALL; a single ``TipSpot`` -> SINGLE.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def test_column_of_spots_picks_up_eight_in_all_layout(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + + asyncio.run(head.pick_up_tips(rack.column(0))) + + pickup = _commands_of(transport, "pickUpTip") + self.assertEqual(len(pickup), 1) + self.assertEqual(pickup[0]["params"]["wellName"], "A1") + self.assertEqual(sum(1 for t in head.get_mounted_tips() if t is not None), 8) + # A fresh head is already ALL, so no SINGLE reconfiguration is emitted. + styles = [ + c["params"]["configurationParams"]["style"] + for c in _commands_of(transport, "configureNozzleLayout") + ] + self.assertNotIn("SINGLE", styles) + finally: + asyncio.run(flex.stop()) + + def test_single_spot_with_use_channels_configures_single_primary_nozzle(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"), use_channels=[0])) + + configure = _commands_of(transport, "configureNozzleLayout") + cfg = configure[-1]["params"]["configurationParams"] + self.assertEqual(cfg["style"], "SINGLE") + self.assertEqual(cfg["primaryNozzle"], "A1") + pickup = _commands_of(transport, "pickUpTip") + self.assertEqual(pickup[-1]["params"]["wellName"], "A1") + tips = head.get_mounted_tips() + self.assertIsNotNone(tips[0]) + self.assertEqual(sum(1 for t in tips if t is not None), 1) + finally: + asyncio.run(flex.stop()) + + def test_single_spot_use_channels_off_anchor_refuses(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + # An 8-channel Flex can single-anchor only on A1 (ch 0) or H1 (ch 7). + with self.assertRaises((ValueError, OpentronsError)): + asyncio.run(head.pick_up_tips(rack.get_item("A1"), use_channels=[3])) + finally: + asyncio.run(flex.stop()) + + +class TestUnifiedPartialPickUp(unittest.TestCase): + """A contiguous partial ``use_channels`` from the front (H1) end emits the QUADRANT + config Opentrons' own protocol_api produces (start=H1 -> primary=frontRight=H1, + backLeft=rear-most active nozzle) and picks up that partial column.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def test_front_partial_emits_quadrant_and_picks_the_front_channels(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + + # Front 4 nozzles E,F,G,H = channels 4..7; spots in channel order. + await_spots = rack.column(0)[4:8] + asyncio.run(head.pick_up_tips(await_spots, use_channels=[4, 5, 6, 7])) + + cfg = _commands_of(transport, "configureNozzleLayout")[-1]["params"]["configurationParams"] + self.assertEqual(cfg["style"], "QUADRANT") + self.assertEqual(cfg["primaryNozzle"], "H1") + self.assertEqual(cfg["frontRightNozzle"], "H1") + self.assertEqual(cfg["backLeftNozzle"], "E1") # rear-most of the front-4 + + pickup = _commands_of(transport, "pickUpTip") + self.assertEqual(pickup[-1]["params"]["wellName"], "H1") # anchor = primary nozzle's well + + tips = head.get_mounted_tips() + self.assertEqual([i for i, t in enumerate(tips) if t is not None], [4, 5, 6, 7]) + finally: + asyncio.run(flex.stop()) + + def test_non_contiguous_partial_refuses(self): + flex, transport, head = _flex_head8() + try: + rack = _rack_on(flex) + spots = [rack.get_item("A1"), rack.get_item("C1"), rack.get_item("E1")] + with self.assertRaises((ValueError, OpentronsError)): + asyncio.run(head.pick_up_tips(spots, use_channels=[0, 2, 4])) + finally: + asyncio.run(flex.stop()) + + +class TestSingleNozzleLiquidClearance(unittest.TestCase): + """The single-nozzle aspirate/dispense refuses when the idle nozzles would + overhang the adjacent slot's labware (the same guard pickup runs), and allows + it when the trailing row is empty.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def test_single_aspirate_refuses_when_idle_nozzles_overhang_a_tip_rack(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + plate = _plate_on(flex, "D1") # D1 front; C1 (a tip rack) is directly behind it + plate.get_item("A1").tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"), use_channels=[7])) # H1 single + with self.assertRaises((ValueError, OpentronsError)): + asyncio.run(head.aspirate(plate.get_item("A1"), volume=20)) + + self.assertEqual(len(_commands_of(transport, "aspirate")), 0, "no aspirate may be sent") + finally: + asyncio.run(flex.stop()) + + def test_single_aspirate_allowed_when_trailing_row_empty(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "D1") # pick from D1 (behind it, C1, is empty) + plate = _plate_on(flex, "B1") # aspirate B1; behind it, A1, is empty + plate.get_item("A1").tracker.set_volume(100.0) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"), use_channels=[7])) + asyncio.run(head.aspirate(plate.get_item("A1"), volume=20)) + + self.assertEqual(len(_commands_of(transport, "aspirate")), 1) + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_motion_tests.py b/pylabrobot/opentrons/flex_motion_tests.py new file mode 100644 index 00000000000..328e9ecb0bc --- /dev/null +++ b/pylabrobot/opentrons/flex_motion_tests.py @@ -0,0 +1,675 @@ +"""Tests for the Flex direct-motion surface: head jog + position read +(``_FlexHead.position``/``_FlexHead.move_to``) and gripper motion + jaw +control (``FlexGripper.move_to``/``grip``/``open_jaw``). + +Drives ``OpentronsFlex.setup()`` with an injected ``ChatterboxTransport`` and +asserts the exact wire commands: ``savePosition`` reads, ``moveToCoordinates`` +axis merging and ``minimumZHeight``/``speed`` handling, the ``robot/moveTo`` +extension-mount params, jaw force validation before any wire command, and the +robot-software version gate on the robot/* command family. +""" + +import asyncio +import unittest +from typing import Any, Dict, List, Tuple + +from pylabrobot.opentrons.checks import traversal_z +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_gripper import FlexGripper, _require_robot_commands +from pylabrobot.opentrons.flex_head import FlexHead8, _FlexHead +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import set_tip_tracking +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_plates import corning_96_wellplate_360ul_flat +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul + + +def _flex_with_gripper(**transport_kwargs) -> Tuple[OpentronsFlex, ChatterboxTransport]: + """An ``OpentronsFlex`` with a single-channel right-mount pipette and a + gripper, returning the transport too so a test can inspect recorded + commands. ``transport_kwargs`` are forwarded to ``ChatterboxTransport``. + """ + transport = ChatterboxTransport( + pipettes=[("p1000_single_flex", 1, 1.0, 1000.0, "right")], + gripper=True, + **transport_kwargs, + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + return flex, transport + + +def _head(flex: OpentronsFlex) -> _FlexHead: + head = flex.right + assert head is not None + return head + + +def _gripper(flex: OpentronsFlex) -> FlexGripper: + gripper = flex.gripper + assert gripper is not None + return gripper + + +def _cmds(transport: ChatterboxTransport, command_type: str) -> List[Dict[str, Any]]: + return [c for c in transport.commands if c["commandType"] == command_type] + + +def _flex_with_version(api_version: str) -> Tuple[OpentronsFlex, ChatterboxTransport]: + """A gripper-equipped Flex whose ``/health`` reports ``api_version``, so a + test can drive the robot/* version gate.""" + return _flex_with_gripper(api_version=api_version) + + +class TestHeadPosition(unittest.TestCase): + """position() reads the head's pose from a savePosition command result.""" + + def test_position_reads_save_position_result(self): + flex, transport = _flex_with_gripper(saved_position={"x": 10.0, "y": 20.0, "z": 30.5}) + asyncio.run(flex.setup()) + try: + head = _head(flex) + + position = asyncio.run(head.position()) + + self.assertEqual(position, Coordinate(10.0, 20.0, 30.5)) + save_cmds = _cmds(transport, "savePosition") + self.assertEqual(len(save_cmds), 1) + self.assertEqual(save_cmds[0]["params"], {"pipetteId": head.pipette_id}) + finally: + asyncio.run(flex.stop()) + + +class TestHeadMoveTo(unittest.TestCase): + """move_to fills unspecified axes from the current position and sends ONE + moveToCoordinates command. No tip is mounted in any of these tests: jogging + is for teaching/recovery and must not require one. + """ + + def test_partial_axes_merge_saved_with_given(self): + flex, transport = _flex_with_gripper(saved_position={"x": 10.0, "y": 20.0, "z": 30.0}) + asyncio.run(flex.setup()) + try: + head = _head(flex) + + asyncio.run(head.move_to(x=50.0)) + + self.assertEqual(len(_cmds(transport, "savePosition")), 1) + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertEqual(len(move_cmds), 1) + self.assertEqual( + move_cmds[0]["params"], + { + "pipetteId": head.pipette_id, + "coordinates": {"x": 50.0, "y": 20.0, "z": 30.0}, + # Default minimumZHeight is now the computed tip-safe plane (tallest + # labware top + arc margin), not a hardcoded 120.0 magic number. + "minimumZHeight": traversal_z(flex.deck), + }, + ) + finally: + asyncio.run(flex.stop()) + + def test_all_axes_given_skips_position_read(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0)) + + self.assertEqual(len(_cmds(transport, "savePosition")), 0) + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertEqual(len(move_cmds), 1) + self.assertEqual(move_cmds[0]["params"]["coordinates"], {"x": 1.0, "y": 2.0, "z": 3.0}) + finally: + asyncio.run(flex.stop()) + + def test_minimum_z_height_override(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0, minimum_z_height=35.0)) + + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertEqual(move_cmds[0]["params"]["minimumZHeight"], 35.0) + finally: + asyncio.run(flex.stop()) + + def test_speed_passthrough(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0, speed=40.0)) + + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertEqual(move_cmds[0]["params"]["speed"], 40.0) + finally: + asyncio.run(flex.stop()) + + def test_speed_omitted_by_default(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0)) + + move_cmds = _cmds(transport, "moveToCoordinates") + self.assertNotIn("speed", move_cmds[0]["params"]) + finally: + asyncio.run(flex.stop()) + + def test_no_axes_raises_before_any_wire_command(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + with self.assertRaises(ValueError): + asyncio.run(_head(flex).move_to()) + + self.assertEqual(len(_cmds(transport, "savePosition")), 0) + self.assertEqual(len(_cmds(transport, "moveToCoordinates")), 0) + finally: + asyncio.run(flex.stop()) + + +class TestGripperMoveTo(unittest.TestCase): + """Gripper move_to sends robot/moveTo with the extension mount.""" + + def test_exact_wire_params(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).move_to(100.0, 50.0, 75.5)) + + move_cmds = _cmds(transport, "robot/moveTo") + self.assertEqual(len(move_cmds), 1) + self.assertEqual( + move_cmds[0]["params"], + {"mount": "extension", "destination": {"x": 100.0, "y": 50.0, "z": 75.5}}, + ) + finally: + asyncio.run(flex.stop()) + + def test_speed_passthrough(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).move_to(1.0, 2.0, 3.0, speed=25.0)) + + move_cmds = _cmds(transport, "robot/moveTo") + self.assertEqual(move_cmds[0]["params"]["speed"], 25.0) + finally: + asyncio.run(flex.stop()) + + +class TestGripperJaw(unittest.TestCase): + """grip() validates force before the wire; open_jaw() homes the jaw open.""" + + def test_grip_without_force_sends_empty_params(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).grip()) + + close_cmds = _cmds(transport, "robot/closeGripperJaw") + self.assertEqual(len(close_cmds), 1) + self.assertEqual(close_cmds[0]["params"], {}) + finally: + asyncio.run(flex.stop()) + + def test_grip_force_boundaries_accepted(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + gripper = _gripper(flex) + + asyncio.run(gripper.grip(force=2.0)) + asyncio.run(gripper.grip(force=30.0)) + + close_cmds = _cmds(transport, "robot/closeGripperJaw") + self.assertEqual(len(close_cmds), 2) + self.assertEqual(close_cmds[0]["params"], {"force": 2.0}) + self.assertEqual(close_cmds[1]["params"], {"force": 30.0}) + finally: + asyncio.run(flex.stop()) + + def test_grip_force_out_of_range_raises_before_any_wire_command(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + gripper = _gripper(flex) + + for force in (1.9, 30.1, 0.0, -5.0): + with self.assertRaises(OpentronsError): + asyncio.run(gripper.grip(force=force)) + + self.assertEqual(len(_cmds(transport, "robot/closeGripperJaw")), 0) + finally: + asyncio.run(flex.stop()) + + def test_open_jaw_sends_open_gripper_jaw(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + + open_cmds = _cmds(transport, "robot/openGripperJaw") + self.assertEqual(len(open_cmds), 1) + self.assertEqual(open_cmds[0]["params"], {}) + finally: + asyncio.run(flex.stop()) + + +class TestRobotCommandsVersionGate(unittest.TestCase): + """robot/* commands require robot software 8.2.0+; dev builds and offline + stand-ins (non-release version strings) are exempt. Head motion + (savePosition/moveToCoordinates) is NOT gated -- it predates the robot/* + family. + """ + + def _assert_no_robot_commands(self, transport: ChatterboxTransport) -> None: + robot_cmds = [c for c in transport.commands if c["commandType"].startswith("robot/")] + self.assertEqual(len(robot_cmds), 0, "no robot/* wire command may be sent") + + def test_old_release_raises_and_sends_no_robot_commands(self): + flex, transport = _flex_with_version("8.1.0") + asyncio.run(flex.setup()) + try: + gripper = _gripper(flex) + + with self.assertRaises(OpentronsError) as ctx: + asyncio.run(gripper.move_to(1.0, 2.0, 3.0)) + self.assertIn("8.2.0", str(ctx.exception)) + with self.assertRaises(OpentronsError): + asyncio.run(gripper.grip()) + with self.assertRaises(OpentronsError): + asyncio.run(gripper.open_jaw()) + + self._assert_no_robot_commands(transport) + finally: + asyncio.run(flex.stop()) + + def test_minimum_release_passes(self): + flex, transport = _flex_with_version("8.2.0") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_double_digit_major_passes(self): + # A lexicographic comparison would put "10.0.0" below "8.2.0". + flex, transport = _flex_with_version("10.0.0") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_two_part_version_passes(self): + # "8.2" pads to (8, 2, 0), equal to the minimum, not below it. + flex, transport = _flex_with_version("8.2") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_patch_release_below_minimum_rejected(self): + flex, transport = _flex_with_version("8.1.9") + asyncio.run(flex.setup()) + try: + with self.assertRaises(OpentronsError): + asyncio.run(_gripper(flex).open_jaw()) + self._assert_no_robot_commands(transport) + finally: + asyncio.run(flex.stop()) + + def test_unparseable_version_rejected(self): + # A version the gate cannot parse must raise, not silently pass. + flex, transport = _flex_with_version("unknown") + asyncio.run(flex.setup()) + try: + with self.assertRaises(OpentronsError) as ctx: + asyncio.run(_gripper(flex).open_jaw()) + self.assertIn("unknown", str(ctx.exception)) + self._assert_no_robot_commands(transport) + finally: + asyncio.run(flex.stop()) + + def test_dev_build_passes(self): + flex, transport = _flex_with_version("0.0.0.dev0") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_dev_build_cut_off_a_too_old_tag_is_still_gated(self): + # An untagged build reports 0.0.0.dev*; a build cut off a release tag + # reports that tag plus a dev suffix, and is as old as the tag says. + flex, transport = _flex_with_version("8.1.0.dev5") + asyncio.run(flex.setup()) + try: + with self.assertRaises(OpentronsError) as ctx: + asyncio.run(_gripper(flex).open_jaw()) + self.assertIn("8.2.0", str(ctx.exception)) + self._assert_no_robot_commands(transport) + finally: + asyncio.run(flex.stop()) + + def test_dev_build_cut_off_a_new_enough_tag_passes(self): + flex, transport = _flex_with_version("8.2.0.dev3") + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_default_chatterbox_passes(self): + flex, transport = _flex_with_gripper() # /health reports "dry-run" + asyncio.run(flex.setup()) + try: + asyncio.run(_gripper(flex).open_jaw()) + self.assertEqual(len(_cmds(transport, "robot/openGripperJaw")), 1) + finally: + asyncio.run(flex.stop()) + + def test_head_motion_is_not_gated(self): + flex, transport = _flex_with_version("8.1.0") + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to(x=1.0, y=2.0, z=3.0)) + self.assertEqual(len(_cmds(transport, "moveToCoordinates")), 1) + finally: + asyncio.run(flex.stop()) + + def test_unknown_version_raises(self): + with self.assertRaises(OpentronsError): + _require_robot_commands("robot/moveTo", None) + + +class TestUntestedHardwareWarnings(unittest.TestCase): + """Hardware-verification coverage is op-scoped: FlexHead8's verified + column-pickup lineage never warns; every other head or gripper op logs the + one-time untested-hardware notice, naming the op.""" + + def setUp(self): + set_tip_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + + def _flex_head8(self) -> Tuple[OpentronsFlex, FlexHead8]: + transport = ChatterboxTransport(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, head + + def test_head8_verified_pickup_does_not_warn(self): + flex, head = self._flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + with self.assertRaises(AssertionError): + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING"): + asyncio.run(head.pick_up_tips(rack, column=0)) + finally: + asyncio.run(flex.stop()) + + def test_head8_op_outside_verified_lineage_warns_once(self): + flex, head = self._flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(head.pick_up_tips(rack, column=0)) + + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING") as log_ctx: + asyncio.run(head.blow_out()) + self.assertTrue(any("FlexHead8.blow_out" in msg for msg in log_ctx.output)) + self.assertTrue(any("not yet verified" in msg.lower() for msg in log_ctx.output)) + + # Only the FIRST unverified op on an instance logs. + with self.assertRaises(AssertionError): + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING"): + asyncio.run(head.blow_out()) + finally: + asyncio.run(flex.stop()) + + def test_base_motion_ops_warn_on_unverified_heads(self): + """A base-class op warns unless THIS head's verified set names it. + + Uses FlexHead8, whose verified lineage is column pickup only, so a motion + op it never covered still warns. + """ + flex, head = self._flex_head8() + try: + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING") as log_ctx: + asyncio.run(head.position()) + self.assertTrue(any("FlexHead8.position" in msg for msg in log_ctx.output)) + finally: + asyncio.run(flex.stop()) + + def test_head1_hardware_verified_ops_do_not_warn(self): + """FlexHead1's ops were confirmed on a p50 single channel, so they stay quiet.""" + transport = ChatterboxTransport(pipettes=[("p50_single_flex", 1, 1.0, 50.0, "left")]) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + try: + head = flex.left + assert head is not None + with self.assertRaises(AssertionError): + with self.assertLogs("pylabrobot.opentrons.flex_head", level="WARNING"): + asyncio.run(head.position()) + finally: + asyncio.run(flex.stop()) + + def test_gripper_ops_warn_once(self): + flex, _transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + gripper = _gripper(flex) + with self.assertLogs("pylabrobot.opentrons.flex_gripper", level="WARNING") as log_ctx: + asyncio.run(gripper.move_to(1.0, 2.0, 3.0)) + self.assertTrue(any("FlexGripper.move_to" in msg for msg in log_ctx.output)) + + with self.assertRaises(AssertionError): + with self.assertLogs("pylabrobot.opentrons.flex_gripper", level="WARNING"): + asyncio.run(gripper.open_jaw()) + finally: + asyncio.run(flex.stop()) + + +if __name__ == "__main__": + unittest.main() + + +class TestMoveToWell(unittest.TestCase): + """move_to_well names the well and lets the robot resolve where that is.""" + + def _flex_with_plate(self): + flex, transport = _flex_with_gripper() + plate = corning_96_wellplate_360ul_flat(name="plate") + flex.deck.assign_child_at_slot(plate, "C1") + return flex, transport, plate + + def test_a_tip_spot_is_a_valid_target(self): + """Looking at where a pickup would descend, before committing to it. The + robot addresses a tip rack's wells by the same names a plate's use.""" + flex, transport = _flex_with_gripper() + rack = flex_96_tiprack_50ul(name="tips") + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to_well(rack.get_item("A1"), offset=Coordinate(0, 0, 20))) + + (cmd,) = _cmds(transport, "moveToWell") + self.assertEqual(cmd["params"]["wellName"], "A1") + self.assertEqual(cmd["params"]["labwareId"], transport.labware_ids["tips"]) + self.assertEqual(cmd["params"]["wellLocation"]["offset"]["z"], 20) + finally: + asyncio.run(flex.stop()) + + def test_names_the_well_and_defaults_to_the_top_origin(self): + flex, transport, plate = self._flex_with_plate() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to_well(plate.get_item("D2"))) + + (cmd,) = _cmds(transport, "moveToWell") + self.assertEqual(cmd["params"]["wellName"], "D2") + self.assertEqual( + cmd["params"]["wellLocation"], + {"origin": "top", "offset": {"x": 0, "y": 0, "z": 0}}, + ) + self.assertNotIn("coordinates", cmd["params"]) + finally: + asyncio.run(flex.stop()) + + def test_offset_above_the_well_rides_the_top_origin(self): + """'10 mm above the D2 well' is an offset from the top, not a coordinate.""" + flex, transport, plate = self._flex_with_plate() + asyncio.run(flex.setup()) + try: + asyncio.run( + _head(flex).move_to_well(plate.get_item("D2"), offset=Coordinate(0, 0, 10), speed=50.0) + ) + + (cmd,) = _cmds(transport, "moveToWell") + self.assertEqual(cmd["params"]["wellLocation"]["origin"], "top") + self.assertEqual(cmd["params"]["wellLocation"]["offset"]["z"], 10) + self.assertEqual(cmd["params"]["speed"], 50.0) + finally: + asyncio.run(flex.stop()) + + def test_unknown_origin_is_refused_before_any_wire_command(self): + flex, transport, plate = self._flex_with_plate() + asyncio.run(flex.setup()) + try: + with self.assertRaisesRegex(ValueError, "origin must be one of"): + asyncio.run(_head(flex).move_to_well(plate.get_item("A1"), origin="sideways")) + self.assertEqual(_cmds(transport, "moveToWell"), []) + finally: + asyncio.run(flex.stop()) + + def test_no_mounted_tip_required(self): + """Jogging to a well is for teaching and recovery, so it must not need a tip.""" + flex, transport, plate = self._flex_with_plate() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_to_well(plate.get_item("A1"))) + self.assertEqual(len(_cmds(transport, "moveToWell")), 1) + finally: + asyncio.run(flex.stop()) + + +class TestMoveRelative(unittest.TestCase): + """move_relative jogs one axis without reading the position first.""" + + def test_sends_axis_and_distance_only(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(_head(flex).move_relative("z", -5.0)) + + (cmd,) = _cmds(transport, "moveRelative") + self.assertEqual(cmd["params"]["axis"], "z") + self.assertEqual(cmd["params"]["distance"], -5.0) + self.assertEqual(_cmds(transport, "savePosition"), []) + finally: + asyncio.run(flex.stop()) + + def test_unknown_axis_is_refused_before_any_wire_command(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + with self.assertRaisesRegex(ValueError, "axis must be one of"): + asyncio.run(_head(flex).move_relative("w", 1.0)) + self.assertEqual(_cmds(transport, "moveRelative"), []) + finally: + asyncio.run(flex.stop()) + + +class TestMoveToAddressableArea(unittest.TestCase): + """move_to_addressable_area targets a deck fixture by name.""" + + def test_names_the_area_and_carries_the_offset(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run( + _head(flex).move_to_addressable_area("movableTrashA3", offset=Coordinate(0, 0, 5)) + ) + + (cmd,) = _cmds(transport, "moveToAddressableArea") + self.assertEqual(cmd["params"]["addressableAreaName"], "movableTrashA3") + self.assertEqual(cmd["params"]["offset"], {"x": 0, "y": 0, "z": 5}) + self.assertFalse(cmd["params"]["stayAtHighestPossibleZ"]) + finally: + asyncio.run(flex.stop()) + + +class TestSendCommandEscapeHatch(unittest.TestCase): + """send_command reaches commands the driver wraps no method around.""" + + def test_passes_command_type_and_params_through_untouched(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + params = {"moduleId": "abc", "celsius": 37.0} + asyncio.run(flex.send_command("heaterShaker/setTargetTemperature", params)) + + (cmd,) = _cmds(transport, "heaterShaker/setTargetTemperature") + self.assertEqual(cmd["params"], params) + finally: + asyncio.run(flex.stop()) + + def test_defaults_params_to_an_empty_payload(self): + flex, transport = _flex_with_gripper() + asyncio.run(flex.setup()) + try: + asyncio.run(flex.send_command("unsafe/engageAxes")) + (cmd,) = _cmds(transport, "unsafe/engageAxes") + self.assertEqual(cmd["params"], {}) + finally: + asyncio.run(flex.stop()) + + +class TestSyncTipsToRobot(unittest.TestCase): + """sync_tips_to_robot pushes PyLabRobot's tip layout onto the robot.""" + + def test_splits_present_and_absent_into_one_command_each(self): + flex, transport = _flex_with_gripper() + rack = flex_96_tiprack_50ul(name="tips") + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex.setup()) + try: + rack.set_tip_state({spot.get_identifier(): False for spot in rack.get_all_items()}) + rack.set_tip_state({"A1": True, "B1": True}) + + asyncio.run(flex.sync_tips_to_robot(rack)) + + cmds = _cmds(transport, "setTipState") + by_state = {c["params"]["tipWellState"]: c["params"]["wellNames"] for c in cmds} + self.assertEqual(by_state["clean"], ["A1", "B1"]) + self.assertEqual(len(by_state["empty"]), 94) + finally: + asyncio.run(flex.stop()) + + def test_a_uniform_rack_sends_only_the_state_it_has(self): + flex, transport = _flex_with_gripper() + rack = flex_96_tiprack_50ul(name="tips") + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex.setup()) + try: + asyncio.run(flex.sync_tips_to_robot(rack)) + + states = {c["params"]["tipWellState"] for c in _cmds(transport, "setTipState")} + self.assertEqual(states, {"clean"}) + finally: + asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/flex_tests.py b/pylabrobot/opentrons/flex_tests.py new file mode 100644 index 00000000000..9514c1ea83c --- /dev/null +++ b/pylabrobot/opentrons/flex_tests.py @@ -0,0 +1,1251 @@ +"""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 TestLifecycleHalves(unittest.TestCase): + """``setup``/``stop`` are the two halves run together; each half is callable alone. + + The split exists because taking the robot and moving it are separate acts. An + operator reclaiming the touchscreen wants the session ended and nothing else; + homing an arm over a deck someone is reaching into is the thing to avoid. + """ + + def test_connect_opens_the_link_but_starts_no_run(self): + """The run is what the robot holds against its touchscreen, so taking the + robot is create_run's doing, not connect's.""" + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + + asyncio.run(flex.connect()) + try: + self.assertIsNone(flex.run_id) + self.assertEqual([c for c in transport.commands if c["commandType"] == "home"], []) + finally: + asyncio.run(flex.disconnect()) + + def test_initialize_discovers_without_moving(self): + """Asking what is mounted must not require moving the robot to find out.""" + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + asyncio.run(flex.connect()) + asyncio.run(flex.create_run()) + + asyncio.run(flex.initialize()) + try: + self.assertIsInstance(flex.left, FlexHead8) + self.assertEqual([c for c in transport.commands if c["commandType"] == "home"], []) + finally: + asyncio.run(flex.disconnect()) + + def test_cancel_run_frees_local_control_without_dropping_the_link(self): + """The operator's 'give me the robot back' action: end the session, stay connected.""" + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + asyncio.run(flex.setup()) + homes_from_setup = len([c for c in transport.commands if c["commandType"] == "home"]) + + asyncio.run(flex.cancel_run()) + try: + self.assertIsNone(flex.run_id) + self.assertEqual( + len([c for c in transport.commands if c["commandType"] == "home"]), homes_from_setup + ) + finally: + asyncio.run(flex.disconnect()) + + def test_disconnect_ends_the_run_without_homing(self): + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + asyncio.run(flex.setup()) + homes_from_setup = len([c for c in transport.commands if c["commandType"] == "home"]) + + asyncio.run(flex.disconnect()) + + self.assertIsNone(flex.run_id) + self.assertEqual( + len([c for c in transport.commands if c["commandType"] == "home"]), homes_from_setup + ) + + def test_stop_still_homes_before_releasing(self): + """The one-call teardown keeps parking the gantry; only disconnect skips it.""" + flex, transport = _flex_with_transport([("p50_multi_flex", 8, 1.0, 50.0, "left")]) + asyncio.run(flex.setup()) + homes_from_setup = len([c for c in transport.commands if c["commandType"] == "home"]) + + asyncio.run(flex.stop()) + + self.assertEqual( + len([c for c in transport.commands if c["commandType"] == "home"]), homes_from_setup + 1 + ) + self.assertIsNone(flex.run_id) + + +class TestHeadDiscovery(unittest.TestCase): + """setup() discovers mounted pipettes and composes the matching head per mount.""" + + 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): + """Priming a well-addressed aspirate is the robot's job, never the driver's. + + The robot moves to the well TOP, primes there in open air and then descends, + which is the only way to prime safely: the plunger travels several uL worth, + so priming with the tip already in the liquid would draw an unmeasured slug + of the well into the tip. A prepareToAspirate the driver sends first sets the + robot's ready flag and so skips that safe handling. `prepare_to_aspirate` is + the caller's escape hatch for the in-place ops, which name no well.""" + + def setUp(self): + set_tip_tracking(True) + set_volume_tracking(True) + + def tearDown(self): + set_tip_tracking(False) + set_volume_tracking(False) + + def _bench(self): + flex, transport, head = _flex_head8() + rack = flex_96_tiprack_50ul(name="rack") + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + flex.deck.assign_child_at_slot(plate, "C2") + for well in plate.get_all_items(): + well.tracker.set_volume(100.0) + return flex, transport, head, rack, plate + + def test_no_prepare_is_sent_around_a_transfer_loop(self): + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.aspirate(plate, column=0, volume=10)) + asyncio.run(head.dispense(plate, column=1, volume=10)) + asyncio.run(head.aspirate(plate, column=1, volume=10)) + + cmd_types = [c["commandType"] for c in transport.commands] + self.assertEqual(cmd_types.count("aspirate"), 2) + self.assertNotIn("prepareToAspirate", cmd_types) + finally: + asyncio.run(flex.stop()) + + def test_a_well_addressed_aspirate_still_works_with_the_plunger_pushed_past_bottom(self): + """The whole reason the driver can stay out of it: after a blow-out the + robot primes the aspirate itself rather than refusing it.""" + flex, transport, head, rack, plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.blow_out()) + asyncio.run(head.aspirate(plate, column=0, volume=10)) + + cmd_types = [c["commandType"] for c in transport.commands] + self.assertEqual(cmd_types.count("aspirate"), 1) + self.assertNotIn("prepareToAspirate", cmd_types) + finally: + asyncio.run(flex.stop()) + + def test_prepare_to_aspirate_sends_one_command_carrying_only_the_pipette(self): + flex, transport, head, rack, _plate = self._bench() + try: + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.prepare_to_aspirate()) + + prepares = [c for c in transport.commands if c["commandType"] == "prepareToAspirate"] + self.assertEqual(len(prepares), 1) + self.assertEqual(prepares[0]["params"], {"pipetteId": head.pipette_id}) + finally: + asyncio.run(flex.stop()) + + def test_prepare_to_aspirate_without_a_tip_raises_before_the_wire(self): + flex, transport, head, _rack, _plate = self._bench() + try: + n_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.prepare_to_aspirate()) + self.assertEqual(len(transport.commands), n_before) + finally: + asyncio.run(flex.stop()) + + +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_tip_presence_is_read_as_a_run_command_never_over_the_instruments_route(self): + """The tip check must not touch ``GET /instruments`` once a run is live. + + That REST read re-caches the attached pipettes, which clears the run's + record of the attached tip, so the next pipetting command fails with + "cannot perform PREPARE_ASPIRATE without a tip attached". Reproduced + against the Opentrons robot-server simulator. + """ + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + reads_after_setup = transport.instrument_reads + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.drop_tips(rack, column=0)) + + self.assertEqual(transport.instrument_reads, reads_after_setup) + self.assertIn("getTipPresence", [c["commandType"] for c in transport.commands]) + finally: + asyncio.run(flex.stop()) + + def test_simulated_stuck_tip_after_drop_logs_warning(self): + flex, _transport, head = _flex_head8(simulate_stuck_tip=True) + try: + 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(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) + + asyncio.run(head.pick_up_tips(rack.get_item("A1"))) + + pickup_cmds = [c for c in transport.commands if c["commandType"] == "pickUpTip"] + self.assertEqual(len(pickup_cmds), 1) + self.assertEqual(pickup_cmds[0]["params"]["wellName"], "A1") + self.assertIsNotNone(head.get_mounted_tips()[0]) + self.assertEqual(len(head.get_mounted_tips()), 1) + + asyncio.run(head.aspirate(target_well, volume=10)) + + cmd_types = [c["commandType"] for c in transport.commands] + aspirate_indices = [i for i, t in enumerate(cmd_types) if t == "aspirate"] + self.assertEqual(len(aspirate_indices), 1) + self.assertNotIn("prepareToAspirate", cmd_types, "the robot primes for a named well") + self.assertEqual(transport.commands[aspirate_indices[0]]["params"]["wellName"], "B3") + + # Exactly 1 Well tracked -- every other well on the plate is untouched. + 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"] + self.assertEqual(len(aspirate_cmds), 1) + self.assertEqual(aspirate_cmds[0]["params"]["wellName"], "A1") + self.assertNotIn("prepareToAspirate", cmd_types, "the robot primes for a named well") + + 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) + + +class TrashAddressableAreaTests(unittest.TestCase): + """A Flex takes a movable trash in any of eight slots, not just A3.""" + + def test_the_drop_follows_the_trash_to_another_slot(self): + flex, transport, head = _flex_head8() + try: + rack = flex_96_tiprack_50ul(name="rack") + flex.deck.assign_child_at_slot(rack, "C1") + trash = flex.deck.get_trash_area() + flex.deck.unassign_child_at_slot("A3") + flex.deck.assign_child_at_slot(trash, "B3") + + asyncio.run(head.pick_up_tips(rack, column=0)) + asyncio.run(head.discard_tips(trash)) + + move_cmd = next( + c for c in transport.commands if c["commandType"] == "moveToAddressableAreaForDropTip" + ) + self.assertEqual(move_cmd["params"]["addressableAreaName"], "movableTrashB3") + finally: + asyncio.run(flex.stop()) + + def test_a_trash_outside_a_trash_slot_is_refused(self): + flex, _transport, head = _flex_head8() + try: + trash = flex.deck.get_trash_area() + flex.deck.unassign_child_at_slot("A3") + flex.deck.assign_child_at_slot(trash, "C2") + with self.assertRaises(OpentronsError): + head._trash_addressable_area(trash) + finally: + asyncio.run(flex.stop()) + + +class DeclaredIdentityTests(unittest.TestCase): + """How a PLR resource resolves to an Opentrons load name.""" + + def test_a_declared_load_name_is_what_the_resource_loads_by(self): + plate = cor_96_wellplate_360uL_Fb(name="anything at all") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + load_name, version = OpentronsFlex._ot_declared_identity(plate) + self.assertEqual(load_name, "corning_96_wellplate_360ul_flat") + self.assertEqual(version, 1) + + def test_the_revision_is_the_resource_s_to_declare_too(self): + # Revision 1 is the only one every robot holds, so a caller who wants a + # later one (for its gripper grip height) has to say so. + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + plate.ot_version = 2 # type: ignore[attr-defined] + self.assertEqual( + OpentronsFlex._ot_declared_identity(plate), ("corning_96_wellplate_360ul_flat", 2) + ) + + def test_a_declared_name_is_passed_through_rather_than_checked_against_a_list(self): + # The robot resolves against its own shipped definitions AND a lab's own + # uploads, so any list here would be wrong for somebody's robot. + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.ot_load_name = "a_lab_uploaded_this_one_themselves" # type: ignore[attr-defined] + load_name, version = OpentronsFlex._ot_declared_identity(plate) + self.assertEqual(load_name, "a_lab_uploaded_this_one_themselves") + self.assertEqual(version, 1) + + def test_a_resource_declaring_nothing_asks_for_a_synthesized_definition(self): + plate = cor_96_wellplate_360uL_Fb(name="plate") + with self.assertRaises(OpentronsError): + OpentronsFlex._ot_declared_identity(plate) + + def test_the_model_never_decides_the_load_name(self): + # A PLR model is not an Opentrons load name, and sending one that merely + # looks like a load name would load the wrong labware. Declaring is the rule. + plate = cor_96_wellplate_360uL_Fb(name="plate") + plate.model = "corning_96_wellplate_360ul_flat" + with self.assertRaises(OpentronsError): + OpentronsFlex._ot_declared_identity(plate) + + def test_the_instance_name_never_decides_the_load_name(self): + # It is a user-chosen label, so naming a plate after a tip rack must not + # load a tip rack. + plate = cor_96_wellplate_360uL_Fb(name="flex_96_tiprack_50ul") + plate.model = None + with self.assertRaises(OpentronsError): + OpentronsFlex._ot_declared_identity(plate) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/flex_wire.py b/pylabrobot/opentrons/flex_wire.py new file mode 100644 index 00000000000..28804a9ab85 --- /dev/null +++ b/pylabrobot/opentrons/flex_wire.py @@ -0,0 +1,123 @@ +"""Wire-level facts shared by the Flex device, its heads and its gripper. + +Small pieces that more than one of :mod:`~pylabrobot.opentrons.flex`, +:mod:`~pylabrobot.opentrons.flex_head` and +:mod:`~pylabrobot.opentrons.flex_gripper` needs, and that belong to none of +them: how a deck slot is spelled on the wire, which axes the robot addresses by +name, the software-version gate on the robot/* command family, and the notice +every not-yet-hardware-verified op logs. They live here so the always-present +device module does not have to reach into the optional gripper module (or the +heads) for them. +""" + +from typing import Dict, FrozenSet, List, Optional, Tuple + +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import OFFLINE_API_VERSION + +# Shared by the heads and the gripper so the notice reads identically +# everywhere; each module logs it through its own logger. +UNTESTED_HARDWARE_WARNING = ( + "%s.%s is coded but NOT YET VERIFIED on real Opentrons Flex hardware -- " + "tested only against ChatterboxTransport/simulated transport. Verify behavior " + "on real hardware before relying on it in a production protocol." +) + +# The robot-server's DeckSlotName covers only the A1-D3 grid; the column-4 +# staging slots are addressable areas and ride a different location key. +STAGING_SLOT_NAMES: FrozenSet[str] = frozenset({"A4", "B4", "C4", "D4"}) + +# Every axis the robot addresses by name: gantry, each mount's z and plunger, +# the gripper's z and jaw (extensionZ/extensionJaw), the 96-channel head's cam. +ROBOT_AXES: FrozenSet[str] = frozenset( + { + "x", + "y", + "leftZ", + "rightZ", + "leftPlunger", + "rightPlunger", + "extensionZ", + "extensionJaw", + "axis96ChannelCam", + } +) + +# The robot/* direct-motion command family (moveTo, moveAxesTo, +# moveAxesRelative, openGripperJaw, closeGripperJaw) landed in robot-server 8.2.0. +_ROBOT_COMMANDS_MIN_VERSION = "8.2.0" + + +def slot_wire_location(slot: str) -> Dict[str, str]: + """The ``loadLabware``/``moveLabware`` location for a Flex slot name.""" + if slot in STAGING_SLOT_NAMES: + return {"addressableAreaName": slot} + return {"slotName": slot} + + +def _version_tuple(version: str) -> Tuple[int, ...]: + """Parse a dotted robot-software version into comparable integers. + + Comparing these as strings puts "10.0.0" below "7.1.0", so the version gate + compares numerically. Each dotted segment contributes its leading integer + ("0-beta" -> 0); a segment with no leading digit stops the parse, and short + results pad with zeros so "8.2" compares equal to "8.2.0". + + Raises: + ValueError: If the version has no leading numeric segment at all. + """ + parts: List[int] = [] + for part in version.split("."): + digits = "" + for char in part: + if not char.isdigit(): + break + digits += char + if digits == "": + break + parts.append(int(digits)) + if not parts: + raise ValueError(f"unparseable version string: {version!r}") + while len(parts) < 3: + parts.append(0) + return tuple(parts) + + +def _require_robot_commands(command: str, api_version: Optional[str]) -> None: + """Raise unless the robot's software supports the robot/* command family. + + ``api_version`` is the ``GET /health`` ``api_version`` the owning robot + stored at setup (``flex.api_version``). Released builds report a plain + numeric version and are gated against ``_ROBOT_COMMANDS_MIN_VERSION``. + + Two exemptions, both narrow. An UNTAGGED build reports "0.0.0.dev*" (the + version an unreleased source checkout carries, including the simulated + robot-server) and runs current code, so it passes -- but a build cut off a + real tag reports that tag plus a dev suffix ("8.1.0.dev5"), which is gated + on the tag like any release. ``ChatterboxTransport``'s offline "dry-run" + sentinel passes too; it reaches no robot at all. Any other unparseable + version raises rather than silently passing the gate. + """ + if api_version is None: + raise OpentronsError( + "Robot version unknown", + f"{command} requires setup() to have run, to read the robot's version.", + ) + if api_version == OFFLINE_API_VERSION: + return + try: + version = _version_tuple(api_version) + except ValueError: + raise OpentronsError( + "Robot version unrecognized", + f"{command} is gated on robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, but this " + f"robot reports the unrecognized version {api_version!r}.", + ) from None + if version == (0, 0, 0) and "dev" in api_version: + return + if version < _version_tuple(_ROBOT_COMMANDS_MIN_VERSION): + raise OpentronsError( + "Robot software too old", + f"{command} requires Opentrons robot software {_ROBOT_COMMANDS_MIN_VERSION} or newer, " + f"but this robot reports {api_version}.", + ) diff --git a/pylabrobot/opentrons/labware_definitions.py b/pylabrobot/opentrons/labware_definitions.py new file mode 100644 index 00000000000..fb2b780083c --- /dev/null +++ b/pylabrobot/opentrons/labware_definitions.py @@ -0,0 +1,465 @@ +"""Builders for custom Opentrons labware definitions from PLR resource geometry. + +Labware without an official Opentrons definition (third-party plates, troughs, +lids) cannot ``loadLabware`` by name. The pure functions here (no I/O) build a +robot-server labware definition dict from the PLR resource's own geometry; +:class:`~pylabrobot.opentrons.flex.OpentronsFlex` uploads the dict to +``POST /runs/{run_id}/labware_definitions`` and then loads the labware by the +uploaded definition's ``namespace``/``loadName``/``version``. + +Frame note: PLR and Opentrons schema-2 definitions both anchor labware at the +front-left-bottom corner of the slot (every shipped Opentrons definition +carries ``cornerOffsetFromSlot`` of zero), so PLR positions carry over +directly -- for UNROTATED labware. Schema 2 has no way to express a rotated +labware, so the plate and tip-rack builders refuse one rather than upload a +definition whose wells and footprint disagree. + +Height note: an Opentrons well's ``z`` is the CAVITY floor ("center-bottom of +well" in the schema), while a PLR well's/container's own origin is its OUTER +bottom. The two differ by the labware's wall thickness +(``Container.material_z_thickness``), so the pipettable builders add it -- +without that, the default 1 mm bottom clearance aims the tip INTO the plastic. +(The movable stub is exempt: its one fake well is somewhere to grip, not to +pipette, which is why pipetting ops refuse stub-loaded labware outright.) + +The builders are pure: they raise ``ValueError`` for geometry no Opentrons +definition can describe, and :class:`~pylabrobot.opentrons.flex.OpentronsFlex` +turns that into an ``OpentronsError`` before any wire command. +""" + +import hashlib +import re +from typing import Optional, Tuple, cast + +from pylabrobot.resources import ( + Container, + Coordinate, + CrossSectionType, + Plate, + Resource, + Tip, + TipRack, + TipSpot, + Trough, + TroughBottomType, + Well, + WellBottomType, +) +from pylabrobot.utils import reshape_2d + +_NAMESPACE = "pylabrobot" +_VERSION = 1 +_SCHEMA_VERSION = 2 + +_WELL_BOTTOM_SHAPES = { + WellBottomType.FLAT: "flat", + WellBottomType.U: "u", + WellBottomType.V: "v", + WellBottomType.UNKNOWN: "flat", +} + +# The robot-server validates wellBottomShape as Literal["flat", "u", "v"], +# so the enum's own "U"/"V"/"unknown" values cannot be passed through. +_TROUGH_BOTTOM_SHAPES = { + TroughBottomType.FLAT: "flat", + TroughBottomType.U: "u", + TroughBottomType.V: "v", + TroughBottomType.UNKNOWN: "flat", +} + + +def _definition_load_name(resource: Resource) -> str: + """Sanitized resource name plus a short digest of the raw name. + + Opentrons load names must match ``^[a-z0-9._]+$`` while PLR names are + unrestricted, so distinct names can sanitize identically; the digest keeps + their definitions from silently sharing one ``definitionUri``. + """ + sanitized = re.sub(r"[^a-z0-9._]", "_", resource.name.lower()) + digest = hashlib.sha1(resource.name.encode()).hexdigest()[:6] + return f"{sanitized}_{digest}" + + +def _format_from_grid(num_items_x: int, num_items_y: int) -> str: + """The SBS formats the robot-server recognizes; anything else is irregular.""" + if (num_items_x, num_items_y) == (12, 8): + return "96Standard" + if (num_items_x, num_items_y) == (24, 16): + return "384Standard" + return "irregular" + + +def container_footprint(container: Container) -> Tuple[float, float]: + """The container's x/y bounding box in the deck frame, as the robot sees it. + + This is the OUTER footprint, an upper bound on the cavity: PLR containers + carry their overall size, not their cavity's, so a trough's real cavity is + narrower than what this returns by the wall thickness on each side. Ops that + reason about whether a nozzle array fits, and the uploaded definition's own + well, both read this so they can never disagree. + + Rotation-aware: a rotated container (or one under a rotated parent) presents + its bounding box to the robot, which has no notion of PLR's rotation, so + this must be read rather than the container's own + ``get_size_x``/``get_size_y``. + """ + return container.get_absolute_size_x(), container.get_absolute_size_y() + + +def _cavity_floor_z(container: Container) -> float: + """The height of the cavity floor above the container's own bottom. + + ``Container.material_z_thickness`` is optional in PLR and raises when it was + never declared. There is no safe default: falling back to zero would put the + Opentrons well floor at the labware's outer base and aim every default + aspirate a wall thickness INTO the plastic, so an undeclared thickness is + refused instead. + """ + try: + return container.material_z_thickness + except NotImplementedError as e: + raise ValueError( + f"'{container.name}' does not declare material_z_thickness, so the height of its " + "cavity floor above its base is unknown. An Opentrons definition anchors liquid " + "ops at that floor, so building one would aim them at the labware's outer base " + "instead. Give the resource a material_z_thickness, or an official Opentrons " + "load name." + ) from e + + +def _require_unrotated(resource: Resource) -> None: + """Refuse a rotated resource: an Opentrons definition cannot describe one. + + A schema-2 definition places its front-left-bottom corner at the slot's, and + positions every well from that corner, with no rotation anywhere. PLR + rotates about the resource's own origin without recentering, so baking the + rotation into the well coordinates would still leave the labware body and + the slot disagreeing. + """ + rotation = resource.get_absolute_rotation() + if (rotation.x, rotation.y, rotation.z) != (0, 0, 0): + raise ValueError( + f"'{resource.name}' has absolute rotation {rotation}. An Opentrons labware " + "definition anchors wells to the slot's front-left corner and cannot express a " + "rotated labware. Assign it unrotated, or give it an official Opentrons load name." + ) + + +def _container_bottom_shape(container: Container) -> str: + """The schema's ``wellBottomShape`` for any container the builders can see. + + Only troughs and wells carry a bottom shape; a plain ``Container``, + ``PetriDish``, ``Trash`` or ``Tube`` has none, so they get the schema's + safest default. + """ + if isinstance(container, Trough): + return _TROUGH_BOTTOM_SHAPES[container.bottom_type] + if isinstance(container, Well): + return _WELL_BOTTOM_SHAPES[container.bottom_type] + return "flat" + + +def _well_shape(well: Well) -> dict: + if well.cross_section_type == CrossSectionType.RECTANGLE: + return { + "shape": "rectangular", + "xDimension": well.get_size_x(), + "yDimension": well.get_size_y(), + } + return {"shape": "circular", "diameter": well.get_size_x()} + + +def _plate_well(well: Well) -> dict: + """One ``wells`` entry: the well's cavity floor, depth and footprint. + + ``depth`` is the well's own size_z, which in PLR already spans cavity floor + to rim, so ``z + depth`` lands on the plate's top the way every shipped + Opentrons plate definition does. + """ + _require_unrotated(well) + location = cast(Coordinate, well.location) + return { + "depth": well.get_size_z(), + "x": location.x + well.get_size_x() / 2, + "y": location.y + well.get_size_y() / 2, + "z": location.z + _cavity_floor_z(well), + "totalLiquidVolume": well.max_volume, + **_well_shape(well), + } + + +def build_plate_definition(plate: Plate, grip_distance_from_top: Optional[float] = None) -> dict: + """Build a robot-server wellPlate definition from a PLR plate's geometry. + + Wells carry their real depth, volume, cross-section (circular or + rectangular) and bottom shape so well-referencing commands (``touchTip``, + ``liquidProbe``) get the true geometry. Wells are keyed by their PLR child + identifier ("A1" style), matching the ``wellName`` the pipetting commands + send. Each well's ``z`` is its CAVITY floor -- its PLR origin plus its wall + thickness -- which puts the well top (``z + depth``) at the plate's real + rim. ``gripHeightFromLabwareBottom`` is included only when + ``grip_distance_from_top`` is given; without it the robot-server grips at + its default mid-height. + + Raises: + ValueError: If the plate (or a well) is rotated, or a well does not + declare the wall thickness its cavity floor is measured from. + """ + _require_unrotated(plate) + wells = plate.get_all_items() + well_names = [plate.get_child_identifier(well) for well in wells] + definition: dict = { + "schemaVersion": _SCHEMA_VERSION, + "version": _VERSION, + "namespace": _NAMESPACE, + "metadata": { + "displayName": plate.name, + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + }, + "brand": {"brand": "unknown"}, + "parameters": { + "format": _format_from_grid(plate.num_items_x, plate.num_items_y), + "isTiprack": False, + "loadName": _definition_load_name(plate), + "isMagneticModuleCompatible": False, + }, + "ordering": reshape_2d(well_names, (plate.num_items_x, plate.num_items_y)), + "cornerOffsetFromSlot": {"x": 0, "y": 0, "z": 0}, + "dimensions": { + "xDimension": plate.get_absolute_size_x(), + "yDimension": plate.get_absolute_size_y(), + "zDimension": plate.get_absolute_size_z(), + }, + "wells": {plate.get_child_identifier(well): _plate_well(well) for well in wells}, + "groups": [ + { + "wells": well_names, + "metadata": {"wellBottomShape": _WELL_BOTTOM_SHAPES[wells[0].bottom_type]}, + } + ], + } + if grip_distance_from_top is not None: + definition["gripHeightFromLabwareBottom"] = max( + 0.0, plate.get_absolute_size_z() - grip_distance_from_top + ) + return definition + + +def _tip_rack_well(tip_rack: TipRack, spot: TipSpot, tip: Tip) -> dict: + """One ``wells`` entry describing the tip seated in ``spot``. + + A ``TipSpot`` has no height of its own (PLR leaves its size_z at zero), so + the depth comes from the prototype tip the rack's ``tipLength`` already + reports; using the spot's box would send a zero-depth well and collapse the + pickup target onto the rack's base. + """ + _require_unrotated(spot) + location = cast(Coordinate, spot.location) + if location.z < 0: + raise ValueError( + f"'{tip_rack.name}' seats its tips {-location.z} mm BELOW its own base (spot " + f"'{spot.name}'), which an Opentrons definition cannot express -- its well z must " + "be non-negative, and the robot-server rejects the upload outright. Re-anchor the " + "rack so its tip seats sit at or above its base, or give it an official Opentrons " + "load name." + ) + return { + "depth": tip.total_tip_length, + "x": location.x + spot.get_size_x() / 2, + "y": location.y + spot.get_size_y() / 2, + "z": location.z, + # Tip-rack wells stay circular regardless of the PLR cross-section: + # the engine rejects non-circular tip-rack wells (LabwareIsNotTipRackError). + "shape": "circular", + "diameter": spot.get_size_x(), + "totalLiquidVolume": tip.maximal_volume, + } + + +def build_tip_rack_definition( + tip_rack: TipRack, grip_distance_from_top: Optional[float] = None +) -> dict: + """Build a robot-server tipRack definition from a PLR tip rack's geometry. + + Tip length and overlap come from the rack's A1 prototype tip, so the robot + computes the same pickup z the PLR tip model implies. A tip-rack well + describes the seated TIP, not the spot's own box: ``z`` is where the tip + end sits and ``depth`` is the tip's length, so ``z + depth`` is the tip's + top -- the height a ``pickUpTip`` (top origin) descends to. + + Raises: + ValueError: If the rack (or a spot) is rotated, or its tips reach below + the rack's own base, which schema 2 cannot express. + """ + _require_unrotated(tip_rack) + tip = tip_rack.get_item("A1").make_tip() + spot_names = [tip_rack.get_child_identifier(spot) for spot in tip_rack.get_all_items()] + definition: dict = { + "schemaVersion": _SCHEMA_VERSION, + "version": _VERSION, + "namespace": _NAMESPACE, + "metadata": { + "displayName": tip_rack.name, + "displayCategory": "tipRack", + "displayVolumeUnits": "µL", + }, + "brand": {"brand": "unknown"}, + "parameters": { + "format": _format_from_grid(tip_rack.num_items_x, tip_rack.num_items_y), + "isTiprack": True, + "tipLength": tip.total_tip_length, + "tipOverlap": tip.fitting_depth, + "loadName": _definition_load_name(tip_rack), + "isMagneticModuleCompatible": False, + }, + "ordering": reshape_2d(spot_names, (tip_rack.num_items_x, tip_rack.num_items_y)), + "cornerOffsetFromSlot": {"x": 0, "y": 0, "z": 0}, + "dimensions": { + "xDimension": tip_rack.get_absolute_size_x(), + "yDimension": tip_rack.get_absolute_size_y(), + "zDimension": tip_rack.get_absolute_size_z(), + }, + "wells": { + tip_rack.get_child_identifier(spot): _tip_rack_well(tip_rack, spot, tip) + for spot in tip_rack.get_all_items() + }, + "groups": [ + { + "wells": spot_names, + "metadata": { + "displayName": None, + "displayCategory": "tipRack", + "wellBottomShape": "flat", # required even for tip racks + }, + } + ], + } + if grip_distance_from_top is not None: + definition["gripHeightFromLabwareBottom"] = max( + 0.0, tip_rack.get_absolute_size_z() - grip_distance_from_top + ) + return definition + + +def build_container_definition( + container: Container, grip_distance_from_top: Optional[float] = None +) -> dict: + """Build a robot-server reservoir definition from a PLR container's geometry. + + A container (e.g. a trough) is a single cavity, so the definition has one + well "A1" whose rectangular footprint spans the whole container, with depth + and volume from the container's geometry. The ``centerMultichannelOnWells`` + quirk matches every shipped Opentrons 1-well reservoir: the engine centers + a multi-channel nozzle array on the cavity itself, so ops send no manual + centering offsets. + + The cavity floor sits the container's wall thickness above its base, and the + cavity depth shrinks by the same amount, so the well's top stays at the + container's real rim (``z + depth == zDimension``, as on every shipped + Opentrons reservoir). The well's x/y footprint is the container's OUTER one + (see ``container_footprint``): PLR carries no cavity x/y to narrow it with. + + Raises: + ValueError: If the container does not declare the wall thickness its + cavity floor is measured from. + """ + size_x, size_y = container_footprint(container) + size_z = container.get_absolute_size_z() + floor_z = _cavity_floor_z(container) + definition: dict = { + "schemaVersion": _SCHEMA_VERSION, + "version": _VERSION, + "namespace": _NAMESPACE, + "metadata": { + "displayName": container.name, + "displayCategory": "reservoir", + "displayVolumeUnits": "µL", + }, + "brand": {"brand": "unknown"}, + "parameters": { + "format": "irregular", + "quirks": ["centerMultichannelOnWells"], + "isTiprack": False, + "loadName": _definition_load_name(container), + "isMagneticModuleCompatible": False, + }, + "ordering": [["A1"]], + "cornerOffsetFromSlot": {"x": 0, "y": 0, "z": 0}, + "dimensions": {"xDimension": size_x, "yDimension": size_y, "zDimension": size_z}, + "wells": { + "A1": { + "depth": size_z - floor_z, + "x": size_x / 2, + "y": size_y / 2, + "z": floor_z, + "shape": "rectangular", + "xDimension": size_x, + "yDimension": size_y, + "totalLiquidVolume": container.max_volume, + } + }, + "groups": [ + {"wells": ["A1"], "metadata": {"wellBottomShape": _container_bottom_shape(container)}} + ], + } + if grip_distance_from_top is not None: + definition["gripHeightFromLabwareBottom"] = max(0.0, size_z - grip_distance_from_top) + return definition + + +def build_movable_labware_definition( + resource: Resource, grip_distance_from_top: Optional[float] = None +) -> dict: + """Build a minimal single-well stub definition for gripper moves of any resource. + + The stub is not pipettable (one fake well, zero depth and volume); it exists + so the robot-server can gripper-move labware it has no real definition for. + ``gripHeightFromLabwareBottom`` is included only when + ``grip_distance_from_top`` is given; without it the robot-server grips at + its default mid-height. + """ + size_x = resource.get_absolute_size_x() + size_y = resource.get_absolute_size_y() + size_z = resource.get_absolute_size_z() + definition: dict = { + "schemaVersion": _SCHEMA_VERSION, + "version": _VERSION, + "namespace": _NAMESPACE, + "metadata": { + "displayName": resource.name, + "displayCategory": "wellPlate", + "displayVolumeUnits": "µL", + }, + "brand": {"brand": "unknown"}, + "parameters": { + "format": "irregular", + "isTiprack": False, + "loadName": _definition_load_name(resource), + "isMagneticModuleCompatible": False, + }, + "ordering": [["A1"]], + "cornerOffsetFromSlot": {"x": 0, "y": 0, "z": 0}, + "dimensions": {"xDimension": size_x, "yDimension": size_y, "zDimension": size_z}, + "wells": { + "A1": { + "depth": 0, + "x": size_x / 2, + "y": size_y / 2, + "z": 0, + "shape": "circular", + "diameter": 5, + "totalLiquidVolume": 0, + } + }, + "groups": [{"wells": ["A1"], "metadata": {"wellBottomShape": "flat"}}], + "gripperOffsets": { + "default": { + "pickUpOffset": {"x": 0, "y": 0, "z": 0}, + "dropOffset": {"x": 0, "y": 0, "z": 0}, + } + }, + } + if grip_distance_from_top is not None: + definition["gripHeightFromLabwareBottom"] = max(0.0, size_z - grip_distance_from_top) + return definition diff --git a/pylabrobot/opentrons/labware_definitions_tests.py b/pylabrobot/opentrons/labware_definitions_tests.py new file mode 100644 index 00000000000..3c38fb50aa5 --- /dev/null +++ b/pylabrobot/opentrons/labware_definitions_tests.py @@ -0,0 +1,926 @@ +"""Tests for custom Opentrons labware definition building and uploading. + +Builder-level tests pin the definition content produced from PLR geometry +(dimensions, well positions and shapes, the shared front-left slot anchoring, +grip height). Flex-level tests drive ``OpentronsFlex._ensure_labware_loaded`` +with an injected ``ChatterboxTransport`` and assert labware without an +official Opentrons definition is uploaded and then loaded by the uploaded +definition's namespace/loadName/version, that the run-scoped caches reset +with the run, and that official-name labware keeps loading with zero uploads. +""" + +import asyncio +import unittest +from typing import Any, Dict, Optional, Tuple + +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.flex_head import FlexHead8 +from pylabrobot.opentrons.labware_definitions import ( + build_container_definition, + build_movable_labware_definition, + build_plate_definition, + build_tip_rack_definition, + container_footprint, +) +from pylabrobot.opentrons.robot import OpentronsError +from pylabrobot.opentrons.transport import ChatterboxTransport +from pylabrobot.resources import ( + Container, + CrossSectionType, + Plate, + Resource, + ResourceHolder, + TipRack, + TipSpot, + Trough, + TroughBottomType, + TubeRack, + Well, + WellBottomType, +) +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.corning import cor_96_wellplate_360uL_Fb +from pylabrobot.resources.hamilton import hamilton_1_trough_60mL_Vb +from pylabrobot.resources.opentrons import opentrons_96_filtertiprack_200ul +from pylabrobot.resources.opentrons.flex_deck import FlexDeck +from pylabrobot.resources.opentrons.flex_tip_racks import flex_96_tiprack_50ul +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.utils import create_ordered_items_2d + + +def _plate( + name: str = "Black Plate-1", + num_items_x: int = 2, + num_items_y: int = 2, + cross_section_type: CrossSectionType = CrossSectionType.CIRCLE, + bottom_type: WellBottomType = WellBottomType.UNKNOWN, + material_z_thickness: Optional[float] = 0.5, +) -> Plate: + """A plate with hand-picked geometry so expected numbers are exact. + + ``material_z_thickness`` is the wall between a well's own bottom and the + cavity floor liquid ops are anchored at; ``None`` builds the wells without + one, which is the shape the builder refuses. + """ + return Plate( + name=name, + size_x=127.0, + size_y=80.0, + size_z=14.0, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=num_items_x, + num_items_y=num_items_y, + dx=10.0, + dy=8.0, + dz=1.0, + item_dx=9.0, + item_dy=9.0, + size_x=6.0, + size_y=6.0, + size_z=10.0, + material_z_thickness=material_z_thickness, + max_volume=360.0, + cross_section_type=cross_section_type, + bottom_type=bottom_type, + ), + ) + + +def _tip_rack( + name: str = "hamilton tips 300", + num_items_x: int = 2, + num_items_y: int = 2, + dz: float = 0.0, +) -> TipRack: + """A tip rack with hand-picked geometry and a pinned prototype tip. + + ``dz`` is where the seated tips' ends sit relative to the rack's own base; + a negative one models the Hamilton-style racks whose tips hang below it. + """ + + def make_tip(name: str) -> Tip: + return Tip( + has_filter=False, + total_tip_length=50.0, + maximal_volume=200.0, + fitting_depth=8.0, + name=name, + ) + + return TipRack( + name=name, + size_x=120.0, + size_y=82.0, + size_z=90.0, + ordered_items=create_ordered_items_2d( + TipSpot, + num_items_x=num_items_x, + num_items_y=num_items_y, + dx=10.0, + dy=8.0, + dz=dz, + item_dx=9.0, + item_dy=9.0, + size_x=5.0, + size_y=5.0, + make_tip=make_tip, + ), + ) + + +def _trough( + name: str = "hamilton trough", + material_z_thickness: Optional[float] = 1.5, + bottom_type: TroughBottomType = TroughBottomType.UNKNOWN, +) -> Trough: + """A single-cavity trough; ``material_z_thickness`` is its floor thickness.""" + return Trough( + name=name, + size_x=120.0, + size_y=80.0, + size_z=40.0, + material_z_thickness=material_z_thickness, + bottom_type=bottom_type, + max_volume=290000.0, + ) + + +def _tube_rack(name: str = "tube rack") -> TubeRack: + """A 12x8 tube rack: deck-assignable and column-shaped, but not pipettable. + + The rack's holders are not wells, so no well-bearing definition can be + built from it -- the mainstream labware type that reaches the unbuildable + path through a pipetting op. + """ + return TubeRack( + name=name, + size_x=127.0, + size_y=86.0, + size_z=45.0, + ordered_items=create_ordered_items_2d( + ResourceHolder, + num_items_x=12, + num_items_y=8, + dx=10.0, + dy=8.0, + dz=0.0, + item_dx=9.0, + item_dy=9.0, + size_x=8.0, + size_y=8.0, + size_z=40.0, + ), + ) + + +class TestDefinitionLoadNames(unittest.TestCase): + """Load names are the sanitized PLR name plus a digest of the raw name, so + distinct names that sanitize identically never share a definitionUri.""" + + def test_load_name_is_sanitized_name_plus_digest(self): + definition = build_plate_definition(_plate()) + self.assertEqual(definition["parameters"]["loadName"], "black_plate_1_e2a464") + + def test_colliding_sanitized_names_get_distinct_load_names(self): + a = build_plate_definition(_plate(name="My Plate")) + b = build_plate_definition(_plate(name="my plate")) + self.assertEqual(a["parameters"]["loadName"], "my_plate_3f2f85") + self.assertEqual(b["parameters"]["loadName"], "my_plate_1aa3c7") + self.assertNotEqual(a["parameters"]["loadName"], b["parameters"]["loadName"]) + for definition in (a, b): + self.assertRegex(definition["parameters"]["loadName"], r"^[a-z0-9._]+$") + + +class TestBuildPlateDefinition(unittest.TestCase): + """build_plate_definition maps PLR plate geometry into a wellPlate definition.""" + + def test_identity_and_dimensions(self): + definition = build_plate_definition(_plate()) + self.assertEqual(definition["namespace"], "pylabrobot") + self.assertEqual(definition["version"], 1) + self.assertEqual(definition["schemaVersion"], 2) + self.assertEqual(definition["metadata"]["displayCategory"], "wellPlate") + self.assertEqual(definition["metadata"]["displayName"], "Black Plate-1") + self.assertEqual(definition["parameters"]["loadName"], "black_plate_1_e2a464") + self.assertFalse(definition["parameters"]["isTiprack"]) + self.assertEqual( + definition["dimensions"], + {"xDimension": 127.0, "yDimension": 80.0, "zDimension": 14.0}, + ) + + def test_ordering_is_column_major(self): + definition = build_plate_definition(_plate()) + self.assertEqual(definition["ordering"], [["A1", "B1"], ["A2", "B2"]]) + + def test_corner_offset_is_zero_front_left_anchor(self): + # PLR and Opentrons schema-2 definitions both anchor labware at the + # slot's front-left-bottom corner, so no frame conversion applies. + definition = build_plate_definition(_plate()) + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) + + def test_well_geometry_carries_depth_volume_and_centers(self): + definition = build_plate_definition(_plate()) + # A1 (back-left well): origin (10, 17, 1), 6 mm square, so center (13, 20). + # Its z is the CAVITY floor: the well's own bottom (1.0) plus the 0.5 mm + # of plastic beneath it, matching how Opentrons reads a well's z. + self.assertEqual( + definition["wells"]["A1"], + { + "depth": 10.0, + "x": 13.0, + "y": 20.0, + "z": 1.5, + "shape": "circular", + "diameter": 6.0, + "totalLiquidVolume": 360.0, + }, + ) + # B1 is one 9 mm pitch toward the front: center y = 8 + 3 = 11. + self.assertEqual(definition["wells"]["B1"]["y"], 11.0) + self.assertEqual(definition["groups"][0]["wells"], ["A1", "B1", "A2", "B2"]) + + def test_well_z_is_the_cavity_floor_not_the_wells_outer_bottom(self): + # The default liquid position is 1 mm above a well's z, so a z at the + # well's outer bottom aims it into the plastic. PLR's corning plate: A1 + # bottom 3.03 + 0.5 mm wall; Opentrons ships z = 3.55 for that plate. + definition = build_plate_definition(cor_96_wellplate_360uL_Fb(name="corning")) + self.assertAlmostEqual(definition["wells"]["A1"]["z"], 3.53) + self.assertAlmostEqual(definition["wells"]["A1"]["depth"], 10.67) + # z + depth is the real rim, which is what touchTip and liquidProbe ride. + self.assertAlmostEqual( + definition["wells"]["A1"]["z"] + definition["wells"]["A1"]["depth"], + definition["dimensions"]["zDimension"], + ) + + def test_plate_without_material_thickness_is_refused(self): + # No cavity floor to anchor liquid ops at, and no safe default: falling + # back to zero is what aims them at the plastic. + with self.assertRaises(ValueError) as caught: + build_plate_definition(_plate(material_z_thickness=None)) + self.assertIn("material_z_thickness", str(caught.exception)) + self.assertIn("Black Plate-1", str(caught.exception)) + + def test_rotated_plate_is_refused(self): + # An Opentrons definition positions wells from the slot's front-left + # corner and cannot express a rotation, so a rotated plate would upload + # unrotated well coordinates inside a rotated bounding box. + for angle in (90, 180): + plate = _plate() + plate.rotation = Rotation(z=angle) + with self.assertRaises(ValueError) as caught: + build_plate_definition(plate) + self.assertIn("rotation", str(caught.exception)) + + def test_plate_under_a_rotated_parent_is_refused(self): + holder = ResourceHolder(name="holder", size_x=130.0, size_y=90.0, size_z=1.0) + holder.rotation = Rotation(z=90) + plate = _plate() + holder.assign_child_resource(plate, location=Coordinate.zero()) + with self.assertRaises(ValueError): + build_plate_definition(plate) + + def test_rectangular_wells_carry_x_y_dimensions(self): + definition = build_plate_definition(_plate(cross_section_type=CrossSectionType.RECTANGLE)) + well = definition["wells"]["A1"] + self.assertEqual(well["shape"], "rectangular") + self.assertEqual(well["xDimension"], 6.0) + self.assertEqual(well["yDimension"], 6.0) + self.assertNotIn("diameter", well) + + def test_well_bottom_shape_maps_from_plr_bottom_type(self): + self.assertEqual( + build_plate_definition(_plate(bottom_type=WellBottomType.V))["groups"][0]["metadata"], + {"wellBottomShape": "v"}, + ) + self.assertEqual( + build_plate_definition(_plate(bottom_type=WellBottomType.U))["groups"][0]["metadata"], + {"wellBottomShape": "u"}, + ) + # UNKNOWN falls back to flat, the schema's safest default. + self.assertEqual( + build_plate_definition(_plate())["groups"][0]["metadata"], + {"wellBottomShape": "flat"}, + ) + + def test_format_derives_from_grid(self): + self.assertEqual(build_plate_definition(_plate())["parameters"]["format"], "irregular") + plate_96 = _plate(num_items_x=12, num_items_y=8) + self.assertEqual(build_plate_definition(plate_96)["parameters"]["format"], "96Standard") + plate_384 = _plate(num_items_x=24, num_items_y=16) + self.assertEqual(build_plate_definition(plate_384)["parameters"]["format"], "384Standard") + + def test_grip_height_from_grip_distance(self): + self.assertNotIn("gripHeightFromLabwareBottom", build_plate_definition(_plate())) + definition = build_plate_definition(_plate(), grip_distance_from_top=4.0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 10.0) # 14 - 4 + clamped = build_plate_definition(_plate(), grip_distance_from_top=20.0) + self.assertEqual(clamped["gripHeightFromLabwareBottom"], 0.0) + + +class TestBuildTipRackDefinition(unittest.TestCase): + """build_tip_rack_definition maps rack geometry and the prototype tip.""" + + def test_tip_parameters_come_from_prototype_tip(self): + definition = build_tip_rack_definition(_tip_rack()) + self.assertEqual(definition["metadata"]["displayCategory"], "tipRack") + self.assertEqual(definition["parameters"]["format"], "irregular") # 2x2 is not an SBS grid + self.assertTrue(definition["parameters"]["isTiprack"]) + self.assertEqual(definition["parameters"]["tipLength"], 50.0) + self.assertEqual(definition["parameters"]["tipOverlap"], 8.0) + self.assertEqual(definition["parameters"]["loadName"], "hamilton_tips_300_0558ff") + + def test_full_rack_format_is_96standard(self): + definition = build_tip_rack_definition(_tip_rack(num_items_x=12, num_items_y=8)) + self.assertEqual(definition["parameters"]["format"], "96Standard") + + def test_spot_geometry_and_zero_corner_offset(self): + definition = build_tip_rack_definition(_tip_rack()) + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) + self.assertEqual(definition["ordering"], [["A1", "B1"], ["A2", "B2"]]) + # A1 spot origin (10, 17, 0), 5 mm square: center (12.5, 19.5). The depth + # is the prototype tip's length: a TipSpot has no height of its own, and + # a pickUpTip descends to z + depth, the seated tip's top. + self.assertEqual( + definition["wells"]["A1"], + { + "depth": 50.0, + "x": 12.5, + "y": 19.5, + "z": 0.0, + "shape": "circular", + "diameter": 5.0, + "totalLiquidVolume": 200.0, + }, + ) + + def test_well_depth_reproduces_a_shipped_opentrons_rack(self): + # PLR builds this rack BY loading Opentrons' own definition, so the + # rebuilt one must carry that file's numbers back: z 5.39, depth 59.3, + # tipLength 59.3. + definition = build_tip_rack_definition(opentrons_96_filtertiprack_200ul(name="ot rack")) + self.assertAlmostEqual(definition["wells"]["A1"]["z"], 5.39) + self.assertAlmostEqual(definition["wells"]["A1"]["depth"], 59.3) + self.assertAlmostEqual(definition["parameters"]["tipLength"], 59.3) + + def test_rack_whose_tips_hang_below_its_base_is_refused(self): + # The robot-server's schema declares well z non-negative, so this uploads + # as a 422 per well; refuse it here, naming the rack and the spot. + with self.assertRaises(ValueError) as caught: + build_tip_rack_definition(_tip_rack(dz=-50.5)) + self.assertIn("hamilton tips 300", str(caught.exception)) + self.assertIn("50.5 mm BELOW", str(caught.exception)) + + def test_rotated_tip_rack_is_refused(self): + rack = _tip_rack() + rack.rotation = Rotation(z=90) + with self.assertRaises(ValueError): + build_tip_rack_definition(rack) + + def test_grip_height_from_grip_distance(self): + self.assertNotIn("gripHeightFromLabwareBottom", build_tip_rack_definition(_tip_rack())) + definition = build_tip_rack_definition(_tip_rack(), grip_distance_from_top=10.0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 80.0) # 90 - 10 + + +class TestBuildContainerDefinition(unittest.TestCase): + """build_container_definition maps a container to a single-cavity reservoir.""" + + def test_single_a1_cavity_spans_the_container(self): + definition = build_container_definition(_trough()) + self.assertEqual(definition["namespace"], "pylabrobot") + self.assertEqual(definition["metadata"]["displayCategory"], "reservoir") + self.assertEqual(definition["parameters"]["loadName"], "hamilton_trough_9544de") + self.assertEqual(definition["ordering"], [["A1"]]) + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) + self.assertEqual( + definition["dimensions"], + {"xDimension": 120.0, "yDimension": 80.0, "zDimension": 40.0}, + ) + # The cavity floor sits on the 1.5 mm of plastic under it, and the depth + # loses the same, so the well's top stays at the container's real rim. + self.assertEqual( + definition["wells"], + { + "A1": { + "depth": 38.5, + "x": 60.0, + "y": 40.0, + "z": 1.5, + "shape": "rectangular", + "xDimension": 120.0, + "yDimension": 80.0, + "totalLiquidVolume": 290000.0, + } + }, + ) + self.assertEqual(definition["groups"][0]["wells"], ["A1"]) + + def test_cavity_floor_sits_on_the_wall_thickness(self): + # Pinned against real labware: hamilton_1_trough_60mL_Vb is 65.5 mm tall + # with a 1.58 mm floor, and every shipped Opentrons reservoir likewise + # has z + depth == zDimension with a non-zero z. + definition = build_container_definition(hamilton_1_trough_60mL_Vb(name="trough")) + well = definition["wells"]["A1"] + self.assertAlmostEqual(well["z"], 1.58) + self.assertAlmostEqual(well["depth"], 63.92) + self.assertAlmostEqual(well["z"] + well["depth"], definition["dimensions"]["zDimension"]) + + def test_container_without_material_thickness_is_refused(self): + with self.assertRaises(ValueError) as caught: + build_container_definition(_trough(material_z_thickness=None)) + self.assertIn("material_z_thickness", str(caught.exception)) + self.assertIn("hamilton trough", str(caught.exception)) + + def test_well_bottom_shape_maps_from_the_troughs_bottom_type(self): + # The enum's own values are "U"/"V"/"unknown", which the robot-server's + # Literal["flat", "u", "v"] rejects, so the mapping must lower-case them. + for bottom_type, expected in ( + (TroughBottomType.V, "v"), + (TroughBottomType.U, "u"), + (TroughBottomType.FLAT, "flat"), + (TroughBottomType.UNKNOWN, "flat"), + ): + definition = build_container_definition(_trough(bottom_type=bottom_type)) + self.assertEqual(definition["groups"][0]["metadata"], {"wellBottomShape": expected}) + # A plain Container carries no bottom shape at all. + plain = Container(name="plain", size_x=10.0, size_y=10.0, size_z=10.0, material_z_thickness=1.0) + self.assertEqual( + build_container_definition(plain)["groups"][0]["metadata"], + {"wellBottomShape": "flat"}, + ) + + def test_rotated_cavity_uploads_the_shared_deck_frame_footprint(self): + # The uploaded rectangle and the ops' fit guard must read the same + # helper, or a rotated cavity is guarded on the wrong axis. + trough = _trough() + trough.rotation = Rotation(z=90) + definition = build_container_definition(trough) + cavity_x, cavity_y = container_footprint(trough) + self.assertEqual((cavity_x, cavity_y), (80.0, 120.0)) + self.assertEqual(definition["dimensions"]["xDimension"], cavity_x) + self.assertEqual(definition["dimensions"]["yDimension"], cavity_y) + self.assertEqual(definition["wells"]["A1"]["xDimension"], cavity_x) + self.assertEqual(definition["wells"]["A1"]["yDimension"], cavity_y) + + def test_center_multichannel_quirk_matches_shipped_reservoirs(self): + # Every shipped Opentrons 1-well reservoir carries this quirk; the engine + # centers a multi-channel nozzle array on the cavity because of it, so + # container ops send no manual centering offsets. + definition = build_container_definition(_trough()) + self.assertEqual(definition["parameters"]["quirks"], ["centerMultichannelOnWells"]) + + def test_grip_height_from_grip_distance(self): + self.assertNotIn("gripHeightFromLabwareBottom", build_container_definition(_trough())) + definition = build_container_definition(_trough(), grip_distance_from_top=10.0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 30.0) # 40 - 10 + + +class TestBuildMovableLabwareDefinition(unittest.TestCase): + """build_movable_labware_definition builds the minimal gripper-move stub.""" + + def test_stub_has_fake_well_and_grip_geometry(self): + resource = Resource(name="lid stack", size_x=100.0, size_y=90.0, size_z=20.0) + definition = build_movable_labware_definition(resource, grip_distance_from_top=5.0) + self.assertEqual(definition["namespace"], "pylabrobot") + self.assertEqual(definition["parameters"]["loadName"], "lid_stack_2196eb") + self.assertEqual(definition["ordering"], [["A1"]]) + self.assertEqual(definition["cornerOffsetFromSlot"], {"x": 0, "y": 0, "z": 0}) + self.assertEqual( + definition["wells"]["A1"], + { + "depth": 0, + "x": 50.0, + "y": 45.0, + "z": 0, + "shape": "circular", + "diameter": 5, + "totalLiquidVolume": 0, + }, + ) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 15.0) # 20 - 5 + self.assertEqual( + definition["gripperOffsets"], + { + "default": { + "pickUpOffset": {"x": 0, "y": 0, "z": 0}, + "dropOffset": {"x": 0, "y": 0, "z": 0}, + } + }, + ) + + def test_grip_height_omitted_without_grip_distance(self): + resource = Resource(name="lid stack", size_x=100.0, size_y=90.0, size_z=20.0) + definition = build_movable_labware_definition(resource) + self.assertNotIn("gripHeightFromLabwareBottom", definition) + + def test_grip_height_clamped_at_labware_bottom(self): + resource = Resource(name="shim", size_x=10.0, size_y=10.0, size_z=3.0) + definition = build_movable_labware_definition(resource, grip_distance_from_top=7.0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 0.0) + + +def _flex_with_transport( + transport: Optional[ChatterboxTransport] = None, +) -> Tuple[OpentronsFlex, ChatterboxTransport]: + transport = transport or ChatterboxTransport( + pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right" + ) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + return flex, transport + + +def _flex_head8_with_gripper() -> Tuple[OpentronsFlex, ChatterboxTransport, FlexHead8]: + """A set-up Flex with an 8-channel head AND a gripper, so one bench can + drive both the gripper-intent and pipetting-intent load paths.""" + transport = ChatterboxTransport(pipettes=[("p50_multi_flex", 8, 1.0, 50.0, "left")], gripper=True) + flex = OpentronsFlex(deck=FlexDeck(), host="localhost", transport=transport) + asyncio.run(flex.setup()) + head = flex.left + assert isinstance(head, FlexHead8) + return flex, transport, head + + +def _load_labware_commands(transport: ChatterboxTransport) -> list: + return [c for c in transport.commands if c["commandType"] == "loadLabware"] + + +def _mount_tips(flex: OpentronsFlex, head: FlexHead8) -> None: + """Pick up a column of tips, so a liquid op reaches past the mounted-tip guard.""" + rack = flex_96_tiprack_50ul(name=f"tips for {head.mount}") + flex.deck.assign_child_at_slot(rack, "D1") + asyncio.run(head.pick_up_tips(rack, column=0)) + + +class _FailFirstUploadTransport(ChatterboxTransport): + """Chatterbox whose FIRST labware-definition upload raises; retries succeed.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._upload_failed_once = False + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if path.endswith("/labware_definitions") and not self._upload_failed_once: + self._upload_failed_once = True + raise RuntimeError("simulated definition upload failure") + return await super().post(path, json) + + +class _FailFirstLoadTransport(ChatterboxTransport): + """Chatterbox whose FIRST loadLabware command fails at the robot; retries succeed.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._load_failed_once = False + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + result = await super().post(path, json) + data = (json or {}).get("data", {}) + if ( + path.endswith("/commands") + and data.get("commandType") == "loadLabware" + and not self._load_failed_once + ): + self._load_failed_once = True + cmd_data = result["data"] + cmd_data["status"] = "failed" + cmd_data["error"] = {"detail": "simulated loadLabware failure"} + return result + + +class TestUnbuildableLabwareGuard(unittest.TestCase): + """Labware no well-bearing definition can be built from is gripper-movable + but never pipettable: the movable stub's single fake well is zero-depth and + sits at the labware's own bottom, so pipetting it would drive a tip at the + deck. Pipetting callers are refused before any wire command.""" + + def test_pipetting_a_tube_rack_raises_before_any_wire_command(self): + flex, transport, head = _flex_head8_with_gripper() + try: + rack = _tube_rack() + flex.deck.assign_child_at_slot(rack, "C1") + _mount_tips(flex, head) + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + # An untyped script can hand a rack to a Plate parameter; that is + # exactly the caller this guard exists for. + asyncio.run(head.aspirate(rack, column=0, volume=20)) # type: ignore[arg-type] + + self.assertEqual(len(transport.labware_definitions), 0) + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_gripper_move_of_the_same_rack_still_works(self): + flex, transport, head = _flex_head8_with_gripper() + try: + rack = _tube_rack() + flex.deck.assign_child_at_slot(rack, "C1") + gripper = flex.gripper + assert gripper is not None + + asyncio.run(gripper.move_labware(rack, "C2")) + + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(transport.labware_definitions[0]["wells"]["A1"]["depth"], 0) + self.assertEqual(flex.deck.get_slot(rack), "C2") + finally: + asyncio.run(flex.stop()) + + def test_pipetting_after_a_gripper_move_still_raises_on_the_load_cache_hit(self): + # The load cache returns before any type dispatch, so the stub-loaded + # names are tracked separately for the pipetting refusal to see them. + flex, transport, head = _flex_head8_with_gripper() + try: + rack = _tube_rack() + flex.deck.assign_child_at_slot(rack, "C1") + gripper = flex.gripper + assert gripper is not None + asyncio.run(gripper.move_labware(rack, "C2")) + _mount_tips(flex, head) + + commands_before = len(transport.commands) + with self.assertRaises(OpentronsError): + asyncio.run(head.aspirate(rack, column=0, volume=20)) # type: ignore[arg-type] + + self.assertEqual(len(transport.commands), commands_before) + finally: + asyncio.run(flex.stop()) + + def test_off_deck_clears_the_stub_record(self): + flex, _transport, head = _flex_head8_with_gripper() + try: + rack = _tube_rack() + flex.deck.assign_child_at_slot(rack, "C1") + gripper = flex.gripper + assert gripper is not None + asyncio.run(gripper.move_labware(rack, "C2")) + self.assertIn(rack.name, flex._stub_labware) + + asyncio.run(flex.labware_moved_off_deck(rack)) + self.assertNotIn(rack.name, flex._stub_labware) + finally: + asyncio.run(flex.stop()) + + +class TestCustomLabwareLoadFlow(unittest.TestCase): + """_ensure_labware_loaded uploads a definition for labware with no official name.""" + + def test_plate_without_official_name_uploads_then_loads(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + asyncio.run(flex._ensure_labware_loaded(plate)) + + self.assertEqual(len(transport.labware_definitions), 1) + definition = transport.labware_definitions[0] + load_cmds = _load_labware_commands(transport) + self.assertEqual(len(load_cmds), 1) + params = load_cmds[0]["params"] + # loadLabware must reference exactly the uploaded definition's identity. + self.assertEqual(params["namespace"], definition["namespace"]) + self.assertEqual(params["loadName"], definition["parameters"]["loadName"]) + self.assertEqual(params["version"], definition["version"]) + self.assertEqual(params["namespace"], "pylabrobot") + self.assertEqual(params["loadName"], "black_plate_1_e2a464") + self.assertEqual(params["version"], 1) + self.assertEqual(params["location"], {"slotName": "C1"}) + finally: + asyncio.run(flex.stop()) + + def test_load_into_a_staging_slot_uses_the_addressable_area_form(self): + # The robot-server's DeckSlotName covers only the A1-D3 grid, so the + # column-4 staging slots ride a different location key. + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "A4") + asyncio.run(flex._ensure_labware_loaded(plate)) + + params = _load_labware_commands(transport)[0]["params"] + self.assertEqual(params["location"], {"addressableAreaName": "A4"}) + finally: + asyncio.run(flex.stop()) + + def test_second_use_hits_cache_no_second_upload_or_load(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + first = asyncio.run(flex._ensure_labware_loaded(plate)) + second = asyncio.run(flex._ensure_labware_loaded(plate)) + + self.assertEqual(first, second) + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(len(_load_labware_commands(transport)), 1) + finally: + asyncio.run(flex.stop()) + + def test_reload_after_off_deck_reuploads_definition(self): + # The definition-identity cache is evicted with the departed labware: a + # different same-named resource re-added later must not inherit the old + # geometry, so the re-add re-uploads. + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + asyncio.run(flex._ensure_labware_loaded(plate)) + asyncio.run(flex.labware_moved_off_deck(plate)) + self.assertNotIn(plate.name, flex._defined_labware) + + flex.deck.assign_child_at_slot(plate, "D2") + asyncio.run(flex._ensure_labware_loaded(plate)) + + self.assertEqual(len(transport.labware_definitions), 2) + load_cmds = _load_labware_commands(transport) + self.assertEqual(len(load_cmds), 2) + self.assertEqual(load_cmds[1]["params"]["location"], {"slotName": "D2"}) + finally: + asyncio.run(flex.stop()) + + def test_second_setup_clears_run_scoped_caches(self): + # labwareIds and uploaded definitions are both run-scoped server-side, so + # a new run (new setup) must re-upload and re-load. + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertEqual(len(transport.labware_definitions), 1) + + asyncio.run(flex.setup()) # new run + self.assertEqual(flex._loaded_labware, {}) + self.assertEqual(flex._defined_labware, {}) + + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertEqual(len(transport.labware_definitions), 2) + self.assertEqual(len(_load_labware_commands(transport)), 2) + finally: + asyncio.run(flex.stop()) + + def test_failed_upload_leaves_caches_clean_and_retry_works(self): + flex, transport = _flex_with_transport( + _FailFirstUploadTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right") + ) + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + + with self.assertRaises(RuntimeError): + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertNotIn(plate.name, flex._defined_labware) + self.assertNotIn(plate.name, flex._loaded_labware) + + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(len(_load_labware_commands(transport)), 1) + self.assertIn(plate.name, flex._loaded_labware) + finally: + asyncio.run(flex.stop()) + + def test_failed_load_leaves_no_labware_id_and_retry_reuses_definition(self): + flex, transport = _flex_with_transport( + _FailFirstLoadTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right") + ) + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + + with self.assertRaises(RuntimeError): + asyncio.run(flex._ensure_labware_loaded(plate)) + self.assertNotIn(plate.name, flex._loaded_labware) + + asyncio.run(flex._ensure_labware_loaded(plate)) + # The upload succeeded the first time, so the retry re-loads without a + # duplicate upload. + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(len(_load_labware_commands(transport)), 2) + self.assertIn(plate.name, flex._loaded_labware) + finally: + asyncio.run(flex.stop()) + + def test_definition_cache_hit_logs_the_ignored_grip_distance(self): + # The upload survives a failed load, so the retry reuses the stored + # definition -- and the grip height it already carries. + flex, transport = _flex_with_transport( + _FailFirstLoadTransport(pipette=("p1000_single_flex", 1, 1.0, 1000.0), mount="right") + ) + asyncio.run(flex.setup()) + try: + plate = _plate() + flex.deck.assign_child_at_slot(plate, "C1") + with self.assertRaises(RuntimeError): + asyncio.run(flex._ensure_labware_loaded(plate, grip_distance_from_top=4.0)) + + with self.assertLogs("pylabrobot.opentrons.flex", level="WARNING") as logs: + asyncio.run(flex._ensure_labware_loaded(plate, grip_distance_from_top=8.0)) + + self.assertEqual(len(transport.labware_definitions), 1) + self.assertEqual(transport.labware_definitions[0]["gripHeightFromLabwareBottom"], 10.0) + self.assertTrue(any("grip_distance_from_top=8.0" in line for line in logs.output)) + finally: + asyncio.run(flex.stop()) + + def test_official_name_labware_loads_with_zero_uploads(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + plate = _plate() + plate.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(plate, "C1") + asyncio.run(flex._ensure_labware_loaded(plate)) + + self.assertEqual(len(transport.labware_definitions), 0) + load_cmds = _load_labware_commands(transport) + self.assertEqual(len(load_cmds), 1) + params = load_cmds[0]["params"] + self.assertEqual(params["namespace"], "opentrons") + self.assertEqual(params["loadName"], "corning_96_wellplate_360ul_flat") + # This resource declares no ot_version, and revision 1 is the only one + # every robot is guaranteed to hold, so that is what goes out. + self.assertEqual(params["version"], 1) + finally: + asyncio.run(flex.stop()) + + def test_version_falls_back_to_1_and_a_resource_can_override_it(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + rack = _tip_rack() + rack.ot_load_name = "opentrons_flex_96_tiprack_50ul" # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex._ensure_labware_loaded(rack)) + + pinned = _plate(name="pinned plate") + pinned.ot_load_name = "corning_96_wellplate_360ul_flat" # type: ignore[attr-defined] + pinned.ot_version = 4 # type: ignore[attr-defined] + flex.deck.assign_child_at_slot(pinned, "C2") + asyncio.run(flex._ensure_labware_loaded(pinned)) + + versions = [c["params"]["version"] for c in _load_labware_commands(transport)] + self.assertEqual(versions, [1, 4]) + finally: + asyncio.run(flex.stop()) + + def test_container_uploads_single_cavity_definition(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + trough = _trough() + flex.deck.assign_child_at_slot(trough, "B1") + asyncio.run(flex._ensure_labware_loaded(trough)) + + self.assertEqual(len(transport.labware_definitions), 1) + definition = transport.labware_definitions[0] + self.assertEqual(list(definition["wells"]), ["A1"]) + params = _load_labware_commands(transport)[0]["params"] + self.assertEqual(params["namespace"], "pylabrobot") + self.assertEqual(params["loadName"], "hamilton_trough_9544de") + finally: + asyncio.run(flex.stop()) + + def test_tip_rack_without_official_name_uploads_tiprack_definition(self): + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + rack = _tip_rack() + flex.deck.assign_child_at_slot(rack, "C1") + asyncio.run(flex._ensure_labware_loaded(rack)) + + self.assertEqual(len(transport.labware_definitions), 1) + self.assertTrue(transport.labware_definitions[0]["parameters"]["isTiprack"]) + params = _load_labware_commands(transport)[0]["params"] + self.assertEqual(params["loadName"], "hamilton_tips_300_0558ff") + finally: + asyncio.run(flex.stop()) + + def test_bare_resource_uploads_movable_stub(self): + # A resource that is not a Plate/TipRack/Container routes to the + # non-pipettable movable stub, which only allow_stub callers may ask for. + flex, transport = _flex_with_transport() + asyncio.run(flex.setup()) + try: + widget = Resource(name="widget", size_x=100.0, size_y=90.0, size_z=20.0) + flex.deck.assign_child_at_slot(widget, "C1") + asyncio.run(flex._ensure_labware_loaded(widget, allow_stub=True, grip_distance_from_top=5.0)) + + self.assertEqual(len(transport.labware_definitions), 1) + definition = transport.labware_definitions[0] + self.assertEqual(definition["ordering"], [["A1"]]) + self.assertEqual(definition["wells"]["A1"]["depth"], 0) + self.assertEqual(definition["gripHeightFromLabwareBottom"], 15.0) # 20 - 5 + params = _load_labware_commands(transport)[0]["params"] + self.assertEqual(params["namespace"], "pylabrobot") + self.assertEqual(params["loadName"], "widget_ff700e") + finally: + asyncio.run(flex.stop()) diff --git a/pylabrobot/opentrons/pipette_defaults.py b/pylabrobot/opentrons/pipette_defaults.py new file mode 100644 index 00000000000..4e54adbbcdc --- /dev/null +++ b/pylabrobot/opentrons/pipette_defaults.py @@ -0,0 +1,143 @@ +"""Opentrons' own default flow rates for the Flex pipettes, per model and tip. + +Every Opentrons liquid-handling command carries a required ``flowRate``, and no +robot-server endpoint serves the defaults: ``GET /instruments`` reports channels +and volume range only, and the protocol engine rejects a command that omits the +field rather than filling one in. The numbers therefore have to live on the +client, the same way the Hamilton backends carry their liquid classes. + +They are transcribed from Opentrons' own pipette definitions +(``shared-data/pipette/definitions/2/liquid``) at version 9.1.2. +``pipette_defaults_tests`` re-reads those definitions and fails on any drift, +whenever ``opentrons-shared-data`` happens to be installed. + +Rates span more than two orders of magnitude across the range, 6 uL/s to 716, so +one constant is wrong for almost every pipette. They also change between +VERSIONS of the same model: a p1000 on a 50 uL tip is 6 uL/s at v3.3 and 478 at +v3.4. Models are therefore keyed by full version string, and one this table does +not name raises instead of falling back, because no neighbour is near enough to +guess with. +""" + +from typing import Dict, NamedTuple, Tuple + + +class FlowRates(NamedTuple): + """Default aspirate, dispense and blow-out rates in uL/s.""" + + aspirate: float + dispense: float + blow_out: float + + +_P1000_ORIGINAL = { + "t50": FlowRates(6, 6, 80), + "t200": FlowRates(80, 80, 80), + "t1000": FlowRates(160, 160, 80), +} + +_P1000_FAST = { + "t50": FlowRates(478, 478, 478), + "t200": FlowRates(716, 716, 716), + "t1000": FlowRates(716, 716, 716), +} + +_P200_96_ORIGINAL = { + "t20": FlowRates(6.5, 6.5, 10), + "t50": FlowRates(6, 6, 10), + "t200": FlowRates(10, 10, 10), +} + +_P200_96_REVISED = { + "t20": FlowRates(6.5, 6.5, 10), + "t50": FlowRates(22, 22, 22), + "t200": FlowRates(15, 15, 10), +} + +_P50_SLOW_ON_50 = { + "t20": FlowRates(35, 57, 57), + "t50": FlowRates(8, 8, 4), +} + +_P50_FAST_ON_50 = { + "t20": FlowRates(35, 57, 57), + "t50": FlowRates(35, 57, 57), +} + +_P50_SLOW_ON_20 = { + "t20": FlowRates(22, 22, 57), + "t50": FlowRates(35, 57, 57), +} + +_DEFAULTS: Dict[str, Dict[str, FlowRates]] = { + "p1000_96_v3.0": _P1000_ORIGINAL, + "p1000_96_v3.3": _P1000_ORIGINAL, + "p1000_96_v3.4": _P1000_ORIGINAL, + "p1000_96_v3.5": _P1000_ORIGINAL, + "p1000_96_v3.6": _P1000_ORIGINAL, + "p1000_96_v3.7": _P1000_ORIGINAL, + "p1000_multi_v3.0": _P1000_ORIGINAL, + "p1000_multi_v3.3": _P1000_ORIGINAL, + "p1000_multi_v3.4": _P1000_FAST, + "p1000_multi_v3.5": _P1000_FAST, + "p1000_multi_v3.6": _P1000_FAST, + "p1000_single_v3.0": _P1000_ORIGINAL, + "p1000_single_v3.3": _P1000_ORIGINAL, + "p1000_single_v3.4": _P1000_FAST, + "p1000_single_v3.5": _P1000_FAST, + "p1000_single_v3.6": _P1000_FAST, + "p1000_single_v3.7": _P1000_FAST, + "p200_96_v3.0": _P200_96_ORIGINAL, + "p200_96_v3.1": _P200_96_REVISED, + "p200_96_v3.2": _P200_96_REVISED, + "p200_96_v3.3": _P200_96_REVISED, + "p50_multi_v3.0": _P50_SLOW_ON_50, + "p50_multi_v3.3": _P50_SLOW_ON_50, + "p50_multi_v3.4": _P50_FAST_ON_50, + "p50_multi_v3.5": _P50_FAST_ON_50, + "p50_single_v3.0": _P50_SLOW_ON_50, + "p50_single_v3.3": _P50_SLOW_ON_50, + "p50_single_v3.4": _P50_FAST_ON_50, + "p50_single_v3.5": _P50_FAST_ON_50, + "p50_single_v3.6": _P50_SLOW_ON_20, + "p50_single_v3.7": _P50_SLOW_ON_20, +} + + +def _rates_by_tip(pipette_model: str) -> Dict[str, FlowRates]: + if not pipette_model: + raise ValueError("No pipette model given, so its default flow rates are unknown.") + + rates = _DEFAULTS.get(pipette_model) + if rates is None: + raise ValueError( + f"No default flow rates are recorded for pipette '{pipette_model}'. Pass an explicit " + "flow_rate, or add the model to pylabrobot.opentrons.pipette_defaults. Rates change " + "between versions of the same pipette, so the nearest version is not a safe stand-in." + ) + return rates + + +def flow_rates(pipette_model: str, tip_volume: float) -> FlowRates: + """Look up the defaults for a pipette model ("p50_multi_v3.5") and tip volume. + + Raises: + ValueError: If the pipette does not support a tip of that volume. There is + no near-enough tip to fall back to: the p1000 eight-channel alone spans + 478 uL/s on a 50 uL tip and 716 on a 200, so guessing would silently + pipette at the wrong speed. + """ + rates = _rates_by_tip(pipette_model) + tip_name = f"t{int(tip_volume)}" + if tip_name not in rates: + supported = ", ".join(sorted(rates)) + raise ValueError( + f"'{pipette_model}' publishes no default flow rate for a {tip_volume} uL tip " + f"(it supports {supported}). Pass an explicit flow_rate for this tip." + ) + return rates[tip_name] + + +def supported_tip_volumes(pipette_model: str) -> Tuple[float, ...]: + """The tip volumes this pipette publishes defaults for, ascending.""" + return tuple(sorted(float(name[1:]) for name in _rates_by_tip(pipette_model))) diff --git a/pylabrobot/opentrons/pipette_defaults_tests.py b/pylabrobot/opentrons/pipette_defaults_tests.py new file mode 100644 index 00000000000..07f7d286e98 --- /dev/null +++ b/pylabrobot/opentrons/pipette_defaults_tests.py @@ -0,0 +1,88 @@ +import unittest + +import pytest + +from pylabrobot.opentrons.pipette_defaults import ( + _DEFAULTS, + flow_rates, + supported_tip_volumes, +) + + +class PipetteDefaultsTests(unittest.TestCase): + """Flow-rate defaults come from Opentrons' own shipped pipette data.""" + + def test_rates_differ_by_pipette_and_by_tip(self): + # The whole reason a single constant cannot serve: these span two orders of + # magnitude across pipettes the same robot can mount. + self.assertEqual(flow_rates("p50_multi_v3.5", 50), (35.0, 57.0, 57.0)) + self.assertEqual(flow_rates("p1000_multi_v3.5", 1000), (716.0, 716.0, 716.0)) + self.assertEqual(flow_rates("p1000_multi_v3.5", 50), (478.0, 478.0, 478.0)) + self.assertEqual(flow_rates("p200_96_v3.3", 20), (6.5, 6.5, 10.0)) + + def test_rates_differ_between_versions_of_one_pipette(self): + # Same pipette, same tip, different hardware revision: v3.0 runs a 50uL tip + # at 8 uL/s where v3.5 runs it at 35. + self.assertEqual(flow_rates("p50_multi_v3.0", 50).aspirate, 8.0) + self.assertEqual(flow_rates("p50_multi_v3.5", 50).aspirate, 35.0) + + def test_unsupported_tip_is_refused_and_names_what_is_supported(self): + with self.assertRaises(ValueError) as caught: + flow_rates("p50_multi_v3.5", 300) + self.assertIn("300", str(caught.exception)) + self.assertIn("t50", str(caught.exception)) + + def test_empty_model_is_refused_rather_than_resolving_to_a_p1000(self): + # Opentrons' own parser maps "" to a p1000 single-channel instead of raising, + # which would blow a p50 out at up to 716 uL/s. + with self.assertRaises(ValueError): + flow_rates("", 50) + + def test_supported_tip_volumes_are_ascending(self): + self.assertEqual(supported_tip_volumes("p50_multi_v3.5"), (20.0, 50.0)) + self.assertEqual(supported_tip_volumes("p1000_multi_v3.5"), (50.0, 200.0, 1000.0)) + + def test_an_unrecorded_model_is_refused_rather_than_run_at_a_neighbour_s_rate(self): + with self.assertRaises(ValueError) as caught: + flow_rates("p50_single_v9.9", 50) + self.assertIn("p50_single_v9.9", str(caught.exception)) + self.assertIn("flow_rate", str(caught.exception)) + + +class PipetteDefaultsMatchOpentronsTests(unittest.TestCase): + """The vendored table is a copy, so prove it still matches what it was copied from. + + Skipped unless ``opentrons-shared-data`` is installed; PyLabRobot does not + depend on it. + """ + + def test_every_recorded_model_matches_opentrons_own_definition(self): + pytest.importorskip("opentrons_shared_data") + from opentrons_shared_data.pipette.load_data import load_liquid_model + from opentrons_shared_data.pipette.pipette_load_name_conversions import ( + convert_pipette_model, + ) + from opentrons_shared_data.pipette.types import PipetteModel, PipetteOEMType + + for model, recorded in _DEFAULTS.items(): + with self.subTest(model=model): + version = convert_pipette_model(PipetteModel(model)) + liquid_model = load_liquid_model( + version.pipette_type, + version.pipette_channels, + version.pipette_version, + PipetteOEMType.OT, + ) + theirs = { + tip_type.name: ( + tip.default_aspirate_flowrate.default, + tip.default_dispense_flowrate.default, + tip.default_blowout_flowrate.default, + ) + for tip_type, tip in liquid_model["default"].supported_tips.items() + } + self.assertEqual({name: tuple(r) for name, r in recorded.items()}, theirs) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/opentrons/robot.py b/pylabrobot/opentrons/robot.py new file mode 100644 index 00000000000..41de8eabdab --- /dev/null +++ b/pylabrobot/opentrons/robot.py @@ -0,0 +1,400 @@ +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__) + +# Seconds of polling a command gets on top of however long its own motion takes. +COMMAND_POLL_HEADROOM = 30.0 + + +def _plunger_seconds(params: Dict[str, Any]) -> float: + """How long a command's own plunger travel takes, from its volume and rate. + + A 1000 uL aspirate at a viscous-liquid flow rate outlasts any fixed poll + timeout, and giving up mid-command rolls the trackers back while the robot + keeps pipetting. + """ + volume, flow_rate = params.get("volume"), params.get("flowRate") + if not isinstance(volume, (int, float)) or not isinstance(flow_rate, (int, float)): + return 0.0 + return abs(volume) / flow_rate if flow_rate > 0 else 0.0 + + +class OpentronsError(Exception): + def __init__(self, title: str, message: Optional[str] = None) -> None: + self.title, self.message = title, message + super().__init__(f"{title}: {message}" if message else title) + + +class OpentronsCommandError(RuntimeError): + """A robot-server command completed with status "failed". + + Carries the command's error payload (the robot-server's ErrorOccurrence + dict) so callers can react to defined errors by ``error_type`` (e.g. + ``"liquidNotFound"``) instead of parsing the message string. + """ + + def __init__(self, command_type: str, error: Dict[str, Any]) -> None: + super().__init__(f"Opentrons command '{command_type}' failed: {error.get('detail', error)}") + self.command_type = command_type + self.error = error + + @property + def error_type(self) -> Optional[str]: + """The machine-readable error identifier, e.g. "liquidNotFound".""" + return cast(Optional[str], self.error.get("errorType")) + + +@dataclass +class PipetteInfo: + mount: str + 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}" + # Built here rather than on connect: a pylabrobot io refuses construction + # once a capture is armed, so a robot built first can still be recorded. + self._transport: OpentronsTransport = transport or HttpxTransport(base_url=self.base_url) + self.run_id: Optional[str] = None + self.pipette: Optional[PipetteInfo] = None + self.api_version: Optional[str] = None + self.robot_model: Optional[str] = None + + async def setup(self) -> None: + """Bring the robot fully up, PyLabRobot's usual one-call lifecycle entry. + + Composed of the steps below so a caller who needs only some of them (talk to + the robot without moving it, hand it back without homing it) can take them + one at a time. + """ + await self.connect() + await self.create_run() + await self.home() + await self.initialize() + + async def stop(self) -> None: + """Park the gantry and release the robot, PyLabRobot's usual teardown.""" + # Home inside the run (before cancelling it) so the gantry parks in a known + # pose; a failure here must not block the release that follows. + try: + await self.home() + except Exception: + logger.warning("home() before stop failed; continuing to release", exc_info=True) + await self.cancel_run() + await self.disconnect() + + async def connect(self) -> None: + """Open the link and confirm the robot answers. Starts no run, moves nothing.""" + await self._connect() + + async def create_run(self) -> None: + """Start a control session. + + The robot reports itself as in use and refuses its own touchscreen for as + long as a run is current, so this is the step that takes it from the + operator, not ``connect``. + + Cancels a run this object already holds before opening the next one. + ``run_id`` is the only handle we have on a run, so overwriting it strands + the old one on the robot, still current, with nothing left able to release + it: the touchscreen stays locked and a power cycle is the only way out. + """ + await self._cancel_run() + await self._create_run() + + async def initialize(self) -> None: + """Discover what is mounted and get it ready to command. Moves nothing. + + Homing is ``home()``, deliberately not folded in here: a caller that wants + to know what is on the robot should not have to move it to find out. + """ + await self._model_setup() + + async def cancel_run(self) -> None: + """End the control session, handing the robot back to its own touchscreen. + + Safe with no run open. This, not ``disconnect``, is what frees local + control: the run is what the robot holds, and it outlives our link. + """ + await self._cancel_run() + + async def disconnect(self) -> None: + """Drop the link, moving nothing. + + Cancels an open run first, since a run left current keeps the robot locked + out of its own controls with nothing left to release it. + """ + await self._cancel_run() + await self._disconnect() + + @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: + """Open the transport and verify connectivity. + + Sends a health check to confirm the robot is reachable and the robot + server is running (not in Jupyter/Python API mode). + """ + await self._transport.setup() + 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, keeping the object so a reconnect can reopen it. + + Discarding it here would mean rebuilding an io on the next connect, which + a capture armed in the meantime refuses. + """ + await self._transport.close() + + async def send_command( + self, + command_type: str, + params: Optional[Dict[str, Any]] = None, + wait: bool = True, + timeout: float = 30.0, + ) -> Dict[str, Any]: + """Send any robot command by name, for the parts of the robot this class does not wrap. + + The robot accepts far more commands than this driver exposes as methods: + the module families (heater-shaker, thermocycler, temperature, magnetic, + absorbance reader, vacuum, Flex Stacker), calibration, and whatever a + later robot software release adds. ``command_type`` is the robot's own + command name (``"moveToWell"``, ``"heaterShaker/setTargetTemperature"``) + and ``params`` its payload; both are passed through untouched, and the + returned dict is the completed command including its ``result``. + + Prefer a typed method where one exists. This one validates nothing and + updates no resource-tree state, so a command that moves labware or changes + tip or liquid state leaves PyLabRobot's own trackers describing the state + before it ran. + """ + return await self._execute_command(command_type, params or {}, wait=wait, timeout=timeout) + + # --- Low-Level Wire Calls --- + + async def _get(self, path: str) -> Dict[str, Any]: + """Wire GET, return parsed JSON.""" + 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.""" + return await self._transport.post(path, json=data or {}) + + async def _delete(self, path: str) -> Dict[str, Any]: + """Wire DELETE, return parsed JSON.""" + 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: + OpentronsCommandError: If the command reports status "failed". + RuntimeError: If the command times out. + """ + assert self.run_id is not None, "No active run. Call create_run() first." + timeout = max(timeout, _plunger_seconds(params) + COMMAND_POLL_HEADROOM) + 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": + raise OpentronsCommandError(command_type, cmd_data.get("error", {})) + await asyncio.sleep(0.2) + + raise RuntimeError(f"Opentrons command '{command_type}' timed out after {timeout}s") + + # --- 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..15a29b3744a --- /dev/null +++ b/pylabrobot/opentrons/transport.py @@ -0,0 +1,526 @@ +"""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 pathlib import Path +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Protocol, + Tuple, + Union, + cast, + runtime_checkable, +) + +from pylabrobot.io.capture import CaptureReader +from pylabrobot.io.http import HTTP, HTTPValidator + +logger = logging.getLogger(__name__) + +# The robot-server rejects a request that does not name the API version it +# should be read as. +DEFAULT_HEADERS = {"opentrons-version": "3"} + +# ChatterboxTransport's default /health version: deliberately not a version +# string, so a caller gating on robot software can tell offline from any robot. +OFFLINE_API_VERSION = "dry-run" + +# /instruments serves a versioned model, and flow-rate defaults differ between +# versions of the same pipette, so the fake cannot just echo the name back. +_MODELS_BY_NAME = { + "p50_single_flex": "p50_single_v3.5", + "p50_multi_flex": "p50_multi_v3.5", + "p1000_single_flex": "p1000_single_v3.5", + "p1000_multi_flex": "p1000_multi_v3.5", + "p1000_96": "p1000_96_v3.5", + "p200_96": "p200_96_v3.3", +} + + +# Enum-valued command params, with what the robot-server accepts. A fake that +# takes any string lets a driver ship a value the real robot answers 422 to. +_COMMAND_ENUMS = { + ("setTipState", "tipWellState"): frozenset({"clean", "used", "empty"}), + ("verifyTipPresence", "expectedState"): frozenset({"present", "absent"}), + ("moveLabware", "strategy"): frozenset( + {"usingGripper", "manualMoveWithPause", "manualMoveWithoutPause"} + ), +} + + +def _reject_unknown_enum_values(command_type: str, params: Dict[str, Any]) -> None: + """Refuse a param value the real robot-server would reject.""" + for (ctype, key), allowed in _COMMAND_ENUMS.items(): + if ctype != command_type or key not in params: + continue + if params[key] not in allowed: + raise ValueError( + f"{command_type} param {key}={params[key]!r} is not one of {sorted(allowed)}; " + "the robot-server answers 422 to this." + ) + + +@runtime_checkable +class OpentronsTransport(Protocol): + """Wire-level seam: the subset of HTTP that ``OpentronsRobot`` needs. + + 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 setup(self) -> None: ... + + async def get(self, path: str) -> Dict[str, Any]: ... + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: ... + + async def delete(self, path: str) -> Dict[str, Any]: ... + + async def close(self) -> None: ... + + +class HttpxTransport: + """Real transport, over the :class:`~pylabrobot.io.http.HTTP` io. + + Going through the io rather than a bare ``httpx.AsyncClient`` is what puts + every robot-server exchange in PyLabRobot's capture log, so a run wrapped in + ``start_capture()`` can later be replayed by :class:`ReplayTransport`. + """ + + def __init__( + self, + base_url: str, + timeout: float = 30.0, + headers: Optional[Dict[str, str]] = None, + ) -> None: + self.io = HTTP(base_url=base_url, timeout=timeout, headers=headers or DEFAULT_HEADERS) + + async def setup(self) -> None: + await self.io.setup() + + async def get(self, path: str) -> Dict[str, Any]: + return cast(Dict[str, Any], await self.io.get(path)) + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return cast(Dict[str, Any], await self.io.post(path, json=json or {})) + + async def delete(self, path: str) -> Dict[str, Any]: + return cast(Dict[str, Any], await self.io.delete(path)) + + async def close(self) -> None: + await self.io.stop() + + +class ReplayTransport: + """Offline transport that replays a capture file recorded from a real robot. + + Where :class:`ChatterboxTransport` returns responses we wrote by hand, this + returns the ones an actual robot gave, in the order it gave them, and raises + the same refusal on an exchange that failed. Nothing reaches the network. + + Build the capture file by wrapping a live run in ``start_capture()`` / + ``stop_capture()``. Construct the robot BEFORE arming the capture: every + pylabrobot io refuses construction while one is active, and the robot builds + its transport in ``__init__`` for exactly that reason. + + flex = OpentronsFlex(deck=deck, host=host) # transport built here + pylabrobot.start_capture(path) + await flex.setup() + ... + pylabrobot.stop_capture() + + Call :meth:`assert_fully_replayed` at the end of a test: it fails when the + protocol stopped short of the recording, which is what catches a dropped + command. + """ + + def __init__( + self, + capture_file: Union[str, Path], + base_url: str, + headers: Optional[Dict[str, str]] = None, + ) -> None: + self._cr = CaptureReader(path=str(capture_file)) + self.io = HTTPValidator(self._cr, base_url=base_url, headers=headers or DEFAULT_HEADERS) + + async def setup(self) -> None: + await self.io.setup() + + async def get(self, path: str) -> Dict[str, Any]: + return cast(Dict[str, Any], await self.io.get(path)) + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return cast(Dict[str, Any], await self.io.post(path, json=json or {})) + + async def delete(self, path: str) -> Dict[str, Any]: + return cast(Dict[str, Any], await self.io.delete(path)) + + async def close(self) -> None: + await self.io.stop() + + def assert_fully_replayed(self) -> None: + """Raise unless every recorded exchange was consumed.""" + self._cr.done() + + +class ChatterboxTransport: + """Offline transport: logs commands, returns canned 'succeeded' responses. + + Instead of reaching a robot server it returns the fixed ``/health``, + ``/instruments``, ``/runs``, ``/runs/{id}/commands`` and + ``/runs/{id}/labware_definitions`` shapes the ``OpentronsRobot`` lifecycle + (``setup()``: health check, create-run, discover pipette) and labware + loading read, so a caller can drive the robot with no network. + + Scope: this exercises PLR-native checks only. It does NOT reproduce the + Opentrons Protocol Engine's *analysis* stage (deck-conflict, capacity, + partial-tip extents) — that is protocol-file based (``opentrons_simulate`` + against a virtual Protocol Engine) and needs the ``opentrons`` package, + which an HTTP transport cannot reach. + + It does track the engine's plunger-priming rule (see ``_track_plunger``), + so a draw the real robot would refuse fails here too. That rule cost a + round of tests that passed on wire traffic hardware rejects. + """ + + def __init__( + 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, + liquid_probe_z: Optional[float] = None, + simulate_liquid_probe_not_found: bool = False, + gripper: bool = False, + saved_position: Optional[Dict[str, float]] = None, + api_version: str = OFFLINE_API_VERSION, + ) -> None: + """Args: + pipette: the simulated mounted pipette as ``(name, channels, min_vol, max_vol)``. + 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()`` reads "absent" and raises. + Default False: a pickup always seats a tip (existing behavior). + simulate_stuck_tip: if True, ``dropTip``/``dropTipInPlace`` do NOT clear + the issuing pipette's simulated tip-presence sensor -- models a tip + stuck to the nozzle after a drop, so ``_FlexHead._confirm_tips_cleared()`` + reads "present" and logs a warning. Default False: a drop always clears + the sensor (existing behavior). + liquid_probe_z: the liquid height (mm) a ``liquidProbe``/``tryLiquidProbe`` + command reports as ``z_position`` in its result. Default None: the key + is omitted from the result entirely (not set to null), matching the + real robot-server's shape when no liquid is found. + simulate_liquid_probe_not_found: if True, a ``liquidProbe`` command FAILS + with the engine's defined "liquidNotFound" error -- the real-hardware + behavior when no liquid is detected -- instead of succeeding with the + ``z_position`` key absent. ``tryLiquidProbe`` is unaffected: it + genuinely succeeds with the key absent. Default False. + gripper: if True, ``/instruments`` also reports a gripper on the extension + mount, so tests can drive gripper discovery. Default False: no gripper + mounted (existing behavior). + saved_position: the position a ``savePosition`` command reports in its + result, as an ``{"x", "y", "z"}`` dict. Default None: report + ``{"x": 100.0, "y": 100.0, "z": 100.0}``. + api_version: the robot software version ``/health`` reports. Defaults to + the ``OFFLINE_API_VERSION`` sentinel, which no real robot returns; pass + a real version string (e.g. ``"8.1.0"``) to drive a caller's own + version gating without subclassing. + """ + if pipettes is not None: + self._pipettes: List[Tuple[str, int, float, float, str]] = list(pipettes) + else: + name, channels, min_v, max_v = pipette + self._pipettes = [(name, channels, min_v, max_v, mount)] + self._gripper = gripper + self.api_version = api_version + self._log = log or logger.info + self._cmds: Dict[str, Dict[str, Any]] = {} # cmd_id -> full command data + self._n = 0 + self._pipette_load_count = 0 + self._labware_load_count = 0 + self.load_pipette_commands: List[Dict[str, Any]] = [] # recorded loadPipette params + self.commands: List[Dict[str, Any]] = [] # every command, in send order: {commandType, params} + # Reading GET /instruments re-caches the pipettes, which clears the run's + # record of the attached tip, so it must not happen mid-run. + self.instrument_reads = 0 + self.labware_definitions: List[Dict[str, Any]] = [] # recorded custom definition uploads + self.labware_ids: Dict[str, str] = {} # displayName -> the id this transport assigned it + self.simulate_failed_pickup = simulate_failed_pickup + self.simulate_stuck_tip = simulate_stuck_tip + self.liquid_probe_z = liquid_probe_z + self.simulate_liquid_probe_not_found = simulate_liquid_probe_not_found + self.saved_position = saved_position + # Per-mount simulated hardware tip-presence sensor state (Flex reports + # ONE bool per pipette, not per nozzle -- see /instruments below). + 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] = {} + # Simulated plunger state per pipetteId, so a draw the real robot would + # refuse is refused here too. See _plunger_is_ready. + self._plunger_primed: Dict[str, bool] = {} + self._tip_volume: Dict[str, Optional[float]] = {} + + async def setup(self) -> None: + """No connection to open.""" + + async def get(self, path: str) -> Dict[str, Any]: + if path == "/health": + return { + "api_version": self.api_version, + "robot_model": "OT-3 Standard", + "name": "chatterbox", + } + if path == "/instruments": + self.instrument_reads += 1 + instruments: List[Dict[str, Any]] = [ + { + "instrumentType": "pipette", + "mount": mount, + "instrumentName": name, + "instrumentModel": _MODELS_BY_NAME.get(name, name), + "data": {"channels": channels, "min_volume": min_v, "max_volume": max_v}, + "state": {"tipDetected": self._tip_detected.get(mount, False)}, + } + for name, channels, min_v, max_v, mount in self._pipettes + ] + if self._gripper: + instruments.append( + { + "instrumentType": "gripper", + "mount": "extension", + "instrumentName": "flexGripper", + "instrumentModel": "gripperV1.3", + "data": {"jawState": "unhomed"}, + } + ) + return {"data": instruments} + if "/commands/" in path: # a poll for one command's status + cmd_id = path.rsplit("/", 1)[-1] + cmd_data = self._cmds.get( + cmd_id, {"id": cmd_id, "commandType": "", "status": "succeeded", "result": {}} + ) + return {"data": cmd_data} + return {"data": {}} + + def _plunger_is_ready(self, pipette_id: str) -> bool: + """Whether the robot would let this pipette draw where it stands. + + Mirrors ``HardwarePipettingHandler.get_is_ready_to_aspirate``: the + plunger must be at its dispense bottom AND the robot must still know how + much the tip holds. Dropping a tip makes the contents unknown, which is + why a drop leaves the pipette not ready even though nothing pushed the + plunger down. + """ + return self._tip_volume.get(pipette_id) is not None and self._plunger_primed.get( + pipette_id, False + ) + + def _track_plunger(self, ctype: str, params: Dict[str, Any]) -> None: + """Apply one command's effect on the simulated plunger and tip contents. + + Straight from the Protocol Engine's own state updates. The two that + surprise people: a tip pickup PRIMES the plunger (the engine primes it as + part of the pickup), and a dispense only un-primes when it empties the + tip, because that is the one the robot follows with a push-out. + """ + pid = params.get("pipetteId") + if not isinstance(pid, str): + return + volume = float(params.get("volume", 0.0) or 0.0) + held = self._tip_volume.get(pid) + if ctype == "configureForVolume": + self._plunger_primed[pid] = False + elif ctype in ("pickUpTip", "prepareToAspirate", "liquidProbe", "tryLiquidProbe"): + self._plunger_primed[pid] = True + self._tip_volume[pid] = 0.0 + elif ctype in ("aspirate", "aspirateInPlace", "airGapInPlace"): + # A well-addressed aspirate primes itself at the well top when it has to. + self._plunger_primed[pid] = True + self._tip_volume[pid] = (held or 0.0) + volume + elif ctype in ("dispense", "dispenseInPlace"): + left = (held or 0.0) - volume + self._tip_volume[pid] = left + push_out = params.get("pushOut") + self._plunger_primed[pid] = push_out == 0 if push_out is not None else abs(left) > 1e-9 + elif ctype in ("blowOut", "blowOutInPlace", "unsafe/blowOutInPlace"): + self._plunger_primed[pid] = False + self._tip_volume[pid] = 0.0 + elif ctype in ("dropTip", "dropTipInPlace", "unsafe/dropTipInPlace"): + self._tip_volume[pid] = None + + def _plunger_refusal(self, ctype: str, params: Dict[str, Any]) -> Optional[str]: + """The robot's own message when a command needs a primed plunger and finds none. + + Only commands that name no well refuse: a well-addressed ``aspirate`` + primes itself at the well top instead. A liquid probe refuses despite + naming a well, deliberately, because getting there means the tip has held + liquid and a probe wants a dry one. + """ + pid = params.get("pipetteId") + if not isinstance(pid, str): + return None + if ctype in ("aspirateInPlace", "airGapInPlace") and not self._plunger_is_ready(pid): + return ( + "Pipette cannot aspirate in place because a previous dispense or blowout pushed " + "the plunger beyond the bottom position. The subsequent aspirate must be from a " + "specific well so the plunger can be reset in a known safe position." + ) + if ctype in ("liquidProbe", "tryLiquidProbe") and self._tip_volume.get(pid) is None: + return ( + "The pipette cannot probe liquid because a previous dispense or blowout pushed " + "the plunger beyond the bottom position. The plunger must be reset while the tip " + "is somewhere away from liquid." + ) + return None + + async def post(self, path: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + if path == "/runs": + return {"data": {"id": "chatterbox-run"}} + if path.endswith("/labware_definitions"): # custom labware definition upload + definition = (json or {}).get("data", {}) + self.labware_definitions.append(dict(definition)) + # The real robot-server answers with the stored definition's URI, which + # the caller parses to reference the definition in loadLabware. + uri = "/".join( + str(part) + for part in ( + definition.get("namespace"), + definition.get("parameters", {}).get("loadName"), + definition.get("version"), + ) + ) + self._log("Chatterbox: defineLabware %s", uri) + return {"data": {"definitionUri": uri}} + if path.endswith("/commands"): + data = (json or {}).get("data", {}) + ctype = data.get("commandType", "?") + params = data.get("params", {}) + _reject_unknown_enum_values(ctype, params) + self._n += 1 + cmd_id = f"cmd-{self._n}" + self.commands.append({"commandType": ctype, "params": dict(params)}) + result: Dict[str, Any] = {} + refusal = self._plunger_refusal(ctype, params) + if refusal is not None: + # Raised undefined by the engine, so the wire carries the exception's + # class name rather than a defined error code. + cmd_data = { + "id": cmd_id, + "commandType": ctype, + "status": "failed", + "error": { + "errorType": "PipetteNotReadyToAspirateError", + "errorCode": "4000", + "detail": refusal, + }, + } + self._cmds[cmd_id] = cmd_data + self._log("Chatterbox: %s %s REFUSED (plunger not primed)", ctype, params) + return {"data": cmd_data} + if ctype == "loadPipette": + self._pipette_load_count += 1 + pipette_id = f"chatterbox-pip-{self._pipette_load_count}" + 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 + elif ctype == "loadLabware": + self._labware_load_count += 1 + labware_id = f"chatterbox-labware-{self._labware_load_count}" + result = {"labwareId": labware_id} + self.labware_ids[str(params.get("displayName"))] = labware_id + else: + result = {} + if ctype == "pickUpTip": + 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 + elif ctype in ("getTipPresence", "verifyTipPresence"): + mount = self._pipette_id_to_mount.get(params.get("pipetteId")) + detected = self._tip_detected.get(mount, False) if mount is not None else False + result = {"status": "present" if detected else "absent"} + elif ctype in ("liquidProbe", "tryLiquidProbe"): + if self.liquid_probe_z is not None: + result = {"z_position": self.liquid_probe_z} + elif ctype == "savePosition": + pos = self.saved_position or {"x": 100.0, "y": 100.0, "z": 100.0} + result = {"position": dict(pos)} + cmd_data = {"id": cmd_id, "commandType": ctype, "status": "succeeded", "result": result} + if ctype == "liquidProbe" and self.simulate_liquid_probe_not_found: + # The real robot-server fails the command with a defined + # ErrorOccurrence when the probe finds no liquid. + cmd_data = { + "id": cmd_id, + "commandType": ctype, + "status": "failed", + "error": { + "errorType": "liquidNotFound", + "errorCode": "2017", + "detail": "No liquid detected during the liquid probe process.", + "isDefined": True, + }, + } + if ctype == "verifyTipPresence" and result.get("status") != params.get("expectedState"): + # The point of verifyTipPresence over getTipPresence: the robot, not + # the caller, refuses the mismatch. + cmd_data = { + "id": cmd_id, + "commandType": ctype, + "status": "failed", + "error": { + "errorType": "tipPresenceMismatch", + "detail": f"Expected tip to be {params.get('expectedState')}.", + "isDefined": True, + }, + } + if cmd_data["status"] == "succeeded": + self._track_plunger(ctype, params) + self._cmds[cmd_id] = cmd_data + self._log("Chatterbox: %s %s", ctype, params) + return {"data": cmd_data} + 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..d299306e9c0 --- /dev/null +++ b/pylabrobot/opentrons/transport_tests.py @@ -0,0 +1,281 @@ +"""Tests for the OpentronsRobot transport seam (Protocol + chatterbox).""" + +import asyncio +import json +import tempfile +import unittest +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List + +import pytest + +import pylabrobot +from pylabrobot.io.errors import ValidationError +from pylabrobot.opentrons.flex import OpentronsFlex +from pylabrobot.opentrons.robot import OpentronsRobot +from pylabrobot.opentrons.transport import ( + ChatterboxTransport, + HttpxTransport, + OpentronsTransport, + ReplayTransport, +) +from pylabrobot.resources.opentrons.flex_deck import FlexDeck + +if TYPE_CHECKING: + import httpx +else: + httpx = pytest.importorskip("httpx") + +from pylabrobot.testing.http_server import serving # noqa: E402 + + +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) + + +class TestTransportIsBuiltBeforeCaptureCanBeArmed(unittest.TestCase): + """A robot builds its transport in __init__, not on connect. + + Every pylabrobot io refuses construction while a capture is active, so + building it on connect made the documented recording recipe (construct, + start_capture, setup) die with "Cannot create a new HTTP object while + capture or validation is active" unless the caller passed a transport in. + """ + + def test_a_robot_built_with_no_transport_still_has_one(self): + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test") + self.assertIsInstance(flex._transport, HttpxTransport) + + def test_arming_a_capture_after_construction_does_not_refuse_the_io(self): + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test") + with tempfile.TemporaryDirectory() as tmp: + pylabrobot.start_capture(Path(tmp) / "c.json") + try: + # The io already exists, so nothing here needs to construct one. + self.assertIsInstance(flex._transport, HttpxTransport) + finally: + pylabrobot.stop_capture() + + +class RecordAndReplayTests(unittest.IsolatedAsyncioTestCase): + """A recorded Flex lifecycle replays with nothing on the network. + + The recording is taken over ``HttpxTransport``, which is the path a real + robot uses, so what the replay proves is that the driver reaches the same + state from the capture file alone. + """ + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.capture_file = Path(self._dir.name) / "flex_setup.json" + + def tearDown(self): + self._dir.cleanup() + + async def _record_a_setup(self) -> None: + """Drive setup() over HTTP against a stand-in server, capturing as we go.""" + robot_server = ChatterboxTransport( + pipettes=[("p1000_multi_flex", 8, 5.0, 1000.0, "left")], + gripper=True, + ) + + async def answer(request: httpx.Request) -> httpx.Response: + path = request.url.path + if request.method == "GET": + body = await robot_server.get(path) + elif request.method == "DELETE": + body = await robot_server.delete(path) + else: + sent = json.loads(request.content) if request.content else {} + body = await robot_server.post(path, json=sent) + return httpx.Response(200, json=body) + + # The transport is built before capture starts: every pylabrobot io + # refuses construction while a capture is active. + transport = HttpxTransport(base_url="http://robot.test:31950") + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test", transport=transport) + with serving(answer): + pylabrobot.start_capture(self.capture_file) + try: + await flex.setup() + finally: + pylabrobot.stop_capture() + + async def test_replayed_setup_discovers_the_same_head(self): + await self._record_a_setup() + + replay = ReplayTransport(self.capture_file, base_url="http://robot.test:31950") + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test", transport=replay) + await flex.setup() + + left = flex.left + self.assertIsNotNone(left) + self.assertIsNone(flex.right) + assert left is not None + self.assertEqual(left.channels, 8) + replay.assert_fully_replayed() + + async def test_a_dropped_command_fails_the_replay(self): + """Skipping a step must fail, or a replay could pass while doing less.""" + await self._record_a_setup() + + replay = ReplayTransport(self.capture_file, base_url="http://robot.test:31950") + flex = OpentronsFlex(deck=FlexDeck(), host="robot.test", transport=replay) + await flex.connect() + + with self.assertRaisesRegex(ValidationError, "not fully read"): + replay.assert_fully_replayed() + + +if __name__ == "__main__": + unittest.main() 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..323fe410f6a --- /dev/null +++ b/pylabrobot/resources/opentrons/flex_deck.py @@ -0,0 +1,443 @@ +"""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, cast + +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 --- + + @property + def slots(self) -> Dict[str, Optional[Resource]]: + """The labware in each slot (standard and staging), or ``None`` for empty slots. + + The Flex counterpart of :attr:`OTDeck.slots`, keyed by slot name rather than by position. + """ + return {slot: holder.resource for slot, holder in self._slot_holders.items()} + + @property + def slot_locations(self) -> Dict[str, Coordinate]: + """The front-left corner of each slot in the robot frame, keyed by slot name.""" + return {slot: cast(Coordinate, holder.location) for slot, holder in self._slot_holders.items()} + + def get_slot_holder(self, slot: str) -> ResourceHolder: + """The ResourceHolder for a slot, e.g. as a ``move_plate`` gripper destination.""" + slot = self._validate_slot(slot) + return self._slot_holders[slot] + + def get_slot_at_location(self, location: Coordinate, tolerance: float = 2.0) -> Optional[str]: + """The slot whose front-left corner matches ``location`` (x/y within ``tolerance`` mm). + + The gripper receives a destination as a deck-frame coordinate (the LFB of the moved labware), + never a slot name, so a move resolves its target slot by matching that corner here. + """ + for slot, holder in self._slot_holders.items(): + corner = cast(Coordinate, holder.location) + if abs(corner.x - location.x) <= tolerance and abs(corner.y - location.y) <= tolerance: + return slot + return None + + def get_slot_location(self, slot: str) -> Dict[str, float]: + """Get the XYZ coordinate for a slot.""" + slot = self._validate_slot(slot) + 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_resource( + self, + resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True, + ) -> None: + """Assign a slot holder to the deck, or route labware into its slot. + + The deck's direct children are the slot holders created in ``__init__``; deserialization + re-assigns those holders by name, replacing the placeholder with the loaded one. Labware is + normally placed with :meth:`assign_child_at_slot`. A gripper ``move_plate`` to a bare + :class:`~pylabrobot.resources.Coordinate` also lands here, because the liquid handler assigns + the moved plate to the deck at the destination coordinate; such a resource is routed into the + slot whose corner matches that coordinate, keeping the robot and the resource tree in sync. + """ + + existing = next((child for child in self.children if child.name == resource.name), None) + if existing is not None: + if not reassign: + raise ValueError(f"Resource '{resource.name}' already assigned to deck") + super().unassign_child_resource(existing) + for occupied_slot, holder in self._slot_holders.items(): + if holder is existing: + self._slot_holders[occupied_slot] = cast(ResourceHolder, resource) + break + super().assign_child_resource(resource, location=location, reassign=reassign) + return + + if isinstance(resource, ResourceHolder): + super().assign_child_resource(resource, location=location, reassign=reassign) + return + + slot = self.get_slot_at_location(location) if location is not None else None + if slot is None: + raise ValueError( + f"Cannot assign '{resource.name}' to the deck at {location}: it matches no Flex slot. " + "Place labware with assign_child_at_slot, or move it to a slot holder or slot location." + ) + self.assign_child_at_slot(resource, slot) + + def unassign_child_resource(self, resource: Resource) -> None: + for holder in self._slot_holders.values(): + if holder.resource is resource: + holder.unassign_child_resource(resource) + return + super().unassign_child_resource(resource) + + def assign_child_at_slot(self, resource: Resource, slot: str) -> None: + """Place a resource at a named slot. + + 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.") + + def get_trash_area96(self) -> Trash: + # The Flex has one movable trash bin; the 96 head discards into the same bin as the others. + return self.get_trash_area() + + # --- OT-2 Conversion --- + + @staticmethod + 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_deck_tests.py b/pylabrobot/resources/opentrons/flex_deck_tests.py new file mode 100644 index 00000000000..fcb7eb73621 --- /dev/null +++ b/pylabrobot/resources/opentrons/flex_deck_tests.py @@ -0,0 +1,71 @@ +import unittest + +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.opentrons import FlexDeck + + +class FlexDeckTests(unittest.TestCase): + def test_has_16_slots_with_trash_at_a3(self): + # 12 standard slots A1-D3 plus the 4 column-4 staging slots. + deck = FlexDeck() + self.assertEqual(len(deck.slots), 16) + trash = deck.slots["A3"] + assert trash is not None + self.assertEqual(trash.name, "trash") + self.assertIsNone(deck.slots["A1"]) + + def test_with_trash_false_leaves_a3_empty(self): + deck = FlexDeck(with_trash_bin=False) + self.assertIsNone(deck.slots["A3"]) + + def test_trash_area_96_is_the_same_movable_bin(self): + # The 96 head discards into the one movable trash bin, so the frontend's discard_tips96 + # path (which asks the deck for a 96 trash area) resolves to the same trash. + deck = FlexDeck() + self.assertIs(deck.get_trash_area96(), deck.get_trash_area()) + + def test_d1_is_the_robot_origin(self): + deck = FlexDeck() + self.assertEqual(deck.slot_locations["D1"], Coordinate(0.0, 0.0, 0.0)) + self.assertEqual(deck.slot_locations["A1"], Coordinate(0.0, 321.0, 0.0)) + self.assertEqual(deck.slot_locations["D3"], Coordinate(328.0, 0.0, 0.0)) + + def test_assign_and_get_slot(self): + deck = FlexDeck() + plate = Resource(name="plate", size_x=127.0, size_y=85.0, size_z=14.0) + deck.assign_child_at_slot(plate, "C2") + self.assertIs(deck.slots["C2"], plate) + self.assertEqual(deck.get_slot(plate), "C2") + + def test_occupied_slot_raises(self): + deck = FlexDeck() + deck.assign_child_at_slot(Resource(name="a", size_x=1, size_y=1, size_z=1), "B1") + with self.assertRaises(ValueError): + deck.assign_child_at_slot(Resource(name="b", size_x=1, size_y=1, size_z=1), "B1") + + def test_unknown_slot_raises(self): + deck = FlexDeck() + with self.assertRaises(ValueError): + deck.assign_child_at_slot(Resource(name="x", size_x=1, size_y=1, size_z=1), "E9") + + def test_get_slot_at_location_reverse_lookup(self): + deck = FlexDeck() + # the gripper resolves a destination coordinate (the LFB corner) back to a slot name + self.assertEqual(deck.get_slot_at_location(Coordinate(328.0, 214.0, 0.0)), "B3") + self.assertEqual(deck.get_slot_at_location(Coordinate(328.5, 214.3, 0.0)), "B3") # tolerance + self.assertIsNone(deck.get_slot_at_location(Coordinate(50.0, 50.0, 0.0))) + + def test_coordinate_destination_routes_into_matching_slot(self): + # a gripper move to a bare Coordinate (the slot's corner) must land the plate in that slot's + # holder, matching how the backend resolves the same coordinate, so robot and tree stay in sync + deck = FlexDeck() + plate = Resource(name="plate", size_x=127.0, size_y=85.0, size_z=14.0) + deck.assign_child_resource(plate, location=deck.slot_locations["C2"]) + self.assertIs(deck.slots["C2"], plate) + self.assertEqual(deck.get_slot(plate), "C2") + + def test_offgrid_coordinate_destination_raises(self): + deck = FlexDeck() + plate = Resource(name="plate", size_x=127.0, size_y=85.0, size_z=14.0) + with self.assertRaises(ValueError): + deck.assign_child_resource(plate, location=Coordinate(500.0, 500.0, 0.0)) diff --git a/pylabrobot/resources/opentrons/flex_plates.py b/pylabrobot/resources/opentrons/flex_plates.py new file mode 100644 index 00000000000..d858cb1ceaf --- /dev/null +++ b/pylabrobot/resources/opentrons/flex_plates.py @@ -0,0 +1,125 @@ +"""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, + version: int = 1, +) -> 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. + version: which revision of that definition to load. Stored as + ``ot_version``. Revision 1 is the one every definition has, and the only + one safe to assume, but for much of Opentrons' catalogue it predates the + Flex and states no gripper grip height, so the robot grips at the plate's + mid-height. A later revision usually fixes that and leaves the well + geometry alone. Naming a revision the robot does not hold fails the load, + so raise this only for a plate you know the robot has. + + Returns: + A PLR ``Plate`` with a nominal 96-well grid (see module docstring) and + ``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] + plate.ot_version = version # 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. + + Loads revision 2, the earliest that states a gripper grip height (12.2 mm); + revision 1 states none and the robot grips at the plate's mid-height instead. + The well geometry is the same in both. + """ + return flex_plate( + load_name="corning_96_wellplate_360ul_flat", + name=name, + num_wells=96, + well_volume=360.0, + version=2, + ) 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, + ) diff --git a/pylabrobot/testing/http_server.py b/pylabrobot/testing/http_server.py new file mode 100644 index 00000000000..4b213a58572 --- /dev/null +++ b/pylabrobot/testing/http_server.py @@ -0,0 +1,33 @@ +"""Answer a device driver's HTTP calls from a handler instead of the network. + +Test support for drivers built on :class:`~pylabrobot.io.http.HTTP`: wrap the +part of a test that talks to the device, and every request reaches `handler` +rather than a socket. + + async def robot(request): + return httpx.Response(200, json={"api_version": "8.8.0"}) + + with serving(robot): + await io.setup() + await io.get("/health") +""" + +from typing import Any, Callable, Union +from unittest import mock + +import httpx + +Handler = Callable[[httpx.Request], Union[httpx.Response, Any]] + +# Bound before any patch: the patch target is the httpx module itself, so +# calling httpx.AsyncClient inside the factory would re-enter it. +_REAL_ASYNC_CLIENT = httpx.AsyncClient + + +def serving(handler: Handler): + """Patch the HTTP io's client to answer from `handler`. Sync or async handler.""" + + def make_client(**kwargs: Any) -> httpx.AsyncClient: + return _REAL_ASYNC_CLIENT(transport=httpx.MockTransport(handler), **kwargs) + + return mock.patch("pylabrobot.io.http.httpx.AsyncClient", make_client) diff --git a/pyproject.toml b/pyproject.toml index 06d86e3aef2..9df951e8672 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ usb = ["pyusb", "libusb-package"] ftdi = ["pylibftdi", "pyusb"] hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] -opentrons = ["opentrons-http-api-client==0.2.1"] +opentrons = ["opentrons-http-api-client==0.2.1", "httpx"] sila = ["zeroconf>=0.131.0", "grpcio"] cytation-microscopy = ["numpy>=1.26", "opencv-python", "PyGObject"] pico = ["PyLabRobot[sila]", "opencv-python", "numpy"]