Skip to content

Commit 9a65cc8

Browse files
authored
feat(shared-data): add transitional shape calculations (#16554)
<!-- Thanks for taking the time to open a Pull Request (PR)! Please make sure you've read the "Opening Pull Requests" section of our Contributing Guide: https://github.com/Opentrons/opentrons/blob/edge/CONTRIBUTING.md#opening-pull-requests GitHub provides robust markdown to format your PR. Links, diagrams, pictures, and videos along with text formatting make it possible to create a rich and informative PR. For more information on GitHub markdown, see: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax To ensure your code is reviewed quickly and thoroughly, please fill out the sections below to the best of your ability! --> # Overview Adds a cached property to the "Squared cone" labware geometry segment that computes the height/volume tables if needed. Also update frustum helpers to use these and don't raise a Not implemented error on this shape. <!-- Describe your PR at a high level. State acceptance criteria and how this PR fits into other work. Link issues, PRs, and other relevant resources. --> ## Test Plan and Hands on Testing <!-- Describe your testing of the PR. Emphasize testing not reflected in the code. Attach protocols, logs, screenshots and any other assets that support your testing. --> ## Changelog <!-- List changes introduced by this PR considering future developers and the end user. Give careful thought and clear documentation to breaking changes. --> ## Review requests <!-- - What do you need from reviewers to feel confident this PR is ready to merge? - Ask questions. --> ## Risk assessment <!-- - Indicate the level of attention this PR needs. - Provide context to guide reviewers. - Discuss trade-offs, coupling, and side effects. - Look for the possibility, even if you think it's small, that your change may affect some other part of the system. - For instance, changing return tip behavior may also change the behavior of labware calibration. - How do your unit tests and on hands on testing mitigate this PR's risks and the risk of future regressions? - Especially in high risk PRs, explain how you know your testing is enough. -->
1 parent 42699e3 commit 9a65cc8

File tree

7 files changed

+150
-9
lines changed

7 files changed

+150
-9
lines changed

api/src/opentrons/protocol_engine/state/frustum_helpers.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
SphericalSegment,
1212
ConicalFrustum,
1313
CuboidalFrustum,
14+
SquaredConeSegment,
1415
)
1516

1617

