diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 8f593d94cd2..2ffe17d2eac 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -681,6 +681,28 @@ def get_nozzle_configuration(self) -> NozzleConfigurationType: self._pipette_id ) + def is_tip_tracking_available(self) -> bool: + primary_nozzle = self._engine_client.state.pipettes.get_primary_nozzle( + self._pipette_id + ) + if self.get_nozzle_configuration() == NozzleConfigurationType.FULL: + return True + else: + if self.get_channels() == 96: + # SINGLE configuration with H12 nozzle is technically supported by the + # current tip tracking implementation but we don't do any deck conflict + # checks for it, so we won't provide full support for it yet. + return ( + self.get_nozzle_configuration() == NozzleConfigurationType.COLUMN + and primary_nozzle == "A12" + ) + if self.get_channels() == 8: + return ( + self.get_nozzle_configuration() == NozzleConfigurationType.SINGLE + and primary_nozzle == "H1" + ) + return False + def set_flow_rate( self, aspirate: Optional[float] = None, diff --git a/api/src/opentrons/protocol_api/core/instrument.py b/api/src/opentrons/protocol_api/core/instrument.py index 1a54ddb892f..6c034adb4a5 100644 --- a/api/src/opentrons/protocol_api/core/instrument.py +++ b/api/src/opentrons/protocol_api/core/instrument.py @@ -274,5 +274,9 @@ def configure_nozzle_layout( """ ... + def is_tip_tracking_available(self) -> bool: + """Return whether auto tip tracking is available for the pipette's current nozzle configuration.""" + ... + InstrumentCoreType = TypeVar("InstrumentCoreType", bound=AbstractInstrument[Any]) diff --git a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py index b91a8821c97..f70540534af 100644 --- a/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy/legacy_instrument_core.py @@ -547,3 +547,7 @@ def configure_nozzle_layout( def get_active_channels(self) -> int: """This will never be called because it was added in API 2.16.""" assert False, "get_active_channels only supported in API 2.16 & later" + + def is_tip_tracking_available(self) -> bool: + # Tip tracking is always available in legacy context + return True diff --git a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py index 549275c3983..cd1c3b84a5d 100644 --- a/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py +++ b/api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py @@ -465,3 +465,7 @@ def configure_nozzle_layout( def get_active_channels(self) -> int: """This will never be called because it was added in API 2.16.""" assert False, "get_active_channels only supported in API 2.16 & later" + + def is_tip_tracking_available(self) -> bool: + # Tip tracking is always available in legacy context + return True diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 29a8114e364..3ab9a6d8cbe 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -6,6 +6,7 @@ from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, + UnexpectedTipRemovalError, ) from opentrons.legacy_broker import LegacyBroker from opentrons.hardware_control.dev_types import PipetteDict @@ -26,7 +27,6 @@ requires_version, APIVersionError, ) -from opentrons_shared_data.errors.exceptions import UnexpectedTipRemovalError from .core.common import InstrumentCore, ProtocolCore from .core.engine import ENGINE_CORE_API_VERSION @@ -860,6 +860,14 @@ def pick_up_tip( ) if location is None: + if not self._core.is_tip_tracking_available(): + raise CommandPreconditionViolated( + "Automatic tip tracking is not available for the current pipette" + " nozzle configuration. We suggest switching to a configuration" + " that supports automatic tip tracking or specifying the exact tip" + " to pick up." + ) + tip_rack, well = labware.next_available_tip( starting_tip=self.starting_tip, tip_racks=self.tip_racks, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py index 78c2411174f..d87812f9ad0 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_instrument_core.py @@ -1,5 +1,5 @@ """Test for the ProtocolEngine-based instrument API core.""" -from typing import cast, Optional +from typing import cast, Optional, Union import pytest from decoy import Decoy @@ -8,6 +8,7 @@ from opentrons.hardware_control import SyncHardwareAPI from opentrons.hardware_control.dev_types import PipetteDict +from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType from opentrons.protocol_engine import ( DeckPoint, LoadedPipette, @@ -1011,3 +1012,42 @@ def test_configure_nozzle_layout( decoy.verify( mock_engine_client.configure_nozzle_layout(subject._pipette_id, expected_model) ) + + +@pytest.mark.parametrize( + argnames=["pipette_channels", "nozzle_layout", "primary_nozzle", "expected_result"], + argvalues=[ + (96, NozzleConfigurationType.FULL, "A1", True), + (96, NozzleConfigurationType.FULL, None, True), + (96, NozzleConfigurationType.ROW, "A1", False), + (96, NozzleConfigurationType.COLUMN, "A1", False), + (96, NozzleConfigurationType.COLUMN, "A12", True), + (96, NozzleConfigurationType.SINGLE, "H12", False), + (96, NozzleConfigurationType.SINGLE, "A1", False), + (8, NozzleConfigurationType.FULL, "A1", True), + (8, NozzleConfigurationType.FULL, None, True), + (8, NozzleConfigurationType.SINGLE, "H1", True), + (8, NozzleConfigurationType.SINGLE, "A1", False), + (1, NozzleConfigurationType.FULL, None, True), + ], +) +def test_is_tip_tracking_available( + decoy: Decoy, + mock_engine_client: EngineClient, + subject: InstrumentCore, + pipette_channels: int, + nozzle_layout: NozzleConfigurationType, + primary_nozzle: Union[str, None], + expected_result: bool, +) -> None: + """It should return whether tip tracking is available based on nozzle configuration.""" + decoy.when( + mock_engine_client.state.tips.get_pipette_channels(subject.pipette_id) + ).then_return(pipette_channels) + decoy.when( + mock_engine_client.state.pipettes.get_nozzle_layout_type(subject.pipette_id) + ).then_return(nozzle_layout) + decoy.when( + mock_engine_client.state.pipettes.get_primary_nozzle(subject.pipette_id) + ).then_return(primary_nozzle) + assert subject.is_tip_tracking_available() == expected_result diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index 328aed5b01f..78369be3f95 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -600,6 +600,7 @@ def test_pick_up_from_associated_tip_racks( mock_well = decoy.mock(cls=Well) top_location = Location(point=Point(1, 2, 3), labware=mock_well) + decoy.when(mock_instrument_core.is_tip_tracking_available()).then_return(True) decoy.when(mock_instrument_core.get_active_channels()).then_return(123) decoy.when( labware.next_available_tip( @@ -626,6 +627,22 @@ def test_pick_up_from_associated_tip_racks( ) +def test_pick_up_fails_when_tip_tracking_unavailable( + decoy: Decoy, mock_instrument_core: InstrumentCore, subject: InstrumentContext +) -> None: + """It should raise an error if automatic tip tracking is not available..""" + mock_tip_rack_1 = decoy.mock(cls=Labware) + + decoy.when(mock_instrument_core.is_tip_tracking_available()).then_return(False) + decoy.when(mock_instrument_core.get_active_channels()).then_return(123) + + subject.tip_racks = [mock_tip_rack_1] + with pytest.raises( + CommandPreconditionViolated, match="Automatic tip tracking is not available" + ): + subject.pick_up_tip() + + def test_drop_tip_to_well( decoy: Decoy, mock_instrument_core: InstrumentCore, subject: InstrumentContext ) -> None: