Skip to content

Commit

Permalink
fix(api): allow aspirate/dispense at arbitrary locations (#11352)
Browse files Browse the repository at this point in the history
Closes #11302, re: RSS-70

Co-authored-by: Max Marrone <[email protected]>
  • Loading branch information
mcous and SyntaxColoring authored Aug 17, 2022
1 parent 3c12e94 commit d9107d2
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 46 deletions.
1 change: 1 addition & 0 deletions api/docs/v2/versioning.rst
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,4 @@ Version 2.13

- :py:meth:`.InstrumentContext.drop_tip` now has a ``prep_after`` parameter.
- :py:meth:`.InstrumentContext.home` may home *both* pipettes as needed to avoid collision risks.
- :py:meth:`.InstrumentContext.aspirate` and :py:meth:`.InstrumentContext.dispense` will avoid interacting directly with modules.
8 changes: 6 additions & 2 deletions api/src/opentrons/protocol_api/instrument_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ def aspirate(
"knows where it is."
)
if self.api_version >= APIVersion(2, 11):
instrument.validate_can_aspirate(dest)
instrument.validate_takes_liquid(
location=dest, reject_module=self.api_version >= APIVersion(2, 13)
)

if self.current_volume == 0:
# Make sure we're at the top of the labware and clear of any
Expand Down Expand Up @@ -314,7 +316,9 @@ def dispense(
"knows where it is."
)
if self.api_version >= APIVersion(2, 11):
instrument.validate_can_dispense(loc)
instrument.validate_takes_liquid(
location=loc, reject_module=self.api_version >= APIVersion(2, 13)
)

c_vol = self.current_volume if not volume else volume

Expand Down
43 changes: 13 additions & 30 deletions api/src/opentrons/protocols/api_support/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,42 +111,25 @@ def determine_drop_target(
return location.top(-z_height)


def validate_can_aspirate(location: types.Location) -> None:
"""Can one aspirate on the given `location` or not? This method is
pretty basic and will probably remain so (?) as the future holds neat
ambitions for how validation is implemented. And as robots become more
intelligent more rigorous testing will be possible
def validate_takes_liquid(location: types.Location, reject_module: bool) -> None:
"""Validate that a location is a valid liquid handling target.
Args:
location: target for aspiration
location: target location.
Raises:
RuntimeError:
ValueError: the given location is not a valid liquid handling target.
"""
if _is_tiprack(location):
raise RuntimeError("Cannot aspirate a tiprack")
labware = None

if location.labware.is_labware:
labware = location.labware.as_labware()

def validate_can_dispense(location: types.Location) -> None:
"""Can one dispense to the given `location` or not? This method is
pretty basic and will probably remain so (?) as the future holds neat
ambitions for how validation is implemented. And as robots become more
intelligent more rigorous testing will be possible
Args:
location: target for dispense
Raises:
RuntimeError:
"""
if _is_tiprack(location):
raise RuntimeError("Cannot dispense to a tiprack")
if location.labware.is_well:
labware = location.labware.as_well().parent

if location.labware.is_module and reject_module:
raise ValueError("Cannot aspirate/dispense directly to a module")

# TODO(mc, 2021-09-08): this `as_labware` looks wrong. I get the feeling
# this is coincidentally working because `both `Well` and `Labware` have
# a `parent` property. Also, it doesn't seem to handle the wide range of
# things a `types.Location` can be (i.e. module, labware, well, etc.)
def _is_tiprack(location: types.Location) -> bool:
labware = location.labware.as_labware()
return labware.parent and labware.parent.is_tiprack # type: ignore[return-value, union-attr]
if labware is not None and labware.is_tiprack:
raise ValueError("Cannot aspirate/dispense to a tip rack")
70 changes: 56 additions & 14 deletions api/tests/opentrons/protocols/api_support/test_instrument.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
from unittest import mock

import pytest
from opentrons.protocol_api import ProtocolContext
from opentrons.protocol_api.labware import Well
from opentrons.protocols.api_support.instrument import (
determine_drop_target,
validate_can_aspirate,
validate_can_dispense,
validate_takes_liquid,
)
from opentrons.protocols.geometry.well_geometry import WellGeometry
from opentrons.protocols.context.well import WellImplementation
from opentrons.protocols.api_support.types import APIVersion
from opentrons.types import Point
from opentrons.types import Location, Point


@pytest.mark.parametrize(
Expand Down Expand Up @@ -55,18 +55,60 @@ def test_determine_drop_target(api_version, expected_point):
assert r.point == expected_point


def test_validate_can_aspirate(ctx):
@pytest.mark.parametrize("reject_module", [True, False])
def test_validate_takes_liquid(ctx: ProtocolContext, reject_module: bool) -> None:
well_plate = ctx.load_labware("corning_96_wellplate_360ul_flat", 1)
tip_rack = ctx.load_labware("opentrons_96_tiprack_300ul", 2)
# test type `Location`
validate_can_aspirate(well_plate.wells()[0].top())
with pytest.raises(RuntimeError):
validate_can_aspirate(tip_rack.wells_by_name()["A1"].top())

validate_takes_liquid(
location=Location(Point(1, 2, 3), None),
reject_module=reject_module,
)
validate_takes_liquid(
location=Location(Point(1, 2, 3), well_plate),
reject_module=reject_module,
)
validate_takes_liquid(
location=Location(Point(1, 2, 3), well_plate.wells()[0]),
reject_module=reject_module,
)
validate_takes_liquid(
location=well_plate.wells()[0].top(),
reject_module=reject_module,
)

def test_validate_can_dispense(ctx):
well_plate = ctx.load_labware("corning_96_wellplate_360ul_flat", 1)
tip_rack = ctx.load_labware("opentrons_96_tiprack_300ul", 2)
validate_can_dispense(well_plate.wells()[0].top())
with pytest.raises(RuntimeError):
validate_can_dispense(tip_rack.wells_by_name()["A1"].top())
with pytest.raises(ValueError, match="Cannot aspirate/dispense to a tip rack"):
validate_takes_liquid(
location=Location(Point(1, 2, 3), tip_rack),
reject_module=reject_module,
)

with pytest.raises(ValueError, match="Cannot aspirate/dispense to a tip rack"):
validate_takes_liquid(
location=Location(Point(1, 2, 3), tip_rack.wells()[0]),
reject_module=reject_module,
)

with pytest.raises(ValueError, match="Cannot aspirate/dispense to a tip rack"):
validate_takes_liquid(
location=tip_rack.wells_by_name()["A1"].top(),
reject_module=reject_module,
)


def test_validate_takes_liquid_module_location(ctx):
module = ctx.load_module("magdeck", 1)

validate_takes_liquid(
location=module.geometry.location,
reject_module=False,
)

with pytest.raises(
ValueError,
match="Cannot aspirate/dispense directly to a module",
):
validate_takes_liquid(
location=module.geometry.location,
reject_module=True,
)

0 comments on commit d9107d2

Please sign in to comment.