From c9cda35aebad759932474882c955fb904fb4f3c7 Mon Sep 17 00:00:00 2001 From: Ryan Howard Date: Fri, 1 Nov 2024 11:02:33 -0400 Subject: [PATCH] feat(shared-data): Add the ability to handle sub wells in labware geometry (#16658) # Overview Some Labware, particularly larger reservoirs have wells that have "sub well" geometry at the bottom, such as the Nest 1 well 195ml, which is one well with 96 little pyramids at the bottom. This PR lets there be a "count" field in the geometry stack up which tells the system that there are "count" number of sub features in this well. For the Height->Volume, we do the normal height->volume of one of these sub features and then multiple the resulting volume by "count" For Volume-Height, we first divide the volume by "count" to get the volume change in one sub well and then do the normal calculation. ## Test Plan and Hands on Testing ## Changelog ## Review requests ## Risk assessment --- .../protocol_engine/state/frustum_helpers.py | 89 ++++++++++++------- .../3/agilent_1_reservoir_290ml/2.json | 22 ++++- .../3/nest_1_reservoir_195ml/3.json | 23 ++++- .../3/usascientific_12_reservoir_22ml/2.json | 22 ++++- shared-data/labware/schemas/3.json | 30 +++++++ .../labware/labware_definition.py | 72 +++++++++++++++ 6 files changed, 223 insertions(+), 35 deletions(-) diff --git a/api/src/opentrons/protocol_engine/state/frustum_helpers.py b/api/src/opentrons/protocol_engine/state/frustum_helpers.py index dfdb0eec56f..83499fb2510 100644 --- a/api/src/opentrons/protocol_engine/state/frustum_helpers.py +++ b/api/src/opentrons/protocol_engine/state/frustum_helpers.py @@ -220,28 +220,40 @@ 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, + return ( + _volume_from_height_spherical( + target_height=segment.topHeight, + radius_of_curvature=segment.radiusOfCurvature, + ) + * segment.count ) case CuboidalFrustum(): - 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, + 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, + ) + * segment.count ) case ConicalFrustum(): - return _volume_from_height_circular( - target_height=section_height, - total_frustum_height=section_height, - bottom_radius=(segment.bottomDiameter / 2), - top_radius=(segment.topDiameter / 2), + return ( + _volume_from_height_circular( + target_height=section_height, + total_frustum_height=section_height, + bottom_radius=(segment.bottomDiameter / 2), + top_radius=(segment.topDiameter / 2), + ) + * segment.count ) case SquaredConeSegment(): - return _volume_from_height_squared_cone(section_height, segment) + return ( + _volume_from_height_squared_cone(section_height, segment) + * segment.count + ) case _: # TODO: implement volume calculations for truncated circular and rounded rectangular segments raise NotImplementedError( @@ -272,6 +284,7 @@ def height_at_volume_within_section( section_height: float, ) -> float: """Calculate a height within a bounded section according to geometry.""" + target_volume_relative = target_volume_relative / section.count match section: case SphericalSegment(): return _height_from_volume_spherical( @@ -311,28 +324,40 @@ def volume_at_height_within_section( """Calculate a volume within a bounded section according to geometry.""" match section: case SphericalSegment(): - return _volume_from_height_spherical( - target_height=target_height_relative, - radius_of_curvature=section.radiusOfCurvature, + return ( + _volume_from_height_spherical( + target_height=target_height_relative, + radius_of_curvature=section.radiusOfCurvature, + ) + * section.count ) 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), + 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), + ) + * section.count ) 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, + 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, + ) + * section.count ) case SquaredConeSegment(): - return _volume_from_height_squared_cone(target_height_relative, section) + return ( + _volume_from_height_squared_cone(target_height_relative, section) + * section.count + ) 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/labware/definitions/3/agilent_1_reservoir_290ml/2.json b/shared-data/labware/definitions/3/agilent_1_reservoir_290ml/2.json index 984af88d3ed..4a06fc94f97 100644 --- a/shared-data/labware/definitions/3/agilent_1_reservoir_290ml/2.json +++ b/shared-data/labware/definitions/3/agilent_1_reservoir_290ml/2.json @@ -54,7 +54,27 @@ }, "innerLabwareGeometry": { "cuboidalWell": { - "sections": [] + "sections": [ + { + "shape": "cuboidal", + "topXDimension": 107.25, + "topYDimension": 8, + "bottomXDimension": 101.25, + "bottomYDimension": 1.66, + "topHeight": 2, + "bottomHeight": 8, + "yCount": 8 + }, + { + "shape": "cuboidal", + "topXDimension": 107.5, + "topYDimension": 71.25, + "bottomXDimension": 107.25, + "bottomYDimension": 71.0, + "topHeight": 39.22, + "bottomHeight": 2 + } + ] } } } diff --git a/shared-data/labware/definitions/3/nest_1_reservoir_195ml/3.json b/shared-data/labware/definitions/3/nest_1_reservoir_195ml/3.json index 5930984eab5..842b916fb8c 100644 --- a/shared-data/labware/definitions/3/nest_1_reservoir_195ml/3.json +++ b/shared-data/labware/definitions/3/nest_1_reservoir_195ml/3.json @@ -56,7 +56,28 @@ }, "innerLabwareGeometry": { "cuboidalWell": { - "sections": [] + "sections": [ + { + "shape": "cuboidal", + "topXDimension": 9, + "topYDimension": 9, + "bottomXDimension": 1.93, + "bottomYDimension": 1.93, + "topHeight": 2, + "bottomHeight": 0, + "xCount": 12, + "yCount": 8 + }, + { + "shape": "cuboidal", + "topXDimension": 71.3, + "topYDimension": 70.6, + "bottomXDimension": 107.3, + "bottomYDimension": 106.8, + "topHeight": 26.85, + "bottomHeight": 2 + } + ] } } } diff --git a/shared-data/labware/definitions/3/usascientific_12_reservoir_22ml/2.json b/shared-data/labware/definitions/3/usascientific_12_reservoir_22ml/2.json index 6a8375138d5..d17d27c041a 100644 --- a/shared-data/labware/definitions/3/usascientific_12_reservoir_22ml/2.json +++ b/shared-data/labware/definitions/3/usascientific_12_reservoir_22ml/2.json @@ -203,7 +203,27 @@ }, "innerLabwareGeometry": { "cuboidalWell": { - "sections": [] + "sections": [ + { + "shape": "squaredcone", + "bottomCrossSection": "circular", + "circleDiameter": 2.5, + "rectangleXDimension": 7.98, + "rectangleYDimension": 70.98, + "topHeight": 4.05, + "bottomHeight": 0.0, + "yCount": 8 + }, + { + "shape": "cuboidal", + "topXDimension": 8.34, + "topYDimension": 71.85, + "bottomXDimension": 7.98, + "bottomYDimension": 70.98, + "topHeight": 41.75, + "bottomHeight": 4.05 + } + ] } } } diff --git a/shared-data/labware/schemas/3.json b/shared-data/labware/schemas/3.json index e38c070919a..eef1252c419 100644 --- a/shared-data/labware/schemas/3.json +++ b/shared-data/labware/schemas/3.json @@ -83,6 +83,12 @@ }, "bottomHeight": { "type": "number" + }, + "xCount": { + "type": "integer" + }, + "yCount": { + "type": "integer" } } }, @@ -112,6 +118,12 @@ }, "bottomHeight": { "type": "number" + }, + "xCount": { + "type": "integer" + }, + "yCount": { + "type": "integer" } } }, @@ -149,6 +161,12 @@ }, "bottomHeight": { "type": "number" + }, + "xCount": { + "type": "integer" + }, + "yCount": { + "type": "integer" } } }, @@ -187,6 +205,12 @@ }, "bottomHeight": { "type": "number" + }, + "xCount": { + "type": "integer" + }, + "yCount": { + "type": "integer" } } }, @@ -225,6 +249,12 @@ }, "bottomHeight": { "type": "number" + }, + "xCount": { + "type": "integer" + }, + "yCount": { + "type": "integer" } } }, 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 d82a76d55c4..3363c874c55 100644 --- a/shared-data/python/opentrons_shared_data/labware/labware_definition.py +++ b/shared-data/python/opentrons_shared_data/labware/labware_definition.py @@ -256,6 +256,21 @@ class SphericalSegment(BaseModel): ..., description="Height of the bottom of the segment, must be 0.0", ) + xCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) + yCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) + + @cached_property + def count(self) -> int: + return self.xCount * self.yCount + + class Config: + keep_untouched = (cached_property,) class ConicalFrustum(BaseModel): @@ -276,6 +291,21 @@ class ConicalFrustum(BaseModel): ..., description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", ) + xCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) + yCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) + + @cached_property + def count(self) -> int: + return self.xCount * self.yCount + + class Config: + keep_untouched = (cached_property,) class CuboidalFrustum(BaseModel): @@ -305,6 +335,21 @@ class CuboidalFrustum(BaseModel): ..., description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", ) + xCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) + yCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) + + @cached_property + def count(self) -> int: + return self.xCount * self.yCount + + class Config: + keep_untouched = (cached_property,) # A squared cone is the intersection of a cube and a cone that both @@ -354,6 +399,14 @@ class SquaredConeSegment(BaseModel): ..., description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", ) + xCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) + yCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) @staticmethod def _area_trap_points( @@ -433,6 +486,10 @@ def height_to_volume_table(self) -> Dict[float, float]: def volume_to_height_table(self) -> Dict[float, float]: return dict((v, k) for k, v in self.height_to_volume_table.items()) + @cached_property + def count(self) -> int: + return self.xCount * self.yCount + class Config: keep_untouched = (cached_property,) @@ -546,6 +603,21 @@ class RoundedCuboidSegment(BaseModel): ..., description="The height at the bottom of a bounded subsection of a well, relative to the bottom of the well", ) + xCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) + yCount: _StrictNonNegativeInt = Field( + default=1, + description="Number of instances of this shape in the stackup, used for wells that have multiple sub-wells", + ) + + @cached_property + def count(self) -> int: + return self.xCount * self.yCount + + class Config: + keep_untouched = (cached_property,) class Metadata1(BaseModel):