UniteLabs
Tutorial

Liquid handling as code

One simulator script that defines labware, places it on a Hamilton STAR deck, transfers 100 µL into a plate column and leaves the robot in a safe state.

A liquid handler only moves to positions it can compute. Before any command, the SDK needs to know which labware exists, where it sits on the deck, and which device the deck belongs to. Every liquid handling script follows that order: labware, deck, device, commands. The script below walks it once against a simulated Hamilton STAR, so you need no hardware.

You need uv and access to the UniteLabs package registry (see Set up your development machine). Then create a project:

uv init first-transfer && cd first-transfer
uv add unitelabs-labware unitelabs-liquid-handling
transfer.py
import asyncio

from unitelabs.labware import PredefinedLiquids, Standard96Plate, StandardTrough
from unitelabs.labware.hamilton import (
    PLT_CAR_L5MD_A00,
    TIP_CAR_480_A00,
    HamiltonTip_300,
    HamiltonTipRack_300,
    LiquidClass,
)
from unitelabs.liquid_handling.testing import MicrolabSTARMock


async def main():
    # 1. Labware: every object the robot will touch, with a stable identifier
    plate = Standard96Plate(identifier="destination_plate")
    trough = StandardTrough(identifier="water_source")
    trough.containers[0].add_liquid(PredefinedLiquids.WATER, 150_000)  # µL
    tips = HamiltonTipRack_300(filled_with=HamiltonTip_300)

    # 2. Carriers hold labware, the way they sit on the real deck
    tip_carrier = TIP_CAR_480_A00(identifier="tip_carrier")
    plate_carrier = PLT_CAR_L5MD_A00(identifier="plate_carrier")
    tip_carrier[0] = tips
    plate_carrier[0] = plate
    plate_carrier[1] = trough

    # 3. Device: a simulated Hamilton STAR, then its deck
    lh = MicrolabSTARMock()
    await lh.configure()
    await lh.initialize()
    lh.deck.add(tip_carrier, track=7)
    lh.deck.add(plate_carrier, track=1)

    # 4. Commands: 100 µL from the trough into column 1, eight channels at once
    water = LiquidClass.HamiltonTip_300_Water_DispenseJet_Empty()
    await lh.pipettes.pick_up_tips_from(channels=range(8), rack=tips)
    try:
        await lh.pipettes.aspirate(
            source=trough, channels=range(8), volume=100, liquid_class=water
        )
        await lh.pipettes.dispense(
            target=plate["A1":"H1"],
            channels=range(8),
            volume=100,
            liquid_class=water,
        )
    finally:
        # 5. Safe state: drop the tips even if a command failed
        await lh.pipettes.discard_tips(channels=range(8))

    # The simulator tracked every volume
    print("Trough:", trough.containers[0].volume, "µL")
    print("A1:", plate["A1"].container.volume, "µL")


asyncio.run(main())

Run it with uv run transfer.py. It prints Trough: 149200 µL and A1: 100 µL: eight channels each took 100 µL from the trough and put it into column 1. The simulator tracks every volume, so it would also catch an overfilled well or an empty trough before real samples are at stake.

Three things in the script carry over to every real one:

PartWhy it matters
IdentifiersA stable identifier lets you save the deck and load it again on the next run. Without one, names change every run.
The deckCommands find wells through the chain deck → carrier → labware → well. Place something wrongly and the simulator raises before anything moves.
try / finallyA run can fail halfway. The finally block puts the robot back into a safe state, here without tips, so the next run doesn't start with tips loaded.

To run the same script on a real STAR, replace MicrolabSTARMock() with MicrolabSTAR(name="Microlab STAR", client=AsyncApiClient()), imported from unitelabs.liquid_handling.hamilton and unitelabs.sdk. Use the name your instrument has in GroundControl.

Next steps

Last updated