Hamilton Prep: getting started#

The Prep driver in the STAR v1 architecture, from setup to plate movement:

  1. Setup - a simulated or real Prep, and what it reports about itself

  2. Moving - the X arm, and the channels along Y and Z, each axis on its own

  3. Teaching needle - pick it up, move over a plate, put it back

  4. Dual-channel liquid handling - tips, aspirate, dispense, with tip and volume tracking

  5. The 8-channel head - when the device has one

  6. Probing - capacitive LLD along Z and along Y

  7. Plate movement - CoRe grippers

  8. Deck light, a declared configuration, and putting it down

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.

prep is a 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.

1. Imports and config#

import logging
import tempfile
from pathlib import Path

from pylabrobot.hamilton.prep import Prep
from pylabrobot.resources import Coordinate, set_tip_tracking, set_volume_tracking
from pylabrobot.resources.corning.axygen.plates import cor_axy_96_wellplate_500uL_Ub
from pylabrobot.resources.hamilton import PrepDeck, hamilton_96_tiprack_50uL_NTR

SIMULATION = True  # False to drive a Prep on the network
HOST = "192.168.100.102"  # ignored in simulation
PORT = 2000

# Commands and answers are logged at DEBUG (decoded) and at pylabrobot.io.LOG_LEVEL_IO (raw frames).
logging.getLogger("pylabrobot").setLevel(logging.WARNING)

# Opt-in labware tracking: tip spots and well volumes follow what the channels do.
set_tip_tracking(True)
set_volume_tracking(True)

2. Deck and device#

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.

deck = PrepDeck(with_core_grippers=True)
tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr_50", with_tips=True)
plate = deck[4] = cor_axy_96_wellplate_500uL_Ub("plate")

prep = Prep(deck=deck, simulation=SIMULATION, host=HOST, port=PORT)
await prep.setup()
print(prep.driver.format_setup_summary())
[Hamilton Prep] Connected on simulation (no link)
  Serial: PRPAA1087
  Firmware: MLPrep Runtime V1.2.2.444 99020-02 Rev G
  Configuration: enclosure none, safe speeds off, traverse height 167.5 mm
  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
  Pipettes: 2, v2 aspirate/dispense
    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
    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
  8-channel head: none

3. What the device reports#

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.

configuration = prep.driver.configuration
print(configuration.serial_number, "|", configuration.firmware_version)
print("channels:", configuration.num_channels, "| 8-channel head:", configuration.head8_installed)
print("channel order (firmware ChannelIndex, back to front):", [int(c) for c in prep.pipettes.channel_order])
print("Y windows:", [c.y_range for c in prep.pipettes.configuration.channels])
print("minimum Y spacing:", prep.pipettes._min_spacing_between(0, 1), "mm")
print("teaching needle at", deck.get_resource("teaching_tip").location)

configuration_path = str(Path(tempfile.gettempdir()) / "prep_configuration.json")
prep.driver.save_configuration(configuration_path)
print("saved", Path(configuration_path).name, "in the temporary directory")
PRPAA1087 | MLPrep Runtime V1.2.2.444 99020-02 Rev G
channels: 2 | 8-channel head: False
channel order (firmware ChannelIndex, back to front): [2, 1]
Y windows: [(0.0, 385.0), (-9.0, 376.0)]
minimum Y spacing: 9.0 mm
teaching needle at Coordinate(284.760, 214.290, 023.850)
saved prep_configuration.json in the temporary directory

4. Moving#

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.

await prep.x_arm.move_to_x_position(150, speed=200)

await prep.pipettes.move_to_y_positions({0: 250}, make_space=True, speed=100)
await prep.pipettes.move_to_y_positions({0: 200, 1: 150}, speed=100)
await prep.pipettes.move_tool_bottom_to_z_positions({0: 150, 1: 140}, speed=50, acceleration=400)
await prep.pipettes.move_to_safe_z()

for channel, location in enumerate(await prep.pipettes.request_locations()):
  print(f"channel {channel}: x {location.x:.2f}, y {location.y:.2f}, z {location.z:.2f}")
channel 0: x 150.00, y 200.00, z 167.50
channel 1: x 150.00, y 150.00, z 167.50
# A move that would put the channels too close is refused before anything is sent, saying what would fit.
try:
  await prep.pipettes.move_to_y_positions({0: 150})
except ValueError as error:
  print(error)
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.

5. Teaching needle#

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.

SAFE_HEIGHT_ABOVE_WELL = 20

