Skip to content

Commit

Permalink
feat(protocol engine): calculate well volumetric capacities from labw…
Browse files Browse the repository at this point in the history
…are defs (#16222)
  • Loading branch information
caila-marashaj authored Sep 13, 2024
1 parent 59139d2 commit a0f42de
Show file tree
Hide file tree
Showing 12 changed files with 217 additions and 162 deletions.
2 changes: 2 additions & 0 deletions api/src/opentrons/protocol_engine/errors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
NotSupportedOnRobotType,
CommandNotAllowedError,
InvalidLiquidHeightFound,
InvalidWellDefinitionError,
)

from .error_occurrence import ErrorOccurrence, ProtocolCommandFailedError
Expand Down Expand Up @@ -148,4 +149,5 @@
"ErrorOccurrence",
"CommandNotAllowedError",
"InvalidLiquidHeightFound",
"InvalidWellDefinitionError",
]
13 changes: 13 additions & 0 deletions api/src/opentrons/protocol_engine/errors/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1056,3 +1056,16 @@ def __init__(
) -> None:
"""Build a TipNotEmptyError."""
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)


class InvalidWellDefinitionError(ProtocolEngineError):
"""Raised when an InnerWellGeometry definition is invalid."""

def __init__(
self,
message: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
wrapping: Optional[Sequence[EnumeratedError]] = None,
) -> None:
"""Build an InvalidWellDefinitionError."""
super().__init__(ErrorCodes.GENERAL_ERROR, message, details, wrapping)
69 changes: 68 additions & 1 deletion api/src/opentrons/protocol_engine/state/frustum_helpers.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
"""Helper functions for liquid-level related calculations inside a given frustum."""
from typing import List, Tuple
from typing import List, Tuple, Iterator, Sequence, Any
from numpy import pi, iscomplex, roots, real

from ..errors.exceptions import InvalidLiquidHeightFound
from opentrons_shared_data.labware.types import (
is_circular_frusta_list,
is_rectangular_frusta_list,
)
from opentrons_shared_data.labware.labware_definition import InnerWellGeometry


