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): Pause when pick_up_tip() errors in a Python protocol #14753

Merged
merged 25 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
42a36ec
Type-safe wait_for().
SyntaxColoring Mar 28, 2024
33225f7
Internal support for waiting for a specific command's recovery.
SyntaxColoring Mar 28, 2024
98446ba
WIP
SyntaxColoring Mar 29, 2024
459f14d
Another todo comment.
SyntaxColoring Mar 29, 2024
b4d4d80
Add SetTipUsedAction.
SyntaxColoring Apr 1, 2024
b61913f
Revert "Add SetTipUsedAction."
SyntaxColoring Apr 1, 2024
9322657
Consume tips in failed pickUpTip commands.
SyntaxColoring Apr 1, 2024
33d33c9
Document some background for the estop() method.
SyntaxColoring Apr 2, 2024
dbb03db
Update estop().
SyntaxColoring Apr 2, 2024
b13db26
Enflippen der waitenforen.
SyntaxColoring Apr 3, 2024
838572b
Add unit test for get_recovery_in_progress_for_command().
SyntaxColoring Apr 3, 2024
1d5ba4b
Merge branch 'edge' into papi_pause_on_error
SyntaxColoring Apr 4, 2024
6353d7d
Fix merge mistake.
SyntaxColoring Apr 4, 2024
1ef9bc9
Fix run-stop handling.
SyntaxColoring Apr 4, 2024
cb5bca1
Update various unit tests for new action field.
SyntaxColoring Apr 5, 2024
51bd1d2
Update and port command store tests.
SyntaxColoring Apr 5, 2024
6b79625
If you write a comment describing your sins, that makes the sins okay.
SyntaxColoring Apr 5, 2024
0213657
transports.py linting, docs, and error simplification.
SyntaxColoring Apr 8, 2024
ff22758
Small fixups.
SyntaxColoring Apr 8, 2024
8ad0eef
Refactor _wait_for().
SyntaxColoring Apr 8, 2024
bd14851
Merge branch 'edge' into papi_pause_on_error
SyntaxColoring Apr 8, 2024
a880096
Explicitly keep track of the current recovery target.
SyntaxColoring Apr 8, 2024
50e1706
Update todo comment to be more specific.
SyntaxColoring Apr 8, 2024
97b0ada
Raise a distinct error type.
SyntaxColoring Apr 8, 2024
cf1e936
Replace todo with note.
SyntaxColoring Apr 8, 2024
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
8 changes: 7 additions & 1 deletion api/src/opentrons/protocol_api/core/engine/instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,13 +408,19 @@ def pick_up_tip(
well_name=well_name,
well_location=well_location,
)
self._engine_client.pick_up_tip(
self._engine_client.pick_up_tip_wait_for_recovery(
pipette_id=self._pipette_id,
labware_id=labware_id,
well_name=well_name,
well_location=well_location,
)
# FIX BEFORE MERGE?: If the pickUpTip fails, the tip tracker (which is sort of
SyntaxColoring marked this conversation as resolved.
Show resolved Hide resolved
# kind of in Protocol Engine) doesn't think the tip has been consumed. So,
# the next PAPI pick_up_tip() after the error recovery will try to pick up
# the same tip, diverging from the protocol's analysis.

# FIX BEFORE MERGE: We should probably only set_last_location() if the
Copy link
Member

Choose a reason for hiding this comment

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

I disagree - I think there are three answers each of which is honestly reasonable, but one which is the best (and also hardest). As a preamble, we should probably push last-location down to the engine.

  1. the best and hardest is, base the decision of setting last location on when the error occurred (during which action) and what the error is. For instance, if you got a stall while moving to the tiprack, we should clear the last location since position is now indeterminate; but if we failed our tip-state consistency check after the pickup, we should set the last location to the rack.
  2. the simplest and most robust is, always clear the last location on any error
  3. and a slightly more optimistic version is to always set it on any error since the most likely causes of failure will in fact leave the gantry in the specified location

Copy link
Contributor Author

@SyntaxColoring SyntaxColoring Apr 8, 2024

Choose a reason for hiding this comment

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

After looking at this closer, I think it's appropriate to call this set_last_location() unconditionally. See this new comment:

self._engine_client.pick_up_tip_wait_for_recovery(
pipette_id=self._pipette_id,
labware_id=labware_id,
well_name=well_name,
well_location=well_location,
)
# Set the "last location" unconditionally, even if the command failed
# and was recovered from and we don't know if the pipette is physically here.
# This isn't used for path planning, but rather for implicit destination
# selection like in `pipette.aspirate(location=None)`.
self._protocol_core.set_last_location(location=location, mount=self.get_mount())

# pickUpTip succeeded.
self._protocol_core.set_last_location(location=location, mount=self.get_mount())

def drop_tip(
Expand Down
19 changes: 19 additions & 0 deletions api/src/opentrons/protocol_engine/clients/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,25 @@ def pick_up_tip(

return cast(commands.PickUpTipResult, result)

# FIX BEFORE MERGE?: See if we can cut down on this duplication.
# We do not want to have to do this for every method here.
def pick_up_tip_wait_for_recovery(
self,
pipette_id: str,
labware_id: str,
well_name: str,
well_location: WellLocation,
) -> None:
request = commands.PickUpTipCreate(
params=commands.PickUpTipParams(
pipetteId=pipette_id,
labwareId=labware_id,
wellName=well_name,
wellLocation=well_location,
)
)
self._transport.execute_command_wait_for_recovery(request=request)

def drop_tip(
self,
pipette_id: str,
Expand Down
6 changes: 6 additions & 0 deletions api/src/opentrons/protocol_engine/clients/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ def execute_command(self, request: CommandCreate) -> CommandResult:

return command.result

def execute_command_wait_for_recovery(self, request: CommandCreate) -> None:
run_coroutine_threadsafe(
self._engine.add_and_execute_command_wait_for_recovery(request=request),
loop=self._loop,
).result()

@overload
def call_method(
self,
Expand Down
21 changes: 21 additions & 0 deletions api/src/opentrons/protocol_engine/protocol_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,27 @@ async def add_and_execute_command(
await self.wait_for_command(command.id)
return self._state_store.commands.get(command.id)

async def add_and_execute_command_wait_for_recovery(
self, request: commands.CommandCreate
) -> None:
Copy link
Contributor Author

@SyntaxColoring SyntaxColoring Apr 1, 2024

Choose a reason for hiding this comment

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

I'm open to ideas for better names for this.

Or maybe we should make it a wait_for_recovery: bool argument on the existing add_and_execute() method.

"""Like `add_and_execute_command()`, except wait for error recovery.

Unlike `add_and_execute_command()`, if the command fails, this will not
immediately return the failed command. Instead, if the error is recoverable,
it will wait until error recovery has completed (e.g. when some other task
calls `self.resume_from_recovery()`).
"""
command = self.add_command(request)
await self.wait_for_command(command_id=command.id)
await self._state_store.wait_for(
# Per the warnings on `wait_for()`, we want our condition function to
# specifically check that *this* command's recovery has completed,
# rather than just checking that the overall run state
# != "awaiting-recovery."
self.state_view.commands.get_command_recovery_complete,
command_id=command.id,
)

def estop(
self,
# TODO(mm, 2024-03-26): Maintenance runs are a robot-server concept that
Expand Down
20 changes: 20 additions & 0 deletions api/src/opentrons/protocol_engine/state/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,26 @@ def get_all_commands_final(self) -> bool:

return no_command_running and no_command_to_execute

def get_command_recovery_complete(self, command_id: str) -> bool:
SyntaxColoring marked this conversation as resolved.
Show resolved Hide resolved
"""Return whether we're finished with error recovery for the given command.

If the given command didn't have a recovery phase (because it hasn't completed
yet, or because it succeeded without an error, or because it failed with an
error that wasn't recoverable), that counts as `True`.

If the given command did have a recovery phase, but it was interrupted by a
stop, that also counts as `True`.
"""
command_is_most_recent_to_fail = (
Copy link
Member

Choose a reason for hiding this comment

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

this is a little gross to me, could we save the command we're currently recovering from explicitly? like, what happens if a fixit command fails, wouldn't it instantly make this False?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed in a880096.

self._state.failed_command is not None
and command_id == self._state.failed_command.command.id
)
if command_is_most_recent_to_fail:
recovery_is_ongoing = self.get_status() == EngineStatus.AWAITING_RECOVERY
return not recovery_is_ongoing
else:
return True

def raise_fatal_command_error(self) -> None:
"""Raise the run's fatal command error, if there was one, as an exception.

Expand Down
15 changes: 9 additions & 6 deletions api/src/opentrons/protocol_engine/state/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

from dataclasses import dataclass
from functools import partial
from typing import Any, Callable, Dict, List, Optional, Sequence, TypeVar
from typing import Callable, Dict, List, Optional, Sequence, TypeVar
from typing_extensions import ParamSpec

from opentrons_shared_data.deck.dev_types import DeckDefinitionV4

Expand All @@ -30,7 +31,9 @@
from .state_summary import StateSummary
from ..types import DeckConfigurationType

ReturnT = TypeVar("ReturnT")

_ParamsT = ParamSpec("_ParamsT")
_ReturnT = TypeVar("_ReturnT")


@dataclass(frozen=True)
Expand Down Expand Up @@ -207,10 +210,10 @@ def handle_action(self, action: Action) -> None:

async def wait_for(
self,
condition: Callable[..., Optional[ReturnT]],
*args: Any,
**kwargs: Any,
) -> ReturnT:
condition: Callable[_ParamsT, Optional[_ReturnT]],
*args: _ParamsT.args,
**kwargs: _ParamsT.kwargs,
) -> _ReturnT:
"""Wait for a condition to become true, checking whenever state changes.

If the condition is already true, return immediately.
Expand Down
Loading