diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 7f6237e107f..f83d04a9303 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -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, diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 84c5fe53e2f..1eda11b628c 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -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) @@ -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: diff --git a/pylabrobot/liquid_handling/standard.py b/pylabrobot/liquid_handling/standard.py index 419502d2cae..8524ec440b1 100644 --- a/pylabrobot/liquid_handling/standard.py +++ b/pylabrobot/liquid_handling/standard.py @@ -47,6 +47,7 @@ class PickupTipRack: class DropTipRack: resource: Union[TipRack, Trash] offset: Coordinate + tips: Sequence[Optional[Tip]] @dataclass(frozen=True)