From 5a939eafc8f7c0fdcace8aa3109ab5f65bac35b6 Mon Sep 17 00:00:00 2001 From: Derek Maggio Date: Mon, 18 Dec 2023 09:22:07 -0800 Subject: [PATCH] fix(api): allow volume 0 commands in engine (#14211) A previous PR (12a630b / #13989 ) changed the python protocol api in version 2.16 to allow commanding 0ul liquid handling commands like aspirate, mix, and dispense. This is useful in programmatic protocols that read out volumes to handle; they can now handle 0 volume properly. In 2.15 and previous, specifying 0 would lead to those commands doing the most volume they could (i.e. aspirate the full tip volume, dispense whatever's currently in the pipette, mix at full volume) and this likely was an unintentional side effect because of python truthiness. However, that change only touched the python protocol API; that API would emit commands to the engine that specified 0 volume, and those were not allowed: the pydantic models for the commands and responses required strictly greater than 0 volume. This PR - changes the pydantic models and updates the schema to allow 0ul commands - adds a python protocol to be an integration test - adds unit tests for the python protocol api aspirate and dispense commands --------- Co-authored-by: Seth Foster Co-authored-by: Max Marrone --- .../commands/pipetting_common.py | 6 +- .../protocol_api/test_instrument_context.py | 137 ++++++++++++++++++ ...C_HS_TM_2_16_aspirateDispenseMix0Volume.py | 71 +++++++++ shared-data/command/schemas/8.json | 20 +-- 4 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 app-testing/files/protocols/py/OT2_P300M_P20S_TC_HS_TM_2_16_aspirateDispenseMix0Volume.py diff --git a/api/src/opentrons/protocol_engine/commands/pipetting_common.py b/api/src/opentrons/protocol_engine/commands/pipetting_common.py index a2dc1c8e5cd..77d4769bbd3 100644 --- a/api/src/opentrons/protocol_engine/commands/pipetting_common.py +++ b/api/src/opentrons/protocol_engine/commands/pipetting_common.py @@ -19,9 +19,9 @@ class VolumeMixin(BaseModel): volume: float = Field( ..., - description="Amount of liquid in uL. Must be greater than 0 and less " + description="Amount of liquid in uL. Must be at least 0 and no greater " "than a pipette-specific maximum volume.", - gt=0, + ge=0, ) @@ -88,7 +88,7 @@ class BaseLiquidHandlingResult(BaseModel): volume: float = Field( ..., description="Amount of liquid in uL handled in the operation.", - gt=0, + ge=0, ) diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index 5b9b2e422f9..328aed5b01f 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -990,3 +990,140 @@ def test_configure_nozzle_layout( """The correct model is passed to the engine client.""" with exception: subject.configure_nozzle_layout(style, primary_nozzle, front_right_nozzle) + + +@pytest.mark.parametrize("api_version", [APIVersion(2, 15)]) +def test_dispense_0_volume_means_dispense_everything( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + subject: InstrumentContext, + mock_protocol_core: ProtocolCore, +) -> None: + """It should dispense all liquid to a well.""" + input_location = Location(point=Point(2, 2, 2), labware=None) + decoy.when( + mock_validation.validate_location(location=input_location, last_location=None) + ).then_return(mock_validation.PointTarget(location=input_location, in_place=False)) + decoy.when(mock_instrument_core.get_current_volume()).then_return(100) + decoy.when(mock_instrument_core.get_dispense_flow_rate(1.23)).then_return(5.67) + subject.dispense(volume=0, location=input_location, rate=1.23, push_out=None) + + decoy.verify( + mock_instrument_core.dispense( + location=input_location, + well_core=None, + in_place=False, + volume=100, + rate=1.23, + flow_rate=5.67, + push_out=None, + ), + times=1, + ) + + +@pytest.mark.parametrize("api_version", [APIVersion(2, 16)]) +def test_dispense_0_volume_means_dispense_nothing( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + subject: InstrumentContext, + mock_protocol_core: ProtocolCore, +) -> None: + """It should dispense no liquid to a well.""" + input_location = Location(point=Point(2, 2, 2), labware=None) + decoy.when( + mock_validation.validate_location(location=input_location, last_location=None) + ).then_return(mock_validation.PointTarget(location=input_location, in_place=False)) + decoy.when(mock_instrument_core.get_dispense_flow_rate(1.23)).then_return(5.67) + subject.dispense(volume=0, location=input_location, rate=1.23, push_out=None) + + decoy.verify( + mock_instrument_core.dispense( + location=input_location, + well_core=None, + in_place=False, + volume=0, + rate=1.23, + flow_rate=5.67, + push_out=None, + ), + times=1, + ) + + +@pytest.mark.parametrize("api_version", [APIVersion(2, 15)]) +def test_aspirate_0_volume_means_aspirate_everything( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + subject: InstrumentContext, + mock_protocol_core: ProtocolCore, +) -> None: + """It should aspirate to a well.""" + mock_well = decoy.mock(cls=Well) + input_location = Location(point=Point(2, 2, 2), labware=mock_well) + last_location = Location(point=Point(9, 9, 9), labware=None) + decoy.when(mock_instrument_core.get_mount()).then_return(Mount.RIGHT) + + decoy.when(mock_protocol_core.get_last_location(Mount.RIGHT)).then_return( + last_location + ) + + decoy.when( + mock_validation.validate_location( + location=input_location, last_location=last_location + ) + ).then_return(WellTarget(well=mock_well, location=input_location, in_place=False)) + decoy.when(mock_instrument_core.get_aspirate_flow_rate(1.23)).then_return(5.67) + decoy.when(mock_instrument_core.get_available_volume()).then_return(200) + subject.aspirate(volume=0, location=input_location, rate=1.23) + + decoy.verify( + mock_instrument_core.aspirate( + location=input_location, + well_core=mock_well._core, + in_place=False, + volume=200, + rate=1.23, + flow_rate=5.67, + ), + times=1, + ) + + +@pytest.mark.parametrize("api_version", [APIVersion(2, 16)]) +def test_aspirate_0_volume_means_aspirate_nothing( + decoy: Decoy, + mock_instrument_core: InstrumentCore, + subject: InstrumentContext, + mock_protocol_core: ProtocolCore, +) -> None: + """It should aspirate to a well.""" + mock_well = decoy.mock(cls=Well) + input_location = Location(point=Point(2, 2, 2), labware=mock_well) + last_location = Location(point=Point(9, 9, 9), labware=None) + decoy.when(mock_instrument_core.get_mount()).then_return(Mount.RIGHT) + + decoy.when(mock_protocol_core.get_last_location(Mount.RIGHT)).then_return( + last_location + ) + + decoy.when( + mock_validation.validate_location( + location=input_location, last_location=last_location + ) + ).then_return(WellTarget(well=mock_well, location=input_location, in_place=False)) + decoy.when(mock_instrument_core.get_aspirate_flow_rate(1.23)).then_return(5.67) + + subject.aspirate(volume=0, location=input_location, rate=1.23) + + decoy.verify( + mock_instrument_core.aspirate( + location=input_location, + well_core=mock_well._core, + in_place=False, + volume=0, + rate=1.23, + flow_rate=5.67, + ), + times=1, + ) diff --git a/app-testing/files/protocols/py/OT2_P300M_P20S_TC_HS_TM_2_16_aspirateDispenseMix0Volume.py b/app-testing/files/protocols/py/OT2_P300M_P20S_TC_HS_TM_2_16_aspirateDispenseMix0Volume.py new file mode 100644 index 00000000000..edf43366e1a --- /dev/null +++ b/app-testing/files/protocols/py/OT2_P300M_P20S_TC_HS_TM_2_16_aspirateDispenseMix0Volume.py @@ -0,0 +1,71 @@ +"""Smoke Test v3.0 """ +from opentrons import protocol_api + +metadata = { + "protocolName": "API 2.16 Aspirate Dispense Mix 0 Volume", + "author": "Opentrons Engineering ", + "source": "Software Testing Team", +} + +requirements = {"robotType": "OT-2", "apiLevel": "2.16"} + + +def run(ctx: protocol_api.ProtocolContext) -> None: + """This method is run by the protocol engine.""" + + ctx.set_rail_lights(True) + + # deck positions + tips_300ul_position = "5" + tips_20ul_position = "4" + dye_source_position = "3" + logo_position = "2" + + # 300ul tips + tips_300ul = [ + ctx.load_labware( + load_name="opentrons_96_tiprack_300ul", + location=tips_300ul_position, + label="300ul tips", + ) + ] + + # 20ul tips + tips_20ul = [ + ctx.load_labware( + load_name="opentrons_96_tiprack_20ul", + location=tips_20ul_position, + label="20ul tips", + ) + ] + + # pipettes + ctx.load_instrument(instrument_name="p300_multi_gen2", mount="left", tip_racks=tips_300ul) + + pipette_right = ctx.load_instrument(instrument_name="p20_single_gen2", mount="right", tip_racks=tips_20ul) + + dye_container = ctx.load_labware( + load_name="nest_12_reservoir_15ml", + location=dye_source_position, + label="dye container", + ) + + # >= 2.14 define_liquid and load_liquid + water = ctx.define_liquid( + name="water", description="H₂O", display_color="#42AB2D" + ) # subscript 2 https://www.compart.com/en/unicode/U+2082 + + dye_container.wells_by_name()["A1"].load_liquid(liquid=water, volume=20) + + pipette_right.pick_up_tip() + + # >= 2.15: Aspirate everything, then dispense everything + # < 2.15: Aspirate nothing, then dispense everything(Which in this case means nothing) + # pipette_right.aspirate(volume=0, location=dye_container.wells_by_name()["A1"]) + # pipette_right.dispense(location=dye_container.wells_by_name()["A1"]) + + # >= 2.15: Aspirate everything, dispense everything, mix everything + # < 2.15: Aspirate everything, dispense nothing, mix nothing + pipette_right.aspirate(volume=20, location=dye_container.wells_by_name()["A1"]) + pipette_right.dispense(volume=0, location=dye_container.wells_by_name()["A1"]) + pipette_right.mix(volume=0, location=dye_container.wells_by_name()["A1"]) diff --git a/shared-data/command/schemas/8.json b/shared-data/command/schemas/8.json index 1cff4c6ef11..c95c466db5e 100644 --- a/shared-data/command/schemas/8.json +++ b/shared-data/command/schemas/8.json @@ -259,8 +259,8 @@ }, "volume": { "title": "Volume", - "description": "Amount of liquid in uL. Must be greater than 0 and less than a pipette-specific maximum volume.", - "exclusiveMinimum": 0, + "description": "Amount of liquid in uL. Must be at least 0 and no greater than a pipette-specific maximum volume.", + "minimum": 0, "type": "number" }, "pipetteId": { @@ -320,8 +320,8 @@ }, "volume": { "title": "Volume", - "description": "Amount of liquid in uL. Must be greater than 0 and less than a pipette-specific maximum volume.", - "exclusiveMinimum": 0, + "description": "Amount of liquid in uL. Must be at least 0 and no greater than a pipette-specific maximum volume.", + "minimum": 0, "type": "number" }, "pipetteId": { @@ -412,8 +412,8 @@ "properties": { "volume": { "title": "Volume", - "description": "Amount of liquid in uL. Must be greater than 0 and less than a pipette-specific maximum volume.", - "exclusiveMinimum": 0, + "description": "Amount of liquid in uL. Must be at least 0 and no greater than a pipette-specific maximum volume.", + "minimum": 0, "type": "number" }, "pipetteId": { @@ -684,8 +684,8 @@ }, "volume": { "title": "Volume", - "description": "Amount of liquid in uL. Must be greater than 0 and less than a pipette-specific maximum volume.", - "exclusiveMinimum": 0, + "description": "Amount of liquid in uL. Must be at least 0 and no greater than a pipette-specific maximum volume.", + "minimum": 0, "type": "number" }, "pipetteId": { @@ -744,8 +744,8 @@ }, "volume": { "title": "Volume", - "description": "Amount of liquid in uL. Must be greater than 0 and less than a pipette-specific maximum volume.", - "exclusiveMinimum": 0, + "description": "Amount of liquid in uL. Must be at least 0 and no greater than a pipette-specific maximum volume.", + "minimum": 0, "type": "number" }, "pipetteId": {