Skip to content

Commit c450050

Browse files
caila-marashajandySigler
authored andcommitted
feat(api): add new user-facing helper functions for static liquid tracking (#17360)
1 parent dd148b6 commit c450050

File tree

16 files changed

+389
-11
lines changed

16 files changed

+389
-11
lines changed

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

+21
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,27 @@ def detect_liquid_presence(self, well_core: WellCore, loc: Location) -> bool:
10601060

10611061
return result.z_position is not None
10621062

1063+
def get_minimum_liquid_sense_height(self) -> float:
1064+
attached_tip = self._engine_client.state.pipettes.get_attached_tip(
1065+
self._pipette_id
1066+
)
1067+
if attached_tip:
1068+
tip_volume = attached_tip.volume
1069+
else:
1070+
raise TipNotAttachedError(
1071+
"Need to have a tip attached for liquid-sense operations."
1072+
)
1073+
lld_settings = self._engine_client.state.pipettes.get_pipette_lld_settings(
1074+
pipette_id=self.pipette_id
1075+
)
1076+
if lld_settings:
1077+
lld_min_height_for_tip_attached = lld_settings[f"t{tip_volume}"][
1078+
"minHeight"
1079+
]
1080+
return lld_min_height_for_tip_attached
1081+
else:
1082+
raise ValueError("liquid-level detection settings not found.")
1083+
10631084
def liquid_probe_with_recovery(self, well_core: WellCore, loc: Location) -> None:
10641085
labware_id = well_core.labware_id
10651086
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
@@ -243,6 +243,10 @@ def get_current_volume(self) -> float:
243243
def get_available_volume(self) -> float:
244244
...
245245

246+
@abstractmethod
247+
def get_minimum_liquid_sense_height(self) -> float:
248+
...
249+
246250
@abstractmethod
247251
def get_hardware_state(self) -> PipetteDict:
248252
"""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
@@ -667,3 +667,14 @@ def _pressure_supported_by_pipette(self) -> bool:
667667
def nozzle_configuration_valid_for_lld(self) -> bool:
668668
"""Check if the nozzle configuration currently supports LLD."""
669669
return False
670+
671+
def get_minimum_liquid_sense_height(self) -> float:
672+
return 0.0
673+
674+
def estimate_liquid_height(
675+
self,
676+
well_core: LegacyWellCore,
677+
starting_liquid_height: float,
678+
operation_volume: float,
679+
) -> float:
680+
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
@@ -559,3 +559,15 @@ def _pressure_supported_by_pipette(self) -> bool:
559559
def nozzle_configuration_valid_for_lld(self) -> bool:
560560
"""Check if the nozzle configuration currently supports LLD."""
561561
return False
562+
563+
def get_minimum_liquid_sense_height(self) -> float:
564+
return 0.0
565+
566+
def estimate_liquid_height(
567+
self,
568+
well_core: LegacyWellCore,
569+
starting_liquid_height: float,
570+
operation_volume: float,
571+
) -> float:
572+
"""This will never be called because it was added in API 2.21."""
573+
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
@@ -166,6 +166,11 @@ def default_speed(self) -> float:
166166
def default_speed(self, speed: float) -> None:
167167
self._core.set_default_speed(speed)
168168

169+
@requires_version(2, 21)
170+
def get_minimum_liquid_sense_height(self) -> float:
171+
"""Get the minimum allowed height for liquid-level detection."""
172+
return self._core.get_minimum_liquid_sense_height()
173+
169174
@requires_version(2, 0)
170175
def aspirate(
171176
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
@@ -148,7 +148,6 @@ async def execute(self, params: AspirateParams) -> _ExecuteReturn:
148148
labware_id=labware_id,
149149
well_name=well_name,
150150
)
151-
152151
move_result = await move_to_well(
153152
movement=self._movement,
154153
model_utils=self._model_utils,

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

+2
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
CommandNotAllowedError,
7777
InvalidLiquidHeightFound,
7878
LiquidHeightUnknownError,
79+
LiquidVolumeUnknownError,
7980
IncompleteLabwareDefinitionError,
8081
IncompleteWellDefinitionError,
8182
OperationLocationNotInWellError,
@@ -169,6 +170,7 @@
169170
"CommandNotAllowedError",
170171
"InvalidLiquidHeightFound",
171172
"LiquidHeightUnknownError",
173+
"LiquidVolumeUnknownError",
172174
"IncompleteLabwareDefinitionError",
173175
"IncompleteWellDefinitionError",
174176
"OperationLocationNotInWellError",

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

+13
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,19 @@ def __init__(
11141114
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
11151115

11161116

1117+
class LiquidVolumeUnknownError(ProtocolEngineError):
1118+
"""Raised when attempting to report an unknown liquid volume."""
1119+
1120+
def __init__(
1121+
self,
1122+
message: Optional[str] = None,
1123+
details: Optional[Dict[str, Any]] = None,
1124+
wrapping: Optional[Sequence[EnumeratedError]] = None,
1125+
) -> None:
1126+
"""Build a LiquidVolumeUnknownError."""
1127+
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
1128+
1129+
11171130
class EStopActivatedError(ProtocolEngineError):
11181131
"""Represents an E-stop event."""
11191132

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

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

+67-2
Original file line numberDiff line numberDiff line change
@@ -1431,7 +1431,7 @@ def get_well_offset_adjustment(
14311431
volume = operation_volume or 0.0
14321432

14331433
if volume:
1434-
return self.get_well_height_after_volume(
1434+
return self.get_well_height_after_liquid_handling(
14351435
labware_id=labware_id,
14361436
well_name=well_name,
14371437
initial_height=initial_handling_height,
@@ -1440,6 +1440,49 @@ def get_well_offset_adjustment(
14401440
else:
14411441
return initial_handling_height
14421442

1443+
def get_current_well_volume(
1444+
self,
1445+
labware_id: str,
1446+
well_name: str,
1447+
) -> float:
1448+
"""Returns most recently updated volume in specified well."""
1449+
last_updated = self._wells.get_last_liquid_update(labware_id, well_name)
1450+
if last_updated is None:
1451+
raise errors.LiquidHeightUnknownError(
1452+
"Must LiquidProbe or LoadLiquid before specifying WellOrigin.MENISCUS."
1453+
)
1454+
1455+
well_liquid = self._wells.get_well_liquid_info(
1456+
labware_id=labware_id, well_name=well_name
1457+
)
1458+
if (
1459+
well_liquid.probed_height is not None
1460+
and well_liquid.probed_height.height is not None
1461+
and well_liquid.probed_height.last_probed == last_updated
1462+
):
1463+
return self.get_well_volume_at_height(
1464+
labware_id=labware_id,
1465+
well_name=well_name,
1466+
height=well_liquid.probed_height.height,
1467+
)
1468+
elif (
1469+
well_liquid.loaded_volume is not None
1470+
and well_liquid.loaded_volume.volume is not None
1471+
and well_liquid.loaded_volume.last_loaded == last_updated
1472+
):
1473+
return well_liquid.loaded_volume.volume
1474+
elif (
1475+
well_liquid.probed_volume is not None
1476+
and well_liquid.probed_volume.volume is not None
1477+
and well_liquid.probed_volume.last_probed == last_updated
1478+
):
1479+
return well_liquid.probed_volume.volume
1480+
else:
1481+
# This should not happen if there was an update but who knows
1482+
raise errors.LiquidVolumeUnknownError(
1483+
f"Unable to find liquid volume despite an update at {last_updated}."
1484+
)
1485+
14431486
def get_meniscus_height(
14441487
self,
14451488
labware_id: str,
@@ -1496,7 +1539,7 @@ def get_well_handling_height(
14961539
)
14971540
return float(handling_height)
14981541

1499-
def get_well_height_after_volume(
1542+
def get_well_height_after_liquid_handling(
15001543
self, labware_id: str, well_name: str, initial_height: float, volume: float
15011544
) -> float:
15021545
"""Return the height of liquid in a labware well after a given volume has been handled.
@@ -1514,6 +1557,28 @@ def get_well_height_after_volume(
15141557
target_volume=final_volume, well_geometry=well_geometry
15151558
)
15161559

1560+
def get_well_height_after_liquid_handling_no_error(
1561+
self, labware_id: str, well_name: str, initial_height: float, volume: float
1562+
) -> float:
1563+
"""Return what the height of liquid in a labware well after liquid handling will be.
1564+
1565+
This raises no error if the value returned is an invalid physical location, so it should never be
1566+
used for navigation, only for a pre-emptive estimate.
1567+
"""
1568+
well_geometry = self._labware.get_well_geometry(
1569+
labware_id=labware_id, well_name=well_name
1570+
)
1571+
initial_volume = find_volume_at_well_height(
1572+
target_height=initial_height, well_geometry=well_geometry
1573+
)
1574+
final_volume = initial_volume + volume
1575+
well_volume = find_height_at_well_volume(
1576+
target_volume=final_volume,
1577+
well_geometry=well_geometry,
1578+
raise_error_if_result_invalid=False,
1579+
)
1580+
return well_volume
1581+
15171582
def get_well_height_at_volume(
15181583
self, labware_id: str, well_name: str, volume: float
15191584
) -> float:

0 commit comments

Comments
 (0)