Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fb51c2d
EmbeddedTipRack; remodel; pickup/drop 1000uL tips on STAR with new model
rickwierenga Jan 28, 2026
b6875a8
fix STARBackend.{pick_up,drop}_tips96
rickwierenga Jan 29, 2026
5b8209d
move collar_height to Tip
rickwierenga Jan 29, 2026
0f2a932
serialize sinking depth in EmbeddedTipRack
rickwierenga Jan 29, 2026
2ffc788
Add collar_height to Tip class
rickwierenga Jan 29, 2026
5c62b17
Add frame_height to TipRack class
rickwierenga Jan 29, 2026
98bb5be
add tip rack holder file
rickwierenga Jan 29, 2026
b3d2490
Update Opentrons tip racks to use StandingTipRack
rickwierenga Jan 30, 2026
03a7bc3
Update Vantage backend to use collar_height for tip pickup/drop
rickwierenga Jan 30, 2026
4f94472
Fix portrait tip rack test for EmbeddedTipRack model
rickwierenga Jan 30, 2026
39e369f
Add tests for tip pickup/drop z positions across all tip sizes
rickwierenga Jan 30, 2026
aacc94f
Add tests for Vantage tip pickup/drop z positions across all tip sizes
rickwierenga Jan 30, 2026
d000801
Merge branch 'main' into refactor-tip-racks
rickwierenga Jan 30, 2026
e9e3b21
Merge branch 'main' into refactor-tip-racks
rickwierenga Jan 30, 2026
7511e54
Change dz to 7.7mm in hamilton_universal_rack
rickwierenga Jan 30, 2026
05b538a
Merge main with Nimbus tip tests
rickwierenga Jan 30, 2026
6ca5619
Update Nimbus backend to use EmbeddedTipRack model
rickwierenga Jan 30, 2026
1488f1f
Merge main into refactor-tip-racks
rickwierenga Jan 30, 2026
e966c5d
update star test for negative tr location
rickwierenga Jan 30, 2026
dcf182a
tiny format
rickwierenga Jan 30, 2026
f18e7b7
remove tip_offset from nimbus backend
rickwierenga Jan 30, 2026
a8e1cf7
?
rickwierenga Jan 30, 2026
ddf7d27
Merge branch 'main' into refactor-tip-racks
BioCam Jan 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 19 additions & 38 deletions pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1573,28 +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)
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)
Expand Down Expand Up @@ -1662,15 +1658,21 @@ 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()
fitting_depth = tips[0].fitting_depth

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 + collar_height - fitting_depth) * 10)
if end_tip_deposit_process is None
else round(end_tip_deposit_process * 10)
)
Expand Down Expand Up @@ -2933,26 +2935,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)

Expand Down Expand Up @@ -3000,12 +2987,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

Expand Down
2 changes: 1 addition & 1 deletion pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,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()

Expand Down
115 changes: 61 additions & 54 deletions pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -1483,8 +1436,36 @@ 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")

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(
[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)
Expand Down Expand Up @@ -1618,9 +1599,35 @@ 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")

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(
[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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()

Expand All @@ -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()

Expand All @@ -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()

Expand All @@ -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()

Expand Down
Loading
Loading