6  Pipetting

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,
    nest_1_troughplate_195000uL_Vb,
)

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")

trough = nest_1_troughplate_195000uL_Vb(name="buffer")
carrier[1] = trough
trough["A1"][0].set_volume(10000)          # a source that has liquid

Python you need here

Every recipe does a pick-up-then-pipette-then-drop cycle. The tip mechanics are the subject of chapter 8; here tips just work.


6.1 Put a tip on and take it off

Nothing pipettes without a tip on the channel. Two calls bracket every operation.

await lh.pick_up_tips(tip_rack["A1"])
# ... aspirate and dispense ...
await lh.drop_tips(tip_rack["A1"])
1
Takes a list of tip spots, one per channel.
2
Puts them back where they came from. lh.return_tips() does the same without naming the spot, and lh.discard_tips() sends them to the deck trash instead.
print("mounted:", [t is not None for t in lh.get_mounted_tips()][:4], "...")
mounted: [False, False, False, False] ...

Attempting to pipette with no tip raises NoTipError, and picking up onto a channel that already holds one raises HasTipError (chapter 10).

See also: chapter 8 covers the rest: which of the three put-away calls to use, streaming across racks when one runs out, and what the rack model believes.


6.2 Aspirate and dispense

Move liquid from one well to another, with a tip on the head.

await lh.pick_up_tips(tip_rack["A1"])
await lh.aspirate(plate["A1"], vols=[50])
await lh.dispense(plate["B1"], vols=[50])
await lh.drop_tips(tip_rack["A1"])
1
One tip, one channel — the default mapping. plate["A1"] is always a list, matching vols.
2
aspirate(resources, vols) pulls liquid from those wells into the tips.
3
dispense(resources, vols) pushes it back out into the target wells.
WarningGotcha: vols is a list, not a number

vols=[50], not vols=50. The length of vols must equal the number of channels used (here, the number of resources). A scalar raises TypeError: 'int' object is not iterable. The one place a scalar works is inside helpers like transfer — see chapter 6.

Python you need here

None as “backend default”, not zero. flow_rates=None means use the machine’s default; flow_rates=0 means stop the pump.

See also: mix= at the end of this chapter performs repeated aspirate/dispense cycles in one call.


6.3 Control speed and height

Pipette slowly and low, to avoid aerating a viscous liquid.

await lh.pick_up_tips(tip_rack["A2"])
await lh.aspirate(
    plate["A2"], vols=[80],
    flow_rates=[50.0],
    liquid_height=[2.0],
    blow_out_air_volume=[10.0],
)
await lh.dispense(
    plate["B2"], vols=[80],
    flow_rates=[80.0],
    blow_out_air_volume=[10.0],
)
await lh.drop_tips(tip_rack["A2"])
1
flow_rates — µL/s per channel. A slow aspiration avoids disturbing the pellet or aerating the liquid; a fast dispense mixes the target. Each is one value per channel.
2
liquid_height — aspirate from 2 mm off the bottom, in mm, not from the surface. This is the argument for “leave the pellet alone”.
3
blow_out_air_volume — µL of air dispensed after the liquid to clear the tip. None uses the backend default; the value here is explicit.
WarningGotcha: liquid_height is distance from the bottom, not a z-coordinate

2.0 means “2 mm above the well bottom”, whatever the well’s total depth. It is not an absolute z. If you meant “near the surface of a 100 µL column”, compute that from the volume first — the next recipe does exactly that.

See also: flow_rates, liquid_height, and blow_out_air_volume accept per-channel lists so channels can behave differently; use_channels (below) is how you address a subset.


6.4 Follow the liquid surface down

Ask a well how full it is, and how high that liquid stands.

well = plate["A6"][0]
well.set_volume(100)

print("used: ", well.tracker.get_used_volume(), "uL")
print("free: ", well.tracker.get_free_volume(), "uL")
print("max:  ", well.max_volume, "uL")
1
Writing the tracker directly, the way chapter 5 fills a plate. Aspirating and dispensing move the same number when volume tracking is on (chapter 1).
2
get_free_volume() is max_volume minus what is in there — the headroom a dispense has before it raises TooLittleVolumeError (chapter 10).
used:  100 uL
free:  260 uL
max:   360 uL

