11  Moving labware

from pylabrobot.liquid_handling import LiquidHandler
from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend
from pylabrobot.liquid_handling.standard import GripDirection
from pylabrobot.resources import (
    STARLetDeck,
    PLT_CAR_L5AC_A00,
    cor_96_wellplate_360uL_Fb,
    alpaqua_96_plateadapter_magnum_flx,
    Coordinate,
    Lid,
)

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
setup() opens the connection and initialises head state. Nothing else works before it — PLR’s own @need_setup_finished decorator enforces this on aspirate, dispense, and the tip methods.
2
Carriers are labware that holds labware. PLT_CAR_L5AC_A00 is a five-position Hamilton plate carrier.
3
rails= is Hamilton-specific: deck positions are numbered rails, not slots. On an OTDeck you would pass a slot number instead.

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.


11.1 Move a plate to a carrier position

Move a plate from one carrier position to another.

await lh.move_plate(plate, carrier[2])

The destination is deliberately permissive. Any of these work:

await lh.move_plate(plate, carrier[2])
from pylabrobot.resources import Coordinate
await lh.move_plate(plate, Coordinate(x=300, y=100, z=100))
await lh.move_plate(plate, some_machine.loading_tray)

Gotcha: the grip is on the plate, not the plate and its lid.

move_plate grips the plate body. If the plate has a lid on it, the lid is not held — depending on the gripper and the plate, it can be left behind or dragged. Move the lid first (next recipe), or move the plate and lid as a unit only if your gripper supports it.

See also: lh.move_resource() for the general form, lh.pick_up_resource() / move_picked_up_resource() / drop_resource() when you need the three phases separately.


11.2 Take the lid off a plate and put it back

Move a plate’s lid off, then put it back.

lidded = cor_96_wellplate_360uL_Fb(name="lidded_plate", with_lid=True)
carrier[3] = lidded

lid = lidded.lid
await lh.move_lid(lid, carrier[4])
assert not lidded.has_lid()

# ... pipette into the open plate ...

await lh.move_lid(lid, lidded)
1
move_lid takes the Lid object, which you reach through plate.lid. Keep a reference to it — once the lid is off, lidded.lid is None, so this is your only handle for putting it back.
2
has_lid() is a method, not a property. not plate.has_lid is always False, because a bound method is truthy — the parentheses are load-bearing.
3
Moving a lid to a plate re-lids it — the plate is a valid destination, not just a location.
lidded = cor_96_wellplate_360uL_Fb(name="lidded_demo", with_lid=True)
lid = lidded.lid
print("plate size_z:      ", lidded.get_size_z())
print("lid nesting_z:     ", lid.nesting_z_height)
print("lid seated at z:   ", lidded.get_lid_location(lid).z)
print("has_lid():         ", lidded.has_lid())

lidded.lid = None
print("after `lid = None`:", lidded.has_lid())
1
with_lid=True builds the plate with its lid already seated.
2
nesting_z_height is the vertical overlap between lid and parent. A Lid constructed with 0 prints a warning, because a lid that does not nest is almost always a mismeasurement.
3
The lid is centred on the parent’s top face and sunk by that overlap: 14.2 − 7.6 = 6.6 mm.
4
lid is a property derived from the children, with a setter: assigning None unassigns the lid, and assigning a Lid seats one.
plate size_z:       14.2
lid nesting_z:      7.6
lid seated at z:    6.6
has_lid():          True
after `lid = None`: False

A lid must cover what it sits on. Liddable.assign_child_resource compares footprints and allows a shortfall of at most LID_UNDERSIZE_TOLERANCE (1.0 mm), since real lids sit just inside the rim:

small = Lid(name="wrong_lid", size_x=100.0, size_y=60.0, size_z=8.9, nesting_z_height=7.6)
try:
    lidded.lid = small
except ValueError as e:
    print(f"ValueError: {e}")
ValueError: Lid 'wrong_lid' (100.0 x 60.0 mm) is smaller than 'lidded_demo' (127.76 x 85.48 mm) and cannot cover it.

Liddable is mixed into Container as well as Plate, so troughs, tubes, petri dishes, and individual wells can carry a lid on the same API.

A seal is not a lid.

