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
114 changes: 92 additions & 22 deletions pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -2466,38 +2466,108 @@ async def drop_tips96(
drop: DropTipRack,
minimum_height_command_end: Optional[float] = None,
minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None,
check_tip_presence: bool = True,
drop_safe_distance_above_rack: float = 0.0, # mm
):
"""Drop tips from the 96 head."""
"""Drop tips using the 96 head.

Releases tips from the 96 head into a tip rack or trash resource. The drop
Z-position is computed based on tip type if `check_tip_presence` is True,
ensuring tips are safely ejected without crashing into the rack. If
`check_tip_presence` is False, a fixed Z-position of 216.4 + drop_safe_distance_above_rack
(mm) is used, corresponding to standard Hamilton tip racks on standard carriers.

This method is robust to uncertain system state: disabling
`check_tip_presence` allows tips to be dropped even when PyLabRobot has
lost track of whether tips are present on the head.

Args:
drop: Target tip rack or trash resource to drop tips into, along with a
positional offset.
minimum_height_command_end: Optional minimum Z-height (in mm) at the end
of the movement.
minimum_traverse_height_at_beginning_of_a_command: Optional minimum
Z-height (in mm) at the beginning of the movement.
check_tip_presence: Whether to verify that tips are currently mounted on
the 96 head. If True, tip type information is used to compute a safe
Z-drop position. If False, the drop Z is assumed to be a fixed standard
height (216.4 mm).

Raises:
AssertionError: If the 96 head is not installed.
ValueError: If `check_tip_presence` is True but no tips are found on the
head.
TypeError: If the tips are not instances of HamiltonTip.
"""

assert self.core96_head_installed, "96 head must be installed"

prototypical_tip = next((tip for tip in drop.tips if tip is not None), None)

if check_tip_presence and prototypical_tip is None:
raise ValueError("No tips found on head96.")

if isinstance(drop.resource, TipRack):
tip_spot_a1 = drop.resource.get_item("A1")
position = tip_spot_a1.get_location_wrt(self.deck) + tip_spot_a1.center() + drop.offset

tip_rack = tip_spot_a1.parent
assert tip_rack is not None
position.z = tip_rack.get_location_wrt(self.deck).z + 1.45
# This should be the case for all normal hamilton tip carriers + racks
# In the future, we might want to make this more flexible
assert abs(position.z - 216.4) < 1e-6, f"z position must be 216.4, got {position.z}"
else:
position = self._position_96_head_in_resource(drop.resource) + drop.offset

self._check_96_position_legal(position, skip_z=True)
if check_tip_presence:
if not isinstance(prototypical_tip, HamiltonTip):
raise TypeError("Tip type must be HamiltonTip.")

x_direction = 0 if position.x >= 0 else 1
assert (
len({tip.total_tip_length for tip in drop.tips}) == 1
), "All tips must have the same length."

return await self.discard_tips_core96(
x_position=abs(round(position.x * 10)),
x_direction=x_direction,
y_position=round(position.y * 10),
z_deposit_position=round(position.z * 10),
minimum_traverse_height_at_beginning_of_a_command=round(
(minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10
),
minimum_height_command_end=round(
(minimum_height_command_end or self._channel_traversal_height) * 10
),
)
tip_length = prototypical_tip.total_tip_length
fitting_depth = prototypical_tip.fitting_depth
tip_engage_height_from_tipspot = tip_length - fitting_depth

# Tip size–based z-adjustment
if prototypical_tip.tip_size == TipSize.LOW_VOLUME:
tip_engage_height_from_tipspot += 2
elif prototypical_tip.tip_size != TipSize.STANDARD_VOLUME:
tip_engage_height_from_tipspot -= 2

# Compute pickup Z
tip_spot_z = tip_spot_a1.get_location_wrt(self.deck).z + drop.offset.z
z_drop_coordinate = (
tip_spot_z + tip_engage_height_from_tipspot + drop_safe_distance_above_rack
) # add 1.5mm to avoid crashing into the rack

else:
z_drop_coordinate = (
216.4 + drop_safe_distance_above_rack
) # default z for hamilton tip racks on standard tip_carrier

# Compute full position
drop_position = tip_spot_a1.get_location_wrt(self.deck) + tip_spot_a1.center() + drop.offset
drop_position.z = round(z_drop_coordinate, 2)

else: # drop.resource is Trash or other (e.g. Container)
drop_position = self._position_96_head_in_resource(drop.resource) + drop.offset

self._check_96_position_legal(drop_position, skip_z=False)

try:
return await self.discard_tips_core96(
x_position=abs(round(drop_position.x * 10)),
x_direction=0 if drop_position.x >= 0 else 1,
y_position=round(drop_position.y * 10),
z_deposit_position=round(drop_position.z * 10),
minimum_traverse_height_at_beginning_of_a_command=round(
(minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10
),
minimum_height_command_end=round(
(minimum_height_command_end or self._channel_traversal_height) * 10
),
)
except STARFirmwareError as e:
if plr_e := convert_star_firmware_error_to_plr_error(e):
raise plr_e from e
raise e

async def aspirate96(
self,
Expand Down
4 changes: 3 additions & 1 deletion pylabrobot/liquid_handling/liquid_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1536,12 +1536,14 @@ async def drop_tips96(
for extra in extras:
del backend_kwargs[extra]

tips_on_head96 = []
# queue operation on all tip trackers
for i in range(96):
# it's possible not every channel on this head has a tip.
if not self.head96[i].has_tip:
continue
tip = self.head96[i].get_tip()
tips_on_head96.append(tip)
if tip.tracker.get_used_volume() > 0 and not allow_nonzero_volume:
error = f"Cannot drop tip with volume {tip.tracker.get_used_volume()} on channel {i}"
raise RuntimeError(error)
Expand All @@ -1551,7 +1553,7 @@ async def drop_tips96(
tip_spot.tracker.add_tip(tip, commit=False)
self.head96[i].remove_tip()

drop_operation = DropTipRack(resource=resource, offset=offset)
drop_operation = DropTipRack(resource=resource, offset=offset, tips=tips_on_head96)
try:
await self.backend.drop_tips96(drop=drop_operation, **backend_kwargs)
except Exception as e:
Expand Down
1 change: 1 addition & 0 deletions pylabrobot/liquid_handling/standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class PickupTipRack:
class DropTipRack:
resource: Union[TipRack, Trash]
offset: Coordinate
tips: Sequence[Optional[Tip]]


@dataclass(frozen=True)
Expand Down
Loading