diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index 27e417aa8b4..4f132ac3b40 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -1,19 +1,20 @@ """Helper functions for liquid-level related calculations inside a given frustum.""" -from typing import List, Tuple, Iterator, Sequence, Any, Union, Optional +from typing import List, Tuple from numpy import pi, iscomplex, roots, real from math import isclose -from ..errors.exceptions import InvalidLiquidHeightFound, InvalidWellDefinitionError -from opentrons_shared_data.labware.types import ( - is_circular_frusta_list, - is_rectangular_frusta_list, - CircularBoundedSection, - RectangularBoundedSection, +from ..errors.exceptions import InvalidLiquidHeightFound + +from opentrons_shared_data.labware.labware_definition import ( + InnerWellGeometry, + WellSegment, + SphericalSegment, + ConicalFrustum, + CuboidalFrustum, ) -from opentrons_shared_data.labware.labware_definition import InnerWellGeometry -def reject_unacceptable_heights( +def _reject_unacceptable_heights( potential_heights: List[float], max_height: float ) -> float: """Reject any solutions to a polynomial equation that cannot be the height of a frustum.""" @@ -33,34 +34,18 @@ def reject_unacceptable_heights( return valid_heights[0] -def get_cross_section_area( - bounded_section: Union[CircularBoundedSection, RectangularBoundedSection] -) -> float: - """Find the shape of a cross-section and calculate the area appropriately.""" - if bounded_section["shape"] == "circular": - cross_section_area = cross_section_area_circular(bounded_section["diameter"]) - elif bounded_section["shape"] == "rectangular": - cross_section_area = cross_section_area_rectangular( - bounded_section["xDimension"], - bounded_section["yDimension"], - ) - else: - raise InvalidWellDefinitionError(message="Invalid well volume components.") - return cross_section_area - - -def cross_section_area_circular(diameter: float) -> float: +def _cross_section_area_circular(diameter: float) -> float: """Get the area of a circular cross-section.""" radius = diameter / 2 return pi * (radius**2) -def cross_section_area_rectangular(x_dimension: float, y_dimension: float) -> float: +def _cross_section_area_rectangular(x_dimension: float, y_dimension: float) -> float: """Get the area of a rectangular cross-section.""" return x_dimension * y_dimension -def rectangular_frustum_polynomial_roots( +def _rectangular_frustum_polynomial_roots( bottom_length: float, bottom_width: float, top_length: float, @@ -82,7 +67,7 @@ def rectangular_frustum_polynomial_roots( return a, b, c -def circular_frustum_polynomial_roots( +def _circular_frustum_polynomial_roots( bottom_radius: float, top_radius: float, total_frustum_height: float, @@ -95,14 +80,14 @@ def circular_frustum_polynomial_roots( return a, b, c -def volume_from_height_circular( +def _volume_from_height_circular( target_height: float, total_frustum_height: float, bottom_radius: float, top_radius: float, ) -> float: """Find the volume given a height within a circular frustum.""" - a, b, c = circular_frustum_polynomial_roots( + a, b, c = _circular_frustum_polynomial_roots( bottom_radius=bottom_radius, top_radius=top_radius, total_frustum_height=total_frustum_height, @@ -111,7 +96,7 @@ def volume_from_height_circular( return volume -def volume_from_height_rectangular( +def _volume_from_height_rectangular( target_height: float, total_frustum_height: float, bottom_length: float, @@ -120,7 +105,7 @@ def volume_from_height_rectangular( top_width: float, ) -> float: """Find the volume given a height within a rectangular frustum.""" - a, b, c = rectangular_frustum_polynomial_roots( + a, b, c = _rectangular_frustum_polynomial_roots( bottom_length=bottom_length, bottom_width=bottom_width, top_length=top_length, @@ -131,7 +116,7 @@ def volume_from_height_rectangular( return volume -def volume_from_height_spherical( +def _volume_from_height_spherical( target_height: float, radius_of_curvature: float, ) -> float: @@ -142,14 +127,14 @@ def volume_from_height_spherical( return volume -def height_from_volume_circular( +def _height_from_volume_circular( volume: float, total_frustum_height: float, bottom_radius: float, top_radius: float, ) -> float: """Find the height given a volume within a circular frustum.""" - a, b, c = circular_frustum_polynomial_roots( + a, b, c = _circular_frustum_polynomial_roots( bottom_radius=bottom_radius, top_radius=top_radius, total_frustum_height=total_frustum_height, @@ -158,14 +143,14 @@ def height_from_volume_circular( x_intercept_roots = (a, b, c, d) height_from_volume_roots = roots(x_intercept_roots) - height = reject_unacceptable_heights( + height = _reject_unacceptable_heights( potential_heights=list(height_from_volume_roots), max_height=total_frustum_height, ) return height -def height_from_volume_rectangular( +def _height_from_volume_rectangular( volume: float, total_frustum_height: float, bottom_length: float, @@ -174,7 +159,7 @@ def height_from_volume_rectangular( top_width: float, ) -> float: """Find the height given a volume within a rectangular frustum.""" - a, b, c = rectangular_frustum_polynomial_roots( + a, b, c = _rectangular_frustum_polynomial_roots( bottom_length=bottom_length, bottom_width=bottom_width, top_length=top_length, @@ -185,14 +170,14 @@ def height_from_volume_rectangular( x_intercept_roots = (a, b, c, d) height_from_volume_roots = roots(x_intercept_roots) - height = reject_unacceptable_heights( + height = _reject_unacceptable_heights( potential_heights=list(height_from_volume_roots), max_height=total_frustum_height, ) return height -def height_from_volume_spherical( +def _height_from_volume_spherical( volume: float, radius_of_curvature: float, total_frustum_height: float, @@ -205,20 +190,43 @@ def height_from_volume_spherical( x_intercept_roots = (a, b, c, d) height_from_volume_roots = roots(x_intercept_roots) - height = reject_unacceptable_heights( + height = _reject_unacceptable_heights( potential_heights=list(height_from_volume_roots), max_height=total_frustum_height, ) return height -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) - for next_el in iter_f: - yield el, next_el - el = next_el +def _get_segment_capacity(segment: WellSegment) -> float: + match segment: + case SphericalSegment(): + return _volume_from_height_spherical( + target_height=segment.topHeight, + radius_of_curvature=segment.radiusOfCurvature, + ) + case CuboidalFrustum(): + section_height = segment.topHeight - segment.bottomHeight + return _volume_from_height_rectangular( + target_height=section_height, + bottom_length=segment.bottomYDimension, + bottom_width=segment.bottomXDimension, + top_length=segment.topYDimension, + top_width=segment.topXDimension, + total_frustum_height=section_height, + ) + case ConicalFrustum(): + section_height = segment.topHeight - segment.bottomHeight + return _volume_from_height_circular( + target_height=section_height, + total_frustum_height=section_height, + bottom_radius=(segment.bottomDiameter / 2), + top_radius=(segment.topDiameter / 2), + ) + case _: + # TODO: implement volume calculations for truncated circular and rounded rectangular segments + raise NotImplementedError( + f"volume calculation for shape: {segment.shape} not yet implemented." + ) def get_well_volumetric_capacity( @@ -228,140 +236,105 @@ def get_well_volumetric_capacity( # dictionary map of heights to volumetric capacities within their respective segment # {top_height_0: volume_0, top_height_1: volume_1, top_height_2: volume_2} well_volume = [] - if well_geometry.bottomShape is not None: - 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.radiusOfCurvature, - target_height=bottom_spherical_section_depth, - ) - well_volume.append((bottom_spherical_section_depth, bottom_sphere_volume)) - - # get the volume of remaining frusta sorted in ascending order - 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_pairs(sorted_frusta): - top_cross_section_width = next_f["xDimension"] - top_cross_section_length = next_f["yDimension"] - bottom_cross_section_width = f["xDimension"] - bottom_cross_section_length = f["yDimension"] - frustum_height = next_f["topHeight"] - f["topHeight"] - frustum_volume = volume_from_height_rectangular( - target_height=frustum_height, - total_frustum_height=frustum_height, - bottom_length=bottom_cross_section_length, - bottom_width=bottom_cross_section_width, - top_length=top_cross_section_length, - top_width=top_cross_section_width, - ) - well_volume.append((next_f["topHeight"], frustum_volume)) - elif is_circular_frusta_list(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"] - frustum_volume = volume_from_height_circular( - target_height=frustum_height, - total_frustum_height=frustum_height, - bottom_radius=bottom_cross_section_radius, - top_radius=top_cross_section_radius, - ) + # get the well segments sorted in ascending order + sorted_well = sorted(well_geometry.sections, key=lambda section: section.topHeight) - well_volume.append((next_f["topHeight"], frustum_volume)) - else: - raise NotImplementedError( - "Well section with differing boundary shapes not yet implemented." - ) + for segment in sorted_well: + section_volume = _get_segment_capacity(segment) + well_volume.append((segment.topHeight, section_volume)) return well_volume def height_at_volume_within_section( - top_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], - bottom_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], + section: WellSegment, target_volume_relative: float, - frustum_height: float, + section_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 + match section: + case SphericalSegment(): + return _height_from_volume_spherical( + volume=target_volume_relative, + total_frustum_height=section_height, + radius_of_curvature=section.radiusOfCurvature, + ) + case ConicalFrustum(): + return _height_from_volume_circular( + volume=target_volume_relative, + top_radius=(section.bottomDiameter / 2), + bottom_radius=(section.topDiameter / 2), + total_frustum_height=section_height, + ) + case CuboidalFrustum(): + return _height_from_volume_rectangular( + volume=target_volume_relative, + total_frustum_height=section_height, + bottom_width=section.bottomXDimension, + bottom_length=section.bottomYDimension, + top_width=section.topXDimension, + top_length=section.topYDimension, + ) + case _: + raise NotImplementedError( + "Height from volume calculation not yet implemented for this well shape." + ) def volume_at_height_within_section( - top_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], - bottom_cross_section: Union[CircularBoundedSection, RectangularBoundedSection], + section: WellSegment, target_height_relative: float, - frustum_height: float, + section_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 + match section: + case SphericalSegment(): + return _volume_from_height_spherical( + target_height=target_height_relative, + radius_of_curvature=section.radiusOfCurvature, + ) + case ConicalFrustum(): + return _volume_from_height_circular( + target_height=target_height_relative, + total_frustum_height=section_height, + bottom_radius=(section.bottomDiameter / 2), + top_radius=(section.topDiameter / 2), + ) + case CuboidalFrustum(): + return _volume_from_height_rectangular( + target_height=target_height_relative, + total_frustum_height=section_height, + bottom_width=section.bottomXDimension, + bottom_length=section.bottomYDimension, + top_width=section.topXDimension, + top_length=section.topYDimension, + ) + case _: + # TODO(cm): this would be the NEST-96 2uL wells referenced in EXEC-712 + # we need to input the math attached to that issue + raise NotImplementedError( + "Height from volume calculation not yet implemented for this well shape." + ) def _find_volume_in_partial_frustum( - sorted_frusta: List[Any], + sorted_well: List[WellSegment], target_height: float, -) -> Optional[float]: +) -> 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, + for segment in sorted_well: + if segment.bottomHeight < target_height < segment.topHeight: + relative_target_height = target_height - segment.bottomHeight + section_height = segment.topHeight - segment.bottomHeight + return volume_at_height_within_section( + section=segment, target_height_relative=relative_target_height, - frustum_height=frustum_height, + section_height=section_height, ) - return partial_volume + # if we've looked through all sections and can't find the target volume, raise an error + raise InvalidLiquidHeightFound( + f"Unable to find volume at given well-height {target_height}." + ) def find_volume_at_well_height( @@ -384,53 +357,41 @@ def find_volume_at_well_height( 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. + + sorted_well = sorted(well_geometry.sections, key=lambda section: section.topHeight) partial_volume = _find_volume_in_partial_frustum( - sorted_frusta=sorted_frusta, + sorted_well=sorted_well, 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], + sorted_well: List[WellSegment], volumetric_capacity: List[Tuple[float, float]], target_volume: float, -) -> Optional[float]: +) -> 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 + bottom_section_volume = 0.0 + for section, capacity in zip(sorted_well, volumetric_capacity): + section_top_height, section_volume = capacity + if bottom_section_volume < target_volume < section_volume: + relative_target_volume = target_volume - bottom_section_volume + relative_section_height = section.topHeight - section.bottomHeight partial_height = height_at_volume_within_section( - top_cross_section=top_cross_section, - bottom_cross_section=bottom_cross_section, + section=section, target_volume_relative=relative_target_volume, - frustum_height=frustum_height, + section_height=relative_section_height, ) - well_height = partial_height + bottom_height - return well_height + return partial_height + section.bottomHeight + # bottom section volume should always be the volume enclosed in the previously + # viewed section + bottom_section_volume = section_volume + + # if we've looked through all sections and can't find the target volume, raise an error + raise InvalidLiquidHeightFound( + f"Unable to find height at given volume {target_volume}." + ) def find_height_at_well_volume( @@ -442,29 +403,10 @@ def find_height_at_well_volume( 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) + sorted_well = sorted(well_geometry.sections, 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, + return _find_height_in_partial_frustum( + sorted_well=sorted_well, 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 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 6bbd13c5e25..427dececa7b 100644 --- a/api/tests/opentrons/protocol_engine/state/test_geometry_view.py +++ b/api/tests/opentrons/protocol_engine/state/test_geometry_view.py @@ -83,10 +83,10 @@ ) from opentrons.protocol_engine.state.geometry import GeometryView, _GripperMoveType from opentrons.protocol_engine.state.frustum_helpers import ( - height_from_volume_circular, - height_from_volume_rectangular, - volume_from_height_circular, - volume_from_height_rectangular, + _height_from_volume_circular, + _height_from_volume_rectangular, + _volume_from_height_circular, + _volume_from_height_rectangular, ) from ..pipette_fixtures import get_default_nozzle_map from ..mock_circular_frusta import TEST_EXAMPLES as CIRCULAR_TEST_EXAMPLES @@ -2776,7 +2776,7 @@ def _find_volume_from_height_(index: int) -> None: top_width = frustum["width"][index] target_height = frustum["height"][index] - found_volume = volume_from_height_rectangular( + found_volume = _volume_from_height_rectangular( target_height=target_height, total_frustum_height=total_frustum_height, top_length=top_length, @@ -2785,7 +2785,7 @@ def _find_volume_from_height_(index: int) -> None: bottom_width=bottom_width, ) - found_height = height_from_volume_rectangular( + found_height = _height_from_volume_rectangular( volume=found_volume, total_frustum_height=total_frustum_height, top_length=top_length, @@ -2815,14 +2815,14 @@ def _find_volume_from_height_(index: int) -> None: top_radius = frustum["radius"][index] target_height = frustum["height"][index] - found_volume = volume_from_height_circular( + found_volume = _volume_from_height_circular( target_height=target_height, total_frustum_height=total_frustum_height, top_radius=top_radius, bottom_radius=bottom_radius, ) - found_height = height_from_volume_circular( + found_height = _height_from_volume_circular( volume=found_volume, total_frustum_height=total_frustum_height, top_radius=top_radius, diff --git a/api/tests/opentrons/protocol_runner/test_json_translator.py b/api/tests/opentrons/protocol_runner/test_json_translator.py index 62181880dbc..69edd3c1445 100644 --- a/api/tests/opentrons/protocol_runner/test_json_translator.py +++ b/api/tests/opentrons/protocol_runner/test_json_translator.py @@ -13,7 +13,7 @@ Group, Metadata1, WellDefinition, - RectangularBoundedSection, + CuboidalFrustum, InnerWellGeometry, SphericalSegment, ) @@ -683,32 +683,39 @@ def _load_labware_definition_data() -> LabwareDefinition: y=75.43, z=75, totalLiquidVolume=1100000, - shape="rectangular", + shape="circular", ) }, dimensions=Dimensions(yDimension=85.5, zDimension=100, xDimension=127.75), cornerOffsetFromSlot=CornerOffsetFromSlot(x=0, y=0, z=0), innerLabwareGeometry={ "welldefinition1111": InnerWellGeometry( - frusta=[ - RectangularBoundedSection( - shape="rectangular", - xDimension=7.6, - yDimension=8.5, + sections=[ + CuboidalFrustum( + shape="cuboidal", + topXDimension=7.6, + topYDimension=8.5, + bottomXDimension=5.6, + bottomYDimension=6.5, topHeight=45, + bottomHeight=20, ), - RectangularBoundedSection( - shape="rectangular", - xDimension=5.6, - yDimension=6.5, + CuboidalFrustum( + shape="cuboidal", + topXDimension=5.6, + topYDimension=6.5, + bottomXDimension=4.5, + bottomYDimension=4.0, topHeight=20, + bottomHeight=10, + ), + SphericalSegment( + shape="spherical", + radiusOfCurvature=6, + topHeight=10, + bottomHeight=0.0, ), ], - bottomShape=SphericalSegment( - shape="spherical", - radiusOfCurvature=6, - depth=10, - ), ) }, brand=BrandData(brand="foo"), diff --git a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py index 0bf74aae5b2..0b8d3429527 100644 --- a/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py +++ b/api/tests/opentrons/protocols/geometry/test_frustum_helpers.py @@ -2,24 +2,25 @@ from math import pi, isclose from typing import Any, List -from opentrons_shared_data.labware.types import ( - RectangularBoundedSection, - CircularBoundedSection, +from opentrons_shared_data.labware.labware_definition import ( + ConicalFrustum, + CuboidalFrustum, SphericalSegment, ) from opentrons.protocol_engine.state.frustum_helpers import ( - cross_section_area_rectangular, - cross_section_area_circular, - reject_unacceptable_heights, - get_boundary_pairs, - circular_frustum_polynomial_roots, - rectangular_frustum_polynomial_roots, - volume_from_height_rectangular, - volume_from_height_circular, - volume_from_height_spherical, - height_from_volume_circular, - height_from_volume_rectangular, - height_from_volume_spherical, + _cross_section_area_rectangular, + _cross_section_area_circular, + _reject_unacceptable_heights, + _circular_frustum_polynomial_roots, + _rectangular_frustum_polynomial_roots, + _volume_from_height_rectangular, + _volume_from_height_circular, + _volume_from_height_spherical, + _height_from_volume_circular, + _height_from_volume_rectangular, + _height_from_volume_spherical, + height_at_volume_within_section, + _get_segment_capacity, ) from opentrons.protocol_engine.errors.exceptions import InvalidLiquidHeightFound @@ -29,59 +30,130 @@ def fake_frusta() -> List[List[Any]]: frusta = [] frusta.append( [ - RectangularBoundedSection( - shape="rectangular", xDimension=9.0, yDimension=10.0, topHeight=10.0 + CuboidalFrustum( + shape="cuboidal", + topXDimension=9.0, + topYDimension=10.0, + bottomXDimension=8.0, + bottomYDimension=9.0, + topHeight=10.0, + bottomHeight=5.0, ), - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=9.0, topHeight=5.0 + CuboidalFrustum( + shape="cuboidal", + topXDimension=8.0, + topYDimension=9.0, + bottomXDimension=15.0, + bottomYDimension=18.0, + topHeight=5.0, + bottomHeight=1.0, + ), + ConicalFrustum( + shape="conical", + topDiameter=23.0, + bottomDiameter=3.0, + topHeight=2.0, + bottomHeight=1.0, + ), + SphericalSegment( + shape="spherical", + radiusOfCurvature=4.0, + topHeight=1.0, + bottomHeight=0.0, ), - CircularBoundedSection(shape="circular", diameter=23.0, topHeight=1.0), - SphericalSegment(shape="spherical", radiusOfCurvature=4.0, depth=1.0), ] ) frusta.append( [ - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=70.0, topHeight=3.5 - ), - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=75.0, topHeight=2.0 + CuboidalFrustum( + shape="cuboidal", + topXDimension=8.0, + topYDimension=70.0, + bottomXDimension=7.0, + bottomYDimension=75.0, + topHeight=3.5, + bottomHeight=2.0, ), - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=80.0, topHeight=1.0 - ), - RectangularBoundedSection( - shape="rectangular", xDimension=8.0, yDimension=90.0, topHeight=0.0 + CuboidalFrustum( + shape="cuboidal", + topXDimension=8.0, + topYDimension=80.0, + bottomXDimension=8.0, + bottomYDimension=90.0, + topHeight=1.0, + bottomHeight=0.0, ), ] ) frusta.append( [ - CircularBoundedSection(shape="circular", diameter=23.0, topHeight=7.5), - CircularBoundedSection(shape="circular", diameter=11.5, topHeight=5.0), - CircularBoundedSection(shape="circular", diameter=23.0, topHeight=2.5), - CircularBoundedSection(shape="circular", diameter=11.5, topHeight=0.0), + ConicalFrustum( + shape="conical", + topDiameter=23.0, + bottomDiameter=11.5, + topHeight=7.5, + bottomHeight=5.0, + ), + ConicalFrustum( + shape="conical", + topDiameter=11.5, + bottomDiameter=23.0, + topHeight=5.0, + bottomHeight=2.5, + ), + ConicalFrustum( + shape="conical", + topDiameter=23.0, + bottomDiameter=11.5, + topHeight=2.5, + bottomHeight=0.0, + ), ] ) frusta.append( [ - CircularBoundedSection(shape="circular", diameter=4.0, topHeight=3.0), - CircularBoundedSection(shape="circular", diameter=5.0, topHeight=2.0), - SphericalSegment(shape="spherical", radiusOfCurvature=3.5, depth=2.0), + ConicalFrustum( + shape="conical", + topDiameter=4.0, + bottomDiameter=5.0, + topHeight=3.0, + bottomHeight=2.0, + ), + SphericalSegment( + shape="spherical", + radiusOfCurvature=3.5, + topHeight=2.0, + bottomHeight=0.0, + ), ] ) frusta.append( - [SphericalSegment(shape="spherical", radiusOfCurvature=4.0, depth=3.0)] + [ + SphericalSegment( + shape="spherical", + radiusOfCurvature=4.0, + topHeight=3.0, + bottomHeight=0.0, + ) + ] ) frusta.append( [ - RectangularBoundedSection( - shape="rectangular", xDimension=27.0, yDimension=36.0, topHeight=3.5 + CuboidalFrustum( + shape="cuboidal", + topXDimension=27.0, + topYDimension=36.0, + bottomXDimension=36.0, + bottomYDimension=26.0, + topHeight=3.5, + bottomHeight=1.5, ), - RectangularBoundedSection( - shape="rectangular", xDimension=36.0, yDimension=26.0, topHeight=1.5 + SphericalSegment( + shape="spherical", + radiusOfCurvature=4.0, + topHeight=1.5, + bottomHeight=0.0, ), - SphericalSegment(shape="spherical", radiusOfCurvature=4.0, depth=1.5), ] ) return frusta @@ -103,11 +175,11 @@ def test_reject_unacceptable_heights( """Make sure we reject all mathematical solutions that are physically not possible.""" if len(expected_heights) != 1: with pytest.raises(InvalidLiquidHeightFound): - reject_unacceptable_heights( + _reject_unacceptable_heights( max_height=max_height, potential_heights=potential_heights ) else: - found_heights = reject_unacceptable_heights( + found_heights = _reject_unacceptable_heights( max_height=max_height, potential_heights=potential_heights ) assert found_heights == expected_heights[0] @@ -117,7 +189,7 @@ def test_reject_unacceptable_heights( def test_cross_section_area_circular(diameter: float) -> None: """Test circular area calculation.""" expected_area = pi * (diameter / 2) ** 2 - assert cross_section_area_circular(diameter) == expected_area + assert _cross_section_area_circular(diameter) == expected_area @pytest.mark.parametrize( @@ -127,35 +199,27 @@ def test_cross_section_area_rectangular(x_dimension: float, y_dimension: float) """Test rectangular area calculation.""" expected_area = x_dimension * y_dimension assert ( - cross_section_area_rectangular(x_dimension=x_dimension, y_dimension=y_dimension) + _cross_section_area_rectangular( + x_dimension=x_dimension, y_dimension=y_dimension + ) == expected_area ) -@pytest.mark.parametrize("well", fake_frusta()) -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_pairs(well): - assert f == well[i] - assert next_f == well[i + 1] - i += 1 - - @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": + if well[-1].shape == "spherical": return - total_height = well[0]["topHeight"] - 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 + total_height = well[0].topHeight + for segment in well: + if segment.shape == "conical": + top_radius = segment.topDiameter / 2 + bottom_radius = segment.bottomDiameter / 2 a = pi * ((top_radius - bottom_radius) ** 2) / (3 * total_height**2) b = pi * bottom_radius * (top_radius - bottom_radius) / total_height c = pi * bottom_radius**2 - assert circular_frustum_polynomial_roots( + assert _circular_frustum_polynomial_roots( top_radius=top_radius, bottom_radius=bottom_radius, total_frustum_height=total_height, @@ -167,7 +231,7 @@ def test_volume_and_height_circular(well: List[Any]) -> None: + b * (target_height**2) + c * target_height ) - found_volume = volume_from_height_circular( + found_volume = _volume_from_height_circular( target_height=target_height, total_frustum_height=total_height, bottom_radius=bottom_radius, @@ -175,7 +239,7 @@ def test_volume_and_height_circular(well: List[Any]) -> None: ) assert found_volume == expected_volume # test going backwards to get height back - found_height = height_from_volume_circular( + found_height = _height_from_volume_circular( volume=found_volume, total_frustum_height=total_height, bottom_radius=bottom_radius, @@ -187,15 +251,15 @@ def test_volume_and_height_circular(well: List[Any]) -> None: @pytest.mark.parametrize("well", fake_frusta()) def test_volume_and_height_rectangular(well: List[Any]) -> None: """Test both volume and height calculations for rectangular frusta.""" - if well[-1]["shape"] == "spherical": + if well[-1].shape == "spherical": return - total_height = well[0]["topHeight"] - 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"] - bottom_length = f["yDimension"] - bottom_width = f["xDimension"] + total_height = well[0].topHeight + for segment in well: + if segment.shape == "cuboidal": + top_length = segment.topYDimension + top_width = segment.topXDimension + bottom_length = segment.bottomYDimension + bottom_width = segment.bottomXDimension a = ( (top_length - bottom_length) * (top_width - bottom_width) @@ -206,7 +270,7 @@ def test_volume_and_height_rectangular(well: List[Any]) -> None: + (bottom_width * (top_length - bottom_length)) ) / (2 * total_height) c = bottom_length * bottom_width - assert rectangular_frustum_polynomial_roots( + assert _rectangular_frustum_polynomial_roots( top_length=top_length, bottom_length=bottom_length, top_width=top_width, @@ -220,7 +284,7 @@ def test_volume_and_height_rectangular(well: List[Any]) -> None: + b * (target_height**2) + c * target_height ) - found_volume = volume_from_height_rectangular( + found_volume = _volume_from_height_rectangular( target_height=target_height, total_frustum_height=total_height, bottom_length=bottom_length, @@ -230,7 +294,7 @@ def test_volume_and_height_rectangular(well: List[Any]) -> None: ) assert found_volume == expected_volume # test going backwards to get height back - found_height = height_from_volume_rectangular( + found_height = _height_from_volume_rectangular( volume=found_volume, total_frustum_height=total_height, bottom_length=bottom_length, @@ -244,22 +308,33 @@ def test_volume_and_height_rectangular(well: List[Any]) -> None: @pytest.mark.parametrize("well", fake_frusta()) def test_volume_and_height_spherical(well: List[Any]) -> None: """Test both volume and height calculations for spherical segments.""" - if well[0]["shape"] == "spherical": - for target_height in range(round(well[0]["depth"])): + if well[0].shape == "spherical": + for target_height in range(round(well[0].topHeight)): expected_volume = ( (1 / 3) * pi * (target_height**2) - * (3 * well[0]["radiusOfCurvature"] - target_height) + * (3 * well[0].radiusOfCurvature - target_height) ) - found_volume = volume_from_height_spherical( + found_volume = _volume_from_height_spherical( target_height=target_height, - radius_of_curvature=well[0]["radiusOfCurvature"], + radius_of_curvature=well[0].radiusOfCurvature, ) assert found_volume == expected_volume - found_height = height_from_volume_spherical( + found_height = _height_from_volume_spherical( volume=found_volume, - radius_of_curvature=well[0]["radiusOfCurvature"], - total_frustum_height=well[0]["depth"], + radius_of_curvature=well[0].radiusOfCurvature, + total_frustum_height=well[0].topHeight, ) assert isclose(found_height, target_height) + + +@pytest.mark.parametrize("well", fake_frusta()) +def test_height_at_volume_within_section(well: List[Any]) -> None: + """Test that finding the height when volume ~= capacity works.""" + for segment in well: + segment_height = segment.topHeight - segment.bottomHeight + height = height_at_volume_within_section( + segment, _get_segment_capacity(segment), segment_height + ) + assert isclose(height, segment_height) diff --git a/shared-data/js/__tests__/labwareDefSchemaV3.test.ts b/shared-data/js/__tests__/labwareDefSchemaV3.test.ts index 8416e8b60c5..14d0c4bf968 100644 --- a/shared-data/js/__tests__/labwareDefSchemaV3.test.ts +++ b/shared-data/js/__tests__/labwareDefSchemaV3.test.ts @@ -33,14 +33,10 @@ const checkGeometryDefinitions = ( expect(wellGeometryId in labwareDef.innerLabwareGeometry).toBe(true) const wellDepth = labwareDef.wells[wellName].depth - const wellShape = labwareDef.wells[wellName].shape const topFrustumHeight = - labwareDef.innerLabwareGeometry[wellGeometryId].frusta[0].topHeight - const topFrustumShape = - labwareDef.innerLabwareGeometry[wellGeometryId].frusta[0].shape + labwareDef.innerLabwareGeometry[wellGeometryId].sections[0].topHeight expect(wellDepth).toEqual(topFrustumHeight) - expect(wellShape).toEqual(topFrustumShape) } }) } diff --git a/shared-data/js/types.ts b/shared-data/js/types.ts index dbd8c7f59c7..0ffb3f7a649 100644 --- a/shared-data/js/types.ts +++ b/shared-data/js/types.ts @@ -162,25 +162,57 @@ export type LabwareWell = LabwareWellProperties & { export interface SphericalSegment { shape: 'spherical' radiusOfCurvature: number - depth: number + topHeight: number + bottomHeight: number } -export interface CircularBoundedSection { - shape: 'circular' - diameter: number +export interface ConicalFrustum { + shape: 'conical' + bottomDiameter: number + topDiameter: number topHeight: number + bottomHeight: number } -export interface RectangularBoundedSection { - shape: 'rectangular' - xDimension: number - yDimension: number +export interface CuboidalFrustum { + shape: 'cuboidal' + bottomXDimension: number + bottomYDimension: number + topXDimension: number + topYDimension: number topHeight: number + bottomHeight: number } +export interface SquaredConeSegment { + shape: 'squaredcone' + bottomCrossSection: string + circleDiameter: number + rectangleXDimension: number + rectangleYDimension: number + topHeight: number + bottomHeight: number +} + +export interface RoundedCuboidSegment { + shape: 'roundedcuboid' + bottomCrossSection: string + circleDiameter: number + rectangleXDimension: number + rectangleYDimension: number + topHeight: number + bottomHeight: number +} + +export type WellSegment = + | CuboidalFrustum + | ConicalFrustum + | SquaredConeSegment + | SphericalSegment + | RoundedCuboidSegment + export interface InnerWellGeometry { - frusta: CircularBoundedSection[] | RectangularBoundedSection[] - bottomShape?: SphericalSegment | null + sections: WellSegment[] } // TODO(mc, 2019-03-21): exact object is tough to use with the initial value in diff --git a/shared-data/labware/fixtures/3/fixture_2_plate.json b/shared-data/labware/fixtures/3/fixture_2_plate.json index a2e1bb5a3ea..19ea2f82ffc 100644 --- a/shared-data/labware/fixtures/3/fixture_2_plate.json +++ b/shared-data/labware/fixtures/3/fixture_2_plate.json @@ -62,39 +62,34 @@ }, "innerLabwareGeometry": { "daiwudhadfhiew": { - "frusta": [ + "sections": [ { - "shape": "rectangular", - "xDimension": 127.76, - "yDimension": 85.8, - "topHeight": 42.16 - }, - { - "shape": "rectangular", - "xDimension": 70.0, - "yDimension": 50.0, - "topHeight": 20.0 + "shape": "cuboidal", + "topXDimension": 127.76, + "topYDimension": 85.8, + "bottomXDimension": 70.0, + "bottomYDimension": 50.0, + "topHeight": 42.16, + "bottomHeight": 20.0 } ] }, "iuweofiuwhfn": { - "frusta": [ + "sections": [ { - "shape": "circular", - "diameter": 35.0, - "topHeight": 42.16 + "shape": "conical", + "bottomDiameter": 35.0, + "topDiameter": 35.0, + "topHeight": 42.16, + "bottomHeight": 10.0 }, { - "shape": "circular", - "diameter": 35.0, - "topHeight": 20.0 + "shape": "spherical", + "radiusOfCurvature": 20.0, + "topHeight": 10.0, + "bottomHeight": 0.0 } - ], - "bottomShape": { - "shape": "spherical", - "radiusOfCurvature": 20.0, - "depth": 6.0 - } + ] } } } diff --git a/shared-data/labware/fixtures/3/fixture_corning_24_plate.json b/shared-data/labware/fixtures/3/fixture_corning_24_plate.json index d53a6f017ca..679f8916377 100644 --- a/shared-data/labware/fixtures/3/fixture_corning_24_plate.json +++ b/shared-data/labware/fixtures/3/fixture_corning_24_plate.json @@ -323,16 +323,13 @@ }, "innerLabwareGeometry": { "venirhgerug": { - "frusta": [ + "sections": [ { - "shape": "circular", - "diameter": 16.26, - "topHeight": 17.4 - }, - { - "shape": "circular", - "diameter": 16.26, - "topHeight": 0.0 + "shape": "conical", + "bottomDiameter": 16.26, + "topDiameter": 16.26, + "topHeight": 17.4, + "bottomHeight": 0.0 } ] } diff --git a/shared-data/labware/schemas/3.json b/shared-data/labware/schemas/3.json index e03b1c8f064..ecd285c554a 100644 --- a/shared-data/labware/schemas/3.json +++ b/shared-data/labware/schemas/3.json @@ -67,8 +67,9 @@ }, "SphericalSegment": { "type": "object", + "description": "A partial sphere shaped section at the bottom of the well.", "additionalProperties": false, - "required": ["shape", "radiusOfCurvature", "depth"], + "required": ["shape", "radiusOfCurvature", "topHeight", "bottomHeight"], "properties": { "shape": { "type": "string", @@ -77,70 +78,182 @@ "radiusOfCurvature": { "type": "number" }, - "depth": { + "topHeight": { + "type": "number" + }, + "bottomHeight": { "type": "number" } } }, - "CircularBoundedSection": { + "ConicalFrustum": { "type": "object", - "required": ["shape", "diameter", "topHeight"], + "description": "A cone or conical segment, bounded by two circles on the top and bottom.", + "required": [ + "shape", + "bottomDiameter", + "topDiameter", + "topHeight", + "bottomHeight" + ], "properties": { "shape": { "type": "string", - "enum": ["circular"] + "enum": ["conical"] }, - "diameter": { + "bottomDiameter": { + "type": "number" + }, + "topDiameter": { "type": "number" }, "topHeight": { - "type": "number", - "description": "The height at the top of a bounded subsection of a well, relative to the bottom" + "type": "number" + }, + "bottomHeight": { + "type": "number" } } }, - "RectangularBoundedSection": { + "CuboidalFrustum": { "type": "object", - "required": ["shape", "xDimension", "yDimension", "topHeight"], + "description": "A cuboidal shape bounded by two rectangles on the top and bottom", + "required": [ + "shape", + "bottomXDimension", + "bottomYDimension", + "topXDimension", + "topYDimension", + "topHeight", + "bottomHeight" + ], "properties": { "shape": { "type": "string", - "enum": ["rectangular"] + "enum": ["cuboidal"] }, - "xDimension": { + "bottomXDimension": { "type": "number" }, - "yDimension": { + "bottomYDimension": { + "type": "number" + }, + "topXDimension": { + "type": "number" + }, + "topYDimension": { "type": "number" }, "topHeight": { - "type": "number", - "description": "The height at the top of a bounded subsection of a well, relative to the bottom" + "type": "number" + }, + "bottomHeight": { + "type": "number" + } + } + }, + "SquaredConeSegment": { + "type": "object", + "description": "The intersection of a pyramid and a cone that both share a central axis where one face is a circle and one face is a rectangle", + "required": [ + "shape", + "bottomCrossSection", + "circleDiameter", + "rectangleXDimension", + "rectangleYDimension", + "topHeight", + "bottomHeight" + ], + "properties": { + "shape": { + "type": "string", + "enum": ["squaredcone"] + }, + "bottomCrossSection": { + "type": "string", + "enum": ["circular", "rectangular"] + }, + "circleDiameter": { + "type": "number" + }, + "rectangleXDimension": { + "type": "number" + }, + "rectangleYDimension": { + "type": "number" + }, + "topHeight": { + "type": "number" + }, + "bottomHeight": { + "type": "number" + } + } + }, + "RoundedCuboidSegment": { + "type": "object", + "description": "A cuboidal frustum where each corner is filleted out by circles with centers on the diagonals between opposite corners", + "required": [ + "shape", + "bottomCrossSection", + "circleDiameter", + "rectangleXDimension", + "rectangleYDimension", + "topHeight", + "bottomHeight" + ], + "properties": { + "shape": { + "type": "string", + "enum": ["roundedcuboid"] + }, + "bottomCrossSection": { + "type": "string", + "enum": ["circular", "rectangular"] + }, + "circleDiameter": { + "type": "number" + }, + "rectangleXDimension": { + "type": "number" + }, + "rectangleYDimension": { + "type": "number" + }, + "topHeight": { + "type": "number" + }, + "bottomHeight": { + "type": "number" } } }, "InnerWellGeometry": { "type": "object", - "required": ["frusta"], + "required": ["sections"], "properties": { - "frusta": { + "sections": { "description": "A list of all of the sections of the well that have a contiguous shape", "type": "array", "items": { "oneOf": [ { - "$ref": "#/definitions/CircularBoundedSection" + "$ref": "#/definitions/ConicalFrustum" + }, + { + "$ref": "#/definitions/CuboidalFrustum" + }, + { + "$ref": "#/definitions/SquaredConeSegment" }, { - "$ref": "#/definitions/RectangularBoundedSection" + "$ref": "#/definitions/RoundedCuboidSegment" + }, + { + "$ref": "#/definitions/SphericalSegment" } ] } - }, - "bottomShape": { - "type": "object", - "description": "The shape at the bottom of the well: either a spherical segment or a cross-section", - "$ref": "#/definitions/SphericalSegment" } } } diff --git a/shared-data/python/opentrons_shared_data/labware/constants.py b/shared-data/python/opentrons_shared_data/labware/constants.py index 00fbef3c160..9973604937b 100644 --- a/shared-data/python/opentrons_shared_data/labware/constants.py +++ b/shared-data/python/opentrons_shared_data/labware/constants.py @@ -1,7 +1,20 @@ import re from typing_extensions import Final +from typing import Literal, Union # Regular expression to validate and extract row, column from well name # (ie A3, C1) WELL_NAME_PATTERN: Final["re.Pattern[str]"] = re.compile(r"^([A-Z]+)([0-9]+)$", re.X) + +# These shapes are for wellshape definitions and describe the top of the well +Circular = Literal["circular"] +Rectangular = Literal["rectangular"] +WellShape = Union[Circular, Rectangular] + +# These shapes are used to describe the 3D primatives used to build wells +Conical = Literal["conical"] +Cuboidal = Literal["cuboidal"] +SquaredCone = Literal["squaredcone"] +RoundedCuboid = Literal["roundedcuboid"] +Spherical = Literal["spherical"] 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 a6ee1804cde..a818afc106a 100644 --- a/shared-data/python/opentrons_shared_data/labware/labware_definition.py +++ b/shared-data/python/opentrons_shared_data/labware/labware_definition.py @@ -19,6 +19,15 @@ ) from typing_extensions import Literal +from .constants import ( + Conical, + Cuboidal, + RoundedCuboid, + SquaredCone, + Spherical, + WellShape, +) + SAFE_STRING_REGEX = "^[a-z0-9._]+$" @@ -228,45 +237,227 @@ class Config: class SphericalSegment(BaseModel): - shape: Literal["spherical"] = Field(..., description="Denote shape as spherical") + shape: Spherical = Field(..., description="Denote shape as spherical") radiusOfCurvature: _NonNegativeNumber = Field( ..., description="radius of curvature of bottom subsection of wells", ) - depth: _NonNegativeNumber = Field( + topHeight: _NonNegativeNumber = Field( ..., description="The depth of a spherical bottom of a well" ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="Height of the bottom of the segment, must be 0.0", + ) + + +class ConicalFrustum(BaseModel): + shape: Conical = Field(..., description="Denote shape as conical") + bottomDiameter: _NonNegativeNumber = Field( + ..., + description="The diameter at the bottom cross-section of a circular frustum", + ) + topDiameter: _NonNegativeNumber = Field( + ..., description="The diameter at the top cross-section of a circular frustum" + ) + topHeight: _NonNegativeNumber = Field( + ..., + description="The height at the top of a bounded subsection of a well, relative to the bottom" + "of the well", + ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", + ) + + +class CuboidalFrustum(BaseModel): + shape: Cuboidal = Field(..., description="Denote shape as cuboidal") + bottomXDimension: _NonNegativeNumber = Field( + ..., + description="x dimension of the bottom cross-section of a rectangular frustum", + ) + bottomYDimension: _NonNegativeNumber = Field( + ..., + description="y dimension of the bottom cross-section of a rectangular frustum", + ) + topXDimension: _NonNegativeNumber = Field( + ..., + description="x dimension of the top cross-section of a rectangular frustum", + ) + topYDimension: _NonNegativeNumber = Field( + ..., + description="y dimension of the top cross-section of a rectangular frustum", + ) + topHeight: _NonNegativeNumber = Field( + ..., + description="The height at the top of a bounded subsection of a well, relative to the bottom" + "of the well", + ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", + ) + + +# A squared cone is the intersection of a cube and a cone that both +# share a central axis, and is a transitional shape between a cone and pyramid +""" +module RectangularPrismToCone(bottom_shape, diameter, x, y, z) { + circle_radius = diameter/2; + r1 = sqrt(x*x + y*y)/2; + r2 = circle_radius/2; + top_r = bottom_shape == "square" ? r1 : r2; + bottom_r = bottom_shape == "square" ? r2 : r1; + intersection() { + cylinder(z,top_r,bottom_r,$fn=100); + translate([0,0,z/2])cube([x, y, z], center=true); + } +} +""" -class CircularBoundedSection(BaseModel): - shape: Literal["circular"] = Field(..., description="Denote shape as circular") - diameter: _NonNegativeNumber = Field( - ..., description="The diameter of a circular cross section of a well" +class SquaredConeSegment(BaseModel): + shape: SquaredCone = Field( + ..., description="Denote shape as a squared conical segment" + ) + bottomCrossSection: WellShape = Field( + ..., + description="Denote if the shape is going from circular to rectangular or vise versa", + ) + circleDiameter: _NonNegativeNumber = Field( + ..., + description="diameter of the circular face of a truncated circular segment", + ) + + rectangleXDimension: _NonNegativeNumber = Field( + ..., + description="x dimension of the rectangular face of a truncated circular segment", + ) + rectangleYDimension: _NonNegativeNumber = Field( + ..., + description="y dimension of the rectangular face of a truncated circular segment", ) topHeight: _NonNegativeNumber = Field( ..., description="The height at the top of a bounded subsection of a well, relative to the bottom" "of the well", ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", + ) -class RectangularBoundedSection(BaseModel): - shape: Literal["rectangular"] = Field( - ..., description="Denote shape as rectangular" +""" +module filitedCuboidSquare(bottom_shape, diameter, width, length, height, steps) { + module _slice(depth, x, y, r) { + echo("called with: ", depth, x, y, r); + circle_centers = [ + [(x/2)-r, (y/2)-r, 0], + [(-x/2)+r, (y/2)-r, 0], + [(x/2)-r, (-y/2)+r, 0], + [(-x/2)+r, (-y/2)+r, 0] + + ]; + translate([0,0,depth/2])cube([x-2*r,y,depth], center=true); + translate([0,0,depth/2])cube([x,y-2*r,depth], center=true); + for (center = circle_centers) { + translate(center) cylinder(depth, r, r, $fn=100); + } + } + for (slice_height = [0:height/steps:height]) { + r = (diameter) * (slice_height/height); + translate([0,0,slice_height]) { + _slice(height/steps , width, length, r/2); + } + } +} +module filitedCuboidForce(bottom_shape, diameter, width, length, height, steps) { + module single_cone(r,x,y,z) { + r = diameter/2; + circle_face = [[ for (i = [0:1: steps]) i ]]; + theta = 360/steps; + circle_points = [for (step = [0:1:steps]) [r*cos(theta*step), r*sin(theta*step), z]]; + final_points = [[x,y,0]]; + all_points = concat(circle_points, final_points); + triangles = [for (step = [0:1:steps-1]) [step, step+1, steps+1]]; + faces = concat(circle_face, triangles); + polyhedron(all_points, faces); + } + module square_section(r, x, y, z) { + points = [ + [x,y,0], + [-x,y,0], + [-x,-y,0], + [x,-y,0], + [r,0,z], + [0,r,z], + [-r,0,z], + [0,-r,z], + ]; + faces = [ + [0,1,2,3], + [4,5,6,7], + [4, 0, 3], + [5, 0, 1], + [6, 1, 2], + [7, 2, 3], + ]; + polyhedron(points, faces); + } + circle_height = bottom_shape == "square" ? height : -height; + translate_height = bottom_shape == "square" ? 0 : height; + translate ([0,0, translate_height]) { + union() { + single_cone(diameter/2, width/2, length/2, circle_height); + single_cone(diameter/2, -width/2, length/2, circle_height); + single_cone(diameter/2, width/2, -length/2, circle_height); + single_cone(diameter/2, -width/2, -length/2, circle_height); + square_section(diameter/2, width/2, length/2, circle_height); + } + } +} + +module filitedCuboid(bottom_shape, diameter, width, length, height) { + if (width == length && width == diameter) { + filitedCuboidSquare(bottom_shape, diameter, width, length, height, 100); + } + else { + filitedCuboidForce(bottom_shape, diameter, width, length, height, 100); + } +}""" + + +class RoundedCuboidSegment(BaseModel): + shape: RoundedCuboid = Field( + ..., description="Denote shape as a rounded cuboidal segment" + ) + bottomCrossSection: WellShape = Field( + ..., + description="Denote if the shape is going from circular to rectangular or vise versa", + ) + circleDiameter: _NonNegativeNumber = Field( + ..., + description="diameter of the circular face of a rounded rectangular segment", ) - xDimension: _NonNegativeNumber = Field( + rectangleXDimension: _NonNegativeNumber = Field( ..., - description="x dimension of a subsection of wells", + description="x dimension of the rectangular face of a rounded rectangular segment", ) - yDimension: _NonNegativeNumber = Field( + rectangleYDimension: _NonNegativeNumber = Field( ..., - description="y dimension of a subsection of wells", + description="y dimension of the rectangular face of a rounded rectangular segment", ) topHeight: _NonNegativeNumber = Field( ..., description="The height at the top of a bounded subsection of a well, relative to the bottom" "of the well", ) + bottomHeight: _NonNegativeNumber = Field( + ..., + description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", + ) class Metadata1(BaseModel): @@ -297,17 +488,20 @@ class Group(BaseModel): ) +WellSegment = Union[ + ConicalFrustum, + CuboidalFrustum, + SquaredConeSegment, + RoundedCuboidSegment, + SphericalSegment, +] + + class InnerWellGeometry(BaseModel): - frusta: Union[ - List[CircularBoundedSection], List[RectangularBoundedSection] - ] = Field( + sections: List[WellSegment] = Field( ..., description="A list of all of the sections of the well that have a contiguous shape", ) - bottomShape: Optional[SphericalSegment] = Field( - None, - description="The shape at the bottom of the well: either a spherical segment or a cross-section", - ) class LabwareDefinition(BaseModel): diff --git a/shared-data/python/opentrons_shared_data/labware/types.py b/shared-data/python/opentrons_shared_data/labware/types.py index 9ea7a83fb6b..d3f6599848c 100644 --- a/shared-data/python/opentrons_shared_data/labware/types.py +++ b/shared-data/python/opentrons_shared_data/labware/types.py @@ -3,9 +3,13 @@ types in this file by and large require the use of typing_extensions. this module shouldn't be imported unless typing.TYPE_CHECKING is true. """ -from typing import Dict, List, NewType, Union, Optional, Any -from typing_extensions import Literal, TypedDict, NotRequired, TypeGuard - +from typing import Dict, List, NewType, Union +from typing_extensions import Literal, TypedDict, NotRequired +from .labware_definition import InnerWellGeometry +from .constants import ( + Circular, + Rectangular, +) LabwareUri = NewType("LabwareUri", str) @@ -35,11 +39,6 @@ Literal["maintenance"], ] -Circular = Literal["circular"] -Rectangular = Literal["rectangular"] -Spherical = Literal["spherical"] -WellShape = Union[Circular, Rectangular] - class NamedOffset(TypedDict): x: float @@ -120,42 +119,6 @@ class WellGroup(TypedDict, total=False): brand: LabwareBrandData -class SphericalSegment(TypedDict): - shape: Spherical - radiusOfCurvature: float - depth: float - - -class RectangularBoundedSection(TypedDict): - shape: Rectangular - xDimension: float - yDimension: float - topHeight: float - - -class CircularBoundedSection(TypedDict): - shape: Circular - diameter: float - topHeight: float - - -def is_circular_frusta_list( - items: List[Any], -) -> TypeGuard[List[CircularBoundedSection]]: - return all(item.shape == "circular" for item in items) - - -def is_rectangular_frusta_list( - items: List[Any], -) -> TypeGuard[List[RectangularBoundedSection]]: - return all(item.shape == "rectangular" for item in items) - - -class InnerWellGeometry(TypedDict): - frusta: Union[List[CircularBoundedSection], List[RectangularBoundedSection]] - bottomShape: Optional[SphericalSegment] - - class LabwareDefinition(TypedDict): schemaVersion: Literal[2] version: int