8  The shortcuts

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 liquid

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
source is a single Well.
2
targets is a list of Wells — one dispense per target, all from the same aspirated volume.
3
target_vols sets each dispense; the aspirate volume is the sum (100 µL here).
WarningGotcha: 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_channels is a context manager: inside the with, every operation defaults to channels 0 and 1. You no longer pass use_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_tips96 takes a whole TipRack — 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
dispense96 mirrors 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()

WarningDo not build on 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. Use target_vols or source_vol+ratios, never both.
  • use_channels is a with block that temporarily changes the default channel set.
  • The *96 family 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 into source. Use aspirate96/dispense96 explicitly until upstream fixes it.