Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(shared-data): add transitional shape calculations #16554

Merged
merged 8 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions api/src/opentrons/protocol_engine/state/frustum_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
SphericalSegment,
ConicalFrustum,
CuboidalFrustum,
SquaredConeSegment,
)


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -197,15 +207,24 @@ 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(
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,
Expand All @@ -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(
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions shared-data/python/Config.in
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions shared-data/python/Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
27 changes: 27 additions & 0 deletions shared-data/python/Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +29,8 @@
SquaredCone,
Spherical,
WellShape,
Circular,
Rectangular,
)

SAFE_STRING_REGEX = "^[a-z0-9._]+$"
Expand Down Expand Up @@ -349,6 +354,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]:
ryanthecoder marked this conversation as resolved.
Show resolved Hide resolved
"""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) {
Expand Down
8 changes: 4 additions & 4 deletions shared-data/python/opentrons_shared_data/labware/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -83,7 +83,7 @@ class LabwareDimensions(TypedDict):


class CircularWellDefinition(TypedDict):
shape: Circular
shape: CircularType
depth: float
totalLiquidVolume: float
x: float
Expand All @@ -94,7 +94,7 @@ class CircularWellDefinition(TypedDict):


class RectangularWellDefinition(TypedDict):
shape: Rectangular
shape: RectangularType
depth: float
totalLiquidVolume: float
x: float
Expand Down
Loading