Skip to content

refactor(api): height from volume binary search #18081

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions api/src/opentrons/protocol_engine/state/frustum_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 35 additions & 12 deletions api/tests/opentrons/protocols/geometry/test_frustum_helpers.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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())
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
)

SAFE_STRING_REGEX = "^[a-z0-9._]+$"
RECURSIVE_SEARCH_VOLUME_TOLERANCE = 0.001


_StrictNonNegativeInt = Annotated[int, Field(strict=True, ge=0)]
Expand Down Expand Up @@ -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:
Expand Down