Skip to content

Commit 05eeed7

Browse files
feat(api): express stalls in a recoverable way (#16861)
Commands that use `move_to_well`, `move_to_coordinates`, `move_to_addressable_area`, and `move_relative` now will return stalls as DefinedErrors, which means they can be hooked into error recovery en masse. This just leaves move labware. Closes EXEC-831 ## Reviews - Feel like it paid off? ## Testing - Do some stalls and make sure they end up with defined errors ## Further work and questions - I think we should probably handle errors during `aspirate`'s automatic prepare for aspirate at the beginning and probably the same with drop tip but we didn't handle it at all so far so I don't know --------- Co-authored-by: Max Marrone <[email protected]>
1 parent 0814d91 commit 05eeed7

27 files changed

+1238
-218
lines changed

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from .movement_common import (
1616
LiquidHandlingWellLocationMixin,
1717
DestinationPositionResult,
18+
StallOrCollisionError,
1819
move_to_well,
1920
)
2021
from .command import (
@@ -60,7 +61,7 @@ class AspirateResult(BaseLiquidHandlingResult, DestinationPositionResult):
6061

6162
_ExecuteReturn = Union[
6263
SuccessData[AspirateResult],
63-
DefinedErrorData[OverpressureError],
64+
DefinedErrorData[OverpressureError] | DefinedErrorData[StallOrCollisionError],
6465
]
6566

6667

@@ -120,13 +121,16 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
120121

121122
move_result = await move_to_well(
122123
movement=self._movement,
124+
model_utils=self._model_utils,
123125
pipette_id=pipette_id,
124126
labware_id=labware_id,
125127
well_name=well_name,
126128
well_location=params.wellLocation,
127129
current_well=current_well,
128130
operation_volume=-params.volume,
129131
)
132+
if isinstance(move_result, DefinedErrorData):
133+
return move_result
130134

131135
aspirate_result = await aspirate_in_place(
132136
pipette_id=pipette_id,
@@ -185,7 +189,11 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
185189
)
186190

187191

188-
class Aspirate(BaseCommand[AspirateParams, AspirateResult, OverpressureError]):
192+
class Aspirate(
193+
BaseCommand[
194+
AspirateParams, AspirateResult, OverpressureError | StallOrCollisionError
195+
]
196+
):
189197
"""Aspirate command model."""
190198

191199
commandType: AspirateCommandType = "aspirate"

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,19 @@
1111
FlowRateMixin,
1212
blow_out_in_place,
1313
)
14-
from .movement_common import WellLocationMixin, DestinationPositionResult, move_to_well
14+
from .movement_common import (
15+
WellLocationMixin,
16+
DestinationPositionResult,
17+
move_to_well,
18+
StallOrCollisionError,
19+
)
1520
from .command import (
1621
AbstractCommandImpl,
1722
BaseCommand,
1823
BaseCommandCreate,
1924
DefinedErrorData,
2025
SuccessData,
2126
)
22-
from ..errors.error_occurrence import ErrorOccurrence
2327
from ..state.update_types import StateUpdate
2428

2529
from opentrons.hardware_control import HardwareControlAPI
@@ -48,7 +52,7 @@ class BlowOutResult(DestinationPositionResult):
4852

4953
_ExecuteReturn = Union[
5054
SuccessData[BlowOutResult],
51-
DefinedErrorData[OverpressureError],
55+
DefinedErrorData[OverpressureError] | DefinedErrorData[StallOrCollisionError],
5256
]
5357

5458

@@ -74,11 +78,14 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn:
7478
"""Move to and blow-out the requested well."""
7579
move_result = await move_to_well(
7680
movement=self._movement,
81+
model_utils=self._model_utils,
7782
pipette_id=params.pipetteId,
7883
labware_id=params.labwareId,
7984
well_name=params.wellName,
8085
well_location=params.wellLocation,
8186
)
87+
if isinstance(move_result, DefinedErrorData):
88+
return move_result
8289
blow_out_result = await blow_out_in_place(
8390
pipette_id=params.pipetteId,
8491
flow_rate=params.flowRate,
@@ -112,7 +119,13 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn:
112119
)
113120

114121

115-
class BlowOut(BaseCommand[BlowOutParams, BlowOutResult, ErrorOccurrence]):
122+
class BlowOut(
123+
BaseCommand[
124+
BlowOutParams,
125+
BlowOutResult,
126+
OverpressureError | StallOrCollisionError,
127+
]
128+
):
116129
"""Blow-out command model."""
117130

118131
commandType: BlowOutCommandType = "blowout"

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
LiquidNotFoundError,
1414
TipPhysicallyAttachedError,
1515
)
16+
from .movement_common import StallOrCollisionError
1617