If your process heat-seals a plate rather than lidding it, there is no Lid object to move — the seal is applied by a machine and PLR has no representation of it. Model it however you like (a flag on your own protocol state), but do not expect has_lid to know about it.


11.3 Move a plate onto a magnetic bead stand

Move a plate onto an Alpaqua Magnum FLX magnetic rack for a bead cleanup, then take it off again.

magnet = alpaqua_96_plateadapter_magnum_flx(name="magnet")
lh.deck.assign_child_resource(magnet, rails=20)

await lh.move_plate(plate, magnet)

# ... beads pellet against the magnet; aspirate the supernatant ...

await lh.move_plate(plate, carrier[0])
1
The magnet is a PlateAdapter, not a machine. There is no turn_on() — see below.
2
Because it is a PlateAdapter, it computes where the plate sits rather than you supplying a location. compute_plate_location() does this from the plate’s own well geometry.
3
Coming off the magnet is the same call in reverse.

A magnet is not a machine.

Nothing turns this magnet on. Engagement is the plate’s position — the field is always there, and the plate being on the adapter is what puts the beads in it. This is the same idea as Incubator.take_in_plate(), whose entire implementation is two lines of tree bookkeeping:

plate.unassign()
site.assign_child_resource(plate)

State expressed by position rather than by command. Once you see it, a lot of PLR reads differently.

WarningGotcha: plate_z_offset is not universal

The shipped definition carries this comment:

dz=27.5,              # refers to magnet hole bottom
plate_z_offset=0.0,   # adjust at runtime based on plate's well geometry

plate_z_offset=0.0 is a default, not a measurement for your plate. A skirted PCR plate and a deep-well block do not sit at the same height on the same magnet. An unchecked value puts the tip above the liquid or into the plate bottom.

Gotcha: use the snake_case name.

Alpaqua_96_magnum_flx still exists but is a deprecation shim marked # TODO: Remove >2026-02. Use alpaqua_96_plateadapter_magnum_flx. This rename is part of a library-wide migration to <vendor>_<n>_<type>_<volume>uL_<bottom>; expect to meet it elsewhere.

11.4 Aspirating off the magnet

The reason bead protocols exist is to remove supernatant without disturbing the pellet, which lives on one side of the well. That is an offset, not a different well:

from pylabrobot.resources import Coordinate

await lh.aspirate(
    plate["A1:H1"],
    vols=[180] * 8,
    offsets=[Coordinate(x=-2.5, y=0, z=1.0)] * 8,
)
1
Pull 2.5 mm away from the magnet side and stay 1 mm off the bottom. Which sign of x is “away” depends on which side your magnet pellets to — check once, write it down.

See also: Chapter 4 for offsets and anchors in general; SergiLabSupplies_96_MagneticRack_250ul_Vb for a second magnet definition to compare against.


11.5 Gripper parameters

Every move takes the same set of gripper arguments. move_plate, move_lid, and move_resource differ only in their defaults.

Argument Meaning
pickup_distance_from_top how far below the resource’s top face the gripper closes, in mm
pickup_offset offset applied at the source
destination_offset offset applied at the destination
pickup_direction which side the gripper approaches from
drop_direction which side it faces when released
intermediate_locations coordinates to traverse through on the way
import inspect
from pylabrobot.liquid_handling import LiquidHandler

for name in ("move_resource", "move_plate", "move_lid"):
    d = inspect.signature(getattr(LiquidHandler, name)).parameters["pickup_distance_from_top"].default
    print(f"{name:15s} pickup_distance_from_top = {d}")
move_resource   pickup_distance_from_top = 0
move_plate      pickup_distance_from_top = 9.87
move_lid        pickup_distance_from_top = 2.37

The defaults are per-method, not per-resource: a plate is gripped 9.87 mm below its top, a lid 2.37 mm below its own top, and move_resource grips at the top face unless told otherwise. A plate whose skirt is not 9.87 mm deep needs the argument.

await lh.move_plate(
    plate, carrier[1],
    pickup_distance_from_top=12.0,
    pickup_offset=Coordinate(x=0, y=0, z=0),
    destination_offset=Coordinate(x=0, y=0, z=1.5),
    intermediate_locations=[Coordinate(x=300, y=200, z=250)],
)
1
Grip 12 mm below the top rather than the default.
2
Offsets shift the grip point at each end independently.
3
Releasing 1.5 mm high drops the plate the last fraction rather than pressing it down.
4
Waypoints between source and destination — how you route around something tall (chapter 3 finds the tall thing).

