diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 3239a6dac339..95ab15279972 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -70,6 +70,16 @@ AdvancedLiquidHandling = v1_transfer.AdvancedLiquidHandling +class _Unset: + """A sentinel value when no value has been supplied for an argument. + User code should never use this explicitly.""" + + def __repr__(self) -> str: + # Without this, the generated docs render the argument as + # "" + return self.__class__.__name__ + + class InstrumentContext(publisher.CommandPublisher): """ A context for a specific pipette or instrument. @@ -644,12 +654,13 @@ def _determine_speed(self, speed: float) -> float: @publisher.publish(command=cmds.touch_tip) @requires_version(2, 0) - def touch_tip( + def touch_tip( # noqa: C901 self, location: Optional[labware.Well] = None, radius: float = 1.0, v_offset: float = -1.0, speed: float = 60.0, + mm_from_edge: Union[float, _Unset] = _Unset(), ) -> InstrumentContext: """ Touch the pipette tip to the sides of a well, with the intent of removing leftover droplets. @@ -675,12 +686,28 @@ def touch_tip( - Maximum: 80.0 mm/s - Minimum: 1.0 mm/s :type speed: float + :param mm_from_edge: How far to move inside the well, as a distance from the + well's edge. + When ``mm_from_edge=0``, the pipette tip will move all the + way to the edge of the target well. When ``mm_from_edge=1``, + the pipette tip will move to 1 mm from the well's edge. + Lower values will press the tip harder into the well's + walls; higher values will touch the well more lightly, or + not at all. + ``mm_from_edge`` and ``radius`` are mutually exclusive: to + use ``mm_from_edge``, ``radius`` must be unspecified (left + to its default value of 1.0). + :type mm_from_edge: float :raises: ``UnexpectedTipRemovalError`` -- If no tip is attached to the pipette. :raises RuntimeError: If no location is specified and the location cache is ``None``. This should happen if ``touch_tip`` is called without first calling a method that takes a location, like :py:meth:`.aspirate` or :py:meth:`dispense`. + :raises: ValueError: If both ``mm_to_edge`` and ``radius`` are specified. :returns: This instance. + + .. versionchanged:: 2.24 + Added the ``mm_from_edge`` parameter. """ if not self._core.has_tip(): raise UnexpectedTipRemovalError("touch_tip", self.name, self.mount) @@ -703,6 +730,18 @@ def touch_tip( else: raise TypeError(f"location should be a Well, but it is {location}") + if not isinstance(mm_from_edge, _Unset): + if self.api_version < APIVersion(2, 24): + raise APIVersionError( + api_element="mm_from_edge", + until_version="2.24", + current_version=f"{self.api_version}", + ) + if radius != 1.0: + raise ValueError( + "radius must be set to 1.0 if mm_from_edge is specified" + ) + if "touchTipDisabled" in parent_labware.quirks: _log.info(f"Ignoring touch tip on labware {well}") return self @@ -722,6 +761,7 @@ def touch_tip( radius=radius, z_offset=v_offset, speed=checked_speed, + mm_from_edge=mm_from_edge if not isinstance(mm_from_edge, _Unset) else None, ) return self diff --git a/api/src/opentrons/protocols/api_support/definitions.py b/api/src/opentrons/protocols/api_support/definitions.py index a353e1d49fe9..b96440a3295b 100644 --- a/api/src/opentrons/protocols/api_support/definitions.py +++ b/api/src/opentrons/protocols/api_support/definitions.py @@ -1,6 +1,6 @@ from .types import APIVersion -MAX_SUPPORTED_VERSION = APIVersion(2, 23) +MAX_SUPPORTED_VERSION = APIVersion(2, 24) """The maximum supported protocol API version in this release.""" MIN_SUPPORTED_VERSION = APIVersion(2, 0) diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index 78e1101a1ee4..8f8f7c419376 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -1043,6 +1043,8 @@ def test_touch_tip( decoy.when(mock_well.parent.quirks).then_return([]) + # touch_tip() with the old `radius` argument: + subject.touch_tip(mock_well, radius=0.123, v_offset=4.56, speed=42.0) decoy.verify( @@ -1052,9 +1054,31 @@ def test_touch_tip( radius=0.123, z_offset=4.56, speed=42.0, + mm_from_edge=None, ) ) + # touch_tip() with the new `mm_from_edge` argument: + + subject.touch_tip(mock_well, v_offset=4.56, speed=42.0, mm_from_edge=0.5) + + decoy.verify( + mock_instrument_core.touch_tip( + location=Location(point=Point(1, 2, 3), labware=mock_well), + well_core=mock_well._core, + radius=1, + z_offset=4.56, + speed=42.0, + mm_from_edge=0.5, + ) + ) + + # `radius` and `mm_from_edge` are mutually exclusive, should raise if both specified: + with pytest.raises(ValueError): + subject.touch_tip( + mock_well, radius=0.75, v_offset=4.56, speed=42.0, mm_from_edge=0.5 + ) + def test_return_height( decoy: Decoy, mock_instrument_core: InstrumentCore, subject: InstrumentContext