Skip to content

Commit

Permalink
Merge branch 'edge' of https://github.com/Opentrons/opentrons into edge
Browse files Browse the repository at this point in the history
  • Loading branch information
rclarke0 committed Jul 24, 2024
2 parents e200e8c + 9344672 commit 022dcb0
Show file tree
Hide file tree
Showing 48 changed files with 968 additions and 162 deletions.
1 change: 1 addition & 0 deletions api/src/opentrons/protocol_engine/commands/aspirate.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
error=e,
)
],
errorInfo={"retryLocation": (position.x, position.y, position.z)},
),
private=OverpressureErrorInternalData(
position=DeckPoint.construct(
Expand Down
85 changes: 62 additions & 23 deletions api/src/opentrons/protocol_engine/commands/dispense.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Dispense 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 pydantic import Field

from ..types import DeckPoint
Expand All @@ -13,12 +15,21 @@
WellLocationMixin,
BaseLiquidHandlingResult,
DestinationPositionResult,
OverpressureError,
OverpressureErrorInternalData,
)
from .command import (
AbstractCommandImpl,
BaseCommand,
BaseCommandCreate,
DefinedErrorData,
SuccessData,
)
from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData
from ..errors.error_occurrence import ErrorOccurrence

if TYPE_CHECKING:
from ..execution import MovementHandler, PipettingHandler
from ..resources import ModelUtils


DispenseCommandType = Literal["dispense"]
Expand All @@ -41,41 +52,69 @@ class DispenseResult(BaseLiquidHandlingResult, DestinationPositionResult):
pass


class DispenseImplementation(
AbstractCommandImpl[DispenseParams, SuccessData[DispenseResult, None]]
):
_ExecuteReturn = Union[
SuccessData[DispenseResult, None],
DefinedErrorData[OverpressureError, OverpressureErrorInternalData],
]


class DispenseImplementation(AbstractCommandImpl[DispenseParams, _ExecuteReturn]):
"""Dispense command implementation."""

def __init__(
self, movement: MovementHandler, pipetting: PipettingHandler, **kwargs: object
self,
movement: MovementHandler,
pipetting: PipettingHandler,
model_utils: ModelUtils,
**kwargs: object,
) -> None:
self._movement = movement
self._pipetting = pipetting
self._model_utils = model_utils

async def execute(
self, params: DispenseParams
) -> SuccessData[DispenseResult, None]:
async def execute(self, params: DispenseParams) -> _ExecuteReturn:
"""Move to and dispense to the requested well."""
position = await self._movement.move_to_well(
pipette_id=params.pipetteId,
labware_id=params.labwareId,
well_name=params.wellName,
well_location=params.wellLocation,
)
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=DispenseResult(
volume=volume,
position=DeckPoint(x=position.x, y=position.y, z=position.z),
),
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:
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": (position.x, position.y, position.z)},
),
private=OverpressureErrorInternalData(
position=DeckPoint.construct(
x=position.x, y=position.y, z=position.z
)
),
)
else:
return SuccessData(
public=DispenseResult(
volume=volume,
position=DeckPoint(x=position.x, y=position.y, z=position.z),
),
private=None,
)


class Dispense(BaseCommand[DispenseParams, DispenseResult, ErrorOccurrence]):
Expand Down
10 changes: 9 additions & 1 deletion api/src/opentrons/protocol_engine/commands/pipetting_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from dataclasses import dataclass
from opentrons_shared_data.errors import ErrorCodes
from pydantic import BaseModel, Field
from typing import Literal, Optional
from typing import Literal, Optional, Tuple, TypedDict

from opentrons.protocol_engine.errors.error_occurrence import ErrorOccurrence

Expand Down Expand Up @@ -123,6 +123,12 @@ class DestinationPositionResult(BaseModel):
)


class ErrorLocationInfo(TypedDict):
"""Holds a retry location for in-place error recovery."""

retryLocation: Tuple[float, float, float]


