diff --git a/CHANGELOG.md b/CHANGELOG.md index 870ab59605e..39026716c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `Plate`: optional `stacking_z_height` parameter -- the per-plate vertical pitch when plates are stacked directly on top of each other (`size_z` minus the nesting overlap), mirroring `NestedTipRack.stacking_z_height`. Because it is a physical dimension, plates that differ in it no longer compare equal; `Plate` also now serializes `stacking_z_height` and the pre-existing `plate_type` so both round-trip through `deserialize`/`copy`. (#1110) - `ResourceStack`: bare plates stacked in the z direction now nest into one another by their `stacking_z_height` (a stack of `N` identical plates is `size_z + (N - 1) * stacking_z_height` tall, for both `get_size_z()` and child placement). Plates without a `stacking_z_height`, and plates wearing a lid, do not nest, so existing behaviour is unchanged. (#1112) +- Background reader task on `pylabrobot.hamilton.transport.tcp.HamiltonTCPClient` that owns the socket for the session, so `on_event` subscribers receive events between commands and a response arriving with no command waiting is dropped and logged instead of being handed to the next command (#1195). +- Command serialization on `HamiltonTCPClient`: one command is in flight at a time. The lock spans write through terminal response and is released before the response is decoded, because error enrichment sends further commands through the same path (#1195). +- `ObjectRegistry.clear()` (`pylabrobot.hamilton.transport.tcp.introspection`), used to drop path and address mappings that are scoped to a single connected session (#1195). + ### Fixed - Imported `unittest.mock` in `pylabrobot/centrifuge/centrifuge_tests.py` (pre-existing bug that prevented the test class from running). +- `HamiltonTCPClient` no longer retransmits a command after a failed read. A read timeout on a slow motion command previously re-sent it, which could execute the motion twice (#1195). +- `HamiltonTCPClient.setup()` now resets all per-session state (client id, sequence numbers, instrument addresses, object registry) rather than carrying it into the new session, and refuses to run on an already-connected client instead of leaking the socket (#1195). +- `HamiltonTCPClient` no longer recurses without bound when a device fails the introspection queries that error enrichment itself issues. Enrichment is now non-re-entrant and falls back to the static HC_RESULT tables, so a degraded instrument yields a terse error instead of a `RecursionError` (#1195). +- `HamiltonTCPClient` no longer fails every command after the device sends a HARP control frame. MLPrep firmware sends one (options, no HOI body) shortly after registration; it was parsed as a command response and killed the reader. Frames with no routable message are skipped, as are unparsable frames, which is safe because frames are length-prefixed and consumed whole (#1195). + +### Changed + +- `HamiltonTCPClient` no longer reconnects automatically; `auto_reconnect` and `max_reconnect_attempts` are gone from its constructor. Recovery is `await client.stop()` followed by `await client.setup()`, matching every other transport in the library. `is_connected` remains for callers implementing their own policy (#1195). +- `TCPCommand` declares `Response` and `uses_physical_channels` as class attributes instead of the transport inferring them by attribute probing. Commands with per-channel firmware errors must set `uses_physical_channels = True` to raise `ChannelizedError` (#1195). ## 0.2.1 diff --git a/docs/api/pylabrobot.hamilton.rst b/docs/api/pylabrobot.hamilton.rst index 45234392833..8b31c3739db 100644 --- a/docs/api/pylabrobot.hamilton.rst +++ b/docs/api/pylabrobot.hamilton.rst @@ -2,3 +2,10 @@ pylabrobot.hamilton package =========================== + +.. autosummary:: + :toctree: _autosummary + :recursive: + + prep + transport.tcp diff --git a/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb new file mode 100644 index 00000000000..cb88e97654a --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb @@ -0,0 +1,485 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0130a732", + "metadata": {}, + "source": [ + "# Hamilton PREP: Concise demo — teaching needle, liquid transfer, plate movement\n", + "\n", + "Single notebook demonstrating:\n", + "1. **Teaching needle** — Pick up teaching tip, move above plate A1 at safe height, drop tip.\n", + "2. **Dual-channel liquid handling** — Tip pickup, aspirate, and dispense (tip + volume tracking).\n", + "3. **8MPH (`head8`) liquid handling** — Full-column tip pickup, aspirate, and dispense when `has_mph`.\n", + "4. **Plate movement** — CoRe gripper: pick plate from deck[4], drop at deck[2].\n", + "\n", + "**Deck layout:** 1× 50 µL NTR tips at deck[3], 1× plate at deck[4] (moved to deck[2]). Visualizer is rooted on the deck: tip-spot occupancy, well fills, and plate assignment update live when tracking is enabled. Pipette mount state lives on `prep.channels.head` / `prep.head8.head` (printed below; not yet wired into the visualizer pipette panel).\n", + "\n", + "Uses {class}`~pylabrobot.hamilton.prep.prep.Prep` with `prep.channels`, `prep.head8`, and `prep.pick_up_core_grippers()`. Firmware tree / command-signature dumps live in the channel introspection notebooks.\n" + ] + }, + { + "cell_type": "markdown", + "id": "9d5a702a", + "metadata": {}, + "source": [ + "## 1. Imports and config\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "530ed0bf", + "metadata": {}, + "outputs": [], + "source": [ + "import logging\n", + "import sys\n", + "from asyncio import sleep\n", + "\n", + "from pylabrobot.hamilton.prep import Prep\n", + "from pylabrobot.resources import Coordinate, set_tip_tracking, set_volume_tracking\n", + "from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub\n", + "from pylabrobot.resources.hamilton import PrepDeck, hamilton_96_tiprack_50uL_NTR\n", + "from pylabrobot.visualizer import Visualizer\n", + "\n", + "logging.getLogger(\"pylabrobot\").setLevel(logging.INFO)\n", + "logging.getLogger(\"pylabrobot\").handlers.clear()\n", + "handler = logging.StreamHandler(sys.stdout)\n", + "handler.setFormatter(logging.Formatter(\"%(levelname)s - %(message)s\"))\n", + "logging.getLogger(\"pylabrobot\").addHandler(handler)\n", + "\n", + "# Opt-in labware tracking (drives TipSpot / well updates in Visualizer(deck)).\n", + "set_tip_tracking(True)\n", + "set_volume_tracking(True)\n", + "\n", + "HOST = \"192.168.100.102\" # \"127.0.0.1\" For port forwarded connection if set up\n", + "PORT = 2000\n", + "SAFE_HEIGHT_MM_ABOVE_WELL = 20\n" + ] + }, + { + "cell_type": "markdown", + "id": "1c6ddc58", + "metadata": {}, + "source": [ + "## 2. Deck layout and visualizer\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "fb2dbcdd", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Websocket server started at http://127.0.0.1:2122\n", + "File server started at http://127.0.0.1:1338 . Open this URL in your browser.\n" + ] + } + ], + "source": [ + "# PrepDeck: spots 0–7 (column-major). With CoRe grippers mount for plate movement.\n", + "deck = PrepDeck(with_core_grippers=True)\n", + "\n", + "tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name=\"ntr_50\", with_tips=True)\n", + "plate = deck[4] = cor_axy_96_wellplate_500uL_Ub(\"plate\")\n", + "\n", + "visualizer = Visualizer(deck, open_browser=False)\n", + "await visualizer.setup()\n" + ] + }, + { + "cell_type": "markdown", + "id": "9df37796", + "metadata": {}, + "source": [ + "## 3. Prep device and setup\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "7c1b3d1c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Initializing Hamilton connection...\n", + "INFO - Registering Hamilton client...\n", + "INFO - Discovering Hamilton root objects...\n", + "INFO - Discovering Hamilton global objects...\n", + "INFO - Hamilton TCP client setup complete. Client ID: 7, globals: 1\n", + "INFO - MLPrep already initialized, skipping Initialize\n", + "INFO - Discovered 2 Channel Root channel drive pair(s)\n", + "INFO - Hardware config: has_enclosure=True, safe_speeds=False, traverse_height=167.5, deck_bounds=DeckBounds(min_x=0.0, max_x=299.0, min_y=-9.0, max_y=385.0, min_z=19.5, max_z=167.5), deck_sites=6, waste_sites=3, num_channels=2, has_mph=True\n", + "INFO - Channel bounds: [{'x_min': 1.5235061645507812, 'x_max': 300.52349853515625, 'y_min': 0.0, 'y_max': 385.0, 'z_min': 19.5, 'z_max': 167.5}, {'x_min': 1.5235061645507812, 'x_max': 300.52349853515625, 'y_min': -9.0, 'y_max': 376.0, 'z_min': 19.5, 'z_max': 167.5}]\n", + "INFO - V2 aspirate/dispense support: True\n", + "INFO - Discovered 1 MPH Channel Root channel drive pair(s)\n", + "INFO - MPH V2 aspirate/dispense support: True\n" + ] + } + ], + "source": [ + "prep = Prep(deck=deck, host=HOST, port=PORT)\n", + "await prep.setup(smart=True, force_initialize=False)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff331c04", + "metadata": {}, + "outputs": [], + "source": [ + "await prep.set_deck_light(255, 0, 0, 0)\n" + ] + }, + { + "cell_type": "markdown", + "id": "f9d0dd3c", + "metadata": {}, + "source": [ + "## 4. Device snapshot\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "72746142", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "channels=2, has_mph=True, traverse_height=167.5, enclosure=True, safe_speeds=False\n" + ] + } + ], + "source": [ + "cfg = prep.info.config\n", + "print(\n", + " f\"channels={cfg.num_channels}, has_mph={cfg.has_mph}, \"\n", + " f\"traverse_height={cfg.default_traverse_height}, \"\n", + " f\"enclosure={cfg.has_enclosure}, safe_speeds={cfg.safe_speeds_enabled}\"\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "5ec9e02e", + "metadata": {}, + "source": [ + "## 5. Teaching needle: above plate A1 at safe height\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87c8a767", + "metadata": {}, + "outputs": [], + "source": [ + "logging.getLogger(\"pylabrobot\").setLevel(logging.DEBUG)\n", + "\n", + "assert prep.channels is not None\n", + "teaching_tip = deck.get_resource(\"teaching_tip\")\n", + "if not teaching_tip.has_tip():\n", + " teaching_tip.tracker.add_tip(teaching_tip.make_tip(), origin=teaching_tip, commit=True)\n", + "\n", + "await prep.channels.pick_up_tips([teaching_tip], use_channels=[0])\n", + "\n", + "a1 = plate.get_item(\"A1\")\n", + "safe_pos = a1.get_absolute_location(\"c\", \"c\", \"b\") + Coordinate(0, 0, SAFE_HEIGHT_MM_ABOVE_WELL)\n", + "await prep.channels.move_to_position(safe_pos.x, safe_pos.y, safe_pos.z, use_channels=[0])\n", + "await sleep(3)\n", + "\n", + "await prep.channels.drop_tips([teaching_tip], use_channels=[0])\n", + "\n", + "logging.getLogger(\"pylabrobot\").setLevel(logging.INFO)\n" + ] + }, + { + "cell_type": "markdown", + "id": "e5f4ee40", + "metadata": {}, + "source": [ + "## 6. Tip pickup, aspirate, dispense (dual channel)\n", + "\n", + "Tip and volume tracking are on: tip spots and well fills update in the visualizer; `prep.channels.head` holds mount identity (printed below).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "86c2325e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "before pick\n", + " tip spots have tip: [True, True]\n", + " mounted tips: [None, None]\n", + " src volumes: [100.0, 100.0]\n", + " dst volumes: [0, 0]\n", + " tip volumes: [None, None]\n", + "after pick\n", + " tip spots have tip: [False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [100.0, 100.0]\n", + " dst volumes: [0, 0]\n", + " tip volumes: [0, 0]\n", + "after aspirate\n", + " tip spots have tip: [False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [65.0, 75.0]\n", + " dst volumes: [0, 0]\n", + " tip volumes: [35.0, 25.0]\n", + "after dispense\n", + " tip spots have tip: [False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B1#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [65.0, 75.0]\n", + " dst volumes: [35.0, 25.0]\n", + " tip volumes: [0.0, 0.0]\n", + "after drop\n", + " tip spots have tip: [True, True]\n", + " mounted tips: [None, None]\n", + " src volumes: [65.0, 75.0]\n", + " dst volumes: [35.0, 25.0]\n", + " tip volumes: [None, None]\n" + ] + } + ], + "source": [ + "assert prep.channels is not None\n", + "tip_spots = tip_rack[\"A1:B1\"]\n", + "channels = [0, 1]\n", + "src = plate[\"A1:B1\"]\n", + "dst = plate[\"A7:B7\"]\n", + "vols = [35.0, 25.0]\n", + "\n", + "for well in src:\n", + " well.tracker.set_volume(100.0)\n", + "\n", + "def _status(label: str) -> None:\n", + " print(label)\n", + " print(f\" tip spots have tip: {[s.has_tip() for s in tip_spots]}\")\n", + " print(f\" mounted tips: {prep.channels.get_mounted_tips()}\")\n", + " print(f\" src volumes: {[w.tracker.get_used_volume() for w in src]}\")\n", + " print(f\" dst volumes: {[w.tracker.get_used_volume() for w in dst]}\")\n", + " tips = [prep.channels.head[ch].get_tip() if prep.channels.head[ch].has_tip else None for ch in channels]\n", + " print(f\" tip volumes: {[t.tracker.get_used_volume() if t is not None else None for t in tips]}\")\n", + "\n", + "_status(\"before pick\")\n", + "await prep.channels.pick_up_tips(tip_spots, use_channels=channels)\n", + "_status(\"after pick\")\n", + "\n", + "await prep.channels.aspirate(\n", + " src,\n", + " vols=vols,\n", + " use_channels=channels,\n", + " liquid_height=[3.0, 3.0],\n", + " z_liquid_exit_speed=[25.0, 25.0],\n", + ")\n", + "_status(\"after aspirate\")\n", + "\n", + "await prep.channels.dispense(\n", + " dst,\n", + " vols=vols,\n", + " use_channels=channels,\n", + " liquid_height=[3.0, 3.0],\n", + " z_liquid_exit_speed=[25.0, 25.0],\n", + ")\n", + "_status(\"after dispense\")\n", + "\n", + "await prep.channels.drop_tips(tip_spots, use_channels=channels)\n", + "_status(\"after drop\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "31fa0172", + "metadata": {}, + "source": [ + "## 7. Tip pickup, aspirate, dispense (8MPH / head8)\n", + "\n", + "Skipped when `prep.head8` is None. Uses tip rack `A2:H2` and plate `A2:H2` → `A4:H4` so it does not collide with the dual-channel wells above. All 8 probes operate together.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "11e1622b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "before pick8\n", + " tip spots have tip: [True, True, True, True, True, True, True, True]\n", + " mounted tips: [None, None, None, None, None, None, None, None]\n", + " src volumes: [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]\n", + " dst volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + " tip volumes: [None, None, None, None, None, None, None, None]\n", + "INFO - [Prep MPH] pick_up_tips: rack=ntr_50, tip_spots=['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2']\n", + "after pick8\n", + " tip spots have tip: [False, False, False, False, False, False, False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_C2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_D2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_E2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_F2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_G2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_H2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]\n", + " dst volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + " tip volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + "INFO - [Prep MPH] aspirate: resource=plate, wells=['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2'], volume=15.000, flow_rate=100.0\n", + "after aspirate8\n", + " tip spots have tip: [False, False, False, False, False, False, False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_C2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_D2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_E2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_F2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_G2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_H2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0]\n", + " dst volumes: [0, 0, 0, 0, 0, 0, 0, 0]\n", + " tip volumes: [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0]\n", + "INFO - [Prep MPH] dispense: resource=plate, wells=['A4', 'B4', 'C4', 'D4', 'E4', 'F4', 'G4', 'H4'], volume=15.000, flow_rate=100.0\n", + "after dispense8\n", + " tip spots have tip: [False, False, False, False, False, False, False, False]\n", + " mounted tips: [HamiltonTip(name='ntr_50_tipspot_A2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_B2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_C2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_D2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_E2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_F2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_G2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK), HamiltonTip(name='ntr_50_tipspot_H2#0', tip_size=STANDARD_VOLUME, has_filter=False, nominal_volume=50, maximal_volume=65, fitting_depth=8, total_tip_length=50.4, pickup_method=OUT_OF_RACK)]\n", + " src volumes: [85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0]\n", + " dst volumes: [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0]\n", + " tip volumes: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]\n", + "INFO - [Prep MPH] drop_tips: dest=ntr_50, resources=['A2', 'B2', 'C2', 'D2', 'E2', 'F2', 'G2', 'H2']\n", + "after drop8\n", + " tip spots have tip: [True, True, True, True, True, True, True, True]\n", + " mounted tips: [None, None, None, None, None, None, None, None]\n", + " src volumes: [85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0, 85.0]\n", + " dst volumes: [15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0, 15.0]\n", + " tip volumes: [None, None, None, None, None, None, None, None]\n" + ] + } + ], + "source": [ + "if prep.head8 is None:\n", + " print(\"head8 not present (has_mph=False); skipping 8MPH transfer\")\n", + "else:\n", + " tip_spots8 = tip_rack[\"A2:H2\"]\n", + " src8 = plate[\"A2:H2\"]\n", + " dst8 = plate[\"A4:H4\"]\n", + " vol8 = 15.0\n", + " for well in src8:\n", + " well.tracker.set_volume(100.0)\n", + "\n", + " def _status8(label: str) -> None:\n", + " print(label)\n", + " print(f\" tip spots have tip: {[s.has_tip() for s in tip_spots8]}\")\n", + " print(f\" mounted tips: {prep.head8.get_mounted_tips()}\")\n", + " print(f\" src volumes: {[w.tracker.get_used_volume() for w in src8]}\")\n", + " print(f\" dst volumes: {[w.tracker.get_used_volume() for w in dst8]}\")\n", + " tips = [\n", + " prep.head8.head[i].get_tip() if prep.head8.head[i].has_tip else None\n", + " for i in range(8)\n", + " ]\n", + " print(f\" tip volumes: {[t.tracker.get_used_volume() if t is not None else None for t in tips]}\")\n", + "\n", + " _status8(\"before pick8\")\n", + " await prep.head8.pick_up_tips8(tip_spots8)\n", + " _status8(\"after pick8\")\n", + "\n", + " await prep.head8.aspirate8(wells=src8, volume=vol8, liquid_height=3.0)\n", + " _status8(\"after aspirate8\")\n", + "\n", + " await prep.head8.dispense8(wells=dst8, volume=vol8, liquid_height=3.0)\n", + " _status8(\"after dispense8\")\n", + "\n", + " await prep.head8.drop_tips8(tip_spots8)\n", + " _status8(\"after drop8\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "6100111d", + "metadata": {}, + "source": [ + "## 8. Plate movement with CoRe gripper (deck[4] → deck[2])\n", + "\n", + "`drop_resource` reassigns the plate in the resource tree; the visualizer moves it to deck[2].\n" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "c5f5981d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "plate parent: spot_0_2\n" + ] + } + ], + "source": [ + "arm = await prep.pick_up_core_grippers()\n", + "await arm.pick_up_resource(plate)\n", + "await arm.drop_resource(deck[2])\n", + "await prep.return_core_grippers()\n", + "print(f\"plate parent: {plate.parent.name if plate.parent is not None else None}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "6eafd340", + "metadata": {}, + "source": [ + "## 9. Teardown\n" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "907a9ce2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Closing connection to socket 127.0.0.1:2000\n", + "INFO - Hamilton TCP client stopped\n" + ] + } + ], + "source": [ + "await prep.park()\n", + "#await prep.disco_mode()\n", + "await prep.stop()\n", + "await visualizer.stop()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "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.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/hamilton/index.md b/docs/user_guide/hamilton/index.md index b3d980e3e2c..59a1985d6d6 100644 --- a/docs/user_guide/hamilton/index.md +++ b/docs/user_guide/hamilton/index.md @@ -4,4 +4,5 @@ :maxdepth: 1 star/index +prep/index ``` diff --git a/docs/user_guide/hamilton/prep/index.md b/docs/user_guide/hamilton/prep/index.md new file mode 100644 index 00000000000..9868eb077c1 --- /dev/null +++ b/docs/user_guide/hamilton/prep/index.md @@ -0,0 +1,7 @@ +# Prep + +```{toctree} +:maxdepth: 1 + +../../00_liquid-handling/hamilton-prep/prep_basic_demo +``` diff --git a/pylabrobot/hamilton/liquid_class_resolver.py b/pylabrobot/hamilton/liquid_class_resolver.py new file mode 100644 index 00000000000..207dccf84bf --- /dev/null +++ b/pylabrobot/hamilton/liquid_class_resolver.py @@ -0,0 +1,100 @@ +"""Resolve Hamilton liquid classes and corrected volumes for Prep PIP ops. + +Automatic lookup defaults to +:func:`~pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star.get_star_liquid_class` +(STAR calibration tables); pass ``lookup=`` for instrument-specific tables. +""" + +from __future__ import annotations + +from typing import Any, Callable, List, Optional, Sequence, Union + +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.resources.hamilton import HamiltonTip +from pylabrobot.resources.liquid import Liquid + +_Lookup = Callable[..., Optional[HamiltonLiquidClass]] + + +def resolve_hamilton_liquid_classes( + explicit: Optional[List[Optional[HamiltonLiquidClass]]], + ops: list, + *, + jet: Union[bool, List[bool]] = False, + blow_out: Union[bool, List[bool]] = False, + is_aspirate: bool = True, + lookup: Optional[_Lookup] = None, +) -> List[Optional[HamiltonLiquidClass]]: + """Resolve per-op Hamilton liquid classes. + + If ``explicit`` is None, resolve from each op's tip via ``lookup`` (default + :func:`get_star_liquid_class`). Non-``HamiltonTip`` tips yield ``None``. + + If ``explicit`` is a list, it is returned as a shallow copy; ``None`` entries + are preserved (legacy STAR behavior). + + Args: + explicit: Caller-provided liquid classes, or None for automatic lookup. + ops: Aspiration or dispense operations (must have a ``tip`` attribute). + jet: Per-op or scalar flags passed to automatic liquid class lookup. + blow_out: Per-op or scalar flags passed to automatic liquid class lookup. + is_aspirate: Reserved for API compatibility with STAR; unused. + lookup: Optional callable with the same signature as ``get_star_liquid_class``. + """ + del is_aspirate + n = len(ops) + if isinstance(jet, bool): + jet = [jet] * n + if isinstance(blow_out, bool): + blow_out = [blow_out] * n + + if explicit is not None: + return list(explicit) + + if lookup is None: + # Lazy import avoids circular import: star package __init__ may pull in pip_backend, + # which imports this module. + from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star import get_star_liquid_class + + fn = get_star_liquid_class + else: + fn = lookup + result: List[Optional[HamiltonLiquidClass]] = [] + for i, op in enumerate(ops): + tip = op.tip + if not isinstance(tip, HamiltonTip): + result.append(None) + continue + result.append( + fn( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=jet[i], + blow_out=blow_out[i], + ) + ) + + return result + + +def corrected_volumes_for_ops( + ops: Sequence[Any], + hlcs: Sequence[Optional[HamiltonLiquidClass]], + disable_volume_correction: Optional[Sequence[bool]] = None, +) -> List[float]: + """Apply liquid-class volume correction per op when enabled.""" + n = len(ops) + if len(hlcs) != n: + raise ValueError(f"hlcs length must match ops ({n}), got {len(hlcs)}") + dvc = list(disable_volume_correction) if disable_volume_correction is not None else [False] * n + if len(dvc) != n: + raise ValueError(f"disable_volume_correction length must match ops ({n}), got {len(dvc)}") + return [ + float(hlc.compute_corrected_volume(op.volume)) + if hlc is not None and not disabled + else float(op.volume) + for op, hlc, disabled in zip(ops, hlcs, dvc) + ] diff --git a/pylabrobot/hamilton/prep/__init__.py b/pylabrobot/hamilton/prep/__init__.py index cc0e36dfd8e..0e2f21016a0 100644 --- a/pylabrobot/hamilton/prep/__init__.py +++ b/pylabrobot/hamilton/prep/__init__.py @@ -1 +1,13 @@ -"""Hamilton Prep support.""" +"""Hamilton Prep liquid handler.""" + +from pylabrobot.hamilton.prep.calibration import PrepCalibration +from pylabrobot.hamilton.prep.chatterbox import PrepChatterboxClient +from pylabrobot.hamilton.prep.client import PrepClient +from pylabrobot.hamilton.prep.prep import Prep + +__all__ = [ + "Prep", + "PrepCalibration", + "PrepChatterboxClient", + "PrepClient", +] diff --git a/pylabrobot/hamilton/prep/calibration.py b/pylabrobot/hamilton/prep/calibration.py new file mode 100644 index 00000000000..33217d9e2a3 --- /dev/null +++ b/pylabrobot/hamilton/prep/calibration.py @@ -0,0 +1,587 @@ +"""Prep calibration: MLPrepCalibration commands and session workflows. + +Firmware-path resolution is JIT: each ``PrepCommand`` subclass declares its own +``firmware_path``, and :meth:`PrepClient.execute` resolves it via the +introspection registry (cache-hot after the first call). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Awaitable, + Callable, + List, + Literal, + Optional, + Tuple, + TypeVar, + Union, +) + +from pylabrobot.resources.tip_rack import TipSpot + +from . import prep_commands as PrepCmd + +if TYPE_CHECKING: + from .client import PrepClient + from .info import PrepInstrumentInfo + +logger = logging.getLogger(__name__) + +_TCalibResult = TypeVar("_TCalibResult") + +# Same mapping as Prep channels for TipPositionParameters / channel indices. +_CHANNEL_INDEX = { + 0: PrepCmd.ChannelIndex.RearChannel, + 1: PrepCmd.ChannelIndex.FrontChannel, +} + + +@dataclass(frozen=True) +class CalibrationCommandReport: + """Structured report for one calibration command execution.""" + + command: str + result: object + before: PrepCmd.CalibrationValues + after: PrepCmd.CalibrationValues + diff: PrepCmd.CalibrationValuesDiff + + @property + def changed_fields_count(self) -> int: + channel_changes = sum( + len(cd.changes) for cd in self.diff.channel_diffs if cd.state == "changed" + ) + return ( + len(self.diff.top_level_changes) + + channel_changes + + sum(1 for cd in self.diff.channel_diffs if cd.state in ("added", "removed")) + ) + + +class PrepCalibration: + """Calibration façade: firmware MLPrepCalibration object + DeckConfiguration site defs.""" + + def __init__(self, *, driver: "PrepClient", info: "PrepInstrumentInfo") -> None: + self._driver = driver + self._info = info + self._calibration_session_active: bool = False + + @property + def client(self) -> "PrepClient": + """Alias for code that uses ``client.execute`` (driver is the TCP client).""" + return self._driver + + @property + def num_channels(self) -> int: + n = self._info.config.num_channels + if n is None: + raise RuntimeError("Instrument config has no num_channels (finish Prep.setup first).") + return n + + @property + def has_mph(self) -> bool: + h = self._info.config.has_mph + if h is None: + raise RuntimeError("Instrument config has no has_mph (finish Prep.setup first).") + return h + + def _set_calibration_session_active(self, active: bool) -> None: + self._calibration_session_active = active + + def calibration_session( + self, + *, + float_tol: float = 1e-6, + report_after_command: bool = True, + report_scope: Literal["related", "full"] = "related", + session_read_timeout: Optional[float] = None, + ) -> PrepCalibrationSession: + """Create a managed calibration session bound to this façade.""" + return PrepCalibrationSession( + self, + float_tol=float_tol, + report_after_command=report_after_command, + report_scope=report_scope, + session_read_timeout=session_read_timeout, + ) + + async def get_calibration_site_definitions(self) -> Tuple[PrepCmd.CalibrationSiteInfo, ...]: + """Return calibration site definitions from DeckConfiguration (GetCalibrationSiteDefinitions, cmd=3).""" + result = await self._driver.execute(PrepCmd.PrepGetCalibrationSiteDefinitions()) + if result is None or not result.sites: + return () + return tuple( + PrepCmd.CalibrationSiteInfo( + id=int(s.id), + left_bottom_front_x=float(s.left_bottom_front_x), + left_bottom_front_y=float(s.left_bottom_front_y), + left_bottom_front_z=float(s.left_bottom_front_z), + length=float(s.length), + width=float(s.width), + height=float(s.height), + post=bool(s.post), + ) + for s in result.sites + ) + + async def begin_calibration(self) -> None: + """Enter calibration mode (BeginCalibration, cmd=1).""" + await self._driver.execute(PrepCmd.PrepBeginCalibration()) + + async def cancel_calibration(self) -> None: + """Cancel an active calibration session (CancelCalibration, cmd=2).""" + await self._driver.execute(PrepCmd.PrepCancelCalibration()) + + async def end_calibration(self, date_time: Optional[PrepCmd.HoiDateTime] = None) -> None: + """End calibration and store results with timestamp (EndCalibration, cmd=3).""" + if date_time is None: + date_time = PrepCmd.HoiDateTime.now() + await self._driver.execute(PrepCmd.PrepEndCalibration(date_time=date_time)) + + async def reset_calibration(self, store: bool = False) -> None: + """Reset calibration data (ResetCalibration, cmd=4).""" + await self._driver.execute(PrepCmd.PrepResetCalibration(store=store)) + + async def calibration_initialize(self) -> None: + """Initialize calibration hardware (CalibrationInitialize, cmd=5).""" + await self._driver.execute(PrepCmd.PrepCalibrationInitialize()) + + async def read_calibration_values( + self, read_timeout: Optional[float] = None + ) -> PrepCmd.CalibrationValues: + """Read calibration values (GetCalibrationValues, cmd=16).""" + result = await self._driver.execute( + PrepCmd.PrepGetCalibrationValues(), + read_timeout=read_timeout, + ) + if result is None: + return PrepCmd.CalibrationValues( + independent_offset_x=0.0, + mph_offset_x=0.0, + channel_values=(), + ) + + return PrepCmd.CalibrationValues( + independent_offset_x=float(result.independent_offset_x), + mph_offset_x=float(result.mph_offset_x), + channel_values=tuple( + PrepCmd.ChannelCalibrationValuesInfo( + index=int(cv.index), + y_offset=float(cv.y_offset), + z_offset=float(cv.z_offset), + squeeze_position=int(cv.squeeze_position), + z_touchoff=int(cv.z_touchoff), + pressure_shift=int(cv.pressure_shift), + pressure_monitoring_shift=int(cv.pressure_monitoring_shift), + dispenser_return_distance=float(cv.dispenser_return_distance), + z_tip_height=float(cv.z_tip_height), + core_ii=bool(cv.core_ii), + ) + for cv in (result.channel_values or []) + ), + ) + + +class PrepCalibrationSession: + """Context manager for stateful Prep calibration workflows.""" + + def __init__( + self, + cal: PrepCalibration, + *, + float_tol: float = 1e-6, + report_after_command: bool = True, + report_scope: Literal["related", "full"] = "related", + session_read_timeout: Optional[float] = None, + ) -> None: + self._cal = cal + self.float_tol = float_tol + self.report_after_command = report_after_command + self.report_scope = report_scope + self.session_read_timeout = session_read_timeout + + self._started = False + self._ended = False + self._baseline: Optional[PrepCmd.CalibrationValues] = None + self._last_snapshot: Optional[PrepCmd.CalibrationValues] = None + self.history: List[CalibrationCommandReport] = [] + + if report_scope not in ("related", "full"): + raise ValueError(f"report_scope must be 'related' or 'full', got: {report_scope}") + + @property + def baseline(self) -> PrepCmd.CalibrationValues: + if self._baseline is None: + raise RuntimeError("Session baseline unavailable. Enter the session first.") + return self._baseline + + @property + def last_snapshot(self) -> PrepCmd.CalibrationValues: + if self._last_snapshot is None: + raise RuntimeError("Session snapshot unavailable. Enter the session first.") + return self._last_snapshot + + def _effective_timeout(self, read_timeout: Optional[float]) -> Optional[float]: + return self.session_read_timeout if read_timeout is None else read_timeout + + def _ensure_started(self) -> None: + if not self._started: + raise RuntimeError("Calibration session is not started. Call `await session.start()` first.") + if self._ended: + raise RuntimeError("Calibration session is already ended.") + + def _select_snapshot_scope( + self, + values: PrepCmd.CalibrationValues, + *, + channel: Optional[PrepCmd.ChannelIndex] = None, + ) -> PrepCmd.CalibrationValues: + if self.report_scope == "full" or channel is None: + return values + channel_index = int(channel) + return PrepCmd.CalibrationValues( + independent_offset_x=values.independent_offset_x, + mph_offset_x=values.mph_offset_x, + channel_values=tuple(cv for cv in values.channel_values if cv.index == channel_index), + ) + + def _log_report(self, report: CalibrationCommandReport) -> None: + if report.diff.has_changes: + logger.info( + "Calibration session %s changed %d field(s)", + report.command, + report.changed_fields_count, + ) + else: + logger.info("Calibration session %s produced no calibration changes", report.command) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "Calibration report diff for %s:\n%s", + report.command, + PrepCmd.format_calibration_diff(report.diff), + ) + + async def _get_calibration_values( + self, + *, + read_timeout: Optional[float] = None, + ) -> PrepCmd.CalibrationValues: + return await self._cal.read_calibration_values(read_timeout=read_timeout) + + async def _run_with_report( + self, + command_name: str, + op: Callable[[Optional[float]], Awaitable[_TCalibResult]], + *, + channel: Optional[PrepCmd.ChannelIndex] = None, + read_timeout: Optional[float] = None, + ) -> Union[_TCalibResult, CalibrationCommandReport]: + timeout = self._effective_timeout(read_timeout) + if not self.report_after_command: + result = await op(timeout) + self._last_snapshot = await self._get_calibration_values(read_timeout=timeout) + return result + + before_full = await self._get_calibration_values(read_timeout=timeout) + result = await op(timeout) + after_full = await self._get_calibration_values(read_timeout=timeout) + self._last_snapshot = after_full + + before = self._select_snapshot_scope(before_full, channel=channel) + after = self._select_snapshot_scope(after_full, channel=channel) + diff = PrepCmd.diff_calibration_values(before, after, float_tol=self.float_tol) + report = CalibrationCommandReport( + command=command_name, + result=result, + before=before, + after=after, + diff=diff, + ) + self.history.append(report) + self._log_report(report) + return report + + async def __aenter__(self) -> PrepCalibrationSession: + await self.start() + return self + + async def start(self) -> PrepCalibrationSession: + """Start calibration mode and capture baseline snapshot.""" + if self._started: + return self + if self._ended: + raise RuntimeError("Calibration session is already ended; create a new session.") + if self._cal._calibration_session_active: + raise RuntimeError("A calibration session is already active on this PrepCalibration.") + await self._cal.begin_calibration() + await self._cal.calibration_initialize() + self._cal._set_calibration_session_active(True) + try: + snapshot = await self._get_calibration_values(read_timeout=self.session_read_timeout) + except Exception: + self._cal._set_calibration_session_active(False) + raise + self._baseline = snapshot + self._last_snapshot = snapshot + self._started = True + logger.info("Calibration session started") + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + if self._ended: + return False + try: + await self.end(save=False) + except Exception: + logger.exception("Failed to rollback calibration session") + if exc is None: + raise + return False + + async def snapshot(self, *, read_timeout: Optional[float] = None) -> PrepCmd.CalibrationValues: + self._ensure_started() + snapshot = await self._get_calibration_values( + read_timeout=self._effective_timeout(read_timeout) + ) + self._last_snapshot = snapshot + return snapshot + + async def diff_from_start( + self, + *, + float_tol: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> PrepCmd.CalibrationValuesDiff: + self._ensure_started() + current = await self.snapshot(read_timeout=read_timeout) + return PrepCmd.diff_calibration_values( + self.baseline, + current, + float_tol=self.float_tol if float_tol is None else float_tol, + ) + + async def diff_from_last( + self, + *, + float_tol: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> PrepCmd.CalibrationValuesDiff: + self._ensure_started() + previous = self.last_snapshot + current = await self.snapshot(read_timeout=read_timeout) + return PrepCmd.diff_calibration_values( + previous, + current, + float_tol=self.float_tol if float_tol is None else float_tol, + ) + + async def end( + self, *, save: bool = True, date_time: Optional[PrepCmd.HoiDateTime] = None + ) -> None: + """End the calibration session, optionally saving values.""" + if self._ended: + return + self._ensure_started() + if save: + await self._cal.end_calibration(date_time=date_time) + logger.info("Calibration session ended and saved") + else: + await self._cal.cancel_calibration() + logger.info("Calibration session ended without saving") + self._ended = True + self._started = False + self._cal._set_calibration_session_active(False) + + async def rollback(self) -> None: + """End the session without saving (alias for ``end(save=False)``).""" + await self.end(save=False) + + async def commit(self) -> None: + """Save calibration and end the session (alias for ``end(save=True)``).""" + await self.end(save=True) + + async def reset(self, *, store: bool = False) -> None: + """Reset calibration values during an active calibration session.""" + self._ensure_started() + await self._cal.reset_calibration(store=store) + self._last_snapshot = await self._get_calibration_values(read_timeout=self.session_read_timeout) + + async def calibrate_x_axis( + self, + *, + site_index: int, + channel: PrepCmd.ChannelIndex, + read_timeout: Optional[float] = None, + ) -> Union[float, CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> float: + result = await self._cal.client.execute( + PrepCmd.PrepCalibrateXAxis( + site_index=site_index, + channel=int(channel), + ), + read_timeout=timeout, + ) + return float(result.offset) + + return await self._run_with_report( + f"calibrate_x_axis(channel={channel.name}, site_index={site_index})", + _op, + channel=channel, + read_timeout=read_timeout, + ) + + async def calibrate_y_axis( + self, + *, + site_index: int, + channel: PrepCmd.ChannelIndex, + read_timeout: Optional[float] = None, + ) -> Union[float, CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> float: + result = await self._cal.client.execute( + PrepCmd.PrepCalibrateYAxis( + site_index=site_index, + channel=int(channel), + ), + read_timeout=timeout, + ) + return float(result.offset) + + return await self._run_with_report( + f"calibrate_y_axis(channel={channel.name}, site_index={site_index})", + _op, + channel=channel, + read_timeout=read_timeout, + ) + + async def calibrate_z_axis( + self, + *, + site_index: int, + channel: PrepCmd.ChannelIndex, + read_timeout: Optional[float] = None, + ) -> Union[float, CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> float: + result = await self._cal.client.execute( + PrepCmd.PrepCalibrateZAxis( + site_index=site_index, + channel=int(channel), + ), + read_timeout=timeout, + ) + return float(result.offset) + + return await self._run_with_report( + f"calibrate_z_axis(channel={channel.name}, site_index={site_index})", + _op, + channel=channel, + read_timeout=read_timeout, + ) + + async def calibrate_squeeze_tips( + self, + tip_spots: List[TipSpot], + *, + use_channels: Optional[List[int]] = None, + z_seek_offset: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> Union[Tuple[int, ...], CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> Tuple[int, ...]: + channels = use_channels if use_channels is not None else list(range(len(tip_spots))) + assert len(tip_spots) == len(channels) + + indexed_spots = {ch: spot for ch, spot in zip(channels, tip_spots)} + tip_positions: List[PrepCmd.TipPositionParameters] = [] + for ch in range(self._cal.num_channels): + if ch not in indexed_spots: + continue + spot = indexed_spots[ch] + loc = spot.get_absolute_location("c", "c", "t") + tip_positions.append( + PrepCmd.TipPositionParameters.for_op( + _CHANNEL_INDEX[ch], + loc, + spot.get_tip(), + z_seek_offset=z_seek_offset, + ) + ) + + result = await self._cal.client.execute( + PrepCmd.PrepCalibrateSqueezeTips( + channels=tip_positions, + ), + read_timeout=timeout, + ) + if result is None or not result.positions: + return () + return tuple(int(p) for p in result.positions) + + return await self._run_with_report( + "calibrate_squeeze_tips", + _op, + read_timeout=read_timeout, + ) + + async def calibrate_squeeze_tips_mph( + self, + tip_spot: Union[TipSpot, List[TipSpot]], + *, + z_seek_offset: Optional[float] = None, + read_timeout: Optional[float] = None, + ) -> Union[Tuple[int, ...], CalibrationCommandReport]: + self._ensure_started() + + async def _op(timeout: Optional[float]) -> Tuple[int, ...]: + if not self._cal.has_mph: + raise RuntimeError( + "Instrument does not have an 8MPH head. Cannot use calibrate_squeeze_tips_mph." + ) + spots = tip_spot if isinstance(tip_spot, list) else [tip_spot] + if not spots: + raise ValueError("calibrate_squeeze_tips_mph: tip_spot list is empty") + + ref_spot = spots[0] + loc = ref_spot.get_absolute_location("c", "c", "t") + tip_position = PrepCmd.TipPositionParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, + loc, + ref_spot.get_tip(), + z_seek_offset=z_seek_offset, + ) + + result = await self._cal.client.execute( + PrepCmd.PrepCalibrateSqueezeTips( + channels=[tip_position], + ), + read_timeout=timeout, + ) + if result is None or not result.positions: + return () + return tuple(int(p) for p in result.positions) + + return await self._run_with_report( + "calibrate_squeeze_tips_mph", + _op, + channel=PrepCmd.ChannelIndex.MPHChannel, + read_timeout=read_timeout, + ) + + +__all__ = [ + "CalibrationCommandReport", + "PrepCalibration", + "PrepCalibrationSession", +] diff --git a/pylabrobot/hamilton/prep/channels.py b/pylabrobot/hamilton/prep/channels.py new file mode 100644 index 00000000000..8fce772ee5a --- /dev/null +++ b/pylabrobot/hamilton/prep/channels.py @@ -0,0 +1,2522 @@ +"""PrepChannels: dual-channel pipettor ops plus per-channel discovery. + +Channel-scoped topology discovery, bounds parsing, and per-channel firmware +queries live alongside tip pickup/drop and aspirate/dispense orchestration. + +The firmware object tree exposes channel internals as a single template under +``MLPrepRoot.Channel Root.Channel`` (and an analogous ``MLPrepRoot.MPH Channel +Root.Channel`` for MPH). Individual physical channels share that template — +per-channel identity lives in the node-ID component of the Address. We probe +the full object tree and match children by **path prefix** +(``".Channel Root.Channel.Squeeze.SDrive"``) rather than computing node +IDs directly. +""" + +from __future__ import annotations + +import enum +import logging +import math +import struct as _struct +from dataclasses import dataclass +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Generic, + List, + Literal, + NamedTuple, + Optional, + Sequence, + Tuple, + TypedDict, + TypeVar, + Union, +) + +from pylabrobot.hamilton.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) +from pylabrobot.hamilton.transport.tcp.hoi_error import HoiError +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.resources import Container, Coordinate, Tip +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers +from pylabrobot.resources.resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + all_channels_succeeded, + finalize_tip_ops, + finalize_volume_ops, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.well import CrossSectionType, Well + +from . import prep_commands as PrepCmd +from .client import PIPETTOR_OBJECT_PATH + +if TYPE_CHECKING: + from pylabrobot.resources.deck import Deck + + from .client import PrepClient + from .info import PrepInstrumentInfo + +logger = logging.getLogger(__name__) + +_T = TypeVar("_T") + + +@dataclass +class _PipetteTransfer: + """Private snapshot for aspirate/dispense resolution (not a public standard op).""" + + resource: Container + tip: Tip + volume: float + offset: Coordinate + liquid_height: Optional[float] = None + flow_rate: Optional[float] = None + blow_out_air_volume: Optional[float] = None + + +_OpT = TypeVar("_OpT", bound=_PipetteTransfer) + + +# ============================================================================= +# Shared pure helpers (also imported by PrepHead8) +# ============================================================================= + + +def fill_in_defaults(val: Optional[List[_T]], default: List[_T]) -> List[_T]: + """Convert optional per-channel overrides into a full list matching ``default`` length.""" + if val is None: + return default + if len(val) != len(default): + raise ValueError(f"Value length must equal num operations ({len(default)}), but is {len(val)}") + return [v if v is not None else d for v, d in zip(val, default)] + + +class LLDMode(enum.Enum): + """Liquid level detection mode. + + Same numbering as STARBackend.LLDMode for cross-backend compatibility. + CAPACITIVE (value=1) is named GAMMA on the STAR — CAPACITIVE is the correct term. + The Prep firmware uses separate command variants for LLD vs no-LLD, so all + channels in a single aspirate/dispense call must use the same mode category + (any LLD mode, or OFF). + """ + + OFF = 0 + CAPACITIVE = 1 # STARBackend.LLDMode.GAMMA — capacitive (cLLD) + PRESSURE = 2 # pressure-based (pLLD) + DUAL = 3 # both capacitive and pressure + + +@dataclass(frozen=True) +class _LldDefaults: + """Resolved pLLD / cLLD parameter pair (shared between aspirate and dispense).""" + + p_lld: PrepCmd.PLldParameters + c_lld: PrepCmd.CLldParameters + + +def default_lld_params( + effective_lld: bool, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, +) -> _LldDefaults: + """Build resolved pLLD / cLLD defaults. + + When LLD is active and no caller override is given, returns non-default + parameters (``default_values=False``) so the firmware actually triggers + detection. Otherwise returns firmware defaults. + """ + if effective_lld: + resolved_p = p_lld or PrepCmd.PLldParameters( + default_values=False, + sensitivity=1, + dispenser_seek_speed=0.0, + lld_height_difference=0.0, + detect_mode=0, + ) + resolved_c = c_lld or PrepCmd.CLldParameters( + default_values=False, + sensitivity=4, + clot_check_enable=False, + z_clot_check=0.0, + detect_mode=0, + ) + else: + resolved_p = p_lld or PrepCmd.PLldParameters.default() + resolved_c = c_lld or PrepCmd.CLldParameters.default() + return _LldDefaults(p_lld=resolved_p, c_lld=resolved_c) + + +def lld_for_well( + effective_lld: bool, lld: Optional[PrepCmd.LldParameters], top_of_well_z: float +) -> PrepCmd.LldParameters: + """Per-channel LLD seek parameters from caller override or well geometry.""" + if effective_lld and lld is None: + return PrepCmd.LldParameters( + default_values=False, + search_start_position=top_of_well_z, + channel_speed=5.0, + z_submerge=2.0, + z_out_of_liquid=0.0, + ) + return lld or PrepCmd.LldParameters.default() + + +def segments_to_cone_geometry( + segments: list[PrepCmd.SegmentDescriptor], fallback_radius: float +) -> Tuple[float, float, float]: + """Convert v2 frustum segments to v1 cone model (tube_radius, cone_height, cone_bottom_radius).""" + if not segments: + return (fallback_radius, 0.0, 0.0) + total_height = sum(s.height for s in segments) + if total_height <= 0: + return (fallback_radius, 0.0, 0.0) + weighted_area = sum(s.height * (s.area_top + s.area_bottom) / 2.0 for s in segments) + avg_area = weighted_area / total_height + tube_radius = math.sqrt(avg_area / math.pi) + bot = segments[0] + if abs(bot.area_bottom - bot.area_top) > 1e-6: + cone_height = bot.height + cone_bottom_radius = math.sqrt(bot.area_bottom / math.pi) + else: + cone_height = 0.0 + cone_bottom_radius = 0.0 + return (tube_radius, cone_height, cone_bottom_radius) + + +def patch_common_with_cone( + common: PrepCmd.CommonParameters, segments: list[PrepCmd.SegmentDescriptor] +) -> PrepCmd.CommonParameters: + """Return CommonParameters with cone geometry derived from segments (v2→v1 downgrade).""" + if len(segments) > 1: + logger.warning( + "v1 command selected: collapsing %d container segments into single cone approximation. " + "Liquid following accuracy may be reduced for complex container geometries.", + len(segments), + ) + tube_r, cone_h, cone_br = segments_to_cone_geometry(segments, common.tube_radius) + return PrepCmd.CommonParameters( + default_values=common.default_values, + empty=common.empty, + z_minimum=common.z_minimum, + z_final=common.z_final, + z_liquid_exit_speed=common.z_liquid_exit_speed, + liquid_volume=common.liquid_volume, + liquid_speed=common.liquid_speed, + transport_air_volume=common.transport_air_volume, + tube_radius=tube_r, + cone_height=cone_h, + cone_bottom_radius=cone_br, + settling_time=common.settling_time, + additional_probes=common.additional_probes, + ) + + +def resolve_command_version( + supports_v2: Optional[bool], + use_v1_flag: bool, + override: Optional[Literal["v1", "v2"]], + *, + v2_error_hint: str = "v2 commands are not supported by this firmware.", +) -> bool: + """Resolve whether to use v2 commands for a pipetting call. Returns True for v2. + + Resolution order: + 1. Per-call ``override`` ("v1" / "v2") — takes precedence. + 2. Backend-level ``use_v1_flag`` / ``supports_v2`` probe result from setup. + """ + if override == "v1": + return False + if override == "v2": + if supports_v2 is False: + raise ValueError(v2_error_hint) + return True + return supports_v2 is True + + +def lld_seek_timeout( + lld_params: PrepCmd.LldParameters, + z_minimum: float, +) -> Optional[float]: + """Compute a read timeout (s) for an LLD seek move, or None if not applicable.""" + if lld_params.channel_speed > 0: + speed: float = float(lld_params.channel_speed) + seek_distance: float = float(lld_params.search_start_position) - z_minimum + if seek_distance > 0: + return seek_distance / speed + 5.0 + return None + + +def _effective_radius(resource) -> float: + """Effective radius for PrepCmd.CommonParameters.tube_radius. + + For circular wells uses the actual radius; for rectangular wells computes the + radius of a circle with equivalent area so tube_radius is meaningful to the + firmware's conical liquid-following model. + """ + if isinstance(resource, Well) and resource.cross_section_type == CrossSectionType.RECTANGLE: + return float(math.sqrt(resource.get_size_x() * resource.get_size_y() / math.pi)) + return float(resource.get_size_x() / 2) + + +def _build_container_segments(resource: object) -> list[PrepCmd.SegmentDescriptor]: + """Derive PrepCmd.SegmentDescriptor list from a Well's geometry for liquid-following. + + Each segment is a frustum. The firmware uses area_bottom/area_top to + interpolate cross-sectional area A(z) within the segment and computes the + Z-axis following speed as dz/dt = Q / A(z), where Q is volumetric flow rate. + + Returns [] when geometry cannot be determined; the firmware then falls back to + the tube_radius / cone model in PrepCmd.CommonParameters. + """ + if not isinstance(resource, Well): + return [] + well: Well = resource + + size_z = well.get_size_z() + + if well.cross_section_type == CrossSectionType.CIRCLE: + area = math.pi * (well.get_size_x() / 2) ** 2 + elif well.cross_section_type == CrossSectionType.RECTANGLE: + area = well.get_size_x() * well.get_size_y() + else: + return [] + + if well.supports_compute_height_volume_functions(): + # Non-linear geometry: approximate with N frustum segments by sampling dV/dh. + n_boundaries = 11 # 10 segments + heights = [size_z * i / (n_boundaries - 1) for i in range(n_boundaries)] + eps = size_z / (n_boundaries - 1) * 0.1 + + def area_at(h: float) -> float: + h_lo = max(0.0, h - eps) + h_hi = min(size_z, h + eps) + dv = well.compute_volume_from_height(h_hi) - well.compute_volume_from_height(h_lo) + return float(dv / (h_hi - h_lo)) + + return [ + PrepCmd.SegmentDescriptor( + area_top=float(area_at(heights[i + 1])), + area_bottom=float(area_at(heights[i])), + height=float(heights[i + 1] - heights[i]), + ) + for i in range(n_boundaries - 1) + ] + + # Simple geometry: single segment with constant cross-section. + return [ + PrepCmd.SegmentDescriptor(area_top=float(area), area_bottom=float(area), height=float(size_z)) + ] + + +class _WellGeometry(NamedTuple): + """Absolute Z positions derived from well geometry.""" + + well_bottom: float + liquid_surface: float + top_of_well: float + z_air: float + + +def _absolute_z_from_well( + resource, + liquid_height: Optional[float] = None, + offset_z: float = 0.0, + z_air_margin_mm: float = 2.0, +) -> _WellGeometry: + """Compute absolute Z values from well/container geometry for aspirate/dispense. + + Args: + resource: Well or Container with get_size_z(). + liquid_height: Distance from well bottom to liquid surface (mm). None = 0. + offset_z: Additional Z applied to the bottom position (e.g. from op.offset.z). + z_air_margin_mm: Clearance above well opening for z_air (approach/exit height). + + Returns: + _WellGeometry with well_bottom, liquid_surface, top_of_well, z_air. + """ + if not isinstance(resource, Container): + raise ValueError( + "Resource must have get_size_z() to derive absolute Z (e.g. a Well or Container). " + "Pass z_minimum, z_fluid, z_air explicitly for this operation." + ) + loc = resource.get_absolute_location("c", "c", "cavity_bottom") + well_bottom_z = loc.z + offset_z + liquid_surface_z = well_bottom_z + (liquid_height or 0.0) + top_of_well_z = loc.z + resource.get_size_z() + z_air_z = top_of_well_z + z_air_margin_mm + return _WellGeometry(well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z) + + +_CHANNEL_INDEX = { + 0: PrepCmd.ChannelIndex.RearChannel, + 1: PrepCmd.ChannelIndex.FrontChannel, +} + + +@dataclass(frozen=True) +class ChannelDriveMap: + """Cached channel-drive topology discovered from the firmware tree. + + One entry per discovered channel for the sleeve sensor (``Squeeze.SDrive``), + the Z drive (``ZAxis.ZDrive``), and the per-node ``NodeInformation`` object + (used for firmware-string queries). Lists are parallel and sorted by tree + traversal order (same order the firmware returns Channel Root instances). + """ + + sleeve_sensor_addrs: List[Address] + zdrive_addrs: List[Address] + node_info_addrs: List[Address] + + @property + def num_channels_discovered(self) -> int: + return len(self.sleeve_sensor_addrs) + + def to_dict(self) -> dict: + """Serialize for logs / notebooks that prefer plain dicts.""" + return { + "num_channels_discovered": self.num_channels_discovered, + "sleeve_sensor_addrs": list(self.sleeve_sensor_addrs), + "zdrive_addrs": list(self.zdrive_addrs), + "node_info_addrs": list(self.node_info_addrs), + } + + +# --------------------------------------------------------------------------- +# Firmware-tree discovery — module-level so it can be called independently of +# any PrepChannels instance (used when building channels in Prep.setup, plus by +# diagnostic notebooks that hold only a client). +# --------------------------------------------------------------------------- + + +async def _find_children_by_name( + intro, + parent_addr: Address, + *names: str, +) -> dict: + """Enumerate ``parent_addr``'s subobjects; return ``{name: Address}`` for matches. + + Bounded by ``subobject_count`` on the parent. Returns early once every + requested name has been found. Children that raise on ``get_object`` (e.g. + unknown firmware types) are skipped with a debug log. + """ + parent = await intro.get_object(parent_addr) + wanted = set(names) + found: dict = {} + for i in range(parent.subobject_count): + try: + sub_addr = await intro.get_subobject_address(parent_addr, i) + sub = await intro.get_object(sub_addr) + except Exception as e: + logger.debug("subobject[%d] of %s failed: %s", i, parent_addr, e) + continue + if sub.name in wanted: + found[sub.name] = sub_addr + if len(found) == len(wanted): + break + return found + + +async def discover_channel_drives( + client: "PrepClient", + *, + root_name: str = "Channel Root", +) -> ChannelDriveMap: + """Discover per-channel drive addresses via bounded subobject enumeration. + + MLPrepRoot exposes one ```` child per physical channel (siblings + with identical names, distinguished by the ``node`` component of their + :class:`Address`). For each one we walk: + + - ``.Channel.Squeeze.SDrive`` → sleeve sensor + - ``.Channel.ZAxis.ZDrive`` → Z drive + - ``.NodeInformation`` → per-channel firmware strings + + Uses ``get_subobject_address`` / ``get_object`` along the known path shape — + no full-tree traversal. Pass ``root_name="MPH Channel Root"`` for the 8MPH + head. For a full firmware-tree dump use + :meth:`PrepInstrumentInfo.get_firmware_tree`. + """ + intro = client.introspection + try: + mlprep_root = await client.resolve_path("MLPrepRoot") + root_info = await intro.get_object(mlprep_root) + except (KeyError, RuntimeError) as e: + logger.debug("MLPrepRoot unavailable (%s); skipping channel discovery", e) + return ChannelDriveMap(sleeve_sensor_addrs=[], zdrive_addrs=[], node_info_addrs=[]) + + channel_root_addrs: List[Address] = [] + for i in range(root_info.subobject_count): + try: + sub_addr = await intro.get_subobject_address(mlprep_root, i) + sub = await intro.get_object(sub_addr) + except Exception as e: + logger.debug("MLPrepRoot subobject[%d] failed: %s", i, e) + continue + if sub.name == root_name: + channel_root_addrs.append(sub_addr) + + sleeve: List[Address] = [] + zdrive: List[Address] = [] + node_info: List[Address] = [] + + for ch_root in channel_root_addrs: + top = await _find_children_by_name(intro, ch_root, "Channel", "NodeInformation") + if "NodeInformation" in top: + node_info.append(top["NodeInformation"]) + + channel_addr = top.get("Channel") + if channel_addr is None: + logger.warning("%s @ %s has no 'Channel' child", root_name, ch_root) + continue + + axes = await _find_children_by_name(intro, channel_addr, "Squeeze", "ZAxis") + if (sq_parent := axes.get("Squeeze")) is not None: + sq = await _find_children_by_name(intro, sq_parent, "SDrive") + if "SDrive" in sq: + sleeve.append(sq["SDrive"]) + if (zx_parent := axes.get("ZAxis")) is not None: + zx = await _find_children_by_name(intro, zx_parent, "ZDrive") + if "ZDrive" in zx: + zdrive.append(zx["ZDrive"]) + + logger.info("Discovered %d %s channel drive pair(s)", len(channel_root_addrs), root_name) + return ChannelDriveMap( + sleeve_sensor_addrs=sleeve, + zdrive_addrs=zdrive, + node_info_addrs=node_info, + ) + + +# --------------------------------------------------------------------------- +# Per-channel movement bounds — parses PipettorService.GetChannelBounds. +# --------------------------------------------------------------------------- + + +class PrepChannelBounds(TypedDict): + """Firmware-reported movement limits for one pipettor channel (mm).""" + + x_min: float + x_max: float + y_min: float + y_max: float + z_min: float + z_max: float + + +async def request_channel_bounds(client: "PrepClient") -> List[PrepChannelBounds]: + """Request per-channel movement bounds from the firmware (cmd=10). + + Returns one dict per channel (keys ``x_min``, ``x_max``, ``y_min``, ``y_max``, + ``z_min``, ``z_max`` in mm), ordered by channel index. Returns ``[]`` when + the service cannot be resolved or the response is empty. + + These are the firmware-enforced limits — positions outside these ranges will + be rejected with 0x0F04 (X), 0x0F05 (Y), or 0x0F06 (Z). Z bounds are for + empty channels; with a tip attached the effective Z minimum is higher. + """ + try: + response = await client.execute(PrepCmd.PrepGetChannelBounds()) + except KeyError: + return [] + channel_indices = {int(value): index for index, value in _CHANNEL_INDEX.items()} + indexed: list[tuple[int, PrepChannelBounds]] = [] + for bounds in response.bounds: + index = channel_indices.get(int(bounds.channel)) + if index is not None: + indexed.append( + ( + index, + { + "x_min": bounds.x_min, + "x_max": bounds.x_max, + "y_min": bounds.y_min, + "y_max": bounds.y_max, + "z_min": bounds.z_min, + "z_max": bounds.z_max, + }, + ) + ) + return [bounds for _, bounds in sorted(indexed, key=lambda pair: pair[0])] + + +# --------------------------------------------------------------------------- +# PrepPIPChannel — thin per-channel facade owned by PrepChannels.channels +# --------------------------------------------------------------------------- + + +class PrepPIPChannel: + """Per-channel facade: drive addresses, movement bounds, firmware-version queries. + + Instances are constructed by :func:`build_prep_channels` from :meth:`Prep.setup` + and exposed as ``prep.channels.channels[i]`` (or the dual-channel peer). + """ + + def __init__( + self, + *, + index: int, + client: "PrepClient", + sleeve_sensor: Optional[Address] = None, + zdrive: Optional[Address] = None, + node_info: Optional[Address] = None, + bounds: Optional[PrepChannelBounds] = None, + ) -> None: + self.index = index + self._client = client + self.sleeve_sensor = sleeve_sensor + self.zdrive = zdrive + self.node_info = node_info + self.bounds = bounds # x_min..z_max from firmware, or None if unavailable + + def __repr__(self) -> str: + return ( + f"PrepPIPChannel(index={self.index}, node_info={self.node_info!r}, " + f"bounds={'set' if self.bounds else 'unset'})" + ) + + async def request_firmware_version(self) -> Optional[str]: + """Per-channel firmware version string (NodeInformation cmd=8). + + Serial number is intentionally not exposed here — NodeInformation's + GetSerialNumber endpoint is unpopulated on shipped instruments, and the + canonical instrument serial (pipettor module) is already surfaced via + :meth:`PrepInstrumentInfo.get_device_serial_number`. + """ + if self.node_info is None: + return None + return await self._client._query_firmware_string(self.node_info, cmd_id=8, iface_id=1) + + +# --------------------------------------------------------------------------- +# Builder called from Prep.setup. +# --------------------------------------------------------------------------- + + +async def build_prep_channels( + client: "PrepClient", + info: "PrepInstrumentInfo", + *, + root_name: str = "Channel Root", + num_channels: Optional[int] = None, +) -> List[PrepPIPChannel]: + """Build per-channel facades, resolve drive addresses, fetch bounds. + + If ``num_channels`` is omitted, uses ``info.config.num_channels``. + """ + drive_map = await discover_channel_drives(client, root_name=root_name) + + if num_channels is None: + try: + num_channels = info.config.num_channels + except RuntimeError: + num_channels = None + if num_channels is None: + num_channels = drive_map.num_channels_discovered + + try: + bounds_list = await request_channel_bounds(client) + except Exception as e: + logger.warning("Failed to query channel bounds: %s", e) + bounds_list = [] + + def _drive_addr(seq: List[Address], i: int) -> Optional[Address]: + return seq[i] if i < len(seq) else None + + channels: List[PrepPIPChannel] = [] + for i in range(num_channels): + channels.append( + PrepPIPChannel( + index=i, + client=client, + sleeve_sensor=_drive_addr(drive_map.sleeve_sensor_addrs, i), + zdrive=_drive_addr(drive_map.zdrive_addrs, i), + node_info=_drive_addr(drive_map.node_info_addrs, i), + bounds=bounds_list[i] if i < len(bounds_list) else None, + ) + ) + return channels + + +# ============================================================================= +# PrepChannels — channel indices and deck routing +# ============================================================================= + + +def _build_pipettor_gantry_move_parameters( + x: float, + channels: List[int], + y: Union[float, List[float]], + z: Union[float, List[float]], +) -> PrepCmd.GantryMoveXYZParameters: + """Build :class:`~prep_commands.GantryMoveXYZParameters` for PipettorRoot move commands. + + Only ``FrontChannel`` and ``RearChannel`` may appear in ``axis_parameters``. MPH + gantry moves must use :class:`~prep_commands.MphMoveToPosition` instead. + """ + axis_parameters: List[PrepCmd.ChannelYZMoveParameters] = [] + for i, ch in enumerate(channels): + y_i = y[i] if isinstance(y, list) else y + z_i = z[i] if isinstance(z, list) else z + enum_ch = _CHANNEL_INDEX[ch] + if enum_ch not in ( + PrepCmd.ChannelIndex.FrontChannel, + PrepCmd.ChannelIndex.RearChannel, + ): + raise ValueError( + f"Pipettor gantry move does not support channel index {ch} (enum {enum_ch!r}). " + "MPH motion uses PrepHead8 / MphMoveToPosition on MLPrepRoot.MphRoot.MPH." + ) + axis_parameters.append( + PrepCmd.ChannelYZMoveParameters( + default_values=False, channel=enum_ch, y_position=y_i, z_position=z_i + ) + ) + return PrepCmd.GantryMoveXYZParameters( + default_values=False, gantry_x_position=x, axis_parameters=axis_parameters + ) + + +# Channel index -> deck waste resource name (PrepDeck: waste_rear, waste_front, waste_mph) +_CHANNEL_TO_WASTE_NAME = { + 0: "waste_rear", + 1: "waste_front", + 2: "waste_mph", +} + +# Expected root name from discovery; validated at setup(). +_EXPECTED_ROOT = "MLPrepRoot" + + +@dataclass(frozen=True) +class _AspirateChannelKit: + """Pre-resolved per-channel values for one aspirate channel. + + Computed once by ``_resolve_aspirate_channels``; the variant (LLD x monitoring + x v1/v2) only decides which fields get assembled into which wire dataclass. + """ + + channel: int + aspirate: PrepCmd.AspirateParameters + common: PrepCmd.CommonParameters + segments: list[PrepCmd.SegmentDescriptor] + no_lld: PrepCmd.NoLldParameters + lld: PrepCmd.LldParameters + p_lld: PrepCmd.PLldParameters + c_lld: PrepCmd.CLldParameters + monitoring: PrepCmd.AspirateMonitoringParameters + tadm: PrepCmd.TadmParameters + mix: PrepCmd.MixParameters + adc: PrepCmd.AdcParameters + + +@dataclass(frozen=True) +class _DispenseChannelKit: + """Pre-resolved per-channel values for one dispense channel.""" + + channel: int + dispense: PrepCmd.DispenseParameters + common: PrepCmd.CommonParameters + segments: list[PrepCmd.SegmentDescriptor] + no_lld: PrepCmd.NoLldParameters + lld: PrepCmd.LldParameters + c_lld: PrepCmd.CLldParameters + tadm: PrepCmd.TadmParameters + mix: PrepCmd.MixParameters + adc: PrepCmd.AdcParameters + + +@dataclass(frozen=True) +class _ChannelContext(Generic[_OpT]): + """Shared resolved state for aspirate/dispense channel resolution. + + Computed once by ``_resolve_channel_context``; operation-specific resolve + methods add their own parameters on top. + """ + + n: int + hlcs: List[Optional[HamiltonLiquidClass]] + disable_volume_correction: List[bool] + ch_to_idx: dict[int, int] + indexed_ops: dict[int, _OpT] + volumes: List[float] + well_geometry: List[_WellGeometry] + z_minimum: List[float] + z_fluid: List[float] + z_air: List[float] + z_final: List[float] + z_bottom_search_offset: List[float] + ch_segments: dict[int, list[PrepCmd.SegmentDescriptor]] + + +class PrepChannels: + """Dual-channel pipettor for Hamilton Prep. + + Narrow constructor: ``client`` (transport + JIT firmware-path resolve) and + ``info`` (instrument-wide metadata). ``self.channels`` is attached by + :meth:`Prep.setup` before :meth:`_on_setup`. + """ + + # V2 aspirate/dispense command IDs (interface 1 on Pipettor). + _V2_PIPETTING_CMD_IDS = {38, 39, 40, 41, 42, 43} + + def __init__( + self, + *, + client: "PrepClient", + info: "PrepInstrumentInfo", + deck: Optional["Deck"] = None, + default_traverse_height: Optional[float] = None, + use_v1_aspirate_dispense: bool = False, + ) -> None: + self._client = client + self._info = info + self.deck = deck + self._user_traverse_height: Optional[float] = default_traverse_height + self._channel_bounds: list[PrepChannelBounds] = [] + self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense + self._supports_v2_pipetting: Optional[bool] = None + self.setup_finished: bool = False + self.channels: List[PrepPIPChannel] = [] + self.head: dict[int, TipTracker] = {} + + def set_default_traverse_height(self, value: float) -> None: + """Set the default traverse height (mm) used when final_z is not passed to pick_up_tips/drop_tips. + + Use this when the instrument did not report a traverse height at setup, or to override + the probed value. + """ + self._user_traverse_height = value + + async def _probe_v2_support(self) -> bool: + """Probe the pipettor for v2 aspirate/dispense command support. + + Enumerates interface 1 method IDs on the pipettor object and checks whether + all v2 command IDs (38-43) are present. Returns False when the firmware only + exposes v1 commands (1-6). + """ + dest = await self._client.resolve_path(PIPETTOR_OBJECT_PATH) + methods = await self._client.introspection.methods_for_interface(dest, interface_id=1) + iface1_ids = {m.method_id for m in methods} + return self._V2_PIPETTING_CMD_IDS.issubset(iface1_ids) + + def _resolve_command_version(self, override: Optional[Literal["v1", "v2"]] = None) -> bool: + return resolve_command_version( + self._supports_v2_pipetting, + self._use_v1_aspirate_dispense, + override, + v2_error_hint=( + "v2 aspirate/dispense commands (cmd 38-43) are not supported by this firmware. " + "Use command_version='v1' or pass use_v1_aspirate_dispense=True to PrepChannels." + ), + ) + + # --------------------------------------------------------------------------- + # Setup + # --------------------------------------------------------------------------- + + async def _on_setup(self): + """Read config and probe pipettor capabilities. + + Called after ``self.channels`` is populated by :meth:`Prep.setup`. Instrument- + level initialization (``MLPrep.Initialize``) runs earlier in + :meth:`Prep.setup` — the pipettor sees an already-initialized instrument. + """ + cfg = self._info.config + logger.info( + "Hardware config: has_enclosure=%s, safe_speeds=%s, traverse_height=%s, " + "deck_bounds=%s, deck_sites=%d, waste_sites=%d, num_channels=%s, has_mph=%s", + cfg.has_enclosure, + cfg.safe_speeds_enabled, + cfg.default_traverse_height, + cfg.deck_bounds, + len(cfg.deck_sites), + len(cfg.waste_sites), + cfg.num_channels, + cfg.has_mph, + ) + + # Per-channel bounds are attached to ``self.channels`` by build_prep_channels. + # Keep a flat list too for legacy call sites that iterate _channel_bounds. + self._channel_bounds = [c.bounds for c in self.channels if c.bounds is not None] + if self._channel_bounds: + logger.info("Channel bounds: %s", self._channel_bounds) + else: + logger.warning("Channel bounds not available — move_to_position will skip validation") + + # Probe pipettor for v2 aspirate/dispense support (cmd 38-43). + if self._use_v1_aspirate_dispense: + self._supports_v2_pipetting = False + logger.info("V2 aspirate/dispense probe skipped (use_v1_aspirate_dispense=True)") + else: + try: + supported = await self._probe_v2_support() + except Exception as e: + logger.warning("PIP V2 support probe failed: %s", e) + supported = False + if not supported: + raise RuntimeError( + "V2 aspirate/dispense commands (cmd 38-43) are not supported by this firmware. " + "Pass use_v1_aspirate_dispense=True to PrepChannels to use v1 commands (cmd 1-6) instead." + ) + self._supports_v2_pipetting = True + logger.info("V2 aspirate/dispense support: True") + + self._ensure_head() + self.setup_finished = True + + async def _on_stop(self): + for tracker in self.head.values(): + tracker.clear() + + def _ensure_head(self) -> None: + """Ensure pipette-side TipTrackers exist for each dual-channel index.""" + for i in range(self.num_channels): + if i not in self.head: + self.head[i] = TipTracker(thing=f"Channel {i}") + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Tips currently mounted on the dual-channel head (``None`` if empty).""" + self._ensure_head() + return [ + self.head[i].get_tip() if self.head[i].has_tip else None for i in range(self.num_channels) + ] + + async def discover_channel_drives(self) -> ChannelDriveMap: + """Re-walk the firmware tree and return a fresh :class:`ChannelDriveMap`. + + Diagnostic helper — channel drive addresses for normal operation are already + cached on each :attr:`channels` entry at build time. + """ + return await discover_channel_drives(self._client, root_name="Channel Root") + + # --------------------------------------------------------------------------- + # Properties + # --------------------------------------------------------------------------- + + @property + def num_channels(self) -> int: + """Number of independent dual-channel pipettor channels (1 or 2). Read from info.config.""" + n: Optional[int] = self._info.config.num_channels + if n is None: + raise RuntimeError("Instrument config has no num_channels (finish Prep.setup first).") + return n + + @property + def has_mph(self) -> bool: + """True if the 8-channel Multi-Pipetting Head (8MPH) is present. Read from info.config.""" + try: + return bool(self._info.config.has_mph) + except RuntimeError: + return False + + @property + def num_arms(self) -> int: + """Number of resource-handling arms. 1 when deck has core_grippers and 2 channels, else 0.""" + if self.deck is None: + return 0 + try: + cfg = self._info.config + except RuntimeError: + return 0 + if cfg.num_channels != 2: + return 0 + try: + mount = self.deck.get_resource("core_grippers") + return 1 if isinstance(mount, HamiltonCoreGrippers) else 0 + except Exception: + return 0 + + def _resolve_traverse_height(self, final_z: Optional[float] = None) -> float: + """Resolve final_z: explicit arg > user-set default > probed value. Raises if none available.""" + if final_z is not None: + return final_z + if self._user_traverse_height is not None: + return self._user_traverse_height + try: + cfg = self._info.config + except RuntimeError: + height: Optional[float] = None + else: + height = cfg.default_traverse_height + if height is not None: + return height + raise RuntimeError( + "Default traverse height is required for this operation but could not be determined. " + "Either pass final_z explicitly to this call, or set it via " + "PrepChannels(..., default_traverse_height=) or set_default_traverse_height(). " + "If the instrument supports it, the value is also probed during setup(); ensure setup() completed successfully." + ) from None + + # --------------------------------------------------------------------------- + # Tip / aspirate / dispense API + # --------------------------------------------------------------------------- + + def _require_mounted_tips(self, use_channels: List[int]) -> List[Tip]: + self._ensure_head() + tips: List[Tip] = [] + for ch in use_channels: + tracker = self.head[ch] + if not tracker.has_tip: + raise RuntimeError(f"No tip mounted on channel {ch}; call pick_up_tips first.") + tips.append(tracker.get_tip()) + return tips + + async def _finalize_channel_command( + self, + use_channels: Sequence[int], + *, + tip_intents: Optional[Sequence[Union[TipPickupIntent, TipDropIntent]]] = None, + volume_intents: Optional[Sequence[VolumeTransferIntent]] = None, + send: Callable[[], Awaitable[None]], + ) -> None: + """Send a Prep command and commit/rollback queued tip or volume intents.""" + error: Optional[BaseException] = None + try: + await send() + successes = all_channels_succeeded(use_channels) + except ChannelizedError as e: + error = e + successes = successes_from_failed_channels(use_channels, e.errors) + except BaseException as e: + error = e + successes = {ch: False for ch in use_channels} + if tip_intents is not None: + finalize_tip_ops(tip_intents, successes) + if volume_intents is not None: + finalize_volume_ops(volume_intents, successes) + if error is not None: + raise error + + async def pick_up_tips( + self, + tip_spots: Sequence[TipSpot], + use_channels: Optional[List[int]] = None, + *, + offsets: Optional[Sequence[Coordinate]] = None, + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + enable_tadm: bool = False, + dispenser_volume: float = 0.0, + dispenser_speed: float = 250.0, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + pre_position: bool = True, + ): + """Pick up tips from tip spots. + + The arm moves to z_seek during lateral XY approach, then descends to z_position + to engage the tip. Default z_seek = z_position + fitting_depth + 5mm (tip-type- + aware; avoids descending into the rack during approach). + """ + tip_spots = list(tip_spots) + use_channels = use_channels if use_channels is not None else list(range(len(tip_spots))) + if len(tip_spots) != len(use_channels): + raise ValueError( + f"len(tip_spots) must equal len(use_channels): {len(tip_spots)} != {len(use_channels)}" + ) + if use_channels: + assert max(use_channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) + offsets_list = list(offsets) if offsets is not None else [Coordinate.zero()] * len(tip_spots) + if len(offsets_list) != len(tip_spots): + raise ValueError("len(offsets) must equal len(tip_spots)") + + tips = [spot.get_tip() for spot in tip_spots] + resolved_final_z = self._resolve_traverse_height(final_z) + + indexed = { + ch: (spot, tip, off) + for ch, spot, tip, off in zip(use_channels, tip_spots, tips, offsets_list) + } + tip_positions: List[PrepCmd.TipPositionParameters] = [] + for ch in range(self.num_channels): + if ch not in indexed: + continue + spot, tip, off = indexed[ch] + loc = spot.get_absolute_location("c", "c", "t") + off + tip_positions.append( + PrepCmd.TipPositionParameters.for_op( + _CHANNEL_INDEX[ch], loc, tip, z_seek_offset=z_seek_offset + ) + ) + + tip0 = tips[0] + if any( + t.maximal_volume != tip0.maximal_volume + or t.has_filter != tip0.has_filter + or (t.total_tip_length - t.fitting_depth) != (tip0.total_tip_length - tip0.fitting_depth) + for t in tips + ): + raise ValueError("All tip spots must use the same tip type") + tip_definition = PrepCmd.TipPickupParameters( + default_values=False, + volume=tip0.maximal_volume, + length=tip0.total_tip_length - tip0.fitting_depth, + tip_type=PrepCmd.TipTypes.StandardVolume, + has_filter=tip0.has_filter, + is_needle=False, + is_tool=False, + ) + + if pre_position: + traverse_h = minimum_traverse_height_at_beginning_of_a_command or resolved_final_z + locs = [ + indexed[ch][0].get_absolute_location("c", "c", "t") + indexed[ch][2] for ch in use_channels + ] + await self.move_to_position( + x=locs[0].x, + y=[loc.y for loc in locs], + z=traverse_h, + use_channels=use_channels, + ) + + self._ensure_head() + tip_intents = [ + TipPickupIntent( + channel=ch, + tip_spot=spot, + tip=tip, + channel_tracker=self.head[ch], + ) + for ch, spot, tip in zip(use_channels, tip_spots, tips) + ] + queue_tip_pickups(tip_intents) + + async def _send() -> None: + await self._client.execute( + PrepCmd.PrepPickUpTips( + tip_positions=tip_positions, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_definition=tip_definition, + enable_tadm=enable_tadm, + dispenser_volume=dispenser_volume, + dispenser_speed=dispenser_speed, + ) + ) + + await self._finalize_channel_command(use_channels, tip_intents=tip_intents, send=_send) + + async def drop_tips( + self, + destinations: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[List[int]] = None, + *, + offsets: Optional[Sequence[Coordinate]] = None, + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + drop_type: PrepCmd.TipDropType = PrepCmd.TipDropType.FixedHeight, + tip_roll_off_distance: float = 0.0, + ): + """Drop tips to tip spots or trash. + + The arm moves to z_seek during lateral XY approach (tip is on pipette, so tip + bottom is at z_seek - (total_tip_length - fitting_depth)). z_position uses + fitting depth so the tip bottom lands at the spot surface; default z_seek = + z_position + 10mm so the tip bottom stays above adjacent tips in the rack. + """ + destinations = list(destinations) + use_channels = use_channels if use_channels is not None else list(range(len(destinations))) + if len(destinations) != len(use_channels): + raise ValueError( + f"len(destinations) must equal len(use_channels): " + f"{len(destinations)} != {len(use_channels)}" + ) + if use_channels: + assert max(use_channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) + tips = self._require_mounted_tips(use_channels) + offsets_list = list(offsets) if offsets is not None else [Coordinate.zero()] * len(destinations) + if len(offsets_list) != len(destinations): + raise ValueError("len(offsets) must equal len(destinations)") + + all_trash = all(isinstance(d, Trash) for d in destinations) + all_tip_spots = all(isinstance(d, TipSpot) for d in destinations) + if not (all_trash or all_tip_spots): + raise ValueError("Cannot mix waste (Trash) and tip spots in a single drop_tips call.") + + resolved_final_z = self._resolve_traverse_height(final_z) + roll_off = 3.0 if (all_trash and tip_roll_off_distance == 0.0) else tip_roll_off_distance + resolved_drop_type = PrepCmd.TipDropType.Stall if all_trash else drop_type + + indexed = { + ch: (dest, tip, off) + for ch, dest, tip, off in zip(use_channels, destinations, tips, offsets_list) + } + tip_positions: List[PrepCmd.TipDropParameters] = [] + for ch in range(self.num_channels): + if ch not in indexed: + continue + dest, tip, off = indexed[ch] + if all_trash: + if self.deck is None: + raise ValueError( + "Cannot drop tips to waste: backend has no deck (assign a deck before drop_tips)." + ) + waste_name = _CHANNEL_TO_WASTE_NAME.get(ch, "waste_mph") + if not self.deck.has_resource(waste_name): + raise ValueError( + f"Cannot drop tips to waste: deck has no waste position '{waste_name}'. " + "Use a deck with waste_rear, waste_front (and waste_mph if using MPH)." + ) + loc = self.deck.get_resource(waste_name).get_absolute_location("c", "c", "t") + else: + loc = dest.get_absolute_location("c", "c", "t") + off + tip_positions.append( + PrepCmd.TipDropParameters.for_op( + _CHANNEL_INDEX[ch], loc, tip, z_seek_offset=z_seek_offset, drop_type=resolved_drop_type + ) + ) + + tip_intents = [ + TipDropIntent( + channel=ch, + destination=dest, + tip=tip, + channel_tracker=self.head[ch], + ) + for ch, dest, tip in zip(use_channels, destinations, tips) + ] + queue_tip_drops(tip_intents) + + async def _send() -> None: + await self._client.execute( + PrepCmd.PrepDropTips( + tip_positions=tip_positions, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=roll_off, + ) + ) + + await self._finalize_channel_command(use_channels, tip_intents=tip_intents, send=_send) + + # --------------------------------------------------------------------------- + # V1/V2 aspirate/dispense dispatch helpers + # --------------------------------------------------------------------------- + + @staticmethod + def _patch_common_with_cone( + common: PrepCmd.CommonParameters, segments: list[PrepCmd.SegmentDescriptor] + ) -> PrepCmd.CommonParameters: + return patch_common_with_cone(common, segments) + + # --------------------------------------------------------------------------- + # Shared LLD / TADM resolution helpers + # --------------------------------------------------------------------------- + + def _resolve_effective_lld( + self, + lld_mode: Optional[List[LLDMode]], + lld: Optional[PrepCmd.LldParameters], + n: int, + *, + allowed_modes: Optional[frozenset[LLDMode]] = None, + ) -> bool: + """Determine whether LLD is active for this pipetting call. + + Validates ``lld_mode`` length, rejects disallowed modes (e.g. PRESSURE for + dispense), enforces all-or-nothing across channels, and returns a single bool. + Falls back to ``lld`` presence when ``lld_mode`` is None. + """ + if lld_mode is not None: + if len(lld_mode) != n: + raise ValueError(f"lld_mode length must match len(ops): {len(lld_mode)} != {n}") + if allowed_modes is not None: + for m in lld_mode: + if m != LLDMode.OFF and m not in allowed_modes: + raise ValueError( + f"Dispense does not support {m.name} LLD — only CAPACITIVE or OFF. " + "Pressure-based LLD requires aspiration (plunger movement)." + ) + lld_on = [m != LLDMode.OFF for m in lld_mode] + if any(lld_on) and not all(lld_on): + raise ValueError( + "Prep firmware requires all channels to use the same LLD mode category. " + "Cannot mix LLDMode.OFF with CAPACITIVE/PRESSURE/DUAL in one call. " + "Split into separate calls for channels with different LLD modes." + ) + return all(lld_on) + return lld is not None + + @staticmethod + def _default_lld_params( + effective_lld: bool, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + ) -> _LldDefaults: + return default_lld_params(effective_lld, p_lld, c_lld) + + @staticmethod + def _lld_for_well( + effective_lld: bool, lld: Optional[PrepCmd.LldParameters], top_of_well_z: float + ) -> PrepCmd.LldParameters: + return lld_for_well(effective_lld, lld, top_of_well_z) + + # --------------------------------------------------------------------------- + # Shared channel resolution + # --------------------------------------------------------------------------- + + def _resolve_channel_context( + self, + ops: Sequence[_OpT], + use_channels: List[int], + *, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ) -> _ChannelContext[_OpT]: + """Resolve shared per-channel state for aspirate or dispense. + + Validates inputs, resolves HLCs, computes volume corrections, well geometry, + z-parameter defaults, and container segments. Operation-specific defaults + (settling_time, flow_rate, etc.) are left to the caller. + """ + if len(ops) != len(use_channels): + raise ValueError(f"len(ops) must equal len(use_channels): {len(ops)} != {len(use_channels)}") + if use_channels and max(use_channels) >= self.num_channels: + raise ValueError(f"use_channels index out of range (valid: 0..{self.num_channels - 1})") + + n = len(ops) + if hamilton_liquid_classes is not None and len(hamilton_liquid_classes) != n: + raise ValueError( + f"hamilton_liquid_classes length must match len(ops): {len(hamilton_liquid_classes)} != {n}" + ) + hlcs = resolve_hamilton_liquid_classes( + list(hamilton_liquid_classes) if hamilton_liquid_classes is not None else None, + list(ops), + jet=False, + blow_out=False, + ) + dvc = disable_volume_correction if disable_volume_correction is not None else [False] * n + if len(dvc) != n: + raise ValueError(f"disable_volume_correction length must match len(ops): {len(dvc)} != {n}") + ch_to_idx = {ch: i for i, ch in enumerate(use_channels)} + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + + volumes = corrected_volumes_for_ops(ops, hlcs, dvc) + + well_geometry = [ + _absolute_z_from_well(op.resource, op.liquid_height, op.offset.z) for op in ops + ] + raw_traverse = self._resolve_traverse_height(None) + z_minimum = fill_in_defaults(z_minimum, [g.well_bottom for g in well_geometry]) + z_fluid = fill_in_defaults(z_fluid, [g.liquid_surface for g in well_geometry]) + z_air = fill_in_defaults(z_air, [g.z_air for g in well_geometry]) + z_final = fill_in_defaults( + z_final, [raw_traverse - (op.tip.total_tip_length - op.tip.fitting_depth) for op in ops] + ) + z_bottom_search_offset = fill_in_defaults(z_bottom_search_offset, [2.0] * n) + + ch_segments: dict[int, list[PrepCmd.SegmentDescriptor]] = {} + for i, ch in enumerate(use_channels): + if container_segments is not None and i < len(container_segments): + ch_segments[ch] = container_segments[i] + elif auto_container_geometry: + ch_segments[ch] = _build_container_segments(indexed_ops[ch].resource) + else: + ch_segments[ch] = [] + + return _ChannelContext( + n=n, + hlcs=hlcs, + disable_volume_correction=dvc, + ch_to_idx=ch_to_idx, + indexed_ops=indexed_ops, + volumes=volumes, + well_geometry=well_geometry, + z_minimum=z_minimum, + z_fluid=z_fluid, + z_air=z_air, + z_final=z_final, + z_bottom_search_offset=z_bottom_search_offset, + ch_segments=ch_segments, + ) + + # --------------------------------------------------------------------------- + # Aspirate: resolve, assemble, send + # --------------------------------------------------------------------------- + + def _resolve_aspirate_channels( + self, + ops: List[_PipetteTransfer], + use_channels: List[int], + effective_lld: bool, + *, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + prewet_volume: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + lld: Optional[PrepCmd.LldParameters] = None, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + tadm: Optional[PrepCmd.TadmParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ) -> list[_AspirateChannelKit]: + """Resolve all per-channel values for aspirate (pure computation, no I/O).""" + ctx = self._resolve_channel_context( + ops, + use_channels, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + # Aspirate-specific HLC defaults + hlcs = ctx.hlcs + settling_time = fill_in_defaults( + settling_time, [hlc.aspiration_settling_time if hlc is not None else 1.0 for hlc in hlcs] + ) + transport_air_volume = fill_in_defaults( + transport_air_volume, + [hlc.aspiration_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + z_liquid_exit_speed = fill_in_defaults( + z_liquid_exit_speed, [hlc.aspiration_swap_speed if hlc is not None else 10.0 for hlc in hlcs] + ) + prewet_volume = fill_in_defaults( + prewet_volume, + [hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + flow_rates = [ + op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100.0) + for op, hlc in zip(ops, hlcs) + ] + blowout_volumes = [ + op.blow_out_air_volume or (hlc.aspiration_blow_out_volume if hlc is not None else 0.0) + for op, hlc in zip(ops, hlcs) + ] + + lld_defaults = self._default_lld_params(effective_lld, p_lld, c_lld) + _tadm = tadm or PrepCmd.TadmParameters.default() + + kits: list[_AspirateChannelKit] = [] + for ch in range(self.num_channels): + if ch not in ctx.indexed_ops: + continue + idx = ctx.ch_to_idx[ch] + asp = ctx.indexed_ops[ch] + loc = asp.resource.get_absolute_location("c", "c", "cavity_bottom") + radius = _effective_radius(asp.resource) + + kits.append( + _AspirateChannelKit( + channel=_CHANNEL_INDEX[ch], + aspirate=PrepCmd.AspirateParameters.from_location( + loc, prewet_volume=prewet_volume[idx], blowout_volume=blowout_volumes[idx] + ), + common=PrepCmd.CommonParameters.for_op( + ctx.volumes[idx], + radius, + flow_rate=flow_rates[idx], + z_minimum=ctx.z_minimum[idx], + z_final=ctx.z_final[idx], + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], + ), + segments=ctx.ch_segments[ch], + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + ctx.z_fluid[idx], ctx.z_air[idx], z_bottom_search_offset=ctx.z_bottom_search_offset[idx] + ), + lld=self._lld_for_well(effective_lld, lld, ctx.well_geometry[idx].top_of_well), + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + monitoring=PrepCmd.AspirateMonitoringParameters.default(), + tadm=_tadm, + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + ) + ) + return kits + + @staticmethod + def _assemble_aspirate_v2( + kit: _AspirateChannelKit, effective_lld: bool, is_tadm: bool + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm2, + PrepCmd.AspirateParametersLldAndMonitoring2, + PrepCmd.AspirateParametersNoLldAndTadm2, + PrepCmd.AspirateParametersNoLldAndMonitoring2, + ]: + """Assemble a v2 aspirate parameter struct from pre-resolved kit values.""" + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + tadm=kit.tadm, + adc=kit.adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + aspirate_monitoring=kit.monitoring, + adc=kit.adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring2( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + container_description=kit.segments, + common=kit.common, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + aspirate_monitoring=kit.monitoring, + ) + + def _assemble_aspirate_v1( + self, kit: _AspirateChannelKit, effective_lld: bool, is_tadm: bool + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm, + PrepCmd.AspirateParametersLldAndMonitoring, + PrepCmd.AspirateParametersNoLldAndTadm, + PrepCmd.AspirateParametersNoLldAndMonitoring, + ]: + """Assemble a v1 aspirate parameter struct (cone-patched, no segments).""" + patched = self._patch_common_with_cone(kit.common, kit.segments) + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + tadm=kit.tadm, + adc=kit.adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + lld=kit.lld, + p_lld=kit.p_lld, + c_lld=kit.c_lld, + mix=kit.mix, + aspirate_monitoring=kit.monitoring, + adc=kit.adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring( + default_values=False, + channel=kit.channel, + aspirate=kit.aspirate, + common=patched, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + aspirate_monitoring=kit.monitoring, + ) + + # Command dispatch tables: (effective_lld, is_tadm, use_v2) → command class + _ASPIRATE_CMD = { + (True, True, True): PrepCmd.PrepAspirateWithLldTadmV2, + (True, True, False): PrepCmd.PrepAspirateWithLldTadm, + (True, False, True): PrepCmd.PrepAspirateWithLldV2, + (True, False, False): PrepCmd.PrepAspirateWithLld, + (False, True, True): PrepCmd.PrepAspirateTadmV2, + (False, True, False): PrepCmd.PrepAspirateTadm, + (False, False, True): PrepCmd.PrepAspirateNoLldMonitoringV2, + (False, False, False): PrepCmd.PrepAspirateNoLldMonitoring, + } + + async def _send_aspirate( + self, + kits: list[_AspirateChannelKit], + effective_lld: bool, + is_tadm: bool, + use_v2: bool, + read_timeout: Optional[float] = None, + ) -> None: + """Assemble the correct param types and send the aspirate command.""" + cmd_cls = self._ASPIRATE_CMD[(effective_lld, is_tadm, use_v2)] + assembler = self._assemble_aspirate_v2 if use_v2 else self._assemble_aspirate_v1 + params = [assembler(k, effective_lld, is_tadm) for k in kits] + await self._client.execute( + cmd_cls(aspirate_parameters=params), # type: ignore[arg-type] + read_timeout=read_timeout if effective_lld else None, + ) + + # --------------------------------------------------------------------------- + # Dispense: resolve, assemble, send + # --------------------------------------------------------------------------- + + def _resolve_dispense_channels( + self, + ops: List[_PipetteTransfer], + use_channels: List[int], + effective_lld: bool, + *, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + cutoff_speed: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ) -> list[_DispenseChannelKit]: + """Resolve all per-channel values for dispense (pure computation, no I/O).""" + ctx = self._resolve_channel_context( + ops, + use_channels, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + # Dispense-specific HLC defaults + hlcs = ctx.hlcs + settling_time = fill_in_defaults( + settling_time, [hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hlcs] + ) + transport_air_volume = fill_in_defaults( + transport_air_volume, + [hlc.dispense_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs], + ) + z_liquid_exit_speed = fill_in_defaults( + z_liquid_exit_speed, [hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs] + ) + stop_back_volume = fill_in_defaults( + stop_back_volume, [hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs] + ) + cutoff_speed = fill_in_defaults( + cutoff_speed, [hlc.dispense_stop_flow_rate if hlc is not None else 100.0 for hlc in hlcs] + ) + flow_rates = [ + op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 100.0) + for op, hlc in zip(ops, hlcs) + ] + + lld_defaults = self._default_lld_params(effective_lld, c_lld=c_lld) + + kits: list[_DispenseChannelKit] = [] + for ch in range(self.num_channels): + if ch not in ctx.indexed_ops: + continue + idx = ctx.ch_to_idx[ch] + op = ctx.indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + radius = _effective_radius(op.resource) + + kits.append( + _DispenseChannelKit( + channel=_CHANNEL_INDEX[ch], + dispense=PrepCmd.DispenseParameters.for_op( + loc, stop_back_volume=stop_back_volume[idx], cutoff_speed=cutoff_speed[idx] + ), + common=PrepCmd.CommonParameters.for_op( + ctx.volumes[idx], + radius, + flow_rate=flow_rates[idx], + z_minimum=ctx.z_minimum[idx], + z_final=ctx.z_final[idx], + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], + ), + segments=ctx.ch_segments[ch], + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + ctx.z_fluid[idx], ctx.z_air[idx], z_bottom_search_offset=ctx.z_bottom_search_offset[idx] + ), + lld=self._lld_for_well(effective_lld, lld, ctx.well_geometry[idx].top_of_well), + c_lld=lld_defaults.c_lld, + tadm=PrepCmd.TadmParameters.default(), + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + ) + ) + return kits + + @staticmethod + def _assemble_dispense_v2( + kit: _DispenseChannelKit, effective_lld: bool + ) -> Union[PrepCmd.DispenseParametersLld2, PrepCmd.DispenseParametersNoLld2]: + """Assemble a v2 dispense parameter struct from pre-resolved kit values.""" + if effective_lld: + return PrepCmd.DispenseParametersLld2( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + container_description=kit.segments, + common=kit.common, + lld=kit.lld, + c_lld=kit.c_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld2( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + container_description=kit.segments, + common=kit.common, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + + def _assemble_dispense_v1( + self, kit: _DispenseChannelKit, effective_lld: bool + ) -> Union[PrepCmd.DispenseParametersLld, PrepCmd.DispenseParametersNoLld]: + """Assemble a v1 dispense parameter struct (cone-patched, no segments).""" + patched = self._patch_common_with_cone(kit.common, kit.segments) + if effective_lld: + return PrepCmd.DispenseParametersLld( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + common=patched, + lld=kit.lld, + c_lld=kit.c_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld( + default_values=False, + channel=kit.channel, + dispense=kit.dispense, + common=patched, + no_lld=kit.no_lld, + mix=kit.mix, + adc=kit.adc, + tadm=kit.tadm, + ) + + # Command dispatch table: (effective_lld, use_v2) → command class + _DISPENSE_CMD = { + (True, True): PrepCmd.PrepDispenseWithLldV2, + (True, False): PrepCmd.PrepDispenseWithLld, + (False, True): PrepCmd.PrepDispenseNoLldV2, + (False, False): PrepCmd.PrepDispenseNoLld, + } + + async def _send_dispense( + self, + kits: list[_DispenseChannelKit], + effective_lld: bool, + use_v2: bool, + read_timeout: Optional[float] = None, + ) -> None: + """Assemble the correct param types and send the dispense command.""" + cmd_cls = self._DISPENSE_CMD[(effective_lld, use_v2)] + assembler = self._assemble_dispense_v2 if use_v2 else self._assemble_dispense_v1 + params = [assembler(k, effective_lld) for k in kits] + await self._client.execute( + cmd_cls(dispense_parameters=params), # type: ignore[arg-type] + read_timeout=read_timeout if effective_lld else None, + ) + + # --------------------------------------------------------------------------- + # Public aspirate / dispense orchestrators + # --------------------------------------------------------------------------- + + def _build_transfers( + self, + resources: Sequence[Container], + vols: Sequence[float], + use_channels: List[int], + *, + offsets: Optional[Sequence[Coordinate]] = None, + liquid_height: Optional[Sequence[Optional[float]]] = None, + flow_rates: Optional[Sequence[Optional[float]]] = None, + blow_out_air_volume: Optional[Sequence[Optional[float]]] = None, + ) -> List[_PipetteTransfer]: + resources = list(resources) + vols = [float(v) for v in vols] + if len(resources) != len(use_channels) or len(vols) != len(use_channels): + raise ValueError("resources, vols, and use_channels must have the same length") + tips = self._require_mounted_tips(use_channels) + n = len(use_channels) + offs = list(offsets) if offsets is not None else [Coordinate.zero()] * n + lhs = list(liquid_height) if liquid_height is not None else [None] * n + frs = list(flow_rates) if flow_rates is not None else [None] * n + bavs = list(blow_out_air_volume) if blow_out_air_volume is not None else [None] * n + for name, seq in ( + ("offsets", offs), + ("liquid_height", lhs), + ("flow_rates", frs), + ("blow_out_air_volume", bavs), + ): + if len(seq) != n: + raise ValueError(f"{name} length must match use_channels ({n})") + return [ + _PipetteTransfer( + resource=r, + tip=t, + volume=v, + offset=o, + liquid_height=lh, + flow_rate=fr, + blow_out_air_volume=bav, + ) + for r, t, v, o, lh, fr, bav in zip(resources, tips, vols, offs, lhs, frs, bavs) + ] + + async def aspirate( + self, + resources: Sequence[Container], + vols: Sequence[float], + use_channels: Optional[List[int]] = None, + *, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + prewet_volume: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + lld_mode: Optional[List[Any]] = None, + lld: Optional[PrepCmd.LldParameters] = None, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + tadm: Optional[PrepCmd.TadmParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ): + """Aspirate from containers using mounted tips. + + Explicit kwargs override Hamilton liquid-class defaults; HLC supplies + unspecified fields and the volume correction curve unless disabled. + """ + resources = list(resources) + use_channels = use_channels if use_channels is not None else list(range(len(resources))) + ops = self._build_transfers( + resources, + vols, + use_channels, + offsets=offsets, + liquid_height=liquid_height, + flow_rates=flow_rates, + blow_out_air_volume=blow_out_air_volume, + ) + effective_lld = self._resolve_effective_lld(lld_mode, lld, len(ops)) + is_tadm = tadm is not None + use_v2 = self._resolve_command_version(command_version) + + kits = self._resolve_aspirate_channels( + ops, + use_channels, + effective_lld, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + z_liquid_exit_speed=z_liquid_exit_speed, + prewet_volume=prewet_volume, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + lld=lld, + p_lld=p_lld, + c_lld=c_lld, + tadm=tadm, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + lld_read_timeout = read_timeout + if lld_read_timeout is None and effective_lld and kits: + min_z_min = min(k.common.z_minimum for k in kits) + lld_read_timeout = lld_seek_timeout(kits[0].lld, min_z_min) + + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=op.resource, + tip=op.tip, + volume_ul=next(k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch]), + direction="aspirate", + ) + for ch, op in zip(use_channels, ops) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._send_aspirate(kits, effective_lld, is_tadm, use_v2, lld_read_timeout) + + await self._finalize_channel_command(use_channels, volume_intents=volume_intents, send=_send) + + async def dispense( + self, + resources: Sequence[Container], + vols: Sequence[float], + use_channels: Optional[List[int]] = None, + *, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + z_final: Optional[List[float]] = None, + z_fluid: Optional[List[float]] = None, + z_air: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + z_liquid_exit_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + cutoff_speed: Optional[List[float]] = None, + z_minimum: Optional[List[float]] = None, + z_bottom_search_offset: Optional[List[float]] = None, + lld_mode: Optional[List[Any]] = None, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ): + """Dispense to containers using mounted tips. + + Explicit kwargs override Hamilton liquid-class defaults; HLC supplies + unspecified fields and the volume correction curve unless disabled. + """ + resources = list(resources) + use_channels = use_channels if use_channels is not None else list(range(len(resources))) + ops = self._build_transfers( + resources, + vols, + use_channels, + offsets=offsets, + liquid_height=liquid_height, + flow_rates=flow_rates, + blow_out_air_volume=blow_out_air_volume, + ) + _DISPENSE_ALLOWED_LLD = frozenset({LLDMode.CAPACITIVE}) + effective_lld = self._resolve_effective_lld( + lld_mode, lld, len(ops), allowed_modes=_DISPENSE_ALLOWED_LLD + ) + use_v2 = self._resolve_command_version(command_version) + + kits = self._resolve_dispense_channels( + ops, + use_channels, + effective_lld, + z_final=z_final, + z_fluid=z_fluid, + z_air=z_air, + settling_time=settling_time, + transport_air_volume=transport_air_volume, + z_liquid_exit_speed=z_liquid_exit_speed, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + z_minimum=z_minimum, + z_bottom_search_offset=z_bottom_search_offset, + lld=lld, + c_lld=c_lld, + container_segments=container_segments, + auto_container_geometry=auto_container_geometry, + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + ) + + lld_read_timeout = read_timeout + if lld_read_timeout is None and effective_lld and kits: + min_z_min = min(k.common.z_minimum for k in kits) + lld_read_timeout = lld_seek_timeout(kits[0].lld, min_z_min) + + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=op.resource, + tip=op.tip, + volume_ul=next(k.common.liquid_volume for k in kits if k.channel == _CHANNEL_INDEX[ch]), + direction="dispense", + ) + for ch, op in zip(use_channels, ops) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._send_dispense(kits, effective_lld, use_v2, lld_read_timeout) + + await self._finalize_channel_command(use_channels, volume_intents=volume_intents, send=_send) + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + """Check if the tip can be picked up by the specified channel. + + Uses the same logic as Nimbus/STAR: only Hamilton tips, no XL tips, + and channel index must be valid. + """ + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + try: + n = self._info.config.num_channels + except RuntimeError: + n = None + if n is not None and channel_idx >= n: + return False + return True + + # --------------------------------------------------------------------------- + # Firmware version queries (per-channel; box-level queries live on PrepClient) + # --------------------------------------------------------------------------- + + async def request_pip_channel_version(self, channel: int) -> Optional[str]: + """Firmware version string for pipettor channel (0=rearmost).""" + if channel >= len(self.channels): + return None + return await self.channels[channel].request_firmware_version() + + # --------------------------------------------------------------------------- + # Channel position queries + # --------------------------------------------------------------------------- + + async def request_channel_bounds(self) -> list[PrepChannelBounds]: + """Per-channel movement bounds (PipettorService.GetChannelBounds). + + Thin delegation to :func:`request_channel_bounds`. + Prefer reading cached values via ``self.channels[i].bounds``; use this when a + fresh re-query is required. + """ + return await request_channel_bounds(self._client) + + async def request_channel_positions(self) -> list[Coordinate]: + """Request the current XYZ positions of all pipettor channels. + + Queries Pipettor.GetPositions (cmd=25). Returns one Coordinate per channel, + ordered by channel index (0=rearmost). + + Uses the typed PrepGetPositions command with ChannelXYZPositionParameters + response struct for reliable parsing across firmware versions. + + Returns: + List of Coordinate, one per channel. + """ + try: + resp_obj = await self._client.execute(PrepCmd.PrepGetPositions()) + except (HoiError, ChannelizedError): + return [] + if not isinstance(resp_obj, PrepCmd.PrepGetPositions.Response): + return [] + resp = resp_obj + if not resp.positions: + return [] + + _CHANNEL_ENUM_TO_IDX = {int(v): k for k, v in _CHANNEL_INDEX.items()} + indexed: list[tuple[int, Coordinate]] = [] + for p in resp.positions: + ch_idx = _CHANNEL_ENUM_TO_IDX.get(p.channel) + if ch_idx is not None: + indexed.append((ch_idx, Coordinate(x=p.position_x, y=p.position_y, z=p.position_z))) + + indexed.sort(key=lambda pair: pair[0]) + return [coord for _, coord in indexed] + + async def request_x_pos_channel_n(self, channel_idx: int = 0) -> float: + """Request X position of pipettor channel n (in mm). + + Analogous to STARBackend.request_x_pos_channel_n(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + X position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + return float(positions[channel_idx].x) + + async def request_y_pos_channel_n(self, channel_idx: int) -> float: + """Request Y position of pipettor channel n (in mm). + + Analogous to STARBackend.request_y_pos_channel_n(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Y position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + return float(positions[channel_idx].y) + + async def request_z_pos_channel_n(self, channel_idx: int) -> float: + """Request Z position of pipettor channel n (in mm). + + Analogous to STARBackend.request_z_pos_channel_n(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Z position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + return float(positions[channel_idx].z) + + async def get_channels_y_positions(self) -> dict[int, float]: + """Request Y positions of all channels. + + Analogous to STARBackend.get_channels_y_positions(). + + Returns: + Dict mapping channel index (0=rearmost) to Y position in mm. + """ + positions = await self.request_channel_positions() + return {i: coord.y for i, coord in enumerate(positions)} + + async def get_channels_z_positions(self) -> dict[int, float]: + """Request Z positions of all channels. + + Analogous to STARBackend.get_channels_z_positions(). + + Returns: + Dict mapping channel index (0=rearmost) to Z position in mm. + """ + positions = await self.request_channel_positions() + return {i: coord.z for i, coord in enumerate(positions)} + + async def request_tip_bottom_z_position(self, channel_idx: int) -> float: + """Request the Z position of the tip bottom on the specified channel. + + GetPositions returns tip-adjusted Z when a tip is mounted — the reported Z + is the tip bottom position, not the channel head. Verified empirically: + channel at traverse (167.5mm) with 50uL NTR tip (extension 42.4mm) reports + Z=125.1mm = 167.5 - 42.4. + + Requires a tip to be mounted (verified via sleeve sensor). + + Analogous to STARBackend.request_tip_bottom_z_position(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Tip bottom Z position in mm. + + Raises: + RuntimeError: If no tip is present on the channel. + """ + tip_presence = await self.sense_tip_presence() + if channel_idx >= len(tip_presence) or not tip_presence[channel_idx]: + raise RuntimeError(f"No tip mounted on channel {channel_idx}") + + return await self.request_z_pos_channel_n(channel_idx) + + async def request_probe_z_position(self, channel_idx: int) -> float: + """Request the Z position of the channel probe/head (excluding tip). + + Since GetPositions returns tip-adjusted Z when a tip is mounted, this + method queries the firmware's held tip definition (GetTipDefinitionHeld, + Pipettor cmd=13) to get the tip length and adds it back. + + When no tip is mounted, returns the same value as request_z_pos_channel_n(). + + Analogous to STARBackend.request_probe_z_position(). + + Args: + channel_idx: Channel index (0=rearmost). + + Returns: + Channel head Z position in mm (excluding tip). + """ + z = await self.request_z_pos_channel_n(channel_idx) + tip_presence = await self.sense_tip_presence() + if channel_idx < len(tip_presence) and tip_presence[channel_idx]: + # Query firmware for the held tip definition to get tip length + pipettor_addr = await self._client.resolve_path(PIPETTOR_OBJECT_PATH) + raw = await self._client.execute(PrepCmd.PrepProbeRequest(dest=pipettor_addr, command_id=13)) + if raw is not None: + import struct as _struct + + data = raw + # TipDefinition struct: default_values, id, volume(F32), length(F32), ... + # The second F32 is the tip extension length + f32_count = 0 + i = 0 + while i < len(data) - 7: + if data[i] == 0x28 and data[i + 1] == 0x00: + f32_count += 1 + if f32_count == 2: # second F32 = length + tip_length = _struct.unpack_from(" 0: + z += tip_length + break + i += 8 + else: + i += 1 + return z + + # --------------------------------------------------------------------------- + # Per-axis channel movement + # --------------------------------------------------------------------------- + + async def move_channel_x(self, channel_idx: int, x: float) -> None: + """Move the gantry X axis to a position (in mm). + + On the Prep, X is shared across all channels (single gantry). The channel_idx + parameter is accepted for STAR API compatibility but does not affect which + channel moves — all channels move together in X. + + Analogous to STARBackend.move_channel_x(). + + Args: + channel_idx: Channel index (0=rearmost). Used to read current Y/Z. + x: Target X position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + await self.move_to_position( + x, positions[channel_idx].y, positions[channel_idx].z, use_channels=channel_idx + ) + + async def move_channel_y(self, channel_idx: int, y: float) -> None: + """Move a channel in the Y direction (in mm). + + Analogous to STARBackend.move_channel_y(). + + Args: + channel_idx: Channel index (0=rearmost). + y: Target Y position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + await self.move_to_position( + positions[channel_idx].x, y, positions[channel_idx].z, use_channels=channel_idx + ) + + async def move_channel_z(self, channel_idx: int, z: float) -> None: + """Move a channel in the Z direction (in mm). + + Analogous to STARBackend.move_channel_z(). + + Args: + channel_idx: Channel index (0=rearmost). + z: Target Z position in mm. + """ + positions = await self.request_channel_positions() + if channel_idx >= len(positions): + raise ValueError(f"Channel {channel_idx} out of range ({len(positions)} channels).") + await self.move_to_position( + positions[channel_idx].x, positions[channel_idx].y, z, use_channels=channel_idx + ) + + # --------------------------------------------------------------------------- + # Tip presence sensing + # --------------------------------------------------------------------------- + + async def sense_tip_presence(self) -> list[bool]: + """Sense whether a tip is physically present on each pipettor channel via the sleeve sensor. + + Resolves each channel's Squeeze.SDrive object from the firmware tree, then + finds GetTipPresent by name in that object's method table. The query uses + the interface and method IDs declared by the firmware. Method tables are + cached by the connection's introspection instance. + + Returns: + List of bools, one per channel (index 0=rearmost). True if tip detected. + """ + + drive_map = await self.discover_channel_drives() + if not drive_map.sleeve_sensor_addrs: + raise RuntimeError("No channel sleeve sensor addresses discovered.") + + results: list[bool] = [] + for addr in drive_map.sleeve_sensor_addrs: + method = await self._client.introspection.get_method_by_name(addr, "GetTipPresent") + raw = await self._client.execute( + PrepCmd.PrepProbeRequest( + dest=addr, command_id=method.method_id, interface_id=method.interface_id + ) + ) + if raw is None or len(raw) < 8: + results.append(False) + else: + val = _struct.unpack_from(" List[Optional[bool]]: + pres = await self.sense_tip_presence() + return [bool(x) for x in pres] + + # --------------------------------------------------------------------------- + # Capacitance-based probing (cLLD) + # --------------------------------------------------------------------------- + + async def clld_probe_x_position_using_channel(self, *args, **kwargs): + """Probe X position using capacitive LLD. Not yet implemented for the Prep. + + TODO: Investigate ChannelCoordinator [1:17] MoveChannelAxisAbsolute and + [1:18] MoveChannelAxisRelative for X-axis probing with cLLD feedback. + The ChannelCoordinator also has [1:19] YSeekLldPosition which may have + an X equivalent, though none was found in introspection. + """ + raise NotImplementedError( + "clld_probe_x_position_using_channel is not yet implemented for PrepChannels." + ) + + async def clld_probe_y_position_using_channel(self, *args, **kwargs): + """Probe Y position using capacitive LLD. Not yet implemented for the Prep. + + TODO: Investigate ChannelCoordinator [1:19] YSeekLldPosition(seekParameters) + which takes a YLLDSeekParameters struct and returns SeekResultParameters. + Also Channel [1:11] LeakCheck has ySeekDistance/yPreloadDistance params + which suggest Y-axis seeking capability. + """ + raise NotImplementedError( + "clld_probe_y_position_using_channel is not yet implemented for PrepChannels." + ) + + async def clld_probe_z_height_using_channel(self, *args, **kwargs): + """Probe Z-height using capacitive LLD. Not yet implemented for the Prep. + + TODO: Implement using the standalone ZSeekLldPosition command: + - Pipettor [1:29] ZSeekLldPosition(seekParameters) -> results: SeekResultParameters + - ChannelCoordinator [1:20] ZSeekLldPosition(seekParameters) -> results: SeekResultParameters + Previously returned HC_RESULT=0x0F06 which was assumed to be "LLD not supported". + Now identified as "Z position out of allowed movement range" — the Z parameters + in LLDChannelSeekParameters were out of bounds. Retry with valid Z values + within deck_bounds (min_z=18.03, max_z=167.5). + + Findings from testing: + - cLLD DOES work through the aspirate path (aspirate with + lld_mode=[LLDMode.CAPACITIVE] and default_values=False on both + LldParameters and CLldParameters). + - Standalone ZSeekLldPosition is rejected with 0x0F06 when Z params are out of range. + - The aspirate-based approach is a workaround, not a proper standalone probe. + + Also investigate ZAxis-level alternatives: + - ZAxis.SeekCapacitiveLld [1:12] (returns 0x0207 when called directly) + - ZAxis.SeekCapacitiveLldTip [1:13] (returns 0x0207 when called directly) + - ZAxis.LiquidStatus [1:16] for reading last detection results + - PipettorService.MeasureLldFrequency [1:6] for sensor health checks + """ + raise NotImplementedError( + "clld_probe_z_height_using_channel is not yet implemented for PrepChannels." + ) + + async def ztouch_probe_z_height_using_channel(self, *args, **kwargs): + """Probe Z-height using force/motor stall detection. Not yet implemented for the Prep. + + TODO: Investigate force-based Z probing commands: + - ZAxis.SeekObstacle [1:14] SeekObstacle(startPosition, endPosition, finalPosition, velocity) + Currently returns 0x0207 when called directly — needs coordinator routing. + - Calibration.ZTouchoff [1:8] — runs a Z touchoff calibration (force-based). + - The STAR implements this via a dedicated "ZH" firmware command with PWM-based + force detection. The Prep may have an equivalent through the ChannelCoordinator + but it was not found in introspection. + """ + raise NotImplementedError( + "ztouch_probe_z_height_using_channel is not yet implemented for PrepChannels." + ) + + # --------------------------------------------------------------------------- + # Pipettor convenience methods + # --------------------------------------------------------------------------- + + async def move_channels_to_safe_z(self, channels: Optional[List[int]] = None) -> None: + """Move the given channels' Z axes up to safe (traverse) height (cmd=28). + + Use after picking up a tool or before returning a tool to avoid collisions + during XY moves. The instrument uses its configured safe/traverse height; + no height parameter is sent. + + Args: + channels: Channel indices to move (0=rearmost). None = all channels. + """ + if channels is None: + channels = list(range(self.num_channels)) + else: + channels = sorted(set(channels)) + if not channels: + return + assert max(channels) < self.num_channels, ( + f"channel index out of range (valid: 0..{self.num_channels - 1})" + ) + channel_enums = [_CHANNEL_INDEX[ch] for ch in channels] + await self._client.execute(PrepCmd.PrepMoveZUpToSafe(channels=channel_enums)) + + async def move_to_position( + self, + x: float, + y: Union[float, List[float]], + z: Union[float, List[float]], + use_channels: Optional[Union[int, List[int]]] = 0, + *, + via_lane: bool = False, + ) -> None: + """Move pipettor to position (cmd=26 or 27). Same (x,y,z) params; via_lane selects cmd 27. + + use_channels defaults to 0 (rear channel). Pass a single channel index (int) or + a list of indices; for all channels use list(range(self.num_channels)). For a + single channel, y and z may be scalars instead of lists. + """ + if use_channels is None: + channels = [0] + elif isinstance(use_channels, list): + channels = list(use_channels) + else: + # int or int-like (e.g. numpy.int64); single channel + channels = [int(use_channels)] + channels = sorted(channels) + if channels: + assert max(channels) < self.num_channels, ( + f"use_channels index out of range (valid: 0..{self.num_channels - 1})" + ) + if isinstance(y, list): + assert len(y) == len(channels), "len(y) must equal len(use_channels)" + if isinstance(z, list): + assert len(z) == len(channels), "len(z) must equal len(use_channels)" + + # Validate against per-channel movement bounds (cached from firmware at setup). + y_vals = y if isinstance(y, list) else [y] * len(channels) + z_vals = z if isinstance(z, list) else [z] * len(channels) + for i, (y_i, z_i) in enumerate(zip(y_vals, z_vals)): + ch = channels[i] + if ch < len(self._channel_bounds): + b = self._channel_bounds[ch] + if not b["x_min"] <= x <= b["x_max"]: + raise ValueError(f"x={x} outside channel {ch} range [{b['x_min']:.1f}, {b['x_max']:.1f}]") + if not b["y_min"] <= y_i <= b["y_max"]: + raise ValueError( + f"y={y_i} outside channel {ch} range [{b['y_min']:.1f}, {b['y_max']:.1f}]" + ) + if z_i > b["z_max"]: + raise ValueError(f"z={z_i} above channel {ch} maximum {b['z_max']:.1f}") + + move_parameters = _build_pipettor_gantry_move_parameters(x, channels, y, z) + + if via_lane: + await self._client.execute(PrepCmd.PrepMoveToPositionViaLane(move_parameters=move_parameters)) + else: + await self._client.execute(PrepCmd.PrepMoveToPosition(move_parameters=move_parameters)) + + async def stop(self) -> None: + self.setup_finished = False + + def serialize(self) -> dict: + return { + "type": self.__class__.__name__, + "default_traverse_height": self._user_traverse_height, + "use_v1_aspirate_dispense": self._use_v1_aspirate_dispense, + } diff --git a/pylabrobot/hamilton/prep/chatterbox.py b/pylabrobot/hamilton/prep/chatterbox.py new file mode 100644 index 00000000000..d63d4549c62 --- /dev/null +++ b/pylabrobot/hamilton/prep/chatterbox.py @@ -0,0 +1,256 @@ +"""PrepChatterboxClient: minimal client for tests without TCP hardware.""" + +from __future__ import annotations + +import logging +from typing import Callable, List, Optional, Union + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.introspection import ( + HamiltonIntrospection, + MethodInfo, + ObjectInfo, +) +from pylabrobot.hamilton.transport.tcp.messages import CommandResponse, HoiParams +from pylabrobot.hamilton.transport.tcp.packets import Address, HarpPacket, HoiPacket, IpPacket +from pylabrobot.hamilton.transport.tcp.protocol import Hoi2Action +from pylabrobot.hamilton.transport.tcp.session import SessionState, TCPSession +from pylabrobot.hamilton.transport.tcp.wire_types import StructArray +from pylabrobot.io.socket import Socket + +from . import prep_commands as PrepCmd +from .client import ( + MLPREP_OBJECT_PATH, + MPH_OBJECT_PATH, + PIPETTOR_OBJECT_PATH, + PrepClient, + _ResolvedPrepCommand, +) +from .info import PrepInstrumentInfo +from .prep_commands import PrepCommand + +logger = logging.getLogger(__name__) + +# Channel v2 support probe expects pipettor interface 1 to expose these method IDs. +_V2_PIPETTING_METHOD_IDS = frozenset(range(38, 44)) +# PrepHead8._probe_v2_support expects MPH interface 1 to expose these method IDs. +_V2_MPH_METHOD_IDS = frozenset(range(29, 35)) + + +class _PrepChatterboxIntrospection(HamiltonIntrospection): + """Offline introspection: v2 probe succeeds when ``use_v1_aspirate_dispense`` is False.""" + + def __init__( + self, + *args, + stub_methods_fn: Callable[[Address, int], Optional[List[MethodInfo]]], + **kwargs, + ): + super().__init__(*args, **kwargs) + self._stub_methods_fn = stub_methods_fn + + async def methods_for_interface( + self, address: Union[Address, str], interface_id: int + ) -> List[MethodInfo]: + self._executor.require_active() + addr = await self._resolve_target_address(address) + stubs = self._stub_methods_fn(addr, interface_id) + if stubs is not None: + return stubs + return await super().methods_for_interface(address, interface_id) + + +class PrepChatterboxInstrumentInfo(PrepInstrumentInfo): + """Offline info: uses canned :class:`~prep_commands.InstrumentConfig` from the chatterbox client.""" + + async def _on_setup(self) -> None: + d = self._driver + assert isinstance(d, PrepChatterboxClient) + self._config = d._canned_config + + +class PrepChatterboxClient(PrepClient): + """Skips TCP; uses canned addresses so Prep channels can be exercised offline. + + Canned firmware state (num_channels, has_mph, traverse height) lives on the + chatterbox client — :class:`PrepChatterboxInstrumentInfo` reads it for ``info.config``. + + Default ``use_v1_aspirate_dispense=False`` matches hardware: introspection stubs + report v2 aspirate/dispense commands on the pipettor. Pass + ``use_v1_aspirate_dispense=True`` for a thinner v1-only offline path. + """ + + def __init__( + self, + num_channels: int = 2, + has_mph: bool = True, + default_traverse_height: float = 180.0, + use_v1_aspirate_dispense: bool = False, + ): + self._canned_config = PrepCmd.InstrumentConfig( + deck_bounds=None, + has_enclosure=False, + safe_speeds_enabled=True, + deck_sites=(), + waste_sites=(), + default_traverse_height=default_traverse_height, + num_channels=num_channels, + has_mph=has_mph, + ) + self._pipettor_addr: Optional[Address] = None + self._mph_addr: Optional[Address] = None + self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense + super().__init__(host="chatterbox", port=2000) + + def _create_session(self) -> TCPSession: + """Create an offline session that still encodes requests and decodes responses.""" + session = _PrepChatterboxSession( + Socket("Prep chatterbox", "chatterbox", 2000), self._canned_config + ) + + def _stub_methods(addr: Address, interface_id: int) -> Optional[List[MethodInfo]]: + if interface_id == 1 and not self._use_v1_aspirate_dispense: + if self._pipettor_addr is not None and addr == self._pipettor_addr: + return [ + MethodInfo(interface_id=1, call_type=0, method_id=mid, name=f"v2_stub_{mid}") + for mid in sorted(_V2_PIPETTING_METHOD_IDS) + ] + if self._mph_addr is not None and addr == self._mph_addr: + return [ + MethodInfo(interface_id=1, call_type=0, method_id=mid, name=f"v2_mph_stub_{mid}") + for mid in sorted(_V2_MPH_METHOD_IDS) + ] + return None + + session.introspection = _PrepChatterboxIntrospection( + registry=session.registry, + global_object_addresses=session.global_object_addresses, + executor=session, + stub_methods_fn=_stub_methods, + ) + return session + + async def setup(self): + if self._session.state is not SessionState.CLOSED: + raise RuntimeError("Prep chatterbox already set up - call stop() first") + self._session = self._create_session() + self._session.state = SessionState.READY + self._session.client_address = Address(2, 1, 65535) + self._session.client_id = 1 + # Seed the introspection registry with every firmware path the codebase + # may touch. The seed list is derived from the command aggregate + # (PrepCommand._ALL_PATHS) plus PrepInstrumentInfo._paths — new commands + # with new firmware_path values get chatterbox parity for free. Addresses + # are assigned deterministically in sorted-path order so they're stable + # across runs. + seed_paths = sorted(PrepCommand._ALL_PATHS | set(PrepInstrumentInfo._paths.values())) + for idx, path in enumerate(seed_paths): + leaf = path.rsplit(".", 1)[-1] + addr = Address(1, 1, 256 + idx) + self.registry.register( + path, + ObjectInfo(name=leaf, version="", method_count=0, subobject_count=0, address=addr), + ) + self._pipettor_addr = await self.resolve_path(PIPETTOR_OBJECT_PATH) + self._mlprep_address = await self.resolve_path(MLPREP_OBJECT_PATH) + if self._canned_config.has_mph: + self._mph_addr = await self.resolve_path(MPH_OBJECT_PATH) + + async def stop(self): + self._pipettor_addr = None + self._mph_addr = None + self._mlprep_address = None + await super().stop() + + +# Canned payloads follow the declared response schemas; empty lists mean no simulated geometry. +_CANNED_RESPONSES: dict[type[TCPCommand], HoiParams] = { + PrepCmd.PrepGetPositions: HoiParams().add([], StructArray()), + PrepCmd.PrepGetIsInitialized: HoiParams().add(False, PrepCmd.PaddedBool), + PrepCmd.PrepGetDeckLight: HoiParams() + .add(0, PrepCmd.PaddedU8) + .add(0, PrepCmd.PaddedU8) + .add(0, PrepCmd.PaddedU8) + .add(0, PrepCmd.PaddedU8), + PrepCmd.PrepIsParked: HoiParams().add(False, PrepCmd.PaddedBool), + PrepCmd.PrepIsSpread: HoiParams().add(False, PrepCmd.PaddedBool), + PrepCmd.PrepGetIsEnclosurePresent: HoiParams().add(False, PrepCmd.PaddedBool), + PrepCmd.PrepGetSafeSpeedsEnabled: HoiParams().add(False, PrepCmd.PaddedBool), + PrepCmd.PrepGetDefaultTraverseHeight: HoiParams().add(0, PrepCmd.F32), + PrepCmd.PrepGetTipAndNeedleDefinitions: HoiParams().add([], StructArray()), + PrepCmd.PrepGetDeckBounds: HoiParams() + .add(0, PrepCmd.F32) + .add(0, PrepCmd.F32) + .add(0, PrepCmd.F32) + .add(0, PrepCmd.F32) + .add(0, PrepCmd.F32) + .add(0, PrepCmd.F32), + PrepCmd.PrepGetCalibrationSiteDefinitions: HoiParams().add([], StructArray()), + PrepCmd.PrepGetDeckSiteDefinitions: HoiParams().add([], StructArray()), + PrepCmd.PrepGetWasteSiteDefinitions: HoiParams().add([], StructArray()), + PrepCmd.PrepGetChannelBounds: HoiParams().add([], StructArray()), + PrepCmd.PrepGetPresentChannels: HoiParams().add([], PrepCmd.EnumArray), + PrepCmd.PrepCalibrateXAxis: HoiParams().add(0, PrepCmd.F32), + PrepCmd.PrepCalibrateYAxis: HoiParams().add(0, PrepCmd.F32), + PrepCmd.PrepCalibrateZAxis: HoiParams().add(0, PrepCmd.F32), + PrepCmd.PrepCalibrateSqueeze: HoiParams().add(0, PrepCmd.U32), + PrepCmd.PrepCalibrateSqueezeTips: HoiParams().add([], PrepCmd.U32Array), + PrepCmd.PrepGetCalibrationValues: HoiParams() + .add(0, PrepCmd.F32) + .add(0, PrepCmd.F32) + .add([], StructArray()), + PrepCmd.PrepGetChannelHardwareConfiguration: HoiParams().add([], StructArray()), +} + + +class _PrepChatterboxSession(TCPSession): + """Offline exchange using the same immutable requests and decoders as TCP.""" + + def __init__(self, io: Socket, config: PrepCmd.InstrumentConfig) -> None: + """Use the configured instrument metadata for matching typed status replies.""" + super().__init__(io) + self._responses = dict(_CANNED_RESPONSES) + self._responses[PrepCmd.PrepGetDefaultTraverseHeight] = HoiParams().add( + config.default_traverse_height, PrepCmd.F32 + ) + self._responses[PrepCmd.PrepGetSafeSpeedsEnabled] = HoiParams().add( + config.safe_speeds_enabled, PrepCmd.PaddedBool + ) + self._responses[PrepCmd.PrepGetIsEnclosurePresent] = HoiParams().add( + config.has_enclosure, PrepCmd.PaddedBool + ) + + async def exchange( + self, command: TCPCommand[object], *, read_timeout: Optional[float] = None + ) -> CommandResponse: + """Encode one request and return a correlated canned success frame.""" + self.require_ready() + assert self.client_address is not None + sequence = self.allocate_sequence(command.dest) + request_frame = HarpPacket.unpack( + IpPacket.unpack(command.build(self.client_address, sequence)).payload + ) + hoi = HoiPacket.unpack(request_frame.payload) + request = command.request if isinstance(command, _ResolvedPrepCommand) else command + logger.info("[Prep chatterbox] %s", type(request).__name__) + payload = self._responses.get(type(request), HoiParams()) + action = ( + Hoi2Action.STATUS_RESPONSE + if hoi.action_code == Hoi2Action.STATUS_REQUEST + else Hoi2Action.COMMAND_RESPONSE + ) + response = HoiPacket( + interface_id=hoi.interface_id, + action_id=hoi.action_id, + params=payload.build(), + action_code=action, + ) + harp = HarpPacket( + src=command.dest, + dst=self.client_address, + seq=sequence, + protocol=2, + action_code=4, + payload=response.pack(), + ) + return CommandResponse.from_bytes(IpPacket(protocol=6, payload=harp.pack()).pack()) diff --git a/pylabrobot/hamilton/prep/client.py b/pylabrobot/hamilton/prep/client.py new file mode 100644 index 00000000000..e016fc5ebf4 --- /dev/null +++ b/pylabrobot/hamilton/prep/client.py @@ -0,0 +1,171 @@ +"""Prep connection and immutable request binding to discovered firmware objects.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional, TypeVar, Union + +from pylabrobot.hamilton.prep.error_tables import PREP_ERROR_CODES +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.messages import ( + CommandMessage, + CommandResponse, + HoiParamsParser, +) +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient +from pylabrobot.hamilton.transport.tcp.wire_types import HcResultEntry + +from . import prep_commands as PrepCmd +from .prep_commands import _UNRESOLVED, PrepCommand + +_EXPECTED_ROOT = "MLPrepRoot" +MLPREP_OBJECT_PATH = "MLPrepRoot.MLPrep" +PIPETTOR_OBJECT_PATH = "MLPrepRoot.PipettorRoot.Pipettor" +MPH_OBJECT_PATH = "MLPrepRoot.MphRoot.MPH" +ResultT = TypeVar("ResultT") + + +@dataclass(frozen=True) +class _ResolvedPrepCommand(TCPCommand[bytes]): + """Bind a reusable Prep request to one connection's discovered destination.""" + + request: PrepCommand[object] + + def build(self, src: Address, seq: int, response_required: bool = True) -> bytes: + """Encode the original request at its resolved destination.""" + request = self.request + if request.interface_id is None or request.command_id is None: + raise ValueError(f"{type(request).__name__} must define interface_id and command_id") + return CommandMessage( + dest=self.dest, + interface_id=request.interface_id, + method_id=request.command_id, + params=request.build_parameters(), + action_code=request.action_code, + harp_protocol=request.harp_protocol, + ip_protocol=request.ip_protocol, + ).build(src, seq, harp_response_required=response_required) + + @property + def uses_physical_channels(self) -> bool: # type: ignore[override] + """Preserve the device request's error attribution.""" + return self.request.uses_physical_channels + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map result ordinals through the original channel selection.""" + return self.request._channel_index_for_entry(entry_index, entry) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> bytes: + """Preserve the checked payload for the original request to decode.""" + return data + + +class PrepClient(HamiltonTCPClient): + """Hamilton TCP client with Prep discovery and firmware-path request binding.""" + + _ERROR_CODES = PREP_ERROR_CODES + + def __init__( + self, + host: str, + port: int = 2000, + read_timeout: float = 300.0, + write_timeout: float = 30.0, + connection_timeout: int = 600, + ): + super().__init__( + host=host, + port=port, + read_timeout=read_timeout, + write_timeout=write_timeout, + connection_timeout=connection_timeout, + ) + self._mlprep_address: Optional[Address] = None + + async def setup(self) -> None: + """Connect, verify the instrument identity, and resolve the MLPrep object.""" + await super().setup() + self._mlprep_address = None + try: + root = await self.discovered_root_name() + if root != _EXPECTED_ROOT: + raise RuntimeError( + f"Expected root '{_EXPECTED_ROOT}' (Prep), but discovered '{root}'. Wrong instrument?" + ) + self._mlprep_address = await self.resolve_path(MLPREP_OBJECT_PATH) + except BaseException: + await self.stop() + raise + + async def stop(self) -> None: + """Close the connection and discard its bootstrap address.""" + try: + await super().stop() + finally: + self._mlprep_address = None + + @property + def mlprep_address(self) -> Address: + """Address of MLPrep in the current connection.""" + if self._mlprep_address is None: + raise RuntimeError("MLPrep address not resolved. Call setup() first.") + return self._mlprep_address + + async def _resolve_command( + self, command: TCPCommand[ResultT] + ) -> Union[TCPCommand[ResultT], _ResolvedPrepCommand]: + """Resolve a fixed firmware path without modifying the caller's request.""" + self._session.require_active() + if not isinstance(command, PrepCommand) or command.dest != _UNRESOLVED: + return command + path = command.firmware_path + if path is None: + raise RuntimeError( + f"{type(command).__name__} has no firmware_path declared and no explicit dest= supplied." + ) + try: + address = await self.resolve_path(path) + except KeyError as exc: + raise RuntimeError( + f"Cannot send {type(command).__name__}: firmware path {path!r} did not resolve ({exc})." + ) from exc + return _ResolvedPrepCommand(dest=address, request=command) + + async def execute( + self, command: TCPCommand[ResultT], *, read_timeout: Optional[float] = None + ) -> ResultT: + """Resolve the request target, execute once, and decode its typed response.""" + session = self._session + resolved = await self._resolve_command(command) + if isinstance(resolved, _ResolvedPrepCommand): + data = await session.execute(resolved, read_timeout=read_timeout) + return command.parse_response_parameters(data) + return await session.execute(command, read_timeout=read_timeout) + + async def exchange( + self, command: TCPCommand[object], *, read_timeout: Optional[float] = None + ) -> CommandResponse: + """Resolve the target and return the full terminal frame for protocol inspection.""" + session = self._session + return await session.exchange(await self._resolve_command(command), read_timeout=read_timeout) + + async def discovered_root_name(self) -> str: + """Read the discovered firmware root's name.""" + roots = self.get_root_object_addresses() + if not roots: + raise RuntimeError("No root objects discovered. Call setup() first.") + return (await self.introspection.get_object(roots[0])).name + + async def _query_firmware_string( + self, addr: Address, cmd_id: int, iface_id: int = 3 + ) -> Optional[str]: + """Execute a status query and decode its string fragment.""" + data = await self.execute( + PrepCmd.PrepProbeRequest(dest=addr, command_id=cmd_id, interface_id=iface_id) + ) + for _, value in HoiParamsParser(data).parse_all(): + if isinstance(value, str): + return value.rstrip("\x00") + return None diff --git a/pylabrobot/hamilton/prep/gripper.py b/pylabrobot/hamilton/prep/gripper.py new file mode 100644 index 00000000000..5cf8c9ada4d --- /dev/null +++ b/pylabrobot/hamilton/prep/gripper.py @@ -0,0 +1,403 @@ +"""Hamilton Prep CoRe gripper and PrepGripperArm frontend helper.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal, Optional + +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.resource_state import place_resource + +from . import prep_commands as PrepCmd + +if TYPE_CHECKING: + from .channels import PrepChannels + from .client import PrepClient + +logger = logging.getLogger(__name__) + + +class PrepGripper: + """CoRe gripper for Prep — translates plate/tool ops to PrepCmd firmware commands. + + Tool management (pick_up_tool / drop_tool) is handled by the + :meth:`Prep.core_grippers` context manager. + """ + + def __init__(self, *, client: "PrepClient", channels: "PrepChannels") -> None: + self._client = client + self._channels = channels + + @property + def client(self) -> "PrepClient": + return self._client + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + *, + resource_length: float, + resource_height: float, + plate_top_z_offset: float, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + """Pick up a plate at the specified location. + + Args: + location: Plate center at grip height (x, y, grip_z) in deck coordinates. + resource_width: Plate width along the grip axis (Y) in mm. + resource_length: Plate length (X) in mm. + resource_height: Plate height (Z) in mm. + plate_top_z_offset: Offset from grip Z to plate top center Z. + clearance_y: Approach clearance along the grip axis (mm). + grip_speed_y: Grip speed (mm/s). + squeeze_mm: Additional squeeze distance beyond clearance (mm). + """ + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=location.x, + y_position=location.y, + z_position=location.z + plate_top_z_offset, + ) + plate_dims = PrepCmd.PlateDimensions( + default_values=False, + length=resource_length, + width=resource_width, + height=resource_height, + ) + grip_distance = clearance_y + squeeze_mm + + await self._client.execute( + PrepCmd.PrepPickUpPlate( + plate_top_center=plate_top_center, + plate=plate_dims, + clearance_y=clearance_y, + grip_speed_y=grip_speed_y, + grip_distance=grip_distance, + grip_height=location.z, + ) + ) + + async def drop_at_location( + self, + location: Coordinate, + resource_width: float, + *, + clearance_y: float = 3.0, + acceleration_scale_x: int = 1, + ) -> None: + """Drop a plate at the specified location. + + Args: + location: Plate center at place height in deck coordinates. + resource_width: Plate width along the grip axis (Y) in mm (unused by firmware). + clearance_y: Release clearance along the grip axis (mm). + acceleration_scale_x: X-axis acceleration scale. + """ + del resource_width + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=location.x, + y_position=location.y, + z_position=location.z, + ) + await self._client.execute( + PrepCmd.PrepDropPlate( + plate_top_center=plate_top_center, + clearance_y=clearance_y, + acceleration_scale_x=acceleration_scale_x, + ) + ) + + async def move_to_location( + self, + location: Coordinate, + *, + acceleration_scale_x: int = 1, + ) -> None: + """Move a held plate to a new position without releasing it. + + Args: + location: Target plate center position in deck coordinates. + acceleration_scale_x: X-axis acceleration scale. + """ + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=location.x, + y_position=location.y, + z_position=location.z, + ) + await self._client.execute( + PrepCmd.PrepMovePlate( + plate_top_center=plate_top_center, + acceleration_scale_x=acceleration_scale_x, + ) + ) + + async def release_plate(self) -> None: + """Open the CoRe gripper and release whatever is held (PrepReleasePlate, cmd=21).""" + await self._client.execute(PrepCmd.PrepReleasePlate()) + + async def pick_up_tool( + self, + tool_position_x: float, + tool_position_z: float, + front_channel_position_y: float, + rear_channel_position_y: float, + *, + tool_seek: Optional[float] = None, + tool_x_radius: float = 2.0, + tool_y_radius: float = 2.0, + tip_definition: Optional[PrepCmd.TipPickupParameters] = None, + pre_position: bool = True, + ) -> None: + """Pick up CoRe gripper tool (PrepPickUpTool, cmd=15). + + When ``pre_position`` is True (default), moves both channels to the tool XY at + traverse height before the firmware pickup (same pattern as tip pickup). + After pickup, moves channels to safe Z. + """ + if tool_seek is None: + tool_seek = tool_position_z + 10.0 + if tip_definition is None: + tip_definition = PrepCmd.CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS + if pre_position: + traverse_h = self._channels._resolve_traverse_height() + await self._channels.move_to_position( + x=tool_position_x, + y=[rear_channel_position_y, front_channel_position_y], + z=traverse_h, + use_channels=[0, 1], + ) + await self._client.execute( + PrepCmd.PrepPickUpTool( + tip_definition=tip_definition, + tool_position_x=tool_position_x, + tool_position_z=tool_position_z, + front_channel_position_y=front_channel_position_y, + rear_channel_position_y=rear_channel_position_y, + tool_seek=tool_seek, + tool_x_radius=tool_x_radius, + tool_y_radius=tool_y_radius, + ) + ) + await self._channels.move_channels_to_safe_z() + + async def drop_tool(self, *, move_to_safe_z_first: bool = True) -> None: + """Drop CoRe gripper tool (PrepDropTool, cmd=16).""" + if move_to_safe_z_first: + await self._channels.move_channels_to_safe_z() + await self._client.execute(PrepCmd.PrepDropTool()) + + +class PrepGripperArm: + """Resource-aware helper over :class:`PrepGripper` pose commands. + + Resource path: ``pick_up_resource`` / ``drop_resource`` resolve geometry from the + resource tree (with optional ``offset``) and reassign the held resource on drop. + + Coordinate path: ``pick_up_at_location`` / ``drop_at_location`` take explicit deck + coordinates (escape hatch for taught points). Prep has no grip-force field; + squeeze is controlled via ``clearance_y``, ``squeeze_mm``, and ``grip_speed_y``. + """ + + def __init__( + self, + backend: PrepGripper, + reference_resource: Resource, + grip_axis: Literal["x", "y"] = "y", + ) -> None: + self.backend = backend + self._reference_resource = reference_resource + self._grip_axis = grip_axis + self._pickup_distance_from_bottom: Optional[float] = None + self._holding_resource_width: Optional[float] = None + self._held_resource: Optional[Resource] = None + + def _resolve_pickup_distance( + self, resource: Resource, pickup_distance_from_bottom: Optional[float] + ) -> float: + if pickup_distance_from_bottom is not None: + return pickup_distance_from_bottom + if resource.preferred_pickup_location is not None: + logger.debug( + "Using preferred pickup location for resource %s as pickup_distance_from_bottom was " + "not specified.", + resource.name, + ) + return resource.preferred_pickup_location.z + logger.debug( + "No preferred pickup location for resource %s. Using default pickup distance of 5mm " + "from top (= size_z - 5).", + resource.name, + ) + return resource.get_size_z() - 5.0 + + def _pickup_location( + self, + resource: Resource, + offset: Coordinate, + pickup_distance_from_bottom: float, + ) -> Coordinate: + center = resource.center().rotated(resource.get_absolute_rotation()) + if resource.is_in_subtree_of(self._reference_resource): + loc = resource.get_location_wrt(self._reference_resource, "l", "f", "b") + center + offset + else: + loc = center + offset + return Coordinate(loc.x, loc.y, loc.z + pickup_distance_from_bottom) + + def _drop_location(self, destination: Resource, offset: Coordinate) -> Coordinate: + if self._held_resource is None or self._pickup_distance_from_bottom is None: + raise RuntimeError( + "drop_resource requires a prior pick_up_resource (held resource and grip height)." + ) + held = self._held_resource + pdfb = self._pickup_distance_from_bottom + if isinstance(destination, ResourceHolder): + child = destination.get_default_child_location(held) + else: + child = Coordinate.zero() + center = held.center().rotated(held.get_absolute_rotation()) + plate_lfb = destination.get_location_wrt(self._reference_resource, "l", "f", "b") + child + loc = plate_lfb + center + offset + return Coordinate(loc.x, loc.y, loc.z + pdfb) + + def _resource_width(self, resource: Resource) -> float: + if self._grip_axis == "y": + return resource.get_absolute_size_y() + return resource.get_absolute_size_x() + + def _clear_held_state(self) -> None: + self._holding_resource_width = None + self._pickup_distance_from_bottom = None + self._held_resource = None + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_bottom: Optional[float] = None, + *, + resource_width: Optional[float] = None, + resource_length: Optional[float] = None, + resource_height: Optional[float] = None, + plate_top_z_offset: Optional[float] = None, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + pdfb = self._resolve_pickup_distance(resource, pickup_distance_from_bottom) + if resource_width is None: + resource_width = self._resource_width(resource) + if resource_length is None: + resource_length = resource.get_absolute_size_x() + if resource_height is None: + resource_height = resource.get_absolute_size_z() + if plate_top_z_offset is None: + plate_top_z_offset = resource.get_absolute_size_z() - pdfb + + location = self._pickup_location(resource, offset, pdfb) + await self.backend.pick_up_at_location( + location, + resource_width, + resource_length=resource_length, + resource_height=resource_height, + plate_top_z_offset=plate_top_z_offset, + clearance_y=clearance_y, + grip_speed_y=grip_speed_y, + squeeze_mm=squeeze_mm, + ) + self._pickup_distance_from_bottom = pdfb + self._holding_resource_width = resource_width + self._held_resource = resource + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + *, + resource_length: float, + resource_height: float, + plate_top_z_offset: float, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + """Pick up at an explicit grip-point coordinate (no resource-tree geometry). + + Sets held width so ``drop_at_location`` works. Does not set a held + :class:`Resource`; use ``drop_resource`` only after ``pick_up_resource``. + """ + await self.backend.pick_up_at_location( + location, + resource_width, + resource_length=resource_length, + resource_height=resource_height, + plate_top_z_offset=plate_top_z_offset, + clearance_y=clearance_y, + grip_speed_y=grip_speed_y, + squeeze_mm=squeeze_mm, + ) + self._holding_resource_width = resource_width + self._pickup_distance_from_bottom = None + self._held_resource = None + + async def drop_resource( + self, + destination: Resource, + offset: Coordinate = Coordinate.zero(), + *, + clearance_y: float = 3.0, + acceleration_scale_x: int = 1, + ) -> None: + """Drop the held resource onto a destination resource (e.g. a PrepDeck spot). + + Resolves place geometry from the destination holder + held plate, then + reassigns the resource tree after a successful firmware drop. + """ + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + if self._held_resource is None or self._pickup_distance_from_bottom is None: + raise RuntimeError( + "drop_resource requires a prior pick_up_resource (held resource and grip height)." + ) + held = self._held_resource + destination.check_can_drop_resource_here(held) + location = self._drop_location(destination, offset) + await self.backend.drop_at_location( + location, + self._holding_resource_width, + clearance_y=clearance_y, + acceleration_scale_x=acceleration_scale_x, + ) + self._clear_held_state() + place_resource(held, destination) + + async def drop_at_location( + self, + location: Coordinate, + *, + clearance_y: float = 3.0, + acceleration_scale_x: int = 1, + ) -> None: + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + await self.backend.drop_at_location( + location, + self._holding_resource_width, + clearance_y=clearance_y, + acceleration_scale_x=acceleration_scale_x, + ) + self._clear_held_state() + + async def move_to_location( + self, + location: Coordinate, + *, + acceleration_scale_x: int = 1, + ) -> None: + await self.backend.move_to_location(location, acceleration_scale_x=acceleration_scale_x) diff --git a/pylabrobot/hamilton/prep/head8.py b/pylabrobot/hamilton/prep/head8.py new file mode 100644 index 00000000000..aa6954084dc --- /dev/null +++ b/pylabrobot/hamilton/prep/head8.py @@ -0,0 +1,1304 @@ +"""PrepHead8 — 8MPH head for the Hamilton Prep. + +The 8MPH is a ganged head: a single X/Y/Z gantry and a single dispenser piston +drive all 8 probes together. Individual sleeves are mechanically coupled — partial +sleeve engagement produces insufficient grip force and tips fall off. All +operations therefore require all 8 channels simultaneously. + +------------------------------ +- PickupTips / DropTips: single TipPositionParameters struct; Y = probe-0 reference. + PickupTips has tipMask (0xFF default) for Hamilton service tooling; DropTips + has NO tip mask — all probes drop together unconditionally. +- Aspirate / Dispense: StructArray with exactly ONE entry. The gantry moves to + the probe-0 (row A) reference position and all 8 probes operate simultaneously. + Channel field = ChannelIndex.MPHChannel. + +Physical arrangement +-------------------- +Probes are ordered by Y (highest Y = probe 0 = row A). Pitch = PROBE_PITCH_MM. +""" + +from __future__ import annotations + +import logging +import struct as _struct +from typing import TYPE_CHECKING, Awaitable, Callable, List, Literal, Optional, Sequence, Union + +from pylabrobot.hamilton.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.resources import Container, Coordinate, Tip, Trash +from pylabrobot.resources.resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + all_channels_succeeded, + finalize_tip_ops, + finalize_volume_ops, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker +from pylabrobot.resources.well import Well + +from . import prep_commands as PrepCmd +from .channels import ( + LLDMode, + _absolute_z_from_well, + _build_container_segments, + _effective_radius, + _LldDefaults, +) +from .channels import ( + default_lld_params as _default_lld_params_fn, +) +from .channels import ( + lld_for_well as _lld_for_well_fn, +) +from .channels import ( + lld_seek_timeout as _lld_seek_timeout, +) +from .channels import ( + patch_common_with_cone as _patch_common_with_cone_fn, +) +from .channels import ( + resolve_command_version as _resolve_command_version_fn, +) +from .client import MPH_OBJECT_PATH + +if TYPE_CHECKING: + from .client import PrepClient + from .info import PrepInstrumentInfo + +logger = logging.getLogger(__name__) + +PROBE_PITCH_MM: float = 9.0 +NUM_PROBES: int = 8 +_FULL_TIP_MASK: int = 0xFF +_V2_MPH_CMD_IDS: frozenset = frozenset({29, 30, 31, 32, 33, 34}) +_PROBE_POS_TOLERANCE_MM: float = 1.0 # max deviation from expected 9mm pitch before raising + + +class PrepHead8: + """8-channel Multi-Pipetting Head for the Hamilton Prep. + + All 8 probes must participate in every operation. Partial channel selection + is rejected at this layer because the head is physically ganged (single drive + per axis, single piston) and partial sleeve engagement produces insufficient + grip force. + """ + + # Command dispatch tables: (effective_lld, is_tadm, use_v2) → command class + _ASPIRATE_CMD = { + (True, True, True): PrepCmd.MphAspirateWithLldTadm2, + (True, True, False): PrepCmd.MphAspirateWithLldTadm, + (True, False, True): PrepCmd.MphAspirateWithLld2, + (True, False, False): PrepCmd.MphAspirateWithLld, + (False, True, True): PrepCmd.MphAspirateTadm2, + (False, True, False): PrepCmd.MphAspirateTadm, + (False, False, True): PrepCmd.MphAspirateNoLldMonitoring2, + (False, False, False): PrepCmd.MphAspirateNoLldMonitoring, + } + + # Command dispatch tables: (effective_lld, use_v2) → command class + _DISPENSE_CMD = { + (True, True): PrepCmd.MphDispenseWithLld2, + (True, False): PrepCmd.MphDispenseWithLld, + (False, True): PrepCmd.MphDispenseNoLld2, + (False, False): PrepCmd.MphDispenseNoLld, + } + + def __init__( + self, + *, + client: "PrepClient", + info: "PrepInstrumentInfo", + default_traverse_height: Optional[float] = None, + use_v1_aspirate_dispense: bool = False, + ) -> None: + self._client = client + self._info = info + self._user_traverse_height = default_traverse_height + self._use_v1_aspirate_dispense: bool = use_v1_aspirate_dispense + self.channels: list = [] # populated by build_prep_channels after construction + self._supports_v2_pipetting: Optional[bool] = None + self.head: dict[int, TipTracker] = { + i: TipTracker(thing=f"Head8 channel {i}") for i in range(NUM_PROBES) + } + + # --------------------------------------------------------------------------- + # Setup / V2 probing + # --------------------------------------------------------------------------- + + async def _probe_v2_support(self) -> bool: + """Return True if the MPH firmware exposes V2 aspirate/dispense (cmds 29-34).""" + dest = await self._client.resolve_path(MPH_OBJECT_PATH) + methods = await self._client.introspection.methods_for_interface(dest, interface_id=1) + iface1_ids = {m.method_id for m in methods} + return _V2_MPH_CMD_IDS.issubset(iface1_ids) + + async def _on_setup(self) -> None: + if self._use_v1_aspirate_dispense: + self._supports_v2_pipetting = False + logger.info("MPH V2 aspirate/dispense probe skipped (use_v1_aspirate_dispense=True)") + else: + try: + supported = await self._probe_v2_support() + except Exception as e: + logger.warning("MPH V2 support probe failed: %s", e) + supported = False + if not supported: + raise RuntimeError( + "V2 aspirate/dispense commands (cmd 29-34) are not supported by this MPH firmware. " + "Pass use_v1_aspirate_dispense=True to PrepHead8 to use v1 commands instead." + ) + self._supports_v2_pipetting = True + logger.info("MPH V2 aspirate/dispense support: True") + + async def _on_stop(self) -> None: + self._supports_v2_pipetting = None + for tracker in self.head.values(): + tracker.clear() + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Tips currently mounted on the 8MPH (``None`` if empty).""" + return [self.head[i].get_tip() if self.head[i].has_tip else None for i in range(NUM_PROBES)] + + async def _finalize_head8_command( + self, + use_channels: Sequence[int], + *, + tip_intents: Optional[Sequence[Union[TipPickupIntent, TipDropIntent]]] = None, + volume_intents: Optional[Sequence[VolumeTransferIntent]] = None, + send: Callable[[], Awaitable[None]], + ) -> None: + error: Optional[BaseException] = None + try: + await send() + successes = all_channels_succeeded(use_channels) + except ChannelizedError as e: + error = e + successes = successes_from_failed_channels(use_channels, e.errors) + except BaseException as e: + error = e + successes = {ch: False for ch in use_channels} + if tip_intents is not None: + finalize_tip_ops(tip_intents, successes) + if volume_intents is not None: + finalize_volume_ops(volume_intents, successes) + if error is not None: + raise error + + # --------------------------------------------------------------------------- + # Internal helpers + # --------------------------------------------------------------------------- + + def _resolve_command_version(self, override: Optional[Literal["v1", "v2"]] = None) -> bool: + return _resolve_command_version_fn( + self._supports_v2_pipetting, + self._use_v1_aspirate_dispense, + override, + v2_error_hint=( + "v2 aspirate/dispense commands (cmd 29-34) are not supported by this firmware. " + "Use command_version='v1' or pass use_v1_aspirate_dispense=True to PrepHead8." + ), + ) + + def _resolve_traverse_height(self, final_z: Optional[float] = None) -> float: + if final_z is not None: + return final_z + if self._user_traverse_height is not None: + return self._user_traverse_height + height: Optional[float] = self._info.config.default_traverse_height + if height is None: + raise RuntimeError("No traverse height available; set default_traverse_height") + return height + + def _resolve_probe_positions(self, wells) -> List[float]: + """Compute expected probe Y positions and validate actual well Ys match. + + Probe 0 = row A = highest Y. Expected position for probe i: + wells[0].y - i * PROBE_PITCH_MM + + Works for any labware at 9mm pitch: standard 96-well columns, or + interleaved 384-well selections (every other row = 2 × 4.5mm = 9mm). + + Returns the expected Y values (one per probe) for logging/accounting. + Raises ValueError if any well deviates beyond _PROBE_POS_TOLERANCE_MM. + """ + ref_y = wells[0].get_absolute_location("c", "c", "cavity_bottom").y + expected_ys = [ref_y - i * PROBE_PITCH_MM for i in range(len(wells))] + + mismatches = [] + for i, (well, exp_y) in enumerate(zip(wells, expected_ys)): + actual_y = well.get_absolute_location("c", "c", "cavity_bottom").y + if abs(actual_y - exp_y) > _PROBE_POS_TOLERANCE_MM: + mismatches.append( + f" probe {i} ({well.name}): expected y={exp_y:.2f}, actual y={actual_y:.2f}" + ) + + if mismatches: + actual_ys = [round(w.get_absolute_location("c", "c", "cavity_bottom").y, 2) for w in wells] + raise ValueError( + f"Wells are not at {PROBE_PITCH_MM} mm probe pitch from wells[0]. " + f"Pass wells in row-A-first order at {PROBE_PITCH_MM} mm spacing " + f"(for 384-well plates: every other row).\n" + + "\n".join(mismatches) + + f"\nActual Y values: {actual_ys}" + ) + + return expected_ys + + def _validate_container_span(self, container) -> None: + """Raise ValueError if the container is too narrow for all 8 probes. + + Minimum Y span = (NUM_PROBES - 1) * PROBE_PITCH_MM = 63 mm. + """ + min_span = (NUM_PROBES - 1) * PROBE_PITCH_MM + span = container.get_size_y() + if span < min_span: + raise ValueError( + f"Container '{container.name}' Y span ({span:.1f} mm) is too narrow for " + f"{NUM_PROBES} probes at {PROBE_PITCH_MM} mm pitch " + f"(minimum {min_span:.1f} mm required)." + ) + + def _require_all_channels(self, use_channels: List[int], op: str) -> None: + """Raise ValueError unless use_channels is exactly [0..7]. + + The 8MPH is a ganged head — all 8 probes must participate in every operation. + Partial channel selection produces insufficient tip grip force (physical + constraint confirmed via firmware/hardware inspection). + """ + if list(use_channels) != list(range(NUM_PROBES)): + raise ValueError( + f"PrepHead8.{op}: the 8MPH is a fully-ganged head — all {NUM_PROBES} " + f"channels must participate. Received use_channels={use_channels}. " + "Partial tip pickup/drop/aspirate/dispense is not physically supported." + ) + + def _resolve_effective_lld( + self, + lld_mode: Optional[LLDMode], + lld: Optional[PrepCmd.LldParameters], + *, + allowed_modes: Optional[frozenset] = None, + ) -> bool: + """Determine whether LLD is active for this MPH pipetting call. + + Unlike the PIP backend (which takes a per-channel list), the MPH accepts a + single LLDMode because the ganged head operates as one unit. + """ + if lld_mode is not None: + if lld_mode != LLDMode.OFF: + if allowed_modes is not None and lld_mode not in allowed_modes: + raise ValueError( + f"Dispense does not support {lld_mode.name} LLD — only CAPACITIVE or OFF. " + "Pressure-based LLD requires aspiration (plunger movement)." + ) + return True + return False + return lld is not None + + # --------------------------------------------------------------------------- + # Aspirate assembly helpers + # --------------------------------------------------------------------------- + + def _assemble_aspirate_v2( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + prewet_volume: float, + blowout_volume: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + is_tadm: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + tadm: PrepCmd.TadmParameters, + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm2, + PrepCmd.AspirateParametersLldAndMonitoring2, + PrepCmd.AspirateParametersNoLldAndTadm2, + PrepCmd.AspirateParametersNoLldAndMonitoring2, + ]: + aspirate = PrepCmd.AspirateParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + prewet_volume=prewet_volume, + blowout_volume=blowout_volume, + ) + common = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + no_lld = PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + tadm=tadm, + adc=adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + adc=adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + container_description=segments, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + ) + + def _assemble_aspirate_v1( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + prewet_volume: float, + blowout_volume: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + is_tadm: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + tadm: PrepCmd.TadmParameters, + ) -> Union[ + PrepCmd.AspirateParametersLldAndTadm, + PrepCmd.AspirateParametersLldAndMonitoring, + PrepCmd.AspirateParametersNoLldAndTadm, + PrepCmd.AspirateParametersNoLldAndMonitoring, + ]: + aspirate = PrepCmd.AspirateParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + prewet_volume=prewet_volume, + blowout_volume=blowout_volume, + ) + common_v2 = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + common = _patch_common_with_cone_fn(common_v2, segments) + no_lld = PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + + if effective_lld and is_tadm: + return PrepCmd.AspirateParametersLldAndTadm( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + tadm=tadm, + adc=adc, + ) + elif effective_lld: + return PrepCmd.AspirateParametersLldAndMonitoring( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + lld=lld_params, + p_lld=lld_defaults.p_lld, + c_lld=lld_defaults.c_lld, + mix=mix, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + adc=adc, + ) + elif is_tadm: + return PrepCmd.AspirateParametersNoLldAndTadm( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.AspirateParametersNoLldAndMonitoring( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + aspirate=aspirate, + common=common, + no_lld=no_lld, + mix=mix, + adc=adc, + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + ) + + # --------------------------------------------------------------------------- + # Dispense assembly helpers + # --------------------------------------------------------------------------- + + def _assemble_dispense_v2( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + stop_back_volume: float, + cutoff_speed: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + ) -> Union[PrepCmd.DispenseParametersLld2, PrepCmd.DispenseParametersNoLld2]: + dispense = PrepCmd.DispenseParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + common = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + tadm = PrepCmd.TadmParameters.default() + + if effective_lld: + return PrepCmd.DispenseParametersLld2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + container_description=segments, + common=common, + lld=lld_params, + c_lld=lld_defaults.c_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld2( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + container_description=segments, + common=common, + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ), + mix=mix, + adc=adc, + tadm=tadm, + ) + + def _assemble_dispense_v1( + self, + ref_x: float, + ref_y: float, + volume: float, + tube_radius: float, + final_z: float, + z_minimum: float, + z_fluid: float, + z_air: float, + z_bottom_search_offset: float, + settling_time: float, + transport_air_volume: float, + z_liquid_exit_speed: float, + stop_back_volume: float, + cutoff_speed: float, + flow_rate: Optional[float], + segments: List[PrepCmd.SegmentDescriptor], + effective_lld: bool, + lld_params: PrepCmd.LldParameters, + lld_defaults: _LldDefaults, + ) -> Union[PrepCmd.DispenseParametersLld, PrepCmd.DispenseParametersNoLld]: + dispense = PrepCmd.DispenseParameters( + default_values=False, + x_position=ref_x, + y_position=ref_y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + common_v2 = PrepCmd.CommonParameters.for_op( + volume, + tube_radius, + flow_rate=flow_rate, + z_final=final_z, + z_minimum=z_minimum, + z_liquid_exit_speed=z_liquid_exit_speed, + transport_air_volume=transport_air_volume, + settling_time=settling_time, + ) + common = _patch_common_with_cone_fn(common_v2, segments) + mix = PrepCmd.MixParameters.default() + adc = PrepCmd.AdcParameters.default() + tadm = PrepCmd.TadmParameters.default() + + if effective_lld: + return PrepCmd.DispenseParametersLld( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + common=common, + lld=lld_params, + c_lld=lld_defaults.c_lld, + mix=mix, + adc=adc, + tadm=tadm, + ) + else: + return PrepCmd.DispenseParametersNoLld( + default_values=False, + channel=PrepCmd.ChannelIndex.MPHChannel, + dispense=dispense, + common=common, + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + z_fluid=z_fluid, z_air=z_air, z_bottom_search_offset=z_bottom_search_offset + ), + mix=mix, + adc=adc, + tadm=tadm, + ) + + # --------------------------------------------------------------------------- + # MPH gantry (IMph MoveToPosition) + # --------------------------------------------------------------------------- + + async def move_to_position( + self, + x: float, + y: float, + z: float, + *, + via_lane: bool = False, + ) -> None: + """Move the ganged 8-channel head to absolute deck ``(x, y, z)`` (mm). + + Sends :class:`~prep_commands.MphMoveToPosition` or + :class:`~prep_commands.MphMoveToPositionViaLane` on ``MLPrepRoot.MphRoot.MPH``. + One pose for the whole head — unlike independent-channel ``move_to_position``, + there are no per-channel ``y``/``z`` lists. + + Args: + x: Gantry X. + y: Gantry Y at the probe-0 (row A) reference. + z: Z height (e.g. traverse). + via_lane: Use lane-aware move when True. + """ + if via_lane: + await self._client.execute( + PrepCmd.MphMoveToPositionViaLane(x_position=x, y_position=y, z_position=z) + ) + else: + await self._client.execute( + PrepCmd.MphMoveToPosition(x_position=x, y_position=y, z_position=z) + ) + + # --------------------------------------------------------------------------- + # Tip / aspirate / dispense + # --------------------------------------------------------------------------- + + def _require_mounted_tips(self) -> List[Tip]: + tips: List[Tip] = [] + for i in range(NUM_PROBES): + tracker = self.head[i] + if not tracker.has_tip: + raise RuntimeError("No tips mounted on head8; call pick_up_tips8 first.") + tips.append(tracker.get_tip()) + return tips + + def _require_mounted_tip(self) -> Tip: + return self._require_mounted_tips()[0] + + async def pick_up_tips8( + self, + tip_spots: Sequence[TipSpot], + use_channels: Optional[Sequence[int]] = None, + *, + offset: Coordinate = Coordinate.zero(), + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + enable_tadm: bool = False, + dispenser_volume: float = 0.0, + dispenser_speed: float = 250.0, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + pre_position: bool = True, + ) -> None: + tip_spots = list(tip_spots) + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) + self._require_all_channels(use_channels, "pick_up_tips8") + if len(tip_spots) != NUM_PROBES: + raise ValueError(f"pick_up_tips8 requires {NUM_PROBES} tip spots, got {len(tip_spots)}") + resolved_final_z = self._resolve_traverse_height(final_z) + + tips = [s.get_tip() for s in tip_spots] + ref_spot = tip_spots[0] + tip = tips[0] + rack = ref_spot.parent + logger.info( + "[Prep MPH] pick_up_tips: rack=%s, tip_spots=%s", + rack.name if rack is not None else ref_spot.name, + [s.name.rsplit("_", 1)[-1] for s in tip_spots], + ) + loc = ref_spot.get_absolute_location("c", "c", "t") + offset + + if pre_position: + traverse_h = minimum_traverse_height_at_beginning_of_a_command or resolved_final_z + await self.move_to_position(loc.x, loc.y, traverse_h) + + tip_position = PrepCmd.TipPositionParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, loc, tip, z_seek_offset=z_seek_offset + ) + tip_definition = PrepCmd.TipPickupParameters( + default_values=False, + volume=tip.maximal_volume, + length=tip.total_tip_length - tip.fitting_depth, + tip_type=PrepCmd.TipTypes.StandardVolume, + has_filter=tip.has_filter, + is_needle=False, + is_tool=False, + ) + tip_intents = [ + TipPickupIntent( + channel=ch, + tip_spot=spot, + tip=t, + channel_tracker=self.head[ch], + ) + for ch, spot, t in zip(use_channels, tip_spots, tips) + ] + queue_tip_pickups(tip_intents) + + async def _send() -> None: + await self._client.execute( + PrepCmd.MphPickupTips( + tip_position=tip_position, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_definition=tip_definition, + enable_tadm=enable_tadm, + dispenser_volume=dispenser_volume, + dispenser_speed=dispenser_speed, + tip_mask=_FULL_TIP_MASK, + ) + ) + + await self._finalize_head8_command(use_channels, tip_intents=tip_intents, send=_send) + + async def drop_tips8( + self, + destinations: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[Sequence[int]] = None, + *, + offset: Coordinate = Coordinate.zero(), + final_z: Optional[float] = None, + seek_speed: float = 15.0, + z_seek_offset: Optional[float] = None, + tip_roll_off_distance: float = 0.0, + ) -> None: + destinations = list(destinations) + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) + self._require_all_channels(use_channels, "drop_tips8") + if len(destinations) != NUM_PROBES: + raise ValueError(f"drop_tips8 requires {NUM_PROBES} destinations, got {len(destinations)}") + tip = self._require_mounted_tip() + resolved_final_z = self._resolve_traverse_height(final_z) + + ref_spot = destinations[0] + is_trash = isinstance(ref_spot, Trash) + dest = ref_spot if is_trash else ref_spot.parent + logger.info( + "[Prep MPH] drop_tips: dest=%s, resources=%s", + dest.name if dest is not None else ref_spot.name, + [s.name.rsplit("_", 1)[-1] for s in destinations], + ) + + loc = ref_spot.get_absolute_location("c", "c", "t") + if not is_trash: + loc = loc + offset + drop_type = PrepCmd.TipDropType.Stall if is_trash else PrepCmd.TipDropType.FixedHeight + + tip_position = PrepCmd.TipDropParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, + loc, + tip, + z_seek_offset=z_seek_offset, + drop_type=drop_type, + ) + roll_off = 3.0 if (is_trash and tip_roll_off_distance == 0.0) else tip_roll_off_distance + mounted = self._require_mounted_tips() + tip_intents = [ + TipDropIntent( + channel=ch, + destination=dest, + tip=mounted[ch], + channel_tracker=self.head[ch], + ) + for ch, dest in zip(use_channels, destinations) + ] + queue_tip_drops(tip_intents) + + async def _send() -> None: + await self._client.execute( + PrepCmd.MphDropTips( + tip_position=tip_position, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=roll_off, + ) + ) + + await self._finalize_head8_command(use_channels, tip_intents=tip_intents, send=_send) + + async def aspirate8( + self, + wells: Optional[Sequence[Well]] = None, + *, + container: Optional[Container] = None, + volume: float, + use_channels: Optional[Sequence[int]] = None, + offset: Coordinate = Coordinate.zero(), + liquid_height: Optional[float] = None, + flow_rate: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + z_final: Optional[float] = None, + z_fluid: Optional[float] = None, + z_air: Optional[float] = None, + z_minimum: Optional[float] = None, + settling_time: Optional[float] = None, + transport_air_volume: Optional[float] = None, + z_liquid_exit_speed: Optional[float] = None, + prewet_volume: Optional[float] = None, + z_bottom_search_offset: Optional[float] = None, + lld_mode: Optional[LLDMode] = None, + lld: Optional[PrepCmd.LldParameters] = None, + p_lld: Optional[PrepCmd.PLldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + tadm: Optional[PrepCmd.TadmParameters] = None, + container_segments: Optional[List[PrepCmd.SegmentDescriptor]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[ + Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]] + ] = None, + disable_volume_correction: bool = False, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ) -> None: + del offset # geometry uses well/container absolute locations + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) + self._require_all_channels(use_channels, "aspirate8") + if (wells is None) == (container is None): + raise ValueError("aspirate8 requires exactly one of wells= or container=") + tip = self._require_mounted_tip() + + explicit: Optional[List[Optional[HamiltonLiquidClass]]] + if isinstance(hamilton_liquid_classes, HamiltonLiquidClass) or hamilton_liquid_classes is None: + explicit = None if hamilton_liquid_classes is None else [hamilton_liquid_classes] + else: + explicit = list(hamilton_liquid_classes) + if len(explicit) == NUM_PROBES: + explicit = [explicit[0]] + elif len(explicit) != 1: + raise ValueError("hamilton_liquid_classes must be a single HLC or length-8 list") + + class _TipVol: + def __init__(self, tip: Tip, volume: float): + self.tip = tip + self.volume = volume + + tip_vol = _TipVol(tip, float(volume)) + hlcs = resolve_hamilton_liquid_classes(explicit, [tip_vol], jet=False, blow_out=False) + hlc = hlcs[0] + corrected = corrected_volumes_for_ops([tip_vol], hlcs, [disable_volume_correction])[0] + + traverse_z = self._resolve_traverse_height() + final_z_resolved = ( + z_final if z_final is not None else traverse_z - (tip.total_tip_length - tip.fitting_depth) + ) + + if container is not None: + self._validate_container_span(container) + resource_name = container.parent.name if container.parent is not None else container.name + op_targets: Union[str, List[str]] = container.name + loc = container.get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = loc.x, loc.y + 3.5 * PROBE_PITCH_MM + wg = _absolute_z_from_well(container, liquid_height) + ref_segments = container_segments or ( + _build_container_segments(container) if auto_container_geometry else [] + ) + ref_resource = container + else: + wells_list = list(wells) # type: ignore[arg-type] + if len(wells_list) != NUM_PROBES: + raise ValueError(f"aspirate8 requires {NUM_PROBES} wells, got {len(wells_list)}") + self._resolve_probe_positions(wells_list) + resource_name = ( + wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + ) + op_targets = [w.name.rsplit("_", 1)[-1] for w in wells_list] + ref_loc = wells_list[0].get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = ref_loc.x, ref_loc.y + wg = _absolute_z_from_well(wells_list[0], liquid_height) + ref_segments = container_segments or ( + _build_container_segments(wells_list[0]) if auto_container_geometry else [] + ) + ref_resource = wells_list[0] + + resolved_z_fluid = z_fluid if z_fluid is not None else wg.liquid_surface + resolved_z_air = z_air if z_air is not None else wg.z_air + resolved_z_minimum = z_minimum if z_minimum is not None else wg.well_bottom + resolved_z_bottom_search_offset = ( + z_bottom_search_offset if z_bottom_search_offset is not None else 2.0 + ) + resolved_settling_time = ( + settling_time + if settling_time is not None + else (hlc.aspiration_settling_time if hlc is not None else 1.0) + ) + resolved_transport_air_volume = ( + transport_air_volume + if transport_air_volume is not None + else (hlc.aspiration_air_transport_volume if hlc is not None else 0.0) + ) + resolved_z_liquid_exit_speed = ( + z_liquid_exit_speed + if z_liquid_exit_speed is not None + else (hlc.aspiration_swap_speed if hlc is not None else 10.0) + ) + resolved_prewet_volume = ( + prewet_volume + if prewet_volume is not None + else (hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0) + ) + resolved_flow = ( + flow_rate + if flow_rate is not None + else (hlc.aspiration_flow_rate if hlc is not None else 100.0) + ) + blowout_volume = ( + blow_out_air_volume + if blow_out_air_volume is not None + else (hlc.aspiration_blow_out_volume if hlc is not None else 0.0) + ) + + logger.info( + "[Prep MPH] aspirate: resource=%s, wells=%s, volume=%.3f, flow_rate=%s", + resource_name, + op_targets, + corrected, + round(resolved_flow, 3), + ) + + tube_radius = _effective_radius(ref_resource) + effective_lld = self._resolve_effective_lld(lld_mode, lld) + is_tadm = tadm is not None + use_v2 = self._resolve_command_version(command_version) + + lld_defaults = _default_lld_params_fn(effective_lld, p_lld, c_lld) + lld_params = _lld_for_well_fn(effective_lld, lld, wg.top_of_well) + resolved_tadm = tadm or PrepCmd.TadmParameters.default() + + assemble = self._assemble_aspirate_v2 if use_v2 else self._assemble_aspirate_v1 + param_struct = assemble( + ref_x=ref_x, + ref_y=ref_y, + volume=corrected, + tube_radius=tube_radius, + final_z=final_z_resolved, + z_minimum=resolved_z_minimum, + z_fluid=resolved_z_fluid, + z_air=resolved_z_air, + z_bottom_search_offset=resolved_z_bottom_search_offset, + settling_time=resolved_settling_time, + transport_air_volume=resolved_transport_air_volume, + z_liquid_exit_speed=resolved_z_liquid_exit_speed, + prewet_volume=resolved_prewet_volume, + blowout_volume=blowout_volume, + flow_rate=resolved_flow, + segments=ref_segments, + effective_lld=effective_lld, + is_tadm=is_tadm, + lld_params=lld_params, + lld_defaults=lld_defaults, + tadm=resolved_tadm, + ) + + cmd_cls = self._ASPIRATE_CMD[(effective_lld, is_tadm, use_v2)] + + resolved_read_timeout = read_timeout + if resolved_read_timeout is None and effective_lld: + resolved_read_timeout = _lld_seek_timeout(lld_params, resolved_z_minimum) + + mounted = self._require_mounted_tips() + if container is not None: + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=container, + tip=mounted[ch], + volume_ul=corrected, + direction="aspirate", + ) + for ch in use_channels + ] + else: + wells_list = list(wells) # type: ignore[arg-type] + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=well, + tip=mounted[ch], + volume_ul=corrected, + direction="aspirate", + ) + for ch, well in zip(use_channels, wells_list) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._client.execute( + cmd_cls(aspirate_parameters=[param_struct]), # type: ignore[arg-type] + read_timeout=resolved_read_timeout if effective_lld else None, + ) + + await self._finalize_head8_command(use_channels, volume_intents=volume_intents, send=_send) + + async def dispense8( + self, + wells: Optional[Sequence[Well]] = None, + *, + container: Optional[Container] = None, + volume: float, + use_channels: Optional[Sequence[int]] = None, + offset: Coordinate = Coordinate.zero(), + liquid_height: Optional[float] = None, + flow_rate: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + z_final: Optional[float] = None, + z_fluid: Optional[float] = None, + z_air: Optional[float] = None, + z_minimum: Optional[float] = None, + settling_time: Optional[float] = None, + transport_air_volume: Optional[float] = None, + z_liquid_exit_speed: Optional[float] = None, + stop_back_volume: Optional[float] = None, + cutoff_speed: Optional[float] = None, + z_bottom_search_offset: Optional[float] = None, + lld_mode: Optional[LLDMode] = None, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[PrepCmd.SegmentDescriptor]] = None, + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[ + Union[HamiltonLiquidClass, List[Optional[HamiltonLiquidClass]]] + ] = None, + disable_volume_correction: bool = False, + read_timeout: Optional[float] = None, + command_version: Optional[Literal["v1", "v2"]] = None, + ) -> None: + del offset + del blow_out_air_volume # dispense blowout not on Prep dispense wire path today + use_channels = list(use_channels) if use_channels is not None else list(range(NUM_PROBES)) + self._require_all_channels(use_channels, "dispense8") + if (wells is None) == (container is None): + raise ValueError("dispense8 requires exactly one of wells= or container=") + tip = self._require_mounted_tip() + + explicit: Optional[List[Optional[HamiltonLiquidClass]]] + if isinstance(hamilton_liquid_classes, HamiltonLiquidClass) or hamilton_liquid_classes is None: + explicit = None if hamilton_liquid_classes is None else [hamilton_liquid_classes] + else: + explicit = list(hamilton_liquid_classes) + if len(explicit) == NUM_PROBES: + explicit = [explicit[0]] + elif len(explicit) != 1: + raise ValueError("hamilton_liquid_classes must be a single HLC or length-8 list") + + class _TipVol: + def __init__(self, tip: Tip, volume: float): + self.tip = tip + self.volume = volume + + tip_vol = _TipVol(tip, float(volume)) + hlcs = resolve_hamilton_liquid_classes(explicit, [tip_vol], jet=False, blow_out=False) + hlc = hlcs[0] + corrected = corrected_volumes_for_ops([tip_vol], hlcs, [disable_volume_correction])[0] + + traverse_z = self._resolve_traverse_height() + final_z_resolved = ( + z_final if z_final is not None else traverse_z - (tip.total_tip_length - tip.fitting_depth) + ) + + if container is not None: + self._validate_container_span(container) + resource_name = container.parent.name if container.parent is not None else container.name + op_targets: Union[str, List[str]] = container.name + loc = container.get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = loc.x, loc.y + 3.5 * PROBE_PITCH_MM + wg = _absolute_z_from_well(container, liquid_height) + ref_segments = container_segments or ( + _build_container_segments(container) if auto_container_geometry else [] + ) + ref_resource = container + else: + wells_list = list(wells) # type: ignore[arg-type] + if len(wells_list) != NUM_PROBES: + raise ValueError(f"dispense8 requires {NUM_PROBES} wells, got {len(wells_list)}") + self._resolve_probe_positions(wells_list) + resource_name = ( + wells_list[0].parent.name if wells_list[0].parent is not None else wells_list[0].name + ) + op_targets = [w.name.rsplit("_", 1)[-1] for w in wells_list] + ref_loc = wells_list[0].get_absolute_location("c", "c", "cavity_bottom") + ref_x, ref_y = ref_loc.x, ref_loc.y + wg = _absolute_z_from_well(wells_list[0], liquid_height) + ref_segments = container_segments or ( + _build_container_segments(wells_list[0]) if auto_container_geometry else [] + ) + ref_resource = wells_list[0] + + resolved_z_fluid = z_fluid if z_fluid is not None else wg.liquid_surface + resolved_z_air = z_air if z_air is not None else wg.z_air + resolved_z_minimum = z_minimum if z_minimum is not None else wg.well_bottom + resolved_z_bottom_search_offset = ( + z_bottom_search_offset if z_bottom_search_offset is not None else 2.0 + ) + resolved_settling_time = ( + settling_time + if settling_time is not None + else (hlc.dispense_settling_time if hlc is not None else 0.0) + ) + resolved_transport_air_volume = ( + transport_air_volume + if transport_air_volume is not None + else (hlc.dispense_air_transport_volume if hlc is not None else 0.0) + ) + resolved_z_liquid_exit_speed = ( + z_liquid_exit_speed + if z_liquid_exit_speed is not None + else (hlc.dispense_swap_speed if hlc is not None else 10.0) + ) + resolved_stop_back_volume = ( + stop_back_volume + if stop_back_volume is not None + else (hlc.dispense_stop_back_volume if hlc is not None else 0.0) + ) + resolved_cutoff_speed = ( + cutoff_speed + if cutoff_speed is not None + else (hlc.dispense_stop_flow_rate if hlc is not None else 100.0) + ) + resolved_flow = ( + flow_rate if flow_rate is not None else (hlc.dispense_flow_rate if hlc is not None else 100.0) + ) + + logger.info( + "[Prep MPH] dispense: resource=%s, wells=%s, volume=%.3f, flow_rate=%s", + resource_name, + op_targets, + corrected, + round(resolved_flow, 3), + ) + + tube_radius = _effective_radius(ref_resource) + _DISPENSE_ALLOWED_LLD = frozenset({LLDMode.CAPACITIVE}) + effective_lld = self._resolve_effective_lld(lld_mode, lld, allowed_modes=_DISPENSE_ALLOWED_LLD) + use_v2 = self._resolve_command_version(command_version) + + lld_defaults = _default_lld_params_fn(effective_lld, c_lld=c_lld) + lld_params = _lld_for_well_fn(effective_lld, lld, wg.top_of_well) + + assemble = self._assemble_dispense_v2 if use_v2 else self._assemble_dispense_v1 + param_struct = assemble( + ref_x=ref_x, + ref_y=ref_y, + volume=corrected, + tube_radius=tube_radius, + final_z=final_z_resolved, + z_minimum=resolved_z_minimum, + z_fluid=resolved_z_fluid, + z_air=resolved_z_air, + z_bottom_search_offset=resolved_z_bottom_search_offset, + settling_time=resolved_settling_time, + transport_air_volume=resolved_transport_air_volume, + z_liquid_exit_speed=resolved_z_liquid_exit_speed, + stop_back_volume=resolved_stop_back_volume, + cutoff_speed=resolved_cutoff_speed, + flow_rate=resolved_flow, + segments=ref_segments, + effective_lld=effective_lld, + lld_params=lld_params, + lld_defaults=lld_defaults, + ) + + cmd_cls = self._DISPENSE_CMD[(effective_lld, use_v2)] + + resolved_read_timeout = read_timeout + if resolved_read_timeout is None and effective_lld: + resolved_read_timeout = _lld_seek_timeout(lld_params, resolved_z_minimum) + + mounted = self._require_mounted_tips() + if container is not None: + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=container, + tip=mounted[ch], + volume_ul=corrected, + direction="dispense", + ) + for ch in use_channels + ] + else: + wells_list = list(wells) # type: ignore[arg-type] + volume_intents = [ + VolumeTransferIntent( + channel=ch, + container=well, + tip=mounted[ch], + volume_ul=corrected, + direction="dispense", + ) + for ch, well in zip(use_channels, wells_list) + ] + queue_volume_transfers(volume_intents) + + async def _send() -> None: + await self._client.execute( + cmd_cls(dispense_parameters=[param_struct]), # type: ignore[arg-type] + read_timeout=resolved_read_timeout if effective_lld else None, + ) + + await self._finalize_head8_command(use_channels, volume_intents=volume_intents, send=_send) + + # --------------------------------------------------------------------------- + # Tip presence sensing + # --------------------------------------------------------------------------- + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Sense whether tips are present on the 8MPH head via the sleeve sensor (cmd=15). + + The 8MPH is a single ganged controller — the firmware tree exposes one sleeve + sensor node (on the probe-0 / channel-0 entry). The result is broadcast across + all 8 positions since the head picks up and drops all probes together. + + Returns: + 8-element list. True=tips detected, False=no tips, None=sensor unavailable. + """ + if not self.channels: + raise RuntimeError("MPH channels not populated; call build_prep_channels first.") + + addr = self.channels[0].sleeve_sensor + if addr is None: + return [None] * NUM_PROBES + + raw = await self._client.execute(PrepCmd.PrepProbeRequest(dest=addr, command_id=15)) + if raw is None or len(raw) < 8: + result = False + else: + val = _struct.unpack_from(" Address: + """Resolve a diagnostic path alias; raises if absent.""" + if key not in self._paths: + raise KeyError(f"unknown info path key: {key!r}") + return await self._driver.resolve_path(self._paths[key]) + + async def _try_require(self, key: str) -> Optional[Address]: + """Resolve a diagnostic path alias; returns ``None`` if the path is absent.""" + try: + return await self._require(key) + except (KeyError, RuntimeError, TypeError): + return None + + # -- Lifecycle -------------------------------------------------------------- + + async def _on_setup(self) -> None: + """Fetch and cache the instrument config. Called from :meth:`Prep.setup`.""" + self._config = await self._load_instrument_config() + + async def _on_stop(self) -> None: + self._config = None + + # -- Cached config ---------------------------------------------------------- + + @property + def config(self) -> PrepCmd.InstrumentConfig: + """Cached ``InstrumentConfig``. Raises if ``_on_setup`` has not run.""" + if self._config is None: + raise RuntimeError("Instrument config not available. Call Prep.setup() first.") + return self._config + + @property + def num_channels(self) -> int: + n = self.config.num_channels + if n is None: + raise RuntimeError("Instrument config has no num_channels (finish Prep.setup first).") + return n + + @property + def has_mph(self) -> bool: + h = self.config.has_mph + if h is None: + raise RuntimeError("Instrument config has no has_mph (finish Prep.setup first).") + return h + + @property + def deck_bounds(self) -> Optional[PrepCmd.DeckBounds]: + return self.config.deck_bounds + + @property + def deck_sites(self) -> Tuple[PrepCmd.DeckSiteInfo, ...]: + return self.config.deck_sites + + @property + def waste_sites(self) -> Tuple[PrepCmd.WasteSiteInfo, ...]: + return self.config.waste_sites + + @property + def default_traverse_height(self) -> Optional[float]: + return self.config.default_traverse_height + + @property + def has_enclosure(self) -> bool: + return self.config.has_enclosure + + @property + def safe_speeds_enabled(self) -> bool: + return self.config.safe_speeds_enabled + + async def refresh(self) -> PrepCmd.InstrumentConfig: + """Re-query instrument config and update the cached snapshot.""" + self._config = await self._load_instrument_config() + return self._config + + # -- Instrument config (MLPrep / deck / service) ---------------------------- + + async def get_present_channels(self) -> Optional[Tuple[PrepCmd.ChannelIndex, ...]]: + """Query which channels are present (GetPresentChannels on MLPrepService).""" + d = self._driver + service_addr = await self._try_require("mlprep_service") + if service_addr is None: + return None + try: + resp = await d.execute(PrepCmd.PrepGetPresentChannels(dest=service_addr)) + if resp is None or not resp.channels: + return None + return tuple( + PrepCmd.ChannelIndex(v) if v in (0, 1, 2, 3) else PrepCmd.ChannelIndex.InvalidIndex + for v in resp.channels + ) + except ( + TimeoutError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + BrokenPipeError, + OSError, + ): + raise + except Exception as e: + logger.warning("Failed to query present channels: %s", e) + return None + + async def _load_instrument_config(self) -> PrepCmd.InstrumentConfig: + """Aggregate MLPrep, DeckConfiguration, and MLPrepService into ``InstrumentConfig``.""" + d = self._driver + mlprep = d.mlprep_address + enc_resp = await d.execute(PrepCmd.PrepGetIsEnclosurePresent(dest=mlprep)) + safe_resp = await d.execute(PrepCmd.PrepGetSafeSpeedsEnabled(dest=mlprep)) + height_resp = await d.execute(PrepCmd.PrepGetDefaultTraverseHeight(dest=mlprep)) + has_enclosure = bool(enc_resp.value) if enc_resp else False + safe_speeds_enabled = bool(safe_resp.value) if safe_resp else False + default_traverse_height = float(height_resp.value) if height_resp else None + + deck_bounds: Optional[PrepCmd.DeckBounds] = None + deck_sites: Tuple[PrepCmd.DeckSiteInfo, ...] = () + waste_sites: Tuple[PrepCmd.WasteSiteInfo, ...] = () + deck_addr = await self._try_require("deck_config") + if deck_addr is None: + raise RuntimeError("DeckConfiguration path did not resolve — cannot load instrument config") + + bounds_resp = await d.execute(PrepCmd.PrepGetDeckBounds(dest=deck_addr)) + if bounds_resp: + deck_bounds = PrepCmd.DeckBounds( + min_x=bounds_resp.min_x, + max_x=bounds_resp.max_x, + min_y=bounds_resp.min_y, + max_y=bounds_resp.max_y, + min_z=bounds_resp.min_z, + max_z=bounds_resp.max_z, + ) + + sites_resp = await d.execute(PrepCmd.PrepGetDeckSiteDefinitions(dest=deck_addr)) + if sites_resp and sites_resp.sites: + deck_sites = tuple( + PrepCmd.DeckSiteInfo( + id=int(s.id), + left_bottom_front_x=float(s.left_bottom_front_x), + left_bottom_front_y=float(s.left_bottom_front_y), + left_bottom_front_z=float(s.left_bottom_front_z), + length=float(s.length), + width=float(s.width), + height=float(s.height), + ) + for s in sites_resp.sites + ) + logger.debug("Discovered %d deck sites", len(deck_sites)) + + waste_resp = await d.execute(PrepCmd.PrepGetWasteSiteDefinitions(dest=deck_addr)) + if waste_resp and waste_resp.sites: + waste_sites = tuple( + PrepCmd.WasteSiteInfo( + index=int(s.index), + x_position=float(s.x_position), + y_position=float(s.y_position), + z_position=float(s.z_position), + z_seek=float(s.z_seek), + ) + for s in waste_resp.sites + ) + logger.debug("Discovered %d waste sites: %s", len(waste_sites), waste_sites) + + present = await self.get_present_channels() + if present is not None: + dual = [ + c + for c in present + if c in (PrepCmd.ChannelIndex.FrontChannel, PrepCmd.ChannelIndex.RearChannel) + ] + num_channels = len(dual) + has_mph = PrepCmd.ChannelIndex.MPHChannel in present + else: + num_channels = 2 + has_mph = False + + return PrepCmd.InstrumentConfig( + deck_bounds=deck_bounds, + has_enclosure=has_enclosure, + safe_speeds_enabled=safe_speeds_enabled, + deck_sites=deck_sites, + waste_sites=waste_sites, + default_traverse_height=default_traverse_height, + num_channels=num_channels, + has_mph=has_mph, + ) + + async def is_initialized(self) -> bool: + """Whether MLPrep reports as initialized (GetIsInitialized, cmd=2).""" + result = await self._driver.execute( + PrepCmd.PrepGetIsInitialized(dest=self._driver.mlprep_address) + ) + if result is None: + return False + return bool(result.value) + + async def get_tip_and_needle_definitions(self) -> Tuple[PrepCmd.TipDefinition, ...]: + """Tip/needle definitions (GetTipAndNeedleDefinitions, cmd=11).""" + result = await self._driver.execute( + PrepCmd.PrepGetTipAndNeedleDefinitions(dest=self._driver.mlprep_address) + ) + if result is None or not result.definitions: + return () + return tuple(result.definitions) + + # -- Firmware string queries (orchestration; decode on PrepClient) ---------- + + async def get_firmware_version(self) -> Optional[str]: + addr = await self._try_require("mlprep_cpu") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=8) + + async def get_device_serial_number(self) -> Optional[str]: + addr = await self._try_require("mlprep_cpu") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=9) + + async def get_bootloader_version(self) -> Optional[str]: + addr = await self._try_require("mlprep_cpu") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=2, iface_id=2) + + async def get_module_part_number(self) -> Optional[str]: + addr = await self._try_require("module_information") + if addr is None: + return None + return await self._driver._query_firmware_string(addr, cmd_id=5) + + async def get_firmware_tree(self, refresh: bool = False) -> FirmwareTreeNode: + """Firmware object tree. ``print(await info.get_firmware_tree())`` for a diagnostic dump.""" + return await self._driver.introspection.get_firmware_tree(refresh=refresh) diff --git a/pylabrobot/hamilton/prep/method.py b/pylabrobot/hamilton/prep/method.py new file mode 100644 index 00000000000..dce88922c99 --- /dev/null +++ b/pylabrobot/hamilton/prep/method.py @@ -0,0 +1,55 @@ +"""Prep method lifecycle service. + +Owns MLPrep method commands (``PrepMethodBegin`` / ``PrepMethodEnd`` / ``PrepMethodAbort``) +via ``PrepClient`` transport, and exposes an async context manager +(:meth:`PrepMethodLifecycle.run`) that calls ``abort`` on exception and ``end`` on +clean exit — mirrors the ``Prep.core_grippers()`` pattern in ``prep.py``. +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, AsyncIterator + +from . import prep_commands as PrepCmd + +if TYPE_CHECKING: + from .client import PrepClient + + +class PrepMethodLifecycle: + """Method begin/end/abort + ``async with`` safety net.""" + + def __init__(self, driver: "PrepClient"): + self._driver = driver + + async def begin(self, automatic_pause: bool = False) -> None: + """Signal the start of a liquid-handling method.""" + await self._driver.execute(PrepCmd.PrepMethodBegin(automatic_pause=automatic_pause)) + + async def end(self) -> None: + """Signal the end of a liquid-handling method.""" + await self._driver.execute(PrepCmd.PrepMethodEnd()) + + async def abort(self) -> None: + """Abort the current method.""" + await self._driver.execute(PrepCmd.PrepMethodAbort()) + + @asynccontextmanager + async def run(self, automatic_pause: bool = False) -> AsyncIterator["PrepMethodLifecycle"]: + """Bracket a liquid-handling block with ``begin`` / ``end``; ``abort`` on exception. + + Usage:: + + async with prep.method.run(): + await prep.channels.pick_up_tips(...) + await prep.channels.aspirate(...) + """ + await self.begin(automatic_pause=automatic_pause) + try: + yield self + except BaseException: + await self.abort() + raise + else: + await self.end() diff --git a/pylabrobot/hamilton/prep/prep.py b/pylabrobot/hamilton/prep/prep.py new file mode 100644 index 00000000000..f805680d846 --- /dev/null +++ b/pylabrobot/hamilton/prep/prep.py @@ -0,0 +1,269 @@ +"""Prep device: orchestrates transport, instrument info, and peer construction.""" + +from __future__ import annotations + +import asyncio +import logging +import random +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional, Tuple + +from pylabrobot.resources.deck import Deck +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers + +from . import prep_commands as PrepCmd +from .calibration import PrepCalibration +from .channels import PrepChannels, build_prep_channels +from .chatterbox import PrepChatterboxClient, PrepChatterboxInstrumentInfo +from .client import PrepClient +from .gripper import PrepGripper, PrepGripperArm +from .head8 import PrepHead8 +from .info import PrepInstrumentInfo +from .method import PrepMethodLifecycle + +logger = logging.getLogger(__name__) + + +class Prep: + """Hamilton Prep liquid handler. + + Setup constructs peers (``channels``, ``head8``, ``method``, ``calibration``, + gripper factory) directly. Firmware paths live on each :class:`PrepCommand` + subclass and are resolved JIT by :meth:`PrepClient.execute`. + """ + + def __init__( + self, + deck: Deck, + chatterbox: bool = False, + host: Optional[str] = None, + port: int = 2000, + ): + if chatterbox: + client: PrepClient = PrepChatterboxClient() + else: + if not host: + raise ValueError("host must be provided when chatterbox is False.") + client = PrepClient(host=host, port=port) + self.client: PrepClient = client + self.deck = deck + self.info = PrepChatterboxInstrumentInfo(client) if chatterbox else PrepInstrumentInfo(client) + self._core_gripper_arm: Optional[PrepGripperArm] = None + self.channels: Optional[PrepChannels] = None + self.head8: Optional[PrepHead8] = None + self.gripper: Optional[PrepGripper] = None + self.method: Optional[PrepMethodLifecycle] = None + self.calibration: Optional[PrepCalibration] = None + self._setup_finished: bool = False + + async def setup( + self, + *, + smart: bool = True, + force_initialize: bool = False, + default_traverse_height: Optional[float] = None, + use_v1_aspirate_dispense: bool = False, + ): + """Connect, bootstrap info, initialize MLPrep, construct peers.""" + try: + await self.client.setup() + await self.info._on_setup() + await self._initialize_instrument(smart=smart, force_initialize=force_initialize) + + self.method = PrepMethodLifecycle(self.client) + self.calibration = PrepCalibration(driver=self.client, info=self.info) + channels = PrepChannels( + client=self.client, + info=self.info, + deck=self.deck, + default_traverse_height=default_traverse_height, + use_v1_aspirate_dispense=use_v1_aspirate_dispense, + ) + channels.channels = await build_prep_channels(self.client, self.info) + self.channels = channels + await channels._on_setup() + + if channels.has_mph: + head8 = PrepHead8( + client=self.client, + info=self.info, + default_traverse_height=default_traverse_height, + use_v1_aspirate_dispense=use_v1_aspirate_dispense, + ) + head8.channels = await build_prep_channels( + self.client, self.info, root_name="MPH Channel Root", num_channels=8 + ) + self.head8 = head8 + await head8._on_setup() + + self.gripper = PrepGripper(client=self.client, channels=channels) + self._setup_finished = True + except Exception: + await self.info._on_stop() + await self.client.stop() + raise + + async def _initialize_instrument(self, *, smart: bool, force_initialize: bool) -> None: + """Send ``MLPrep.Initialize`` when needed.""" + if not force_initialize: + try: + already = await self.info.is_initialized() + except Exception as e: + logger.error("GetIsInitialized failed; cannot decide whether to init: %s", e) + raise + if already: + logger.info("MLPrep already initialized, skipping Initialize") + return + + await self.client.execute( + PrepCmd.PrepInitialize( + smart=smart, + tip_drop_params=PrepCmd.InitTipDropParameters( + default_values=True, + x_position=287.0, + rolloff_distance=3, + channel_parameters=[], + ), + ) + ) + logger.info( + "Prep initialization complete%s", + " (force_initialize=True)" if force_initialize else "", + ) + + async def stop(self): + if not self._setup_finished: + return + if self._core_gripper_arm is not None: + logger.warning( + "Prep.stop() called with CoRe grippers still mounted. " + "stop() only manages connection teardown and will NOT move the instrument. " + "Call `await prep.return_core_grippers()` first if you want the tools returned." + ) + self._core_gripper_arm = None + if self.channels is not None: + await self.channels._on_stop() + if self.head8 is not None: + await self.head8._on_stop() + await self.client.stop() + await self.info._on_stop() + self.channels = None + self.head8 = None + self.gripper = None + self.method = None + self.calibration = None + self._setup_finished = False + + # -- CoRe grippers ----------------------------------------------------------- + + @property + def core_gripper_arm(self) -> PrepGripperArm: + """The mounted CoRe gripper arm. Raises if grippers are not currently picked up.""" + if self._core_gripper_arm is None: + raise RuntimeError( + "CoRe grippers not mounted. Call `await prep.pick_up_core_grippers()` first, " + "or use `async with prep.core_grippers() as arm:`." + ) + return self._core_gripper_arm + + @property + def core_grippers_mounted(self) -> bool: + return self._core_gripper_arm is not None + + async def pick_up_core_grippers(self) -> PrepGripperArm: + """Pick up the CoRe gripper tools and return the mounted arm.""" + if self._core_gripper_arm is not None: + raise RuntimeError("CoRe grippers already mounted") + if self.channels is None or self.gripper is None: + raise RuntimeError("Prep.setup() has not run.") + + mount = self.deck.get_resource("core_grippers") + if not isinstance(mount, HamiltonCoreGrippers): + raise TypeError( + "deck must have a resource named 'core_grippers' of type HamiltonCoreGrippers" + ) + + loc = mount.get_location_wrt(self.deck) + await self.gripper.pick_up_tool( + tool_position_x=loc.x, + tool_position_z=loc.z, + front_channel_position_y=loc.y + mount.front_channel_y_center, + rear_channel_position_y=loc.y + mount.back_channel_y_center, + tool_seek=loc.z + 10.0, + ) + + self._core_gripper_arm = PrepGripperArm( + backend=self.gripper, reference_resource=self.deck, grip_axis="y" + ) + return self._core_gripper_arm + + async def return_core_grippers(self) -> None: + if self._core_gripper_arm is None: + return + try: + await self._core_gripper_arm.backend.drop_tool() + finally: + self._core_gripper_arm = None + + @asynccontextmanager + async def core_grippers(self) -> AsyncIterator[PrepGripperArm]: + arm = await self.pick_up_core_grippers() + try: + yield arm + finally: + await self.return_core_grippers() + + # -- Motion, power, lights (MLPrep via client transport) -------------------- + + async def park(self) -> None: + await self.client.execute(PrepCmd.PrepPark()) + + async def spread(self) -> None: + await self.client.execute(PrepCmd.PrepSpread()) + + async def is_parked(self) -> bool: + result = await self.client.execute(PrepCmd.PrepIsParked()) + if result is None: + return False + return bool(result.value) + + async def is_spread(self) -> bool: + result = await self.client.execute(PrepCmd.PrepIsSpread()) + if result is None: + return False + return bool(result.value) + + async def power_down_request(self) -> None: + await self.client.execute(PrepCmd.PrepPowerDownRequest()) + + async def confirm_power_down(self) -> None: + await self.client.execute(PrepCmd.PrepConfirmPowerDown()) + + async def cancel_power_down(self) -> None: + await self.client.execute(PrepCmd.PrepCancelPowerDown()) + + async def get_deck_light(self) -> Tuple[int, int, int, int]: + result = await self.client.execute(PrepCmd.PrepGetDeckLight()) + if result is None: + raise ValueError("No response from GetDeckLight.") + return (result.white, result.red, result.green, result.blue) + + async def set_deck_light(self, white: int, red: int, green: int, blue: int) -> None: + await self.client.execute( + PrepCmd.PrepSetDeckLight(white=white, red=red, green=green, blue=blue) + ) + + async def disco_mode(self) -> None: + """Easter egg: cycle deck lights then restore previous state.""" + white, red, green, blue = await self.get_deck_light() + try: + for _ in range(69): + await self.set_deck_light( + white=random.randint(1, 255), + red=random.randint(1, 255), + green=random.randint(1, 255), + blue=random.randint(1, 255), + ) + await asyncio.sleep(0.1) + finally: + await self.set_deck_light(white=white, red=red, green=green, blue=blue) diff --git a/pylabrobot/hamilton/prep/prep_commands.py b/pylabrobot/hamilton/prep/prep_commands.py new file mode 100644 index 00000000000..39a96abdd4d --- /dev/null +++ b/pylabrobot/hamilton/prep/prep_commands.py @@ -0,0 +1,4623 @@ +"""Prep command dataclasses and wire-type parameter structs. + +Pure data definitions for the Hamilton Prep protocol — enums, hardware config, +wire-type annotated parameter structs, and PrepCommand subclasses. No business +logic; used by Prep channels / head8 peers for command construction and serialization. + +Moved from prep_backend.py to separate protocol contracts from domain logic. +""" + +from __future__ import annotations + +import datetime +import math +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Annotated, ClassVar, Optional, Set, Tuple, TypeVar + +from pylabrobot.hamilton.transport.tcp.commands import TCPCommand +from pylabrobot.hamilton.transport.tcp.messages import HoiParams, HoiParamsParser, parse_into_struct +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.protocol import HamiltonProtocol, Hoi2Action +from pylabrobot.hamilton.transport.tcp.wire_types import ( + F32, + I8, + I16, + U16, + U32, + EnumArray, + HcResultEntry, + I16Array, + PaddedBool, + PaddedU8, + Str, + Struct, + StructArray, + U8Array, + U32Array, +) +from pylabrobot.hamilton.transport.tcp.wire_types import ( + Enum as WEnum, +) + +# ============================================================================= +# Enums (mirrored from Prep protocol spec) +# ============================================================================= + + +class ChannelIndex(IntEnum): + InvalidIndex = 0 + FrontChannel = 1 + RearChannel = 2 + MPHChannel = 3 + + +class TipDropType(IntEnum): + FixedHeight = 0 + Stall = 1 + CLLDSeek = 2 + + +class TipTypes(IntEnum): + None_ = 0 + LowVolume = 1 + StandardVolume = 2 + HighVolume = 3 + + +class TadmRecordingModes(IntEnum): + NoRecording = 0 + Errors = 1 + All = 2 + + +# ============================================================================= +# Hardware config (probed from instrument, immutable) +# ============================================================================= + + +@dataclass(frozen=True) +class DeckBounds: + """Deck axis bounds in mm (from GetDeckBounds / DeckConfiguration).""" + + min_x: float + max_x: float + min_y: float + max_y: float + min_z: float + max_z: float + + +@dataclass(frozen=True) +class DeckSiteInfo: + """A deck slot read from DeckConfiguration.GetDeckSiteDefinitions.""" + + id: int + left_bottom_front_x: float + left_bottom_front_y: float + left_bottom_front_z: float + length: float + width: float + height: float + + +@dataclass(frozen=True) +class WasteSiteInfo: + """A waste position read from DeckConfiguration.GetWasteSiteDefinitions.""" + + index: int + x_position: float + y_position: float + z_position: float + z_seek: float + + +@dataclass +class HoiDateTime: + """Hamilton network/built-in dateTime struct (source_id=3, ref_id=3). + + Wire format: 7 DataFragments — year(U16), month(PaddedU8), day(PaddedU8), + hour(PaddedU8), minute(PaddedU8), second(PaddedU8), millisecond(U16). + + Used by EndCalibration and SetChannelHardwareConfiguration to timestamp + calibration data. Construct from ``datetime.datetime`` via ``from_datetime()``. + """ + + year: U16 + month: PaddedU8 + day: PaddedU8 + hour: PaddedU8 + minute: PaddedU8 + second: PaddedU8 + millisecond: U16 + + @classmethod + def from_datetime(cls, dt: datetime.datetime) -> "HoiDateTime": + """Create from a Python datetime (microseconds truncated to milliseconds).""" + return cls( + year=dt.year, + month=dt.month, + day=dt.day, + hour=dt.hour, + minute=dt.minute, + second=dt.second, + millisecond=dt.microsecond // 1000, + ) + + @classmethod + def now(cls) -> "HoiDateTime": + """Create from the current local time.""" + return cls.from_datetime(datetime.datetime.now()) + + def to_datetime(self) -> datetime.datetime: + """Convert to a Python datetime.""" + return datetime.datetime( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + self.millisecond * 1000, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.year, U16) + .add(self.month, PaddedU8) + .add(self.day, PaddedU8) + .add(self.hour, PaddedU8) + .add(self.minute, PaddedU8) + .add(self.second, PaddedU8) + .add(self.millisecond, U16) + ) + + +@dataclass(frozen=True) +class CalibrationSiteInfo: + """A calibration site from DeckConfiguration.GetCalibrationSiteDefinitions.""" + + id: int + left_bottom_front_x: float + left_bottom_front_y: float + left_bottom_front_z: float + length: float + width: float + height: float + post: bool + + +@dataclass(frozen=True) +class ChannelHardwareConfigInfo: + """Per-channel hardware config from MLPrepCalibration.GetChannelHardwareConfiguration.""" + + channel: int # ChannelIndex enum value + hardware: int # Hardware type enum value + + +@dataclass(frozen=True) +class ChannelCalibrationValuesInfo: + """Per-channel calibration values from MLPrepCalibration.GetCalibrationValues.""" + + index: int # ChannelIndex enum value + y_offset: float + z_offset: float + squeeze_position: int + z_touchoff: int + pressure_shift: int + pressure_monitoring_shift: int + dispenser_return_distance: float + z_tip_height: float + core_ii: bool + + def to_pretty_string(self) -> str: + """Return a stable one-line representation for logging/reporting.""" + return ( + f"index={self.index}, y_offset={self.y_offset}, z_offset={self.z_offset}, " + f"squeeze_position={self.squeeze_position}, z_touchoff={self.z_touchoff}, " + f"pressure_shift={self.pressure_shift}, " + f"pressure_monitoring_shift={self.pressure_monitoring_shift}, " + f"dispenser_return_distance={self.dispenser_return_distance}, " + f"z_tip_height={self.z_tip_height}, core_ii={self.core_ii}" + ) + + +@dataclass(frozen=True) +class CalibrationValues: + """Full calibration values from MLPrepCalibration.GetCalibrationValues.""" + + independent_offset_x: float + mph_offset_x: float + channel_values: Tuple["ChannelCalibrationValuesInfo", ...] + + def to_pretty_string(self, sort_by_index: bool = True) -> str: + """Return deterministic, human-readable calibration output.""" + channels = self.channel_values + if sort_by_index: + channels = tuple(sorted(channels, key=lambda cv: cv.index)) + + lines = [ + f"Independent offset X: {self.independent_offset_x}", + f"MPH offset X: {self.mph_offset_x}", + "Per-channel calibration values:", + ] + for cv in channels: + lines.append(f" {cv.to_pretty_string()}") + return "\n".join(lines) + + def __str__(self) -> str: + return self.to_pretty_string() + + +@dataclass(frozen=True) +class CalibrationFieldChange: + field: str + old: object + new: object + + +@dataclass(frozen=True) +class ChannelCalibrationDiff: + index: int + state: str # "added" | "removed" | "changed" + changes: Tuple[CalibrationFieldChange, ...] + old: Optional[ChannelCalibrationValuesInfo] + new: Optional[ChannelCalibrationValuesInfo] + + +@dataclass(frozen=True) +class CalibrationValuesDiff: + top_level_changes: Tuple[CalibrationFieldChange, ...] + channel_diffs: Tuple[ChannelCalibrationDiff, ...] + + @property + def has_changes(self) -> bool: + return bool(self.top_level_changes or self.channel_diffs) + + +def _calibration_value_equal(old: object, new: object, float_tol: float) -> bool: + if isinstance(old, float) and isinstance(new, float): + return math.isclose(old, new, rel_tol=0.0, abs_tol=float_tol) + return old == new + + +def diff_calibration_values( + old: CalibrationValues, + new: CalibrationValues, + float_tol: float = 1e-6, +) -> CalibrationValuesDiff: + """Return structured diff between two calibration snapshots.""" + + top_level_changes = [] + for field_name, old_value, new_value in ( + ("independent_offset_x", old.independent_offset_x, new.independent_offset_x), + ("mph_offset_x", old.mph_offset_x, new.mph_offset_x), + ): + if not _calibration_value_equal(old_value, new_value, float_tol=float_tol): + top_level_changes.append( + CalibrationFieldChange(field=field_name, old=old_value, new=new_value) + ) + + old_channels = {cv.index: cv for cv in old.channel_values} + new_channels = {cv.index: cv for cv in new.channel_values} + channel_diffs = [] + for idx in sorted(set(old_channels) | set(new_channels)): + old_cv = old_channels.get(idx) + new_cv = new_channels.get(idx) + if old_cv is None and new_cv is not None: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="added", + changes=(), + old=None, + new=new_cv, + ) + ) + continue + if old_cv is not None and new_cv is None: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="removed", + changes=(), + old=old_cv, + new=None, + ) + ) + continue + assert old_cv is not None and new_cv is not None + + field_changes = [] + for field_name, old_value, new_value in ( + ("index", old_cv.index, new_cv.index), + ("y_offset", old_cv.y_offset, new_cv.y_offset), + ("z_offset", old_cv.z_offset, new_cv.z_offset), + ("squeeze_position", old_cv.squeeze_position, new_cv.squeeze_position), + ("z_touchoff", old_cv.z_touchoff, new_cv.z_touchoff), + ("pressure_shift", old_cv.pressure_shift, new_cv.pressure_shift), + ( + "pressure_monitoring_shift", + old_cv.pressure_monitoring_shift, + new_cv.pressure_monitoring_shift, + ), + ( + "dispenser_return_distance", + old_cv.dispenser_return_distance, + new_cv.dispenser_return_distance, + ), + ("z_tip_height", old_cv.z_tip_height, new_cv.z_tip_height), + ("core_ii", old_cv.core_ii, new_cv.core_ii), + ): + if not _calibration_value_equal(old_value, new_value, float_tol=float_tol): + field_changes.append(CalibrationFieldChange(field=field_name, old=old_value, new=new_value)) + if field_changes: + channel_diffs.append( + ChannelCalibrationDiff( + index=idx, + state="changed", + changes=tuple(field_changes), + old=old_cv, + new=new_cv, + ) + ) + + return CalibrationValuesDiff( + top_level_changes=tuple(top_level_changes), + channel_diffs=tuple(channel_diffs), + ) + + +def format_calibration_diff(diff: CalibrationValuesDiff) -> str: + """Return a concise, human-readable diff summary.""" + if not diff.has_changes: + return "No calibration differences." + + lines = ["Calibration differences:"] + if diff.top_level_changes: + lines.append("Top-level:") + for change in diff.top_level_changes: + lines.append(f" {change.field}: {change.old} -> {change.new}") + + if diff.channel_diffs: + lines.append("Per-channel:") + for channel_diff in diff.channel_diffs: + if channel_diff.state == "added": + assert channel_diff.new is not None + lines.append(f" index={channel_diff.index}: added ({channel_diff.new.to_pretty_string()})") + continue + if channel_diff.state == "removed": + assert channel_diff.old is not None + lines.append( + f" index={channel_diff.index}: removed ({channel_diff.old.to_pretty_string()})" + ) + continue + changed_fields = ", ".join( + f"{change.field}: {change.old} -> {change.new}" for change in channel_diff.changes + ) + lines.append(f" index={channel_diff.index}: {changed_fields}") + + return "\n".join(lines) + + +@dataclass(frozen=True) +class InstrumentConfig: + """Instrument hardware configuration probed at setup.""" + + deck_bounds: Optional[DeckBounds] + has_enclosure: bool + safe_speeds_enabled: bool + deck_sites: Tuple[DeckSiteInfo, ...] + waste_sites: Tuple[WasteSiteInfo, ...] + default_traverse_height: Optional[float] = ( + None # None if probe failed; user can set via set_default_traverse_height + ) + num_channels: Optional[int] = None # 1 or 2 dual-channel pipettor; from GetPresentChannels + has_mph: Optional[bool] = None # True if 8MPH present; from GetPresentChannels + + +# ============================================================================= +# Inner parameter dataclasses (wire-type annotated, serialized via from_struct) +# ============================================================================= + + +@dataclass +class SeekParameters: + x_start: F32 + y_start: F32 + z_start: F32 + distance: F32 + expected_position: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.x_start, F32) + .add(self.y_start, F32) + .add(self.z_start, F32) + .add(self.distance, F32) + .add(self.expected_position, F32) + ) + + +@dataclass +class XYZCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_position: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.x_position, F32) + .add(self.y_position, F32) + .add(self.z_position, F32) + ) + + +@dataclass +class XYCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.x_position, F32) + .add(self.y_position, F32) + ) + + +@dataclass +class ChannelYZMoveParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_position: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.y_position, F32) + .add(self.z_position, F32) + ) + + +@dataclass +class GantryMoveXYZParameters: + default_values: PaddedBool + gantry_x_position: F32 + axis_parameters: Annotated[list[ChannelYZMoveParameters], StructArray()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.gantry_x_position, F32) + .add(self.axis_parameters, StructArray()) + ) + + +@dataclass +class PlateDimensions: + default_values: PaddedBool + length: F32 + width: F32 + height: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.length, F32) + .add(self.width, F32) + .add(self.height, F32) + ) + + +@dataclass +class TipDefinition: + default_values: PaddedBool + id: PaddedU8 + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + label: Str + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.id, PaddedU8) + .add(self.volume, F32) + .add(self.length, F32) + .add(self.tip_type, WEnum) + .add(self.has_filter, PaddedBool) + .add(self.is_needle, PaddedBool) + .add(self.is_tool, PaddedBool) + .add(self.label, Str) + ) + + +@dataclass +class TipPickupParameters: + default_values: PaddedBool + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.volume, F32) + .add(self.length, F32) + .add(self.tip_type, WEnum) + .add(self.has_filter, PaddedBool) + .add(self.is_needle, PaddedBool) + .add(self.is_tool, PaddedBool) + ) + + +@dataclass +class AspirateParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + prewet_volume: F32 + blowout_volume: F32 + + @classmethod + def from_location( + cls, + loc, + *, + prewet_volume: float = 0.0, + blowout_volume: float = 0.0, + ) -> AspirateParameters: + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + prewet_volume=prewet_volume, + blowout_volume=blowout_volume, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.x_position, F32) + .add(self.y_position, F32) + .add(self.prewet_volume, F32) + .add(self.blowout_volume, F32) + ) + + +@dataclass +class DispenseParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + stop_back_volume: F32 + cutoff_speed: F32 + + @classmethod + def for_op( + cls, + loc, + stop_back_volume: float = 0.0, + cutoff_speed: float = 100.0, + ) -> DispenseParameters: + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + stop_back_volume=stop_back_volume, + cutoff_speed=cutoff_speed, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.x_position, F32) + .add(self.y_position, F32) + .add(self.stop_back_volume, F32) + .add(self.cutoff_speed, F32) + ) + + +@dataclass +class CommonParameters: + default_values: PaddedBool + empty: PaddedBool + z_minimum: F32 + z_final: F32 + z_liquid_exit_speed: F32 + liquid_volume: F32 + liquid_speed: F32 + transport_air_volume: F32 + tube_radius: F32 + cone_height: F32 + cone_bottom_radius: F32 + settling_time: F32 + additional_probes: U32 + + @classmethod + def for_op( + cls, + volume: float, + radius: float, + *, + flow_rate: Optional[float] = None, + empty: bool = True, + z_minimum: float = 5.0, + z_final: float = 96.97, + z_liquid_exit_speed: float = 10.0, + transport_air_volume: float = 0.0, + cone_height: float = 0.0, + cone_bottom_radius: float = 0.0, + settling_time: float = 1.0, + additional_probes: int = 0, + ) -> CommonParameters: + """Build CommonParameters for a single aspirate/dispense op. + + z_minimum is in mm; default 5.0 keeps the head above the deck surface (deck has + its own size_z). High-level aspirate()/dispense() override with well bottom when None. + z_liquid_exit_speed is in mm/s; default 10.0 aligns with STAR swap speed. + """ + return cls( + default_values=False, + empty=empty, + z_minimum=z_minimum, + z_final=z_final, + z_liquid_exit_speed=z_liquid_exit_speed, + liquid_volume=volume, + liquid_speed=flow_rate or 100.0, + transport_air_volume=transport_air_volume, + tube_radius=radius, + cone_height=cone_height, + cone_bottom_radius=cone_bottom_radius, + settling_time=settling_time, + additional_probes=additional_probes, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.empty, PaddedBool) + .add(self.z_minimum, F32) + .add(self.z_final, F32) + .add(self.z_liquid_exit_speed, F32) + .add(self.liquid_volume, F32) + .add(self.liquid_speed, F32) + .add(self.transport_air_volume, F32) + .add(self.tube_radius, F32) + .add(self.cone_height, F32) + .add(self.cone_bottom_radius, F32) + .add(self.settling_time, F32) + .add(self.additional_probes, U32) + ) + + +@dataclass +class NoLldParameters: + default_values: PaddedBool + z_fluid: F32 + z_air: F32 + bottom_search: PaddedBool + z_bottom_search_offset: F32 + z_bottom_offset: F32 + + @classmethod + def for_fixed_z( + cls, + z_fluid: float = 94.97, + z_air: float = 96.97, + *, + z_bottom_search_offset: float = 2.0, + z_bottom_offset: float = 0.0, + ) -> NoLldParameters: + return cls( + default_values=False, + z_fluid=z_fluid, + z_air=z_air, + bottom_search=False, + z_bottom_search_offset=z_bottom_search_offset, + z_bottom_offset=z_bottom_offset, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.z_fluid, F32) + .add(self.z_air, F32) + .add(self.bottom_search, PaddedBool) + .add(self.z_bottom_search_offset, F32) + .add(self.z_bottom_offset, F32) + ) + + +@dataclass +class LldParameters: + default_values: PaddedBool + search_start_position: F32 + channel_speed: F32 + z_submerge: F32 + z_out_of_liquid: F32 + + @classmethod + def default(cls) -> LldParameters: + return cls( + default_values=True, + search_start_position=0.0, + channel_speed=0.0, + z_submerge=0.0, + z_out_of_liquid=0.0, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.search_start_position, F32) + .add(self.channel_speed, F32) + .add(self.z_submerge, F32) + .add(self.z_out_of_liquid, F32) + ) + + +@dataclass +class CLldParameters: + default_values: PaddedBool + sensitivity: WEnum + clot_check_enable: PaddedBool + z_clot_check: F32 + detect_mode: WEnum + + @classmethod + def default(cls) -> CLldParameters: + return cls( + default_values=True, sensitivity=1, clot_check_enable=False, z_clot_check=0.0, detect_mode=0 + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.sensitivity, WEnum) + .add(self.clot_check_enable, PaddedBool) + .add(self.z_clot_check, F32) + .add(self.detect_mode, WEnum) + ) + + +@dataclass +class PLldParameters: + default_values: PaddedBool + sensitivity: WEnum + dispenser_seek_speed: F32 + lld_height_difference: F32 + detect_mode: WEnum + + @classmethod + def default(cls) -> PLldParameters: + return cls( + default_values=True, + sensitivity=1, + dispenser_seek_speed=0.0, + lld_height_difference=0.0, + detect_mode=0, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.sensitivity, WEnum) + .add(self.dispenser_seek_speed, F32) + .add(self.lld_height_difference, F32) + .add(self.detect_mode, WEnum) + ) + + +@dataclass +class TadmReturnParameters: + default_values: PaddedBool + channel: WEnum + entries: U32 + error: PaddedBool + data: I16Array + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.entries, U32) + .add(self.error, PaddedBool) + .add(self.data, I16Array) + ) + + +@dataclass +class TadmParameters: + default_values: PaddedBool + limit_curve_index: U16 + recording_mode: WEnum + + @classmethod + def default(cls) -> TadmParameters: + return cls( + default_values=True, + limit_curve_index=0, + recording_mode=TadmRecordingModes.Errors, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.limit_curve_index, U16) + .add(self.recording_mode, WEnum) + ) + + +@dataclass +class AspirateMonitoringParameters: + default_values: PaddedBool + c_lld_enable: PaddedBool + p_lld_enable: PaddedBool + minimum_differential: U16 + maximum_differential: U16 + clot_threshold: U16 + + @classmethod + def default(cls) -> AspirateMonitoringParameters: + return cls( + default_values=True, + c_lld_enable=False, + p_lld_enable=False, + minimum_differential=30, + maximum_differential=30, + clot_threshold=20, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.c_lld_enable, PaddedBool) + .add(self.p_lld_enable, PaddedBool) + .add(self.minimum_differential, U16) + .add(self.maximum_differential, U16) + .add(self.clot_threshold, U16) + ) + + +@dataclass +class MixParameters: + default_values: PaddedBool + z_offset: F32 + volume: F32 + cycles: PaddedU8 + speed: F32 + + @classmethod + def default(cls) -> MixParameters: + return cls( + default_values=True, + z_offset=0.0, + volume=0.0, + cycles=0, + speed=250.0, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.z_offset, F32) + .add(self.volume, F32) + .add(self.cycles, PaddedU8) + .add(self.speed, F32) + ) + + +@dataclass +class AdcParameters: + default_values: PaddedBool + errors: PaddedBool + maximum_volume: F32 + + @classmethod + def default(cls) -> AdcParameters: + return cls( + default_values=True, + errors=True, + maximum_volume=4.5, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.errors, PaddedBool) + .add(self.maximum_volume, F32) + ) + + +@dataclass +class ChannelBoundsParameters: + """Per-channel movement bounds returned by PipettorService.GetChannelBounds.""" + + channel: WEnum + x_min: F32 + x_max: F32 + y_min: F32 + y_max: F32 + z_min: F32 + z_max: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.channel, WEnum) + .add(self.x_min, F32) + .add(self.x_max, F32) + .add(self.y_min, F32) + .add(self.y_max, F32) + .add(self.z_min, F32) + .add(self.z_max, F32) + ) + + +@dataclass +class ChannelXYZPositionParameters: + default_values: PaddedBool + channel: WEnum + position_x: F32 + position_y: F32 + position_z: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.position_x, F32) + .add(self.position_y, F32) + .add(self.position_z, F32) + ) + + +@dataclass +class PressureReturnParameters: + default_values: PaddedBool + channel: WEnum + pressure: U16 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool).add(self.channel, WEnum).add(self.pressure, U16) + ) + + +@dataclass +class LiquidHeightReturnParameters: + default_values: PaddedBool + channel: WEnum + c_lld_detected: PaddedBool + c_lld_liquid_height: F32 + p_lld_detected: PaddedBool + p_lld_liquid_height: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.c_lld_detected, PaddedBool) + .add(self.c_lld_liquid_height, F32) + .add(self.p_lld_detected, PaddedBool) + .add(self.p_lld_liquid_height, F32) + ) + + +@dataclass +class DispenserVolumeReturnParameters: + default_values: PaddedBool + channel: WEnum + volume: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool).add(self.channel, WEnum).add(self.volume, F32) + ) + + +@dataclass +class PotentiometerParameters: + default_values: PaddedBool + channel: WEnum + gain: PaddedU8 + offset: PaddedU8 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.gain, PaddedU8) + .add(self.offset, PaddedU8) + ) + + +@dataclass +class YLLDSeekParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_position_y: F32 + seek_velocity_y: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.start_position_x, F32) + .add(self.start_position_y, F32) + .add(self.start_position_z, F32) + .add(self.seek_position_y, F32) + .add(self.seek_velocity_y, F32) + .add(self.lld_sensitivity, WEnum) + .add(self.detect_mode, WEnum) + ) + + +@dataclass +class ChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.seek_position_x, F32) + .add(self.seek_position_y, F32) + .add(self.seek_height, F32) + .add(self.min_seek_height, F32) + .add(self.final_position_z, F32) + ) + + +@dataclass +class LLDChannelSeekParameters: + default_values: PaddedBool + channel: WEnum + seek_position_x: F32 + seek_position_y: F32 + seek_velocity_z: F32 + seek_height: F32 + min_seek_height: F32 + final_position_z: F32 + lld_sensitivity: WEnum + detect_mode: WEnum + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.seek_position_x, F32) + .add(self.seek_position_y, F32) + .add(self.seek_velocity_z, F32) + .add(self.seek_height, F32) + .add(self.min_seek_height, F32) + .add(self.final_position_z, F32) + .add(self.lld_sensitivity, WEnum) + .add(self.detect_mode, WEnum) + ) + + +@dataclass +class SeekResultParameters: + default_values: PaddedBool + channel: WEnum + detected: PaddedBool + position: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.detected, PaddedBool) + .add(self.position, F32) + ) + + +@dataclass +class ChannelCounterParameters: + default_values: PaddedBool + channel: WEnum + tip_pickup_counter: U32 + tip_eject_counter: U32 + aspirate_counter: U32 + dispense_counter: U32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.tip_pickup_counter, U32) + .add(self.tip_eject_counter, U32) + .add(self.aspirate_counter, U32) + .add(self.dispense_counter, U32) + ) + + +@dataclass +class ChannelCalibrationParameters: + default_values: PaddedBool + channel: WEnum + dispenser_return_steps: U32 + squeeze_position: F32 + z_touchoff: F32 + z_tip_height: F32 + pressure_monitoring_shift: U32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.dispenser_return_steps, U32) + .add(self.squeeze_position, F32) + .add(self.z_touchoff, F32) + .add(self.z_tip_height, F32) + .add(self.pressure_monitoring_shift, U32) + ) + + +@dataclass +class LeakCheckSimpleParameters: + default_values: PaddedBool + channel: WEnum + time: F32 + high_pressure: PaddedBool + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.time, F32) + .add(self.high_pressure, PaddedBool) + ) + + +@dataclass +class LeakCheckParameters: + default_values: PaddedBool + channel: WEnum + start_position_x: F32 + start_position_y: F32 + start_position_z: F32 + seek_distance_y: F32 + pre_load_distance_y: F32 + final_z: F32 + tip_definition_id: PaddedU8 + test_time: F32 + high_pressure: PaddedBool + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.start_position_x, F32) + .add(self.start_position_y, F32) + .add(self.start_position_z, F32) + .add(self.seek_distance_y, F32) + .add(self.pre_load_distance_y, F32) + .add(self.final_z, F32) + .add(self.tip_definition_id, PaddedU8) + .add(self.test_time, F32) + .add(self.high_pressure, PaddedBool) + ) + + +@dataclass +class DriveStatus: + initialized: PaddedBool + position: F32 + encoder_position: F32 + in_home_sensor: PaddedBool + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.initialized, PaddedBool) + .add(self.position, F32) + .add(self.encoder_position, F32) + .add(self.in_home_sensor, PaddedBool) + ) + + +@dataclass +class ChannelDriveStatus: + default_values: PaddedBool + channel: WEnum + y_axis_drive_status: Annotated[DriveStatus, Struct()] + z_axis_drive_status: Annotated[DriveStatus, Struct()] + dispenser_drive_status: Annotated[DriveStatus, Struct()] + squeeze_drive_status: Annotated[DriveStatus, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.y_axis_drive_status, Struct()) + .add(self.z_axis_drive_status, Struct()) + .add(self.dispenser_drive_status, Struct()) + .add(self.squeeze_drive_status, Struct()) + ) + + +@dataclass +class AspirateParametersNoLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.aspirate, Struct()) + .add(self.common, Struct()) + .add(self.no_lld, Struct()) + .add(self.mix, Struct()) + .add(self.adc, Struct()) + .add(self.aspirate_monitoring, Struct()) + ) + + +@dataclass +class AspirateParametersNoLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.aspirate, Struct()) + .add(self.common, Struct()) + .add(self.no_lld, Struct()) + .add(self.mix, Struct()) + .add(self.adc, Struct()) + .add(self.tadm, Struct()) + ) + + +@dataclass +class AspirateParametersLldAndMonitoring: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.aspirate, Struct()) + .add(self.common, Struct()) + .add(self.lld, Struct()) + .add(self.p_lld, Struct()) + .add(self.c_lld, Struct()) + .add(self.mix, Struct()) + .add(self.aspirate_monitoring, Struct()) + .add(self.adc, Struct()) + ) + + +@dataclass +class AspirateParametersLldAndTadm: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.aspirate, Struct()) + .add(self.common, Struct()) + .add(self.lld, Struct()) + .add(self.p_lld, Struct()) + .add(self.c_lld, Struct()) + .add(self.mix, Struct()) + .add(self.tadm, Struct()) + .add(self.adc, Struct()) + ) + + +@dataclass +class DispenseParametersNoLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.dispense, Struct()) + .add(self.common, Struct()) + .add(self.no_lld, Struct()) + .add(self.mix, Struct()) + .add(self.adc, Struct()) + .add(self.tadm, Struct()) + ) + + +@dataclass +class DispenseParametersLld: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.dispense, Struct()) + .add(self.common, Struct()) + .add(self.lld, Struct()) + .add(self.c_lld, Struct()) + .add(self.mix, Struct()) + .add(self.adc, Struct()) + .add(self.tadm, Struct()) + ) + + +@dataclass +class DropTipParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_seek: F32 + z_tip: F32 + z_final: F32 + z_seek_speed: F32 + drop_type: WEnum + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.y_position, F32) + .add(self.z_seek, F32) + .add(self.z_tip, F32) + .add(self.z_final, F32) + .add(self.z_seek_speed, F32) + .add(self.drop_type, WEnum) + ) + + +@dataclass +class InitTipDropParameters: + default_values: PaddedBool + x_position: F32 + rolloff_distance: F32 + channel_parameters: Annotated[list[DropTipParameters], StructArray()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.x_position, F32) + .add(self.rolloff_distance, F32) + .add(self.channel_parameters, StructArray()) + ) + + +@dataclass +class DispenseInitToWasteParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.x_position, F32) + .add(self.y_position, F32) + .add(self.z_position, F32) + ) + + +@dataclass +class MoveAxisAbsoluteParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + position: F32 + delay: U32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.axis, WEnum) + .add(self.position, F32) + .add(self.delay, U32) + ) + + +@dataclass +class MoveAxisRelativeParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + distance: F32 + delay: U32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.axis, WEnum) + .add(self.distance, F32) + .add(self.delay, U32) + ) + + +@dataclass +class LimitCurveEntry: + default_values: PaddedBool + sample: U16 + pressure: I16 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return params.add(self.default_values, PaddedBool).add(self.sample, U16).add(self.pressure, I16) + + +@dataclass +class TipPositionParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + ) -> TipPositionParameters: + """Build from an op location and tip (pickup). + + z_seek default: z_position + fitting_depth + 5mm guard (tip-type-aware, + comparable to Nimbus/Vantage). z_seek_offset: additive mm on top of + computed default (None = 0). + """ + z = loc.z + tip.total_tip_length - tip.fitting_depth + z_seek = z + tip.fitting_depth + 5.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.x_position, F32) + .add(self.y_position, F32) + .add(self.z_position, F32) + .add(self.z_seek, F32) + ) + + +@dataclass +class TipDropParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + z_seek: F32 + drop_type: WEnum + + @classmethod + def for_op( + cls, + channel: WEnum, + loc, + tip, + *, + z_seek_offset: Optional[float] = None, + drop_type: Optional[TipDropType] = None, + ) -> TipDropParameters: + """Build from an op location and tip (drop). + + z_position uses (total_tip_length - fitting_depth) so the tip bottom lands + at the spot surface (consistent with STAR and with pickup). + z_seek default: loc.z + total_tip_length + 5mm so tip bottom clears adjacent tips during + lateral approach. z_seek_offset: additive mm on top of computed default + (None = 0). + """ + z = loc.z + (tip.total_tip_length - tip.fitting_depth) + z_seek = loc.z + tip.total_tip_length + 2.0 + (z_seek_offset or 0.0) + return cls( + default_values=False, + channel=channel, + x_position=loc.x, + y_position=loc.y, + z_position=z, + z_seek=z_seek, + drop_type=drop_type if drop_type is not None else TipDropType.FixedHeight, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.x_position, F32) + .add(self.y_position, F32) + .add(self.z_position, F32) + .add(self.z_seek, F32) + .add(self.drop_type, WEnum) + ) + + +@dataclass +class TipHeightCalibrationParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_start: F32 + z_stop: F32 + z_final: F32 + volume: F32 + tip_type: WEnum + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.x_position, F32) + .add(self.y_position, F32) + .add(self.z_start, F32) + .add(self.z_stop, F32) + .add(self.z_final, F32) + .add(self.volume, F32) + .add(self.tip_type, WEnum) + ) + + +@dataclass +class DispenserVolumeEntry: + default_values: PaddedBool + type: WEnum + volume: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return params.add(self.default_values, PaddedBool).add(self.type, WEnum).add(self.volume, F32) + + +@dataclass +class DispenserVolumeStackReturnParameters: + default_values: PaddedBool + channel: WEnum + total_volume: F32 + volumes: Annotated[list[DispenserVolumeEntry], StructArray()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.total_volume, F32) + .add(self.volumes, StructArray()) + ) + + +@dataclass +class SegmentDescriptor: + area_top: F32 + area_bottom: F32 + height: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return params.add(self.area_top, F32).add(self.area_bottom, F32).add(self.height, F32) + + +@dataclass +class AspirateParametersNoLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.aspirate, Struct()) + .add(self.container_description, StructArray()) + .add(self.common, Struct()) + .add(self.no_lld, Struct()) + .add(self.mix, Struct()) + .add(self.adc, Struct()) + .add(self.aspirate_monitoring, Struct()) + ) + + +@dataclass +class AspirateParametersNoLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.aspirate, Struct()) + .add(self.container_description, StructArray()) + .add(self.common, Struct()) + .add(self.no_lld, Struct()) + .add(self.mix, Struct()) + .add(self.adc, Struct()) + .add(self.tadm, Struct()) + ) + + +@dataclass +class AspirateParametersLldAndMonitoring2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + aspirate_monitoring: Annotated[AspirateMonitoringParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.aspirate, Struct()) + .add(self.container_description, StructArray()) + .add(self.common, Struct()) + .add(self.lld, Struct()) + .add(self.p_lld, Struct()) + .add(self.c_lld, Struct()) + .add(self.mix, Struct()) + .add(self.aspirate_monitoring, Struct()) + .add(self.adc, Struct()) + ) + + +@dataclass +class AspirateParametersLldAndTadm2: + default_values: PaddedBool + channel: WEnum + aspirate: Annotated[AspirateParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + p_lld: Annotated[PLldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.aspirate, Struct()) + .add(self.container_description, StructArray()) + .add(self.common, Struct()) + .add(self.lld, Struct()) + .add(self.p_lld, Struct()) + .add(self.c_lld, Struct()) + .add(self.mix, Struct()) + .add(self.tadm, Struct()) + .add(self.adc, Struct()) + ) + + +@dataclass +class DispenseParametersNoLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + no_lld: Annotated[NoLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.dispense, Struct()) + .add(self.container_description, StructArray()) + .add(self.common, Struct()) + .add(self.no_lld, Struct()) + .add(self.mix, Struct()) + .add(self.adc, Struct()) + .add(self.tadm, Struct()) + ) + + +@dataclass +class DispenseParametersLld2: + default_values: PaddedBool + channel: WEnum + dispense: Annotated[DispenseParameters, Struct()] + container_description: Annotated[list[SegmentDescriptor], StructArray()] + common: Annotated[CommonParameters, Struct()] + lld: Annotated[LldParameters, Struct()] + c_lld: Annotated[CLldParameters, Struct()] + mix: Annotated[MixParameters, Struct()] + adc: Annotated[AdcParameters, Struct()] + tadm: Annotated[TadmParameters, Struct()] + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.channel, WEnum) + .add(self.dispense, Struct()) + .add(self.container_description, StructArray()) + .add(self.common, Struct()) + .add(self.lld, Struct()) + .add(self.c_lld, Struct()) + .add(self.mix, Struct()) + .add(self.adc, Struct()) + .add(self.tadm, Struct()) + ) + + +# ============================================================================= +# PrepCommand base class +# ============================================================================= + + +# An unresolved command is bound to a firmware address by PrepClient for each execution. +_UNRESOLVED = Address(-1, -1, -1) +_CHANNEL_TO_INDEX = {int(ChannelIndex.RearChannel): 0, int(ChannelIndex.FrontChannel): 1} + + +def _plr_channel_index(channel: int, entry_index: int) -> Optional[int]: + """Map pipettor channel enums or ordered MPH probe entries to PLR indices.""" + if channel == ChannelIndex.MPHChannel: + return entry_index + return _CHANNEL_TO_INDEX.get(channel) + + +ResponseT = TypeVar("ResponseT", covariant=True) + + +@dataclass(frozen=True) +class PrepCommand(TCPCommand[ResponseT]): + """Immutable Prep request, with its destination resolved at execution time. + + Concrete commands explicitly encode their wire fields and decode their declared + response. Commands targeting multiple firmware objects declare a constructor + ``dest`` field; fixed-target commands declare ``firmware_path``. + """ + + dest: Address = field(default=_UNRESOLVED, init=False) + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + firmware_path: ClassVar[Optional[str]] = None + _ALL_PATHS: ClassVar[Set[str]] = set() + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if cls.firmware_path is not None: + PrepCommand._ALL_PATHS.add(cls.firmware_path) + + +@dataclass(frozen=True) +class PrepStatusRequest(PrepCommand[ResponseT]): + """Prep status request; decoding follows the concrete command's response type.""" + + action_code = Hoi2Action.STATUS_REQUEST + + +@dataclass(frozen=True) +class PrepProbeRequest(PrepCommand[bytes]): + """Ad-hoc STATUS_REQUEST with runtime command_id and interface_id. + + Use with :meth:`~PrepClient.exchange` when the target command_id is only + known at runtime. Always supply ``dest=`` explicitly; the JIT firmware-path + resolver is bypassed because ``firmware_path = None``. + + ``command_id`` and ``interface_id`` are dataclass instance fields that shadow + the class-level defaults in :class:`~pylabrobot.hamilton.transport.tcp.commands.TCPCommand`, + so :meth:`TCPCommand.build` picks up the per-instance values correctly. + """ + + action_code = Hoi2Action.STATUS_REQUEST + firmware_path = None + dest: Address + command_id: int # type: ignore[misc] # Runtime identity on an immutable probe request. + interface_id: int = 3 # type: ignore[misc] + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> bytes: + """Decode the declared success response.""" + return data + + +# ============================================================================= +# Pipettor / ChannelCoordinator command classes +# ============================================================================= + + +@dataclass(frozen=True) +class PrepAspirateNoLldMonitoring(PrepCommand[None]): + """Aspirate without LLD or monitoring (cmd=1, dest=Pipettor).""" + + command_id = 1 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepAspirateTadm(PrepCommand[None]): + """Aspirate with TADM, no LLD (cmd=2, dest=Pipettor).""" + + command_id = 2 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepAspirateWithLld(PrepCommand[None]): + """Aspirate with LLD and monitoring (cmd=3, dest=Pipettor).""" + + command_id = 3 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepAspirateWithLldTadm(PrepCommand[None]): + """Aspirate with LLD and TADM (cmd=4, dest=Pipettor).""" + + command_id = 4 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepDispenseNoLld(PrepCommand[None]): + """Dispense without LLD (cmd=5, dest=Pipettor).""" + + command_id = 5 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.dispense_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.dispense_parameters): + return None + return _plr_channel_index(int(self.dispense_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepDispenseWithLld(PrepCommand[None]): + """Dispense with LLD (cmd=6, dest=Pipettor).""" + + command_id = 6 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.dispense_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.dispense_parameters): + return None + return _plr_channel_index(int(self.dispense_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepDispenseInitToWaste(PrepCommand[None]): + """Dispense initialize to waste (cmd=7, dest=Pipettor).""" + + command_id = 7 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + waste_parameters: Annotated[list[DispenseInitToWasteParameters], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.waste_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.waste_parameters): + return None + return _plr_channel_index(int(self.waste_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepPickUpTipsById(PrepCommand[None]): + """Pick up tips by tip-definition ID (cmd=8, dest=Pipettor).""" + + command_id = 8 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.tip_positions, StructArray()) + .add(self.final_z, F32) + .add(self.seek_speed, F32) + .add(self.tip_definition_id, PaddedU8) + .add(self.enable_tadm, PaddedBool) + .add(self.dispenser_volume, F32) + .add(self.dispenser_speed, F32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.tip_positions): + return None + return _plr_channel_index(int(self.tip_positions[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepPickUpTips(PrepCommand[None]): + """Pick up tips by tip-definition struct (cmd=9, dest=Pipettor).""" + + command_id = 9 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.tip_positions, StructArray()) + .add(self.final_z, F32) + .add(self.seek_speed, F32) + .add(self.tip_definition, Struct()) + .add(self.enable_tadm, PaddedBool) + .add(self.dispenser_volume, F32) + .add(self.dispenser_speed, F32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.tip_positions): + return None + return _plr_channel_index(int(self.tip_positions[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepPickUpNeedlesById(PrepCommand[None]): + """Pick up needles by tip-definition ID (cmd=10, dest=Pipettor).""" + + command_id = 10 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition_id: PaddedU8 + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.tip_positions, StructArray()) + .add(self.final_z, F32) + .add(self.seek_speed, F32) + .add(self.tip_definition_id, PaddedU8) + .add(self.blowout_offset, F32) + .add(self.blowout_speed, F32) + .add(self.enable_tadm, PaddedBool) + .add(self.dispenser_volume, F32) + .add(self.dispenser_speed, F32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.tip_positions): + return None + return _plr_channel_index(int(self.tip_positions[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepPickUpNeedles(PrepCommand[None]): + """Pick up needles by tip-definition struct (cmd=11, dest=Pipettor).""" + + command_id = 11 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipPositionParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + blowout_offset: F32 + blowout_speed: F32 + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.tip_positions, StructArray()) + .add(self.final_z, F32) + .add(self.seek_speed, F32) + .add(self.tip_definition, Struct()) + .add(self.blowout_offset, F32) + .add(self.blowout_speed, F32) + .add(self.enable_tadm, PaddedBool) + .add(self.dispenser_volume, F32) + .add(self.dispenser_speed, F32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.tip_positions): + return None + return _plr_channel_index(int(self.tip_positions[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepDropTips(PrepCommand[None]): + """Drop tips (cmd=12, dest=Pipettor).""" + + command_id = 12 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_positions: Annotated[list[TipDropParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.tip_positions, StructArray()) + .add(self.final_z, F32) + .add(self.seek_speed, F32) + .add(self.tip_roll_off_distance, F32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.tip_positions): + return None + return _plr_channel_index(int(self.tip_positions[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphPickupTips(PrepCommand[None]): + """Pick up tips via MPH coordinator (iface=1 id=9, dest=MphRoot.MPH). + + Resolved introspection signature: + PickupTips(tipParameters: struct(iface=1), finalZ: f32, + tipDefinition: struct(iface=1), tadm: bool, + dispenserVolume: f32, dispenserSpeed: f32, + tipMask: u32) -> { seekSpeed: List[u16] } + + The MPH takes a SINGLE struct (type_57) for tip_position, not a + StructArray (type_61) like the Pipettor. All 8 probes move as one unit; + tip_mask selects which channels engage. + """ + + command_id = 9 + firmware_path = "MLPrepRoot.MphRoot.MPH" + tip_position: Annotated[TipPositionParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_definition: Annotated[TipPickupParameters, Struct()] + enable_tadm: PaddedBool + dispenser_volume: F32 + dispenser_speed: F32 + tip_mask: U32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.tip_position, Struct()) + .add(self.final_z, F32) + .add(self.seek_speed, F32) + .add(self.tip_definition, Struct()) + .add(self.enable_tadm, PaddedBool) + .add(self.dispenser_volume, F32) + .add(self.dispenser_speed, F32) + .add(self.tip_mask, U32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class MphMoveToPosition(PrepCommand[None]): + """Move MPH gantry to absolute XYZ on IMph (cmd=17, dest=MphRoot.MPH). + + Wire matches vendor ``MoveToPosition(positionX, positionY, positionZ)`` as three + plain ``f32`` scalars (see mph.yaml) — not :class:`GantryMoveXYZParameters`, which + is Pipettor-only. Use :class:`MphMoveToPosition` / :class:`MphMoveToPositionViaLane` + for MPH motion; :class:`PrepMoveToPosition` targets PipettorRoot only. + """ + + command_id = 17 + firmware_path = "MLPrepRoot.MphRoot.MPH" + x_position: F32 + y_position: F32 + z_position: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.x_position, F32).add(self.y_position, F32).add(self.z_position, F32) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class MphMoveToPositionViaLane(PrepCommand[None]): + """Move MPH gantry to absolute XYZ via lane (cmd=18, dest=MphRoot.MPH). + + Same payload as :class:`MphMoveToPosition`; vendor ``MoveToPositionViaLane``. + """ + + command_id = 18 + firmware_path = "MLPrepRoot.MphRoot.MPH" + x_position: F32 + y_position: F32 + z_position: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.x_position, F32).add(self.y_position, F32).add(self.z_position, F32) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class MphDropTips(PrepCommand[None]): + """Drop tips via MPH coordinator (iface=1 id=12, dest=MphRoot.MPH). + + Resolved introspection signature: + DropTips(dropTipParameters: struct(iface=1), finalZ: f32, + tipRollOffDistance: f32) -> seekSpeed: List[u16] + + Single struct (type_57) for drop position — all probes drop together. + """ + + command_id = 12 + firmware_path = "MLPrepRoot.MphRoot.MPH" + tip_position: Annotated[TipDropParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.tip_position, Struct()) + .add(self.final_z, F32) + .add(self.seek_speed, F32) + .add(self.tip_roll_off_distance, F32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class MphAspirateNoLldMonitoring(PrepCommand[None]): + """Aspirate without LLD via MPH coordinator (cmd=1, dest=MphRoot.MPH). + + One AspirateParametersNoLldAndMonitoring struct per active probe — each with + its own explicit x/y position. ``channel`` is ChannelIndex.MPHChannel for all + entries. The array length equals the number of active probes (not necessarily 8). + """ + + command_id = 1 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphDispenseNoLld(PrepCommand[None]): + """Dispense without LLD via MPH coordinator (cmd=5, dest=MphRoot.MPH). + + One DispenseParametersNoLld struct per active probe — each with its own + explicit x/y position. ``channel`` is ChannelIndex.MPHChannel for all entries. + """ + + command_id = 5 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.dispense_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.dispense_parameters): + return None + return _plr_channel_index(int(self.dispense_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphAspirateNoLldMonitoring2(PrepCommand[None]): + """Aspirate V2 with liquid-following via MPH coordinator (cmd=29, dest=MphRoot.MPH). + + Uses ``AspirateParametersNoLldAndMonitoring2`` which includes a + ``ContainerDescription`` frustum-segment array for Z-axis liquid-following. + One entry per active probe; array length equals the number of active probes. + """ + + command_id = 29 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphDispenseNoLld2(PrepCommand[None]): + """Dispense V2 without LLD via MPH coordinator (cmd=33, dest=MphRoot.MPH). + + Uses ``DispenseParametersNoLld2`` which includes a ``ContainerDescription`` + frustum-segment array for Z-axis liquid-following. + One entry per active probe; array length equals the number of active probes. + """ + + command_id = 33 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.dispense_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.dispense_parameters): + return None + return _plr_channel_index(int(self.dispense_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphAspirateTadm(PrepCommand[None]): + """Aspirate with TADM, no LLD via MPH coordinator (cmd=2, dest=MphRoot.MPH).""" + + command_id = 2 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphAspirateWithLld(PrepCommand[None]): + """Aspirate with LLD and monitoring via MPH coordinator (cmd=3, dest=MphRoot.MPH).""" + + command_id = 3 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphAspirateWithLldTadm(PrepCommand[None]): + """Aspirate with LLD and TADM via MPH coordinator (cmd=4, dest=MphRoot.MPH).""" + + command_id = 4 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphDispenseWithLld(PrepCommand[None]): + """Dispense with LLD via MPH coordinator (cmd=6, dest=MphRoot.MPH).""" + + command_id = 6 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.dispense_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.dispense_parameters): + return None + return _plr_channel_index(int(self.dispense_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphAspirateTadm2(PrepCommand[None]): + """Aspirate V2 with TADM, no LLD via MPH coordinator (cmd=30, dest=MphRoot.MPH).""" + + command_id = 30 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphAspirateWithLld2(PrepCommand[None]): + """Aspirate V2 with LLD and monitoring via MPH coordinator (cmd=31, dest=MphRoot.MPH).""" + + command_id = 31 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphAspirateWithLldTadm2(PrepCommand[None]): + """Aspirate V2 with LLD and TADM via MPH coordinator (cmd=32, dest=MphRoot.MPH).""" + + command_id = 32 + firmware_path = "MLPrepRoot.MphRoot.MPH" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class MphDispenseWithLld2(PrepCommand[None]): + """Dispense V2 with LLD via MPH coordinator (cmd=34, dest=MphRoot.MPH).""" + + command_id = 34 + firmware_path = "MLPrepRoot.MphRoot.MPH" + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.dispense_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.dispense_parameters): + return None + return _plr_channel_index(int(self.dispense_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepPickUpToolById(PrepCommand[None]): + """Pick up tool by tip-definition ID (cmd=14, dest=Pipettor).""" + + command_id = 14 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_definition_id: PaddedU8 + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.tip_definition_id, PaddedU8) + .add(self.tool_position_x, F32) + .add(self.tool_position_z, F32) + .add(self.front_channel_position_y, F32) + .add(self.rear_channel_position_y, F32) + .add(self.tool_seek, F32) + .add(self.tool_x_radius, F32) + .add(self.tool_y_radius, F32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepPickUpTool(PrepCommand[None]): + """Pick up tool by tip-definition struct (cmd=15, dest=Pipettor).""" + + command_id = 15 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + tip_definition: Annotated[TipPickupParameters, Struct()] + tool_position_x: F32 + tool_position_z: F32 + front_channel_position_y: F32 + rear_channel_position_y: F32 + tool_seek: F32 + tool_x_radius: F32 + tool_y_radius: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.tip_definition, Struct()) + .add(self.tool_position_x, F32) + .add(self.tool_position_z, F32) + .add(self.front_channel_position_y, F32) + .add(self.rear_channel_position_y, F32) + .add(self.tool_seek, F32) + .add(self.tool_x_radius, F32) + .add(self.tool_y_radius, F32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepDropTool(PrepCommand[None]): + """Drop tool (cmd=16, dest=Pipettor).""" + + command_id = 16 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepPickUpPlate(PrepCommand[None]): + """Pick up plate (cmd=17, dest=Pipettor).""" + + command_id = 17 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + plate_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.plate_top_center, Struct()) + .add(self.plate, Struct()) + .add(self.clearance_y, F32) + .add(self.grip_speed_y, F32) + .add(self.grip_distance, F32) + .add(self.grip_height, F32) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepDropPlate(PrepCommand[None]): + """Drop plate (cmd=18, dest=Pipettor).""" + + command_id = 18 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + plate_top_center: Annotated[XYZCoord, Struct()] + clearance_y: F32 + acceleration_scale_x: PaddedU8 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.plate_top_center, Struct()) + .add(self.clearance_y, F32) + .add(self.acceleration_scale_x, PaddedU8) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepMovePlate(PrepCommand[None]): + """Move plate to position (cmd=19, dest=Pipettor).""" + + command_id = 19 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + plate_top_center: Annotated[XYZCoord, Struct()] + acceleration_scale_x: PaddedU8 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.plate_top_center, Struct()).add(self.acceleration_scale_x, PaddedU8) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepTransferPlate(PrepCommand[None]): + """Transfer plate from source to destination (cmd=20, dest=Pipettor).""" + + command_id = 20 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + plate_source_top_center: Annotated[XYZCoord, Struct()] + plate_destination_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + acceleration_scale_x: PaddedU8 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.plate_source_top_center, Struct()) + .add(self.plate_destination_top_center, Struct()) + .add(self.plate, Struct()) + .add(self.clearance_y, F32) + .add(self.grip_speed_y, F32) + .add(self.grip_distance, F32) + .add(self.grip_height, F32) + .add(self.acceleration_scale_x, PaddedU8) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepReleasePlate(PrepCommand[None]): + """Release plate / open gripper (cmd=21, dest=Pipettor).""" + + command_id = 21 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +# CORE gripper tool definition for PrepPickUpTool (struct); matches instrument id=11. +CO_RE_GRIPPER_TIP_PICKUP_PARAMETERS = TipPickupParameters( + default_values=False, + volume=1.0, + length=22.9, + tip_type=TipTypes.None_, + has_filter=False, + is_needle=False, + is_tool=True, +) + + +@dataclass(frozen=True) +class PrepEmptyDispenser(PrepCommand[None]): + """Empty dispenser (cmd=23, dest=Pipettor).""" + + command_id = 23 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channels: EnumArray + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.channels, EnumArray) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepMoveToPosition(PrepCommand[None]): + """Move pipettor gantry to position (cmd=26, dest=PipettorRoot only). + + Payload is :class:`GantryMoveXYZParameters` with ``FrontChannel`` / ``RearChannel`` + only in ``axis_parameters``. MPH motion must use :class:`MphMoveToPosition` on + ``MLPrepRoot.MphRoot.MPH``, not this command. + """ + + command_id = 26 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.move_parameters, Struct()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepMoveToPositionViaLane(PrepCommand[None]): + """Move pipettor gantry via lane (cmd=27, dest=PipettorRoot only). + + Same constraints as :class:`PrepMoveToPosition`. MPH: use + :class:`MphMoveToPositionViaLane`. + """ + + command_id = 27 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.move_parameters, Struct()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepGetPositions(PrepStatusRequest["PrepGetPositions.Response"]): + """GetPositions (cmd=25, dest=Pipettor). + + Returns the current XYZ position of each channel as a StructArray of + ChannelXYZPositionParameters. + """ + + command_id = 25 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + + @dataclass(frozen=True) + class Response: + positions: Annotated[list[ChannelXYZPositionParameters], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetPositions.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepMoveZUpToSafe(PrepCommand[None]): + """Move Z axes up to safe height (cmd=28, dest=Pipettor).""" + + command_id = 28 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channels: EnumArray + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.channels, EnumArray) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepZSeekLldPosition(PrepCommand[None]): + """Z-seek LLD position (cmd=29, dest=Pipettor).""" + + command_id = 29 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + seek_parameters: Annotated[list[LLDChannelSeekParameters], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.seek_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.seek_parameters): + return None + return _plr_channel_index(int(self.seek_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepCreateTadmLimitCurve(PrepCommand[None]): + """Create TADM limit curve (cmd=31, dest=Pipettor).""" + + command_id = 31 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + name: Str + lower_limit: Annotated[list[LimitCurveEntry], StructArray()] + upper_limit: Annotated[list[LimitCurveEntry], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.channel, U32) + .add(self.name, Str) + .add(self.lower_limit, StructArray()) + .add(self.upper_limit, StructArray()) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepEraseTadmLimitCurves(PrepCommand[None]): + """Erase TADM limit curves for a channel (cmd=32, dest=Pipettor).""" + + command_id = 32 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.channel, U32) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepGetTadmLimitCurveNames(PrepCommand[None]): + """Get TADM limit curve names for a channel (cmd=33, dest=Pipettor).""" + + command_id = 33 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.channel, U32) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepGetTadmLimitCurveInfo(PrepCommand[None]): + """Get TADM limit curve info (cmd=34, dest=Pipettor).""" + + command_id = 34 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + name: Str + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.channel, U32).add(self.name, Str) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepRetrieveTadmData(PrepCommand[None]): + """Retrieve TADM data for a channel (cmd=35, dest=Pipettor).""" + + command_id = 35 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channel: U32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.channel, U32) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepResetTadmFifo(PrepCommand[None]): + """Reset TADM FIFO (cmd=36, dest=Pipettor).""" + + command_id = 36 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + channels: EnumArray + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.channels, EnumArray) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepAspirateNoLldMonitoringV2(PrepCommand[None]): + """Aspirate v2 without LLD or monitoring (cmd=38, dest=Pipettor).""" + + command_id = 38 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepAspirateTadmV2(PrepCommand[None]): + """Aspirate v2 with TADM, no LLD (cmd=39, dest=Pipettor).""" + + command_id = 39 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepAspirateWithLldV2(PrepCommand[None]): + """Aspirate v2 with LLD and monitoring (cmd=40, dest=Pipettor).""" + + command_id = 40 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepAspirateWithLldTadmV2(PrepCommand[None]): + """Aspirate v2 with LLD and TADM (cmd=41, dest=Pipettor).""" + + command_id = 41 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.aspirate_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.aspirate_parameters): + return None + return _plr_channel_index(int(self.aspirate_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepDispenseNoLldV2(PrepCommand[None]): + """Dispense v2 without LLD (cmd=42, dest=Pipettor).""" + + command_id = 42 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.dispense_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.dispense_parameters): + return None + return _plr_channel_index(int(self.dispense_parameters[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepDispenseWithLldV2(PrepCommand[None]): + """Dispense v2 with LLD (cmd=43, dest=Pipettor).""" + + command_id = 43 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor" + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.dispense_parameters, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.dispense_parameters): + return None + return _plr_channel_index(int(self.dispense_parameters[entry_index].channel), entry_index) + + +# ============================================================================= +# MLPrep command classes +# ============================================================================= + + +@dataclass(frozen=True) +class PrepInitialize(PrepCommand[None]): + """Initialize MLPrep (cmd=1, dest=MLPrep).""" + + command_id = 1 + firmware_path = "MLPrepRoot.MLPrep" + smart: PaddedBool + tip_drop_params: Annotated[InitTipDropParameters, Struct()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.smart, PaddedBool).add(self.tip_drop_params, Struct()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepGetIsInitialized(PrepStatusRequest["PrepGetIsInitialized.Response"]): + """Query whether MLPrep is initialized. Firmware yaml: [1:2] GetIsInitialized(void) -> value: bool.""" + + command_id = 2 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetIsInitialized.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepPark(PrepCommand[None]): + """Park MLPrep (cmd=3, dest=MLPrep).""" + + command_id = 3 + firmware_path = "MLPrepRoot.MLPrep" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepSpread(PrepCommand[None]): + """Spread channels (cmd=4, dest=MLPrep).""" + + command_id = 4 + firmware_path = "MLPrepRoot.MLPrep" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepAddTipAndNeedleDefinition(PrepCommand[None]): + """Add tip/needle definition (cmd=12, dest=MLPrep).""" + + command_id = 12 + firmware_path = "MLPrepRoot.MLPrep" + tip_definition: Annotated[TipDefinition, Struct()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.tip_definition, Struct()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepRemoveTipAndNeedleDefinition(PrepCommand[None]): + """Remove tip/needle definition by ID (cmd=13, dest=MLPrep).""" + + command_id = 13 + firmware_path = "MLPrepRoot.MLPrep" + id_: WEnum + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.id_, WEnum) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepReadStorage(PrepCommand[None]): + """Read from instrument storage (cmd=14, dest=MLPrep).""" + + command_id = 14 + firmware_path = "MLPrepRoot.MLPrep" + offset: U32 + length: U32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.offset, U32).add(self.length, U32) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepWriteStorage(PrepCommand[None]): + """Write to instrument storage (cmd=15, dest=MLPrep).""" + + command_id = 15 + firmware_path = "MLPrepRoot.MLPrep" + offset: U32 + data: U8Array + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.offset, U32).add(self.data, U8Array) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepPowerDownRequest(PrepCommand[None]): + """Request power down (cmd=17, dest=MLPrep).""" + + command_id = 17 + firmware_path = "MLPrepRoot.MLPrep" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepConfirmPowerDown(PrepCommand[None]): + """Confirm power down (cmd=18, dest=MLPrep).""" + + command_id = 18 + firmware_path = "MLPrepRoot.MLPrep" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepCancelPowerDown(PrepCommand[None]): + """Cancel power down (cmd=19, dest=MLPrep).""" + + command_id = 19 + firmware_path = "MLPrepRoot.MLPrep" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepRemoveChannelPower(PrepCommand[None]): + """Remove channel power for head swap (cmd=23, dest=MLPrep).""" + + command_id = 23 + firmware_path = "MLPrepRoot.MLPrep" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepRestoreChannelPower(PrepCommand[None]): + """Restore channel power after head swap (cmd=24, dest=MLPrep).""" + + command_id = 24 + firmware_path = "MLPrepRoot.MLPrep" + delay_ms: U32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.delay_ms, U32) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepSetDeckLight(PrepCommand[None]): + """Set deck LED colour (cmd=25, dest=MLPrep).""" + + command_id = 25 + firmware_path = "MLPrepRoot.MLPrep" + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.white, PaddedU8) + .add(self.red, PaddedU8) + .add(self.green, PaddedU8) + .add(self.blue, PaddedU8) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepGetDeckLight(PrepStatusRequest["PrepGetDeckLight.Response"]): + """Get deck LED colour (cmd=26, dest=MLPrep).""" + + command_id = 26 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetDeckLight.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepSuspendedPark(PrepCommand[None]): + """Suspended park / move to load position (cmd=29, dest=MLPrep). + + Reuses :class:`GantryMoveXYZParameters` on the **MLPrep** coordinator, not + PipettorRoot — distinct from :class:`PrepMoveToPosition` and from MPH moves + (:class:`MphMoveToPosition`). + """ + + command_id = 29 + firmware_path = "MLPrepRoot.MLPrep" + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.move_parameters, Struct()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepMethodBegin(PrepCommand[None]): + """Begin method (cmd=30, dest=MLPrep).""" + + command_id = 30 + firmware_path = "MLPrepRoot.MLPrep" + automatic_pause: PaddedBool + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.automatic_pause, PaddedBool) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepMethodEnd(PrepCommand[None]): + """End method (cmd=31, dest=MLPrep).""" + + command_id = 31 + firmware_path = "MLPrepRoot.MLPrep" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepMethodAbort(PrepCommand[None]): + """Abort method (cmd=33, dest=MLPrep).""" + + command_id = 33 + firmware_path = "MLPrepRoot.MLPrep" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepIsParked(PrepStatusRequest["PrepIsParked.Response"]): + """Query parked status (cmd=34, dest=MLPrep). Firmware yaml: IsParked(void) -> parked: bool.""" + + command_id = 34 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepIsParked.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepIsSpread(PrepStatusRequest["PrepIsSpread.Response"]): + """Query spread status (cmd=35, dest=MLPrep). Same HOI pattern as :class:`PrepIsParked`.""" + + command_id = 35 + firmware_path = "MLPrepRoot.MLPrep" + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepIsSpread.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +# ----------------------------------------------------------------------------- +# Wire structs for config responses (used by nested Response and InstrumentConfig) +# ----------------------------------------------------------------------------- + + +@dataclass +class _DeckSiteDefinitionWire: + """Wire shape for one DeckSiteDefinition (GetDeckSiteDefinitions element).""" + + default_values: PaddedBool + id: U32 + left_bottom_front_x: F32 + left_bottom_front_y: F32 + left_bottom_front_z: F32 + length: F32 + width: F32 + height: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.id, U32) + .add(self.left_bottom_front_x, F32) + .add(self.left_bottom_front_y, F32) + .add(self.left_bottom_front_z, F32) + .add(self.length, F32) + .add(self.width, F32) + .add(self.height, F32) + ) + + +@dataclass +class _CalibrationSiteDefinitionWire: + """Wire shape for one CalibrationSiteDefinition (GetCalibrationSiteDefinitions element). + + Same fields as DeckSiteDefinition plus trailing Post (BOOL). + """ + + default_values: PaddedBool + id: U32 + left_bottom_front_x: F32 + left_bottom_front_y: F32 + left_bottom_front_z: F32 + length: F32 + width: F32 + height: F32 + post: PaddedBool + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.id, U32) + .add(self.left_bottom_front_x, F32) + .add(self.left_bottom_front_y, F32) + .add(self.left_bottom_front_z, F32) + .add(self.length, F32) + .add(self.width, F32) + .add(self.height, F32) + .add(self.post, PaddedBool) + ) + + +@dataclass +class _ChannelHardwareConfigWire: + """Wire shape for ChannelHardwareConfig (GetChannelHardwareConfiguration element).""" + + channel: WEnum # ChannelIndex + hardware: WEnum # Hardware type enum (interface 2, id 1) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return params.add(self.channel, WEnum).add(self.hardware, WEnum) + + +@dataclass +class _ChannelCalibrationValuesWire: + """Wire shape for ChannelCalibrationValues (GetCalibrationValues element).""" + + index: WEnum # ChannelIndex + y_offset: F32 + z_offset: F32 + squeeze_position: U32 + z_touchoff: U32 + pressure_shift: U32 + pressure_monitoring_shift: U32 + dispenser_return_distance: F32 + z_tip_height: F32 + core_ii: PaddedBool + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.index, WEnum) + .add(self.y_offset, F32) + .add(self.z_offset, F32) + .add(self.squeeze_position, U32) + .add(self.z_touchoff, U32) + .add(self.pressure_shift, U32) + .add(self.pressure_monitoring_shift, U32) + .add(self.dispenser_return_distance, F32) + .add(self.z_tip_height, F32) + .add(self.core_ii, PaddedBool) + ) + + +@dataclass +class _WasteSiteDefinitionWire: + """Wire shape for one WasteSiteDefinition (GetWasteSiteDefinitions element).""" + + default_values: PaddedBool + index: WEnum + x_position: I8 + y_position: U16 + z_position: F32 + z_seek: F32 + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.index, WEnum) + .add(self.x_position, I8) + .add(self.y_position, U16) + .add(self.z_position, F32) + .add(self.z_seek, F32) + ) + + +# ----------------------------------------------------------------------------- +# Config queries (MLPrep / DeckConfiguration) for _get_hardware_config +# (inherit :class:`PrepStatusRequest`, defined above) +# ----------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PrepGetIsEnclosurePresent(PrepStatusRequest["PrepGetIsEnclosurePresent.Response"]): + """GetIsEnclosurePresent (cmd=21, dest=MLPrep). Firmware yaml: -> value: bool.""" + + command_id = 21 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetIsEnclosurePresent.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetSafeSpeedsEnabled(PrepStatusRequest["PrepGetSafeSpeedsEnabled.Response"]): + """GetSafeSpeedsEnabled (cmd=28, dest=MLPrep). Firmware yaml: -> value: bool.""" + + command_id = 28 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + value: PaddedBool + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetSafeSpeedsEnabled.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetDefaultTraverseHeight(PrepStatusRequest["PrepGetDefaultTraverseHeight.Response"]): + """GetDefaultTraverseHeight (cmd=10, dest=MLPrep). Returns F32.""" + + command_id = 10 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + value: F32 + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetDefaultTraverseHeight.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetTipAndNeedleDefinitions(PrepStatusRequest["PrepGetTipAndNeedleDefinitions.Response"]): + """GetTipAndNeedleDefinitions (cmd=11, dest=MLPrep). + + Returns the list of tip/needle definitions registered on the instrument. + Introspection: iface=1 id=11 GetTipAndNeedleDefinitions(value: type_64) -> void + (response carries STRUCTURE_ARRAY of tip definition structs). + """ + + command_id = 11 + firmware_path = "MLPrepRoot.MLPrep" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + definitions: Annotated[list[TipDefinition], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetTipAndNeedleDefinitions.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetDeckBounds(PrepStatusRequest["PrepGetDeckBounds.Response"]): + """GetDeckBounds (cmd=1, dest=DeckConfiguration). Returns 6× F32 (min/max x,y,z).""" + + command_id = 1 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + min_x: F32 + max_x: F32 + min_y: F32 + max_y: F32 + min_z: F32 + max_z: F32 + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetDeckBounds.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetCalibrationSiteDefinitions( + PrepStatusRequest["PrepGetCalibrationSiteDefinitions.Response"] +): + """GetCalibrationSiteDefinitions (cmd=3, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of CalibrationSiteDefinition structs: + DefaultValues: BOOL, Id: U32, LeftBottomFrontX/Y/Z: F32, Length, Width, Height: F32, Post: BOOL + """ + + command_id = 3 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_CalibrationSiteDefinitionWire], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetCalibrationSiteDefinitions.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetDeckSiteDefinitions(PrepStatusRequest["PrepGetDeckSiteDefinitions.Response"]): + """GetDeckSiteDefinitions (cmd=7, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of DeckSiteDefinition structs: + DefaultValues: BOOL, Id: U32, LeftBottomFrontX: F32, LeftBottomFrontY: F32, + LeftBottomFrontZ: F32, Length: F32, Width: F32, Height: F32 + """ + + command_id = 7 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_DeckSiteDefinitionWire], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetDeckSiteDefinitions.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetWasteSiteDefinitions(PrepStatusRequest["PrepGetWasteSiteDefinitions.Response"]): + """GetWasteSiteDefinitions (cmd=12, dest=DeckConfiguration). + + Response is a STRUCTURE_ARRAY of WasteSiteDefinition structs: + DefaultValues: BOOL, Index: ENUM, XPosition: I8, YPosition: U16, + ZPosition: F32, ZSeek: F32 + """ + + command_id = 12 + firmware_path = "MLPrepRoot.MLPrepCalibration.DeckConfiguration" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetWasteSiteDefinitions.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetChannelBounds(PrepStatusRequest["PrepGetChannelBounds.Response"]): + """GetChannelBounds (cmd=10, dest=PipettorService). + + Returns per-channel movement bounds (x_min, x_max, y_min, y_max, z_min, z_max) + as a StructArray of ChannelBoundsParameters. + """ + + command_id = 10 + firmware_path = "MLPrepRoot.PipettorRoot.Pipettor.PipettorService" + + @dataclass(frozen=True) + class Response: + bounds: Annotated[list[ChannelBoundsParameters], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetChannelBounds.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetPresentChannels(PrepStatusRequest["PrepGetPresentChannels.Response"]): + """GetPresentChannels (cmd=17, dest=MLPrepService). + + Returns a list of enum values (iface=1, id=5): which channels are present. + Map to ChannelIndex: 0=InvalidIndex, 1=FrontChannel, 2=RearChannel, 3=MPHChannel. + Use this to determine hardware configuration: 1 vs 2 channels, or 8MPH presence. + """ + + command_id = 17 + firmware_path = "MLPrepRoot.MLPrepService" + dest: Address = _UNRESOLVED + + @dataclass(frozen=True) + class Response: + channels: EnumArray # list of ints: map to ChannelIndex for present channels + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetPresentChannels.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +# ----------------------------------------------------------------------------- +# MLPrepCalibration commands +# ----------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PrepBeginCalibration(PrepCommand[None]): + """BeginCalibration (cmd=1, dest=MLPrepCalibration). Enter calibration mode.""" + + command_id = 1 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepCancelCalibration(PrepCommand[None]): + """CancelCalibration (cmd=2, dest=MLPrepCalibration). Cancel active calibration session.""" + + command_id = 2 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepEndCalibration(PrepCommand[None]): + """EndCalibration (cmd=3, dest=MLPrepCalibration). End calibration and store results with timestamp.""" + + command_id = 3 + firmware_path = "MLPrepRoot.MLPrepCalibration" + date_time: Annotated[HoiDateTime, Struct()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.date_time, Struct()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepResetCalibration(PrepCommand[None]): + """ResetCalibration (cmd=4, dest=MLPrepCalibration). Reset calibration data, optionally storing.""" + + command_id = 4 + firmware_path = "MLPrepRoot.MLPrepCalibration" + store: PaddedBool + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.store, PaddedBool) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepCalibrationInitialize(PrepCommand[None]): + """CalibrationInitialize (cmd=5, dest=MLPrepCalibration). Initialize calibration hardware.""" + + command_id = 5 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass +class NeedleDefinition: + """Wire shape for NeedleDefinition (MLPrepCalibration local struct, id=2). + + When default_values=True the firmware uses stored defaults for all fields. + TipDefinition is nested (global pool source_id=1, ref_id=8). + """ + + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_start: F32 + z_stop: F32 + tip_definition: Annotated[TipDefinition, Struct()] + tip_mask: U32 + + @classmethod + def defaults(cls) -> "NeedleDefinition": + """Return an all-defaults instance (firmware fills in stored values).""" + return cls( + default_values=True, + x_position=0.0, + y_position=0.0, + z_start=0.0, + z_stop=0.0, + tip_definition=TipDefinition( + default_values=True, + id=0, + volume=0.0, + length=0.0, + tip_type=0, + has_filter=False, + is_needle=False, + is_tool=False, + label="", + ), + tip_mask=0, + ) + + def encode_into(self, params: HoiParams) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + params.add(self.default_values, PaddedBool) + .add(self.x_position, F32) + .add(self.y_position, F32) + .add(self.z_start, F32) + .add(self.z_stop, F32) + .add(self.tip_definition, Struct()) + .add(self.tip_mask, U32) + ) + + +@dataclass(frozen=True) +class PrepSelfCalibrate(PrepCommand[None]): + """SelfCalibrate (cmd=6, dest=MLPrepCalibration). + + Runs a full self-calibration sequence. Set individual booleans to select + which calibration phases to run. Pass NeedleDefinition.defaults() to use + firmware-stored needle parameters. + """ + + command_id = 6 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channels: WEnum # ChannelIndex + axis: PaddedBool + pressure: PaddedBool + touchoff: PaddedBool + needle: Annotated[NeedleDefinition, Struct()] + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return ( + HoiParams() + .add(self.site_index, U32) + .add(self.channels, WEnum) + .add(self.axis, PaddedBool) + .add(self.pressure, PaddedBool) + .add(self.touchoff, PaddedBool) + .add(self.needle, Struct()) + ) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> None: + """Decode the declared success response.""" + return None + + +@dataclass(frozen=True) +class PrepCalibrateXAxis(PrepCommand["PrepCalibrateXAxis.Response"]): + """CalibrateXAxis (cmd=7, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 7 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.site_index, U32).add(self.channel, WEnum) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepCalibrateXAxis.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepCalibrateYAxis(PrepCommand["PrepCalibrateYAxis.Response"]): + """CalibrateYAxis (cmd=8, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 8 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.site_index, U32).add(self.channel, WEnum) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepCalibrateYAxis.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepCalibrateZAxis(PrepCommand["PrepCalibrateZAxis.Response"]): + """CalibrateZAxis (cmd=9, dest=MLPrepCalibration). Returns offset: F32.""" + + command_id = 9 + firmware_path = "MLPrepRoot.MLPrepCalibration" + site_index: U32 + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + offset: F32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.site_index, U32).add(self.channel, WEnum) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepCalibrateZAxis.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepCalibrateSqueeze(PrepCommand["PrepCalibrateSqueeze.Response"]): + """CalibrateSqueeze (cmd=14, dest=MLPrepCalibration). Returns position: U32.""" + + command_id = 14 + firmware_path = "MLPrepRoot.MLPrepCalibration" + channel: WEnum # ChannelIndex + + @dataclass(frozen=True) + class Response: + position: U32 + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.channel, WEnum) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepCalibrateSqueeze.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepCalibrateSqueezeTips(PrepCommand["PrepCalibrateSqueezeTips.Response"]): + """CalibrateSqueezeTips (cmd=15, dest=MLPrepCalibration). + + Takes per-channel TipPositionParameters (same struct as pick_up_tips) and + returns per-channel squeeze positions as a list of u32. + """ + + command_id = 15 + firmware_path = "MLPrepRoot.MLPrepCalibration" + channels: Annotated[list[TipPositionParameters], StructArray()] + + @dataclass(frozen=True) + class Response: + positions: U32Array + + def build_parameters(self) -> HoiParams: + """Encode fields in firmware-defined order.""" + return HoiParams().add(self.channels, StructArray()) + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepCalibrateSqueezeTips.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + uses_physical_channels = True + + def _channel_index_for_entry(self, entry_index: int, entry: HcResultEntry) -> Optional[int]: + """Map the firmware result ordinal to the requested PLR channel.""" + if entry_index >= len(self.channels): + return None + return _plr_channel_index(int(self.channels[entry_index].channel), entry_index) + + +@dataclass(frozen=True) +class PrepGetCalibrationValues(PrepStatusRequest["PrepGetCalibrationValues.Response"]): + """GetCalibrationValues (cmd=16, dest=MLPrepCalibration). + + Returns independentOffsetX (F32), mphOffsetX (F32), and per-channel + calibration values as a StructArray of ChannelCalibrationValues. + """ + + command_id = 16 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + @dataclass(frozen=True) + class Response: + independent_offset_x: F32 + mph_offset_x: F32 + channel_values: Annotated[list[_ChannelCalibrationValuesWire], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetCalibrationValues.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) + + +@dataclass(frozen=True) +class PrepGetChannelHardwareConfiguration( + PrepStatusRequest["PrepGetChannelHardwareConfiguration.Response"] +): + """GetChannelHardwareConfiguration (cmd=24, dest=MLPrepCalibration). + + Response is a StructArray of ChannelHardwareConfig: Channel (enum) + Hardware (enum). + """ + + command_id = 24 + firmware_path = "MLPrepRoot.MLPrepCalibration" + + @dataclass(frozen=True) + class Response: + channels: Annotated[list[_ChannelHardwareConfigWire], StructArray()] + + def build_parameters(self) -> HoiParams: + """Encode the request payload.""" + return HoiParams() + + @classmethod + def parse_response_parameters(cls, data: bytes) -> PrepGetChannelHardwareConfiguration.Response: + """Decode the declared success response.""" + return parse_into_struct(HoiParamsParser(data), cls.Response) diff --git a/pylabrobot/hamilton/prep/tests/__init__.py b/pylabrobot/hamilton/prep/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/prep/tests/channels_tests.py b/pylabrobot/hamilton/prep/tests/channels_tests.py new file mode 100644 index 00000000000..10b70813083 --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/channels_tests.py @@ -0,0 +1,140 @@ +"""PrepPIPChannel facade + enumeration against the chatterbox.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from pylabrobot.hamilton.prep import Prep +from pylabrobot.hamilton.prep.channels import PrepChannels, PrepPIPChannel +from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub +from pylabrobot.resources.hamilton import PrepDeck, STARLetDeck, hamilton_96_tiprack_50uL_NTR +from pylabrobot.resources.tip_tracker import set_tip_tracking +from pylabrobot.resources.volume_tracker import set_volume_tracking + + +def _run(coro): + asyncio.run(coro) + + +def test_channels_match_info_num_channels(): + """PrepChannels.channels length matches info.config.num_channels on a default chatterbox.""" + + async def _t(): + p = Prep(deck=STARLetDeck(), chatterbox=True) + await p.setup() + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + assert len(p.channels.channels) == p.info.config.num_channels + for i, ch in enumerate(p.channels.channels): + assert isinstance(ch, PrepPIPChannel) + assert ch.index == i + await p.stop() + + _run(_t()) + + +def test_channels_attach_bounds_even_when_empty_offline(): + """Chatterbox firmware tree is empty, so bounds are None — but the attribute must exist.""" + + async def _t(): + p = Prep(deck=STARLetDeck(), chatterbox=True) + await p.setup() + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + for ch in p.channels.channels: + assert ch.bounds is None + await p.stop() + + _run(_t()) + + +def test_channels_tip_trackers_pick_and_drop(): + """pick_up_tips / drop_tips update spot + channel TipTrackers when tip tracking is on.""" + + async def _t(): + set_tip_tracking(True) + try: + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.channels is not None + spots = [tip_rack.get_item("A1"), tip_rack.get_item("B1")] + n = min(2, p.channels.num_channels) + spots = spots[:n] + use = list(range(n)) + assert all(s.has_tip() for s in spots) + assert all(t is None for t in p.channels.get_mounted_tips()[:n]) + + await p.channels.pick_up_tips(spots, use_channels=use) + assert all(not s.has_tip() for s in spots) + mounted = p.channels.get_mounted_tips() + assert all(mounted[i] is not None for i in use) + assert all(p.channels.head[i].has_tip for i in use) + + await p.channels.drop_tips(spots, use_channels=use) + assert all(s.has_tip() for s in spots) + assert all(not p.channels.head[i].has_tip for i in use) + await p.stop() + finally: + set_tip_tracking(False) + + _run(_t()) + + +def test_channels_volume_trackers_aspirate_dispense(): + """aspirate/dispense update well and tip VolumeTrackers when volume tracking is on.""" + + async def _t(): + set_tip_tracking(True) + set_volume_tracking(True) + try: + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + plate = deck[0] = cor_axy_96_wellplate_500uL_Ub("plate") + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.channels is not None + n = min(2, p.channels.num_channels) + spots = [tip_rack.get_item("A1"), tip_rack.get_item("B1")][:n] + use = list(range(n)) + src = plate["A1:B1"][:n] + dst = plate["A7:B7"][:n] + vols = [20.0] * n + for well in src: + well.tracker.set_volume(100.0) + + await p.channels.pick_up_tips(spots, use_channels=use) + await p.channels.aspirate( + src, + vols=vols, + use_channels=use, + disable_volume_correction=[True] * n, + ) + for well in src: + assert well.tracker.get_used_volume() == pytest.approx(80.0) + for ch in use: + tip = p.channels.head[ch].get_tip() + assert tip.tracker.get_used_volume() == pytest.approx(20.0) + + await p.channels.dispense( + dst, + vols=vols, + use_channels=use, + disable_volume_correction=[True] * n, + ) + for well in dst: + assert well.tracker.get_used_volume() == pytest.approx(20.0) + for ch in use: + tip = p.channels.head[ch].get_tip() + assert tip.tracker.get_used_volume() == pytest.approx(0.0) + + await p.channels.drop_tips(spots, use_channels=use) + await p.stop() + finally: + set_tip_tracking(False) + set_volume_tracking(False) + + _run(_t()) diff --git a/pylabrobot/hamilton/prep/tests/client_tests.py b/pylabrobot/hamilton/prep/tests/client_tests.py new file mode 100644 index 00000000000..c88a92312c7 --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/client_tests.py @@ -0,0 +1,203 @@ +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from pylabrobot.hamilton.prep import Prep, PrepChatterboxClient +from pylabrobot.hamilton.prep import prep_commands as PrepCmd +from pylabrobot.hamilton.prep.channels import PrepChannels +from pylabrobot.hamilton.prep.client import PrepClient +from pylabrobot.hamilton.prep.gripper import PrepGripper, PrepGripperArm +from pylabrobot.hamilton.transport.tcp.packets import Address +from pylabrobot.hamilton.transport.tcp.protocol import Hoi2Action +from pylabrobot.resources.hamilton import STARLetDeck + + +@pytest.mark.parametrize("command_id,interface_id", [(9, 3), (8, 3), (2, 2), (5, 3)]) +def test_firmware_string_queries_send_status_requests(command_id, interface_id): + """Identity queries send the requested method and decode its string response.""" + + async def _run() -> None: + client = PrepClient(host="127.0.0.1") + address = Address(1, 1, 0x100) + payload = b"\x0f\x00\x08\x00PREP123\x00" + client.execute = AsyncMock(return_value=payload) # type: ignore[method-assign] + + result = await client._query_firmware_string(address, command_id, interface_id) + + assert result == "PREP123" + client.execute.assert_awaited_once() + command = client.execute.call_args.args[0] + assert command.dest == address + assert command.command_id == command_id + assert command.interface_id == interface_id + assert command.action_code == Hoi2Action.STATUS_REQUEST + + asyncio.run(_run()) + + +def test_chatterbox_sets_resolved_interfaces_and_channels(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + + assert isinstance(p.client.mlprep_address, Address) + addr = await p.client.resolve_path("MLPrepRoot.PipettorRoot.Pipettor") + assert isinstance(addr, Address) + assert p.info.config.num_channels == 2 + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + assert p.channels.num_channels == 2 + assert p.channels.setup_finished is True + # Default setup: use_v1_aspirate_dispense=False → v2 probe passes (chatterbox stubs). + assert p.channels._supports_v2_pipetting is True + + await p.stop() + assert p.info._config is None + + asyncio.run(_run()) + + +def test_chatterbox_use_v1_skips_v2_probe(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup(use_v1_aspirate_dispense=True) + assert p.channels is not None + assert isinstance(p.channels, PrepChannels) + assert p.channels.setup_finished is True + assert p.channels._supports_v2_pipetting is False + + await p.stop() + + asyncio.run(_run()) + + +def test_prep_device_motion_method_and_power_commands(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + await p.park() + await p.spread() + assert p.method is not None + await p.method.begin(automatic_pause=False) + await p.method.end() + await p.cancel_power_down() + await p.stop() + + asyncio.run(_run()) + + +def test_prep_method_run_context_manager_aborts_on_exception(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.method is not None + + calls: list[str] = [] + orig_begin = p.method.begin + orig_end = p.method.end + orig_abort = p.method.abort + + async def rec_begin(automatic_pause: bool = False) -> None: + calls.append("begin") + await orig_begin(automatic_pause=automatic_pause) + + async def rec_end() -> None: + calls.append("end") + await orig_end() + + async def rec_abort() -> None: + calls.append("abort") + await orig_abort() + + p.method.begin = rec_begin # type: ignore[method-assign] + p.method.end = rec_end # type: ignore[method-assign] + p.method.abort = rec_abort # type: ignore[method-assign] + + # Clean exit: begin + end, no abort. + async with p.method.run(): + pass + assert calls == ["begin", "end"] + + # Exception inside: begin + abort, re-raised. + calls.clear() + with pytest.raises(RuntimeError, match="boom"): + async with p.method.run(): + raise RuntimeError("boom") + assert calls == ["begin", "abort"] + + await p.stop() + + asyncio.run(_run()) + + +def test_prep_device_wires_calibration_after_setup(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.info.num_channels == p.info.config.num_channels + assert p.info.has_mph == p.info.config.has_mph + assert p.calibration is not None + assert p.calibration.num_channels == p.info.config.num_channels + assert p.calibration.has_mph == p.info.config.has_mph + assert isinstance(p.gripper, PrepGripper) + async with p.core_grippers() as arm: + assert isinstance(arm, PrepGripperArm) + assert isinstance(arm.backend, PrepGripper) + await p.stop() + + asyncio.run(_run()) + + +def test_execute_surfaces_clear_error_for_unresolvable_path(): + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + + missing = "MLPrepRoot.MLPrep" + orig_resolve = p.client.resolve_path + + async def _fake_resolve(path: str): + if path == missing: + raise KeyError(path) + return await orig_resolve(path) + + p.client.resolve_path = _fake_resolve # type: ignore[assignment] + + with pytest.raises(RuntimeError, match="firmware path"): + await p.client.execute(PrepCmd.PrepPark()) + await p.stop() + + asyncio.run(_run()) + + +def test_chatterbox_preregisters_diagnostic_paths(): + async def _run() -> None: + d = PrepChatterboxClient() + await d.setup() + assert isinstance(await d.resolve_path("MLPrepRoot.MLPrepCpu"), Address) + assert isinstance(await d.resolve_path("MLPrepRoot.PipettorRoot.ModuleInformation"), Address) + await d.stop() + + asyncio.run(_run()) + + +def test_force_initialize_skips_is_initialized_check(): + """When force_initialize=True, Prep.setup() never queries is_initialized.""" + from unittest.mock import AsyncMock + + async def _run() -> None: + deck = STARLetDeck() + p = Prep(deck=deck, chatterbox=True) + p.info.is_initialized = AsyncMock(side_effect=AssertionError("should not be called")) # type: ignore[method-assign] + await p.setup(force_initialize=True) + p.info.is_initialized.assert_not_called() + await p.stop() + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/prep/tests/gripper_tests.py b/pylabrobot/hamilton/prep/tests/gripper_tests.py new file mode 100644 index 00000000000..9249e3a7dfe --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/gripper_tests.py @@ -0,0 +1,257 @@ +"""Tests for PrepGripperArm resource/coordinate pick and drop helpers.""" + +from __future__ import annotations + +import asyncio +from typing import Any, List, Optional +from unittest.mock import AsyncMock + +import pytest + +from pylabrobot.hamilton.prep import Prep +from pylabrobot.hamilton.prep import prep_commands as PrepCmd +from pylabrobot.hamilton.prep.gripper import PrepGripper, PrepGripperArm +from pylabrobot.resources import Coordinate +from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub +from pylabrobot.resources.hamilton import HamiltonCoreGrippers, PrepDeck + + +def _record_send(prep: Prep) -> list[Any]: + captured: list[Any] = [] + orig_send = prep.client.execute + + async def recording(command, **kw): + captured.append(command) + return await orig_send(command, **kw) + + prep.client.execute = recording # type: ignore[method-assign, assignment] + return captured + + +def _make_arm(deck: PrepDeck) -> PrepGripperArm: + backend = PrepGripper(client=AsyncMock(), channels=AsyncMock()) + backend.pick_up_at_location = AsyncMock() # type: ignore[method-assign] + backend.drop_at_location = AsyncMock() # type: ignore[method-assign] + return PrepGripperArm(backend=backend, reference_resource=deck, grip_axis="y") + + +def test_drop_location_matches_holder_geometry_and_offset(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + dest = deck[2] + arm = _make_arm(deck) + + pdfb = arm._resolve_pickup_distance(plate, None) + arm._held_resource = plate + arm._pickup_distance_from_bottom = pdfb + arm._holding_resource_width = arm._resource_width(plate) + + offset = Coordinate(1.0, 2.0, 3.0) + got = arm._drop_location(dest, offset) + + expected = ( + dest.get_absolute_location("l", "f", "b") + + dest.get_default_child_location(plate) + + plate.center() + + offset + + Coordinate(0, 0, pdfb) + ) + assert got.x == pytest.approx(expected.x) + assert got.y == pytest.approx(expected.y) + assert got.z == pytest.approx(expected.z) + + +def test_drop_resource_not_holding_raises(): + deck = PrepDeck(with_core_grippers=True) + arm = _make_arm(deck) + + async def _run() -> None: + with pytest.raises(RuntimeError, match="Not holding anything"): + await arm.drop_resource(deck[2]) + + asyncio.run(_run()) + + +def test_drop_resource_after_coordinate_pick_raises(): + deck = PrepDeck(with_core_grippers=True) + arm = _make_arm(deck) + + async def _run() -> None: + await arm.pick_up_at_location( + Coordinate(100, 200, 50), + resource_width=85.0, + resource_length=127.0, + resource_height=14.0, + plate_top_z_offset=5.0, + ) + with pytest.raises(RuntimeError, match="pick_up_resource"): + await arm.drop_resource(deck[2]) + + asyncio.run(_run()) + + +def test_drop_resource_reassigns_holder(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + dest = deck[2] + arm = _make_arm(deck) + dropped: List[Coordinate] = [] + + async def _capture_drop(location: Coordinate, resource_width: float, **kwargs: Any) -> None: + del resource_width, kwargs + dropped.append(location) + + arm.backend.drop_at_location = _capture_drop # type: ignore[method-assign] + + async def _run() -> None: + await arm.pick_up_resource(plate) + assert plate.parent is deck[4] + await arm.drop_resource(dest) + assert plate.parent is dest + assert dest.resource is plate + assert deck[4].resource is None + assert arm._held_resource is None + assert arm._holding_resource_width is None + assert len(dropped) == 1 + + asyncio.run(_run()) + + +def test_pick_up_resource_width_override(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + arm = _make_arm(deck) + captured: dict[str, Any] = {} + + async def _capture_pick( + location: Coordinate, + resource_width: float, + *, + resource_length: float, + resource_height: float, + plate_top_z_offset: float, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ) -> None: + del location, resource_length, resource_height, plate_top_z_offset + del clearance_y, grip_speed_y, squeeze_mm + captured["resource_width"] = resource_width + + arm.backend.pick_up_at_location = _capture_pick # type: ignore[method-assign] + + async def _run() -> None: + await arm.pick_up_resource(plate, resource_width=80.5) + assert captured["resource_width"] == 80.5 + assert arm._holding_resource_width == 80.5 + + asyncio.run(_run()) + + +def test_pick_up_at_location_enables_drop_at_location(): + deck = PrepDeck(with_core_grippers=True) + arm = _make_arm(deck) + place = Coordinate(10, 20, 30) + + async def _run() -> None: + await arm.pick_up_at_location( + Coordinate(1, 2, 3), + resource_width=85.0, + resource_length=127.0, + resource_height=14.0, + plate_top_z_offset=5.0, + ) + assert arm._holding_resource_width == 85.0 + assert arm._held_resource is None + await arm.drop_at_location(place) + arm.backend.drop_at_location.assert_awaited_once() # type: ignore[attr-defined] + args = arm.backend.drop_at_location.await_args # type: ignore[attr-defined] + assert args is not None + assert args.args[0] == place + assert args.args[1] == 85.0 + assert arm._holding_resource_width is None + + asyncio.run(_run()) + + +def test_drop_resource_applies_offset_to_firmware_location(): + deck = PrepDeck(with_core_grippers=True) + plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate") + dest = deck[2] + arm = _make_arm(deck) + dropped_loc: Optional[Coordinate] = None + + async def _capture_drop(location: Coordinate, resource_width: float, **kwargs: Any) -> None: + nonlocal dropped_loc + del resource_width, kwargs + dropped_loc = location + + arm.backend.drop_at_location = _capture_drop # type: ignore[method-assign] + offset = Coordinate(0.5, -0.25, 1.0) + + async def _run() -> None: + await arm.pick_up_resource(plate) + expected = arm._drop_location(dest, offset) + await arm.drop_resource(dest, offset=offset) + assert dropped_loc is not None + assert dropped_loc.x == pytest.approx(expected.x) + assert dropped_loc.y == pytest.approx(expected.y) + assert dropped_loc.z == pytest.approx(expected.z) + + asyncio.run(_run()) + + +def test_pick_up_tool_default_pre_position_moves_then_picks(): + """Default pre_position=True issues PrepMoveToPosition before PrepPickUpTool.""" + + async def _run() -> None: + deck = PrepDeck(with_core_grippers=True) + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.gripper is not None + captured = _record_send(p) + + await p.pick_up_core_grippers() + + seq = [ + c for c in captured if isinstance(c, (PrepCmd.PrepMoveToPosition, PrepCmd.PrepPickUpTool)) + ] + assert len(seq) >= 2 + assert isinstance(seq[0], PrepCmd.PrepMoveToPosition) + assert isinstance(seq[1], PrepCmd.PrepPickUpTool) + + await p.return_core_grippers() + await p.stop() + + asyncio.run(_run()) + + +def test_pick_up_tool_pre_position_false_skips_move(): + """Explicit pre_position=False sends PrepPickUpTool without a prior PrepMoveToPosition.""" + + async def _run() -> None: + deck = PrepDeck(with_core_grippers=True) + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.gripper is not None + captured = _record_send(p) + + mount = deck.get_resource("core_grippers") + assert isinstance(mount, HamiltonCoreGrippers) + loc = mount.get_location_wrt(deck) + await p.gripper.pick_up_tool( + tool_position_x=loc.x, + tool_position_z=loc.z, + front_channel_position_y=loc.y + mount.front_channel_y_center, + rear_channel_position_y=loc.y + mount.back_channel_y_center, + pre_position=False, + ) + + moves = [c for c in captured if isinstance(c, PrepCmd.PrepMoveToPosition)] + pickups = [c for c in captured if isinstance(c, PrepCmd.PrepPickUpTool)] + assert moves == [] + assert len(pickups) >= 1 + + await p.stop() + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/prep/tests/head8_tests.py b/pylabrobot/hamilton/prep/tests/head8_tests.py new file mode 100644 index 00000000000..e94427a23fa --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/head8_tests.py @@ -0,0 +1,589 @@ +"""Tests for PrepHead8. + +Covers core logic that must survive refactors: + - _resolve_probe_positions: pitch validation for 96-well columns and interleaved 384-well + - _validate_container_span: minimum Y-span check for trough path + - all-8-channel enforcement (ganged head constraint) + - V1/V2 aspirate/dispense dispatch and LLD/TADM kwargs +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from pylabrobot.hamilton.prep import Prep +from pylabrobot.hamilton.prep import prep_commands as PrepCmd +from pylabrobot.hamilton.prep.channels import ( + LLDMode, + _build_pipettor_gantry_move_parameters, +) +from pylabrobot.hamilton.prep.head8 import PROBE_PITCH_MM, PrepHead8 +from pylabrobot.resources import Coordinate +from pylabrobot.resources.corning.axygen.plates import Cor_Axy_96_wellplate_500uL_Ub +from pylabrobot.resources.hamilton import PrepDeck, hamilton_96_tiprack_50uL_NTR + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_deck(): + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + src_plate = deck[0] = Cor_Axy_96_wellplate_500uL_Ub("src") + dst_plate = deck[4] = Cor_Axy_96_wellplate_500uL_Ub("dst") + return deck, tip_rack, src_plate, dst_plate + + +def _make_head8() -> PrepHead8: + return PrepHead8(client=None, info=None) # type: ignore[arg-type] + + +def _record_send(prep: Prep) -> tuple[list[Any], Any]: + captured: list[Any] = [] + orig_send = prep.client.execute + + async def recording(command, **kw): + captured.append(command) + return await orig_send(command, **kw) + + prep.client.execute = recording # type: ignore[method-assign, assignment] + return captured, orig_send + + +# --------------------------------------------------------------------------- +# Group 1: _resolve_probe_positions / _validate_container_span +# --------------------------------------------------------------------------- + + +def test_resolve_probe_positions_valid_96well_column(): + """96-well column A→H has exactly 9mm pitch — should pass and return expected Ys.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + plate.location = Coordinate(100, 200, 0) + wells = plate.column(0) + + be = _make_head8() + ys = be._resolve_probe_positions(wells) + + assert len(ys) == 8 + ref_y = wells[0].get_absolute_location("c", "c", "cavity_bottom").y + for i, y in enumerate(ys): + assert y == pytest.approx(ref_y - i * PROBE_PITCH_MM), ( + f"probe {i}: expected {ref_y - i * PROBE_PITCH_MM}, got {y}" + ) + + +def test_resolve_probe_positions_misaligned_raises(): + """Wells not at 9mm pitch must raise ValueError with a descriptive message.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + plate.location = Coordinate(100, 200, 0) + col = plate.column(0) + # Swap rows 0 and 1 — now the pitch from well[0] to well[1] is wrong. + bad_wells = [col[1], col[0]] + list(col[2:]) + + be = _make_head8() + with pytest.raises(ValueError, match="9.0 mm probe pitch"): + be._resolve_probe_positions(bad_wells) + + +def test_resolve_probe_positions_interleaved_384well(): + """Every-other-row selection on a 96-well plate (simulating 4.5mm × 2 = 9mm pitch) passes.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + plate.location = Coordinate(100, 200, 0) + col = plate.column(0) + be = _make_head8() + ys = be._resolve_probe_positions(col) + ref_y = col[0].get_absolute_location("c", "c", "cavity_bottom").y + assert ys[0] == pytest.approx(ref_y) + assert ys[7] == pytest.approx(ref_y - 7 * PROBE_PITCH_MM) + + +def test_validate_container_span_sufficient(): + """Container wider than 63mm passes without error.""" + plate = Cor_Axy_96_wellplate_500uL_Ub("p") + # Cor_Axy_96 is 85.48mm in Y — well above 63mm minimum. + be = _make_head8() + be._validate_container_span(plate) # should not raise + + +def test_validate_container_span_too_narrow(): + """Container narrower than 63mm raises ValueError.""" + narrow = MagicMock() + narrow.name = "narrow_container" + narrow.get_size_y.return_value = 40.0 # less than 63mm + + be = _make_head8() + with pytest.raises(ValueError, match="too narrow"): + be._validate_container_span(narrow) + + +# --------------------------------------------------------------------------- +# Group 2: all-8-channel enforcement + PrepHead8 wiring +# --------------------------------------------------------------------------- + + +def test_partial_channel_pickup_raises_value_error(): + """PrepHead8 rejects pick_up_tips8 with fewer than all 8 channels.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(1)[4:] # E2, F2, G2, H2 + with pytest.raises(ValueError, match="fully-ganged head"): + await p.head8.pick_up_tips8(spots, use_channels=(4, 5, 6, 7)) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_present_after_chatterbox_setup(): + async def _run() -> None: + deck, _, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + assert isinstance(p.head8, PrepHead8) + await p.stop() + + asyncio.run(_run()) + + +def test_head8_full_flow(): + """pick_up_tips8 → aspirate8 → dispense8 → drop_tips8 on chatterbox.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=20) + await p.head8.dispense8(wells=dst_plate.column(0), volume=20) + await p.head8.drop_tips8(spots) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_tip_trackers_pick_and_drop(): + """8 TipTrackers stay in sync across pick_up_tips8 / drop_tips8 with tip tracking on.""" + from pylabrobot.resources.tip_tracker import set_tip_tracking + + async def _run() -> None: + set_tip_tracking(True) + try: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + spots = tip_rack.column(0) + assert all(s.has_tip() for s in spots) + await p.head8.pick_up_tips8(spots) + assert all(not s.has_tip() for s in spots) + assert all(p.head8.head[i].has_tip for i in range(8)) + assert all(t is not None for t in p.head8.get_mounted_tips()) + await p.head8.drop_tips8(spots) + assert all(s.has_tip() for s in spots) + assert all(not p.head8.head[i].has_tip for i in range(8)) + await p.stop() + finally: + set_tip_tracking(False) + + asyncio.run(_run()) + + +def test_mph_move_to_position_command_metadata(): + move = PrepCmd.MphMoveToPosition(x_position=1.5, y_position=2.5, z_position=120.0) + assert move.firmware_path == "MLPrepRoot.MphRoot.MPH" + assert move.command_id == 17 + assert move.x_position == 1.5 and move.y_position == 2.5 and move.z_position == 120.0 + + via = PrepCmd.MphMoveToPositionViaLane(x_position=0.0, y_position=0.0, z_position=0.0) + assert via.command_id == 18 + assert via.firmware_path == move.firmware_path + params = move.build_parameters() + assert params is not None + + +def test_build_pipettor_gantry_move_parameters_maps_rear_front(): + m = _build_pipettor_gantry_move_parameters(10.0, [0, 1], [20.0, 30.0], [40.0, 50.0]) + assert m.gantry_x_position == 10.0 + assert len(m.axis_parameters) == 2 + assert m.axis_parameters[0].channel == PrepCmd.ChannelIndex.RearChannel + assert m.axis_parameters[0].y_position == 20.0 + assert m.axis_parameters[0].z_position == 40.0 + assert m.axis_parameters[1].channel == PrepCmd.ChannelIndex.FrontChannel + assert m.axis_parameters[1].y_position == 30.0 + assert m.axis_parameters[1].z_position == 50.0 + + +def test_head8_move_to_position_sends_mph_wire_commands(): + """PrepHead8.move_to_position sends MphMoveToPosition / ViaLane.""" + + async def _run() -> None: + deck, _, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + await p.head8.move_to_position(11.0, 22.5, 99.0) + direct = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPosition)] + assert len(direct) == 1 + assert direct[0].x_position == 11.0 + assert direct[0].y_position == 22.5 + assert direct[0].z_position == 99.0 + + await p.head8.move_to_position(1.0, 2.0, 3.0, via_lane=True) + lanes = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPositionViaLane)] + assert len(lanes) == 1 + assert lanes[0].x_position == 1.0 and lanes[0].y_position == 2.0 and lanes[0].z_position == 3.0 + + await p.stop() + + asyncio.run(_run()) + + +def test_pick_up_tips_default_pre_position_sends_mph_move_then_pickup(): + """Default pre_position=True issues MphMoveToPosition before MphPickupTips.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + await p.head8.pick_up_tips8(tip_rack.column(0)) + + mph_seq = [ + c for c in captured if isinstance(c, (PrepCmd.MphMoveToPosition, PrepCmd.MphPickupTips)) + ] + assert len(mph_seq) >= 2 + assert isinstance(mph_seq[0], PrepCmd.MphMoveToPosition) + assert isinstance(mph_seq[1], PrepCmd.MphPickupTips) + + await p.stop() + + asyncio.run(_run()) + + +def test_pick_up_tips_pre_position_false_skips_mph_move(): + """Explicit pre_position=False sends only MphPickupTips among MPH move/pickup pair.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + await p.head8.pick_up_tips8(tip_rack.column(1), pre_position=False) + + mph_moves = [c for c in captured if isinstance(c, PrepCmd.MphMoveToPosition)] + pickups = [c for c in captured if isinstance(c, PrepCmd.MphPickupTips)] + assert mph_moves == [] + assert len(pickups) >= 1 + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_partial_channel_aspirate_raises_value_error(): + """PrepHead8 rejects aspirate8 with fewer than all 8 channels.""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + + with pytest.raises(ValueError, match="fully-ganged head"): + await p.head8.aspirate8( + wells=src_plate.column(0)[:4], + volume=10, + use_channels=(0, 1, 2, 3), + ) + + await p.stop() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# Group 3: V2 aspirate/dispense dispatch +# --------------------------------------------------------------------------- + + +def test_head8_v2_aspirate_sends_mphaspiratenolldmonitoring2(): + """Chatterbox default (use_v1=False) → V2 command class is sent.""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + + asp_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + v1_cmds = [ + c + for c in captured + if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring) + and not isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2) + ] + assert len(asp_cmds) == 1, f"Expected 1 MphAspirateNoLldMonitoring2, got {len(asp_cmds)}" + assert len(v1_cmds) == 0, "V1 aspirate command should not be sent when V2 is supported" + assert len(asp_cmds[0].aspirate_parameters) == 1, ( + "MPH sends a single struct element (probe-0 reference); firmware drives all 8 probes" + ) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_v2_dispense_sends_mphdispensetnolld2(): + """Chatterbox default (use_v1=False) → V2 dispense command class is sent.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + await p.head8.dispense8(wells=dst_plate.column(0), volume=10) + + disp_cmds = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] + v1_cmds = [ + c + for c in captured + if isinstance(c, PrepCmd.MphDispenseNoLld) and not isinstance(c, PrepCmd.MphDispenseNoLld2) + ] + assert len(disp_cmds) == 1, f"Expected 1 MphDispenseNoLld2, got {len(disp_cmds)}" + assert len(v1_cmds) == 0, "V1 dispense command should not be sent when V2 is supported" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_v1_fallback_when_use_v1_flag_set(): + """use_v1_aspirate_dispense=True → V1 command classes are sent for MPH too.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup(use_v1_aspirate_dispense=True) + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + await p.head8.dispense8(wells=dst_plate.column(0), volume=10) + + v2_asp = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + v2_disp = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] + v1_asp = [ + c + for c in captured + if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring) + and not isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2) + ] + v1_disp = [ + c + for c in captured + if isinstance(c, PrepCmd.MphDispenseNoLld) and not isinstance(c, PrepCmd.MphDispenseNoLld2) + ] + + assert len(v2_asp) == 0, "V2 aspirate should not be sent with use_v1=True" + assert len(v2_disp) == 0, "V2 dispense should not be sent with use_v1=True" + assert len(v1_asp) == 1, f"Expected 1 V1 aspirate, got {len(v1_asp)}" + assert len(v1_disp) == 1, f"Expected 1 V1 dispense, got {len(v1_disp)}" + + await p.stop() + + asyncio.run(_run()) + + +# --------------------------------------------------------------------------- +# Group 4: LLD and TADM dispatch +# --------------------------------------------------------------------------- + + +def test_head8_aspirate_tadm_sends_mphaspirate_tadm2(): + """tadm= kwargs → MphAspirateTadm2 (v2, no LLD).""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8( + wells=src_plate.column(0), + volume=10, + tadm=PrepCmd.TadmParameters.default(), + ) + + tadm_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateTadm2)] + assert len(tadm_cmds) == 1, f"Expected 1 MphAspirateTadm2, got {len(tadm_cmds)}" + no_lld_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + assert len(no_lld_cmds) == 0, "NoLldMonitoring2 should not be sent when tadm= is set" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_aspirate_clld_sends_mphaspirate_with_lld2(): + """lld_mode=CAPACITIVE → MphAspirateWithLld2 (v2, LLD, no TADM).""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8( + wells=src_plate.column(0), + volume=10, + lld_mode=LLDMode.CAPACITIVE, + ) + + lld_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateWithLld2)] + assert len(lld_cmds) == 1, f"Expected 1 MphAspirateWithLld2, got {len(lld_cmds)}" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_aspirate_lld_and_tadm_sends_mphaspirate_with_lld_tadm2(): + """lld_mode=CAPACITIVE + tadm= → MphAspirateWithLldTadm2.""" + + async def _run() -> None: + deck, tip_rack, src_plate, _ = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8( + wells=src_plate.column(0), + volume=10, + lld_mode=LLDMode.CAPACITIVE, + tadm=PrepCmd.TadmParameters.default(), + ) + + lld_tadm_cmds = [c for c in captured if isinstance(c, PrepCmd.MphAspirateWithLldTadm2)] + assert len(lld_tadm_cmds) == 1, f"Expected 1 MphAspirateWithLldTadm2, got {len(lld_tadm_cmds)}" + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_dispense_lld_pressure_raises(): + """lld_mode=PRESSURE on dispense raises ValueError — pressure LLD needs aspiration.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8(wells=src_plate.column(0), volume=10) + + with pytest.raises(ValueError, match="PRESSURE"): + await p.head8.dispense8( + wells=dst_plate.column(0), + volume=10, + lld_mode=LLDMode.PRESSURE, + ) + + await p.stop() + + asyncio.run(_run()) + + +def test_head8_command_version_override_v1(): + """command_version='v1' per-call override forces v1 even when v2 is available.""" + + async def _run() -> None: + deck, tip_rack, src_plate, dst_plate = _make_deck() + p = Prep(deck=deck, chatterbox=True) + await p.setup() + assert p.head8 is not None + + captured, _ = _record_send(p) + + spots = tip_rack.column(0) + await p.head8.pick_up_tips8(spots) + await p.head8.aspirate8( + wells=src_plate.column(0), + volume=10, + command_version="v1", + ) + await p.head8.dispense8( + wells=dst_plate.column(0), + volume=10, + command_version="v1", + ) + + v1_asp = [c for c in captured if type(c) is PrepCmd.MphAspirateNoLldMonitoring] + v2_asp = [c for c in captured if isinstance(c, PrepCmd.MphAspirateNoLldMonitoring2)] + v1_disp = [c for c in captured if type(c) is PrepCmd.MphDispenseNoLld] + v2_disp = [c for c in captured if isinstance(c, PrepCmd.MphDispenseNoLld2)] + + assert len(v1_asp) == 1, f"Expected 1 V1 aspirate with override, got {len(v1_asp)}" + assert len(v2_asp) == 0, "V2 aspirate must not be sent with command_version='v1'" + assert len(v1_disp) == 1, f"Expected 1 V1 dispense with override, got {len(v1_disp)}" + assert len(v2_disp) == 0, "V2 dispense must not be sent with command_version='v1'" + + await p.stop() + + asyncio.run(_run()) diff --git a/pylabrobot/hamilton/prep/tests/transport_tests.py b/pylabrobot/hamilton/prep/tests/transport_tests.py new file mode 100644 index 00000000000..84a37c8d892 --- /dev/null +++ b/pylabrobot/hamilton/prep/tests/transport_tests.py @@ -0,0 +1,340 @@ +"""Prep integration with real TCP sessions over framed, in-memory I/O.""" + +import asyncio +from dataclasses import FrozenInstanceError +from unittest.mock import AsyncMock, patch + +from pylabrobot.hamilton.prep import PrepChatterboxClient +from pylabrobot.hamilton.prep import prep_commands as C +from pylabrobot.hamilton.prep.channels import ChannelDriveMap, PrepChannels +from pylabrobot.hamilton.prep.client import MLPREP_OBJECT_PATH, PIPETTOR_OBJECT_PATH, PrepClient +from pylabrobot.hamilton.prep.error_tables import PREP_ERROR_CODES +from pylabrobot.hamilton.prep.info import PrepInstrumentInfo +from pylabrobot.hamilton.transport.tcp.hoi_error import HoiError +from pylabrobot.hamilton.transport.tcp.introspection import MethodInfo, ObjectInfo +from pylabrobot.hamilton.transport.tcp.messages import HoiParams +from pylabrobot.hamilton.transport.tcp.packets import Address, HarpPacket, HoiPacket +from pylabrobot.hamilton.transport.tcp.protocol import Hoi2Action +from pylabrobot.hamilton.transport.tcp.session import SessionState, TCPSession +from pylabrobot.hamilton.transport.tcp.tcp import HamiltonTCPClient +from pylabrobot.hamilton.transport.tcp.tests.tcp_tests import _MemorySocket, _response, _SessionTest +from pylabrobot.hamilton.transport.tcp.wire_types import F32, Str +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError + + +class TestPrepTransport(_SessionTest): + """Check request binding, typed responses, and failure ownership end to end.""" + + def start_session(self, client: PrepClient, address: Address) -> _MemorySocket: + """Install a fresh production session without opening a network connection.""" + io = _MemorySocket() + client._session = TCPSession(io, error_codes=PREP_ERROR_CODES) + client._session.client_address = Address(2, 1, 65535) + client._session.state = SessionState.CONNECTING + for path in (MLPREP_OBJECT_PATH, PIPETTOR_OBJECT_PATH): + client.registry.register(path, ObjectInfo(path.rsplit(".", 1)[-1], "", 0, 0, address)) + client._session.start_reader() + return io + + def make_client(self) -> tuple[PrepClient, _MemorySocket]: + """Provide a Prep client using the production reader and transaction code.""" + client = PrepClient("memory-only", 0) + io = self.start_session(client, Address(1, 1, 257)) + self.addAsyncCleanup(client.stop) + return client, io + + async def test_tip_presence_uses_each_objects_named_method_and_cached_table(self): + """Sensor IDs come from discovery; repeated reads reuse the session's method tables.""" + client, io = self.make_client() + channels = PrepChannels(client=client, info=PrepInstrumentInfo(client)) + rear, front = Address(1, 236, 514), Address(1, 237, 514) + tables = { + rear: [ + MethodInfo(3, 0, 15, "UnrelatedMethod"), + MethodInfo(4, 0, 88, "GetTipPresent"), + ], + front: [ + MethodInfo(2, 0, 21, "GetTipPresent"), + MethodInfo(3, 0, 15, "UnrelatedMethod"), + ], + } + + async def respond(request: HarpPacket) -> None: + """Only answer the discovered sensor query on its own object.""" + hoi = HoiPacket.unpack(request.payload) + ids = (4, 88) if request.dst == rear else (2, 21) + self.assertEqual((hoi.interface_id, hoi.action_id), ids) + self.assertEqual(hoi.action_code, Hoi2Action.STATUS_REQUEST) + self.assertEqual(hoi.params, b"") + io.feed( + _response( + source=request.dst, + sequence=request.seq, + action=Hoi2Action.STATUS_RESPONSE, + params=bytes.fromhex("0600040001000000" if request.dst == rear else "0600040000000000"), + ) + ) + + io.on_write = respond + with ( + patch.object( + channels, + "discover_channel_drives", + new=AsyncMock(return_value=ChannelDriveMap([rear, front], [], [])), + ), + patch.object( + client.introspection, + "get_object", + new=AsyncMock(side_effect=lambda addr: ObjectInfo("SDrive", "", 2, 0, addr)), + ), + patch.object( + client.introspection, + "get_method", + new=AsyncMock(side_effect=lambda addr, index: tables[addr][index]), + ) as get_method, + ): + self.assertEqual( + await asyncio.wait_for(channels.sense_tip_presence(), timeout=1), [True, False] + ) + self.assertEqual( + await asyncio.wait_for(channels.sense_tip_presence(), timeout=1), [True, False] + ) + self.assertEqual(get_method.await_count, 4) + self.assertEqual(len(io.writes), 4) + + async def test_tip_presence_requires_one_named_method_before_querying(self): + """Absent or ambiguous names cannot fall back to numeric sensor IDs.""" + for methods in ( + [MethodInfo(3, 0, 15, "UnrelatedMethod")], + [MethodInfo(1, 0, 15, "GetTipPresent"), MethodInfo(2, 0, 15, "GetTipPresent")], + ): + with self.subTest(methods=methods): + client, io = self.make_client() + channels = PrepChannels(client=client, info=PrepInstrumentInfo(client)) + addr = Address(1, 236, 514) + with ( + patch.object( + channels, + "discover_channel_drives", + new=AsyncMock(return_value=ChannelDriveMap([addr], [], [])), + ), + patch.object( + client.introspection, + "ensure_method_table", + new=AsyncMock(return_value=methods), + ), + ): + with self.assertRaisesRegex(RuntimeError, "GetTipPresent"): + await asyncio.wait_for(channels.sense_tip_presence(), timeout=1) + self.assertEqual(io.writes, []) + + async def test_reusable_request_rebinds_after_reconnection(self): + client, io = self.make_client() + command = C.PrepGetDefaultTraverseHeight() + for address in (Address(1, 1, 257), Address(1, 2, 300)): + if address.node == 2: + await client.stop() + io = self.start_session(client, address) + + async def respond(request: HarpPacket) -> None: + """Return the requested scalar to the current session.""" + self.assertEqual(request.dst, address) + io.feed( + _response( + source=address, + sequence=request.seq, + action=Hoi2Action.STATUS_RESPONSE, + params=HoiParams().add(180.0, F32).build(), + ) + ) + + io.on_write = respond + responses = await asyncio.gather(client.execute(command), client.execute(command)) + self.assertEqual(responses, [C.PrepGetDefaultTraverseHeight.Response(180.0)] * 2) + self.assertEqual([request.seq for request in io.writes], [1, 2]) + self.assertEqual(command, C.PrepGetDefaultTraverseHeight()) + with self.assertRaises(FrozenInstanceError): + command.dest = Address(1, 1, 999) # type: ignore[misc] + + async def test_identity_query_uses_runtime_interface_and_method(self): + client, io = self.make_client() + + async def respond(request: HarpPacket) -> None: + """Inspect the real encoded probe and return a string fragment.""" + hoi = HoiPacket.unpack(request.payload) + self.assertEqual( + (hoi.interface_id, hoi.action_id, hoi.action_code), (3, 9, Hoi2Action.STATUS_REQUEST) + ) + io.feed( + _response( + sequence=request.seq, + action=Hoi2Action.STATUS_RESPONSE, + params=HoiParams().add("PREP123", Str).build(), + ) + ) + + io.on_write = respond + self.assertEqual(await client._query_firmware_string(Address(1, 1, 257), 9), "PREP123") + + async def test_execute_raises_firmware_error_and_exchange_preserves_frame(self): + client, io = self.make_client() + payload = HoiParams().add("0x0001.0x0001.0x0101:0x01,0x0006,0x0F08", Str).build() + + async def respond(request: HarpPacket) -> None: + """Return an error without accepting any diagnostic round trips.""" + io.feed(_response(sequence=request.seq, action=Hoi2Action.COMMAND_EXCEPTION, params=payload)) + + io.on_write = respond + with self.assertRaises(HoiError) as caught: + await client.execute(C.PrepPark()) + self.assertEqual(caught.exception.raw_response, payload) + self.assertEqual(len(io.writes), 1) + raw = await client.exchange(C.PrepPark()) + self.assertEqual(raw.hoi.params, payload) + self.assertEqual(raw.hoi.action_code, Hoi2Action.COMMAND_EXCEPTION) + self.assertEqual(client.connection_info.state, SessionState.READY) + + async def test_errors_follow_selected_channels_instead_of_firmware_ordinals(self): + client, io = self.make_client() + command = C.PrepDispenseInitToWaste( + waste_parameters=[ + C.DispenseInitToWasteParameters(False, C.ChannelIndex.FrontChannel, 12.5, 12.5, 12.5), + ] + ) + payload = HoiParams().add("0x0001.0x0001.0x0101:0x01,0x0006,0x0F08", Str).build() + + async def respond(request: HarpPacket) -> None: + """Attribute the first firmware result to the selected front channel.""" + io.feed(_response(sequence=request.seq, action=Hoi2Action.COMMAND_EXCEPTION, params=payload)) + + io.on_write = respond + with self.assertRaises(ChannelizedError) as caught: + await client.execute(command) + self.assertEqual(set(caught.exception.errors), {1}) + + async def test_cancellation_prevents_further_prep_queries(self): + client, io = self.make_client() + task = asyncio.create_task(client.execute(C.PrepGetDefaultTraverseHeight())) + await self.wait_until(lambda: len(io.writes) == 1) + task.cancel() + with self.assertRaises(asyncio.CancelledError): + await task + self.assertEqual(client.connection_info.state, SessionState.UNCERTAIN) + with self.assertRaises(ConnectionError): + await client.execute(C.PrepPark()) + self.assertEqual(len(io.writes), 1) + + async def test_failed_prep_identity_check_closes_the_session(self): + client, io = self.make_client() + with ( + patch.object(HamiltonTCPClient, "setup", new=AsyncMock()), + patch.object(client, "discovered_root_name", new=AsyncMock(return_value="WrongRoot")), + ): + with self.assertRaisesRegex(RuntimeError, "Wrong instrument"): + await client.setup() + self.assertTrue(io.closed) + self.assertEqual(client.connection_info.state, SessionState.CLOSED) + + async def test_chatterbox_decodes_queries_and_invalidates_retained_discovery(self): + client = PrepChatterboxClient() + await client.setup() + self.addAsyncCleanup(client.stop) + intro = client.introspection + address = await client.resolve_path(PIPETTOR_OBJECT_PATH) + result = await client.execute(C.PrepGetIsInitialized(dest=client.mlprep_address)) + self.assertIsInstance(result, C.PrepGetIsInitialized.Response) + frame = await client.exchange(C.PrepPark()) + self.assertEqual(frame.harp.action_code, 4) + await client.stop() + await client.setup() + with self.assertRaises(ConnectionError): + await intro.methods_for_interface(address, 1) + + def test_nested_move_payload_matches_firmware_bytes(self): + command = C.PrepMoveToPosition( + move_parameters=C.GantryMoveXYZParameters( + default_values=True, + gantry_x_position=12.5, + axis_parameters=[C.ChannelYZMoveParameters(True, C.ChannelIndex.RearChannel, 12.5, 12.5)], + ) + ) + self.assertEqual( + command.build_parameters().build(), + bytes.fromhex( + "1e00340017010200010028000400000048411f0022001e001e00" + "170102000100200004000200000028000400000048412800040000004841" + ), + ) + + async def test_channel_bounds_decode_nested_structures_in_channel_order(self): + from pylabrobot.hamilton.prep.channels import request_channel_bounds + + client, io = self.make_client() + client.registry.register( + C.PrepGetChannelBounds.firmware_path, + ObjectInfo("PipettorService", "", 0, 0, Address(1, 1, 257)), + ) + # GetChannelBounds response from MLPrep Runtime V3.0.20.675 (PRPBD1394). + payload = bytes.fromhex( + "1f0078001e003800200004000100000028000400c095033f28000400cbc19543" + "28000400000010c1280004000000bc432800040000009c412800040000802743" + "1e003800200004000200000028000400c095033f28000400cbc1954328000400" + "00000000280004000080c0432800040000009c412800040000802743" + ) + + async def respond(request: HarpPacket) -> None: + """Return a real structure array in firmware channel order.""" + io.feed( + _response( + sequence=request.seq, + action=Hoi2Action.STATUS_RESPONSE, + params=payload, + ) + ) + + io.on_write = respond + bounds = await request_channel_bounds(client) + self.assertEqual( + bounds, + [ + dict( + x_min=0.5140037536621094, + x_max=299.5140075683594, + y_min=0, + y_max=385, + z_min=19.5, + z_max=167.5, + ), + dict( + x_min=0.5140037536621094, + x_max=299.5140075683594, + y_min=-9, + y_max=376, + z_min=19.5, + z_max=167.5, + ), + ], + ) + + async def test_reconnection_during_path_resolution_cannot_send_an_old_address(self): + for raw in (False, True): + client, io = self.make_client() + fresh_sockets: list[_MemorySocket] = [] + + async def resolve(path: str) -> Address: + """Replace the session while a firmware-path lookup is in flight.""" + await client.stop() + fresh = self.start_session(client, Address(1, 2, 300)) + fresh.on_write = AsyncMock(side_effect=AssertionError("stale address reached new session")) + fresh_sockets.append(fresh) + return Address(1, 1, 257) + + client.resolve_path = resolve # type: ignore[method-assign] + with self.assertRaises(ConnectionError): + if raw: + await client.exchange(C.PrepPark()) + else: + await client.execute(C.PrepPark()) + self.assertTrue(io.closed) + self.assertEqual(fresh_sockets[0].writes, []) + self.assertEqual(client.connection_info.state, SessionState.READY) diff --git a/pylabrobot/hamilton/tests/__init__.py b/pylabrobot/hamilton/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py b/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py new file mode 100644 index 00000000000..6c6adb72e57 --- /dev/null +++ b/pylabrobot/hamilton/tests/liquid_class_resolver_tests.py @@ -0,0 +1,110 @@ +"""Tests for :mod:`liquid_class_resolver`.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, Dict + +import pytest + +from pylabrobot.hamilton.liquid_class_resolver import ( + corrected_volumes_for_ops, + resolve_hamilton_liquid_classes, +) +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import HamiltonLiquidClass +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.star import get_star_liquid_class +from pylabrobot.resources.hamilton import HamiltonTip, TipPickupMethod, TipSize +from pylabrobot.resources.liquid import Liquid + + +def _hlc(**overrides: Any) -> HamiltonLiquidClass: + base: Dict[str, Any] = dict( + curve={0.0: 0.0, 1000.0: 1000.0}, + aspiration_flow_rate=1.0, + aspiration_mix_flow_rate=2.0, + aspiration_air_transport_volume=3.0, + aspiration_blow_out_volume=4.0, + aspiration_swap_speed=5.0, + aspiration_settling_time=6.0, + aspiration_over_aspirate_volume=7.0, + aspiration_clot_retract_height=8.0, + dispense_flow_rate=9.0, + dispense_mode=0.0, + dispense_mix_flow_rate=10.0, + dispense_air_transport_volume=11.0, + dispense_blow_out_volume=12.0, + dispense_swap_speed=13.0, + dispense_settling_time=14.0, + dispense_stop_flow_rate=15.0, + dispense_stop_back_volume=16.0, + ) + base.update(overrides) + return HamiltonLiquidClass(**base) + + +def test_resolve_explicit_returns_copy(): + h = _hlc() + out = resolve_hamilton_liquid_classes([h], [], jet=False, blow_out=False) + assert out == [h] + out[0] = None # type: ignore[assignment] + assert h is not None + + +def test_resolve_auto_non_hamilton_tip_is_none(): + op = SimpleNamespace(tip=object()) + assert resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False) == [None] + + +def test_resolve_auto_hamilton_tip_matches_get_star(): + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + op = SimpleNamespace(tip=tip) + a = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False)[0] + b = get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + assert a is not None and b is not None + assert a.aspiration_flow_rate == b.aspiration_flow_rate + + +def test_resolve_custom_lookup(): + custom = _hlc(aspiration_flow_rate=99.0) + + def lookup(**kwargs): # noqa: ARG001 + return custom + + tip = HamiltonTip( + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + op = SimpleNamespace(tip=tip) + got = resolve_hamilton_liquid_classes(None, [op], jet=False, blow_out=False, lookup=lookup)[0] + assert got is not None + assert got.aspiration_flow_rate == 99.0 + + +def test_corrected_volumes_respects_disable_and_none_hlc(): + ops = [SimpleNamespace(volume=100.0)] + hlc = _hlc(curve={0.0: 0.0, 100.0: 200.0, 200.0: 400.0}) + assert corrected_volumes_for_ops(ops, [hlc], None) == [200.0] + assert corrected_volumes_for_ops(ops, [hlc], [True]) == [100.0] + assert corrected_volumes_for_ops(ops, [None], None) == [100.0] + + +def test_corrected_volumes_length_mismatch_raises(): + with pytest.raises(ValueError, match="hlcs length"): + corrected_volumes_for_ops([SimpleNamespace(volume=1.0)], []) diff --git a/pylabrobot/hamilton/transport/tcp/introspection.py b/pylabrobot/hamilton/transport/tcp/introspection.py index f56a58b51cc..455429f2d44 100644 --- a/pylabrobot/hamilton/transport/tcp/introspection.py +++ b/pylabrobot/hamilton/transport/tcp/introspection.py @@ -1451,6 +1451,22 @@ async def methods_for_interface( table = await self.ensure_method_table(addr) return [m for m in table if m.interface_id == interface_id] + async def get_method_by_name(self, address: Union[Address, str], name: str) -> MethodInfo: + """Resolve one named method on an object using the session's cached method table. + + Args: + address: Object address or firmware-tree path. + name: Exact firmware method name. + + Raises: + RuntimeError: The name is absent or appears in more than one interface. + """ + methods = await self.ensure_method_table(address) + matches = [method for method in methods if method.name == name] + if len(matches) != 1: + raise RuntimeError(f"Expected one {name!r} method on {address}, found {len(matches)}.") + return matches[0] + async def ensure_structs_enums(self, address: Union[Address, str], interface_id: int) -> None: """Run GetStructs/GetEnums for one HO interface and cache under ``(address, interface_id)``.""" self._executor.require_active() diff --git a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py index 8e6907bdf2a..daf7e60cee0 100644 --- a/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py +++ b/pylabrobot/hamilton/transport/tcp/tests/tcp_tests.py @@ -1439,6 +1439,28 @@ async def test_second_ensure_method_table_skips_get_method(self): self.assertEqual(gm.call_count, 2) r3 = await self.intro.ensure_method_table(self.addr) self.assertIs(r1, r3) + self.assertIs(await self.intro.get_method_by_name(self.addr, "a"), r1[0]) + self.assertIs(await self.intro.get_method_by_name(self.addr, "b"), r1[1]) + self.assertEqual(gm.call_count, 2) + + async def test_named_method_lookup_accepts_paths_and_requires_unique_names(self): + """Named lookup is scoped to an object and rejects missing or ambiguous methods.""" + info = ObjectInfo("Sensor", "", 3, 0, self.addr) + self.intro._registry.register("Root.Sensor", info) + method = MethodInfo(7, 0, 42, "ReadState") + self.intro.get_object = AsyncMock(return_value=info) # type: ignore[method-assign] + self.intro.get_method = AsyncMock( # type: ignore[method-assign] + side_effect=[ + method, + MethodInfo(2, 0, 13, "ReadVersion"), + MethodInfo(4, 0, 9, "ReadVersion"), + ] + ) + self.assertIs(await self.intro.get_method_by_name("Root.Sensor", "ReadState"), method) + for name, count in (("Missing", 0), ("ReadVersion", 2)): + with self.subTest(name=name): + with self.assertRaisesRegex(RuntimeError, f"{name!r}.*found {count}"): + await self.intro.get_method_by_name(self.addr, name) async def test_lazy_signature_loads_only_referenced_iface(self): st = StructInfo(struct_id=0, name="TipParams", fields={}, interface_id=1) diff --git a/pylabrobot/resources/__init__.py b/pylabrobot/resources/__init__.py index ca92f0edc5b..449326eceb0 100644 --- a/pylabrobot/resources/__init__.py +++ b/pylabrobot/resources/__init__.py @@ -40,6 +40,19 @@ from .powder import Powder from .resource import Resource from .resource_stack import ResourceStack +from .resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + all_channels_succeeded, + finalize_tip_ops, + finalize_volume_ops, + place_resource, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) from .revvity import * from .rotation import Rotation from .sergi import * diff --git a/pylabrobot/resources/hamilton/__init__.py b/pylabrobot/resources/hamilton/__init__.py index 8bcc28e3f86..1fc362db6ef 100644 --- a/pylabrobot/resources/hamilton/__init__.py +++ b/pylabrobot/resources/hamilton/__init__.py @@ -1,12 +1,15 @@ from .hamilton_decks import ( + HamiltonCoreGrippers, HamiltonDeck, HamiltonSTARDeck, + PrepDeck, STARDeck, STARLetDeck, + prep_core_gripper_mount, ) from .mfx_carriers import * from .mfx_modules import * -from .nimbus_decks import NimbusDeck +from .nimbus_decks import NimbusDeck, nimbus_core_gripper_1000ul_at_waste from .plate_adapters import * from .plate_carriers import * from .tip_carriers import * diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 9d81dbbf25b..d1d3b5ea6ef 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -2,13 +2,16 @@ import logging from abc import ABCMeta, abstractmethod -from typing import Literal, Optional, cast +from typing import List, Literal, Optional, cast from pylabrobot.resources.carrier import ResourceHolder from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.deck import Deck from pylabrobot.resources.errors import NoLocationError -from pylabrobot.resources.hamilton.tip_creators import hamilton_teaching_needle_300uL +from pylabrobot.resources.hamilton.tip_creators import ( + hamilton_teaching_needle_300uL, + hamilton_tip_300uL_filter, +) from pylabrobot.resources.resource import Resource from pylabrobot.resources.tip_rack import TipRack, TipSpot from pylabrobot.resources.trash import Trash @@ -389,6 +392,24 @@ def serialize(self): } +def prep_core_gripper_mount() -> HamiltonCoreGrippers: + """CORE gripper mount for PREP decks. Assign at Coordinate(290, 266.5, 62). + + Physical rear paddle at (290, 257.5, 62), front at (290, 275.5, 62). + front_channel_y_center / back_channel_y_center are named for the PREP command + (front_channel_position_y, rear_channel_position_y) so the correct paddle is used. + """ + return HamiltonCoreGrippers( + name="core_grippers", + back_channel_y_center=9.0, + front_channel_y_center=-9.0, + size_x=20.0, + size_y=20.0, + size_z=24.0, + model="prep_core_gripper_mount", + ) + + def hamilton_core_gripper_1000ul_at_waste() -> HamiltonCoreGrippers: # inner hole diameter is 8.6mm # distance from base of rack to outer base of containers: -7mm @@ -617,3 +638,85 @@ def STARDeck( with_teaching_rack=with_teaching_rack, core_grippers=core_grippers, ) + + +class PrepDeck(Deck): + """Hamilton PREP deck: labware spots, trash, teaching tip site, and waste positions. + + Geometry aligns with the prep_tcp / MLPrep DeckConfiguration teaching site and waste + sites used by :class:`~pylabrobot.hamilton.prep.channels.PrepChannels` + (``waste_rear``, ``waste_front``, ``waste_mph``). Validate coordinates on hardware + (plastic mounts, calibration) before production use. + + This is **not** a :class:`HamiltonSTARDeck` (rails/teaching rack layout differ). + """ + + def __init__( + self, + name: str = "deck", + size_x: float = 300.0, + size_y: float = 394.0, + size_z: float = 0, + origin: Coordinate = Coordinate.zero(), + category: str = "deck", + with_core_grippers: bool = False, + ): + super().__init__( + name=name, size_x=size_x, size_y=size_y, size_z=size_z, origin=origin, category=category + ) + if with_core_grippers: + self.assign_child_resource(prep_core_gripper_mount(), location=Coordinate(290, 266.5, 62.5)) + spots_list: List[ResourceHolder] = [] + for column in range(2): + for row in range(4): + x = column * 140 + y = row * 95.125 + spot = ResourceHolder( + name=f"spot_{column}_{row}", + size_x=127.76, + size_y=92, + size_z=12.5, + child_location=Coordinate( + 0, 1.5, 3.75 + ), # Adjusted for plastic corner mounts; validate on hardware + ) + self.assign_child_resource(spot, location=Coordinate(x, y, 0)) + spots_list.append(spot) + self.spots: List[ResourceHolder] = spots_list + + trash = Trash(name="trash", size_x=13, size_y=132.7, size_z=73) + self.assign_child_resource(trash, location=Coordinate(280.3, -3, 0)) + + teaching_tip_spot = TipSpot( + name="teaching_tip", + size_x=6.0, + size_y=6.0, + make_tip=hamilton_tip_300uL_filter, + size_z=0.0, + category="teaching_tip", + ) + self.assign_child_resource( + teaching_tip_spot, + location=Coordinate(x=284.76, y=214.29, z=23.85), + ) + + for waste_name, y_pos in [("waste_rear", 30.0), ("waste_front", 10.0), ("waste_mph", 112.0)]: + waste = Trash( + name=waste_name, + size_x=6.0, + size_y=6.0, + size_z=0.0, + category="waste_position", + ) + self.assign_child_resource( + waste, + location=Coordinate(x=286.8, y=y_pos, z=68.4), + ) + + def __getitem__(self, key: int) -> ResourceHolder: + """Labware spot by index 0–7 (column-major: ``spot_0_0`` … ``spot_1_3``).""" + return self.spots[key] + + def __setitem__(self, key: int, value: Resource): + """Assign a resource to labware spot ``key`` (0–7).""" + self.spots[key].assign_child_resource(value) diff --git a/pylabrobot/resources/hamilton/nimbus_decks.py b/pylabrobot/resources/hamilton/nimbus_decks.py index 00b90c2cbac..7221a2357ee 100644 --- a/pylabrobot/resources/hamilton/nimbus_decks.py +++ b/pylabrobot/resources/hamilton/nimbus_decks.py @@ -12,7 +12,10 @@ from typing import Any, Dict, List, Literal, Optional from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.hamilton.hamilton_decks import HamiltonDeck +from pylabrobot.resources.hamilton.hamilton_decks import ( + HamiltonCoreGrippers, + HamiltonDeck, +) from pylabrobot.resources.resource import Resource from pylabrobot.resources.trash import Trash from pylabrobot.serializer import serialize @@ -20,6 +23,25 @@ logger = logging.getLogger(__name__) +def nimbus_core_gripper_1000ul_at_waste() -> HamiltonCoreGrippers: + """CORE gripper rack for Nimbus decks, co-located with the waste block. + + Derived from measured Hamilton coordinates on the default Nimbus8 deck: + Front paddle (ch_last): Ham(557.352, -293.030, 147.559) → PLR y = 70.800 + Back paddle (ch1): Ham(557.352, -263.820, 147.559) → PLR y = 100.010 + Resource center placed at PLR(708.862, 85.405, 147.559). + """ + return HamiltonCoreGrippers( + name="core_grippers", + back_channel_y_center=14.605, + front_channel_y_center=-14.605, + size_x=20.0, + size_y=30.0, + size_z=25.0, + model="nimbus_core_gripper_1000ul_at_waste", + ) + + class NimbusDeck(HamiltonDeck): """Hamilton Nimbus deck. @@ -45,6 +67,7 @@ def __init__( category: str = "deck", origin: Coordinate = Coordinate.zero(), waste_type: Optional[Literal["default_long"]] = "default_long", + core_grippers: Optional[Literal["1000uL-at-waste"]] = "1000uL-at-waste", ) -> None: """Create a new Nimbus deck. @@ -68,6 +91,10 @@ def __init__( origin: PyLabRobot origin coordinate (default: Coordinate.zero()) waste_type: Waste configuration type (default: "default_long"). If "default_long", creates a waste block with 8 channel positions. If None, no waste is created. + core_grippers: CORE gripper rack type (default: "1000uL-at-waste"). If + "1000uL-at-waste", assigns the gripper rack resource at the waste block + using the standard Nimbus8 paddle positions. Requires waste_type="default_long". + If None, no gripper resource is created. """ super().__init__( num_rails=num_rails, @@ -94,10 +121,13 @@ def __init__( # Store waste type for waste position lookup self.waste_type = waste_type + self.core_grippers_type = core_grippers # Create waste resources if specified if waste_type == "default_long": self._create_default_long_waste() + if core_grippers == "1000uL-at-waste": + self._create_core_grippers() def _create_default_long_waste(self) -> None: """Create default_long waste block with 8 channel positions. @@ -164,6 +194,22 @@ def _create_default_long_waste(self) -> None: # Assign waste position to waste block waste_block.assign_child_resource(waste_position, location=pos_plr_rel) + def _create_core_grippers(self) -> None: + """Assign CORE gripper rack to the waste block at the standard Nimbus8 paddle position.""" + waste_block = self.get_resource("default_long_block") + waste_loc = waste_block.get_location_wrt(self) + + # Center of the two paddles in Hamilton coordinates, converted to PLR + center_ham = Coordinate(x=557.352, y=(-293.030 + -263.820) / 2, z=147.559) + center_plr = self.from_hamilton_coordinate(center_ham) + + rel = Coordinate( + x=center_plr.x - waste_loc.x, + y=center_plr.y - waste_loc.y, + z=center_plr.z - waste_loc.z, + ) + waste_block.assign_child_resource(nimbus_core_gripper_1000ul_at_waste(), location=rel) + def rails_to_location(self, rails: int) -> Coordinate: """Convert a rail identifier to an absolute (x, y, z) coordinate. @@ -289,6 +335,7 @@ def serialize(self) -> dict: "rail_width": self._rail_width, "rail_y": self._rail_y, "waste_type": self.waste_type, + "core_grippers": None, # encoded as child resource; prevent double-creation on deserialize } @classmethod @@ -308,15 +355,16 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> "NimbusDeck": """ data_copy = data.copy() original_waste_type = data_copy.get("waste_type") - # Set waste_type=None to prevent __init__() from creating waste block - # The waste block will come from children data (already serialized) + original_core_grippers = data_copy.get("core_grippers") + # Suppress creation of waste/gripper resources in __init__; children carry the serialized data data_copy["waste_type"] = None + data_copy["core_grippers"] = None - # Call parent deserialize (waste block won't be created in __init__) deck = super().deserialize(data_copy, allow_marshal=allow_marshal) - # Restore waste_type attribute from serialized data to keep instance consistent + # Restore type attributes so the instance stays consistent with what was serialized deck.waste_type = original_waste_type + deck.core_grippers_type = original_core_grippers return deck @@ -338,6 +386,7 @@ def from_files( rail_width: Optional[float] = None, rail_y: Optional[float] = None, waste_type: Optional[Literal["default_long"]] = None, + core_grippers: Optional[Literal["1000uL-at-waste"]] = None, ) -> NimbusDeck: """Create a Nimbus deck by parsing config files. @@ -630,4 +679,5 @@ def extract_dck_exsite_ids(layout_num: int) -> List[str]: rail_y=rail_y_val, origin=origin, waste_type=waste_type, + core_grippers=core_grippers, ) diff --git a/pylabrobot/resources/hamilton/tip_carriers.py b/pylabrobot/resources/hamilton/tip_carriers.py index fc06281825f..a19ea670c7b 100644 --- a/pylabrobot/resources/hamilton/tip_carriers.py +++ b/pylabrobot/resources/hamilton/tip_carriers.py @@ -352,3 +352,20 @@ def TIP_CAR_NTR_A00(name: str) -> TipCarrier: ), model="TIP_CAR_NTR_A00", ) + + +def hamilton_prep_ftr_pedestal(name: str) -> TipCarrier: + """Hamilton cat. no.: 6600553-01 + Pedestal for elevating fixed tip racks (FTR) on the MicroLab Prep. + Body: 133.11 x 89.96 x 53.37 mm. FTR rack seats on top of the pedestal. + """ + site = ResourceHolder(name=f"{name}-0", size_x=122.4, size_y=82.6, size_z=0) + site.location = Coordinate(1.5, 1, 53.37) + return TipCarrier( + name=name, + size_x=133.11, + size_y=89.96, + size_z=53.37, + sites={0: site}, + model="hamilton_prep_ftr_pedestal", + ) diff --git a/pylabrobot/resources/resource_state.py b/pylabrobot/resources/resource_state.py new file mode 100644 index 00000000000..ee1c480cdc8 --- /dev/null +++ b/pylabrobot/resources/resource_state.py @@ -0,0 +1,150 @@ +"""Shared tip / volume / deck state helpers for device peers. + +Devices adapt instrument outcomes into :data:`ChannelSuccesses` / bools, then call +these helpers. No vendor or transport imports — safe for Prep, Nimbus, and a future +LiquidHandler to share. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Collection, Literal, Mapping, Optional, Sequence, Union + +from pylabrobot.resources.container import Container +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker, does_tip_tracking +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.volume_tracker import does_volume_tracking + +ChannelSuccesses = Mapping[int, bool] + + +def all_channels_succeeded(use_channels: Sequence[int]) -> dict[int, bool]: + return {ch: True for ch in use_channels} + + +def successes_from_failed_channels( + use_channels: Sequence[int], + failed: Collection[int], +) -> dict[int, bool]: + failed_set = set(failed) + return {ch: ch not in failed_set for ch in use_channels} + + +@dataclass(frozen=True) +class TipPickupIntent: + channel: int + tip_spot: TipSpot + tip: Tip + channel_tracker: TipTracker + + +@dataclass(frozen=True) +class TipDropIntent: + channel: int + destination: Union[TipSpot, Trash] + tip: Tip + channel_tracker: TipTracker + + +@dataclass(frozen=True) +class VolumeTransferIntent: + channel: int + container: Container + tip: Tip + volume_ul: float + direction: Literal["aspirate", "dispense"] + + +def queue_tip_pickups(intents: Sequence[TipPickupIntent]) -> None: + """Queue spot remove + channel add (commit=False). Spot ops gated by tip tracking.""" + for intent in intents: + if intent.channel_tracker.has_tip: + raise RuntimeError(f"Channel {intent.channel} already has a tip") + if does_tip_tracking() and not intent.tip_spot.tracker.is_disabled: + intent.tip_spot.tracker.remove_tip(commit=False) + intent.channel_tracker.add_tip(intent.tip, origin=intent.tip_spot, commit=False) + + +def queue_tip_drops(intents: Sequence[TipDropIntent]) -> None: + """Queue channel remove; TipSpot destinations get the tip back. Trash: channel only.""" + for intent in intents: + if not intent.tip.tracker.is_disabled and intent.tip.tracker.get_used_volume() > 1e-6: + raise RuntimeError( + f"Cannot drop tip on channel {intent.channel} with volume " + f"{intent.tip.tracker.get_used_volume()} uL" + ) + if not intent.channel_tracker.has_tip: + raise RuntimeError(f"Channel {intent.channel} has no tip to drop") + intent.channel_tracker.remove_tip(commit=False) + if isinstance(intent.destination, TipSpot): + if does_tip_tracking() and not intent.destination.tracker.is_disabled: + intent.destination.tracker.add_tip(intent.tip, origin=None, commit=False) + + +def finalize_tip_ops( + intents: Sequence[Union[TipPickupIntent, TipDropIntent]], + successes: ChannelSuccesses, +) -> None: + for intent in intents: + ok = successes.get(intent.channel, False) + if isinstance(intent, TipPickupIntent): + if does_tip_tracking() and not intent.tip_spot.tracker.is_disabled: + (intent.tip_spot.tracker.commit if ok else intent.tip_spot.tracker.rollback)() + (intent.channel_tracker.commit if ok else intent.channel_tracker.rollback)() + else: + (intent.channel_tracker.commit if ok else intent.channel_tracker.rollback)() + if isinstance(intent.destination, TipSpot): + if does_tip_tracking() and not intent.destination.tracker.is_disabled: + (intent.destination.tracker.commit if ok else intent.destination.tracker.rollback)() + + +def queue_volume_transfers(intents: Sequence[VolumeTransferIntent]) -> None: + if not does_volume_tracking(): + return + for intent in intents: + if intent.direction == "aspirate": + if not intent.container.tracker.is_disabled: + intent.container.tracker.remove_liquid(intent.volume_ul) + if not intent.tip.tracker.is_disabled: + intent.tip.tracker.add_liquid(intent.volume_ul) + else: + if not intent.tip.tracker.is_disabled: + intent.tip.tracker.remove_liquid(intent.volume_ul) + if not intent.container.tracker.is_disabled: + intent.container.tracker.add_liquid(intent.volume_ul) + + +def finalize_volume_ops( + intents: Sequence[VolumeTransferIntent], + successes: ChannelSuccesses, +) -> None: + if not does_volume_tracking(): + return + for intent in intents: + ok = successes.get(intent.channel, False) + if not intent.container.tracker.is_disabled: + (intent.container.tracker.commit if ok else intent.container.tracker.rollback)() + if not intent.tip.tracker.is_disabled: + (intent.tip.tracker.commit if ok else intent.tip.tracker.rollback)() + + +def place_resource( + resource: Resource, + destination: Resource, + *, + location: Optional[Coordinate] = None, +) -> None: + """Reassign ``resource`` under ``destination`` after a successful place.""" + destination.check_can_drop_resource_here(resource) + resource.unassign() + if isinstance(destination, ResourceHolder): + destination.assign_child_resource(resource, location=location) + else: + destination.assign_child_resource( + resource, location=location if location is not None else Coordinate.zero() + ) diff --git a/pylabrobot/resources/resource_state_tests.py b/pylabrobot/resources/resource_state_tests.py new file mode 100644 index 00000000000..e53e7672087 --- /dev/null +++ b/pylabrobot/resources/resource_state_tests.py @@ -0,0 +1,157 @@ +"""Tests for shared tip / volume / deck resource state helpers.""" + +from __future__ import annotations + +import unittest + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.resource_state import ( + TipDropIntent, + TipPickupIntent, + VolumeTransferIntent, + finalize_tip_ops, + finalize_volume_ops, + place_resource, + queue_tip_drops, + queue_tip_pickups, + queue_volume_transfers, + successes_from_failed_channels, +) +from pylabrobot.resources.tip import Tip +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.tip_tracker import TipTracker, set_tip_tracking +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.volume_tracker import set_volume_tracking +from pylabrobot.resources.well import Well, WellBottomType + + +def _tip(name: str = "t") -> Tip: + return Tip( + has_filter=False, + total_tip_length=50, + maximal_volume=200, + fitting_depth=10, + name=name, + ) + + +def _spot(name: str = "spot") -> TipSpot: + spot = TipSpot(name=name, size_x=9, size_y=9, size_z=0, make_tip=_tip) + spot.tracker.add_tip(spot.make_tip(), origin=spot, commit=True) + return spot + + +class TestResourceStateTips(unittest.TestCase): + def setUp(self) -> None: + set_tip_tracking(True) + set_volume_tracking(False) + + def tearDown(self) -> None: + set_tip_tracking(False) + set_volume_tracking(False) + + def test_pickup_commit_clears_spot_and_mounts_channel(self) -> None: + spot = _spot() + channel = TipTracker(thing="ch0") + tip = spot.get_tip() + intents = [TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel)] + queue_tip_pickups(intents) + finalize_tip_ops(intents, {0: True}) + self.assertFalse(spot.has_tip()) + self.assertTrue(channel.has_tip) + self.assertIs(channel.get_tip(), tip) + + def test_pickup_rollback_restores_spot(self) -> None: + spot = _spot() + channel = TipTracker(thing="ch0") + tip = spot.get_tip() + intents = [TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel)] + queue_tip_pickups(intents) + finalize_tip_ops(intents, {0: False}) + self.assertTrue(spot.has_tip()) + self.assertFalse(channel.has_tip) + + def test_drop_to_spot_and_trash(self) -> None: + spot = _spot("src") + dest = TipSpot(name="dest", size_x=9, size_y=9, size_z=0, make_tip=_tip) + trash = Trash(name="trash", size_x=10, size_y=10, size_z=10) + channel = TipTracker(thing="ch0") + tip = spot.get_tip() + pick = [TipPickupIntent(channel=0, tip_spot=spot, tip=tip, channel_tracker=channel)] + queue_tip_pickups(pick) + finalize_tip_ops(pick, {0: True}) + + drop_spot = [TipDropIntent(channel=0, destination=dest, tip=tip, channel_tracker=channel)] + queue_tip_drops(drop_spot) + finalize_tip_ops(drop_spot, {0: True}) + self.assertTrue(dest.has_tip()) + self.assertFalse(channel.has_tip) + + tip2 = dest.get_tip() + pick2 = [TipPickupIntent(channel=0, tip_spot=dest, tip=tip2, channel_tracker=channel)] + queue_tip_pickups(pick2) + finalize_tip_ops(pick2, {0: True}) + drop_trash = [TipDropIntent(channel=0, destination=trash, tip=tip2, channel_tracker=channel)] + queue_tip_drops(drop_trash) + finalize_tip_ops(drop_trash, {0: True}) + self.assertFalse(channel.has_tip) + self.assertFalse(dest.has_tip()) + + def test_successes_from_failed_channels(self) -> None: + self.assertEqual( + successes_from_failed_channels([0, 1], {1: Exception("x")}), + {0: True, 1: False}, + ) + + +class TestResourceStateVolume(unittest.TestCase): + def setUp(self) -> None: + set_volume_tracking(True) + set_tip_tracking(False) + + def tearDown(self) -> None: + set_volume_tracking(False) + set_tip_tracking(False) + + def test_aspirate_commit(self) -> None: + well = Well( + name="w", + size_x=9, + size_y=9, + size_z=10, + bottom_type=WellBottomType.FLAT, + max_volume=200, + ) + well.tracker.set_volume(100) + tip = _tip() + intents = [ + VolumeTransferIntent( + channel=0, + container=well, + tip=tip, + volume_ul=25, + direction="aspirate", + ) + ] + queue_volume_transfers(intents) + finalize_volume_ops(intents, {0: True}) + self.assertAlmostEqual(well.tracker.get_used_volume(), 75) + self.assertAlmostEqual(tip.tracker.get_used_volume(), 25) + + +class TestPlaceResource(unittest.TestCase): + def test_place_onto_holder(self) -> None: + holder_a = ResourceHolder(name="a", size_x=100, size_y=100, size_z=10) + holder_b = ResourceHolder(name="b", size_x=100, size_y=100, size_z=10) + plate = Resource(name="p", size_x=127, size_y=85, size_z=14) + holder_a.assign_child_resource(plate) + place_resource(plate, holder_b) + self.assertIs(holder_b.resource, plate) + self.assertIsNone(holder_a.resource) + self.assertEqual(plate.location, Coordinate.zero()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/resources/tip_tracker.py b/pylabrobot/resources/tip_tracker.py index 1a876ce86f5..4b1ee76b92c 100644 --- a/pylabrobot/resources/tip_tracker.py +++ b/pylabrobot/resources/tip_tracker.py @@ -57,15 +57,15 @@ def has_tip(self) -> bool: return self._pending_tip is not None def get_tip(self) -> "Tip": - """Get the tip. Note that does includes pending operations. + """Get the tip. Note that this includes pending operations. Raises: NoTipError: If the tip spot does not have a tip. """ - if self._tip is None: + if self._pending_tip is None: raise NoTipError(f"{self.thing} does not have a tip.") - return self._tip + return self._pending_tip def disable(self) -> None: """Disable the tip tracker."""