Skip to content

Commit 5973646

Browse files
sfoster1ecormany
andauthored
feat(api): add load-empty-liquid (#16676)
We have a loadLiquid command and associated python protocol API method, which allow users to specify that a well contains a given amount of a given (predefined) liquid. This was all that we needed for a while because we didn't really do anything with that information except display the starting deck state based on it. Now that we track liquid, however, this isn't enough, because we also need a way for users to specify that a well is known to be empty. To that end, introduce a special EMPTY sentinel value for liquid ID and restrict loadLiquid commands that use it to setting a 0 volume. I think we probably should have a better API for setting multiple wells but that's not this PR. Closes EXEC-801 ## reviews - seem like a valid thing to do? Didn't make sense to have a whole different command ## testing - make sure empty liquids get loaded ## notes - [x] will rebase this PR to edge once the current target is merged --------- Co-authored-by: Ed Cormany <[email protected]>
1 parent beede80 commit 5973646

File tree

15 files changed

+167
-22
lines changed

15 files changed

+167
-22
lines changed

api/src/opentrons/protocol_api/core/engine/well.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ def load_liquid(
130130
liquid: Liquid,
131131
volume: float,
132132
) -> None:
133-
"""Load liquid into a well."""
133+
"""Load liquid into a well.
134+
135+
If the well is known to be empty, use ``load_empty()`` instead of calling this with a 0.0 volume.
136+
"""
134137
self._engine_client.execute_command(
135138
cmd.LoadLiquidParams(
136139
labwareId=self._labware_id,
@@ -139,6 +142,22 @@ def load_liquid(
139142
)
140143
)
141144

145+
def load_empty(
146+
self,
147+
) -> None:
148+
"""Inform the system that a well is known to be empty.
149+
150+
This should be done early in the protocol, at the same time as a load_liquid command might
151+
be used.
152+
"""
153+
self._engine_client.execute_command(
154+
cmd.LoadLiquidParams(
155+
labwareId=self._labware_id,
156+
liquidId="EMPTY",
157+
volumeByWell={self._name: 0.0},
158+
)
159+
)
160+
142161
def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
143162
"""Gets point in deck coordinates based on percentage of the radius of each axis."""
144163
well_size = self._engine_client.state.labware.get_well_size(

api/src/opentrons/protocol_api/core/legacy/legacy_well_core.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ def load_liquid(
114114
"""Load liquid into a well."""
115115
raise APIVersionError(api_element="Loading a liquid")
116116

117+
def load_empty(self) -> None:
118+
"""Mark a well as empty."""
119+
assert False, "load_empty only supported on engine core"
120+
117121
def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
118122
"""Gets point in deck coordinates based on percentage of the radius of each axis."""
119123
return self._geometry.from_center_cartesian(x, y, z)

api/src/opentrons/protocol_api/core/well.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ def load_liquid(
7979
) -> None:
8080
"""Load liquid into a well."""
8181

82+
@abstractmethod
83+
def load_empty(self) -> None:
84+
"""Mark a well as containing no liquid."""
85+
8286
@abstractmethod
8387
def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
8488
"""Gets point in deck coordinates based on percentage of the radius of each axis."""

api/src/opentrons/protocol_api/labware.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,12 +280,20 @@ def load_liquid(self, liquid: Liquid, volume: float) -> None:
280280
281281
:param Liquid liquid: The liquid to load into the well.
282282
:param float volume: The volume of liquid to load, in µL.
283+
284+
.. note::
285+
In API version 2.22 and later, use :py:meth:`~.Well.load_empty()` to mark a well as empty at the beginning of a protocol, rather than using this method with ``volume=0``.
283286
"""
284287
self._core.load_liquid(
285288
liquid=liquid,
286289
volume=volume,
287290
)
288291

292+
@requires_version(2, 22)
293+
def load_empty(self) -> None:
294+
"""Mark a well as empty."""
295+
self._core.load_empty()
296+
289297
def _from_center_cartesian(self, x: float, y: float, z: float) -> Point:
290298
"""
291299
Private version of from_center_cartesian. Present only for backward

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from typing_extensions import Literal
66

77
from opentrons.protocol_engine.state.update_types import StateUpdate
8+
from opentrons.protocol_engine.types import LiquidId
9+
from opentrons.protocol_engine.errors import InvalidLiquidError
810

911
from .command import AbstractCommandImpl, BaseCommand, BaseCommandCreate, SuccessData
1012
from ..errors.error_occurrence import ErrorOccurrence
@@ -19,17 +21,17 @@
1921
class LoadLiquidParams(BaseModel):
2022
"""Payload required to load a liquid into a well."""
2123

22-
liquidId: str = Field(
24+
liquidId: LiquidId = Field(
2325
...,
24-
description="Unique identifier of the liquid to load.",
26+
description="Unique identifier of the liquid to load. If this is the sentinel value EMPTY, all values of volumeByWell must be 0.",
2527
)
2628
labwareId: str = Field(
2729
...,
2830
description="Unique identifier of labware to load liquid into.",
2931
)
3032
volumeByWell: Dict[str, float] = Field(
3133
...,
32-
description="Volume of liquid, in µL, loaded into each well by name, in this labware.",
34+
description="Volume of liquid, in µL, loaded into each well by name, in this labware. If the liquid id is the sentinel value EMPTY, all volumes must be 0.",
3335
)
3436

3537

@@ -57,6 +59,12 @@ async def execute(self, params: LoadLiquidParams) -> SuccessData[LoadLiquidResul
5759
self._state_view.labware.validate_liquid_allowed_in_labware(
5860
labware_id=params.labwareId, wells=params.volumeByWell
5961
)
62+
if params.liquidId == "EMPTY":
63+
for well_name, volume in params.volumeByWell.items():
64+
if volume != 0.0:
65+
raise InvalidLiquidError(
66+
'loadLiquid commands that specify the special liquid "EMPTY" must set volume to be 0.0, but the volume for {well_name} is {volume}'
67+
)
6068

6169
state_update = StateUpdate()
6270
state_update.set_liquid_loaded(

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
OperationLocationNotInWellError,
7878
InvalidDispenseVolumeError,
7979
StorageLimitReachedError,
80+
InvalidLiquidError,
8081
)
8182

8283
from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError
@@ -137,6 +138,7 @@
137138
"InvalidTargetSpeedError",
138139
"InvalidBlockVolumeError",
139140
"InvalidHoldTimeError",
141+
"InvalidLiquidError",
140142
"CannotPerformModuleAction",
141143
"ResumeFromRecoveryNotAllowedError",
142144
"PauseNotAllowedError",

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,19 @@ def __init__(
244244
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
245245

246246

247+
class InvalidLiquidError(ProtocolEngineError):
248+
"""Raised when attempting to add a liquid with an invalid property."""
249+
250+
def __init__(
251+
self,
252+
message: Optional[str] = None,
253+
details: Optional[Dict[str, Any]] = None,
254+
wrapping: Optional[Sequence[EnumeratedError]] = None,
255+
) -> None:
256+
"""Build an InvalidLiquidError."""
257+
super().__init__(ErrorCodes.INVALID_PROTOCOL_DATA, message, details, wrapping)
258+
259+
247260
class LabwareDefinitionDoesNotExistError(ProtocolEngineError):
248261
"""Raised when referencing a labware definition that does not exist."""
249262

api/src/opentrons/protocol_engine/protocol_engine.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -566,9 +566,12 @@ def add_liquid(
566566
description=(description or ""),
567567
displayColor=color,
568568
)
569+
validated_liquid = self._state_store.liquid.validate_liquid_allowed(
570+
liquid=liquid
571+
)
569572

570-
self._action_dispatcher.dispatch(AddLiquidAction(liquid=liquid))
571-
return liquid
573+
self._action_dispatcher.dispatch(AddLiquidAction(liquid=validated_liquid))
574+
return validated_liquid
572575

573576
def add_addressable_area(self, addressable_area_name: str) -> None:
574577
"""Add an addressable area to state."""

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
"""Basic liquid data state and store."""
22
from dataclasses import dataclass
33
from typing import Dict, List
4-
from opentrons.protocol_engine.types import Liquid
4+
from opentrons.protocol_engine.types import Liquid, LiquidId
55

66
from ._abstract_store import HasState, HandlesActions
77
from ..actions import Action, AddLiquidAction
8-
from ..errors import LiquidDoesNotExistError
8+
from ..errors import LiquidDoesNotExistError, InvalidLiquidError
99

1010

1111
@dataclass
@@ -51,11 +51,23 @@ def get_all(self) -> List[Liquid]:
5151
"""Get all protocol liquids."""
5252
return list(self._state.liquids_by_id.values())
5353

54-
def validate_liquid_id(self, liquid_id: str) -> str:
54+
def validate_liquid_id(self, liquid_id: LiquidId) -> LiquidId:
5555
"""Check if liquid_id exists in liquids."""
56+
is_empty = liquid_id == "EMPTY"
57+
if is_empty:
58+
return liquid_id
5659
has_liquid = liquid_id in self._state.liquids_by_id
5760
if not has_liquid:
5861
raise LiquidDoesNotExistError(
5962
f"Supplied liquidId: {liquid_id} does not exist in the loaded liquids."
6063
)
6164
return liquid_id
65+
66+
def validate_liquid_allowed(self, liquid: Liquid) -> Liquid:
67+
"""Validate that a liquid is legal to load."""
68+
is_empty = liquid.id == "EMPTY"
69+
if is_empty:
70+
raise InvalidLiquidError(
71+
message='Protocols may not define a liquid with the special id "EMPTY".'
72+
)
73+
return liquid

api/src/opentrons/protocol_engine/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,10 @@ def _color_is_a_valid_hex(cls, v: str) -> str:
828828
return v
829829

830830

831+
EmptyLiquidId = Literal["EMPTY"]
832+
LiquidId = str | EmptyLiquidId
833+
834+
831835
class Liquid(BaseModel):
832836
"""Payload required to create a liquid."""
833837

0 commit comments

Comments
 (0)