From 2e2f8415bec683f1eb90f237f9b18535dbcc51da Mon Sep 17 00:00:00 2001 From: margaretsantiago Date: Thu, 19 Feb 2026 00:10:25 -0500 Subject: [PATCH 1/3] fix: add volume functions for Corning 96-well flat and V-bottom plates --- pylabrobot/resources/corning/plates.py | 98 +++++++++++++- .../resources/height_volume_functions.py | 66 +++++++++ test_corning_volumes.py | 125 ++++++++++++++++++ 3 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 test_corning_volumes.py diff --git a/pylabrobot/resources/corning/plates.py b/pylabrobot/resources/corning/plates.py index 7b2d5e802aa..1b823d2e3e6 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,82 @@ 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.") + + +# Previous names in PLR: +def Cos_96_Vb(name: str, with_lid: bool = False) -> Plate: + raise NotImplementedError( + "Deprecated. Use 'Cor_96_wellplate_320ul_Vb' instead." + ) + + # # # # # # # # # # 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) diff --git a/test_corning_volumes.py b/test_corning_volumes.py new file mode 100644 index 00000000000..3b12e048d6f --- /dev/null +++ b/test_corning_volumes.py @@ -0,0 +1,125 @@ +"""Verification script for Corning plate volume/height functions.""" + +import math +import sys + +from pylabrobot.resources.corning.plates import ( + Cor_96_wellplate_360ul_Fb, + Cor_96_wellplate_320ul_Vb, +) + +PASS = 0 +FAIL = 0 + + +def check(label, actual, expected, tol=1.0): + global PASS, FAIL + ok = abs(actual - expected) <= tol + status = "PASS" if ok else "FAIL" + if ok: + PASS += 1 + else: + FAIL += 1 + print(f" [{status}] {label}: got {actual:.4f}, expected {expected:.4f} (tol={tol})") + return ok + + +def main(): + global PASS, FAIL + + # ---- Instantiate plates ---- + fb_plate = Cor_96_wellplate_360ul_Fb("test_fb") + vb_plate = Cor_96_wellplate_320ul_Vb("test_vb") + + fb_well = fb_plate.get_well("A1") + vb_well = vb_plate.get_well("A1") + + print("=" * 70) + print("Cor_96_wellplate_360ul_Fb (flat-bottom, conical frustum)") + print("=" * 70) + fb_depth = fb_well.get_size_z() + print(f" Well depth: {fb_depth} mm") + print(f" Bottom type: {fb_well.bottom_type}") + print(f" Cross section: {fb_well.cross_section_type}") + print() + + # 1. Max volume at full well depth should match Corning's 360 uL + print("--- Test 1: Max volume at full depth ---") + fb_vol_at_max = fb_well.compute_volume_from_height(fb_depth) + check("Flat-bottom max volume vs 360 uL", fb_vol_at_max, 360.0, tol=6.0) + print() + + # 2. Volume at several heights + print("--- Test 2: Volume at various heights ---") + for h in [0.0, 1.0, 2.0, 5.0, 8.0, 10.0, 10.67]: + vol = fb_well.compute_volume_from_height(h) + print(f" h={h:6.2f} mm -> vol={vol:8.3f} uL") + print() + + # 3. Round-trip: height -> volume -> height + print("--- Test 3: Round-trip (height -> vol -> height) ---") + for h_in in [0.5, 2.0, 5.0, 8.0, 10.0]: + vol = fb_well.compute_volume_from_height(h_in) + h_out = fb_well.compute_height_from_volume(vol) + check(f"h={h_in} mm round-trip", h_out, h_in, tol=0.01) + print() + + # 4. Round-trip: volume -> height -> volume + print("--- Test 4: Round-trip (vol -> height -> vol) ---") + for v_in in [10.0, 50.0, 100.0, 200.0, 350.0]: + h = fb_well.compute_height_from_volume(v_in) + v_out = fb_well.compute_volume_from_height(h) + check(f"v={v_in} uL round-trip", v_out, v_in, tol=0.01) + print() + + print("=" * 70) + print("Cor_96_wellplate_320ul_Vb (V-bottom, cone + cylinder)") + print("=" * 70) + vb_depth = vb_well.get_size_z() + print(f" Well depth: {vb_depth} mm") + print(f" Bottom type: {vb_well.bottom_type}") + print(f" Cross section: {vb_well.cross_section_type}") + print() + + # 5. Max volume at full well depth should match Corning's 320 uL + print("--- Test 5: Max volume at full depth ---") + vb_vol_at_max = vb_well.compute_volume_from_height(vb_depth) + check("V-bottom max volume vs 320 uL", vb_vol_at_max, 320.0, tol=5.0) + print() + + # 6. Volume at several heights + print("--- Test 6: Volume at various heights ---") + for h in [0.0, 0.5, 1.0, 1.62, 3.0, 6.0, 9.0, 11.12]: + vol = vb_well.compute_volume_from_height(h) + print(f" h={h:6.2f} mm -> vol={vol:8.3f} uL") + print() + + # 7. Round-trip: height -> volume -> height + print("--- Test 7: Round-trip (height -> vol -> height) ---") + for h_in in [0.5, 1.0, 1.62, 3.0, 6.0, 9.0, 11.0]: + vol = vb_well.compute_volume_from_height(h_in) + h_out = vb_well.compute_height_from_volume(vol) + check(f"h={h_in} mm round-trip", h_out, h_in, tol=0.01) + print() + + # 8. Round-trip: volume -> height -> volume + print("--- Test 8: Round-trip (vol -> height -> vol) ---") + for v_in in [5.0, 20.0, 50.0, 100.0, 200.0, 310.0]: + h = vb_well.compute_height_from_volume(v_in) + v_out = vb_well.compute_volume_from_height(h) + check(f"v={v_in} uL round-trip", v_out, v_in, tol=0.01) + print() + + # ---- Summary ---- + print("=" * 70) + total = PASS + FAIL + print(f"RESULTS: {PASS}/{total} passed, {FAIL}/{total} failed") + if FAIL > 0: + print("SOME TESTS FAILED") + sys.exit(1) + else: + print("ALL TESTS PASSED") + + +if __name__ == "__main__": + main() From 0571c7ebd98ba603930e4ecc4dce7387725f6339 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 19 Feb 2026 08:51:55 -0800 Subject: [PATCH 2/3] Delete test_corning_volumes.py --- test_corning_volumes.py | 125 ---------------------------------------- 1 file changed, 125 deletions(-) delete mode 100644 test_corning_volumes.py diff --git a/test_corning_volumes.py b/test_corning_volumes.py deleted file mode 100644 index 3b12e048d6f..00000000000 --- a/test_corning_volumes.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Verification script for Corning plate volume/height functions.""" - -import math -import sys - -from pylabrobot.resources.corning.plates import ( - Cor_96_wellplate_360ul_Fb, - Cor_96_wellplate_320ul_Vb, -) - -PASS = 0 -FAIL = 0 - - -def check(label, actual, expected, tol=1.0): - global PASS, FAIL - ok = abs(actual - expected) <= tol - status = "PASS" if ok else "FAIL" - if ok: - PASS += 1 - else: - FAIL += 1 - print(f" [{status}] {label}: got {actual:.4f}, expected {expected:.4f} (tol={tol})") - return ok - - -def main(): - global PASS, FAIL - - # ---- Instantiate plates ---- - fb_plate = Cor_96_wellplate_360ul_Fb("test_fb") - vb_plate = Cor_96_wellplate_320ul_Vb("test_vb") - - fb_well = fb_plate.get_well("A1") - vb_well = vb_plate.get_well("A1") - - print("=" * 70) - print("Cor_96_wellplate_360ul_Fb (flat-bottom, conical frustum)") - print("=" * 70) - fb_depth = fb_well.get_size_z() - print(f" Well depth: {fb_depth} mm") - print(f" Bottom type: {fb_well.bottom_type}") - print(f" Cross section: {fb_well.cross_section_type}") - print() - - # 1. Max volume at full well depth should match Corning's 360 uL - print("--- Test 1: Max volume at full depth ---") - fb_vol_at_max = fb_well.compute_volume_from_height(fb_depth) - check("Flat-bottom max volume vs 360 uL", fb_vol_at_max, 360.0, tol=6.0) - print() - - # 2. Volume at several heights - print("--- Test 2: Volume at various heights ---") - for h in [0.0, 1.0, 2.0, 5.0, 8.0, 10.0, 10.67]: - vol = fb_well.compute_volume_from_height(h) - print(f" h={h:6.2f} mm -> vol={vol:8.3f} uL") - print() - - # 3. Round-trip: height -> volume -> height - print("--- Test 3: Round-trip (height -> vol -> height) ---") - for h_in in [0.5, 2.0, 5.0, 8.0, 10.0]: - vol = fb_well.compute_volume_from_height(h_in) - h_out = fb_well.compute_height_from_volume(vol) - check(f"h={h_in} mm round-trip", h_out, h_in, tol=0.01) - print() - - # 4. Round-trip: volume -> height -> volume - print("--- Test 4: Round-trip (vol -> height -> vol) ---") - for v_in in [10.0, 50.0, 100.0, 200.0, 350.0]: - h = fb_well.compute_height_from_volume(v_in) - v_out = fb_well.compute_volume_from_height(h) - check(f"v={v_in} uL round-trip", v_out, v_in, tol=0.01) - print() - - print("=" * 70) - print("Cor_96_wellplate_320ul_Vb (V-bottom, cone + cylinder)") - print("=" * 70) - vb_depth = vb_well.get_size_z() - print(f" Well depth: {vb_depth} mm") - print(f" Bottom type: {vb_well.bottom_type}") - print(f" Cross section: {vb_well.cross_section_type}") - print() - - # 5. Max volume at full well depth should match Corning's 320 uL - print("--- Test 5: Max volume at full depth ---") - vb_vol_at_max = vb_well.compute_volume_from_height(vb_depth) - check("V-bottom max volume vs 320 uL", vb_vol_at_max, 320.0, tol=5.0) - print() - - # 6. Volume at several heights - print("--- Test 6: Volume at various heights ---") - for h in [0.0, 0.5, 1.0, 1.62, 3.0, 6.0, 9.0, 11.12]: - vol = vb_well.compute_volume_from_height(h) - print(f" h={h:6.2f} mm -> vol={vol:8.3f} uL") - print() - - # 7. Round-trip: height -> volume -> height - print("--- Test 7: Round-trip (height -> vol -> height) ---") - for h_in in [0.5, 1.0, 1.62, 3.0, 6.0, 9.0, 11.0]: - vol = vb_well.compute_volume_from_height(h_in) - h_out = vb_well.compute_height_from_volume(vol) - check(f"h={h_in} mm round-trip", h_out, h_in, tol=0.01) - print() - - # 8. Round-trip: volume -> height -> volume - print("--- Test 8: Round-trip (vol -> height -> vol) ---") - for v_in in [5.0, 20.0, 50.0, 100.0, 200.0, 310.0]: - h = vb_well.compute_height_from_volume(v_in) - v_out = vb_well.compute_volume_from_height(h) - check(f"v={v_in} uL round-trip", v_out, v_in, tol=0.01) - print() - - # ---- Summary ---- - print("=" * 70) - total = PASS + FAIL - print(f"RESULTS: {PASS}/{total} passed, {FAIL}/{total} failed") - if FAIL > 0: - print("SOME TESTS FAILED") - sys.exit(1) - else: - print("ALL TESTS PASSED") - - -if __name__ == "__main__": - main() From 410f0004c30444849c37d82e98c01a7675e81e5d Mon Sep 17 00:00:00 2001 From: margaretsantiago Date: Thu, 19 Feb 2026 19:44:27 -0500 Subject: [PATCH 3/3] remove Cos_96_Vb stub per review --- pylabrobot/resources/corning/plates.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pylabrobot/resources/corning/plates.py b/pylabrobot/resources/corning/plates.py index 1b823d2e3e6..9284f1f4b84 100644 --- a/pylabrobot/resources/corning/plates.py +++ b/pylabrobot/resources/corning/plates.py @@ -171,13 +171,6 @@ def Cor_96_wellplate_320ul_Vb_Lid(name: str) -> Lid: raise NotImplementedError("This lid is not currently defined.") -# Previous names in PLR: -def Cos_96_Vb(name: str, with_lid: bool = False) -> Plate: - raise NotImplementedError( - "Deprecated. Use 'Cor_96_wellplate_320ul_Vb' instead." - ) - - # # # # # # # # # # Cor_96_wellplate_2mL_Vb # # # # # # # # # #