Skip to content

Commit 252d849

Browse files
refactor(api): silently recover from invalid liquid heights after aspirating/dispensing (#18292)
1 parent 1715bfe commit 252d849

File tree

5 files changed

+188
-146
lines changed

5 files changed

+188
-146
lines changed

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,12 +197,14 @@ def estimate_liquid_height_after_pipetting(
197197
raise PipetteNotAttachedError(f"No pipette present on mount {mount}")
198198
pipette_id = pipette_from_mount.id
199199
starting_liquid_height = self.current_liquid_height()
200-
projected_final_height = self._engine_client.state.geometry.get_well_height_after_liquid_handling_no_error(
201-
labware_id=labware_id,
202-
well_name=well_name,
203-
pipette_id=pipette_id,
204-
initial_height=starting_liquid_height,
205-
volume=operation_volume,
200+
projected_final_height = (
201+
self._engine_client.state.geometry.get_well_height_after_liquid_handling(
202+
labware_id=labware_id,
203+
well_name=well_name,
204+
pipette_id=pipette_id,
205+
initial_height=starting_liquid_height,
206+
volume=operation_volume,
207+
)
206208
)
207209
return projected_final_height
208210

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,8 @@ def _find_height_in_partial_frustum(
423423
# if we finish looping through the whole well, bottom_section will be the well's volume
424424
total_well_volume = bottom_section_volume
425425
# if we've looked through all sections and can't find the target volume, raise an error
426-
# also this code should never be reached bc an error should be raised by find_height_at_well_volume
426+
# also this code should never be reached bc an invalid target volume should be changed
427+
# by find_height_at_well_volume
427428
raise InvalidLiquidHeightFound(
428429
f"Target volume {target_volume} uL exceeds the well volume {total_well_volume} uL."
429430
)
@@ -432,7 +433,6 @@ def _find_height_in_partial_frustum(
432433
def find_height_at_well_volume(
433434
target_volume: LiquidTrackingType,
434435
well_geometry: InnerWellGeometry,
435-
raise_error_if_result_invalid: bool = True,
436436
) -> LiquidTrackingType:
437437
"""Find the height within a well, at a known volume."""
438438
# comparisons with SimulatedProbeResult objects aren't meaningful, just
@@ -443,12 +443,10 @@ def find_height_at_well_volume(
443443
volumetric_capacity = get_well_volumetric_capacity(well_geometry)
444444
max_volume = sum(row[1] for row in volumetric_capacity)
445445

446-
if raise_error_if_result_invalid:
447-
if target_volume < 0 or target_volume > max_volume:
448-
raise InvalidLiquidHeightFound(
449-
f"Invalid target volume {target_volume} uL; max volume is {max_volume} uL"
450-
)
451-
446+
if target_volume < 0:
447+
target_volume = 0
448+
elif target_volume > max_volume:
449+
target_volume = max_volume
452450
sorted_well = sorted(well_geometry.sections, key=lambda section: section.topHeight)
453451
# find the section the target volume is in and compute the height
454452
return _find_height_in_partial_frustum(

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

Lines changed: 27 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from typing import Optional, List, Tuple, Union, cast, TypeVar, Dict, Set
88
from dataclasses import dataclass
99
from functools import cached_property
10-
from math import isclose
1110

1211
from opentrons.types import (
1312
Point,
@@ -33,7 +32,6 @@
3332
LabwareNotLoadedOnLabwareError,
3433
LabwareNotLoadedOnModuleError,
3534
LabwareMovementNotAllowedError,
36-
OperationLocationNotInWellError,
3735
InvalidLabwarePositionError,
3836
LabwareNotOnDeckError,
3937
)
@@ -522,39 +520,23 @@ def get_labware_position(self, labware_id: str) -> Point:
522520
z=origin_pos.z + cal_offset.z,
523521
)
524522

525-
def validate_well_position(
523+
def _validate_well_position(
526524
self,
527-
well_location: WellLocationType,
528-
z_offset: float,
529-
pipette_id: Optional[str] = None,
530-
) -> None:
531-
"""Raise exception if operation location is not within well.
532-
533-
Primarily this checks if there is not enough liquid in a well to do meniscus-relative static aspiration.
534-
"""
535-
if well_location.origin == WellOrigin.MENISCUS:
536-
assert pipette_id is not None, "pipette id is None"
537-
lld_min_height = self._pipettes.get_current_tip_lld_settings(
538-
pipette_id=pipette_id
539-
)
540-
if z_offset < lld_min_height:
541-
if isinstance(well_location, LiquidHandlingWellLocation):
542-
raise OperationLocationNotInWellError(
543-
f"Specifying {well_location.origin} with a height offset of {well_location.offset.z} results in a height of {z_offset} mm; the minimum allowed height for liquid tracking is {lld_min_height} mm"
544-
)
545-
else:
546-
raise OperationLocationNotInWellError(
547-
f"Specifying {well_location.origin} with an offset of {well_location.offset} results in an operation location that could be below the bottom of the well"
548-
)
549-
elif z_offset < 0 and not isclose(z_offset, 0, abs_tol=0.0000001):
550-
if isinstance(well_location, LiquidHandlingWellLocation):
551-
raise OperationLocationNotInWellError(
552-
f"Specifying {well_location.origin} with an offset of {well_location.offset} and a volume offset of {well_location.volumeOffset} results in an operation location below the bottom of the well"
553-
)
554-
else:
555-
raise OperationLocationNotInWellError(
556-
f"Specifying {well_location.origin} with an offset of {well_location.offset} results in an operation location below the bottom of the well"
557-
)
525+
target_height: LiquidTrackingType, # height in mm inside a well relative to the bottom
526+
well_max_height: float,
527+
pipette_id: str,
528+
) -> LiquidTrackingType:
529+
"""If well offset would be outside the bounds of a well, silently bring it back to the boundary."""
530+
if isinstance(target_height, SimulatedProbeResult):
531+
return target_height
532+
lld_min_height = self._pipettes.get_current_tip_lld_settings(
533+
pipette_id=pipette_id
534+
)
535+
if target_height < lld_min_height:
536+
target_height = lld_min_height
537+
elif target_height > well_max_height:
538+
target_height = well_max_height
539+
return target_height
558540

559541
def validate_probed_height(
560542
self,
@@ -595,7 +577,7 @@ def get_well_position(
595577

596578
offset = WellOffset(x=0, y=0, z=well_depth)
597579
if well_location is not None:
598-
offset = well_location.offset
580+
offset = well_location.offset # location of the bottom of the well
599581
offset_adjustment = self.get_well_offset_adjustment(
600582
labware_id=labware_id,
601583
well_name=well_name,
@@ -606,11 +588,6 @@ def get_well_position(
606588
)
607589
if not isinstance(offset_adjustment, SimulatedProbeResult):
608590
offset = offset.model_copy(update={"z": offset.z + offset_adjustment})
609-
self.validate_well_position(
610-
well_location=well_location,
611-
z_offset=offset.z,
612-
pipette_id=pipette_id,
613-
)
614591
return Point(
615592
x=labware_pos.x + offset.x + well_def.x,
616593
y=labware_pos.y + offset.y + well_def.y,
@@ -2107,6 +2084,8 @@ def get_well_height_after_liquid_handling(
21072084
21082085
This is given an initial handling height, with reference to the well bottom.
21092086
"""
2087+
well_def = self._labware.get_well_definition(labware_id, well_name)
2088+
well_depth = well_def.depth
21102089
well_geometry = self._labware.get_well_geometry(
21112090
labware_id=labware_id, well_name=well_name
21122091
)
@@ -2122,49 +2101,17 @@ def get_well_height_after_liquid_handling(
21222101
pipette_id=pipette_id,
21232102
)
21242103
)
2125-
return find_height_at_well_volume(
2104+
# NOTE(cm): if final_volume is outside the bounds of the well, it will get
2105+
# adjusted inside find_height_at_well_volume to accomodate well the height
2106+
# calculation.
2107+
height_inside_well = find_height_at_well_volume(
21262108
target_volume=final_volume, well_geometry=well_geometry
21272109
)
2128-
except InvalidLiquidHeightFound as _exception:
2129-
raise InvalidLiquidHeightFound(
2130-
message=_exception.message
2131-
+ f"for well {well_name} of {self._labware.get_display_name(labware_id)} on slot {self.get_ancestor_slot_name(labware_id)}"
2132-
)
2133-
2134-
def get_well_height_after_liquid_handling_no_error(
2135-
self,
2136-
labware_id: str,
2137-
well_name: str,
2138-
pipette_id: str,
2139-
initial_height: LiquidTrackingType,
2140-
volume: float,
2141-
) -> LiquidTrackingType:
2142-
"""Return what the height of liquid in a labware well after liquid handling will be.
2143-
2144-
This raises no error if the value returned is an invalid physical location, so it should never be
2145-
used for navigation, only for a pre-emptive estimate.
2146-
"""
2147-
well_geometry = self._labware.get_well_geometry(
2148-
labware_id=labware_id, well_name=well_name
2149-
)
2150-
try:
2151-
initial_volume = find_volume_at_well_height(
2152-
target_height=initial_height, well_geometry=well_geometry
2153-
)
2154-
final_volume = initial_volume + (
2155-
volume
2156-
* self.get_nozzles_per_well(
2157-
labware_id=labware_id,
2158-
target_well_name=well_name,
2159-
pipette_id=pipette_id,
2160-
)
2161-
)
2162-
well_volume = find_height_at_well_volume(
2163-
target_volume=final_volume,
2164-
well_geometry=well_geometry,
2165-
raise_error_if_result_invalid=False,
2110+
return self._validate_well_position(
2111+
target_height=height_inside_well,
2112+
well_max_height=well_depth,
2113+
pipette_id=pipette_id,
21662114
)
2167-
return well_volume
21682115
except InvalidLiquidHeightFound as _exception:
21692116
raise InvalidLiquidHeightFound(
21702117
message=_exception.message

api/tests/opentrons/protocol_api/core/engine/test_well_core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ def test_estimate_liquid_height_after_pipetting(
360360
fake_final_height = 10000000
361361
decoy.when(subject.current_liquid_height()).then_return(initial_liquid_height)
362362
decoy.when(
363-
mock_engine_client.state.geometry.get_well_height_after_liquid_handling_no_error(
363+
mock_engine_client.state.geometry.get_well_height_after_liquid_handling(
364364
labware_id="labware-id",
365365
well_name="well-name",
366366
pipette_id="pipette-id",

0 commit comments

Comments
 (0)