from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend
import itertools
from pylabrobot.resources import STARLetDeck, PLT_CAR_L5AC_A00, cor_96_wellplate_360uL_Fb
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="sample_plate")7 Indexing
Lab automation is full of indexed containers and resources, like 96-well plates. PyLabRobot provides several indexing schemas that allow for convenient addressing formulas.
Python you need here
Slices ("A1:H1"), __getitem__ (the plate[...] spelling), comprehensions ([w for w in ...]), and generators (traverse yields on demand). If a loop you are writing walks a grid, there is probably a built-in for it.
7.1 Select wells
Choose a set of wells in one expression. The grammar:
| Expression | Selects | Result |
|---|---|---|
plate["A1:H1"] |
the rectangle spanned by A1 and H1 — here column 1 | 8 wells (column 1) |
plate["A1", "B2"] |
exactly the listed wells | 2 wells |
plate[0] |
the well at index 0 | 1 well (always a list) |
plate[range(5)] |
wells at indices 0–4 | 5 wells |
plate[0:5] |
the same, as a slice | 5 wells |
plate.get_item((0, 0)) |
the well at (row, column) | 1 well |
print("A1:H1 ->", [w.get_identifier() for w in plate["A1:H1"]][:3], "... (8 total)")
print("A1,B2 ->", [w.get_identifier() for w in plate["A1", "B2"]])
print("0 ->", [w.get_identifier() for w in plate[0]])
print("(0,0) ->", plate.get_item((0, 0)).get_identifier()) # row A, column 1- 1
-
A colon is a rectangular span: every well in the rectangle whose corners are the two identifiers.
"A1:H1"is column 1;"A1:A12"would be row A. - 2
-
A comma-separated list is an explicit set. Note the outer brackets —
plate["A1", "B2"].
A1:H1 -> ['A1', 'B1', 'C1'] ... (8 total)
A1,B2 -> ['A1', 'B2']
0 -> ['A1']
(0,0) -> A1
plate[(0, 0)] is not row, column
A tuple is treated as a sequence of integer indices, so plate[(0, 0)] returns two copies of index 0 — a silent bug that duplicates wells. For row, column use plate.get_item((0, 0)), which returns one well.
See also: plate["A1:E1"] uses the same colon syntax as Python slices; get_items(...) accepts the same identifiers.
7.2 Rows and columns
Get one full row or column, or pull wells by index.
print("row(0) ->", len(plate.row(0)), "wells:", [w.get_identifier() for w in plate.row(0)][:3], "...")
print("row('A') ->", len(plate.row("A")), "wells") # row() accepts the label too
print("column(0)->", len(plate.column(0)), "wells:", [w.get_identifier() for w in plate.column(0)])row(0) -> 12 wells: ['A1', 'A2', 'A3'] ...
row('A') -> 12 wells
column(0)-> 8 wells: ['A1', 'B1', 'C1', 'D1', 'E1', 'F1', 'G1', 'H1']
Which to reach for.
Use row()/column() when the shape of what you want is a line. Use the #select grammar when it is not. The overlap is fine — plate.row("A") and plate["A1:A12"] are the same set by different names.
See also: get_items(["A1", "A2"]) returns exactly the listed wells — the non-range form of the comma syntax above.
7.3 Convert between a linear index and a grid position
plate[0] uses a linear index; plate.get_item((row, col)) uses a grid position. The plate fills column-major — down the first column, then the next — so the divisor is the row count, not the column count:
i = 12
col, row = divmod(i, plate.num_items_y)
print(i, "->", plate.get_item((row, col)).get_identifier()) # E2
back = col * plate.num_items_y + row
print("round-trip:", back)- 1
- The thirteenth well in fill order — down column 1 (A1..H1), then A2, B2, …
- 2
-
divmod(i, num_items_y)unpacks into (column, row):row = i % 8,col = i // 8. The modulo is the number of rows, which is the periodicity of the layout — get the divisor wrong and the 13th well comes back as B2 instead of E2. - 3
-
The forward form is
i = col * num_items_y + row. Same%periodicity that makes the rotation in the streaming recipe work.
12 -> E2
round-trip: 12
Modulo also shows up as parity in the 96→384 quadrant mapping: a 96-well plate’s well (r, c) lands at (2r, 2c) in its 384 checkerboard quadrant.
See also: Coordinate supports + and -, so well.get_absolute_location() + Coordinate(x=1, y=0, z=1.5) is the operator form of the offsets in chapter 4.
7.4 Walk a plate in batches
Hand eight-channel operations a stream of columns, one batch at a time.
for batch in itertools.islice(
plate.traverse(batch_size=8, start="top_left", direction="right"), 3
):
print(len(batch), "wells:", batch[0].get_identifier(), "..", batch[-1].get_identifier())
# the real body would be: await lh.aspirate(batch, vols=[25] * 8)- 1
-
traverseis a lazy generator, soislicebounds the demo to the first three of twelve passes — without it this cell prints all twelve. - 2
- Each batch is exactly what one 8-channel pass can reach, so the pipetting call slots in here.
8 wells: A1 .. A8
8 wells: A9 .. B4
8 wells: B5 .. B12
Gotcha: the signature is traverse(batch_size, start, direction).
Older docs show traverse(direction, batch_size). In 0.2.2 batch_size comes first and start is required — omit it and you get TypeError: missing 1 required positional argument: 'start'.
See also: the batching helpers in chapter 7 chunk worklists across channels the same way.
7.5 Stream, pair, and batch a plan
A few itertools idioms replace hand-rolled loop bookkeeping in everyday plans: rotate sources evenly, pull the next chunk of an unbounded worklist, build a dose-response block with replicates, skip border wells, and batch a plan by the tip that fits.
7.5.1 Rotate sources evenly
Four source wells serving eight destinations: give each destination the next source in turn, so all four deplete evenly instead of draining A1 first.
sources = plate["A1:D1"] # four source wells
targets = plate["A2:H2"] # eight destinations
for target, source in zip(targets, itertools.cycle(sources)):
print(f"{target.get_identifier()} <- {source.get_identifier()}")A2 <- A1
B2 <- B1
C2 <- C1
D2 <- D1
E2 <- A1
F2 <- B1
G2 <- C1
H2 <- D1
cycle repeats the sources forever; zip stops at the last target. The rotation a hand loop would carry as i % len(sources) is implicit.
7.5.2 Pull the next chunk of an unbounded worklist
A worklist built from traverse(..., repeat=True) never ends. An 8-channel head consumes it in chunks, resuming exactly where it left off:
wells = (batch[0] for batch in
plate.traverse(1, "top_left", "right", repeat=True))
for _ in range(3): # three passes of an 8-channel head
chunk = list(itertools.islice(wells, 8))
print("next 8:", [w.get_identifier() for w in chunk])
# the real body would be: await lh.aspirate(chunk, vols=[25] * 8)- 1
-
traverseyields batches even atbatch_size=1, sobatch[0]unwraps each to the well itself — a stream of wells, which is what the pipetting call takes. - 2
-
islicepulls exactly the next eight, and the stream’s cursor survives across pulls, so nothing counts or indexes by hand.
next 8: ['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8']
next 8: ['A9', 'A10', 'A11', 'A12', 'B1', 'B2', 'B3', 'B4']
next 8: ['B5', 'B6', 'B7', 'B8', 'B9', 'B10', 'B11', 'B12']
7.5.3 Dose a plate in replicates
A dose-response block: four concentrations, three replicates each. product builds every (dose, replicate) pairing; the block is a deliberately partial plate — 12 of 96 wells, the rest stay empty.
doses = plate["E1:H1"] # four concentrations, down column 1
replicates = range(3) # three replicates per dose
plan = itertools.product(doses, replicates)
for (dose, rep), well in zip(plan, plate.row(0)):
print(f"{well.get_identifier()} <- {dose.get_identifier()} rep {rep + 1}")- 1
-
productis the Cartesian product — the same asfor dose in doses: for rep in replicates:, one level of nesting shallower, and lazy. - 2
-
zipagainst the destination row hands out the wells: dose 1 → A1:A3, dose 2 → A4:A6, and so on. The index-carrying form —for i, (dose, rep) in enumerate(plan)withplate.get_item((0, i))— computes a position the row already knows, and keeps running past well 12 into the tuple-indexing gotcha.zipstops at the shorter side, so a plan too big for one row is visibly truncated rather than silently wrong.
A1 <- E1 rep 1
A2 <- E1 rep 2
A3 <- E1 rep 3
A4 <- F1 rep 1
A5 <- F1 rep 2
A6 <- F1 rep 3
A7 <- G1 rep 1
A8 <- G1 rep 2
A9 <- G1 rep 3
A10 <- H1 rep 1
A11 <- H1 rep 2
A12 <- H1 rep 3
Assay plates usually leave the border ring empty to avoid edge effects. For the common rectangle, the #select grammar does it directly:
interior = plate["B2:G11"]
print(len(interior), "of", len(plate.get_all_items()), "wells are usable")- 1
-
"B2:G11"is the rectangular span between the two corners — rows B–G, columns 2–11 — so the border ring (row A, row H, col 1, col 12) is excluded by construction.
60 of 96 wells are usable
When the drop is not a clean rectangle, subtract a set instead:
border = set(plate.row("A") + plate.row("H") + plate.column(0) + plate.column(11))
keep = [w for w in plate.get_all_items() if w not in border]
print(len(keep), "wells, plate order preserved")- 1
-
The ring as a set — the four edge lines.
row()andcolumn()are the line views from above; a set makes theintest constant-time. - 2
- “Plate minus border”: every well not in the ring, still in plate order — the subset you act on. Both forms give the same 60 wells; the grammar is the shorter spelling, set subtraction the general one.
60 wells, plate order preserved
7.5.4 Batch a plan by the tip that fits
The day’s plan has transfers at several volumes. Group them by the smallest rack that fits, so each group runs with one tip setup:
plan = [("A1", 200), ("B1", 25), ("C1", 150), ("D1", 40)]
def tip_for(volume):
return "10 uL" if volume <= 50 else "300 uL" if volume <= 300 else "1000 uL"
for tip, group in itertools.groupby(
sorted(plan, key=lambda row: row[1]), # <1> sort by volume
key=lambda row: tip_for(row[1]), # group by the tip it needs
):
print(f"{tip}:", [well for well, _ in group])10 uL: ['B1', 'D1']
300 uL: ['C1', 'A1']
groupbybatches only consecutive equal keys.tip_forgrows with volume, so sorting by volume keeps same-tip rows adjacent; in general, sort by the key you group by.
See also: traverse above does fixed-size batching over the whole plate, and F.linear_tip_spot_generator in chapter 8 is the same streaming idea with a disk-backed index.
7.6 Map 96 wells into a 384-well quadrant
You have a 384-well plate and an 8-channel head. Aspirate from the wells that correspond to one 96-well quadrant of it.
from pylabrobot.resources import biorad_384_wellplate_50uL_Vb
plate384 = biorad_384_wellplate_50uL_Vb(name="dense_plate")
carrier[1] = plate384
q1 = plate384.get_quadrant("tl")
print("top-left quadrant:", len(q1), "wells")
qb = plate384.get_quadrant("br", quadrant_type="block")
print("bottom-right block:", len(qb), "wells")- 1
-
A 384-well plate — the quadrant grammar only means something on one. On a 96-well plate
get_quadrantstill works, but a “quadrant” is then 24 wells and maps to nothing standard. - 2
-
The default
quadrant_type="checkerboard"interleaves wells the way a 96-in-384 mapping physically arranges them — every other well in each direction. All 96 wells of one quadrant. - 3
-
quadrant_type="block"takes a contiguous corner instead — also 96 wells, but a solid 8×12 block. You choose by how the source plate was seeded, not by taste.
top-left quadrant: 96 wells
bottom-right block: 96 wells
Gotcha: quadrant names are compass points, not numbers.
get_quadrant takes "tl", "tr", "bl", "br" (or the long forms). "Q1" raises ValueError. The return is a plain list, not a plate — index it like one, but do not expect plate[...] grammar on it.
See also: quadrant_internal_fill_order="column-major" vs "row-major" controls the order within a quadrant; defaults are sensible for most cases.
7.7 Convert between labels and indices
Convert "C7" to (2, 6), or 7 to "H".
from pylabrobot.resources.utils import row_index_to_label, label_to_row_index, split_identifier
print("row_index_to_label(7):", row_index_to_label(7)) # "H"
print("label_to_row_index('C'):", label_to_row_index("C")) # 2
print("split_identifier('B12'):", split_identifier("B12")) # ("B", "12")row_index_to_label(7): H
label_to_row_index('C'): 2
split_identifier('B12'): ('B', '12')
New in 0.2.2.
These are recent additions. If you are porting code from an older PLR, you probably reimplemented them by hand — replace that with the real thing.
See also: split_identifier is the primitive behind chapter 7’s CSV row validation.
7.8 Print an occupancy map
Print a plate as a grid, marking which wells have liquid.
for w in plate["A1", "B1", "C1"]:
w.set_volume(50)
def occupied(well) -> str:
return "X" if well.tracker.get_used_volume() > 0 else "."
print(plate.summary(occupied_func=occupied))- 1
-
set_volumeseeds the tracker directly — a way to tell PLR what is in a well without pipetting (see chapter 11). - 2
-
occupied_funcreturns one character per well. It must return a string — returning a bool raisesTypeError.
Plate(name='sample_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))
1 2 3 4 5 6 7 8 9 10 11 12
A: X . . . . . . . . . . .
B: X . . . . . . . . . . .
C: X . . . . . . . . . . .
D: . . . . . . . . . . . .
E: . . . . . . . . . . . .
F: . . . . . . . . . . . .
G: . . . . . . . . . . . .
H: . . . . . . . . . . . .
12x8 Plate
summary() methods are opposites
lh.summary() prints the deck map and returns None. plate.summary() returns the grid as a string and prints nothing — which is why this recipe wraps it in print(). Forget the print() and the call looks like it silently did nothing.
See also: get_used_volume() / get_free_volume() on the tracker are the volume primitives; chapter 10 covers TooLittleLiquidError, which is what makes an “empty” well fail loudly later.
7.9 What to remember
plate[...]always returns a list. Colon is a range; commas are a set; an int is one well.plate.get_item((row, col))for row,column —plate[(row, col)]duplicates the well.row()/column()are the axis views;row()takes a letter or an index.plate[i]fills column-major:col, row = divmod(i, num_items_y)— the modulo is the row count.traverse(batch_size, start, direction)—startis required, and rememberrepeat=True+islice.cyclerotates,islicepulls the next chunk,productbuilds every dose×replicate pairing, andgroupbybatches consecutive equal keys — sort by that key first.- Border padding:
plate["B2:G11"]selects the 60 interior wells directly; for drops that aren’t a clean rectangle, subtract asetof the lines to drop. get_quadrant("tl"/"tr"/"bl"/"br")maps 96→384; checkerboard by default.summary(occupied_func)prints a grid; the callback returns one character per well.