def reject_unacceptable_heights(
Expand Down Expand Up @@ -174,3 +179,65 @@ def height_from_volume_spherical(
max_height=total_frustum_height,
)
return height


def get_boundary_cross_sections(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_well_volumetric_capacity(
well_geometry: InnerWellGeometry,
) -> List[Tuple[float, float]]:
"""Return the total volumetric capacity of a well as a map of height borders to volume."""
# 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.radius_of_curvature,
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_cross_sections(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_cross_sections(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,
)

well_volume.append((next_f["topHeight"], frustum_volume))

return well_volume
16 changes: 16 additions & 0 deletions api/src/opentrons/protocol_engine/state/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
LabwareNotLoadedOnLabwareError,
LabwareNotLoadedOnModuleError,
LabwareMovementNotAllowedError,
InvalidWellDefinitionError,
)
from ..resources import fixture_validation
from ..types import (
Expand Down Expand Up @@ -51,6 +52,7 @@
from .modules import ModuleView
from .pipettes import PipetteView
from .addressable_areas import AddressableAreaView
from .frustum_helpers import get_well_volumetric_capacity


SLOT_WIDTH = 128
Expand Down Expand Up @@ -1189,3 +1191,17 @@ def get_offset_location(self, labware_id: str) -> Optional[LabwareOffsetLocation
)

return None

def get_well_volumetric_capacity(
self, labware_id: str, well_id: str
) -> List[Tuple[float, float]]:
"""Return a map of heights to partial volumes."""
labware_def = self._labware.get_definition(labware_id)
if labware_def.innerLabwareGeometry is None:
raise InvalidWellDefinitionError(message="No InnerLabwareGeometry found.")
well_geometry = labware_def.innerLabwareGeometry.get(well_id)
if well_geometry is None:
raise InvalidWellDefinitionError(
message=f"No InnerWellGeometry found for well id: {well_id}"
)
return get_well_volumetric_capacity(well_geometry)
23 changes: 9 additions & 14 deletions api/tests/opentrons/protocol_runner/test_json_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@
Group,
Metadata1,
WellDefinition,
BoundedSection,
RectangularCrossSection,
RectangularBoundedSection,
InnerWellGeometry,
SphericalSegment,
)
Expand Down Expand Up @@ -692,20 +691,16 @@ def _load_labware_definition_data() -> LabwareDefinition:
innerLabwareGeometry={
"welldefinition1111": InnerWellGeometry(
frusta=[
BoundedSection(
geometry=RectangularCrossSection(
shape="rectangular",
xDimension=7.6,
yDimension=8.5,
),
RectangularBoundedSection(
shape="rectangular",
xDimension=7.6,
yDimension=8.5,
topHeight=45,
),
BoundedSection(
geometry=RectangularCrossSection(
shape="rectangular",
xDimension=5.6,
yDimension=6.5,
),
RectangularBoundedSection(
shape="rectangular",
xDimension=5.6,
yDimension=6.5,
topHeight=20,
),
],
Expand Down
2 changes: 1 addition & 1 deletion shared-data/js/__tests__/labwareDefSchemaV3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const checkGeometryDefinitions = (
const topFrustumHeight =
labwareDef.innerLabwareGeometry[wellGeometryId].frusta[0].topHeight
const topFrustumShape =
labwareDef.innerLabwareGeometry[wellGeometryId].frusta[0].geometry.shape
labwareDef.innerLabwareGeometry[wellGeometryId].frusta[0].shape

expect(wellDepth).toEqual(topFrustumHeight)
expect(wellShape).toEqual(topFrustumShape)
Expand Down
32 changes: 11 additions & 21 deletions shared-data/js/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,38 +159,28 @@ export type LabwareWell = LabwareWellProperties & {
geometryDefinitionId?: string
}

export interface CircularCrossSection {
shape: 'circular'
diameter: number
}

export interface RectangularCrossSection {
shape: 'rectangular'
xDimension: number
yDimension: number
}

export interface SphericalSegment {
shape: 'spherical'
radiusOfCurvature: number
depth: number
}

export type TopCrossSection = CircularCrossSection | RectangularCrossSection

export type BottomShape =
| CircularCrossSection
| RectangularCrossSection
| SphericalSegment
export interface CircularBoundedSection {
shape: 'circular'
diameter: number
topHeight: number
}

export interface BoundedSection {
geometry: TopCrossSection
export interface RectangularBoundedSection {
shape: 'rectangular'
xDimension: number
yDimension: number
topHeight: number
}

export interface InnerWellGeometry {
frusta: BoundedSection[]
bottomShape: BottomShape
frusta: CircularBoundedSection[] | RectangularBoundedSection[]
bottomShape?: SphericalSegment | null
}

// TODO(mc, 2019-03-21): exact object is tough to use with the initial value in
Expand Down
35 changes: 11 additions & 24 deletions shared-data/labware/fixtures/3/fixture_2_plate.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,42 +64,29 @@
"daiwudhadfhiew": {
"frusta": [
{
"geometry": {
"shape": "rectangular",
"xDimension": 127.76,
"yDimension": 85.8
},
"shape": "rectangular",
"xDimension": 127.76,
"yDimension": 85.8,
"topHeight": 42.16
},
{
"geometry": {
"shape": "rectangular",
"xDimension": 70.0,
"yDimension": 50.0
},
"shape": "rectangular",
"xDimension": 70.0,
"yDimension": 50.0,
"topHeight": 20.0
}
],
"bottomShape": {
"shape": "rectangular",
"xDimension": 2.0,
"yDimension": 3.0
}
]
},
"iuweofiuwhfn": {
"frusta": [
{
"geometry": {
"shape": "circular",
"diameter": 35.0
},
"shape": "circular",
"diameter": 35.0,
"topHeight": 42.16
},
{
"geometry": {
"shape": "circular",
"diameter": 22.0
},
"shape": "circular",
"diameter": 35.0,
"topHeight": 20.0
}
],
Expand Down
17 changes: 8 additions & 9 deletions shared-data/labware/fixtures/3/fixture_corning_24_plate.json
Original file line number Diff line number Diff line change
Expand Up @@ -325,17 +325,16 @@
"venirhgerug": {
"frusta": [
{
"geometry": {
"shape": "circular",
"diameter": 16.26
},
"shape": "circular",
"diameter": 16.26,
"topHeight": 17.4
},
{
"shape": "circular",
"diameter": 16.26,
"topHeight": 0.0
}
],
"bottomShape": {
"shape": "circular",
"diameter": 16.26
}
]
}
}
}
Loading

0 comments on commit a0f42de

Please sign in to comment.