diff --git a/api/docs/v2/versioning.rst b/api/docs/v2/versioning.rst index a2d68a652ae..6535d4272ba 100644 --- a/api/docs/v2/versioning.rst +++ b/api/docs/v2/versioning.rst @@ -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. diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 8f890aedce1..f4e581bcdcb 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -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 @@ -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 diff --git a/api/src/opentrons/protocols/api_support/instrument.py b/api/src/opentrons/protocols/api_support/instrument.py index 9a7c65aa2f4..e800d25b2c3 100644 --- a/api/src/opentrons/protocols/api_support/instrument.py +++ b/api/src/opentrons/protocols/api_support/instrument.py @@ -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") diff --git a/api/tests/opentrons/protocols/api_support/test_instrument.py b/api/tests/opentrons/protocols/api_support/test_instrument.py index ef508b73478..651f96d8b0f 100644 --- a/api/tests/opentrons/protocols/api_support/test_instrument.py +++ b/api/tests/opentrons/protocols/api_support/test_instrument.py @@ -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( @@ -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, + )