{ "cells": [ { "cell_type": "markdown", "id": "8ae8432c", "metadata": {}, "source": [ "# Hamilton Prep: getting started\n", "\n", "The Prep driver in the STAR v1 architecture, from setup to plate movement:\n", "\n", "1. **Setup** - a simulated or real Prep, and what it reports about itself\n", "2. **Moving** - the X arm, and the channels along Y and Z, each axis on its own\n", "3. **Teaching needle** - pick it up, move over a plate, put it back\n", "4. **Dual-channel liquid handling** - tips, aspirate, dispense, with tip and volume tracking\n", "5. **The 8-channel head** - when the device has one\n", "6. **Probing** - capacitive LLD along Z and along Y\n", "7. **Plate movement** - CoRe grippers\n", "8. **Deck light, a declared configuration, and putting it down**\n", "\n", "`SIMULATION = True` runs everything against `PrepSimulationDriver`, which answers from a recorded Prep (PRPAA1087, MLPrep Runtime V1.2.2), so the notebook runs without a device. Set it to `False` and `HOST` to your Prep's address to run the same cells on a device.\n", "\n", "`prep` is a {class}`~pylabrobot.hamilton.prep.device.PrepDevice`: the device is a resource and its deck is its child. `prep.driver` speaks to the device; `prep.pipettes`, `prep.x_arm`, `prep.head8` and `prep.core_grippers` are its features, built at setup." ] }, { "cell_type": "markdown", "id": "466d9993", "metadata": {}, "source": [ "## 1. Imports and config" ] }, { "cell_type": "code", "execution_count": 1, "id": "e7651097", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:48.676045Z", "iopub.status.busy": "2026-09-14T22:26:48.675863Z", "iopub.status.idle": "2026-09-14T22:26:51.224648Z", "shell.execute_reply": "2026-09-14T22:26:51.224124Z" } }, "outputs": [], "source": [ "import logging\n", "import tempfile\n", "from pathlib import Path\n", "\n", "from pylabrobot.hamilton.prep import Prep\n", "from pylabrobot.resources import Coordinate, set_tip_tracking, set_volume_tracking\n", "from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub\n", "from pylabrobot.resources.hamilton import PrepDeck, hamilton_96_tiprack_50uL_NTR\n", "\n", "SIMULATION = True # False to drive a Prep on the network\n", "HOST = \"192.168.100.102\" # ignored in simulation\n", "PORT = 2000\n", "\n", "# Commands and answers are logged at DEBUG (decoded) and at pylabrobot.io.LOG_LEVEL_IO (raw frames).\n", "logging.getLogger(\"pylabrobot\").setLevel(logging.WARNING)\n", "\n", "# Opt-in labware tracking: tip spots and well volumes follow what the channels do.\n", "set_tip_tracking(True)\n", "set_volume_tracking(True)" ] }, { "cell_type": "markdown", "id": "749ac79c", "metadata": {}, "source": [ "## 2. Deck and device\n", "\n", "`PrepDeck` has eight labware spots (column-major, `deck[0]`..`deck[7]`), the waste block, the liquid waste container, the teaching needle and the waste positions; `with_core_grippers=True` adds the CoRe gripper mount." ] }, { "cell_type": "code", "execution_count": 2, "id": "21b22518", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.226350Z", "iopub.status.busy": "2026-09-14T22:26:51.226196Z", "iopub.status.idle": "2026-09-14T22:26:51.274215Z", "shell.execute_reply": "2026-09-14T22:26:51.273775Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "[Hamilton Prep] Connected on simulation (no link)\n", " Serial: PRPAA1087\n", " Firmware: MLPrep Runtime V1.2.2.444 99020-02 Rev G\n", " Configuration: enclosure none, safe speeds off, traverse height 167.5 mm\n", " Deck: x 0.00 to 299.00 mm, y -9.00 to 385.00 mm, z 18.03 to 167.50 mm; 3 sites, 3 waste sites\n", " Pipettes: 2, v2 aspirate/dispense\n", " channel 0 (rear): firmware Channel1ml Runtime V1.4.9.249 98228-02 Rev K, x 0.19 to 299.19 mm, y 0.00 to 385.00 mm, z 18.03 to 167.50 mm\n", " channel 1 (front): firmware Channel1ml Runtime V1.4.9.249 98228-02 Rev K, x 0.19 to 299.19 mm, y -9.00 to 376.00 mm, z 18.03 to 167.50 mm\n", " 8-channel head: none\n" ] } ], "source": [ "deck = PrepDeck(with_core_grippers=True)\n", "tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name=\"ntr_50\", with_tips=True)\n", "plate = deck[4] = cor_axy_96_wellplate_500uL_Ub(\"plate\")\n", "\n", "prep = Prep(deck=deck, simulation=SIMULATION, host=HOST, port=PORT)\n", "await prep.setup()\n", "print(prep.driver.format_setup_summary())" ] }, { "cell_type": "markdown", "id": "b990b0f1", "metadata": {}, "source": [ "## 3. What the device reports\n", "\n", "Setup reads the device: serial and firmware, what is fitted, the deck and channel bounds, and where the teaching needle and waste positions are - which it places on the deck. The configuration can be saved, and declared later: a real device is then checked against it at setup, and a simulated one answers as it says." ] }, { "cell_type": "code", "execution_count": 3, "id": "8874568c", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.275542Z", "iopub.status.busy": "2026-09-14T22:26:51.275452Z", "iopub.status.idle": "2026-09-14T22:26:51.284573Z", "shell.execute_reply": "2026-09-14T22:26:51.284183Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "PRPAA1087 | MLPrep Runtime V1.2.2.444 99020-02 Rev G\n", "channels: 2 | 8-channel head: False\n", "channel order (firmware ChannelIndex, back to front): [2, 1]\n", "Y windows: [(0.0, 385.0), (-9.0, 376.0)]\n", "minimum Y spacing: 9.0 mm\n", "teaching needle at Coordinate(284.760, 214.290, 023.850)\n", "saved prep_configuration.json in the temporary directory\n" ] } ], "source": [ "configuration = prep.driver.configuration\n", "print(configuration.serial_number, \"|\", configuration.firmware_version)\n", "print(\"channels:\", configuration.num_channels, \"| 8-channel head:\", configuration.head8_installed)\n", "print(\"channel order (firmware ChannelIndex, back to front):\", [int(c) for c in prep.pipettes.channel_order])\n", "print(\"Y windows:\", [c.y_range for c in prep.pipettes.configuration.channels])\n", "print(\"minimum Y spacing:\", prep.pipettes._min_spacing_between(0, 1), \"mm\")\n", "print(\"teaching needle at\", deck.get_resource(\"teaching_tip\").location)\n", "\n", "configuration_path = str(Path(tempfile.gettempdir()) / \"prep_configuration.json\")\n", "prep.driver.save_configuration(configuration_path)\n", "print(\"saved\", Path(configuration_path).name, \"in the temporary directory\")" ] }, { "cell_type": "markdown", "id": "0b7a4fa1", "metadata": {}, "source": [ "## 4. Moving\n", "\n", "The X arm moves the gantry both channels ride. `move_to_y_positions` and `move_tool_bottom_to_z_positions` move one axis for all channels in one command (`ChannelXYZCoordinator.MoveYAbsolute` / `MoveZAbsolute`), each at a `speed` in mm/s; the Z moves also take an `acceleration` in mm/s2. Channels are numbered from the back and stay at least their minimum spacing apart: `make_space=True` lets a channel that is in the way move aside." ] }, { "cell_type": "code", "execution_count": 4, "id": "1aac949f", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.286285Z", "iopub.status.busy": "2026-09-14T22:26:51.286170Z", "iopub.status.idle": "2026-09-14T22:26:51.301930Z", "shell.execute_reply": "2026-09-14T22:26:51.301571Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "channel 0: x 150.00, y 200.00, z 167.50\n", "channel 1: x 150.00, y 150.00, z 167.50\n" ] } ], "source": [ "await prep.x_arm.move_to_x_position(150, speed=200)\n", "\n", "await prep.pipettes.move_to_y_positions({0: 250}, make_space=True, speed=100)\n", "await prep.pipettes.move_to_y_positions({0: 200, 1: 150}, speed=100)\n", "await prep.pipettes.move_tool_bottom_to_z_positions({0: 150, 1: 140}, speed=50, acceleration=400)\n", "await prep.pipettes.move_to_safe_z()\n", "\n", "for channel, location in enumerate(await prep.pipettes.request_locations()):\n", " print(f\"channel {channel}: x {location.x:.2f}, y {location.y:.2f}, z {location.z:.2f}\")" ] }, { "cell_type": "code", "execution_count": 5, "id": "e5c9874d", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.302925Z", "iopub.status.busy": "2026-09-14T22:26:51.302854Z", "iopub.status.idle": "2026-09-14T22:26:51.305026Z", "shell.execute_reply": "2026-09-14T22:26:51.304803Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Channels 0 and 1 would be 0.00 mm apart (y=150.00 and y=150.00 mm); they must be at least 9.0 mm apart. Send channel 0 to y >= 159.00 or channel 1 to y <= 141.00. Channel 1 was not named, so it stays at y=150.00 mm; make_space=True moves it out of the way.\n" ] } ], "source": [ "# A move that would put the channels too close is refused before anything is sent, saying what would fit.\n", "try:\n", " await prep.pipettes.move_to_y_positions({0: 150})\n", "except ValueError as error:\n", " print(error)" ] }, { "cell_type": "markdown", "id": "d74bae25", "metadata": {}, "source": [ "## 5. Teaching needle\n", "\n", "Pick up the teaching needle, move it over well A1 at a safe height, and put it back. On a device, check the needle's pickup height first: on PRPAA1087 its top was measured 1.6 mm below the deck model's." ] }, { "cell_type": "code", "execution_count": 6, "id": "1a26528b", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.306100Z", "iopub.status.busy": "2026-09-14T22:26:51.306032Z", "iopub.status.idle": "2026-09-14T22:26:51.310506Z", "shell.execute_reply": "2026-09-14T22:26:51.310212Z" } }, "outputs": [], "source": [ "SAFE_HEIGHT_ABOVE_WELL = 20\n", "\n", "teaching_tip = deck.get_resource(\"teaching_tip\")\n", "if not teaching_tip.has_tip(): # with tip tracking on, the spot starts out without its needle\n", " teaching_tip.tracker.add_tip(teaching_tip.make_tip(), origin=teaching_tip, commit=True)\n", "await prep.pipettes.pick_up_tips([teaching_tip], use_channels=[0])\n", "\n", "a1 = plate.get_item(\"A1\").get_location_wrt(deck, \"c\", \"c\", \"b\")\n", "await prep.pipettes.move_to_y_positions({0: a1.y}, make_space=True)\n", "await prep.pipettes.move_to_location(Coordinate(a1.x, a1.y, a1.z + SAFE_HEIGHT_ABOVE_WELL), use_channels=0)\n", "\n", "await prep.pipettes.drop_tips([teaching_tip], use_channels=[0])" ] }, { "cell_type": "markdown", "id": "02838533", "metadata": {}, "source": [ "## 6. Tip pickup, aspirate, dispense (dual channel)" ] }, { "cell_type": "code", "execution_count": 7, "id": "060b8662", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.311472Z", "iopub.status.busy": "2026-09-14T22:26:51.311411Z", "iopub.status.idle": "2026-09-14T22:26:51.315898Z", "shell.execute_reply": "2026-09-14T22:26:51.315618Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "after pickup: mounted ['ntr_50_tipspot_A1#0', 'ntr_50_tipspot_B1#0'], source [100.0, 100.0], destination [0, 0]\n", "after aspirate: mounted ['ntr_50_tipspot_A1#0', 'ntr_50_tipspot_B1#0'], source [65.0, 75.0], destination [0, 0]\n", "after dispense: mounted ['ntr_50_tipspot_A1#0', 'ntr_50_tipspot_B1#0'], source [65.0, 75.0], destination [35.0, 25.0]\n", "after drop: mounted [None, None], source [65.0, 75.0], destination [35.0, 25.0]\n" ] } ], "source": [ "tip_spots = tip_rack[\"A1:B1\"]\n", "channels = [0, 1]\n", "source = plate[\"A1:B1\"]\n", "destination = plate[\"A7:B7\"]\n", "volumes = [35.0, 25.0]\n", "for well in source:\n", " well.tracker.set_volume(100.0)\n", "\n", "def status(label):\n", " print(f\"{label}: mounted {[t.name if t else None for t in prep.pipettes.get_mounted_tips()]}, \"\n", " f\"source {[w.tracker.get_used_volume() for w in source]}, \"\n", " f\"destination {[w.tracker.get_used_volume() for w in destination]}\")\n", "\n", "await prep.pipettes.pick_up_tips(tip_spots, use_channels=channels)\n", "status(\"after pickup\")\n", "await prep.pipettes.aspirate(source, vols=volumes, use_channels=channels, liquid_height=[3.0, 3.0])\n", "status(\"after aspirate\")\n", "await prep.pipettes.dispense(destination, vols=volumes, use_channels=channels, liquid_height=[3.0, 3.0])\n", "status(\"after dispense\")\n", "await prep.pipettes.drop_tips(tip_spots, use_channels=channels)\n", "status(\"after drop\")" ] }, { "cell_type": "markdown", "id": "cfc8a4c4", "metadata": {}, "source": [ "## 7. The 8-channel head\n", "\n", "Skipped on a device without one. To simulate a Prep with the head, build it with `declared_configuration_json` pointing at `pylabrobot/hamilton/prep/driver/recordings/prep_PRPAA1087_v1_2_2_head8.json`." ] }, { "cell_type": "code", "execution_count": 8, "id": "c9a86a23", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.316832Z", "iopub.status.busy": "2026-09-14T22:26:51.316762Z", "iopub.status.idle": "2026-09-14T22:26:51.318523Z", "shell.execute_reply": "2026-09-14T22:26:51.318136Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "no 8-channel head on this device; skipping\n" ] } ], "source": [ "if prep.head8 is None:\n", " print(\"no 8-channel head on this device; skipping\")\n", "else:\n", " tip_spots8 = tip_rack[\"A2:H2\"]\n", " source8, destination8 = plate[\"A2:H2\"], plate[\"A4:H4\"]\n", " for well in source8:\n", " well.tracker.set_volume(100.0)\n", " await prep.head8.pick_up_tips(tip_spots8)\n", " await prep.head8.aspirate(wells=source8, volume=15.0, liquid_height=3.0)\n", " await prep.head8.dispense(wells=destination8, volume=15.0, liquid_height=3.0)\n", " await prep.head8.drop_tips(tip_spots8)" ] }, { "cell_type": "markdown", "id": "4c799805", "metadata": {}, "source": [ "## 8. Probing with capacitive LLD\n", "\n", "`probe_z_using_clld` lowers a channel where it stands until its cLLD triggers, and returns the height (None when nothing triggers); on PRPAA1087 it found a finger and the teaching needle's top. `clld_probe_y_position_using_channel` searches along Y from where the channel stands, kept clear of its neighbours; its detection is not yet reliable on a device. The simulator has nothing to detect." ] }, { "cell_type": "code", "execution_count": 9, "id": "e719d757", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.319306Z", "iopub.status.busy": "2026-09-14T22:26:51.319243Z", "iopub.status.idle": "2026-09-14T22:26:51.322613Z", "shell.execute_reply": "2026-09-14T22:26:51.322296Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Z probe: None\n", "Y probe: channel 1 detected nothing between y=352.50 and y=60.00 mm\n" ] } ], "source": [ "height = await prep.pipettes.probe_z_using_clld(1, lowest_immers_pos=100.0, speed=20.0)\n", "print(\"Z probe:\", height)\n", "\n", "try:\n", " surface = await prep.pipettes.clld_probe_y_position_using_channel(1, \"forward\", end_pos_search=60.0, speed=5.0)\n", " print(\"Y probe: surface at\", surface)\n", "except RuntimeError as error:\n", " print(\"Y probe:\", error)" ] }, { "cell_type": "markdown", "id": "182f82d1", "metadata": {}, "source": [ "## 9. Plate movement with CoRe grippers (deck[4] to deck[2])\n", "\n", "`drop_resource` reassigns the plate in the resource tree. On a device, check the gripper mount position first: the deck's default is not yet measured." ] }, { "cell_type": "code", "execution_count": 10, "id": "cb65729e", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.323567Z", "iopub.status.busy": "2026-09-14T22:26:51.323497Z", "iopub.status.idle": "2026-09-14T22:26:51.327466Z", "shell.execute_reply": "2026-09-14T22:26:51.327123Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "plate is now on spot_0_2\n" ] } ], "source": [ "arm = await prep.driver.pick_up_core_grippers()\n", "await arm.pick_up_resource(plate)\n", "await arm.drop_resource(deck[2])\n", "await prep.driver.return_core_grippers()\n", "print(\"plate is now on\", plate.parent.name)" ] }, { "cell_type": "markdown", "id": "f43c3f2b", "metadata": {}, "source": [ "## 10. Deck light, a declared configuration, and putting it down" ] }, { "cell_type": "code", "execution_count": 11, "id": "d7d0d253", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.328238Z", "iopub.status.busy": "2026-09-14T22:26:51.328186Z", "iopub.status.idle": "2026-09-14T22:26:51.362813Z", "shell.execute_reply": "2026-09-14T22:26:51.362529Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "declared: PRPAA1087 2 channels\n" ] } ], "source": [ "before = await prep.driver.request_deck_light()\n", "await prep.driver.set_deck_light(white=0, red=0, green=255, blue=0)\n", "await prep.driver.set_deck_light(*before)\n", "\n", "# A simulated Prep answering as the saved configuration says.\n", "declared = Prep(simulation=True, declared_configuration_json=configuration_path)\n", "await declared.setup()\n", "print(\"declared:\", declared.driver.configuration.serial_number, declared.driver.configuration.num_channels, \"channels\")\n", "await declared.stop()" ] }, { "cell_type": "code", "execution_count": 12, "id": "8578a9bc", "metadata": { "execution": { "iopub.execute_input": "2026-09-14T22:26:51.363783Z", "iopub.status.busy": "2026-09-14T22:26:51.363725Z", "iopub.status.idle": "2026-09-14T22:26:51.381218Z", "shell.execute_reply": "2026-09-14T22:26:51.380916Z" } }, "outputs": [], "source": [ "await prep.driver.park()\n", "await prep.stop() # raises the channels to Z safety, then closes the link" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.12" } }, "nbformat": 4, "nbformat_minor": 5 }