Skip to content
Merged
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
18 changes: 15 additions & 3 deletions api/src/opentrons/protocol_api/core/engine/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,9 @@ def _pick_up_tip() -> WellCore:
last_tip_picked_up_from = _pick_up_tip()

prev_src: Optional[Tuple[Location, WellCore]] = None
prev_dest: Optional[
Union[Tuple[Location, WellCore], TrashBin, WasteChute]
] = None
post_disp_tip_contents = [
tx_comps_executor.LiquidAndAirGapPair(
liquid=0,
Expand All @@ -1357,10 +1360,18 @@ def _pick_up_tip() -> WellCore:
except StopIteration:
is_last_step = True

if new_tip == TransferTipPolicyV2.ALWAYS or (
new_tip == TransferTipPolicyV2.PER_SOURCE and step_source != prev_src
if (
new_tip == TransferTipPolicyV2.ALWAYS
or (
new_tip == TransferTipPolicyV2.PER_SOURCE
and step_source != prev_src
)
or (
new_tip == TransferTipPolicyV2.PER_DESTINATION
and step_destination != prev_dest
)
):
if prev_src is not None:
if prev_src is not None and prev_dest is not None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this is fine. But since you have an and here, is there ever a case where prev_source is None but prev_dest is not None?

_drop_tip()
last_tip_picked_up_from = _pick_up_tip()
post_disp_tip_contents = [
Expand Down Expand Up @@ -1409,6 +1420,7 @@ def _pick_up_tip() -> WellCore:
trash_location=trash_location,
)
prev_src = step_source
prev_dest = step_destination
if new_tip != TransferTipPolicyV2.NEVER:
_drop_tip()

Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_api/instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1667,6 +1667,8 @@ def transfer_with_liquid_class(
- ``"always"``: Use a new tip for each set of aspirate and dispense steps.
- ``"per source"``: Use one tip for each source well, even if
:ref:`tip refilling <complex-tip-refilling>` is required.
- ``"per destination"``: Use one tip for each destination well, even if
:ref:`tip refilling <complex-tip-refilling>` is required.
- ``"never"``: Do not pick up or drop tips at all.

See :ref:`param-tip-handling` for details.
Expand Down
2 changes: 1 addition & 1 deletion api/src/opentrons/protocol_api/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,7 @@ def ensure_new_tip_policy(value: str) -> TransferTipPolicyV2:
except ValueError:
raise ValueError(
f"'{value}' is invalid value for 'new_tip'."
f" Acceptable value is either 'never', 'once', 'always' or 'per source'."
f" Acceptable value is either 'never', 'once', 'always', 'per source' or 'per destination'."
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ class TransferTipPolicyV2(enum.Enum):
NEVER = "never"
ALWAYS = "always"
PER_SOURCE = "per source"
PER_DESTINATION = "per destination"


TransferTipPolicyV2Type = Literal["once", "always", "per source", "never"]
TransferTipPolicyV2Type = Literal[
"once", "always", "per source", "never", "per destination"
]

Target = TypeVar("Target")

Expand Down
4 changes: 2 additions & 2 deletions api/tests/opentrons/protocol_api/test_instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -2316,7 +2316,7 @@ def test_distribute_liquid_raises_if_tip_has_liquid(


@pytest.mark.parametrize("robot_type", ["OT-2 Standard", "OT-3 Standard"])
@pytest.mark.parametrize("new_tip", ["always", "per source"])
@pytest.mark.parametrize("new_tip", ["always", "per source", "per destination"])
def test_distribute_liquid_raises_for_incompatible_tip_policies(
decoy: Decoy,
mock_protocol_core: ProtocolCore,
Expand Down Expand Up @@ -2599,7 +2599,7 @@ def test_consolidate_liquid_raises_if_tip_has_liquid(


@pytest.mark.parametrize("robot_type", ["OT-2 Standard", "OT-3 Standard"])
@pytest.mark.parametrize("new_tip", ["always", "per source"])
@pytest.mark.parametrize("new_tip", ["always", "per source", "per destination"])
def test_consolidate_liquid_raises_for_incompatible_tip_policies(
decoy: Decoy,
mock_protocol_core: ProtocolCore,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,128 @@ def test_order_of_water_transfer_steps(
alternate_tip_drop=True,
),
]
assert len(mock_manager.mock_calls) == 9
assert mock_manager.mock_calls == expected_calls


@pytest.mark.ot3_only
@pytest.mark.parametrize(
"simulated_protocol_context", [("2.23", "Flex")], indirect=True
)
def test_order_of_water_transfer_steps_with_new_tip_per_destination(
simulated_protocol_context: ProtocolContext,
) -> None:
"""It should run the transfer steps while picking up a new tip only for a new destination."""
trash = simulated_protocol_context.load_trash_bin("A3")
tiprack = simulated_protocol_context.load_labware(
"opentrons_flex_96_tiprack_50ul", "D1"
)
pipette_50 = simulated_protocol_context.load_instrument(
"flex_1channel_50", mount="left", tip_racks=[tiprack]
)
nest_plate = simulated_protocol_context.load_labware(
"nest_96_wellplate_200ul_flat", "C3"
)

water = simulated_protocol_context.define_liquid_class("water")
with (
mock.patch.object(
InstrumentCore,
"pick_up_tip",
side_effect=InstrumentCore.pick_up_tip,
autospec=True,
) as patched_pick_up_tip,
mock.patch.object(
InstrumentCore,
"aspirate_liquid_class",
side_effect=InstrumentCore.aspirate_liquid_class,
autospec=True,
) as patched_aspirate,
mock.patch.object(
InstrumentCore,
"dispense_liquid_class",
side_effect=InstrumentCore.dispense_liquid_class,
autospec=True,
) as patched_dispense,
mock.patch.object(
InstrumentCore,
"drop_tip_in_disposal_location",
side_effect=InstrumentCore.drop_tip_in_disposal_location,
autospec=True,
) as patched_drop_tip,
):
mock_manager = mock.Mock()
mock_manager.attach_mock(patched_pick_up_tip, "pick_up_tip")
mock_manager.attach_mock(patched_aspirate, "aspirate_liquid_class")
mock_manager.attach_mock(patched_dispense, "dispense_liquid_class")
mock_manager.attach_mock(patched_drop_tip, "drop_tip_in_disposal_location")
pipette_50.transfer_with_liquid_class(
liquid_class=water,
volume=60,
source=nest_plate.rows()[0][:2],
dest=nest_plate.rows()[1][:2],
new_tip="per destination",
trash_location=trash,
)
expected_calls_per_tip = [
mock.call.pick_up_tip(
mock.ANY,
location=mock.ANY,
well_core=mock.ANY,
presses=mock.ANY,
increment=mock.ANY,
),
mock.call.aspirate_liquid_class(
mock.ANY,
volume=30,
source=mock.ANY,
transfer_properties=mock.ANY,
transfer_type=TransferType.ONE_TO_ONE,
tip_contents=[LiquidAndAirGapPair(liquid=0, air_gap=0)],
volume_for_pipette_mode_configuration=30,
),
mock.call.dispense_liquid_class(
mock.ANY,
volume=30,
dest=mock.ANY,
source=mock.ANY,
transfer_properties=mock.ANY,
transfer_type=TransferType.ONE_TO_ONE,
tip_contents=[LiquidAndAirGapPair(liquid=30, air_gap=0.1)],
add_final_air_gap=True,
trash_location=mock.ANY,
),
mock.call.aspirate_liquid_class(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey, what is this test supposed to do? If there are 2 destination wells, should it have picked up tips twice if you said "per destination"?

Copy link
Copy Markdown
Member Author

@sanni-t sanni-t May 7, 2025

Choose a reason for hiding this comment

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

Yep, and it does. This list is 'expected calls per tip'. The assert in the bottom checks that there's two calls. I didn't want to add 50 more lines duplicating the calls if I could avoid it.

mock.ANY,
volume=30,
source=mock.ANY,
transfer_properties=mock.ANY,
transfer_type=TransferType.ONE_TO_ONE,
tip_contents=[LiquidAndAirGapPair(liquid=0, air_gap=0.1)],
volume_for_pipette_mode_configuration=30,
),
mock.call.dispense_liquid_class(
mock.ANY,
volume=30,
dest=mock.ANY,
source=mock.ANY,
transfer_properties=mock.ANY,
transfer_type=TransferType.ONE_TO_ONE,
tip_contents=[LiquidAndAirGapPair(liquid=30, air_gap=0.1)],
add_final_air_gap=True,
trash_location=mock.ANY,
),
mock.call.drop_tip_in_disposal_location(
mock.ANY,
disposal_location=trash,
home_after=False,
alternate_tip_drop=True,
),
]
assert (
mock_manager.mock_calls == expected_calls_per_tip + expected_calls_per_tip
)


@pytest.mark.ot3_only
@pytest.mark.parametrize(
"simulated_protocol_context", [("2.23", "Flex")], indirect=True
Expand Down