Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(api): Return a defined tipPhysicallyMissing error from pickUpTip commands #15176

Merged
merged 13 commits into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions api/src/opentrons/protocol_engine/actions/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@

from opentrons_shared_data.errors import EnumeratedError

from ..commands import Command, CommandCreate, CommandPrivateResult
from ..commands import (
Command,
CommandCreate,
CommandDefinedErrorData,
CommandPrivateResult,
)
from ..error_recovery_policy import ErrorRecoveryType
from ..notes.notes import CommandNote
from ..types import (
Expand Down Expand Up @@ -158,13 +163,25 @@ class FailCommandAction:
"""An ID to assign to the command's error.

Must be unique to this occurrence of the error.

todo(mm, 2024-05-13): This is redundant with `error` when it's a defined error.
SyntaxColoring marked this conversation as resolved.
Show resolved Hide resolved
"""

failed_at: datetime
"""When the command failed."""
"""When the command failed.

todo(mm, 2024-05-13): This is redundant with `error` when it's a defined error.
SyntaxColoring marked this conversation as resolved.
Show resolved Hide resolved
"""

error: EnumeratedError
"""The underlying exception that caused this command to fail."""
error: Union[CommandDefinedErrorData, EnumeratedError]
"""The error that caused the command to fail.

If it was a defined error, this should be the `DefinedErrorData` that the command
returned.

If it was an undefined error, this should be the underlying exception
that caused the command to fail, represented as an `EnumeratedError`.
"""

notes: List[CommandNote]
"""Overwrite the command's `.notes` with these."""
Expand Down
5 changes: 2 additions & 3 deletions api/src/opentrons/protocol_engine/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
CommandResult,
CommandType,
CommandPrivateResult,
CommandT,
CommandDefinedErrorData,
)

from .aspirate import (
Expand Down Expand Up @@ -332,10 +332,9 @@
"CommandResult",
"CommandType",
"CommandPrivateResult",
"CommandT",
"CommandDefinedErrorData",
# base interfaces
"AbstractCommandImpl",
"AbstractCommandWithPrivateResultImpl",
"BaseCommand",
"BaseCommandCreate",
"CommandStatus",
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/commands/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from opentrons.hardware_control import HardwareControlAPI

from ..resources import ModelUtils
from ..errors import ErrorOccurrence
from ..notes import CommandNote, CommandNoteAdder

Expand Down Expand Up @@ -264,6 +265,7 @@ def __init__(
tip_handler: execution.TipHandler,
run_control: execution.RunControlHandler,
rail_lights: execution.RailLightsHandler,
model_utils: ModelUtils,
status_bar: execution.StatusBarHandler,
command_note_adder: CommandNoteAdder,
) -> None:
Expand Down
12 changes: 10 additions & 2 deletions api/src/opentrons/protocol_engine/commands/command_unions.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Union types of concrete command definitions."""

from typing import Union, TypeVar
from typing import Union
from typing_extensions import Annotated

from pydantic import Field

from .command import DefinedErrorData

from . import heater_shaker
from . import magnetic_module
from . import temperature_module
Expand Down Expand Up @@ -203,6 +205,8 @@
PickUpTipCreate,
PickUpTipResult,
PickUpTipCommandType,
TipPhysicallyMissingError,
TipPhysicallyMissingErrorInternalData,
)

from .touch_tip import (
Expand Down Expand Up @@ -624,4 +628,8 @@
ConfigureNozzleLayoutPrivateResult,
]

CommandT = TypeVar("CommandT", bound=Command)
# All `DefinedErrorData`s that implementations will actually return in practice.
# There's just one right now, but this will eventually be a Union.
CommandDefinedErrorData = DefinedErrorData[
TipPhysicallyMissingError, TipPhysicallyMissingErrorInternalData
]
120 changes: 97 additions & 23 deletions api/src/opentrons/protocol_engine/commands/pick_up_tip.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
"""Pick up tip command request, result, and implementation models."""
from __future__ import annotations
from dataclasses import dataclass
from opentrons_shared_data.errors import ErrorCodes
from pydantic import Field
from typing import TYPE_CHECKING, Optional, Type
from typing import TYPE_CHECKING, Optional, Type, Union
from typing_extensions import Literal

from opentrons.protocol_engine.errors.exceptions import TipNotAttachedError

from ..errors import ErrorOccurrence
from ..resources import ModelUtils
from ..types import DeckPoint
from .pipetting_common import (
PipetteIdMixin,
WellLocationMixin,
DestinationPositionResult,
)
from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData
from ..errors.error_occurrence import ErrorOccurrence
from .command import (
AbstractCommandImpl,
BaseCommand,
BaseCommandCreate,
DefinedErrorData,
SuccessData,
)

if TYPE_CHECKING:
from ..state import StateView
Expand Down Expand Up @@ -50,25 +61,65 @@ class PickUpTipResult(DestinationPositionResult):
)


class TipPhysicallyMissingError(ErrorOccurrence):
"""Returned when sensors determine that no tip was physically picked up.

That space in the tip rack is marked internally as not having any tip,
as if the tip were consumed by a pickup.

The pipette will act as if no tip was picked up. So, you won't be able to aspirate
anything, and movement commands will assume there is no tip hanging off the bottom
of the pipette.
"""
Comment on lines +64 to +73
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

movement commands will assume there is no tip hanging off the bottom of the pipette.

Is this a good idea? Theoretically, we could assume that there is a tip hanging off the bottom of the pipette.

That might be a bit harder to implement and maintain, since there "is a tip" for movement purposes but "there is not a tip" for liquid handling purposes. But it would be safer for the client. Imagine this error triggers falsely because of a flaky sensor, so physically, there actually is a tip attached. Then, if a client commands a moveToWell as part of its recovery flow, we probably want the robot to go high enough to clear all the labware on the deck, right?

Copy link
Contributor Author

@SyntaxColoring SyntaxColoring May 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand, if a client commands a moveToWell to be followed by a pickUpTipInPlace, we want the robot to put the pipette nozzle in the proper tip pickup position, which means acting as if there is no tip attached...


errorType: Literal["tipPhysicallyMissing"] = "tipPhysicallyMissing"
errorCode: str = ErrorCodes.TIP_PICKUP_FAILED.value.code
detail: str = "No tip detected."


@dataclass
class TipPhysicallyMissingErrorInternalData:
"""Internal-to-ProtocolEngine data about a TipPhysicallyMissingError."""

pipette_id: str
labware_id: str
well_name: str
Comment on lines +80 to +86
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tips.py will need this info to logically remove the tip from the tip rack. It's currently getting this info from the command's params, which was quick hack that probably won't scale to other error types.

For the sake of being incremental, I'll undo that hack in a separate PR, not this one. So, as of this PR, this data is just in anticipation of that, and is not currently consumed by anything.



SyntaxColoring marked this conversation as resolved.
Show resolved Hide resolved
class PickUpTipImplementation(
AbstractCommandImpl[PickUpTipParams, SuccessData[PickUpTipResult, None]]
AbstractCommandImpl[
PickUpTipParams,
Union[
SuccessData[PickUpTipResult, None],
DefinedErrorData[
TipPhysicallyMissingError, TipPhysicallyMissingErrorInternalData
],
],
]
):
"""Pick up tip command implementation."""

def __init__(
self,
state_view: StateView,
tip_handler: TipHandler,
model_utils: ModelUtils,
movement: MovementHandler,
**kwargs: object,
) -> None:
self._state_view = state_view
self._tip_handler = tip_handler
self._model_utils = model_utils
self._movement = movement

async def execute(
self, params: PickUpTipParams
) -> SuccessData[PickUpTipResult, None]:
) -> Union[
SuccessData[PickUpTipResult, None],
DefinedErrorData[
TipPhysicallyMissingError, TipPhysicallyMissingErrorInternalData
],
]:
"""Move to and pick up a tip using the requested pipette."""
pipette_id = params.pipetteId
labware_id = params.labwareId
Expand All @@ -82,24 +133,47 @@ async def execute(
well_location=well_location,
)

tip_geometry = await self._tip_handler.pick_up_tip(
pipette_id=pipette_id,
labware_id=labware_id,
well_name=well_name,
)

return SuccessData(
public=PickUpTipResult(
tipVolume=tip_geometry.volume,
tipLength=tip_geometry.length,
tipDiameter=tip_geometry.diameter,
position=DeckPoint(x=position.x, y=position.y, z=position.z),
),
private=None,
)


class PickUpTip(BaseCommand[PickUpTipParams, PickUpTipResult, ErrorOccurrence]):
try:
tip_geometry = await self._tip_handler.pick_up_tip(
pipette_id=pipette_id,
labware_id=labware_id,
well_name=well_name,
)
except TipNotAttachedError as e:
return DefinedErrorData(
public=TipPhysicallyMissingError(
id=self._model_utils.generate_id(),
SyntaxColoring marked this conversation as resolved.
Show resolved Hide resolved
createdAt=self._model_utils.get_timestamp(),
# TODO: Is this the correct way to wrap this?
wrappedErrors=[
ErrorOccurrence.from_failed(
id=self._model_utils.generate_id(),
createdAt=self._model_utils.get_timestamp(),
error=e,
)
],
),
private=TipPhysicallyMissingErrorInternalData(
pipette_id=pipette_id,
labware_id=labware_id,
well_name=well_name,
),
)
else:
return SuccessData(
public=PickUpTipResult(
tipVolume=tip_geometry.volume,
tipLength=tip_geometry.length,
tipDiameter=tip_geometry.diameter,
position=DeckPoint(x=position.x, y=position.y, z=position.z),
),
private=None,
)


class PickUpTip(
BaseCommand[PickUpTipParams, PickUpTipResult, TipPhysicallyMissingError]
):
"""Pick up tip command model."""

commandType: PickUpTipCommandType = "pickUpTip"
Expand Down
4 changes: 2 additions & 2 deletions api/src/opentrons/protocol_engine/commands/save_position.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ class SavePositionImplementation(
def __init__(
self,
gantry_mover: GantryMover,
model_utils: Optional[ModelUtils] = None,
model_utils: ModelUtils,
SyntaxColoring marked this conversation as resolved.
Show resolved Hide resolved
**kwargs: object,
) -> None:
self._gantry_mover = gantry_mover
self._model_utils = model_utils or ModelUtils()
self._model_utils = model_utils

async def execute(
self, params: SavePositionParams
Expand Down
36 changes: 23 additions & 13 deletions api/src/opentrons/protocol_engine/error_recovery_policy.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# noqa: D100

import enum
from typing import Protocol

from opentrons_shared_data.errors import EnumeratedError, ErrorCodes
from typing import Optional, Protocol

from opentrons.config import feature_flags as ff
from opentrons.protocol_engine.commands import Command
from opentrons.protocol_engine.commands import (
Command,
CommandDefinedErrorData,
PickUpTip,
)
from opentrons.protocol_engine.commands.pick_up_tip import TipPhysicallyMissingError


class ErrorRecoveryType(enum.Enum):
Expand Down Expand Up @@ -36,17 +39,24 @@ class ErrorRecoveryPolicy(Protocol):
This describes a function that Protocol Engine calls after each command failure,
with the details of that failure. The implementation should inspect those details
and return an appropriate `ErrorRecoveryType`.

Args:
failed_command: The command that failed, in its final `status=="failed"` state.
defined_error_data: If the command failed with a defined error, details about
that error. If the command failed with an undefined error, `None`.
By design, this callable isn't given details about undefined errors,
since it would be fragile to rely on them.
"""

@staticmethod
def __call__( # noqa: D102
failed_command: Command, exception: Exception
failed_command: Command, defined_error_data: Optional[CommandDefinedErrorData]
) -> ErrorRecoveryType:
...


def error_recovery_by_ff(
failed_command: Command, exception: Exception
failed_command: Command, defined_error_data: Optional[CommandDefinedErrorData]
) -> ErrorRecoveryType:
"""Use API feature flags to decide how to handle an error.

Expand All @@ -56,20 +66,20 @@ def error_recovery_by_ff(
# todo(mm, 2024-03-18): Do we need to do anything explicit here to disable
# error recovery on the OT-2?
if ff.enable_error_recovery_experiments() and _is_recoverable(
failed_command, exception
failed_command, defined_error_data
):
return ErrorRecoveryType.WAIT_FOR_RECOVERY
else:
return ErrorRecoveryType.FAIL_RUN


def _is_recoverable(failed_command: Command, exception: Exception) -> bool:
def _is_recoverable(
failed_command: Command, error_data: Optional[CommandDefinedErrorData]
) -> bool:
if (
failed_command.commandType == "pickUpTip"
and isinstance(exception, EnumeratedError)
# Hack(?): It seems like this should be ErrorCodes.TIP_PICKUP_FAILED, but that's
# not what gets raised in practice.
and exception.code == ErrorCodes.UNEXPECTED_TIP_REMOVAL
isinstance(failed_command, PickUpTip)
and error_data is not None
and isinstance(error_data.public, TipPhysicallyMissingError)
):
return True
else:
Expand Down
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/errors/error_occurrence.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def from_failed(
),
)

# TODO: Document new meaning of errorType.
# TODO(mm, 2023-09-07):
# The Opentrons App and Flex ODD use `errorType` in the title of error modals, but it's unclear
# if they should. Is this field redundant now that we have `errorCode` and `detail`?
Expand All @@ -66,6 +67,7 @@ def from_failed(
),
)

# TODO: Punctuation?
detail: str = Field(
...,
description=(
Expand Down
Loading
Loading