Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -2095,8 +2095,19 @@ async def set_up_pip():
# Skip pip-channel I/O; the __init__ defaults stand in.
# TODO: does not yet gate request_tip_presence or instrument-init moves.
return
if not initialized or any(tip_presences):
has_tips = any(tip_presences)
if has_tips:
await self._initialize_pip_without_tip_discard()
elif not initialized:
Comment on lines +2098 to +2101

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this initialize call not be gated by if initialized?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intention was to keep somewhat consistent with the old behavior.

The old behavior, if I'm understanding correctly, is:

If not initialized or there's any tip left on the channels, do initialize_pip(). initialize_pip() then does the C0DI with tm1&. That works fine when there are no tips, but crashes, at least in my case, with tips.

So I split the two conditions. If there are tips present, call the new _initialize_pip_without_tip_discard() which sends C0DI but with tm0& instead. This does the initialization but the tips stay afterward. If there are no tips, just do the old initialize_pip().

But since _initialize_pip_without_tip_discard() doesn't discard tips, we need to actually discard the tips to complete the initialization as Method Editor does. This is checked and done below. And the check won't run if it went the initialize_pip() route at the start since there were no tips to begin with.

await self.initialize_pip()
if has_tips:
await self._discard_existing_tips_to_waste(tip_presences)
tip_presences_after_discard = await self.request_tip_presence()
if any(tip_presences_after_discard):
raise RuntimeError(
"Failed to discard tips during backend initialization: "
f"{tip_presences_after_discard}"
)
self._channels_minimum_y_spacing = await self.channels_request_y_minimum_spacing()

# VW is not supported on firmware from 2016 or older (see issue #1004). Skip the
Expand Down Expand Up @@ -6301,6 +6312,44 @@ async def initialize_pip(self):
discarding_method=0,
)

async def _initialize_pip_without_tip_discard(self) -> None:
"""Initialize pip channels without using the DI command to discard mounted tips."""
dy = (4050 - 2175) // (self.num_channels - 1)
y_positions = [4050 - i * dy for i in range(self.num_channels)]

await self.initialize_pipetting_channels(
x_positions=[
int(self.extended_conf.tip_waste_x_position * 10)
], # Tip eject waste X position.
y_positions=y_positions,
begin_of_tip_deposit_process=int(self._channel_traversal_height * 10),
end_of_tip_deposit_process=1220,
z_position_at_end_of_a_command=3600,
tip_pattern=[False] * self.num_channels,
tip_type=4, # TODO: get from tip types
discarding_method=0,
)

async def _discard_existing_tips_to_waste(self, tip_presences: List[Optional[bool]]) -> None:
"""Discard tips found on channels during backend initialization."""
dy = (4050 - 2175) // (self.num_channels - 1)
y_positions = [4050 - i * dy for i in range(self.num_channels)]
x_position = int(self.extended_conf.tip_waste_x_position * 10)

trash = self.deck.get_trash_area()
max_z = trash.get_location_wrt(self.deck).z

await self.discard_tip(
x_positions=[x_position] * self.num_channels,
y_positions=y_positions,
tip_pattern=[bool(tip_present) for tip_present in tip_presences],
begin_tip_deposit_process=round((max_z + 59.9) * 10),
end_tip_deposit_process=round((max_z + 49.9) * 10),
minimum_traverse_height_at_beginning_of_a_command=int(self._channel_traversal_height * 10),
z_position_at_end_of_a_command=int(self._channel_traversal_height * 10),
discarding_method=TipDropMethod.PLACE_SHIFT,
)

async def initialize_pipetting_channels(
self,
x_positions: List[int] = [0],
Expand Down
61 changes: 61 additions & 0 deletions pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2227,6 +2227,67 @@ async def test_1000uL_tips(self):
tip_rack.unassign()


class TestSTARSetupTipDiscard(unittest.IsolatedAsyncioTestCase):
"""Tests for setup-time handling of tips already mounted on STAR pip channels."""

def _make_backend(self, num_channels: int = 4) -> STARBackend:
backend = STARBackend(read_timeout=1)
backend._num_channels = num_channels
backend._extended_conf = _DEFAULT_EXTENDED_CONFIGURATION
backend._iswap_parked = True
# The setup-time discard helper derives the TR Z range from the deck trash resource.
backend.set_deck(STARLetDeck())
backend._write_and_read_command = unittest.mock.AsyncMock(return_value="C0DIid0001er00/00")
return backend

async def test_initialize_pip_keeps_existing_active_tip_pattern(self):
backend = self._make_backend()

await backend.initialize_pip()

# The public initialization wrapper should keep its previous behavior for normal init.
backend._write_and_read_command.assert_has_calls(
[
_any_write_and_read_command_call(
"C0DIid0001xp08000&yp4050 3425 2800 2175tp2450tz1220te3600tm1 1 1 1tt04ti0"
)
]
)

async def test_initialize_pip_without_tip_discard_uses_inactive_tip_pattern(self):
backend = self._make_backend()

await backend._initialize_pip_without_tip_discard()

# Mounted-tip setup uses DI for initialization only: tm0 prevents DI from discarding tips.
backend._write_and_read_command.assert_has_calls(
[
_any_write_and_read_command_call(
"C0DIid0001xp08000&yp4050 3425 2800 2175tp2450tz1220te3600tm0 0 0 0tt04ti0"
)
]
)

async def test_discard_existing_tips_to_waste_uses_tr_with_tip_presence_pattern(self):
backend = self._make_backend()
backend._write_and_read_command.return_value = (
"C0TRid0001er00/00kz000 000 000 000vz000 000 000 000"
)

await backend._discard_existing_tips_to_waste([True, False, True, False])

# Actual setup-time tip disposal happens through TR and preserves the mounted-tip pattern.
# The tp/tz values come from the same trash-height formula used by normal drop_tips().
backend._write_and_read_command.assert_has_calls(
[
_any_write_and_read_command_call(
"C0TRid0001xp08000 08000 08000 08000yp4050 3425 2800 2175"
"tm1 0 1 0tp1970tz1870th2450te2450ti0"
)
]
)


class TestChannelsMinimumYSpacing(unittest.IsolatedAsyncioTestCase):
"""Test that different channel spacing configurations produce different behavior.

Expand Down