Skip to content

Commit 253c300

Browse files
authored
fix(api): use smaller of max pipette volume and max tip volume for splitting large transfer volumes (#17387)
# Overview Found a bug in the logic that splits large volume transfers into smaller volumes. Bug is that we only check the transfer volume against max pipette volume. So if you use a 50uL pipette with a 200uL tip, everything works correctly, but if you use a 1000uL pipette with 50uL tip, it attempts to pipette upto 1000uL, instead of clipping to the max tip volume of 50uL. This PR fixes that by taking the smaller of pipette volume and tip volume as the max volume a single transfer can take. ## Risk assessment Low. A scope-limited bug fix
1 parent 18da739 commit 253c300

File tree

3 files changed

+51
-9
lines changed

3 files changed

+51
-9
lines changed

api/src/opentrons/protocol_api/core/engine/instrument.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1008,7 +1008,12 @@ def transfer_liquid( # noqa: C901
10081008
source_dest_per_volume_step = tx_commons.expand_for_volume_constraints(
10091009
volumes=[volume for _ in range(len(source))],
10101010
targets=zip(source, dest),
1011-
max_volume=self.get_max_volume(),
1011+
max_volume=min(
1012+
self.get_max_volume(),
1013+
tip_racks[0][1]
1014+
.get_well_core("A1")
1015+
.get_max_volume(), # Assuming all tips in tiprack are of same volume
1016+
),
10121017
)
10131018

10141019
def _drop_tip() -> None:
@@ -1175,10 +1180,11 @@ def aspirate_liquid_class(
11751180
Return: List of liquid and air gap pairs in tip.
11761181
"""
11771182
aspirate_props = transfer_properties.aspirate
1183+
# TODO (spp, 2025-01-30): check if check_valid_volume_parameters is necessary and is enough.
11781184
tx_commons.check_valid_volume_parameters(
11791185
disposal_volume=0, # No disposal volume for 1-to-1 transfer
11801186
air_gap=aspirate_props.retract.air_gap_by_volume.get_for_volume(volume),
1181-
max_volume=self.get_max_volume(),
1187+
max_volume=self.get_working_volume(),
11821188
)
11831189
source_loc, source_well = source
11841190
aspirate_point = (

api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1786,6 +1786,42 @@ def test_aspirate_liquid_class(
17861786
assert result == [LiquidAndAirGapPair(air_gap=222, liquid=111)]
17871787

17881788

1789+
def test_aspirate_liquid_class_raises_for_more_than_max_volume(
1790+
decoy: Decoy,
1791+
mock_engine_client: EngineClient,
1792+
subject: InstrumentCore,
1793+
minimal_liquid_class_def2: LiquidClassSchemaV1,
1794+
mock_transfer_components_executor: TransferComponentsExecutor,
1795+
) -> None:
1796+
"""It should call aspirate sub-steps execution based on liquid class."""
1797+
source_well = decoy.mock(cls=WellCore)
1798+
source_location = Location(Point(1, 2, 3), labware=None)
1799+
test_liquid_class = LiquidClass.create(minimal_liquid_class_def2)
1800+
test_transfer_properties = test_liquid_class.get_for(
1801+
"flex_1channel_50", "opentrons_flex_96_tiprack_50ul"
1802+
)
1803+
decoy.when(
1804+
mock_engine_client.state.pipettes.get_working_volume("abc123")
1805+
).then_return(100)
1806+
decoy.when(
1807+
tx_commons.check_valid_volume_parameters(
1808+
disposal_volume=0,
1809+
air_gap=test_transfer_properties.aspirate.retract.air_gap_by_volume.get_for_volume(
1810+
123
1811+
),
1812+
max_volume=100,
1813+
)
1814+
).then_raise(ValueError("Oh oh!"))
1815+
with pytest.raises(ValueError, match="Oh oh!"):
1816+
subject.aspirate_liquid_class(
1817+
volume=123,
1818+
source=(source_location, source_well),
1819+
transfer_properties=test_transfer_properties,
1820+
transfer_type=TransferType.ONE_TO_ONE,
1821+
tip_contents=[],
1822+
)
1823+
1824+
17891825
def test_dispense_liquid_class(
17901826
decoy: Decoy,
17911827
mock_engine_client: EngineClient,

api/tests/opentrons/protocol_api_integration/test_transfer_with_liquid_classes.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ def test_water_transfer_with_volume_more_than_tip_max(
2727
tiprack = simulated_protocol_context.load_labware(
2828
"opentrons_flex_96_tiprack_50ul", "D1"
2929
)
30-
pipette_50 = simulated_protocol_context.load_instrument(
31-
"flex_1channel_50", mount="left", tip_racks=[tiprack]
30+
pipette_1k = simulated_protocol_context.load_instrument(
31+
"flex_1channel_1000", mount="left", tip_racks=[tiprack]
3232
)
3333
nest_plate = simulated_protocol_context.load_labware(
3434
"nest_96_wellplate_200ul_flat", "C3"
@@ -47,7 +47,7 @@ def test_water_transfer_with_volume_more_than_tip_max(
4747
mock_manager = mock.Mock()
4848
mock_manager.attach_mock(patched_pick_up_tip, "pick_up_tip")
4949

50-
pipette_50.transfer_liquid(
50+
pipette_1k.transfer_liquid(
5151
liquid_class=water,
5252
volume=60,
5353
source=nest_plate.rows()[0],
@@ -58,7 +58,7 @@ def test_water_transfer_with_volume_more_than_tip_max(
5858
assert patched_pick_up_tip.call_count == 24
5959
patched_pick_up_tip.reset_mock()
6060

61-
pipette_50.transfer_liquid(
61+
pipette_1k.transfer_liquid(
6262
liquid_class=water,
6363
volume=100,
6464
source=nest_plate.rows()[0],
@@ -69,16 +69,16 @@ def test_water_transfer_with_volume_more_than_tip_max(
6969
assert patched_pick_up_tip.call_count == 12
7070
patched_pick_up_tip.reset_mock()
7171

72-
pipette_50.pick_up_tip()
73-
pipette_50.transfer_liquid(
72+
pipette_1k.pick_up_tip()
73+
pipette_1k.transfer_liquid(
7474
liquid_class=water,
7575
volume=50,
7676
source=nest_plate.rows()[0],
7777
dest=arma_plate.rows()[0],
7878
new_tip="never",
7979
trash_location=trash,
8080
)
81-
pipette_50.drop_tip()
81+
pipette_1k.drop_tip()
8282
assert patched_pick_up_tip.call_count == 1
8383

8484

0 commit comments

Comments
 (0)