teaching_tip = deck.get_resource("teaching_tip")
if not teaching_tip.has_tip():  # with tip tracking on, the spot starts out without its needle
  teaching_tip.tracker.add_tip(teaching_tip.make_tip(), origin=teaching_tip, commit=True)
await prep.pipettes.pick_up_tips([teaching_tip], use_channels=[0])

a1 = plate.get_item("A1").get_location_wrt(deck, "c", "c", "b")
await prep.pipettes.move_to_y_positions({0: a1.y}, make_space=True)
await prep.pipettes.move_to_location(Coordinate(a1.x, a1.y, a1.z + SAFE_HEIGHT_ABOVE_WELL), use_channels=0)

await prep.pipettes.drop_tips([teaching_tip], use_channels=[0])

6. Tip pickup, aspirate, dispense (dual channel)#

tip_spots = tip_rack["A1:B1"]
channels = [0, 1]
source = plate["A1:B1"]
destination = plate["A7:B7"]
volumes = [35.0, 25.0]
for well in source:
  well.tracker.set_volume(100.0)

def status(label):
  print(f"{label}: mounted {[t.name if t else None for t in prep.pipettes.get_mounted_tips()]}, "
        f"source {[w.tracker.get_used_volume() for w in source]}, "
        f"destination {[w.tracker.get_used_volume() for w in destination]}")

await prep.pipettes.pick_up_tips(tip_spots, use_channels=channels)
status("after pickup")
await prep.pipettes.aspirate(source, vols=volumes, use_channels=channels, liquid_height=[3.0, 3.0])
status("after aspirate")
await prep.pipettes.dispense(destination, vols=volumes, use_channels=channels, liquid_height=[3.0, 3.0])
status("after dispense")
await prep.pipettes.drop_tips(tip_spots, use_channels=channels)
status("after drop")
after pickup: mounted ['ntr_50_tipspot_A1#0', 'ntr_50_tipspot_B1#0'], source [100.0, 100.0], destination [0, 0]
after aspirate: mounted ['ntr_50_tipspot_A1#0', 'ntr_50_tipspot_B1#0'], source [65.0, 75.0], destination [0, 0]
after dispense: mounted ['ntr_50_tipspot_A1#0', 'ntr_50_tipspot_B1#0'], source [65.0, 75.0], destination [35.0, 25.0]
after drop: mounted [None, None], source [65.0, 75.0], destination [35.0, 25.0]

7. The 8-channel head#

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.

if prep.head8 is None:
  print("no 8-channel head on this device; skipping")
else:
  tip_spots8 = tip_rack["A2:H2"]
  source8, destination8 = plate["A2:H2"], plate["A4:H4"]
  for well in source8:
    well.tracker.set_volume(100.0)
  await prep.head8.pick_up_tips(tip_spots8)
  await prep.head8.aspirate(wells=source8, volume=15.0, liquid_height=3.0)
  await prep.head8.dispense(wells=destination8, volume=15.0, liquid_height=3.0)
  await prep.head8.drop_tips(tip_spots8)
no 8-channel head on this device; skipping

8. Probing with capacitive LLD#

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.

height = await prep.pipettes.probe_z_using_clld(1, lowest_immers_pos=100.0, speed=20.0)
print("Z probe:", height)

try:
  surface = await prep.pipettes.clld_probe_y_position_using_channel(1, "forward", end_pos_search=60.0, speed=5.0)
  print("Y probe: surface at", surface)
except RuntimeError as error:
  print("Y probe:", error)
Z probe: None
Y probe: channel 1 detected nothing between y=352.50 and y=60.00 mm

9. Plate movement with CoRe grippers (deck[4] to deck[2])#

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.

arm = await prep.driver.pick_up_core_grippers()
await arm.pick_up_resource(plate)
await arm.drop_resource(deck[2])
await prep.driver.return_core_grippers()
print("plate is now on", plate.parent.name)
plate is now on spot_0_2

10. Deck light, a declared configuration, and putting it down#

before = await prep.driver.request_deck_light()
await prep.driver.set_deck_light(white=0, red=0, green=255, blue=0)
await prep.driver.set_deck_light(*before)

# A simulated Prep answering as the saved configuration says.
declared = Prep(simulation=True, declared_configuration_json=configuration_path)
await declared.setup()
print("declared:", declared.driver.configuration.serial_number, declared.driver.configuration.num_channels, "channels")
await declared.stop()
declared: PRPAA1087 2 channels
await prep.driver.park()
await prep.stop()  # raises the channels to Z safety, then closes the link