Volume is not height. The well’s own geometry converts between them:

print("has geometry:", well.supports_compute_height_volume_functions())
print("100 uL stands:", round(well.compute_height_from_volume(100), 2), "mm")
print("250 uL stands:", round(well.compute_height_from_volume(250), 2), "mm")
print("5 mm holds:   ", round(well.compute_volume_from_height(5), 1), "uL")
print("well depth:   ", well.get_size_z(), "mm")
1
Not every definition carries the curve. Check before trusting it — a definition without one raises rather than guessing, and this is the gate that tells you which you have.
2
The math is about the cavity, not the plastic: 250 µL stands 7.72 mm up a well that is 10.67 mm deep.
has geometry: True
100 uL stands: 3.22 mm
250 uL stands: 7.72 mm
5 mm holds:    159.5 uL
well depth:    10.67 mm

Which is what makes “aspirate just under the surface” writable. liquid_height is measured from the bottom, so the surface of the current volume is the number to start from:

surface = well.compute_height_from_volume(
    well.tracker.get_used_volume()
)

await lh.pick_up_tips(tip_rack["A6"])
await lh.aspirate(
    plate["A6"], vols=[20],
    liquid_height=[max(0.5, surface - 2.0)],
)
await lh.drop_tips(tip_rack["A6"])
1
The height of the liquid that is in the well now — 3.22 mm for the 100 µL set above.
2
Two millimetres below the surface, floored so a nearly empty well cannot drive the tip into the bottom. Chasing the surface down matters when the tip must not be dragged through the whole column, or when carryover on the outside of the tip is the thing you are avoiding.
WarningGotcha: the tracker is a model, and it does not follow the tip down

compute_height_from_volume answers from the volume the tracker believes. It is right only if every transfer in and out of that well went through PLR with tracking on. A well filled by hand, or one whose tracking was suppressed (chapter 10), reports a surface that is not there. PLR has no level sensing behind this number.

See also: the volume↔︎height functions are supplied when a definition is written — writing them for your own labware is chapter 17.


6.5 Target a position within a well

Aspirate from a position in the well other than its centre.

from pylabrobot.resources import Coordinate

await lh.pick_up_tips(tip_rack["A3"])
await lh.aspirate(
    plate["A3"], vols=[20],
    offsets=[Coordinate(x=1.0, y=0, z=1.5)],
)
await lh.drop_tips(tip_rack["A3"])
1
offsets shift each channel from the well center by a Coordinate. The z-component here keeps the tip 1.5 mm off the bottom.
well = plate["A3"][0]
print("center:", well.center())
print("anchor:", well.get_anchor(x="l", y="b"))
1
center() is where a channel goes by default — the origin for offsets.
2
get_anchor(x=..., y=..., z=...) resolves named positions ("l"/"r", "f"/"b", "t"/"b" for left/right, front/back, top/bottom). Compose them: get_anchor(x="l", y="b") is the bottom-left corner.

Gotcha: offsets are applied to the resolved target, not to the deck.

An offset moves a channel within the well it is already aimed at. It does not pick a different well and it is not an absolute coordinate — mixing those up drives tips into the plate between wells.

See also: get_anchor and Coordinate also appear in chapter 3, at the deck level.


6.6 Aspirate from one trough with 8 channels

Fill eight channels from a single 195 mL reservoir.

await lh.pick_up_tips(tip_rack["A1:H1"])

w = trough["A1"][0]

await lh.aspirate(
    [w] * 8,
    vols=[50] * 8,
    use_channels=list(range(8)),
    spread="wide",
)
await lh.drop_tips(tip_rack["A1:H1"])
1
Eight tips for eight channels — the default pairing from chapter 8.
2
trough["A1"] is a list of one well; [0] unwraps it. The trough is one big well, so all eight channels share it.
3
The single-resource idiom: repeat the resource once per channel. PLR sees len(set(resources)) == 1 and spaces the channels across it, computing per-channel offsets from spread.
4
spread="wide" puts channels as far apart as the trough allows (their spacing is min_spacing, default 9 mm); spread="tight" pulls them as close as the machine allows. The third value, spread="custom", hands the placement back to you — supply offsets yourself, one per channel, and PLR computes nothing.

