Skip to content

Commit 8b484e9

Browse files
authored
fix(api): do not rely on inexact volume in tip to determine last dispense (#19030)
# Overview In `distribute_with_liquid_class()`, after we dispense to the last well of a multi-dispense, we're supposed to blow out the disposal volume. This is triggered by calling `retract_during_multi_dispensing(is_last_retract=True)`. However, we were computing `is_last_retract` as: ``` components_executor.retract_during_multi_dispensing( is_last_retract=tip_starting_volume - volume == disposal_volume, ) ``` where `tip_starting_volume` is the current volume in the tip before the dispense, `volume` is the amount we're about to dispense, and `disposal_volume` is the disposal volume. This equality becomes **inexact** when `tip_starting_volume`, `volume`, and `disposal_volume` are not nice round numbers, which often happens with liquid classes because the `disposal_volume` is interpolated. This floating point bug causes `is_last_retract` to be `False`, which causes us to never dispose of the disposal volume. (AUTH-2151) This fix changes the code to no longer rely on `tip_starting_volume` to determine whether we are about to do the last dispense. Instead, in the loop that performs the multi-dispense, we simply see if we're in the last iteration of the loop. ## Test Plan and Hands on Testing I updated the unit tests. Note that we already have existing unit tests for both the `is_last_retract=False` and `is_last_retract=True` cases. After my change, I was able to delete `decoy.when(get_aspirated_volume(...)).then_return(...)` from the unit tests, which ensures that our implementation no longer looks at the current volume in the tip. I also manually analyzed this protocol to check that we are blowing out the disposal volume: ``` from opentrons import protocol_api, types requirements = {"robotType": "Flex", "apiLevel": "2.24"} def run(protocol: protocol_api.ProtocolContext) -> None: tip_rack = protocol.load_labware( "opentrons_flex_96_tiprack_200ul", location="C2", namespace="opentrons", ) well_plate = protocol.load_labware( "opentrons_96_wellplate_200ul_pcr_full_skirt", location="B1", namespace="opentrons", ) pipette = protocol.load_instrument( "flex_1channel_1000", "left", tip_racks=[tip_rack], ) waste_chute = protocol.load_waste_chute() liquid_class = protocol.get_liquid_class(name="water") transfer_props = liquid_class.get_for(pipette, tip_rack) transfer_props.multi_dispense.retract.blowout.location = "trash" # Before this fix, we would not blow out this disposal volume: transfer_props.multi_dispense.disposal_by_volume.set_for_all_volumes(1.1) pipette.distribute_with_liquid_class( liquid_class=liquid_class, volume=10, source=[well_plate["A1"]], dest=[well_plate["G11"], well_plate["H12"]], new_tip="once", return_tip=True, ) ``` ## Risk assessment Medium. This is rather complicated code.
1 parent eabe3a6 commit 8b484e9

File tree

3 files changed

+13
-10
lines changed

3 files changed

+13
-10
lines changed

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,7 +1705,7 @@ def _pick_up_tip() -> Tuple[Location, WellCore]:
17051705
# multi-dispense in those destinations.
17061706
# If the tip has a volume corresponding to a single destination, then
17071707
# do a single-dispense into that destination.
1708-
for dispense_vol, dispense_dest in vol_dest_combo:
1708+
for idx, (dispense_vol, dispense_dest) in enumerate(vol_dest_combo):
17091709
if use_single_dispense:
17101710
tip_contents = self.dispense_liquid_class(
17111711
volume=dispense_vol,
@@ -1733,6 +1733,7 @@ def _pick_up_tip() -> Tuple[Location, WellCore]:
17331733
trash_location=trash_location,
17341734
conditioning_volume=conditioning_vol,
17351735
disposal_volume=disposal_vol,
1736+
is_last_dispense_in_tip=(idx == len(vol_dest_combo) - 1),
17361737
)
17371738
is_first_step = False
17381739

@@ -2286,6 +2287,7 @@ def dispense_liquid_class_during_multi_dispense(
22862287
trash_location: Union[Location, TrashBin, WasteChute],
22872288
conditioning_volume: float,
22882289
disposal_volume: float,
2290+
is_last_dispense_in_tip: bool,
22892291
) -> List[tx_comps_executor.LiquidAndAirGapPair]:
22902292
"""Execute a dispense step that's part of a multi-dispense.
22912293
@@ -2332,9 +2334,8 @@ def dispense_liquid_class_during_multi_dispense(
23322334
components_executor.submerge(
23332335
submerge_properties=dispense_props.submerge, post_submerge_action="dispense"
23342336
)
2335-
tip_starting_volume = self.get_current_volume()
23362337
is_last_dispense_without_disposal_vol = (
2337-
disposal_volume == 0 and tip_starting_volume == volume
2338+
disposal_volume == 0 and is_last_dispense_in_tip
23382339
)
23392340
push_out_vol = (
23402341
# TODO (spp): verify if it's okay to use push_out_by_volume of single dispense
@@ -2354,7 +2355,7 @@ def dispense_liquid_class_during_multi_dispense(
23542355
source_well=source[1] if source else None,
23552356
conditioning_volume=conditioning_volume,
23562357
add_final_air_gap=add_final_air_gap,
2357-
is_last_retract=tip_starting_volume - volume == disposal_volume,
2358+
is_last_retract=is_last_dispense_in_tip,
23582359
)
23592360
last_contents = components_executor.tip_state.last_liquid_and_air_gap_in_tip
23602361
new_tip_contents = tip_contents[0:-1] + [last_contents]

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2666,9 +2666,6 @@ def test_dispense_liquid_class_during_multi_dispense(
26662666
decoy.when(
26672667
mock_transfer_components_executor.tip_state.last_liquid_and_air_gap_in_tip
26682668
).then_return(LiquidAndAirGapPair(liquid=333, air_gap=444))
2669-
decoy.when(
2670-
mock_engine_client.state.pipettes.get_aspirated_volume("abc123")
2671-
).then_return(12345)
26722669
result = subject.dispense_liquid_class_during_multi_dispense(
26732670
volume=123,
26742671
dest=(dest_location, dest_well),
@@ -2680,6 +2677,7 @@ def test_dispense_liquid_class_during_multi_dispense(
26802677
trash_location=Location(Point(1, 2, 3), labware=None),
26812678
conditioning_volume=conditioning_volume,
26822679
disposal_volume=disposal_volume,
2680+
is_last_dispense_in_tip=False, # testing the case when this is not last dispense
26832681
)
26842682
decoy.verify(
26852683
mock_transfer_components_executor.submerge(
@@ -2750,9 +2748,6 @@ def test_last_dispense_liquid_class_during_multi_dispense(
27502748
decoy.when(
27512749
mock_transfer_components_executor.tip_state.last_liquid_and_air_gap_in_tip
27522750
).then_return(LiquidAndAirGapPair(liquid=333, air_gap=444))
2753-
decoy.when(
2754-
mock_engine_client.state.pipettes.get_aspirated_volume("abc123")
2755-
).then_return(123)
27562751
result = subject.dispense_liquid_class_during_multi_dispense(
27572752
volume=123,
27582753
dest=(dest_location, dest_well),
@@ -2764,6 +2759,7 @@ def test_last_dispense_liquid_class_during_multi_dispense(
27642759
trash_location=Location(Point(1, 2, 3), labware=None),
27652760
conditioning_volume=conditioning_volume,
27662761
disposal_volume=disposal_volume,
2762+
is_last_dispense_in_tip=True,
27672763
)
27682764
decoy.verify(
27692765
mock_transfer_components_executor.submerge(

api/tests/opentrons/protocol_api_integration/test_transfer_with_liquid_classes.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1512,6 +1512,7 @@ def test_order_of_water_distribution_steps_using_multi_dispense(
15121512
trash_location=mock.ANY,
15131513
conditioning_volume=expected_conditioning_volume,
15141514
disposal_volume=expected_disposal_volume,
1515+
is_last_dispense_in_tip=False,
15151516
),
15161517
mock.call.dispense_liquid_class_during_multi_dispense(
15171518
mock.ANY,
@@ -1532,6 +1533,7 @@ def test_order_of_water_distribution_steps_using_multi_dispense(
15321533
trash_location=mock.ANY,
15331534
conditioning_volume=expected_conditioning_volume,
15341535
disposal_volume=expected_disposal_volume,
1536+
is_last_dispense_in_tip=True,
15351537
),
15361538
mock.call.aspirate_liquid_class(
15371539
mock.ANY,
@@ -1562,6 +1564,7 @@ def test_order_of_water_distribution_steps_using_multi_dispense(
15621564
trash_location=mock.ANY,
15631565
conditioning_volume=expected_conditioning_volume,
15641566
disposal_volume=expected_disposal_volume,
1567+
is_last_dispense_in_tip=False,
15651568
),
15661569
mock.call.dispense_liquid_class_during_multi_dispense(
15671570
mock.ANY,
@@ -1582,6 +1585,7 @@ def test_order_of_water_distribution_steps_using_multi_dispense(
15821585
trash_location=mock.ANY,
15831586
conditioning_volume=expected_conditioning_volume,
15841587
disposal_volume=expected_disposal_volume,
1588+
is_last_dispense_in_tip=True,
15851589
),
15861590
mock.call.drop_tip_in_disposal_location(
15871591
mock.ANY,
@@ -2108,6 +2112,7 @@ def test_order_of_water_distribution_steps_using_mixed_dispense(
21082112
trash_location=mock.ANY,
21092113
conditioning_volume=expected_conditioning_volume,
21102114
disposal_volume=expected_disposal_volume,
2115+
is_last_dispense_in_tip=False,
21112116
),
21122117
mock.call.dispense_liquid_class_during_multi_dispense(
21132118
mock.ANY,
@@ -2125,6 +2130,7 @@ def test_order_of_water_distribution_steps_using_mixed_dispense(
21252130
trash_location=mock.ANY,
21262131
conditioning_volume=expected_conditioning_volume,
21272132
disposal_volume=expected_disposal_volume,
2133+
is_last_dispense_in_tip=True,
21282134
),
21292135
mock.call.aspirate_liquid_class(
21302136
mock.ANY,

0 commit comments

Comments
 (0)