class OverpressureError(ErrorOccurrence):
"""Returned when sensors detect an overpressure error while moving liquid.
Expand All @@ -138,6 +144,8 @@ class OverpressureError(ErrorOccurrence):
errorCode: str = ErrorCodes.PIPETTE_OVERPRESSURE.value.code
detail: str = ErrorCodes.PIPETTE_OVERPRESSURE.value.detail

errorInfo: ErrorLocationInfo


@dataclass(frozen=True)
class OverpressureErrorInternalData:
Expand Down
6 changes: 2 additions & 4 deletions api/src/opentrons/protocol_engine/error_recovery_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ class ErrorRecoveryType(enum.Enum):
WAIT_FOR_RECOVERY = enum.auto()
"""Stop and wait for the error to be recovered from manually."""

# TODO(mm, 2023-03-18): Add something like this for
# https://opentrons.atlassian.net/browse/EXEC-302.
# CONTINUE = enum.auto()
# """Continue with the run, as if the command never failed."""
IGNORE_AND_CONTINUE = enum.auto()
"""Continue with the run, as if the command never failed."""


class ErrorRecoveryPolicy(Protocol):
Expand Down
4 changes: 2 additions & 2 deletions api/src/opentrons/protocol_engine/errors/error_occurrence.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from datetime import datetime
from textwrap import dedent
from typing import Any, Dict, List, Type, Union, Optional, Sequence
from typing import Any, Dict, Mapping, List, Type, Union, Optional, Sequence
from pydantic import BaseModel, Field
from opentrons_shared_data.errors.codes import ErrorCodes
from .exceptions import ProtocolEngineError
Expand Down Expand Up @@ -118,7 +118,7 @@ def from_failed(
),
)

errorInfo: Dict[str, str] = Field(
errorInfo: Mapping[str, object] = Field(
default={},
description=dedent(
"""\
Expand Down
5 changes: 4 additions & 1 deletion api/src/opentrons/protocol_engine/state/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,10 @@ def _handle_fail_command_action(self, action: FailCommandAction) -> None:
other_command_ids_to_fail = list(
self._state.command_history.get_queue_ids()
)
elif action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY:
elif (
action.type == ErrorRecoveryType.WAIT_FOR_RECOVERY
or action.type == ErrorRecoveryType.IGNORE_AND_CONTINUE
):
other_command_ids_to_fail = []
else:
assert_never(action.type)
Expand Down
5 changes: 3 additions & 2 deletions api/src/opentrons/protocol_engine/state/pipettes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
)
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.command import DefinedErrorData
from opentrons.protocol_engine.commands.pipetting_common import (
OverpressureError,
Expand Down Expand Up @@ -316,7 +317,7 @@ def _update_current_location( # noqa: C901
)
elif (
isinstance(action, FailCommandAction)
and isinstance(action.running_command, Aspirate)
and isinstance(action.running_command, (Aspirate, Dispense))
and isinstance(action.error, DefinedErrorData)
and isinstance(action.error.public, OverpressureError)
):
Expand Down Expand Up @@ -412,7 +413,7 @@ def _update_deck_point(
)
elif (
isinstance(action, FailCommandAction)
and isinstance(action.running_command, Aspirate)
and isinstance(action.running_command, (Aspirate, Dispense))
and isinstance(action.error, DefinedErrorData)
and isinstance(action.error.public, OverpressureError)
):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,10 @@ async def test_overpressure_error(

assert result == DefinedErrorData(
public=OverpressureError.construct(
id=error_id, createdAt=error_timestamp, wrappedErrors=[matchers.Anything()]
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)
Expand Down
91 changes: 87 additions & 4 deletions api/tests/opentrons/protocol_engine/commands/test_dispense.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,47 @@
"""Test dispense commands."""
from decoy import Decoy
from datetime import datetime

import pytest
from decoy import Decoy, matchers

from opentrons_shared_data.errors.exceptions import PipetteOverpressureError

from opentrons.protocol_engine import WellLocation, WellOrigin, WellOffset, DeckPoint
from opentrons.protocol_engine.execution import MovementHandler, PipettingHandler
from opentrons.types import Point

from opentrons.protocol_engine.commands.command import SuccessData
from opentrons.protocol_engine.commands.command import SuccessData, DefinedErrorData
from opentrons.protocol_engine.commands.dispense import (
DispenseParams,
DispenseResult,
DispenseImplementation,
)
from opentrons.protocol_engine.resources.model_utils import ModelUtils
from opentrons.protocol_engine.commands.pipetting_common import (
OverpressureError,
OverpressureErrorInternalData,
)


@pytest.fixture
def subject(
movement: MovementHandler,
pipetting: PipettingHandler,
model_utils: ModelUtils,
) -> DispenseImplementation:
"""Get the implementation subject."""
return DispenseImplementation(
movement=movement, pipetting=pipetting, model_utils=model_utils
)


async def test_dispense_implementation(
decoy: Decoy,
movement: MovementHandler,
pipetting: PipettingHandler,
subject: DispenseImplementation,
) -> None:
"""It should move to the target location and then dispense."""
subject = DispenseImplementation(movement=movement, pipetting=pipetting)

well_location = WellLocation(
origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)
)
Expand Down Expand Up @@ -55,3 +76,65 @@ async def test_dispense_implementation(
public=DispenseResult(volume=42, position=DeckPoint(x=1, y=2, z=3)),
private=None,
)


async def test_overpressure_error(
decoy: Decoy,
movement: MovementHandler,
pipetting: PipettingHandler,
subject: DispenseImplementation,
model_utils: ModelUtils,
) -> None:
"""It should return an overpressure error if the hardware API indicates that."""
pipette_id = "pipette-id"
labware_id = "labware-id"
well_name = "well-name"
well_location = WellLocation(
origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)
)

position = Point(x=1, y=2, z=3)

error_id = "error-id"
error_timestamp = datetime(year=2020, month=1, day=2)

data = DispenseParams(
pipetteId=pipette_id,
labwareId=labware_id,
wellName=well_name,
wellLocation=well_location,
volume=50,
flowRate=1.23,
)

decoy.when(
await movement.move_to_well(
pipette_id=pipette_id,
labware_id=labware_id,
well_name=well_name,
well_location=well_location,
),
).then_return(position)

decoy.when(
await pipetting.dispense_in_place(
pipette_id=pipette_id, volume=50, flow_rate=1.23, push_out=None
),
).then_raise(PipetteOverpressureError())

decoy.when(model_utils.generate_id()).then_return(error_id)
decoy.when(model_utils.get_timestamp()).then_return(error_timestamp)

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)
),
)
Loading

0 comments on commit 022dcb0

Please sign in to comment.