diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index 6333bf16db86..a3fc763447ce 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -88,10 +88,12 @@ def _circular_frustum_polynomial_roots( def _volume_from_height_circular( target_height: float, segment: ConicalFrustum ) -> float: - """Find the volume given a height within a circular frustum.""" - heights = segment.height_to_volume_table.keys() - best_fit_height = min(heights, key=lambda x: abs(x - target_height)) - return segment.height_to_volume_table[best_fit_height] + return segment.volume_from_height_circular( + top_radius=segment.topDiameter / 2, + bottom_radius=segment.bottomDiameter / 2, + target_height=target_height, + total_height=segment.topHeight - segment.bottomHeight, + ) def _volume_from_height_rectangular( @@ -138,9 +140,7 @@ def _height_from_volume_circular( target_volume: float, segment: ConicalFrustum ) -> float: """Find the height given a volume within a squared cone segment.""" - volumes = segment.volume_to_height_table.keys() - best_fit_volume = min(volumes, key=lambda x: abs(x - target_volume)) - return segment.volume_to_height_table[best_fit_volume] + return segment.height_from_volume_search(target_volume) def _height_from_volume_rectangular( @@ -401,8 +401,12 @@ def _find_height_in_partial_frustum( ) -> float: """Look through a sorted list of frusta for a target volume, and find the height at that volume.""" bottom_section_volume = 0.0 + if target_volume == 0.0: + return 0.0 for section, capacity in zip(sorted_well, volumetric_capacity): section_top_height, section_volume = capacity + if target_volume == section_volume + bottom_section_volume: + return section_top_height if ( bottom_section_volume <= target_volume diff --git a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py index 7f26c205d630..93d3ce7dd560 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -3515,7 +3515,7 @@ def _find_volume_from_height_(index: int) -> None: segment=segment, ) - assert isclose(found_height, frustum["height"][index]) + assert isclose(found_height, frustum["height"][index], abs_tol=0.001) for i in range(len(frustum["height"])): _find_volume_from_height_(i) diff --git a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py index 4ec9a4a60322..b99269742590 100644 --- a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py +++ b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py @@ -1,6 +1,7 @@ import pytest from math import pi, isclose from typing import Any, List, cast +from hypothesis import given, strategies as st from opentrons_shared_data.labware.labware_definition import ( ConicalFrustum, @@ -19,9 +20,9 @@ _height_from_volume_circular, _height_from_volume_rectangular, _height_from_volume_spherical, - height_at_volume_within_section, - _get_segment_capacity, + find_height_at_well_volume, find_volume_at_well_height, + _get_segment_capacity, ) from opentrons.protocol_engine.errors.exceptions import InvalidLiquidHeightFound @@ -208,17 +209,29 @@ def test_cross_section_area_rectangular(x_dimension: float, y_dimension: float) @pytest.mark.parametrize("well", fake_frusta()) -def test_volume_and_height_circular(well: List[Any]) -> None: +@given(target_height_st=st.data()) +def test_volume_and_height_circular(well: List[Any], target_height_st: Any) -> None: """Test both volume and height calculations for circular frusta.""" if well[-1].shape == "spherical": return + if any([seg.shape != "conical" for seg in well]): + return for segment in well: if segment.shape == "conical": a = segment.topDiameter / 2 b = segment.bottomDiameter / 2 # test volume within a bunch of arbitrary heights segment_height = segment.topHeight - segment.bottomHeight - for target_height in range(round(segment_height)): + for i in range(50): + target_height = target_height_st.draw( + st.floats( + min_value=0, + max_value=segment_height, + allow_infinity=False, + allow_nan=False, + width=32, + ) + ) r_y = (target_height / segment_height) * (a - b) + b expected_volume = (pi * target_height / 3) * ( b**2 + b * r_y + r_y**2 @@ -232,7 +245,7 @@ def test_volume_and_height_circular(well: List[Any]) -> None: found_height = _height_from_volume_circular( target_volume=found_volume, segment=segment ) - assert isclose(found_height, target_height) + assert isclose(found_height, target_height, abs_tol=0.001) @pytest.mark.parametrize("well", fake_frusta()) @@ -319,14 +332,24 @@ def test_volume_and_height_spherical(well: List[Any]) -> None: @pytest.mark.parametrize("well", fake_frusta()) def test_height_at_volume_at_section_boundaries(well: List[Any]) -> None: """Test that finding the height when volume 0 or ~= capacity works.""" - for segment in well: - segment_height = segment.topHeight - segment.bottomHeight - height = height_at_volume_within_section(segment, 0.0, segment_height) - assert isclose(height, 0.0) - height = height_at_volume_within_section( - segment, _get_segment_capacity(segment), segment_height + inner_well_geometry = InnerWellGeometry(sections=well) + sorted_well = sorted( + inner_well_geometry.sections, key=lambda section: section.topHeight + ) + running_volume = 0.0 + height = find_height_at_well_volume( + target_volume=0.0, well_geometry=inner_well_geometry + ) + assert isinstance(height, float) + assert isclose(height, 0.0) + for segment in sorted_well: + running_volume += _get_segment_capacity(segment) + height = find_height_at_well_volume( + target_volume=running_volume, + well_geometry=inner_well_geometry, ) - assert isclose(height, segment_height) + assert isinstance(height, float) + assert isclose(height, segment.topHeight) @pytest.mark.parametrize("well", fake_frusta()) diff --git a/shared-data/python/opentrons_shared_data/labware/labware_definition.py b/shared-data/python/opentrons_shared_data/labware/labware_definition.py index 400635f2076e..995c24a67c49 100644 --- a/shared-data/python/opentrons_shared_data/labware/labware_definition.py +++ b/shared-data/python/opentrons_shared_data/labware/labware_definition.py @@ -34,6 +34,7 @@ ) SAFE_STRING_REGEX = "^[a-z0-9._]+$" +RECURSIVE_SEARCH_VOLUME_TOLERANCE = 0.001 _StrictNonNegativeInt = Annotated[int, Field(strict=True, ge=0)] @@ -201,29 +202,70 @@ class ConicalFrustum(BaseModel): xCount: _StrictNonNegativeInt = 1 yCount: _StrictNonNegativeInt = 1 - @cached_property - def height_to_volume_table(self) -> dict[float, float]: - """Return a lookup table of heights to volumes.""" - # the accuracy of this method is approximately +- 10*dx so for dx of 0.001 we have a +- 0.01 ul - dx = 0.005 + def height_from_volume_search(self, target_volume: float) -> float: total_height = self.topHeight - self.bottomHeight - y = 0.0 - table: dict[float, float] = {} - # fill in the table - a = self.topDiameter / 2 - b = self.bottomDiameter / 2 - while y < total_height: - r_y = (y / total_height) * (a - b) + b - table[y] = (pi * y / 3) * (b**2 + b * r_y + r_y**2) - y = y + dx - - # we always want to include the volume at the max height - table[total_height] = (pi * total_height / 3) * (b**2 + a * b + a**2) - return table - - @cached_property - def volume_to_height_table(self) -> dict[float, float]: - return dict((v, k) for k, v in self.height_to_volume_table.items()) + max_height, min_height = total_height, 0.0 + volume_at_max_height = self.volume_from_height_circular( + top_radius=self.topDiameter / 2, + bottom_radius=self.bottomDiameter / 2, + target_height=total_height, + total_height=total_height, + ) + if target_volume == volume_at_max_height: + return max_height + volume_at_min_height = self.volume_from_height_circular( + top_radius=self.topDiameter / 2, + bottom_radius=self.bottomDiameter / 2, + target_height=0, + total_height=total_height, + ) + if target_volume == volume_at_min_height: + return min_height + + y = total_height / 2 + volume_at_y = self.volume_from_height_circular( + top_radius=self.topDiameter / 2, + bottom_radius=self.bottomDiameter / 2, + target_height=y, + total_height=total_height, + ) + guesses = [ + (volume_at_min_height, min_height), + (volume_at_max_height, max_height), + ] + while abs(volume_at_y - target_volume) > RECURSIVE_SEARCH_VOLUME_TOLERANCE: + max_height, max_volume = guesses[-1][1], guesses[-1][0] + min_height, min_volume = guesses[0][1], guesses[0][0] + + # between volume_at_y and max value- undershot + if volume_at_y < target_volume < max_volume: + guesses = [(volume_at_y, y), (max_volume, max_height)] + # overshot + elif min_volume < target_volume < volume_at_y: + guesses = [(min_volume, min_height), (volume_at_y, y)] + y = (guesses[0][1] + guesses[1][1]) / 2 + + volume_at_y = self.volume_from_height_circular( + top_radius=self.topDiameter / 2, + bottom_radius=self.bottomDiameter / 2, + target_height=y, + total_height=total_height, + ) + return y + + def volume_from_height_circular( + self, + top_radius: float, + bottom_radius: float, + target_height: float, + total_height: float, + ) -> float: + r_y = (target_height / total_height) * ( + top_radius - bottom_radius + ) + bottom_radius + return (pi * target_height / 3) * ( + bottom_radius**2 + bottom_radius * r_y + r_y**2 + ) @cached_property def count(self) -> int: