From 31fd1530016ea621c5f6b50c511d12659bb3bfe1 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Tue, 6 Oct 2026 22:53:46 +0100 Subject: [PATCH] v1 `STAR` and Prep tip commands: a rack no head can reach is refused The legacy `LiquidHandler` refuses to pick up from, or drop into, a tip rack with a lid on or with another rack standing on it. The v1 `STAR` and Prep drivers do not: handed the bottom rack of a stack of four, `pick_up_tips` sends the command. The check behind legacy's refusal is a private property on `TipRack` that nothing else may read. - `TipRack.available_for_tip_handling` is that property, public: a rack is available when it is placed, has no lid on, and no other rack stands on it in its stack. The private name goes; legacy's one caller reads the public one. - `check_tip_racks_available(tip_spots, action)` raises for a rack that is not available, asking each rack once. - `pick_up_tips` and `drop_tips` call it before anything is sent: on the `STAR`'s channels and 96-head, and on the Prep's channels and head8. - `find_tip_spots(..., available_for_tip_handling=True)` returns spots only from racks a head can reach, `False` only from those it cannot, and `None`, the default, from both. Co-Authored-By: Claude Opus 5.5 --- .../hamilton/prep/driver/features/head8.py | 7 +++- .../prep/driver/features/head8_tests.py | 29 ++++++++++++++++ .../hamilton/prep/driver/features/pipettes.py | 7 +++- .../prep/driver/features/pipettes_tests.py | 34 +++++++++++++++++++ .../hamilton/star/driver/features/head96.py | 4 ++- .../star/driver/features/head_tests.py | 16 +++++++++ .../hamilton/star/driver/features/pipettes.py | 9 ++++- .../star/driver/features/pipettes_tests.py | 30 ++++++++++++++++ .../legacy/liquid_handling/liquid_handler.py | 8 ++--- .../liquid_handling/liquid_handler_tests.py | 8 ++--- .../lib/liquid_handling/tip_spot_finding.py | 11 +++++- .../liquid_handling/tip_spot_finding_tests.py | 31 ++++++++++++++++- pylabrobot/resources/tip_rack.py | 32 +++++++++++++---- pylabrobot/resources/tip_rack_tests.py | 12 ++++--- 14 files changed, 214 insertions(+), 24 deletions(-) diff --git a/pylabrobot/hamilton/prep/driver/features/head8.py b/pylabrobot/hamilton/prep/driver/features/head8.py index 95fbbe474a3..aa334e98125 100644 --- a/pylabrobot/hamilton/prep/driver/features/head8.py +++ b/pylabrobot/hamilton/prep/driver/features/head8.py @@ -56,7 +56,7 @@ queue_volume_transfers, successes_from_failed_channels, ) -from pylabrobot.resources.tip_rack import TipSpot, tip_origin +from pylabrobot.resources.tip_rack import TipSpot, check_tip_racks_available, tip_origin from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.volume_tracker import does_volume_tracking @@ -547,6 +547,7 @@ async def pick_up_tips( self._require_all_channels(use_channels, "pick_up_tips") if len(tip_spots) != NUM_PROBES: raise ValueError(f"pick_up_tips requires {NUM_PROBES} tip spots, got {len(tip_spots)}") + check_tip_racks_available(tip_spots, "pick up tips from") resolved_end = self._resolve_traverse_height(minimum_traverse_height_end) for ch in use_channels: @@ -643,6 +644,10 @@ async def drop_tips( self._require_all_channels(use_channels, "drop_tips") if len(destinations) != NUM_PROBES: raise ValueError(f"drop_tips requires {NUM_PROBES} destinations, got {len(destinations)}") + check_tip_racks_available( + [destination for destination in destinations if isinstance(destination, TipSpot)], + "drop tips into", + ) tip = self._require_mounted_tip() resolved_end = self._resolve_traverse_height(minimum_traverse_height_end) diff --git a/pylabrobot/hamilton/prep/driver/features/head8_tests.py b/pylabrobot/hamilton/prep/driver/features/head8_tests.py index f1a04f983ae..ae5a8d0058a 100644 --- a/pylabrobot/hamilton/prep/driver/features/head8_tests.py +++ b/pylabrobot/hamilton/prep/driver/features/head8_tests.py @@ -36,6 +36,7 @@ hamilton_96_tiprack_50uL_NTR, hamilton_96_tiprack_1000uL, ) +from pylabrobot.resources.lid import Lid from pylabrobot.resources.tip_tracker import does_tip_tracking, set_tip_tracking from pylabrobot.resources.volume_tracker import does_volume_tracking, set_volume_tracking @@ -166,6 +167,34 @@ async def _run() -> None: asyncio.run(_run()) +def test_head8_tip_commands_refuse_a_rack_no_head_can_reach(): + """A rack under a lid is not picked up from, nor dropped into, and nothing is sent.""" + + async def _run() -> None: + deck, tip_rack, _, _ = _make_deck() + p = PrepSimulationDriver(deck=deck, declared_configuration_json=RECORDING_PREP_HEAD8) + await p.setup() + assert p.head8 is not None + spots = tip_rack.column(0) + + tip_rack.lid = Lid("lid", size_x=127.0, size_y=86.0, size_z=10.0, nesting_z_height=2.0) + sent, _ = _record_send(p) + with pytest.raises(ValueError, match="pick up tips from 'ntr': it is not available"): + await p.head8.pick_up_tips(spots) + assert sent == [] + + tip_rack.lid.unassign() + await p.head8.pick_up_tips(spots) + tip_rack.lid = Lid("lid", size_x=127.0, size_y=86.0, size_z=10.0, nesting_z_height=2.0) + sent.clear() + with pytest.raises(ValueError, match="drop tips into 'ntr': it is not available"): + await p.head8.drop_tips(spots) + assert sent == [] + await p.stop() + + asyncio.run(_run()) + + def test_head8_tips_move_between_spots_and_shafts(): """With tip tracking on, each tip moves off its spot onto its probe's shaft, and back.""" from pylabrobot.resources.tip_tracking import set_tip_tracking diff --git a/pylabrobot/hamilton/prep/driver/features/pipettes.py b/pylabrobot/hamilton/prep/driver/features/pipettes.py index 1d241b97676..614b828317f 100644 --- a/pylabrobot/hamilton/prep/driver/features/pipettes.py +++ b/pylabrobot/hamilton/prep/driver/features/pipettes.py @@ -86,7 +86,7 @@ queue_volume_transfers, successes_from_failed_channels, ) -from pylabrobot.resources.tip_rack import TipSpot, tip_origin +from pylabrobot.resources.tip_rack import TipSpot, check_tip_racks_available, tip_origin from pylabrobot.resources.trash import Trash from pylabrobot.resources.well import CrossSectionType, Well, WellBottomType @@ -4649,6 +4649,7 @@ async def pick_up_tips( offsets_list = list(offsets) if offsets is not None else [Coordinate.zero()] * len(tip_spots) if len(offsets_list) != len(tip_spots): raise ValueError("len(offsets) must equal len(tip_spots)") + check_tip_racks_available(tip_spots, "pick up tips from") # The fewest groups of spots the channels can reach without moving the gantry between them batches = plan_batches( @@ -4919,6 +4920,10 @@ async def drop_tips( offsets_list = list(offsets) if offsets is not None else [Coordinate.zero()] * len(destinations) if len(offsets_list) != len(destinations): raise ValueError("len(offsets) must equal len(destinations)") + check_tip_racks_available( + [destination for destination in destinations if isinstance(destination, TipSpot)], + "drop tips into", + ) if all(isinstance(destination, Trash) for destination in destinations): await self._drop_tips_in_one_move( diff --git a/pylabrobot/hamilton/prep/driver/features/pipettes_tests.py b/pylabrobot/hamilton/prep/driver/features/pipettes_tests.py index 918dbce4343..54e8f96988b 100644 --- a/pylabrobot/hamilton/prep/driver/features/pipettes_tests.py +++ b/pylabrobot/hamilton/prep/driver/features/pipettes_tests.py @@ -68,6 +68,7 @@ hamilton_tip_300uL, ) from pylabrobot.resources.hamilton.core_gripper_tools import hamilton_core_gripper_tool +from pylabrobot.resources.lid import Lid from pylabrobot.resources.liquid import Liquid from pylabrobot.resources.tip_tracking import set_tip_tracking from pylabrobot.resources.volume_tracker import set_volume_tracking @@ -168,6 +169,39 @@ async def _t(): _run(_t()) +def test_tip_commands_refuse_a_rack_no_head_can_reach(): + """A rack under a lid is not picked up from, nor dropped into, and nothing is sent.""" + + async def _t(): + set_tip_tracking(True) + try: + deck = PrepDeck() + tip_rack = deck[3] = hamilton_96_tiprack_50uL_NTR(name="ntr", with_tips=True) + p = PrepSimulationDriver(deck=deck) + await p.setup() + assert p.pipettes is not None + spots = [tip_rack.get_item("A1"), tip_rack.get_item("B1")] + + tip_rack.lid = Lid("lid", size_x=127.0, size_y=86.0, size_z=10.0, nesting_z_height=2.0) + sent = _record(p) + with pytest.raises(ValueError, match="pick up tips from 'ntr': it is not available"): + await p.pipettes.pick_up_tips(spots, use_channels=[0, 1]) + assert sent == [] + + tip_rack.lid.unassign() + await p.pipettes.pick_up_tips(spots, use_channels=[0, 1]) + tip_rack.lid = Lid("lid", size_x=127.0, size_y=86.0, size_z=10.0, nesting_z_height=2.0) + sent.clear() + with pytest.raises(ValueError, match="drop tips into 'ntr': it is not available"): + await p.pipettes.drop_tips(spots, use_channels=[0, 1]) + assert sent == [] + await p.stop() + finally: + set_tip_tracking(False) + + _run(_t()) + + def test_channels_tip_and_volume_trackers_follow_pick_up_aspirate_dispense_and_drop(): """Tips move between their spots and the channels' shafts; wells and tips track the volume moved.""" diff --git a/pylabrobot/hamilton/star/driver/features/head96.py b/pylabrobot/hamilton/star/driver/features/head96.py index 81d9babed1b..98d938f712b 100644 --- a/pylabrobot/hamilton/star/driver/features/head96.py +++ b/pylabrobot/hamilton/star/driver/features/head96.py @@ -38,7 +38,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.resource import Resource from pylabrobot.resources.tip import Tip -from pylabrobot.resources.tip_rack import TipRack, tip_origin +from pylabrobot.resources.tip_rack import TipRack, check_tip_racks_available, tip_origin from pylabrobot.resources.volume_tracker import VolumeTracker, does_volume_tracking from pylabrobot.resources.well import Well @@ -698,6 +698,7 @@ async def pick_up_tips( raise RuntimeError("the head already carries tips; drop them before picking up more") if tip_rack.num_items != 96: raise ValueError("Tip rack must have 96 tips") + check_tip_racks_available(tip_rack.get_all_items()[:1], "pick up tips from") tips = [ spot.tip_for_pickup() if not spot.tracks_tips or spot.tip is not None else None for spot in tip_rack.get_all_items() @@ -783,6 +784,7 @@ async def drop_tips( if isinstance(resource, TipRack): if resource.num_items != 96: raise ValueError("Tip rack must have 96 tips") + check_tip_racks_available(resource.get_all_items()[:1], "drop tips into") location = resource.get_item("A1").get_location_wrt(deck, x="c", y="c", z="b") else: location = self._position_centred_in(resource) diff --git a/pylabrobot/hamilton/star/driver/features/head_tests.py b/pylabrobot/hamilton/star/driver/features/head_tests.py index 420f16e6169..d746b60a286 100644 --- a/pylabrobot/hamilton/star/driver/features/head_tests.py +++ b/pylabrobot/hamilton/star/driver/features/head_tests.py @@ -182,6 +182,22 @@ def recorded(written: str, read: Optional[str]) -> None: self.driver._log_exchange = recorded # type: ignore[method-assign] + async def test_a_covered_rack_is_not_picked_up_from_nor_dropped_into(self): + from pylabrobot.resources.lid import Lid + + self.tip_rack.lid = Lid("lid", size_x=127.0, size_y=86.0, size_z=10.0, nesting_z_height=2.0) + with self.assertRaisesRegex(ValueError, "pick up tips from 'tip_rack_01': it is not available"): + await self.head.pick_up_tips(self.tip_rack) + self.assertEqual(self.sent, []) + + self.tip_rack.lid.unassign() + await self.head.pick_up_tips(self.tip_rack) + self.tip_rack.lid = Lid("lid", size_x=127.0, size_y=86.0, size_z=10.0, nesting_z_height=2.0) + self.sent.clear() + with self.assertRaisesRegex(ValueError, "drop tips into 'tip_rack_01': it is not available"): + await self.head.drop_tips(self.tip_rack) + self.assertEqual(self.sent, []) + async def test_pick_up_and_drop_send_what_legacy_sends(self): await self.head.pick_up_tips(self.tip_rack) await self.head.drop_tips(self.tip_rack) diff --git a/pylabrobot/hamilton/star/driver/features/pipettes.py b/pylabrobot/hamilton/star/driver/features/pipettes.py index 931221ef832..eb6c3e413ad 100644 --- a/pylabrobot/hamilton/star/driver/features/pipettes.py +++ b/pylabrobot/hamilton/star/driver/features/pipettes.py @@ -70,7 +70,12 @@ from pylabrobot.resources.n_channel_pipettes import NChannelPipette, TipMountingShaft from pylabrobot.resources.resource import Resource from pylabrobot.resources.tip import Tip -from pylabrobot.resources.tip_rack import TipRack, TipSpot, tip_origin +from pylabrobot.resources.tip_rack import ( + TipRack, + TipSpot, + check_tip_racks_available, + tip_origin, +) from pylabrobot.resources.volume_tracker import VolumeTracker, does_volume_tracking from pylabrobot.resources.well import Well @@ -4270,6 +4275,7 @@ async def pick_up_tips( if not tip_spots: return + check_tip_racks_available(tip_spots, "pick up tips from") tips = [spot.tip_for_pickup() for spot in tip_spots] if not all(isinstance(tip, HamiltonTip) for tip in tips): raise TypeError("the STAR picks up Hamilton tips") @@ -4540,6 +4546,7 @@ async def drop_tips( offsets = [Coordinate.zero()] * len(destinations) if offsets is None else list(offsets) spots = [place for place in destinations if isinstance(place, TipSpot)] + check_tip_racks_available(spots, "drop tips into") if len({id(spot) for spot in spots}) != len(spots): raise ValueError("each tip must go into a spot of its own") for spot in spots: diff --git a/pylabrobot/hamilton/star/driver/features/pipettes_tests.py b/pylabrobot/hamilton/star/driver/features/pipettes_tests.py index 5f6b854bf1d..a4e01eae17a 100644 --- a/pylabrobot/hamilton/star/driver/features/pipettes_tests.py +++ b/pylabrobot/hamilton/star/driver/features/pipettes_tests.py @@ -2299,6 +2299,36 @@ async def answered(module: str, command: str, **kwargs: Any): driver._answer = answered +class TestTipCommandsRefuseARackNoHeadCanReach(unittest.IsolatedAsyncioTestCase): + """A rack under a lid is not picked up from, nor dropped into, and nothing is sent.""" + + def setUp(self): + from pylabrobot.resources import set_tip_tracking + + set_tip_tracking(True) + self.addCleanup(set_tip_tracking, False) + + async def test_a_pickup_from_a_covered_rack_is_refused(self): + from pylabrobot.resources.lid import Lid + + pipettes, rack, sent = await channels_over_a_rack() + rack.lid = Lid("lid", size_x=127.0, size_y=86.0, size_z=10.0, nesting_z_height=2.0) + with self.assertRaisesRegex(ValueError, "pick up tips from 'rack': it is not available"): + await pipettes.pick_up_tips(rack["A1:H1"]) + self.assertEqual(sent, []) + + async def test_a_drop_into_a_covered_rack_is_refused(self): + from pylabrobot.resources.lid import Lid + + pipettes, rack, sent = await channels_over_a_rack() + await pipettes.pick_up_tips(rack["A1:H1"]) + rack.lid = Lid("lid", size_x=127.0, size_y=86.0, size_z=10.0, nesting_z_height=2.0) + sent.clear() + with self.assertRaisesRegex(ValueError, "drop tips into 'rack': it is not available"): + await pipettes.drop_tips(rack["A1:H1"]) + self.assertEqual(sent, []) + + class TestProbeTipPresenceViaPickup(unittest.IsolatedAsyncioTestCase): """Spots probed by picking their tips up and putting them back; empty ones answer as a device.""" diff --git a/pylabrobot/legacy/liquid_handling/liquid_handler.py b/pylabrobot/legacy/liquid_handling/liquid_handler.py index 8a6a0ed2ef2..ca63756ac3f 100644 --- a/pylabrobot/legacy/liquid_handling/liquid_handler.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler.py @@ -330,12 +330,12 @@ def _check_no_lid(resource: Resource, action: str) -> None: def _check_tip_racks_available(resources: Sequence[TipSpot], action: str) -> None: - """Raise if a lid or another tip rack sits on a rack behind ``resources``, checking each rack once. - ``action`` is a verb phrase for the error.""" + """Raise if a rack behind ``resources`` is not available for tip handling, checking each rack + once. ``action`` is a verb phrase for the error.""" racks = {id(r.parent): r.parent for r in resources if isinstance(r.parent, TipRack)} for rack in racks.values(): - if not rack._available_for_tip_handling: - raise ValueError(f"Cannot {action} {rack.name!r}: something is stacked on top of it.") + if not rack.available_for_tip_handling: + raise ValueError(f"Cannot {action} {rack.name!r}: it is not available for tip handling.") class LiquidHandler(Resource, Machine): diff --git a/pylabrobot/legacy/liquid_handling/liquid_handler_tests.py b/pylabrobot/legacy/liquid_handling/liquid_handler_tests.py index 1aa2cda906d..cbff586b9f8 100644 --- a/pylabrobot/legacy/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler_tests.py @@ -1403,22 +1403,22 @@ async def asyncSetUp(self): await self.lh.setup() async def test_only_the_top_rack_of_a_stack_can_be_picked_up_from(self): - with self.assertRaisesRegex(ValueError, "'bottom': something is stacked on top of it"): + with self.assertRaisesRegex(ValueError, "'bottom': it is not available for tip handling"): await self.lh.pick_up_tips([self.bottom.get_item("A1")]) self.backend.pick_up_tips.assert_not_called() await self.lh.pick_up_tips([self.top.get_item("A1")]) self.backend.pick_up_tips.assert_called_once() async def test_tips_are_not_dropped_into_a_covered_rack(self): - with self.assertRaisesRegex(ValueError, "'bottom': something is stacked on top of it"): + with self.assertRaisesRegex(ValueError, "'bottom': it is not available for tip handling"): await self.lh.drop_tips([self.bottom.get_item("A1")]) self.backend.drop_tips.assert_not_called() async def test_a_lid_covers_a_rack_for_the_96_head(self): self.top.lid = Lid("lid", size_x=127.76, size_y=85.48, size_z=10.0, nesting_z_height=2.0) - with self.assertRaisesRegex(ValueError, "'top': something is stacked on top of it"): + with self.assertRaisesRegex(ValueError, "'top': it is not available for tip handling"): await self.lh.pick_up_tips96(self.top) - with self.assertRaisesRegex(ValueError, "'top': something is stacked on top of it"): + with self.assertRaisesRegex(ValueError, "'top': it is not available for tip handling"): await self.lh.drop_tips96(self.top) self.backend.pick_up_tips96.assert_not_called() self.backend.drop_tips96.assert_not_called() diff --git a/pylabrobot/lib/liquid_handling/tip_spot_finding.py b/pylabrobot/lib/liquid_handling/tip_spot_finding.py index 5eca18315e4..9a3a69b5551 100644 --- a/pylabrobot/lib/liquid_handling/tip_spot_finding.py +++ b/pylabrobot/lib/liquid_handling/tip_spot_finding.py @@ -104,6 +104,7 @@ def find_tip_spots( has_filter: Optional[bool] = None, count: Optional[int] = None, x_aligned: bool = False, + available_for_tip_handling: Optional[bool] = None, ) -> List[TipSpot]: """Find tip spots in consumption order. @@ -126,6 +127,9 @@ def find_tip_spots( x_aligned: Return spots from a single rack column: the first column in consumption order holding at least count spots. A shorter column is skipped. Falls back to the unaligned batch when no column can serve count. Without count, returns the first column. + available_for_tip_handling: True for spots in racks a head can reach, False for spots in + racks it cannot, e.g. under a lid or under another rack in a stack, None for both. See + `TipRack.available_for_tip_handling`. Returns: Matching tip spots. @@ -137,7 +141,12 @@ def find_tip_spots( if count is not None and count <= 0: raise ValueError(f"count must be positive, got {count}") - racks = _get_tip_racks(root) + racks = [ + (rack, frame) + for rack, frame in _get_tip_racks(root) + if available_for_tip_handling is None + or rack.available_for_tip_handling == available_for_tip_handling + ] rack_order = sorted(range(len(racks)), key=lambda index: _get_rack_rank(*racks[index], index)) diff --git a/pylabrobot/lib/liquid_handling/tip_spot_finding_tests.py b/pylabrobot/lib/liquid_handling/tip_spot_finding_tests.py index be8309237b9..32472aaf2c7 100644 --- a/pylabrobot/lib/liquid_handling/tip_spot_finding_tests.py +++ b/pylabrobot/lib/liquid_handling/tip_spot_finding_tests.py @@ -7,10 +7,18 @@ import pytest from pylabrobot.lib.liquid_handling.tip_spot_finding import find_tip_spots -from pylabrobot.resources import TIP_CAR_480_A00, Coordinate, Resource, TipRack, TipSpot +from pylabrobot.resources import ( + TIP_CAR_480_A00, + Coordinate, + Resource, + ResourceStack, + TipRack, + TipSpot, +) from pylabrobot.resources.hamilton import ( hamilton_96_tiprack_50uL, hamilton_96_tiprack_300uL_filter, + hamilton_96_tiprack_300uL_NTR, hamilton_96_tiprack_1000uL_filter, ) @@ -193,6 +201,27 @@ def test_matches_reference_on_random_decks(seed: int) -> None: assert find_tip_spots(root, *query) == _reference_find_tip_spots(racks, *query), query +def test_available_for_tip_handling_selects_by_whether_a_head_can_reach_the_rack() -> None: + """Asked for, a rack with another standing on it gives no spots until that one is taken off.""" + root = Resource("root", size_x=1000, size_y=1000, size_z=100) + stack = ResourceStack("stack", "z") + root.assign_child_resource(stack, location=Coordinate(0, 0, 0)) + lower, upper = hamilton_96_tiprack_300uL_NTR("lower"), hamilton_96_tiprack_300uL_NTR("upper") + stack.assign_child_resource(lower) + stack.assign_child_resource(upper) + + either = find_tip_spots(root, has_tip=True) + reachable = find_tip_spots(root, has_tip=True, available_for_tip_handling=True) + covered = find_tip_spots(root, has_tip=True, available_for_tip_handling=False) + assert {spot.parent for spot in either} == {lower, upper} + assert {spot.parent for spot in reachable} == {upper} + assert {spot.parent for spot in covered} == {lower} + + stack.unassign_child_resource(upper) + reachable = find_tip_spots(root, has_tip=True, available_for_tip_handling=True) + assert {spot.parent for spot in reachable} == {lower} + + def test_root_can_be_any_level_of_the_tree() -> None: """A single rack, a carrier or a facility above several benches all work as the root.""" facility = Resource("facility", size_x=5000, size_y=5000, size_z=100) diff --git a/pylabrobot/resources/tip_rack.py b/pylabrobot/resources/tip_rack.py index 198f7f8499c..633e4640139 100644 --- a/pylabrobot/resources/tip_rack.py +++ b/pylabrobot/resources/tip_rack.py @@ -353,14 +353,18 @@ def __repr__(self) -> str: ) @property - def _available_for_tip_handling(self) -> bool: - """Whether nothing, a lid or another rack in its stack, sits on top of this rack.""" + def available_for_tip_handling(self) -> bool: + """Whether a head can reach this rack's tips: it is placed, has no lid on, and no other rack + stands on it in its stack.""" + if self.location is None: + return False if self.lid is not None: return False - stack = self.parent - return not ( - isinstance(stack, ResourceStack) and stack.direction == "z" and stack.children[-1] is not self - ) + parent = self.parent + if isinstance(parent, ResourceStack) and parent.direction == "z": + return parent.children[-1] is self + # TODO: build gating logic for placement in a storage unit such as a carousel or hotel + return True @staticmethod def _occupied_func(item: TipSpot): @@ -453,6 +457,22 @@ def get_all_tips(self) -> List[Tip]: return [ts.get_tip() for ts in self.get_all_items()] +def check_tip_racks_available(tip_spots: Sequence[TipSpot], action: str) -> None: + """Raise if a head cannot reach a rack behind `tip_spots`, asking each rack once. + + Args: + tip_spots: the spots a tip command is about. + action: what the command does with the rack, for the error, e.g. "pick up tips from". + + Raises: + ValueError: If a rack is not available for tip handling. + """ + racks = {id(spot.parent): spot.parent for spot in tip_spots if isinstance(spot.parent, TipRack)} + for rack in racks.values(): + if not rack.available_for_tip_handling: + raise ValueError(f"Cannot {action} {rack.name!r}: it is not available for tip handling.") + + class EmbeddedTipRack(TipRack): """The EmbeddedTipRack - this is what some might call a "standard" TipRack; they cannot stand on their own, they require an EmbeddedTipRackHolder at all times to be functional. diff --git a/pylabrobot/resources/tip_rack_tests.py b/pylabrobot/resources/tip_rack_tests.py index 2569c300480..79c5ba235b5 100644 --- a/pylabrobot/resources/tip_rack_tests.py +++ b/pylabrobot/resources/tip_rack_tests.py @@ -166,10 +166,14 @@ def _lid(self, name="lid"): def test_a_lid_seats_on_the_top_face_and_covers_the_rack(self): rack = StandingTipRack("rack", size_x=10, size_y=10, size_z=55, ordered_items={}) - self.assertTrue(rack._available_for_tip_handling) + self.assertFalse(rack.available_for_tip_handling) # placed nowhere + Resource("root", size_x=100, size_y=100, size_z=100).assign_child_resource( + rack, location=Coordinate(0, 0, 0) + ) + self.assertTrue(rack.available_for_tip_handling) rack.lid = self._lid() self.assertEqual(rack.lid.location, Coordinate(0, 0, 53)) - self.assertFalse(rack._available_for_tip_handling) + self.assertFalse(rack.available_for_tip_handling) with self.assertRaisesRegex(ValueError, "already has a lid"): rack.lid = self._lid("lid_2") @@ -179,8 +183,8 @@ def test_only_the_top_rack_of_a_stack_is_available(self): stack = ResourceStack("stack", "z") stack.assign_child_resource(lower) stack.assign_child_resource(upper) - self.assertFalse(lower._available_for_tip_handling) - self.assertTrue(upper._available_for_tip_handling) + self.assertFalse(lower.available_for_tip_handling) + self.assertTrue(upper.available_for_tip_handling) def test_a_nested_tip_rack_takes_a_lid(self): with self.assertWarns(DeprecationWarning):