GripDirection is FRONT, BACK, LEFT, or RIGHT.

WarningGotcha: a direction change rotates the plate

If drop_direction differs from pickup_direction, the frontend rotates the resource to match, and records that rotation in the tree:

before = plate.rotation.z

await lh.move_plate(plate, carrier[2],
                    pickup_direction=GripDirection.FRONT,
                    drop_direction=GripDirection.RIGHT)
after_right = plate.rotation.z

await lh.move_plate(plate, carrier[3],
                    pickup_direction=GripDirection.RIGHT,
                    drop_direction=GripDirection.LEFT)
after_left = plate.rotation.z
print("before:        ", before)
print("FRONT -> RIGHT:", after_right)
print("RIGHT -> LEFT: ", after_left)
before:         0
FRONT -> RIGHT: 90
RIGHT -> LEFT:  270

Adjacent directions rotate 90°, opposite directions 180°, and the rotation accumulates on the resource’s existing rotation. A plate that comes back at 270° indexes along its own axes (chapter 3), so plate["A1"] is no longer where it was.

The backend receives this as three frozen dataclasses — ResourcePickup, ResourceMove, and ResourceDrop. ResourceDrop carries both directions plus the computed rotation, which is what a backend needs to command the arm (chapter 18).

See also: the three phases are separately callable as pick_up_resource, move_picked_up_resource, and drop_resource. Picking up twice without dropping raises RuntimeError: Resource ... already picked up, and a backend with no arm raises RuntimeError: No robotic arm is installed on this liquid handler.


11.6 Where a resource is gripped

pick_up_resource takes pickup_distance_from_top=None, and resolves it from the resource.

print("default:", plate.preferred_pickup_location)

plate.preferred_pickup_location = Coordinate(x=63.88, y=42.74, z=10.0)
implied = plate.get_size_z() - plate.preferred_pickup_location.z
print("implies pickup_distance_from_top =", round(implied, 2))
1
None on most definitions.
2
A point on the resource, measured in its own frame, where the gripper should close.
3
Resolution order when pickup_distance_from_top is None: use size_z - preferred_pickup_location.z if the resource has one, otherwise 5.0 mm. Both branches log at debug.
default: None
implies pickup_distance_from_top = 4.2

Machine definitions use this where the grip point is not a guess: the Byonoy plate readers ship preferred_pickup_location=Coordinate(x=size_x / 2, y=size_y / 2, z=29.5), so a plate is taken from the reader at the height the reader presents it.

Note the asymmetry: move_plate and move_lid pass a numeric default, so they never consult preferred_pickup_location. It applies to pick_up_resource and to move_resource when the argument is left as None.


11.7 Take the top plate off a stack

Take the top plate off a stack without tracking the index yourself.

from pylabrobot.resources import ResourceStack

stack = ResourceStack(name="hotel", direction="z")
lh.deck.assign_child_resource(stack, rails=25)

top = stack.get_top_item()
await lh.move_plate(top, carrier[0])
1
get_top_item() returns whatever is currently on top. After the move it returns the next plate down, because move_plate reassigned the one you took.

Gotcha: an empty stack.

get_top_item() on an empty stack raises rather than returning None. Check length first if the stack can run out.


11.8 Tell PLR a plate moved while it was off-deck

A plate was moved by hand, with no PLR call. Update the model to match.

plate.unassign()
carrier[4].assign_child_resource(plate)
1
Detach from wherever PLR thinks it is.
2
Attach where it actually is.

Every protocol that involves a human step, an off-deck machine, or a manual intervention needs this. The alternative — leaving PLR’s model stale — produces the worst class of bug in lab automation: the robot is confidently, precisely wrong.


11.9 What to remember

  • move_plate / move_lid / move_resource move the object and update the model. Both halves.
  • A PlateAdapter computes where a plate sits. Do not hand-compute z onto a magnet or a block.
  • A magnet is not a machine; position is the state.
  • When something moves without PLR knowing, unassign() then assign_child_resource().