5  Putting things on the deck

In lab automation, resources must be precisely located in space at well-defined positions. These positions can be defined on movable plate carriers like on the Hamilton STAR, or machined deck positions like on the OT-2. PyLabRobot makes it easy to assign labware to these defined positions as we will show below.

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

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")
1
PLT_CAR_L5AC_A00 is a five-position Hamilton plate carrier. Carriers are the parents here: the plate’s parent is the carrier’s site 0, not the deck itself.
2
rails= is Hamilton-specific: the deck is numbered in rails, and the carrier is placed at the leftmost rail it occupies. On an OTDeck you hand a slot number instead — see the build-a-deck recipe.

Python you need here

carrier[0] = plate works because PlateCarrier implements __setitem__. It is the same assignment you would write for a list, and it does the assign_child_resource call for you.

Watch for mutable default arguments when you write deck-builder helpers: a list default is created once and shared by every call.


5.1 Build a deck from carriers

Put a plate on a Hamilton deck, and the same plate on an OT-2 deck, using each deck’s native positioning.

carrier[1] = cor_96_wellplate_360uL_Fb(name="second_plate")
print("carrier children:", [c.name for c in carrier.children])
1
carrier[0] and carrier[1] are the same operation at different sites — the __setitem__ sugar for assigning a resource to a carrier position. You never hand the carrier a coordinate; it computes where site N is.
carrier children: ['carrier-0', 'carrier-1', 'carrier-2', 'carrier-3', 'carrier-4']
from pylabrobot.resources import OTDeck, cor_96_wellplate_360uL_Fb

ot_deck = OTDeck()
ot_deck.assign_child_at_slot(
    cor_96_wellplate_360uL_Fb(name="ot_plate"), slot=1
)
1
The OT-2 has no rails. OTDeck is a grid of twelve numbered slots, and assign_child_at_slot is its equivalent of rails=.
WarningGotcha: assign plates to the carrier, not the deck

lh.deck.assign_child_resource(plate, ...) technically works, but the plate ends up a direct child of the deck with no holder — no site tracking, and move_plate to a carrier position gets confused. Carriers are the interface for labware; the deck is the interface for carriers.

See also: carriers hold plates and tip racks; PlateHolder.pedestal_size_z controls how high a plate sits and is required in 0.2.2 — omit it and PLR raises.


5.2 Coordinates of a resource

Read where a resource is.

print("plate .location (relative to its holder):", plate.location)
print("plate absolute:                          ", plate.get_absolute_location())
1
.location is parent-relative. The plate’s location of Coordinate(0, 0, -3.03) means “3 mm below the top of its holder” — it tells you nothing about where the holder is on the deck.
2
get_absolute_location() walks the tree and returns the deck coordinate: (306.5, 71.5, 183.1). This is the number that matters for collision checks and for reasoning about the physical deck.
plate .location (relative to its holder): Coordinate(000.000, 000.000, -03.030)
plate absolute:                           Coordinate(306.500, 071.500, 183.120)
WarningGotcha: .location and get_absolute_location() are different numbers

A plate on a deck rarely has a .location you would recognize as a deck position. If a coordinate ever looks wrong, ask which of the two you are reading. z="t" in get_absolute_location(z="t") gives the top surface, which matters when you are placing a second resource on top.

See also: center() and get_anchor() in chapter 4 resolve positions inside a well, the next level down.


5.3 Will it fit

Ask PLR whether a resource can be placed somewhere before committing to it.

new_plate = cor_96_wellplate_360uL_Fb(name="incoming")

lh.deck.check_can_drop_resource_here(new_plate)
print("empty space is fine")

try:
    lh.deck.check_can_drop_resource_here(carrier, reassign=False)
except ValueError as e:
    print("occupied space is not:", str(e)[:70])

print("highest known point on deck:", lh.deck.get_highest_known_point())
1
A plate that is not yet assigned anywhere.
2
check_can_drop_resource_here is a non-mutating preflight — it validates the assignment without making it, so you can probe a position and decide.
3
A carrier is already there (via reassign=False this raises rather than replacing).
4
get_highest_known_point() returns the highest z over the whole deck — the number to check before a gripper traverses with something tall, or before stacking.
empty space is fine
occupied space is not: Will not reassign resource 'carrier' to the same parent: 'deck'. Will 
highest known point on deck: 230.0

The two kinds of ‘no’.

  • check_can_drop_resource_here answers placement: does this fit, is this spot free.
  • Name conflicts answer identity: is a resource with this name already on the deck (chapter 2).

Different questions; both raise ValueError.

See also: ignore_collision=True on assign_child_resource skips the placement check.


5.4 Rotate a plate

Place a plate in portrait, not landscape.

from pylabrobot.resources import Rotation

rotated = cor_96_wellplate_360uL_Fb(name="plate_rotated").rotated(z=90)
print("rotation recorded:", rotated.rotation)

print("flat footprint:   ", plate.get_absolute_size_x(), "x", plate.get_absolute_size_y())
print("rotated footprint:", rotated.get_absolute_size_x(), "x",
      rotated.get_absolute_size_y())

carrier[2] = rotated
print("rotated plate sits at:", rotated.get_absolute_location())
1
rotated(z=90) returns a new resource rotated 90° about z — the original plate is untouched, so you can keep both a flat and a rotated view. Give the copy its own name: a rotation keeps the source name, and two resources cannot share one on the same deck (chapter 2).
2
The copy carries a Rotation(x, y, z) dataclass. Rotation alone does nothing; it is metadata that placement honors.
3
This is the rotation, visible: get_absolute_size_x/y() compute the bounding box from the rotated corners, so the 127.76 × 85.48 mm footprint comes back swapped. Portrait vs landscape is decided here, not by fiddling coordinates.
4
Assign the copy to a carrier site like any plate. An unassigned copy has no location — calling get_absolute_location() on it raises NoLocationError. Do not read the rotation out of this coordinate: it is site 2’s position, and it differs from plate’s mostly because it is a different site.
rotation recorded: Rotation(x=0, y=0, z=90)
flat footprint:    127.76 x 85.48
rotated footprint: 85.48 x 127.76
rotated plate sits at: Coordinate(391.980, 263.500, 183.120)

Gotcha: rotation is not mutation.

plate.rotated(z=90) does not change plate; if you forget to keep the return value, nothing has rotated. Prefer rotated = plate.rotated(z=90) and assign rotated to the carrier, not plate.

See also: Rotation composes for grippers and stacks; the well-level consequence of a rotated plate is that indexing (chapter 5) runs along the plate’s own axes.


5.5 What to remember

  • Carriers hold labware; the deck holds carriers. Plates go to carrier[i], never straight to the deck.
  • Hamilton decks use rails=; OT decks use assign_child_at_slot(slot=). Same idea, two words.
  • .location is parent-relative; get_absolute_location() is the deck coordinate. Read the absolute one.
  • check_can_drop_resource_here asks before committing; get_highest_known_point() tells you how tall the deck already is.
  • Rotation is a Rotation dataclass carried by a copy from rotated() — never a mutation.