Gotcha: vols must match use_channels, even for one well.

vols=[50] * 8 with use_channels=list(range(8)). Omit either and you get ValueError: Length of vols must match length of use_channels. There is no magic scalar for the multi-channel single-resource case.

See also: use_channels also selects which channels act (e.g. [0, 1, 2] for a partial pick-up) — chapter 6 and chapter 8 rely on it.


6.7 Mix during a transfer

Mix the contents of a well while you transfer, in one call.

from pylabrobot.liquid_handling.standard import Mix

await lh.pick_up_tips(tip_rack["A4"])
await lh.aspirate(
    plate["A4"], vols=[100],
    mix=[Mix(volume=80, repetitions=3, flow_rate=100.0,
             surface_following_distance=2.0)],
)
await lh.dispense(
    plate["B4"], vols=[100],
    mix=[Mix(volume=80, repetitions=3, flow_rate=100.0)],
)
await lh.drop_tips(tip_rack["A4"])

Gotcha: the chatterbox does not narrate the mix cycles.

The narration shows the transfer volume, not the mix’s internal cycles — a real backend performs them, but chatterbox prints the op it was given. Verify a mix with the machine or the run log (chapter 13), not with the chatterbox output.

See also: surface_following_distance tracks the liquid surface as it drops; pairing it with liquid_height from the speed/height recipe covers most cell-mixing use cases.


6.8 When channels do not fit

Check channel spacing, and read the error when channels do not fit.

print("default spacings:", spacings)
print("refused:", refusal)
default spacings: [9, 9, 9, 9, 9, 9, 9, 9]
refused: Resource is too small to space channels.
await lh.pick_up_tips(tip_rack["A5:H5"])
spacings = lh.backend.get_channel_spacings(list(range(8)))

small = plate["A5"][0]
refusal = None
try:
    await lh.aspirate(
        [small] * 8, vols=[10] * 8,
        use_channels=list(range(8)), spread="wide",
    )
except ValueError as e:
    refusal = e
await lh.drop_tips(tip_rack["A5:H5"])
1
Eight tips for the eight channels being asked about — the refusal happens at planning, before any tip moves.
2
get_channel_spacings on the backend reports the physical channel spacing — 9 mm for a generic head. That is the minimum centre-to-centre distance PLR will plan.
3
A 96-well plate well is ~6.9 mm across. Eight channels at 9 mm spacing cannot fit in it.
4
The refusal here is ValueError: Resource is too small to space channels. — spread can’t pack the channels into the well.

Gotcha: ChannelsDoNotFitError needs no-go zones.

On a plain well you get the ValueError above, not ChannelsDoNotFitError. The latter is raised specifically by the compartment algorithm when a well has no_go_zones set. Catch both if you are probing, or fix the plan: fewer channels, a wider resource, or spread="custom" with your own offsets.

See also: spread="custom" in the spread recipe takes explicit per-channel offsets.


6.9 What to remember

  • vols is a list, one entry per channel. None means backend default, never zero.
  • flow_rates, liquid_height, blow_out_air_volume are the three modifiers that make a call real; liquid_height is from the bottom, in mm.
  • offsets move a channel within the aimed well — use center() / get_anchor() to compute them.
  • One resource + many channels means [well] * n + use_channels + spread; spread places the channels and computes the offsets for you.
  • mix=[Mix(...)] replaces hand-rolled mixing loops.
  • A well converts volume to height and back through its own geometry, gated by supports_compute_height_volume_functions(). That is how you aspirate below a falling surface instead of at a fixed height – and it is only as true as the tracker behind it.
  • Too many channels for a well: ValueError on plain wells, ChannelsDoNotFitError with no-go zones. Check get_channel_spacings and plan accordingly.