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 liquid6 Pipetting
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, andlh.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, matchingvols. - 2
-
aspirate(resources, vols)pulls liquid from those wells into the tips. - 3
-
dispense(resources, vols)pushes it back out into the target wells.
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.Noneuses the backend default; the value here is explicit.
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()ismax_volumeminus what is in there — the headroom a dispense has before it raisesTooLittleVolumeError(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.
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
-
offsetsshift each channel from the well center by aCoordinate. 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)) == 1and spaces the channels across it, computing per-channel offsets fromspread. - 4
-
spread="wide"puts channels as far apart as the trough allows (their spacing ismin_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 — supplyoffsetsyourself, 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_spacingson 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
volsis a list, one entry per channel.Nonemeans backend default, never zero.flow_rates,liquid_height,blow_out_air_volumeare the three modifiers that make a call real;liquid_heightis from the bottom, in mm.offsetsmove a channel within the aimed well — usecenter()/get_anchor()to compute them.- One resource + many channels means
[well] * n+use_channels+spread;spreadplaces 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:
ValueErroron plain wells,ChannelsDoNotFitErrorwith no-go zones. Checkget_channel_spacingsand plan accordingly.