Skip to content

Commit 65b0d7f

Browse files
fix(api): Fix liquidProbe error message after blowout (#16294)
## Overview This is the error message fix part of RQA-3171. ## Changelog When we're not ready to do a `liquidProbe`, be a little bit finer-grained about why we're not ready. Give the "missing tip" case and "plunger not ready" case their own error messages.
1 parent 6293e07 commit 65b0d7f

File tree

4 files changed

+80
-95
lines changed

4 files changed

+80
-95
lines changed

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

+27-7
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
from __future__ import annotations
44
from typing import TYPE_CHECKING, Optional, Type, Union
5-
from opentrons.protocol_engine.errors.exceptions import MustHomeError, TipNotEmptyError
5+
from opentrons.protocol_engine.errors.exceptions import (
6+
MustHomeError,
7+
PipetteNotReadyToAspirateError,
8+
TipNotEmptyError,
9+
)
610
from opentrons.types import MountType
711
from opentrons_shared_data.errors.exceptions import (
812
PipetteLiquidNotFoundError,
@@ -32,6 +36,7 @@
3236
if TYPE_CHECKING:
3337
from ..execution import MovementHandler, PipettingHandler
3438
from ..resources import ModelUtils
39+
from ..state import StateView
3540

3641

3742
LiquidProbeCommandType = Literal["liquidProbe"]
@@ -92,11 +97,13 @@ class LiquidProbeImplementation(
9297

9398
def __init__(
9499
self,
100+
state_view: StateView,
95101
movement: MovementHandler,
96102
pipetting: PipettingHandler,
97103
model_utils: ModelUtils,
98104
**kwargs: object,
99105
) -> None:
106+
self._state_view = state_view
100107
self._movement = movement
101108
self._pipetting = pipetting
102109
self._model_utils = model_utils
@@ -112,20 +119,30 @@ async def execute(self, params: _CommonParams) -> _LiquidProbeExecuteReturn:
112119
the pipette.
113120
TipNotEmptyError: as an undefined error, if the tip starts with liquid
114121
in it.
122+
PipetteNotReadyToAspirateError: as an undefined error, if the plunger is not
123+
in a safe position to do the liquid probe.
115124
MustHomeError: as an undefined error, if the plunger is not in a valid
116125
position.
117126
"""
118127
pipette_id = params.pipetteId
119128
labware_id = params.labwareId
120129
well_name = params.wellName
121130

122-
# _validate_tip_attached in pipetting.py is a private method so we're using
123-
# get_is_ready_to_aspirate as an indirect way to throw a TipNotAttachedError if appropriate
124-
self._pipetting.get_is_ready_to_aspirate(pipette_id=pipette_id)
125-
126-
if self._pipetting.get_is_empty(pipette_id=pipette_id) is False:
131+
# May raise TipNotAttachedError.
132+
aspirated_volume = self._state_view.pipettes.get_aspirated_volume(pipette_id)
133+
134+
if aspirated_volume is None:
135+
# Theoretically, we could avoid raising an error by automatically preparing
136+
# to aspirate above the well like AspirateImplementation does. However, the
137+
# only way for this to happen is if someone tries to do a liquid probe with
138+
# a tip that's previously held liquid, which they should avoid anyway.
139+
raise PipetteNotReadyToAspirateError(
140+
"The pipette cannot probe liquid because of a previous blow out."
141+
" The plunger must be reset while the tip is somewhere away from liquid."
142+
)
143+
elif aspirated_volume != 0:
127144
raise TipNotEmptyError(
128-
message="This operation requires a tip with no liquid in it."
145+
message="The pipette cannot probe for liquid when the tip has liquid in it."
129146
)
130147

131148
if await self._movement.check_for_valid_position(mount=MountType.LEFT) is False:
@@ -182,11 +199,13 @@ class TryLiquidProbeImplementation(
182199

183200
def __init__(
184201
self,
202+
state_view: StateView,
185203
movement: MovementHandler,
186204
pipetting: PipettingHandler,
187205
model_utils: ModelUtils,
188206
**kwargs: object,
189207
) -> None:
208+
self._state_view = state_view
190209
self._movement = movement
191210
self._pipetting = pipetting
192211
self._model_utils = model_utils
@@ -203,6 +222,7 @@ async def execute(self, params: _CommonParams) -> _TryLiquidProbeExecuteReturn:
203222
# Otherwise, we return the result or propagate the exception unchanged.
204223

205224
original_impl = LiquidProbeImplementation(
225+
state_view=self._state_view,
206226
movement=self._movement,
207227
pipetting=self._pipetting,
208228
model_utils=self._model_utils,

api/src/opentrons/protocol_engine/execution/pipetting.py

-11
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,6 @@
2929
class PipettingHandler(TypingProtocol):
3030
"""Liquid handling commands."""
3131

32-
def get_is_empty(self, pipette_id: str) -> bool:
33-
"""Get whether a pipette has an aspirated volume equal to 0."""
34-
3532
def get_is_ready_to_aspirate(self, pipette_id: str) -> bool:
3633
"""Get whether a pipette is ready to aspirate."""
3734

@@ -81,10 +78,6 @@ def __init__(self, state_view: StateView, hardware_api: HardwareControlAPI) -> N
8178
self._state_view = state_view
8279
self._hardware_api = hardware_api
8380

84-
def get_is_empty(self, pipette_id: str) -> bool:
85-
"""Get whether a pipette has an aspirated volume equal to 0."""
86-
return self._state_view.pipettes.get_aspirated_volume(pipette_id) == 0
87-
8881
def get_is_ready_to_aspirate(self, pipette_id: str) -> bool:
8982
"""Get whether a pipette is ready to aspirate."""
9083
hw_pipette = self._state_view.pipettes.get_hardware_pipette(
@@ -236,10 +229,6 @@ def __init__(
236229
"""Initialize a PipettingHandler instance."""
237230
self._state_view = state_view
238231

239-
def get_is_empty(self, pipette_id: str) -> bool:
240-
"""Get whether a pipette has an aspirated volume equal to 0."""
241-
return self._state_view.pipettes.get_aspirated_volume(pipette_id) == 0
242-
243232
def get_is_ready_to_aspirate(self, pipette_id: str) -> bool:
244233
"""Get whether a pipette is ready to aspirate."""
245234
return self._state_view.pipettes.get_aspirated_volume(pipette_id) is not None

api/src/opentrons/protocol_engine/state/pipettes.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,7 @@ def get_aspirated_volume(self, pipette_id: str) -> Optional[float]:
611611
612612
Returns:
613613
The volume the pipette has aspirated.
614-
None, after blow-out and the plunger is in an unsafe position or drop-tip and there is no tip attached.
614+
None, after blow-out and the plunger is in an unsafe position.
615615
616616
Raises:
617617
PipetteNotLoadedError: pipette ID does not exist.

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

+52-76
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from opentrons.protocol_engine.errors.exceptions import (
66
MustHomeError,
7+
PipetteNotReadyToAspirateError,
78
TipNotAttachedError,
89
TipNotEmptyError,
910
)
@@ -37,7 +38,6 @@
3738
PipettingHandler,
3839
)
3940
from opentrons.protocol_engine.resources.model_utils import ModelUtils
40-
from opentrons.protocol_engine.types import LoadedPipette
4141

4242

4343
EitherImplementationType = Union[
@@ -84,12 +84,14 @@ def result_type(types: tuple[object, object, EitherResultType]) -> EitherResultT
8484
@pytest.fixture
8585
def subject(
8686
implementation_type: EitherImplementationType,
87+
state_view: StateView,
8788
movement: MovementHandler,
8889
pipetting: PipettingHandler,
8990
model_utils: ModelUtils,
9091
) -> Union[LiquidProbeImplementation, TryLiquidProbeImplementation]:
9192
"""Get the implementation subject."""
9293
return implementation_type(
94+
state_view=state_view,
9395
pipetting=pipetting,
9496
movement=movement,
9597
model_utils=model_utils,
@@ -99,6 +101,7 @@ def subject(
99101
async def test_liquid_probe_implementation_no_prep(
100102
decoy: Decoy,
101103
movement: MovementHandler,
104+
state_view: StateView,
102105
pipetting: PipettingHandler,
103106
subject: EitherImplementation,
104107
params_type: EitherParamsType,
@@ -114,7 +117,9 @@ async def test_liquid_probe_implementation_no_prep(
114117
wellLocation=location,
115118
)
116119

117-
decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id="abc")).then_return(True)
120+
decoy.when(state_view.pipettes.get_aspirated_volume(pipette_id="abc")).then_return(
121+
0
122+
)
118123

119124
decoy.when(
120125
await movement.move_to_well(
@@ -143,69 +148,9 @@ async def test_liquid_probe_implementation_no_prep(
143148
)
144149

145150

146-
async def test_liquid_probe_implementation_with_prep(
147-
decoy: Decoy,
148-
state_view: StateView,
149-
movement: MovementHandler,
150-
pipetting: PipettingHandler,
151-
subject: EitherImplementation,
152-
params_type: EitherParamsType,
153-
result_type: EitherResultType,
154-
) -> None:
155-
"""A Liquid Probe should have an execution implementation with preparing to aspirate."""
156-
location = WellLocation(origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=2))
157-
158-
data = params_type(
159-
pipetteId="abc",
160-
labwareId="123",
161-
wellName="A3",
162-
wellLocation=location,
163-
)
164-
165-
decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id="abc")).then_return(False)
166-
167-
decoy.when(state_view.pipettes.get(pipette_id="abc")).then_return(
168-
LoadedPipette.construct( # type:ignore[call-arg]
169-
mount=MountType.LEFT
170-
)
171-
)
172-
decoy.when(
173-
await movement.move_to_well(
174-
pipette_id="abc", labware_id="123", well_name="A3", well_location=location
175-
),
176-
).then_return(Point(x=1, y=2, z=3))
177-
178-
decoy.when(
179-
await pipetting.liquid_probe_in_place(
180-
pipette_id="abc",
181-
labware_id="123",
182-
well_name="A3",
183-
well_location=location,
184-
),
185-
).then_return(15.0)
186-
187-
result = await subject.execute(data)
188-
189-
assert type(result.public) is result_type # Pydantic v1 only compares the fields.
190-
assert result == SuccessData(
191-
public=result_type(z_position=15.0, position=DeckPoint(x=1, y=2, z=3)),
192-
private=None,
193-
)
194-
195-
decoy.verify(
196-
await movement.move_to_well(
197-
pipette_id="abc",
198-
labware_id="123",
199-
well_name="A3",
200-
well_location=WellLocation(
201-
origin=WellOrigin.TOP, offset=WellOffset(x=0, y=0, z=2)
202-
),
203-
),
204-
)
205-
206-
207151
async def test_liquid_not_found_error(
208152
decoy: Decoy,
153+
state_view: StateView,
209154
movement: MovementHandler,
210155
pipetting: PipettingHandler,
211156
subject: EitherImplementation,
@@ -232,9 +177,7 @@ async def test_liquid_not_found_error(
232177
wellLocation=well_location,
233178
)
234179

235-
decoy.when(pipetting.get_is_ready_to_aspirate(pipette_id=pipette_id)).then_return(
236-
True
237-
)
180+
decoy.when(state_view.pipettes.get_aspirated_volume(pipette_id)).then_return(0)
238181

239182
decoy.when(
240183
await movement.move_to_well(
@@ -282,11 +225,11 @@ async def test_liquid_not_found_error(
282225

283226
async def test_liquid_probe_tip_checking(
284227
decoy: Decoy,
285-
pipetting: PipettingHandler,
228+
state_view: StateView,
286229
subject: EitherImplementation,
287230
params_type: EitherParamsType,
288231
) -> None:
289-
"""It should return a TipNotAttached error if the hardware API indicates that."""
232+
"""It should raise a TipNotAttached error if the state view indicates that."""
290233
pipette_id = "pipette-id"
291234
labware_id = "labware-id"
292235
well_name = "well-name"
@@ -301,18 +244,42 @@ async def test_liquid_probe_tip_checking(
301244
wellLocation=well_location,
302245
)
303246

304-
decoy.when(
305-
pipetting.get_is_ready_to_aspirate(
306-
pipette_id=pipette_id,
307-
),
308-
).then_raise(TipNotAttachedError())
247+
decoy.when(state_view.pipettes.get_aspirated_volume(pipette_id)).then_raise(
248+
TipNotAttachedError()
249+
)
309250
with pytest.raises(TipNotAttachedError):
310251
await subject.execute(data)
311252

312253

254+
async def test_liquid_probe_plunger_preparedness_checking(
255+
decoy: Decoy,
256+
state_view: StateView,
257+
subject: EitherImplementation,
258+
params_type: EitherParamsType,
259+
) -> None:
260+
"""It should raise a PipetteNotReadyToAspirate error if the state view indicates that."""
261+
pipette_id = "pipette-id"
262+
labware_id = "labware-id"
263+
well_name = "well-name"
264+
well_location = WellLocation(
265+
origin=WellOrigin.BOTTOM, offset=WellOffset(x=0, y=0, z=1)
266+
)
267+
268+
data = params_type(
269+
pipetteId=pipette_id,
270+
labwareId=labware_id,
271+
wellName=well_name,
272+
wellLocation=well_location,
273+
)
274+
275+
decoy.when(state_view.pipettes.get_aspirated_volume(pipette_id)).then_return(None)
276+
with pytest.raises(PipetteNotReadyToAspirateError):
277+
await subject.execute(data)
278+
279+
313280
async def test_liquid_probe_volume_checking(
314281
decoy: Decoy,
315-
pipetting: PipettingHandler,
282+
state_view: StateView,
316283
subject: EitherImplementation,
317284
params_type: EitherParamsType,
318285
) -> None:
@@ -330,15 +297,23 @@ async def test_liquid_probe_volume_checking(
330297
wellName=well_name,
331298
wellLocation=well_location,
332299
)
300+
333301
decoy.when(
334-
pipetting.get_is_empty(pipette_id=pipette_id),
335-
).then_return(False)
302+
state_view.pipettes.get_aspirated_volume(pipette_id=pipette_id),
303+
).then_return(123)
336304
with pytest.raises(TipNotEmptyError):
337305
await subject.execute(data)
338306

307+
decoy.when(
308+
state_view.pipettes.get_aspirated_volume(pipette_id=pipette_id),
309+
).then_return(None)
310+
with pytest.raises(PipetteNotReadyToAspirateError):
311+
await subject.execute(data)
312+
339313

340314
async def test_liquid_probe_location_checking(
341315
decoy: Decoy,
316+
state_view: StateView,
342317
movement: MovementHandler,
343318
subject: EitherImplementation,
344319
params_type: EitherParamsType,
@@ -357,6 +332,7 @@ async def test_liquid_probe_location_checking(
357332
wellName=well_name,
358333
wellLocation=well_location,
359334
)
335+
decoy.when(state_view.pipettes.get_aspirated_volume(pipette_id)).then_return(0)
360336
decoy.when(
361337
await movement.check_for_valid_position(
362338
mount=MountType.LEFT,

0 commit comments

Comments
 (0)