Skip to content

Commit ce7c015

Browse files
committed
feat(api): Handle overpressures in dispense
As in aspirate, we handle overpressure errors and turn them into DefinedError return values, allowing us to recover from overpressures via client-driven error recovery. Closes EXEC-498
1 parent 6b8f7fd commit ce7c015

File tree

2 files changed

+147
-26
lines changed

2 files changed

+147
-26
lines changed

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

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
"""Dispense command request, result, and implementation models."""
22
from __future__ import annotations
3-
from typing import TYPE_CHECKING, Optional, Type
3+
from typing import TYPE_CHECKING, Optional, Type, Union
44
from typing_extensions import Literal
55

6+
from opentrons_shared_data.errors.exceptions import PipetteOverpressureError
7+
68
from pydantic import Field
79

810
from ..types import DeckPoint
@@ -13,12 +15,21 @@
1315
WellLocationMixin,
1416
BaseLiquidHandlingResult,
1517
DestinationPositionResult,
18+
OverpressureError,
19+
OverpressureErrorInternalData,
20+
)
21+
from .command import (
22+
AbstractCommandImpl,
23+
BaseCommand,
24+
BaseCommandCreate,
25+
DefinedErrorData,
26+
SuccessData,
1627
)
17-
from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData
1828
from ..errors.error_occurrence import ErrorOccurrence
1929

2030
if TYPE_CHECKING:
2131
from ..execution import MovementHandler, PipettingHandler
32+
from ..resources import ModelUtils
2233

2334

2435
DispenseCommandType = Literal["dispense"]
@@ -41,41 +52,68 @@ class DispenseResult(BaseLiquidHandlingResult, DestinationPositionResult):
4152
pass
4253

4354

44-
class DispenseImplementation(
45-
AbstractCommandImpl[DispenseParams, SuccessData[DispenseResult, None]]
46-
):
55+
_ExecuteReturn = Union[
56+
SuccessData[DispenseResult, None],
57+
DefinedErrorData[OverpressureError, OverpressureErrorInternalData],
58+
]
59+
60+
61+
class DispenseImplementation(AbstractCommandImpl[DispenseParams, _ExecuteReturn]):
4762
"""Dispense command implementation."""
4863

4964
def __init__(
50-
self, movement: MovementHandler, pipetting: PipettingHandler, **kwargs: object
65+
self,
66+
movement: MovementHandler,
67+
pipetting: PipettingHandler,
68+
model_utils: ModelUtils,
69+
**kwargs: object,
5170
) -> None:
5271
self._movement = movement
5372
self._pipetting = pipetting
73+
self._model_utils = model_utils
5474

55-
async def execute(
56-
self, params: DispenseParams
57-
) -> SuccessData[DispenseResult, None]:
75+
async def execute(self, params: DispenseParams) -> _ExecuteReturn:
5876
"""Move to and dispense to the requested well."""
5977
position = await self._movement.move_to_well(
6078
pipette_id=params.pipetteId,
6179
labware_id=params.labwareId,
6280
well_name=params.wellName,
6381
well_location=params.wellLocation,
6482
)
65-
volume = await self._pipetting.dispense_in_place(
66-
pipette_id=params.pipetteId,
67-
volume=params.volume,
68-
flow_rate=params.flowRate,
69-
push_out=params.pushOut,
70-
)
71-
72-
return SuccessData(
73-
public=DispenseResult(
74-
volume=volume,
75-
position=DeckPoint(x=position.x, y=position.y, z=position.z),
76-
),
77-
private=None,
78-
)
83+
try:
84+
volume = await self._pipetting.dispense_in_place(
85+
pipette_id=params.pipetteId,
86+
volume=params.volume,
87+
flow_rate=params.flowRate,
88+
push_out=params.pushOut,
89+
)
90+
except PipetteOverpressureError as e:
91+
return DefinedErrorData(
92+
public=OverpressureError(
93+
id=self._model_utils.generate_id(),
94+
createdAt=self._model_utils.get_timestamp(),
95+
wrappedErrors=[
96+
ErrorOccurrence.from_failed(
97+
id=self._model_utils.generate_id(),
98+
createdAt=self._model_utils.get_timestamp(),
99+
error=e,
100+
)
101+
],
102+
),
103+
private=OverpressureErrorInternalData(
104+
position=DeckPoint.construct(
105+
x=position.x, y=position.y, z=position.z
106+
)
107+
),
108+
)
109+
else:
110+
return SuccessData(
111+
public=DispenseResult(
112+
volume=volume,
113+
position=DeckPoint(x=position.x, y=position.y, z=position.z),
114+
),
115+
private=None,
116+
)
79117

80118

81119
class Dispense(BaseCommand[DispenseParams, DispenseResult, ErrorOccurrence]):

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

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,47 @@
11
"""Test dispense commands."""
2-
from decoy import Decoy
2+
from datetime import datetime
3+
4+
import pytest
5+
from decoy import Decoy, matchers
6+
7+
from opentrons_shared_data.errors.exceptions import PipetteOverpressureError
38

49
from opentrons.protocol_engine import WellLocation, WellOrigin, WellOffset, DeckPoint
510
from opentrons.protocol_engine.execution import MovementHandler, PipettingHandler
611
from opentrons.types import Point
712

8-
from opentrons.protocol_engine.commands.command import SuccessData
13+
from opentrons.protocol_engine.commands.command import SuccessData, DefinedErrorData
914
from opentrons.protocol_engine.commands.dispense import (
1015
DispenseParams,
1116
DispenseResult,
1217
DispenseImplementation,
1318
)
19+
from opentrons.protocol_engine.resources.model_utils import ModelUtils
20+
from opentrons.protocol_engine.commands.pipetting_common import (
21+
OverpressureError,
22+
OverpressureErrorInternalData,
23+
)
24+
25+
26+
@pytest.fixture
27+
def subject(
28+
movement: MovementHandler,
29+
pipetting: PipettingHandler,
30+
model_utils: ModelUtils,
31+
) -> DispenseImplementation:
32+
"""Get the implementation subject."""
33+
return DispenseImplementation(
34+
movement=movement, pipetting=pipetting, model_utils=model_utils
35+
)
1436

1537

1638
async def test_dispense_implementation(
1739
decoy: Decoy,
1840
movement: MovementHandler,
1941
pipetting: PipettingHandler,
42+
subject: DispenseImplementation,
2043
) -> None:
2144
"""It should move to the target location and then dispense."""
22-
subject = DispenseImplementation(movement=movement, pipetting=pipetting)
2345

2446
well_location = WellLocation(
2547
origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)
@@ -55,3 +77,64 @@ async def test_dispense_implementation(
5577
public=DispenseResult(volume=42, position=DeckPoint(x=1, y=2, z=3)),
5678
private=None,
5779
)
80+
81+
82+
async def test_overpressure_error(
83+
decoy: Decoy,
84+
movement: MovementHandler,
85+
pipetting: PipettingHandler,
86+
subject: DispenseImplementation,
87+
model_utils: ModelUtils,
88+
) -> None:
89+
"""It should return an overpressure error if the hardware API indicates that."""
90+
pipette_id = "pipette-id"
91+
labware_id = "labware-id"
92+
well_name = "well-name"
93+
well_location = WellLocation(
94+
origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)
95+
)
96+
97+
position = Point(x=1, y=2, z=3)
98+
99+
error_id = "error-id"
100+
error_timestamp = datetime(year=2020, month=1, day=2)
101+
102+
data = DispenseParams(
103+
pipetteId=pipette_id,
104+
labwareId=labware_id,
105+
wellName=well_name,
106+
wellLocation=well_location,
107+
volume=50,
108+
flowRate=1.23,
109+
)
110+
111+
decoy.when(
112+
await movement.move_to_well(
113+
pipette_id=pipette_id,
114+
labware_id=labware_id,
115+
well_name=well_name,
116+
well_location=well_location,
117+
),
118+
).then_return(position)
119+
120+
decoy.when(
121+
await pipetting.dispense_in_place(
122+
pipette_id=pipette_id, volume=50, flow_rate=1.23, push_out=None
123+
),
124+
).then_raise(PipetteOverpressureError())
125+
126+
decoy.when(model_utils.generate_id()).then_return(error_id)
127+
decoy.when(model_utils.get_timestamp()).then_return(error_timestamp)
128+
129+
result = await subject.execute(data)
130+
131+
assert result == DefinedErrorData(
132+
public=OverpressureError.construct(
133+
id=error_id,
134+
createdAt=error_timestamp,
135+
wrappedErrors=[matchers.Anything()],
136+
),
137+
private=OverpressureErrorInternalData(
138+
position=DeckPoint(x=position.x, y=position.y, z=position.z)
139+
),
140+
)

0 commit comments

Comments
 (0)