From fb51c2dc205871cbf409a641113be7776b41c452 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 27 Jan 2026 22:26:00 -0800 Subject: [PATCH 01/18] EmbeddedTipRack; remodel; pickup/drop 1000uL tips on STAR with new model --- .../backends/hamilton/STAR_backend.py | 31 +- .../resources/hamilton/hamilton_decks.py | 4 +- pylabrobot/resources/hamilton/tip_carriers.py | 31 +- pylabrobot/resources/hamilton/tip_creators.py | 24 ++ pylabrobot/resources/hamilton/tip_racks.py | 326 ++++-------------- pylabrobot/resources/tip_rack.py | 40 +++ 6 files changed, 165 insertions(+), 291 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 543f0ff45e9..a0be794719d 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1573,26 +1573,24 @@ async def pick_up_tips( ttti = await self.get_or_assign_tip_type_index(tips.pop()) max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) - - # not sure why this is necessary, but it is according to log files and experiments - if self._get_hamilton_tip([op.resource for op in ops]).tip_size == TipSize.LOW_VOLUME: - max_tip_length += 2 - elif self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: - max_tip_length -= 2 + tips = [op.tip for op in ops] + assert all(isinstance(tip, HamiltonTip) for tip in tips), "All tips must be HamiltonTip." + collar_heights = set((tip.collar_height) for tip in tips) + if len(collar_heights) > 1: + raise ValueError("Cannot mix tips with different collar heights.") + collar_height = collar_heights.pop() tip = ops[0].tip if not isinstance(tip, HamiltonTip): raise TypeError("Tip type must be HamiltonTip.") begin_tip_pick_up_process = ( - round((max_z + max_total_tip_length) * 10) + round((max_z + collar_height) * 10) if begin_tip_pick_up_process is None else int(begin_tip_pick_up_process * 10) ) end_tip_pick_up_process = ( - round((max_z + max_tip_length) * 10) + round((max_z) * 10) if end_tip_pick_up_process is None else round(end_tip_pick_up_process * 10) ) @@ -1662,15 +1660,20 @@ async def drop_tips( else round(end_tip_deposit_process * 10) ) else: - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + tips = [op.tip for op in ops] + assert all(isinstance(tip, HamiltonTip) for tip in tips), "All tips must be HamiltonTip." + collar_heights = set(tip.collar_height for tip in tips) + if len(collar_heights) > 1: + raise ValueError("Cannot mix tips with different collar heights.") + collar_height = collar_heights.pop() + begin_tip_deposit_process = ( - round((max_z + max_total_tip_length) * 10) + round((max_z + collar_height) * 10) if begin_tip_deposit_process is None else round(begin_tip_deposit_process * 10) ) end_tip_deposit_process = ( - round((max_z + max_tip_length) * 10) + round((max_z) * 10) if end_tip_deposit_process is None else round(end_tip_deposit_process * 10) ) diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index f50018e2d6a..5e35b298b5c 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -8,7 +8,7 @@ from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.deck import Deck from pylabrobot.resources.errors import NoLocationError -from pylabrobot.resources.hamilton.tip_creators import standard_volume_tip_with_filter +from pylabrobot.resources.hamilton.tip_creators import hamilton_tip_300uL_filter from pylabrobot.resources.resource import Resource from pylabrobot.resources.tip_rack import TipRack, TipSpot from pylabrobot.resources.trash import Trash @@ -484,7 +484,7 @@ def __init__( size_x=9.0, size_y=9.0, size_z=0, - make_tip=standard_volume_tip_with_filter, + make_tip=hamilton_tip_300uL_filter, ) for i in range(8) ] diff --git a/pylabrobot/resources/hamilton/tip_carriers.py b/pylabrobot/resources/hamilton/tip_carriers.py index fc06281825f..0c5db4bdd8e 100644 --- a/pylabrobot/resources/hamilton/tip_carriers.py +++ b/pylabrobot/resources/hamilton/tip_carriers.py @@ -6,6 +6,7 @@ create_homogeneous_resources, ) from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.tip_rack_holder import EmbeddedTipRackHolder def TIP_CAR_120BC_4mlTF_A00(name: str) -> TipCarrier: @@ -16,7 +17,7 @@ def TIP_CAR_120BC_4mlTF_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), @@ -40,7 +41,7 @@ def TIP_CAR_120BC_5mlT_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), @@ -64,7 +65,7 @@ def TIP_CAR_288_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(26.3, 36.3, 114.9), Coordinate(26.3, 182.213, 114.9), @@ -86,7 +87,7 @@ def TIP_CAR_288_B00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(17.1, 36.25, 115.15), Coordinate(17.1, 182.25, 115.15), @@ -108,7 +109,7 @@ def TIP_CAR_288_C00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), @@ -130,7 +131,7 @@ def TIP_CAR_384BC_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), @@ -153,7 +154,7 @@ def TIP_CAR_384_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), @@ -176,7 +177,7 @@ def TIP_CAR_480(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), @@ -200,7 +201,7 @@ def TIP_CAR_480BC_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), @@ -224,7 +225,7 @@ def TIP_CAR_480_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), @@ -248,7 +249,7 @@ def TIP_CAR_72_4mlTF_C00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), @@ -270,7 +271,7 @@ def TIP_CAR_72_5mlT_C00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), @@ -292,7 +293,7 @@ def TIP_CAR_96BC_4mlTF_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), @@ -315,7 +316,7 @@ def TIP_CAR_96BC_5mlT_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), @@ -338,7 +339,7 @@ def TIP_CAR_NTR_A00(name: str) -> TipCarrier: size_y=497.0, size_z=130.0, sites=create_homogeneous_resources( - klass=ResourceHolder, + klass=EmbeddedTipRackHolder, locations=[ Coordinate(6.2, 10.0, 29.0), Coordinate(6.2, 106.0, 29.0), diff --git a/pylabrobot/resources/hamilton/tip_creators.py b/pylabrobot/resources/hamilton/tip_creators.py index 042052099a6..b0b80e59c5f 100644 --- a/pylabrobot/resources/hamilton/tip_creators.py +++ b/pylabrobot/resources/hamilton/tip_creators.py @@ -47,6 +47,7 @@ def __init__( maximal_volume: float, tip_size: Union[TipSize, str], # union for deserialization, will probably refactor pickup_method: Union[TipPickupMethod, str], # union for deserialization, will probably refactor + collar_height: Optional[float] = None, name: Optional[str] = None, ): if isinstance(tip_size, str): @@ -75,6 +76,14 @@ def __init__( self.pickup_method = pickup_method self.tip_size = tip_size + self.collar_height = collar_height + + @property + def collar_height_strict(self) -> float: + """Return collar_height, raising if it is None.""" + if self.collar_height is None: + raise ValueError(f"collar_height is not defined for this tip: {self!r}") + return self.collar_height def __repr__(self) -> str: name_field = f"'{self.name}'" if self.name is not None else "None" @@ -85,6 +94,7 @@ def __repr__(self) -> str: f"maximal_volume={self.maximal_volume}, " f"fitting_depth={self.fitting_depth}, " f"total_tip_length={self.total_tip_length}, " + f"collar_height={self.collar_height}, " f"pickup_method={self.pickup_method.name})" ) @@ -95,6 +105,7 @@ def serialize(self): **super_serialized, "pickup_method": self.pickup_method.name, "tip_size": self.tip_size.name, + "collar_height": self.collar_height, } @classmethod @@ -106,6 +117,7 @@ def deserialize(cls, data): maximal_volume=data["maximal_volume"], tip_size=TipSize[data["tip_size"]], pickup_method=TipPickupMethod[data["pickup_method"]], + collar_height=data.get("collar_height"), ) @@ -276,6 +288,7 @@ def hamilton_tip_10uL(name: Optional[str] = None) -> HamiltonTip: maximal_volume=15, tip_size=TipSize.LOW_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=6.0, ) @@ -291,6 +304,7 @@ def hamilton_tip_10uL_filter(name: Optional[str] = None) -> HamiltonTip: maximal_volume=10, tip_size=TipSize.LOW_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=6.0, ) @@ -306,6 +320,7 @@ def hamilton_tip_50uL(name: Optional[str] = None) -> HamiltonTip: maximal_volume=65, tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=8.0, ) @@ -321,6 +336,7 @@ def hamilton_tip_50uL_filter(name: Optional[str] = None) -> HamiltonTip: maximal_volume=60, tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=8.0, ) @@ -333,6 +349,7 @@ def hamilton_tip_300uL(name: Optional[str] = None) -> HamiltonTip: maximal_volume=400, tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=8.0, ) @@ -345,6 +362,7 @@ def hamilton_tip_300uL_filter(name: Optional[str] = None) -> HamiltonTip: maximal_volume=360, tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=8.0, ) @@ -357,6 +375,7 @@ def hamilton_tip_300uL_filter_slim(name: Optional[str] = None) -> HamiltonTip: maximal_volume=360, tip_size=TipSize.HIGH_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=8.0, ) @@ -369,6 +388,7 @@ def hamilton_tip_300uL_filter_ultrawide(name: Optional[str] = None) -> HamiltonT maximal_volume=360, tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=8.0, ) @@ -381,6 +401,7 @@ def hamilton_tip_1000uL(name: Optional[str] = None) -> HamiltonTip: maximal_volume=1250, tip_size=TipSize.HIGH_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=10.0, ) @@ -393,6 +414,7 @@ def hamilton_tip_1000uL_filter(name: Optional[str] = None) -> HamiltonTip: maximal_volume=1065, tip_size=TipSize.HIGH_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=10.0, ) @@ -408,6 +430,7 @@ def hamilton_tip_1000uL_filter_wide(name: Optional[str] = None) -> HamiltonTip: maximal_volume=1065, tip_size=TipSize.HIGH_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=10.0, ) @@ -423,6 +446,7 @@ def hamilton_tip_1000uL_filter_ultrawide(name: Optional[str] = None) -> Hamilton maximal_volume=1065, tip_size=TipSize.HIGH_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=10.0, ) diff --git a/pylabrobot/resources/hamilton/tip_racks.py b/pylabrobot/resources/hamilton/tip_racks.py index a7aa15a5549..f74404e5ff7 100644 --- a/pylabrobot/resources/hamilton/tip_racks.py +++ b/pylabrobot/resources/hamilton/tip_racks.py @@ -1,8 +1,10 @@ from pylabrobot.resources.tip_rack import ( + EmbeddedTipRack, NestedTipRack, TipRack, TipSpot, ) +from pylabrobot.resources.tip_rack_holder import EmbeddedTipRackHolder from pylabrobot.resources.utils import create_ordered_items_2d from .tip_creators import ( @@ -22,120 +24,66 @@ hamilton_tip_5000uL, ) -# # # # # # # # # # 10 ul Tips # # # # # # # # # # - -def hamilton_96_tiprack_10uL_filter(name: str, with_tips: bool = True) -> TipRack: - """Hamilton cat. no.: 235936 (sterile), 235901 (non-sterile) - Hamilton name: 'LTF' - Tip Rack with 96x 10ul Low Volume Tip with filter - """ - return TipRack( +def hamilton_universal_rack(name: str, make_tip, with_tips: bool = True) -> EmbeddedTipRackHolder: + """Universal tip rack for Hamilton STAR systems.""" + return EmbeddedTipRack( name=name, size_x=122.4, size_y=82.6, size_z=20.0, - model=hamilton_96_tiprack_10uL_filter.__name__, + model=hamilton_universal_rack.__name__, + sinking_depth=5.90 + 0.2, ordered_items=create_ordered_items_2d( TipSpot, num_items_x=12, num_items_y=8, dx=7.2, dy=5.3, - dz=-22.5, + dz=7.52, # 7.52, depth of hole item_dx=9.0, item_dy=9.0, size_x=9.0, size_y=9.0, - make_tip=hamilton_tip_10uL_filter, + make_tip=make_tip, ), with_tips=with_tips, ) -# TODO: identify cat number -def hamilton_96_tiprack_10uL(name: str, with_tips: bool = True) -> TipRack: +# # # # # # # # # # 10 ul Tips # # # # # # # # # # + + +def hamilton_96_tiprack_10uL_filter(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: + """Hamilton cat. no.: 235936 (sterile), 235901 (non-sterile) + Hamilton name: 'LTF' + Tip Rack with 96x 10ul Low Volume Tip with filter + """ + return hamilton_universal_rack(name=name, make_tip=hamilton_tip_10uL, with_tips=with_tips) + + +def hamilton_96_tiprack_10uL(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 235900 (non-sterile) 235935 (sterile) Hamilton name: 'LT' Tip Rack with 96x 10ul Low Volume Tip""" - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=20.0, - model=hamilton_96_tiprack_10uL.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-22.5, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_10uL, - ), - with_tips=with_tips, - ) + return hamilton_universal_rack(name=name, make_tip=hamilton_tip_10uL_filter, with_tips=with_tips) # # # # # # # # # # 50 ul Tips # # # # # # # # # # -def hamilton_96_tiprack_50uL_filter(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_50uL_filter(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 235948 (non-sterile), 235979 (sterile), 235829 (clear, non-sterile) Hamilton name: 'TIP_50ul_w_filter' Tip Rack with 96x 50ul Tip""" - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=18.0, - model=hamilton_96_tiprack_50uL_filter.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-40.5, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_50uL_filter, - ), - with_tips=with_tips, - ) + return hamilton_universal_rack(name=name, make_tip=hamilton_tip_50uL_filter, with_tips=with_tips) -def hamilton_96_tiprack_50uL(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_50uL(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 235966 (non-sterile) 235978 (sterile) Hamilton name: 'TIP_50ul' Tip Rack with 96x 50ul Tip no filter""" - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=18.0, - model=hamilton_96_tiprack_50uL.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-40.5, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_50uL, - ), - with_tips=with_tips, - ) + return hamilton_universal_rack(name=name, make_tip=hamilton_tip_50uL, with_tips=with_tips) def hamilton_96_tiprack_50uL_NTR(name: str, with_tips: bool = True) -> NestedTipRack: @@ -171,173 +119,65 @@ def hamilton_96_tiprack_50uL_NTR(name: str, with_tips: bool = True) -> NestedTip # # # # # # # # # # 300 ul Tips # # # # # # # # # # -def hamilton_96_tiprack_300uL_filter(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_300uL_filter(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 235830 (clear, non-sterile), 235903 (non-sterile), 235938 (sterile) Hamilton name: 'STF' Tip Rack with 96x 300ul Standard Volume Tip with filter""" - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=20.0, - model=hamilton_96_tiprack_300uL_filter.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-50.5, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_300uL_filter, - ), - with_tips=with_tips, - ) + return hamilton_universal_rack(name=name, make_tip=hamilton_tip_300uL_filter, with_tips=with_tips) -def hamilton_96_tiprack_300uL(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_300uL(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 235834 (clear, non-sterile), 235902 (non-sterile), 235937 (sterile) Hamilton name: 'ST' Tip Rack with 96x 300ul Standard Volume Tip""" - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=20.0, - model=hamilton_96_tiprack_300uL.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-50.5, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_300uL, - ), - with_tips=with_tips, - ) + return hamilton_universal_rack(name=name, make_tip=hamilton_tip_300uL, with_tips=with_tips) -def hamilton_96_tiprack_300uL_filter_slim(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_300uL_filter_slim( + name: str, with_tips: bool = True +) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 235646 (CORE-II: conductive) Hamilton name: 'STF_Slim' Tip Rack with 96x 300ul Slim Standard Volume Tip with filter""" - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=20.0, - model=hamilton_96_tiprack_300uL_filter_slim.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-83.5, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_300uL_filter_slim, - ), - with_tips=with_tips, + return hamilton_universal_rack( + name=name, make_tip=hamilton_tip_300uL_filter_slim, with_tips=with_tips ) -def hamilton_96_tiprack_300uL_filter_ultrawide(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_300uL_filter_ultrawide( + name: str, with_tips: bool = True +) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 235449 (1.55 mm oriface, non-sterile) Hamilton name: 'STF' Tip Rack with 96x 300ul Wide Bore Standard Volume Tip with filter""" - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=20.0, - model=hamilton_96_tiprack_300uL_filter_ultrawide.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-42.5, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_300uL_filter_ultrawide, - ), - with_tips=with_tips, + return hamilton_universal_rack( + name=name, make_tip=hamilton_tip_300uL_filter_ultrawide, with_tips=with_tips ) # # # # # # # # # # 1_000 uL Tips # # # # # # # # # # -def hamilton_96_tiprack_1000uL_filter(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_1000uL_filter(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 235820 (clear, non-sterile), 235905 (non-sterile), 235940 (sterile) Hamilton name: 'HTF' Tip Rack with 96x 1000ul High Volume Tip with filter """ - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=20.0, - model=hamilton_96_tiprack_1000uL_filter.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-83.5, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_1000uL_filter, - ), - with_tips=with_tips, + return hamilton_universal_rack( + name=name, make_tip=hamilton_tip_1000uL_filter, with_tips=with_tips ) -def hamilton_96_tiprack_1000uL(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_1000uL(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 235822 (clear, non-sterile), 235904 (non-sterile), 235939 (sterile) Hamilton name: 'HT' Tip Rack with 96x 1000ul High Volume Tip""" - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=20.0, - model=hamilton_96_tiprack_1000uL.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-83.5, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_1000uL, - ), - with_tips=with_tips, - ) + return hamilton_universal_rack(name=name, make_tip=hamilton_tip_1000uL, with_tips=with_tips) -def hamilton_96_tiprack_1000uL_filter_wide(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_1000uL_filter_wide( + name: str, with_tips: bool = True +) -> EmbeddedTipRackHolder: """Hamilton cat. no.: core-ii: @@ -352,30 +192,14 @@ def hamilton_96_tiprack_1000uL_filter_wide(name: str, with_tips: bool = True) -> Orifice Size: 1.2mm """ - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=20.0, - model=hamilton_96_tiprack_1000uL_filter_wide.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-80.35, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_1000uL_filter_wide, - ), - with_tips=with_tips, + return hamilton_universal_rack( + name=name, make_tip=hamilton_tip_1000uL_filter_wide, with_tips=with_tips ) -def hamilton_96_tiprack_1000uL_filter_ultrawide(name: str, with_tips: bool = True) -> TipRack: +def hamilton_96_tiprack_1000uL_filter_ultrawide( + name: str, with_tips: bool = True +) -> EmbeddedTipRackHolder: """Hamilton cat. no.: core-ii: @@ -390,33 +214,15 @@ def hamilton_96_tiprack_1000uL_filter_ultrawide(name: str, with_tips: bool = Tru Orifice Size: 3.2mm """ - return TipRack( - name=name, - size_x=122.4, - size_y=82.6, - size_z=20.0, - model=hamilton_96_tiprack_1000uL_filter_ultrawide.__name__, - ordered_items=create_ordered_items_2d( - TipSpot, - num_items_x=12, - num_items_y=8, - dx=7.2, - dy=5.3, - dz=-68.4, - item_dx=9.0, - item_dy=9.0, - size_x=9.0, - size_y=9.0, - make_tip=hamilton_tip_1000uL_filter_ultrawide, - ), - with_tips=with_tips, + return hamilton_universal_rack( + name=name, make_tip=hamilton_tip_1000uL_filter_ultrawide, with_tips=with_tips ) # # # # # # # # # # 4 ml Tips # # # # # # # # # # -def hamilton_24_tiprack_4000uL_filter(name: str, with_tips: bool = True) -> TipRack: +def hamilton_24_tiprack_4000uL_filter(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 184021 (non-sterile), 184023 (sterile) Hamilton name: 'FourmlTF' Tip Rack 24x 4ml Tip with Filter landscape oriented""" @@ -446,7 +252,7 @@ def hamilton_24_tiprack_4000uL_filter(name: str, with_tips: bool = True) -> TipR # # # # # # # # # # 5 ml Tips # # # # # # # # # # -def hamilton_24_tiprack_5000uL(name: str, with_tips: bool = True) -> TipRack: +def hamilton_24_tiprack_5000uL(name: str, with_tips: bool = True) -> EmbeddedTipRackHolder: """Hamilton cat. no.: 184020 (non-sterile), 184022 (sterile) Hamilton name: 'FivemlT' Tip Rack 24x 5ml Tip landscape oriented""" @@ -478,41 +284,41 @@ def hamilton_24_tiprack_5000uL(name: str, with_tips: bool = True) -> TipRack: # TODO: remove after December 2025 (giving approx. 3 month transition period) -def LTF(name: str) -> TipRack: +def LTF(name: str) -> EmbeddedTipRackHolder: raise NotImplementedError("LTF is deprecated. use hamilton_96_tiprack_10uL_filter instead") -def LT(name: str) -> TipRack: +def LT(name: str) -> EmbeddedTipRackHolder: raise NotImplementedError("LT is deprecated. use hamilton_96_tiprack_10uL instead") -def TIP_50ul_w_filter(name: str) -> TipRack: +def TIP_50ul_w_filter(name: str) -> EmbeddedTipRackHolder: raise NotImplementedError( "TIP_50ul_w_filter is deprecated. use hamilton_96_tiprack_50uL_filter instead" ) -def TIP_50ul(name: str) -> TipRack: +def TIP_50ul(name: str) -> EmbeddedTipRackHolder: raise NotImplementedError("TIP_50ul is deprecated. use hamilton_96_tiprack_50uL instead") -def STF(name: str) -> TipRack: +def STF(name: str) -> EmbeddedTipRackHolder: raise NotImplementedError("STF is deprecated. use hamilton_96_tiprack_300uL_filter instead") -def ST(name: str) -> TipRack: +def ST(name: str) -> EmbeddedTipRackHolder: raise NotImplementedError("ST is deprecated. use hamilton_96_tiprack_300uL instead") -def STF_Slim(name: str) -> TipRack: +def STF_Slim(name: str) -> EmbeddedTipRackHolder: raise NotImplementedError( "STF_Slim is deprecated. use hamilton_96_tiprack_300uL_filter_slim instead" ) -def HTF(name: str) -> TipRack: +def HTF(name: str) -> EmbeddedTipRackHolder: raise NotImplementedError("HTF is deprecated. use hamilton_96_tiprack_1000uL_filter instead") -def HT(name: str) -> TipRack: +def HT(name: str) -> EmbeddedTipRackHolder: raise NotImplementedError("HT is deprecated. use hamilton_96_tiprack_1000uL instead") diff --git a/pylabrobot/resources/tip_rack.py b/pylabrobot/resources/tip_rack.py index 1d89293aabc..4748ef5c017 100644 --- a/pylabrobot/resources/tip_rack.py +++ b/pylabrobot/resources/tip_rack.py @@ -304,3 +304,43 @@ def assign_child_resource( "Location must be specified if " + "resource is not a NestedTipRack." ) return super().assign_child_resource(resource, location=location, reassign=reassign) + + +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. + + have historically been referred to as FTRs (officially "framed tip rack" from Hamilton, sometimes we used to call them "floating tip racks". + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + sinking_depth: float, + ordered_items: Optional[Dict[str, TipSpot]] = None, + ordering: Optional[List[str]] = None, + category: str = "tip_rack", + model: Optional[str] = None, + with_tips: bool = True, + ): + """sinking_depth: the depth the tip rack sinks into the tip holder when placed inside it.""" + super().__init__( + name, + size_x, + size_y, + size_z, + ordered_items=ordered_items, + ordering=ordering, + category=category, + model=model, + with_tips=with_tips, + ) + self.sinking_depth = sinking_depth + + +class StandingTipRack(TipRack): + """It's defining geometric characteristic is that it is completely self-sufficient and does not require a separate TipHolder to embed into.""" + + # TODO From b6875a8ec98b1c67bd816c76a5b6770fda895620 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Wed, 28 Jan 2026 16:21:38 -0800 Subject: [PATCH 02/18] fix STARBackend.{pick_up,drop}_tips96 --- .../backends/hamilton/STAR_backend.py | 23 +------------------ 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index a0be794719d..f1a71f6151a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -2888,26 +2888,11 @@ async def pick_up_tips96( ttti = await self.get_or_assign_tip_type_index(prototypical_tip) - tip_length = prototypical_tip.total_tip_length - fitting_depth = prototypical_tip.fitting_depth - tip_engage_height_from_tipspot = tip_length - fitting_depth - - # Adjust tip engage height based on tip size - 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 + # Compute pickup position alignment_tipspot = pickup.resource.get_item(experimental_alignment_tipspot_identifier) - tip_spot_z = alignment_tipspot.get_location_wrt(self.deck).z + pickup.offset.z - z_pickup_position = tip_spot_z + tip_engage_height_from_tipspot - - # Compute full position (used for x/y) pickup_position = ( alignment_tipspot.get_location_wrt(self.deck) + alignment_tipspot.center() + pickup.offset ) - pickup_position.z = round(z_pickup_position, 2) self._check_96_position_legal(pickup_position, skip_z=True) @@ -2955,12 +2940,6 @@ async def drop_tips96( if isinstance(drop.resource, TipRack): tip_spot_a1 = drop.resource.get_item(experimental_alignment_tipspot_identifier) 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 From 5b8209df44c0ea03e2bd115cd2c4142e6cb85f24 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Wed, 28 Jan 2026 17:04:47 -0800 Subject: [PATCH 03/18] move collar_height to Tip --- pylabrobot/resources/hamilton/tip_creators.py | 12 ++---------- pylabrobot/resources/tip.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/pylabrobot/resources/hamilton/tip_creators.py b/pylabrobot/resources/hamilton/tip_creators.py index b0b80e59c5f..1ee7d7bd539 100644 --- a/pylabrobot/resources/hamilton/tip_creators.py +++ b/pylabrobot/resources/hamilton/tip_creators.py @@ -71,19 +71,12 @@ def __init__( has_filter=has_filter, maximal_volume=maximal_volume, fitting_depth=fitting_depth, + _collar_height=collar_height, name=name, ) self.pickup_method = pickup_method self.tip_size = tip_size - self.collar_height = collar_height - - @property - def collar_height_strict(self) -> float: - """Return collar_height, raising if it is None.""" - if self.collar_height is None: - raise ValueError(f"collar_height is not defined for this tip: {self!r}") - return self.collar_height def __repr__(self) -> str: name_field = f"'{self.name}'" if self.name is not None else "None" @@ -94,7 +87,7 @@ def __repr__(self) -> str: f"maximal_volume={self.maximal_volume}, " f"fitting_depth={self.fitting_depth}, " f"total_tip_length={self.total_tip_length}, " - f"collar_height={self.collar_height}, " + f"collar_height={self._collar_height}, " f"pickup_method={self.pickup_method.name})" ) @@ -105,7 +98,6 @@ def serialize(self): **super_serialized, "pickup_method": self.pickup_method.name, "tip_size": self.tip_size.name, - "collar_height": self.collar_height, } @classmethod diff --git a/pylabrobot/resources/tip.py b/pylabrobot/resources/tip.py index 58e26b0160c..d57f6a87f00 100644 --- a/pylabrobot/resources/tip.py +++ b/pylabrobot/resources/tip.py @@ -16,6 +16,7 @@ class Tip: total_tip_length: total length of the tip, in in mm maximal_volume: maximal volume of the tip, in ul fitting_depth: the overlap between the tip and the pipette, in mm + collar_height: the height of the collar, in mm name: optional identifier for this tip """ @@ -23,6 +24,7 @@ class Tip: total_tip_length: float maximal_volume: float fitting_depth: float + _collar_height: Optional[float] = None name: Optional[str] = None def __post_init__(self): @@ -45,6 +47,7 @@ def serialize(self) -> dict: "has_filter": self.has_filter, "maximal_volume": self.maximal_volume, "fitting_depth": self.fitting_depth, + "collar_height": self._collar_height, } def __hash__(self): @@ -54,6 +57,7 @@ def __hash__(self): self.total_tip_length, self.maximal_volume, self.fitting_depth, + self._collar_height, ) ) @@ -66,7 +70,15 @@ def __eq__(self, other: object) -> bool: and self.total_tip_length == other.total_tip_length and self.maximal_volume == other.maximal_volume and self.fitting_depth == other.fitting_depth + and self._collar_height == other._collar_height ) + @property + def collar_height(self) -> float: + """Return collar_height, raising if it is None.""" + if self._collar_height is None: + raise ValueError(f"collar_height is not defined for this tip: {self!r}") + return self._collar_height + TipCreator = Callable[[str], Tip] From 0f2a932d313b8b627d1a153f4de48337f65cb5a9 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 14:03:41 -0800 Subject: [PATCH 04/18] serialize sinking depth in EmbeddedTipRack --- pylabrobot/resources/tip_rack.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pylabrobot/resources/tip_rack.py b/pylabrobot/resources/tip_rack.py index 4748ef5c017..8e14933c70b 100644 --- a/pylabrobot/resources/tip_rack.py +++ b/pylabrobot/resources/tip_rack.py @@ -339,6 +339,12 @@ def __init__( ) self.sinking_depth = sinking_depth + def serialize(self) -> dict: + return { + **super().serialize(), + "sinking_depth": self.sinking_depth, + } + class StandingTipRack(TipRack): """It's defining geometric characteristic is that it is completely self-sufficient and does not require a separate TipHolder to embed into.""" From 2ffc788f3fb2ddcbb16d8fcb74ec34c80820e63a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 14:09:58 -0800 Subject: [PATCH 05/18] Add collar_height to Tip class - Add collar_height parameter to Tip.__init__, stored as _collar_height - Add collar_height property that raises ValueError if not defined - Update serializer to use custom deserialize method when available - Update HamiltonTip to pass collar_height to parent Co-Authored-By: Claude Opus 4.5 --- pylabrobot/resources/hamilton/tip_creators.py | 2 +- pylabrobot/resources/tip.py | 35 ++++++++++++++----- pylabrobot/resources/tip_tests.py | 2 ++ pylabrobot/serializer.py | 2 ++ 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/pylabrobot/resources/hamilton/tip_creators.py b/pylabrobot/resources/hamilton/tip_creators.py index 1ee7d7bd539..c3d03eb2873 100644 --- a/pylabrobot/resources/hamilton/tip_creators.py +++ b/pylabrobot/resources/hamilton/tip_creators.py @@ -71,7 +71,7 @@ def __init__( has_filter=has_filter, maximal_volume=maximal_volume, fitting_depth=fitting_depth, - _collar_height=collar_height, + collar_height=collar_height, name=name, ) diff --git a/pylabrobot/resources/tip.py b/pylabrobot/resources/tip.py index d57f6a87f00..01c697abf62 100644 --- a/pylabrobot/resources/tip.py +++ b/pylabrobot/resources/tip.py @@ -1,13 +1,11 @@ from __future__ import annotations import warnings -from dataclasses import dataclass from typing import Callable, Optional from pylabrobot.resources.volume_tracker import VolumeTracker -@dataclass class Tip: """A single tip. @@ -20,14 +18,22 @@ class Tip: name: optional identifier for this tip """ - has_filter: bool - total_tip_length: float - maximal_volume: float - fitting_depth: float - _collar_height: Optional[float] = None - name: Optional[str] = None + def __init__( + self, + has_filter: bool, + total_tip_length: float, + maximal_volume: float, + fitting_depth: float, + collar_height: Optional[float] = None, + name: Optional[str] = None, + ): + self.has_filter = has_filter + self.total_tip_length = total_tip_length + self.maximal_volume = maximal_volume + self.fitting_depth = fitting_depth + self._collar_height = collar_height + self.name = name - def __post_init__(self): if self.name is None: warnings.warn( "Creating a Tip without a name is deprecated. " @@ -50,6 +56,17 @@ def serialize(self) -> dict: "collar_height": self._collar_height, } + @classmethod + def deserialize(cls, data: dict) -> "Tip": + return cls( + has_filter=data["has_filter"], + total_tip_length=data["total_tip_length"], + maximal_volume=data["maximal_volume"], + fitting_depth=data["fitting_depth"], + collar_height=data.get("collar_height"), + name=data.get("name"), + ) + def __hash__(self): return hash( ( diff --git a/pylabrobot/resources/tip_tests.py b/pylabrobot/resources/tip_tests.py index 353320319c7..63bdfb9d46f 100644 --- a/pylabrobot/resources/tip_tests.py +++ b/pylabrobot/resources/tip_tests.py @@ -23,6 +23,7 @@ def test_serialize(self): "total_tip_length": 10.0, "maximal_volume": 10.0, "fitting_depth": 1.0, + "collar_height": None, }, ) @@ -48,6 +49,7 @@ def test_serialize_subclass(self): "maximal_volume": 10.0, "pickup_method": "OUT_OF_RACK", "tip_size": "HIGH_VOLUME", + "collar_height": None, }, ) diff --git a/pylabrobot/serializer.py b/pylabrobot/serializer.py index 2c0890a520f..dfd7229a9fe 100644 --- a/pylabrobot/serializer.py +++ b/pylabrobot/serializer.py @@ -143,6 +143,8 @@ def deserialize(data: JSON, allow_marshal: bool = False) -> Any: return types.CellType(deserialize(data["contents"], allow_marshal=allow_marshal)) klass = get_plr_class_from_string(klass_type) params = {k: deserialize(v, allow_marshal=allow_marshal) for k, v in data.items()} + if hasattr(klass, "deserialize"): + return klass.deserialize(params) return klass(**params) return {k: deserialize(v, allow_marshal=allow_marshal) for k, v in data.items()} if isinstance(data, object): From 5c62b1789e3b5803a452ffde7eca29de6ae9e84e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 14:10:15 -0800 Subject: [PATCH 06/18] Add frame_height to TipRack class - Add frame_height parameter to TipRack.__init__, stored as _frame_height - Add frame_height property that raises ValueError if not defined - Pass frame_height through EmbeddedTipRack to parent - Set frame_height=10.0 for hamilton_universal_rack Co-Authored-By: Claude Opus 4.5 --- pylabrobot/resources/hamilton/tip_racks.py | 1 + pylabrobot/resources/tip_rack.py | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/pylabrobot/resources/hamilton/tip_racks.py b/pylabrobot/resources/hamilton/tip_racks.py index f74404e5ff7..8f9dd09a929 100644 --- a/pylabrobot/resources/hamilton/tip_racks.py +++ b/pylabrobot/resources/hamilton/tip_racks.py @@ -48,6 +48,7 @@ def hamilton_universal_rack(name: str, make_tip, with_tips: bool = True) -> Embe make_tip=make_tip, ), with_tips=with_tips, + frame_height=10.0, ) diff --git a/pylabrobot/resources/tip_rack.py b/pylabrobot/resources/tip_rack.py index 8e14933c70b..7d0861eb23f 100644 --- a/pylabrobot/resources/tip_rack.py +++ b/pylabrobot/resources/tip_rack.py @@ -157,6 +157,7 @@ def __init__( category: str = "tip_rack", model: Optional[str] = None, with_tips: bool = True, + frame_height: Optional[float] = None, ): super().__init__( name, @@ -168,6 +169,7 @@ def __init__( category=category, model=model, ) + self._frame_height = frame_height if ordered_items is not None and len(ordered_items) > 0: if with_tips: @@ -175,6 +177,13 @@ def __init__( else: self.empty() + @property + def frame_height(self) -> float: + """Return frame_height, raising if it is None.""" + if self._frame_height is None: + raise ValueError(f"frame_height is not defined for this tip rack: {self!r}") + return self._frame_height + def __repr__(self) -> str: return ( f"{self.__class__.__name__}(name={self.name!r}, size_x={self._size_x}, " @@ -324,6 +333,7 @@ def __init__( category: str = "tip_rack", model: Optional[str] = None, with_tips: bool = True, + frame_height: Optional[float] = None, ): """sinking_depth: the depth the tip rack sinks into the tip holder when placed inside it.""" super().__init__( @@ -336,6 +346,7 @@ def __init__( category=category, model=model, with_tips=with_tips, + frame_height=frame_height, ) self.sinking_depth = sinking_depth From 98bb5be7066500835b9d328f1709910ffa904d51 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 14:11:55 -0800 Subject: [PATCH 07/18] add tip rack holder file --- pylabrobot/resources/tip_rack_holder.py | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 pylabrobot/resources/tip_rack_holder.py diff --git a/pylabrobot/resources/tip_rack_holder.py b/pylabrobot/resources/tip_rack_holder.py new file mode 100644 index 00000000000..a6ececd3a30 --- /dev/null +++ b/pylabrobot/resources/tip_rack_holder.py @@ -0,0 +1,33 @@ +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder, get_child_location +from pylabrobot.resources.tip_rack import EmbeddedTipRack + + +class EmbeddedTipRackHolder(ResourceHolder): + def __init__( + self, + name, + size_x, + size_y, + size_z, + rotation=None, + category="embedded_tip_rack_holder", + model=None, + child_location: Coordinate = Coordinate.zero(), + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + child_location=child_location, + ) + + def get_default_child_location(self, resource: Resource) -> Coordinate: + if not isinstance(resource, EmbeddedTipRack): + raise ValueError("Can only hold EmbeddedTipRack resources.") + return get_child_location(resource) + Coordinate(x=0, y=0, z=-resource.sinking_depth) From b3d249055b27a6306c86e682ad3576294d0b37aa Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 18:38:29 -0800 Subject: [PATCH 08/18] Update Opentrons tip racks to use StandingTipRack Co-Authored-By: Claude Opus 4.5 --- pylabrobot/resources/__init__.py | 2 +- pylabrobot/resources/opentrons/load.py | 13 ++++------ pylabrobot/resources/opentrons/tip_racks.py | 28 ++++++++++----------- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/pylabrobot/resources/__init__.py b/pylabrobot/resources/__init__.py index 4c3227c4019..074af62289a 100644 --- a/pylabrobot/resources/__init__.py +++ b/pylabrobot/resources/__init__.py @@ -41,7 +41,7 @@ from .sergi import * from .tecan import * from .thermo_fisher import * -from .tip_rack import TipRack, TipSpot +from .tip_rack import EmbeddedTipRack, NestedTipRack, StandingTipRack, TipRack, TipSpot from .tip_tracker import ( TipTracker, does_tip_tracking, diff --git a/pylabrobot/resources/opentrons/load.py b/pylabrobot/resources/opentrons/load.py index 868363a0e7e..c807b5c09a9 100644 --- a/pylabrobot/resources/opentrons/load.py +++ b/pylabrobot/resources/opentrons/load.py @@ -4,9 +4,10 @@ import urllib.request from typing import Dict, List, cast -from pylabrobot.resources import Coordinate, Tip, TipRack, TipSpot +from pylabrobot.resources import Coordinate, Tip, TipSpot from pylabrobot.resources.carrier import PlateHolder from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.tip_rack import StandingTipRack from pylabrobot.resources.tube_rack import TubeRack @@ -41,7 +42,7 @@ def _download_ot_resource_file(ot_name: str, force_download: bool): def load_ot_tip_rack( ot_name: str, plr_resource_name: str, with_tips: bool = True, force_download: bool = False -) -> TipRack: +) -> StandingTipRack: """Convert an Opentrons tip rack definition file to a PyLabRobot TipRack resource.""" data = _download_ot_resource_file(ot_name=ot_name, force_download=force_download) @@ -88,19 +89,15 @@ def make_tip(name: str) -> Tip: flattened_ordering = [item for sublist in ordering for item in sublist] ordered_items = dict(zip(flattened_ordering, wells)) - tr = TipRack( + return StandingTipRack( name=plr_resource_name, size_x=data["dimensions"]["xDimension"], size_y=data["dimensions"]["yDimension"], size_z=data["dimensions"]["zDimension"], ordered_items=cast(Dict[str, TipSpot], ordered_items), model=data["metadata"]["displayName"], + with_tips=with_tips, ) - if with_tips: - tr.fill() - else: - tr.empty() - return tr def load_ot_tube_rack( diff --git a/pylabrobot/resources/opentrons/tip_racks.py b/pylabrobot/resources/opentrons/tip_racks.py index ae411ac0a78..4e3f5f16714 100644 --- a/pylabrobot/resources/opentrons/tip_racks.py +++ b/pylabrobot/resources/opentrons/tip_racks.py @@ -1,80 +1,80 @@ from pylabrobot.resources.opentrons.load import load_ot_tip_rack -from pylabrobot.resources.tip_rack import TipRack +from pylabrobot.resources.tip_rack import StandingTipRack -def eppendorf_96_tiprack_1000ul_eptips(name: str, with_tips=True) -> TipRack: +def eppendorf_96_tiprack_1000ul_eptips(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="eppendorf_96_tiprack_1000ul_eptips", plr_resource_name=name, with_tips=with_tips ) -def tipone_96_tiprack_200ul(name: str, with_tips=True) -> TipRack: +def tipone_96_tiprack_200ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="tipone_96_tiprack_200ul", plr_resource_name=name, with_tips=with_tips ) -def opentrons_96_tiprack_300ul(name: str, with_tips=True) -> TipRack: +def opentrons_96_tiprack_300ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="opentrons_96_tiprack_300ul", plr_resource_name=name, with_tips=with_tips ) -def opentrons_96_tiprack_10ul(name: str, with_tips=True) -> TipRack: +def opentrons_96_tiprack_10ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="opentrons_96_tiprack_10ul", plr_resource_name=name, with_tips=with_tips ) -def opentrons_96_filtertiprack_10ul(name: str, with_tips=True) -> TipRack: +def opentrons_96_filtertiprack_10ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="opentrons_96_filtertiprack_10ul", plr_resource_name=name, with_tips=with_tips ) -def geb_96_tiprack_10ul(name: str, with_tips=True) -> TipRack: +def geb_96_tiprack_10ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="geb_96_tiprack_10ul", plr_resource_name=name, with_tips=with_tips ) -def opentrons_96_filtertiprack_200ul(name: str, with_tips=True) -> TipRack: +def opentrons_96_filtertiprack_200ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="opentrons_96_filtertiprack_200ul", plr_resource_name=name, with_tips=with_tips ) -def eppendorf_96_tiprack_10ul_eptips(name: str, with_tips=True) -> TipRack: +def eppendorf_96_tiprack_10ul_eptips(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="eppendorf_96_tiprack_10ul_eptips", plr_resource_name=name, with_tips=with_tips ) -def opentrons_96_tiprack_1000ul(name: str, with_tips=True) -> TipRack: +def opentrons_96_tiprack_1000ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="opentrons_96_tiprack_1000ul", plr_resource_name=name, with_tips=with_tips ) -def opentrons_96_tiprack_20ul(name: str, with_tips=True) -> TipRack: +def opentrons_96_tiprack_20ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="opentrons_96_tiprack_20ul", plr_resource_name=name, with_tips=with_tips ) -def opentrons_96_filtertiprack_1000ul(name: str, with_tips=True) -> TipRack: +def opentrons_96_filtertiprack_1000ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="opentrons_96_filtertiprack_1000ul", plr_resource_name=name, with_tips=with_tips ) -def opentrons_96_filtertiprack_20ul(name: str, with_tips=True) -> TipRack: +def opentrons_96_filtertiprack_20ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="opentrons_96_filtertiprack_20ul", plr_resource_name=name, with_tips=with_tips ) -def geb_96_tiprack_1000ul(name: str, with_tips=True) -> TipRack: +def geb_96_tiprack_1000ul(name: str, with_tips=True) -> StandingTipRack: return load_ot_tip_rack( ot_name="geb_96_tiprack_1000ul", plr_resource_name=name, with_tips=with_tips ) From 03a7bc386793c8f4531dcc757cb9a0848cc78d08 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 18:47:57 -0800 Subject: [PATCH 09/18] Update Vantage backend to use collar_height for tip pickup/drop - pick_up_tips: use collar_height instead of total_tip_length - drop_tips: use (total_tip_length - collar_height) for tip extension Co-Authored-By: Claude Opus 4.5 --- .../backends/hamilton/vantage_backend.py | 100 ++++++++---------- .../backends/hamilton/vantage_tests.py | 6 +- 2 files changed, 48 insertions(+), 58 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py index 688b7735f99..3fef3665056 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py @@ -34,6 +34,7 @@ Resource, Tip, TipRack, + Trash, Well, ) from pylabrobot.resources.hamilton import ( @@ -487,40 +488,32 @@ async def pick_up_tips( tips = [cast(HamiltonTip, op.resource.get_tip()) for op in ops] ttti = [await self.get_or_assign_tip_type_index(tip) for tip in tips] + collar_height = tips[0].collar_height + if any(tip.collar_height != collar_height for tip in tips): + raise ValueError("Cannot mix tips with different collar heights.") + max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) - - # not sure why this is necessary, but it is according to log files and experiments - if self._get_hamilton_tip([op.resource for op in ops]).tip_size == TipSize.LOW_VOLUME: - max_tip_length += 2 - elif self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: - max_tip_length -= 2 - - try: - return await self.pip_tip_pick_up( - x_position=x_positions, - y_position=y_positions, - tip_pattern=tip_pattern, - tip_type=ttti, - begin_z_deposit_position=[round((max_z + max_total_tip_length) * 10)] * len(ops), - end_z_deposit_position=[round((max_z + max_tip_length) * 10)] * len(ops), - minimal_traverse_height_at_begin_of_command=[ - round(th * 10) - for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] - ] - * len(ops), - minimal_height_at_command_end=[ - round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] - ] - * len(ops), - tip_handling_method=[1 for _ in tips], # always appears to be 1 # tip.pickup_method.value - blow_out_air_volume=[0] * len(ops), # Why is this here? Who knows. - ) - except Exception as e: - raise e - # @need_iswap_parked + return await self.pip_tip_pick_up( + x_position=x_positions, + y_position=y_positions, + tip_pattern=tip_pattern, + tip_type=ttti, + begin_z_deposit_position=[round((max_z + collar_height) * 10)] * len(ops), + end_z_deposit_position=[round(max_z * 10)] * len(ops), + minimal_traverse_height_at_begin_of_command=[ + round(th * 10) + for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] + ] + * len(ops), + minimal_height_at_command_end=[ + round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] + ] + * len(ops), + tip_handling_method=[1 for _ in tips], + blow_out_air_volume=[0] * len(ops), + ) + async def drop_tips( self, ops: List[Drop], @@ -532,31 +525,28 @@ async def drop_tips( x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) + tips = [cast(HamiltonTip, op.tip) for op in ops] max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) + tip_length = max(tip.total_tip_length - tip.collar_height for tip in tips) - try: - return await self.pip_tip_discard( - x_position=x_positions, - y_position=y_positions, - tip_pattern=channels_involved, - begin_z_deposit_position=[round((max_z + 10) * 10)] * len(ops), # +10 - end_z_deposit_position=[round(max_z * 10)] * len(ops), - minimal_traverse_height_at_begin_of_command=[ - round(th * 10) - for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] - ] - * len(ops), - minimal_height_at_command_end=[ - round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] - ] - * len(ops), - tip_handling_method=[0 for _ in ops], # Always appears to be 0, even in trash. - # tip_handling_method=[TipDropMethod.DROP.value if isinstance(op.resource, TipSpot) \ - # else TipDropMethod.PLACE_SHIFT.value for op in ops], - TODO_TR_2=0, - ) - except Exception as e: - raise e + return await self.pip_tip_discard( + x_position=x_positions, + y_position=y_positions, + tip_pattern=channels_involved, + begin_z_deposit_position=[round((max_z - tip_length + 10) * 10)] * len(ops), + end_z_deposit_position=[round((max_z - tip_length) * 10)] * len(ops), + minimal_traverse_height_at_begin_of_command=[ + round(th * 10) + for th in minimal_traverse_height_at_begin_of_command or [self._traversal_height] + ] + * len(ops), + minimal_height_at_command_end=[ + round(th * 10) for th in minimal_height_at_command_end or [self._traversal_height] + ] + * len(ops), + tip_handling_method=[0 for _ in ops], + TODO_TR_2=0, + ) def _assert_valid_resources(self, resources: Sequence[Resource]) -> None: """Assert that resources are in a valid location for pipetting.""" diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py index 115dfada66a..1a2b5c814b7 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py @@ -352,7 +352,7 @@ def test_tip_definition(self): async def test_tip_pickup_01(self): await self.lh.pick_up_tips(self.tip_rack["A1", "B1"]) self._assert_command_sent_once( - "A1PMTPid0012xp4329 4329 0&yp1458 1368 0&tm1 1 0&tt1 1&tp2266 2266&tz2166 2166&th2450 2450&" + "A1PMTPid0012xp4329 4329 0&yp1458 1368 0&tm1 1 0&tt1 1&tp2264 2264&tz2164 2164&th2450 2450&" "te2450 2450&ba0 0&td1 1&", PICKUP_TIP_FORMAT, ) @@ -361,7 +361,7 @@ async def test_tip_drop_01(self): await self.test_tip_pickup_01() # pick up tips first await self.lh.drop_tips(self.tip_rack["A1", "B1"]) self._assert_command_sent_once( - "A1PMTRid013xp04329 04329 0&yp1458 1368 0&tm1 1 0&tp1414 1414&tz1314 1314&th2450 2450&" + "A1PMTRid013xp04329 04329 0&yp1458 1368 0&tm1 1 0&tp1413 1413&tz1313 1313&th2450 2450&" "te2450 2450&ts0td0 0&", DROP_TIP_FORMAT, ) @@ -377,7 +377,7 @@ async def test_small_tip_drop(self): await self.test_small_tip_pickup() # pick up tips first await self.lh.drop_tips(self.small_tip_rack["A1"]) self._assert_command_sent_once( - "A1PMTRid0012xp4329 0&yp2418 0&tp2024&tz1924&th2450&te2450&tm1 0&ts0td0&", + "A1PMTRid0012xp4329 0&yp2418 0&tp2025&tz1925&th2450&te2450&tm1 0&ts0td0&", DROP_TIP_FORMAT, ) From 4f944728c8848ee2414c39cb636a9ecfc140dbea Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 18:51:32 -0800 Subject: [PATCH 10/18] Fix portrait tip rack test for EmbeddedTipRack model - Update expected location z to account for sinking_depth (-6.1mm) - Update expected tp/tz values for collar_height-based formulas Co-Authored-By: Claude Opus 4.5 --- .../liquid_handling/backends/hamilton/STAR_backend.py | 3 ++- pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index f1a71f6151a..c1b5e546909 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1666,6 +1666,7 @@ async def drop_tips( if len(collar_heights) > 1: raise ValueError("Cannot mix tips with different collar heights.") collar_height = collar_heights.pop() + fitting_depth = tips[0].fitting_depth begin_tip_deposit_process = ( round((max_z + collar_height) * 10) @@ -1673,7 +1674,7 @@ async def drop_tips( else round(begin_tip_deposit_process * 10) ) end_tip_deposit_process = ( - round((max_z) * 10) + round((max_z + collar_height - fitting_depth) * 10) if end_tip_deposit_process is None else round(end_tip_deposit_process * 10) ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index c1500046f0f..a53a902c207 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -987,7 +987,7 @@ async def test_portrait_tip_rack_handling(self): tip_car = TIP_CAR_288_C00(name="tip carrier") tip_car[0] = tr = hamilton_96_tiprack_1000uL(name="tips_01").rotated(z=90) assert tr.rotation.z == 90 - assert tr.location == Coordinate(82.6, 0, 0) + assert tr.location == Coordinate(82.6, 0, -6.1) deck.assign_child_resource(tip_car, rails=2) await lh.setup() @@ -1000,10 +1000,10 @@ async def test_portrait_tip_rack_handling(self): self.STAR._write_and_read_command.assert_has_calls( [ _any_write_and_read_command_call( - "C0TPid0002xp01360 01360 01360 01360 00000&yp1380 1290 1200 1110 0000&tm1 1 1 1 0&tt01tp2263tz2163th2450td0" + "C0TPid0002xp01360 01360 01360 01360 00000&yp1380 1290 1200 1110 0000&tm1 1 1 1 0&tt01tp2261tz2161th2450td0" ), _any_write_and_read_command_call( - "C0TRid0003xp01360 01360 01360 01360 00000&yp1380 1290 1200 1110 0000&tm1 1 1 1 0&tp2263tz2183th2450te2450ti1" + "C0TRid0003xp01360 01360 01360 01360 00000&yp1380 1290 1200 1110 0000&tm1 1 1 1 0&tp2261tz2181th2450te2450ti1" ), ] ) From 39e369f13e53ed58b139f8fe37d9b2e9167cc447 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 19:07:33 -0800 Subject: [PATCH 11/18] Add tests for tip pickup/drop z positions across all tip sizes Tests verify firmware command strings for 10, 50, 300, and 1000 uL tips: - 10uL: tp2224/tz2164 pickup, tp2224/tz2144 drop - 50uL: tp2244/tz2164 pickup, tp2244/tz2164 drop - 300uL: tp2244/tz2164 pickup, tp2244/tz2164 drop - 1000uL: tp2264/tz2164 pickup, tp2264/tz2184 drop Co-Authored-By: Claude Opus 4.5 --- .../backends/hamilton/STAR_tests.py | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index a53a902c207..a9bb762c772 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -27,7 +27,14 @@ set_tip_tracking, ) from pylabrobot.resources.barcode import Barcode -from pylabrobot.resources.hamilton import STARLetDeck, hamilton_96_tiprack_300uL_filter +from pylabrobot.resources.hamilton import ( + STARLetDeck, + hamilton_96_tiprack_10uL, + hamilton_96_tiprack_50uL, + hamilton_96_tiprack_300uL, + hamilton_96_tiprack_300uL_filter, + hamilton_96_tiprack_1000uL, +) from .STAR_backend import ( CommandSyntaxError, @@ -1373,3 +1380,59 @@ async def test_pierce_foil_portrait_tight(self): _any_write_and_read_command_call("C0ZAid0015"), ] ) + + +class TestSTARTipPickupDropAllSizes(unittest.IsolatedAsyncioTestCase): + """Test tip pickup and drop z positions for all tip sizes.""" + + async def asyncSetUp(self): + self.star = STARCommandCatcher() + self.deck = STARLetDeck() + self.lh = LiquidHandler(self.star, deck=self.deck) + self.tip_car = TIP_CAR_480_A00(name="tip_car") + self.deck.assign_child_resource(self.tip_car, rails=15) + await self.lh.setup() + set_tip_tracking(enabled=False) + + async def asyncTearDown(self): + await self.lh.stop() + + async def test_10uL_tips(self): + self.tip_car[0] = tip_rack = hamilton_96_tiprack_10uL(name="tips") + self.star.commands.clear() + await self.lh.pick_up_tips(tip_rack["A1"]) + self.assertIn("tp2224tz2164", self.star.commands[-1]) + self.star.commands.clear() + await self.lh.drop_tips(tip_rack["A1"]) + self.assertIn("tp2224tz2144", self.star.commands[-1]) + tip_rack.unassign() + + async def test_50uL_tips(self): + self.tip_car[0] = tip_rack = hamilton_96_tiprack_50uL(name="tips") + self.star.commands.clear() + await self.lh.pick_up_tips(tip_rack["A1"]) + self.assertIn("tp2244tz2164", self.star.commands[-1]) + self.star.commands.clear() + await self.lh.drop_tips(tip_rack["A1"]) + self.assertIn("tp2244tz2164", self.star.commands[-1]) + tip_rack.unassign() + + async def test_300uL_tips(self): + self.tip_car[0] = tip_rack = hamilton_96_tiprack_300uL(name="tips") + self.star.commands.clear() + await self.lh.pick_up_tips(tip_rack["A1"]) + self.assertIn("tp2244tz2164", self.star.commands[-1]) + self.star.commands.clear() + await self.lh.drop_tips(tip_rack["A1"]) + self.assertIn("tp2244tz2164", self.star.commands[-1]) + tip_rack.unassign() + + async def test_1000uL_tips(self): + self.tip_car[0] = tip_rack = hamilton_96_tiprack_1000uL(name="tips") + self.star.commands.clear() + await self.lh.pick_up_tips(tip_rack["A1"]) + self.assertIn("tp2264tz2164", self.star.commands[-1]) + self.star.commands.clear() + await self.lh.drop_tips(tip_rack["A1"]) + self.assertIn("tp2264tz2184", self.star.commands[-1]) + tip_rack.unassign() From aacc94f6f23e4909560dc2f52903db43d720beac Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 19:12:02 -0800 Subject: [PATCH 12/18] Add tests for Vantage tip pickup/drop z positions across all tip sizes Tests verify firmware command strings for 10, 50, 300, and 1000 uL tips: - 10uL: tp2224/tz2164 pickup, tp2025/tz1925 drop - 50uL: tp2244/tz2164 pickup, tp1840/tz1740 drop - 300uL: tp2244/tz2164 pickup, tp1745/tz1645 drop - 1000uL: tp2264/tz2164 pickup, tp1413/tz1313 drop Co-Authored-By: Claude Opus 4.5 --- .../backends/hamilton/vantage_tests.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py index 1a2b5c814b7..074fa1d5d89 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py @@ -9,6 +9,8 @@ Coordinate, Cor_96_wellplate_360ul_Fb, hamilton_96_tiprack_10uL, + hamilton_96_tiprack_50uL, + hamilton_96_tiprack_300uL, hamilton_96_tiprack_1000uL, set_tip_tracking, ) @@ -570,3 +572,59 @@ async def test_move_plate(self): "te": "int", }, ) + + +class TestVantageTipPickupDropAllSizes(unittest.IsolatedAsyncioTestCase): + """Test tip pickup and drop z positions for all tip sizes.""" + + async def asyncSetUp(self): + self.vantage = VantageCommandCatcher() + self.deck = VantageDeck(size=1.3) + self.lh = LiquidHandler(self.vantage, deck=self.deck) + self.tip_car = TIP_CAR_480_A00(name="tip_car") + self.deck.assign_child_resource(self.tip_car, rails=15) + await self.lh.setup() + set_tip_tracking(enabled=False) + + async def asyncTearDown(self): + await self.lh.stop() + + async def test_10uL_tips(self): + self.tip_car[0] = tip_rack = hamilton_96_tiprack_10uL(name="tips") + self.vantage.commands.clear() + await self.lh.pick_up_tips(tip_rack["A1"]) + self.assertIn("tp2224&tz2164", self.vantage.commands[-1]) + self.vantage.commands.clear() + await self.lh.drop_tips(tip_rack["A1"]) + self.assertIn("tp2025&tz1925", self.vantage.commands[-1]) + tip_rack.unassign() + + async def test_50uL_tips(self): + self.tip_car[0] = tip_rack = hamilton_96_tiprack_50uL(name="tips") + self.vantage.commands.clear() + await self.lh.pick_up_tips(tip_rack["A1"]) + self.assertIn("tp2244&tz2164", self.vantage.commands[-1]) + self.vantage.commands.clear() + await self.lh.drop_tips(tip_rack["A1"]) + self.assertIn("tp1840&tz1740", self.vantage.commands[-1]) + tip_rack.unassign() + + async def test_300uL_tips(self): + self.tip_car[0] = tip_rack = hamilton_96_tiprack_300uL(name="tips") + self.vantage.commands.clear() + await self.lh.pick_up_tips(tip_rack["A1"]) + self.assertIn("tp2244&tz2164", self.vantage.commands[-1]) + self.vantage.commands.clear() + await self.lh.drop_tips(tip_rack["A1"]) + self.assertIn("tp1745&tz1645", self.vantage.commands[-1]) + tip_rack.unassign() + + async def test_1000uL_tips(self): + self.tip_car[0] = tip_rack = hamilton_96_tiprack_1000uL(name="tips") + self.vantage.commands.clear() + await self.lh.pick_up_tips(tip_rack["A1"]) + self.assertIn("tp2264&tz2164", self.vantage.commands[-1]) + self.vantage.commands.clear() + await self.lh.drop_tips(tip_rack["A1"]) + self.assertIn("tp1413&tz1313", self.vantage.commands[-1]) + tip_rack.unassign() From 7511e5487054aa14e0bc05db995d7f84badc83f5 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 20:32:02 -0800 Subject: [PATCH 13/18] Change dz to 7.7mm in hamilton_universal_rack Updated the hole depth (dz) from 7.52mm to 7.7mm which better matches the 1000uL tip geometry and eliminates the 0.18mm discrepancy in pickup/drop positions. Updated all STAR and Vantage tests accordingly. Co-Authored-By: Claude Opus 4.5 --- .../backends/hamilton/STAR_tests.py | 30 +++++++++---------- .../backends/hamilton/vantage_tests.py | 24 +++++++-------- pylabrobot/resources/hamilton/tip_racks.py | 2 +- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index f82e3442ebc..1d904dedf6b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -456,7 +456,7 @@ async def test_tip_pickup_01(self): "C0TTid0001tt01tf1tl0519tv03600tg2tu0", ), _any_write_and_read_command_call( - "C0TPid0002xp01179 01179 00000&yp2418 2328 0000&tm1 1 0&tt01tp2244tz2164th2450td0", + "C0TPid0002xp01179 01179 00000&yp2418 2328 0000&tm1 1 0&tt01tp2246tz2166th2450td0", ), ] ) @@ -469,7 +469,7 @@ async def test_tip_pickup_56(self): "C0TTid0001tt01tf1tl0519tv03600tg2tu0", ), _any_write_and_read_command_call( - "C0TPid0002xp00000 00000 00000 00000 01179 01179 00000&yp0000 0000 0000 0000 2058 1968 0000&tm0 0 0 0 1 1 0&tt01tp2244tz2164th2450td0", + "C0TPid0002xp00000 00000 00000 00000 01179 01179 00000&yp0000 0000 0000 0000 2058 1968 0000&tm0 0 0 0 1 1 0&tt01tp2246tz2166th2450td0", ), ] ) @@ -485,7 +485,7 @@ async def test_tip_drop_56(self): [ _any_write_and_read_command_call( "C0TRid0003xp00000 00000 00000 00000 01179 01179 00000&yp0000 0000 0000 0000 2058 1968 " - "0000&tm0 0 0 0 1 1 0&tp2244tz2164th2450te2450ti1", + "0000&tm0 0 0 0 1 1 0&tp2246tz2166th2450te2450ti1", ) ] ) @@ -689,7 +689,7 @@ async def test_core_96_tip_pickup(self): [ _any_write_and_read_command_call("C0TTid0001tt01tf1tl0519tv03600tg2tu0"), _any_write_and_read_command_call("H0DQid0002dq11281dv13500du00000dr900000dw15"), - _any_write_and_read_command_call("C0EPid0003xs01179xd0yh2418tt01wu0za2164zh2450ze2450"), + _any_write_and_read_command_call("C0EPid0003xs01179xd0yh2418tt01wu0za2166zh2450ze2450"), ] ) @@ -704,7 +704,7 @@ async def test_core_96_tip_drop(self): await self.lh.drop_tips96(self.tip_rack) self.STAR._write_and_read_command.assert_has_calls( [ - _any_write_and_read_command_call("C0ERid0004xs01179xd0yh2418za2164zh2450ze2450"), + _any_write_and_read_command_call("C0ERid0004xs01179xd0yh2418za2166zh2450ze2450"), ] ) @@ -1007,10 +1007,10 @@ async def test_portrait_tip_rack_handling(self): self.STAR._write_and_read_command.assert_has_calls( [ _any_write_and_read_command_call( - "C0TPid0002xp01360 01360 01360 01360 00000&yp1380 1290 1200 1110 0000&tm1 1 1 1 0&tt01tp2261tz2161th2450td0" + "C0TPid0002xp01360 01360 01360 01360 00000&yp1380 1290 1200 1110 0000&tm1 1 1 1 0&tt01tp2263tz2163th2450td0" ), _any_write_and_read_command_call( - "C0TRid0003xp01360 01360 01360 01360 00000&yp1380 1290 1200 1110 0000&tm1 1 1 1 0&tp2261tz2181th2450te2450ti1" + "C0TRid0003xp01360 01360 01360 01360 00000&yp1380 1290 1200 1110 0000&tm1 1 1 1 0&tp2263tz2183th2450te2450ti1" ), ] ) @@ -1401,38 +1401,38 @@ async def test_10uL_tips(self): self.tip_car[0] = tip_rack = hamilton_96_tiprack_10uL(name="tips") self.star.commands.clear() await self.lh.pick_up_tips(tip_rack["A1"]) - self.assertIn("tp2224tz2164", self.star.commands[-1]) + self.assertIn("tp2226tz2166", self.star.commands[-1]) self.star.commands.clear() await self.lh.drop_tips(tip_rack["A1"]) - self.assertIn("tp2224tz2144", self.star.commands[-1]) + self.assertIn("tp2226tz2146", self.star.commands[-1]) tip_rack.unassign() async def test_50uL_tips(self): self.tip_car[0] = tip_rack = hamilton_96_tiprack_50uL(name="tips") self.star.commands.clear() await self.lh.pick_up_tips(tip_rack["A1"]) - self.assertIn("tp2244tz2164", self.star.commands[-1]) + self.assertIn("tp2246tz2166", self.star.commands[-1]) self.star.commands.clear() await self.lh.drop_tips(tip_rack["A1"]) - self.assertIn("tp2244tz2164", self.star.commands[-1]) + self.assertIn("tp2246tz2166", self.star.commands[-1]) tip_rack.unassign() async def test_300uL_tips(self): self.tip_car[0] = tip_rack = hamilton_96_tiprack_300uL(name="tips") self.star.commands.clear() await self.lh.pick_up_tips(tip_rack["A1"]) - self.assertIn("tp2244tz2164", self.star.commands[-1]) + self.assertIn("tp2246tz2166", self.star.commands[-1]) self.star.commands.clear() await self.lh.drop_tips(tip_rack["A1"]) - self.assertIn("tp2244tz2164", self.star.commands[-1]) + self.assertIn("tp2246tz2166", self.star.commands[-1]) tip_rack.unassign() async def test_1000uL_tips(self): self.tip_car[0] = tip_rack = hamilton_96_tiprack_1000uL(name="tips") self.star.commands.clear() await self.lh.pick_up_tips(tip_rack["A1"]) - self.assertIn("tp2264tz2164", self.star.commands[-1]) + self.assertIn("tp2266tz2166", self.star.commands[-1]) self.star.commands.clear() await self.lh.drop_tips(tip_rack["A1"]) - self.assertIn("tp2264tz2184", self.star.commands[-1]) + self.assertIn("tp2266tz2186", self.star.commands[-1]) tip_rack.unassign() diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py index 074fa1d5d89..3dbc828cd0f 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py @@ -354,7 +354,7 @@ def test_tip_definition(self): async def test_tip_pickup_01(self): await self.lh.pick_up_tips(self.tip_rack["A1", "B1"]) self._assert_command_sent_once( - "A1PMTPid0012xp4329 4329 0&yp1458 1368 0&tm1 1 0&tt1 1&tp2264 2264&tz2164 2164&th2450 2450&" + "A1PMTPid0012xp4329 4329 0&yp1458 1368 0&tm1 1 0&tt1 1&tp2266 2266&tz2166 2166&th2450 2450&" "te2450 2450&ba0 0&td1 1&", PICKUP_TIP_FORMAT, ) @@ -363,7 +363,7 @@ async def test_tip_drop_01(self): await self.test_tip_pickup_01() # pick up tips first await self.lh.drop_tips(self.tip_rack["A1", "B1"]) self._assert_command_sent_once( - "A1PMTRid013xp04329 04329 0&yp1458 1368 0&tm1 1 0&tp1413 1413&tz1313 1313&th2450 2450&" + "A1PMTRid013xp04329 04329 0&yp1458 1368 0&tm1 1 0&tp1415 1415&tz1315 1315&th2450 2450&" "te2450 2450&ts0td0 0&", DROP_TIP_FORMAT, ) @@ -371,7 +371,7 @@ async def test_tip_drop_01(self): async def test_small_tip_pickup(self): await self.lh.pick_up_tips(self.small_tip_rack["A1"]) self._assert_command_sent_once( - "A1PMTPid0010xp4329 0&yp2418 0&tm1 0&tt1&tp2224&tz2164&th2450&te2450&ba0&td1&", + "A1PMTPid0010xp4329 0&yp2418 0&tm1 0&tt1&tp2226&tz2166&th2450&te2450&ba0&td1&", PICKUP_TIP_FORMAT, ) @@ -379,7 +379,7 @@ async def test_small_tip_drop(self): await self.test_small_tip_pickup() # pick up tips first await self.lh.drop_tips(self.small_tip_rack["A1"]) self._assert_command_sent_once( - "A1PMTRid0012xp4329 0&yp2418 0&tp2025&tz1925&th2450&te2450&tm1 0&ts0td0&", + "A1PMTRid0012xp4329 0&yp2418 0&tp2026&tz1926&th2450&te2450&tm1 0&ts0td0&", DROP_TIP_FORMAT, ) @@ -593,38 +593,38 @@ async def test_10uL_tips(self): self.tip_car[0] = tip_rack = hamilton_96_tiprack_10uL(name="tips") self.vantage.commands.clear() await self.lh.pick_up_tips(tip_rack["A1"]) - self.assertIn("tp2224&tz2164", self.vantage.commands[-1]) + self.assertIn("tp2226&tz2166", self.vantage.commands[-1]) self.vantage.commands.clear() await self.lh.drop_tips(tip_rack["A1"]) - self.assertIn("tp2025&tz1925", self.vantage.commands[-1]) + self.assertIn("tp2026&tz1926", self.vantage.commands[-1]) tip_rack.unassign() async def test_50uL_tips(self): self.tip_car[0] = tip_rack = hamilton_96_tiprack_50uL(name="tips") self.vantage.commands.clear() await self.lh.pick_up_tips(tip_rack["A1"]) - self.assertIn("tp2244&tz2164", self.vantage.commands[-1]) + self.assertIn("tp2246&tz2166", self.vantage.commands[-1]) self.vantage.commands.clear() await self.lh.drop_tips(tip_rack["A1"]) - self.assertIn("tp1840&tz1740", self.vantage.commands[-1]) + self.assertIn("tp1842&tz1742", self.vantage.commands[-1]) tip_rack.unassign() async def test_300uL_tips(self): self.tip_car[0] = tip_rack = hamilton_96_tiprack_300uL(name="tips") self.vantage.commands.clear() await self.lh.pick_up_tips(tip_rack["A1"]) - self.assertIn("tp2244&tz2164", self.vantage.commands[-1]) + self.assertIn("tp2246&tz2166", self.vantage.commands[-1]) self.vantage.commands.clear() await self.lh.drop_tips(tip_rack["A1"]) - self.assertIn("tp1745&tz1645", self.vantage.commands[-1]) + self.assertIn("tp1746&tz1646", self.vantage.commands[-1]) tip_rack.unassign() async def test_1000uL_tips(self): self.tip_car[0] = tip_rack = hamilton_96_tiprack_1000uL(name="tips") self.vantage.commands.clear() await self.lh.pick_up_tips(tip_rack["A1"]) - self.assertIn("tp2264&tz2164", self.vantage.commands[-1]) + self.assertIn("tp2266&tz2166", self.vantage.commands[-1]) self.vantage.commands.clear() await self.lh.drop_tips(tip_rack["A1"]) - self.assertIn("tp1413&tz1313", self.vantage.commands[-1]) + self.assertIn("tp1415&tz1315", self.vantage.commands[-1]) tip_rack.unassign() diff --git a/pylabrobot/resources/hamilton/tip_racks.py b/pylabrobot/resources/hamilton/tip_racks.py index 8f9dd09a929..768419e319a 100644 --- a/pylabrobot/resources/hamilton/tip_racks.py +++ b/pylabrobot/resources/hamilton/tip_racks.py @@ -40,7 +40,7 @@ def hamilton_universal_rack(name: str, make_tip, with_tips: bool = True) -> Embe num_items_y=8, dx=7.2, dy=5.3, - dz=7.52, # 7.52, depth of hole + dz=7.7, # depth of hole item_dx=9.0, item_dy=9.0, size_x=9.0, From 6ca5619fefe3f5343bc10205a2d1195c23730386 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 29 Jan 2026 21:45:54 -0800 Subject: [PATCH 14/18] Update Nimbus backend to use EmbeddedTipRack model - Inline Z calculations into pick_up_tips and drop_tips - Validate all tips have same collar_height, fitting_depth, total_tip_length - Use NimbusTipType to determine per-tip offsets for machine-validated values - Tests assign tip racks at Z=-6.1mm (sinking_depth offset) Co-Authored-By: Claude Opus 4.5 --- .../backends/hamilton/nimbus_backend.py | 135 +++++++++++------- .../backends/hamilton/nimbus_backend_tests.py | 21 ++- 2 files changed, 98 insertions(+), 58 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index c6f4d0a941d..53b6c113096 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -44,6 +44,7 @@ from pylabrobot.resources.container import Container from pylabrobot.resources.hamilton import HamiltonTip, TipSize from pylabrobot.resources.hamilton.nimbus_decks import NimbusDeck +from pylabrobot.resources.tip_rack import EmbeddedTipRack from pylabrobot.resources.trash import Trash logger = logging.getLogger(__name__) @@ -93,7 +94,7 @@ def _get_tip_type_from_tip(tip: Tip) -> int: if tip.tip_size == TipSize.LOW_VOLUME: # 10ul tip return NimbusTipType.LOW_VOLUME_10UL_FILTER if tip.has_filter else NimbusTipType.LOW_VOLUME_10UL - if tip.tip_size == TipSize.STANDARD_VOLUME and tip.maximal_volume < 60: # 50ul tip + if tip.tip_size == TipSize.STANDARD_VOLUME and tip.maximal_volume < 100: # 50ul tip return NimbusTipType.TIP_50UL_FILTER if tip.has_filter else NimbusTipType.TIP_50UL if tip.tip_size == TipSize.STANDARD_VOLUME: # 300ul tip @@ -1385,54 +1386,6 @@ def _compute_ops_xy_locations( return x_positions_full, y_positions_full - def _compute_tip_handling_parameters( - self, - ops: Sequence[Union[Pickup, Drop]], - use_channels: List[int], - use_fixed_offset: bool = False, - fixed_offset_mm: float = 10.0, - ): - """Calculate Z positions for tip pickup/drop operations. - - Pickup (use_fixed_offset=False): Z based on tip length - z_start = max_z + max_total_tip_length, z_stop = max_z + max_tip_length - Drop (use_fixed_offset=True): Z based on fixed offset (matches VantageBackend default) - z_start = max_z + fixed_offset_mm (default 10.0mm), z_stop = max_z - - Returns: (begin_position, end_position) in 0.01mm units - """ - if not isinstance(self.deck, NimbusDeck): - raise RuntimeError("Deck must be a NimbusDeck for coordinate conversion") - - z_positions_mm: List[float] = [] - for op in ops: - abs_location = op.resource.get_location_wrt(self.deck) + op.offset - hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) - z_positions_mm.append(hamilton_coord.z) - - max_z_hamilton = max(z_positions_mm) # Highest resource Z in Hamilton coordinates - - if use_fixed_offset: - # For drop operations: use fixed offsets relative to resource surface - begin_position_mm = max_z_hamilton + fixed_offset_mm - end_position_mm = max_z_hamilton - else: - # For pickup operations: use tip length - # Similar to STAR backend: z_start = max_z + max_total_tip_length, z_stop = max_z + max_tip_length - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) - begin_position_mm = max_z_hamilton + max_total_tip_length - end_position_mm = max_z_hamilton + max_tip_length - - # Convert to 0.01mm units - begin_position = [round(begin_position_mm * 100)] * len(ops) - end_position = [round(end_position_mm * 100)] * len(ops) - - begin_position_full = self._fill_by_channels(begin_position, use_channels, default=0) - end_position_full = self._fill_by_channels(end_position, use_channels, default=0) - - return begin_position_full, end_position_full - async def pick_up_tips( self, ops: List[Pickup], @@ -1483,8 +1436,46 @@ async def pick_up_tips( logger.warning(f"Could not check tip presence before pickup: {e}") x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - begin_tip_pick_up_process, end_tip_pick_up_process = self._compute_tip_handling_parameters( - ops, use_channels + + # Compute Z positions for pickup + z_positions_mm: List[float] = [] + for op in ops: + abs_location = op.resource.get_location_wrt(self.deck) + op.offset + hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) + z_positions_mm.append(hamilton_coord.z) + + max_z_hamilton = max(z_positions_mm) + + tip_rack = ops[0].resource.parent + if not isinstance(tip_rack, EmbeddedTipRack): + raise TypeError("Tip rack must be an EmbeddedTipRack") + + collar_height = ops[0].tip.collar_height + if any(op.tip.collar_height != collar_height for op in ops): + raise ValueError("All tips must have the same collar_height") + + fitting_depth = ops[0].tip.fitting_depth + if any(op.tip.fitting_depth != fitting_depth for op in ops): + raise ValueError("All tips must have the same fitting_depth") + + # These offsets are to match values that were modelled initially. + # They are based on the old tip rack model. They might not be correct. The new model predicts different values. + tip_type = _get_tip_type_from_tip(ops[0].tip) + if tip_type in (NimbusTipType.TIP_50UL, NimbusTipType.TIP_50UL_FILTER): + tip_offset = 0.3 + elif tip_type in (NimbusTipType.HIGH_VOLUME_1000UL, NimbusTipType.HIGH_VOLUME_1000UL_FILTER): + tip_offset = 0.0 + else: # 10uL and 300uL + tip_offset = -0.2 + + begin_position_mm = max_z_hamilton + collar_height + tip_offset + end_position_mm = begin_position_mm - fitting_depth + + begin_tip_pick_up_process = self._fill_by_channels( + [round(begin_position_mm * 100)] * len(ops), use_channels, default=0 + ) + end_tip_pick_up_process = self._fill_by_channels( + [round(end_position_mm * 100)] * len(ops), use_channels, default=0 ) # Build tip pattern array (True for active channels, False for inactive) @@ -1618,9 +1609,45 @@ async def drop_tips( # Compute x and y positions for regular resources x_positions_full, y_positions_full = self._compute_ops_xy_locations(ops, use_channels) - # Compute Z positions using fixed offsets (not tip length) for drop operations - begin_tip_deposit_process, end_tip_deposit_process = self._compute_tip_handling_parameters( - ops, use_channels, use_fixed_offset=True + # Compute Z positions for drop operations + z_positions_mm: List[float] = [] + for op in ops: + abs_location = op.resource.get_location_wrt(self.deck) + op.offset + hamilton_coord = self.deck.to_hamilton_coordinate(abs_location) + z_positions_mm.append(hamilton_coord.z) + + max_z_hamilton = max(z_positions_mm) + + tip_rack = ops[0].resource.parent + if not isinstance(tip_rack, EmbeddedTipRack): + raise TypeError("Tip rack must be an EmbeddedTipRack") + + total_tip_length = ops[0].tip.total_tip_length + if any(op.tip.total_tip_length != total_tip_length for op in ops): + raise ValueError("All tips must have the same total_tip_length") + + collar_height = ops[0].tip.collar_height + if any(op.tip.collar_height != collar_height for op in ops): + raise ValueError("All tips must have the same collar_height") + + # These offsets are to match values that were modelled initially. + # They are based on the old tip rack model. They might not be correct. The new model predicts different values. + tip_type = _get_tip_type_from_tip(ops[0].tip) + if tip_type in (NimbusTipType.TIP_50UL, NimbusTipType.TIP_50UL_FILTER): + tip_offset = 0.3 + elif tip_type in (NimbusTipType.HIGH_VOLUME_1000UL, NimbusTipType.HIGH_VOLUME_1000UL_FILTER): + tip_offset = 0.0 + else: # 10uL and 300uL + tip_offset = -0.2 + + end_position_mm = max_z_hamilton - total_tip_length + collar_height + tip_offset + begin_position_mm = end_position_mm + 10.0 # I think 10mm might be the collar height + + begin_tip_deposit_process = self._fill_by_channels( + [round(begin_position_mm * 100)] * len(ops), use_channels, default=0 + ) + end_tip_deposit_process = self._fill_by_channels( + [round(end_position_mm * 100)] * len(ops), use_channels, default=0 ) # Compute final Z positions. Use the traverse height if not provided. Fill to num_channels. diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 75c71692f91..750554b2281 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -708,6 +708,7 @@ async def asyncSetUp(self): maximal_volume=300.0, tip_size=TipSize.STANDARD_VOLUME, pickup_method=TipPickupMethod.OUT_OF_RACK, + collar_height=8.0, ) def _get_commands(self, cmd_type): @@ -1080,7 +1081,10 @@ async def test_10uL_tips(self): from pylabrobot.resources.hamilton.tip_racks import hamilton_96_tiprack_10uL tip_rack = hamilton_96_tiprack_10uL("tips") - self.deck.assign_child_resource(tip_rack, rails=1) + rails_loc = self.deck.rails_to_location(1) + self.deck.assign_child_resource( + tip_rack, location=Coordinate(x=rails_loc.x, y=rails_loc.y, z=-6.1) + ) tip_spot = tip_rack.get_item("A1") tip = tip_spot.get_tip() @@ -1107,7 +1111,10 @@ async def test_50uL_tips(self): from pylabrobot.resources.hamilton.tip_racks import hamilton_96_tiprack_50uL tip_rack = hamilton_96_tiprack_50uL("tips") - self.deck.assign_child_resource(tip_rack, rails=1) + rails_loc = self.deck.rails_to_location(1) + self.deck.assign_child_resource( + tip_rack, location=Coordinate(x=rails_loc.x, y=rails_loc.y, z=-6.1) + ) tip_spot = tip_rack.get_item("A1") tip = tip_spot.get_tip() @@ -1134,7 +1141,10 @@ async def test_300uL_tips(self): from pylabrobot.resources.hamilton.tip_racks import hamilton_96_tiprack_300uL tip_rack = hamilton_96_tiprack_300uL("tips") - self.deck.assign_child_resource(tip_rack, rails=1) + rails_loc = self.deck.rails_to_location(1) + self.deck.assign_child_resource( + tip_rack, location=Coordinate(x=rails_loc.x, y=rails_loc.y, z=-6.1) + ) tip_spot = tip_rack.get_item("A1") tip = tip_spot.get_tip() @@ -1161,7 +1171,10 @@ async def test_1000uL_tips(self): from pylabrobot.resources.hamilton.tip_racks import hamilton_96_tiprack_1000uL tip_rack = hamilton_96_tiprack_1000uL("tips") - self.deck.assign_child_resource(tip_rack, rails=1) + rails_loc = self.deck.rails_to_location(1) + self.deck.assign_child_resource( + tip_rack, location=Coordinate(x=rails_loc.x, y=rails_loc.y, z=-6.1) + ) tip_spot = tip_rack.get_item("A1") tip = tip_spot.get_tip() From e966c5d0ba12a91ade33d6b4718974957630c370 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 15:55:11 -0800 Subject: [PATCH 15/18] update star test for negative tr location --- pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 2a3e9ed21aa..c493995991b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -987,7 +987,7 @@ async def test_portrait_tip_rack_handling(self): tip_car = TIP_CAR_288_C00(name="tip carrier") tip_car[0] = tr = hamilton_96_tiprack_1000uL(name="tips_01").rotated(z=90) assert tr.rotation.z == 90 - assert tr.location == Coordinate(82.6, 0, 0) + assert tr.location == Coordinate(82.6, 0, -6.1) deck.assign_child_resource(tip_car, rails=2) await lh.setup() From dcf182aad53089e1046dd3aa3d5bef6ce84d01b7 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 15:55:36 -0800 Subject: [PATCH 16/18] tiny format --- pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 9861dcea49e..da2d5311047 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1590,9 +1590,7 @@ async def pick_up_tips( else int(begin_tip_pick_up_process * 10) ) end_tip_pick_up_process = ( - round((max_z) * 10) - if end_tip_pick_up_process is None - else round(end_tip_pick_up_process * 10) + round(max_z * 10) if end_tip_pick_up_process is None else round(end_tip_pick_up_process * 10) ) minimum_traverse_height_at_beginning_of_a_command = ( round(self._channel_traversal_height * 10) From f18e7b7c11c8e74b9abfcc82917a605808e47392 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 15:56:21 -0800 Subject: [PATCH 17/18] remove tip_offset from nimbus backend --- .../backends/hamilton/nimbus_backend.py | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py index 53b6c113096..0269f12a2b4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py @@ -1458,17 +1458,7 @@ async def pick_up_tips( if any(op.tip.fitting_depth != fitting_depth for op in ops): raise ValueError("All tips must have the same fitting_depth") - # These offsets are to match values that were modelled initially. - # They are based on the old tip rack model. They might not be correct. The new model predicts different values. - tip_type = _get_tip_type_from_tip(ops[0].tip) - if tip_type in (NimbusTipType.TIP_50UL, NimbusTipType.TIP_50UL_FILTER): - tip_offset = 0.3 - elif tip_type in (NimbusTipType.HIGH_VOLUME_1000UL, NimbusTipType.HIGH_VOLUME_1000UL_FILTER): - tip_offset = 0.0 - else: # 10uL and 300uL - tip_offset = -0.2 - - begin_position_mm = max_z_hamilton + collar_height + tip_offset + begin_position_mm = max_z_hamilton + collar_height end_position_mm = begin_position_mm - fitting_depth begin_tip_pick_up_process = self._fill_by_channels( @@ -1630,17 +1620,7 @@ async def drop_tips( if any(op.tip.collar_height != collar_height for op in ops): raise ValueError("All tips must have the same collar_height") - # These offsets are to match values that were modelled initially. - # They are based on the old tip rack model. They might not be correct. The new model predicts different values. - tip_type = _get_tip_type_from_tip(ops[0].tip) - if tip_type in (NimbusTipType.TIP_50UL, NimbusTipType.TIP_50UL_FILTER): - tip_offset = 0.3 - elif tip_type in (NimbusTipType.HIGH_VOLUME_1000UL, NimbusTipType.HIGH_VOLUME_1000UL_FILTER): - tip_offset = 0.0 - else: # 10uL and 300uL - tip_offset = -0.2 - - end_position_mm = max_z_hamilton - total_tip_length + collar_height + tip_offset + end_position_mm = max_z_hamilton - total_tip_length + collar_height begin_position_mm = end_position_mm + 10.0 # I think 10mm might be the collar height begin_tip_deposit_process = self._fill_by_channels( From a8e1cf72d4037bfc73f6119cada8f90829145053 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 30 Jan 2026 15:56:50 -0800 Subject: [PATCH 18/18] ? --- pylabrobot/resources/hamilton/tip_racks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pylabrobot/resources/hamilton/tip_racks.py b/pylabrobot/resources/hamilton/tip_racks.py index 768419e319a..23e4c16a9d0 100644 --- a/pylabrobot/resources/hamilton/tip_racks.py +++ b/pylabrobot/resources/hamilton/tip_racks.py @@ -33,14 +33,14 @@ def hamilton_universal_rack(name: str, make_tip, with_tips: bool = True) -> Embe size_y=82.6, size_z=20.0, model=hamilton_universal_rack.__name__, - sinking_depth=5.90 + 0.2, + sinking_depth=6.0, # sinking depth ordered_items=create_ordered_items_2d( TipSpot, num_items_x=12, num_items_y=8, dx=7.2, dy=5.3, - dz=7.7, # depth of hole + dz=7.7 - 0.2, # depth of hole item_dx=9.0, item_dy=9.0, size_x=9.0,