From c253ae2daf27299123812a280eba6705fb9fe936 Mon Sep 17 00:00:00 2001 From: Claudio Date: Wed, 24 Jun 2026 04:00:47 +0200 Subject: [PATCH] `ResourceStack`: nest stacked plates by `Plate.stacking_z_height` Follow-up to #1110. When stacking bare plates in the z direction, a plate placed directly on another bare plate now nests in by `size_z - stacking_z_height` instead of resting at the lower plate's full height, so a stack of N identical plates is `size_z + (N - 1) * stacking_z_height` tall (both `get_size_z()` and child placement). Gated on the value being set and skipped when the lower plate wears a lid, so existing behaviour is unchanged for plates without a `stacking_z_height`. This parallels the lid-nesting `ResourceStack` already did. Also includes the documentation for `stacking_z_height` (the Plate resource page, the ResourceStack notebook, and the example definition in the contributor guide) that was part of the original #1110 work. These changes were force-pushed to the #1110 branch shortly after it was already merged, so they never made it into main; this PR brings them in. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../contributing-new-resources.md | 4 ++ .../itemized-resource/plate/plate.rst | 18 ++++++ .../resource-stack/resource-stack.ipynb | 31 ++++++++++ pylabrobot/resources/resource_stack.py | 52 +++++++++++++---- pylabrobot/resources/resource_stack_tests.py | 56 +++++++++++++++++++ 5 files changed, 151 insertions(+), 10 deletions(-) diff --git a/docs/contributor_guide/contributing-new-resources.md b/docs/contributor_guide/contributing-new-resources.md index addf70c5614..45e5653cbe7 100644 --- a/docs/contributor_guide/contributing-new-resources.md +++ b/docs/contributor_guide/contributing-new-resources.md @@ -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 ... ) ```` diff --git a/docs/resources/itemized-resource/plate/plate.rst b/docs/resources/itemized-resource/plate/plate.rst index 0f670b97369..7e2de7dbf36 100644 --- a/docs/resources/itemized-resource/plate/plate.rst +++ b/docs/resources/itemized-resource/plate/plate.rst @@ -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. diff --git a/docs/resources/resource-stack/resource-stack.ipynb b/docs/resources/resource-stack/resource-stack.ipynb index 76f6faf9c08..fb6b9710a7e 100644 --- a/docs/resources/resource-stack/resource-stack.ipynb +++ b/docs/resources/resource-stack/resource-stack.ipynb @@ -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", diff --git a/pylabrobot/resources/resource_stack.py b/pylabrobot/resources/resource_stack.py index b66b4b87632..c526220f81f 100644 --- a/pylabrobot/resources/resource_stack.py +++ b/pylabrobot/resources/resource_stack.py @@ -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 `. + 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 @@ -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": @@ -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 diff --git a/pylabrobot/resources/resource_stack_tests.py b/pylabrobot/resources/resource_stack_tests.py index 4a31fe60d0a..17083caf534 100644 --- a/pylabrobot/resources/resource_stack_tests.py +++ b/pylabrobot/resources/resource_stack_tests.py @@ -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))