Skip to content

Commit 047dd21

Browse files
authored
fix(api): Add ready-to-aspirate into engine state so analysis correctly fails (#17636)
<!-- Thanks for taking the time to open a Pull Request (PR)! Please make sure you've read the "Opening Pull Requests" section of our Contributing Guide: https://github.com/Opentrons/opentrons/blob/edge/CONTRIBUTING.md#opening-pull-requests GitHub provides robust markdown to format your PR. Links, diagrams, pictures, and videos along with text formatting make it possible to create a rich and informative PR. For more information on GitHub markdown, see: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax To ensure your code is reviewed quickly and thoroughly, please fill out the sections below to the best of your ability! --> # Overview The "ready to aspirate" state was previously only known to the hardware control layer, this adds the state updates to the protocol engine so that commands will fail analysis when steps would produce an error at runtime without removing any of the hardware layer checks. This stems from a scenario where the following steps would produce a "not ready to aspirate" error at runtime but pass analysis. 1. Pipette picks up tips 2. Does an air gap 3. Does an aspirate 4. does a dispense with push out 5. does an airgap command Since we automatically do a prepare-to-aspirate as part of the aspirate command and the pick up tip, it successfully passes step 2 but we don't automatically do it during dispense or air gap so when the hardware attempts to start step 5, and step 4 leaves it in an "not-ready" state the hardware controller fails at runtime. This PR updates all of the various protocol engine command that effect the "ready" state to have a new StateUpdate element that stores the current state in the state view. this way analysis will fail when a particular order of calls would result in an illegal move. <!-- Describe your PR at a high level. State acceptance criteria and how this PR fits into other work. Link issues, PRs, and other relevant resources. --> ## Test Plan and Hands on Testing <!-- Describe your testing of the PR. Emphasize testing not reflected in the code. Attach protocols, logs, screenshots and any other assets that support your testing. --> ## Changelog <!-- List changes introduced by this PR considering future developers and the end user. Give careful thought and clear documentation to breaking changes. --> ## Review requests <!-- - What do you need from reviewers to feel confident this PR is ready to merge? - Ask questions. --> ## Risk assessment <!-- - Indicate the level of attention this PR needs. - Provide context to guide reviewers. - Discuss trade-offs, coupling, and side effects. - Look for the possibility, even if you think it's small, that your change may affect some other part of the system. - For instance, changing return tip behavior may also change the behavior of labware calibration. - How do your unit tests and on hands on testing mitigate this PR's risks and the risk of future regressions? - Especially in high risk PRs, explain how you know your testing is enough. -->
1 parent 2d02c78 commit 047dd21

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+437
-59
lines changed

api/src/opentrons/hardware_control/api.py

+1
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,7 @@ async def dispense(
10771077
rate: float = 1.0,
10781078
push_out: Optional[float] = None,
10791079
correction_volume: float = 0.0,
1080+
is_full_dispense: bool = False,
10801081
) -> None:
10811082
"""
10821083
Dispense a volume of liquid in microliters(uL) using this pipette.

api/src/opentrons/hardware_control/instruments/ot3/pipette_handler.py

+16-1
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,7 @@ def plan_check_dispense(
607607
volume: Optional[float],
608608
rate: float,
609609
push_out: Optional[float],
610+
is_full_dispense: bool,
610611
correction_volume: float = 0.0,
611612
) -> Optional[LiquidActionSpec]:
612613
"""Check preconditions for dispense, parse args, and calculate positions.
@@ -644,7 +645,21 @@ def plan_check_dispense(
644645
# of the OT-2 version of this class. Protocol Engine does its own clamping,
645646
# so we don't expect this to trigger in practice.
646647
disp_vol = min(instrument.current_volume, disp_vol)
647-
is_full_dispense = numpy.isclose(instrument.current_volume - disp_vol, 0)
648+
649+
# TODO (Ryan): Remove this check in the future.
650+
# we moved this logic up to protocol_engine but replacing with this check to make sure
651+
# we don't accidentally call this incorrectly from somewhere else.
652+
if not is_full_dispense and numpy.isclose(
653+
instrument.current_volume - disp_vol, 0
654+
):
655+
raise CommandPreconditionViolated(
656+
message="Command created a full-dispense without the full dispense argument",
657+
detail={
658+
"command": "dispense",
659+
"current-volume": str(instrument.current_volume),
660+
"dispense-volume": str(disp_vol),
661+
},
662+
)
648663

649664
if disp_vol == 0:
650665
return None

api/src/opentrons/hardware_control/ot3api.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -2100,6 +2100,7 @@ async def dispense(
21002100
rate: float = 1.0,
21012101
push_out: Optional[float] = None,
21022102
correction_volume: float = 0.0,
2103+
is_full_dispense: bool = False,
21032104
) -> None:
21042105
"""
21052106
Dispense a volume of liquid in microliters(uL) using this pipette."""
@@ -2109,6 +2110,7 @@ async def dispense(
21092110
volume=volume,
21102111
rate=rate,
21112112
push_out=push_out,
2113+
is_full_dispense=is_full_dispense,
21122114
correction_volume=correction_volume,
21132115
)
21142116
if not dispense_spec:
@@ -3049,6 +3051,7 @@ async def dispense_while_tracking(
30493051
volume: float,
30503052
push_out: Optional[float],
30513053
flow_rate: float = 1.0,
3054+
is_full_dispense: bool = False,
30523055
) -> None:
30533056
"""
30543057
Dispense a volume of liquid (in microliters/uL) while moving the z axis synchronously.
@@ -3060,7 +3063,7 @@ async def dispense_while_tracking(
30603063
"""
30613064
realmount = OT3Mount.from_mount(mount)
30623065
dispense_spec = self._pipette_handler.plan_check_dispense(
3063-
realmount, volume, flow_rate, push_out
3066+
realmount, volume, flow_rate, push_out, is_full_dispense
30643067
)
30653068
if not dispense_spec:
30663069
return

api/src/opentrons/hardware_control/protocols/liquid_handler.py

+2
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ async def dispense(
146146
rate: float = 1.0,
147147
push_out: Optional[float] = None,
148148
correction_volume: float = 0.0,
149+
is_full_dispense: bool = False,
149150
) -> None:
150151
"""
151152
Dispense a volume of liquid in microliters(uL) using this pipette
@@ -167,6 +168,7 @@ async def dispense_while_tracking(
167168
volume: float,
168169
push_out: Optional[float],
169170
flow_rate: float = 1.0,
171+
is_full_dispense: bool = False,
170172
) -> None:
171173
"""
172174
Dispense a volume of liquid (in microliters/uL) while moving the z axis synchronously.

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

+1-2
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,8 @@ async def execute(self, params: AirGapInPlaceParams) -> _ExecuteReturn:
8585
PipetteNotReadyToAirGapError: pipette plunger is not ready.
8686
"""
8787
ready_to_aspirate = self._pipetting.get_is_ready_to_aspirate(
88-
pipette_id=params.pipetteId,
88+
pipette_id=params.pipetteId
8989
)
90-
9190
if not ready_to_aspirate:
9291
raise PipetteNotReadyToAspirateError(
9392
"Pipette cannot air gap in place because of a previous blow out."

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ async def execute(self, params: AspirateInPlaceParams) -> _ExecuteReturn:
8383
PipetteNotReadyToAspirateError: pipette plunger is not ready.
8484
"""
8585
ready_to_aspirate = self._pipetting.get_is_ready_to_aspirate(
86-
pipette_id=params.pipetteId,
86+
pipette_id=params.pipetteId
8787
)
8888
if not ready_to_aspirate:
8989
raise PipetteNotReadyToAspirateError(

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ async def execute(self, params: AspirateWhileTrackingParams) -> _ExecuteReturn:
9191
TipNotAttachedError: if no tip is attached to the pipette.
9292
PipetteNotReadyToAspirateError: pipette plunger is not ready.
9393
"""
94-
ready_to_aspirate = self._pipetting.get_is_ready_to_aspirate(
95-
pipette_id=params.pipetteId,
94+
ready_to_aspirate = self._state_view.pipettes.get_ready_to_aspirate(
95+
pipette_id=params.pipetteId
9696
)
9797
if not ready_to_aspirate:
9898
raise PipetteNotReadyToAspirateError(

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

+2
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ async def execute(self, params: BlowOutParams) -> _ExecuteReturn:
115115
public=BlowOutResult(position=move_result.public.position),
116116
state_update=StateUpdate.reduce(
117117
move_result.state_update, blow_out_result.state_update
118+
).set_pipette_ready_to_aspirate(
119+
pipette_id=params.pipetteId, ready_to_aspirate=False
118120
),
119121
)
120122

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

+4-1
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ async def execute(self, params: BlowOutInPlaceParams) -> _ExecuteReturn:
8989
if isinstance(result, DefinedErrorData):
9090
return result
9191
return SuccessData(
92-
public=BlowOutInPlaceResult(), state_update=result.state_update
92+
public=BlowOutInPlaceResult(),
93+
state_update=result.state_update.set_pipette_ready_to_aspirate(
94+
pipette_id=params.pipetteId, ready_to_aspirate=False
95+
),
9396
)
9497

9598

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

+3
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ async def execute(
7474
config=pipette_result.static_config,
7575
serial_number=pipette_result.serial_number,
7676
)
77+
state_update.set_pipette_ready_to_aspirate(
78+
pipette_result.pipette_id, ready_to_aspirate=False
79+
)
7780

7881
return SuccessData(
7982
public=ConfigureForVolumeResult(),

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

-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
from typing import TYPE_CHECKING, Optional, Type, Union, Any
55
from typing_extensions import Literal
66

7-
87
from pydantic import Field
98
from pydantic.json_schema import SkipJsonSchema
109

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

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44
from typing import TYPE_CHECKING, Optional, Type, Union, Any
55
from typing_extensions import Literal
6+
67
from pydantic import Field
78
from pydantic.json_schema import SkipJsonSchema
89

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

+6
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ async def _execute_common( # noqa: C901
190190
well_location=params.wellLocation,
191191
)
192192
except PipetteLiquidNotFoundError as exception:
193+
move_result.state_update.set_pipette_ready_to_aspirate(
194+
pipette_id=pipette_id, ready_to_aspirate=True
195+
)
193196
return _ExecuteCommonResult(
194197
z_pos_or_error=exception,
195198
state_update=move_result.state_update,
@@ -223,6 +226,9 @@ async def _execute_common( # noqa: C901
223226
),
224227
)
225228
else:
229+
move_result.state_update.set_pipette_ready_to_aspirate(
230+
pipette_id=pipette_id, ready_to_aspirate=True
231+
)
226232
return _ExecuteCommonResult(
227233
z_pos_or_error=z_pos,
228234
state_update=move_result.state_update,

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

+3
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@ async def execute(
138138
config=loaded_pipette.static_config,
139139
)
140140
state_update.set_fluid_unknown(pipette_id=loaded_pipette.pipette_id)
141+
state_update.set_pipette_ready_to_aspirate(
142+
pipette_id=loaded_pipette.pipette_id, ready_to_aspirate=False
143+
),
141144

142145
return SuccessData(
143146
public=LoadPipetteResult(pipetteId=loaded_pipette.pipette_id),

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

+3
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,9 @@ async def execute(
189189
pipette_id=pipette_id, labware_id=labware_id, well_name=well_name
190190
)
191191
.set_fluid_empty(pipette_id=pipette_id)
192+
.set_pipette_ready_to_aspirate(
193+
pipette_id=pipette_id, ready_to_aspirate=True
194+
)
192195
)
193196
return SuccessData(
194197
public=PickUpTipResult(

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

+35-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44
from typing import Literal, Tuple, TYPE_CHECKING, Optional
5-
5+
import numpy
66
from typing_extensions import TypedDict
77
from pydantic import BaseModel, Field
88

@@ -292,6 +292,12 @@ async def dispense_while_tracking(
292292
model_utils: ModelUtils,
293293
) -> SuccessData[BaseLiquidHandlingResult] | DefinedErrorData[OverpressureError]:
294294
"""Execute an dispense while tracking microoperation."""
295+
# The current volume won't be none since it passed validation
296+
current_volume = (
297+
pipetting.get_state_view().pipettes.get_aspirated_volume(pipette_id) or 0.0
298+
)
299+
is_full_dispense = bool(numpy.isclose(current_volume - volume, 0))
300+
ready = push_out == 0 if push_out is not None else not is_full_dispense
295301
try:
296302
volume_dispensed = await pipetting.dispense_while_tracking(
297303
pipette_id=pipette_id,
@@ -300,6 +306,7 @@ async def dispense_while_tracking(
300306
volume=volume,
301307
flow_rate=flow_rate,
302308
push_out=push_out,
309+
is_full_dispense=is_full_dispense,
303310
)
304311
except PipetteOverpressureError as e:
305312
return DefinedErrorData(
@@ -315,16 +322,24 @@ async def dispense_while_tracking(
315322
],
316323
errorInfo=location_if_error,
317324
),
318-
state_update=StateUpdate().set_fluid_unknown(pipette_id=pipette_id),
325+
state_update=StateUpdate()
326+
.set_fluid_unknown(pipette_id=pipette_id)
327+
.set_pipette_ready_to_aspirate(
328+
pipette_id=pipette_id, ready_to_aspirate=False
329+
),
319330
)
320331
else:
321332
return SuccessData(
322333
public=BaseLiquidHandlingResult(
323334
volume=volume_dispensed,
324335
),
325-
state_update=StateUpdate().set_fluid_ejected(
336+
state_update=StateUpdate()
337+
.set_fluid_ejected(
326338
pipette_id=pipette_id,
327339
volume=volume_dispensed,
340+
)
341+
.set_pipette_ready_to_aspirate(
342+
pipette_id=pipette_id, ready_to_aspirate=ready
328343
),
329344
)
330345

@@ -340,12 +355,19 @@ async def dispense_in_place(
340355
model_utils: ModelUtils,
341356
) -> SuccessData[BaseLiquidHandlingResult] | DefinedErrorData[OverpressureError]:
342357
"""Dispense-in-place as a micro-operation."""
358+
# The current volume won't be none since it passed validation
359+
current_volume = (
360+
pipetting.get_state_view().pipettes.get_aspirated_volume(pipette_id) or 0.0
361+
)
362+
is_full_dispense = bool(numpy.isclose(current_volume - volume, 0))
363+
ready: bool = push_out == 0 if push_out is not None else not is_full_dispense
343364
try:
344365
volume = await pipetting.dispense_in_place(
345366
pipette_id=pipette_id,
346367
volume=volume,
347368
flow_rate=flow_rate,
348369
push_out=push_out,
370+
is_full_dispense=is_full_dispense,
349371
correction_volume=correction_volume,
350372
)
351373
except PipetteOverpressureError as e:
@@ -362,13 +384,20 @@ async def dispense_in_place(
362384
],
363385
errorInfo=location_if_error,
364386
),
365-
state_update=StateUpdate().set_fluid_unknown(pipette_id=pipette_id),
387+
state_update=StateUpdate()
388+
.set_fluid_unknown(pipette_id=pipette_id)
389+
.set_pipette_ready_to_aspirate(
390+
pipette_id=pipette_id, ready_to_aspirate=False
391+
),
366392
)
367393
else:
394+
print(f"Volume {volume}")
368395
return SuccessData(
369396
public=BaseLiquidHandlingResult(volume=volume),
370-
state_update=StateUpdate().set_fluid_ejected(
371-
pipette_id=pipette_id, volume=volume
397+
state_update=StateUpdate()
398+
.set_fluid_ejected(pipette_id=pipette_id, volume=volume)
399+
.set_pipette_ready_to_aspirate(
400+
pipette_id=pipette_id, ready_to_aspirate=ready
372401
),
373402
)
374403

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

+3-1
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ async def execute(self, params: PrepareToAspirateParams) -> _ExecuteReturn:
8484
else:
8585
return SuccessData(
8686
public=PrepareToAspirateResult(),
87-
state_update=prepare_result.state_update,
87+
state_update=prepare_result.state_update.set_pipette_ready_to_aspirate(
88+
pipette_id=params.pipetteId, ready_to_aspirate=True
89+
),
8890
)
8991

9092

api/src/opentrons/protocol_engine/commands/unsafe/unsafe_blow_out_in_place.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ async def execute(
6969
)
7070
state_update = update_types.StateUpdate()
7171
state_update.set_fluid_empty(pipette_id=params.pipetteId)
72-
72+
state_update.set_pipette_ready_to_aspirate(
73+
pipette_id=params.pipetteId, ready_to_aspirate=False
74+
)
7375
return SuccessData(
7476
public=UnsafeBlowOutInPlaceResult(), state_update=state_update
7577
)

0 commit comments

Comments
 (0)