Skip to content

Commit b4831cf

Browse files
feat(api): add new user-facing helper functions for static liquid tracking (#17360)
1 parent 3bab4df commit b4831cf

File tree

16 files changed

+390
-20
lines changed

16 files changed

+390
-20
lines changed

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

+22-9
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import (
6-
Optional,
7-
TYPE_CHECKING,
8-
cast,
9-
Union,
10-
List,
11-
Tuple,
12-
NamedTuple,
13-
)
5+
from typing import Optional, TYPE_CHECKING, cast, Union, List, Tuple, NamedTuple
146
from opentrons.types import Location, Mount, NozzleConfigurationType, NozzleMapInterface
157
from opentrons.hardware_control import SyncHardwareAPI
168
from opentrons.hardware_control.dev_types import PipetteDict
@@ -1369,6 +1361,27 @@ def detect_liquid_presence(self, well_core: WellCore, loc: Location) -> bool:
13691361

13701362
return result.z_position is not None
13711363

1364+
def get_minimum_liquid_sense_height(self) -> float:
1365+
attached_tip = self._engine_client.state.pipettes.get_attached_tip(
1366+
self._pipette_id
1367+
)
1368+
if attached_tip:
1369+
tip_volume = attached_tip.volume
1370+
else:
1371+
raise TipNotAttachedError(
1372+
"Need to have a tip attached for liquid-sense operations."
1373+
)
1374+
lld_settings = self._engine_client.state.pipettes.get_pipette_lld_settings(
1375+
pipette_id=self.pipette_id
1376+
)
1377+
if lld_settings:
1378+
lld_min_height_for_tip_attached = lld_settings[f"t{tip_volume}"][
1379+
"minHeight"
1380+
]
1381+
return lld_min_height_for_tip_attached
1382+
else:
1383+
raise ValueError("liquid-level detection settings not found.")
1384+
13721385
def liquid_probe_with_recovery(self, well_core: WellCore, loc: Location) -> None:
13731386
labware_id = well_core.labware_id
13741387
well_name = well_core.get_name()

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

+32
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,35 @@ def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
155155
y_ratio=y,
156156
z_ratio=z,
157157
)
158+
159+
def estimate_liquid_height_after_pipetting(
160+
self,
161+
operation_volume: float,
162+
) -> float:
163+
"""Return an estimate of liquid height after pipetting without raising an error."""
164+
labware_id = self.labware_id
165+
well_name = self._name
166+
starting_liquid_height = self.current_liquid_height()
167+
projected_final_height = self._engine_client.state.geometry.get_well_height_after_liquid_handling_no_error(
168+
labware_id=labware_id,
169+
well_name=well_name,
170+
initial_height=starting_liquid_height,
171+
volume=operation_volume,
172+
)
173+
return projected_final_height
174+
175+
def current_liquid_height(self) -> float:
176+
"""Return the current liquid height within a well."""
177+
labware_id = self.labware_id
178+
well_name = self._name
179+
return self._engine_client.state.geometry.get_meniscus_height(
180+
labware_id=labware_id, well_name=well_name
181+
)
182+
183+
def get_liquid_volume(self) -> float:
184+
"""Return the current volume in a well."""
185+
labware_id = self.labware_id
186+
well_name = self._name
187+
return self._engine_client.state.geometry.get_current_well_volume(
188+
labware_id=labware_id, well_name=well_name
189+
)

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

+4
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ def get_current_volume(self) -> float:
225225
def get_available_volume(self) -> float:
226226
...
227227

228+
@abstractmethod
229+
def get_minimum_liquid_sense_height(self) -> float:
230+
...
231+
228232
@abstractmethod
229233
def get_hardware_state(self) -> PipetteDict:
230234
"""Get the current state of the pipette hardware as a dictionary."""

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

+11
Original file line numberDiff line numberDiff line change
@@ -643,3 +643,14 @@ def _pressure_supported_by_pipette(self) -> bool:
643643
def nozzle_configuration_valid_for_lld(self) -> bool:
644644
"""Check if the nozzle configuration currently supports LLD."""
645645
return False
646+
647+
def get_minimum_liquid_sense_height(self) -> float:
648+
return 0.0
649+
650+
def estimate_liquid_height(
651+
self,
652+
well_core: LegacyWellCore,
653+
starting_liquid_height: float,
654+
operation_volume: float,
655+
) -> float:
656+
return 0.0

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

