19  Define custom labware: a PCR plate

0.2.2 ships no PCR plate. nest_96_wellplate_100ul_pcr_full_skirt existed in older PLR and has no replacement.

import functools

from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend
from pylabrobot.resources import (
    Plate, Well, Lid, WellBottomType, CrossSectionType, PlateHolder,
    STARLetDeck, PLT_CAR_L5AC_A00, set_volume_tracking,
)
from pylabrobot.resources.utils import create_ordered_items_2d
from pylabrobot.resources.height_volume_functions import (
    calculate_liquid_volume_container_2segments_round_vbottom,
    calculate_liquid_height_in_container_2segments_round_vbottom,
)

19.1 Step 1: Measure the plate

The numbers a definition needs, and the datum each is measured from.

Measurement Goes to Datum
Plate footprint x, y size_x, size_y outer edges of the skirt
Plate total height size_z bench surface to the top rim
Well spacing item_dx, item_dy centre to centre, 9.0 mm on a 96-well SBS plate
First well offset dx, dy plate origin to the lower-left corner of well A1, not its centre
Well top opening well size_x, size_y inner diameter at the rim
Well depth well size_z rim down to the inside bottom
Bottom thickness material_z_thickness inside bottom to outside bottom
Cone and cylinder heights the geometry functions the conical part, and the straight part above it

The numbers used below:

PLATE_X, PLATE_Y, PLATE_Z = 127.76, 85.48, 15.7
WELL_DIAMETER = 5.5
CONE_HEIGHT = 9.0
CYLINDER_HEIGHT = 6.0
MATERIAL_Z = 0.5
MAX_VOLUME = 200

print("well depth:", CONE_HEIGHT + CYLINDER_HEIGHT, "mm")
1
127.76 × 85.48 mm is the SBS footprint — the same for every standard microplate.
2
A PCR well is a cone with a short straight section above it. Those two heights are what the volume maths needs.
well depth: 15.0 mm

dx/dy are corner offsets.

create_ordered_items_2d places each item by its lower-left corner, not its centre. Shipped definitions record the arithmetic in a comment, e.g. dx=10.87, # 14.3-6.86/2 — the well centre minus half the well width.


19.2 Step 2: Choose a base class

Pick the class to subclass or instantiate:

Base Use when Cost
Resource arbitrary children at arbitrary positions no indexing, no volume tracking
Container one addressable volume no child grid
ItemizedResource a full rectangular grid of anything must be a full grid, or it raises
Plate a grid of Wells, with lid support plate semantics assumed
ContainerRack a grid of holders, each taking a removable container access is indirect, via ResourceHolder

A PCR plate is a grid of wells with a lid, so it is a Plate. No subclassing is needed — a factory function returning a configured Plate is how every shipped definition does it.

WarningGotcha: the grid is a contract

ItemizedResource computes its dimensions from the item identifiers, and demands a complete rectangle:

from pylabrobot.resources import cor_96_wellplate_360uL_Fb
reference = cor_96_wellplate_360uL_Fb(name="reference")

print("full:", reference._get_grid_size(["A1", "A2", "B1", "B2"]))
try:
    reference._get_grid_size(["A1", "A2", "B1"])
except ValueError as e:
    print("sparse:", e)
1
B2 is missing, so the identifiers are not a rectangle.
full: (2, 2)
sparse: Not a full grid: ['A1', 'A2', 'B1']

The check runs when num_items_x / num_items_y are read, not at construction — so a sparse resource builds fine and raises later. With a full grid you get row, column, traverse, and get_quadrant (chapter 5); without one you keep num_items and get_item and lose the rest.


19.3 Step 3: Write the volume ↔︎ height functions

Give the well two functions: volume from a liquid height, and height from a volume.

pylabrobot.resources.height_volume_functions ships 20 of them. The pair for a round V-bottom well made of a cone plus a cylinder:

def volume_from_height(h: float) -> float:
    return calculate_liquid_volume_container_2segments_round_vbottom(
        d=WELL_DIAMETER, h_cone=CONE_HEIGHT, h_cylinder=CYLINDER_HEIGHT,
        liquid_height=h)

def height_from_volume(v: float) -> float:
    return calculate_liquid_height_in_container_2segments_round_vbottom(
        d=WELL_DIAMETER, h_cone=CONE_HEIGHT, h_cylinder=CYLINDER_HEIGHT,
        liquid_volume=v)

for h in (2.0, 5.0, 10.0):
    print(f"  {h:4.1f} mm -> {volume_from_height(h):7.2f} uL")
