Skip to content

Commit

Permalink
volume at arbitrary height for most above spherical section heights (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
caila-marashaj authored Sep 25, 2024
1 parent b49a756 commit 04439fe
Show file tree
Hide file tree
Showing 5 changed files with 246 additions and 49 deletions.
222 changes: 202 additions & 20 deletions api/src/opentrons/protocol_engine/state/frustum_helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Helper functions for liquid-level related calculations inside a given frustum."""
from typing import List, Tuple, Iterator, Sequence, Any, Union
from typing import List, Tuple, Iterator, Sequence, Any, Union, Optional
from numpy import pi, iscomplex, roots, real
from math import sqrt, isclose
from math import isclose

from ..errors.exceptions import InvalidLiquidHeightFound, InvalidWellDefinitionError
from opentrons_shared_data.labware.types import (
Expand Down Expand Up @@ -60,12 +60,6 @@ def cross_section_area_rectangular(x_dimension: float, y_dimension: float) -> fl
return x_dimension * y_dimension


def volume_from_frustum_formula(area_1: float, area_2: float, height: float) -> float:
"""Get the area of a section with differently shaped boundary cross-sections."""
area_term = area_1 + area_2 + sqrt(area_1 * area_2)
return (height / 3) * area_term


def rectangular_frustum_polynomial_roots(
bottom_length: float,
bottom_width: float,
Expand Down Expand Up @@ -218,7 +212,7 @@ def height_from_volume_spherical(
return height


def get_boundary_cross_sections(frusta: Sequence[Any]) -> Iterator[Tuple[Any, Any]]:
def get_boundary_pairs(frusta: Sequence[Any]) -> Iterator[Tuple[Any, Any]]:
"""Yield tuples representing two cross-section boundaries of a segment of a well."""
iter_f = iter(frusta)
el = next(iter_f)
Expand All @@ -238,7 +232,7 @@ def get_well_volumetric_capacity(
if well_geometry.bottomShape.shape == "spherical":
bottom_spherical_section_depth = well_geometry.bottomShape.depth
bottom_sphere_volume = volume_from_height_spherical(
radius_of_curvature=well_geometry.bottomShape.radius_of_curvature,
radius_of_curvature=well_geometry.bottomShape.radiusOfCurvature,
target_height=bottom_spherical_section_depth,
)
well_volume.append((bottom_spherical_section_depth, bottom_sphere_volume))
Expand All @@ -247,7 +241,7 @@ def get_well_volumetric_capacity(
sorted_frusta = sorted(well_geometry.frusta, key=lambda section: section.topHeight)

if is_rectangular_frusta_list(sorted_frusta):
for f, next_f in get_boundary_cross_sections(sorted_frusta):
for f, next_f in get_boundary_pairs(sorted_frusta):
top_cross_section_width = next_f["xDimension"]
top_cross_section_length = next_f["yDimension"]
bottom_cross_section_width = f["xDimension"]
Expand All @@ -264,7 +258,7 @@ def get_well_volumetric_capacity(

well_volume.append((next_f["topHeight"], frustum_volume))
elif is_circular_frusta_list(sorted_frusta):
for f, next_f in get_boundary_cross_sections(sorted_frusta):
for f, next_f in get_boundary_pairs(sorted_frusta):
top_cross_section_radius = next_f["diameter"] / 2.0
bottom_cross_section_radius = f["diameter"] / 2.0
frustum_height = next_f["topHeight"] - f["topHeight"]
Expand All @@ -277,12 +271,200 @@ def get_well_volumetric_capacity(

well_volume.append((next_f["topHeight"], frustum_volume))
else:
for f, next_f in get_boundary_cross_sections(sorted_frusta):
bottom_cross_section_area = get_cross_section_area(f)
top_cross_section_area = get_cross_section_area(next_f)
section_height = next_f["topHeight"] - f["topHeight"]
bounded_volume = volume_from_frustum_formula(
bottom_cross_section_area, top_cross_section_area, section_height
)
well_volume.append((next_f["topHeight"], bounded_volume))
raise NotImplementedError(
"Well section with differing boundary shapes not yet implemented."
)
return well_volume


def height_at_volume_within_section(
top_cross_section: Union[CircularBoundedSection, RectangularBoundedSection],
bottom_cross_section: Union[CircularBoundedSection, RectangularBoundedSection],
target_volume_relative: float,
frustum_height: float,
) -> float:
"""Calculate a height within a bounded section according to geometry."""
if top_cross_section["shape"] == bottom_cross_section["shape"] == "circular":
frustum_height = height_from_volume_circular(
volume=target_volume_relative,
top_radius=(top_cross_section["diameter"] / 2),
bottom_radius=(bottom_cross_section["diameter"] / 2),
total_frustum_height=frustum_height,
)
elif top_cross_section["shape"] == bottom_cross_section["shape"] == "rectangular":
frustum_height = height_from_volume_rectangular(
volume=target_volume_relative,
total_frustum_height=frustum_height,
bottom_width=bottom_cross_section["xDimension"],
bottom_length=bottom_cross_section["yDimension"],
top_width=top_cross_section["xDimension"],
top_length=top_cross_section["yDimension"],
)
else:
raise NotImplementedError(
"Height from volume calculation not yet implemented for this well shape."
)
return frustum_height


def volume_at_height_within_section(
top_cross_section: Union[CircularBoundedSection, RectangularBoundedSection],
bottom_cross_section: Union[CircularBoundedSection, RectangularBoundedSection],
target_height_relative: float,
frustum_height: float,
) -> float:
"""Calculate a volume within a bounded section according to geometry."""
if top_cross_section["shape"] == bottom_cross_section["shape"] == "circular":
frustum_volume = volume_from_height_circular(
target_height=target_height_relative,
total_frustum_height=frustum_height,
bottom_radius=(bottom_cross_section["diameter"] / 2),
top_radius=(top_cross_section["diameter"] / 2),
)
elif top_cross_section["shape"] == bottom_cross_section["shape"] == "rectangular":
frustum_volume = volume_from_height_rectangular(
target_height=target_height_relative,
total_frustum_height=frustum_height,
bottom_width=bottom_cross_section["xDimension"],
bottom_length=bottom_cross_section["yDimension"],
top_width=top_cross_section["xDimension"],
top_length=top_cross_section["yDimension"],
)
# TODO(cm): this would be the NEST-96 2uL wells referenced in EXEC-712
# we need to input the math attached to that issue
else:
raise NotImplementedError(
"Height from volume calculation not yet implemented for this well shape."
)
return frustum_volume


def _find_volume_in_partial_frustum(
sorted_frusta: List[Any],
target_height: float,
) -> Optional[float]:
"""Look through a sorted list of frusta for a target height, and find the volume at that height."""
partial_volume: Optional[float] = None
for bottom_cross_section, top_cross_section in get_boundary_pairs(sorted_frusta):
if (
bottom_cross_section["topHeight"]
< target_height
< top_cross_section["targetHeight"]
):
relative_target_height = target_height - bottom_cross_section["topHeight"]
frustum_height = (
top_cross_section["topHeight"] - bottom_cross_section["topHeight"]
)
partial_volume = volume_at_height_within_section(
top_cross_section=top_cross_section,
bottom_cross_section=bottom_cross_section,
target_height_relative=relative_target_height,
frustum_height=frustum_height,
)
return partial_volume


def find_volume_at_well_height(
target_height: float, well_geometry: InnerWellGeometry
) -> float:
"""Find the volume within a well, at a known height."""
volumetric_capacity = get_well_volumetric_capacity(well_geometry)
max_height = volumetric_capacity[-1][0]
if target_height < 0 or target_height > max_height:
raise InvalidLiquidHeightFound("Invalid target height.")
# volumes in volumetric_capacity are relative to each frustum,
# so we have to find the volume of all the full sections enclosed
# beneath the target height
closed_section_volume = 0.0
for boundary_height, section_volume in volumetric_capacity:
if boundary_height > target_height:
break
closed_section_volume += section_volume
# if target height is a boundary cross-section, we already know the volume
if target_height == boundary_height:
return closed_section_volume
# find the section the target height is in and compute the volume
# since bottomShape is not in list of frusta, check here first
if well_geometry.bottomShape:
bottom_segment_height = volumetric_capacity[0][0]
if (
target_height < bottom_segment_height
and well_geometry.bottomShape.shape == "spherical"
):
return volume_from_height_spherical(
target_height=target_height,
radius_of_curvature=well_geometry.bottomShape.radiusOfCurvature,
)
sorted_frusta = sorted(well_geometry.frusta, key=lambda section: section.topHeight)
# TODO(cm): handle non-frustum section that is not at the bottom.
partial_volume = _find_volume_in_partial_frustum(
sorted_frusta=sorted_frusta,
target_height=target_height,
)
if not partial_volume:
raise InvalidLiquidHeightFound("Unable to find volume at given well-height.")
return partial_volume + closed_section_volume


def _find_height_in_partial_frustum(
sorted_frusta: List[Any],
volumetric_capacity: List[Tuple[float, float]],
target_volume: float,
) -> Optional[float]:
"""Look through a sorted list of frusta for a target volume, and find the height at that volume."""
well_height: Optional[float] = None
for cross_sections, capacity in zip(
get_boundary_pairs(sorted_frusta),
get_boundary_pairs(volumetric_capacity),
):
bottom_cross_section, top_cross_section = cross_sections
(bottom_height, bottom_volume), (top_height, top_volume) = capacity

if bottom_volume < target_volume < top_volume:
relative_target_volume = target_volume - bottom_volume
frustum_height = top_height - bottom_height
partial_height = height_at_volume_within_section(
top_cross_section=top_cross_section,
bottom_cross_section=bottom_cross_section,
target_volume_relative=relative_target_volume,
frustum_height=frustum_height,
)
well_height = partial_height + bottom_height
return well_height


def find_height_at_well_volume(
target_volume: float, well_geometry: InnerWellGeometry
) -> float:
"""Find the height within a well, at a known volume."""
volumetric_capacity = get_well_volumetric_capacity(well_geometry)
max_volume = volumetric_capacity[-1][1]
if target_volume < 0 or target_volume > max_volume:
raise InvalidLiquidHeightFound("Invalid target volume.")

sorted_frusta = sorted(well_geometry.frusta, key=lambda section: section.topHeight)
# find the section the target volume is in and compute the height
# since bottomShape is not in list of frusta, check here first
if well_geometry.bottomShape:
volume_within_bottom_segment = volumetric_capacity[0][1]
if (
target_volume < volume_within_bottom_segment
and well_geometry.bottomShape.shape == "spherical"
):
return height_from_volume_spherical(
volume=target_volume,
radius_of_curvature=well_geometry.bottomShape.radiusOfCurvature,
total_frustum_height=well_geometry.bottomShape.depth,
)
# if bottom shape is present but doesn't contain the target volume,
# then we need to look through the volumetric capacity list without the bottom shape
# so volumetric_capacity and sorted_frusta will be aligned
volumetric_capacity.pop(0)
well_height = _find_height_in_partial_frustum(
sorted_frusta=sorted_frusta,
volumetric_capacity=volumetric_capacity,
target_volume=target_volume,
)
if not well_height:
raise InvalidLiquidHeightFound("Unable to find height at given well-volume.")
return well_height
38 changes: 37 additions & 1 deletion api/src/opentrons/protocol_engine/state/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@
from .modules import ModuleView
from .pipettes import PipetteView
from .addressable_areas import AddressableAreaView
from .frustum_helpers import get_well_volumetric_capacity
from .frustum_helpers import (
get_well_volumetric_capacity,
find_volume_at_well_height,
find_height_at_well_volume,
)


SLOT_WIDTH = 128
Expand Down Expand Up @@ -1218,3 +1222,35 @@ def get_well_volumetric_capacity(
message=f"No InnerWellGeometry found for well id: {well_id}"
)
return get_well_volumetric_capacity(well_geometry)

def get_volume_at_height(
self, labware_id: str, well_id: str, target_height: float
) -> float:
"""Find the volume at any height within a well."""
labware_def = self._labware.get_definition(labware_id)
if labware_def.innerLabwareGeometry is None:
raise InvalidWellDefinitionError(message="No InnerLabwareGeometry found.")
well_geometry = labware_def.innerLabwareGeometry.get(well_id)
if well_geometry is None:
raise InvalidWellDefinitionError(
message=f"No InnerWellGeometry found for well id: {well_id}"
)
return find_volume_at_well_height(
target_height=target_height, well_geometry=well_geometry
)

def get_height_at_volume(
self, labware_id: str, well_id: str, target_volume: float
) -> float:
"""Find the height from any volume in a well."""
labware_def = self._labware.get_definition(labware_id)
if labware_def.innerLabwareGeometry is None:
raise InvalidWellDefinitionError(message="No InnerLabwareGeometry found.")
well_geometry = labware_def.innerLabwareGeometry.get(well_id)
if well_geometry is None:
raise InvalidWellDefinitionError(
message=f"No InnerWellGeometry found for well id: {well_id}"
)
return find_height_at_well_volume(
target_volume=target_volume, well_geometry=well_geometry
)
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ def _load_labware_definition_data() -> LabwareDefinition:
],
bottomShape=SphericalSegment(
shape="spherical",
radius_of_curvature=6,
radiusOfCurvature=6,
depth=10,
),
)
Expand Down
31 changes: 5 additions & 26 deletions api/tests/opentrons/protocols/geometry/test_frustum_helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from math import pi, sqrt, isclose
from math import pi, isclose
from typing import Any, List

from opentrons_shared_data.labware.types import (
Expand All @@ -11,9 +11,7 @@
cross_section_area_rectangular,
cross_section_area_circular,
reject_unacceptable_heights,
get_boundary_cross_sections,
get_cross_section_area,
volume_from_frustum_formula,
get_boundary_pairs,
circular_frustum_polynomial_roots,
rectangular_frustum_polynomial_roots,
volume_from_height_rectangular,
Expand Down Expand Up @@ -138,38 +136,19 @@ def test_cross_section_area_rectangular(x_dimension: float, y_dimension: float)
def test_get_cross_section_boundaries(well: List[List[Any]]) -> None:
"""Make sure get_cross_section_boundaries returns the expected list indices."""
i = 0
for f, next_f in get_boundary_cross_sections(well):
for f, next_f in get_boundary_pairs(well):
assert f == well[i]
assert next_f == well[i + 1]
i += 1


@pytest.mark.parametrize("well", fake_frusta())
def test_frustum_formula_volume(well: List[Any]) -> None:
"""Test volume-of-a-frustum formula calculation."""
for f, next_f in get_boundary_cross_sections(well):
if f["shape"] == "spherical" or next_f["shape"] == "spherical":
# not going to use formula on spherical segments
continue
f_area = get_cross_section_area(f)
next_f_area = get_cross_section_area(next_f)
frustum_height = next_f["topHeight"] - f["topHeight"]
expected_volume = (f_area + next_f_area + sqrt(f_area * next_f_area)) * (
frustum_height / 3
)
found_volume = volume_from_frustum_formula(
area_1=f_area, area_2=next_f_area, height=frustum_height
)
assert found_volume == expected_volume


@pytest.mark.parametrize("well", fake_frusta())
def test_volume_and_height_circular(well: List[Any]) -> None:
"""Test both volume and height calculations for circular frusta."""
if well[-1]["shape"] == "spherical":
return
total_height = well[0]["topHeight"]
for f, next_f in get_boundary_cross_sections(well):
for f, next_f in get_boundary_pairs(well):
if f["shape"] == next_f["shape"] == "circular":
top_radius = next_f["diameter"] / 2
bottom_radius = f["diameter"] / 2
Expand Down Expand Up @@ -211,7 +190,7 @@ def test_volume_and_height_rectangular(well: List[Any]) -> None:
if well[-1]["shape"] == "spherical":
return
total_height = well[0]["topHeight"]
for f, next_f in get_boundary_cross_sections(well):
for f, next_f in get_boundary_pairs(well):
if f["shape"] == next_f["shape"] == "rectangular":
top_length = next_f["yDimension"]
top_width = next_f["xDimension"]
Expand Down
Loading

0 comments on commit 04439fe

Please sign in to comment.