diff --git a/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py b/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py index a70d0cf7f39..23b11598573 100644 --- a/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/aspirate_in_place.py @@ -1,9 +1,11 @@ """Aspirate in place command request, result, and implementation models.""" from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Type +from typing import TYPE_CHECKING, Optional, Type, Union from typing_extensions import Literal +from opentrons_shared_data.errors.exceptions import PipetteOverpressureError + from opentrons.hardware_control import HardwareControlAPI from .pipetting_common import ( @@ -11,13 +13,23 @@ AspirateVolumeMixin, FlowRateMixin, BaseLiquidHandlingResult, + OverpressureError, + OverpressureErrorInternalData, +) +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + SuccessData, + DefinedErrorData, ) -from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence from ..errors.exceptions import PipetteNotReadyToAspirateError +from ..types import DeckPoint if TYPE_CHECKING: - from ..execution import PipettingHandler + from ..execution import PipettingHandler, GantryMover + from ..resources import ModelUtils from ..state import StateView from ..notes import CommandNoteAdder @@ -36,8 +48,14 @@ class AspirateInPlaceResult(BaseLiquidHandlingResult): pass +_ExecuteReturn = Union[ + SuccessData[AspirateInPlaceResult, None], + DefinedErrorData[OverpressureError, OverpressureErrorInternalData], +] + + class AspirateInPlaceImplementation( - AbstractCommandImpl[AspirateInPlaceParams, SuccessData[AspirateInPlaceResult, None]] + AbstractCommandImpl[AspirateInPlaceParams, _ExecuteReturn] ): """AspirateInPlace command implementation.""" @@ -47,16 +65,18 @@ def __init__( hardware_api: HardwareControlAPI, state_view: StateView, command_note_adder: CommandNoteAdder, + model_utils: ModelUtils, + gantry_mover: GantryMover, **kwargs: object, ) -> None: self._pipetting = pipetting self._state_view = state_view self._hardware_api = hardware_api self._command_note_adder = command_note_adder + self._model_utils = model_utils + self._gantry_mover = gantry_mover - async def execute( - self, params: AspirateInPlaceParams - ) -> SuccessData[AspirateInPlaceResult, None]: + async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn: """Aspirate without moving the pipette. Raises: @@ -73,14 +93,48 @@ async def execute( " The first aspirate following a blow-out must be from a specific well" " so the plunger can be reset in a known safe position." ) - volume = await self._pipetting.aspirate_in_place( - pipette_id=params.pipetteId, - volume=params.volume, - flow_rate=params.flowRate, - command_note_adder=self._command_note_adder, - ) - - return SuccessData(public=AspirateInPlaceResult(volume=volume), private=None) + try: + volume = await self._pipetting.aspirate_in_place( + pipette_id=params.pipetteId, + volume=params.volume, + flow_rate=params.flowRate, + command_note_adder=self._command_note_adder, + ) + except PipetteOverpressureError as e: + current_position = await self._gantry_mover.get_position(params.pipetteId) + return DefinedErrorData( + public=OverpressureError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=e, + ) + ], + errorInfo=( + { + "retryLocation": ( + current_position.x, + current_position.y, + current_position.z, + ) + } + ), + ), + private=OverpressureErrorInternalData( + position=DeckPoint( + x=current_position.x, + y=current_position.y, + z=current_position.z, + ), + ), + ) + else: + return SuccessData( + public=AspirateInPlaceResult(volume=volume), private=None + ) class AspirateInPlace( diff --git a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py index 160345de469..d71f191d1df 100644 --- a/api/src/opentrons/protocol_engine/commands/dispense_in_place.py +++ b/api/src/opentrons/protocol_engine/commands/dispense_in_place.py @@ -1,21 +1,32 @@ """Dispense-in-place command request, result, and implementation models.""" from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Type +from typing import TYPE_CHECKING, Optional, Type, Union from typing_extensions import Literal - from pydantic import Field +from opentrons_shared_data.errors.exceptions import PipetteOverpressureError + from .pipetting_common import ( PipetteIdMixin, DispenseVolumeMixin, FlowRateMixin, BaseLiquidHandlingResult, + OverpressureError, + OverpressureErrorInternalData, +) +from .command import ( + AbstractCommandImpl, + BaseCommand, + BaseCommandCreate, + SuccessData, + DefinedErrorData, ) -from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData from ..errors.error_occurrence import ErrorOccurrence +from ..types import DeckPoint if TYPE_CHECKING: - from ..execution import PipettingHandler + from ..execution import PipettingHandler, GantryMover + from ..resources import ModelUtils DispenseInPlaceCommandType = Literal["dispenseInPlace"] @@ -36,25 +47,72 @@ class DispenseInPlaceResult(BaseLiquidHandlingResult): pass +_ExecuteReturn = Union[ + SuccessData[DispenseInPlaceResult, None], + DefinedErrorData[OverpressureError, OverpressureErrorInternalData], +] + + class DispenseInPlaceImplementation( - AbstractCommandImpl[DispenseInPlaceParams, SuccessData[DispenseInPlaceResult, None]] + AbstractCommandImpl[DispenseInPlaceParams, _ExecuteReturn] ): """DispenseInPlace command implementation.""" - def __init__(self, pipetting: PipettingHandler, **kwargs: object) -> None: + def __init__( + self, + pipetting: PipettingHandler, + gantry_mover: GantryMover, + model_utils: ModelUtils, + **kwargs: object, + ) -> None: self._pipetting = pipetting + self._gantry_mover = gantry_mover + self._model_utils = model_utils - async def execute( - self, params: DispenseInPlaceParams - ) -> SuccessData[DispenseInPlaceResult, None]: + async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn: """Dispense without moving the pipette.""" - volume = await self._pipetting.dispense_in_place( - pipette_id=params.pipetteId, - volume=params.volume, - flow_rate=params.flowRate, - push_out=params.pushOut, - ) - return SuccessData(public=DispenseInPlaceResult(volume=volume), private=None) + try: + volume = await self._pipetting.dispense_in_place( + pipette_id=params.pipetteId, + volume=params.volume, + flow_rate=params.flowRate, + push_out=params.pushOut, + ) + except PipetteOverpressureError as e: + current_position = await self._gantry_mover.get_position(params.pipetteId) + return DefinedErrorData( + public=OverpressureError( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + wrappedErrors=[ + ErrorOccurrence.from_failed( + id=self._model_utils.generate_id(), + createdAt=self._model_utils.get_timestamp(), + error=e, + ) + ], + errorInfo=( + { + "retryLocation": ( + current_position.x, + current_position.y, + current_position.z, + ) + } + ), + ), + private=OverpressureErrorInternalData( + position=DeckPoint( + x=current_position.x, + y=current_position.y, + z=current_position.z, + ), + ), + ) + else: + return SuccessData( + public=DispenseInPlaceResult(volume=volume), private=None + ) class DispenseInPlace( diff --git a/api/src/opentrons/protocol_engine/state/pipettes.py b/api/src/opentrons/protocol_engine/state/pipettes.py index 35cfca94f33..e1dd00bbfb2 100644 --- a/api/src/opentrons/protocol_engine/state/pipettes.py +++ b/api/src/opentrons/protocol_engine/state/pipettes.py @@ -14,6 +14,8 @@ from opentrons.protocol_engine.actions.actions import FailCommandAction from opentrons.protocol_engine.commands.aspirate import Aspirate from opentrons.protocol_engine.commands.dispense import Dispense +from opentrons.protocol_engine.commands.aspirate_in_place import AspirateInPlace +from opentrons.protocol_engine.commands.dispense_in_place import DispenseInPlace from opentrons.protocol_engine.commands.command import DefinedErrorData from opentrons.protocol_engine.commands.pipetting_common import ( OverpressureError, @@ -413,7 +415,10 @@ def _update_deck_point( ) elif ( isinstance(action, FailCommandAction) - and isinstance(action.running_command, (Aspirate, Dispense)) + and isinstance( + action.running_command, + (Aspirate, Dispense, AspirateInPlace, DispenseInPlace), + ) and isinstance(action.error, DefinedErrorData) and isinstance(action.error.public, OverpressureError) ): diff --git a/api/tests/opentrons/protocol_engine/commands/conftest.py b/api/tests/opentrons/protocol_engine/commands/conftest.py index 99046f7c84a..8749023c96f 100644 --- a/api/tests/opentrons/protocol_engine/commands/conftest.py +++ b/api/tests/opentrons/protocol_engine/commands/conftest.py @@ -13,6 +13,7 @@ LabwareMovementHandler, StatusBarHandler, TipHandler, + GantryMover, ) from opentrons.protocol_engine.resources.model_utils import ModelUtils from opentrons.protocol_engine.state import StateView @@ -76,3 +77,9 @@ def model_utils(decoy: Decoy) -> ModelUtils: def status_bar(decoy: Decoy) -> StatusBarHandler: """Get a mocked out StatusBarHandler.""" return decoy.mock(cls=StatusBarHandler) + + +@pytest.fixture +def gantry_mover(decoy: Decoy) -> GantryMover: + """Get a mocked out GantryMover.""" + return decoy.mock(cls=GantryMover) diff --git a/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py index c6197f2d26f..26f62231a56 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_aspirate_in_place.py @@ -1,22 +1,32 @@ """Test aspirate-in-place commands.""" +from datetime import datetime + import pytest -from decoy import Decoy +from decoy import Decoy, matchers + +from opentrons_shared_data.errors.exceptions import PipetteOverpressureError +from opentrons.types import Point from opentrons.hardware_control import API as HardwareAPI -from opentrons.protocol_engine.execution import PipettingHandler +from opentrons.protocol_engine.execution import PipettingHandler, GantryMover from opentrons.protocol_engine.commands.aspirate_in_place import ( AspirateInPlaceParams, AspirateInPlaceResult, AspirateInPlaceImplementation, ) -from opentrons.protocol_engine.commands.command import SuccessData +from opentrons.protocol_engine.commands.command import SuccessData, DefinedErrorData from opentrons.protocol_engine.errors.exceptions import PipetteNotReadyToAspirateError from opentrons.protocol_engine.notes import CommandNoteAdder - +from opentrons.protocol_engine.resources import ModelUtils from opentrons.protocol_engine.state import ( StateStore, ) +from opentrons.protocol_engine.types import DeckPoint +from opentrons.protocol_engine.commands.pipetting_common import ( + OverpressureError, + OverpressureErrorInternalData, +) @pytest.fixture @@ -43,6 +53,8 @@ def subject( state_store: StateStore, hardware_api: HardwareAPI, mock_command_note_adder: CommandNoteAdder, + model_utils: ModelUtils, + gantry_mover: GantryMover, ) -> AspirateInPlaceImplementation: """Get the impelementation subject.""" return AspirateInPlaceImplementation( @@ -50,6 +62,8 @@ def subject( hardware_api=hardware_api, state_view=state_store, command_note_adder=mock_command_note_adder, + model_utils=model_utils, + gantry_mover=gantry_mover, ) @@ -143,3 +157,57 @@ async def test_aspirate_raises_volume_error( with pytest.raises(AssertionError): await subject.execute(data) + + +async def test_overpressure_error( + decoy: Decoy, + gantry_mover: GantryMover, + pipetting: PipettingHandler, + subject: AspirateInPlaceImplementation, + model_utils: ModelUtils, + mock_command_note_adder: CommandNoteAdder, +) -> None: + """It should return an overpressure error if the hardware API indicates that.""" + pipette_id = "pipette-id" + + position = Point(x=1, y=2, z=3) + + error_id = "error-id" + error_timestamp = datetime(year=2020, month=1, day=2) + + data = AspirateInPlaceParams( + pipetteId=pipette_id, + volume=50, + flowRate=1.23, + ) + + decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id=pipette_id)).then_return( + True + ) + + decoy.when( + await pipetting.aspirate_in_place( + pipette_id=pipette_id, + volume=50, + flow_rate=1.23, + command_note_adder=mock_command_note_adder, + ), + ).then_raise(PipetteOverpressureError()) + + decoy.when(model_utils.generate_id()).then_return(error_id) + decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) + decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + + result = await subject.execute(data) + + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + private=OverpressureErrorInternalData( + position=DeckPoint(x=position.x, y=position.y, z=position.z) + ), + ) diff --git a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py index e1bb654613c..3b37e1078b7 100644 --- a/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py +++ b/api/tests/opentrons/protocol_engine/commands/test_dispense_in_place.py @@ -1,22 +1,37 @@ """Test dispense-in-place commands.""" -from decoy import Decoy +from datetime import datetime -from opentrons.protocol_engine.execution import PipettingHandler +from decoy import Decoy, matchers -from opentrons.protocol_engine.commands.command import SuccessData +from opentrons_shared_data.errors.exceptions import PipetteOverpressureError + +from opentrons.types import Point +from opentrons.protocol_engine.execution import PipettingHandler, GantryMover + +from opentrons.protocol_engine.commands.command import SuccessData, DefinedErrorData from opentrons.protocol_engine.commands.dispense_in_place import ( DispenseInPlaceParams, DispenseInPlaceResult, DispenseInPlaceImplementation, ) +from opentrons.protocol_engine.types import DeckPoint +from opentrons.protocol_engine.commands.pipetting_common import ( + OverpressureError, + OverpressureErrorInternalData, +) +from opentrons.protocol_engine.resources import ModelUtils async def test_dispense_in_place_implementation( decoy: Decoy, pipetting: PipettingHandler, + gantry_mover: GantryMover, + model_utils: ModelUtils, ) -> None: """It should dispense in place.""" - subject = DispenseInPlaceImplementation(pipetting=pipetting) + subject = DispenseInPlaceImplementation( + pipetting=pipetting, gantry_mover=gantry_mover, model_utils=model_utils + ) data = DispenseInPlaceParams( pipetteId="pipette-id-abc", @@ -33,3 +48,56 @@ async def test_dispense_in_place_implementation( result = await subject.execute(data) assert result == SuccessData(public=DispenseInPlaceResult(volume=42), private=None) + + +async def test_overpressure_error( + decoy: Decoy, + gantry_mover: GantryMover, + pipetting: PipettingHandler, + model_utils: ModelUtils, +) -> None: + """It should return an overpressure error if the hardware API indicates that.""" + subject = DispenseInPlaceImplementation( + pipetting=pipetting, gantry_mover=gantry_mover, model_utils=model_utils + ) + + pipette_id = "pipette-id" + + position = Point(x=1, y=2, z=3) + + error_id = "error-id" + error_timestamp = datetime(year=2020, month=1, day=2) + + data = DispenseInPlaceParams( + pipetteId=pipette_id, + volume=50, + flowRate=1.23, + pushOut=10, + ) + + decoy.when( + await pipetting.dispense_in_place( + pipette_id=pipette_id, + volume=50, + flow_rate=1.23, + push_out=10, + ), + ).then_raise(PipetteOverpressureError()) + + decoy.when(model_utils.generate_id()).then_return(error_id) + decoy.when(model_utils.get_timestamp()).then_return(error_timestamp) + decoy.when(await gantry_mover.get_position(pipette_id)).then_return(position) + + result = await subject.execute(data) + + assert result == DefinedErrorData( + public=OverpressureError.construct( + id=error_id, + createdAt=error_timestamp, + wrappedErrors=[matchers.Anything()], + errorInfo={"retryLocation": (position.x, position.y, position.z)}, + ), + private=OverpressureErrorInternalData( + position=DeckPoint(x=position.x, y=position.y, z=position.z) + ), + ) diff --git a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py index c132ea56c73..863a12a20bc 100644 --- a/api/tests/opentrons/protocol_engine/state/test_pipette_store.py +++ b/api/tests/opentrons/protocol_engine/state/test_pipette_store.py @@ -970,6 +970,64 @@ def test_add_pipette_config( notes=[], type=ErrorRecoveryType.WAIT_FOR_RECOVERY, ), + FailCommandAction( + running_command=cmd.AspirateInPlace( + params=cmd.AspirateInPlaceParams( + pipetteId="pipette-id", + volume=125, + flowRate=1.23, + ), + id="command-id", + key="command-key", + createdAt=datetime.now(), + status=cmd.CommandStatus.RUNNING, + ), + error=DefinedErrorData( + public=OverpressureError( + id="error-id", + detail="error-detail", + createdAt=datetime.now(), + errorInfo={"retryLocation": (11, 22, 33)}, + ), + private=OverpressureErrorInternalData( + position=DeckPoint(x=11, y=22, z=33) + ), + ), + command_id="command-id", + error_id="error-id", + failed_at=datetime.now(), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + ), + FailCommandAction( + running_command=cmd.DispenseInPlace( + params=cmd.DispenseInPlaceParams( + pipetteId="pipette-id", + volume=125, + flowRate=1.23, + ), + id="command-id", + key="command-key", + createdAt=datetime.now(), + status=cmd.CommandStatus.RUNNING, + ), + error=DefinedErrorData( + public=OverpressureError( + id="error-id", + detail="error-detail", + createdAt=datetime.now(), + errorInfo={"retryLocation": (11, 22, 33)}, + ), + private=OverpressureErrorInternalData( + position=DeckPoint(x=11, y=22, z=33) + ), + ), + command_id="command-id", + error_id="error-id", + failed_at=datetime.now(), + notes=[], + type=ErrorRecoveryType.WAIT_FOR_RECOVERY, + ), ), ) def test_movement_commands_update_deck_point(