Skip to content

Commit df80263

Browse files
fix(app, robot-server): support retryLocation when retrying dropTipInPlace during Error Recovery (#16839)
Closes RQA-3591 When dropping a tip in a trash bin or waste chute, the pipette moves downwards before doing the drop tip. During Error Recovery, when we retry the failed command with a fixit intent, we need to perform a moveToCoordinates first before dispatching the dropTipInPlace or the robot drops the tip(s) from too high a location. To know how far to move, we need the failedCommand to contain retryLocation metadata. We have a pattern for this already with overpressure in place commands, so we extend the same functionality here. Co-authored-by: Max Marrone <[email protected]>
1 parent 972c592 commit df80263

File tree

11 files changed

+93
-26
lines changed

11 files changed

+93
-26
lines changed

api/src/opentrons/protocol_engine/commands/aspirate_in_place.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,22 +83,22 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn:
8383
TipNotAttachedError: if no tip is attached to the pipette.
8484
PipetteNotReadyToAspirateError: pipette plunger is not ready.
8585
"""
86+
state_update = StateUpdate()
87+
8688
ready_to_aspirate = self._pipetting.get_is_ready_to_aspirate(
8789
pipette_id=params.pipetteId,
8890
)
89-
9091
if not ready_to_aspirate:
9192
raise PipetteNotReadyToAspirateError(
9293
"Pipette cannot aspirate in place because of a previous blow out."
9394
" The first aspirate following a blow-out must be from a specific well"
9495
" so the plunger can be reset in a known safe position."
9596
)
9697

97-
state_update = StateUpdate()
9898
current_location = self._state_view.pipettes.get_current_location()
99+
current_position = await self._gantry_mover.get_position(params.pipetteId)
99100

100101
try:
101-
current_position = await self._gantry_mover.get_position(params.pipetteId)
102102
volume = await self._pipetting.aspirate_in_place(
103103
pipette_id=params.pipetteId,
104104
volume=params.volume,

api/src/opentrons/protocol_engine/commands/command.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ class BaseCommand(
185185
)
186186
error: Union[
187187
_ErrorT,
188-
# ErrorOccurrence here is for undefined errors not captured by _ErrorT.
188+
# ErrorOccurrence here is a catch-all for undefined errors not captured by
189+
# _ErrorT, or defined errors that don't parse into _ErrorT because, for example,
190+
# they are from an older software version that was missing some fields.
189191
ErrorOccurrence,
190192
None,
191193
] = Field(

api/src/opentrons/protocol_engine/commands/dispense_in_place.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ async def execute(self, params: DispenseInPlaceParams) -> _ExecuteReturn:
7676
"""Dispense without moving the pipette."""
7777
state_update = StateUpdate()
7878
current_location = self._state_view.pipettes.get_current_location()
79+
current_position = await self._gantry_mover.get_position(params.pipetteId)
7980
try:
80-
current_position = await self._gantry_mover.get_position(params.pipetteId)
8181
volume = await self._pipetting.dispense_in_place(
8282
pipette_id=params.pipetteId,
8383
volume=params.volume,

api/src/opentrons/protocol_engine/commands/drop_tip.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ async def execute(self, params: DropTipParams) -> _ExecuteReturn:
145145
error=exception,
146146
)
147147
],
148+
errorInfo={"retryLocation": position},
148149
)
149150
state_update_if_false_positive = update_types.StateUpdate()
150151
state_update_if_false_positive.update_pipette_tip_state(
@@ -165,7 +166,7 @@ async def execute(self, params: DropTipParams) -> _ExecuteReturn:
165166
)
166167

167168

168-
class DropTip(BaseCommand[DropTipParams, DropTipResult, ErrorOccurrence]):
169+
class DropTip(BaseCommand[DropTipParams, DropTipResult, TipPhysicallyAttachedError]):
169170
"""Drop tip command model."""
170171

171172
commandType: DropTipCommandType = "dropTip"

api/src/opentrons/protocol_engine/commands/drop_tip_in_place.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from ..state import update_types
1919

2020
if TYPE_CHECKING:
21-
from ..execution import TipHandler
21+
from ..execution import TipHandler, GantryMover
2222

2323

2424
DropTipInPlaceCommandType = Literal["dropTipInPlace"]
@@ -57,15 +57,19 @@ def __init__(
5757
self,
5858
tip_handler: TipHandler,
5959
model_utils: ModelUtils,
60+
gantry_mover: GantryMover,
6061
**kwargs: object,
6162
) -> None:
6263
self._tip_handler = tip_handler
6364
self._model_utils = model_utils
65+
self._gantry_mover = gantry_mover
6466

6567
async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn:
6668
"""Drop a tip using the requested pipette."""
6769
state_update = update_types.StateUpdate()
6870

71+
retry_location = await self._gantry_mover.get_position(params.pipetteId)
72+
6973
try:
7074
await self._tip_handler.drop_tip(
7175
pipette_id=params.pipetteId, home_after=params.homeAfter
@@ -85,6 +89,7 @@ async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn:
8589
error=exception,
8690
)
8791
],
92+
errorInfo={"retryLocation": retry_location},
8893
)
8994
return DefinedErrorData(
9095
public=error,
@@ -99,7 +104,7 @@ async def execute(self, params: DropTipInPlaceParams) -> _ExecuteReturn:
99104

100105

101106
class DropTipInPlace(
102-
BaseCommand[DropTipInPlaceParams, DropTipInPlaceResult, ErrorOccurrence]
107+
BaseCommand[DropTipInPlaceParams, DropTipInPlaceResult, TipPhysicallyAttachedError]
103108
):
104109
"""Drop tip in place command model."""
105110

api/src/opentrons/protocol_engine/commands/pipetting_common.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,12 @@ class DestinationPositionResult(BaseModel):
148148

149149

150150
class ErrorLocationInfo(TypedDict):
151-
"""Holds a retry location for in-place error recovery."""
151+
"""Holds a retry location for in-place error recovery.
152+
153+
This is appropriate to pass to a `moveToCoordinates` command,
154+
assuming the pipette has not been configured with a different nozzle layout
155+
in the meantime.
156+
"""
152157

153158
retryLocation: Tuple[float, float, float]
154159

@@ -201,3 +206,5 @@ class TipPhysicallyAttachedError(ErrorOccurrence):
201206

202207
errorCode: str = ErrorCodes.TIP_DROP_FAILED.value.code
203208
detail: str = ErrorCodes.TIP_DROP_FAILED.value.detail
209+
210+
errorInfo: ErrorLocationInfo

api/src/opentrons/protocol_engine/errors/error_occurrence.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
log = getLogger(__name__)
1313

1414

15-
# TODO(mc, 2021-11-12): flesh this model out with structured error data
16-
# for each error type so client may produce better error messages
1715
class ErrorOccurrence(BaseModel):
1816
"""An occurrence of a specific error during protocol execution."""
1917

@@ -44,8 +42,15 @@ def from_failed(
4442
id: str = Field(..., description="Unique identifier of this error occurrence.")
4543
createdAt: datetime = Field(..., description="When the error occurred.")
4644

45+
# Our Python should probably always set this to False--if we want it to be True,
46+
# we should probably be using a more specific subclass of ErrorOccurrence anyway.
47+
# However, we can't make this Literal[False], because we want this class to be able
48+
# to act as a catch-all for parsing defined errors that might be missing some
49+
# `errorInfo` fields because they were serialized by older software.
4750
isDefined: bool = Field(
48-
default=False, # default=False for database backwards compatibility.
51+
# default=False for database backwards compatibility, so we can parse objects
52+
# serialized before isDefined existed.
53+
default=False,
4954
description=dedent(
5055
"""\
5156
Whether this error is *defined.*

api/tests/opentrons/protocol_engine/commands/test_drop_tip.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ async def test_tip_attached_error(
281281
id="error-id",
282282
createdAt=datetime(year=1, month=2, day=3),
283283
wrappedErrors=[matchers.Anything()],
284+
errorInfo={"retryLocation": (111, 222, 333)},
284285
),
285286
state_update=update_types.StateUpdate(
286287
pipette_location=update_types.PipetteLocationUpdate(

api/tests/opentrons/protocol_engine/commands/test_drop_tip_in_place.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414
DropTipInPlaceImplementation,
1515
)
1616
from opentrons.protocol_engine.errors.exceptions import TipAttachedError
17-
from opentrons.protocol_engine.execution import TipHandler
17+
from opentrons.protocol_engine.execution import TipHandler, GantryMover
1818
from opentrons.protocol_engine.resources.model_utils import ModelUtils
1919
from opentrons.protocol_engine.state.update_types import (
2020
PipetteTipStateUpdate,
2121
StateUpdate,
2222
)
23+
from opentrons.types import Point
2324

2425

2526
@pytest.fixture
@@ -34,14 +35,23 @@ def mock_model_utils(decoy: Decoy) -> ModelUtils:
3435
return decoy.mock(cls=ModelUtils)
3536

3637

38+
@pytest.fixture
39+
def mock_gantry_mover(decoy: Decoy) -> GantryMover:
40+
"""Get a mock GantryMover."""
41+
return decoy.mock(cls=GantryMover)
42+
43+
3744
async def test_success(
3845
decoy: Decoy,
3946
mock_tip_handler: TipHandler,
4047
mock_model_utils: ModelUtils,
48+
mock_gantry_mover: GantryMover,
4149
) -> None:
4250
"""A DropTip command should have an execution implementation."""
4351
subject = DropTipInPlaceImplementation(
44-
tip_handler=mock_tip_handler, model_utils=mock_model_utils
52+
tip_handler=mock_tip_handler,
53+
model_utils=mock_model_utils,
54+
gantry_mover=mock_gantry_mover,
4555
)
4656
params = DropTipInPlaceParams(pipetteId="abc", homeAfter=False)
4757

@@ -64,14 +74,20 @@ async def test_tip_attached_error(
6474
decoy: Decoy,
6575
mock_tip_handler: TipHandler,
6676
mock_model_utils: ModelUtils,
77+
mock_gantry_mover: GantryMover,
6778
) -> None:
6879
"""A DropTip command should have an execution implementation."""
6980
subject = DropTipInPlaceImplementation(
70-
tip_handler=mock_tip_handler, model_utils=mock_model_utils
81+
tip_handler=mock_tip_handler,
82+
model_utils=mock_model_utils,
83+
gantry_mover=mock_gantry_mover,
7184
)
7285

7386
params = DropTipInPlaceParams(pipetteId="abc", homeAfter=False)
7487

88+
decoy.when(await mock_gantry_mover.get_position(pipette_id="abc")).then_return(
89+
Point(9, 8, 7)
90+
)
7591
decoy.when(
7692
await mock_tip_handler.drop_tip(pipette_id="abc", home_after=False)
7793
).then_raise(TipAttachedError("Egads!"))
@@ -88,6 +104,7 @@ async def test_tip_attached_error(
88104
id="error-id",
89105
createdAt=datetime(year=1, month=2, day=3),
90106
wrappedErrors=[matchers.Anything()],
107+
errorInfo={"retryLocation": (9, 8, 7)},
91108
),
92109
state_update=StateUpdate(),
93110
state_update_if_false_positive=StateUpdate(

app/src/organisms/ErrorRecoveryFlows/hooks/__tests__/useRecoveryCommands.test.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,24 +139,41 @@ describe('useRecoveryCommands', () => {
139139
false
140140
)
141141
})
142-
;([
142+
143+
const IN_PLACE_COMMANDS = [
143144
'aspirateInPlace',
144145
'dispenseInPlace',
145146
'blowOutInPlace',
146147
'dropTipInPlace',
147148
'prepareToAspirate',
148-
] as const).forEach(inPlaceCommandType => {
149-
it(`Should move to retryLocation if failed command is ${inPlaceCommandType} and error is appropriate when retrying`, async () => {
149+
] as const
150+
151+
const ERROR_SCENARIOS = [
152+
{ type: 'overpressure', code: '3006' },
153+
{ type: 'tipPhysicallyAttached', code: '3007' },
154+
] as const
155+
156+
it.each(
157+
ERROR_SCENARIOS.flatMap(error =>
158+
IN_PLACE_COMMANDS.map(commandType => ({
159+
errorType: error.type,
160+
errorCode: error.code,
161+
commandType,
162+
}))
163+
)
164+
)(
165+
'Should move to retryLocation if failed command is $commandType and error is $errorType when retrying',
166+
async ({ errorType, errorCode, commandType }) => {
150167
const { result } = renderHook(() => {
151168
const failedCommand = {
152169
...mockFailedCommand,
153-
commandType: inPlaceCommandType,
170+
commandType,
154171
params: {
155172
pipetteId: 'mock-pipette-id',
156173
},
157174
error: {
158-
errorType: 'overpressure',
159-
errorCode: '3006',
175+
errorType,
176+
errorCode,
160177
isDefined: true,
161178
errorInfo: {
162179
retryLocation: [1, 2, 3],
@@ -180,9 +197,11 @@ describe('useRecoveryCommands', () => {
180197
selectedRecoveryOption: RECOVERY_MAP.RETRY_NEW_TIPS.ROUTE,
181198
})
182199
})
200+
183201
await act(async () => {
184202
await result.current.retryFailedCommand()
185203
})
204+
186205
expect(mockChainRunCommands).toHaveBeenLastCalledWith(
187206
[
188207
{
@@ -194,14 +213,14 @@ describe('useRecoveryCommands', () => {
194213
},
195214
},
196215
{
197-
commandType: inPlaceCommandType,
216+
commandType,
198217
params: { pipetteId: 'mock-pipette-id' },
199218
},
200219
],
201220
false
202221
)
203-
})
204-
})
222+
}
223+
)
205224

206225
it('should call resumeRun with runId and show success toast on success', async () => {
207226
const { result } = renderHook(() => useRecoveryCommands(props))

0 commit comments

Comments
 (0)