for v in (10.0, 50.0, 150.0):
    print(f"  {v:5.1f} uL -> {height_from_volume(v):6.2f} mm")
1
A plain wrapper function, not functools.partial — see below.
   2.0 mm ->    0.78 uL
   5.0 mm ->   12.22 uL
  10.0 mm ->   95.03 uL
   10.0 uL ->   4.68 mm
   50.0 uL ->   8.00 mm
  150.0 uL ->  12.31 mm
WarningGotcha: functools.partial does not work here

The natural way to bind the fixed dimensions fails:

bound = functools.partial(
    calculate_liquid_volume_container_2segments_round_vbottom,
    d=WELL_DIAMETER, h_cone=CONE_HEIGHT, h_cylinder=CYLINDER_HEIGHT)

try:
    bound(5.0)
except TypeError as e:
    print("TypeError:", str(e)[:95])
1
PLR calls the stored callable positionally, as f(height).
TypeError: calculate_liquid_volume_container_2segments_round_vbottom() got multiple values for argument 'd

The function’s first positional parameter is d, which the partial already bound by keyword, so a positional call collides with it. A wrapper function taking one argument avoids the problem — which is what every shipped definition uses.

Custom shapes.

For a geometry none of the 20 functions describe, pass your own callables, or give the well height_volume_data — a dict of height → volume measured empirically.


19.4 Step 4: Lay out the wells

Create 96 wells in a grid, each with the geometry from step 3.

wells = create_ordered_items_2d(
    Well,
    num_items_x=12,
    num_items_y=8,
    dx=11.15,
    dy=7.62,
    dz=0.9,
    item_dx=9.0,
    item_dy=9.0,
    size_x=WELL_DIAMETER,
    size_y=WELL_DIAMETER,
    size_z=CONE_HEIGHT + CYLINDER_HEIGHT,
    bottom_type=WellBottomType.V,
    cross_section_type=CrossSectionType.CIRCLE,
    material_z_thickness=MATERIAL_Z,
    max_volume=MAX_VOLUME,
    compute_volume_from_height=volume_from_height,
    compute_height_from_volume=height_from_volume,
)

print("created:", len(wells), "wells")
print("identifiers:", list(wells)[:4], "...")
print("A1 location:", wells["A1"].location)
1
The class to instantiate. create_ordered_items_2d is generic — tip racks pass TipSpot.
2
Corner offsets from step 1.
3
dz is the height of the well’s inside bottom above the plate’s own origin.
4
Everything from here down is forwarded to each Well.__init__ as **kwargs.
created: 96 wells
identifiers: ['A1', 'B1', 'C1', 'D1'] ...
A1 location: Coordinate(011.150, 070.620, 000.900)

19.5 Step 5: Wrap it in a factory function

Package the definition the way PLR ships definitions: a function taking a name and returning the resource.

def bmelab_96_wellplate_200uL_Vb(name: str, with_lid: bool = False) -> Plate:
    """A 96-well skirted PCR plate with conical wells.

    - brand: (your lab)
    - cat. no.: n/a
    - material: polypropylene
    """
    return Plate(
        name=name,
        size_x=PLATE_X,
        size_y=PLATE_Y,
        size_z=PLATE_Z,
        plate_type="skirted",
        model=bmelab_96_wellplate_200uL_Vb.__name__,
        lid=None,
        ordered_items=create_ordered_items_2d(
            Well,
            num_items_x=12, num_items_y=8,
            dx=11.15, dy=7.62, dz=0.9,
            item_dx=9.0, item_dy=9.0,
            size_x=WELL_DIAMETER, size_y=WELL_DIAMETER,
            size_z=CONE_HEIGHT + CYLINDER_HEIGHT,
            bottom_type=WellBottomType.V,
            cross_section_type=CrossSectionType.CIRCLE,
            material_z_thickness=MATERIAL_Z,
            max_volume=MAX_VOLUME,
            compute_volume_from_height=volume_from_height,
            compute_height_from_volume=height_from_volume,
        ),
    )

plate = bmelab_96_wellplate_200uL_Vb(name="pcr_plate")
print(plate)
1
The name follows <vendor>_<n>_<type>_<volume>uL_<bottom> (chapter 2), so it decodes like any shipped definition.
2
plate_type is "skirted", "semi-skirted", or "non-skirted".
3
model records which factory produced this object. It is the string written to the type field when the deck is serialized, and the one find_subclass looks up on load (chapter 11).
Plate(name='pcr_plate', size_x=127.76, size_y=85.48, size_z=15.7, stacking_z_height=None, location=None)

