From afe23d475714643a575bc7a9958ba8904c7535c3 Mon Sep 17 00:00:00 2001 From: Laura Cox <31892318+Laura-Danielle@users.noreply.github.com> Date: Thu, 16 Nov 2023 21:52:44 +0200 Subject: [PATCH] feat(api): disable return tip for partially configured pipette (#13972) It is unsafe for us to return tips to a tiprack while in partial tip configuration because we risk the potential of picking up used tips. Closes RSS-360 --------- Co-authored-by: Seth Foster --- .../protocol_api/core/engine/instrument.py | 6 ++++++ .../opentrons/protocol_engine/commands/drop_tip.py | 8 +++++++- .../opentrons/protocol_engine/state/geometry.py | 8 ++++++++ .../opentrons/protocol_engine/state/pipettes.py | 4 ++++ .../protocol_engine/commands/test_drop_tip.py | 14 +++++++++++++- .../resources/test_pipette_data_provider.py | 1 + .../protocol_engine/state/test_geometry_view.py | 11 +++++++++-- 7 files changed, 48 insertions(+), 4 deletions(-) diff --git a/api/src/opentrons/protocol_api/core/engine/instrument.py b/api/src/opentrons/protocol_api/core/engine/instrument.py index 0c762358c14..f05b3c9eeda 100644 --- a/api/src/opentrons/protocol_api/core/engine/instrument.py +++ b/api/src/opentrons/protocol_api/core/engine/instrument.py @@ -31,6 +31,7 @@ from opentrons_shared_data.pipette.dev_types import PipetteNameType from opentrons.protocol_api._nozzle_layout import NozzleLayout +from opentrons.hardware_control.nozzle_manager import NozzleConfigurationType from ..instrument import AbstractInstrument from .well import WellCore @@ -574,6 +575,11 @@ def get_dispense_flow_rate(self, rate: float = 1.0) -> float: def get_blow_out_flow_rate(self, rate: float = 1.0) -> float: return self._blow_out_flow_rate * rate + def get_nozzle_configuration(self) -> NozzleConfigurationType: + return self._engine_client.state.pipettes.get_nozzle_layout_type( + self._pipette_id + ) + def set_flow_rate( self, aspirate: Optional[float] = None, diff --git a/api/src/opentrons/protocol_engine/commands/drop_tip.py b/api/src/opentrons/protocol_engine/commands/drop_tip.py index 90b0c04484b..923c384e630 100644 --- a/api/src/opentrons/protocol_engine/commands/drop_tip.py +++ b/api/src/opentrons/protocol_engine/commands/drop_tip.py @@ -82,8 +82,14 @@ async def execute(self, params: DropTipParams) -> DropTipResult: else: well_location = params.wellLocation + is_partially_configured = self._state_view.pipettes.get_is_partially_configured( + pipette_id=pipette_id + ) tip_drop_location = self._state_view.geometry.get_checked_tip_drop_location( - pipette_id=pipette_id, labware_id=labware_id, well_location=well_location + pipette_id=pipette_id, + labware_id=labware_id, + well_location=well_location, + partially_configured=is_partially_configured, ) position = await self._movement_handler.move_to_well( diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 4051d2383c9..4b5ba2adc07 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -416,12 +416,20 @@ def get_checked_tip_drop_location( pipette_id: str, labware_id: str, well_location: DropTipWellLocation, + partially_configured: bool = False, ) -> WellLocation: """Get tip drop location given labware and hardware pipette. This makes sure that the well location has an appropriate origin & offset if one is not already set previously. """ + if ( + self._labware.get_definition(labware_id).parameters.isTiprack + and partially_configured + ): + raise errors.UnexpectedProtocolError( + "Cannot return tip to a tiprack while the pipette is configured for partial tip." + ) if well_location.origin != DropTipWellOrigin.DEFAULT: return WellLocation( origin=WellOrigin(well_location.origin.value), diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index 4d1f7278971..6529433e0e8 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -623,3 +623,7 @@ def get_nozzle_layout_type(self, pipette_id: str) -> NozzleConfigurationType: return nozzle_map_for_pipette.configuration else: return NozzleConfigurationType.FULL + + def get_is_partially_configured(self, pipette_id: str) -> bool: + """Determine if the provided pipette is partially configured.""" + return self.get_nozzle_layout_type(pipette_id) != NozzleConfigurationType.FULL diff --git a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py index 7551c67ea25..4a3c547c07a 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py +++ b/api/tests/opentrons/protocol_engine/commands/test_drop_tip.py @@ -86,11 +86,16 @@ async def test_drop_tip_implementation( homeAfter=True, ) + decoy.when( + mock_state_view.pipettes.get_is_partially_configured(pipette_id="abc") + ).then_return(False) + decoy.when( mock_state_view.geometry.get_checked_tip_drop_location( pipette_id="abc", labware_id="123", well_location=DropTipWellLocation(offset=WellOffset(x=1, y=2, z=3)), + partially_configured=False, ) ).then_return(WellLocation(offset=WellOffset(x=4, y=5, z=6))) @@ -142,9 +147,16 @@ async def test_drop_tip_with_alternating_locations( ) ).then_return(drop_location) + decoy.when( + mock_state_view.pipettes.get_is_partially_configured(pipette_id="abc") + ).then_return(False) + decoy.when( mock_state_view.geometry.get_checked_tip_drop_location( - pipette_id="abc", labware_id="123", well_location=drop_location + pipette_id="abc", + labware_id="123", + well_location=drop_location, + partially_configured=False, ) ).then_return(WellLocation(offset=WellOffset(x=4, y=5, z=6))) diff --git a/api/tests/opentrons/protocol_engine/resources/test_pipette_data_provider.py b/api/tests/opentrons/protocol_engine/resources/test_pipette_data_provider.py index 7478e34f131..444ced17857 100644 --- a/api/tests/opentrons/protocol_engine/resources/test_pipette_data_provider.py +++ b/api/tests/opentrons/protocol_engine/resources/test_pipette_data_provider.py @@ -149,6 +149,7 @@ def test_load_virtual_pipette_nozzle_layout( ) result = subject_instance.get_nozzle_layout_for_pipette("my-96-pipette") assert result.configuration.value == "ROW" + subject_instance.configure_virtual_pipette_nozzle_layout( "my-96-pipette", "p1000_96_v3.5", "A1", "A1" ) diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 86e74a88985..696d02ca5d9 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -1116,15 +1116,22 @@ def test_get_tip_drop_location_with_non_tiprack( ) -def test_get_tip_drop_explicit_location(subject: GeometryView) -> None: +def test_get_tip_drop_explicit_location( + decoy: Decoy, + labware_view: LabwareView, + subject: GeometryView, + tip_rack_def: LabwareDefinition, +) -> None: """It should pass the location through if origin is not WellOrigin.DROP_TIP.""" + decoy.when(labware_view.get_definition("tip-rack-id")).then_return(tip_rack_def) + input_location = DropTipWellLocation( origin=DropTipWellOrigin.TOP, offset=WellOffset(x=1, y=2, z=3), ) result = subject.get_checked_tip_drop_location( - pipette_id="pipette-id", labware_id="labware-id", well_location=input_location + pipette_id="pipette-id", labware_id="tip-rack-id", well_location=input_location ) assert result == WellLocation(