from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend
from pylabrobot.resources import (
STARLetDeck, PLT_CAR_L5AC_A00, cor_96_wellplate_360uL_Fb,
opentrons_96_filtertiprack_200ul, TIP_CAR_288_C00,
)
lh = LiquidHandler(backend=LiquidHandlerChatterboxBackend(), deck=STARLetDeck())
await lh.setup()
carrier = PLT_CAR_L5AC_A00(name="carrier")
lh.deck.assign_child_resource(carrier, rails=10)
carrier[0] = plate = cor_96_wellplate_360uL_Fb(name="plate")
tips = TIP_CAR_288_C00(name="tip_carrier")
lh.deck.assign_child_resource(tips, rails=2)
tips[0] = tip_rack = opentrons_96_filtertiprack_200ul(name="tips")
plate["A1"][0].set_volume(1000) # a source with liquid8 The shortcuts
8.1 One call instead of twenty lines
Distribute a volume from one source well into several target wells.
await lh.pick_up_tips(tip_rack["A1"])
await lh.transfer(
plate["A1"][0],
targets=[plate["B1"][0], plate["C1"][0]],
target_vols=[40, 60],
)
await lh.drop_tips(tip_rack["A1"])- 1
-
sourceis a singleWell. - 2
-
targetsis a list ofWells — one dispense per target, all from the same aspirated volume. - 3
-
target_volssets each dispense; the aspirate volume is the sum (100 µL here).
source_vol and target_vols are mutually exclusive
transfer(..., source_vol=..., target_vols=...) raises TypeError: Cannot specify source_vol and target_vols at the same time. Pick one source of truth: give target_vols (aspirate volume is derived), or give source_vol plus ratios:
# 90 µL out, split 1:2 -> 30 µL and 60 µL
await lh.transfer(source, targets=[b, c], source_vol=90, ratios=[1, 2])See also: ratios scales without knowing the target count in advance — the natural fit for the standard-curve or dilution patterns in chapter 7.
8.2 Set default channels
Set the channels used by every operation in a block, then revert.
await lh.pick_up_tips(tip_rack["A3", "B3"])
with lh.use_channels([0, 1]):
await lh.aspirate(plate["D1", "E1"], vols=[30, 30])
await lh.dispense(plate["D2", "E2"], vols=[30, 30])
await lh.drop_tips(tip_rack["A3", "B3"])- 1
-
use_channelsis a context manager: inside thewith, every operation defaults to channels 0 and 1. You no longer passuse_channels=on each call. - 2
- Outside the block, the default reverts — the drop uses whatever the default head is.
Gotcha: use_channels is a synchronous context manager.
It is with lh.use_channels(...), not async with. Using async with raises TypeError: ... does not support the asynchronous context manager protocol. The operations inside the block are still awaited — only the channel scoping is sync.
See also: use_channels combines with the [well] * n idiom from chapter 4 when several channels share one source.
8.3 96-head operations
Aspirate from every well of a plate at once, or move all 96 tips in one call.
await lh.pick_up_tips96(tip_rack)
await lh.aspirate96(plate, volume=25)
await lh.dispense96(plate, volume=25)
await lh.drop_tips96(tip_rack)- 1
-
pick_up_tips96takes a wholeTipRack— all 96 tips, one call. - 2
-
aspirate96(plate, volume)takes the plate and a scalar volume, not a list — every well gets the same amount. - 3
-
dispense96mirrors it. A full plate processed in three calls.
Picking up tips from tips.
Aspirating 25.0 from Plate(name='plate', size_x=127.76, size_y=85.48, size_z=14.2, stacking_z_height=None, location=Coordinate(000.000, 000.000, -03.030)).
Dispensing 25.0 to Plate(name='plate', size_x=127.76, size_y=85.48, size_z=14.2, stacking_z_height=None, location=Coordinate(000.000, 000.000, -03.030)).
Dropping tips to tips.
The whole-plate flavor.
aspirate96(plate) reads the entire plate. To hit only a column or a subset, pass a list of wells instead — aspirate96(plate["A1:H1"], volume=25) works the same way. You keep the scalar volume either way.
See also: stamp() is supposed to be the plate-to-plate shortcut built on the 96-head — see below for why it is not usable yet.
8.4 The one that is broken: stamp()
lh.stamp() in 0.2.2
stamp(source, target, volume) dispenses back into source, not target. The narration proves it — run it and both operations name the source plate:
Aspirating 25.0 from Plate(name='stamp_source', ...)
Dispensing 25.0 to Plate(name='stamp_source', ...) # should be stamp_target
target is used only to check the two plates are the same shape. The bug is upstream in liquid_handling/liquid_handler.py (stamp() at line ~2001 dispenses into source). Until it is fixed, do a plate-to-plate transfer the explicit way — aspirate96(source) then dispense96(target) — and if you can, file the upstream PR; it is a two-line fix.
8.5 What to remember
transfer= aspirate once, dispense many. Usetarget_volsorsource_vol+ratios, never both.use_channelsis awithblock that temporarily changes the default channel set.- The
*96family takes a whole plate or rack and a scalar volume — three calls for a full plate. stamp()is broken in 0.2.2: it dispenses intosource. Useaspirate96/dispense96explicitly until upstream fixes it.