19.6 Step 6: Check it behaves like a plate

Confirm the definition indexes, tracks, and sits on a deck.

print("wells:", plate.num_items, "| grid:", plate.num_items_x, "x", plate.num_items_y)
print("row A:", len(plate.row("A")), "| column 1:", len(plate.column(0)))
print("quadrant tl:", len(plate.get_quadrant("tl")))

well = plate.get_item("A1")
print("depth:", well.get_size_z(), "mm | max:", well.max_volume, "uL")
print("bottom:", well.bottom_type)
1
Reading num_items_x is what runs the full-grid check from step 2.
wells: 96 | grid: 12 x 8
row A: 12 | column 1: 8
quadrant tl: 24
depth: 15.0 mm | max: 200 uL
bottom: WellBottomType.V
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

set_volume_tracking(True)
plate["A1"][0].set_volume(100)
1
The definition goes onto a carrier like any other plate.
print("absolute location:", plate.get_absolute_location())
print("A1 volume:", plate["A1"][0].tracker.get_used_volume(), "uL")
print("liquid height:", round(well.compute_height_from_volume(100), 2), "mm")
1
100 uL of 200 stands 10.21 mm up a 15 mm well: the cone holds less per millimetre than the cylinder above it.
absolute location: Coordinate(306.500, 071.500, 185.250)
A1 volume: 100 uL
liquid height: 10.21 mm

ResourceDefinitionIncompleteError will not tell you what is missing.

The class exists for definitions that are missing data, but nothing in 0.2.2 raises it (chapter 10). An incomplete definition fails later and elsewhere — a missing max_volume surfaces as a volume-tracking error, a wrong dz as a channel at the wrong height. The checks in this step are the validation.


19.7 Step 7: Make it sit at the right height

Place the plate in a holder.

holder = PlateHolder(
    name="pcr_holder",
    size_x=PLATE_X, size_y=PLATE_Y, size_z=10.0,
    pedestal_size_z=-3.0,
)
print("holder pedestal:", holder.pedestal_size_z)
1
Required in 0.2.2. A negative value means the plate sinks into the holder.
holder pedestal: -3.0
try:
    PlateHolder(name="bad", size_x=PLATE_X, size_y=PLATE_Y, size_z=10.0)
except ValueError as e:
    print("ValueError:", str(e)[:80])
1
Omitting it raises at construction.
ValueError: pedestal_size_z must be provided. See https://docs.pylabrobot.org/resources/reso

19.8 Step 8: Contribute it upstream

Turn a local definition into a shipped one.

  1. Put the factory function in pylabrobot/resources/<vendor>/plates.py, creating the vendor package if it does not exist.
  2. Name it <vendor>_<n>_<type>_<volume>uL_<bottom>.
  3. Docstring: catalog number first, then manufacturer link, distributor, material, and notes — the format in chapter 2.
  4. Export it from the vendor package’s __init__.py, which is what makes it importable from pylabrobot.resources and registers the class for deserialization (chapter 11).
  5. Add a lid definition if the plate ships with one.
  6. docs/contributor_guide/contributing-new-resources.md in the PLR repository has the current checklist.

19.9 What geometry can and cannot express

Three different answers:

  • Geometry is fully extensible. Sizes, offsets, grids, bottom shapes, and the volume ↔︎ height relationship are all yours to define. Anything with a rectangular grid of containers can be described.
  • State is partially extensible. A Well gets one volume tracker holding one number. You can set it, save it, and load it (chapter 11), but the tracker’s shape is fixed — there is no per-liquid identity in 0.2.2 (chapter 10).
  • Connectivity is not modelled at all. Volume trackers are per-container and independent. Nothing flows between them: no PLR mechanism makes one well’s volume change because another’s did. Labware with plumbing — microfluidic chips, flow cells, anything with channels between compartments — can be described geometrically, and the connections between compartments have to live in your own code.

19.10 What to remember

  • Measure to PLR’s datums: dx/dy are the corner of well A1, not its centre.
  • A grid of wells with a lid is a Plate, built by a factory function, not a subclass.
  • ItemizedResource demands a complete rectangle; the check runs when num_items_x is read.
  • Bind well geometry with a wrapper function, not functools.partial — PLR calls the callable positionally.
  • create_ordered_items_2d forwards its extra kwargs to each item’s constructor.
  • model= is what gets serialized and looked up on load.
  • PlateHolder.pedestal_size_z is required and raises ValueError if omitted.
  • ResourceDefinitionIncompleteError never fires; validate by using the definition.