1718
from . import absorbance_reader
1819
from . import heater_shaker
@@ -754,6 +755,7 @@
754755
DefinedErrorData[OverpressureError],
755756
DefinedErrorData[LiquidNotFoundError],
756757
DefinedErrorData[GripperMovementError],
758+
DefinedErrorData[StallOrCollisionError],
757759
]
758760

759761

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .movement_common import (
2020
LiquidHandlingWellLocationMixin,
2121
DestinationPositionResult,
22+
StallOrCollisionError,
2223
move_to_well,
2324
)
2425
from .command import (
@@ -57,7 +58,7 @@ class DispenseResult(BaseLiquidHandlingResult, DestinationPositionResult):
5758

5859
_ExecuteReturn = Union[
5960
SuccessData[DispenseResult],
60-
DefinedErrorData[OverpressureError],
61+
DefinedErrorData[OverpressureError] | DefinedErrorData[StallOrCollisionError],
6162
]
6263

6364

@@ -88,11 +89,14 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn:
8889

8990
move_result = await move_to_well(
9091
movement=self._movement,
92+
model_utils=self._model_utils,
9193
pipette_id=params.pipetteId,
9294
labware_id=labware_id,
9395
well_name=well_name,
9496
well_location=well_location,
9597
)
98+
if isinstance(move_result, DefinedErrorData):
99+
return move_result
96100
dispense_result = await dispense_in_place(
97101
pipette_id=params.pipetteId,
98102
volume=volume,
@@ -159,7 +163,11 @@ async def execute(self, params: DispenseParams) -> _ExecuteReturn:
159163
)
160164

161165

162-
class Dispense(BaseCommand[DispenseParams, DispenseResult, OverpressureError]):
166+
class Dispense(
167+
BaseCommand[
168+
DispenseParams, DispenseResult, OverpressureError | StallOrCollisionError
169+
]
170+
):
163171
"""Dispense command model."""
164172

165173
commandType: DispenseCommandType = "dispense"

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
PipetteIdMixin,
1616
TipPhysicallyAttachedError,
1717
)
18-
from .movement_common import DestinationPositionResult, move_to_well
18+
from .movement_common import (
19+
DestinationPositionResult,
20+
move_to_well,
21+
StallOrCollisionError,
22+
)
1923
from .command import (
2024
AbstractCommandImpl,
2125
BaseCommand,
@@ -69,7 +73,9 @@ class DropTipResult(DestinationPositionResult):
6973

7074

7175
_ExecuteReturn = (
72-
SuccessData[DropTipResult] | DefinedErrorData[TipPhysicallyAttachedError]
76+
SuccessData[DropTipResult]
77+
| DefinedErrorData[TipPhysicallyAttachedError]
78+
| DefinedErrorData[StallOrCollisionError]
7379
)
7480

7581

@@ -117,11 +123,14 @@ async def execute(self, params: DropTipParams) -> _ExecuteReturn:
117123

118124
move_result = await move_to_well(
119125
movement=self._movement_handler,
126+
model_utils=self._model_utils,
120127
pipette_id=pipette_id,
121128
labware_id=labware_id,
122129
well_name=well_name,
123130
well_location=tip_drop_location,
124131
)
132+
if isinstance(move_result, DefinedErrorData):
133+
return move_result
125134

126135
try:
127136
await self._tip_handler.drop_tip(

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

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .movement_common import (
2828
WellLocationMixin,
2929
DestinationPositionResult,
30+
StallOrCollisionError,
3031
move_to_well,
3132
)
3233
from .command import (
@@ -91,9 +92,11 @@ class TryLiquidProbeResult(DestinationPositionResult):
9192

9293
_LiquidProbeExecuteReturn = Union[
9394
SuccessData[LiquidProbeResult],
94-
DefinedErrorData[LiquidNotFoundError],
95+
DefinedErrorData[LiquidNotFoundError] | DefinedErrorData[StallOrCollisionError],
9596
]
96-
_TryLiquidProbeExecuteReturn = SuccessData[TryLiquidProbeResult]
97+
_TryLiquidProbeExecuteReturn = (
98+
SuccessData[TryLiquidProbeResult] | DefinedErrorData[StallOrCollisionError]
99+
)
97100

98101

99102
class _ExecuteCommonResult(NamedTuple):
@@ -110,8 +113,9 @@ async def _execute_common(
110113
state_view: StateView,
111114
movement: MovementHandler,
112115
pipetting: PipettingHandler,
116+
model_utils: ModelUtils,
113117
params: _CommonParams,
114-
) -> _ExecuteCommonResult:
118+
) -> _ExecuteCommonResult | DefinedErrorData[StallOrCollisionError]:
115119
pipette_id = params.pipetteId
116120
labware_id = params.labwareId
117121
well_name = params.wellName
@@ -145,12 +149,14 @@ async def _execute_common(
145149
# liquid_probe process start position
146150
move_result = await move_to_well(
147151
movement=movement,
152+
model_utils=model_utils,
148153
pipette_id=pipette_id,
149154
labware_id=labware_id,
150155
well_name=well_name,
151156
well_location=params.wellLocation,
152157
)
153-
158+
if isinstance(move_result, DefinedErrorData):
159+
return move_result
154160
try:
155161
z_pos = await pipetting.liquid_probe_in_place(
156162
pipette_id=pipette_id,
@@ -206,9 +212,16 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn:
206212
MustHomeError: as an undefined error, if the plunger is not in a valid
207213
position.
208214
"""
209-
z_pos_or_error, state_update, deck_point = await _execute_common(
210-
self._state_view, self._movement, self._pipetting, params
215+
result = await _execute_common(
216+
state_view=self._state_view,
217+
movement=self._movement,
218+
pipetting=self._pipetting,
219+
model_utils=self._model_utils,
220+
params=params,
211221
)
222+
if isinstance(result, DefinedErrorData):
223+
return result
224+
z_pos_or_error, state_update, deck_point = result
212225
if isinstance(z_pos_or_error, PipetteLiquidNotFoundError):
213226
state_update.set_liquid_probed(
214227
labware_id=params.labwareId,
@@ -282,9 +295,16 @@ async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn:
282295
found, `tryLiquidProbe` returns a success result with `z_position=null` instead
283296
of a defined error.
284297
"""
285-
z_pos_or_error, state_update, deck_point = await _execute_common(
286-
self._state_view, self._movement, self._pipetting, params
298+
result = await _execute_common(
299+
state_view=self._state_view,
300+
movement=self._movement,
301+
pipetting=self._pipetting,
302+
model_utils=self._model_utils,
303+
params=params,
287304
)
305+
if isinstance(result, DefinedErrorData):
306+
return result
307+
z_pos_or_error, state_update, deck_point = result
288308

289309
if isinstance(z_pos_or_error, PipetteLiquidNotFoundError):
290310
z_pos = None
@@ -316,7 +336,11 @@ async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn:
316336

317337

318338
class LiquidProbe(
319-
BaseCommand[LiquidProbeParams, LiquidProbeResult, LiquidNotFoundError]
339+
BaseCommand[
340+
LiquidProbeParams,
341+
LiquidProbeResult,
342+
LiquidNotFoundError | StallOrCollisionError,
343+
]
320344
):
321345
"""The model for a full `liquidProbe` command."""
322346

@@ -328,7 +352,7 @@ class LiquidProbe(
328352

329353

330354
class TryLiquidProbe(
331-
BaseCommand[TryLiquidProbeParams, TryLiquidProbeResult, ErrorOccurrence]
355+
BaseCommand[TryLiquidProbeParams, TryLiquidProbeResult, StallOrCollisionError]
332356
):
333357
"""The model for a full `tryLiquidProbe` command."""
334358

0 commit comments

Comments
 (0)