Skip to content

Commit

Permalink
fix(api): allow volume 0 commands in engine (#14211)
Browse files Browse the repository at this point in the history
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 <[email protected]>
Co-authored-by: Max Marrone <[email protected]>
  • Loading branch information
3 people authored Dec 18, 2023
1 parent 5fc0e29 commit 5a939ea
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -88,7 +88,7 @@ class BaseLiquidHandlingResult(BaseModel):
volume: float = Field(
...,
description="Amount of liquid in uL handled in the operation.",
gt=0,
ge=0,
)


Expand Down
137 changes: 137 additions & 0 deletions api/tests/opentrons/protocol_api/test_instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
"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"])
20 changes: 10 additions & 10 deletions shared-data/command/schemas/8.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down

0 comments on commit 5a939ea

Please sign in to comment.