diff --git a/.vscode/settings.json b/.vscode/settings.json index 81e34d8d2ed..3d7ab6358db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,17 +1,52 @@ { "editor.tabSize": 2, "files.trimTrailingWhitespace": true, + "cSpell.words": [ + "agrow", + "agrowpumps", + "coro", + "cytomat", + "decalibrate", + "Defaultable", + "Deprecated", + "frontmost", + "hepa", + "Inheco", + "iswap", + "jsonify", + "klass", + "labware", + "modbus", + "pylabrobot", + "pytest", + "subclassing", + "subresource", + "tadm", + "tiprack", + "usascientific", + "websockets" + ], "python.linting.mypyEnabled": true, "python.linting.enabled": true, - "python.testing.pytestArgs": ["."], + "python.testing.pytestArgs": [ + "." + ], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python.testing.autoTestDiscoverOnSaveEnabled": true, - "editor.rulers": [100], + "editor.rulers": [ + 100 + ], "files.exclude": { "**/__pycache__": true }, - "python.testing.unittestArgs": ["-v", "-s", ".", "-p", "*_test.py"], + "python.testing.unittestArgs": [ + "-v", + "-s", + ".", + "-p", + "*_test.py" + ], "[markdown]": { "editor.wordWrap": "bounded", "editor.wordWrapColumn": 100 @@ -21,4 +56,4 @@ "editor.wordWrapColumn": 100 }, "mypy.runUsingActiveInterpreter": true -} +} \ No newline at end of file diff --git a/docs/user_guide/00_liquid-handling/_liquid-handling.rst b/docs/user_guide/00_liquid-handling/_liquid-handling.rst index 32ea5d7d564..005fb12abbc 100644 --- a/docs/user_guide/00_liquid-handling/_liquid-handling.rst +++ b/docs/user_guide/00_liquid-handling/_liquid-handling.rst @@ -17,6 +17,7 @@ Examples: hamilton-star/_hamilton-star hamilton-vantage/_hamilton-vantage hamilton-prep/_hamilton-prep + hamilton-nimbus/_hamilton-nimbus opentrons/ot2/ot2 tecan-evo/_tecan-evo plate-washing/plate-washing diff --git a/docs/user_guide/00_liquid-handling/hamilton-nimbus/_hamilton-nimbus.md b/docs/user_guide/00_liquid-handling/hamilton-nimbus/_hamilton-nimbus.md new file mode 100644 index 00000000000..36a1da16935 --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-nimbus/_hamilton-nimbus.md @@ -0,0 +1,10 @@ +# Hamilton Nimbus + +Basic Support for Channels. + +```{toctree} +:maxdepth: 1 +:hidden: + +nimbus_basic_demo +``` diff --git a/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_basic_demo.ipynb b/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_basic_demo.ipynb new file mode 100644 index 00000000000..542b6e1a3d9 --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-nimbus/nimbus_basic_demo.ipynb @@ -0,0 +1,434 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Nimbus Aspirate and Dispense Demo\n", + "\n", + "This notebook demonstrates aspirate and dispense operations with the Hamilton Nimbus backend.\n", + "\n", + "The demo covers:\n", + "1. Creating a Nimbus Deck and assigning resources\n", + "2. Setting up the NimbusBackend and LiquidHandler\n", + "3. Picking up tips from the tip rack\n", + "4. Aspirating 50 µL from wells (2mm above bottom)\n", + "5. Dispensing to wells (2mm above bottom)\n", + "6. Dropping tips to waste\n", + "7. Cleaning up and closing the connection\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Deck created: deck\n", + " Size: 831.85 x 424.18 x 300.0 mm\n", + " Rails: 30\n", + "\n", + "Tip rack assigned: Tips\n", + "Wellplate assigned: Plate\n", + " Waste block: default_long_block\n", + "Websocket server started at http://127.0.0.1:2121\n", + "File server started at http://127.0.0.1:1337 . Open this URL in your browser.\n" + ] + } + ], + "source": [ + "# Import necessary modules\n", + "import sys\n", + "import logging\n", + "\n", + "from pylabrobot.liquid_handling import LiquidHandler\n", + "from pylabrobot.liquid_handling.backends.hamilton.nimbus_backend import NimbusBackend\n", + "from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck\n", + "from pylabrobot.resources import (\n", + " hamilton_96_tiprack_300uL_filter,\n", + " Cor_Axy_96_wellplate_500uL_Ub,\n", + " Coordinate,\n", + ")\n", + "from pylabrobot.visualizer import Visualizer\n", + "\n", + "\n", + "# Setup logging\n", + "plr_logger = logging.getLogger('pylabrobot')\n", + "plr_logger.setLevel(logging.INFO) # INFO for normal use, DEBUG for troubleshooting\n", + "plr_logger.handlers.clear()\n", + "console_handler = logging.StreamHandler(sys.stdout)\n", + "console_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))\n", + "plr_logger.addHandler(console_handler)\n", + "\n", + "# ========================================================================\n", + "# CREATE DECK AND RESOURCES (using coordinates from nimbus_deck_setup.ipynb)\n", + "# ========================================================================\n", + "\n", + "# Create NimbusDeck using default values (layout 8 dimensions)\n", + "deck = NimbusDeck()\n", + "\n", + "print(f\"Deck created: {deck.name}\")\n", + "print(f\" Size: {deck.get_size_x()} x {deck.get_size_y()} x {deck.get_size_z()} mm\")\n", + "print(f\" Rails: {deck.num_rails}\")\n", + "\n", + "tip_rack = hamilton_96_tiprack_300uL_filter(name=\"Tips\", with_tips=True)\n", + "deck.assign_child_resource(tip_rack, location=Coordinate(x=305.750, y=126.537, z=128.620))\n", + "\n", + "print(f\"\\nTip rack assigned: {tip_rack.name}\")\n", + "wellplate = Cor_Axy_96_wellplate_500uL_Ub(name=\"Plate\", with_lid=False)\n", + "deck.assign_child_resource(wellplate, location=Coordinate(x=438.070, y=124.837, z=101.490))\n", + "\n", + "print(f\"Wellplate assigned: {wellplate.name}\")\n", + "print(f\" Waste block: {deck.get_resource('default_long_block').name}\")\n", + "\n", + "visualizer = Visualizer(deck, open_browser=False)\n", + "await visualizer.setup()" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "LiquidHandler created successfully\n", + "INFO - Connection initialized (Client ID: 3, Address: 2:3:65535)\n", + "INFO - Client registered.\n", + "INFO - Setup complete. Registered as Client ID 3 (2:3:65535), Root: NimbusCORE\n", + "INFO - Interfaces: door_lock, nimbus_core, pipette\n", + "INFO - Channel configuration: 4 channels\n", + "INFO - Tip presence: [False, False, False, False]\n", + "INFO - Instrument initialized: True\n", + "INFO - Door already locked\n", + "INFO - Instrument already initialized, skipping initialization\n", + "\n", + "============================================================\n", + "SETUP COMPLETE\n", + "============================================================\n", + "Setup finished: True\n", + "\n", + "Instrument Configuration:\n", + " Number of channels: 4\n" + ] + } + ], + "source": [ + "# Create NimbusBackend instance\n", + "# Replace with your instrument's IP address\n", + "backend = NimbusBackend(host=\"192.168.100.100\", port=2000)\n", + "# Create LiquidHandler with backend and deck\n", + "lh = LiquidHandler(backend=backend, deck=deck)\n", + "print(\"LiquidHandler created successfully\")\n", + "\n", + "# Setup\n", + "await lh.setup(unlock_door=False)\n", + "print(\"\\n\" + \"=\"*60)\n", + "print(\"SETUP COMPLETE\")\n", + "print(\"=\"*60)\n", + "print(f\"Setup finished: {backend.setup_finished}\")\n", + "print(f\"\\nInstrument Configuration:\")\n", + "print(f\" Number of channels: {backend.num_channels}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Define Resources" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tip rack: Tips (96 tips)\n", + "Source/Destination plate: Plate (using same plate, different wells)\n", + "Waste positions: ['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4']\n" + ] + } + ], + "source": [ + "# Resources are already created in the setup cell above\n", + "# tip_rack and wellplate variables are available\n", + "\n", + "print(f\"Tip rack: {tip_rack.name} ({tip_rack.num_items} tips)\")\n", + "print(f\"Source/Destination plate: {wellplate.name} (using same plate, different wells)\")\n", + "\n", + "# Use wellplate as both source and destination\n", + "source_plate = wellplate\n", + "destination_plate = wellplate\n", + "\n", + "# Get waste positions\n", + "waste_block = deck.get_resource(\"default_long_block\")\n", + "waste_positions = waste_block.children[:4]\n", + "\n", + "print(f\"Waste positions: {[wp.name for wp in waste_positions]}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pick Up Tips\n", + "\n", + "Pick up tips from positions A1-D1.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Picking up tips from positions: ['A1', 'B1', 'C1', 'D1']\n", + "DEBUG - pick_up_tips(tip_spots=['Tips_tipspot_A1', 'Tips_tipspot_B1', 'Tips_tipspot_C1', 'Tips_tipspot_D1'], use_channels=None, offsets=None)\n", + "DEBUG - IsTipPresent parameters: {}\n", + "DEBUG - PickupTips parameters: {'channels_involved': [1, 1, 1, 1], 'x_positions': [16144, 16144, 16144, 16144], 'y_positions': [-16899, -17799, -18699, -19599], 'minimum_traverse_height_at_beginning_of_a_command': 14600, 'begin_tip_pick_up_process': [13802, 13802, 13802, 13802], 'end_tip_pick_up_process': [13002, 13002, 13002, 13002], 'tip_types': [, , , ]}\n", + "INFO - Picked up tips on channels [0, 1, 2, 3]\n", + "✓ Tips picked up successfully!\n" + ] + } + ], + "source": [ + "plr_logger.setLevel(logging.DEBUG) # INFO for normal use, DEBUG for troubleshooting\n", + "# Get the first 4 tip spots (A1, B1, C1, D1)\n", + "tip_spots = tip_rack['A1:D1']\n", + "\n", + "print(f\"Picking up tips from positions: {[ts.get_identifier() for ts in tip_spots]}\")\n", + "await lh.pick_up_tips(tip_spots)\n", + "print(\"✓ Tips picked up successfully!\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Aspirate Operation\n", + "\n", + "Aspirate 50 µL from wells A1-D1, 2mm above the bottom of the well.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Aspirating 50 µL from wells: ['A1', 'B1', 'C1', 'D1']\n", + " Liquid height: 2.0 mm above bottom\n", + "DEBUG - aspirate(resources=['Plate_well_A1', 'Plate_well_B1', 'Plate_well_C1', 'Plate_well_D1'], vols=[50, 50, 50, 50], use_channels=None, flow_rates=[100, 100, 100, 100], offsets=None, liquid_height=[2, 2, 2, 2], blow_out_air_volume=None)\n", + "DEBUG - DisableADC parameters: {'channels_involved': [1, 1, 1, 1]}\n", + "INFO - Disabled ADC before aspirate\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 1, 'indexes': [2]}\n", + "DEBUG - Channel 1 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 2, 'indexes': [2]}\n", + "DEBUG - Channel 2 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 3, 'indexes': [2]}\n", + "DEBUG - Channel 3 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 4, 'indexes': [2]}\n", + "DEBUG - Channel 4 configuration (index 2): enabled=False\n", + "DEBUG - Aspirate parameters: {'aspirate_type': [0, 0, 0, 0], 'channels_involved': [1, 1, 1, 1], 'x_positions': [29616, 29616, 29616, 29616], 'y_positions': [-16899, -17799, -18699, -19599], 'minimum_traverse_height_at_beginning_of_a_command': 14600, 'lld_search_height': [1225, 1225, 1225, 1225], 'liquid_height': [10587, 10587, 10587, 10587], 'immersion_depth': [0, 0, 0, 0], 'surface_following_distance': [0, 0, 0, 0], 'minimum_height': [10387, 10387, 10387, 10387], 'clot_detection_height': [0, 0, 0, 0], 'min_z_endpos': 14600, 'swap_speed': [200, 200, 200, 200], 'blow_out_air_volume': [400, 400, 400, 400], 'pre_wetting_volume': [0, 0, 0, 0], 'aspirate_volume': [500, 500, 500, 500], 'transport_air_volume': [50, 50, 50, 50], 'aspiration_speed': [1000, 1000, 1000, 1000], 'settling_time': [10, 10, 10, 10], 'mix_volume': [0, 0, 0, 0], 'mix_cycles': [0, 0, 0, 0], 'mix_position_from_liquid_surface': [0, 0, 0, 0], 'mix_surface_following_distance': [0, 0, 0, 0], 'mix_speed': [1000, 1000, 1000, 1000], 'tube_section_height': [0, 0, 0, 0], 'tube_section_ratio': [0, 0, 0, 0], 'lld_mode': [0, 0, 0, 0], 'gamma_lld_sensitivity': [0, 0, 0, 0], 'dp_lld_sensitivity': [0, 0, 0, 0], 'lld_height_difference': [0, 0, 0, 0], 'tadm_enabled': False, 'limit_curve_index': [0, 0, 0, 0], 'recording_mode': 0}\n", + "INFO - Aspirated on channels [0, 1, 2, 3]\n", + "✓ Aspiration complete!\n" + ] + } + ], + "source": [ + "# Get source wells (A1, B1, C1, D1)\n", + "source_wells = source_plate['A1:D1']\n", + "\n", + "print(f\"Aspirating 50 µL from wells: {[w.get_identifier() for w in source_wells]}\")\n", + "print(f\" Liquid height: 2.0 mm above bottom\")\n", + "\n", + "# Aspirate with liquid_height=2.0mm\n", + "# Tips are already picked up, so LiquidHandler will use them automatically\n", + "await lh.aspirate(\n", + " source_wells,\n", + " vols = 4 *[50], # Can be a single number (applies to all channels) or a list\n", + " liquid_height = 4 *[2], # 2mm above bottom of well (can be a single float or list)\n", + " flow_rates = 4 *[100],\n", + ")\n", + "\n", + "print(\"✓ Aspiration complete!\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Dispense Operation\n", + "\n", + "Dispense 50 µL to wells A2-D2, 2mm above the bottom of the well.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Dispensing 50 µL to wells: ['A7', 'B7', 'C7', 'D7']\n", + " Liquid height: 2.0 mm above bottom\n", + "DEBUG - dispense(resources=['Plate_well_A7', 'Plate_well_B7', 'Plate_well_C7', 'Plate_well_D7'], vols=[50, 50, 50, 50], use_channels=None, flow_rates=[120, 120, 120, 120], offsets=None, liquid_height=[2, 2, 2, 2], blow_out_air_volume=None)\n", + "DEBUG - DisableADC parameters: {'channels_involved': [1, 1, 1, 1]}\n", + "INFO - Disabled ADC before dispense\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 1, 'indexes': [2]}\n", + "DEBUG - Channel 1 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 2, 'indexes': [2]}\n", + "DEBUG - Channel 2 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 3, 'indexes': [2]}\n", + "DEBUG - Channel 3 configuration (index 2): enabled=False\n", + "DEBUG - GetChannelConfiguration parameters: {'channel': 4, 'indexes': [2]}\n", + "DEBUG - Channel 4 configuration (index 2): enabled=False\n", + "DEBUG - Dispense parameters: {'dispense_type': [0, 0, 0, 0], 'channels_involved': [1, 1, 1, 1], 'x_positions': [35016, 35016, 35016, 35016], 'y_positions': [-16899, -17799, -18699, -19599], 'minimum_traverse_height_at_beginning_of_a_command': 14600, 'lld_search_height': [1225, 1225, 1225, 1225], 'liquid_height': [10587, 10587, 10587, 10587], 'immersion_depth': [0, 0, 0, 0], 'surface_following_distance': [0, 0, 0, 0], 'minimum_height': [10387, 10387, 10387, 10387], 'min_z_endpos': 14600, 'swap_speed': [200, 200, 200, 200], 'transport_air_volume': [50, 50, 50, 50], 'dispense_volume': [500, 500, 500, 500], 'stop_back_volume': [0, 0, 0, 0], 'blow_out_air_volume': [400, 400, 400, 400], 'dispense_speed': [1200, 1200, 1200, 1200], 'cut_off_speed': [250, 250, 250, 250], 'settling_time': [10, 10, 10, 10], 'mix_volume': [0, 0, 0, 0], 'mix_cycles': [0, 0, 0, 0], 'mix_position_from_liquid_surface': [0, 0, 0, 0], 'mix_surface_following_distance': [0, 0, 0, 0], 'mix_speed': [1200, 1200, 1200, 1200], 'side_touch_off_distance': 0, 'dispense_offset': [0, 0, 0, 0], 'tube_section_height': [0, 0, 0, 0], 'tube_section_ratio': [0, 0, 0, 0], 'lld_mode': [0, 0, 0, 0], 'gamma_lld_sensitivity': [0, 0, 0, 0], 'tadm_enabled': False, 'limit_curve_index': [0, 0, 0, 0], 'recording_mode': 0}\n", + "INFO - Dispensed on channels [0, 1, 2, 3]\n", + "✓ Dispense complete!\n" + ] + } + ], + "source": [ + "# Get destination wells (A2, B2, C2, D2)\n", + "dest_wells = destination_plate['A7:D7']\n", + "\n", + "print(f\"Dispensing 50 µL to wells: {[w.get_identifier() for w in dest_wells]}\")\n", + "print(f\" Liquid height: 2.0 mm above bottom\")\n", + "\n", + "# Dispense with liquid_height=2.0mm\n", + "# Tips are already picked up, so LiquidHandler will use them automatically\n", + "await lh.dispense(\n", + " dest_wells,\n", + " vols= 4 *[50], # Can be a single number (applies to all channels) or a list\n", + " liquid_height= 4 *[2], # 2mm above bottom of well (can be a single float or list)\n", + " flow_rates= 4 *[120],\n", + ")\n", + "\n", + "print(\"✓ Dispense complete!\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Drop Tips\n", + "\n", + "Drop tips to waste positions.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Dropping tips at waste positions: ['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4']\n", + "DEBUG - drop_tips(tip_spots=['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4'], use_channels=None, offsets=None, allow_nonzero_volume=False)\n", + "DEBUG - DropTipsRoll parameters: {'channels_involved': [1, 1, 1, 1], 'x_positions': [55375, 55375, 55375, 55375], 'y_positions': [1986, 188, -7615, -9413], 'minimum_traverse_height_at_beginning_of_a_command': 14600, 'begin_tip_deposit_process': [13539, 13539, 13539, 13539], 'end_tip_deposit_process': [13139, 13139, 13139, 13139], 'z_position_at_end_of_a_command': [14600, 14600, 14600, 14600], 'roll_distances': [900, 900, 900, 900]}\n", + "INFO - Dropped tips on channels [0, 1, 2, 3]\n", + "✓ Tips dropped successfully!\n" + ] + } + ], + "source": [ + "print(f\"Dropping tips at waste positions: {[wp.name for wp in waste_positions]}\")\n", + "await lh.drop_tips(waste_positions)\n", + "\n", + "print(\"✓ Tips dropped successfully!\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Cleanup\n", + "\n", + "Finally, we'll stop the liquid handler and close the connection.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Instrument parked successfully\n", + "INFO - Door unlocked successfully\n", + "INFO - Closing connection to socket 192.168.100.100:2000\n", + "INFO - Hamilton TCP client stopped\n", + "Connection closed successfully\n" + ] + } + ], + "source": [ + "plr_logger.setLevel(logging.INFO) # INFO for normal use, DEBUG for troubleshooting\n", + "\n", + "# Stop and close connection\n", + "await lh.backend.park()\n", + "await lh.backend.unlock_door()\n", + "await lh.stop()\n", + "\n", + "print(\"Connection closed successfully\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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": 2 +} diff --git a/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md b/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md index 1eceeccc73c..9943ce2ae54 100644 --- a/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md +++ b/docs/user_guide/00_liquid-handling/hamilton-prep/_hamilton-prep.md @@ -1,3 +1,10 @@ # Hamilton Prep -Coming soon. See [https://github.com/PyLabRobot/pylabrobot/pull/407](https://github.com/PyLabRobot/pylabrobot/pull/407). +Channel and CORE gripper support. + +```{toctree} +:maxdepth: 1 +:hidden: + +prep_basic_demo +``` 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..4d79df3b057 --- /dev/null +++ b/docs/user_guide/00_liquid-handling/hamilton-prep/prep_basic_demo.ipynb @@ -0,0 +1,429 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "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. **Liquid handling** — Tip pickup, dual-channel aspirate and dispense.\n", + "3. **Plate movement** — CORE gripper: pick plate from deck[0], drop at deck[1].\n", + "\n", + "**Deck layout:** 1× 50 µL NTR tips at deck[3], 1× plate at deck[0] (moved to deck[1]). Visualizer runs after deck creation." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Imports and config" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "8165c4f9", + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import logging\n", + "from asyncio import sleep\n", + "\n", + "from pylabrobot.liquid_handling.backends.hamilton.prep_backend import PrepBackend\n", + "from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient\n", + "from pylabrobot.liquid_handling import LiquidHandler\n", + "from pylabrobot.resources import Coordinate\n", + "from pylabrobot.resources.hamilton import PrepDeck\n", + "from pylabrobot.resources import hamilton_96_tiprack_50uL_NTR, Cor_Axy_96_wellplate_500uL_Ub\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", + "HOST = \"192.168.100.102\"\n", + "PORT = 2000\n", + "SAFE_HEIGHT_MM_ABOVE_WELL = 25" + ] + }, + { + "cell_type": "markdown", + "id": "a5fdf5b0", + "metadata": {}, + "source": [ + "## 2. Deck layout and visualizer" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c02643fb", + "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 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[0] = Cor_Axy_96_wellplate_500uL_Ub(\"plate\")\n", + "\n", + "visualizer = Visualizer(deck, open_browser=False)\n", + "await visualizer.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "6ed4c83f", + "metadata": {}, + "source": [ + "## 3. Backend and liquid handler setup" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "b457df04", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Connection initialized (Client ID: 35, Address: 2:35:65535)\n", + "INFO - Client registered.\n", + "INFO - Setup complete. Registered as Client ID 35 (2:35:65535), Root: MLPrepRoot\n", + "WARNING - Unknown introspection type category for type_id=113; treating as parameter\n", + "INFO - Interfaces: coordinator, deck_config, mlprep, mlprep_service, mph, pipettor\n", + "INFO - MLPrep already initialized, skipping Initialize\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" + ] + } + ], + "source": [ + "backend = PrepBackend(host=HOST, port=PORT)\n", + "lh = LiquidHandler(backend=backend, deck=deck)\n", + "await lh.setup(smart=True, force_initialize=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9c4f5771", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Instrument configuration:\n", + " 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)\n", + " Has enclosure: True\n", + " Safe speeds enabled: False\n", + " Default traverse height: 167.5\n", + " Number of channels: 2\n", + " Has MPH: True\n", + "\n", + "Deck sites:\n", + "DeckSiteInfo(id=0, left_bottom_front_x=-2.2300000190734863, left_bottom_front_y=390.0, left_bottom_front_z=0.0, length=310.0, width=100.0, height=167.0)\n", + "DeckSiteInfo(id=1, left_bottom_front_x=270.0, left_bottom_front_y=-3.0, left_bottom_front_z=0.0, length=100.0, width=400.0, height=73.0)\n", + "DeckSiteInfo(id=2, left_bottom_front_x=284.760009765625, left_bottom_front_y=214.2899932861328, left_bottom_front_z=0.0, length=6.0, width=6.0, height=85.0)\n", + "DeckSiteInfo(id=0, left_bottom_front_x=-2.2300000190734863, left_bottom_front_y=390.0, left_bottom_front_z=0.0, length=310.0, width=100.0, height=165.60000610351562)\n", + "DeckSiteInfo(id=1, left_bottom_front_x=270.0, left_bottom_front_y=-3.0, left_bottom_front_z=0.0, length=100.0, width=400.0, height=73.0)\n", + "DeckSiteInfo(id=2, left_bottom_front_x=284.760009765625, left_bottom_front_y=214.2899932861328, left_bottom_front_z=0.0, length=6.0, width=6.0, height=85.0)\n", + "\n", + "Waste sites:\n", + "WasteSiteInfo(index=1, x_position=286.79998779296875, y_position=10.0, z_position=68.4000015258789, z_seek=69.4000015258789)\n", + "WasteSiteInfo(index=2, x_position=286.79998779296875, y_position=30.0, z_position=68.4000015258789, z_seek=69.4000015258789)\n", + "WasteSiteInfo(index=3, x_position=286.79998779296875, y_position=112.0, z_position=68.4000015258789, z_seek=69.4000015258789)\n" + ] + } + ], + "source": [ + "# Deck, waste, and other config data from setup can be accessed here.\n", + "config = lh.backend._config\n", + "print(\"Instrument configuration:\")\n", + "print(f\" Deck bounds: {config.deck_bounds}\")\n", + "print(f\" Has enclosure: {config.has_enclosure}\")\n", + "print(f\" Safe speeds enabled: {config.safe_speeds_enabled}\")\n", + "print(f\" Default traverse height: {config.default_traverse_height}\")\n", + "print(f\" Number of channels: {config.num_channels}\")\n", + "print(f\" Has MPH: {config.has_mph}\")\n", + "\n", + "print(\"\\nDeck sites:\")\n", + "for site in config.deck_sites:\n", + " print(site)\n", + "\n", + "print(\"\\nWaste sites:\")\n", + "for waste in config.waste_sites:\n", + " print(waste)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "711d509a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Global type pool built: 57 structs, 11 enums from 1 global objects\n", + "Command signatures on MLPrepRoot.PipettorRoot.Pipettor (49 methods):\n", + "\n", + " [1:1] Aspirate(aspirateParameters: AspirateParametersNoLldAndMonitoring) -> void\n", + " [1:2] AspirateTadm(aspirateParameters: AspirateParametersNoLldAndTadm) -> void\n", + " [1:3] AspirateLld(aspirateParameters: AspirateParametersLldAndMonitoring) -> void\n", + " [1:4] AspirateLldTadm(aspirateParameters: AspirateParametersLldAndTadm) -> void\n", + " [1:5] Dispense(dispenseParameters: DispenseParametersNoLld) -> void\n", + " [1:6] DispenseLld(dispenseParameters: DispenseParametersLld) -> void\n", + " [1:7] DispenseInitializeToWaste(wasteParameters: DispenseInitToWasteParameters) -> void\n", + " [1:8] PickupTips(tipParameters: TipPositionParameters, finalZ: f32, seekSpeed: f32, tipDefinitionId: u8, tadm: bool, dispenserVolume: f32, dispenserSpeed: f32) -> void\n", + " [1:9] PickupTips(tipParameters: TipPositionParameters, finalZ: f32, seekSpeed: f32, tipDefinition: TipPickupParameters, tadm: bool, dispenserVolume: f32, dispenserSpeed: f32) -> void\n", + " [1:10] PickupNeedles(tipParameters: TipPositionParameters, finalZ: f32, seekSpeed: f32, tipDefinitionId: u8, blowoutOffset: f32, blowoutSpeed: f32, tadm: bool, dispenserVolume: f32, dispenserSpeed: f32) -> void\n", + " [1:11] PickupNeedles(tipParameters: TipPositionParameters, finalZ: f32, seekSpeed: f32, tipDefinition: TipPickupParameters, blowoutOffset: f32, blowoutSpeed: f32, tadm: bool, dispenserVolume: f32, dispenserSpeed: f32) -> void\n", + " [1:12] DropTips(tipParameters: TipDropParameters, finalZ: f32, seekSpeed: f32, tipRollOffDistance: f32) -> void\n", + " [1:13] GetTipDefinitionHeld(void) -> value: TipDefinition\n", + " [1:14] PickupTool(tipDefinitionId: u8, toolPositionX: f32, toolPositionZ: f32, frontChannelPositionY: f32, rearChannelPositionY: f32, toolSeek: f32, toolXRadius: f32, toolYRadius: f32) -> void\n", + " [1:15] PickupTool(tipDefinition: TipPickupParameters, toolPositionX: f32, toolPositionZ: f32, frontChannelPositionY: f32, rearChannelPositionY: f32, toolSeek: f32, toolXRadius: f32, toolYRadius: f32) -> void\n", + " [1:16] DropTool(void) -> void\n", + " [1:17] PickupPlate(plateTopCenter: XYZCoord, plate: PlateDimensions, clearanceY: f32, gripSpeedY: f32, gripDistance: f32, gripHeight: f32) -> void\n", + " [1:18] DropPlate(plateTopCenter: XYZCoord, clearanceY: f32, accelerationScaleX: u8) -> void\n", + " [1:19] MovePlate(plateTopCenter: XYZCoord, accelerationScaleX: u8) -> void\n", + " [1:20] TransferPlate(plateSourceTopCenter: XYZCoord, plateDestinationTopCenter: XYZCoord, plate: PlateDimensions, clearanceY: f32, gripSpeedY: f32, gripDistance: f32, gripHeight: f32, accelerationScaleX: u8) -> void\n", + " [1:21] ReleasePlate(void) -> void\n", + " [1:22] GetPlateHeld(void) -> value: bool\n", + " [1:23] EmptyDispenser(channels: ChannelIndex) -> void\n", + " [1:24] GetCurrentDispenserVolume(void) -> value: DispenserVolumeReturnParameters\n", + " [1:25] GetPositions(void) -> value: ChannelXYZPositionParameters\n", + " [1:26] MoveToPosition(moveParameters: GantryMoveXYZParameters) -> void\n", + " [1:27] MoveToPositionViaLane(moveParameters: GantryMoveXYZParameters) -> void\n", + " [1:28] MoveZUpToSafe(channels: ChannelIndex) -> void\n", + " [1:29] ZSeekLldPosition(seekParameters: LLDChannelSeekParameters) -> results: SeekResultParameters\n", + " [1:30] GetLiquidHeight(void) -> value: LiquidHeightReturnParameters\n", + " [1:31] CreateTadmLimitCurve(channel: ChannelIndex, name: str, lowerLimit: LimitCurveEntry, upperLimit: LimitCurveEntry) -> index: u16\n", + " [1:32] EraseTadmLimitCurves(channel: ChannelIndex) -> void\n", + " [1:33] GetTadmLimitCurveNames(channel: ChannelIndex, names: List[str]) -> void\n", + " [1:34] GetTadmLimitCurveInfo(channel: ChannelIndex, name: str) -> { index: u16, lowerLimits: u16, upperLimits: u16 }\n", + " [1:35] RetrieveTadmData(channel: ChannelIndex) -> tadmData: TadmReturnParameters\n", + " [1:36] ResetTadmFifo(channels: ChannelIndex) -> void\n", + " [1:37] GetDispenserVolumeStack(void) -> value: DispenserVolumeStackReturnParameters\n", + " [1:38] Aspirate(aspirateParameters: AspirateParametersNoLldAndMonitoring2) -> void\n", + " [1:39] AspirateTadm(aspirateParameters: AspirateParametersNoLldAndTadm2) -> void\n", + " [1:40] AspirateLld(aspirateParameters: AspirateParametersLldAndMonitoring2) -> void\n", + " [1:41] AspirateLldTadm(aspirateParameters: AspirateParametersLldAndTadm2) -> void\n", + " [1:42] Dispense(dispenseParameters: DispenseParametersNoLld2) -> void\n", + " [1:43] DispenseLld(dispenseParameters: DispenseParametersLld2) -> void\n", + " [0:1] ObjectInfo(void) -> { name: str, version: str, methods: u32, subobjects: u16 }\n", + " [0:2] MethodInfo(method: u32) -> { interfaceid: u8, action: u8, actionid: u16, name: str, parametertypes: str, parameternames: str }\n", + " [0:3] SubObjectInfo(subobject: u16) -> { moduleID: u16, nodeID: u16, objectID: u16 }\n", + " [0:4] InterfaceDescriptors(void) -> { interfaceIds: bytes, interfaceDescriptors: List[str] }\n", + " [0:5] EnumInfo(interfaceId: u8) -> { enumerationNames: List[str], numberEnumerationValues: List[u32], enumerationValues: List[i32], enumerationValueDescriptions: List[str] }\n", + " [0:6] StructInfo(interfaceId: u8) -> { structNames: List[str], numberStructureElements: List[u32], structureElementTypes: bytes, structureElementDescriptions: List[str] }\n" + ] + } + ], + "source": [ + "# INTROSPECTION: PREP pipette interface: list command signatures via backend.client.introspect()\n", + "PIPETTOR_PATH = \"MLPrepRoot.PipettorRoot.Pipettor\"\n", + "pool, reg = await backend.client.introspect(PIPETTOR_PATH)\n", + "\n", + "print(f\"Command signatures on {PIPETTOR_PATH} ({len(reg.methods)} methods):\\n\")\n", + "for m in reg.methods:\n", + " print(f\" {m.get_signature_string(reg)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "8fc3d808", + "metadata": {}, + "source": [ + "## 4. Teaching needle: above plate A1 at safe height" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "70f1360b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG - pick_up_tips(tip_spots=['teaching_tip'], use_channels=None, offsets=None)\n", + "DEBUG - PrepPickUpTips parameters: {'tip_positions': [TipPositionParameters(default_values=False, channel=, x_position=287.76, y_position=217.29, z_position=75.75, z_seek=88.75)], 'final_z': 167.5, 'seek_speed': 15.0, 'tip_definition': TipPickupParameters(default_values=False, volume=360, length=51.9, tip_type=, has_filter=True, is_needle=False, is_tool=False), 'enable_tadm': False, 'dispenser_volume': 0.0, 'dispenser_speed': 250.0}\n", + "DEBUG - PrepMoveToPosition parameters: {'move_parameters': GantryMoveXYZParameters(default_values=False, gantry_x_position=13.6, axis_parameters=[ChannelYZMoveParameters(default_values=False, channel=, y_position=75.5, z_position=29.95)])}\n", + "DEBUG - drop_tips(tip_spots=['teaching_tip'], use_channels=None, offsets=None, allow_nonzero_volume=False)\n", + "DEBUG - PrepDropTips parameters: {'tip_positions': [TipDropParameters(default_values=False, channel=, x_position=287.76, y_position=217.29, z_position=75.75, z_seek=93.75, drop_type=)], 'final_z': 167.5, 'seek_speed': 30.0, 'tip_roll_off_distance': 0.0}\n" + ] + } + ], + "source": [ + "# Set Logger to debug so we can see params as sent to the backend.\n", + "logging.getLogger(\"pylabrobot\").setLevel(logging.DEBUG)\n", + "\n", + "teaching_tip = deck.get_resource(\"teaching_tip\")\n", + "await lh.pick_up_tips([teaching_tip])\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 lh.backend.move_to_position(safe_pos.x, safe_pos.y, safe_pos.z)\n", + "await sleep(3)\n", + "\n", + "await lh.drop_tips([teaching_tip])" + ] + }, + { + "cell_type": "markdown", + "id": "4680bbb0", + "metadata": {}, + "source": [ + "## 5. Tip pickup, aspirate, dispense (dual channel)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "5b386354", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG - pick_up_tips(tip_spots=['ntr_50_tipspot_A1', 'ntr_50_tipspot_B1'], use_channels=None, offsets=None)\n", + "DEBUG - PrepPickUpTips parameters: {'tip_positions': [TipPositionParameters(default_values=False, channel=, x_position=13.525, y_position=361.5, z_position=59.650000000000006, z_seek=72.65), TipPositionParameters(default_values=False, channel=, x_position=13.525, y_position=352.5, z_position=59.650000000000006, z_seek=72.65)], 'final_z': 167.5, 'seek_speed': 15.0, 'tip_definition': TipPickupParameters(default_values=False, volume=65, length=42.4, tip_type=, has_filter=False, is_needle=False, is_tool=False), 'enable_tadm': False, 'dispenser_volume': 0.0, 'dispenser_speed': 250.0}\n", + "DEBUG - aspirate(resources=['plate_well_A1', 'plate_well_B1'], vols=[35, 25], use_channels=None, flow_rates=None, offsets=None, liquid_height=[3, 3], blow_out_air_volume=None)\n", + "DEBUG - PrepAspirateNoLldMonitoringV2 parameters: {'aspirate_parameters': [AspirateParametersNoLldAndMonitoring2(default_values=False, channel=, aspirate=AspirateParameters(default_values=False, x_position=13.6, y_position=75.5, prewet_volume=2.0, blowout_volume=1.0), container_description=[], common=CommonParameters(default_values=False, empty=True, z_minimum=6.13, z_final=125.1, z_liquid_exit_speed=25, liquid_volume=38.45, liquid_speed=100.0, transport_air_volume=0.0, tube_radius=4.0, cone_height=0.0, cone_bottom_radius=0.0, settling_time=1.0, additional_probes=0), no_lld=NoLldParameters(default_values=False, z_fluid=9.129999999999999, z_air=20.38, bottom_search=False, z_bottom_search_offset=2.0, z_bottom_offset=0.0), mix=MixParameters(default_values=True, z_offset=0.0, volume=0.0, cycles=0, speed=250.0), adc=AdcParameters(default_values=True, errors=True, maximum_volume=4.5), aspirate_monitoring=AspirateMonitoringParameters(default_values=True, c_lld_enable=False, p_lld_enable=False, minimum_differential=30, maximum_differential=30, clot_threshold=20)), AspirateParametersNoLldAndMonitoring2(default_values=False, channel=, aspirate=AspirateParameters(default_values=False, x_position=13.6, y_position=66.5, prewet_volume=2.0, blowout_volume=1.0), container_description=[], common=CommonParameters(default_values=False, empty=True, z_minimum=6.13, z_final=125.1, z_liquid_exit_speed=25, liquid_volume=27.75, liquid_speed=100.0, transport_air_volume=0.0, tube_radius=4.0, cone_height=0.0, cone_bottom_radius=0.0, settling_time=1.0, additional_probes=0), no_lld=NoLldParameters(default_values=False, z_fluid=9.129999999999999, z_air=20.38, bottom_search=False, z_bottom_search_offset=2.0, z_bottom_offset=0.0), mix=MixParameters(default_values=True, z_offset=0.0, volume=0.0, cycles=0, speed=250.0), adc=AdcParameters(default_values=True, errors=True, maximum_volume=4.5), aspirate_monitoring=AspirateMonitoringParameters(default_values=True, c_lld_enable=False, p_lld_enable=False, minimum_differential=30, maximum_differential=30, clot_threshold=20))]}\n", + "DEBUG - dispense(resources=['plate_well_A7', 'plate_well_B7'], vols=[35, 25], use_channels=None, flow_rates=None, offsets=None, liquid_height=[3, 3], blow_out_air_volume=None)\n", + "DEBUG - PrepDispenseNoLldV2 parameters: {'dispense_parameters': [DispenseParametersNoLld2(default_values=False, channel=, dispense=DispenseParameters(default_values=False, x_position=67.6, y_position=75.5, stop_back_volume=0.0, cutoff_speed=1.0), container_description=[], common=CommonParameters(default_values=False, empty=True, z_minimum=6.13, z_final=125.1, z_liquid_exit_speed=25, liquid_volume=38.45, liquid_speed=120.0, transport_air_volume=0.0, tube_radius=4.0, cone_height=0.0, cone_bottom_radius=0.0, settling_time=0.0, additional_probes=0), no_lld=NoLldParameters(default_values=False, z_fluid=9.129999999999999, z_air=20.38, bottom_search=False, z_bottom_search_offset=2.0, z_bottom_offset=0.0), mix=MixParameters(default_values=True, z_offset=0.0, volume=0.0, cycles=0, speed=250.0), adc=AdcParameters(default_values=True, errors=True, maximum_volume=4.5), tadm=TadmParameters(default_values=True, limit_curve_index=0, recording_mode=)), DispenseParametersNoLld2(default_values=False, channel=, dispense=DispenseParameters(default_values=False, x_position=67.6, y_position=66.5, stop_back_volume=0.0, cutoff_speed=1.0), container_description=[], common=CommonParameters(default_values=False, empty=True, z_minimum=6.13, z_final=125.1, z_liquid_exit_speed=25, liquid_volume=27.75, liquid_speed=120.0, transport_air_volume=0.0, tube_radius=4.0, cone_height=0.0, cone_bottom_radius=0.0, settling_time=0.0, additional_probes=0), no_lld=NoLldParameters(default_values=False, z_fluid=9.129999999999999, z_air=20.38, bottom_search=False, z_bottom_search_offset=2.0, z_bottom_offset=0.0), mix=MixParameters(default_values=True, z_offset=0.0, volume=0.0, cycles=0, speed=250.0), adc=AdcParameters(default_values=True, errors=True, maximum_volume=4.5), tadm=TadmParameters(default_values=True, limit_curve_index=0, recording_mode=))]}\n", + "DEBUG - discard_tips(use_channels=None, allow_nonzero_volume=True, offsets=None)\n", + "DEBUG - drop_tips(tip_spots=['trash', 'trash'], use_channels=[0, 1], offsets=[Coordinate(x=0, y=4.5, z=0), Coordinate(x=0, y=-4.5, z=0)], allow_nonzero_volume=True)\n", + "DEBUG - PrepDropTips parameters: {'tip_positions': [TipDropParameters(default_values=False, channel=, x_position=289.8, y_position=33.0, z_position=110.80000000000001, z_seek=128.8, drop_type=), TipDropParameters(default_values=False, channel=, x_position=289.8, y_position=13.0, z_position=110.80000000000001, z_seek=128.8, drop_type=)], 'final_z': 167.5, 'seek_speed': 30.0, 'tip_roll_off_distance': 3.0}\n" + ] + } + ], + "source": [ + "tip_spots = tip_rack['A1:B1']\n", + "await lh.pick_up_tips(tip_spots)\n", + "\n", + "await lh.aspirate(\n", + " plate[\"A1:B1\"],\n", + " vols=[35, 25],\n", + " liquid_height=[3, 3],\n", + " z_liquid_exit_speed = [25, 25],\n", + ")\n", + "\n", + "await lh.dispense(\n", + " plate['A7:B7'],\n", + " vols=[35, 25],\n", + " liquid_height=[3, 3],\n", + " z_liquid_exit_speed = [25, 25],\n", + ")\n", + "\n", + "#await lh.drop_tips(tip_spots)\n", + "await lh.discard_tips()" + ] + }, + { + "cell_type": "markdown", + "id": "42046278", + "metadata": {}, + "source": [ + "## 6. Plate movement with CORE gripper (deck[0] → deck[1])" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "3c401d56", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG - pick_up_resource(resource=plate, offset=Coordinate(000.000, 000.000, 000.000), pickup_distance_from_top=None, direction=GripDirection.FRONT)\n", + "DEBUG - No preferred pickup location for resource plate. Using default pickup distance of 5mm.\n", + "DEBUG - PrepPickUpTool parameters: {'tip_definition': TipPickupParameters(default_values=False, volume=1.0, length=22.9, tip_type=, has_filter=False, is_needle=False, is_tool=True), 'tool_position_x': 290.0, 'tool_position_z': 62.5, 'front_channel_position_y': 257.5, 'rear_channel_position_y': 275.5, 'tool_seek': 72.5, 'tool_x_radius': 2.0, 'tool_y_radius': 2.0}\n", + "DEBUG - PrepMoveZUpToSafe parameters: {'channels': [, ]}\n", + "DEBUG - PrepPickUpPlate parameters: {'plate_top_center': XYZCoord(default_values=False, x_position=63.5, y_position=44.255, z_position=18.57), 'plate': PlateDimensions(default_values=False, length=127.0, width=85.51, height=14.82), 'clearance_y': 2.5, 'grip_speed_y': 5.0, 'grip_distance': 4.5, 'grip_height': 13.57}\n", + "DEBUG - drop_resource(destination=spot_0_1, offset=Coordinate(000.000, 000.000, 000.000), direction=GripDirection.FRONT)\n", + "DEBUG - PrepDropPlate parameters: {'plate_top_center': XYZCoord(default_values=False, x_position=63.5, y_position=139.38, z_position=13.57), 'clearance_y': 3.0, 'acceleration_scale_x': 1}\n", + "DEBUG - PrepMoveZUpToSafe parameters: {'channels': [, ]}\n", + "DEBUG - PrepDropTool parameters: {}\n" + ] + } + ], + "source": [ + "await lh.pick_up_resource(plate)\n", + "await lh.drop_resource(destination=deck[1], return_gripper=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "INFO - Closing connection to socket 192.168.100.102:2000\n", + "INFO - Hamilton TCP client stopped\n" + ] + } + ], + "source": [ + "logging.getLogger(\"pylabrobot\").setLevel(logging.INFO)\n", + "\n", + "await lh.backend.park()\n", + "await lh.backend.disco_mode()\n", + "await lh.stop()" + ] + } + ], + "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/nimbus-dev/nimbus_aspirate_dispense_demo.ipynb b/nimbus-dev/nimbus_aspirate_dispense_demo.ipynb deleted file mode 100644 index a9b8e7e658f..00000000000 --- a/nimbus-dev/nimbus_aspirate_dispense_demo.ipynb +++ /dev/null @@ -1,762 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Nimbus Aspirate and Dispense Demo\n", - "\n", - "This notebook demonstrates aspirate and dispense operations with the Hamilton Nimbus backend.\n", - "\n", - "The demo covers:\n", - "1. Creating a Nimbus Deck and assigning resources\n", - "2. Setting up the NimbusBackend and LiquidHandler\n", - "3. Picking up tips from the tip rack\n", - "4. Aspirating 50 µL from wells (2mm above bottom)\n", - "5. Dispensing to wells (2mm above bottom)\n", - "6. Dropping tips to waste\n", - "7. Cleaning up and closing the connection\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Deck created: deck\n", - " Size: 831.85 x 424.18 x 300.0 mm\n", - " Rails: 30\n", - "\n", - "Tip rack assigned: HAM_FTR_300_0001\n", - "Wellplate assigned: Cor_96_wellplate_2mL_Vb_0001\n", - " Waste block: default_long_block\n", - "LiquidHandler created successfully\n", - "INFO - Connecting to TCP server 192.168.100.100:2000...\n", - "INFO - Connected to TCP server 192.168.100.100:2000\n", - "INFO - Initializing Hamilton connection...\n", - "INFO - [INIT] Sending Protocol 7 initialization packet:\n", - "INFO - [INIT] Length: 28 bytes\n", - "INFO - [INIT] Hex: 1a 00 07 30 00 00 00 00 03 00 01 10 00 00 00 00 02 10 00 00 01 00 04 10 00 00 1e 00\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO - [INIT] Received response:\n", - "INFO - [INIT] Length: 28 bytes\n", - "INFO - [INIT] Hex: 1a 00 07 30 00 00 00 00 03 00 01 11 00 00 02 00 02 11 07 00 01 00 04 11 00 00 1e 00\n", - "INFO - [INIT] ✓ Client ID: 2, Address: 2:2:65535\n", - "INFO - Registering Hamilton client...\n", - "INFO - [REGISTER] Sending registration packet:\n", - "INFO - [REGISTER] Length: 48 bytes, Seq: 1\n", - "INFO - [REGISTER] Hex: 2e 00 06 30 00 00 02 00 02 00 ff ff 00 00 00 00 fe ff 01 00 03 03 2a 00 00 00 00 00 00 00 00 00 00 00 02 00 02 00 ff ff 00 00 00 00 00 00 00 00\n", - "INFO - [REGISTER] Src: 2:2:65535, Dst: 0:0:65534\n", - "INFO - [REGISTER] Received response:\n", - "INFO - [REGISTER] Length: 48 bytes\n", - "INFO - [REGISTER] ✓ Registration complete\n", - "INFO - Discovering Hamilton root objects...\n", - "INFO - [DISCOVER_ROOT] Sending root object discovery:\n", - "INFO - [DISCOVER_ROOT] Length: 52 bytes, Seq: 2\n", - "INFO - [DISCOVER_ROOT] Hex: 32 00 06 30 00 00 02 00 02 00 ff ff 00 00 00 00 fe ff 02 00 03 13 2e 00 00 00 00 00 0c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 04 00 05 02 02 01\n", - "INFO - [DISCOVER_ROOT] ✓ Found 1 root objects\n", - "INFO - ✓ Discovery complete: 1 root objects\n", - "INFO - Hamilton backend setup complete. Client ID: 2\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 0\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:259\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 1\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:263\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 2\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:768\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 3\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:260\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 4\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:257\n", - "INFO - Found Pipette at 1:1:257\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 5\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:262\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 6\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:261\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 7\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:265\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 8\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:266\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 9\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:258\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 10\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:48880\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 11\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:270\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 12\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:271\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 13\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:269\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 14\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:384\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 15\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49152\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 16\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49408\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 17\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:272\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 18\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49409\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 19\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:273\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 20\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49410\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 21\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:274\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 22\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:49411\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 23\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:275\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 24\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:264\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 25\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:1:268\n", - "INFO - Found DoorLock at 1:1:268\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 26\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:128:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 27\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:129:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 28\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:96:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 29\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 1:32:48896\n", - "INFO - GetSubobjectAddressCommand parameters:\n", - "INFO - object_address: 1:1:48896\n", - "INFO - subobject_index: 30\n", - "INFO - GetObjectCommand parameters:\n", - "INFO - object_address: 96:1:48896\n", - "INFO - GetChannelConfiguration_1 parameters:\n", - "INFO - Channel configuration: 4 channels\n", - "INFO - IsTipPresent parameters:\n", - "INFO - Tip presence: [0, 0, 0, 0]\n", - "INFO - IsInitialized parameters:\n", - "INFO - Instrument initialized: False\n", - "INFO - IsDoorLocked parameters:\n", - "INFO - LockDoor parameters:\n", - "INFO - Door locked successfully\n", - "INFO - SetChannelConfiguration parameters:\n", - "INFO - channel: 1\n", - "INFO - indexes: [1, 3, 4]\n", - "INFO - enables: [True, False, False, False]\n", - "INFO - SetChannelConfiguration parameters:\n", - "INFO - channel: 2\n", - "INFO - indexes: [1, 3, 4]\n", - "INFO - enables: [True, False, False, False]\n", - "INFO - SetChannelConfiguration parameters:\n", - "INFO - channel: 3\n", - "INFO - indexes: [1, 3, 4]\n", - "INFO - enables: [True, False, False, False]\n", - "INFO - SetChannelConfiguration parameters:\n", - "INFO - channel: 4\n", - "INFO - indexes: [1, 3, 4]\n", - "INFO - enables: [True, False, False, False]\n", - "INFO - Channel configuration set for 4 channels\n", - "INFO - InitializeSmartRoll parameters:\n", - "INFO - x_positions: [55375, 55375, 55375, 55375]\n", - "INFO - y_positions: [1986, 188, -7615, -9413]\n", - "INFO - z_start_positions: [13539, 13539, 13539, 13539]\n", - "INFO - z_stop_positions: [13139, 13139, 13139, 13139]\n", - "INFO - z_final_positions: [14600, 14600, 14600, 14600]\n", - "INFO - roll_distances: [900, 900, 900, 900]\n", - "INFO - NimbusCore initialized with InitializeSmartRoll successfully\n", - "\n", - "============================================================\n", - "SETUP COMPLETE\n", - "============================================================\n", - "Setup finished: True\n", - "\n", - "Instrument Configuration:\n", - " Number of channels: 4\n" - ] - } - ], - "source": [ - "# Import necessary modules\n", - "import sys\n", - "import logging\n", - "\n", - "from pylabrobot.liquid_handling import LiquidHandler\n", - "from pylabrobot.liquid_handling.backends.hamilton.nimbus_backend import NimbusBackend\n", - "from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck\n", - "from pylabrobot.resources.hamilton.tip_racks import hamilton_96_tiprack_300uL_filter\n", - "from pylabrobot.resources.corning import Cor_96_wellplate_2mL_Vb\n", - "from pylabrobot.resources.coordinate import Coordinate\n", - "\n", - "# Setup logging\n", - "plr_logger = logging.getLogger('pylabrobot')\n", - "plr_logger.setLevel(logging.INFO) # INFO for normal use, DEBUG for troubleshooting\n", - "plr_logger.handlers.clear()\n", - "console_handler = logging.StreamHandler(sys.stdout)\n", - "console_handler.setFormatter(logging.Formatter('%(levelname)s - %(message)s'))\n", - "plr_logger.addHandler(console_handler)\n", - "\n", - "# ========================================================================\n", - "# CREATE DECK AND RESOURCES (using coordinates from nimbus_deck_setup.ipynb)\n", - "# ========================================================================\n", - "\n", - "# Create NimbusDeck using default values (layout 8 dimensions)\n", - "deck = NimbusDeck()\n", - "\n", - "print(f\"Deck created: {deck.name}\")\n", - "print(f\" Size: {deck.get_size_x()} x {deck.get_size_y()} x {deck.get_size_z()} mm\")\n", - "print(f\" Rails: {deck.num_rails}\")\n", - "\n", - "# Create and assign tip rack (HAM_FTR_300_0001)\n", - "# Using pre-calculated origin from nimbus_deck_setup.ipynb output:\n", - "# Tip rack origin (PyLabRobot): Coordinate(305.750, 126.537, 128.620)\n", - "tip_rack = hamilton_96_tiprack_300uL_filter(name=\"HAM_FTR_300_0001\", with_tips=True)\n", - "deck.assign_child_resource(tip_rack, location=Coordinate(x=305.750, y=126.537, z=128.620))\n", - "\n", - "print(f\"\\nTip rack assigned: {tip_rack.name}\")\n", - "\n", - "# Create and assign wellplate (Cor_96_wellplate_2mL_Vb_0001)\n", - "# Using pre-calculated origin from nimbus_deck_setup.ipynb output:\n", - "# Wellplate origin (PyLabRobot): Coordinate(438.070, 124.837, 101.490)\n", - "wellplate = Cor_96_wellplate_2mL_Vb(name=\"Cor_96_wellplate_2mL_Vb_0001\", with_lid=False)\n", - "deck.assign_child_resource(wellplate, location=Coordinate(x=438.070, y=124.837, z=101.490))\n", - "\n", - "print(f\"Wellplate assigned: {wellplate.name}\")\n", - "print(f\" Waste block: {deck.get_resource('default_long_block').name}\")\n", - "\n", - "# Serialize the deck #\n", - "#serialized = deck.serialize()\n", - "#with open(\"test_nimbus_deck.json\", \"w\") as f:\n", - "# json.dump(serialized, f, indent=2)\n", - "\n", - "# Load from file and deserialize\n", - "#with open(\"test_nimbus_deck.json\", \"r\") as f:\n", - "# deck_data = json.load(f)\n", - "# Read deck from file example\n", - "# loaded_deck = NimbusDeck.deserialize(deck_data)\n", - "\n", - "# Create NimbusBackend instance\n", - "# Replace with your instrument's IP address\n", - "backend = NimbusBackend(\n", - " host=\"192.168.100.100\", # Replace with your instrument's IP\n", - " port=2000,\n", - " read_timeout=30,\n", - " write_timeout=30\n", - ")\n", - "\n", - "# Create LiquidHandler with backend and deck\n", - "lh = LiquidHandler(backend=backend, deck=deck)\n", - "\n", - "print(\"LiquidHandler created successfully\")\n", - "\n", - "# Setup the robot\n", - "await lh.setup(unlock_door=False)\n", - "\n", - "print(\"\\n\" + \"=\"*60)\n", - "print(\"SETUP COMPLETE\")\n", - "print(\"=\"*60)\n", - "print(f\"Setup finished: {backend.setup_finished}\")\n", - "print(f\"\\nInstrument Configuration:\")\n", - "print(f\" Number of channels: {backend.num_channels}\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Define Resources" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Tip rack: HAM_FTR_300_0001 (96 tips)\n", - "Source/Destination plate: Cor_96_wellplate_2mL_Vb_0001 (using same plate, different wells)\n", - "Waste positions: ['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4']\n" - ] - } - ], - "source": [ - "# Resources are already created in the setup cell above\n", - "# tip_rack and wellplate variables are available\n", - "\n", - "print(f\"Tip rack: {tip_rack.name} ({tip_rack.num_items} tips)\")\n", - "print(f\"Source/Destination plate: {wellplate.name} (using same plate, different wells)\")\n", - "\n", - "# Use wellplate as both source and destination\n", - "source_plate = wellplate\n", - "destination_plate = wellplate\n", - "\n", - "# Get waste positions\n", - "waste_block = deck.get_resource(\"default_long_block\")\n", - "waste_positions = waste_block.children[:4]\n", - "\n", - "print(f\"Waste positions: {[wp.name for wp in waste_positions]}\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Pick Up Tips\n", - "\n", - "Pick up tips from positions A1-D1.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Picking up tips from positions: ['E4', 'F4', 'G4', 'H4']\n", - "INFO - IsTipPresent parameters:\n", - "INFO - PickupTips parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [18844, 18844, 18844, 18844]\n", - "INFO - y_positions: [-20499, -21399, -22299, -23199]\n", - "INFO - traverse_height: 14600\n", - "INFO - z_start_positions: [13802, 13802, 13802, 13802]\n", - "INFO - z_stop_positions: [13002, 13002, 13002, 13002]\n", - "INFO - tip_types: [, , , ]\n", - "INFO - num_channels: 4\n", - "INFO - PickupTips parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [18844, 18844, 18844, 18844]\n", - "INFO - y_positions: [-20499, -21399, -22299, -23199]\n", - "INFO - traverse_height: 14600\n", - "INFO - z_start_positions: [13802, 13802, 13802, 13802]\n", - "INFO - z_stop_positions: [13002, 13002, 13002, 13002]\n", - "INFO - tip_types: [, , , ]\n", - "INFO - Picked up tips on channels [0, 1, 2, 3]\n", - "✓ Tips picked up successfully!\n" - ] - } - ], - "source": [ - "# Get the first 4 tip spots (A1, B1, C1, D1)\n", - "tip_spots = tip_rack[\"E4\":\"A5\"]\n", - "\n", - "print(f\"Picking up tips from positions: {[ts.get_identifier() for ts in tip_spots]}\")\n", - "await lh.pick_up_tips(tip_spots)\n", - "\n", - "print(\"✓ Tips picked up successfully!\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Aspirate Operation\n", - "\n", - "Aspirate 50 µL from wells A1-D1, 2mm above the bottom of the well.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Aspirating 50 µL from wells: ['A7', 'B7', 'C7', 'D7']\n", - " Liquid height: 2.0 mm above bottom\n", - "INFO - DisableADC parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - Disabled ADC before aspirate\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 1\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 2\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 3\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 4\n", - "INFO - indexes: [2]\n", - "INFO - Aspirate parameters:\n", - "INFO - aspirate_type: [0, 0, 0, 0]\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [35016, 35016, 35016, 35016]\n", - "INFO - y_positions: [-16899, -17799, -18699, -19599]\n", - "INFO - traverse_height: 14600\n", - "INFO - liquid_seek_height: [500, 500, 500, 500]\n", - "INFO - liquid_surface_height: [10594, 10594, 10594, 10594]\n", - "INFO - submerge_depth: [0, 0, 0, 0]\n", - "INFO - follow_depth: [0, 0, 0, 0]\n", - "INFO - z_min_position: [10394, 10394, 10394, 10394]\n", - "INFO - clot_check_height: [0, 0, 0, 0]\n", - "INFO - z_final: 14600\n", - "INFO - liquid_exit_speed: [200, 200, 200, 200]\n", - "INFO - blowout_volume: [400, 400, 400, 400]\n", - "INFO - prewet_volume: [0, 0, 0, 0]\n", - "INFO - aspirate_volume: [500, 500, 500, 500]\n", - "INFO - transport_air_volume: [50, 50, 50, 50]\n", - "INFO - aspirate_speed: [2500, 2500, 2500, 2500]\n", - "INFO - settling_time: [10, 10, 10, 10]\n", - "INFO - mix_volume: [0, 0, 0, 0]\n", - "INFO - mix_cycles: [0, 0, 0, 0]\n", - "INFO - mix_position: [0, 0, 0, 0]\n", - "INFO - mix_follow_distance: [0, 0, 0, 0]\n", - "INFO - mix_speed: [2500, 2500, 2500, 2500]\n", - "INFO - tube_section_height: [0, 0, 0, 0]\n", - "INFO - tube_section_ratio: [0, 0, 0, 0]\n", - "INFO - lld_mode: [0, 0, 0, 0]\n", - "INFO - capacitive_lld_sensitivity: [0, 0, 0, 0]\n", - "INFO - pressure_lld_sensitivity: [0, 0, 0, 0]\n", - "INFO - lld_height_difference: [0, 0, 0, 0]\n", - "INFO - tadm_enabled: False\n", - "INFO - limit_curve_index: [0, 0, 0, 0]\n", - "INFO - recording_mode: 0\n", - "INFO - Aspirated on channels [0, 1, 2, 3]\n", - "✓ Aspiration complete!\n" - ] - } - ], - "source": [ - "# Get source wells (A1, B1, C1, D1)\n", - "source_wells = source_plate[\"A7\":\"E7\"]\n", - "\n", - "print(f\"Aspirating 50 µL from wells: {[w.get_identifier() for w in source_wells]}\")\n", - "print(f\" Liquid height: 2.0 mm above bottom\")\n", - "\n", - "# Aspirate with liquid_height=2.0mm\n", - "# Tips are already picked up, so LiquidHandler will use them automatically\n", - "await lh.aspirate(\n", - " source_wells,\n", - " vols=[50.0, 50.0, 50.0, 50.0], # Can be a single number (applies to all channels) or a list\n", - " liquid_height=[2.0, 2.0, 2.0, 2.0], # 2mm above bottom of well (can be a single float or list)\n", - " flow_rates=[250.0, 250.0, 250.0, 250.0],\n", - " liquid_seek_height=[5.0, 5.0, 5.0, 5.0],\n", - ")\n", - "\n", - "print(\"✓ Aspiration complete!\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Dispense Operation\n", - "\n", - "Dispense 50 µL to wells A2-D2, 2mm above the bottom of the well.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Dispensing 50 µL to wells: ['A12', 'B12', 'C12', 'D12']\n", - " Liquid height: 2.0 mm above bottom\n", - "INFO - DisableADC parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - Disabled ADC before dispense\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 1\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 2\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 3\n", - "INFO - indexes: [2]\n", - "INFO - GetChannelConfiguration parameters:\n", - "INFO - channel: 4\n", - "INFO - indexes: [2]\n", - "INFO - Dispense parameters:\n", - "INFO - dispense_type: [0, 0, 0, 0]\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [39516, 39516, 39516, 39516]\n", - "INFO - y_positions: [-16899, -17799, -18699, -19599]\n", - "INFO - traverse_height: 14600\n", - "INFO - liquid_seek_height: [500, 500, 500, 500]\n", - "INFO - dispense_height: [10594, 10594, 10594, 10594]\n", - "INFO - submerge_depth: [0, 0, 0, 0]\n", - "INFO - follow_depth: [0, 0, 0, 0]\n", - "INFO - z_min_position: [10394, 10394, 10394, 10394]\n", - "INFO - z_final: 14600\n", - "INFO - liquid_exit_speed: [200, 200, 200, 200]\n", - "INFO - transport_air_volume: [50, 50, 50, 50]\n", - "INFO - dispense_volume: [500, 500, 500, 500]\n", - "INFO - stop_back_volume: [0, 0, 0, 0]\n", - "INFO - blowout_volume: [400, 400, 400, 400]\n", - "INFO - dispense_speed: [4000, 4000, 4000, 4000]\n", - "INFO - cutoff_speed: [250, 250, 250, 250]\n", - "INFO - settling_time: [10, 10, 10, 10]\n", - "INFO - mix_volume: [0, 0, 0, 0]\n", - "INFO - mix_cycles: [0, 0, 0, 0]\n", - "INFO - mix_position: [0, 0, 0, 0]\n", - "INFO - mix_follow_distance: [0, 0, 0, 0]\n", - "INFO - mix_speed: [4000, 4000, 4000, 4000]\n", - "INFO - touch_off_distance: 0\n", - "INFO - dispense_offset: [0, 0, 0, 0]\n", - "INFO - tube_section_height: [0, 0, 0, 0]\n", - "INFO - tube_section_ratio: [0, 0, 0, 0]\n", - "INFO - lld_mode: [0, 0, 0, 0]\n", - "INFO - capacitive_lld_sensitivity: [0, 0, 0, 0]\n", - "INFO - tadm_enabled: False\n", - "INFO - limit_curve_index: [0, 0, 0, 0]\n", - "INFO - recording_mode: 0\n", - "INFO - Dispensed on channels [0, 1, 2, 3]\n", - "✓ Dispense complete!\n" - ] - } - ], - "source": [ - "# Get destination wells (A2, B2, C2, D2)\n", - "dest_wells = destination_plate[\"A12\":\"E12\"]\n", - "\n", - "print(f\"Dispensing 50 µL to wells: {[w.get_identifier() for w in dest_wells]}\")\n", - "print(f\" Liquid height: 2.0 mm above bottom\")\n", - "\n", - "# Dispense with liquid_height=2.0mm\n", - "# Tips are already picked up, so LiquidHandler will use them automatically\n", - "await lh.dispense(\n", - " dest_wells,\n", - " vols=[50.0, 50.0, 50.0, 50.0], # Can be a single number (applies to all channels) or a list\n", - " liquid_height=[2.0, 2.0, 2.0, 2.0], # 2mm above bottom of well (can be a single float or list)\n", - " flow_rates=[400.0, 400.0, 400.0, 400.0],\n", - " liquid_seek_height=[5.0, 5.0, 5.0, 5.0],\n", - ")\n", - "\n", - "print(\"✓ Dispense complete!\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Drop Tips\n", - "\n", - "Drop tips to waste positions.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Dropping tips at waste positions: ['default_long_1', 'default_long_2', 'default_long_3', 'default_long_4']\n", - "INFO - DropTipsRoll parameters:\n", - "INFO - tips_used: [1, 1, 1, 1]\n", - "INFO - x_positions: [55375, 55375, 55375, 55375]\n", - "INFO - y_positions: [1986, 188, -7615, -9413]\n", - "INFO - traverse_height: 14600\n", - "INFO - z_start_positions: [13539, 13539, 13539, 13539]\n", - "INFO - z_stop_positions: [13139, 13139, 13139, 13139]\n", - "INFO - z_final_positions: [14600, 14600, 14600, 14600]\n", - "INFO - roll_distances: [900, 900, 900, 900]\n", - "INFO - Dropped tips on channels [0, 1, 2, 3]\n", - "✓ Tips dropped successfully!\n" - ] - } - ], - "source": [ - "print(f\"Dropping tips at waste positions: {[wp.name for wp in waste_positions]}\")\n", - "await lh.drop_tips(waste_positions)\n", - "\n", - "print(\"✓ Tips dropped successfully!\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Cleanup\n", - "\n", - "Finally, we'll stop the liquid handler and close the connection.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO - Park parameters:\n", - "INFO - Instrument parked successfully\n", - "INFO - UnlockDoor parameters:\n", - "INFO - Door unlocked successfully\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "WARNING:root:Closing connection to TCP server.\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "INFO - Hamilton backend stopped\n", - "Connection closed successfully\n" - ] - } - ], - "source": [ - "# Stop and close connection\n", - "await lh.backend.park()\n", - "await lh.backend.unlock_door()\n", - "await lh.stop()\n", - "\n", - "print(\"Connection closed successfully\")\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "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.10.18" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/pylabrobot/liquid_handling/backends/hamilton/__init__.py b/pylabrobot/liquid_handling/backends/hamilton/__init__.py index 4c36917049f..7d19b3f863e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/__init__.py +++ b/pylabrobot/liquid_handling/backends/hamilton/__init__.py @@ -1,6 +1,7 @@ """Hamilton backends for liquid handling.""" from .base import HamiltonLiquidHandler +from .prep_backend import PrepBackend from .pump import Pump # TODO: move elsewhere. from .STAR_backend import STAR from .vantage_backend import Vantage diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index 7018e22590b..ca65ad28821 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -1,29 +1,40 @@ """Hamilton Nimbus backend implementation. -This module provides the NimbusBackend class for controlling Hamilton Nimbus -instruments via TCP communication using the Hamilton protocol. +NimbusBackend composes HamiltonTCPClient as self.client for TCP and introspection. +Callers may pass host and optionally port for default TCP settings, or inject a +pre-configured client (dependency injection). +Interfaces: self.client.interfaces..address for routing. Optional presence +via .is_available or firmware probe (DoorLock uses .is_available). """ from __future__ import annotations import enum import logging -from typing import Dict, List, Optional, Sequence, Tuple, TypeVar, Union +from dataclasses import dataclass +from typing import Dict, List, Optional, Sequence, Tuple, TypeVar, Union, overload +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( - HamiltonIntrospection, -) -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( - HoiParams, - HoiParamsParser, -) +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( - HamiltonProtocol, +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + I32, + U16, + Bool, + BoolArray, + I16Array, + I32Array, + U16Array, + U32Array, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import ( + HamiltonInterfaceResolver, + HamiltonTCPClient, + InterfaceSpec, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend from pylabrobot.liquid_handling.standard import ( Drop, DropTipRack, @@ -144,761 +155,281 @@ def _get_default_flow_rate(tip: Tip, is_aspirate: bool) -> float: # ============================================================================ -class LockDoor(HamiltonCommand): - """Lock door command (DoorLock at 1:1:268, interface_id=1, command_id=1).""" +@dataclass +class NimbusCommand(HamiltonCommand): + """Base for Nimbus commands. Subclasses are dataclasses with dest + Annotated payload fields. + + build_parameters() -> HoiParams.from_struct(self); dest is skipped (no Annotated). + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 1 + dest: Address + + def __post_init__(self) -> None: + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return HoiParams.from_struct(self) + + +@dataclass +class LockDoor(NimbusCommand): + """Lock door command (DoorLock at 1:1:268, interface_id=1, command_id=1).""" + command_id = 1 -class UnlockDoor(HamiltonCommand): +@dataclass +class UnlockDoor(NimbusCommand): """Unlock door command (DoorLock at 1:1:268, interface_id=1, command_id=2).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 2 -class IsDoorLocked(HamiltonCommand): +@dataclass +class IsDoorLocked(NimbusCommand): """Check if door is locked (DoorLock at 1:1:268, interface_id=1, command_id=3).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 3 action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsDoorLocked response.""" - parser = HoiParamsParser(data) - _, locked = parser.parse_next() - return {"locked": bool(locked)} + @dataclass(frozen=True) + class Response: + locked: Bool -class PreInitializeSmart(HamiltonCommand): +@dataclass +class PreInitializeSmart(NimbusCommand): """Pre-initialize smart command (Pipette at 1:1:257, interface_id=1, command_id=32).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 32 -class InitializeSmartRoll(HamiltonCommand): +@dataclass +class InitializeSmartRoll(NimbusCommand): """Initialize smart roll command (NimbusCore at 1:1:48896, interface_id=1, command_id=29).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 29 + # All position/distance fields in 0.01 mm units + x_positions: I32Array + y_positions: I32Array + begin_tip_deposit_process: I32Array # Z start positions + end_tip_deposit_process: I32Array # Z stop positions + z_position_at_end_of_a_command: I32Array + roll_distances: I32Array - def __init__( - self, - dest: Address, - x_positions: List[int], - y_positions: List[int], - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - roll_distances: List[int], - ): - """Initialize InitializeSmartRoll command. - Args: - dest: Destination address (NimbusCore) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - roll_distances: Roll distances in 0.01mm units - """ - super().__init__(dest) - self.x_positions = x_positions - self.y_positions = y_positions - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.roll_distances = roll_distances - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .i32_array(self.roll_distances) - ) - - -class IsInitialized(HamiltonCommand): +@dataclass +class IsInitialized(NimbusCommand): """Check if instrument is initialized (NimbusCore at 1:1:48896, interface_id=1, command_id=14).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 14 action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsInitialized response.""" - parser = HoiParamsParser(data) - _, initialized = parser.parse_next() - return {"initialized": bool(initialized)} + @dataclass(frozen=True) + class Response: + value: Bool -class IsTipPresent(HamiltonCommand): +@dataclass +class IsTipPresent(NimbusCommand): """Check tip presence (Pipette at 1:1:257, interface_id=1, command_id=16).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 16 action_code = 0 - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse IsTipPresent response - returns List[i16].""" - parser = HoiParamsParser(data) - # Parse array of i16 values representing tip presence per channel - _, tip_presence = parser.parse_next() - return {"tip_present": tip_presence} + @dataclass(frozen=True) + class Response: + tip_present: I16Array -class GetChannelConfiguration_1(HamiltonCommand): +@dataclass +class GetChannelConfiguration_1(NimbusCommand): """Get channel configuration (NimbusCore root, interface_id=1, command_id=15).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 15 action_code = 0 - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration_1 response. + @dataclass(frozen=True) + class Response: + channels: U16 + channel_types: I16Array - Returns: (channels: u16, channel_types: List[i16]) - """ - parser = HoiParamsParser(data) - _, channels = parser.parse_next() - _, channel_types = parser.parse_next() - return {"channels": channels, "channel_types": channel_types} - -class SetChannelConfiguration(HamiltonCommand): +@dataclass +class SetChannelConfiguration(NimbusCommand): """Set channel configuration (Pipette at 1:1:257, interface_id=1, command_id=67).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 67 + channel: U16 # Channel number (1-based) + indexes: I16Array # e.g. [1,3,4]: 1=Tip Recognition, 2=pLLD, 3=cLLD aspirate, 4=cLLD clot + enables: BoolArray # Enable flag per index - def __init__( - self, - dest: Address, - channel: int, - indexes: List[int], - enables: List[bool], - ): - """Initialize SetChannelConfiguration command. - Args: - dest: Destination address (Pipette) - channel: Channel number (1-based) - indexes: List of configuration indexes (e.g., [1, 3, 4]) - 1: Tip Recognition, 2: Aspirate and clot monitoring pLLD, - 3: Aspirate monitoring with cLLD, 4: Clot monitoring with cLLD - enables: List of enable flags (e.g., [True, False, False, False]) - """ - super().__init__(dest) - self.channel = channel - self.indexes = indexes - self.enables = enables - - def build_parameters(self) -> HoiParams: - return HoiParams().u16(self.channel).i16_array(self.indexes).bool_array(self.enables) - - -class Park(HamiltonCommand): +@dataclass +class Park(NimbusCommand): """Park command (NimbusCore at 1:1:48896, interface_id=1, command_id=3).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 3 -class PickupTips(HamiltonCommand): +@dataclass +class PickupTips(NimbusCommand): """Pick up tips command (Pipette at 1:1:257, interface_id=1, command_id=4).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 4 + channels_involved: U16Array # Tip pattern (1=active, 0=inactive per channel) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + begin_tip_pick_up_process: I32Array # Z start, 0.01 mm + end_tip_pick_up_process: I32Array # Z stop, 0.01 mm + tip_types: U16Array # Tip type id per channel - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_pick_up_process: List[int], - end_tip_pick_up_process: List[int], - tip_types: List[int], - ): - """Initialize PickupTips command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_pick_up_process: Z start positions in 0.01mm units - end_tip_pick_up_process: Z stop positions in 0.01mm units - tip_types: Tip type integers for each channel - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_pick_up_process = begin_tip_pick_up_process - self.end_tip_pick_up_process = end_tip_pick_up_process - self.tip_types = tip_types - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_pick_up_process) - .i32_array(self.end_tip_pick_up_process) - .u16_array(self.tip_types) - ) - - -class DropTips(HamiltonCommand): +@dataclass +class DropTips(NimbusCommand): """Drop tips command (Pipette at 1:1:257, interface_id=1, command_id=5).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 5 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - default_waste: bool, - ): - """Initialize DropTips command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - default_waste: If True, drop to default waste (positions may be ignored) - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.default_waste = default_waste - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .bool_value(self.default_waste) - ) - - -class DropTipsRoll(HamiltonCommand): + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + begin_tip_deposit_process: I32Array # Z start, 0.01 mm + end_tip_deposit_process: I32Array # Z stop, 0.01 mm + z_position_at_end_of_a_command: I32Array # 0.01 mm + default_waste: Bool # If True, drop to default waste (positions may be ignored) + + +@dataclass +class DropTipsRoll(NimbusCommand): """Drop tips with roll command (Pipette at 1:1:257, interface_id=1, command_id=82).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 82 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - begin_tip_deposit_process: List[int], - end_tip_deposit_process: List[int], - z_position_at_end_of_a_command: List[int], - roll_distances: List[int], - ): - """Initialize DropTipsRoll command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - begin_tip_deposit_process: Z start positions in 0.01mm units - end_tip_deposit_process: Z stop positions in 0.01mm units - z_position_at_end_of_a_command: Z position at end of command in 0.01mm units - roll_distances: Roll distance for each channel in 0.01mm units - """ - super().__init__(dest) - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.begin_tip_deposit_process = begin_tip_deposit_process - self.end_tip_deposit_process = end_tip_deposit_process - self.z_position_at_end_of_a_command = z_position_at_end_of_a_command - self.roll_distances = roll_distances - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.begin_tip_deposit_process) - .i32_array(self.end_tip_deposit_process) - .i32_array(self.z_position_at_end_of_a_command) - .i32_array(self.roll_distances) - ) - - -class EnableADC(HamiltonCommand): + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + begin_tip_deposit_process: I32Array # Z start, 0.01 mm + end_tip_deposit_process: I32Array # Z stop, 0.01 mm + z_position_at_end_of_a_command: I32Array # 0.01 mm + roll_distances: I32Array # 0.01 mm per channel + + +@dataclass +class EnableADC(NimbusCommand): """Enable ADC command (Pipette at 1:1:257, interface_id=1, command_id=43).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 43 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize EnableADC command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - """ - super().__init__(dest) - self.channels_involved = channels_involved - - def build_parameters(self) -> HoiParams: - return HoiParams().u16_array(self.channels_involved) + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) -class DisableADC(HamiltonCommand): +@dataclass +class DisableADC(NimbusCommand): """Disable ADC command (Pipette at 1:1:257, interface_id=1, command_id=44).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 44 - - def __init__( - self, - dest: Address, - channels_involved: List[int], - ): - """Initialize DisableADC command. - - Args: - dest: Destination address (Pipette) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - """ - super().__init__(dest) - self.channels_involved = channels_involved - - def build_parameters(self) -> HoiParams: - return HoiParams().u16_array(self.channels_involved) + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) -class GetChannelConfiguration(HamiltonCommand): +@dataclass +class GetChannelConfiguration(NimbusCommand): """Get channel configuration command (Pipette at 1:1:257, interface_id=1, command_id=66).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 66 action_code = 0 # Must be 0 (STATUS_REQUEST), default is 3 (COMMAND_REQUEST) + channel: U16 # Channel number (1-based) + indexes: I16Array # e.g. [2] for "Aspirate monitoring with cLLD" - def __init__( - self, - dest: Address, - channel: int, - indexes: List[int], - ): - """Initialize GetChannelConfiguration command. - - Args: - dest: Destination address (Pipette) - channel: Channel number (1-based) - indexes: List of configuration indexes (e.g., [2] for "Aspirate monitoring with cLLD") - """ - super().__init__(dest) - self.channel = channel - self.indexes = indexes - - def build_parameters(self) -> HoiParams: - return HoiParams().u16(self.channel).i16_array(self.indexes) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse GetChannelConfiguration response. - - Returns: { enabled: List[bool] } - """ - parser = HoiParamsParser(data) - _, enabled = parser.parse_next() - return {"enabled": enabled} + @dataclass(frozen=True) + class Response: + enabled: BoolArray -class Aspirate(HamiltonCommand): +@dataclass +class Aspirate(NimbusCommand): """Aspirate command (Pipette at 1:1:257, interface_id=1, command_id=6).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 6 - - def __init__( - self, - dest: Address, - aspirate_type: List[int], - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - lld_search_height: List[int], - liquid_height: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - minimum_height: List[int], - clot_detection_height: List[int], - min_z_endpos: int, - swap_speed: List[int], - blow_out_air_volume: List[int], - pre_wetting_volume: List[int], - aspirate_volume: List[int], - transport_air_volume: List[int], - aspiration_speed: List[int], - settling_time: List[int], - mix_volume: List[int], - mix_cycles: List[int], - mix_position_from_liquid_surface: List[int], - mix_surface_following_distance: List[int], - mix_speed: List[int], - tube_section_height: List[int], - tube_section_ratio: List[int], - lld_mode: List[int], - gamma_lld_sensitivity: List[int], - dp_lld_sensitivity: List[int], - lld_height_difference: List[int], - tadm_enabled: bool, - limit_curve_index: List[int], - recording_mode: int, - ): - """Initialize Aspirate command. - - Args: - dest: Destination address (Pipette) - aspirate_type: Aspirate type for each channel (List[i16]) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - lld_search_height: LLD search height for each channel in 0.01mm units - liquid_height: Liquid height for each channel in 0.01mm units - immersion_depth: Immersion depth for each channel in 0.01mm units - surface_following_distance: Surface following distance for each channel in 0.01mm units - minimum_height: Minimum height for each channel in 0.01mm units - clot_detection_height: Clot detection height for each channel in 0.01mm units - min_z_endpos: Minimum Z end position in 0.01mm units - swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units - blow_out_air_volume: Blowout volume for each channel in 0.1uL units - pre_wetting_volume: Pre-wetting volume for each channel in 0.1uL units - aspirate_volume: Aspirate volume for each channel in 0.1uL units - transport_air_volume: Transport air volume for each channel in 0.1uL units - aspiration_speed: Aspirate speed for each channel in 0.1uL/s units - settling_time: Settling time for each channel in 0.1s units - mix_volume: Mix volume for each channel in 0.1uL units - mix_cycles: Mix cycles for each channel - mix_position_from_liquid_surface: Mix position from liquid surface for each channel in 0.01mm units - mix_surface_following_distance: Mix follow distance for each channel in 0.01mm units - mix_speed: Mix speed for each channel in 0.1uL/s units - tube_section_height: Tube section height for each channel in 0.01mm units - tube_section_ratio: Tube section ratio for each channel - lld_mode: LLD mode for each channel (List[i16]) - gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) - dp_lld_sensitivity: DP LLD sensitivity for each channel (List[i16]) - lld_height_difference: LLD height difference for each channel in 0.01mm units - tadm_enabled: TADM enabled flag - limit_curve_index: Limit curve index for each channel - recording_mode: Recording mode (u16) - """ - super().__init__(dest) - self.aspirate_type = aspirate_type - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.lld_search_height = lld_search_height - self.liquid_height = liquid_height - self.immersion_depth = immersion_depth - self.surface_following_distance = surface_following_distance - self.minimum_height = minimum_height - self.clot_detection_height = clot_detection_height - self.min_z_endpos = min_z_endpos - self.swap_speed = swap_speed - self.blow_out_air_volume = blow_out_air_volume - self.pre_wetting_volume = pre_wetting_volume - self.aspirate_volume = aspirate_volume - self.transport_air_volume = transport_air_volume - self.aspiration_speed = aspiration_speed - self.settling_time = settling_time - self.mix_volume = mix_volume - self.mix_cycles = mix_cycles - self.mix_position_from_liquid_surface = mix_position_from_liquid_surface - self.mix_surface_following_distance = mix_surface_following_distance - self.mix_speed = mix_speed - self.tube_section_height = tube_section_height - self.tube_section_ratio = tube_section_ratio - self.lld_mode = lld_mode - self.gamma_lld_sensitivity = gamma_lld_sensitivity - self.dp_lld_sensitivity = dp_lld_sensitivity - self.lld_height_difference = lld_height_difference - self.tadm_enabled = tadm_enabled - self.limit_curve_index = limit_curve_index - self.recording_mode = recording_mode - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i16_array(self.aspirate_type) - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.lld_search_height) - .i32_array(self.liquid_height) - .i32_array(self.immersion_depth) - .i32_array(self.surface_following_distance) - .i32_array(self.minimum_height) - .i32_array(self.clot_detection_height) - .i32(self.min_z_endpos) - .u32_array(self.swap_speed) - .u32_array(self.blow_out_air_volume) - .u32_array(self.pre_wetting_volume) - .u32_array(self.aspirate_volume) - .u32_array(self.transport_air_volume) - .u32_array(self.aspiration_speed) - .u32_array(self.settling_time) - .u32_array(self.mix_volume) - .u32_array(self.mix_cycles) - .i32_array(self.mix_position_from_liquid_surface) - .i32_array(self.mix_surface_following_distance) - .u32_array(self.mix_speed) - .i32_array(self.tube_section_height) - .i32_array(self.tube_section_ratio) - .i16_array(self.lld_mode) - .i16_array(self.gamma_lld_sensitivity) - .i16_array(self.dp_lld_sensitivity) - .i32_array(self.lld_height_difference) - .bool_value(self.tadm_enabled) - .u32_array(self.limit_curve_index) - .u16(self.recording_mode) - ) - - -class Dispense(HamiltonCommand): + aspirate_type: I16Array # Per channel (I16) + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + lld_search_height: I32Array # 0.01 mm + liquid_height: I32Array # 0.01 mm + immersion_depth: I32Array # 0.01 mm + surface_following_distance: I32Array # 0.01 mm + minimum_height: I32Array # 0.01 mm + clot_detection_height: I32Array # 0.01 mm + min_z_endpos: I32 # 0.01 mm + swap_speed: U32Array # 0.1 µL/s (on leaving liquid) + blow_out_air_volume: U32Array # 0.1 µL + pre_wetting_volume: U32Array # 0.1 µL + aspirate_volume: U32Array # 0.1 µL + transport_air_volume: U32Array # 0.1 µL + aspiration_speed: U32Array # 0.1 µL/s + settling_time: U32Array # 0.1 s + mix_volume: U32Array # 0.1 µL + mix_cycles: U32Array + mix_position_from_liquid_surface: I32Array # 0.01 mm + mix_surface_following_distance: I32Array # 0.01 mm + mix_speed: U32Array # 0.1 µL/s + tube_section_height: I32Array # 0.01 mm + tube_section_ratio: I32Array + lld_mode: I16Array + gamma_lld_sensitivity: I16Array + dp_lld_sensitivity: I16Array + lld_height_difference: I32Array # 0.01 mm + tadm_enabled: Bool + limit_curve_index: U32Array + recording_mode: U16 + + +@dataclass +class Dispense(NimbusCommand): """Dispense command (Pipette at 1:1:257, interface_id=1, command_id=7).""" - protocol = HamiltonProtocol.OBJECT_DISCOVERY - interface_id = 1 command_id = 7 - - def __init__( - self, - dest: Address, - dispense_type: List[int], - channels_involved: List[int], - x_positions: List[int], - y_positions: List[int], - minimum_traverse_height_at_beginning_of_a_command: int, - lld_search_height: List[int], - liquid_height: List[int], - immersion_depth: List[int], - surface_following_distance: List[int], - minimum_height: List[int], - min_z_endpos: int, - swap_speed: List[int], - transport_air_volume: List[int], - dispense_volume: List[int], - stop_back_volume: List[int], - blow_out_air_volume: List[int], - dispense_speed: List[int], - cut_off_speed: List[int], - settling_time: List[int], - mix_volume: List[int], - mix_cycles: List[int], - mix_position_from_liquid_surface: List[int], - mix_surface_following_distance: List[int], - mix_speed: List[int], - side_touch_off_distance: int, - dispense_offset: List[int], - tube_section_height: List[int], - tube_section_ratio: List[int], - lld_mode: List[int], - gamma_lld_sensitivity: List[int], - tadm_enabled: bool, - limit_curve_index: List[int], - recording_mode: int, - ): - """Initialize Dispense command. - - Args: - dest: Destination address (Pipette) - dispense_type: Dispense type for each channel (List[i16]) - channels_involved: Tip pattern (1 for active channels, 0 for inactive) - x_positions: X positions in 0.01mm units - y_positions: Y positions in 0.01mm units - minimum_traverse_height_at_beginning_of_a_command: Traverse height in 0.01mm units - lld_search_height: LLD search height for each channel in 0.01mm units - liquid_height: Liquid height for each channel in 0.01mm units - immersion_depth: Immersion depth for each channel in 0.01mm units - surface_following_distance: Surface following distance for each channel in 0.01mm units - minimum_height: Minimum height for each channel in 0.01mm units - min_z_endpos: Minimum Z end position in 0.01mm units - swap_speed: Swap speed (on leaving liquid) for each channel in 0.1uL/s units - transport_air_volume: Transport air volume for each channel in 0.1uL units - dispense_volume: Dispense volume for each channel in 0.1uL units - stop_back_volume: Stop back volume for each channel in 0.1uL units - blow_out_air_volume: Blowout volume for each channel in 0.1uL units - dispense_speed: Dispense speed for each channel in 0.1uL/s units - cut_off_speed: Cut off speed for each channel in 0.1uL/s units - settling_time: Settling time for each channel in 0.1s units - mix_volume: Mix volume for each channel in 0.1uL units - mix_cycles: Mix cycles for each channel - mix_position_from_liquid_surface: Mix position from liquid surface for each channel in 0.01mm units - mix_surface_following_distance: Mix follow distance for each channel in 0.01mm units - mix_speed: Mix speed for each channel in 0.1uL/s units - side_touch_off_distance: Side touch off distance in 0.01mm units - dispense_offset: Dispense offset for each channel in 0.01mm units - tube_section_height: Tube section height for each channel in 0.01mm units - tube_section_ratio: Tube section ratio for each channel - lld_mode: LLD mode for each channel (List[i16]) - gamma_lld_sensitivity: Gamma LLD sensitivity for each channel (List[i16]) - tadm_enabled: TADM enabled flag - limit_curve_index: Limit curve index for each channel - recording_mode: Recording mode (u16) - """ - super().__init__(dest) - self.dispense_type = dispense_type - self.channels_involved = channels_involved - self.x_positions = x_positions - self.y_positions = y_positions - self.minimum_traverse_height_at_beginning_of_a_command = ( - minimum_traverse_height_at_beginning_of_a_command - ) - self.lld_search_height = lld_search_height - self.liquid_height = liquid_height - self.immersion_depth = immersion_depth - self.surface_following_distance = surface_following_distance - self.minimum_height = minimum_height - self.min_z_endpos = min_z_endpos - self.swap_speed = swap_speed - self.transport_air_volume = transport_air_volume - self.dispense_volume = dispense_volume - self.stop_back_volume = stop_back_volume - self.blow_out_air_volume = blow_out_air_volume - self.dispense_speed = dispense_speed - self.cut_off_speed = cut_off_speed - self.settling_time = settling_time - self.mix_volume = mix_volume - self.mix_cycles = mix_cycles - self.mix_position_from_liquid_surface = mix_position_from_liquid_surface - self.mix_surface_following_distance = mix_surface_following_distance - self.mix_speed = mix_speed - self.side_touch_off_distance = side_touch_off_distance - self.dispense_offset = dispense_offset - self.tube_section_height = tube_section_height - self.tube_section_ratio = tube_section_ratio - self.lld_mode = lld_mode - self.gamma_lld_sensitivity = gamma_lld_sensitivity - self.tadm_enabled = tadm_enabled - self.limit_curve_index = limit_curve_index - self.recording_mode = recording_mode - - def build_parameters(self) -> HoiParams: - return ( - HoiParams() - .i16_array(self.dispense_type) - .u16_array(self.channels_involved) - .i32_array(self.x_positions) - .i32_array(self.y_positions) - .i32(self.minimum_traverse_height_at_beginning_of_a_command) - .i32_array(self.lld_search_height) - .i32_array(self.liquid_height) - .i32_array(self.immersion_depth) - .i32_array(self.surface_following_distance) - .i32_array(self.minimum_height) - .i32(self.min_z_endpos) - .u32_array(self.swap_speed) - .u32_array(self.transport_air_volume) - .u32_array(self.dispense_volume) - .u32_array(self.stop_back_volume) - .u32_array(self.blow_out_air_volume) - .u32_array(self.dispense_speed) - .u32_array(self.cut_off_speed) - .u32_array(self.settling_time) - .u32_array(self.mix_volume) - .u32_array(self.mix_cycles) - .i32_array(self.mix_position_from_liquid_surface) - .i32_array(self.mix_surface_following_distance) - .u32_array(self.mix_speed) - .i32(self.side_touch_off_distance) - .i32_array(self.dispense_offset) - .i32_array(self.tube_section_height) - .i32_array(self.tube_section_ratio) - .i16_array(self.lld_mode) - .i16_array(self.gamma_lld_sensitivity) - .bool_value(self.tadm_enabled) - .u32_array(self.limit_curve_index) - .u16(self.recording_mode) - ) + dispense_type: I16Array # Per channel (I16) + channels_involved: U16Array # Tip pattern (1=active, 0=inactive) + x_positions: I32Array # 0.01 mm + y_positions: I32Array # 0.01 mm + minimum_traverse_height_at_beginning_of_a_command: I32 # 0.01 mm + lld_search_height: I32Array # 0.01 mm + liquid_height: I32Array # 0.01 mm + immersion_depth: I32Array # 0.01 mm + surface_following_distance: I32Array # 0.01 mm + minimum_height: I32Array # 0.01 mm + min_z_endpos: I32 # 0.01 mm + swap_speed: U32Array # 0.1 µL/s (on leaving liquid) + transport_air_volume: U32Array # 0.1 µL + dispense_volume: U32Array # 0.1 µL + stop_back_volume: U32Array # 0.1 µL + blow_out_air_volume: U32Array # 0.1 µL + dispense_speed: U32Array # 0.1 µL/s + cut_off_speed: U32Array # 0.1 µL/s + settling_time: U32Array # 0.1 s + mix_volume: U32Array # 0.1 µL + mix_cycles: U32Array + mix_position_from_liquid_surface: I32Array # 0.01 mm + mix_surface_following_distance: I32Array # 0.01 mm + mix_speed: U32Array # 0.1 µL/s + side_touch_off_distance: I32 # 0.01 mm + dispense_offset: I32Array # 0.01 mm + tube_section_height: I32Array # 0.01 mm + tube_section_ratio: I32Array + lld_mode: I16Array + gamma_lld_sensitivity: I16Array + tadm_enabled: Bool + limit_curve_index: U32Array + recording_mode: U16 + + +# Expected root name from discovery; validated at setup(). +_EXPECTED_ROOT = "NimbusCORE" # ============================================================================ @@ -906,64 +437,87 @@ def build_parameters(self) -> HoiParams: # ============================================================================ -class NimbusBackend(HamiltonTCPBackend): +class NimbusBackend(LiquidHandlerBackend): """Backend for Hamilton Nimbus liquid handling instruments. - This backend uses TCP communication with the Hamilton protocol to control - Nimbus instruments. It inherits from both TCPBackend (for communication) - and LiquidHandlerBackend (for liquid handling interface). + Uses HamiltonTCPClient (self.client) for TCP communication and introspection; + implements LiquidHandlerBackend for liquid handling. + Interfaces resolved lazily via _require() on first use. + Construction accepts either host (and optionally port) or an injected client + (dependency injection), same pattern as PrepBackend. - Attributes: - _door_lock_available: Whether door lock is available on this instrument. + On-demand introspection: ``await self.client.introspect(path)``. """ + # Declare known object paths via InterfaceSpec. Optional interfaces (e.g. pipette, door_lock) may be absent on some systems. + _INTERFACES: dict[str, InterfaceSpec] = { + "nimbus_core": InterfaceSpec("NimbusCORE", True, True), + "pipette": InterfaceSpec("NimbusCORE.Pipette", False, True), + "door_lock": InterfaceSpec("NimbusCORE.DoorLock", False, True), + } + + @overload + def __init__(self, *, host: str, port: int = 2000) -> None: ... + + @overload + def __init__(self, *, client: HamiltonTCPClient) -> None: ... + def __init__( self, - host: str, + *, + host: Optional[str] = None, port: int = 2000, - read_timeout: float = 30.0, - write_timeout: float = 30.0, - auto_reconnect: bool = True, - max_reconnect_attempts: int = 3, - ): + client: Optional[HamiltonTCPClient] = None, + ) -> None: """Initialize Nimbus backend. Args: - host: Hamilton instrument IP address - port: Hamilton instrument port (default: 2000) - read_timeout: Read timeout in seconds - write_timeout: Write timeout in seconds - auto_reconnect: Enable automatic reconnection - max_reconnect_attempts: Maximum reconnection attempts + host: Instrument hostname or IP; used when client is not provided. + port: TCP port (default 2000). + client: Optional pre-configured HamiltonTCPClient (mutually exclusive + with host). """ - super().__init__( - host=host, - port=port, - read_timeout=read_timeout, - write_timeout=write_timeout, - auto_reconnect=auto_reconnect, - max_reconnect_attempts=max_reconnect_attempts, - ) + super().__init__() + if client is not None: + if host is not None: + raise TypeError("Provide either host or client, not both") + self.client = client + elif host is not None: + self.client = HamiltonTCPClient(host=host, port=port) + else: + raise TypeError("Provide either host or client") self._num_channels: Optional[int] = None - self._pipette_address: Optional[Address] = None - self._door_lock_address: Optional[Address] = None - self._nimbus_core_address: Optional[Address] = None self._is_initialized: Optional[bool] = None self._channel_configurations: Optional[Dict[int, Dict[int, bool]]] = None self._channel_traversal_height: float = 146.0 # Default traversal height in mm + self._resolver = HamiltonInterfaceResolver(self.client, self._INTERFACES) + + # --------------------------------------------------------------------------- + # Setup & interface resolution + # --------------------------------------------------------------------------- + + def _has_interface(self, name: str) -> bool: + """Return True if the interface was resolved and is present.""" + return self._resolver.has_interface(name) + + async def _require(self, name: str) -> Address: + """Resolve and return an interface address, lazy on first call. Raises RuntimeError if not found.""" + return await self._resolver.require(name) async def setup(self, unlock_door: bool = False, force_initialize: bool = False): """Set up the Nimbus backend. + Interfaces: self.client.interfaces..address for required paths; optional via _has_interface(name). + This method: 1. Establishes TCP connection and performs protocol initialization 2. Discovers instrument objects 3. Queries channel configuration to get num_channels 4. Queries tip presence 5. Queries initialization status - 6. Locks door if available + 6. Locks door if available (when _has_interface(\"door_lock\")) 7. Conditionally initializes NimbusCore with InitializeSmartRoll (only if not initialized) 8. Optionally unlocks door after initialization @@ -971,24 +525,27 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) unlock_door: If True, unlock door after initialization (default: False) force_initialize: If True, force initialization even if already initialized """ - # Call parent setup (TCP connection, Protocol 7 init, Protocol 3 registration) - await super().setup() + # Call client setup (TCP connection, Protocol 7 init, Protocol 3 registration, depth-1 discovery) + await self.client.setup() + + # Validate discovered root matches this backend + discovered = self.client.discovered_root_name() + if discovered != _EXPECTED_ROOT: + raise RuntimeError( + f"Expected root '{_EXPECTED_ROOT}' (Nimbus), but discovered '{discovered}'. Wrong instrument?" + ) from None - # Discover instrument objects - await self._discover_instrument_objects() + # Resolve all interfaces (required fail-fast; optional log and continue) + await self._resolver.run_setup_loop() - # Ensure required objects are discovered - if self._pipette_address is None: - raise RuntimeError("Pipette object not discovered. Cannot proceed with setup.") - if self._nimbus_core_address is None: - raise RuntimeError("NimbusCore root object not discovered. Cannot proceed with setup.") + nimbus_core = await self._require("nimbus_core") - # Query channel configuration to get num_channels (use discovered address only) + # Query channel configuration to get num_channels try: - config = await self.send_command(GetChannelConfiguration_1(self._nimbus_core_address)) + config = await self.client.send_command(GetChannelConfiguration_1(dest=nimbus_core)) assert config is not None, "GetChannelConfiguration_1 command returned None" - self._num_channels = config["channels"] - logger.info(f"Channel configuration: {config['channels']} channels") + self._num_channels = config.channels + logger.info(f"Channel configuration: {config.channels} channels") except Exception as e: logger.error(f"Failed to query channel configuration: {e}") raise @@ -1000,11 +557,11 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) except Exception as e: logger.warning(f"Failed to query tip presence: {e}") - # Query initialization status (use discovered address only) + # Query initialization status try: - init_status = await self.send_command(IsInitialized(self._nimbus_core_address)) + init_status = await self.client.send_command(IsInitialized(dest=nimbus_core)) assert init_status is not None, "IsInitialized command returned None" - self._is_initialized = init_status.get("initialized", False) + self._is_initialized = bool(init_status.value) logger.info(f"Instrument initialized: {self._is_initialized}") except Exception as e: logger.error(f"Failed to query initialization status: {e}") @@ -1012,7 +569,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) # Lock door if available (optional - no error if not found) # This happens before initialization - if self._door_lock_address is not None: + if self._has_interface("door_lock"): try: if not await self.is_door_locked(): await self.lock_door() @@ -1026,23 +583,26 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) # Conditional initialization - only if not already initialized if not self._is_initialized or force_initialize: - # Set channel configuration for each channel (required before InitializeSmartRoll) - try: - # Configure all channels (1 to num_channels) - one SetChannelConfiguration call per channel - # Parameters: channel (1-based), indexes=[1, 3, 4], enables=[True, False, False, False] - for channel in range(1, self.num_channels + 1): - await self.send_command( - SetChannelConfiguration( - dest=self._pipette_address, - channel=channel, - indexes=[1, 3, 4], - enables=[True, False, False, False], + # Set channel configuration for each channel (when Pipette is present; required before InitializeSmartRoll) + if self._has_interface("pipette"): + try: + # Configure all channels (1 to num_channels) - one SetChannelConfiguration call per channel + # Parameters: channel (1-based), indexes=[1, 3, 4], enables=[True, False, False, False] + for channel in range(1, self.num_channels + 1): + await self.client.send_command( + SetChannelConfiguration( + dest=await self._require("pipette"), + channel=channel, + indexes=[1, 3, 4], + enables=[True, False, False, False], + ) ) - ) - logger.info(f"Channel configuration set for {self.num_channels} channels") - except Exception as e: - logger.error(f"Failed to set channel configuration: {e}") - raise + logger.info(f"Channel configuration set for {self.num_channels} channels") + except Exception as e: + logger.error(f"Failed to set channel configuration: {e}") + raise + else: + logger.info("Skipping channel configuration (no Pipette interface)") # Initialize NimbusCore with InitializeSmartRoll using waste positions try: @@ -1064,9 +624,9 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) roll_distance=None, # Will default to 9.0mm ) - await self.send_command( + await self.client.send_command( InitializeSmartRoll( - dest=self._nimbus_core_address, + dest=nimbus_core, x_positions=x_positions_full, y_positions=y_positions_full, begin_tip_deposit_process=begin_tip_deposit_process_full, @@ -1084,7 +644,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) logger.info("Instrument already initialized, skipping initialization") # Unlock door if requested (optional - no error if not found) - if unlock_door and self._door_lock_address is not None: + if unlock_door and self._has_interface("door_lock"): try: await self.unlock_door() except RuntimeError: @@ -1093,49 +653,7 @@ async def setup(self, unlock_door: bool = False, force_initialize: bool = False) except Exception as e: logger.warning(f"Failed to unlock door: {e}") - async def _discover_instrument_objects(self): - """Discover instrument-specific objects using introspection.""" - introspection = HamiltonIntrospection(self) - - # Get root objects (already discovered in setup) - root_objects = self._discovered_objects.get("root", []) - if not root_objects: - logger.warning("No root objects discovered") - return - - # Use first root object as NimbusCore - nimbus_core_addr = root_objects[0] - self._nimbus_core_address = nimbus_core_addr - - try: - # Get NimbusCore object info - core_info = await introspection.get_object(nimbus_core_addr) - - # Discover subobjects to find Pipette and DoorLock - for i in range(core_info.subobject_count): - try: - sub_addr = await introspection.get_subobject_address(nimbus_core_addr, i) - sub_info = await introspection.get_object(sub_addr) - - # Check if this is the Pipette by interface name - if sub_info.name == "Pipette": - self._pipette_address = sub_addr - logger.info(f"Found Pipette at {sub_addr}") - - # Check if this is the DoorLock by interface name - if sub_info.name == "DoorLock": - self._door_lock_address = sub_addr - logger.info(f"Found DoorLock at {sub_addr}") - - except Exception as e: - logger.debug(f"Failed to get subobject {i}: {e}") - - except Exception as e: - logger.warning(f"Failed to discover instrument objects: {e}") - - # If door lock not found via introspection, it's not available - if self._door_lock_address is None: - logger.info("DoorLock not available on this instrument") + self.setup_finished = True def _fill_by_channels(self, values: List[T], use_channels: List[int], default: T) -> List[T]: """Returns a full-length list of size `num_channels` where positions in `channels` @@ -1174,13 +692,10 @@ async def park(self): """Park the instrument. Raises: - RuntimeError: If NimbusCore address was not discovered during setup. + RuntimeError: If NimbusCORE address was not discovered during setup. """ - if self._nimbus_core_address is None: - raise RuntimeError("NimbusCore address not discovered. Call setup() first.") - try: - await self.send_command(Park(self._nimbus_core_address)) + await self.client.send_command(Park(await self._require("nimbus_core"))) logger.info("Instrument parked successfully") except Exception as e: logger.error(f"Failed to park instrument: {e}") @@ -1190,55 +705,38 @@ async def is_door_locked(self) -> bool: """Check if the door is locked. Returns: - True if door is locked, False if unlocked. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. + True if door is locked, False if unlocked or if door lock is not available. """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) + if not self._has_interface("door_lock"): + return False try: - status = await self.send_command(IsDoorLocked(self._door_lock_address)) + status = await self.client.send_command(IsDoorLocked(await self._require("door_lock"))) assert status is not None, "IsDoorLocked command returned None" - return bool(status["locked"]) + return bool(status.locked) except Exception as e: logger.error(f"Failed to check door lock status: {e}") raise async def lock_door(self) -> None: - """Lock the door. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) + """Lock the door. No-op if door lock is not available.""" + if not self._has_interface("door_lock"): + return try: - await self.send_command(LockDoor(self._door_lock_address)) + await self.client.send_command(LockDoor(await self._require("door_lock"))) logger.info("Door locked successfully") except Exception as e: logger.error(f"Failed to lock door: {e}") raise async def unlock_door(self) -> None: - """Unlock the door. - - Raises: - RuntimeError: If door lock is not available on this instrument, or if setup() has not been called yet. - """ - if self._door_lock_address is None: - raise RuntimeError( - "Door lock is not available on this instrument or setup() has not been called." - ) + """Unlock the door. No-op if door lock is not available.""" + if not self._has_interface("door_lock"): + return try: - await self.send_command(UnlockDoor(self._door_lock_address)) + await self.client.send_command(UnlockDoor(await self._require("door_lock"))) logger.info("Door unlocked successfully") except Exception as e: logger.error(f"Failed to unlock door: {e}") @@ -1246,7 +744,11 @@ async def unlock_door(self) -> None: async def stop(self): """Stop the backend and close connection.""" - await HamiltonTCPBackend.stop(self) + await self.client.stop() + self.setup_finished = False + + def serialize(self) -> dict: + return {**super().serialize(), **self.client.serialize()} async def request_tip_presence(self) -> List[Optional[bool]]: """Request tip presence on each channel. @@ -1255,12 +757,9 @@ async def request_tip_presence(self) -> List[Optional[bool]]: A list of length `num_channels` where each element is `True` if a tip is mounted, `False` if not, or `None` if unknown. """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - tip_status = await self.send_command(IsTipPresent(self._pipette_address)) + tip_status = await self.client.send_command(IsTipPresent(await self._require("pipette"))) assert tip_status is not None, "IsTipPresent command returned None" - tip_present = tip_status.get("tip_present", []) - return [bool(v) for v in tip_present] + return [bool(v) for v in tip_status.tip_present] def _build_waste_position_params( self, @@ -1469,9 +968,6 @@ async def pick_up_tips( RuntimeError: If pipette address or deck is not set ValueError: If deck is not a NimbusDeck and minimum_traverse_height_at_beginning_of_a_command is not provided """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - # Validate we have a NimbusDeck for coordinate conversion if not isinstance(self.deck, NimbusDeck): raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") @@ -1514,7 +1010,7 @@ async def pick_up_tips( # Create and send command command = PickupTips( - dest=self._pipette_address, + dest=await self._require("pipette"), channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -1525,7 +1021,7 @@ async def pick_up_tips( ) try: - await self.send_command(command) + await self.client.send_command(command) logger.info(f"Picked up tips on channels {use_channels}") except Exception as e: logger.error(f"Failed to pick up tips: {e}") @@ -1566,9 +1062,6 @@ async def drop_tips( RuntimeError: If pipette address or deck is not set ValueError: If operations mix waste and regular resources """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - # Validate we have a NimbusDeck for coordinate conversion if not isinstance(self.deck, NimbusDeck): raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") @@ -1614,7 +1107,7 @@ async def drop_tips( ) command = DropTipsRoll( - dest=self._pipette_address, + dest=await self._require("pipette"), channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -1647,7 +1140,7 @@ async def drop_tips( ) command = DropTips( - dest=self._pipette_address, + dest=await self._require("pipette"), channels_involved=channels_involved, x_positions=x_positions_full, y_positions=y_positions_full, @@ -1659,7 +1152,7 @@ async def drop_tips( ) try: - await self.send_command(command) + await self.client.send_command(command) logger.info(f"Dropped tips on channels {use_channels}") except Exception as e: logger.error(f"Failed to drop tips: {e}") @@ -1706,7 +1199,7 @@ async def aspirate( settling_time: Settling time (s), default: [1.0] * n transport_air_volume: Transport air volume (uL), default: [5.0] * n pre_wetting_volume: Pre-wetting volume (uL), default: [0.0] * n - swap_speed: Swap speed on leaving liquid (uL/s), default: [20.0] * n + swap_speed: Swap speed on leaving liquid (mm/s), default: [20.0] * n mix_position_from_liquid_surface: Mix position from liquid surface (mm), default: [0.0] * n limit_curve_index: Limit curve index, default: [0] * n tadm_enabled: TADM enabled flag, default: False @@ -1714,9 +1207,6 @@ async def aspirate( Raises: RuntimeError: If pipette address or deck is not set """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - # Validate we have a NimbusDeck for coordinate conversion if not isinstance(self.deck, NimbusDeck): raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") @@ -1732,10 +1222,10 @@ async def aspirate( # Call ADC command (EnableADC or DisableADC) if adc_enabled: - await self.send_command(EnableADC(self._pipette_address, channels_involved)) + await self.client.send_command(EnableADC(await self._require("pipette"), channels_involved)) logger.info("Enabled ADC before aspirate") else: - await self.send_command(DisableADC(self._pipette_address, channels_involved)) + await self.client.send_command(DisableADC(await self._require("pipette"), channels_involved)) logger.info("Disabled ADC before aspirate") # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") @@ -1744,15 +1234,15 @@ async def aspirate( for channel_idx in use_channels: channel_num = channel_idx + 1 # Convert to 1-based try: - config = await self.send_command( + config = await self.client.send_command( GetChannelConfiguration( - self._pipette_address, + await self._require("pipette"), channel=channel_num, indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" ) ) assert config is not None, "GetChannelConfiguration returned None" - enabled = config["enabled"][0] if config["enabled"] else False + enabled = config.enabled[0] if config.enabled else False if channel_num not in self._channel_configurations: self._channel_configurations[channel_num] = {} self._channel_configurations[channel_num][2] = enabled @@ -1921,7 +1411,7 @@ async def aspirate( # Create and send Aspirate command command = Aspirate( - dest=self._pipette_address, + dest=await self._require("pipette"), aspirate_type=aspirate_type, channels_involved=channels_involved, x_positions=x_positions_full, @@ -1958,7 +1448,7 @@ async def aspirate( ) try: - await self.send_command(command) + await self.client.send_command(command) logger.info(f"Aspirated on channels {use_channels}") except Exception as e: logger.error(f"Failed to aspirate: {e}") @@ -2013,9 +1503,6 @@ async def dispense( Raises: RuntimeError: If pipette address or deck is not set """ - if self._pipette_address is None: - raise RuntimeError("Pipette address not discovered. Call setup() first.") - # Validate we have a NimbusDeck for coordinate conversion if not isinstance(self.deck, NimbusDeck): raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") @@ -2031,10 +1518,10 @@ async def dispense( # Call ADC command (EnableADC or DisableADC) if adc_enabled: - await self.send_command(EnableADC(self._pipette_address, channels_involved)) + await self.client.send_command(EnableADC(await self._require("pipette"), channels_involved)) logger.info("Enabled ADC before dispense") else: - await self.send_command(DisableADC(self._pipette_address, channels_involved)) + await self.client.send_command(DisableADC(await self._require("pipette"), channels_involved)) logger.info("Disabled ADC before dispense") # Call GetChannelConfiguration for each active channel (index 2 = "Aspirate monitoring with cLLD") @@ -2043,15 +1530,15 @@ async def dispense( for channel_idx in use_channels: channel_num = channel_idx + 1 # Convert to 1-based try: - config = await self.send_command( + config = await self.client.send_command( GetChannelConfiguration( - self._pipette_address, + await self._require("pipette"), channel=channel_num, indexes=[2], # Index 2 = "Aspirate monitoring with cLLD" ) ) assert config is not None, "GetChannelConfiguration returned None" - enabled = config["enabled"][0] if config["enabled"] else False + enabled = config.enabled[0] if config.enabled else False if channel_num not in self._channel_configurations: self._channel_configurations[channel_num] = {} self._channel_configurations[channel_num][2] = enabled @@ -2223,7 +1710,7 @@ async def dispense( # Create and send Dispense command command = Dispense( - dest=self._pipette_address, + dest=await self._require("pipette"), dispense_type=dispense_type, channels_involved=channels_involved, x_positions=x_positions_full, @@ -2260,7 +1747,7 @@ async def dispense( ) try: - await self.send_command(command) + await self.client.send_command(command) logger.info(f"Dispensed on channels {use_channels}") except Exception as e: logger.error(f"Failed to dispense: {e}") diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 75c71692f91..733987548dd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -6,7 +6,6 @@ import unittest import unittest.mock -from typing import Optional from pylabrobot.liquid_handling.backends.hamilton.nimbus_backend import ( Aspirate, @@ -31,9 +30,25 @@ UnlockDoor, _get_tip_type_from_tip, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ObjectInfo +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( + CommandResponse, + HoiParams, + HoiParamsParser, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( + Address, + HarpPacket, + HoiPacket, + IpPacket, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol, Hoi2Action +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + U16, + Bool, + I16Array, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPClient from pylabrobot.liquid_handling.standard import ( Drop, Pickup, @@ -188,15 +203,15 @@ def test_is_door_locked_command(self): # STATUS_REQUEST must have action_code=0 self.assertEqual(cmd.action_code, 0) - def test_is_door_locked_parse_response(self): - # Simulate response: bool fragment with True - response_data = HoiParams().bool_value(True).build() - result = IsDoorLocked.parse_response_parameters(response_data) - self.assertEqual(result, {"locked": True}) - - response_data = HoiParams().bool_value(False).build() - result = IsDoorLocked.parse_response_parameters(response_data) - self.assertEqual(result, {"locked": False}) + def test_is_door_locked_interpret_response(self): + """Round-trip: HOI params -> interpret_response -> Response.""" + cmd = IsDoorLocked(Address(1, 1, 268)) + for wire_true in (True, False): + params = HoiParams().add(wire_true, Bool).build() + response = _build_command_response(params) + result = cmd.interpret_response(response) + self.assertIsInstance(result, IsDoorLocked.Response) + self.assertEqual(result.locked, wire_true) def test_park_command(self): cmd = Park(Address(1, 1, 48896)) @@ -209,10 +224,14 @@ def test_is_initialized_command(self): self.assertEqual(cmd.command_id, 14) self.assertEqual(cmd.action_code, 0) - def test_is_initialized_parse_response(self): - response_data = HoiParams().bool_value(True).build() - result = IsInitialized.parse_response_parameters(response_data) - self.assertEqual(result, {"initialized": True}) + def test_is_initialized_interpret_response(self): + """Round-trip: HOI params -> interpret_response -> Response.""" + cmd = IsInitialized(Address(1, 1, 48896)) + params = HoiParams().add(True, Bool).build() + response = _build_command_response(params) + result = cmd.interpret_response(response) + self.assertIsInstance(result, IsInitialized.Response) + self.assertTrue(result.value) def test_is_tip_present_command(self): cmd = IsTipPresent(Address(1, 1, 257)) @@ -220,11 +239,14 @@ def test_is_tip_present_command(self): self.assertEqual(cmd.command_id, 16) self.assertEqual(cmd.action_code, 0) - def test_is_tip_present_parse_response(self): - # Simulate response with i16 array - response_data = HoiParams().i16_array([1, 0, 1, 0, 0, 0, 0, 0]).build() - result = IsTipPresent.parse_response_parameters(response_data) - self.assertEqual(result["tip_present"], [1, 0, 1, 0, 0, 0, 0, 0]) + def test_is_tip_present_interpret_response(self): + """Round-trip: HOI params -> interpret_response -> Response.""" + cmd = IsTipPresent(Address(1, 1, 257)) + params = HoiParams().add([1, 0, 1, 0, 0, 0, 0, 0], I16Array).build() + response = _build_command_response(params) + result = cmd.interpret_response(response) + self.assertIsInstance(result, IsTipPresent.Response) + self.assertEqual(result.tip_present, [1, 0, 1, 0, 0, 0, 0, 0]) def test_get_channel_configuration_1_command(self): cmd = GetChannelConfiguration_1(Address(1, 1, 48896)) @@ -232,11 +254,15 @@ def test_get_channel_configuration_1_command(self): self.assertEqual(cmd.command_id, 15) self.assertEqual(cmd.action_code, 0) - def test_get_channel_configuration_1_parse_response(self): - response_data = HoiParams().u16(8).i16_array([0, 0, 0, 0, 0, 0, 0, 0]).build() - result = GetChannelConfiguration_1.parse_response_parameters(response_data) - self.assertEqual(result["channels"], 8) - self.assertEqual(result["channel_types"], [0, 0, 0, 0, 0, 0, 0, 0]) + def test_get_channel_configuration_1_interpret_response(self): + """Round-trip: HOI params -> interpret_response -> Response.""" + cmd = GetChannelConfiguration_1(Address(1, 1, 48896)) + params = HoiParams().add(8, U16).add([0, 0, 0, 0, 0, 0, 0, 0], I16Array).build() + response = _build_command_response(params) + result = cmd.interpret_response(response) + self.assertIsInstance(result, GetChannelConfiguration_1.Response) + self.assertEqual(result.channels, 8) + self.assertEqual(result.channel_types, [0, 0, 0, 0, 0, 0, 0, 0]) def test_pre_initialize_smart_command(self): cmd = PreInitializeSmart(Address(1, 1, 257)) @@ -535,17 +561,22 @@ class TestNimbusBackendUnit(unittest.IsolatedAsyncioTestCase): async def test_backend_init(self): backend = NimbusBackend(host="192.168.1.100", port=2000) - self.assertEqual(backend.io._host, "192.168.1.100") - self.assertEqual(backend.io._port, 2000) + self.assertEqual(backend.client.io._host, "192.168.1.100") + self.assertEqual(backend.client.io._port, 2000) self.assertIsNone(backend._num_channels) - self.assertIsNone(backend._pipette_address) - self.assertIsNone(backend._door_lock_address) - self.assertIsNone(backend._nimbus_core_address) + self.assertEqual(backend.client._registry._objects, {}) self.assertEqual(backend._channel_traversal_height, 146.0) async def test_backend_init_default_port(self): backend = NimbusBackend(host="192.168.1.100") - self.assertEqual(backend.io._port, 2000) + self.assertEqual(backend.client.io._port, 2000) + + async def test_backend_init_with_client(self): + """Injected client is stored and used by the backend.""" + client = HamiltonTCPClient(host="192.168.1.1", port=2000) + backend = NimbusBackend(client=client) + self.assertIs(backend.client, client) + self.assertEqual(backend.client.io._host, "192.168.1.1") async def test_num_channels_before_setup_raises(self): backend = NimbusBackend(host="192.168.1.100") @@ -587,28 +618,66 @@ async def test_fill_by_channels_mismatched_lengths(self): backend._fill_by_channels([1, 2], [0, 1, 2], default=0) -def _mock_send_command_response(command) -> Optional[dict]: - """Return appropriate mock responses based on command type.""" +def _build_command_response(params: bytes) -> CommandResponse: + """Build a minimal CommandResponse with the given HOI params (for interpret_response tests).""" + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.COMMAND_RESPONSE, + action_id=0, + params=params, + ) + harp = HarpPacket( + src=Address(0, 0, 0), + dst=Address(0, 0, 0), + seq=0, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + ip = IpPacket(protocol=6, payload=harp.pack()) + return CommandResponse.from_bytes(ip.pack()) + + +def _mock_send_command_response(command): + """Return appropriate mock responses based on command type (Response instances or None).""" if isinstance(command, IsTipPresent): - return {"tip_present": [0] * 8} + return IsTipPresent.Response(tip_present=[0] * 8) if isinstance(command, IsDoorLocked): - return {"locked": True} + return IsDoorLocked.Response(locked=True) if isinstance(command, IsInitialized): - return {"initialized": True} + return IsInitialized.Response(value=True) if isinstance(command, GetChannelConfiguration_1): - return {"channels": 8, "channel_types": [0] * 8} + return GetChannelConfiguration_1.Response(channels=8, channel_types=[0] * 8) if isinstance(command, GetChannelConfiguration): - return {"enabled": [False]} + return GetChannelConfiguration.Response(enabled=[False]) return None def _setup_backend() -> NimbusBackend: - """Create a NimbusBackend with pre-configured state for testing.""" + """Create a NimbusBackend with pre-configured state for testing (registry populated). + + Tests seed the registry with Address(...) and ObjectInfo directly (rather than + going through the proxy) so that wire-format and address values are under test. + """ backend = NimbusBackend(host="192.168.1.100", port=2000) backend._num_channels = 8 - backend._pipette_address = Address(1, 1, 257) - backend._door_lock_address = Address(1, 1, 268) - backend._nimbus_core_address = Address(1, 1, 48896) + backend.client._registry.set_root_addresses([Address(1, 1, 48896)]) + backend.client._registry.register( + "NimbusCORE", + ObjectInfo("NimbusCORE", "1.0", 10, 2, Address(1, 1, 48896)), + ) + backend.client._registry.register( + "NimbusCORE.Pipette", + ObjectInfo("Pipette", "1.0", 20, 0, Address(1, 1, 257)), + ) + backend.client._registry.register( + "NimbusCORE.DoorLock", + ObjectInfo("DoorLock", "1.0", 3, 0, Address(1, 1, 268)), + ) + # Pre-populate resolver cache so _has_interface and _require use cached addresses + backend._resolver._resolved["nimbus_core"] = Address(1, 1, 48896) + backend._resolver._resolved["pipette"] = Address(1, 1, 257) + backend._resolver._resolved["door_lock"] = Address(1, 1, 268) backend._is_initialized = True return backend @@ -626,7 +695,7 @@ class TestNimbusBackendCommands(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = _setup_backend() self.mock_send = unittest.mock.AsyncMock(side_effect=_mock_send_command_response) - self.backend.send_command = self.mock_send # type: ignore[method-assign] + self.backend.client.send_command = self.mock_send # type: ignore[method-assign] def _get_command(self, cmd_type): for call in self.mock_send.call_args_list: @@ -656,22 +725,28 @@ async def test_park(self): self.assertIsInstance(self._get_command(Park), Park) async def test_door_methods_without_address_raise(self): - self.backend._door_lock_address = None - - with self.assertRaises(RuntimeError): - await self.backend.lock_door() + # When door lock is not available (_has_interface("door_lock")=False), methods return early without sending. + self.backend._resolver._resolved["door_lock"] = None + self.mock_send.reset_mock() - with self.assertRaises(RuntimeError): - await self.backend.unlock_door() + await self.backend.lock_door() + await self.backend.unlock_door() + self.assertEqual(self.mock_send.call_count, 0) - with self.assertRaises(RuntimeError): - await self.backend.is_door_locked() + result = await self.backend.is_door_locked() + self.assertFalse(result) + self.assertEqual(self.mock_send.call_count, 0) async def test_park_without_address_raises(self): - self.backend._nimbus_core_address = None + # Backend with no resolved interfaces (e.g. setup() not run); _require raises RuntimeError + backend = NimbusBackend(host="192.168.1.100", port=2000) + backend._num_channels = 8 + backend._is_initialized = True - with self.assertRaises(RuntimeError): - await self.backend.park() + with self.assertRaises(RuntimeError) as ctx: + await backend.park() + self.assertIn("Could not find interface", str(ctx.exception)) + self.assertIn("nimbus_core", str(ctx.exception)) class TestNimbusBackendSerialization(unittest.IsolatedAsyncioTestCase): @@ -679,11 +754,12 @@ class TestNimbusBackendSerialization(unittest.IsolatedAsyncioTestCase): async def test_serialize(self): backend = NimbusBackend(host="192.168.1.100", port=2000) - backend._client_id = 5 + backend.client._client_id = 5 serialized = backend.serialize() self.assertEqual(serialized["client_id"], 5) - self.assertIn("instrument_addresses", serialized) + self.assertIn("registry_paths", serialized) + self.assertEqual(serialized["registry_paths"], []) class TestNimbusLiquidHandling(unittest.IsolatedAsyncioTestCase): @@ -693,7 +769,7 @@ async def asyncSetUp(self): self.deck = NimbusDeck() self.backend = _setup_backend_with_deck(self.deck) self.mock_send = unittest.mock.AsyncMock(side_effect=_mock_send_command_response) - self.backend.send_command = self.mock_send # type: ignore[method-assign] + self.backend.client.send_command = self.mock_send # type: ignore[method-assign] self.tip_rack = hamilton_96_tiprack_300uL("tip_rack") self.deck.assign_child_resource(self.tip_rack, rails=1) @@ -1069,7 +1145,7 @@ async def asyncSetUp(self): self.deck = NimbusDeck() self.backend = _setup_backend_with_deck(self.deck) self.mock_send = unittest.mock.AsyncMock(side_effect=_mock_send_command_response) - self.backend.send_command = self.mock_send # type: ignore[method-assign] + self.backend.client.send_command = self.mock_send # type: ignore[method-assign] def _get_commands(self, cmd_type): return [ diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py new file mode 100644 index 00000000000..4dda366cfc5 --- /dev/null +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend.py @@ -0,0 +1,2590 @@ +"""Hamilton Prep backend implementation. + +Three-layer design: + +- **HamiltonTCPClient** (``self.client``): Transport and introspection. + All device communication goes through ``self.client.send_command()``. + Address resolution: ``self.client.interfaces..address``. + The backend composes the client via dependency injection: callers pass host + (and optionally port) for default TCP settings, or pass a pre-configured + HamiltonTCPClient for full control. + +- **Command dataclasses** (e.g. ``PrepCmd.PrepDropTips``, ``PrepCmd.MphPickupTips``): Pure wire shapes. + Defined in ``prep_commands.py``; ``@dataclass`` with ``dest: Address`` + + ``Annotated`` payload fields; ``build_parameters()`` uses ``HoiParams.from_struct(self)``. + +- **PrepBackend methods**: Domain logic and defaults. + Single source of truth for Prep-specific parameter defaults. + +Standalone access: ``lh.backend.client.interfaces.MLPrepRoot.MphRoot.MPH.address``, +``HamiltonIntrospection(lh.backend.client)``. +""" + +from __future__ import annotations + +import asyncio +import enum +import logging +import math +import random +from typing import List, Optional, Tuple, Union, overload + +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.liquid_handling.backends.hamilton import prep_commands as PrepCmd +from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import ( + HamiltonInterfaceResolver, + HamiltonTCPClient, + InterfaceSpec, +) +from pylabrobot.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass, + get_star_liquid_class, +) +from pylabrobot.liquid_handling.standard import ( + Drop, + DropTipRack, + GripDirection, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import Coordinate, Tip +from pylabrobot.resources.hamilton import HamiltonTip, TipSize +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers +from pylabrobot.resources.liquid import Liquid +from pylabrobot.resources.tip_rack import TipSpot +from pylabrobot.resources.trash import Trash +from pylabrobot.resources.well import CrossSectionType, Well + +logger = logging.getLogger(__name__) + + +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) -> 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 [] + + size_z = resource.get_size_z() + + if resource.cross_section_type == CrossSectionType.CIRCLE: + area = math.pi * (resource.get_size_x() / 2) ** 2 + elif resource.cross_section_type == CrossSectionType.RECTANGLE: + area = resource.get_size_x() * resource.get_size_y() + else: + return [] + + if resource.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 = resource.compute_volume_from_height(h_hi) - resource.compute_volume_from_height(h_lo) + return 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)) + ] + + +def _absolute_z_from_well(op, z_air_margin_mm: float = 2.0): + """Compute absolute Z values from well geometry for aspirate/dispense (STAR-aligned). + + Returns (well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z). The resource + must have get_size_z() (e.g. a well/container); otherwise raises ValueError. + """ + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + well_bottom_z = loc.z + op.offset.z + liquid_surface_z = well_bottom_z + (op.liquid_height or 0.0) + + if not hasattr(op.resource, "get_size_z"): + 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." + ) + size_z = op.resource.get_size_z() + top_of_well_z = loc.z + size_z + z_air_z = top_of_well_z + z_air_margin_mm + return (well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z) + + +# ============================================================================= +# PrepBackend +# ============================================================================= + +_CHANNEL_INDEX = { + 0: PrepCmd.ChannelIndex.RearChannel, + 1: PrepCmd.ChannelIndex.FrontChannel, +} + +# 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" + + +class PrepBackend(LiquidHandlerBackend): + """Backend for Hamilton Prep instruments using the shared TCP stack. + + Uses HamiltonTCPClient (self.client) for communication and introspection; + implements LiquidHandlerBackend for liquid handling. + Interfaces resolved lazily via _require() on first use. + Construction accepts either host (and optionally port) to create the client + with defaults, or client to inject a pre-configured HamiltonTCPClient. + + On-demand introspection: ``await self.client.introspect(path)``. + """ + + 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). Mixing OFF with CAPACITIVE/PRESSURE in one call is + not supported and will raise ValueError. + """ + + OFF = 0 + CAPACITIVE = 1 # STARBackend.LLDMode.GAMMA — capacitive (cLLD) + PRESSURE = 2 # pressure-based (pLLD) + DUAL = 3 # both capacitive and pressure + + # Declare known object paths via InterfaceSpec. deck_config required (key positions, traverse height, deck info). + _INTERFACES: dict[str, InterfaceSpec] = { + "mlprep": InterfaceSpec("MLPrepRoot.MLPrep", True, True), + "pipettor": InterfaceSpec("MLPrepRoot.PipettorRoot.Pipettor", True, True), + "coordinator": InterfaceSpec("MLPrepRoot.ChannelCoordinator", True, True), + "deck_config": InterfaceSpec("MLPrepRoot.MLPrepCalibration.DeckConfiguration", True, True), + "mph": InterfaceSpec("MLPrepRoot.MphRoot.MPH", False, True), + "mlprep_service": InterfaceSpec("MLPrepRoot.MLPrepService", False, True), + } + + @overload + def __init__( + self, + *, + host: str, + port: int = 2000, + default_traverse_height: Optional[float] = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + client: HamiltonTCPClient, + default_traverse_height: Optional[float] = None, + ) -> None: ... + + def __init__( + self, + *, + host: Optional[str] = None, + port: int = 2000, + client: Optional[HamiltonTCPClient] = None, + default_traverse_height: Optional[float] = None, + ) -> None: + """Initialize Prep backend. + + Args: + host: Instrument hostname or IP; used when client is not provided. + port: TCP port (default 2000). + client: Optional pre-configured HamiltonTCPClient (mutually exclusive + with host). + default_traverse_height: Optional default traverse height in mm. + """ + super().__init__() + if client is not None: + if host is not None: + raise TypeError("Provide either host or client, not both") + self.client = client + elif host is not None: + self.client = HamiltonTCPClient(host=host, port=port) + else: + raise TypeError("Provide either host or client") + self._config: Optional[PrepCmd.InstrumentConfig] = None + self._user_traverse_height: Optional[float] = default_traverse_height + self._resolver = HamiltonInterfaceResolver(self.client, self._INTERFACES) + self._num_channels: Optional[int] = None + self._has_mph: Optional[bool] = None + self._gripper_tool_on: bool = False + self._channel_sleeve_sensor_addrs: list[Address] = [] + self._channel_zdrive_addrs: list[Address] = [] + self._channel_node_info_addrs: list[Address] = [] + self._mlprep_cpu_addr: Optional[Address] = None + self._module_info_addr: Optional[Address] = None + self._channel_bounds: list[dict] = [] + + def _has_interface(self, name: str) -> bool: + """Return True if the interface was resolved and is present.""" + return self._resolver.has_interface(name) + + 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 + + # --------------------------------------------------------------------------- + # Setup & interface resolution + # --------------------------------------------------------------------------- + + async def _require(self, name: str) -> Address: + """Resolve and return an interface address, lazy on first call. Raises RuntimeError if not found.""" + return await self._resolver.require(name) + + async def get_present_channels(self) -> Optional[Tuple[PrepCmd.ChannelIndex, ...]]: + """Query which channels are present (GetPresentChannels on MLPrepService). + + Maps raw enum values to PrepCmd.ChannelIndex: 0=InvalidIndex, 1=FrontChannel, + 2=RearChannel, 3=MPHChannel. Returns None if MLPrepService is unavailable + or the command fails (caller should use defaults). + """ + if not self._has_interface("mlprep_service"): + return None + try: + service_addr = await self._require("mlprep_service") + resp = await self.client.send_command(PrepCmd.PrepGetPresentChannels(dest=service_addr)) + if resp is None or not getattr(resp, "channels", None): + return None + present = tuple( + PrepCmd.ChannelIndex(v) if v in (0, 1, 2, 3) else PrepCmd.ChannelIndex.InvalidIndex + for v in resp.channels + ) + return present + except Exception: + return None + + async def setup(self, smart: bool = True, force_initialize: bool = False): + """Set up Prep: connect, discover objects, then conditionally initialize MLPrep. + + Interfaces: .address for MLPrep/Pipettor; depth-2 paths resolved in setup. + + Order: + 1. TCP + Protocol 7/3 init, root discovery, and depth-1 interface discovery (self.client.setup()) + 2. Lazy-resolve Pipettor (depth-2) for commands + 3. If force_initialize: always run Initialize(smart=smart). + Else: query GetIsInitialized; only run Initialize(smart=smart) when not initialized. + 4. Mark setup complete. + + Args: + smart: When we call Initialize, pass this to the firmware (default True). + force_initialize: If True, always run Initialize. If False, run Initialize only + when GetIsInitialized reports not initialized (e.g. reconnect-safe). + """ + await self.client.setup() + + # Validate discovered root matches this backend + discovered = self.client.discovered_root_name() + if discovered != _EXPECTED_ROOT: + raise RuntimeError( + f"Expected root '{_EXPECTED_ROOT}' (Prep), but discovered '{discovered}'. Wrong instrument?" + ) from None + + # Resolve all interfaces (required fail-fast; optional log and continue) + await self._resolver.run_setup_loop() + + if force_initialize: + await self._run_initialize(smart=smart) + logger.info("Prep initialization complete (force_initialize=True)") + else: + try: + already = await self.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") + else: + await self._run_initialize(smart=smart) + logger.info("Prep initialization complete") + + self._config = await self._get_hardware_config() + self._num_channels = self._config.num_channels + self._has_mph = self._config.has_mph + 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", + self._config.has_enclosure, + self._config.safe_speeds_enabled, + self._config.default_traverse_height, + self._config.deck_bounds, + len(self._config.deck_sites), + len(self._config.waste_sites), + self._config.num_channels, + self._config.has_mph, + ) + + # Discover per-channel drive addresses from the object tree (after init). + await self._discover_channel_drives() + + # Cache per-channel movement bounds from firmware + try: + self._channel_bounds = await self.request_channel_bounds() + except Exception as e: + logger.warning("Failed to query channel bounds: %s", e) + self._channel_bounds = [] + 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") + + self.setup_finished = True + + async def _discover_channel_drives(self) -> None: + """Walk the MLPrepRoot object tree to discover per-channel and module addresses by name. + + Channel drives (per pipettor channel, skipping "MPH Channel Root"): + MLPrepRoot → "Channel Root" → "Channel" → "Squeeze" → "SDrive" (sleeve sensor) + MLPrepRoot → "Channel Root" → "Channel" → "ZAxis" → "ZDrive" + MLPrepRoot → "Channel Root" → "NodeInformation" + + Module-level objects (for firmware version queries): + MLPrepRoot → "MLPrepCpu" + MLPrepRoot → "PipettorRoot" → "ModuleInformation" + + All lookups are by object name, not hardcoded object IDs. + """ + from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import HamiltonIntrospection + + self._channel_sleeve_sensor_addrs = [] + self._channel_zdrive_addrs = [] + self._channel_node_info_addrs = [] + self._mlprep_cpu_addr = None + self._module_info_addr = None + + intro = HamiltonIntrospection(self.client) + root_addrs = self.client._registry.get_root_addresses() + if not root_addrs: + return + + root_addr = root_addrs[0] + root_info = await intro.get_object(root_addr) + + async def find_child_by_name(parent_addr, parent_info, name): + """Find a direct child object by name. Returns (address, info) or (None, None).""" + for i in range(parent_info.subobject_count): + try: + child_addr = await intro.get_subobject_address(parent_addr, i) + child_info = await intro.get_object(child_addr) + if child_info.name == name: + return child_addr, child_info + except Exception: + continue + return None, None + + for i in range(root_info.subobject_count): + try: + sub_addr = await intro.get_subobject_address(root_addr, i) + sub_info = await intro.get_object(sub_addr) + except Exception: + continue + + # MLPrepCpu — controller firmware version info + if sub_info.name == "MLPrepCpu": + self._mlprep_cpu_addr = sub_addr + logger.debug("Discovered MLPrepCpu at %s", sub_addr) + continue + + # PipettorRoot → ModuleInformation + if sub_info.name == "PipettorRoot": + mod_addr, _ = await find_child_by_name(sub_addr, sub_info, "ModuleInformation") + if mod_addr is not None: + self._module_info_addr = mod_addr + logger.debug("Discovered ModuleInformation at %s", mod_addr) + continue + + if sub_info.name != "Channel Root": + continue + + # Channel Root → Channel → Squeeze → SDrive + channel_addr, channel_info = await find_child_by_name(sub_addr, sub_info, "Channel") + if channel_addr is None: + logger.warning("Channel Root on node %d has no 'Channel' child, skipping", sub_addr.node) + continue + + squeeze_addr, squeeze_info = await find_child_by_name(channel_addr, channel_info, "Squeeze") + sdrive_addr = None + if squeeze_addr is not None and squeeze_info is not None: + sdrive_addr, _ = await find_child_by_name(squeeze_addr, squeeze_info, "SDrive") + + # Channel Root → Channel → ZAxis → ZDrive + zaxis_addr, zaxis_info = await find_child_by_name(channel_addr, channel_info, "ZAxis") + zdrive_addr = None + if zaxis_addr is not None and zaxis_info is not None: + zdrive_addr, _ = await find_child_by_name(zaxis_addr, zaxis_info, "ZDrive") + + # Channel Root → NodeInformation + node_info_addr, _ = await find_child_by_name(sub_addr, sub_info, "NodeInformation") + + if sdrive_addr is not None: + self._channel_sleeve_sensor_addrs.append(sdrive_addr) + else: + logger.warning("Channel Root on node %d: could not find Squeeze.SDrive", sub_addr.node) + + if zdrive_addr is not None: + self._channel_zdrive_addrs.append(zdrive_addr) + else: + logger.warning("Channel Root on node %d: could not find ZAxis.ZDrive", sub_addr.node) + + if node_info_addr is not None: + self._channel_node_info_addrs.append(node_info_addr) + else: + logger.warning("Channel Root on node %d: could not find NodeInformation", sub_addr.node) + + logger.debug( + "Discovered channel on node %d: sleeve_sensor=%s, ZDrive=%s, NodeInfo=%s", + sub_addr.node, + sdrive_addr, + zdrive_addr, + node_info_addr, + ) + + logger.info( + "Discovered %d pipettor channel drive pairs", len(self._channel_sleeve_sensor_addrs) + ) + + async def _run_initialize(self, smart: bool): + """Send PrepCmd.PrepInitialize to MLPrep (shared by setup).""" + await self.client.send_command( + PrepCmd.PrepInitialize( + dest=await self._require("mlprep"), + smart=smart, + tip_drop_params=PrepCmd.InitTipDropParameters( + default_values=True, + x_position=287.0, + rolloff_distance=3, + channel_parameters=[], + ), + ) + ) + + async def _get_hardware_config(self) -> PrepCmd.InstrumentConfig: + """Aggregate getters: query MLPrep, DeckConfiguration, and MLPrepService for hardware config. + + Includes deck/enclosure, deck sites, waste sites, traverse height, and channel + configuration (num_channels, has_mph) from GetPresentChannels. + """ + mlprep = await self._require("mlprep") + enc_resp = await self.client.send_command(PrepCmd.PrepGetIsEnclosurePresent(dest=mlprep)) + safe_resp = await self.client.send_command(PrepCmd.PrepGetSafeSpeedsEnabled(dest=mlprep)) + height_resp = await self.client.send_command(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._require("deck_config") + + bounds_resp = await self.client.send_command(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 self.client.send_command(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 self.client.send_command(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) + + # Channel configuration (1 vs 2 dual-channel pipettor, 8MPH) from MLPrepService + 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, + ) + + # --------------------------------------------------------------------------- + # Properties + # --------------------------------------------------------------------------- + + @property + def num_channels(self) -> int: + """Number of independent dual-channel pipettor channels (1 or 2). Set at setup from GetPresentChannels.""" + if self._num_channels is None: + raise RuntimeError("num_channels not set. Call setup() first.") + return self._num_channels + + @property + def has_mph(self) -> bool: + """True if the 8-channel Multi-Pipetting Head (8MPH) is present. Set at setup from GetPresentChannels.""" + return bool(self._has_mph) if self._has_mph is not None else 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 or self._num_channels is None or self._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]) -> 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 + if self._config is not None and self._config.default_traverse_height is not None: + return self._config.default_traverse_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 " + "PrepBackend(..., default_traverse_height=) or backend.set_default_traverse_height(). " + "If the instrument supports it, the value is also probed during setup(); ensure setup() completed successfully." + ) from None + + async def is_initialized(self) -> bool: + """Query whether MLPrep reports as initialized (GetIsInitialized, cmd=2). + + Uses MLPrep method from introspection: GetIsInitialized(()) -> value: I64. + Requires MLPrep to be discovered (e.g. after self.client.setup() and + _discover_prep_objects()). Call before or after PrepCmd.PrepInitialize to test. + """ + result = await self.client.send_command( + PrepCmd.PrepGetIsInitialized(dest=await self._require("mlprep")) + ) + if result is None: + return False + return bool(result.value) + + async def get_tip_and_needle_definitions(self) -> Tuple[PrepCmd.TipDefinition, ...]: + """Return tip/needle definitions registered on the instrument (GetTipAndNeedleDefinitions, cmd=11).""" + result = await self.client.send_command( + PrepCmd.PrepGetTipAndNeedleDefinitions(dest=await self._require("mlprep")) + ) + if result is None or not getattr(result, "definitions", None): + return () + return tuple(result.definitions) + + async def is_parked(self) -> bool: + """Query whether MLPrep is parked (IsParked, cmd=34).""" + result = await self.client.send_command( + PrepCmd.PrepIsParked(dest=await self._require("mlprep")) + ) + if result is None: + return False + return bool(result.value) + + async def is_spread(self) -> bool: + """Query whether channels are spread (IsSpread, cmd=35). Pipettor commands typically require spread state.""" + result = await self.client.send_command( + PrepCmd.PrepIsSpread(dest=await self._require("mlprep")) + ) + if result is None: + return False + return bool(result.value) + + # --------------------------------------------------------------------------- + # LiquidHandlerBackend abstract methods + # --------------------------------------------------------------------------- + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + 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, + ): + """Pick up tips. + + 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). + + Args: + final_z: Traverse/safe height (mm) for the move and Z position after command. + If None, uses the user-set value (constructor or set_default_traverse_height) or the + value probed from the instrument at setup. Raises RuntimeError if none is available. + seek_speed: Speed (mm/s) for the seek/approach phase. + z_seek_offset: Additive mm on top of the geometry-based default. None = 0 + (use default only). Use to raise or lower the approach height if needed. + enable_tadm: Enable tip-adjust during pickup. + dispenser_volume: Dispenser volume for TADM (if enabled). + dispenser_speed: Dispenser speed for TADM (if enabled). + """ + assert len(ops) == 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})" + ) + + resolved_final_z = self._resolve_traverse_height(final_z) + + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + tip_positions: List[PrepCmd.TipPositionParameters] = [] + for ch in range(self.num_channels): + if ch not in indexed_ops: + continue + op = indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "t") + params = PrepCmd.TipPositionParameters.for_op( + _CHANNEL_INDEX[ch], + loc, + op.resource.get_tip(), + z_seek_offset=z_seek_offset, + ) + tip_positions.append(params) + + assert len(set(op.tip for op in ops)) == 1, "All ops must use the same tip type" + tip = ops[0].tip + 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, + ) + + await self.client.send_command( + PrepCmd.PrepPickUpTips( + dest=await self._require("pipettor"), + 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, + ) + ) + + async def drop_tips( + self, + ops: List[Drop], + use_channels: List[int], + final_z: Optional[float] = None, + seek_speed: float = 30.0, + z_seek_offset: Optional[float] = None, + drop_type: PrepCmd.TipDropType = PrepCmd.TipDropType.FixedHeight, + tip_roll_off_distance: float = 0.0, + ): + """Drop tips. + + 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. + + Args: + final_z: Traverse/safe height (mm) for the move and Z position after command. + If None, uses the user-set value (constructor or set_default_traverse_height) or the + value probed from the instrument at setup. Raises RuntimeError if none is available. + seek_speed: Speed (mm/s) for the seek/approach phase. + z_seek_offset: Additive mm on top of the geometry-based default. None = 0 + (use default only). Use to raise or lower the approach height if needed. + drop_type: How the tip is released (FixedHeight, Stall, or CLLDSeek). + tip_roll_off_distance: Roll-off distance (mm) for tip release. + """ + assert len(ops) == 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})" + ) + + all_trash = all(isinstance(op.resource, Trash) for op in ops) + all_tip_spots = all(isinstance(op.resource, TipSpot) for op in ops) + 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 + # Use Stall when dropping to waste so the pipette detects contact before release. + resolved_drop_type = PrepCmd.TipDropType.Stall if all_trash else drop_type + + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + tip_positions: List[PrepCmd.TipDropParameters] = [] + for ch in range(self.num_channels): + if ch not in indexed_ops: + continue + op = indexed_ops[ch] + tip = op.tip + if all_trash: + 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 = op.resource.get_absolute_location("c", "c", "t") + op.offset + params = PrepCmd.TipDropParameters.for_op( + _CHANNEL_INDEX[ch], + loc, + tip, + z_seek_offset=z_seek_offset, + drop_type=resolved_drop_type, + ) + tip_positions.append(params) + + await self.client.send_command( + PrepCmd.PrepDropTips( + dest=await self._require("pipettor"), + tip_positions=tip_positions, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=roll_off, + ) + ) + + # --------------------------------------------------------------------------- + # MPH head tip operations + # --------------------------------------------------------------------------- + + async def pick_up_tips_mph( + self, + tip_spot: Union[TipSpot, List[TipSpot]], + tip_mask: int = 0xFF, + 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, + ) -> None: + """Pick up tips with the MPH (multi-probe) head. + + Routes to MLPrepRoot.MphRoot.MPH (PickupTips, iface=1 id=9). The MPH + takes a single reference position (type_57 = single struct) rather than + a per-channel list (type_61). All 8 probes move as one unit; tip_mask + selects which channels engage (default 0xFF = all 8). + + The first TipSpot is used as the reference position. For a full column + pickup, pass tip_rack["A1:H1"] — only the first spot's (x,y,z) is sent, + all 8 probes engage via tip_mask. + + Args: + tip_spot: A single TipSpot or a list. The first spot is used as the + reference position for all probes. + tip_mask: 8-bit bitmask of active MPH channels (bit 0 = channel 0, + bit 7 = channel 7). Default 0xFF picks up with all 8 channels. + final_z: Traverse/safe height (mm) after command. If None, uses the + probed or user-set default traverse height. + seek_speed: Speed (mm/s) for the Z approach phase. + z_seek_offset: Additive mm offset on top of the geometry-based seek Z + (tip.fitting_depth + 5 mm). None = 0. + enable_tadm: Enable tip-attachment detection (TADM) during pickup. + dispenser_volume: Dispenser volume for TADM (ignored when False). + dispenser_speed: Dispenser speed for TADM (ignored when False). + """ + if not self.has_mph: + raise RuntimeError("Instrument does not have an 8MPH head. Cannot use pick_up_tips_mph.") + if isinstance(tip_spot, list): + spots = tip_spot + else: + spots = [tip_spot] + if not spots: + raise ValueError("pick_up_tips_mph: tip_spot list is empty") + resolved_final_z = self._resolve_traverse_height(final_z) + + ref_spot = spots[0] + tip = ref_spot.get_tip() + loc = ref_spot.get_absolute_location("c", "c", "t") + tip_parameters = 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, + ) + + await self.client.send_command( + PrepCmd.MphPickupTips( + dest=await self._require("mph"), + tip_parameters=tip_parameters, + 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=tip_mask, + ) + ) + + async def drop_tips_mph( + self, + tip_spot: Union[TipSpot, List[TipSpot]], + final_z: Optional[float] = None, + seek_speed: float = 30.0, + z_seek_offset: Optional[float] = None, + drop_type: PrepCmd.TipDropType = PrepCmd.TipDropType.FixedHeight, + tip_roll_off_distance: float = 0.0, + ) -> None: + """Drop tips held by the MPH head. + + Routes to MLPrepRoot.MphRoot.MPH (DropTips, iface=1 id=12). The MPH + takes a single reference position (type_57 = single struct); all probes + drop together at the same location. + + Args: + tip_spot: Target drop position. The first spot is used as the reference + position for all probes. + final_z: Traverse/safe height (mm) after command. If None, uses the + probed or user-set default traverse height. + seek_speed: Speed (mm/s) for the Z seek/approach phase. + z_seek_offset: Additive mm offset on top of the geometry-based seek Z. + None = 0 (default seeks tip_bottom + total_tip_length + 10 mm). + drop_type: How tips are released (FixedHeight, Stall, or CLLDSeek). + tip_roll_off_distance: Roll-off distance (mm) for tip release. + """ + if not self.has_mph: + raise RuntimeError("Instrument does not have an 8MPH head. Cannot use drop_tips_mph.") + if isinstance(tip_spot, list): + spots = tip_spot + else: + spots = [tip_spot] + if not spots: + raise ValueError("drop_tips_mph: tip_spot list is empty") + resolved_final_z = self._resolve_traverse_height(final_z) + + ref_spot = spots[0] + tip = ref_spot.get_tip() + loc = ref_spot.get_absolute_location("c", "c", "t") + drop_parameters = PrepCmd.TipDropParameters.for_op( + PrepCmd.ChannelIndex.MPHChannel, + loc, + tip, + z_seek_offset=z_seek_offset, + drop_type=drop_type, + ) + + await self.client.send_command( + PrepCmd.MphDropTips( + dest=await self._require("mph"), + drop_parameters=drop_parameters, + final_z=resolved_final_z, + seek_speed=seek_speed, + tip_roll_off_distance=tip_roll_off_distance, + ) + ) + + async def aspirate( + self, + ops: List[SingleChannelAspiration], + use_channels: List[int], + 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, + monitoring_mode: PrepCmd.MonitoringMode = PrepCmd.MonitoringMode.MONITORING, + lld_mode: Optional[List[LLDMode]] = None, + use_lld: bool = False, + 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, # TODO: Doesn't work with No LLD + auto_container_geometry: bool = False, + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + read_timeout: Optional[float] = None, + ): + """Aspirate using v2 commands, dispatching to the appropriate variant. + + Selects the command variant based on ``lld_mode`` (LLD on/off) and + ``monitoring_mode`` (Monitoring vs TADM). Z/geometry parameters (z_final, + z_fluid, z_air, z_minimum, z_bottom_search_offset): None = use defaults for all + channels (derived from well geometry, STAR-aligned). Otherwise pass a list of + length len(ops) with one value per channel (no None in list). For per-channel + defaults, build the list from liquid class or constants. + + Liquid-class-derived parameters (settling_time, transport_air_volume, + z_liquid_exit_speed, prewet_volume): None = use defaults for all channels (HLC + or fallback per channel). Otherwise pass a list of length len(ops) with one + value per channel (no None in list). + + Args: + z_final: Z after the move (retract height) per channel. None = defaults for all; else list of len(ops), no None in list. + z_fluid: Liquid surface Z when not using LLD, per channel. None = defaults for all; else list of len(ops). + z_air: Z in air (above liquid), per channel. None = defaults for all; else list of len(ops). + settling_time: Settling time (s) per channel. None = defaults for all; else list of len(ops). + transport_air_volume: Transport air volume (µL) per channel. None = defaults for all; else list of len(ops). + z_liquid_exit_speed: Z speed on leaving liquid (mm/s) per channel. None = defaults for all; else list of len(ops). + prewet_volume: Pre-wet volume (µL) per channel. None = defaults for all; else list of len(ops). + z_minimum: Minimum Z (well floor) per channel. None = defaults for all; else list of len(ops). + z_bottom_search_offset: Bottom search offset (mm) per channel. None = defaults for all; else list of len(ops). + monitoring_mode: Select TADM or Monitoring (default: Monitoring). + lld_mode: Per-channel LLD mode list. Any non-OFF mode activates the LLD + command variant. All channels must use the same category (all LLD or all OFF). + use_lld: Enable LLD aspirate variant. Deprecated — use ``lld_mode`` instead. + lld: LLD seek parameters. When None and LLD active, built from labware geometry. + p_lld: Pressure LLD parameters (LLD variants only). + c_lld: Capacitive LLD parameters (LLD variants only). + tadm: TADM parameters (TADM variants only). Firmware defaults when None. + container_segments: Per-channel SegmentDescriptor lists for liquid following. + auto_container_geometry: Build container segments from well geometry. + hamilton_liquid_classes: Per-op Hamilton liquid classes. None = auto from tip/liquid. + disable_volume_correction: Per-op flag to skip volume correction. + read_timeout: Override read timeout (seconds) for this command. When None, + auto-calculated from LLD seek distance/speed + 5s buffer. + + Example:: + + await backend.aspirate(ops, [0], z_final=[95.0], settling_time=[2.0]) + await backend.aspirate(ops, [0], lld_mode=[PrepBackend.LLDMode.CAPACITIVE]) + await backend.aspirate(ops, [0], monitoring_mode=PrepCmd.MonitoringMode.TADM) + """ + assert len(ops) == 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})" + ) + + n = len(ops) + hlcs: List[Optional[HamiltonLiquidClass]] + if hamilton_liquid_classes is not None: + if len(hamilton_liquid_classes) != n: + raise ValueError( + f"hamilton_liquid_classes length must match len(ops): {len(hamilton_liquid_classes)} != {n}" + ) + hlcs = list(hamilton_liquid_classes) + else: + # Defaults from STAR calibration table; add get_prep_liquid_class if Prep needs different values. + hlcs = [ + get_star_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + for op in ops + ] + disable_volume_correction = ( + disable_volume_correction if disable_volume_correction is not None else [False] * n + ) + if len(disable_volume_correction) != n: + raise ValueError( + f"disable_volume_correction length must match len(ops): {len(disable_volume_correction)} != {n}" + ) + ch_to_idx = {ch: i for i, ch in enumerate(use_channels)} + + # Default lists from HLC (fallbacks when HLC is None) + default_settling = [hlc.aspiration_settling_time if hlc is not None else 1.0 for hlc in hlcs] + default_transport_air_volume = [ + hlc.aspiration_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs + ] + default_z_liquid_exit_speed = [ + hlc.aspiration_swap_speed if hlc is not None else 10.0 for hlc in hlcs + ] + default_prewet_volume = [ + hlc.aspiration_over_aspirate_volume if hlc is not None else 0.0 for hlc in hlcs + ] + settling_time = fill_in_defaults(settling_time, default_settling) + transport_air_volume = fill_in_defaults(transport_air_volume, default_transport_air_volume) + z_liquid_exit_speed = fill_in_defaults(z_liquid_exit_speed, default_z_liquid_exit_speed) + prewet_volume = fill_in_defaults(prewet_volume, default_prewet_volume) + + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hlcs, disable_volume_correction) + ] + 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) + ] + + # Resolve LLD mode: lld_mode list takes precedence, then use_lld bool, + # then lld parameter presence. The Prep firmware uses separate command variants + # for LLD vs no-LLD, so all channels must agree on LLD on/off. + 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}") + lld_on = [m != self.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." + ) + effective_lld = all(lld_on) + else: + effective_lld = use_lld or (lld is not None) + + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + + # Precompute well geometry once (used for default Z lists and for LLD in the loop). + well_geometry = [_absolute_z_from_well(op) for op in ops] + default_z_minimum = [g[0] for g in well_geometry] + default_z_fluid = [g[1] for g in well_geometry] + default_z_air = [g[3] for g in well_geometry] + raw_traverse = self._resolve_traverse_height(None) + default_z_final = [ + raw_traverse - (op.tip.total_tip_length - op.tip.fitting_depth) for op in ops + ] + default_z_bottom_search_offset = [2.0] * n + z_minimum = fill_in_defaults(z_minimum, default_z_minimum) + z_fluid = fill_in_defaults(z_fluid, default_z_fluid) + z_air = fill_in_defaults(z_air, default_z_air) + z_final = fill_in_defaults(z_final, default_z_final) + z_bottom_search_offset = fill_in_defaults( + z_bottom_search_offset, default_z_bottom_search_offset + ) + + # Build per-channel segment lists. + 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] = [] + + # For LLD to actually trigger, both LldParameters and CLldParameters must use + # default_values=False. With default_values=True the firmware silently skips LLD. + # Empirically validated: sensitivity=4, detect_mode=0 triggers cLLD on the Prep. + if effective_lld: + _p_lld = p_lld or PrepCmd.PLldParameters( + default_values=False, + sensitivity=1, + dispenser_seek_speed=0.0, + lld_height_difference=0.0, + detect_mode=0, + ) + _c_lld = c_lld or PrepCmd.CLldParameters( + default_values=False, + sensitivity=4, + clot_check_enable=False, + z_clot_check=0.0, + detect_mode=0, + ) + else: + _p_lld = p_lld or PrepCmd.PLldParameters.default() + _c_lld = c_lld or PrepCmd.CLldParameters.default() + _tadm = tadm or PrepCmd.TadmParameters.default() + + params_lld_mon: List[PrepCmd.AspirateParametersLldAndMonitoring2] = [] + params_lld_tadm: List[PrepCmd.AspirateParametersLldAndTadm2] = [] + params_nolld_mon: List[PrepCmd.AspirateParametersNoLldAndMonitoring2] = [] + params_nolld_tadm: List[PrepCmd.AspirateParametersNoLldAndTadm2] = [] + + for ch in range(self.num_channels): + if ch not in indexed_ops: + continue + idx = ch_to_idx[ch] + op = indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + radius = _effective_radius(op.resource) + asp = PrepCmd.AspirateParameters.for_op( + loc, + op, + prewet_volume=prewet_volume[idx], + blowout_volume=blowout_volumes[idx], + ) + segs = ch_segments[ch] + + z_minimum_ch = z_minimum[idx] + z_final_ch = z_final[idx] + z_fluid_ch = z_fluid[idx] + z_air_ch = z_air[idx] + z_bottom_search_offset_ch = z_bottom_search_offset[idx] + + if effective_lld and lld is None: + top_of_well_z = well_geometry[idx][2] + _lld = PrepCmd.LldParameters( + default_values=False, + search_start_position=top_of_well_z, + channel_speed=5.0, # mm/s — must be >0 or firmware rejects with 0x0011 + z_submerge=2.0, + z_out_of_liquid=0.0, + ) + else: + _lld = lld or PrepCmd.LldParameters.default() + + common = PrepCmd.CommonParameters.for_op( + volumes[idx], + radius, + flow_rate=flow_rates[idx], + z_minimum=z_minimum_ch, + z_final=z_final_ch, + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], + ) + no_lld = PrepCmd.NoLldParameters.for_fixed_z( + z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset_ch + ) + + if effective_lld and monitoring_mode == PrepCmd.MonitoringMode.TADM: + params_lld_tadm.append( + PrepCmd.AspirateParametersLldAndTadm2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + lld=_lld, + p_lld=_p_lld, + c_lld=_c_lld, + mix=PrepCmd.MixParameters.default(), + tadm=_tadm, + adc=PrepCmd.AdcParameters.default(), + ) + ) + elif effective_lld: + params_lld_mon.append( + PrepCmd.AspirateParametersLldAndMonitoring2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + lld=_lld, + p_lld=_p_lld, + c_lld=_c_lld, + mix=PrepCmd.MixParameters.default(), + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + adc=PrepCmd.AdcParameters.default(), + ) + ) + elif monitoring_mode == PrepCmd.MonitoringMode.TADM: + params_nolld_tadm.append( + PrepCmd.AspirateParametersNoLldAndTadm2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + no_lld=no_lld, + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + tadm=_tadm, + ) + ) + else: + params_nolld_mon.append( + PrepCmd.AspirateParametersNoLldAndMonitoring2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + aspirate=asp, + container_description=segs, + common=common, + no_lld=no_lld, + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + aspirate_monitoring=PrepCmd.AspirateMonitoringParameters.default(), + ) + ) + + dest = await self._require("pipettor") + + # For LLD aspirates, auto-calculate read_timeout from seek distance and speed + # to prevent connection timeout during slow descents. + # Explicit read_timeout from caller takes precedence. + lld_read_timeout = read_timeout + if lld_read_timeout is None and effective_lld and _lld.channel_speed > 0: + seek_distance = _lld.search_start_position - min(z_minimum) + if seek_distance > 0: + lld_read_timeout = seek_distance / _lld.channel_speed + 5.0 + + if effective_lld and monitoring_mode == PrepCmd.MonitoringMode.TADM: + await self.client.send_command( + PrepCmd.PrepAspirateWithLldTadmV2(dest=dest, aspirate_parameters=params_lld_tadm), + read_timeout=lld_read_timeout, + ) + elif effective_lld: + await self.client.send_command( + PrepCmd.PrepAspirateWithLldV2(dest=dest, aspirate_parameters=params_lld_mon), + read_timeout=lld_read_timeout, + ) + elif monitoring_mode == PrepCmd.MonitoringMode.TADM: + await self.client.send_command( + PrepCmd.PrepAspirateTadmV2(dest=dest, aspirate_parameters=params_nolld_tadm) + ) + else: + await self.client.send_command( + PrepCmd.PrepAspirateNoLldMonitoringV2(dest=dest, aspirate_parameters=params_nolld_mon) + ) + + async def dispense( + self, + ops: List[SingleChannelDispense], + use_channels: List[int], + final_z: 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[LLDMode]] = None, + use_lld: bool = False, + lld: Optional[PrepCmd.LldParameters] = None, + c_lld: Optional[PrepCmd.CLldParameters] = None, + container_segments: Optional[List[List[PrepCmd.SegmentDescriptor]]] = None, + auto_container_geometry: bool = False, # TODO: Doesn't work with no LLD + hamilton_liquid_classes: Optional[List[HamiltonLiquidClass]] = None, + disable_volume_correction: Optional[List[bool]] = None, + ): + """Dispense using v2 commands, dispatching to NoLLD or LLD variant. + + Z/geometry parameters (final_z, z_fluid, z_air, z_minimum, z_bottom_search_offset): + None = use defaults for all channels (derived from well geometry, STAR-aligned). + Otherwise pass a list of length len(ops) with one value per channel (no None in list). + For per-channel defaults, build the list from liquid class or constants. + + Liquid-class-derived parameters (settling_time, transport_air_volume, + z_liquid_exit_speed, stop_back_volume, cutoff_speed): None = use defaults for all + channels (HLC or fallback per channel). Otherwise pass a list of length len(ops) + with one value per channel (no None in list). + + Args: + final_z: Z after the move per channel. None = defaults for all; else list of len(ops), no None in list. + z_fluid: Liquid surface Z when not using LLD, per channel. None = defaults for all; else list of len(ops). + z_air: Z in air (above liquid), per channel. None = defaults for all; else list of len(ops). + settling_time: Settling time (s) per channel. None = defaults for all; else list of len(ops). + transport_air_volume: Transport air volume (µL) per channel. None = defaults for all; else list of len(ops). + z_liquid_exit_speed: Z speed on leaving liquid (mm/s) per channel. None = defaults for all; else list of len(ops). + stop_back_volume: Stop-back volume (µL) per channel. None = defaults for all; else list of len(ops). + cutoff_speed: Cutoff/stop flow rate (µL/s) per channel. None = defaults for all; else list of len(ops). + z_minimum: Minimum Z (well floor) per channel. None = defaults for all; else list of len(ops). + z_bottom_search_offset: Bottom search offset (mm) per channel. None = defaults for all; else list of len(ops). + lld_mode: Per-channel LLD mode list. Only CAPACITIVE or OFF supported for + dispense (pressure LLD is physically impossible during dispense). + use_lld: Enable LLD dispense variant. Deprecated — use ``lld_mode`` instead. + lld: LLD seek parameters. When None and LLD active, built from labware geometry. + c_lld: Capacitive LLD parameters (LLD variant only). + container_segments: Per-channel SegmentDescriptor lists for liquid following. + auto_container_geometry: Build container segments from well geometry. + hamilton_liquid_classes: Per-op Hamilton liquid classes. None = auto from tip/liquid. + disable_volume_correction: Per-op flag to skip volume correction. + + Example:: + + await backend.dispense(ops, [0], final_z=[95.0], settling_time=[0.5]) + await backend.dispense(ops, [0], lld_mode=[PrepBackend.LLDMode.CAPACITIVE]) + """ + assert len(ops) == 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})" + ) + + n = len(ops) + hlcs: List[Optional[HamiltonLiquidClass]] + if hamilton_liquid_classes is not None: + if len(hamilton_liquid_classes) != n: + raise ValueError( + f"hamilton_liquid_classes length must match len(ops): {len(hamilton_liquid_classes)} != {n}" + ) + hlcs = list(hamilton_liquid_classes) + else: + # Defaults from STAR calibration table; add get_prep_liquid_class if Prep needs different values. + hlcs = [ + get_star_liquid_class( + tip_volume=op.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=op.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + for op in ops + ] + disable_volume_correction = ( + disable_volume_correction if disable_volume_correction is not None else [False] * n + ) + if len(disable_volume_correction) != n: + raise ValueError( + f"disable_volume_correction length must match len(ops): {len(disable_volume_correction)} != {n}" + ) + ch_to_idx = {ch: i for i, ch in enumerate(use_channels)} + + # Default lists from HLC (fallbacks when HLC is None) + default_settling = [hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hlcs] + default_transport_air_volume = [ + hlc.dispense_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs + ] + default_z_liquid_exit_speed = [ + hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs + ] + default_stop_back_volume = [ + hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs + ] + default_cutoff_speed = [ + hlc.dispense_stop_flow_rate if hlc is not None else 100.0 for hlc in hlcs + ] + settling_time = fill_in_defaults(settling_time, default_settling) + transport_air_volume = fill_in_defaults(transport_air_volume, default_transport_air_volume) + z_liquid_exit_speed = fill_in_defaults(z_liquid_exit_speed, default_z_liquid_exit_speed) + stop_back_volume = fill_in_defaults(stop_back_volume, default_stop_back_volume) + cutoff_speed = fill_in_defaults(cutoff_speed, default_cutoff_speed) + + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hlcs, disable_volume_correction) + ] + 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) + ] + + # Resolve LLD mode — same structure as aspirate(), but dispense only supports + # capacitive LLD (pressure LLD is physically impossible during dispense). + 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}") + for m in lld_mode: + if m in (self.LLDMode.PRESSURE, self.LLDMode.DUAL): + raise ValueError( + f"Dispense does not support {m.name} LLD — only CAPACITIVE or OFF. " + "Pressure-based LLD requires aspiration (plunger movement)." + ) + lld_on = [m != self.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 in one call. " + "Split into separate calls for channels with different LLD modes." + ) + effective_lld = all(lld_on) + else: + effective_lld = use_lld or (lld is not None) + + indexed_ops = {ch: op for ch, op in zip(use_channels, ops)} + + # Precompute well geometry once (used for default Z lists and for LLD in the loop). + well_geometry = [_absolute_z_from_well(op) for op in ops] + default_z_minimum = [g[0] for g in well_geometry] + default_z_fluid = [g[1] for g in well_geometry] + default_z_air = [g[3] for g in well_geometry] + raw_traverse = self._resolve_traverse_height(None) + default_final_z = [ + raw_traverse - (op.tip.total_tip_length - op.tip.fitting_depth) for op in ops + ] + default_z_bottom_search_offset = [2.0] * n + z_minimum = fill_in_defaults(z_minimum, default_z_minimum) + z_fluid = fill_in_defaults(z_fluid, default_z_fluid) + z_air = fill_in_defaults(z_air, default_z_air) + final_z = fill_in_defaults(final_z, default_final_z) + z_bottom_search_offset = fill_in_defaults( + z_bottom_search_offset, default_z_bottom_search_offset + ) + + 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] = [] + + # See aspirate() comment — default_values=False required for LLD to trigger. + if effective_lld: + _c_lld = c_lld or PrepCmd.CLldParameters( + default_values=False, + sensitivity=4, + clot_check_enable=False, + z_clot_check=0.0, + detect_mode=0, + ) + else: + _c_lld = c_lld or PrepCmd.CLldParameters.default() + + params_nolld: List[PrepCmd.DispenseParametersNoLld2] = [] + params_lld: List[PrepCmd.DispenseParametersLld2] = [] + + for ch in range(self.num_channels): + if ch not in indexed_ops: + continue + idx = ch_to_idx[ch] + op = indexed_ops[ch] + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + radius = _effective_radius(op.resource) + disp = PrepCmd.DispenseParameters.for_op( + loc, + stop_back_volume=stop_back_volume[idx], + cutoff_speed=cutoff_speed[idx], + ) + segs = ch_segments[ch] + + z_minimum_ch = z_minimum[idx] + z_final_ch = final_z[idx] + z_fluid_ch = z_fluid[idx] + z_air_ch = z_air[idx] + z_bottom_search_offset_ch = z_bottom_search_offset[idx] + + if effective_lld and lld is None: + top_of_well_z = well_geometry[idx][2] + _lld = PrepCmd.LldParameters( + default_values=False, + search_start_position=top_of_well_z, + channel_speed=5.0, # mm/s — must be >0 or firmware rejects with 0x0011 + z_submerge=2.0, + z_out_of_liquid=0.0, + ) + else: + _lld = lld or PrepCmd.LldParameters.default() + + common = PrepCmd.CommonParameters.for_op( + volumes[idx], + radius, + flow_rate=flow_rates[idx], + z_minimum=z_minimum_ch, + z_final=z_final_ch, + z_liquid_exit_speed=z_liquid_exit_speed[idx], + transport_air_volume=transport_air_volume[idx], + settling_time=settling_time[idx], + ) + + if effective_lld: + params_lld.append( + PrepCmd.DispenseParametersLld2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + dispense=disp, + container_description=segs, + common=common, + lld=_lld, + c_lld=_c_lld, + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + tadm=PrepCmd.TadmParameters.default(), + ) + ) + else: + params_nolld.append( + PrepCmd.DispenseParametersNoLld2( + default_values=False, + channel=_CHANNEL_INDEX[ch], + dispense=disp, + container_description=segs, + common=common, + no_lld=PrepCmd.NoLldParameters.for_fixed_z( + z_fluid_ch, z_air_ch, z_bottom_search_offset=z_bottom_search_offset_ch + ), + mix=PrepCmd.MixParameters.default(), + adc=PrepCmd.AdcParameters.default(), + tadm=PrepCmd.TadmParameters.default(), + ) + ) + + dest = await self._require("pipettor") + if effective_lld: + await self.client.send_command( + PrepCmd.PrepDispenseWithLldV2(dest=dest, dispense_parameters=params_lld) + ) + else: + await self.client.send_command( + PrepCmd.PrepDispenseNoLldV2(dest=dest, dispense_parameters=params_nolld) + ) + + async def pick_up_tips96(self, pickup: PickupTipRack): + raise NotImplementedError("pick_up_tips96 is not supported on the Prep") + + async def drop_tips96(self, drop: DropTipRack): + raise NotImplementedError("drop_tips96 is not supported on the Prep") + + async def aspirate96( + self, aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + ): + raise NotImplementedError("aspirate96 is not supported on the Prep") + + async def dispense96(self, dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer]): + raise NotImplementedError("dispense96 is not supported on the Prep") + + 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, + ) -> None: + """Pick up tool from the given position (PrepCmd.PrepPickUpTool, cmd=15). Sets _gripper_tool_on and 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 + await self.client.send_command( + PrepCmd.PrepPickUpTool( + dest=await self._require("pipettor"), + 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, + ) + ) + self._gripper_tool_on = True + await self.move_channels_to_safe_z() + + async def drop_tool(self, *, move_to_safe_z_first: bool = True) -> None: + """Drop tool (PrepCmd.PrepDropTool, cmd=16). Optionally move channels to safe Z first. Clears _gripper_tool_on.""" + if move_to_safe_z_first: + await self.move_channels_to_safe_z() + await self.client.send_command(PrepCmd.PrepDropTool(dest=await self._require("pipettor"))) + self._gripper_tool_on = False + + async def release_plate(self) -> None: + """Release plate / open gripper (PrepCmd.PrepReleasePlate, cmd=21). No parameters.""" + await self.client.send_command(PrepCmd.PrepReleasePlate(dest=await self._require("pipettor"))) + + async def pick_up_resource( + self, + pickup: ResourcePickup, + *, + clearance_y: float = 2.5, + grip_speed_y: float = 5.0, + squeeze_mm: float = 2.0, + ): + if self.deck is None: + raise RuntimeError("deck not set") + if pickup.direction != GripDirection.FRONT: + raise NotImplementedError("PREP CORE gripper only supports GripDirection.FRONT") + resource = pickup.resource + center = resource.get_location_wrt(self.deck, "c", "c", "t") + pickup.offset + grip_height = center.z - pickup.pickup_distance_from_top + # plate_top_center = literal top center of plate (x, y, z_top); grip_height is separate. + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=center.x, + y_position=center.y, + z_position=center.z, + ) + # Grip distance = how far the grippers close from open (travel). Open = labware_y + clearance_y, final = labware_y - squeeze_mm → close by clearance_y + squeeze_mm. + grip_distance = clearance_y + squeeze_mm + plate_dims = PrepCmd.PlateDimensions( + default_values=False, + length=resource.get_absolute_size_x(), + width=resource.get_absolute_size_y(), + height=resource.get_absolute_size_z(), + ) + if not self._gripper_tool_on: + 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.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, + ) + await self.client.send_command( + PrepCmd.PrepPickUpPlate( + dest=await self._require("pipettor"), + 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=grip_height, + ) + ) + + async def move_picked_up_resource(self, move: ResourceMove): + if self.deck is None: + raise RuntimeError("deck not set") + center = ( + move.location + + move.resource.get_anchor("c", "c", "t") + - Coordinate(z=move.pickup_distance_from_top) + + move.offset + ) + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=center.x, + y_position=center.y, + z_position=center.z, + ) + await self.client.send_command( + PrepCmd.PrepMovePlate( + dest=await self._require("pipettor"), + plate_top_center=plate_top_center, + acceleration_scale_x=1, + ) + ) + + async def drop_resource( + self, + drop: ResourceDrop, + *, + return_gripper: bool = True, + clearance_y: float = 3.0, + ): + if self.deck is None: + raise RuntimeError("deck not set") + resource = drop.resource + dest_center = drop.destination + resource.get_anchor("c", "c", "t") + drop.offset + place_z = drop.destination.z + resource.get_absolute_size_z() - drop.pickup_distance_from_top + plate_top_center = PrepCmd.XYZCoord( + default_values=False, + x_position=dest_center.x, + y_position=dest_center.y, + z_position=place_z, + ) + await self.client.send_command( + PrepCmd.PrepDropPlate( + dest=await self._require("pipettor"), + plate_top_center=plate_top_center, + clearance_y=clearance_y, + acceleration_scale_x=1, + ) + ) + if return_gripper: + await self.drop_tool() + + 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 + if self._num_channels is not None and channel_idx >= self._num_channels: + return False + return True + + # --------------------------------------------------------------------------- + # Firmware version queries + # --------------------------------------------------------------------------- + + @staticmethod + def _decode_firmware_string(raw: Optional[tuple]) -> Optional[str]: + """Decode a string from a raw HOI response. + + Hamilton string wire format: 0x0F + type_byte + u16 length + chars. + type_byte is 0x00 (plain) or 0x01 (with null terminator); both are handled. + """ + if raw is None: + return None + data: bytes = raw[0] + i = 0 + while i < len(data) - 3: + if data[i] == 0x0F and data[i + 1] in (0x00, 0x01): + slen = int.from_bytes(data[i + 2 : i + 4], "little") + if slen > 0 and i + 4 + slen <= len(data): + return data[i + 4 : i + 4 + slen].decode("utf-8", errors="replace").rstrip("\x00") + i += 1 + return None + + async def _query_firmware_string( + self, addr: Address, cmd_id: int, iface_id: int = 3 + ) -> Optional[str]: + """Send a status query and decode the string response.""" + Cmd = type( + "_FWQuery", + (PrepCmd._PrepStatusQuery,), + {"command_id": cmd_id, "interface_id": iface_id, "__annotations__": {"dest": Address}}, + ) + raw: Optional[tuple] = await self.client.send_command( + Cmd(dest=addr), return_raw=True, raise_on_error=False + ) + return self._decode_firmware_string(raw) + + async def request_firmware_version(self) -> Optional[str]: + """Request the instrument controller firmware version string. + + Returns a string like "MLPrep Runtime V1.2.2.444 99020-02 Rev G", + or None if MLPrepCpu was not discovered. + + Analogous to STARBackend.request_firmware_version(). + """ + if self._mlprep_cpu_addr is None: + return None + return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=8) + + async def request_device_serial_number(self) -> Optional[str]: + """Request the instrument serial number. + + Analogous to STARBackend.request_device_serial_number(). + """ + if self._mlprep_cpu_addr is None: + return None + return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=9) + + async def request_bootloader_version(self) -> Optional[str]: + """Request the instrument bootloader version string.""" + if self._mlprep_cpu_addr is None: + return None + return await self._query_firmware_string(self._mlprep_cpu_addr, cmd_id=2, iface_id=2) + + async def request_pip_channel_version(self, channel: int) -> Optional[str]: + """Request the firmware version string for a pipettor channel. + + Args: + channel: Channel index (0=rearmost). + + Analogous to STARBackend.request_pip_channel_version(). + """ + if channel >= len(self._channel_node_info_addrs): + return None + return await self._query_firmware_string( + self._channel_node_info_addrs[channel], cmd_id=8, iface_id=1 + ) + + async def request_pip_channel_serial_number(self, channel: int) -> Optional[str]: + """Request the serial number for a pipettor channel. + + Args: + channel: Channel index (0=rearmost). + """ + if channel >= len(self._channel_node_info_addrs): + return None + return await self._query_firmware_string( + self._channel_node_info_addrs[channel], cmd_id=9, iface_id=1 + ) + + async def request_module_version(self) -> Optional[str]: + """Request the pipettor module version string (from PipettorRoot.ModuleInformation).""" + if self._module_info_addr is None: + return None + return await self._query_firmware_string(self._module_info_addr, cmd_id=8) + + async def request_module_part_number(self) -> Optional[str]: + """Request the firmware part number (from PipettorRoot.ModuleInformation).""" + if self._module_info_addr is None: + return None + return await self._query_firmware_string(self._module_info_addr, cmd_id=5) + + # --------------------------------------------------------------------------- + # Channel position queries + # --------------------------------------------------------------------------- + + async def request_channel_bounds(self) -> list[dict]: + """Request per-channel movement bounds from the firmware. + + Queries PipettorService.GetChannelBounds (cmd=10). Returns one entry per + channel, ordered by channel index. Each entry is a dict with keys: + x_min, x_max, y_min, y_max, z_min, z_max (all in mm). + + 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. + + Returns: + List of dicts, one per channel. Each dict has keys: + x_min, x_max, y_min, y_max, z_min, z_max (all in mm). + """ + import struct as _struct + + # GetChannelBounds is on PipettorService (child of Pipettor), not MLPrepService + try: + await self.client.interfaces["MLPrepRoot.PipettorRoot.Pipettor.PipettorService"].resolve() + pip_svc = self.client.interfaces["MLPrepRoot.PipettorRoot.Pipettor.PipettorService"].address + except KeyError: + return [] + + raw = await self.client.send_command( + PrepCmd.PrepGetChannelBounds(dest=pip_svc), + return_raw=True, + raise_on_error=False, + ) + if raw is None: + return [] + + # Parse per-channel bounds from raw response. + # Each channel block: channel_enum (u32 at 0x20), then 6× f32 (at 0x28): + # x_min, x_max, y_min, y_max, z_min, z_max + data = raw[0] + _CHANNEL_ENUM_TO_IDX = {v: k for k, v in _CHANNEL_INDEX.items()} + indexed = [] + + i = 0 + while i < len(data) - 20: + if data[i] == 0x20 and data[i + 1] == 0x00 and data[i + 2] == 0x04: + ch_val = _struct.unpack_from(" 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. + """ + resp = await self.client.send_command( + PrepCmd.PrepGetPositions(dest=await self._require("pipettor")), + raise_on_error=False, + ) + if resp is None or not resp.positions: + return [] + + _CHANNEL_ENUM_TO_IDX = {v: k for k, v in _CHANNEL_INDEX.items()} + indexed = [] + 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 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 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 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 + Cmd = type( + "_GetTipDefHeld", + (PrepCmd._PrepStatusQuery,), + {"command_id": 13, "__annotations__": {"dest": Address}}, + ) + raw = await self.client.send_command( + Cmd(dest=await self._require("pipettor")), + return_raw=True, + raise_on_error=False, + ) + if raw is not None: + import struct as _struct + + data = raw[0] + # 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. + + Reads the physical sleeve displacement sensor (GetTipPresent, cmd=15) on each + channel's SDrive sub-object. The sensor responds in real-time to sleeve + displacement — verified by manual sleeve push tests without any tip pickup. + + Note: the firmware exposes this sensor through the SDrive (squeezer drive) object + at object_id 514, but it reads the sleeve displacement sensor independently of + the squeeze motor state. + + Channel addresses are discovered from the object tree at setup time + (stored in ``_channel_sleeve_sensor_addrs``), so this works regardless of the + node IDs assigned by the firmware on a given instrument. + + Returns: + List of bools, one per channel (index 0=rearmost). True if tip detected. + """ + import struct as _struct + + if not self._channel_sleeve_sensor_addrs: + raise RuntimeError("No channel sleeve sensor addresses discovered. Call setup() first.") + + results: list[bool] = [] + for addr in self._channel_sleeve_sensor_addrs: + Cmd = type( + "_GetTipPresent", + (PrepCmd._PrepStatusQuery,), + {"command_id": 15, "__annotations__": {"dest": Address}}, + ) + raw = await self.client.send_command( + Cmd(dest=addr), + return_raw=True, + raise_on_error=False, + ) + if raw is None or len(raw[0]) < 8: + results.append(False) + else: + val = _struct.unpack_from(" 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 use_lld=True 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 PrepBackend." + ) + + 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 PrepBackend." + ) + + # --------------------------------------------------------------------------- + # Object tree inspection + # --------------------------------------------------------------------------- + + async def print_firmware_tree(self) -> None: + """Walk the full firmware object tree and print a formatted tree representation. + + Each object shows its name, address, firmware version, method count, and child count. + Useful for diagnostics and understanding the instrument's firmware topology. + """ + from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import HamiltonIntrospection + + intro = HamiltonIntrospection(self.client) + root_addrs = self.client._registry.get_root_addresses() + if not root_addrs: + print("(no root objects discovered)") + return + + lines: list[str] = [] + + async def walk(addr, prefix="", is_last=True): + try: + obj = await intro.get_object(addr) + except Exception: + lines.append(f"{prefix}{'└── ' if is_last else '├── '}? @ {addr} (failed to query)") + return + + connector = "└── " if is_last else "├── " + version_str = f", version={obj.version}" if obj.version else "" + lines.append( + f"{prefix}{connector}{obj.name} @ {addr} " + f"(methods={obj.method_count}, children={obj.subobject_count}{version_str})" + ) + + child_prefix = prefix + (" " if is_last else "│ ") + children_found = [] + for i in range(obj.subobject_count): + try: + child_addr = await intro.get_subobject_address(addr, i) + child_obj = await intro.get_object(child_addr) + children_found.append((child_addr, child_obj)) + except Exception: + continue + + for idx, (child_addr, _) in enumerate(children_found): + await walk(child_addr, child_prefix, is_last=(idx == len(children_found) - 1)) + + for root_idx, root_addr in enumerate(root_addrs): + try: + root_obj = await intro.get_object(root_addr) + except Exception: + lines.append(f"? @ {root_addr} (failed to query)") + continue + + version_str = f", version={root_obj.version}" if root_obj.version else "" + lines.append( + f"{root_obj.name} @ {root_addr} " + f"(methods={root_obj.method_count}, children={root_obj.subobject_count}{version_str})" + ) + + children_found = [] + for i in range(root_obj.subobject_count): + try: + child_addr = await intro.get_subobject_address(root_addr, i) + child_obj = await intro.get_object(child_addr) + children_found.append((child_addr, child_obj)) + except Exception: + continue + + for idx, (child_addr, _) in enumerate(children_found): + await walk(child_addr, "", is_last=(idx == len(children_found) - 1)) + + print("\n".join(lines)) + + # --------------------------------------------------------------------------- + # MLPrep convenience methods + # --------------------------------------------------------------------------- + + async def park(self) -> None: + """Park the instrument.""" + await self.client.send_command(PrepCmd.PrepPark(dest=await self._require("mlprep"))) + + async def spread(self) -> None: + """Spread channels.""" + await self.client.send_command(PrepCmd.PrepSpread(dest=await self._require("mlprep"))) + + async def method_begin(self, automatic_pause: bool = False) -> None: + """Signal the start of a liquid-handling method.""" + await self.client.send_command( + PrepCmd.PrepMethodBegin( + dest=await self._require("mlprep"), + automatic_pause=automatic_pause, + ) + ) + + async def method_end(self) -> None: + """Signal the end of a liquid-handling method.""" + await self.client.send_command(PrepCmd.PrepMethodEnd(dest=await self._require("mlprep"))) + + async def method_abort(self) -> None: + """Abort the current method.""" + await self.client.send_command(PrepCmd.PrepMethodAbort(dest=await self._require("mlprep"))) + + async def power_down_request(self) -> None: + """Request power down (instrument will prepare for shutdown; use cancel_power_down to abort).""" + await self.client.send_command(PrepCmd.PrepPowerDownRequest(dest=await self._require("mlprep"))) + + async def confirm_power_down(self) -> None: + """Confirm power down (completes shutdown; only call when safe to power off).""" + await self.client.send_command(PrepCmd.PrepConfirmPowerDown(dest=await self._require("mlprep"))) + + async def cancel_power_down(self) -> None: + """Cancel a pending power-down request.""" + await self.client.send_command(PrepCmd.PrepCancelPowerDown(dest=await self._require("mlprep"))) + + async def get_deck_light(self) -> Tuple[int, int, int, int]: + """Get the current deck LED colour (white, red, green, blue).""" + result = await self.client.send_command( + PrepCmd.PrepGetDeckLight(dest=await self._require("mlprep")) + ) + 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: + """Set the deck LED colour.""" + await self.client.send_command( + PrepCmd.PrepSetDeckLight( + dest=await self._require("mlprep"), + 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) + + # --------------------------------------------------------------------------- + # 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.send_command( + PrepCmd.PrepMoveZUpToSafe( + dest=await self._require("pipettor"), + 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}") + + axis_parameters: List[PrepCmd.ChannelYZMoveParameters] = [] + for i, ch in enumerate(channels): + y_i = y if isinstance(y, (int, float)) else y[i] + z_i = z if isinstance(z, (int, float)) else z[i] + axis_parameters.append( + PrepCmd.ChannelYZMoveParameters( + default_values=False, + channel=_CHANNEL_INDEX[ch], + y_position=y_i, + z_position=z_i, + ) + ) + move_parameters = PrepCmd.GantryMoveXYZParameters( + default_values=False, + gantry_x_position=x, + axis_parameters=axis_parameters, + ) + + if via_lane: + await self.client.send_command( + PrepCmd.PrepMoveToPositionViaLane( + dest=await self._require("pipettor"), + move_parameters=move_parameters, + ) + ) + else: + await self.client.send_command( + PrepCmd.PrepMoveToPosition( + dest=await self._require("pipettor"), + move_parameters=move_parameters, + ) + ) + + async def stop(self) -> None: + await self.client.stop() + self.setup_finished = False + + def serialize(self) -> dict: + return {**super().serialize(), **self.client.serialize()} diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py new file mode 100644 index 00000000000..9f4a82a2eda --- /dev/null +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_backend_tests.py @@ -0,0 +1,1156 @@ +"""Tests for Hamilton Prep backend logic and command generation. + +Verifies PrepBackend method behavior: how operations are transformed into +commands, geometry computed, command variants dispatched, and state managed. +All tests mock client.send_command — no real TCP connection required. +""" + +import asyncio +import math +import unittest +import unittest.mock + +from pylabrobot.liquid_handling.backends.hamilton import prep_commands as PrepCmd +from pylabrobot.liquid_handling.backends.hamilton.prep_backend import ( + PrepBackend, + _absolute_z_from_well, + _build_container_segments, + _effective_radius, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.liquid_handling.liquid_classes.hamilton import get_star_liquid_class +from pylabrobot.liquid_handling.standard import ( + Drop, + GripDirection, + Pickup, + ResourceDrop, + ResourceMove, + ResourcePickup, + SingleChannelAspiration, + SingleChannelDispense, +) +from pylabrobot.resources import ( + Coordinate, + Cor_96_wellplate_360ul_Fb, + CrossSectionType, + Deck, + HamiltonTip, + Liquid, + Plate, + PrepDeck, + Rotation, + TipPickupMethod, + TipSize, + Trash, + Well, + hamilton_96_tiprack_300uL_filter, +) + +# ============================================================================= +# Setup helpers +# ============================================================================= + +_MLPREP_ADDR = Address(1, 1, 0x0015) +_PIPETTOR_ADDR = Address(1, 1, 0x00E0) +_COORD_ADDR = Address(1, 1, 0x00C0) +_DECK_CONFIG_ADDR = Address(1, 1, 0x00D0) +_MPH_ADDR = Address(1, 1, 0x00F0) +_SERVICE_ADDR = Address(1, 1, 0x0017) + +_TRAVERSE_HEIGHT = 96.97 + + +def _setup_backend(num_channels: int = 2, has_mph: bool = False) -> PrepBackend: + """PrepBackend with pre-resolved interfaces, bypassing TCP.""" + backend = PrepBackend(host="192.168.100.102", port=2000) + backend._num_channels = num_channels + backend._has_mph = has_mph + backend._user_traverse_height = _TRAVERSE_HEIGHT + backend._config = PrepCmd.InstrumentConfig( + deck_bounds=PrepCmd.DeckBounds(0.0, 300.0, 0.0, 320.0, 0.0, 100.0), + has_enclosure=False, + safe_speeds_enabled=False, + deck_sites=(), + waste_sites=(), + default_traverse_height=_TRAVERSE_HEIGHT, + num_channels=num_channels, + has_mph=has_mph, + ) + backend._resolver._resolved["mlprep"] = _MLPREP_ADDR + backend._resolver._resolved["pipettor"] = _PIPETTOR_ADDR + backend._resolver._resolved["coordinator"] = _COORD_ADDR + backend._resolver._resolved["deck_config"] = _DECK_CONFIG_ADDR + backend._resolver._resolved["mph"] = _MPH_ADDR if has_mph else None + backend._resolver._resolved["mlprep_service"] = _SERVICE_ADDR + backend.setup_finished = True + return backend + + +def _setup_backend_with_deck( + num_channels: int = 2, + has_mph: bool = False, + with_core_grippers: bool = False, +) -> tuple: + """Returns (backend, deck, tip_rack, plate).""" + backend = _setup_backend(num_channels=num_channels, has_mph=has_mph) + deck = PrepDeck(with_core_grippers=with_core_grippers) + backend._deck = deck + + tip_rack = hamilton_96_tiprack_300uL_filter("tip_rack") + deck[0] = tip_rack + + plate = Cor_96_wellplate_360ul_Fb("plate") + deck[1] = plate + + return backend, deck, tip_rack, plate + + +def _get_commands(mock_send, cmd_type): + """Extract sent commands of a specific type from mock call list.""" + return [call.args[0] for call in mock_send.call_args_list if isinstance(call.args[0], cmd_type)] + + +# ============================================================================= +# 1. Helper function logic +# ============================================================================= + + +class TestPrepHelperFunctions(unittest.TestCase): + """Tests for pure geometry helper functions — no mocking.""" + + def _make_circular_well(self, diameter: float, height: float) -> Well: + return Well( + name="w", + size_x=diameter, + size_y=diameter, + size_z=height, + cross_section_type=CrossSectionType.CIRCLE, + ) + + def _make_rect_well(self, x: float, y: float, height: float) -> Well: + return Well( + name="w", + size_x=x, + size_y=y, + size_z=height, + cross_section_type=CrossSectionType.RECTANGLE, + ) + + # --- _effective_radius --- + + def test_effective_radius_circular(self): + well = self._make_circular_well(diameter=8.0, height=10.0) + self.assertAlmostEqual(_effective_radius(well), 4.0) + + def test_effective_radius_rectangular(self): + well = self._make_rect_well(x=6.0, y=4.0, height=10.0) + expected = math.sqrt(6.0 * 4.0 / math.pi) + self.assertAlmostEqual(_effective_radius(well), expected) + + def test_effective_radius_non_well_uses_size_x(self): + # For non-Well objects the function falls back to size_x / 2 + from pylabrobot.resources import Resource + + resource = Resource(name="r", size_x=10.0, size_y=10.0, size_z=5.0) + self.assertAlmostEqual(_effective_radius(resource), 5.0) + + # --- _build_container_segments --- + + def test_build_container_segments_non_well(self): + from pylabrobot.resources import Resource + + resource = Resource(name="r", size_x=10.0, size_y=10.0, size_z=5.0) + segs = _build_container_segments(resource) + self.assertEqual(segs, []) + + def test_build_container_segments_simple_circular(self): + well = self._make_circular_well(diameter=6.0, height=10.0) + segs = _build_container_segments(well) + self.assertEqual(len(segs), 1) + expected_area = math.pi * (3.0**2) + self.assertIsInstance(segs[0], PrepCmd.SegmentDescriptor) + self.assertAlmostEqual(segs[0].area_top, expected_area, places=4) + self.assertAlmostEqual(segs[0].area_bottom, expected_area, places=4) + self.assertAlmostEqual(segs[0].height, 10.0, places=4) + + def test_build_container_segments_simple_rect(self): + well = self._make_rect_well(x=6.0, y=4.0, height=10.0) + segs = _build_container_segments(well) + self.assertEqual(len(segs), 1) + expected_area = 6.0 * 4.0 + self.assertAlmostEqual(segs[0].area_top, expected_area, places=4) + self.assertAlmostEqual(segs[0].height, 10.0, places=4) + + def test_build_container_segments_heights_sum_to_size_z(self): + """Wells with compute_height_volume should produce 10 segments summing to size_z.""" + area = math.pi * 3.0**2 + well = Well( + name="w", + size_x=6.0, + size_y=6.0, + size_z=10.0, + cross_section_type=CrossSectionType.CIRCLE, + compute_volume_from_height=lambda h: area * h, + compute_height_from_volume=lambda v: v / area, + ) + segs = _build_container_segments(well) + self.assertEqual(len(segs), 10) + total_height = sum(s.height for s in segs) + self.assertAlmostEqual(total_height, well.get_size_z(), places=4) + + # --- _absolute_z_from_well --- + + def test_absolute_z_from_well_geometry(self): + self._make_circular_well(diameter=6.0, height=10.0) + deck = PrepDeck() + deck[0] = Cor_96_wellplate_360ul_Fb("p") + plate = deck[0].resource + assert plate is not None and isinstance(plate, Plate) + # Use a plate well with known absolute location + from pylabrobot.liquid_handling.standard import SingleChannelAspiration + + tip = hamilton_96_tiprack_300uL_filter("tr").get_item("A1").get_tip() + op = SingleChannelAspiration( + resource=plate.get_item("A1"), + offset=Coordinate.zero(), + tip=tip, + volume=10.0, + flow_rate=None, + liquid_height=0.0, + blow_out_air_volume=None, + mix=None, + ) + well_bottom_z, liquid_surface_z, top_of_well_z, z_air_z = _absolute_z_from_well(op) + size_z = op.resource.get_size_z() + # liquid_surface_z = well_bottom_z + liquid_height + self.assertAlmostEqual(liquid_surface_z - well_bottom_z, op.liquid_height or 0.0, places=4) + # top_of_well_z = well_bottom_z + size_z (cavity location is at cavity_bottom) + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + self.assertAlmostEqual(top_of_well_z, loc.z + size_z, places=4) + # z_air_z = top_of_well_z + 2mm margin + self.assertAlmostEqual(z_air_z, top_of_well_z + 2.0, places=4) + + def test_absolute_z_from_well_liquid_height_offset(self): + plate = Cor_96_wellplate_360ul_Fb("p") + deck = PrepDeck() + deck[0] = plate + tip = hamilton_96_tiprack_300uL_filter("tr").get_item("A1").get_tip() + well = plate.get_item("A1") + + def make_op(liquid_height): + return SingleChannelAspiration( + resource=well, + offset=Coordinate.zero(), + tip=tip, + volume=10.0, + flow_rate=None, + liquid_height=liquid_height, + blow_out_air_volume=None, + mix=None, + ) + + _, ls_0, _, _ = _absolute_z_from_well(make_op(0.0)) + _, ls_5, _, _ = _absolute_z_from_well(make_op(5.0)) + self.assertAlmostEqual(ls_5 - ls_0, 5.0, places=4) + + +# ============================================================================= +# 2. Backend unit tests (properties, state, validation) +# ============================================================================= + + +class TestPrepBackendUnit(unittest.TestCase): + """Backend construction, properties, and traverse height resolution.""" + + def setUp(self): + super().setUp() + try: + asyncio.get_running_loop() + except RuntimeError: + asyncio.set_event_loop(asyncio.new_event_loop()) + + def test_num_channels_raises_before_setup(self): + backend = PrepBackend(host="localhost", port=2000) + with self.assertRaises(RuntimeError): + _ = backend.num_channels + + def test_has_mph_false_before_setup(self): + backend = PrepBackend(host="localhost", port=2000) + self.assertFalse(backend.has_mph) + + def test_num_arms_no_deck(self): + # Backend without _deck set (never assigned) + backend = PrepBackend(host="localhost", port=2000) + backend._num_channels = 2 + self.assertEqual(backend.num_arms, 0) + + def test_num_arms_with_core_grippers(self): + backend, _, _, _ = _setup_backend_with_deck(with_core_grippers=True) + self.assertEqual(backend.num_arms, 1) + + def test_num_arms_without_core_grippers(self): + backend, _, _, _ = _setup_backend_with_deck(with_core_grippers=False) + self.assertEqual(backend.num_arms, 0) + + def test_resolve_traverse_height_explicit(self): + backend = _setup_backend() + self.assertAlmostEqual(backend._resolve_traverse_height(50.0), 50.0) + + def test_resolve_traverse_height_user_set(self): + backend = PrepBackend(host="localhost", port=2000) + backend._user_traverse_height = 80.0 + self.assertAlmostEqual(backend._resolve_traverse_height(None), 80.0) + + def test_resolve_traverse_height_probed(self): + backend = PrepBackend(host="localhost", port=2000) + backend._config = PrepCmd.InstrumentConfig( + deck_bounds=None, + has_enclosure=False, + safe_speeds_enabled=False, + deck_sites=(), + waste_sites=(), + default_traverse_height=75.0, + ) + self.assertAlmostEqual(backend._resolve_traverse_height(None), 75.0) + + def test_resolve_traverse_height_nothing_raises(self): + backend = PrepBackend(host="localhost", port=2000) + with self.assertRaises(RuntimeError): + backend._resolve_traverse_height(None) + + def test_set_default_traverse_height(self): + backend = PrepBackend(host="localhost", port=2000) + backend.set_default_traverse_height(88.0) + self.assertAlmostEqual(backend._resolve_traverse_height(None), 88.0) + + def test_resolve_traverse_height_explicit_beats_user(self): + backend = _setup_backend() + backend._user_traverse_height = 80.0 + self.assertAlmostEqual(backend._resolve_traverse_height(50.0), 50.0) + + def test_can_pick_up_tip_hamilton_tip(self): + backend = _setup_backend() + tip = HamiltonTip( + name="t", + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + self.assertTrue(backend.can_pick_up_tip(0, tip)) + + def test_can_pick_up_tip_non_hamilton(self): + from pylabrobot.resources import Tip + + backend = _setup_backend() + tip = Tip( + name="generic_tip", + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + fitting_depth=8.0, + ) + self.assertFalse(backend.can_pick_up_tip(0, tip)) + + def test_can_pick_up_tip_xl_rejected(self): + backend = _setup_backend() + tip = HamiltonTip( + name="t", + has_filter=False, + total_tip_length=95.0, + maximal_volume=5000.0, + tip_size=TipSize.XL, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + self.assertFalse(backend.can_pick_up_tip(0, tip)) + + def test_can_pick_up_tip_channel_out_of_range(self): + backend = _setup_backend(num_channels=2) + tip = HamiltonTip( + name="t", + has_filter=False, + total_tip_length=59.9, + maximal_volume=300.0, + tip_size=TipSize.STANDARD_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK, + ) + self.assertFalse(backend.can_pick_up_tip(2, tip)) + + def test_not_implemented_96_head_methods(self): + import asyncio + + backend = _setup_backend() + with self.assertRaises(NotImplementedError): + asyncio.run(backend.pick_up_tips96(None)) # type: ignore[arg-type] + + +# ============================================================================= +# 3. Tip pick-up and drop +# ============================================================================= + + +class TestPrepBackendTipOps(unittest.IsolatedAsyncioTestCase): + """Tip pickup/drop: channel mapping, Z geometry, waste handling.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck() + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + + # --- pick_up_tips --- + + async def test_pick_up_tips_single_channel_ch0(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + cmds = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips) + self.assertEqual(len(cmds), 1) + cmd = cmds[0] + self.assertEqual(cmd.dest, _PIPETTOR_ADDR) + self.assertEqual(len(cmd.tip_positions), 1) + tp = cmd.tip_positions[0] + self.assertEqual(tp.channel, PrepCmd.ChannelIndex.RearChannel) + + # Verify Z geometry + loc = tip_spot.get_absolute_location("c", "c", "t") + expected_z = loc.z + tip.total_tip_length - tip.fitting_depth + expected_z_seek = expected_z + tip.fitting_depth + 5.0 + self.assertAlmostEqual(tp.z_position, expected_z, places=3) + self.assertAlmostEqual(tp.z_seek, expected_z_seek, places=3) + self.assertAlmostEqual(tp.x_position, loc.x, places=3) + self.assertAlmostEqual(tp.y_position, loc.y, places=3) + + async def test_pick_up_tips_two_channels(self): + spot_a = self.tip_rack.get_item("A1") + spot_b = self.tip_rack.get_item("B1") + await self.backend.pick_up_tips( + [ + Pickup(resource=spot_a, offset=Coordinate.zero(), tip=spot_a.get_tip()), + Pickup(resource=spot_b, offset=Coordinate.zero(), tip=spot_b.get_tip()), + ], + use_channels=[0, 1], + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips)[0] + self.assertEqual(len(cmd.tip_positions), 2) + channels = [tp.channel for tp in cmd.tip_positions] + self.assertIn(PrepCmd.ChannelIndex.RearChannel, channels) + self.assertIn(PrepCmd.ChannelIndex.FrontChannel, channels) + + async def test_pick_up_tips_custom_final_z(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + final_z=55.0, + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips)[0] + self.assertAlmostEqual(cmd.final_z, 55.0) + + async def test_pick_up_tips_default_final_z_from_traverse(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips)[0] + self.assertAlmostEqual(cmd.final_z, _TRAVERSE_HEIGHT) + + async def test_pick_up_tips_z_seek_offset(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + z_seek_offset=3.0, + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpTips)[0] + tp = cmd.tip_positions[0] + loc = tip_spot.get_absolute_location("c", "c", "t") + base_z = loc.z + tip.total_tip_length - tip.fitting_depth + expected_z_seek = base_z + tip.fitting_depth + 5.0 + 3.0 + self.assertAlmostEqual(tp.z_seek, expected_z_seek, places=3) + + async def test_pick_up_tips_channel_out_of_range(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + with self.assertRaises(AssertionError): + await self.backend.pick_up_tips( + [Pickup(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[2], # num_channels=2, valid range 0-1 + ) + + # --- drop_tips --- + + async def test_drop_tips_to_rack_uses_tip_geometry(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.drop_tips( + [Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] + self.assertEqual(len(cmd.tip_positions), 1) + dp = cmd.tip_positions[0] + loc = tip_spot.get_absolute_location("c", "c", "t") + expected_z = loc.z + (tip.total_tip_length - tip.fitting_depth) + expected_z_seek = loc.z + tip.total_tip_length + 10.0 + self.assertAlmostEqual(dp.z_position, expected_z, places=3) + self.assertAlmostEqual(dp.z_seek, expected_z_seek, places=3) + + async def test_drop_tips_to_waste_position(self): + waste = self.deck.get_resource("waste_rear") + tip = self.tip_rack.get_item("A1").get_tip() + await self.backend.drop_tips( + [Drop(resource=waste, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] + dp = cmd.tip_positions[0] + loc = waste.get_absolute_location("c", "c", "t") + # Waste: same as tip spots — z_position so tip bottom lands at surface; z_seek for approach + expected_z = loc.z + (tip.total_tip_length - tip.fitting_depth) + expected_z_seek = loc.z + tip.total_tip_length + 10.0 + self.assertAlmostEqual(dp.z_position, expected_z, places=3) + self.assertAlmostEqual(dp.z_seek, expected_z_seek, places=3) + # Default roll-off when all Trash; use Stall so pipette detects contact before release + self.assertAlmostEqual(cmd.tip_roll_off_distance, 3.0) + self.assertEqual(dp.drop_type, PrepCmd.TipDropType.Stall) + + async def test_drop_tips_stall_type(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.drop_tips( + [Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + drop_type=PrepCmd.TipDropType.Stall, + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] + self.assertEqual(cmd.tip_positions[0].drop_type, PrepCmd.TipDropType.Stall) + + async def test_drop_tips_roll_off_distance(self): + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + await self.backend.drop_tips( + [Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + tip_roll_off_distance=2.5, + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] + self.assertAlmostEqual(cmd.tip_roll_off_distance, 2.5) + + async def test_drop_tips_all_trash_resolves_to_deck_waste_and_default_roll(self): + """When all ops are Trash (e.g. discard_tips), resolve to waste_rear/waste_front; default roll 3mm.""" + trash = self.deck.get_trash_area() + tip = self.tip_rack.get_item("A1").get_tip() + tip2 = self.tip_rack.get_item("B1").get_tip() + await self.backend.drop_tips( + [ + Drop(resource=trash, offset=Coordinate.zero(), tip=tip), + Drop(resource=trash, offset=Coordinate.zero(), tip=tip2), + ], + use_channels=[0, 1], + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDropTips)[0] + self.assertEqual(len(cmd.tip_positions), 2) + waste_rear = self.deck.get_resource("waste_rear") + waste_front = self.deck.get_resource("waste_front") + loc_rear = waste_rear.get_absolute_location("c", "c", "t") + loc_front = waste_front.get_absolute_location("c", "c", "t") + dp0, dp1 = cmd.tip_positions[0], cmd.tip_positions[1] + expected_z_rear = loc_rear.z + (tip.total_tip_length - tip.fitting_depth) + expected_z_seek_rear = loc_rear.z + tip.total_tip_length + 10.0 + expected_z_front = loc_front.z + (tip2.total_tip_length - tip2.fitting_depth) + expected_z_seek_front = loc_front.z + tip2.total_tip_length + 10.0 + self.assertAlmostEqual(dp0.z_position, expected_z_rear, places=3) + self.assertAlmostEqual(dp0.z_seek, expected_z_seek_rear, places=3) + self.assertAlmostEqual(dp1.z_position, expected_z_front, places=3) + self.assertAlmostEqual(dp1.z_seek, expected_z_seek_front, places=3) + self.assertAlmostEqual(cmd.tip_roll_off_distance, 3.0) + + async def test_drop_tips_all_trash_deck_missing_waste_raises(self): + """When all ops are Trash but deck has no waste_rear/waste_front, raise ValueError.""" + backend = _setup_backend() + deck = Deck(size_x=300.0, size_y=320.0, size_z=0.0) + trash = Trash(name="trash", size_x=0.0, size_y=0.0, size_z=0.0) + deck.assign_child_resource(trash, location=Coordinate(287.0, 0.0, 0.0)) + backend._deck = deck + backend.client.send_command = unittest.mock.AsyncMock(return_value=None) # type: ignore[method-assign] + tip = hamilton_96_tiprack_300uL_filter("_tmp").get_item("A1").get_tip() + with self.assertRaises(ValueError) as ctx: + await backend.drop_tips( + [Drop(resource=trash, offset=Coordinate.zero(), tip=tip)], + use_channels=[0], + ) + self.assertIn("waste_rear", str(ctx.exception)) + self.assertIn("deck has no waste position", str(ctx.exception)) + + async def test_drop_tips_mixed_trash_and_tip_spot_raises(self): + """Mixing Trash and TipSpot in one drop_tips call raises ValueError.""" + trash = self.deck.get_trash_area() + tip_spot = self.tip_rack.get_item("A1") + tip = tip_spot.get_tip() + tip2 = self.tip_rack.get_item("B1").get_tip() + with self.assertRaises(ValueError) as ctx: + await self.backend.drop_tips( + [ + Drop(resource=trash, offset=Coordinate.zero(), tip=tip), + Drop(resource=tip_spot, offset=Coordinate.zero(), tip=tip2), + ], + use_channels=[0, 1], + ) + self.assertIn("Cannot mix waste", str(ctx.exception)) + + +# ============================================================================= +# 4. Aspirate dispatch and parameter logic +# ============================================================================= + + +class TestPrepBackendAspirate(unittest.IsolatedAsyncioTestCase): + """Aspirate: 4-way dispatch, liquid class defaults, volume correction, Z geometry.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck() + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + self.tip = self.tip_rack.get_item("A1").get_tip() + + def _make_asp( + self, well_name="A1", volume=100.0, flow_rate=None, liquid_height=5.0, blow_out_air_volume=0.0 + ): + return SingleChannelAspiration( + resource=self.plate.get_item(well_name), + offset=Coordinate.zero(), + tip=self.tip, + volume=volume, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=None, + ) + + # --- Dispatch --- + + async def test_aspirate_default_sends_nolld_monitoring(self): + await self.backend.aspirate([self._make_asp()], use_channels=[0]) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)), 1) + + async def test_aspirate_tadm_mode(self): + await self.backend.aspirate( + [self._make_asp()], + use_channels=[0], + monitoring_mode=PrepCmd.MonitoringMode.TADM, + ) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepAspirateTadmV2)), 1) + + async def test_aspirate_lld_mode(self): + await self.backend.aspirate([self._make_asp()], use_channels=[0], use_lld=True) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepAspirateWithLldV2)), 1) + + async def test_aspirate_lld_tadm_mode(self): + await self.backend.aspirate( + [self._make_asp()], + use_channels=[0], + use_lld=True, + monitoring_mode=PrepCmd.MonitoringMode.TADM, + ) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepAspirateWithLldTadmV2)), 1) + + async def test_aspirate_implicit_lld_via_lld_param(self): + """Passing lld= activates LLD path without use_lld=True.""" + custom_lld = PrepCmd.LldParameters( + default_values=False, + search_start_position=90.0, + channel_speed=5.0, + z_submerge=2.0, + z_out_of_liquid=1.0, + ) + await self.backend.aspirate( + [self._make_asp()], + use_channels=[0], + lld=custom_lld, + ) + cmds = _get_commands(self.mock_send, PrepCmd.PrepAspirateWithLldV2) + self.assertEqual(len(cmds), 1) + # Verify the provided LLD parameters are used (not auto-derived) + lld = cmds[0].aspirate_parameters[0].lld + self.assertAlmostEqual(lld.search_start_position, 90.0) + + async def test_aspirate_lld_auto_seek_z(self): + """Auto-derived LLD search_start_position equals the top-of-well Z.""" + self.plate.get_item("A1") + op = self._make_asp() + _, _, top_of_well_z, _ = _absolute_z_from_well(op) + await self.backend.aspirate([op], use_channels=[0], use_lld=True) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateWithLldV2)[0] + lld = cmd.aspirate_parameters[0].lld + self.assertAlmostEqual(lld.search_start_position, top_of_well_z, places=3) + + # --- Channel mapping --- + + async def test_aspirate_channel_0_is_rear(self): + await self.backend.aspirate([self._make_asp()], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + self.assertEqual(cmd.aspirate_parameters[0].channel, PrepCmd.ChannelIndex.RearChannel) + + async def test_aspirate_channel_1_is_front(self): + await self.backend.aspirate([self._make_asp()], use_channels=[1]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + self.assertEqual(cmd.aspirate_parameters[0].channel, PrepCmd.ChannelIndex.FrontChannel) + + async def test_aspirate_two_channels(self): + ops = [ + self._make_asp("A1", volume=100.0, flow_rate=50.0), + self._make_asp("B1", volume=150.0, flow_rate=75.0), + ] + await self.backend.aspirate(ops, use_channels=[0, 1]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + self.assertEqual(len(cmd.aspirate_parameters), 2) + channels = {p.channel for p in cmd.aspirate_parameters} + self.assertIn(PrepCmd.ChannelIndex.RearChannel, channels) + self.assertIn(PrepCmd.ChannelIndex.FrontChannel, channels) + + # --- Volume and flow rate --- + + async def test_aspirate_volume_corrected_by_hlc(self): + """HLC-corrected volume is sent, not raw op.volume.""" + op = self._make_asp(volume=100.0) + hlc = get_star_liquid_class( + tip_volume=self.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=self.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + if hlc is not None: + expected_vol = hlc.compute_corrected_volume(100.0) + else: + expected_vol = 100.0 + await self.backend.aspirate([op], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + actual_vol = cmd.aspirate_parameters[0].common.liquid_volume + self.assertAlmostEqual(actual_vol, expected_vol, places=2) + + async def test_aspirate_disable_volume_correction(self): + """Raw volume used when disable_volume_correction=True.""" + raw_volume = 100.0 + await self.backend.aspirate( + [self._make_asp(volume=raw_volume)], + use_channels=[0], + disable_volume_correction=[True], + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + actual_vol = cmd.aspirate_parameters[0].common.liquid_volume + self.assertAlmostEqual(actual_vol, raw_volume, places=2) + + async def test_aspirate_explicit_flow_rate(self): + await self.backend.aspirate([self._make_asp(flow_rate=60.0)], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + self.assertAlmostEqual(cmd.aspirate_parameters[0].common.liquid_speed, 60.0) + + async def test_aspirate_flow_rate_from_hlc_default(self): + """flow_rate=None -> uses HLC aspiration_flow_rate.""" + hlc = get_star_liquid_class( + tip_volume=self.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=self.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + await self.backend.aspirate([self._make_asp(flow_rate=None)], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + expected = hlc.aspiration_flow_rate if hlc is not None else 100.0 + self.assertAlmostEqual(cmd.aspirate_parameters[0].common.liquid_speed, expected, places=2) + + async def test_aspirate_explicit_settling_time_override(self): + await self.backend.aspirate( + [self._make_asp()], + use_channels=[0], + settling_time=[2.0], + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + self.assertAlmostEqual(cmd.aspirate_parameters[0].common.settling_time, 2.0) + + async def test_aspirate_hlc_settling_time_default(self): + """Settling time from HLC when not explicitly passed.""" + hlc = get_star_liquid_class( + tip_volume=self.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=self.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + await self.backend.aspirate([self._make_asp()], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + expected = hlc.aspiration_settling_time if hlc is not None else 1.0 + self.assertAlmostEqual(cmd.aspirate_parameters[0].common.settling_time, expected, places=3) + + async def test_aspirate_auto_container_geometry(self): + """auto_container_geometry=True produces non-empty container_description.""" + await self.backend.aspirate( + [self._make_asp()], + use_channels=[0], + auto_container_geometry=True, + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepAspirateNoLldMonitoringV2)[0] + segs = cmd.aspirate_parameters[0].container_description + self.assertGreater(len(segs), 0) + self.assertIsInstance(segs[0], PrepCmd.SegmentDescriptor) + + +# ============================================================================= +# 5. Dispense dispatch and parameter logic +# ============================================================================= + + +class TestPrepBackendDispense(unittest.IsolatedAsyncioTestCase): + """Dispense: 2-way dispatch, volume correction, HLC defaults.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck() + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + self.tip = self.tip_rack.get_item("A1").get_tip() + + def _make_disp( + self, well_name="A1", volume=100.0, flow_rate=None, liquid_height=5.0, blow_out_air_volume=0.0 + ): + return SingleChannelDispense( + resource=self.plate.get_item(well_name), + offset=Coordinate.zero(), + tip=self.tip, + volume=volume, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=None, + ) + + async def test_dispense_default_sends_nolld(self): + await self.backend.dispense([self._make_disp()], use_channels=[0]) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)), 1) + + async def test_dispense_lld_mode(self): + await self.backend.dispense([self._make_disp()], use_channels=[0], use_lld=True) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepDispenseWithLldV2)), 1) + + async def test_dispense_volume_corrected(self): + hlc = get_star_liquid_class( + tip_volume=self.tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=self.tip.has_filter, + liquid=Liquid.WATER, + jet=False, + blow_out=False, + ) + raw = 100.0 + expected = hlc.compute_corrected_volume(raw) if hlc else raw + await self.backend.dispense([self._make_disp(volume=raw)], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] + self.assertAlmostEqual(cmd.dispense_parameters[0].common.liquid_volume, expected, places=2) + + async def test_dispense_explicit_stop_back_volume(self): + await self.backend.dispense( + [self._make_disp()], + use_channels=[0], + stop_back_volume=[3.0], + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] + self.assertAlmostEqual(cmd.dispense_parameters[0].dispense.stop_back_volume, 3.0) + + async def test_dispense_explicit_cutoff_speed(self): + await self.backend.dispense( + [self._make_disp()], + use_channels=[0], + cutoff_speed=[75.0], + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] + self.assertAlmostEqual(cmd.dispense_parameters[0].dispense.cutoff_speed, 75.0) + + async def test_dispense_two_channels(self): + ops = [self._make_disp("A1", volume=100.0), self._make_disp("B1", volume=200.0)] + await self.backend.dispense(ops, use_channels=[0, 1]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] + self.assertEqual(len(cmd.dispense_parameters), 2) + + async def test_dispense_z_minimum_from_well_bottom(self): + op = self._make_disp() + loc = op.resource.get_absolute_location("c", "c", "cavity_bottom") + await self.backend.dispense([op], use_channels=[0]) + cmd = _get_commands(self.mock_send, PrepCmd.PrepDispenseNoLldV2)[0] + self.assertAlmostEqual(cmd.dispense_parameters[0].common.z_minimum, loc.z, places=3) + + +# ============================================================================= +# 6. MPH head +# ============================================================================= + + +class TestPrepBackendMPH(unittest.IsolatedAsyncioTestCase): + """MPH head: single Struct (not StructArray), tip_mask, guard checks.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck(has_mph=True) + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + + async def test_mph_pickup_sends_mph_command(self): + tip_spot = self.tip_rack.get_item("A1") + await self.backend.pick_up_tips_mph(tip_spot) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.MphPickupTips)), 1) + # Must not send single-channel PrepCmd.PrepPickUpTips + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepPickUpTips)), 0) + + async def test_mph_pickup_default_tip_mask(self): + tip_spot = self.tip_rack.get_item("A1") + await self.backend.pick_up_tips_mph(tip_spot) + cmd = _get_commands(self.mock_send, PrepCmd.MphPickupTips)[0] + self.assertEqual(cmd.tip_mask, 0xFF) + + async def test_mph_pickup_custom_tip_mask(self): + tip_spot = self.tip_rack.get_item("A1") + await self.backend.pick_up_tips_mph(tip_spot, tip_mask=0x0F) + cmd = _get_commands(self.mock_send, PrepCmd.MphPickupTips)[0] + self.assertEqual(cmd.tip_mask, 0x0F) + + async def test_mph_drop_sends_mph_command(self): + tip_spot = self.tip_rack.get_item("A1") + await self.backend.drop_tips_mph(tip_spot) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.MphDropTips)), 1) + + async def test_mph_pickup_raises_when_no_mph(self): + backend = _setup_backend(has_mph=False) + with self.assertRaises(RuntimeError): + await backend.pick_up_tips_mph(self.tip_rack.get_item("A1")) + + async def test_mph_pickup_raises_empty_list(self): + with self.assertRaises(ValueError): + await self.backend.pick_up_tips_mph([]) + + +# ============================================================================= +# 7. CORE gripper +# ============================================================================= + + +class TestPrepBackendGripper(unittest.IsolatedAsyncioTestCase): + """CORE gripper: tool lifecycle, plate geometry, drop with/without return.""" + + async def asyncSetUp(self): + self.backend, self.deck, self.tip_rack, self.plate = _setup_backend_with_deck( + with_core_grippers=True + ) + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send + + def _make_pickup(self, resource, pickup_distance_from_top=5.0): + return ResourcePickup( + resource=resource, + offset=Coordinate.zero(), + pickup_distance_from_top=pickup_distance_from_top, + direction=GripDirection.FRONT, + ) + + def _make_drop(self, resource, destination: Coordinate, pickup_distance_from_top=5.0): + return ResourceDrop( + resource=resource, + destination=destination, + destination_absolute_rotation=Rotation(), + offset=Coordinate.zero(), + pickup_distance_from_top=pickup_distance_from_top, + pickup_direction=GripDirection.FRONT, + direction=GripDirection.FRONT, + rotation=0.0, + ) + + def _make_move(self, resource, location: Coordinate, pickup_distance_from_top=5.0): + return ResourceMove( + resource=resource, + location=location, + gripped_direction=GripDirection.FRONT, + pickup_distance_from_top=pickup_distance_from_top, + offset=Coordinate.zero(), + ) + + async def test_auto_picks_up_tool_before_plate(self): + """When _gripper_tool_on=False, PrepCmd.PrepPickUpTool is sent before PrepCmd.PrepPickUpPlate.""" + self.assertFalse(self.backend._gripper_tool_on) + await self.backend.pick_up_resource(self._make_pickup(self.plate)) + tool_cmds = _get_commands(self.mock_send, PrepCmd.PrepPickUpTool) + plate_cmds = _get_commands(self.mock_send, PrepCmd.PrepPickUpPlate) + self.assertEqual(len(tool_cmds), 1) + self.assertEqual(len(plate_cmds), 1) + # Tool must be picked up before plate + all_calls = [c.args[0] for c in self.mock_send.call_args_list] + self.assertLess(all_calls.index(tool_cmds[0]), all_calls.index(plate_cmds[0])) + self.assertTrue(self.backend._gripper_tool_on) + + async def test_skip_tool_pickup_when_already_holding(self): + self.backend._gripper_tool_on = True + await self.backend.pick_up_resource(self._make_pickup(self.plate)) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepPickUpTool)), 0) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepPickUpPlate)), 1) + + async def test_plate_dimensions_from_resource(self): + await self.backend.pick_up_resource(self._make_pickup(self.plate)) + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpPlate)[0] + self.assertAlmostEqual(cmd.plate.length, self.plate.get_absolute_size_x(), places=3) + self.assertAlmostEqual(cmd.plate.width, self.plate.get_absolute_size_y(), places=3) + self.assertAlmostEqual(cmd.plate.height, self.plate.get_absolute_size_z(), places=3) + + async def test_grip_distance_is_clearance_plus_squeeze(self): + clearance_y = 2.5 + squeeze_mm = 2.0 + await self.backend.pick_up_resource( + self._make_pickup(self.plate), + clearance_y=clearance_y, + squeeze_mm=squeeze_mm, + ) + cmd = _get_commands(self.mock_send, PrepCmd.PrepPickUpPlate)[0] + self.assertAlmostEqual(cmd.grip_distance, clearance_y + squeeze_mm) + + async def test_grip_direction_not_front_raises(self): + pickup = ResourcePickup( + resource=self.plate, + offset=Coordinate.zero(), + pickup_distance_from_top=5.0, + direction=GripDirection.LEFT, + ) + with self.assertRaises(NotImplementedError): + await self.backend.pick_up_resource(pickup) + + async def test_drop_resource_with_return_gripper(self): + self.backend._gripper_tool_on = True + plate_loc = self.plate.get_absolute_location() + drop = self._make_drop(self.plate, destination=plate_loc) + await self.backend.drop_resource(drop, return_gripper=True) + drop_plate_cmds = _get_commands(self.mock_send, PrepCmd.PrepDropPlate) + drop_tool_cmds = _get_commands(self.mock_send, PrepCmd.PrepDropTool) + self.assertEqual(len(drop_plate_cmds), 1) + self.assertEqual(len(drop_tool_cmds), 1) + self.assertFalse(self.backend._gripper_tool_on) + + async def test_drop_resource_without_return_gripper(self): + self.backend._gripper_tool_on = True + plate_loc = self.plate.get_absolute_location() + drop = self._make_drop(self.plate, destination=plate_loc) + await self.backend.drop_resource(drop, return_gripper=False) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepDropTool)), 0) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepDropPlate)), 1) + + async def test_move_picked_up_resource(self): + self.backend._gripper_tool_on = True + dest = Coordinate(100.0, 50.0, 10.0) + move = self._make_move(self.plate, location=dest) + await self.backend.move_picked_up_resource(move) + cmds = _get_commands(self.mock_send, PrepCmd.PrepMovePlate) + self.assertEqual(len(cmds), 1) + + +# ============================================================================= +# 8. Convenience methods +# ============================================================================= + + +class TestPrepBackendConvenience(unittest.IsolatedAsyncioTestCase): + """Convenience methods: correct command type to correct interface address.""" + + async def asyncSetUp(self): + self.backend = _setup_backend() + self.mock_send = unittest.mock.AsyncMock(return_value=None) + self.backend.client.send_command = self.mock_send # type: ignore[method-assign] + + async def test_park(self): + await self.backend.park() + cmds = _get_commands(self.mock_send, PrepCmd.PrepPark) + self.assertEqual(len(cmds), 1) + self.assertEqual(cmds[0].dest, _MLPREP_ADDR) + + async def test_spread(self): + await self.backend.spread() + cmds = _get_commands(self.mock_send, PrepCmd.PrepSpread) + self.assertEqual(len(cmds), 1) + self.assertEqual(cmds[0].dest, _MLPREP_ADDR) + + async def test_method_begin_automatic_pause(self): + await self.backend.method_begin(automatic_pause=True) + cmds = _get_commands(self.mock_send, PrepCmd.PrepMethodBegin) + self.assertEqual(len(cmds), 1) + self.assertTrue(cmds[0].automatic_pause) + + async def test_method_begin_no_automatic_pause(self): + await self.backend.method_begin(automatic_pause=False) + cmds = _get_commands(self.mock_send, PrepCmd.PrepMethodBegin) + self.assertFalse(cmds[0].automatic_pause) + + async def test_method_end(self): + await self.backend.method_end() + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepMethodEnd)), 1) + + async def test_method_abort(self): + await self.backend.method_abort() + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepMethodAbort)), 1) + + async def test_move_to_position(self): + await self.backend.move_to_position(x=100.0, y=50.0, z=20.0, use_channels=[0]) + cmds = _get_commands(self.mock_send, PrepCmd.PrepMoveToPosition) + self.assertEqual(len(cmds), 1) + cmd = cmds[0] + self.assertAlmostEqual(cmd.move_parameters.gantry_x_position, 100.0) + self.assertEqual(len(cmd.move_parameters.axis_parameters), 1) + self.assertEqual( + cmd.move_parameters.axis_parameters[0].channel, PrepCmd.ChannelIndex.RearChannel + ) + self.assertAlmostEqual(cmd.move_parameters.axis_parameters[0].y_position, 50.0) + self.assertAlmostEqual(cmd.move_parameters.axis_parameters[0].z_position, 20.0) + + async def test_move_to_position_via_lane(self): + await self.backend.move_to_position(x=100.0, y=50.0, z=20.0, use_channels=[0], via_lane=True) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepMoveToPositionViaLane)), 1) + self.assertEqual(len(_get_commands(self.mock_send, PrepCmd.PrepMoveToPosition)), 0) + + async def test_move_channels_to_safe_z_all(self): + await self.backend.move_channels_to_safe_z() + cmds = _get_commands(self.mock_send, PrepCmd.PrepMoveZUpToSafe) + self.assertEqual(len(cmds), 1) + channels = cmds[0].channels + self.assertIn(PrepCmd.ChannelIndex.RearChannel, channels) + self.assertIn(PrepCmd.ChannelIndex.FrontChannel, channels) + + async def test_set_deck_light(self): + await self.backend.set_deck_light(white=100, red=50, green=25, blue=200) + cmds = _get_commands(self.mock_send, PrepCmd.PrepSetDeckLight) + self.assertEqual(len(cmds), 1) + cmd = cmds[0] + self.assertEqual(cmd.white, 100) + self.assertEqual(cmd.red, 50) + self.assertEqual(cmd.green, 25) + self.assertEqual(cmd.blue, 200) + self.assertEqual(cmd.dest, _MLPREP_ADDR) + + async def test_not_implemented_96_ops(self): + with self.assertRaises(NotImplementedError): + await self.backend.pick_up_tips96(None) # type: ignore[arg-type] + with self.assertRaises(NotImplementedError): + await self.backend.drop_tips96(None) # type: ignore[arg-type] + with self.assertRaises(NotImplementedError): + await self.backend.aspirate96(None) # type: ignore[arg-type] + with self.assertRaises(NotImplementedError): + await self.backend.dispense96(None) # type: ignore[arg-type] + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py new file mode 100644 index 00000000000..276979fccf6 --- /dev/null +++ b/pylabrobot/liquid_handling/backends/hamilton/prep_commands.py @@ -0,0 +1,1791 @@ +"""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 PrepBackend for command construction and serialization. + +Moved from prep_backend.py to separate protocol contracts from domain logic. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from typing import Annotated, Optional, Tuple + +from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams +from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + F32, + I8, + I16, + I64, + U16, + U32, + EnumArray, + I16Array, + PaddedBool, + PaddedU8, + Str, + Struct, + StructArray, + U8Array, +) +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + Enum as WEnum, +) +from pylabrobot.liquid_handling.standard import SingleChannelAspiration + +# ============================================================================= +# 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 + + +class MonitoringMode(IntEnum): + """Selects aspirate monitoring vs TADM for pipetting commands.""" + + MONITORING = 0 # AspirateMonitoringParameters (default, matches v1 behavior) + TADM = 1 # TadmParameters + + +# ============================================================================= +# 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(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 + + +@dataclass +class XYZCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class XYCoord: + default_values: PaddedBool + x_position: F32 + y_position: F32 + + +@dataclass +class ChannelYZMoveParameters: + default_values: PaddedBool + channel: WEnum + y_position: F32 + z_position: F32 + + +@dataclass +class GantryMoveXYZParameters: + default_values: PaddedBool + gantry_x_position: F32 + axis_parameters: Annotated[list[ChannelYZMoveParameters], StructArray()] + + +@dataclass +class PlateDimensions: + default_values: PaddedBool + length: F32 + width: F32 + 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 + + +@dataclass +class TipPickupParameters: + default_values: PaddedBool + volume: F32 + length: F32 + tip_type: WEnum + has_filter: PaddedBool + is_needle: PaddedBool + is_tool: PaddedBool + + +@dataclass +class AspirateParameters: + default_values: PaddedBool + x_position: F32 + y_position: F32 + prewet_volume: F32 + blowout_volume: F32 + + @classmethod + def for_op( + cls, + loc, + op: SingleChannelAspiration, + prewet_volume: float = 0.0, + blowout_volume: Optional[float] = None, + ) -> AspirateParameters: + return cls( + default_values=False, + x_position=loc.x, + y_position=loc.y, + prewet_volume=prewet_volume, + blowout_volume=(op.blow_out_air_volume or 0.0) if blowout_volume is None else blowout_volume, + ) + + +@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, + ) + + +@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, + ) + + +@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, + ) + + +@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, + ) + + +@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 + ) + + +@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, + ) + + +@dataclass +class TadmReturnParameters: + default_values: PaddedBool + channel: WEnum + entries: U32 + error: PaddedBool + 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, + ) + + +@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, + ) + + +@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, + ) + + +@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, + ) + + +@dataclass +class ChannelBoundsParameters: + """Per-channel movement bounds returned by PipettorService.GetChannelBounds.""" + + default_values: PaddedBool + channel: WEnum + x_min: F32 + x_max: F32 + y_min: F32 + y_max: F32 + z_min: F32 + z_max: F32 + + +@dataclass +class ChannelXYZPositionParameters: + default_values: PaddedBool + channel: WEnum + position_x: F32 + position_y: F32 + position_z: F32 + + +@dataclass +class PressureReturnParameters: + default_values: PaddedBool + channel: WEnum + 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 + + +@dataclass +class DispenserVolumeReturnParameters: + default_values: PaddedBool + channel: WEnum + volume: F32 + + +@dataclass +class PotentiometerParameters: + default_values: PaddedBool + channel: WEnum + gain: PaddedU8 + 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 + + +@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 + + +@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 + + +@dataclass +class SeekResultParameters: + default_values: PaddedBool + channel: WEnum + detected: PaddedBool + position: F32 + + +@dataclass +class ChannelCounterParameters: + default_values: PaddedBool + channel: WEnum + tip_pickup_counter: U32 + tip_eject_counter: U32 + aspirate_counter: U32 + 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 + + +@dataclass +class LeakCheckSimpleParameters: + default_values: PaddedBool + channel: WEnum + time: F32 + 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 + + +@dataclass +class DriveStatus: + initialized: PaddedBool + position: F32 + encoder_position: F32 + 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()] + + +@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()] + + +@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()] + + +@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()] + + +@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()] + + +@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()] + + +@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()] + + +@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 + + +@dataclass +class InitTipDropParameters: + default_values: PaddedBool + x_position: F32 + rolloff_distance: F32 + channel_parameters: Annotated[list[DropTipParameters], StructArray()] + + +@dataclass +class DispenseInitToWasteParameters: + default_values: PaddedBool + channel: WEnum + x_position: F32 + y_position: F32 + z_position: F32 + + +@dataclass +class MoveAxisAbsoluteParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + position: F32 + delay: U32 + + +@dataclass +class MoveAxisRelativeParameters: + default_values: PaddedBool + channel: WEnum + axis: WEnum + distance: F32 + delay: U32 + + +@dataclass +class LimitCurveEntry: + default_values: PaddedBool + sample: U16 + 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, + ) + + +@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 + 10.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, + ) + + +@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 + + +@dataclass +class DispenserVolumeEntry: + default_values: PaddedBool + type: WEnum + volume: F32 + + +@dataclass +class DispenserVolumeStackReturnParameters: + default_values: PaddedBool + channel: WEnum + total_volume: F32 + volumes: Annotated[list[DispenserVolumeEntry], StructArray()] + + +@dataclass +class SegmentDescriptor: + area_top: F32 + area_bottom: F32 + 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()] + + +@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()] + + +@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()] + + +@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()] + + +@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()] + + +@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()] + + +# ============================================================================= +# PrepCommand base class +# ============================================================================= + + +@dataclass +class PrepCommand(HamiltonCommand): + """Base for all Prep instrument commands. + + Subclasses are dataclasses with ``dest: Address`` (inherited) plus any + ``Annotated`` payload fields. ``build_parameters()`` calls + ``HoiParams.from_struct(self)`` which serialises only ``Annotated`` fields, + so ``dest`` is automatically excluded from the wire payload. + """ + + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + + dest: Address + + def __post_init__(self): + super().__init__(self.dest) + + def build_parameters(self) -> HoiParams: + return HoiParams.from_struct(self) + + +# ============================================================================= +# Pipettor / ChannelCoordinator command classes +# ============================================================================= + + +@dataclass +class PrepAspirateNoLldMonitoring(PrepCommand): + """Aspirate without LLD or monitoring (cmd=1, dest=Pipettor).""" + + command_id = 1 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateTadm(PrepCommand): + """Aspirate with TADM, no LLD (cmd=2, dest=Pipettor).""" + + command_id = 2 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm], StructArray()] + + +@dataclass +class PrepAspirateWithLld(PrepCommand): + """Aspirate with LLD and monitoring (cmd=3, dest=Pipettor).""" + + command_id = 3 + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadm(PrepCommand): + """Aspirate with LLD and TADM (cmd=4, dest=Pipettor).""" + + command_id = 4 + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm], StructArray()] + + +@dataclass +class PrepDispenseNoLld(PrepCommand): + """Dispense without LLD (cmd=5, dest=Pipettor).""" + + command_id = 5 + dispense_parameters: Annotated[list[DispenseParametersNoLld], StructArray()] + + +@dataclass +class PrepDispenseWithLld(PrepCommand): + """Dispense with LLD (cmd=6, dest=Pipettor).""" + + command_id = 6 + dispense_parameters: Annotated[list[DispenseParametersLld], StructArray()] + + +@dataclass +class PrepDispenseInitToWaste(PrepCommand): + """Dispense initialize to waste (cmd=7, dest=Pipettor).""" + + command_id = 7 + waste_parameters: Annotated[list[DispenseInitToWasteParameters], StructArray()] + + +@dataclass +class PrepPickUpTipsById(PrepCommand): + """Pick up tips by tip-definition ID (cmd=8, dest=Pipettor).""" + + command_id = 8 + 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 + + +@dataclass +class PrepPickUpTips(PrepCommand): + """Pick up tips by tip-definition struct (cmd=9, dest=Pipettor).""" + + command_id = 9 + 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 + + +@dataclass +class PrepPickUpNeedlesById(PrepCommand): + """Pick up needles by tip-definition ID (cmd=10, dest=Pipettor).""" + + command_id = 10 + 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 + + +@dataclass +class PrepPickUpNeedles(PrepCommand): + """Pick up needles by tip-definition struct (cmd=11, dest=Pipettor).""" + + command_id = 11 + 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 + + +@dataclass +class PrepDropTips(PrepCommand): + """Drop tips (cmd=12, dest=Pipettor).""" + + command_id = 12 + tip_positions: Annotated[list[TipDropParameters], StructArray()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class MphPickupTips(PrepCommand): + """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_parameters, not a + StructArray (type_61) like the Pipettor. All 8 probes move as one unit; + tip_mask selects which channels engage. + """ + + command_id = 9 + tip_parameters: 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 + + +@dataclass +class MphDropTips(PrepCommand): + """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 + drop_parameters: Annotated[TipDropParameters, Struct()] + final_z: F32 + seek_speed: F32 + tip_roll_off_distance: F32 + + +@dataclass +class PrepPickUpToolById(PrepCommand): + """Pick up tool by tip-definition ID (cmd=14, dest=Pipettor).""" + + command_id = 14 + 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 + + +@dataclass +class PrepPickUpTool(PrepCommand): + """Pick up tool by tip-definition struct (cmd=15, dest=Pipettor).""" + + command_id = 15 + 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 + + +@dataclass +class PrepDropTool(PrepCommand): + """Drop tool (cmd=16, dest=Pipettor).""" + + command_id = 16 + + +@dataclass +class PrepPickUpPlate(PrepCommand): + """Pick up plate (cmd=17, dest=Pipettor).""" + + command_id = 17 + plate_top_center: Annotated[XYZCoord, Struct()] + plate: Annotated[PlateDimensions, Struct()] + clearance_y: F32 + grip_speed_y: F32 + grip_distance: F32 + grip_height: F32 + + +@dataclass +class PrepDropPlate(PrepCommand): + """Drop plate (cmd=18, dest=Pipettor).""" + + command_id = 18 + plate_top_center: Annotated[XYZCoord, Struct()] + clearance_y: F32 + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepMovePlate(PrepCommand): + """Move plate to position (cmd=19, dest=Pipettor).""" + + command_id = 19 + plate_top_center: Annotated[XYZCoord, Struct()] + acceleration_scale_x: PaddedU8 + + +@dataclass +class PrepTransferPlate(PrepCommand): + """Transfer plate from source to destination (cmd=20, dest=Pipettor).""" + + command_id = 20 + 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 + + +@dataclass +class PrepReleasePlate(PrepCommand): + """Release plate / open gripper (cmd=21, dest=Pipettor).""" + + command_id = 21 + + +# 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 +class PrepEmptyDispenser(PrepCommand): + """Empty dispenser (cmd=23, dest=Pipettor).""" + + command_id = 23 + channels: EnumArray + + +@dataclass +class PrepMoveToPosition(PrepCommand): + """Move to position (cmd=26, dest=Pipettor or ChannelCoordinator).""" + + command_id = 26 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMoveToPositionViaLane(PrepCommand): + """Move to position via lane (cmd=27, dest=Pipettor or ChannelCoordinator).""" + + command_id = 27 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepGetPositions(PrepCommand): + """GetPositions (cmd=25, dest=Pipettor). + + Returns the current XYZ position of each channel as a StructArray of + ChannelXYZPositionParameters. + """ + + command_id = 25 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + positions: Annotated[list[ChannelXYZPositionParameters], StructArray()] + + +@dataclass +class PrepMoveZUpToSafe(PrepCommand): + """Move Z axes up to safe height (cmd=28, dest=Pipettor).""" + + command_id = 28 + channels: EnumArray + + +@dataclass +class PrepZSeekLldPosition(PrepCommand): + """Z-seek LLD position (cmd=29, dest=Pipettor).""" + + command_id = 29 + seek_parameters: Annotated[list[LLDChannelSeekParameters], StructArray()] + + +@dataclass +class PrepCreateTadmLimitCurve(PrepCommand): + """Create TADM limit curve (cmd=31, dest=Pipettor).""" + + command_id = 31 + channel: U32 + name: Str + lower_limit: Annotated[list[LimitCurveEntry], StructArray()] + upper_limit: Annotated[list[LimitCurveEntry], StructArray()] + + +@dataclass +class PrepEraseTadmLimitCurves(PrepCommand): + """Erase TADM limit curves for a channel (cmd=32, dest=Pipettor).""" + + command_id = 32 + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveNames(PrepCommand): + """Get TADM limit curve names for a channel (cmd=33, dest=Pipettor).""" + + command_id = 33 + channel: U32 + + +@dataclass +class PrepGetTadmLimitCurveInfo(PrepCommand): + """Get TADM limit curve info (cmd=34, dest=Pipettor).""" + + command_id = 34 + channel: U32 + name: Str + + +@dataclass +class PrepRetrieveTadmData(PrepCommand): + """Retrieve TADM data for a channel (cmd=35, dest=Pipettor).""" + + command_id = 35 + channel: U32 + + +@dataclass +class PrepResetTadmFifo(PrepCommand): + """Reset TADM FIFO (cmd=36, dest=Pipettor).""" + + command_id = 36 + channels: EnumArray + + +@dataclass +class PrepAspirateNoLldMonitoringV2(PrepCommand): + """Aspirate v2 without LLD or monitoring (cmd=38, dest=Pipettor).""" + + command_id = 38 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateTadmV2(PrepCommand): + """Aspirate v2 with TADM, no LLD (cmd=39, dest=Pipettor).""" + + command_id = 39 + aspirate_parameters: Annotated[list[AspirateParametersNoLldAndTadm2], StructArray()] + + +@dataclass +class PrepAspirateWithLldV2(PrepCommand): + """Aspirate v2 with LLD and monitoring (cmd=40, dest=Pipettor).""" + + command_id = 40 + aspirate_parameters: Annotated[list[AspirateParametersLldAndMonitoring2], StructArray()] + + +@dataclass +class PrepAspirateWithLldTadmV2(PrepCommand): + """Aspirate v2 with LLD and TADM (cmd=41, dest=Pipettor).""" + + command_id = 41 + aspirate_parameters: Annotated[list[AspirateParametersLldAndTadm2], StructArray()] + + +@dataclass +class PrepDispenseNoLldV2(PrepCommand): + """Dispense v2 without LLD (cmd=42, dest=Pipettor).""" + + command_id = 42 + dispense_parameters: Annotated[list[DispenseParametersNoLld2], StructArray()] + + +@dataclass +class PrepDispenseWithLldV2(PrepCommand): + """Dispense v2 with LLD (cmd=43, dest=Pipettor).""" + + command_id = 43 + dispense_parameters: Annotated[list[DispenseParametersLld2], StructArray()] + + +# ============================================================================= +# MLPrep command classes +# ============================================================================= + + +@dataclass +class PrepInitialize(PrepCommand): + """Initialize MLPrep (cmd=1, dest=MLPrep).""" + + command_id = 1 + smart: PaddedBool + tip_drop_params: Annotated[InitTipDropParameters, Struct()] + + +@dataclass +class PrepGetIsInitialized(PrepCommand): + """Query whether MLPrep is initialized. + + From introspection (MLPrepRoot.MLPrep): iface=1 id=2 GetIsInitialized(()) -> value: I64. + Sent as STATUS_REQUEST (0); response is STATUS_RESPONSE (1) with one I64. + """ + + command_id = 2 # GetIsInitialized per introspection_output/MLPrepRoot_MLPrep.txt + action_code = 0 # STATUS_REQUEST (query methods use 0, like Nimbus IsInitialized) + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepPark(PrepCommand): + """Park MLPrep (cmd=3, dest=MLPrep).""" + + command_id = 3 + + +@dataclass +class PrepSpread(PrepCommand): + """Spread channels (cmd=4, dest=MLPrep).""" + + command_id = 4 + + +@dataclass +class PrepAddTipAndNeedleDefinition(PrepCommand): + """Add tip/needle definition (cmd=12, dest=MLPrep).""" + + command_id = 12 + tip_definition: Annotated[TipDefinition, Struct()] + + +@dataclass +class PrepRemoveTipAndNeedleDefinition(PrepCommand): + """Remove tip/needle definition by ID (cmd=13, dest=MLPrep).""" + + command_id = 13 + id_: WEnum + + +@dataclass +class PrepReadStorage(PrepCommand): + """Read from instrument storage (cmd=14, dest=MLPrep).""" + + command_id = 14 + offset: U32 + length: U32 + + +@dataclass +class PrepWriteStorage(PrepCommand): + """Write to instrument storage (cmd=15, dest=MLPrep).""" + + command_id = 15 + offset: U32 + data: U8Array + + +@dataclass +class PrepPowerDownRequest(PrepCommand): + """Request power down (cmd=17, dest=MLPrep).""" + + command_id = 17 + + +@dataclass +class PrepConfirmPowerDown(PrepCommand): + """Confirm power down (cmd=18, dest=MLPrep).""" + + command_id = 18 + + +@dataclass +class PrepCancelPowerDown(PrepCommand): + """Cancel power down (cmd=19, dest=MLPrep).""" + + command_id = 19 + + +@dataclass +class PrepRemoveChannelPower(PrepCommand): + """Remove channel power for head swap (cmd=23, dest=MLPrep).""" + + command_id = 23 + + +@dataclass +class PrepRestoreChannelPower(PrepCommand): + """Restore channel power after head swap (cmd=24, dest=MLPrep).""" + + command_id = 24 + delay_ms: U32 + + +@dataclass +class PrepSetDeckLight(PrepCommand): + """Set deck LED colour (cmd=25, dest=MLPrep).""" + + command_id = 25 + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepGetDeckLight(PrepCommand): + """Get deck LED colour (cmd=26, dest=MLPrep).""" + + command_id = 26 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + white: PaddedU8 + red: PaddedU8 + green: PaddedU8 + blue: PaddedU8 + + +@dataclass +class PrepSuspendedPark(PrepCommand): + """Suspended park / move to load position (cmd=29, dest=MLPrep).""" + + command_id = 29 + move_parameters: Annotated[GantryMoveXYZParameters, Struct()] + + +@dataclass +class PrepMethodBegin(PrepCommand): + """Begin method (cmd=30, dest=MLPrep).""" + + command_id = 30 + automatic_pause: PaddedBool + + +@dataclass +class PrepMethodEnd(PrepCommand): + """End method (cmd=31, dest=MLPrep).""" + + command_id = 31 + + +@dataclass +class PrepMethodAbort(PrepCommand): + """Abort method (cmd=33, dest=MLPrep).""" + + command_id = 33 + + +@dataclass +class PrepIsParked(PrepCommand): + """Query parked status (cmd=34, dest=MLPrep). Introspection: IsParked(()) -> parked: I64.""" + + command_id = 34 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepIsSpread(PrepCommand): + """Query spread status (cmd=35, dest=MLPrep). Introspection: IsSpread(()) -> parked: I64.""" + + command_id = 35 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + value: I64 + + +# ----------------------------------------------------------------------------- +# 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 + + +@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 + + +# ----------------------------------------------------------------------------- +# Config queries (MLPrep / DeckConfiguration) for _get_hardware_config +# ----------------------------------------------------------------------------- + + +@dataclass +class _PrepStatusQuery(PrepCommand): + """Base for MLPrep status queries: STATUS_REQUEST (0), no params.""" + + action_code = 0 + + +@dataclass +class PrepGetIsEnclosurePresent(_PrepStatusQuery): + """GetIsEnclosurePresent (cmd=21, dest=MLPrep). Returns I64 as bool.""" + + command_id = 21 + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepGetSafeSpeedsEnabled(_PrepStatusQuery): + """GetSafeSpeedsEnabled (cmd=28, dest=MLPrep). Returns I64 as bool.""" + + command_id = 28 + + @dataclass(frozen=True) + class Response: + value: I64 + + +@dataclass +class PrepGetDefaultTraverseHeight(_PrepStatusQuery): + """GetDefaultTraverseHeight (cmd=10, dest=MLPrep). Returns F32.""" + + command_id = 10 + + @dataclass(frozen=True) + class Response: + value: F32 + + +@dataclass +class PrepGetTipAndNeedleDefinitions(_PrepStatusQuery): + """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 + + @dataclass(frozen=True) + class Response: + definitions: Annotated[list[TipDefinition], StructArray()] + + +@dataclass +class PrepGetDeckBounds(_PrepStatusQuery): + """GetDeckBounds (cmd=1, dest=DeckConfiguration). Returns 6× F32 (min/max x,y,z).""" + + command_id = 1 + + @dataclass(frozen=True) + class Response: + min_x: F32 + max_x: F32 + min_y: F32 + max_y: F32 + min_z: F32 + max_z: F32 + + +@dataclass +class PrepGetDeckSiteDefinitions(_PrepStatusQuery): + """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 + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_DeckSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetWasteSiteDefinitions(_PrepStatusQuery): + """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 + + @dataclass(frozen=True) + class Response: + sites: Annotated[list[_WasteSiteDefinitionWire], StructArray()] + + +@dataclass +class PrepGetChannelBounds(PrepCommand): + """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 + action_code = 0 # STATUS_REQUEST + + @dataclass(frozen=True) + class Response: + bounds: Annotated[list[ChannelBoundsParameters], StructArray()] + + +@dataclass +class PrepGetPresentChannels(_PrepStatusQuery): + """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 + + @dataclass(frozen=True) + class Response: + channels: EnumArray # list of ints: map to ChannelIndex for present channels diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py index 633a6b15c01..ec45345526d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py @@ -1,13 +1,14 @@ -"""Hamilton command architecture using new simplified TCP stack. +"""Command layer for Hamilton TCP. -This module provides the HamiltonCommand base class that uses the new refactored -architecture: Wire → HoiParams → Packets → Messages → Commands. +HamiltonCommand base: build_parameters() returns HoiParams; interpret_response() +auto-decodes success responses via nested Response dataclasses (wire-type +annotations and parse_into_struct). Wire → HoiParams → Packets → Messages → Commands. """ from __future__ import annotations import inspect -from typing import Optional +from typing import Any, Optional from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( CommandMessage, @@ -38,7 +39,7 @@ def __init__(self, dest: Address, value: int): self.value = value def build_parameters(self) -> HoiParams: - return HoiParams().i32(self.value) + return HoiParams().add(self.value, I32) @classmethod def parse_response_parameters(cls, data: bytes) -> dict: @@ -150,18 +151,27 @@ def build( # Build final packet return msg.build(source, sequence, harp_response_required=response_required) - def interpret_response(self, response: CommandResponse) -> Optional[dict]: - """Interpret success response. + def interpret_response(self, response: CommandResponse) -> Any: + """Interpret success response (command layer auto-decode). - This is the new interface used by the backend. Default implementation - directly calls parse_response_parameters for efficiency. + If the command class defines a nested Response dataclass with wire-type + annotations, decode via parse_into_struct and return a Response instance. + Otherwise fall back to parse_response_parameters (dict or None). Args: response: CommandResponse from network Returns: - Dictionary with parsed response data, or None if no data to extract + Command.Response instance, dict, or None """ + cls = type(self) + if hasattr(cls, "Response") and response.hoi.params: + from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( + HoiParamsParser, + parse_into_struct, + ) + + return parse_into_struct(HoiParamsParser(response.hoi.params), cls.Response) return self.parse_response_parameters(response.hoi.params) @classmethod diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py index 247de40fde1..d0dd3fccdcd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py @@ -1,22 +1,51 @@ """Hamilton TCP Introspection API. -This module provides dynamic discovery of Hamilton instrument capabilities -using Interface 0 introspection methods. It allows discovering available -objects, methods, interfaces, enums, and structs at runtime. +Wraps HamiltonTCPClient to provide dynamic discovery of instrument capabilities +via Interface 0 methods (GetObject, GetMethod, GetStructs, GetEnums, +GetInterfaces, GetSubobjectAddress). + +Canonical usage:: + + intro = HamiltonIntrospection(client) # standalone + intro = HamiltonIntrospection(lh.backend.client) # from LiquidHandler + + # Build a cached registry for one object (uses InterfaceDescriptors): + registry = await intro.build_type_registry("MLPrepRoot.MphRoot.MPH") + registry.print_summary() + + # Diagnose a COMMAND_EXCEPTION: + print(await intro.diagnose_error(str(e), registry)) + + # Resolve a method signature: + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9, registry) """ from __future__ import annotations import logging from dataclasses import dataclass, field -from typing import Any, Dict, List +from typing import Dict, List, Optional, Set, Union, cast from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser +from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( + PADDED_FLAG, + HoiParams, + HoiParamsParser, + inspect_hoi_params, +) from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + U8, + U16, + U32, HamiltonDataType, - HamiltonProtocol, + I8Array, + I32Array, + Str, + StrArray, + U8Array, + U32Array, ) logger = logging.getLogger(__name__) @@ -41,18 +70,6 @@ def resolve_type_id(type_id: int) -> str: return f"UNKNOWN_TYPE_{type_id}" -def resolve_type_ids(type_ids: List[int]) -> List[str]: - """Resolve list of Hamilton type IDs to readable names. - - Args: - type_ids: List of Hamilton data type IDs - - Returns: - List of human-readable type names - """ - return [resolve_type_id(tid) for tid in type_ids] - - # ============================================================================ # INTROSPECTION TYPE MAPPING # ============================================================================ @@ -65,6 +82,8 @@ def resolve_type_ids(type_ids: List[int]) -> List[str]: # - ReturnValue types: Single return value _INTROSPECTION_TYPE_NAMES: dict[int, str] = { + # Void (0) - used for empty/placeholder parameters + 0: "void", # Argument types (1-8, 33, 41, 45, 49, 53, 61, 66, 82, 102) 1: "i8", 2: "u8", @@ -79,8 +98,11 @@ def resolve_type_ids(type_ids: List[int]) -> List[str]: 45: "List[u16]", 49: "List[i32]", 53: "List[u32]", - 61: "List[struct]", # Complex type, needs source_id + struct_id + 57: "struct", # Complex type: (57, source_id, ref_id) → single struct + 61: "List[struct]", # Complex type: (61, source_id, ref_id) → list of structs + 63: "struct", # Return element: struct ref in a return list (e.g. MoveYAbsolute return) 66: "List[bool]", + 77: "List[str]", 82: "List[enum]", # Complex type, needs source_id + enum_id 102: "f32", # ReturnElement types (18-24, 35, 43, 47, 51, 55, 68, 76) @@ -124,10 +146,60 @@ def resolve_type_ids(type_ids: List[int]) -> List[str]: } # Type ID sets for categorization -_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 61, 66, 82, 102} -_RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 68, 76} -_RETURN_VALUE_TYPE_IDS = {25, 26, 27, 28, 29, 30, 31, 32, 36, 44, 48, 52, 56, 69, 81, 85, 104, 105} -_COMPLEX_TYPE_IDS = {60, 61, 64, 78, 81, 82, 85} # Types that need additional bytes +# 78 = enum (Argument); 60, 64 = struct (ReturnValue) — see _INTROSPECTION_TYPE_NAMES comments +_ARGUMENT_TYPE_IDS = {1, 2, 3, 4, 5, 6, 7, 8, 33, 41, 45, 49, 53, 57, 61, 66, 77, 78, 82, 102} +_RETURN_ELEMENT_TYPE_IDS = {18, 19, 20, 21, 22, 23, 24, 35, 43, 47, 51, 55, 63, 68, 76} +_RETURN_VALUE_TYPE_IDS = { + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 36, + 44, + 48, + 52, + 56, + 60, + 64, + 69, + 81, + 85, + 104, + 105, +} + +# Complex type sentinels: byte values that begin a 3-byte triple [type_id, source_id, ref_id]. +# The two contexts (method parameterTypes vs struct structureElementTypes) use different sentinels. +_COMPLEX_METHOD_TYPE_IDS = {57, 60, 61, 63, 64, 78, 81, 82, 85} # GetMethod parameterTypes triples +_COMPLEX_STRUCT_TYPE_IDS = {30, 31, 32, 35} # STRUCTURE=30, STRUCT_ARRAY=31, ENUM=32, ENUM_ARRAY=35 +# Backward-compat alias (used by ParameterType.is_complex for method parameters) +_COMPLEX_TYPE_IDS = _COMPLEX_METHOD_TYPE_IDS + +# HC_RESULT codes returned in COMMAND_EXCEPTION / STATUS_EXCEPTION (extend as observed) +_HC_RESULT_DESCRIPTIONS: Dict[int, str] = { + 0x0000: "success", + 0x0005: "invalid parameter / not supported", + 0x0006: "unknown command", + 0x0E01: "door unlocked / safety interlock (command not allowed while door open)", + 0x0200: "hardware error", + 0x020A: "hardware not ready / axis error", + # Empirically identified — require further validation: + 0x0011: "parameter value out of valid range [empirical, needs validation]", + 0x0F03: "drive initialization failed (reference/homing run failure) [empirical, needs validation]", + 0x0F04: "X position out of allowed movement range [empirical, needs validation]", + 0x0F05: "Y position out of allowed movement range [empirical, needs validation]", + 0x0F06: "Z position out of allowed movement range [empirical, needs validation]", + 0x0F08: "tip pickup failed (tip not detected at expected position) [empirical, needs validation]", +} + + +def describe_hc_result(code: int) -> str: + """Return human-readable description for an HC_RESULT code from device errors.""" + return _HC_RESULT_DESCRIPTIONS.get(code, f"HC_RESULT=0x{code:04X} (unknown)") def get_introspection_type_category(type_id: int) -> str: @@ -161,20 +233,6 @@ def resolve_introspection_type_name(type_id: int) -> str: return _INTROSPECTION_TYPE_NAMES.get(type_id, f"UNKNOWN_TYPE_{type_id}") -def is_complex_introspection_type(type_id: int) -> bool: - """Check if introspection type is complex (needs additional bytes). - - Complex types require 3 bytes total: type_id, source_id, struct_id/enum_id - - Args: - type_id: Introspection type ID - - Returns: - True if type is complex - """ - return type_id in _COMPLEX_TYPE_IDS - - # ============================================================================ # DATA STRUCTURES # ============================================================================ @@ -189,6 +247,131 @@ class ObjectInfo: method_count: int subobject_count: int address: Address + children: Dict[str, "ObjectInfo"] = field(default_factory=dict) + + +@dataclass +class ParameterType: + """A resolved type reference used for both method parameters and struct fields. + + Simple types (i8, f32, etc.) have only type_id set. + Complex references additionally carry source_id (the interface defining the + struct/enum) and ref_id (struct_id or enum_id within that interface). + These are encoded as 3-byte triples [type_id, source_id, ref_id] in two + distinct contexts that each use a different sentinel byte: + + - GetMethod parameterTypes: sentinels in _COMPLEX_METHOD_TYPE_IDS (57, 61 …) + - GetStructs structureElementTypes: sentinel 0xE8 (_COMPLEX_STRUCT_TYPE_IDS) + """ + + type_id: int + source_id: Optional[int] = None + ref_id: Optional[int] = None + _byte_width: int = 1 # Bytes consumed in struct element_types (1=simple, 3=ref, 7+=inline) + + @property + def is_complex(self) -> bool: + """True if this is a 3-byte complex reference (method param or struct field).""" + return self.type_id in (_COMPLEX_METHOD_TYPE_IDS | _COMPLEX_STRUCT_TYPE_IDS) + + @property + def is_struct_ref(self) -> bool: + """True if this is a struct reference (type 30 in struct context, 57/61 in method context).""" + return self.type_id in {30, 31, 57, 60, 61, 63, 64} + + @property + def is_enum_ref(self) -> bool: + """True if this is an enum reference (type 32 in struct context, 78/81/82/85 in method).""" + return self.type_id in {32, 35, 78, 81, 82, 85} + + def resolve_name(self, registry: Optional["TypeRegistry"] = None) -> str: + """Resolve to a human-readable name, optionally using a TypeRegistry.""" + base = resolve_introspection_type_name(self.type_id) + if not self.is_complex or self.source_id is None or self.ref_id is None: + return base + if registry is None: + return f"{base}(iface={self.source_id}, id={self.ref_id})" + if self.is_struct_ref: + s = registry.resolve_struct(self.source_id, self.ref_id) + return s.name if s else f"{base}(iface={self.source_id}, id={self.ref_id})" + if self.is_enum_ref: + e = registry.resolve_enum(self.source_id, self.ref_id) + return e.name if e else f"{base}(iface={self.source_id}, id={self.ref_id})" + return f"{base}(iface={self.source_id}, id={self.ref_id})" + + +def _parse_type_seq( + data: bytes | list[int], + complex_ids: set[int], +) -> List[ParameterType]: + """Shared variable-width parser for Hamilton type-ID byte sequences. + + Both GetMethod parameterTypes and GetStructs structureElementTypes encode types + as a byte stream where simple types occupy 1 byte and complex references have + variable width. + + For struct element types (complex_ids = _COMPLEX_STRUCT_TYPE_IDS), complex + sentinels (30=STRUCTURE, 31=STRUCT_ARRAY, 32=ENUM, 35=ENUM_ARRAY) have two + encoding formats determined by the second byte: + + - **Reference** (second byte ≤ 3): 3 bytes ``[sentinel, source_id, ref_id]`` + where source 1=global, 2=local, 3=network. + - **Inline definition** (second byte = 4): variable width, terminated by + ``0xEE`` (238). Typically 7 bytes: ``[sentinel, 4, base_type, 0, 1, 0, 0xEE]``. + The ``base_type`` specifies the underlying wire type (1=I8, 2=I16, 3=I32). + + For method parameter types, only the 3-byte reference format is used. + + Args: + data: Raw bytes or list of ints to parse. + complex_ids: Set of type_id values that introduce a multi-byte entry. + + Returns: + List of ParameterType, one per logical type entry. + """ + _INLINE_MARKER = 4 + _INLINE_TERMINATOR = 0xEE # 238 + + ints = list(data) if isinstance(data, bytes) else data + result: List[ParameterType] = [] + i = 0 + while i < len(ints): + tid = ints[i] + if tid in complex_ids and i + 2 < len(ints): + second = ints[i + 1] + if second == _INLINE_MARKER: + # Inline type definition: scan forward to 0xEE terminator + end = i + 2 + while end < len(ints) and ints[end] != _INLINE_TERMINATOR: + end += 1 + end += 1 # consume the 0xEE byte itself + # Store as ParameterType with the base wire type from byte [i+2] + width = end - i + base_type = ints[i + 2] if i + 2 < len(ints) else 0 + result.append( + ParameterType(tid, source_id=_INLINE_MARKER, ref_id=base_type, _byte_width=width) + ) + i = end + else: + # Standard 3-byte reference: [sentinel, source_id, ref_id] + result.append(ParameterType(tid, source_id=second, ref_id=ints[i + 2], _byte_width=3)) + i += 3 + else: + result.append(ParameterType(tid)) + i += 1 + return result + + +def _parse_type_ids(raw: str | bytes | None) -> List[ParameterType]: + """Parse GetMethod parameterTypes blob. Thin wrapper around _parse_type_seq. + + Accepts bytes (preferred) or str — the device sends STRING (15) but the + payload is binary, so callers must use parse_next_raw() to avoid UTF-8 errors. + """ + if raw is None: + return [] + data: list[int] = list(raw) if isinstance(raw, bytes) else [ord(c) for c in raw] + return _parse_type_seq(data, _COMPLEX_METHOD_TYPE_IDS) @dataclass @@ -199,55 +382,43 @@ class MethodInfo: call_type: int method_id: int name: str - parameter_types: list[int] = field( - default_factory=list - ) # Decoded parameter type IDs (Argument category) - parameter_labels: list[str] = field(default_factory=list) # Parameter names (if available) - return_types: list[int] = field( - default_factory=list - ) # Decoded return type IDs (ReturnElement/ReturnValue category) - return_labels: list[str] = field(default_factory=list) # Return names (if available) - - def get_signature_string(self) -> str: - """Get method signature as a readable string.""" - # Decode parameter types to readable names - if self.parameter_types: - param_type_names = [resolve_introspection_type_name(tid) for tid in self.parameter_types] + parameter_types: list[ParameterType] = field(default_factory=list) + parameter_labels: list[str] = field(default_factory=list) + return_types: list[ParameterType] = field(default_factory=list) + return_labels: list[str] = field(default_factory=list) + + def get_signature_string(self, registry: Optional["TypeRegistry"] = None) -> str: + """Get method signature as a readable string. - # If we have labels, use them; otherwise just show types + If a TypeRegistry is provided, struct/enum references are resolved to + their names (e.g. PickupTipParameters instead of struct(iface=1, id=57)). + """ + if self.parameter_types: + param_type_names = [pt.resolve_name(registry) for pt in self.parameter_types] if self.parameter_labels and len(self.parameter_labels) == len(param_type_names): - # Format as "param1: type1, param2: type2" params = [ f"{label}: {type_name}" for label, type_name in zip(self.parameter_labels, param_type_names) ] param_str = ", ".join(params) else: - # Just show types param_str = ", ".join(param_type_names) else: param_str = "void" - # Decode return types to readable names if self.return_types: - return_type_names = [resolve_introspection_type_name(tid) for tid in self.return_types] - return_categories = [get_introspection_type_category(tid) for tid in self.return_types] - - # Format return based on category + return_type_names = [rt.resolve_name(registry) for rt in self.return_types] + return_categories = [get_introspection_type_category(rt.type_id) for rt in self.return_types] if any(cat == "ReturnElement" for cat in return_categories): - # Multiple return values → struct format if self.return_labels and len(self.return_labels) == len(return_type_names): - # Format as "{ label1: type1, label2: type2 }" returns = [ f"{label}: {type_name}" for label, type_name in zip(self.return_labels, return_type_names) ] return_str = f"{{ {', '.join(returns)} }}" else: - # Just show types return_str = f"{{ {', '.join(return_type_names)} }}" elif len(return_type_names) == 1: - # Single return value if self.return_labels and len(self.return_labels) == 1: return_str = f"{self.return_labels[0]}: {return_type_names[0]}" else: @@ -257,7 +428,82 @@ def get_signature_string(self) -> str: else: return_str = "void" - return f"{self.name}({param_str}) -> {return_str}" + return f"[{self.interface_id}:{self.method_id}] {self.name}({param_str}) -> {return_str}" + + +@dataclass +class TypeRegistry: + """Resolved type information for one object. + + Built once from introspection during setup. Caches structs, enums, and + interface info so method signatures can be fully resolved without additional + device calls. Use build_type_registry() to create. + + Source ID semantics (from piglet): + source_id=1: Global pool (shared type definitions from global objects) + source_id=2: Local to the current object's interface + source_id=3: Built-in NetworkResult error type + + Example: + registry = await intro.build_type_registry(mph_addr) + method = registry.get_method(interface_id=1, method_id=9) + print(method.get_signature_string(registry)) # PickupTips(tipParameters: PickupTipParameters, ...) + """ + + address: Optional[Address] = None + interfaces: Dict[int, "InterfaceInfo"] = field(default_factory=dict) + structs: Dict[int, Dict[int, "StructInfo"]] = field(default_factory=dict) + enums: Dict[int, Dict[int, "EnumInfo"]] = field(default_factory=dict) + methods: List[MethodInfo] = field(default_factory=list) + global_pool: Optional["GlobalTypePool"] = None + + def resolve_struct(self, source_id: int, ref_id: int) -> Optional["StructInfo"]: + """Look up a struct by source_id and ref_id. + + source_id=1: Global pool (1-based index, piglet subtracts 1) + source_id=2: Local interface structs (keyed by interface_id in self.structs) + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_struct(ref_id) + if source_id == 2: + return self.structs.get(source_id, {}).get(ref_id) + logger.warning("resolve_struct: unhandled source_id=%d ref_id=%d", source_id, ref_id) + return None + + def resolve_enum(self, source_id: int, ref_id: int) -> Optional["EnumInfo"]: + """Look up an enum by source_id and ref_id. + + source_id=1: Global pool (1-based index) + source_id=2: Local interface enums + """ + if source_id == 1 and self.global_pool is not None: + return self.global_pool.resolve_enum(ref_id) + return self.enums.get(source_id, {}).get(ref_id) + + def get_method(self, interface_id: int, method_id: int) -> Optional[MethodInfo]: + """Find a method by interface_id and method_id.""" + for m in self.methods: + if m.interface_id == interface_id and m.method_id == method_id: + return m + return None + + def get_interface_ids(self) -> Set[int]: + """Return the set of interface IDs this object implements.""" + return set(self.interfaces.keys()) + + def print_summary(self) -> None: + """Print a summary of all interfaces, structs, enums, and methods.""" + print(f"TypeRegistry for {self.address}") + print(f" Interfaces: {sorted(self.interfaces.keys())}") + for iid, iface in sorted(self.interfaces.items()): + n_structs = len(self.structs.get(iid, {})) + n_enums = len(self.enums.get(iid, {})) + n_methods = sum(1 for m in self.methods if m.interface_id == iid) + print(f" [{iid}] {iface.name}: {n_structs} structs, {n_enums} enums, {n_methods} methods") + for sid, s in sorted(self.structs.get(iid, {}).items()): + print(f" struct {sid}: {s.name} ({len(s.fields)} fields)") + for eid, e in sorted(self.enums.get(iid, {}).items()): + print(f" enum {eid}: {e.name} ({len(e.values)} values)") @dataclass @@ -280,26 +526,114 @@ class EnumInfo: @dataclass class StructInfo: - """Struct definition from introspection.""" + """Struct definition from introspection. + + ``interface_id`` records which interface this struct was defined on, + enabling ``source_id=0`` (same-interface) resolution in the global pool. + + ``fields`` maps field names to ``ParameterType`` instances, preserving the + full (type_id, source_id, ref_id) triple for fields that are complex + references (type 30=STRUCTURE, 32=ENUM). Call ``get_struct_string(registry)`` + to get human-readable names with struct/enum references resolved. + """ struct_id: int name: str - fields: Dict[str, int] # field_name -> type_id + fields: Dict[str, "ParameterType"] # field_name -> ParameterType + interface_id: Optional[int] = None # Interface this struct was defined on @property def field_type_names(self) -> Dict[str, str]: - """Get human-readable field type names.""" - return {field_name: resolve_type_id(type_id) for field_name, type_id in self.fields.items()} + """Get human-readable field type names using HamiltonDataType resolver.""" + return {name: _resolve_struct_field_type(pt) for name, pt in self.fields.items()} - def get_struct_string(self) -> str: - """Get struct definition as a readable string.""" + def get_struct_string(self, registry: Optional["TypeRegistry"] = None) -> str: + """Get struct definition as a readable string. + + If a TypeRegistry is provided, complex references (struct/enum fields) + are resolved to their names. + """ field_strs = [ - f"{field_name}: {resolve_type_id(type_id)}" for field_name, type_id in self.fields.items() + f"{name}: {_resolve_struct_field_type(pt, registry)}" for name, pt in self.fields.items() ] fields_str = "\n ".join(field_strs) if field_strs else " (empty)" return f"struct {self.name} {{\n {fields_str}\n}}" +@dataclass +class GlobalTypePool: + """Flat, sequentially-indexed pool of structs/enums from global objects. + + Piglet builds this by walking ``robot.globals`` objects, iterating each + interface's structs/enums, and inserting them in encounter order. A + ``source_id=1`` reference uses ``ref_id`` as a **1-based** index into this + pool (piglet subtracts 1 for lookup). + """ + + structs: List[StructInfo] = field(default_factory=list) + enums: List[EnumInfo] = field(default_factory=list) + interface_structs: Dict[int, Dict[int, StructInfo]] = field(default_factory=dict) + + def resolve_struct(self, ref_id: int) -> Optional[StructInfo]: + """Look up global struct by 1-based ref_id.""" + idx = ref_id - 1 # 1-based → 0-based + return self.structs[idx] if 0 <= idx < len(self.structs) else None + + def resolve_struct_local(self, interface_id: int, ref_id: int) -> Optional[StructInfo]: + """Resolve a source_id=0 struct ref within a specific interface.""" + return self.interface_structs.get(interface_id, {}).get(ref_id) + + def resolve_enum(self, ref_id: int) -> Optional[EnumInfo]: + """Look up global enum by 1-based ref_id.""" + idx = ref_id - 1 + return self.enums[idx] if 0 <= idx < len(self.enums) else None + + def print_summary(self) -> None: + """Print global pool summary.""" + print(f"GlobalTypePool: {len(self.structs)} structs, {len(self.enums)} enums") + for i, s in enumerate(self.structs): + print(f" struct[{i + 1}]: {s.name} ({len(s.fields)} fields)") + for i, e in enumerate(self.enums): + print(f" enum[{i + 1}]: {e.name} ({len(e.values)} values)") + + +# GetStructs wire format (device sends 4 separate array fragments): +# [0] STRING_ARRAY = struct names (one per struct) +# [1] U32_ARRAY = numberStructureElements — field count for each struct +# [2] U8_ARRAY = structureElementTypes — flat field type bytes (variable width) +# [3] STRING_ARRAY = structureElementDescriptions — flat field names +# +# structureElementTypes byte encoding: +# - Simple types: 1 byte using HamiltonDataType values (40=F32, 23=BOOL, etc.) +# - Complex references: 3 bytes [sentinel, source_id, ref_id] +# sentinel=30 for STRUCTURE, sentinel=32 for ENUM (matches piglet) +# The HamiltonDataType namespace is used here, NOT the introspection type namespace. + + +def _resolve_struct_field_type( + pt: ParameterType, + registry: Optional["TypeRegistry"] = None, +) -> str: + """Resolve a struct field's ParameterType to a human-readable type name. + + Struct field type_ids use the HamiltonDataType wire namespace (e.g. 40=F32, + 23=BOOL) -- not the method-parameter introspection namespace. Complex + references (30=STRUCTURE, 32=ENUM) are resolved via the TypeRegistry when provided. + """ + if pt.is_complex and pt.source_id is not None and pt.ref_id is not None: + if registry is not None: + if pt.is_struct_ref: + s = registry.resolve_struct(pt.source_id, pt.ref_id) + if s: + return f"struct({s.name})" + elif pt.is_enum_ref: + e = registry.resolve_enum(pt.source_id, pt.ref_id) + if e: + return e.name + return f"ref(iface={pt.source_id}, id={pt.ref_id})" + return resolve_type_id(pt.type_id) # HamiltonDataType resolver + + # ============================================================================ # INTROSPECTION COMMAND CLASSES # ============================================================================ @@ -316,23 +650,12 @@ class GetObjectCommand(HamiltonCommand): def __init__(self, object_address: Address): super().__init__(object_address) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_object response.""" - # Parse HOI2 DataFragments - parser = HoiParamsParser(data) - - _, name = parser.parse_next() - _, version = parser.parse_next() - _, method_count = parser.parse_next() - _, subobject_count = parser.parse_next() - - return { - "name": name, - "version": version, - "method_count": method_count, - "subobject_count": subobject_count, - } + @dataclass(frozen=True) + class Response: + name: Str + version: Str + method_count: U32 + subobject_count: U16 class GetMethodCommand(HamiltonCommand): @@ -349,7 +672,7 @@ def __init__(self, object_address: Address, method_index: int): def build_parameters(self) -> HoiParams: """Build parameters for get_method command.""" - return HoiParams().u32(self.method_index) + return HoiParams().add(self.method_index, U32) @classmethod def parse_response_parameters(cls, data: bytes) -> dict: @@ -361,51 +684,53 @@ def parse_response_parameters(cls, data: bytes) -> dict: _, method_id = parser.parse_next() _, name = parser.parse_next() - # The remaining fragments are STRING types containing type IDs as bytes - # Hamilton sends ONE combined list where type IDs encode category (Argument/ReturnElement/ReturnValue) - # First STRING after method name is parameter_types (each byte is a type ID - can be Argument or Return) - # Second STRING (if present) is parameter_labels (comma-separated names - includes both params and returns) - parameter_types_str = None + # The remaining fragments are STRING types containing type IDs as bytes. + # Complex types (struct/enum refs) are 3-byte triples [type_id, source_id, ref_id]. + # Labels are comma-separated, one per *logical* parameter (matching ParameterType count). parameter_labels_str = None if parser.has_remaining(): - _, parameter_types_str = parser.parse_next() + # Fragment 4: parameter_types. Wire type is STRING but payload is binary type IDs; + # use parse_next_raw() to avoid UTF-8 decode failure on bytes 0x80-0xFF. + _, flags, _, param_types_payload = parser.parse_next_raw() + if flags & PADDED_FLAG: + param_types_payload = ( + param_types_payload[:-1] if param_types_payload else param_types_payload + ) + param_types_payload = param_types_payload.rstrip(b"\x00") # STRING null terminator + all_types = _parse_type_ids(param_types_payload) + else: + all_types = [] if parser.has_remaining(): _, parameter_labels_str = parser.parse_next() - # Decode string bytes to type IDs (like piglet does: .as_bytes().to_vec()) - all_type_ids: list[int] = [] - if parameter_types_str: - all_type_ids = [ord(c) for c in parameter_types_str] - - # Parse all labels (comma-separated - includes both parameters and returns) all_labels: list[str] = [] if parameter_labels_str: all_labels = [label.strip() for label in parameter_labels_str.split(",") if label.strip()] - # Categorize by type ID ranges (like piglet does) - # Split into arguments vs returns based on type ID category - parameter_types: list[int] = [] + parameter_types: list[ParameterType] = [] parameter_labels: list[str] = [] - return_types: list[int] = [] + return_types: list[ParameterType] = [] return_labels: list[str] = [] - for i, type_id in enumerate(all_type_ids): - category = get_introspection_type_category(type_id) + for i, pt in enumerate(all_types): + category = get_introspection_type_category(pt.type_id) label = all_labels[i] if i < len(all_labels) else None if category == "Argument": - parameter_types.append(type_id) + parameter_types.append(pt) if label: parameter_labels.append(label) elif category in ("ReturnElement", "ReturnValue"): - return_types.append(type_id) + return_types.append(pt) if label: return_labels.append(label) - # Unknown types - could be parameters or returns, default to parameters else: - parameter_types.append(type_id) + logger.warning( + "Unknown introspection type category for type_id=%d; treating as parameter", pt.type_id + ) + parameter_types.append(pt) if label: parameter_labels.append(label) @@ -414,10 +739,10 @@ def parse_response_parameters(cls, data: bytes) -> dict: "call_type": call_type, "method_id": method_id, "name": name, - "parameter_types": parameter_types, # Decoded type IDs (Argument category only) - "parameter_labels": parameter_labels, # Parameter names only - "return_types": return_types, # Decoded type IDs (ReturnElement/ReturnValue only) - "return_labels": return_labels, # Return names only + "parameter_types": parameter_types, + "parameter_labels": parameter_labels, + "return_types": return_types, + "return_labels": return_labels, } @@ -435,22 +760,21 @@ def __init__(self, object_address: Address, subobject_index: int): def build_parameters(self) -> HoiParams: """Build parameters for get_subobject_address command.""" - return HoiParams().u16(self.subobject_index) # Use u16, not u32 + return HoiParams().add(self.subobject_index, U16) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_subobject_address response.""" - parser = HoiParamsParser(data) - - _, module_id = parser.parse_next() - _, node_id = parser.parse_next() - _, object_id = parser.parse_next() - - return {"address": Address(module_id, node_id, object_id)} + @dataclass(frozen=True) + class Response: + module_id: U16 + node_id: U16 + object_id: U16 class GetInterfacesCommand(HamiltonCommand): - """Get available interfaces (command_id=4).""" + """Get available interfaces (command_id=4). + + Firmware signature: InterfaceDescriptors(()) -> interfaceIds: I8_ARRAY, interfaceDescriptors: STRING_ARRAY + Returns 2 columnar fragments, not count+rows. + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -460,25 +784,20 @@ class GetInterfacesCommand(HamiltonCommand): def __init__(self, object_address: Address): super().__init__(object_address) - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_interfaces response.""" - parser = HoiParamsParser(data) - - interfaces = [] - _, interface_count = parser.parse_next() - - for _ in range(interface_count): - _, interface_id = parser.parse_next() - _, name = parser.parse_next() - _, version = parser.parse_next() - interfaces.append({"interface_id": interface_id, "name": name, "version": version}) - - return {"interfaces": interfaces} + @dataclass(frozen=True) + class Response: + interface_ids: I8Array + interface_names: StrArray class GetEnumsCommand(HamiltonCommand): - """Get enum definitions (command_id=5).""" + """Get enum definitions (command_id=5). + + Firmware signature: EnumInfo(interfaceId) -> enumerationNames: STRING_ARRAY, + numberEnumerationValues: U32_ARRAY, enumerationValues: I32_ARRAY, + enumerationValueDescriptions: STRING_ARRAY + Returns 4 columnar fragments, not count+rows. + """ protocol = HamiltonProtocol.OBJECT_DISCOVERY interface_id = 0 @@ -491,31 +810,14 @@ def __init__(self, object_address: Address, target_interface_id: int): def build_parameters(self) -> HoiParams: """Build parameters for get_enums command.""" - return HoiParams().u8(self.target_interface_id) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_enums response.""" - parser = HoiParamsParser(data) - - enums = [] - _, enum_count = parser.parse_next() + return HoiParams().add(self.target_interface_id, U8) - for _ in range(enum_count): - _, enum_id = parser.parse_next() - _, name = parser.parse_next() - - # Parse enum values - _, value_count = parser.parse_next() - values = {} - for _ in range(value_count): - _, value_name = parser.parse_next() - _, value_value = parser.parse_next() - values[value_name] = value_value - - enums.append({"enum_id": enum_id, "name": name, "values": values}) - - return {"enums": enums} + @dataclass(frozen=True) + class Response: + enum_names: StrArray + value_counts: U32Array + values: I32Array + value_names: StrArray class GetStructsCommand(HamiltonCommand): @@ -532,31 +834,38 @@ def __init__(self, object_address: Address, target_interface_id: int): def build_parameters(self) -> HoiParams: """Build parameters for get_structs command.""" - return HoiParams().u8(self.target_interface_id) - - @classmethod - def parse_response_parameters(cls, data: bytes) -> dict: - """Parse get_structs response.""" - parser = HoiParamsParser(data) - - structs = [] - _, struct_count = parser.parse_next() + return HoiParams().add(self.target_interface_id, U8) + + @dataclass(frozen=True) + class Response: + """GetStructs returns 4 fragments: struct names, per-struct field counts, flat field type IDs, flat field names. + + Fragment layout (device signature: StructInfo): + [0] STRING_ARRAY = struct names (one per struct) + [1] U32_ARRAY = numberStructureElements: field count for each struct (NOT struct IDs) + [2] U8_ARRAY = structureElementTypes: flat field type IDs across all structs + [3] STRING_ARRAY = structureElementDescriptions: flat field names across all structs + Struct IDs are positional (0-indexed); the device does not send them explicitly. + """ - for _ in range(struct_count): - _, struct_id = parser.parse_next() - _, name = parser.parse_next() + struct_names: StrArray + field_counts: U32Array + field_type_ids: U8Array + field_names: StrArray - # Parse struct fields - _, field_count = parser.parse_next() - fields = {} - for _ in range(field_count): - _, field_name = parser.parse_next() - _, field_type = parser.parse_next() - fields[field_name] = field_type - structs.append({"struct_id": struct_id, "name": name, "fields": fields}) +# ============================================================================ +# INTERFACE 0 METHOD IDS (Object Discovery / Introspection) +# ============================================================================ +# Used to guard calls: only call an Interface 0 method if it is in the set +# returned by get_supported_interface0_method_ids (from the object's method table). - return {"structs": structs} +GET_OBJECT = 1 +GET_METHOD = 2 +GET_SUBOBJECT_ADDRESS = 3 +GET_INTERFACES = 4 +GET_ENUMS = 5 +GET_STRUCTS = 6 # ============================================================================ @@ -565,7 +874,12 @@ def parse_response_parameters(cls, data: bytes) -> dict: class HamiltonIntrospection: - """High-level API for Hamilton introspection.""" + """High-level API for Hamilton introspection. + + Uses the object's method table (GetMethod) to determine which Interface 0 + methods are supported and only calls those. Interfaces are per-object; + there is no aggregation from children. + """ def __init__(self, backend): """Initialize introspection API. @@ -575,6 +889,31 @@ def __init__(self, backend): """ self.backend = backend + def _resolve_address(self, addr_or_path: Union[Address, str]) -> Address: + """Resolve dot-path string to Address using the backend's registry, or return Address as-is.""" + if isinstance(addr_or_path, str): + return cast(Address, self.backend._registry.address(addr_or_path)) + return addr_or_path + + async def get_supported_interface0_method_ids(self, address: Address) -> Set[int]: + """Return the set of Interface 0 method IDs this object supports. + + Calls GetObject to get method_count, then GetMethod(address, i) for each + index and collects method_id for every method where interface_id == 0. + Used to guard calls so we never send an Interface 0 command the object + did not advertise. + """ + obj = await self.get_object(address) + supported: Set[int] = set() + for i in range(obj.method_count): + try: + method = await self.get_method(address, i) + if method.interface_id == 0: + supported.add(method.method_id) + except Exception as e: + logger.debug("get_method(%s, %d) failed: %s", address, i, e) + return supported + async def get_object(self, address: Address) -> ObjectInfo: """Get object metadata. @@ -585,13 +924,15 @@ async def get_object(self, address: Address) -> ObjectInfo: Object metadata """ command = GetObjectCommand(address) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetObjectCommand returned None") return ObjectInfo( - name=response["name"], - version=response["version"], - method_count=response["method_count"], - subobject_count=response["subobject_count"], + name=response.name, + version=response.version, + method_count=int(response.method_count), + subobject_count=int(response.subobject_count), address=address, ) @@ -606,7 +947,7 @@ async def get_method(self, address: Address, method_index: int) -> MethodInfo: Method signature """ command = GetMethodCommand(address, method_index) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) return MethodInfo( interface_id=response["interface_id"], @@ -630,34 +971,55 @@ async def get_subobject_address(self, address: Address, subobject_index: int) -> Subobject address """ command = GetSubobjectAddressCommand(address, subobject_index) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetSubobjectAddressCommand returned None") - # Type: ignore needed because response dict is typed as dict[str, Any] - # but we know 'address' key contains Address object - return response["address"] # type: ignore[no-any-return, return-value] + return Address(response.module_id, response.node_id, response.object_id) async def get_interfaces(self, address: Address) -> List[InterfaceInfo]: """Get available interfaces. + The device returns 2 columnar fragments: interface_ids (I8_ARRAY) and + interface_names (STRING_ARRAY). Returns [] if the object does not support + GetInterfaces (interface 0, method 4). + Args: address: Object address Returns: List of interface information """ + supported = await self.get_supported_interface0_method_ids(address) + if GET_INTERFACES not in supported: + logger.debug( + "Object at %s does not support GetInterfaces (interface 0, method 4); returning []", + address, + ) + return [] command = GetInterfacesCommand(address) - response = await self.backend.send_command(command) + response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetInterfacesCommand returned None") + ids = list(response.interface_ids) + names = list(response.interface_names) return [ InterfaceInfo( - interface_id=iface["interface_id"], name=iface["name"], version=iface["version"] + interface_id=int(ids[i]), + name=names[i] if i < len(names) else f"Interface_{ids[i]}", + version="", ) - for iface in response["interfaces"] + for i in range(len(ids)) ] async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo]: """Get enum definitions. + The device returns 4 columnar fragments: enum_names (STRING_ARRAY), + value_counts (U32_ARRAY), values (I32_ARRAY), value_names (STRING_ARRAY). + Values/names are split across enums using the value_counts. + Args: address: Object address interface_id: Interface ID @@ -666,16 +1028,56 @@ async def get_enums(self, address: Address, interface_id: int) -> List[EnumInfo] List of enum definitions """ command = GetEnumsCommand(address, interface_id) - response = await self.backend.send_command(command) - - return [ - EnumInfo(enum_id=enum_def["enum_id"], name=enum_def["name"], values=enum_def["values"]) - for enum_def in response["enums"] - ] + response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetEnumsCommand returned None") + + enum_names = list(response.enum_names) + value_counts = list(response.value_counts) + all_values = list(response.values) + all_value_names = list(response.value_names) + n_enums = len(enum_names) + if n_enums == 0: + return [] + offset = 0 + result: List[EnumInfo] = [] + for i in range(n_enums): + cnt = int(value_counts[i]) if i < len(value_counts) else 0 + names_slice = all_value_names[offset : offset + cnt] + values_slice = all_values[offset : offset + cnt] + vals = dict(zip(names_slice, values_slice)) + result.append(EnumInfo(enum_id=i, name=enum_names[i], values=vals)) + offset += cnt + return result + + async def get_structs_raw(self, address: Address, interface_id: int) -> tuple[bytes, List[dict]]: + """Get raw GetStructs response bytes and a fragment-by-fragment breakdown. + + Use this to see exactly what the device sends so response parsing can + match the wire format. Returns (params_bytes, inspect_hoi_params(params)). + + Example: + raw, fragments = await intro.get_structs_raw(mph_addr, 1) + for i, f in enumerate(fragments): + print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") + """ + command = GetStructsCommand(address, interface_id) + result = await self.backend.send_command(command, ensure_connection=False, return_raw=True) + (params,) = result + return params, inspect_hoi_params(params) async def get_structs(self, address: Address, interface_id: int) -> List[StructInfo]: """Get struct definitions. + The device returns 4 fragments per the StructInfo signature: + [0] struct_names (StrArray): one name per struct + [1] field_counts (U32Array): numberStructureElements — how many fields each struct has + [2] field_type_ids (U8Array): flat field type IDs across all structs + [3] field_names (StrArray): flat field names across all structs + + Struct IDs are positional (0-indexed); the device does not send them explicitly. + field_counts drives the field-to-struct assignment (no even-split heuristic). + Args: address: Object address interface_id: Interface ID @@ -684,26 +1086,58 @@ async def get_structs(self, address: Address, interface_id: int) -> List[StructI List of struct definitions """ command = GetStructsCommand(address, interface_id) - response = await self.backend.send_command(command) - - return [ - StructInfo( - struct_id=struct_def["struct_id"], name=struct_def["name"], fields=struct_def["fields"] - ) - for struct_def in response["structs"] - ] + response = await self.backend.send_command(command, ensure_connection=False) + if response is None: + raise RuntimeError("GetStructsCommand returned None") + + struct_names = list(response.struct_names) + # field_counts = numberStructureElements from the device: logical fields per struct. + # Struct IDs are positional (0-indexed); the device does not send them. + field_counts = [int(c) for c in response.field_counts] + type_bytes = list(response.field_type_ids) # flat byte array; some entries are 3-byte triples + field_names = list(response.field_names) + n_structs = len(field_counts) + if n_structs == 0: + return [] + + # Walk type_bytes with a byte-level cursor (variable width: 1 byte for simple + # types, 3 bytes for 0xE8 complex references). field_counts gives the number + # of *logical* fields per struct, not the number of bytes to consume. + byte_offset = 0 # cursor into type_bytes + name_offset = 0 # cursor into field_names + result: List[StructInfo] = [] + for i, cnt in enumerate(field_counts): + name = struct_names[i] if i < len(struct_names) else f"Struct_{i}" + parsed = _parse_type_seq(type_bytes[byte_offset:], _COMPLEX_STRUCT_TYPE_IDS) + # Consume exactly `cnt` logical entries; advance byte_offset by the bytes used. + type_entries = parsed[:cnt] + bytes_used = sum(pt._byte_width for pt in type_entries) + names_slice = field_names[name_offset : name_offset + cnt] + fields = dict(zip(names_slice, type_entries)) + result.append(StructInfo(struct_id=i, name=name, fields=fields, interface_id=interface_id)) + byte_offset += bytes_used + name_offset += cnt + return result async def get_all_methods(self, address: Address) -> List[MethodInfo]: """Get all methods for an object. + Returns [] if the object does not support GetMethod (interface 0, method 2). + Args: address: Object address Returns: List of all method signatures """ - # First get object info to know how many methods there are object_info = await self.get_object(address) + supported = await self.get_supported_interface0_method_ids(address) + if GET_METHOD not in supported: + logger.debug( + "Object at %s does not support GetMethod (interface 0, method 2); returning []", + address, + ) + return [] methods = [] for i in range(object_info.method_count): @@ -715,115 +1149,653 @@ async def get_all_methods(self, address: Address) -> List[MethodInfo]: return methods - async def discover_hierarchy(self, root_address: Address) -> Dict[str, Any]: - """Recursively discover object hierarchy. + async def build_type_registry( + self, + address: Union[Address, str], + global_pool: Optional[GlobalTypePool] = None, + ) -> TypeRegistry: + """Build a complete TypeRegistry for an object. + + Uses InterfaceDescriptors (get_interfaces) as the canonical source of + interface IDs; then queries structs and enums only for those interfaces. + Only calls Interface 0 methods that the object supports; skips unsupported + commands and builds a partial registry. Args: - root_address: Root object address + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. Returns: - Nested dictionary of discovered objects + TypeRegistry with all type information for this object """ - hierarchy = {} + address = self._resolve_address(address) + registry = TypeRegistry(address=address, global_pool=global_pool) + supported = await self.get_supported_interface0_method_ids(address) + + if GET_INTERFACES in supported: + interfaces = await self.get_interfaces(address) + for iface in interfaces: + registry.interfaces[iface.interface_id] = iface + else: + interfaces = [] - try: - # Get root object info - root_info = await self.get_object(root_address) - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["info"] = root_info # type: ignore[assignment] + if GET_METHOD in supported: + registry.methods = await self.get_all_methods(address) + else: + registry.methods = [] + + for iface in interfaces: + if GET_STRUCTS in supported: + structs = await self.get_structs(address, iface.interface_id) + if structs: + registry.structs[iface.interface_id] = {s.struct_id: s for s in structs} + if GET_ENUMS in supported: + enums = await self.get_enums(address, iface.interface_id) + if enums: + registry.enums[iface.interface_id] = {e.enum_id: e for e in enums} + + return registry + + async def build_type_registry_with_children( + self, + address: Union[Address, str], + subobject_addresses: Optional[List[Address]] = None, + global_pool: Optional[GlobalTypePool] = None, + ) -> TypeRegistry: + """Build a TypeRegistry that includes structs/enums from child objects. + + Complex type references (e.g. type_57 = PickupTipParameters) may be + defined on a child object's interface rather than the parent. This method + builds the parent's registry, then merges in types from each child so + that ParameterType.resolve_name() can find them. - # Discover subobjects - subobjects = {} - for i in range(root_info.subobject_count): - try: - subaddress = await self.get_subobject_address(root_address, i) - subobjects[f"subobject_{i}"] = await self.discover_hierarchy(subaddress) - except Exception as e: - logger.warning(f"Failed to discover subobject {i}: {e}") + Args: + address: Parent object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + subobject_addresses: Optional list of child addresses to include. + If None, all direct subobjects are discovered automatically. + global_pool: Optional GlobalTypePool for resolving source_id=1 refs. - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["subobjects"] = subobjects # type: ignore[assignment] + Returns: + TypeRegistry that can resolve types from both parent and children. + """ + address = self._resolve_address(address) + registry = await self.build_type_registry(address, global_pool=global_pool) - # Discover methods - methods = await self.get_all_methods(root_address) - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["methods"] = methods # type: ignore[assignment] + if subobject_addresses is None: + supported = await self.get_supported_interface0_method_ids(address) + if GET_SUBOBJECT_ADDRESS not in supported: + subobject_addresses = [] + else: + obj_info = await self.get_object(address) + subobject_addresses = [] + for i in range(obj_info.subobject_count): + try: + sub_addr = await self.get_subobject_address(address, i) + subobject_addresses.append(sub_addr) + except Exception: + logger.debug("get_subobject_address(%d) failed for %s", i, address) + + for sub_addr in subobject_addresses: + try: + child_reg = await self.build_type_registry(sub_addr) + for iid, struct_map in child_reg.structs.items(): + registry.structs.setdefault(iid, {}).update(struct_map) + for iid, enum_map in child_reg.enums.items(): + registry.enums.setdefault(iid, {}).update(enum_map) + except Exception as e: + logger.debug("build_type_registry failed for child %s: %s", sub_addr, e) - except Exception as e: - logger.error(f"Failed to discover hierarchy for {root_address}: {e}") - # Type: ignore needed because hierarchy is Dict[str, Any] for flexibility - hierarchy["error"] = str(e) # type: ignore[assignment] + return registry - return hierarchy + async def build_global_type_pool( + self, + global_addresses: List[Address], + ) -> GlobalTypePool: + """Build the global type pool from global objects. - async def discover_all_objects(self, root_addresses: List[Address]) -> Dict[str, Any]: - """Discover all objects starting from root addresses. + This mirrors piglet's approach: walk each global object, iterate its + interfaces, and collect all structs/enums in sequential encounter order. + The resulting flat pool is used for source_id=1 lookups (1-based indexing). Args: - root_addresses: List of root addresses to start discovery from + global_addresses: List of global object addresses + (from HamiltonTCPClient._global_object_addresses). Returns: - Dictionary mapping address strings to discovered hierarchies + GlobalTypePool with all global structs and enums. """ - all_objects = {} + pool = GlobalTypePool() - for root_address in root_addresses: + for addr in global_addresses: try: - hierarchy = await self.discover_hierarchy(root_address) - all_objects[str(root_address)] = hierarchy + supported = await self.get_supported_interface0_method_ids(addr) + if GET_INTERFACES not in supported: + continue + + interfaces = await self.get_interfaces(addr) + for iface in interfaces: + if GET_STRUCTS in supported: + structs = await self.get_structs(addr, iface.interface_id) + pool.structs.extend(structs) + pool.interface_structs[iface.interface_id] = {s.struct_id: s for s in structs} + if GET_ENUMS in supported: + enums = await self.get_enums(addr, iface.interface_id) + pool.enums.extend(enums) except Exception as e: - logger.error(f"Failed to discover objects from {root_address}: {e}") - all_objects[str(root_address)] = {"error": str(e)} + logger.debug("build_global_type_pool failed for %s: %s", addr, e) + + logger.info( + "Global type pool built: %d structs, %d enums from %d global objects", + len(pool.structs), + len(pool.enums), + len(global_addresses), + ) + return pool - return all_objects + async def get_method_by_id( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + registry: Optional[TypeRegistry] = None, + ) -> Optional[MethodInfo]: + """Return the method with the given interface_id and method_id (action id). - def print_method_signatures(self, methods: List[MethodInfo]) -> None: - """Print method signatures in a readable format. + When a TypeRegistry is provided and contains the method, returns it + without any device round-trips. Falls back to a full device scan only + when no registry is available or the method isn't in it. Args: - methods: List of MethodInfo objects to print + address: Object address or dot-path (e.g. "MLPrepRoot.MphRoot.MPH"). + interface_id: Interface ID (e.g. 1 for IChannel/IMph). + method_id: Method/command ID (e.g. 9 for PickupTips). + registry: Optional TypeRegistry with cached methods. + + Returns: + MethodInfo for the matching method, or None if not found. """ - print("Method Signatures:") - print("=" * 50) - for method in methods: - print(f" {method.get_signature_string()}") - print(f" Interface: {method.interface_id}, Method ID: {method.method_id}") - print() + if registry is not None: + cached = registry.get_method(interface_id, method_id) + if cached is not None: + return cached + address = self._resolve_address(address) + methods = await self.get_all_methods(address) + for m in methods: + if m.interface_id == interface_id and m.method_id == method_id: + return m + return None + + async def resolve_signature( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + registry: Optional[TypeRegistry] = None, + ) -> str: + """One-liner: return a fully resolved method signature string. + + Looks up the method and resolves struct/enum references using the + provided TypeRegistry (or falls back to unresolved names). + + Example:: + + sig = await intro.resolve_signature("MLPrepRoot.MphRoot.MPH", 1, 9, mph_registry) + print(sig) + # PickupTips(tipParameters: PickupTipParameters, finalZ: f32, ...) -> ... - def print_struct_definitions(self, structs: List[StructInfo]) -> None: - """Print struct definitions in a readable format. + Returns: + Human-readable signature string, or a descriptive error string. + """ + address = self._resolve_address(address) + method = await self.get_method_by_id(address, interface_id, method_id) + if method is None: + return f"" + return method.get_signature_string(registry) + + async def resolve_error( + self, + address: Union[Address, str], + interface_id: int, + method_id: int, + registry: Optional[TypeRegistry] = None, + error_text: str = "", + hc_result: Optional[int] = None, + ) -> str: + """Build an informative error diagnostic from a COMMAND_EXCEPTION. + + Resolves the object path, method signature, and expected parameters + so the user can see exactly what the firmware expected. When + hc_result is provided, appends a human-readable device error line + via describe_hc_result(). + + Example:: + + info = await intro.resolve_error(addr, 1, 9, mph_registry, hc_result=0x0005) + print(info) + # Error on MLPrepRoot.MphRoot.MPH (57345:1:4352) + # Method [1:9] PickupTips(tipParameters: PickupTipParameters, ...) + # Expected params: tipParameters, finalZ, tipDefinition, ... + # Device error: invalid parameter / not supported (HC_RESULT=0x0005) Args: - structs: List of StructInfo objects to print + address: Object address or dot-path from the error. + interface_id: Interface ID from the error. + method_id: Method/command ID from the error. + registry: Optional TypeRegistry for resolving struct/enum names. + error_text: Raw error string (used when hc_result not provided). + hc_result: HC_RESULT code from the device (e.g. 0x0005); if set, shown via describe_hc_result(). + + Returns: + Multi-line diagnostic string. """ - print("Struct Definitions:") - print("=" * 50) - for struct in structs: - print(struct.get_struct_string()) - print() + address = self._resolve_address(address) + lines: list[str] = [] - def get_methods_by_name(self, methods: List[MethodInfo], name_pattern: str) -> List[MethodInfo]: - """Filter methods by name pattern. + path = self.backend._registry.path(address) if hasattr(self.backend, "_registry") else None + if path: + lines.append(f"Error on {path} ({address})") + else: + lines.append(f"Error on {address}") + + method = await self.get_method_by_id(address, interface_id, method_id) + if method: + sig = method.get_signature_string(registry) + lines.append(f" Method [{interface_id}:{method_id}] {sig}") + if method.parameter_labels: + lines.append(f" Expected params: {', '.join(method.parameter_labels)}") + for pt in method.parameter_types: + if pt.is_complex: + resolved = pt.resolve_name(registry) + lines.append(f" Param type {pt.type_id}: {resolved}") + else: + lines.append(f" Method [{interface_id}:{method_id}] ") - Args: - methods: List of MethodInfo objects to filter - name_pattern: Name pattern to search for (case-insensitive) + if hc_result is not None: + lines.append(f" Device error: {describe_hc_result(hc_result)}") + if error_text: + lines.append(f" Device said: {error_text}") + + return "\n".join(lines) + + @staticmethod + def parse_error_u8_array_message(error_string: str) -> Optional[str]: + """Extract the decoded U8_ARRAY device message from a Hamilton error string. + + The parsed error string may contain ``U8_ARRAY=...`` (or ``U8_ARRAY="..."``) + with the instrument's human-readable message. Returns that value or None. + """ + import re + + # Match U8_ARRAY= then either quoted content or rest until "; " or " [" or end + m = re.search(r"U8_ARRAY=(?:\"([^\"]*)\"|([^;[\]]*?)(?:\s*;\s*|\s*\[|$))", error_string) + if not m: + return None + return (m.group(1) or m.group(2) or "").strip() or None + + @staticmethod + def parse_error_address( + error_string: str, + ) -> Optional[tuple[Address, int, int, int]]: + """Extract (address, interface_id, method_id, hc_result) from a COMMAND_EXCEPTION string. + + The Hamilton error string format includes the address as + ``0xMMMM.0xNNNN.0xOOOO:0xII,0xCCCC,0xRRRR`` where the first part is + the 3-part object address and II/CCCC/RRRR encode interface, command, and HC_RESULT. + + Example:: + + result = HamiltonIntrospection.parse_error_address( + "0x0001.0x0001.0x1100:0x01,0x0009,0x020A" + ) + if result: + addr, iface_id, method_id, hc_result = result Returns: - List of methods matching the name pattern + (Address, interface_id, method_id, hc_result) or None if parsing fails. """ - return [method for method in methods if name_pattern.lower() in method.name.lower()] + import re - def get_methods_by_interface( - self, methods: List[MethodInfo], interface_id: int - ) -> List[MethodInfo]: - """Filter methods by interface ID. + m = re.search( + r"0x([0-9a-fA-F]+)\.0x([0-9a-fA-F]+)\.0x([0-9a-fA-F]+)" + r":0x([0-9a-fA-F]+),0x([0-9a-fA-F]+)(?:,0x([0-9a-fA-F]+))?", + error_string, + ) + if not m: + return None + module_id = int(m.group(1), 16) + node_id = int(m.group(2), 16) + object_id = int(m.group(3), 16) + interface_id = int(m.group(4), 16) + method_id = int(m.group(5), 16) + hc_result = int(m.group(6), 16) if m.group(6) else 0 + return Address(module_id, node_id, object_id), interface_id, method_id, hc_result + + async def diagnose_error( + self, + error_string: str, + registry: Optional[TypeRegistry] = None, + ) -> str: + """One-liner: parse a COMMAND_EXCEPTION string and return a full diagnostic. + + Combines parse_error_address() + resolve_error() into a single call. + Pass the raw error text (e.g. from a RuntimeError message) and get + back a human-readable diagnostic. + + Example:: - Args: - methods: List of MethodInfo objects to filter - interface_id: Interface ID to filter by + try: + await backend.send_command(cmd) + except RuntimeError as e: + print(await intro.diagnose_error(str(e), mph_registry)) Returns: - List of methods from the specified interface + Multi-line diagnostic string, or the original error if parsing fails. """ - return [method for method in methods if method.interface_id == interface_id] + parsed = self.parse_error_address(error_string) + if parsed is None: + return f"Could not parse error address from: {error_string}" + address, interface_id, method_id, hc_result = parsed + error_text = self.parse_error_u8_array_message(error_string) or "" + return await self.resolve_error( + address, + interface_id, + method_id, + registry=registry, + hc_result=hc_result, + error_text=error_text, + ) + + +# ============================================================================ +# STRUCT / COMMAND VALIDATION +# ============================================================================ + + +def _normalize_name(name: str) -> str: + """Normalize a name for comparison (remove underscores, make lowercase). + + Allows Pythonic `snake_case` (e.g. `z_liquid_exit_speed`) to match + Hamilton's arbitrary PascalCase (`ZLiquidExitSpeed` or `Zliquidexitspeed`). + """ + return name.replace("_", "").lower() + + +def _get_wire_type_id(annotation) -> Optional[int]: + """Extract HamiltonDataType type_id from an Annotated type alias. + + Works for all our wire types: F32, PaddedBool, U32, WEnum, Str, + Annotated[X, Struct()], Annotated[list[X], StructArray()], etc. + + Returns None if the annotation doesn't carry a WireType. + """ + # Handle typing.Annotated + metadata = getattr(annotation, "__metadata__", None) + if metadata: + for m in metadata: + if hasattr(m, "type_id"): + return cast(int, m.type_id) + return None + + +def _get_nested_dataclass(annotation): + """For Annotated[SomeDataclass, Struct()], return SomeDataclass. Else None.""" + args = getattr(annotation, "__args__", None) + if not args: + return None + base_type = args[0] + # For Annotated[list[X], StructArray()], dig into the list's inner type + inner_args = getattr(base_type, "__args__", None) + if inner_args: + base_type = inner_args[0] + import dataclasses + + if dataclasses.is_dataclass(base_type): + return base_type + return None + + +@dataclass +class FieldMismatch: + """One field-level mismatch between hand-crafted and introspected definitions.""" + + field_name: str + issue: str # e.g. "missing", "extra", "type mismatch", "order mismatch" + expected: str = "" + actual: str = "" + + def __str__(self): + s = f" {self.field_name}: {self.issue}" + if self.expected or self.actual: + s += f" (expected={self.expected}, actual={self.actual})" + return s + + +@dataclass +class ValidationResult: + """Result of comparing a hand-crafted dataclass against introspection.""" + + name: str + passed: bool = False + mismatches: List[FieldMismatch] = field(default_factory=list) + children: List["ValidationResult"] = field(default_factory=list) + + def __str__(self): + icon = "✅" if self.passed else "❌" + lines = [f"{icon} {self.name}"] + for m in self.mismatches: + lines.append(str(m)) + for child in self.children: + for line in str(child).split("\n"): + lines.append(f" {line}") + return "\n".join(lines) + + +def validate_struct( + dataclass_cls, + introspected: StructInfo, + pool: Optional[GlobalTypePool] = None, +) -> ValidationResult: + """Compare a hand-crafted dataclass against an introspected StructInfo. + + Checks field count, field names (snake_case → PascalCase), field types + (extracts type_id from Annotated metadata), and field order. For nested + structs (Annotated[X, Struct()]), recursively validates the child struct + if a GlobalTypePool is provided. + + Args: + dataclass_cls: The hand-crafted dataclass class (not an instance). + introspected: The introspected StructInfo from the device. + pool: Optional GlobalTypePool for resolving nested struct refs. + + Returns: + ValidationResult with pass/fail and detailed mismatches. + """ + import dataclasses as dc + import typing + + result = ValidationResult(name=dataclass_cls.__name__) + mismatches = result.mismatches + + # Get hand-crafted fields + hints = typing.get_type_hints(dataclass_cls, include_extras=True) + hand_fields = list(dc.fields(dataclass_cls)) + hand_names = [f.name for f in hand_fields] + hand_norm = [_normalize_name(n) for n in hand_names] + + # Get introspected fields + intro_names = list(introspected.fields.keys()) + intro_norm = [_normalize_name(n) for n in intro_names] + intro_types = list(introspected.fields.values()) + + # 1. Field count + if len(hand_names) != len(intro_names): + mismatches.append( + FieldMismatch( + field_name="(count)", + issue="field count mismatch", + expected=str(len(intro_names)), + actual=str(len(hand_names)), + ) + ) + + # 2. Field names (order-aware) + for i, (hn_norm, in_norm) in enumerate(zip(hand_norm, intro_norm)): + if hn_norm != in_norm: + mismatches.append( + FieldMismatch( + field_name=hand_names[i], + issue=f"name mismatch at position {i}", + expected=intro_names[i], + actual=hand_names[i], + ) + ) + + # 3. Extra / missing fields + hand_set = set(hand_norm) + intro_set = set(intro_norm) + + # For error reporting, we want the original casing, so we build reverse maps + hand_map = {hn_norm: h for hn_norm, h in zip(hand_norm, hand_names)} + intro_map = {in_norm: i for in_norm, i in zip(intro_norm, intro_names)} + + for missing_norm in intro_set - hand_set: + original_intro = intro_map[missing_norm] + mismatches.append(FieldMismatch(field_name=original_intro, issue="missing in hand-crafted")) + for extra_norm in hand_set - intro_set: + original_hand = hand_map[extra_norm] + mismatches.append( + FieldMismatch(field_name=original_hand, issue="extra in hand-crafted (not in introspection)") + ) + + # 4. Field types (where names match) + for i, (hand_name, intro_name) in enumerate(zip(hand_names, intro_names)): + if _normalize_name(hand_name) != _normalize_name(intro_name): + continue # Already reported as name mismatch + annotation = hints.get(hand_name) + if annotation is None: + continue + hand_type_id = _get_wire_type_id(annotation) + intro_pt = intro_types[i] + if hand_type_id is not None and hand_type_id != intro_pt.type_id: + try: + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import HamiltonDataType + + expected_name = HamiltonDataType(intro_pt.type_id).name + actual_name = HamiltonDataType(hand_type_id).name + except ValueError: + expected_name = str(intro_pt.type_id) + actual_name = str(hand_type_id) + mismatches.append( + FieldMismatch( + field_name=hand_name, + issue="type mismatch", + expected=expected_name, + actual=actual_name, + ) + ) + + # 5. Recursive validation for nested structs + if ( + pool is not None + and intro_pt.is_complex + and intro_pt.source_id is not None + and intro_pt.ref_id is not None + and intro_pt.type_id == 30 + ): # STRUCTURE + nested_cls = _get_nested_dataclass(annotation) + if nested_cls: + if intro_pt.source_id == 1: + # Global pool ref (1-based index) + nested_struct = pool.resolve_struct(intro_pt.ref_id) + elif intro_pt.source_id == 0 and introspected.interface_id is not None: + # Same-interface ref: look up within that interface's struct group + nested_struct = pool.resolve_struct_local(introspected.interface_id, intro_pt.ref_id) + else: + nested_struct = None + if nested_struct: + child_result = validate_struct(nested_cls, nested_struct, pool) + result.children.append(child_result) + + result.passed = len(mismatches) == 0 and all(c.passed for c in result.children) + return result + + +def validate_command( + command_cls, + registry: TypeRegistry, + pool: GlobalTypePool, + interface_id: int = 1, +) -> ValidationResult: + """Compare a PrepCommand against its introspected method signature. + + Matches the command's command_id to the introspected method_id on the given + interface. Validates that the command's struct parameters match the method's + expected struct types. + + Args: + command_cls: The PrepCommand subclass. + registry: TypeRegistry with the object's methods. + pool: GlobalTypePool for resolving struct refs. + interface_id: Interface ID to look up the method on (default 1 = Pipettor). + + Returns: + ValidationResult with pass/fail and details. + """ + import dataclasses as dc + import typing + + cmd_id = getattr(command_cls, "command_id", None) + result = ValidationResult(name=f"{command_cls.__name__} (cmd={cmd_id})") + + if cmd_id is None: + result.mismatches.append(FieldMismatch(field_name="(class)", issue="no command_id attribute")) + result.passed = False + return result + + # Find matching introspected method + method = registry.get_method(interface_id, cmd_id) + if method is None: + result.mismatches.append( + FieldMismatch( + field_name="(method)", issue=f"no introspected method for [{interface_id}:{cmd_id}]" + ) + ) + result.passed = False + return result + + result.name = f"{command_cls.__name__} ↔ {method.name} [{interface_id}:{cmd_id}]" + + # Get command's payload fields (exclude 'dest' and class-level attrs) + hints = typing.get_type_hints(command_cls, include_extras=True) + payload_fields = [f for f in dc.fields(command_cls) if f.name != "dest"] + + # Match struct payload fields to introspected parameter types positionally + struct_fields = [ + (pf, hints.get(pf.name)) + for pf in payload_fields + if _get_nested_dataclass(hints.get(pf.name)) is not None + ] + struct_params = [ + pt + for pt in method.parameter_types + if pt.is_complex and pt.source_id is not None and pt.ref_id is not None + ] + + for (pf, annotation), pt in zip(struct_fields, struct_params): + ref_id = pt.ref_id + assert ref_id is not None, "struct_params filtered for ref_id is not None" + if pt.source_id == 1: + intro_struct = pool.resolve_struct(ref_id) + elif pt.source_id == 0: + # Same-interface ref: would need interface_id context; skip for now + intro_struct = None + else: + intro_struct = pool.resolve_struct(ref_id) + nested_cls = _get_nested_dataclass(annotation) + if intro_struct and nested_cls: + child_result = validate_struct(nested_cls, intro_struct, pool) + child_result.name = f"{pf.name} → {intro_struct.name} (ref={pt.ref_id})" + result.children.append(child_result) + + result.passed = all(c.passed for c in result.children) and len(result.mismatches) == 0 + return result diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py index df32f5289ab..7e99861a515 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py @@ -1,41 +1,22 @@ -"""High-level Hamilton message builders and response parsers. - -This module provides user-facing message builders and their corresponding -response parsers. Each message type is paired with its response type: - -Request Builders: -- InitMessage: Builds IP[Connection] for initialization -- RegistrationMessage: Builds IP[HARP[Registration]] for discovery -- CommandMessage: Builds IP[HARP[HOI]] for method calls - -Response Parsers: -- InitResponse: Parses initialization responses -- RegistrationResponse: Parses registration responses -- CommandResponse: Parses command responses - -This pairing creates symmetry and makes correlation explicit. - -Architectural Note: -Parameter encoding (HoiParams/HoiParamsParser) is conceptually a separate layer -in the Hamilton protocol architecture (per documented architecture), but is -implemented here for efficiency since it's exclusively used by HOI messages. -This preserves the conceptual separation while optimizing implementation. - -Example: - # Build and send - msg = CommandMessage(dest, interface_id=0, method_id=42) - msg.add_i32(100) - packet_bytes = msg.build(src, seq=1) - - # Parse response - response = CommandResponse.from_bytes(received_bytes) - params = response.hoi.params +"""Framing and protocol message layer for Hamilton TCP. + +HoiParams is a fragment accumulator with add(value, wire_type) and +from_struct(obj); it has no type-specific encoding logic and delegates all +encoding to WireType.encode_into in wire_types. HoiParamsParser is a thin +cursor over sequential DataFragments; it reads [type_id:1][flags:1][length:2] +[data:N] headers and delegates value decoding to wire_types.decode_fragment(). +parse_into_struct() is the dataclass codec that uses WireType annotations to +decode fragment sequences into typed instances. + +Also: message builders (CommandMessage, InitMessage, RegistrationMessage) and +response parsers (CommandResponse, InitResponse, RegistrationResponse). """ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from dataclasses import fields as dc_fields +from typing import Any, List, cast, get_args, get_origin, get_type_hints from pylabrobot.io.binary import Reader, Writer from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( @@ -46,10 +27,15 @@ RegistrationPacket, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( - HamiltonDataType, HarpTransportableProtocol, RegistrationOptionType, ) +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + HamiltonDataType, + decode_fragment, +) + +PADDED_FLAG = 0x01 # ============================================================================ # HOI PARAMETER ENCODING - DataFragment wrapping for HOI protocol @@ -75,9 +61,9 @@ class HoiParams: [0x03|0x00|0x04|0x00|100][0x0F|0x00|0x05|0x00|"test\0"][0x1C|0x00|...array...] params = (HoiParams() - .i32(100) - .string("test") - .u32_array([1, 2, 3]) + .add(100, I32) + .add("test", Str) + .add([1, 2, 3], U32Array) .build()) """ @@ -89,200 +75,57 @@ def _add_fragment(self, type_id: int, data: bytes, flags: int = 0) -> "HoiParams Creates: [type_id:1][flags:1][length:2][data:n] + When flags & PADDED_FLAG, appends a trailing pad byte (Prep convention). + Callers pass unpadded data; _add_fragment centralizes pad handling. + Args: type_id: Data type ID - data: Fragment data bytes - flags: Fragment flags (default: 0, but BOOL_ARRAY uses 0x01) + data: Fragment data bytes (unpadded; pad added here when flags set) + flags: Fragment flags (default: 0; PADDED_FLAG for BoolArray, PaddedBool, PaddedU8) """ + if flags & PADDED_FLAG: + data = data + b"\x00" fragment = Writer().u8(type_id).u8(flags).u16(len(data)).raw_bytes(data).finish() self._fragments.append(fragment) return self - # Scalar integer types - def i8(self, value: int) -> "HoiParams": - """Add signed 8-bit integer parameter.""" - data = Writer().i8(value).finish() - return self._add_fragment(HamiltonDataType.I8, data) - - def i16(self, value: int) -> "HoiParams": - """Add signed 16-bit integer parameter.""" - data = Writer().i16(value).finish() - return self._add_fragment(HamiltonDataType.I16, data) - - def i32(self, value: int) -> "HoiParams": - """Add signed 32-bit integer parameter.""" - data = Writer().i32(value).finish() - return self._add_fragment(HamiltonDataType.I32, data) - - def i64(self, value: int) -> "HoiParams": - """Add signed 64-bit integer parameter.""" - data = Writer().i64(value).finish() - return self._add_fragment(HamiltonDataType.I64, data) - - def u8(self, value: int) -> "HoiParams": - """Add unsigned 8-bit integer parameter.""" - data = Writer().u8(value).finish() - return self._add_fragment(HamiltonDataType.U8, data) - - def u16(self, value: int) -> "HoiParams": - """Add unsigned 16-bit integer parameter.""" - data = Writer().u16(value).finish() - return self._add_fragment(HamiltonDataType.U16, data) - - def u32(self, value: int) -> "HoiParams": - """Add unsigned 32-bit integer parameter.""" - data = Writer().u32(value).finish() - return self._add_fragment(HamiltonDataType.U32, data) - - def u64(self, value: int) -> "HoiParams": - """Add unsigned 64-bit integer parameter.""" - data = Writer().u64(value).finish() - return self._add_fragment(HamiltonDataType.U64, data) - - # Floating-point types - def f32(self, value: float) -> "HoiParams": - """Add 32-bit float parameter.""" - data = Writer().f32(value).finish() - return self._add_fragment(HamiltonDataType.F32, data) - - def f64(self, value: float) -> "HoiParams": - """Add 64-bit double parameter.""" - data = Writer().f64(value).finish() - return self._add_fragment(HamiltonDataType.F64, data) - - # String and bool - def string(self, value: str) -> "HoiParams": - """Add null-terminated string parameter.""" - data = Writer().string(value).finish() - return self._add_fragment(HamiltonDataType.STRING, data) - - def bool_value(self, value: bool) -> "HoiParams": - """Add boolean parameter.""" - data = Writer().u8(1 if value else 0).finish() - return self._add_fragment(HamiltonDataType.BOOL, data) - - # Array types - def i8_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 8-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i8(val) - return self._add_fragment(HamiltonDataType.I8_ARRAY, writer.finish()) - - def i16_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 16-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i16(val) - return self._add_fragment(HamiltonDataType.I16_ARRAY, writer.finish()) - - def i32_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 32-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i32(val) - return self._add_fragment(HamiltonDataType.I32_ARRAY, writer.finish()) - - def i64_array(self, values: list[int]) -> "HoiParams": - """Add array of signed 64-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.i64(val) - return self._add_fragment(HamiltonDataType.I64_ARRAY, writer.finish()) - - def u8_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 8-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u8(val) - return self._add_fragment(HamiltonDataType.U8_ARRAY, writer.finish()) - - def u16_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 16-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u16(val) - return self._add_fragment(HamiltonDataType.U16_ARRAY, writer.finish()) - - def u32_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 32-bit integers. + def add(self, value: Any, wire_type: Any) -> "HoiParams": + """Encode a value using its WireType and append the DataFragment. - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) + wire_type may be a WireType instance or an Annotated alias (e.g. I32, Str). """ - writer = Writer() - for val in values: - writer.u32(val) - return self._add_fragment(HamiltonDataType.U32_ARRAY, writer.finish()) - - def u64_array(self, values: list[int]) -> "HoiParams": - """Add array of unsigned 64-bit integers. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.u64(val) - return self._add_fragment(HamiltonDataType.U64_ARRAY, writer.finish()) - - def f32_array(self, values: list[float]) -> "HoiParams": - """Add array of 32-bit floats. + if hasattr(wire_type, "__metadata__"): + wire_type = wire_type.__metadata__[0] + return cast("HoiParams", wire_type.encode_into(value, self)) - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.f32(val) - return self._add_fragment(HamiltonDataType.F32_ARRAY, writer.finish()) - - def f64_array(self, values: list[float]) -> "HoiParams": - """Add array of 64-bit doubles. - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - """ - writer = Writer() - for val in values: - writer.f64(val) - return self._add_fragment(HamiltonDataType.F64_ARRAY, writer.finish()) - - def bool_array(self, values: list[bool]) -> "HoiParams": - """Add array of booleans (stored as u8: 0 or 1). - - Format: [element0][element1]... (NO count prefix - count derived from DataFragment length) - - Note: BOOL_ARRAY uses flags=0x01 in the DataFragment header (unlike other types which use 0x00). - """ - writer = Writer() - for val in values: - writer.u8(1 if val else 0) - return self._add_fragment(HamiltonDataType.BOOL_ARRAY, writer.finish(), flags=0x01) + # ------------------------------------------------------------------ + # Generic dataclass serialiser (wire_types.py Annotated metadata) + # ------------------------------------------------------------------ - def string_array(self, values: list[str]) -> "HoiParams": - """Add array of null-terminated strings. + @classmethod + def from_struct(cls, obj) -> "HoiParams": + """Serialize any dataclass whose fields use ``Annotated`` wire-type metadata. - Format: [count:4][str0\0][str1\0]... + Fields without ``Annotated`` metadata (e.g. plain ``Address``) are skipped. + The polymorphic ``WireType.encode_into`` on each annotation handles all + dispatch -- no if/elif required here. """ - writer = Writer().u32(len(values)) - for val in values: - writer.string(val) - return self._add_fragment(HamiltonDataType.STRING_ARRAY, writer.finish()) + from dataclasses import fields as dc_fields + from typing import get_type_hints + + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import WireType + + hints = get_type_hints(type(obj), include_extras=True) + params = cls() + for f in dc_fields(obj): + ann = hints.get(f.name) + if ann is None or not hasattr(ann, "__metadata__"): + continue + meta = ann.__metadata__[0] + if not isinstance(meta, WireType): + continue + params = meta.encode_into(getattr(obj, f.name), params) + return cast("HoiParams", params) def build(self) -> bytes: """Return concatenated DataFragments.""" @@ -294,168 +137,263 @@ def count(self) -> int: class HoiParamsParser: - """Parser for HOI DataFragment parameters. + """Cursor over sequential DataFragments in an HOI payload. - Parses DataFragment-wrapped values from HOI response payloads. + Reads [type_id:1][flags:1][length:2][data:N] headers and delegates + value decoding to the unified codec in wire_types.decode_fragment(). """ def __init__(self, data: bytes): + if not isinstance(data, bytes): + raise TypeError( + f"HoiParamsParser requires bytes, got {type(data).__name__}. " + "Use get_structs_raw() and inspect_hoi_params() to see the wire format." + ) self._data = data self._offset = 0 def parse_next(self) -> tuple[int, Any]: - """Parse the next DataFragment and return (type_id, value). - - Returns: - Tuple of (type_id, parsed_value) - - Raises: - ValueError: If data is malformed or insufficient + if self._offset + 4 > len(self._data): + raise ValueError(f"Insufficient data at offset {self._offset}") + type_id = self._data[self._offset] + flags = self._data[self._offset + 1] + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): + raise ValueError( + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" + ) + data = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + if (flags & PADDED_FLAG) and len(data) > 0: + data = data[:-1] + return type_id, decode_fragment(type_id, data) + + def parse_next_raw(self) -> tuple[int, int, int, bytes]: + """Return (type_id, flags, length, payload_bytes) without decoding. + + Use when the wire declares STRING (type_id=15) but the payload is binary + (e.g. GetMethod parameter_types). Normal parse_next() would UTF-8 decode + and fail on bytes like 0xaa. """ if self._offset + 4 > len(self._data): - raise ValueError(f"Insufficient data for DataFragment header at offset {self._offset}") - - # Parse DataFragment header - reader = Reader(self._data[self._offset :]) - type_id = reader.u8() - _flags = reader.u8() # Read but unused - length = reader.u16() - - data_start = self._offset + 4 - data_end = data_start + length - - if data_end > len(self._data): + raise ValueError(f"Insufficient data at offset {self._offset}") + type_id = self._data[self._offset] + flags = self._data[self._offset + 1] + length = int.from_bytes(self._data[self._offset + 2 : self._offset + 4], "little") + payload_end = self._offset + 4 + length + if payload_end > len(self._data): raise ValueError( - f"DataFragment data extends beyond buffer: need {data_end}, have {len(self._data)}" + f"DataFragment data extends beyond buffer: need {payload_end}, have {len(self._data)}" ) - - # Extract data payload - fragment_data = self._data[data_start:data_end] - value = self._parse_value(type_id, fragment_data) - - # Move offset past this fragment - self._offset = data_end - - return (type_id, value) - - def _parse_value(self, type_id: int, data: bytes) -> Any: - """Parse value based on type_id using dispatch table.""" - reader = Reader(data) - - # Dispatch table for scalar types - scalar_parsers = { - HamiltonDataType.I8: reader.i8, - HamiltonDataType.I16: reader.i16, - HamiltonDataType.I32: reader.i32, - HamiltonDataType.I64: reader.i64, - HamiltonDataType.U8: reader.u8, - HamiltonDataType.U16: reader.u16, - HamiltonDataType.U32: reader.u32, - HamiltonDataType.U64: reader.u64, - HamiltonDataType.F32: reader.f32, - HamiltonDataType.F64: reader.f64, - HamiltonDataType.STRING: reader.string, - } - - # Check scalar types first - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in scalar_parsers: - return scalar_parsers[data_type]() - except ValueError: - pass # Not a valid enum value, continue to other checks - - # Special case: bool - if type_id == HamiltonDataType.BOOL: - return reader.u8() == 1 - - # Dispatch table for array element parsers - array_element_parsers = { - HamiltonDataType.I8_ARRAY: reader.i8, - HamiltonDataType.I16_ARRAY: reader.i16, - HamiltonDataType.I32_ARRAY: reader.i32, - HamiltonDataType.I64_ARRAY: reader.i64, - HamiltonDataType.U8_ARRAY: reader.u8, - HamiltonDataType.U16_ARRAY: reader.u16, - HamiltonDataType.U32_ARRAY: reader.u32, - HamiltonDataType.U64_ARRAY: reader.u64, - HamiltonDataType.F32_ARRAY: reader.f32, - HamiltonDataType.F64_ARRAY: reader.f64, - HamiltonDataType.STRING_ARRAY: reader.string, - } - - # Handle arrays - # Arrays don't have a count prefix - count is derived from DataFragment length - # Calculate element size based on type - element_sizes = { - HamiltonDataType.I8_ARRAY: 1, - HamiltonDataType.I16_ARRAY: 2, - HamiltonDataType.I32_ARRAY: 4, - HamiltonDataType.I64_ARRAY: 8, - HamiltonDataType.U8_ARRAY: 1, - HamiltonDataType.U16_ARRAY: 2, - HamiltonDataType.U32_ARRAY: 4, - HamiltonDataType.U64_ARRAY: 8, - HamiltonDataType.F32_ARRAY: 4, - HamiltonDataType.F64_ARRAY: 8, - HamiltonDataType.STRING_ARRAY: None, # Variable length, handled separately - } - - # Cast int to HamiltonDataType enum for dict lookup - try: - data_type = HamiltonDataType(type_id) - if data_type in array_element_parsers: - element_size = element_sizes.get(data_type) - if element_size is not None: - # Fixed-size elements: calculate count from data length - count = len(data) // element_size - return [array_element_parsers[data_type]() for _ in range(count)] - elif data_type == HamiltonDataType.STRING_ARRAY: - # String arrays: null-terminated strings concatenated, no count prefix - # Parse by splitting on null bytes - strings = [] - current_string = bytearray() - for byte in data: - if byte == 0: - if current_string: - strings.append(current_string.decode("utf-8", errors="replace")) - current_string = bytearray() - else: - current_string.append(byte) - # Handle case where last string doesn't end with null (shouldn't happen, but be safe) - if current_string: - strings.append(current_string.decode("utf-8", errors="replace")) - return strings - except ValueError: - # Not a valid enum value, continue to other checks - # This shouldn't happen for valid Hamilton types, but we continue anyway - pass - - # Special case: bool array (1 byte per element) - if type_id == HamiltonDataType.BOOL_ARRAY: - count = len(data) // 1 # Each bool is 1 byte - return [reader.u8() == 1 for _ in range(count)] - - # Unknown type - raise ValueError(f"Unknown or unsupported type_id: {type_id}") + payload = self._data[self._offset + 4 : payload_end] + self._offset = payload_end + return type_id, flags, length, payload def has_remaining(self) -> bool: - """Check if there are more DataFragments to parse.""" return self._offset < len(self._data) def parse_all(self) -> list[tuple[int, Any]]: - """Parse all remaining DataFragments. - - Returns: - List of (type_id, value) tuples - """ results = [] while self.has_remaining(): results.append(self.parse_next()) return results +def inspect_hoi_params(params: bytes) -> List[dict]: + """Inspect raw HOI params bytes fragment-by-fragment for debugging. + + Walks the DataFragment stream [type_id:1][flags:1][length:2][data:N] and + returns a list of dicts with: type_id, flags, length, payload_hex (first 80 + chars), payload_len, decoded (decode_fragment result or exception message). + Use this to see exactly what the device sends and fix response parsing. + + Example: + raw, fragments = await intro.get_structs_raw(mph_addr, 1) + for i, f in enumerate(fragments): + print(f\"{i}: type_id={f['type_id']} len={f['length']} decoded={f['decoded']!r}\") + """ + if not params: + return [] + out: List[dict] = [] + offset = 0 + while offset + 4 <= len(params): + type_id = params[offset] + flags = params[offset + 1] + length = int.from_bytes(params[offset + 2 : offset + 4], "little") + payload_end = offset + 4 + length + if payload_end > len(params): + out.append( + { + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": "", + "payload_len": 0, + "decoded": f"", + } + ) + break + data = params[offset + 4 : payload_end] + hex_preview = data.hex() if len(data) <= 40 else data[:40].hex() + "..." + try: + decoded = decode_fragment(type_id, data) + if isinstance(decoded, bytes): + decoded = ( + decoded.decode("utf-8", errors="replace").rstrip("\x00") or f"" + ) + decoded_repr = ( + repr(decoded) if not isinstance(decoded, (str, int, float, bool)) else str(decoded) + ) + if isinstance(decoded, list): + decoded_repr = ( + f"list[len={len(decoded)}](elem0_type={type(decoded[0]).__name__ if decoded else 'n/a'})" + ) + except Exception as e: + decoded_repr = f"" + out.append( + { + "type_id": type_id, + "flags": flags, + "length": length, + "payload_hex": hex_preview, + "payload_len": len(data), + "decoded": decoded_repr, + } + ) + offset = payload_end + return out + + +def parse_hamilton_error_params(params: bytes) -> str: + """Extract a human-readable message from HOI exception params. + + Hamilton COMMAND_EXCEPTION / STATUS_EXCEPTION responses send params as a + sequence of DataFragments. Often the first or second fragment is a STRING + (type_id=15) with a message like "0xE001.0x0001.0x1100:0x01,0x009,0x020A". + This walks the fragment stream, decodes all fragments, and returns a + single string (so you can see error codes and the message). If parsing + fails, returns a safe fallback (hex or generic message). + """ + parts = _parse_hamilton_error_fragments(params) + if not parts: + return params.hex() if params else "(empty)" + return "; ".join(parts) + + +def _parse_hamilton_error_fragments(params: bytes) -> List[str]: + """Decode all DataFragments in exception params. Returns list of "type: value" strings.""" + if not params: + return [] + out: List[str] = [] + offset = 0 + while offset + 4 <= len(params): + type_id = params[offset] + length = int.from_bytes(params[offset + 2 : offset + 4], "little") + payload_end = offset + 4 + length + if payload_end > len(params): + break + data = params[offset + 4 : payload_end] + try: + decoded = decode_fragment(type_id, data) + try: + type_name = HamiltonDataType(type_id).name + except ValueError: + type_name = f"type_{type_id}" + if isinstance(decoded, bytes): + decoded = decoded.decode("utf-8", errors="replace").rstrip("\x00").strip() + elif ( + type_id == HamiltonDataType.U8_ARRAY + and isinstance(decoded, list) + and all(isinstance(x, int) and 0 <= x <= 255 for x in decoded) + ): + b = bytes(decoded) + s = b.decode("utf-8", errors="replace").rstrip("\x00").strip() + # Strip leading control characters (e.g. length or flags before message text) + s = s.lstrip( + "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0b\x0c\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f" + ).strip() + if s and any(c.isprintable() or c.isspace() for c in s): + decoded = s + out.append(f"{type_name}={decoded}") + except Exception: + out.append(f"type_{type_id}=<{length} bytes>") + offset = payload_end + return out + + +def parse_into_struct(parser: HoiParamsParser, cls: type) -> Any: + """Decode a sequence of DataFragments into a dataclass instance using its wire-type annotations. + + Mirrors HoiParams.from_struct: walks the same Annotated field metadata and, for each field in + order, consumes one fragment (via parser.parse_next()). Scalars/arrays/string yield the value + as returned by the parser; Struct recurses on the payload bytes; StructArray yields a list of + recursively decoded instances. + + Args: + parser: Parser positioned at the start of the fragment sequence (e.g. response payload). + cls: Dataclass type whose fields are annotated with wire_types (F32, Struct(), etc.). + + Returns: + An instance of cls with fields populated from the parsed fragments. + + Raises: + ValueError: If data is malformed or insufficient. + """ + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + CountedFlatArray, + Struct, + StructArray, + WireType, + ) + + hints = get_type_hints(cls, include_extras=True) + values: dict[str, Any] = {} + for f in dc_fields(cls): + ann = hints.get(f.name) + if ann is None or not hasattr(ann, "__metadata__"): + continue + meta = ann.__metadata__[0] + if not isinstance(meta, WireType): + continue + + if isinstance(meta, CountedFlatArray): + _, raw = parser.parse_next() + element_type = get_args(get_args(ann)[0])[0] + if isinstance(raw, list): + # Single fragment was STRUCTURE_ARRAY: list of payload bytes per element + if raw and not isinstance(raw[0], bytes): + raise ValueError( + f"CountedFlatArray decoded to list of {type(raw[0]).__name__}, expected " + "list of bytes (STRUCTURE_ARRAY). Use get_structs_raw() and " + "inspect_hoi_params() to see the exact wire format." + ) + values[f.name] = [parse_into_struct(HoiParamsParser(p), element_type) for p in raw] + else: + # Count then N flat fragments (count-prefixed stream) + count = int(raw) + values[f.name] = [parse_into_struct(parser, element_type) for _ in range(count)] + continue + + type_id, value = parser.parse_next() + + if isinstance(meta, Struct): + inner_type = get_args(ann)[0] + value = parse_into_struct(HoiParamsParser(value), inner_type) + elif isinstance(meta, StructArray): + inner_ann = get_args(ann)[0] + if get_origin(inner_ann) is list: + element_type = get_args(inner_ann)[0] + else: + element_type = inner_ann + value = [parse_into_struct(HoiParamsParser(p), element_type) for p in value] + # else: decode_fragment() already returned correctly-typed value + + values[f.name] = value + + return cls(**values) + + # ============================================================================ # MESSAGE BUILDERS # ============================================================================ diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py index 9e916e91db3..60b89b61dee 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py @@ -1,7 +1,8 @@ -"""Hamilton TCP protocol constants and enumerations. +"""Transport-level protocol constants only. -This module contains all protocol-level constants, enumerations, and type definitions -used throughout the Hamilton TCP communication stack. +HamiltonProtocol, Hoi2Action, HarpTransportableProtocol, RegistrationActionCode, +RegistrationOptionType, HoiRequestId. DataFragment type IDs (I8, I32, STRUCTURE, +etc.) are defined in wire_types.HamiltonDataType. """ from __future__ import annotations @@ -124,49 +125,6 @@ class RegistrationOptionType(IntEnum): HARP_PROTOCOL_RESPONSE = 6 # PRIMARY: Contains object ID lists (most commonly used) -class HamiltonDataType(IntEnum): - """Hamilton parameter data types for wire encoding in DataFragments. - - These constants represent the type identifiers used in Hamilton DataFragments - for HOI2 command parameters. Each type ID corresponds to a specific data format - and encoding scheme used on the wire. - - From Hamilton.Components.TransportLayer.Protocols.Parameter.ParameterTypes. - """ - - # Scalar integer types - I8 = 1 - I16 = 2 - I32 = 3 - U8 = 4 - U16 = 5 - U32 = 6 - I64 = 36 - U64 = 37 - - # Floating-point types - F32 = 40 - F64 = 41 - - # String and boolean - STRING = 15 - BOOL = 23 - - # Array types - U8_ARRAY = 22 - I8_ARRAY = 24 - I16_ARRAY = 25 - U16_ARRAY = 26 - I32_ARRAY = 27 - U32_ARRAY = 28 - BOOL_ARRAY = 29 - STRING_ARRAY = 34 - I64_ARRAY = 38 - U64_ARRAY = 39 - F32_ARRAY = 42 - F64_ARRAY = 43 - - class HoiRequestId(IntEnum): """Request types for HarpProtocolRequest (byte 3 in command_data). diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py index 80c249a424a..d4638efbae5 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -4,7 +4,11 @@ and command classes in the Hamilton TCP protocol stack. """ +import struct import unittest +from dataclasses import dataclass +from typing import Annotated +from unittest.mock import AsyncMock from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( @@ -16,6 +20,7 @@ InitResponse, RegistrationMessage, RegistrationResponse, + parse_into_struct, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( Address, @@ -27,12 +32,33 @@ encode_version_byte, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( - HamiltonDataType, HamiltonProtocol, Hoi2Action, RegistrationActionCode, RegistrationOptionType, ) +from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import ( + F32, + F64, + I8, + I16, + I32, + I64, + U8, + U16, + U32, + U64, + Bool, + BoolArray, + CountedFlatArray, + F32Array, + HamiltonDataType, + I32Array, + Str, + StrArray, + U16Array, + decode_fragment, +) class TestVersionByte(unittest.TestCase): @@ -354,7 +380,7 @@ def test_empty_params(self): self.assertEqual(HoiParams().count(), 0) def test_i8(self): - params = HoiParams().i8(-128).build() + params = HoiParams().add(-128, I8).build() # DataFragment: [type=1][flags=0][length=1][data=-128] self.assertEqual(params[0], HamiltonDataType.I8) self.assertEqual(params[1], 0) # flags @@ -362,98 +388,98 @@ def test_i8(self): self.assertEqual(params[4], 0x80) # -128 as signed byte def test_i16(self): - params = HoiParams().i16(-1000).build() + params = HoiParams().add(-1000, I16).build() self.assertEqual(params[0], HamiltonDataType.I16) self.assertEqual(params[2:4], b"\x02\x00") # length = 2 def test_i32(self): - params = HoiParams().i32(100000).build() + params = HoiParams().add(100000, I32).build() self.assertEqual(params[0], HamiltonDataType.I32) self.assertEqual(params[2:4], b"\x04\x00") # length = 4 def test_i64(self): - params = HoiParams().i64(2**40).build() + params = HoiParams().add(2**40, I64).build() self.assertEqual(params[0], HamiltonDataType.I64) self.assertEqual(params[2:4], b"\x08\x00") # length = 8 def test_u8(self): - params = HoiParams().u8(255).build() + params = HoiParams().add(255, U8).build() self.assertEqual(params[0], HamiltonDataType.U8) self.assertEqual(params[4], 255) def test_u16(self): - params = HoiParams().u16(65535).build() + params = HoiParams().add(65535, U16).build() self.assertEqual(params[0], HamiltonDataType.U16) self.assertEqual(params[4:6], b"\xff\xff") def test_u32(self): - params = HoiParams().u32(0xDEADBEEF).build() + params = HoiParams().add(0xDEADBEEF, U32).build() self.assertEqual(params[0], HamiltonDataType.U32) self.assertEqual(params[4:8], b"\xef\xbe\xad\xde") def test_u64(self): - params = HoiParams().u64(0xDEADBEEFCAFEBABE).build() + params = HoiParams().add(0xDEADBEEFCAFEBABE, U64).build() self.assertEqual(params[0], HamiltonDataType.U64) def test_f32(self): - params = HoiParams().f32(3.14).build() + params = HoiParams().add(3.14, F32).build() self.assertEqual(params[0], HamiltonDataType.F32) self.assertEqual(params[2:4], b"\x04\x00") def test_f64(self): - params = HoiParams().f64(3.14159265358979).build() + params = HoiParams().add(3.14159265358979, F64).build() self.assertEqual(params[0], HamiltonDataType.F64) self.assertEqual(params[2:4], b"\x08\x00") def test_string(self): - params = HoiParams().string("test").build() + params = HoiParams().add("test", Str).build() self.assertEqual(params[0], HamiltonDataType.STRING) self.assertEqual(params[2:4], b"\x05\x00") # length = 5 (including null) self.assertEqual(params[4:9], b"test\x00") def test_bool_true(self): - params = HoiParams().bool_value(True).build() + params = HoiParams().add(True, Bool).build() self.assertEqual(params[0], HamiltonDataType.BOOL) self.assertEqual(params[4], 1) def test_bool_false(self): - params = HoiParams().bool_value(False).build() + params = HoiParams().add(False, Bool).build() self.assertEqual(params[0], HamiltonDataType.BOOL) self.assertEqual(params[4], 0) def test_i32_array(self): - params = HoiParams().i32_array([1, 2, 3]).build() + params = HoiParams().add([1, 2, 3], I32Array).build() self.assertEqual(params[0], HamiltonDataType.I32_ARRAY) self.assertEqual(params[2:4], b"\x0c\x00") # length = 12 (3 * 4) def test_u16_array(self): - params = HoiParams().u16_array([100, 200, 300]).build() + params = HoiParams().add([100, 200, 300], U16Array).build() self.assertEqual(params[0], HamiltonDataType.U16_ARRAY) self.assertEqual(params[2:4], b"\x06\x00") # length = 6 (3 * 2) def test_f32_array(self): - params = HoiParams().f32_array([1.0, 2.0, 3.0]).build() + params = HoiParams().add([1.0, 2.0, 3.0], F32Array).build() self.assertEqual(params[0], HamiltonDataType.F32_ARRAY) self.assertEqual(params[2:4], b"\x0c\x00") # length = 12 (3 * 4) def test_bool_array(self): - params = HoiParams().bool_array([True, False, True]).build() + params = HoiParams().add([True, False, True], BoolArray).build() self.assertEqual(params[0], HamiltonDataType.BOOL_ARRAY) self.assertEqual(params[1], 0x01) # flags = 0x01 for bool arrays - self.assertEqual(params[2:4], b"\x03\x00") # length = 3 + self.assertEqual(params[2:4], b"\x04\x00") # length = 4 (3 bools + pad) self.assertEqual(params[4:7], b"\x01\x00\x01") def test_string_array(self): - params = HoiParams().string_array(["a", "bc"]).build() + params = HoiParams().add(["a", "bc"], StrArray).build() self.assertEqual(params[0], HamiltonDataType.STRING_ARRAY) - # String arrays have u32 count prefix - self.assertEqual(params[4:8], b"\x02\x00\x00\x00") # count = 2 + # String array payload is concatenated null-terminated strings (no count) + self.assertEqual(params[4:9], b"a\x00bc\x00") def test_method_chaining(self): - self.assertEqual(HoiParams().i32(1).string("test").bool_value(True).count(), 3) + self.assertEqual(HoiParams().add(1, I32).add("test", Str).add(True, Bool).count(), 3) def test_count(self): - builder = HoiParams().i32(1).i32(2).i32(3) + builder = HoiParams().add(1, I32).add(2, I32).add(3, I32) self.assertEqual(builder.count(), 3) @@ -461,55 +487,55 @@ class TestHoiParamsParser(unittest.TestCase): """Tests for HoiParamsParser.""" def test_parse_i32(self): - params = HoiParams().i32(12345).build() + params = HoiParams().add(12345, I32).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.I32) self.assertEqual(value, 12345) def test_parse_negative_i32(self): - params = HoiParams().i32(-12345).build() + params = HoiParams().add(-12345, I32).build() parser = HoiParamsParser(params) _, value = parser.parse_next() self.assertEqual(value, -12345) def test_parse_string(self): - params = HoiParams().string("hello").build() + params = HoiParams().add("hello", Str).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.STRING) self.assertEqual(value, "hello") def test_parse_bool(self): - params = HoiParams().bool_value(True).build() + params = HoiParams().add(True, Bool).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.BOOL) self.assertEqual(value, True) def test_parse_i32_array(self): - params = HoiParams().i32_array([10, 20, 30]).build() + params = HoiParams().add([10, 20, 30], I32Array).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.I32_ARRAY) self.assertEqual(value, [10, 20, 30]) def test_parse_u16_array(self): - params = HoiParams().u16_array([100, 200, 300]).build() + params = HoiParams().add([100, 200, 300], U16Array).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.U16_ARRAY) self.assertEqual(value, [100, 200, 300]) def test_parse_bool_array(self): - params = HoiParams().bool_array([True, False, True, False]).build() + params = HoiParams().add([True, False, True, False], BoolArray).build() parser = HoiParamsParser(params) type_id, value = parser.parse_next() self.assertEqual(type_id, HamiltonDataType.BOOL_ARRAY) self.assertEqual(value, [True, False, True, False]) def test_parse_multiple(self): - params = HoiParams().i32(100).string("test").bool_value(False).build() + params = HoiParams().add(100, I32).add("test", Str).add(False, Bool).build() parser = HoiParamsParser(params) _, v1 = parser.parse_next() @@ -521,7 +547,7 @@ def test_parse_multiple(self): self.assertEqual(v3, False) def test_parse_all(self): - params = HoiParams().i32(1).i32(2).i32(3).build() + params = HoiParams().add(1, I32).add(2, I32).add(3, I32).build() parser = HoiParamsParser(params) results = parser.parse_all() @@ -529,7 +555,7 @@ def test_parse_all(self): self.assertEqual([v for _, v in results], [1, 2, 3]) def test_has_remaining(self): - params = HoiParams().i32(1).build() + params = HoiParams().add(1, I32).build() parser = HoiParamsParser(params) self.assertTrue(parser.has_remaining()) parser.parse_next() @@ -551,18 +577,18 @@ def test_roundtrip_all_types(self): bool_val = True builder = HoiParams() - builder.i8(i8_val) - builder.i16(i16_val) - builder.i32(i32_val) - builder.i64(i64_val) - builder.u8(u8_val) - builder.u16(u16_val) - builder.u32(u32_val) - builder.u64(u64_val) - builder.f32(f32_val) - builder.f64(f64_val) - builder.string(string_val) - builder.bool_value(bool_val) + builder.add(i8_val, I8) + builder.add(i16_val, I16) + builder.add(i32_val, I32) + builder.add(i64_val, I64) + builder.add(u8_val, U8) + builder.add(u16_val, U16) + builder.add(u32_val, U32) + builder.add(u64_val, U64) + builder.add(f32_val, F32) + builder.add(f64_val, F64) + builder.add(string_val, Str) + builder.add(bool_val, Bool) params = builder.build() parser = HoiParamsParser(params) @@ -588,7 +614,7 @@ class TestCommandMessage(unittest.TestCase): def test_build_simple_command(self): dest = Address(1, 1, 257) - params = HoiParams().i32(100) + params = HoiParams().add(100, I32) msg = CommandMessage(dest=dest, interface_id=1, method_id=4, params=params) src = Address(2, 1, 65535) @@ -917,7 +943,7 @@ class TestCommand(HamiltonCommand): command_id = 4 def build_parameters(self): - return HoiParams().i32(100) + return HoiParams().add(100, I32) cmd = TestCommand(Address(1, 1, 257)) cmd.source_address = Address(2, 1, 65535) @@ -954,6 +980,103 @@ def __init__(self, dest: Address, value: int, name: str): self.assertNotIn("self", log_params) +class TestInterpretResponseAutoDecode(unittest.TestCase): + """Tests for interpret_response auto-decode when command has Response class.""" + + def test_auto_decode_with_response_class(self): + """Command with nested Response decodes via parse_into_struct.""" + from pylabrobot.liquid_handling.backends.hamilton.tcp.wire_types import I64 + + class CommandWithResponse(HamiltonCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 0 + + @dataclass(frozen=True) + class Response: + value: I64 + + cmd = CommandWithResponse(Address(0, 0, 0)) + params = HoiParams().add(42, I64).build() + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.COMMAND_RESPONSE, + action_id=0, + params=params, + ) + harp = HarpPacket( + src=Address(0, 0, 0), + dst=Address(0, 0, 0), + seq=0, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + ip = IpPacket(protocol=6, payload=harp.pack()) + response = CommandResponse.from_bytes(ip.pack()) + result = cmd.interpret_response(response) + self.assertIsInstance(result, CommandWithResponse.Response) + self.assertEqual(result.value, 42) + + def test_auto_decode_fallback_no_response_class(self): + """Command without Response returns None when params empty.""" + + class CommandNoResponse(HamiltonCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 0 + command_id = 1 + + cmd = CommandNoResponse(Address(0, 0, 0)) + hoi = HoiPacket(interface_id=0, action_code=4, action_id=1, params=b"") + harp = HarpPacket( + src=Address(0, 0, 0), + dst=Address(0, 0, 0), + seq=0, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + ip = IpPacket(protocol=6, payload=harp.pack()) + response = CommandResponse.from_bytes(ip.pack()) + result = cmd.interpret_response(response) + self.assertIsNone(result) + + def test_auto_decode_fallback_parse_response_parameters(self): + """Command with parse_response_parameters override but no Response uses override.""" + + class CommandWithOverride(HamiltonCommand): + protocol = HamiltonProtocol.OBJECT_DISCOVERY + interface_id = 1 + command_id = 0 + + @classmethod + def parse_response_parameters(cls, data): + parser = HoiParamsParser(data) + _, v = parser.parse_next() + return {"value": v} + + cmd = CommandWithOverride(Address(0, 0, 0)) + params = HoiParams().add(100, I32).build() + hoi = HoiPacket( + interface_id=1, + action_code=Hoi2Action.COMMAND_RESPONSE, + action_id=0, + params=params, + ) + harp = HarpPacket( + src=Address(0, 0, 0), + dst=Address(0, 0, 0), + seq=0, + protocol=2, + action_code=4, + payload=hoi.pack(), + ) + ip = IpPacket(protocol=6, payload=harp.pack()) + response = CommandResponse.from_bytes(ip.pack()) + result = cmd.interpret_response(response) + self.assertEqual(result, {"value": 100}) + + class TestProtocolEnums(unittest.TestCase): """Tests for protocol enum values.""" @@ -983,5 +1106,340 @@ def test_hamilton_data_type_values(self): self.assertEqual(HamiltonDataType.I32_ARRAY, 27) +class TestDecodeFragment(unittest.TestCase): + """Tests for decode_fragment() and correct Python types.""" + + def test_decode_i32(self): + data = struct.pack(" HoiParams: + raise NotImplementedError + + def decode_from(self, data: bytes) -> Any: + raise NotImplementedError + + +class Scalar(WireType): + """Fixed-size scalar encoded via ``struct.pack(fmt, value)``. + + When *padded* is ``True`` the Prep convention is used: flags byte = 0x01 + and one ``\\x00`` pad byte is appended after the value. + """ + + __slots__ = ("fmt", "padded") + + def __init__(self, type_id: int, fmt: str, padded: bool = False): + super().__init__(type_id) + self.fmt = fmt + self.padded = padded + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = _struct.pack(self.fmt, value) + return params._add_fragment(self.type_id, data, 0x01 if self.padded else 0) + + def decode_from(self, data: bytes) -> Any: + size = _struct.calcsize(self.fmt) + val = _struct.unpack(self.fmt, data[:size])[0] + if self.type_id == HamiltonDataType.BOOL: + return bool(val) + if self.type_id in ( + HamiltonDataType.F32, + HamiltonDataType.F64, + ): + return float(val) + return int(val) + + +class Array(WireType): + """Homogeneous array of packed scalars (no length prefix on the wire).""" + + __slots__ = ("element_fmt",) + + def __init__(self, type_id: int, element_fmt: str): + super().__init__(type_id) + self.element_fmt = element_fmt + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = _struct.pack(f"{len(value)}{self.element_fmt}", *value) + flags = 0x01 if self.type_id == HamiltonDataType.BOOL_ARRAY else 0 + return params._add_fragment(self.type_id, data, flags) + + def decode_from(self, data: bytes) -> Any: + el_size = _struct.calcsize(self.element_fmt) + count = len(data) // el_size + values = _struct.unpack(f"{count}{self.element_fmt}", data[: count * el_size]) + if self.type_id == HamiltonDataType.BOOL_ARRAY: + return [bool(v) for v in values] + return list(values) + + +class Struct(WireType): + """Nested structure -- recurse via ``HoiParams.from_struct``.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRUCTURE) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams as HP + + return params._add_fragment(self.type_id, HP.from_struct(value).build()) + + def decode_from(self, data: bytes) -> Any: + return data + + +class StructArray(WireType): + """Array of nested structures.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRUCTURE_ARRAY) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams as HP + + inner = b"" + for v in value: + payload = HP.from_struct(v).build() + inner += _struct.pack(" Any: + # Parse concatenated Structure sub-fragments: [type_id:1][flags:1][length:2][data:N] + out: list[bytes] = [] + off = 0 + while off + 4 <= len(data): + type_id = data[off] + length = int.from_bytes(data[off + 2 : off + 4], "little") + off += 4 + if off + length > len(data): + break + if type_id == HamiltonDataType.STRUCTURE: + out.append(data[off : off + length]) + off += length + return out + + +class CountedFlatArray(WireType): + """Count-prefix array where elements share the caller's parser stream. + + Decode-only (introspection protocol uses this; domain commands use StructArray). + """ + + __slots__ = () + + def __init__(self): + super().__init__(type_id=-1) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + raise NotImplementedError("CountedFlatArray is decode-only (introspection protocol)") + + +class StringType(WireType): + """Null-terminated ASCII string.""" + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRING) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = value.encode("utf-8") + b"\x00" + return params._add_fragment(self.type_id, data) + + def decode_from(self, data: bytes) -> Any: + return data.rstrip(b"\x00").decode("utf-8") + + +class StringArrayType(WireType): + """Array of null-terminated strings (type_id=34). + + Wire format: payload is a concatenation of null-terminated UTF-8 strings with + no leading element count. Fragment length in the HOI header defines the + payload boundary. + """ + + __slots__ = () + + def __init__(self): + super().__init__(HamiltonDataType.STRING_ARRAY) + + def encode_into(self, value, params: HoiParams) -> HoiParams: + data = b"" + for s in value: + data += s.encode("utf-8") + b"\x00" + return params._add_fragment(self.type_id, data) + + def decode_from(self, data: bytes) -> Any: + if not data: + return [] + out: list[str] = [] + off = 0 + while off < len(data): + null_pos = data.find(b"\x00", off) + if null_pos == -1: + break + out.append(data[off:null_pos].decode("utf-8")) + off = null_pos + 1 + return out + + +# --------------------------------------------------------------------------- +# Annotated type aliases +# --------------------------------------------------------------------------- + +# Scalars (mypy sees the base Python type: int / float / bool / str) +I8 = Annotated[int, Scalar(HamiltonDataType.I8, "b")] +I16 = Annotated[int, Scalar(HamiltonDataType.I16, "h")] +I32 = Annotated[int, Scalar(HamiltonDataType.I32, "i")] +I64 = Annotated[int, Scalar(HamiltonDataType.I64, "q")] +U8 = Annotated[int, Scalar(HamiltonDataType.U8, "B")] +U16 = Annotated[int, Scalar(HamiltonDataType.U16, "H")] +U32 = Annotated[int, Scalar(HamiltonDataType.U32, "I")] +U64 = Annotated[int, Scalar(HamiltonDataType.U64, "Q")] +F32 = Annotated[float, Scalar(HamiltonDataType.F32, "f")] +F64 = Annotated[float, Scalar(HamiltonDataType.F64, "d")] +Bool = Annotated[bool, Scalar(HamiltonDataType.BOOL, "?")] +Enum = Annotated[int, Scalar(HamiltonDataType.ENUM, "I")] +HcResult = Annotated[int, Scalar(HamiltonDataType.HC_RESULT, "H")] +Str = Annotated[str, StringType()] + +# Prep-padded variants (Bool and U8 are always padded on Prep hardware) +PaddedBool = Annotated[bool, Scalar(HamiltonDataType.BOOL, "?", padded=True)] +PaddedU8 = Annotated[int, Scalar(HamiltonDataType.U8, "B", padded=True)] + +# Arrays (mypy sees ``list``) +I8Array = Annotated[list, Array(HamiltonDataType.I8_ARRAY, "b")] +I16Array = Annotated[list, Array(HamiltonDataType.I16_ARRAY, "h")] +I32Array = Annotated[list, Array(HamiltonDataType.I32_ARRAY, "i")] +I64Array = Annotated[list, Array(HamiltonDataType.I64_ARRAY, "q")] +U8Array = Annotated[list, Array(HamiltonDataType.U8_ARRAY, "B")] +U16Array = Annotated[list, Array(HamiltonDataType.U16_ARRAY, "H")] +U32Array = Annotated[list, Array(HamiltonDataType.U32_ARRAY, "I")] +U64Array = Annotated[list, Array(HamiltonDataType.U64_ARRAY, "Q")] +F32Array = Annotated[list, Array(HamiltonDataType.F32_ARRAY, "f")] +F64Array = Annotated[list, Array(HamiltonDataType.F64_ARRAY, "d")] +BoolArray = Annotated[list, Array(HamiltonDataType.BOOL_ARRAY, "?")] +EnumArray = Annotated[list, Array(HamiltonDataType.ENUM_ARRAY, "I")] +StrArray = Annotated[list, StringArrayType()] + +# Compound types: Structure and StructureArray do NOT have simple aliases +# because ``Annotated[object, Struct()]`` would erase the concrete type for +# mypy. Use inline ``Annotated[ConcreteType, Struct()]`` on each field to +# preserve full type safety. The class singletons are exported so call-sites +# only need ``Struct()`` and ``StructArray()``. + +# --------------------------------------------------------------------------- +# Type registry and decode_fragment +# --------------------------------------------------------------------------- + +_WIRE_TYPE_REGISTRY: dict[int, WireType] = {} + + +def _register(alias: type) -> None: + meta = getattr(alias, "__metadata__", (None,))[0] + assert meta is not None, f"Expected Annotated alias with metadata: {alias}" + _WIRE_TYPE_REGISTRY[meta.type_id] = meta + + +for _alias in [ + I8, + I16, + I32, + I64, + U8, + U16, + U32, + U64, + F32, + F64, + Bool, + Enum, + HcResult, + Str, + I8Array, + I16Array, + I32Array, + I64Array, + U8Array, + U16Array, + U32Array, + U64Array, + F32Array, + F64Array, + BoolArray, + EnumArray, + StrArray, +]: + _register(_alias) + +_WIRE_TYPE_REGISTRY[HamiltonDataType.STRUCTURE] = Struct() +_WIRE_TYPE_REGISTRY[HamiltonDataType.STRUCTURE_ARRAY] = StructArray() + + +def decode_fragment(type_id: int, data: bytes) -> Any: + """Decode a DataFragment payload using the unified type registry.""" + wt = _WIRE_TYPE_REGISTRY.get(type_id) + if wt is None: + raise ValueError(f"Unknown DataFragment type_id: {type_id}") + return wt.decode_from(data) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py index 9c6a9acbb13..d9c551a499a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py @@ -1,7 +1,37 @@ -"""Hamilton TCP Backend Base Class. +"""Hamilton TCP communication layer. -This module provides the base backend for all Hamilton TCP instruments. -It handles connection management, message routing, and the introspection API. +HamiltonTCPClient +----------------- +Standalone, instrument-agnostic TCP transport for Hamilton HOI/HARP protocol. +Use directly in notebooks/scripts for discovery, introspection, and firmware +interaction without a LiquidHandler. Also composed by instrument backends as +``self.client``. + +Usage (standalone):: + + client = HamiltonTCPClient(host="192.168.100.102") + await client.setup() + intro = HamiltonIntrospection(client) + registry = await intro.build_type_registry("MLPrepRoot.MphRoot.MPH") + +Usage (in backends):: + + self.client = HamiltonTCPClient(host=host, port=port) + await self.client.setup() + await self.client.send_command(SomeCommand(...)) + +Backends may construct the client with host/port (using this module's defaults) +or accept a pre-built client from the caller (dependency injection) so TCP +options stay in one place. + +Error handling: By default (detailed_errors=True), command failures include +Layer A (HC_RESULT enum description) and Layer B (async method signature / +parameter diagnosis). Set detailed_errors=False to skip Layer B. + +Key classes +----------- +- ObjectRegistry: maps dot-path strings to Address (e.g. "MLPrepRoot.MphRoot.MPH") +- RegistryProxy: dot-syntax accessor (client.interfaces.MLPrepRoot.MphRoot.MPH.address) """ from __future__ import annotations @@ -9,18 +39,26 @@ import asyncio import logging from dataclasses import dataclass -from typing import Dict, Optional, Union +from typing import Any, Dict, List, Optional, Union, cast from pylabrobot.io.binary import Reader from pylabrobot.io.socket import Socket -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( + GET_SUBOBJECT_ADDRESS, + GlobalTypePool, + HamiltonIntrospection, + ObjectInfo, + TypeRegistry, + describe_hc_result, +) from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( CommandResponse, InitMessage, InitResponse, RegistrationMessage, RegistrationResponse, + parse_hamilton_error_params, ) from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( @@ -33,6 +71,282 @@ logger = logging.getLogger(__name__) +class ObjectRegistry: + """Maps object paths to addresses. Depth-1 eager by default; lazy resolution beyond.""" + + def __init__(self, transport: "HamiltonTCPClient"): + self._transport = transport + self._objects: Dict[str, ObjectInfo] = {} + self._root_addresses: List[Address] = [] + + def set_root_addresses(self, addresses: List[Address]) -> None: + self._root_addresses = list(addresses) + + def get_root_addresses(self) -> List[Address]: + return list(self._root_addresses) + + def register(self, path: str, obj: ObjectInfo) -> None: + self._objects[path] = obj + + def has(self, path: str) -> bool: + return path in self._objects + + def find_path_ending_with(self, suffix: str) -> Optional[str]: + """Return a registered path whose last component equals suffix (e.g. 'Pipette' or 'DoorLock').""" + for path in self._objects: + if path == suffix or path.endswith("." + suffix): + return path + return None + + def address(self, path: str) -> Address: + obj = self._objects.get(path) + if obj is None: + raise KeyError(f"Object '{path}' not discovered") + return obj.address + + def path(self, address: Address) -> Optional[str]: + """Return the registered object path for this address, or None if not in registry.""" + return self.find_path_by_address(address) + + def find_path_by_address(self, address: Address) -> Optional[str]: + """Return the registered object path for this address, or None if not in registry.""" + for path, obj in self._objects.items(): + if ( + obj.address.module == address.module + and obj.address.node == address.node + and obj.address.object == address.object + ): + return path + return None + + async def resolve(self, path: str) -> Address: + """Resolve a dot-path to an Address, lazy-resolving and registering as needed. + + Uses the object's method table (GetMethod) to determine which Interface 0 + methods are supported; only calls GetSubobjectAddress when the parent + supports it. Interfaces are per-object (no aggregation from children). + """ + if path in self._objects: + return self._objects[path].address + parts = [p for p in path.split(".") if p] + if not parts: + raise KeyError(f"Invalid path: '{path}'") + parent_path = ".".join(parts[:-1]) + child_name = parts[-1] + introspection = HamiltonIntrospection(self._transport) + + if not parent_path: + if not self._root_addresses: + raise KeyError("No root addresses; run discovery first") + parent_addr = self._root_addresses[0] + parent_info = await introspection.get_object(parent_addr) + parent_info.children = {} + self.register(parent_info.name, parent_info) + if parent_info.name == child_name: + return parent_info.address + raise KeyError(f"Root object is '{parent_info.name}', not '{child_name}'") + + parent_addr = await self.resolve(parent_path) + parent_info = self._objects[parent_path] + supported = await introspection.get_supported_interface0_method_ids(parent_info.address) + if GET_SUBOBJECT_ADDRESS not in supported: + raise KeyError( + f"Object at path '{parent_path}' does not support GetSubobjectAddress " + f"(interface 0, method 3); cannot resolve child '{child_name}'" + ) + for i in range(parent_info.subobject_count): + sub_addr = await introspection.get_subobject_address(parent_info.address, i) + sub_info = await introspection.get_object(sub_addr) + sub_info.children = {} + child_path = f"{parent_path}.{sub_info.name}" + parent_info.children[sub_info.name] = sub_info + self.register(child_path, sub_info) + if sub_info.name == child_name: + return sub_info.address + raise KeyError(f"Child '{child_name}' not found under '{parent_path}'") + + +class RegistryProxy: + """Chainable dot-syntax accessor over ObjectRegistry. + + Routing: .address for required paths (KeyError if missing). Optional paths: .is_available + or a firmware probe, per interface. Depth-2+ paths: await .resolve() once before .address. + .info for metadata; __dir__ for tab-completion. + """ + + def __init__(self, registry: "ObjectRegistry", path: str = ""): + object.__setattr__(self, "_registry", registry) + object.__setattr__(self, "_path", path) + + def __getattr__(self, name: str) -> "RegistryProxy": + current = object.__getattribute__(self, "_path") + new_path = name if not current else f"{current}.{name}" + return RegistryProxy(object.__getattribute__(self, "_registry"), new_path) + + def __getitem__(self, name: str) -> "RegistryProxy": + """Support backend.interfaces['RootName'] or backend.interfaces['Root.Child'].""" + current = object.__getattribute__(self, "_path") + if not current: + new_path = name + elif "." in name: + new_path = name + else: + new_path = f"{current}.{name}" + return RegistryProxy(object.__getattribute__(self, "_registry"), new_path) + + def __dir__(self): + current = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + prefix = f"{current}." if current else "" + children = set() + for key in registry._objects: + if key.startswith(prefix): + segment = key[len(prefix) :].split(".")[0] + if segment: + children.add(segment) + return list(children) + + async def resolve(self) -> "RegistryProxy": + """Lazily resolve this path (depth-2+). Must be awaited once before .address is accessible.""" + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + await registry.resolve(path) + return self + + @property + def address(self) -> Address: + """Wire-level destination for this path. Use for required interfaces; KeyError if not discovered.""" + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + return cast(Address, registry.address(path)) + + @property + def info(self) -> ObjectInfo: + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + obj = registry._objects.get(path) + if obj is None: + raise KeyError(f"'{path}' not in registry. Call await .resolve() first.") + return cast(ObjectInfo, obj) + + @property + def is_available(self) -> bool: + """True if this path was discovered and is present in the registry.""" + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + return path in registry._objects + + def __repr__(self) -> str: + path = object.__getattribute__(self, "_path") + registry = object.__getattribute__(self, "_registry") + registered = path in registry._objects + return f"" + + +@dataclass +class InterfaceSpec: + """Spec for a backend interface: instrument path, required flag, and raise-when-missing behavior. + + Logs use the dict key (name) and path only; no display_name. + """ + + path: str + required: bool + raise_when_missing: bool = True + + +class HamiltonInterfaceResolver: + """Resolves named interfaces (path -> Address) with caching and required/optional behavior. + + Used by Nimbus and Prep backends. Holds client, interfaces dict, and _resolved cache. + """ + + def __init__(self, client: "HamiltonTCPClient", interfaces: dict[str, InterfaceSpec]): + self.client = client + self.interfaces = interfaces + self._resolved: dict[str, Optional[Address]] = {} + + def clear(self) -> None: + """Clear cached addresses (for reconnect-safe setup).""" + self._resolved.clear() + + def has_interface(self, name: str) -> bool: + """Return True if the interface was resolved and is present.""" + return name in self._resolved and self._resolved[name] is not None + + async def get(self, name: str) -> Optional[Address]: + """Resolve once and cache. Required + missing -> raise. Optional + missing -> cache None, return None.""" + if name not in self.interfaces: + raise KeyError(f"Unknown interface: {name}") + spec = self.interfaces[name] + if name in self._resolved: + return self._resolved[name] + try: + await self.client.interfaces[spec.path].resolve() + addr = self.client.interfaces[spec.path].address + self._resolved[name] = addr + logger.debug("Resolved %s → %s (%s)", name, addr, spec.path) + return addr + except KeyError: + if spec.required: + msg = f"Could not find interface '{name}' ({spec.path}) on instrument." + raise RuntimeError(msg) from None + self._resolved[name] = None + return None + + async def require(self, name: str) -> Address: + """Return address or raise. If optional and missing: log warning when raise_when_missing, then raise.""" + if name not in self.interfaces: + raise KeyError(f"Unknown interface: {name}") + spec = self.interfaces[name] + msg = f"Could not find interface '{name}' ({spec.path}) on instrument." + if name in self._resolved: + if self._resolved[name] is None: + if spec.raise_when_missing: + logger.warning("%s", msg) + raise RuntimeError(msg) from None + addr = self._resolved[name] + assert addr is not None + return addr + try: + await self.client.interfaces[spec.path].resolve() + addr = cast(Address, self.client.interfaces[spec.path].address) + self._resolved[name] = addr + logger.debug("Resolved %s → %s (%s)", name, addr, spec.path) + return addr + except KeyError: + if spec.required: + raise RuntimeError(msg) from None + self._resolved[name] = None + if spec.raise_when_missing: + logger.warning("%s", msg) + raise RuntimeError(msg) from None + + async def run_setup_loop(self) -> None: + """Clear cache, then resolve all interfaces: required fail-fast; optional log and continue.""" + self.clear() + for name, spec in self.interfaces.items(): + if spec.required: + addr = await self.require(name) + logger.debug("Found interface '%s' (%s) at %s", name, spec.path, addr) + else: + optional_addr = await self.get(name) + if optional_addr is not None: + logger.debug("Found interface '%s' (%s) at %s", name, spec.path, optional_addr) + else: + logger.debug("Could not find interface '%s' (%s) on instrument.", name, spec.path) + + found = sorted(name for name in self.interfaces if self.has_interface(name)) + optional_missing = sorted( + name + for name, spec in self.interfaces.items() + if not spec.required and not self.has_interface(name) + ) + logger.info("Interfaces: %s", ", ".join(found)) + if optional_missing: + logger.info("Optional not present: %s", ", ".join(optional_missing)) + + @dataclass class HamiltonError: """Hamilton error response.""" @@ -63,21 +377,20 @@ def parse_error(data: bytes) -> HamiltonError: ) -class HamiltonTCPBackend(LiquidHandlerBackend): - """Base backend for all Hamilton TCP instruments. - - Hamilton TCP instruments include the Nimbus and the Prep, using Hoi and Harp. - STAR and Vantage use the other Hamilton protocol that works over USB. - - This class provides: - - Connection management via Socket (wrapped with state tracking) - - Protocol 7 initialization - - Protocol 3 registration - - Generic command execution - - Object discovery via introspection - - Hamilton uses strict request-response protocol (no unsolicited messages), - so we use simple direct read/write instead of complex routing. +class HamiltonTCPClient: + """Hamilton TCP communication and introspection (instrument-agnostic). + + Handles connection, Protocol 7/3, discovery, object registry, and command + execution. Use standalone for discovery notebooks or assign to + self.client in PrepBackend/NimbusBackend. Does not implement liquid-handling. + interfaces (RegistryProxy): .address for required paths; .is_available or + firmware probe for optional; await .resolve() for depth-2+ paths. + detailed_errors=True (default) enables full diagnosis on command failure. + Connection timeout is configurable; when the connection drops, the next + send_command (with ensure_connection=True) reconnects and retries once. + Backends use composition and optional dependency injection: they may build + the client with host and port (using the defaults below) or accept an + injected instance for full control. """ def __init__( @@ -88,18 +401,23 @@ def __init__( write_timeout: float = 30.0, auto_reconnect: bool = True, max_reconnect_attempts: int = 3, + detailed_errors: bool = True, + connection_timeout: int = 300, ): - """Initialize Hamilton TCP backend. + """Initialize the Hamilton TCP client. + + These arguments are the defaults when backends construct the client with + only host and port. Args: - host: Hamilton instrument IP address - port: Hamilton instrument port (usually 50007) - read_timeout: Read timeout in seconds - write_timeout: Write timeout in seconds - auto_reconnect: Enable automatic reconnection - max_reconnect_attempts: Maximum reconnection attempts + host: Instrument hostname or IP address. + port: TCP port (default 2000). + connection_timeout: Idle timeout in seconds sent to the instrument at + connection init; if no commands are sent for this long the instrument + may close the connection. Default 300 (5 min). If the connection drops, + the next send_command (with ensure_connection=True) reconnects and + retries that command once. """ - self.io = Socket( human_readable_device_name="Hamilton Liquid Handler", host=host, @@ -107,21 +425,33 @@ def __init__( read_timeout=read_timeout, write_timeout=write_timeout, ) - - # Connection state tracking (wrapping Socket) self._connected = False self._reconnect_attempts = 0 self.auto_reconnect = auto_reconnect self.max_reconnect_attempts = max_reconnect_attempts - - # Hamilton-specific state + self.detailed_errors = detailed_errors + self._connection_timeout = connection_timeout self._client_id: Optional[int] = None self.client_address: Optional[Address] = None self._sequence_numbers: Dict[Address, int] = {} - self._discovered_objects: Dict[str, list[Address]] = {} + self._registry = ObjectRegistry(self) + self._type_registries: Dict[Address, TypeRegistry] = {} + self._global_object_addresses: list[Address] = [] - # Instrument-specific addresses (set by subclasses) - self._instrument_addresses: Dict[str, Address] = {} + @property + def interfaces(self) -> RegistryProxy: + """Dot-syntax access to the discovered object registry.""" + return RegistryProxy(self._registry) + + def discovered_root_name(self) -> str: + """Return the root interface name (e.g. NimbusCORE, MLPrepRoot). + + Valid after setup(); use in backends to validate instrument type. + """ + if not self._registry._objects: + raise RuntimeError("No objects discovered. Call setup() first.") + first_key = next(iter(self._registry._objects.keys())) + return first_key.split(".")[0] async def _ensure_connected(self): """Ensure connection is healthy before operations.""" @@ -234,7 +564,9 @@ def is_connected(self) -> bool: """Check if the connection is currently established.""" return self._connected - async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse]: + async def _read_one_message( + self, timeout: Optional[float] = None + ) -> Union[RegistrationResponse, CommandResponse]: """Read one complete Hamilton packet and parse based on protocol. Hamilton packets are length-prefixed: @@ -244,6 +576,9 @@ async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse The method inspects the IP protocol field and, for Protocol 6 (HARP), also checks the HARP protocol field to dispatch correctly. + Args: + timeout: Read timeout in seconds. If None, uses the client's default. + Returns: Union[RegistrationResponse, CommandResponse]: Parsed response @@ -254,11 +589,11 @@ async def _read_one_message(self) -> Union[RegistrationResponse, CommandResponse """ # Read packet size (2 bytes, little-endian) - size_data = await self.read_exact(2) + size_data = await self.read_exact(2, timeout=timeout) packet_size = Reader(size_data).u16() # Read packet payload - payload_data = await self.read_exact(packet_size) + payload_data = await self.read_exact(packet_size, timeout=timeout) complete_data = size_data + payload_data # Parse IP packet to get protocol field (byte 2) @@ -315,7 +650,24 @@ async def setup(self): # Step 4: Discover root objects await self._discover_root() - logger.info(f"Hamilton backend setup complete. Client ID: {self._client_id}") + # Step 4b: Discover global objects (shared type definitions) + await self._discover_globals() + + # Step 5: Register root object only (depth-1+ resolved lazily on demand) + root_addresses = self._registry.get_root_addresses() + if root_addresses: + introspection = HamiltonIntrospection(self) + root_info = await introspection.get_object(root_addresses[0]) + root_info.children = {} + self._registry.register(root_info.name, root_info) + + root_name = self.discovered_root_name() if self._registry._objects else "—" + logger.info( + "Setup complete. Registered as Client ID %s (%s), Root: %s", + self._client_id, + self.client_address, + root_name, + ) async def _initialize_connection(self): """Initialize connection using Protocol 7 (ConnectionPacket). @@ -324,14 +676,14 @@ async def _initialize_connection(self): and read the response directly (blocking) rather than using the normal routing mechanism. """ - logger.info("Initializing Hamilton connection...") + logger.debug("Initializing Hamilton connection...") # Build Protocol 7 ConnectionPacket using new InitMessage - packet = InitMessage(timeout=30).build() + packet = InitMessage(timeout=self._connection_timeout).build() - logger.info("[INIT] Sending Protocol 7 initialization packet:") - logger.info(f"[INIT] Length: {len(packet)} bytes") - logger.info(f"[INIT] Hex: {packet.hex(' ')}") + logger.debug("[INIT] Sending Protocol 7 initialization packet:") + logger.debug("[INIT] Length: %s bytes", len(packet)) + logger.debug("[INIT] Hex: %s", packet.hex(" ")) # Send packet await self.write(packet) @@ -345,9 +697,9 @@ async def _initialize_connection(self): payload_data = await self.read_exact(packet_size) response_bytes = size_data + payload_data - logger.info("[INIT] Received response:") - logger.info(f"[INIT] Length: {len(response_bytes)} bytes") - logger.info(f"[INIT] Hex: {response_bytes.hex(' ')}") + logger.debug("[INIT] Received response:") + logger.debug("[INIT] Length: %s bytes", len(response_bytes)) + logger.debug("[INIT] Hex: %s", response_bytes.hex(" ")) # Parse response using InitResponse response = InitResponse.from_bytes(response_bytes) @@ -356,11 +708,13 @@ async def _initialize_connection(self): # Controller module is 2, node is client_id, object 65535 for general addressing self.client_address = Address(2, response.client_id, 65535) - logger.info(f"[INIT] ✓ Client ID: {self._client_id}, Address: {self.client_address}") + logger.info( + "Connection initialized (Client ID: %s, Address: %s)", self._client_id, self.client_address + ) async def _register_client(self): """Register client using Protocol 3.""" - logger.info("Registering Hamilton client...") + logger.debug("Registering Hamilton client...") # Registration service address (DLL uses 0:0:65534, Piglet comment confirms) registration_service = Address(0, 0, 65534) @@ -385,10 +739,10 @@ async def _register_client(self): harp_response_required=False, # DLL uses 0x03 (no response flag) ) - logger.info("[REGISTER] Sending registration packet:") - logger.info(f"[REGISTER] Length: {len(packet)} bytes, Seq: {seq}") - logger.info(f"[REGISTER] Hex: {packet.hex(' ')}") - logger.info(f"[REGISTER] Src: {self.client_address}, Dst: {registration_service}") + logger.debug("[REGISTER] Sending registration packet:") + logger.debug("[REGISTER] Length: %s bytes, Seq: %s", len(packet), seq) + logger.debug("[REGISTER] Hex: %s", packet.hex(" ")) + logger.debug("[REGISTER] Src: %s, Dst: %s", self.client_address, registration_service) # Send registration packet await self.write(packet) @@ -396,15 +750,15 @@ async def _register_client(self): # Read response response = await self._read_one_message() - logger.info("[REGISTER] Received response:") - logger.info(f"[REGISTER] Length: {len(response.raw_bytes)} bytes") - logger.debug(f"[REGISTER] Hex: {response.raw_bytes.hex(' ')}") + logger.debug("[REGISTER] Received response:") + logger.debug("[REGISTER] Length: %s bytes", len(response.raw_bytes)) + logger.debug("[REGISTER] Hex: %s", response.raw_bytes.hex(" ")) - logger.info("[REGISTER] ✓ Registration complete") + logger.info("Client registered.") async def _discover_root(self): """Discover root objects via Protocol 3 HARP_PROTOCOL_REQUEST""" - logger.info("Discovering Hamilton root objects...") + logger.debug("Discovering Hamilton root objects...") registration_service = Address(0, 0, 65534) @@ -432,9 +786,9 @@ async def _discover_root(self): harp_response_required=True, # Request with response ) - logger.info("[DISCOVER_ROOT] Sending root object discovery:") - logger.info(f"[DISCOVER_ROOT] Length: {len(packet)} bytes, Seq: {seq}") - logger.info(f"[DISCOVER_ROOT] Hex: {packet.hex(' ')}") + logger.debug("[DISCOVER_ROOT] Sending root object discovery:") + logger.debug("[DISCOVER_ROOT] Length: %s bytes, Seq: %s", len(packet), seq) + logger.debug("[DISCOVER_ROOT] Hex: %s", packet.hex(" ")) # Send request await self.write(packet) @@ -443,16 +797,61 @@ async def _discover_root(self): response = await self._read_one_message() assert isinstance(response, RegistrationResponse) - logger.debug(f"[DISCOVER_ROOT] Received response: {len(response.raw_bytes)} bytes") + logger.debug("[DISCOVER_ROOT] Received response: %s bytes", len(response.raw_bytes)) # Parse registration response to extract root object IDs root_objects = self._parse_registration_response(response) - logger.info(f"[DISCOVER_ROOT] ✓ Found {len(root_objects)} root objects") + logger.debug("[DISCOVER_ROOT] Found %s root objects", len(root_objects)) + + self._registry.set_root_addresses(root_objects) + + logger.debug("Discovery complete: %s root objects", len(root_objects)) - # Store discovered root objects - self._discovered_objects["root"] = root_objects + async def _discover_globals(self): + """Discover global objects via Protocol 3 HARP_PROTOCOL_REQUEST. + + Global objects hold shared type definitions (structs/enums) referenced by + source_id=1 in method parameter triples. Piglet calls these "globals" and + uses request_id=2 (GLOBAL_OBJECT_ADDRESS) to discover them. + """ + logger.debug("Discovering Hamilton global objects...") + + registration_service = Address(0, 0, 65534) + + global_msg = RegistrationMessage( + dest=registration_service, action_code=RegistrationActionCode.HARP_PROTOCOL_REQUEST + ) + global_msg.add_registration_option( + RegistrationOptionType.HARP_PROTOCOL_REQUEST, + protocol=2, + request_id=HoiRequestId.GLOBAL_OBJECT_ADDRESS, + ) - logger.info(f"✓ Discovery complete: {len(root_objects)} root objects") + if self.client_address is None or self._client_id is None: + raise RuntimeError("Client not initialized - call _initialize_connection() first") + + seq = self._allocate_sequence_number(registration_service) + packet = global_msg.build( + src=self.client_address, + req_addr=Address(0, 0, 0), + res_addr=Address(0, 0, 0), + seq=seq, + harp_action_code=3, # COMMAND_REQUEST + harp_response_required=True, + ) + + logger.debug("[DISCOVER_GLOBALS] Sending global object discovery:") + logger.debug("[DISCOVER_GLOBALS] Length: %s bytes, Seq: %s", len(packet), seq) + logger.debug("[DISCOVER_GLOBALS] Hex: %s", packet.hex(" ")) + + await self.write(packet) + + response = await self._read_one_message() + assert isinstance(response, RegistrationResponse) + + global_objects = self._parse_registration_response(response) + self._global_object_addresses = global_objects + logger.debug("[DISCOVER_GLOBALS] Found %s global objects", len(global_objects)) def _parse_registration_response(self, response: RegistrationResponse) -> list[Address]: """Parse registration response options to extract object addresses. @@ -512,79 +911,168 @@ def _allocate_sequence_number(self, dest_address: Address) -> int: self._sequence_numbers[dest_address] = next_seq return next_seq - async def send_command(self, command: HamiltonCommand, timeout: float = 10.0) -> Optional[dict]: + async def send_command( + self, + command: HamiltonCommand, + ensure_connection: bool = True, + return_raw: bool = False, + raise_on_error: bool = True, + read_timeout: Optional[float] = None, + ) -> Any: """Send Hamilton command and wait for response. Sets source_address if not already set by caller (for testing). Uses backend's client_address assigned during Protocol 7 initialization. + When ensure_connection=True (default), on connection error (broken pipe, + reset, timeout, etc.) the backend reconnects and retries the command once. + Pass ensure_connection=False for setup/discovery commands so they are sent + once with no retry. + + Read/write timeouts are enforced at the backend level (read_timeout and + write_timeout passed into HamiltonTCPClient and used by the Socket). + Args: - command: Hamilton command to execute - timeout: Maximum time to wait for response + command: Hamilton command to execute. + ensure_connection: If True, reconnect and retry once on connection error. + If False, send once (for setup/discovery). + return_raw: If True, return (params_bytes,) instead of parsing the + response. Use with inspect_hoi_params() to debug wire format. + raise_on_error: If True (default), log ERROR and raise on STATUS_EXCEPTION + / COMMAND_EXCEPTION. If False, log DEBUG and return None (for probing + many object/interface pairs without log spam). Returns: - Parsed response dictionary, or None if command has no information to extract + If return_raw=True: (params_bytes,). Otherwise parsed response + (Command.Response instance, dict, or None). None if raise_on_error=False + and the device returned an exception action. Raises: - TimeoutError: If no response received within timeout - HamiltonError: If command returned an error + ConnectionError: If the connection is not established and auto_reconnect + is disabled, or if reconnection fails. + RuntimeError: If the Hamilton firmware returns an error action code and + raise_on_error is True. """ - # Set source address with smart fallback - if command.source_address is None: - if self.client_address is None: - raise RuntimeError("Backend not initialized - call setup() first to assign client_address") - command.source_address = self.client_address - - # Allocate sequence number for this command - command.sequence_number = self._allocate_sequence_number(command.dest_address) - - # Build command message - message = command.build() - - # Log command parameters for debugging - log_params = command.get_log_params() - logger.info(f"{command.__class__.__name__} parameters:") - for key, value in log_params.items(): - # Format arrays nicely if very long - if isinstance(value, list) and len(value) > 8: - logger.info(f" {key}: {value[:4]}... ({len(value)} items)") - else: - logger.info(f" {key}: {value}") + connection_errors = ( + BrokenPipeError, + ConnectionError, + ConnectionResetError, + ConnectionAbortedError, + TimeoutError, + OSError, + ) + max_attempts = 2 if ensure_connection else 1 + last_error: Optional[BaseException] = None + + for attempt in range(max_attempts): + try: + if command.source_address is None: + if self.client_address is None: + raise RuntimeError( + "Backend not initialized - call setup() first to assign client_address" + ) + command.source_address = self.client_address + + command.sequence_number = self._allocate_sequence_number(command.dest_address) + message = command.build() + + log_params = command.get_log_params() + logger.debug(f"{command.__class__.__name__} parameters: {log_params}") + + await self.write(message) + response_message = await self._read_one_message(timeout=read_timeout) + assert isinstance(response_message, CommandResponse) + + action = Hoi2Action(response_message.hoi.action_code) + if action in ( + Hoi2Action.STATUS_EXCEPTION, + Hoi2Action.COMMAND_EXCEPTION, + Hoi2Action.INVALID_ACTION_RESPONSE, + ): + parsed = parse_hamilton_error_params(response_message.hoi.params) + # Layer A: always append HC_RESULT enum description (synchronous, no round-trips) + parsed_addr = HamiltonIntrospection.parse_error_address(parsed) + hc_suffix = f" [{describe_hc_result(parsed_addr[3])}]" if parsed_addr else "" + enriched_msg = f"Hamilton error {action.name} (action={action:#x}): {parsed}{hc_suffix}" + if raise_on_error: + logger.error(enriched_msg) + if not self.detailed_errors: + raise RuntimeError(enriched_msg) + # Layer B: async TypeRegistry diagnosis (method signature, expected params) + # Use the address from the error response so we resolve the method on the object that threw + intro = HamiltonIntrospection(self) + error_addr = parsed_addr[0] if parsed_addr else command.dest_address + if error_addr not in self._type_registries: + try: + self._type_registries[error_addr] = await intro.build_type_registry(error_addr) + except Exception: + raise RuntimeError(enriched_msg) + diagnostic = await intro.diagnose_error(enriched_msg, self._type_registries[error_addr]) + raise RuntimeError(diagnostic) + logger.debug(enriched_msg) + return None + + if return_raw: + return (response_message.hoi.params,) + return command.interpret_response(response_message) + + except connection_errors as e: + last_error = e + self._connected = False + if not self.auto_reconnect or attempt == max_attempts - 1: + raise + logger.warning( + f"{self.io._unique_id} Command failed (connection error), reconnecting and retrying: {e}" + ) + await self._reconnect() + + assert last_error is not None + raise last_error - # Send command - await self.write(message) + async def introspect( + self, object_path: Optional[str] = None + ) -> tuple[GlobalTypePool, TypeRegistry]: + """Build introspection data on demand (for diagnostics/validation). - # Read response (timeout handled by TCP layer) - response_message = await self._read_one_message() - assert isinstance(response_message, CommandResponse) + Queries the device for global structs/enums and optionally builds a + TypeRegistry for a specific object. Does not cache — each call queries + the device fresh. - # Check for error actions - action = Hoi2Action(response_message.hoi.action_code) - if action in ( - Hoi2Action.STATUS_EXCEPTION, - Hoi2Action.COMMAND_EXCEPTION, - Hoi2Action.INVALID_ACTION_RESPONSE, - ): - error_message = f"Error response (action={action:#x}): {response_message.hoi.params.hex()}" - logger.error(f"Hamilton error {action}: {error_message}") - raise RuntimeError(f"Hamilton error {action}: {error_message}") + Example:: - return command.interpret_response(response_message) + pool, reg = await client.introspect("MLPrepRoot.PipettorRoot.Pipettor") + result = validate_struct(MyStruct, pool_struct, pool) + sig = await intro.resolve_signature(addr, 1, 9, reg) + + Args: + object_path: Optional dot-path to build a TypeRegistry for + (e.g. "MLPrepRoot.PipettorRoot.Pipettor"). If None, returns + an empty TypeRegistry with just the global pool attached. + + Returns: + (GlobalTypePool, TypeRegistry) tuple. + """ + intro = HamiltonIntrospection(self) + pool = await intro.build_global_type_pool(self._global_object_addresses) + if object_path: + reg = await intro.build_type_registry(object_path, global_pool=pool) + else: + reg = TypeRegistry(address=None, global_pool=pool) + return pool, reg async def stop(self): - """Stop the backend and close connection.""" + """Close connection.""" try: await self.io.stop() except Exception as e: logger.warning(f"Error during stop: {e}") finally: self._connected = False - logger.info("Hamilton backend stopped") + logger.info("Hamilton TCP client stopped") def serialize(self) -> dict: - """Serialize backend configuration.""" + """Serialize client configuration.""" return { - **super().serialize(), "client_id": self._client_id, - "instrument_addresses": {k: str(v) for k, v in self._instrument_addresses.items()}, + "registry_paths": list(self._registry._objects.keys()), } diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py b/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py index 81fd3dee5fe..11f6f5e9e2b 100644 --- a/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py +++ b/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py @@ -41,6 +41,8 @@ def get_star_liquid_class( tip_volume = int( { 360.0: 300.0, + 60.0: 50.0, + 65.0: 50.0, 1065.0: 1000.0, 1250.0: 1000.0, 4367.0: 4000.0, @@ -1050,7 +1052,6 @@ def get_star_liquid_class( dispense_stop_back_volume=0.0, ) - star_mapping[(50, False, True, False, Liquid.WATER, False, True)] = ( _250ul_Piercing_Tip_Water_DispenseSurface_Empty ) = HamiltonLiquidClass( @@ -15003,3 +15004,12 @@ def get_star_liquid_class( dispense_stop_flow_rate=1.0, dispense_stop_back_volume=0.0, ) + +# Default (no jet, no blow_out) for 50 µL water: alias to Surface_Empty (closest existing class). +star_mapping[(50, False, True, False, Liquid.WATER, False, False)] = ( + Tip_50ul_Water_DispenseSurface +) = Tip_50ul_Water_DispenseSurface_Empty + +star_mapping[(50, False, True, True, Liquid.WATER, False, False)] = ( + Tip_50ulFilter_Water_DispenseSurface +) = Tip_50ulFilter_Water_DispenseSurface_Empty diff --git a/pylabrobot/resources/hamilton/__init__.py b/pylabrobot/resources/hamilton/__init__.py index 8bcc28e3f86..87dd84a9283 100644 --- a/pylabrobot/resources/hamilton/__init__.py +++ b/pylabrobot/resources/hamilton/__init__.py @@ -1,6 +1,7 @@ from .hamilton_decks import ( HamiltonDeck, HamiltonSTARDeck, + PrepDeck, STARDeck, STARLetDeck, ) diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 4f7acc2ec8b..ae391cac91d 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -2,7 +2,7 @@ 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 @@ -389,6 +389,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 @@ -618,3 +636,89 @@ def STARDeck( with_teaching_rack=with_teaching_rack, core_grippers=core_grippers, ) + + +class PrepDeck(Deck): + """Hamilton PREP deck with spots, trash, teaching tip site, and waste positions. + + Includes a teaching tip site (deck site id=2: 6x6x85 mm), using the same 300uL tip + definition as the STAR teaching rack, and three waste positions (two for dual-channel + pipettor, one for 8MPH) from DeckConfiguration waste site definitions. + """ + + def __init__( + self, + name="deck", + size_x=300.0, + size_y=394.0, + size_z=0, + origin=Coordinate.zero(), + category="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 TODO: Validate on other systems + ) + 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) + # TODO: y coordinate + self.assign_child_resource(trash, location=Coordinate(280.3, -3, 0)) + + # Same tip definition as STAR teaching rack: 300uL tip. Backend sends z_position = + # Slot height=83 mm (measured from tip top); 300uL filter: total_tip_length=59.9 mm + fine adjustment + 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), + ) + + # Waste positions from DeckConfiguration.GetWasteSiteDefinitions (index 1, 2, 3). + # Names match ChannelIndex / use_channels: 0=RearChannel, 1=FrontChannel, MPH separate. + # Channel 1 (Front): (286.8, 10, 68.4), Channel 2 (Rear): (286.8, 30, 68.4), 8MPH: (286.8, 112, 68.4) + # Assign with size (0, 0, 68.4) so get_absolute_location(..., z="t") gives drop height 68.4 + 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: + """Get labware spot by index 0-7 (column-major: 0=spot_0_0, ..., 7=spot_1_3).""" + return self.spots[key] + + def __setitem__(self, key: int, value: Resource): + """Assign resource to labware spot by index 0-7.""" + self.spots[key].assign_child_resource(value)