diff --git a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py index 8922e2a6c43..8afeec4c294 100644 --- a/api/src/opentrons/protocol_api/core/engine/deck_conflict.py +++ b/api/src/opentrons/protocol_api/core/engine/deck_conflict.py @@ -36,6 +36,15 @@ def __init__(self, message: str) -> None: ) +class UnsuitableTiprackForPipetteMotion(MotionPlanningFailureError): + """Error raised when trying to perform a pipette movement to a tip rack, based on adapter status.""" + + def __init__(self, message: str) -> None: + super().__init__( + message=message, + ) + + _log = logging.getLogger(__name__) # TODO (spp, 2023-12-06): move this to a location like motion planning where we can @@ -174,6 +183,60 @@ def check_safe_for_pipette_movement( ) +def check_safe_for_tip_pickup_and_return( + engine_state: StateView, + pipette_id: str, + labware_id: str, +) -> None: + """Check if the presence or absence of a tiprack adapter might cause any pipette movement issues. + + A 96 channel pipette will pick up tips using cam action when it's configured + to use ALL nozzles. For this, the tiprack needs to be on the Flex 96 channel tiprack adapter + or similar or the tips will not be picked up. + + On the other hand, if the pipette is configured with partial nozzle configuration, + it uses the usual pipette presses to pick the tips up, in which case, having the tiprack + on the Flex 96 channel tiprack adapter (or similar) will cause the pipette to + crash against the adapter posts. + + In order to check if the 96-channel can move and pickup/drop tips safely, this method + checks for the height attribute of the tiprack adapter rather than checking for the + specific official adapter since users might create custom labware &/or definitions + compatible with the official adapter. + """ + if not engine_state.pipettes.get_channels(pipette_id) == 96: + # Adapters only matter to 96 ch. + return + + is_partial_config = engine_state.pipettes.get_is_partially_configured(pipette_id) + tiprack_name = engine_state.labware.get_display_name(labware_id) + tiprack_parent = engine_state.labware.get_location(labware_id) + if isinstance(tiprack_parent, OnLabwareLocation): # tiprack is on an adapter + is_96_ch_tiprack_adapter = engine_state.labware.get_has_quirk( + labware_id=labware_id, quirk="tiprackAdapterFor96Channel" + ) + tiprack_height = engine_state.labware.get_dimensions(labware_id).z + adapter_height = engine_state.labware.get_dimensions(tiprack_parent.labwareId).z + if is_partial_config and tiprack_height < adapter_height: + raise PartialTipMovementNotAllowedError( + f"{tiprack_name} cannot be on an adapter taller than the tip rack" + f" when picking up fewer than 96 tips." + ) + elif not is_partial_config and not is_96_ch_tiprack_adapter: + raise UnsuitableTiprackForPipetteMotion( + f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter" + f" in order to pick up or return all 96 tips simultaneously." + ) + + elif ( + not is_partial_config + ): # tiprack is not on adapter and pipette is in full config + raise UnsuitableTiprackForPipetteMotion( + f"{tiprack_name} must be on an Opentrons Flex 96 Tip Rack Adapter" + f" in order to pick up or return all 96 tips simultaneously." + ) + + def _check_deck_conflict_for_96_channel( engine_state: StateView, pipette_id: str, diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 7bce8d9808e..45eeaab5e77 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -383,6 +383,11 @@ def pick_up_tip( well_name=well_name, absolute_point=location.point, ) + deck_conflict.check_safe_for_tip_pickup_and_return( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + ) deck_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, @@ -434,12 +439,19 @@ def drop_tip( ) else: well_location = DropTipWellLocation() + + if self._engine_client.state.labware.is_tiprack(labware_id): + deck_conflict.check_safe_for_tip_pickup_and_return( + engine_state=self._engine_client.state, + pipette_id=self._pipette_id, + labware_id=labware_id, + ) deck_conflict.check_safe_for_pipette_movement( engine_state=self._engine_client.state, pipette_id=self._pipette_id, labware_id=labware_id, well_name=well_name, - well_location=WellLocation(), + well_location=well_location, ) self._engine_client.drop_tip( pipette_id=self._pipette_id, diff --git a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py index a47f5ad04c9..952e0177910 100644 --- a/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py +++ b/api/tests/opentrons/protocol_api/core/engine/test_deck_conflict.py @@ -1,7 +1,7 @@ """Unit tests for the deck_conflict module.""" import pytest -from typing import ContextManager, Any +from typing import ContextManager, Any, NamedTuple, List from decoy import Decoy from contextlib import nullcontext as does_not_raise from opentrons_shared_data.labware.dev_types import LabwareUri @@ -22,6 +22,9 @@ WellOrigin, WellOffset, TipGeometry, + OnDeckLabwareLocation, + OnLabwareLocation, + Dimensions, ) @@ -473,3 +476,116 @@ def test_deck_conflict_raises_for_bad_partial_8_channel_move( well_name="A2", well_location=WellLocation(origin=WellOrigin.TOP, offset=WellOffset(z=10)), ) + + +class PipetteMovementSpec(NamedTuple): + """Spec data to test deck_conflict.check_safe_for_tip_pickup_and_return .""" + + tiprack_parent: OnDeckLabwareLocation + tiprack_dim: Dimensions + is_on_flex_adapter: bool + is_partial_config: bool + expected_raise: ContextManager[Any] + + +pipette_movement_specs: List[PipetteMovementSpec] = [ + PipetteMovementSpec( + tiprack_parent=DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + tiprack_dim=Dimensions(x=0, y=0, z=50), + is_on_flex_adapter=False, + is_partial_config=False, + expected_raise=pytest.raises( + deck_conflict.UnsuitableTiprackForPipetteMotion, + match="A cool tiprack must be on an Opentrons Flex 96 Tip Rack Adapter", + ), + ), + PipetteMovementSpec( + tiprack_parent=OnLabwareLocation(labwareId="adapter-id"), + tiprack_dim=Dimensions(x=0, y=0, z=50), + is_on_flex_adapter=True, + is_partial_config=False, + expected_raise=does_not_raise(), + ), + PipetteMovementSpec( + tiprack_parent=OnLabwareLocation(labwareId="adapter-id"), + tiprack_dim=Dimensions(x=0, y=0, z=50), + is_on_flex_adapter=False, + is_partial_config=False, + expected_raise=pytest.raises( + deck_conflict.UnsuitableTiprackForPipetteMotion, + match="A cool tiprack must be on an Opentrons Flex 96 Tip Rack Adapter", + ), + ), + PipetteMovementSpec( + tiprack_parent=OnLabwareLocation(labwareId="adapter-id"), + tiprack_dim=Dimensions(x=0, y=0, z=50), + is_on_flex_adapter=True, + is_partial_config=True, + expected_raise=pytest.raises( + deck_conflict.PartialTipMovementNotAllowedError, + match="A cool tiprack cannot be on an adapter taller than the tip rack", + ), + ), + PipetteMovementSpec( + tiprack_parent=OnLabwareLocation(labwareId="adapter-id"), + tiprack_dim=Dimensions(x=0, y=0, z=101), + is_on_flex_adapter=True, + is_partial_config=True, + expected_raise=does_not_raise(), + ), + PipetteMovementSpec( + tiprack_parent=DeckSlotLocation(slotName=DeckSlotName.SLOT_5), + tiprack_dim=Dimensions(x=0, y=0, z=50), + is_on_flex_adapter=True, # will be ignored + is_partial_config=True, + expected_raise=does_not_raise(), + ), +] + + +@pytest.mark.parametrize( + ("robot_type", "deck_type"), + [("OT-3 Standard", DeckType.OT3_STANDARD)], +) +@pytest.mark.parametrize( + argnames=PipetteMovementSpec._fields, + argvalues=pipette_movement_specs, +) +def test_valid_96_pipette_movement_for_tiprack_and_adapter( + decoy: Decoy, + mock_state_view: StateView, + tiprack_parent: OnDeckLabwareLocation, + tiprack_dim: Dimensions, + is_on_flex_adapter: bool, + is_partial_config: bool, + expected_raise: ContextManager[Any], +) -> None: + """It should raise appropriate error for unsuitable tiprack parent when moving 96 channel to it.""" + decoy.when(mock_state_view.pipettes.get_channels("pipette-id")).then_return(96) + decoy.when(mock_state_view.labware.get_dimensions("adapter-id")).then_return( + Dimensions(x=0, y=0, z=100) + ) + decoy.when(mock_state_view.labware.get_display_name("labware-id")).then_return( + "A cool tiprack" + ) + decoy.when( + mock_state_view.pipettes.get_is_partially_configured("pipette-id") + ).then_return(is_partial_config) + decoy.when(mock_state_view.labware.get_location("labware-id")).then_return( + tiprack_parent + ) + decoy.when(mock_state_view.labware.get_dimensions("labware-id")).then_return( + tiprack_dim + ) + decoy.when( + mock_state_view.labware.get_has_quirk( + labware_id="labware-id", quirk="tiprackAdapterFor96Channel" + ) + ).then_return(is_on_flex_adapter) + + with expected_raise: + deck_conflict.check_safe_for_tip_pickup_and_return( + engine_state=mock_state_view, + pipette_id="pipette-id", + labware_id="labware-id", + ) 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 ed465699acb..78c2411174f 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 @@ -29,7 +29,12 @@ ColumnNozzleLayoutConfiguration, ) from opentrons.protocol_api._nozzle_layout import NozzleLayout -from opentrons.protocol_api.core.engine import InstrumentCore, WellCore, ProtocolCore +from opentrons.protocol_api.core.engine import ( + InstrumentCore, + WellCore, + ProtocolCore, + deck_conflict, +) from opentrons.types import Location, Mount, MountType, Point @@ -51,6 +56,15 @@ def mock_protocol_core(decoy: Decoy) -> ProtocolCore: return decoy.mock(cls=ProtocolCore) +@pytest.fixture(autouse=True) +def patch_mock_pipette_movement_safety_check( + decoy: Decoy, monkeypatch: pytest.MonkeyPatch +) -> None: + """Replace deck_conflict.check() with a mock.""" + mock = decoy.mock(func=deck_conflict.check_safe_for_pipette_movement) + monkeypatch.setattr(deck_conflict, "check_safe_for_pipette_movement", mock) + + @pytest.fixture def subject( decoy: Decoy, @@ -238,6 +252,20 @@ def test_pick_up_tip( ) decoy.verify( + deck_conflict.check_safe_for_tip_pickup_and_return( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="labware-id", + ), + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="labware-id", + well_name="well-name", + well_location=WellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + ), + ), mock_engine_client.pick_up_tip( pipette_id="abc123", labware_id="labware-id", @@ -276,6 +304,16 @@ def test_drop_tip_no_location( subject.drop_tip(location=None, well_core=well_core, home_after=True) decoy.verify( + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="labware-id", + well_name="well-name", + well_location=DropTipWellLocation( + origin=DropTipWellOrigin.DEFAULT, + offset=WellOffset(x=0, y=0, z=0), + ), + ), mock_engine_client.drop_tip( pipette_id="abc123", labware_id="labware-id", @@ -287,7 +325,6 @@ def test_drop_tip_no_location( home_after=True, alternateDropLocation=False, ), - times=1, ) @@ -309,10 +346,27 @@ def test_drop_tip_with_location( absolute_point=Point(1, 2, 3), ) ).then_return(WellLocation(origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1))) + decoy.when(mock_engine_client.state.labware.is_tiprack("labware-id")).then_return( + True + ) subject.drop_tip(location=location, well_core=well_core, home_after=True) decoy.verify( + deck_conflict.check_safe_for_tip_pickup_and_return( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="labware-id", + ), + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="labware-id", + well_name="well-name", + well_location=DropTipWellLocation( + origin=DropTipWellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + ), + ), mock_engine_client.drop_tip( pipette_id="abc123", labware_id="labware-id", @@ -323,7 +377,6 @@ def test_drop_tip_with_location( home_after=True, alternateDropLocation=False, ), - times=1, ) @@ -356,6 +409,15 @@ def test_aspirate_from_well( ) decoy.verify( + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="123abc", + well_name="my cool well", + well_location=WellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + ), + ), mock_engine_client.aspirate( pipette_id="abc123", labware_id="123abc", @@ -453,6 +515,15 @@ def test_blow_out_to_well( subject.blow_out(location=location, well_core=well_core, in_place=False) decoy.verify( + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="123abc", + well_name="my cool well", + well_location=WellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + ), + ), mock_engine_client.blow_out( pipette_id="abc123", labware_id="123abc", @@ -545,6 +616,15 @@ def test_dispense_to_well( ) decoy.verify( + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="123abc", + well_name="my cool well", + well_location=WellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=3, y=2, z=1) + ), + ), mock_engine_client.dispense( pipette_id="abc123", labware_id="123abc", @@ -857,6 +937,15 @@ def test_touch_tip( ) decoy.verify( + deck_conflict.check_safe_for_pipette_movement( + engine_state=mock_engine_client.state, + pipette_id="abc123", + labware_id="123abc", + well_name="my cool well", + well_location=WellLocation( + origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=4.56) + ), + ), mock_engine_client.touch_tip( pipette_id="abc123", labware_id="123abc", diff --git a/shared-data/js/__tests__/labwareDefQuirks.test.ts b/shared-data/js/__tests__/labwareDefQuirks.test.ts index 80f3ecd003b..6b8eedaddf9 100644 --- a/shared-data/js/__tests__/labwareDefQuirks.test.ts +++ b/shared-data/js/__tests__/labwareDefQuirks.test.ts @@ -11,6 +11,7 @@ const EXPECTED_VALID_QUIRKS = [ 'touchTipDisabled', 'fixedTrash', 'gripperIncompatible', + 'tiprackAdapterFor96Channel', ] describe('check quirks for all labware defs', () => { diff --git a/shared-data/labware/definitions/2/opentrons_flex_96_tiprack_adapter/1.json b/shared-data/labware/definitions/2/opentrons_flex_96_tiprack_adapter/1.json index ff52a60ba5e..157c3f2f769 100644 --- a/shared-data/labware/definitions/2/opentrons_flex_96_tiprack_adapter/1.json +++ b/shared-data/labware/definitions/2/opentrons_flex_96_tiprack_adapter/1.json @@ -24,7 +24,7 @@ ], "parameters": { "format": "96Standard", - "quirks": [], + "quirks": ["tiprackAdapterFor96Channel"], "isTiprack": false, "isMagneticModuleCompatible": false, "loadName": "opentrons_flex_96_tiprack_adapter"