+15
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
118118
"""Gets point in deck coordinates based on percentage of the radius of each axis."""
119119
return self._geometry.from_center_cartesian(x, y, z)
120120

121+
def estimate_liquid_height_after_pipetting(
122+
self,
123+
operation_volume: float,
124+
) -> float:
125+
"""Estimate what the liquid height will be after pipetting, without raising an error."""
126+
return 0.0
127+
128+
def current_liquid_height(self) -> float:
129+
"""Get the current liquid height."""
130+
return 0.0
131+
132+
def get_liquid_volume(self) -> float:
133+
"""Get the current well volume."""
134+
return 0.0
135+
121136
# TODO(mc, 2022-10-28): is this used and/or necessary?
122137
def __repr__(self) -> str:
123138
"""Use the well's display name as its repr."""

api/src/opentrons/protocol_api/core/legacy_simulator/legacy_instrument_core.py

+12
Original file line numberDiff line numberDiff line change
@@ -561,3 +561,15 @@ def _pressure_supported_by_pipette(self) -> bool:
561561
def nozzle_configuration_valid_for_lld(self) -> bool:
562562
"""Check if the nozzle configuration currently supports LLD."""
563563
return False
564+
565+
def get_minimum_liquid_sense_height(self) -> float:
566+
return 0.0
567+
568+
def estimate_liquid_height(
569+
self,
570+
well_core: LegacyWellCore,
571+
starting_liquid_height: float,
572+
operation_volume: float,
573+
) -> float:
574+
"""This will never be called because it was added in API 2.21."""
575+
assert False, "estimate_liquid_height only supported in API 2.21 & later"

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

