From sciagent-skills
Develop portable Python scripts for liquid handling across Hamilton STAR, Tecan Freedom EVO, Opentrons OT-2, or simulator. For protocol automation, plate reformatting, serial dilutions, and lab workflows.
npx claudepluginhub jaechang-hits/sciagent-skills --plugin sciagent-skillsThis skill uses the workspace's default tool permissions.
PyLabRobot is an open-source Python library that abstracts liquid handling robot hardware behind a unified API. Write a protocol once and run it on any supported robot — Hamilton STAR, Tecan Freedom EVO, Opentrons OT-2, or a simulated backend — without changing the protocol code. PyLabRobot handles deck layout, resource management, and aspirate/dispense operations through a clean, async-first i...
Controls laboratory automation equipment like liquid handlers (Hamilton STAR, Opentrons OT-2, Tecan EVO), plate readers, pumps, incubators via Python SDK. Automates workflows, manages deck layouts, simulates protocols.
Controls lab automation hardware including Hamilton STAR, Tecan EVO, Opentrons OT-2, plate readers, pumps via unified Python interface. For multi-vendor workflows, resource management, simulation.
Assists writing Python protocols for Opentrons OT-2/Flex robots using API v2. Covers pipetting, serial dilutions, PCR setup, plate replication, and modules like thermocycler, heater-shaker.
Share bugs, ideas, or general feedback.
PyLabRobot is an open-source Python library that abstracts liquid handling robot hardware behind a unified API. Write a protocol once and run it on any supported robot — Hamilton STAR, Tecan Freedom EVO, Opentrons OT-2, or a simulated backend — without changing the protocol code. PyLabRobot handles deck layout, resource management, and aspirate/dispense operations through a clean, async-first interface.
opentrons Python SDK instead; for multi-vendor portability use PyLabRobot.pylabrobotpylabrobot[hamilton] for Hamilton STAR, pylabrobot[opentrons] for OT-2pip install pylabrobot
pip install "pylabrobot[hamilton]" # add Hamilton USB driver
pip install "pylabrobot[opentrons]" # add Opentrons REST driver
import asyncio
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import SimulatorBackend
from pylabrobot.resources import Deck, Cos_96_Rd, HTF_L
async def main():
backend = SimulatorBackend(open_browser=False)
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup()
plate = Cos_96_Rd(name="plate")
tips = HTF_L(name="tips")
lh.deck.assign_child_resource(plate, rails=2)
lh.deck.assign_child_resource(tips, rails=5)
await lh.pick_up_tips(tips["A1"])
await lh.aspirate(plate["A1"], vols=50)
await lh.dispense(plate["B1"], vols=50)
await lh.drop_tips(tips["A1"])
await lh.stop()
print("Transfer complete: 50 uL from A1 -> B1")
asyncio.run(main())
The LiquidHandler class is the central controller. It wraps a backend and a Deck.
import asyncio
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import SimulatorBackend
from pylabrobot.resources import Deck
async def main():
backend = SimulatorBackend(open_browser=False)
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup() # connect to hardware / start simulator
print("LiquidHandler ready:", lh)
await lh.stop() # disconnect cleanly
asyncio.run(main())
# Connecting to a real Hamilton STAR
from pylabrobot.liquid_handling.backends.hamilton import STAR
async def main():
backend = STAR()
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup()
# lh is now connected to physical hardware
await lh.stop()
Resources (plates, tip racks, reservoirs) are placed on the deck by rail position.
from pylabrobot.resources import (
Deck,
Cos_96_Rd, # Corning 96-well round-bottom plate
Cos_384_Sq, # Corning 384-well plate
HTF_L, # Hamilton tip rack (filtered, large)
Trough_1_Row_1_Col_4, # 4-channel reservoir
)
deck = Deck()
plate_96 = Cos_96_Rd(name="sample_plate")
plate_384 = Cos_384_Sq(name="assay_plate")
tips = HTF_L(name="tip_rack")
reservoir = Trough_1_Row_1_Col_4(name="buffer")
deck.assign_child_resource(plate_96, rails=1)
deck.assign_child_resource(plate_384, rails=4)
deck.assign_child_resource(tips, rails=8)
deck.assign_child_resource(reservoir, rails=11)
print("Deck resources:", [r.name for r in deck.children])
Pick up and drop tips before and after liquid operations.
# Pick up tips from the first column of the tip rack
await lh.pick_up_tips(tips["A1:H1"]) # all 8 tips in column 1
# After liquid operations, drop tips back
await lh.drop_tips(tips["A1:H1"])
# Single tip
await lh.pick_up_tips(tips["A1"])
await lh.drop_tips(tips["A1"])
print("Tip operations complete")
Aspirate liquid from wells. Accepts single wells, ranges, or lists.
# Aspirate 100 uL from a single well
await lh.aspirate(plate["A1"], vols=100)
# Aspirate different volumes from multiple wells simultaneously
await lh.aspirate(
plate["A1:A4"],
vols=[50, 75, 100, 125],
)
print("Aspiration complete")
from pylabrobot.resources import Coordinate
# Aspirate with flow rate and liquid height control
await lh.aspirate(
plate["A1"],
vols=50,
flow_rates=100, # uL/s
offsets=Coordinate(0, 0, 1), # 1 mm above well bottom
)
Dispense liquid into target wells.
# Dispense 100 uL into a single well
await lh.dispense(plate["B1"], vols=100)
# Multi-well dispense with different volumes
await lh.dispense(
plate["B1:B4"],
vols=[50, 75, 100, 125],
)
print("Dispense complete")
transfer combines aspirate and dispense for simple source-to-destination moves.
# Transfer 50 uL from A1 -> B1
await lh.transfer(plate["A1"], plate["B1"], transfer_volume=50)
# Multi-well pairwise transfer
sources = plate["A1:A8"]
destinations = plate["B1:B8"]
await lh.transfer(sources, destinations, transfer_volume=75)
print("Transfer complete")
The SimulatorBackend runs a browser-based visualizer for protocol debugging.
from pylabrobot.liquid_handling.backends import SimulatorBackend
# With visual browser (default — opens http://localhost:2121)
backend = SimulatorBackend(open_browser=True)
# Headless simulation (CI/testing)
backend = SimulatorBackend(open_browser=False)
# After setup(), liquid movements are visualized in real time
await lh.setup()
# Check browser for visual confirmation before running on real hardware
print("Simulator running at http://localhost:2121")
All robot operations (setup, aspirate, dispense, transfer) are Python async coroutines. Run them inside an async def function using asyncio.run() or Jupyter's top-level await syntax.
import asyncio
async def run_protocol(lh, plate, tips):
await lh.pick_up_tips(tips["A1"])
await lh.aspirate(plate["A1"], vols=50)
await lh.dispense(plate["B1"], vols=50)
await lh.drop_tips(tips["A1"])
print("Protocol complete")
asyncio.run(run_protocol(lh, plate, tips))
Wells are addressed by alphanumeric position ("A1") or slice notation ("A1:H1" for a column, "A1:A12" for a row).
well = plate["A1"] # single well
col1 = plate["A1:H1"] # 8 wells in column 1
row_a = plate["A1:A12"] # 12 wells in row A
print(f"Single: {well.name}")
print(f"Column: {len(col1)} wells")
print(f"Row: {len(row_a)} wells")
Goal: Perform a 2-fold serial dilution across a 96-well plate.
import asyncio
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import SimulatorBackend
from pylabrobot.resources import Deck, Cos_96_Rd, HTF_L, Trough_1_Row_1_Col_4
async def serial_dilution():
backend = SimulatorBackend(open_browser=False)
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup()
plate = Cos_96_Rd(name="plate")
tips = HTF_L(name="tips")
diluent = Trough_1_Row_1_Col_4(name="diluent")
lh.deck.assign_child_resource(plate, rails=1)
lh.deck.assign_child_resource(tips, rails=5)
lh.deck.assign_child_resource(diluent, rails=9)
# Add 100 uL diluent to columns 2-12
for col in range(2, 13):
col_label = f"A{col}:H{col}"
await lh.pick_up_tips(tips[f"A{col}:H{col}"])
await lh.aspirate(diluent["A1:H1"], vols=100)
await lh.dispense(plate[col_label], vols=100)
await lh.drop_tips(tips[f"A{col}:H{col}"])
# Serial transfer: col 1 -> 2 -> ... -> 11
for col in range(1, 12):
src = f"A{col}:H{col}"
dst = f"A{col+1}:H{col+1}"
await lh.pick_up_tips(tips[f"A{col}:H{col}"])
await lh.aspirate(plate[src], vols=100)
await lh.dispense(plate[dst], vols=100)
await lh.drop_tips(tips[f"A{col}:H{col}"])
print("Serial dilution complete: 12 columns, 2-fold steps")
await lh.stop()
asyncio.run(serial_dilution())
Goal: Transfer compounds from specified source wells to a destination plate based on a CSV hit list.
import asyncio
import pandas as pd
from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import SimulatorBackend
from pylabrobot.resources import Deck, Cos_96_Rd, HTF_L
async def cherry_pick(hit_list_csv: str, volume: float = 50.0):
# CSV must have columns: source_well, dest_well
hits = pd.read_csv(hit_list_csv)
print(f"Cherry-picking {len(hits)} hits at {volume} uL each")
backend = SimulatorBackend(open_browser=False)
lh = LiquidHandler(backend=backend, deck=Deck())
await lh.setup()
src = Cos_96_Rd(name="source")
dst = Cos_96_Rd(name="destination")
tips = HTF_L(name="tips")
lh.deck.assign_child_resource(src, rails=1)
lh.deck.assign_child_resource(dst, rails=4)
lh.deck.assign_child_resource(tips, rails=8)
# Get all well names from tip rack
tip_wells = [w.name for w in tips.wells]
for i, row in hits.iterrows():
await lh.pick_up_tips(tips[tip_wells[i]])
await lh.transfer(src[row["source_well"]], dst[row["dest_well"]],
transfer_volume=volume)
await lh.drop_tips(tips[tip_wells[i]])
print(f"Cherry-pick complete: {len(hits)} transfers done")
await lh.stop()
# asyncio.run(cherry_pick("hits.csv", volume=50))
| Parameter | Module | Default | Range / Options | Effect |
|---|---|---|---|---|
vols | aspirate / dispense | required | 0 – robot max (µL) | Volume to aspirate or dispense per well |
flow_rates | aspirate / dispense | backend default | 10 – 1000 µL/s | Speed of liquid movement |
blow_out_air_volume | dispense | 0 | 0 – 30 µL | Air volume blown after dispense to empty tip |
offsets | aspirate / dispense | Coordinate(0,0,0) | Any Coordinate | Positional offset from well center (x, y, z mm) |
open_browser | SimulatorBackend | True | True, False | Open browser-based visual simulator on setup |
rails | deck assignment | required | 1 – max deck rails | Physical slot on the deck for a resource |
transfer_volume | transfer | required | 0 – robot max (µL) | Volume for high-level aspirate+dispense transfer |
Always test with the simulator first: Run your full protocol with SimulatorBackend(open_browser=True) before connecting to physical hardware. The browser visualizer shows deck layout and liquid movements in real time.
Use fresh tips for each transfer when contamination matters: Reusing tips in cherry-picking workflows risks cross-contamination. Track tip consumption against tip rack capacity programmatically.
Wrap protocols in try/finally for cleanup: If an exception occurs mid-protocol, always call await lh.stop() to release hardware connections.
try:
await run_my_protocol(lh)
finally:
await lh.stop()
Define resources once at the top of your script: Create resource objects and assign them to the deck once. Reassigning the same resource mid-run can desynchronize the robot's internal state tracking.
Pre-calculate volume and tip requirements: For high-throughput runs, compute total volume and tip count needed before starting, and assert that resources are sufficient.
When to use: Reagent addition to cell culture wells requiring homogeneous mixing.
async def dispense_and_mix(lh, src, dst, tips, volume=50, mix_vol=40, mix_reps=3):
await lh.pick_up_tips(tips["A1"])
await lh.aspirate(src["A1"], vols=volume)
await lh.dispense(dst["A1"], vols=volume)
for _ in range(mix_reps):
await lh.aspirate(dst["A1"], vols=mix_vol)
await lh.dispense(dst["A1"], vols=mix_vol)
await lh.drop_tips(tips["A1"])
print(f"Dispensed {volume} uL and mixed {mix_reps}x")
When to use: Replicate an entire 96-well plate to a second plate.
async def stamp_plate(lh, src_plate, dst_plate, tips, volume=100):
for col in range(1, 13):
col_label = f"A{col}:H{col}"
await lh.pick_up_tips(tips[col_label])
await lh.aspirate(src_plate[col_label], vols=volume)
await lh.dispense(dst_plate[col_label], vols=volume)
await lh.drop_tips(tips[col_label])
print(f"Full plate stamped: {volume} uL per well, 12 columns")
http://localhost:2121 shows animated deck with per-well volume trackingpandas / CSV logging in wrapper code as needed| Problem | Cause | Solution |
|---|---|---|
RuntimeError: No backend connected | lh.setup() not awaited before operations | Ensure await lh.setup() completes before any liquid handling call |
ResourceNotFoundError | Resource name not assigned to deck | Call deck.assign_child_resource(resource, rails=N) before referencing wells |
asyncio.InvalidStateError | Coroutine called outside async context | Wrap top-level calls in async def main() and use asyncio.run(main()) |
Well address KeyError | Incorrect well label format | Use uppercase letter + integer: "A1", "H12", not "a1" or "A01" |
VolumeError: exceeds tip capacity | Requested volume larger than tip max | Use appropriate tip type; HTF_L holds up to 1000 µL |
| Simulator shows no movement | open_browser=False with no viewer | Set open_browser=True or open http://localhost:2121 manually |
ImportError: pylabrobot.hamilton | Backend extras not installed | pip install "pylabrobot[hamilton]" |