diff --git a/pylabrobot/resources/corning/plates.py b/pylabrobot/resources/corning/plates.py index 7b2d5e802aa..9284f1f4b84 100644 --- a/pylabrobot/resources/corning/plates.py +++ b/pylabrobot/resources/corning/plates.py @@ -1,8 +1,12 @@ """ Corning plates. """ from pylabrobot.resources.height_volume_functions import ( + calculate_liquid_height_in_container_2segments_round_vbottom, calculate_liquid_height_in_container_2segments_square_vbottom, + calculate_liquid_volume_container_2segments_round_vbottom, calculate_liquid_volume_container_2segments_square_vbottom, + compute_height_from_volume_conical_frustum_in_well, + compute_volume_from_height_conical_frustum_in_well, ) from pylabrobot.resources.plate import Lid, Plate from pylabrobot.resources.utils import create_ordered_items_2d @@ -35,6 +39,12 @@ def Cor_96_wellplate_360ul_Fb(name: str, with_lid: bool = False) -> Plate: # This used to be Cos_96_EZWash in the Esvelt lab + # Corning MicroplateDimensions96-384-1536.pdf: + # top internal diameter 6.86mm, bottom internal diameter 6.35mm, well depth 10.67mm + BOTTOM_RADIUS = 6.35 / 2 + TOP_RADIUS = 6.86 / 2 + WELL_DEPTH = 10.67 + return Plate( name=name, size_x=127.76, @@ -53,11 +63,21 @@ def Cor_96_wellplate_360ul_Fb(name: str, with_lid: bool = False) -> Plate: item_dy=9.0, size_x=6.86, # top size_y=6.86, # top - size_z=10.67, + size_z=WELL_DEPTH, material_z_thickness=0.5, bottom_type=WellBottomType.FLAT, cross_section_type=CrossSectionType.CIRCLE, max_volume=360, + compute_volume_from_height=lambda liquid_height: ( + compute_volume_from_height_conical_frustum_in_well( + liquid_height, BOTTOM_RADIUS, TOP_RADIUS, WELL_DEPTH + ) + ), + compute_height_from_volume=lambda liquid_volume: ( + compute_height_from_volume_conical_frustum_in_well( + liquid_volume, BOTTOM_RADIUS, TOP_RADIUS, WELL_DEPTH + ) + ), ), ) @@ -82,6 +102,75 @@ def Cos_96_EZWash(name: str, with_lid: bool = False) -> Plate: raise ValueError("Deprecated. You probably want to use Cor_96_wellplate_360ul_Fb instead.") +# # # # # # # # # # Cor_96_wellplate_320ul_Vb # # # # # # # # # # + + +def Cor_96_wellplate_320ul_Vb(name: str, with_lid: bool = False) -> Plate: + """ + Corning cat. no.s: 3894, 3896, 3897, 3898, 3342 + - manufacturer_link: https://ecatalog.corning.com/life-sciences/b2c/US/en/Microplates/ + Assay-Microplates/96-Well-Microplates/Corning%C2%AE-96-well-Clear-Polystyrene-Microplates/p/3894 + - brand: Corning / Costar + - material: Polystyrene + - notes: + - Standard 96-well V-bottom microplate. + - Well geometry: conical V-bottom section (cone height ~1.6mm) transitioning to a + cylindrical section. Dimensions from Corning MicroplateDimensions96-384-1536.pdf: + top internal diameter 6.86mm, bottom internal diameter 6.37mm, well depth 11.12mm, + total well volume 320uL. + """ + + # Corning MicroplateDimensions96-384-1536.pdf: + # top internal diameter 6.86mm, bottom internal diameter 6.37mm, well depth 11.12mm + # The V-bottom is modeled as a cone (apex at bottom) transitioning to a cylinder. + # Cone height back-calculated from published total volume (320uL) using d=6.37mm. + WELL_DIAMETER = 6.37 # diameter at cone-to-cylinder transition + CONE_HEIGHT = 1.62 # mm, back-calculated to match 320uL total volume + CYLINDER_HEIGHT = 11.12 - CONE_HEIGHT # 9.50mm + WELL_DEPTH = 11.12 + + return Plate( + name=name, + size_x=127.76, + size_y=85.48, + size_z=14.2, + lid=Cor_96_wellplate_320ul_Vb_Lid(name=name + "_lid") if with_lid else None, + model="Cor_96_wellplate_320ul_Vb", + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.87, # 14.3-6.86/2 + dy=7.77, # 11.2-6.86/2 + dz=2.58, # 14.2 - 11.12 - 0.5 + item_dx=9.0, + item_dy=9.0, + size_x=6.86, + size_y=6.86, + size_z=WELL_DEPTH, + material_z_thickness=0.5, + bottom_type=WellBottomType.V, + cross_section_type=CrossSectionType.CIRCLE, + compute_volume_from_height=lambda liquid_height: ( + calculate_liquid_volume_container_2segments_round_vbottom( + d=WELL_DIAMETER, h_cone=CONE_HEIGHT, h_cylinder=CYLINDER_HEIGHT, + liquid_height=liquid_height, + ) + ), + compute_height_from_volume=lambda liquid_volume: ( + calculate_liquid_height_in_container_2segments_round_vbottom( + d=WELL_DIAMETER, h_cone=CONE_HEIGHT, h_cylinder=CYLINDER_HEIGHT, + liquid_volume=liquid_volume, + ) + ), + ), + ) + + +def Cor_96_wellplate_320ul_Vb_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + + # # # # # # # # # # Cor_96_wellplate_2mL_Vb # # # # # # # # # # diff --git a/pylabrobot/resources/height_volume_functions.py b/pylabrobot/resources/height_volume_functions.py index 62f6bdb1212..887785258b4 100644 --- a/pylabrobot/resources/height_volume_functions.py +++ b/pylabrobot/resources/height_volume_functions.py @@ -559,6 +559,72 @@ def compute_height_from_volume_conical_frustum( ) +def compute_volume_from_height_conical_frustum_in_well( + liquid_height: float, bottom_radius: float, top_radius: float, total_height: float +) -> float: + """Compute volume (uL) from height (mm) for liquid in a conical-frustum-shaped well. + + Unlike :func:`compute_volume_from_height_conical_frustum`, this correctly interpolates the + radius at the liquid surface based on the fill height. In a tapered well, the cross-sectional + radius varies linearly from ``bottom_radius`` at h=0 to ``top_radius`` at h=total_height. + + Args: + liquid_height: Height of the liquid in mm. + bottom_radius: Inner radius at the bottom of the well in mm. + top_radius: Inner radius at the top of the well in mm. + total_height: Total internal height of the well in mm. + + Returns: + Volume of liquid in uL (= mm^3). + """ + if liquid_height <= 0: + return 0.0 + h = min(liquid_height, total_height) + r_at_h = bottom_radius + (top_radius - bottom_radius) * h / total_height + return (math.pi * h / 3.0) * (r_at_h**2 + r_at_h * bottom_radius + bottom_radius**2) + + +def compute_height_from_volume_conical_frustum_in_well( + liquid_volume: float, bottom_radius: float, top_radius: float, total_height: float +) -> float: + """Compute height (mm) from volume (uL) for liquid in a conical-frustum-shaped well. + + Inverse of :func:`compute_volume_from_height_conical_frustum_in_well`. Uses binary search + because the volume-height relationship is cubic (not analytically invertible in closed form). + + Args: + liquid_volume: Volume of the liquid in uL (= mm^3). + bottom_radius: Inner radius at the bottom of the well in mm. + top_radius: Inner radius at the top of the well in mm. + total_height: Total internal height of the well in mm. + + Returns: + Height of the liquid in mm. + """ + if liquid_volume <= 0: + return 0.0 + + max_volume = compute_volume_from_height_conical_frustum_in_well( + total_height, bottom_radius, top_radius, total_height + ) + if liquid_volume > max_volume: + raise ValueError( + f"Volume {liquid_volume} exceeds maximum well volume {max_volume:.2f}." + ) + + low, high = 0.0, total_height + tolerance = 1e-6 + while high - low > tolerance: + mid = (low + high) / 2 + if compute_volume_from_height_conical_frustum_in_well( + mid, bottom_radius, top_radius, total_height + ) < liquid_volume: + low = mid + else: + high = mid + return (low + high) / 2 + + def compute_volume_from_height_square(liquid_height: float, well_side_length: float) -> float: """Compute volume (uL) from height (mm) for a square well.""" return liquid_height * (well_side_length**2)