Skip to content
Open
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
115 changes: 80 additions & 35 deletions pylabrobot/hamilton/star/driver/features/head96.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
"""
Expand All @@ -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
Expand Down Expand Up @@ -760,32 +780,54 @@ 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.
offset: added to where the head goes, in mm.
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()
Expand Down Expand Up @@ -821,47 +863,50 @@ 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()

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.
Expand Down
59 changes: 57 additions & 2 deletions pylabrobot/hamilton/star/driver/features/head_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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()]
Expand Down Expand Up @@ -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()
Expand All @@ -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, [])

Expand Down
51 changes: 51 additions & 0 deletions pylabrobot/lib/liquid_handling/head_alignment.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading