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.

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

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
WarningGotcha: 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
traverse is a lazy generator, so islice bounds 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
traverse yields batches even at batch_size=1, so batch[0] unwraps each to the well itself — a stream of wells, which is what the pipetting call takes.
2
islice pulls 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
product is the Cartesian product — the same as for dose in doses: for rep in replicates:, one level of nesting shallower, and lazy.
2
zip against 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) with plate.get_item((0, i)) — computes a position the row already knows, and keeps running past well 12 into the tuple-indexing gotcha. zip stops 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() and column() are the line views from above; a set makes the in test 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']
  1. groupby batches only consecutive equal keys. tip_for grows 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_quadrant still 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_volume seeds the tracker directly — a way to tell PLR what is in a well without pipetting (see chapter 11).
2
occupied_func returns one character per well. It must return a string — returning a bool raises TypeError.
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
WarningGotcha: the two 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)start is required, and remember repeat=True + islice.
  • cycle rotates, islice pulls the next chunk, product builds every dose×replicate pairing, and groupby batches 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 a set of 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.