From ea31e34e0076341690e71a41167e98d32ea3d7ef Mon Sep 17 00:00:00 2001 From: aaron-kulkarni <107003644+aaron-kulkarni@users.noreply.github.com> Date: Tue, 23 Jul 2024 14:32:39 -0400 Subject: [PATCH] fix(api): throw error if no valid nozzle has tip (#15721) Provide a check in all LLD functions(called manually or automatically during aspirate) that makes sure that if it's a 96-channel pipette, that at least one of the nozzles with a pressure sensor has a tip on it. # Overview # Test Plan # Changelog # Review requests # Risk assessment --- .../protocol_api/instrument_context.py | 19 ++++++ .../protocol_api/test_instrument_context.py | 59 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/api/src/opentrons/protocol_api/instrument_context.py b/api/src/opentrons/protocol_api/instrument_context.py index 05a8ecdc80c..c39a4aba2ac 100644 --- a/api/src/opentrons/protocol_api/instrument_context.py +++ b/api/src/opentrons/protocol_api/instrument_context.py @@ -2,6 +2,7 @@ import logging from contextlib import ExitStack from typing import Any, List, Optional, Sequence, Union, cast, Dict +from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, @@ -264,6 +265,7 @@ def aspirate( self.api_version >= APIVersion(2, 20) and well is not None and self.liquid_presence_detection + and self._96_tip_config_valid() ): self.require_liquid_presence(well=well) self.prepare_to_aspirate() @@ -1874,6 +1876,19 @@ def _get_last_location_by_api_version(self) -> Optional[types.Location]: else: return self._protocol_core.get_last_location() + def _96_tip_config_valid(self) -> bool: + n_map = self._core.get_nozzle_map() + channels = self._core.get_active_channels() + if channels == 96: + if ( + n_map.back_left != n_map.full_instrument_back_left + and n_map.front_right != n_map.full_instrument_front_right + ): + raise TipNotAttachedError( + "Either the front right or the back left nozzle must have a tip attached to do LLD." + ) + return True + def __repr__(self) -> str: return "<{}: {} in {}>".format( self.__class__.__name__, @@ -2110,6 +2125,7 @@ def detect_liquid_presence(self, well: labware.Well) -> bool: :returns: A boolean. """ loc = well.top() + self._96_tip_config_valid() return self._core.detect_liquid_presence(well._core, loc) @requires_version(2, 20) @@ -2119,6 +2135,7 @@ def require_liquid_presence(self, well: labware.Well) -> None: :returns: None. """ loc = well.top() + self._96_tip_config_valid() self._core.liquid_probe_with_recovery(well._core, loc) @requires_version(2, 20) @@ -2131,6 +2148,8 @@ def measure_liquid_height(self, well: labware.Well) -> float: This is intended for Opentrons internal use only and is not a guaranteed API. """ + loc = well.top() + self._96_tip_config_valid() height = self._core.liquid_probe_without_recovery(well._core, loc) return height diff --git a/api/tests/opentrons/protocol_api/test_instrument_context.py b/api/tests/opentrons/protocol_api/test_instrument_context.py index d98b99a9a6d..0e85082c3e2 100644 --- a/api/tests/opentrons/protocol_api/test_instrument_context.py +++ b/api/tests/opentrons/protocol_api/test_instrument_context.py @@ -17,6 +17,12 @@ from opentrons.legacy_broker import LegacyBroker +from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError +from tests.opentrons.protocol_engine.pipette_fixtures import ( + NINETY_SIX_COLS, + NINETY_SIX_MAP, + NINETY_SIX_ROWS, +) from opentrons.protocols.api_support import instrument as mock_instrument_support from opentrons.protocols.api_support.types import APIVersion from opentrons.protocols.api_support.util import ( @@ -1351,3 +1357,56 @@ def test_measure_liquid_height( with pytest.raises(ProtocolCommandFailedError) as pcfe: subject.measure_liquid_height(mock_well) assert pcfe.value is errorToRaise + + +def test_96_tip_config_valid( + decoy: Decoy, mock_instrument_core: InstrumentCore, subject: InstrumentContext +) -> None: + """It should error when there's no tips on the correct corner nozzles.""" + nozzle_map = NozzleMap.build( + physical_nozzles=NINETY_SIX_MAP, + physical_rows=NINETY_SIX_ROWS, + physical_columns=NINETY_SIX_COLS, + starting_nozzle="A5", + back_left_nozzle="A5", + front_right_nozzle="H5", + valid_nozzle_maps=ValidNozzleMaps(maps={"Column12": NINETY_SIX_COLS["5"]}), + ) + decoy.when(mock_instrument_core.get_nozzle_map()).then_return(nozzle_map) + decoy.when(mock_instrument_core.get_active_channels()).then_return(96) + with pytest.raises(TipNotAttachedError): + subject._96_tip_config_valid() + + +def test_96_tip_config_invalid( + decoy: Decoy, mock_instrument_core: InstrumentCore, subject: InstrumentContext +) -> None: + """It should return True when there are tips on the correct corner nozzles.""" + nozzle_map = NozzleMap.build( + physical_nozzles=NINETY_SIX_MAP, + physical_rows=NINETY_SIX_ROWS, + physical_columns=NINETY_SIX_COLS, + starting_nozzle="A1", + back_left_nozzle="A1", + front_right_nozzle="H12", + valid_nozzle_maps=ValidNozzleMaps( + maps={ + "Full": sum( + [ + NINETY_SIX_ROWS["A"], + NINETY_SIX_ROWS["B"], + NINETY_SIX_ROWS["C"], + NINETY_SIX_ROWS["D"], + NINETY_SIX_ROWS["E"], + NINETY_SIX_ROWS["F"], + NINETY_SIX_ROWS["G"], + NINETY_SIX_ROWS["H"], + ], + [], + ) + } + ), + ) + decoy.when(mock_instrument_core.get_nozzle_map()).then_return(nozzle_map) + decoy.when(mock_instrument_core.get_active_channels()).then_return(96) + assert subject._96_tip_config_valid() is True