diff --git a/pylabrobot/hamilton/star/driver/features/head96.py b/pylabrobot/hamilton/star/driver/features/head96.py index 98d938f712b..9b1806bb95d 100644 --- a/pylabrobot/hamilton/star/driver/features/head96.py +++ b/pylabrobot/hamilton/star/driver/features/head96.py @@ -30,6 +30,10 @@ from pylabrobot.hamilton.star.driver.features.head import Head, HeadConfiguration from pylabrobot.hamilton.star.driver.lld_mode import LLDMode from pylabrobot.hamilton.star.liquid_classes.mapping import get_star_liquid_class +from pylabrobot.lib.liquid_handling.head_alignment import ( + get_items_under_channels, + get_shift_with_channel_over, +) from pylabrobot.lib.liquid_handling.mix import Mix from pylabrobot.resources.container import Container from pylabrobot.resources.coordinate import Coordinate @@ -38,7 +42,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, check_tip_racks_available, 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 @@ -668,24 +672,30 @@ async def pick_up_tips( tip_pickup_method: Literal["from_rack", "from_waste", "full_blowout"] = "from_rack", minimum_height_command_end: Optional[float] = None, minimum_traverse_height_start: Optional[float] = None, + *, + channel: str = "A1", + tip_spot: str = "A1", ) -> None: - """Pick up a rack of tips on the whole head, as legacy's `pick_up_tips96`. `C0 EP`. + """Pick up tips from a rack on the head, as legacy's `pick_up_tips96`. `C0 EP`. - Head channel A1 goes to the centre of spot A1, at the spot's Z. Once the device has picked them - up, the tip in each spot is mounted on the shaft of the channel with the spot's index. + Head channel `channel` goes to the centre of spot `tip_spot`, at the spot's Z. Once the device + has picked them up, each channel over a spot holding a tip has that tip on its shaft. Channels + past the rack's edge come down beside it: what stands there is the caller's to keep clear. Args: tip_rack: a 96 tip rack. Spots without a tip give none. - offset: added to spot A1's centre, in mm. + offset: added to the spot's centre, in mm. tip_pickup_method: `from_rack` sends the dispensing drive down first, since the device does not; `from_waste` and `full_blowout` move the plunger up before mounting. minimum_height_command_end: in mm. `configuration.traversal_z_position` when None. minimum_traverse_height_start: in mm. `configuration.traversal_z_position` when None. + channel: the head channel placed over `tip_spot`, A1 to H12. + tip_spot: the spot `channel` is placed over, A1 to H12. Raises: - ValueError: If the rack does not have 96 spots or holds no tips, or a position cannot be - reached. + ValueError: If the rack does not have 96 spots, no channel is over a tip, a position + cannot be reached, or a channel is past the edge of a rack with a frame. TypeError: If its tips are not Hamilton tips. RuntimeError: If the driver was given no deck, or the head already carries tips. """ @@ -699,19 +709,29 @@ async def pick_up_tips( 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") + tip_spots_under_channels = get_items_under_channels(tip_rack, channel, tip_spot) + # Past the rack's edge the head's body comes down over the rack's frame. + if None in tip_spots_under_channels and tip_rack.frame_height != 0: + raise ValueError( + f"{tip_rack.name!r} has a frame the head would hit unless channel A1 is over spot A1" + ) 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() + spot.tip_for_pickup() + if spot is not None and (not spot.tracks_tips or spot.tip is not None) + else None + for spot in tip_spots_under_channels ] prototypical_tip = next((tip for tip in tips if tip is not None), None) if prototypical_tip is None: - raise ValueError("No tips found in the tip rack.") + raise ValueError("No tips found under the head.") if not isinstance(prototypical_tip, HamiltonTip): raise TypeError("Tip type must be HamiltonTip.") tip_type_index = await self._driver.get_or_assign_tip_type_index(prototypical_tip) - location = tip_rack.get_item("A1").get_location_wrt(deck, x="c", y="c", z="b") + ( - offset or Coordinate.zero() + location = ( + tip_rack.get_item("A1").get_location_wrt(deck, x="c", y="c", z="b") + + get_shift_with_channel_over(tip_rack, channel, tip_spot, deck) + + (offset or Coordinate.zero()) ) traverse_z, end_z = self._resolve_tip_command_heights( minimum_traverse_height_start, minimum_height_command_end @@ -760,12 +780,15 @@ async def drop_tips( offset: Optional[Coordinate] = None, minimum_height_command_end: Optional[float] = None, minimum_traverse_height_start: Optional[float] = None, + *, + channel: str = "A1", + tip_spot: str = "A1", ) -> None: """Drop the head's tips into a tip rack or anywhere else, as legacy's `drop_tips96`. `C0 ER`. - Into a tip rack, head channel A1 goes to the centre of spot A1, at the spot's Z, and each - channel's tip goes into the spot with its index. Anywhere else, the head is centred over the - resource, and the tips belong to nothing afterwards. + Into a tip rack, head channel `channel` goes to the centre of spot `tip_spot`, at the spot's + Z, and each channel's tip goes into the spot under it. Anywhere else, the head is centred over + the resource, and the tips belong to nothing afterwards. Args: resource: a 96 tip rack, or anything else, such as the trash. @@ -773,19 +796,38 @@ async def drop_tips( minimum_height_command_end: in mm. `configuration.traversal_z_position` when None. minimum_traverse_height_start: in mm. `configuration.traversal_z_position` when None. + channel: the head channel placed over `tip_spot`, A1 to H12. A tip rack only. + tip_spot: the spot `channel` is placed over, A1 to H12. A tip rack only. Raises: - ValueError: If a tip rack does not have 96 spots, or a position cannot be reached. + ValueError: If a tip rack does not have 96 spots, a channel carrying a tip is not over a + spot, a position cannot be reached, or a channel is past the edge of a rack with a frame. RuntimeError: If the driver was given no deck. """ deck = self._driver.deck if deck is None: raise RuntimeError("tip commands are placed from the deck; this driver was given none") + tip_spots_under_channels: List[Optional[TipSpot]] = [] 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") + tip_spots_under_channels = get_items_under_channels(resource, channel, tip_spot) + # Past the rack's edge the head's body comes down over the rack's frame. + if None in tip_spots_under_channels and resource.frame_height != 0: + raise ValueError( + f"{resource.name!r} has a frame the head would hit unless channel A1 is over spot A1" + ) + if self.resource is not None: + beside = [ + shaft.name + for shaft, spot in zip(self.resource.get_all_items(), tip_spots_under_channels) + if shaft.has_tip() and spot is None + ] + if beside: + raise ValueError(f"channels {beside} carry a tip but are not over a spot of the rack") location = resource.get_item("A1").get_location_wrt(deck, x="c", y="c", z="b") + location += get_shift_with_channel_over(resource, channel, tip_spot, deck) else: location = self._position_centred_in(resource) location += offset or Coordinate.zero() @@ -821,8 +863,8 @@ async def drop_tips( continue tip = shaft.release_tip() if isinstance(resource, TipRack) and isinstance(tip, Tip): - spot = resource.get_item(i) - if spot.tracks_tips: + spot = tip_spots_under_channels[i] + if spot is not None and spot.tracks_tips: spot.assign_tip(tip) await self._record_after_tip_command(command_error) await self.dispensing_drive_request_uL_position() @@ -830,38 +872,41 @@ async def drop_tips( async def return_tips(self, **kwargs) -> None: """Put the head's tips back in the tip rack they were picked up from. - The rack is found from each tip's origin. Channels without a tip are skipped. + The rack, and the spot each channel stood over, are found from each tip's origin. Channels + without a tip are skipped. Args: kwargs: passed on to `drop_tips`. Raises: RuntimeError: If the driver was given no deck, the head is not modelled or carries no tips, - or a tip does not come from the spot with its channel's index in one tip rack on the deck. + or its tips did not all stand under their channels in one tip rack on the deck. """ deck = self._driver.deck if deck is None: raise RuntimeError("tip commands are placed from the deck; this driver was given none") if self.resource is None: raise RuntimeError("the head is not modelled, so where its tips came from is not known") - tip_rack: Optional[TipRack] = None - for index, shaft in enumerate(self.resource.get_all_items()): + shafts = self.resource.get_all_items() + carrying = next((shaft for shaft in shafts if isinstance(shaft.tip, Tip)), None) + if carrying is None: + raise RuntimeError("No tips have been picked up.") + origin = tip_origin(cast(Tip, carrying.tip), deck) + if origin is None or not isinstance(origin.parent, TipRack): + raise RuntimeError(f"the tip on {carrying.name} did not come from a tip rack on the deck") + tip_rack = origin.parent + # Any one tip names the pick-up: its channel stood over the spot it came from. + channel = self.resource.get_child_identifier(carrying) + tip_spot = tip_rack.get_child_identifier(origin) + tip_spots_under_channels = get_items_under_channels(tip_rack, channel, tip_spot) + for shaft, spot in zip(shafts, tip_spots_under_channels): tip = shaft.tip - if not isinstance(tip, Tip): - continue - spot = tip_origin(tip, deck) - if spot is None or not isinstance(spot.parent, TipRack): - raise RuntimeError(f"{tip.name} on {shaft.name} did not come from a tip rack on the deck") - if tip_rack is None: - tip_rack = spot.parent - if spot.parent is not tip_rack or tip_rack.get_item(index) is not spot: + if isinstance(tip, Tip) and tip_origin(tip, deck) is not spot: raise RuntimeError( - f"{shaft.name}'s tip {tip.name} is not from spot {index} of {tip_rack.name}; the head " - "returns tips only to the one rack they were picked up from" + f"{shaft.name}'s tip {tip.name} did not stand under it in {tip_rack.name}; the head " + "returns tips only as it picked them up from one rack" ) - if tip_rack is None: - raise RuntimeError("No tips have been picked up.") - await self.drop_tips(tip_rack, **kwargs) + await self.drop_tips(tip_rack, channel=channel, tip_spot=tip_spot, **kwargs) async def discard_tips(self, **kwargs) -> None: """Drop the head's tips into the deck's 96-head trash, whatever the model says it carries. diff --git a/pylabrobot/hamilton/star/driver/features/head_tests.py b/pylabrobot/hamilton/star/driver/features/head_tests.py index d746b60a286..59195a3e92e 100644 --- a/pylabrobot/hamilton/star/driver/features/head_tests.py +++ b/pylabrobot/hamilton/star/driver/features/head_tests.py @@ -158,7 +158,12 @@ class TestHead96Tips(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): from pylabrobot.resources import set_tip_tracking - from pylabrobot.resources.hamilton import TIP_CAR_480_A00, hamilton_96_tiprack_300uL_filter + from pylabrobot.resources.hamilton import ( + TIP_CAR_480_A00, + hamilton_96_tiprack_300uL_filter, + hamilton_96_tiprack_raised_core_ii, + hamilton_tip_300uL_filter, + ) set_tip_tracking(True) self.addCleanup(set_tip_tracking, False) @@ -171,6 +176,10 @@ async def asyncSetUp(self): self.head_resource = cast(NChannelPipette, self.head.resource) tip_car = TIP_CAR_480_A00(name="tip carrier") tip_car[1] = self.tip_rack = hamilton_96_tiprack_300uL_filter(name="tip_rack_01") + # A rack without a frame, which the head may be placed past the edge of. + tip_car[3] = self.frameless_tip_rack = hamilton_96_tiprack_raised_core_ii( + name="frameless_tip_rack", make_tip=hamilton_tip_300uL_filter + ) self.deck.assign_child_resource(tip_car, track=1) self.sent: List[str] = [] log = self.driver._log_exchange @@ -244,6 +253,25 @@ async def test_tips_missing_from_a_rack_are_missing_from_the_head(self): self.assertFalse(any(shaft.has_tip() for shaft in shafts)) self.tip_rack.fill() + async def test_a_channel_placed_over_a_spot_takes_only_the_tips_under_the_head(self): + # Channel H12 over spot D6: channels E7 to H12 stand over spots A1 to D6, the rest past the rack. + rack, shafts = self.frameless_tip_rack, self.head_resource + rack.get_item("A2").unassign_tip() + under = { + f"{'EFGH'[row]}{column + 7}": rack.get_item(f"{'ABCD'[row]}{column + 1}") + for row in range(4) + for column in range(6) + } + tips = {channel: spot.tip for channel, spot in under.items()} + + await self.head.pick_up_tips(rack, channel="H12", tip_spot="D6") + + # Channel A1 ends 6 columns left and 4 rows behind spot A1 (xs01179 yh4338): -54 mm X, +36 mm Y. + self.assertEqual(self.sent[-1], "C0EPxs00639xd0yh4698tt01wu0za2310zh2450ze2450") + for shaft in shafts.get_all_items(): + channel = shafts.get_child_identifier(shaft) + self.assertIs(shaft.tip, tips.get(channel), channel) + async def test_tips_dropped_in_the_trash_belong_to_nothing(self): await self.head.pick_up_tips(self.tip_rack) tips = [shaft.tip for shaft in self.head_resource.get_all_items()] @@ -311,6 +339,33 @@ async def test_return_tips_drops_them_in_the_rack_they_came_from(self): self.assertEqual([spot.tip for spot in self.tip_rack.get_all_items()], tips) self.assertFalse(any(shaft.has_tip() for shaft in self.head_resource.get_all_items())) + async def test_a_drop_that_would_let_a_tip_go_beside_the_rack_is_refused(self): + await self.head.pick_up_tips(self.tip_rack) + self.sent.clear() + with self.assertRaisesRegex(ValueError, "carry a tip but are not over a spot"): + await self.head.drop_tips(self.frameless_tip_rack, channel="A1", tip_spot="A10") + self.assertEqual(self.sent, []) + + async def test_a_rack_with_a_frame_takes_the_head_only_channel_a1_over_spot_a1(self): + with self.assertRaisesRegex(ValueError, "'tip_rack_01' has a frame the head would hit"): + await self.head.pick_up_tips(self.tip_rack, channel="H12", tip_spot="D6") + self.assertEqual(self.sent, []) + await self.head.pick_up_tips(self.frameless_tip_rack, channel="H12", tip_spot="D6") + self.sent.clear() + with self.assertRaisesRegex(ValueError, "'tip_rack_01' has a frame the head would hit"): + await self.head.drop_tips(self.tip_rack, channel="H12", tip_spot="D6") + self.assertEqual(self.sent, []) + + async def test_return_tips_puts_them_back_under_the_channels_that_took_them(self): + rack = self.frameless_tip_rack + rack.get_item("A2").unassign_tip() + tips = [spot.tip for spot in rack.get_all_items()] + await self.head.pick_up_tips(rack, channel="H12", tip_spot="D6") + self.sent.clear() + await self.head.return_tips() + self.assertEqual(self.sent, ["C0ERxs00639xd0yh4698za2310zh2450ze2450"]) + self.assertEqual([spot.tip for spot in rack.get_all_items()], tips) + async def test_discard_tips_drops_them_in_the_96_trash(self): await self.head.pick_up_tips(self.tip_rack) self.sent.clear() @@ -333,7 +388,7 @@ async def test_return_tips_refuses_tips_from_two_racks(self): shaft.release_tip() shaft.mount_tip(other_rack.get_item(5).tip_for_pickup()) self.sent.clear() - with self.assertRaisesRegex(RuntimeError, "not from spot 5 of tip_rack_01"): + with self.assertRaisesRegex(RuntimeError, "did not stand under it in tip_rack_01"): await self.head.return_tips() self.assertEqual(self.sent, []) diff --git a/pylabrobot/lib/liquid_handling/head_alignment.py b/pylabrobot/lib/liquid_handling/head_alignment.py new file mode 100644 index 00000000000..79326f2d030 --- /dev/null +++ b/pylabrobot/lib/liquid_handling/head_alignment.py @@ -0,0 +1,51 @@ +"""Align a fixed-grid head with a grid of the same pitch: which item is under which channel. + +A head channel and the item of its name share a row and a column, so a tip rack or a plate of +the head's size is the head's own grid. One channel placed over one item fixes the alignment. +""" + +from typing import List, Optional, TypeVar + +from pylabrobot.resources import Coordinate, ItemizedResource, Resource + +T = TypeVar("T", bound=Resource) + + +def get_items_under_channels( + grid: ItemizedResource[T], channel: str, item: str +) -> List[Optional[T]]: + """The item under each head channel with `channel` over `item`, in channel order. + + Args: + grid: a tip rack or a plate with the head's rows and columns. + channel: the head channel, e.g. A1. + item: the item it is placed over, e.g. A10. + + Returns: + One entry per channel; None for a channel past the grid's edge. + """ + over, under = grid.get_item(item), grid.get_item(channel) + row_shift = grid.get_child_row(over) - grid.get_child_row(under) + column_shift = grid.get_child_column(over) - grid.get_child_column(under) + items: List[Optional[T]] = [] + for named in grid.get_all_items(): + row = grid.get_child_row(named) + row_shift + column = grid.get_child_column(named) + column_shift + inside = 0 <= row < grid.num_items_y and 0 <= column < grid.num_items_x + items.append(grid.get_item((row, column)) if inside else None) + return items + + +def get_shift_with_channel_over( + grid: ItemizedResource[T], channel: str, item: str, deck: Resource +) -> Coordinate: + """How far head channel A1 stands from item A1 with `channel` over `item`, in deck mm. + + Args: + grid: a tip rack or a plate with the head's rows and columns. + channel: the head channel, e.g. A1. + item: the item it is placed over, e.g. A10. + deck: the deck the grid stands on. + """ + over, under = grid.get_item(item), grid.get_item(channel) + return over.get_location_wrt(deck) - under.get_location_wrt(deck) diff --git a/pylabrobot/lib/liquid_handling/head_alignment_tests.py b/pylabrobot/lib/liquid_handling/head_alignment_tests.py new file mode 100644 index 00000000000..21469427ca2 --- /dev/null +++ b/pylabrobot/lib/liquid_handling/head_alignment_tests.py @@ -0,0 +1,36 @@ +"""Tests for aligning a fixed-grid head with a grid of the same pitch.""" + +import pytest + +from pylabrobot.lib.liquid_handling.head_alignment import ( + get_items_under_channels, + get_shift_with_channel_over, +) +from pylabrobot.resources import Coordinate, Resource +from pylabrobot.resources.hamilton import hamilton_96_tiprack_300uL_filter + + +def test_each_channel_stands_over_the_item_the_alignment_puts_under_it() -> None: + # Channel H12 over spot D6: channels E7 to H12 stand over spots A1 to D6, the rest past the rack. + rack = hamilton_96_tiprack_300uL_filter(name="tip_rack") + under = { + f"{'EFGH'[row]}{column + 7}": rack.get_item(f"{'ABCD'[row]}{column + 1}") + for row in range(4) + for column in range(6) + } + + items = get_items_under_channels(rack, channel="H12", item="D6") + + channels = [rack.get_child_identifier(spot) for spot in rack.get_all_items()] + assert items == [under.get(channel) for channel in channels] + + +def test_channel_a1_is_shifted_by_the_rows_and_columns_between_the_two() -> None: + deck = Resource(name="deck", size_x=1000, size_y=600, size_z=0) + rack = hamilton_96_tiprack_300uL_filter(name="tip_rack") + deck.assign_child_resource(rack, location=Coordinate(100, 100, 0)) + + shift = get_shift_with_channel_over(rack, channel="H12", item="D6", deck=deck) + + # 6 columns to the left and 4 rows behind, at the rack's 9 mm pitch. + assert (shift.x, shift.y, shift.z) == pytest.approx((-54.0, 36.0, 0.0)) diff --git a/pylabrobot/resources/hamilton/tip_racks.py b/pylabrobot/resources/hamilton/tip_racks.py index 3fdf3e43197..5481a5f33ea 100644 --- a/pylabrobot/resources/hamilton/tip_racks.py +++ b/pylabrobot/resources/hamilton/tip_racks.py @@ -87,6 +87,7 @@ def hamilton_96_tiprack_ntr( name_prefix=name, ), with_tips=with_tips, + frame_height=0.0, ) @@ -117,6 +118,7 @@ def _hamilton_96_tiprack_raised( name_prefix=name, ), with_tips=with_tips, + frame_height=0.0, )