Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/contributor_guide/contributing-new-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ def AGenBio_96_wellplate_Ub_2200ul(name: str, lid: Optional[Lid] = None) -> Plat
size_x=127.76, # from spec
size_y=85.48, # from spec
size_z=42.5, # from spec
# Optional: the vertical pitch one plate adds to a stack of identical plates
# (size_z minus how far two identical plates nest). Only needed by plate
# stackers (e.g. the Agilent BenchCel); leave unset (None) otherwise.
stacking_z_height=39.0, # measured
...
)
````
Expand Down
18 changes: 18 additions & 0 deletions docs/resources/itemized-resource/plate/plate.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,21 @@ The ``nesting_z_height`` is the overlap between the lid and the plate when the l

.. image:: /resources/img/plate/lid_nesting_z_height.jpeg
:alt: nesting_z_height measurement

----

``stacking_z_height``
---------------------
:class:`~pylabrobot.resources.plate.Plate` accepts an optional ``stacking_z_height`` argument: the vertical pitch (in mm) that one plate adds to a stack when an identical plate is placed directly on top of it. Equivalently, it is ``size_z`` minus the amount two identical plates nest into one another.

It defaults to ``None`` (unknown) and is only required by devices that physically stack plates, such as the Agilent BenchCel microplate handler. It mirrors the ``stacking_z_height`` of a nested tip rack (:class:`~pylabrobot.resources.tip_rack.NestedTipRack`).

To measure it, stack two identical plates and measure the total height with a caliper; then::

stacking_z_height = height_of_two_stacked_plates - size_z

More generally, a stack of ``N`` identical plates is ``size_z + (N - 1) * stacking_z_height`` tall.

When set, :class:`~pylabrobot.resources.resource_stack.ResourceStack` uses this value so that bare plates stacked in the z direction nest into one another (a plate placed on another bare plate sinks in by ``size_z - stacking_z_height``). Plates without a ``stacking_z_height``, and plates wearing a lid, do not nest.