@@ -127,6 +128,15 @@ def _volume_from_height_spherical(
127128
return volume
128129

129130

131+
def _volume_from_height_squared_cone(
132+
target_height: float, segment: SquaredConeSegment
133+
) -> float:
134+
"""Find the volume given a height within a squared cone segment."""
135+
heights = segment.height_to_volume_table.keys()
136+
best_fit_height = min(heights, key=lambda x: abs(x - target_height))
137+
return segment.height_to_volume_table[best_fit_height]
138+
139+
130140
def _height_from_volume_circular(
131141
volume: float,
132142
total_frustum_height: float,
@@ -197,15 +207,24 @@ def _height_from_volume_spherical(
197207
return height
198208

199209

210+
def _height_from_volume_squared_cone(
211+
target_volume: float, segment: SquaredConeSegment
212+
) -> float:
213+
"""Find the height given a volume within a squared cone segment."""
214+
volumes = segment.volume_to_height_table.keys()
215+
best_fit_volume = min(volumes, key=lambda x: abs(x - target_volume))
216+
return segment.volume_to_height_table[best_fit_volume]
217+
218+
200219
def _get_segment_capacity(segment: WellSegment) -> float:
220+
section_height = segment.topHeight - segment.bottomHeight
201221
match segment:
202222
case SphericalSegment():
203223
return _volume_from_height_spherical(
204224
target_height=segment.topHeight,
205225
radius_of_curvature=segment.radiusOfCurvature,
206226
)
207227
case CuboidalFrustum():
208-
section_height = segment.topHeight - segment.bottomHeight
209228
return _volume_from_height_rectangular(
210229
target_height=section_height,
211230
bottom_length=segment.bottomYDimension,
@@ -215,13 +234,14 @@ def _get_segment_capacity(segment: WellSegment) -> float:
215234
total_frustum_height=section_height,
216235
)
217236
case ConicalFrustum():
218-
section_height = segment.topHeight - segment.bottomHeight
219237
return _volume_from_height_circular(
220238
target_height=section_height,
221239
total_frustum_height=section_height,
222240
bottom_radius=(segment.bottomDiameter / 2),
223241
top_radius=(segment.topDiameter / 2),
224242
)
243+
case SquaredConeSegment():
244+
return _volume_from_height_squared_cone(section_height, segment)
225245
case _:
226246
# TODO: implement volume calculations for truncated circular and rounded rectangular segments
227247
raise NotImplementedError(
@@ -275,6 +295,8 @@ def height_at_volume_within_section(
275295
top_width=section.topXDimension,
276296
top_length=section.topYDimension,
277297
)
298+
case SquaredConeSegment():
299+
return _height_from_volume_squared_cone(target_volume_relative, section)
278300
case _:
279301
raise NotImplementedError(
280302
"Height from volume calculation not yet implemented for this well shape."
@@ -309,6 +331,8 @@ def volume_at_height_within_section(
309331
top_width=section.topXDimension,
310332
top_length=section.topYDimension,
311333
)
334+
case SquaredConeSegment():
335+
return _volume_from_height_squared_cone(target_height_relative, section)
312336
case _:
313337
# TODO(cm): this would be the NEST-96 2uL wells referenced in EXEC-712
314338
# we need to input the math attached to that issue

shared-data/python/Config.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ config BR2_PACKAGE_PYTHON_OPENTRONS_SHARED_DATA
33
depends on BR2_PACKAGE_PYTHON3
44
select BR2_PACKAGE_PYTHON_JSONSCHEMA # runtime
55
select BR2_PACKAGE_PYTHON_TYPING_EXTENSIONS # runtime
6+
select BR2_PACKAGE_PYTHON_NUMPY # runtime
67

78
help
89
Opentrons data sources. Used on an OT-2 robot.

shared-data/python/Pipfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ pytest-clarity = "~=1.0.0"
2828
opentrons-shared-data = { editable = true, path = "." }
2929
jsonschema = "==4.21.1"
3030
pydantic = "==1.10.12"
31+
numpy = "==1.22.3"

shared-data/python/Pipfile.lock

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

shared-data/python/opentrons_shared_data/labware/constants.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
WELL_NAME_PATTERN: Final["re.Pattern[str]"] = re.compile(r"^([A-Z]+)([0-9]+)$", re.X)
99

1010
# These shapes are for wellshape definitions and describe the top of the well
11-
Circular = Literal["circular"]
12-
Rectangular = Literal["rectangular"]
13-
WellShape = Union[Circular, Rectangular]
11+
CircularType = Literal["circular"]
12+
Circular: CircularType = "circular"
13+
RectangularType = Literal["rectangular"]
14+
Rectangular: RectangularType = "rectangular"
15+
WellShape = Union[Literal["circular"], Literal["rectangular"]]
1416

1517
# These shapes are used to describe the 3D primatives used to build wells
1618
Conical = Literal["conical"]

shared-data/python/opentrons_shared_data/labware/labware_definition.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
from enum import Enum
99
from typing import TYPE_CHECKING, Dict, List, Optional, Union
10+
from math import sqrt, asin
11+
from numpy import pi, trapz
12+
from functools import cached_property
1013

1114
from pydantic import (
1215
BaseModel,
@@ -26,6 +29,8 @@
2629
SquaredCone,
2730
Spherical,
2831
WellShape,
32+
Circular,
33+
Rectangular,
2934
)
3035

3136
SAFE_STRING_REGEX = "^[a-z0-9._]+$"
@@ -350,6 +355,87 @@ class SquaredConeSegment(BaseModel):
350355
description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well",
351356
)
352357

358+
@staticmethod
359+
def _area_trap_points(
360+
total_frustum_height: float,
361+
circle_diameter: float,
362+
rectangle_x: float,
363+
rectangle_y: float,
364+
dx: float,
365+
) -> List[float]:
366+
"""Grab a bunch of data points of area at given heights."""
367+
368+
def _area_arcs(r: float, c: float, d: float) -> float:
369+
"""Return the area of all 4 arc segments."""
370+
theata_y = asin(c / r)
371+
theata_x = asin(d / r)
372+
theata_arc = (pi / 2) - theata_y - theata_x
373+
# area of all 4 arcs is 4 * pi*r^2*(theata/2pi)
374+
return 2 * r**2 * theata_arc
375+
376+
def _area(r: float) -> float:
377+
"""Return the area of a given r_y."""
378+
# distance from the center of y axis of the rectangle to where the arc intercepts that side
379+
c: float = (
380+
sqrt(r**2 - (rectangle_y / 2) ** 2) if (rectangle_y / 2) < r else 0
381+
)
382+
# distance from the center of x axis of the rectangle to where the arc intercepts that side
383+
d: float = (
384+
sqrt(r**2 - (rectangle_x / 2) ** 2) if (rectangle_x / 2) < r else 0
385+
)
386+
arc_area = _area_arcs(r, c, d)
387+
y_triangles: float = rectangle_y * c
388+
x_triangles: float = rectangle_x * d
389+
return arc_area + y_triangles + x_triangles
390+
391+
r_0 = circle_diameter / 2
392+
r_h = sqrt(rectangle_x**2 + rectangle_y**2) / 2
393+
394+
num_steps = int(total_frustum_height / dx)
395+
points = [0.0]
396+
for i in range(num_steps + 1):
397+
r_y = (i * dx / total_frustum_height) * (r_h - r_0) + r_0
398+
points.append(_area(r_y))
399+
return points
400+
401+
@cached_property
402+
def height_to_volume_table(self) -> Dict[float, float]:
403+
"""Return a lookup table of heights to volumes."""
404+
# the accuracy of this method is approximately +- 10*dx so for dx of 0.001 we have a +- 0.01 ul
405+
dx = 0.001
406+
total_height = self.topHeight - self.bottomHeight
407+
points = SquaredConeSegment._area_trap_points(
408+
total_height,
409+
self.circleDiameter,
410+
self.rectangleXDimension,
411+
self.rectangleYDimension,
412+
dx,
413+
)
414+
if self.bottomCrossSection is Rectangular:
415+
# The points function assumes the circle is at the bottom but if its flipped we just reverse the points
416+
points.reverse()
417+
elif self.bottomCrossSection is not Circular:
418+
raise NotImplementedError(
419+
"If you see this error a new well shape has been added without updating this code"
420+
)
421+
y = 0.0
422+
table: Dict[float, float] = {}
423+
# fill in the table
424+
while y < total_height:
425+
table[y] = trapz(points[0 : int(y / dx)], dx=dx)
426+
y = y + dx
427+
428+
# we always want to include the volume at the max height
429+
table[total_height] = trapz(points, dx=dx)
430+
return table
431+
432+
@cached_property
433+
def volume_to_height_table(self) -> Dict[float, float]:
434+
return dict((v, k) for k, v in self.height_to_volume_table.items())
435+
436+
class Config:
437+
keep_untouched = (cached_property,)
438+
353439

354440
"""
355441
module filitedCuboidSquare(bottom_shape, diameter, width, length, height, steps) {

shared-data/python/opentrons_shared_data/labware/types.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
from typing_extensions import Literal, TypedDict, NotRequired
88
from .labware_definition import InnerWellGeometry
99
from .constants import (
10-
Circular,
11-
Rectangular,
10+
CircularType,
11+
RectangularType,
1212
)
1313

1414
LabwareUri = NewType("LabwareUri", str)
@@ -84,7 +84,7 @@ class LabwareDimensions(TypedDict):
8484

8585

8686
class CircularWellDefinition(TypedDict):
87-
shape: Circular
87+
shape: CircularType
8888
depth: float
8989
totalLiquidVolume: float
9090
x: float
@@ -95,7 +95,7 @@ class CircularWellDefinition(TypedDict):
9595

9696

9797
class RectangularWellDefinition(TypedDict):
98-
shape: Rectangular
98+
shape: RectangularType
9999
depth: float
100100
totalLiquidVolume: float
101101
x: float

0 commit comments

Comments
 (0)