diff --git a/api/src/opentrons/hardware_control/api.py b/api/src/opentrons/hardware_control/api.py index 512d729f0fa..0cb7a96be54 100644 --- a/api/src/opentrons/hardware_control/api.py +++ b/api/src/opentrons/hardware_control/api.py @@ -65,7 +65,7 @@ RobotCalibration, ) from .protocols import HardwareControlInterface -from .instruments.ot2.pipette_handler import PipetteHandlerProvider, PickUpTipSpec +from .instruments.ot2.pipette_handler import PipetteHandlerProvider from .instruments.ot2.instrument_calibration import load_pipette_offset from .motion_utilities import ( target_position_from_absolute, @@ -1155,8 +1155,21 @@ async def update_nozzle_configuration_for_mount( async def tip_pickup_moves( self, mount: top_types.Mount, - spec: PickUpTipSpec, + presses: Optional[int] = None, + increment: Optional[float] = None, ) -> None: + spec, _ = self.plan_check_pick_up_tip( + mount=mount, presses=presses, increment=increment + ) + self._backend.set_active_current(spec.plunger_currents) + target_absolute = target_position_from_plunger( + mount, spec.plunger_prep_pos, self._current_position + ) + await self._move( + target_absolute, + home_flagged_axes=False, + ) + for press in spec.presses: with self._backend.save_current(): self._backend.set_active_current(press.current) @@ -1176,6 +1189,11 @@ async def tip_pickup_moves( await self.retract(mount, spec.retract_target) + def cache_tip(self, mount: top_types.Mount, tip_length: float) -> None: + instrument = self.get_pipette(mount) + instrument.add_tip(tip_length=tip_length) + instrument.set_current_volume(0) + async def pick_up_tip( self, mount: top_types.Mount, @@ -1189,7 +1207,7 @@ async def pick_up_tip( """ spec, _add_tip_to_instrs = self.plan_check_pick_up_tip( - mount, tip_length, presses, increment + mount=mount, presses=presses, increment=increment, tip_length=tip_length ) self._backend.set_active_current(spec.plunger_currents) target_absolute = target_position_from_plunger( @@ -1200,7 +1218,24 @@ async def pick_up_tip( home_flagged_axes=False, ) - await self.tip_pickup_moves(mount, spec) + for press in spec.presses: + with self._backend.save_current(): + self._backend.set_active_current(press.current) + target_down = target_position_from_relative( + mount, press.relative_down, self._current_position + ) + await self._move(target_down, speed=press.speed) + target_up = target_position_from_relative( + mount, press.relative_up, self._current_position + ) + await self._move(target_up) + # neighboring tips tend to get stuck in the space between + # the volume chamber and the drop-tip sleeve on p1000. + # This extra shake ensures those tips are removed + for rel_point, speed in spec.shake_off_list: + await self.move_rel(mount, rel_point, speed=speed) + + await self.retract(mount, spec.retract_target) _add_tip_to_instrs() if prep_after: diff --git a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py index 2f098bc9df0..5a61cf6abee 100644 --- a/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py +++ b/api/src/opentrons/hardware_control/instruments/ot2/pipette_handler.py @@ -744,9 +744,9 @@ def build_one_shake() -> List[Tuple[top_types.Point, Optional[float]]]: def plan_check_pick_up_tip( self, mount: top_types.Mount, - tip_length: float, presses: Optional[int], increment: Optional[float], + tip_length: float = 0, ) -> Tuple[PickUpTipSpec, Callable[[], None]]: ... @@ -754,18 +754,18 @@ def plan_check_pick_up_tip( def plan_check_pick_up_tip( self, mount: OT3Mount, - tip_length: float, presses: Optional[int], increment: Optional[float], + tip_length: float = 0, ) -> Tuple[PickUpTipSpec, Callable[[], None]]: ... def plan_check_pick_up_tip( # type: ignore[no-untyped-def] self, mount, - tip_length, presses, increment, + tip_length=0, ): # Prechecks: ready for pickup tip and press/increment are valid instrument = self.get_pipette(mount) diff --git a/api/src/opentrons/hardware_control/ot3api.py b/api/src/opentrons/hardware_control/ot3api.py index 7fb76e8f7d7..2a4ed1b8ae6 100644 --- a/api/src/opentrons/hardware_control/ot3api.py +++ b/api/src/opentrons/hardware_control/ot3api.py @@ -1799,7 +1799,7 @@ async def hold_jaw_width(self, jaw_width_mm: int) -> None: async def tip_pickup_moves( self, - mount: OT3Mount, + mount: Union[top_types.Mount, OT3Mount], presses: Optional[int] = None, increment: Optional[float] = None, ) -> None: @@ -2191,6 +2191,15 @@ async def _tip_motor_action( ) await self.home_gear_motors() + def cache_tip( + self, mount: Union[top_types.Mount, OT3Mount], tip_length: float + ) -> None: + realmount = OT3Mount.from_mount(mount) + instrument = self._pipette_handler.get_pipette(realmount) + + instrument.add_tip(tip_length=tip_length) + instrument.set_current_volume(0) + async def pick_up_tip( self, mount: Union[top_types.Mount, OT3Mount], @@ -2209,7 +2218,7 @@ def add_tip_to_instr() -> None: await self._move_to_plunger_bottom(realmount, rate=1.0) - await self.tip_pickup_moves(realmount, presses, increment) + await self.tip_pickup_moves(mount, presses, increment) add_tip_to_instr() diff --git a/api/src/opentrons/hardware_control/protocols/__init__.py b/api/src/opentrons/hardware_control/protocols/__init__.py index e47b54dba2c..41de2b54506 100644 --- a/api/src/opentrons/hardware_control/protocols/__init__.py +++ b/api/src/opentrons/hardware_control/protocols/__init__.py @@ -59,6 +59,9 @@ class HardwareControlInterface( def get_robot_type(self) -> Type[OT2RobotType]: return OT2RobotType + def cache_tip(self, mount: MountArgType, tip_length: float) -> None: + ... + class FlexHardwareControlInterface( ModuleProvider, @@ -90,6 +93,9 @@ def motor_status_ok(self, axis: Axis) -> bool: def encoder_status_ok(self, axis: Axis) -> bool: ... + def cache_tip(self, mount: MountArgType, tip_length: float) -> None: + ... + __all__ = [ "HardwareControlAPI", diff --git a/api/src/opentrons/hardware_control/protocols/liquid_handler.py b/api/src/opentrons/hardware_control/protocols/liquid_handler.py index 1aae0ec77ed..8baa786dc9f 100644 --- a/api/src/opentrons/hardware_control/protocols/liquid_handler.py +++ b/api/src/opentrons/hardware_control/protocols/liquid_handler.py @@ -130,6 +130,14 @@ async def blow_out( """ ... + async def tip_pickup_moves( + self, + mount: MountArgType, + presses: Optional[int] = None, + increment: Optional[float] = None, + ) -> None: + ... + async def pick_up_tip( self, mount: MountArgType, diff --git a/api/src/opentrons/protocol_engine/errors/exceptions.py b/api/src/opentrons/protocol_engine/errors/exceptions.py index 7f022652d71..49a711d312a 100644 --- a/api/src/opentrons/protocol_engine/errors/exceptions.py +++ b/api/src/opentrons/protocol_engine/errors/exceptions.py @@ -128,7 +128,7 @@ def __init__( details: Optional[Dict[str, Any]] = None, wrapping: Optional[Sequence[EnumeratedError]] = None, ) -> None: - """Build a PIpetteNotAttachedError.""" + """Build a PipetteNotAttachedError.""" super().__init__(ErrorCodes.UNEXPECTED_TIP_REMOVAL, message, details, wrapping) diff --git a/api/src/opentrons/protocol_engine/execution/tip_handler.py b/api/src/opentrons/protocol_engine/execution/tip_handler.py index e43685d2ebb..6638d216095 100644 --- a/api/src/opentrons/protocol_engine/execution/tip_handler.py +++ b/api/src/opentrons/protocol_engine/execution/tip_handler.py @@ -172,14 +172,14 @@ async def pick_up_tip( nominal_fallback=nominal_tip_geometry.length, ) - await self._hardware_api.pick_up_tip( - mount=hw_mount, - tip_length=actual_tip_length, - presses=None, - increment=None, + await self._hardware_api.tip_pickup_moves( + mount=hw_mount, presses=None, increment=None ) await self.verify_tip_presence(pipette_id, TipPresenceStatus.PRESENT) + self._hardware_api.cache_tip(hw_mount, actual_tip_length) + await self._hardware_api.prepare_for_aspirate(hw_mount) + self._hardware_api.set_current_tiprack_diameter( mount=hw_mount, tiprack_diameter=nominal_tip_geometry.diameter, diff --git a/api/tests/opentrons/hardware_control/test_instruments.py b/api/tests/opentrons/hardware_control/test_instruments.py index 3cadca5fed6..3844c904bfc 100644 --- a/api/tests/opentrons/hardware_control/test_instruments.py +++ b/api/tests/opentrons/hardware_control/test_instruments.py @@ -556,7 +556,7 @@ async def test_tip_pickup_moves(sim_and_instr): spec, _ = hw_api.plan_check_pick_up_tip( mount=mount, tip_length=40.0, presses=None, increment=None ) - await hw_api.tip_pickup_moves(mount, spec) + await hw_api.tip_pickup_moves(mount=mount) else: await hw_api.tip_pickup_moves(mount) diff --git a/api/tests/opentrons/hardware_control/test_moves.py b/api/tests/opentrons/hardware_control/test_moves.py index e5a98173afa..fc87d790719 100644 --- a/api/tests/opentrons/hardware_control/test_moves.py +++ b/api/tests/opentrons/hardware_control/test_moves.py @@ -289,22 +289,22 @@ async def test_tip_pickup_routine(hardware_api, monkeypatch): await hardware_api.cache_instruments() mount = types.Mount.RIGHT - spec, _ = hardware_api.plan_check_pick_up_tip( - mount=mount, tip_length=40.0, presses=None, increment=None - ) - await hardware_api.tip_pickup_moves(mount, spec) + presses = 1 + await hardware_api.tip_pickup_moves(mount, presses=presses) - tip_motor_routine_num_moves = 2 * len(spec.presses) + # tip pickup moves has an initial move to above the tip, then 2 + # moves for each press + tip_motor_routine_num_moves = 2 * presses + 1 - # the tip motor routine should only make the immediate 'press' moves happen assert len(_move.call_args_list) == tip_motor_routine_num_moves _move.reset_mock() # pick_up_tip should have the press moves + a plunger move both before and after await hardware_api.pick_up_tip( - mount=mount, tip_length=40.0, presses=None, increment=None, prep_after=True + mount=mount, tip_length=40.0, presses=1, increment=None, prep_after=True ) - assert len(_move.call_args_list) == tip_motor_routine_num_moves + 2 + # pick_up_tip contains an additional retract + assert len(_move.call_args_list) == tip_motor_routine_num_moves + 1 async def test_new_critical_point_applied(hardware_api): diff --git a/api/tests/opentrons/hardware_control/test_pipette_handler.py b/api/tests/opentrons/hardware_control/test_pipette_handler.py index b6e776a0778..7d2a29e0170 100644 --- a/api/tests/opentrons/hardware_control/test_pipette_handler.py +++ b/api/tests/opentrons/hardware_control/test_pipette_handler.py @@ -137,7 +137,7 @@ def test_plan_check_pick_up_tip_with_presses_argument( ) spec, _add_tip_to_instrs = subject.plan_check_pick_up_tip( - mount, tip_length, presses, increment + mount=mount, tip_length=tip_length, presses=presses, increment=increment ) assert len(spec.presses) == expected_array_length diff --git a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py index e7e0284debe..2e5205bdc66 100644 --- a/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py +++ b/api/tests/opentrons/protocol_engine/execution/test_tip_handler.py @@ -1,6 +1,7 @@ """Pipetting execution handler.""" import pytest from decoy import Decoy +from mock import AsyncMock, patch from typing import Dict, ContextManager, Optional from contextlib import nullcontext as does_not_raise @@ -14,6 +15,7 @@ from opentrons.protocol_engine.state import StateView from opentrons.protocol_engine.types import TipGeometry, TipPresenceStatus from opentrons.protocol_engine.resources import LabwareDataProvider +from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError from opentrons_shared_data.errors.exceptions import ( CommandPreconditionViolated, CommandParameterLimitViolated, @@ -76,6 +78,73 @@ async def test_create_tip_handler( ) +@pytest.mark.ot3_only +@pytest.mark.parametrize("tip_state", [TipStateType.PRESENT, TipStateType.ABSENT]) +async def test_flex_pick_up_tip_state( + decoy: Decoy, + mock_state_view: StateView, + mock_labware_data_provider: LabwareDataProvider, + tip_rack_definition: LabwareDefinition, + tip_state: TipStateType, +) -> None: + """Test the protocol engine's pick_up_tip logic.""" + from opentrons.hardware_control.ot3api import OT3API + + ot3_hardware_api = decoy.mock(cls=OT3API) + decoy.when(ot3_hardware_api.get_robot_type()).then_return(FlexRobotType) + + subject = HardwareTipHandler( + state_view=mock_state_view, + hardware_api=ot3_hardware_api, + labware_data_provider=mock_labware_data_provider, + ) + decoy.when(subject._state_view.config.robot_type).then_return("OT-3 Standard") + decoy.when(mock_state_view.pipettes.get_mount("pipette-id")).then_return( + MountType.LEFT + ) + decoy.when( + mock_state_view.geometry.get_nominal_tip_geometry( + pipette_id="pipette-id", + labware_id="labware-id", + well_name="B2", + ) + ).then_return(TipGeometry(length=50, diameter=5, volume=300)) + + decoy.when( + await mock_labware_data_provider.get_calibrated_tip_length( + pipette_serial="pipette-serial", + labware_definition=tip_rack_definition, + nominal_fallback=50, + ) + ).then_return(42) + + with patch.object( + ot3_hardware_api, "cache_tip", AsyncMock(spec=ot3_hardware_api.cache_tip) + ) as mock_add_tip: + + if tip_state == TipStateType.PRESENT: + await subject.pick_up_tip( + pipette_id="pipette-id", + labware_id="labware-id", + well_name="B2", + ) + mock_add_tip.assert_called_once() + else: + decoy.when( + await subject.verify_tip_presence( + pipette_id="pipette-id", expected=TipPresenceStatus.PRESENT + ) + ).then_raise(TipNotAttachedError()) + # if a TipNotAttchedError is caught, we should not add any tip information + with pytest.raises(TipNotAttachedError): + await subject.pick_up_tip( + pipette_id="pipette-id", + labware_id="labware-id", + well_name="B2", + ) + mock_add_tip.assert_not_called() + + async def test_pick_up_tip( decoy: Decoy, mock_state_view: StateView, @@ -127,9 +196,8 @@ async def test_pick_up_tip( assert result == TipGeometry(length=42, diameter=5, volume=300) decoy.verify( - await mock_hardware_api.pick_up_tip( + await mock_hardware_api.tip_pickup_moves( mount=Mount.LEFT, - tip_length=42, presses=None, increment=None, ),