+15
Original file line numberDiff line numberDiff line change
@@ -83,5 +83,20 @@ def load_liquid(
8383
def from_center_cartesian(self, x: float, y: float, z: float) -> Point:
8484
"""Gets point in deck coordinates based on percentage of the radius of each axis."""
8585

86+
@abstractmethod
87+
def estimate_liquid_height_after_pipetting(
88+
self,
89+
operation_volume: float,
90+
) -> float:
91+
"""Estimate what the liquid height will be after pipetting, without raising an error."""
92+
93+
@abstractmethod
94+
def current_liquid_height(self) -> float:
95+
"""Get the current liquid height."""
96+
97+
@abstractmethod
98+
def get_liquid_volume(self) -> float:
99+
"""Get the current volume within a well."""
100+
86101

87102
WellCoreType = TypeVar("WellCoreType", bound=AbstractWellCore)

api/src/opentrons/protocol_api/instrument_context.py

+5
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ def default_speed(self) -> float:
165165
def default_speed(self, speed: float) -> None:
166166
self._core.set_default_speed(speed)
167167

168+
@requires_version(2, 21)
169+
def get_minimum_liquid_sense_height(self) -> float:
170+
"""Get the minimum allowed height for liquid-level detection."""
171+
return self._core.get_minimum_liquid_sense_height()
172+
168173
@requires_version(2, 0)
169174
def aspirate(
170175
self,

api/src/opentrons/protocol_api/labware.py

+30-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636
UnsupportedAPIError,
3737
)
3838

39-
4039
# TODO(mc, 2022-09-02): re-exports provided for backwards compatibility
4140
# remove when their usage is no longer needed
4241
from opentrons.protocols.labware import ( # noqa: F401
@@ -49,7 +48,10 @@
4948
from ._liquid import Liquid
5049
from ._types import OffDeckType
5150
from .core import well_grid
52-
from .core.engine import ENGINE_CORE_API_VERSION, SET_OFFSET_RESTORED_API_VERSION
51+
from .core.engine import (
52+
ENGINE_CORE_API_VERSION,
53+
SET_OFFSET_RESTORED_API_VERSION,
54+
)
5355
from .core.labware import AbstractLabware
5456
from .core.module import AbstractModuleCore
5557
from .core.core_map import LoadedCoreMap
@@ -301,6 +303,32 @@ def load_liquid(self, liquid: Liquid, volume: float) -> None:
301303
volume=volume,
302304
)
303305

306+
@requires_version(2, 21)
307+
def current_liquid_height(self) -> float:
308+
"""Get the current liquid height in a well."""
309+
return self._core.current_liquid_height()
310+
311+
@requires_version(2, 21)
312+
def current_liquid_volume(self) -> float:
313+
"""Get the current liquid volume in a well."""
314+
return self._core.get_liquid_volume()
315+
316+
@requires_version(2, 21)
317+
def estimate_liquid_height_after_pipetting(self, operation_volume: float) -> float:
318+
"""Check the height of the liquid within a well.
319+
320+
:returns: The height, in mm, of the liquid from the deck.
321+
322+
:meta private:
323+
324+
This is intended for Opentrons internal use only and is not a guaranteed API.
325+
"""
326+
327+
projected_final_height = self._core.estimate_liquid_height_after_pipetting(
328+
operation_volume=operation_volume,
329+
)
330+
return projected_final_height
331+
304332
def _from_center_cartesian(self, x: float, y: float, z: float) -> Point:
305333
"""
306334
Private version of from_center_cartesian. Present only for backward

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

-1
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,6 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
152152
labware_id=labware_id,
153153
well_name=well_name,
154154
)
155-
156155
move_result = await move_to_well(
157156
movement=self._movement,
158157
model_utils=self._model_utils,

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

+2
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
CommandNotAllowedError,
7575
InvalidLiquidHeightFound,
7676
LiquidHeightUnknownError,
77+
LiquidVolumeUnknownError,
7778
IncompleteLabwareDefinitionError,
7879
IncompleteWellDefinitionError,
7980
OperationLocationNotInWellError,
@@ -167,6 +168,7 @@
167168
"CommandNotAllowedError",
168169
"InvalidLiquidHeightFound",
169170
"LiquidHeightUnknownError",
171+
"LiquidVolumeUnknownError",
170172
"IncompleteLabwareDefinitionError",
171173
"IncompleteWellDefinitionError",
172174
"OperationLocationNotInWellError",

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

+13
Original file line numberDiff line numberDiff line change
@@ -1101,6 +1101,19 @@ def __init__(
11011101
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
11021102

11031103

1104+
class LiquidVolumeUnknownError(ProtocolEngineError):
1105+
"""Raised when attempting to report an unknown liquid volume."""
1106+
1107+
def __init__(
1108+
self,
1109+
message: Optional[str] = None,
1110+
details: Optional[Dict[str, Any]] = None,
1111+
wrapping: Optional[Sequence[EnumeratedError]] = None,
1112+
) -> None:
1113+
"""Build a LiquidVolumeUnknownError."""
1114+
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
1115+
1116+
11041117
class EStopActivatedError(ProtocolEngineError):
11051118
"""Represents an E-stop event."""
11061119

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

+8-6
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,8 @@ def _get_segment_capacity(segment: WellSegment) -> float:
241241
def get_well_volumetric_capacity(
242242
well_geometry: InnerWellGeometry,
243243
) -> List[Tuple[float, float]]:
244-
"""Return the total volumetric capacity of a well as a map of height borders to volume."""
245-
# dictionary map of heights to volumetric capacities within their respective segment
246-
# {top_height_0: volume_0, top_height_1: volume_1, top_height_2: volume_2}
244+
"""Return the volumetric capacity of a well as a list of pairs relating segment heights to volumes."""
245+
# [(top_height_0, section_0_volume), (top_height_1, section_1_volume), ...]
247246
well_volume = []
248247

249248
# get the well segments sorted in ascending order
@@ -417,13 +416,16 @@ def _find_height_in_partial_frustum(
417416

418417

419418
def find_height_at_well_volume(
420-
target_volume: float, well_geometry: InnerWellGeometry
419+
target_volume: float,
420+
well_geometry: InnerWellGeometry,
421+
raise_error_if_result_invalid: bool = True,
421422
) -> float:
422423
"""Find the height within a well, at a known volume."""
423424
volumetric_capacity = get_well_volumetric_capacity(well_geometry)
424425
max_volume = sum(row[1] for row in volumetric_capacity)
425-
if target_volume < 0 or target_volume > max_volume:
426-
raise InvalidLiquidHeightFound("Invalid target volume.")
426+
if raise_error_if_result_invalid:
427+
if target_volume < 0 or target_volume > max_volume:
428+
raise InvalidLiquidHeightFound("Invalid target volume.")
427429

428430
sorted_well = sorted(well_geometry.sections, key=lambda section: section.topHeight)
429431
# find the section the target volume is in and compute the height

0 commit comments

Comments
 (0)