diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index 09f726de767..dfdb0eec56f 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -11,6 +11,7 @@ SphericalSegment, ConicalFrustum, CuboidalFrustum, + SquaredConeSegment, ) @@ -127,6 +128,15 @@ def _volume_from_height_spherical( return volume +def _volume_from_height_squared_cone( + target_height: float, segment: SquaredConeSegment +) -> float: + """Find the volume given a height within a squared cone segment.""" + 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] + + def _height_from_volume_circular( volume: float, total_frustum_height: float, @@ -197,7 +207,17 @@ def _height_from_volume_spherical( return height +def _height_from_volume_squared_cone( + target_volume: float, segment: SquaredConeSegment +) -> 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] + + def _get_segment_capacity(segment: WellSegment) -> float: + section_height = segment.topHeight - segment.bottomHeight match segment: case SphericalSegment(): return _volume_from_height_spherical( @@ -205,7 +225,6 @@ def _get_segment_capacity(segment: WellSegment) -> float: 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, @@ -215,13 +234,14 @@ def _get_segment_capacity(segment: WellSegment) -> float: 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 SquaredConeSegment(): + return _volume_from_height_squared_cone(section_height, segment) case _: # TODO: implement volume calculations for truncated circular and rounded rectangular segments raise NotImplementedError( @@ -275,6 +295,8 @@ def height_at_volume_within_section( top_width=section.topXDimension, top_length=section.topYDimension, ) + case SquaredConeSegment(): + return _height_from_volume_squared_cone(target_volume_relative, section) case _: raise NotImplementedError( "Height from volume calculation not yet implemented for this well shape." @@ -309,6 +331,8 @@ def volume_at_height_within_section( top_width=section.topXDimension, top_length=section.topYDimension, ) + case SquaredConeSegment(): + return _volume_from_height_squared_cone(target_height_relative, section) 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 diff --git a/shared-data/python/Config.in b/shared-data/python/Config.in index 43ea8cf2688..8b9affb20ee 100644 --- a/shared-data/python/Config.in +++ b/shared-data/python/Config.in @@ -3,6 +3,7 @@ config BR2_PACKAGE_PYTHON_OPENTRONS_SHARED_DATA depends on BR2_PACKAGE_PYTHON3 select BR2_PACKAGE_PYTHON_JSONSCHEMA # runtime select BR2_PACKAGE_PYTHON_TYPING_EXTENSIONS # runtime + select BR2_PACKAGE_PYTHON_NUMPY # runtime help Opentrons data sources. Used on an OT-2 robot. diff --git a/shared-data/python/Pipfile b/shared-data/python/Pipfile index 0d11a1d68c9..dff2c2318bd 100644 --- a/shared-data/python/Pipfile +++ b/shared-data/python/Pipfile @@ -28,3 +28,4 @@ pytest-clarity = "~=1.0.0" opentrons-shared-data = { editable = true, path = "." } jsonschema = "==4.21.1" pydantic = "==1.10.12" +numpy = "==1.22.3" diff --git a/shared-data/python/Pipfile.lock b/shared-data/python/Pipfile.lock index a125943127f..43ce052e327 100644 --- a/shared-data/python/Pipfile.lock +++ b/shared-data/python/Pipfile.lock @@ -39,6 +39,33 @@ "markers": "python_version >= '3.8'", "version": "==2023.12.1" }, + "numpy": { + "hashes": [ + "sha256:07a8c89a04997625236c5ecb7afe35a02af3896c8aa01890a849913a2309c676", + "sha256:08d9b008d0156c70dc392bb3ab3abb6e7a711383c3247b410b39962263576cd4", + "sha256:201b4d0552831f7250a08d3b38de0d989d6f6e4658b709a02a73c524ccc6ffce", + "sha256:2c10a93606e0b4b95c9b04b77dc349b398fdfbda382d2a39ba5a822f669a0123", + "sha256:3ca688e1b9b95d80250bca34b11a05e389b1420d00e87a0d12dc45f131f704a1", + "sha256:48a3aecd3b997bf452a2dedb11f4e79bc5bfd21a1d4cc760e703c31d57c84b3e", + "sha256:568dfd16224abddafb1cbcce2ff14f522abe037268514dd7e42c6776a1c3f8e5", + "sha256:5bfb1bb598e8229c2d5d48db1860bcf4311337864ea3efdbe1171fb0c5da515d", + "sha256:639b54cdf6aa4f82fe37ebf70401bbb74b8508fddcf4797f9fe59615b8c5813a", + "sha256:8251ed96f38b47b4295b1ae51631de7ffa8260b5b087808ef09a39a9d66c97ab", + "sha256:92bfa69cfbdf7dfc3040978ad09a48091143cffb778ec3b03fa170c494118d75", + "sha256:97098b95aa4e418529099c26558eeb8486e66bd1e53a6b606d684d0c3616b168", + "sha256:a3bae1a2ed00e90b3ba5f7bd0a7c7999b55d609e0c54ceb2b076a25e345fa9f4", + "sha256:c34ea7e9d13a70bf2ab64a2532fe149a9aced424cd05a2c4ba662fd989e3e45f", + "sha256:dbc7601a3b7472d559dc7b933b18b4b66f9aa7452c120e87dfb33d02008c8a18", + "sha256:e7927a589df200c5e23c57970bafbd0cd322459aa7b1ff73b7c2e84d6e3eae62", + "sha256:f8c1f39caad2c896bc0018f699882b345b2a63708008be29b1f355ebf6f933fe", + "sha256:f950f8845b480cffe522913d35567e29dd381b0dc7e4ce6a4a9f9156417d2430", + "sha256:fade0d4f4d292b6f39951b6836d7a3c7ef5b2347f3c420cd9820a1d90d794802", + "sha256:fdf3c08bce27132395d3c3ba1503cac12e17282358cb4bddc25cc46b0aca07aa" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==1.22.3" + }, "opentrons-shared-data": { "editable": true, "markers": "python_version >= '3.7'", diff --git a/shared-data/python/opentrons_shared_data/labware/constants.py b/shared-data/python/opentrons_shared_data/labware/constants.py index 9973604937b..4ce6974d600 100644 --- a/shared-data/python/opentrons_shared_data/labware/constants.py +++ b/shared-data/python/opentrons_shared_data/labware/constants.py @@ -8,9 +8,11 @@ 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] +CircularType = Literal["circular"] +Circular: CircularType = "circular" +RectangularType = Literal["rectangular"] +Rectangular: RectangularType = "rectangular" +WellShape = Union[Literal["circular"], Literal["rectangular"]] # These shapes are used to describe the 3D primatives used to build wells Conical = Literal["conical"] 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 e272eef16d2..6e20cc64809 100644 --- a/shared-data/python/opentrons_shared_data/labware/labware_definition.py +++ b/shared-data/python/opentrons_shared_data/labware/labware_definition.py @@ -7,6 +7,9 @@ from enum import Enum from typing import TYPE_CHECKING, Dict, List, Optional, Union +from math import sqrt, asin +from numpy import pi, trapz +from functools import cached_property from pydantic import ( BaseModel, @@ -26,6 +29,8 @@ SquaredCone, Spherical, WellShape, + Circular, + Rectangular, ) SAFE_STRING_REGEX = "^[a-z0-9._]+$" @@ -350,6 +355,87 @@ class SquaredConeSegment(BaseModel): description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", ) + @staticmethod + def _area_trap_points( + total_frustum_height: float, + circle_diameter: float, + rectangle_x: float, + rectangle_y: float, + dx: float, + ) -> List[float]: + """Grab a bunch of data points of area at given heights.""" + + def _area_arcs(r: float, c: float, d: float) -> float: + """Return the area of all 4 arc segments.""" + theata_y = asin(c / r) + theata_x = asin(d / r) + theata_arc = (pi / 2) - theata_y - theata_x + # area of all 4 arcs is 4 * pi*r^2*(theata/2pi) + return 2 * r**2 * theata_arc + + def _area(r: float) -> float: + """Return the area of a given r_y.""" + # distance from the center of y axis of the rectangle to where the arc intercepts that side + c: float = ( + sqrt(r**2 - (rectangle_y / 2) ** 2) if (rectangle_y / 2) < r else 0 + ) + # distance from the center of x axis of the rectangle to where the arc intercepts that side + d: float = ( + sqrt(r**2 - (rectangle_x / 2) ** 2) if (rectangle_x / 2) < r else 0 + ) + arc_area = _area_arcs(r, c, d) + y_triangles: float = rectangle_y * c + x_triangles: float = rectangle_x * d + return arc_area + y_triangles + x_triangles + + r_0 = circle_diameter / 2 + r_h = sqrt(rectangle_x**2 + rectangle_y**2) / 2 + + num_steps = int(total_frustum_height / dx) + points = [0.0] + for i in range(num_steps + 1): + r_y = (i * dx / total_frustum_height) * (r_h - r_0) + r_0 + points.append(_area(r_y)) + return points + + @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.001 + total_height = self.topHeight - self.bottomHeight + points = SquaredConeSegment._area_trap_points( + total_height, + self.circleDiameter, + self.rectangleXDimension, + self.rectangleYDimension, + dx, + ) + if self.bottomCrossSection is Rectangular: + # The points function assumes the circle is at the bottom but if its flipped we just reverse the points + points.reverse() + elif self.bottomCrossSection is not Circular: + raise NotImplementedError( + "If you see this error a new well shape has been added without updating this code" + ) + y = 0.0 + table: Dict[float, float] = {} + # fill in the table + while y < total_height: + table[y] = trapz(points[0 : int(y / dx)], dx=dx) + y = y + dx + + # we always want to include the volume at the max height + table[total_height] = trapz(points, dx=dx) + 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()) + + class Config: + keep_untouched = (cached_property,) + """ module filitedCuboidSquare(bottom_shape, diameter, width, length, height, steps) { diff --git a/shared-data/python/opentrons_shared_data/labware/types.py b/shared-data/python/opentrons_shared_data/labware/types.py index 8155e45011f..20fb6664485 100644 --- a/shared-data/python/opentrons_shared_data/labware/types.py +++ b/shared-data/python/opentrons_shared_data/labware/types.py @@ -7,8 +7,8 @@ from typing_extensions import Literal, TypedDict, NotRequired from .labware_definition import InnerWellGeometry from .constants import ( - Circular, - Rectangular, + CircularType, + RectangularType, ) LabwareUri = NewType("LabwareUri", str) @@ -84,7 +84,7 @@ class LabwareDimensions(TypedDict): class CircularWellDefinition(TypedDict): - shape: Circular + shape: CircularType depth: float totalLiquidVolume: float x: float @@ -95,7 +95,7 @@ class CircularWellDefinition(TypedDict): class RectangularWellDefinition(TypedDict): - shape: Rectangular + shape: RectangularType depth: float totalLiquidVolume: float x: float