Because ``stacking_z_height`` is a physical dimension, two plates that differ in it are treated as different labware and do not compare equal.
31 changes: 31 additions & 0 deletions docs/resources/resource-stack/resource-stack.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,37 @@
"stacking_area.get_top_item() is plate"
]
},
{
"cell_type": "markdown",
"id": "a1b2c3d4",
"metadata": {},
"source": [
"### Nesting plates by `stacking_z_height`\n",
"\n",
"Bare plates that define a `stacking_z_height` nest into one another when stacked in the\n",
"z direction: a plate placed on another bare plate sinks in by `size_z - stacking_z_height`,\n",
"so a stack of `N` identical plates is `size_z + (N - 1) * stacking_z_height` tall instead of\n",
"`N * size_z`. Plates without a `stacking_z_height`, and plates wearing a lid, do not nest.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a1b2c3d5",
"metadata": {},
"outputs": [],
"source": [
"nesting_stack = ResourceStack(\"nesting_stack\", \"z\")\n",
"nesting_stack.location = Coordinate.zero()\n",
"for i in range(3):\n",
" nesting_stack.assign_child_resource(\n",
" Plate(f\"plate_{i}\", size_x=127, size_y=86, size_z=14, ordered_items={}, stacking_z_height=4)\n",
" )\n",
"\n",
"# 14 + (3 - 1) * 4 = 22, not 3 * 14 = 42\n",
"nesting_stack.get_size_z()"
]
},
{
"cell_type": "markdown",
"id": "dc9633b5",
Expand Down
52 changes: 42 additions & 10 deletions pylabrobot/resources/resource_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ class ResourceStack(Resource):
back. Stacks growing in the z direction are from bottom to top, and function as the
`stack data type <https://en.wikipedia.org/wiki/Stack_(abstract_data_type)>`.

When stacking in the z direction, bare plates nest into one another: if a plate defines a
``stacking_z_height`` (the vertical pitch it adds to a stack) and is placed directly on top of
another bare plate, it sinks in by ``size_z - stacking_z_height`` instead of resting at the
lower plate's full height. A stack of ``N`` identical such plates is therefore
``size_z + (N - 1) * stacking_z_height`` tall. Plates without a ``stacking_z_height``, and plates
wearing a lid, do not nest.

Attributes:
name: The name of the resource group.
location: The location of the resource group. This will be the location of the first resource in
Expand Down Expand Up @@ -84,22 +91,45 @@ def get_size_y(self) -> float:
return sum(child.get_size_y() for child in self.children)
return max(resource.get_size_y() for resource in self.children)

@staticmethod
def _actual_resource_height(resource: Resource) -> float:
"""The height a resource occupies on its own, accounting for the lid nesting height if the
resource is a plate with a lid."""
if isinstance(resource, Plate) and resource.lid is not None:
return resource.get_size_z() + resource.lid.get_size_z() - resource.lid.nesting_z_height
return resource.get_size_z()

def _nesting_overlap(self, upper: Resource, lower: Optional[Resource]) -> float:
"""How far ``upper`` sinks into ``lower`` when stacked in the z direction (``0`` if they do not
nest). Only a bare plate stacked on a bare plate with a known ``stacking_z_height`` nests; the
overlap is then ``size_z - stacking_z_height`` (i.e. the plate adds only its stacking pitch to
the stack instead of its full height)."""
if (
self.direction == "z"
and isinstance(upper, Plate)
and upper.stacking_z_height is not None
and isinstance(lower, Plate)
and lower.lid is None
):
return upper.get_size_z() - upper.stacking_z_height
return 0.0

def get_size_z(self) -> float:
"""Get local size in the z direction."""

def get_actual_resource_height(resource: Resource) -> float:
"""Helper function to get the actual height of a resource, accounting for the lid nesting
height if the resource is a plate with a lid."""
if isinstance(resource, Plate) and resource.lid is not None:
return resource.get_size_z() + resource.lid.get_size_z() - resource.lid.nesting_z_height
return resource.get_size_z()

if len(self.children) == 0:
return 0

if self.direction != "z":
return max(get_actual_resource_height(child) for child in self.children)
return sum(get_actual_resource_height(child) for child in self.children)
return max(self._actual_resource_height(child) for child in self.children)

# Sum bottom -> top, letting bare plates nest into one another by their stacking pitch.
total = 0.0
prev: Optional[Resource] = None
for child in self.children:
total += self._actual_resource_height(child) - self._nesting_overlap(child, prev)
prev = child
return total

def get_resource_stack_edge(self) -> Coordinate:
if self.direction == "x":
Expand All @@ -115,7 +145,9 @@ def get_resource_stack_edge(self) -> Coordinate:

def get_new_child_location(self, resource: Resource) -> Coordinate:
"""Get the location where a new child resource should be placed in the stack."""
return get_child_location(resource) + self.get_resource_stack_edge()
lower = self.children[-1] if len(self.children) > 0 else None
overlap = Coordinate(0, 0, self._nesting_overlap(resource, lower))
return get_child_location(resource) + self.get_resource_stack_edge() - overlap

def assign_child_resource(
self, resource: Resource, location: Optional[Coordinate] = None, reassign: bool = True
Expand Down
56 changes: 56 additions & 0 deletions pylabrobot/resources/resource_stack_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,59 @@ def test_get_absolute_location_stack_height(self):
top_item = stacking_area.get_top_item()
assert top_item is not None
self.assertEqual(top_item.get_absolute_location(), Coordinate(0, 0, 1))


class ResourceStackPlateNestingTests(unittest.TestCase):
"""Bare plates with a known `stacking_z_height` should nest into one another in a z-stack."""

def _plate(self, name, stacking_z_height=None):
return Plate(
name,
size_x=10,
size_y=10,
size_z=10,
ordered_items={},
stacking_z_height=stacking_z_height,
)

def test_without_stacking_z_height_no_nesting(self):
# backwards-compatible: plates without a stacking pitch stack at full size_z.
stack = ResourceStack("s", "z")
stack.location = Coordinate.zero()
stack.assign_child_resource(self._plate("p1"))
stack.assign_child_resource(self._plate("p2"))
self.assertEqual(stack.get_size_z(), 20)
self.assertEqual(stack.get_top_item().get_absolute_location(), Coordinate(0, 0, 10))

def test_two_plates_nest(self):
stack = ResourceStack("s", "z")
stack.location = Coordinate.zero()
stack.assign_child_resource(self._plate("p1", stacking_z_height=4))
stack.assign_child_resource(self._plate("p2", stacking_z_height=4))
# height = size_z + (N-1) * stacking_z_height = 10 + 4
self.assertEqual(stack.get_size_z(), 14)
# second plate sinks into the first: base at the stacking pitch (4), not at full height (10).
self.assertEqual(stack.get_top_item().get_absolute_location(), Coordinate(0, 0, 4))

def test_three_plates_nest(self):
stack = ResourceStack("s", "z")
stack.location = Coordinate.zero()
for i in range(3):
stack.assign_child_resource(self._plate(f"p{i}", stacking_z_height=4))
# height = 10 + 2 * 4
self.assertEqual(stack.get_size_z(), 18)
self.assertEqual(stack.get_top_item().get_absolute_location(), Coordinate(0, 0, 8))

def test_no_nesting_onto_lidded_plate(self):
# a plate cannot nest into a plate that is wearing a lid.
lower = self._plate("lower", stacking_z_height=4)
lower.assign_child_resource(
Lid("lid", size_x=10, size_y=10, size_z=3, nesting_z_height=1),
location=Coordinate(0, 0, 0),
)
stack = ResourceStack("s", "z")
stack.location = Coordinate.zero()
stack.assign_child_resource(lower)
stack.assign_child_resource(self._plate("upper", stacking_z_height=4))
# lower occupies size_z + lid overhang = 10 + (3 - 1) = 12; upper sits on top at full height.
self.assertEqual(stack.get_top_item().get_absolute_location(), Coordinate(0, 0, 12))
Loading