diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index d78018683b78..7cd09105c519 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -66,6 +66,12 @@ def volume_from_frustum_formula(area_1: float, area_2: float, height: float) -> return (height / 3) * area_term +def height_from_frustum_formula(area_1: float, area_2: float, volume: float) -> float: + """Get the volume within a section with differently shaped boundary cross-sections.""" + area_term = area_1 + area_2 + sqrt(area_1 * area_2) + return 3 * volume / area_term + + def rectangular_frustum_polynomial_roots( bottom_length: float, bottom_width: float, @@ -218,7 +224,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) @@ -247,7 +253,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"] @@ -264,7 +270,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"] @@ -277,7 +283,7 @@ 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): + for f, next_f in get_boundary_pairs(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"] @@ -288,6 +294,32 @@ def get_well_volumetric_capacity( 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"], + ) + return frustum_height + + def volume_at_height_within_section( top_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], bottom_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], @@ -312,27 +344,36 @@ def volume_at_height_within_section( top_length=top_cross_section["yDimension"], ) # else: - # add volume of a frustum calculation when it gets merged + # TODO(cm): this would be the NEST-96 2uL wells referenced in EXEC-712 + # we need to input the math attached to that issue return frustum_volume -def find_volume_at_non_boundary_height( +def find_volume_at_well_height( target_height: float, well_geometry: InnerWellGeometry ) -> float: - """Find the volume within a frustum, at a known height.""" + """Find the volume within a well, at a known height.""" volumetric_capacity = get_well_volumetric_capacity(well_geometry) - # throw an error if height > well height + max_height = volumetric_capacity[-1][0] + if target_height < 0 or target_height > max_height: + raise InvalidLiquidHeightFound("Invalid target height.") closed_section_volume = 0.0 - for current_height, current_volume in volumetric_capacity: - if current_height > target_height: + for boundary_height, section_volume in volumetric_capacity: + if boundary_height > target_height: break - closed_section_volume += current_volume - # find the boundary frusta: + 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 sorted_frusta = sorted(well_geometry.frusta, key=lambda section: section.topHeight) - # case: if target height is within list of frusta target_volume: Optional[float] = None - for f, next_f in get_boundary_cross_sections(sorted_frusta): + # find the section the target height is in and compute the volume + for f, next_f in get_boundary_pairs(sorted_frusta): + if target_height < f["topHeight"] and f["shape"] == "spherical": + target_volume = volume_from_height_spherical( + target_height=target_height, radius_of_curvature=f["radiusOfCurvature"] + ) if f["topHeight"] < target_height < next_f["targetHeight"]: relative_target_height = target_height - f["topHeight"] frustum_height = next_f["topHeight"] - f["topHeight"] @@ -343,7 +384,55 @@ def find_volume_at_non_boundary_height( frustum_height=frustum_height, ) if not target_volume: - raise InvalidLiquidHeightFound() + raise InvalidLiquidHeightFound("Unable to find volume at given well-height.") return target_volume + closed_section_volume - # case: if target height is between spherical bottom and frusta[0] + +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 height.") + + closed_section_height = 0.0 + for boundary_height, section_volume in volumetric_capacity: + if section_volume > target_volume: + break + closed_section_height = boundary_height + # if target height is a boundary cross-section, we already know the volume + if target_volume == section_volume: + return boundary_height + sorted_frusta = sorted(well_geometry.frusta, key=lambda section: section.topHeight) + target_height: Optional[float] = None + # find the section the target volume is in and compute the height + 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 ( + target_volume < bottom_volume + and bottom_cross_section["shape"] == "spherical" + ): + target_height = height_from_frustum_formula( + area_1=bottom_cross_section, + area_2=top_cross_section, + volume=target_volume, + ) + if bottom_volume < target_volume < top_volume: + relative_target_volume = target_volume - bottom_volume + frustum_height = top_height - bottom_height + target_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, + ) + if not target_height: + raise InvalidLiquidHeightFound("Unable to find height at given well-volume.") + return closed_section_height + target_height diff --git a/api/src/opentrons/protocol_engine/state/geometry.py b/api/src/opentrons/protocol_engine/state/geometry.py index 5d1a5f010e5d..502f0d4d8eb3 100644 --- a/api/src/opentrons/protocol_engine/state/geometry.py +++ b/api/src/opentrons/protocol_engine/state/geometry.py @@ -55,7 +55,8 @@ from .addressable_areas import AddressableAreaView from .frustum_helpers import ( get_well_volumetric_capacity, - find_volume_at_non_boundary_height, + find_volume_at_well_height, + find_height_at_well_volume, ) @@ -1225,7 +1226,7 @@ def get_well_volumetric_capacity( def get_volume_at_height( self, labware_id: str, well_id: str, target_height: float ) -> float: - """Find the volume at any known height within a well.""" + """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.") @@ -1234,6 +1235,22 @@ def get_volume_at_height( raise InvalidWellDefinitionError( message=f"No InnerWellGeometry found for well id: {well_id}" ) - return find_volume_at_non_boundary_height( + 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 + ) diff --git a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py index 30a2948d50a6..4c1a055839c8 100644 --- a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py +++ b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py @@ -11,7 +11,7 @@ cross_section_area_rectangular, cross_section_area_circular, reject_unacceptable_heights, - get_boundary_cross_sections, + get_boundary_pairs, get_cross_section_area, volume_from_frustum_formula, circular_frustum_polynomial_roots, @@ -138,7 +138,7 @@ 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 @@ -147,7 +147,7 @@ def test_get_cross_section_boundaries(well: List[List[Any]]) -> None: @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): + for f, next_f in get_boundary_pairs(well): if f["shape"] == "spherical" or next_f["shape"] == "spherical": # not going to use formula on spherical segments continue @@ -169,7 +169,7 @@ def test_volume_and_height_circular(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"] == "circular": top_radius = next_f["diameter"] / 2 bottom_radius = f["diameter"] / 2 @@ -211,7 +211,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"] @@ -284,3 +284,8 @@ def test_volume_and_height_spherical(well: List[Any]) -> None: total_frustum_height=well[0]["depth"], ) assert isclose(found_height, target_height) + + +# test that volumetric capacity is always sorted +# test that errors are raised every time and only when given invalid height values for volume_from_height +# test that errors are raised every time and only when given invalid volume values for height_from_volume