From 3e0975f18ea624d71e14e578b407f6f567912fa3 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 16 Mar 2026 13:09:00 +0000 Subject: [PATCH 1/6] Add height_volume_data to Container, migrate calibration dicts --- pylabrobot/resources/container.py | 27 +++++- pylabrobot/resources/container_tests.py | 1 + pylabrobot/resources/eppendorf/tubes.py | 78 +++++---------- pylabrobot/resources/hamilton/troughs.py | 118 ++--------------------- pylabrobot/resources/petri_dish.py | 4 +- pylabrobot/resources/petri_dish_tests.py | 1 + pylabrobot/resources/trash.py | 2 + pylabrobot/resources/trough.py | 4 +- pylabrobot/resources/tube.py | 6 +- pylabrobot/resources/well.py | 6 +- pylabrobot/resources/well_tests.py | 1 + 11 files changed, 77 insertions(+), 171 deletions(-) diff --git a/pylabrobot/resources/container.py b/pylabrobot/resources/container.py index 161e2a7426e..e0ca16dfff5 100644 --- a/pylabrobot/resources/container.py +++ b/pylabrobot/resources/container.py @@ -1,6 +1,7 @@ from typing import Any, Callable, Dict, Optional from pylabrobot.serializer import serialize +from pylabrobot.utils.interpolation import interpolate_1d from .coordinate import Coordinate from .resource import Resource @@ -22,6 +23,7 @@ def __init__( model: Optional[str] = None, compute_volume_from_height: Optional[Callable[[float], float]] = None, compute_height_from_volume: Optional[Callable[[float], float]] = None, + height_volume_data: Optional[Dict[float, float]] = None, ): """Create a new container. @@ -29,6 +31,10 @@ def __init__( material_z_thickness: Container cavity base to the (outer) base of the container object. If `None`, certain operations may not be supported. max_volume: Maximum volume of the container. If `None`, will be inferred from resource size. + height_volume_data: Optional dict mapping height (mm) to volume (uL). When provided, + ``compute_volume_from_height`` and ``compute_height_from_volume`` are auto-generated + via piecewise-linear interpolation if not explicitly passed. The data is also available + for direct use (e.g. building firmware segments from calibration knots). """ super().__init__( @@ -40,6 +46,20 @@ def __init__( model=model, ) self._material_z_thickness = material_z_thickness + self.height_volume_data = height_volume_data + + # Auto-generate volume/height functions from height_volume_data if not explicitly provided. + if height_volume_data is not None: + volume_height_data = {v: h for h, v in height_volume_data.items()} + + if compute_volume_from_height is None: + def compute_volume_from_height(h: float) -> float: + return interpolate_1d(h, height_volume_data, bounds_handling="error") + + if compute_height_from_volume is None: + def compute_height_from_volume(v: float) -> float: + return interpolate_1d(v, volume_height_data, bounds_handling="error") + self.max_volume = max_volume or (size_x * size_y * size_z) self.tracker = VolumeTracker(thing=f"{self.name}_volume_tracker", max_volume=self.max_volume) self._compute_volume_from_height = compute_volume_from_height @@ -59,8 +79,11 @@ def serialize(self) -> dict: **super().serialize(), "max_volume": serialize(self.max_volume), "material_z_thickness": self._material_z_thickness, - "compute_volume_from_height": serialize(self._compute_volume_from_height), - "compute_height_from_volume": serialize(self._compute_height_from_volume), + "compute_volume_from_height": None if self.height_volume_data is not None + else serialize(self._compute_volume_from_height), + "compute_height_from_volume": None if self.height_volume_data is not None + else serialize(self._compute_height_from_volume), + "height_volume_data": self.height_volume_data, } def serialize_state(self) -> Dict[str, Any]: diff --git a/pylabrobot/resources/container_tests.py b/pylabrobot/resources/container_tests.py index df1bcdc9b18..056d3bc5dd4 100644 --- a/pylabrobot/resources/container_tests.py +++ b/pylabrobot/resources/container_tests.py @@ -39,6 +39,7 @@ def compute_height_from_volume(volume): "max_volume": 1000, "compute_volume_from_height": serialize(compute_volume_from_height), "compute_height_from_volume": serialize(compute_height_from_volume), + "height_volume_data": None, "parent_name": None, "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "type": "Container", diff --git a/pylabrobot/resources/eppendorf/tubes.py b/pylabrobot/resources/eppendorf/tubes.py index a8d7827f22c..377796a830a 100644 --- a/pylabrobot/resources/eppendorf/tubes.py +++ b/pylabrobot/resources/eppendorf/tubes.py @@ -3,7 +3,6 @@ import warnings from pylabrobot.resources.tube import Tube, TubeBottomType -from pylabrobot.utils.interpolation import interpolate_1d # --------------------------------------------------------------------------- # # 1.5 mL Eppendorf Tubes @@ -112,61 +111,29 @@ def Eppendorf_DNA_LoBind_2ml_Ub(name: str) -> Tube: # 5 mL Eppendorf Tubes # --------------------------------------------------------------------------- # -_eppendorf_tube_5mL_Vb_snapcap_height_to_volume_measurements = { +# Calibration data: height (mm) → volume (µL). +# Obtained via ztouch probing of cavity_bottom, manual addition of known volumes, +# and LLD measurement of liquid height relative to cavity_bottom. +_eppendorf_tube_5mL_Vb_snapcap_height_volume_data = { 0.0: 0.0, - 5.0: 1.342, - 10.0: 1.875, - 50.0: 4.975, - 100.0: 6.975, - 200.0: 9.909, - 400.0: 13.742, - 600.0: 16.242, - 800.0: 18.375, - 1000.0: 20.142, - 1200.0: 21.809, - 1500.0: 23.842, - 2000.0: 27.242, - 3000.0: 34.242, - 4000.0: 41.009, - 4500.0: 44.175, - 5000.0: 47.509, - 5500.0: 50.675, + 1.342: 5.0, + 1.875: 10.0, + 4.975: 50.0, + 6.975: 100.0, + 9.909: 200.0, + 13.742: 400.0, + 16.242: 600.0, + 18.375: 800.0, + 20.142: 1000.0, + 21.809: 1200.0, + 23.842: 1500.0, + 27.242: 2000.0, + 34.242: 3000.0, + 41.009: 4000.0, + 44.175: 4500.0, + 47.509: 5000.0, + 50.675: 5500.0, } -_eppendorf_tube_5mL_Vb_snapcap_volume_to_height_measurements = { - v: k for k, v in _eppendorf_tube_5mL_Vb_snapcap_height_to_volume_measurements.items() -} - - -def _compute_volume_from_height_eppendorf_tube_5mL_Vb_snapcap(h: float) -> float: - """Estimate liquid volume (µL) from observed liquid height (mm) - in the Eppendorf 5 mL V-bottom snap-cap tube, - using piecewise linear interpolation. - """ - if h < 0: - raise ValueError("Height must be ≥ 0 mm.") - if h > 55.4 * 1.05: # well cavity height + 5% tolerance - raise ValueError(f"Height {h} is too large for eppendorf_tube_5mL_Vb_snapcap.") - - vol_ul = interpolate_1d( - h, data=_eppendorf_tube_5mL_Vb_snapcap_height_to_volume_measurements, bounds_handling="error" - ) - return round(max(0.0, vol_ul), 3) - - -def _compute_height_from_volume_eppendorf_tube_5mL_Vb_snapcap(volume_ul: float) -> float: - """Estimate liquid height (mm) from known liquid volume (µL) - in the Eppendorf 5 mL V-bottom snap-cap tube, - using piecewise linear interpolation. - """ - if volume_ul < 0: - raise ValueError(f"Volume must be ≥ 0 µL; got {volume_ul} µL") - - h_mm = interpolate_1d( - volume_ul, - data=_eppendorf_tube_5mL_Vb_snapcap_volume_to_height_measurements, - bounds_handling="error", - ) - return round(max(0.0, h_mm), 3) def eppendorf_tube_5mL_Vb_snapcap(name: str) -> Tube: @@ -197,6 +164,5 @@ def eppendorf_tube_5mL_Vb_snapcap(name: str) -> Tube: max_volume=5_000, # units: ul material_z_thickness=1.2, bottom_type=TubeBottomType.V, - compute_volume_from_height=_compute_volume_from_height_eppendorf_tube_5mL_Vb_snapcap, - compute_height_from_volume=_compute_height_from_volume_eppendorf_tube_5mL_Vb_snapcap, + height_volume_data=_eppendorf_tube_5mL_Vb_snapcap_height_volume_data, ) diff --git a/pylabrobot/resources/hamilton/troughs.py b/pylabrobot/resources/hamilton/troughs.py index b6011da10a2..3b5d87d84be 100644 --- a/pylabrobot/resources/hamilton/troughs.py +++ b/pylabrobot/resources/hamilton/troughs.py @@ -3,13 +3,15 @@ import warnings from pylabrobot.resources.trough import Trough, TroughBottomType -from pylabrobot.utils.interpolation import interpolate_1d # --------------------------------------------------------------------------- # # Hamilton 1-trough 60 mL (V-bottom) # --------------------------------------------------------------------------- # -_hamilton_1_trough_60ml_Vb_height_to_volume_measurements = { +# Calibration data: height (mm) → volume (µL). +# Obtained via ztouch probing of cavity_bottom, manual addition of known volumes, +# and LLD measurement of liquid height relative to cavity_bottom. +_hamilton_1_trough_60ml_Vb_height_volume_data = { 0.0: 0.0, 2.2: 500.0, 3.5: 1_000.0, @@ -34,39 +36,6 @@ 52.13: 70_000.0, 58.5: 80_000.0, } -_hamilton_1_trough_60ml_Vb_volume_to_height_measurements = { - v: k for k, v in _hamilton_1_trough_60ml_Vb_height_to_volume_measurements.items() -} - - -def _compute_volume_from_height_hamilton_1_trough_60ml_Vb(h: float) -> float: - """Estimate liquid volume (µL) from observed liquid height (mm) - in the Hamilton 1-trough 60 mL (V-bottom, conductive), - using piecewise linear interpolation. - """ - if h < 0: - raise ValueError("Height must be ≥ 0 mm.") - if h > 65.5 * 1.05: - raise ValueError(f"Height {h} is too large for Hamilton_1_trough_60ml_Vb.") - - vol_ul = interpolate_1d( - h, _hamilton_1_trough_60ml_Vb_height_to_volume_measurements, bounds_handling="error" - ) - return round(max(0.0, vol_ul), 3) - - -def _compute_height_from_volume_hamilton_1_trough_60ml_Vb(volume_ul: float) -> float: - """Estimate liquid height (mm) from known liquid volume (µL) - in the Hamilton 1-trough 60 mL (V-bottom, conductive), - using piecewise linear interpolation. - """ - if volume_ul < 0: - raise ValueError(f"Volume must be ≥ 0 µL; got {volume_ul} µL") - - h_mm = interpolate_1d( - volume_ul, _hamilton_1_trough_60ml_Vb_volume_to_height_measurements, bounds_handling="error" - ) - return round(max(0.0, h_mm), 3) def hamilton_1_trough_60ml_Vb(name: str) -> Trough: @@ -90,8 +59,7 @@ def hamilton_1_trough_60ml_Vb(name: str) -> Trough: max_volume=60_000, # units: µL model=hamilton_1_trough_60ml_Vb.__name__, bottom_type=TroughBottomType.V, - compute_volume_from_height=_compute_volume_from_height_hamilton_1_trough_60ml_Vb, - compute_height_from_volume=_compute_height_from_volume_hamilton_1_trough_60ml_Vb, + height_volume_data=_hamilton_1_trough_60ml_Vb_height_volume_data, ) @@ -99,7 +67,7 @@ def hamilton_1_trough_60ml_Vb(name: str) -> Trough: # Hamilton 1-trough 120 mL (V-bottom) # --------------------------------------------------------------------------- # -_hamilton_1_trough_120mL_Vb_height_to_volume_measurements = { +_hamilton_1_trough_120mL_Vb_height_volume_data = { 0.0: 0.0, 5.85: 4_000.0, 6.3: 6_000.0, @@ -117,39 +85,6 @@ def hamilton_1_trough_60ml_Vb(name: str) -> Trough: 70.62: 140_000.0, 80.0: 160_000.0, } -_hamilton_1_trough_120mL_Vb_volume_to_height_measurements = { - v: k for k, v in _hamilton_1_trough_120mL_Vb_height_to_volume_measurements.items() -} - - -def _compute_volume_from_height_hamilton_1_trough_120mL_Vb(h: float) -> float: - """Estimate liquid volume (µL) from observed liquid height (mm) - in the Hamilton 1-trough 120 mL (V-bottom, conductive), - using piecewise linear interpolation. - """ - if h < 0: - raise ValueError("Height must be ≥ 0 mm.") - if h > 80.0 * 1.05: - raise ValueError(f"Height {h} is too large for hamilton_1_trough_120ml_Vb.") - - vol_ul = interpolate_1d( - h, _hamilton_1_trough_120mL_Vb_height_to_volume_measurements, bounds_handling="error" - ) - return round(max(0.0, vol_ul), 3) - - -def _compute_height_from_volume_hamilton_1_trough_120mL_Vb(volume_ul: float) -> float: - """Estimate liquid height (mm) from known liquid volume (µL) - in the Hamilton 1-trough 120 mL (V-bottom, conductive), - using piecewise linear interpolation. - """ - if volume_ul < 0: - raise ValueError(f"Volume must be ≥ 0 µL; got {volume_ul} µL") - - h_mm = interpolate_1d( - volume_ul, _hamilton_1_trough_120mL_Vb_volume_to_height_measurements, bounds_handling="error" - ) - return round(max(0.0, h_mm), 3) def hamilton_1_trough_120mL_Vb(name: str) -> Trough: @@ -174,8 +109,7 @@ def hamilton_1_trough_120mL_Vb(name: str) -> Trough: max_volume=120_000, # units: µL model=hamilton_1_trough_120mL_Vb.__name__, bottom_type=TroughBottomType.V, - compute_volume_from_height=_compute_volume_from_height_hamilton_1_trough_120mL_Vb, - compute_height_from_volume=_compute_height_from_volume_hamilton_1_trough_120mL_Vb, + height_volume_data=_hamilton_1_trough_120mL_Vb_height_volume_data, ) @@ -183,7 +117,7 @@ def hamilton_1_trough_120mL_Vb(name: str) -> Trough: # Hamilton 1-trough 200 mL (V-bottom) # --------------------------------------------------------------------------- # -_hamilton_1_trough_200ml_Vb_height_to_volume_measurements = { +_hamilton_1_trough_200ml_Vb_height_volume_data = { 0.0: 0.0, 5.8: 6_000.0, 7.4: 10_000.0, @@ -195,39 +129,6 @@ def hamilton_1_trough_120mL_Vb(name: str) -> Trough: 72.6: 240_000.0, 88.4: 300_000.0, } -_hamilton_1_trough_200ml_Vb_volume_to_height_measurements = { - v: k for k, v in _hamilton_1_trough_200ml_Vb_height_to_volume_measurements.items() -} - - -def _compute_volume_from_height_hamilton_1_trough_200ml_Vb(h: float) -> float: - """Estimate liquid volume (µL) from observed liquid height (mm) - in the Hamilton 1-trough 200 mL (V-bottom, conductive), - using piecewise linear interpolation. - """ - if h < 0: - raise ValueError("Height must be ≥ 0 mm.") - if h > 95 * 1.05: - raise ValueError(f"Height {h} is too large for Hamilton_1_trough_200ml_Vb.") - - vol_ul = interpolate_1d( - h, _hamilton_1_trough_200ml_Vb_height_to_volume_measurements, bounds_handling="error" - ) - return round(max(0.0, vol_ul), 3) - - -def _compute_height_from_volume_hamilton_1_trough_200ml_Vb(volume_ul: float) -> float: - """Estimate liquid height (mm) from known liquid volume (µL) - in the Hamilton 1-trough 200 mL (V-bottom, conductive), - using piecewise linear interpolation. - """ - if volume_ul < 0: - raise ValueError(f"Volume must be ≥ 0 µL; got {volume_ul} µL") - - h_mm = interpolate_1d( - volume_ul, _hamilton_1_trough_200ml_Vb_volume_to_height_measurements, bounds_handling="error" - ) - return round(max(0.0, h_mm), 3) def hamilton_1_trough_200ml_Vb(name: str) -> Trough: @@ -246,8 +147,7 @@ def hamilton_1_trough_200ml_Vb(name: str) -> Trough: max_volume=200_000, # units: µL model=hamilton_1_trough_200ml_Vb.__name__, bottom_type=TroughBottomType.V, - compute_volume_from_height=_compute_volume_from_height_hamilton_1_trough_200ml_Vb, - compute_height_from_volume=_compute_height_from_volume_hamilton_1_trough_200ml_Vb, + height_volume_data=_hamilton_1_trough_200ml_Vb_height_volume_data, ) diff --git a/pylabrobot/resources/petri_dish.py b/pylabrobot/resources/petri_dish.py index 2faff0cf705..410c105d1ee 100644 --- a/pylabrobot/resources/petri_dish.py +++ b/pylabrobot/resources/petri_dish.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional, cast +from typing import Callable, Dict, Optional, cast from .container import Container from .coordinate import Coordinate @@ -19,6 +19,7 @@ def __init__( max_volume: Optional[float] = None, compute_volume_from_height: Optional[Callable[[float], float]] = None, compute_height_from_volume: Optional[Callable[[float], float]] = None, + height_volume_data: Optional[Dict[float, float]] = None, ): super().__init__( name=name, @@ -31,6 +32,7 @@ def __init__( max_volume=max_volume, compute_volume_from_height=compute_volume_from_height, compute_height_from_volume=compute_height_from_volume, + height_volume_data=height_volume_data, ) self.diameter = diameter self.height = height diff --git a/pylabrobot/resources/petri_dish_tests.py b/pylabrobot/resources/petri_dish_tests.py index d150d5904ab..956f191a964 100644 --- a/pylabrobot/resources/petri_dish_tests.py +++ b/pylabrobot/resources/petri_dish_tests.py @@ -24,6 +24,7 @@ def test_petri_dish_serialization(self): "material_z_thickness": None, "compute_volume_from_height": None, "compute_height_from_volume": None, + "height_volume_data": None, "parent_name": None, "type": "PetriDish", "children": [], diff --git a/pylabrobot/resources/trash.py b/pylabrobot/resources/trash.py index bbd70aa668f..98ecd3e7f52 100644 --- a/pylabrobot/resources/trash.py +++ b/pylabrobot/resources/trash.py @@ -16,6 +16,7 @@ def __init__( model=None, compute_volume_from_height=None, compute_height_from_volume=None, + height_volume_data=None, ): super().__init__( name=name, @@ -28,4 +29,5 @@ def __init__( model=model, compute_volume_from_height=compute_volume_from_height, compute_height_from_volume=compute_height_from_volume, + height_volume_data=height_volume_data, ) diff --git a/pylabrobot/resources/trough.py b/pylabrobot/resources/trough.py index 353a028892c..1bdab42aec8 100644 --- a/pylabrobot/resources/trough.py +++ b/pylabrobot/resources/trough.py @@ -1,5 +1,5 @@ import enum -from typing import Callable, Optional, Union +from typing import Callable, Dict, Optional, Union from .container import Container @@ -30,6 +30,7 @@ def __init__( bottom_type: Union[TroughBottomType, str] = TroughBottomType.UNKNOWN, compute_volume_from_height: Optional[Callable[[float], float]] = None, compute_height_from_volume: Optional[Callable[[float], float]] = None, + height_volume_data: Optional[Dict[float, float]] = None, ): if isinstance(bottom_type, str): bottom_type = TroughBottomType(bottom_type) @@ -45,6 +46,7 @@ def __init__( model=model, compute_volume_from_height=compute_volume_from_height, compute_height_from_volume=compute_height_from_volume, + height_volume_data=height_volume_data, ) self.through_base_to_container_base = through_base_to_container_base self.bottom_type = bottom_type diff --git a/pylabrobot/resources/tube.py b/pylabrobot/resources/tube.py index d0a4072c937..b684f5bb854 100644 --- a/pylabrobot/resources/tube.py +++ b/pylabrobot/resources/tube.py @@ -2,7 +2,7 @@ import enum import warnings -from typing import Callable, List, Optional, Tuple, Union +from typing import Callable, Dict, List, Optional, Tuple, Union from pylabrobot.resources.container import Container from pylabrobot.resources.liquid import Liquid @@ -37,6 +37,7 @@ def __init__( bottom_type: Union[TubeBottomType, str] = TubeBottomType.UNKNOWN, compute_volume_from_height: Optional[Callable[[float], float]] = None, compute_height_from_volume: Optional[Callable[[float], float]] = None, + height_volume_data: Optional[Dict[float, float]] = None, ): """Create a new tube. @@ -48,6 +49,8 @@ def __init__( material_z_thickness: Tube base to cavity base. max_volume: Maximum volume of the tube. category: Category of the tube. + height_volume_data: Optional dict mapping height (mm) to volume (uL). See + :class:`Container` for details. """ if isinstance(bottom_type, str): bottom_type = TubeBottomType(bottom_type) @@ -63,6 +66,7 @@ def __init__( model=model, compute_volume_from_height=compute_volume_from_height, compute_height_from_volume=compute_height_from_volume, + height_volume_data=height_volume_data, ) self.tracker.register_callback(self._state_updated) diff --git a/pylabrobot/resources/well.py b/pylabrobot/resources/well.py index e71f34359a6..3cb6f5a4003 100644 --- a/pylabrobot/resources/well.py +++ b/pylabrobot/resources/well.py @@ -1,6 +1,6 @@ import enum import math -from typing import Callable, List, Optional, Tuple, Union +from typing import Callable, Dict, List, Optional, Tuple, Union from pylabrobot.resources.container import Container from pylabrobot.resources.liquid import Liquid @@ -49,6 +49,7 @@ def __init__( compute_volume_from_height: Optional[Callable[[float], float]] = None, compute_height_from_volume: Optional[Callable[[float], float]] = None, cross_section_type: Union[CrossSectionType, str] = CrossSectionType.CIRCLE, + height_volume_data: Optional[Dict[float, float]] = None, ): """Create a new well. @@ -66,6 +67,8 @@ def __init__( bottom cross_section_type: Type of the cross section of the well. If not specified, the well will be seen as a cylinder. + height_volume_data: Optional dict mapping height (mm) to volume (uL). See + :class:`Container` for details. """ if isinstance(bottom_type, str): @@ -95,6 +98,7 @@ def __init__( compute_volume_from_height=compute_volume_from_height, compute_height_from_volume=compute_height_from_volume, material_z_thickness=material_z_thickness, + height_volume_data=height_volume_data, ) self.bottom_type = bottom_type self.cross_section_type = cross_section_type diff --git a/pylabrobot/resources/well_tests.py b/pylabrobot/resources/well_tests.py index a5fc10b58df..f322d88521b 100644 --- a/pylabrobot/resources/well_tests.py +++ b/pylabrobot/resources/well_tests.py @@ -38,6 +38,7 @@ def test_serialize(self): "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "compute_volume_from_height": None, "compute_height_from_volume": None, + "height_volume_data": None, }, ) From a09030849a9d8987964e3399e32f61d753c1bdf9 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 16 Mar 2026 13:29:26 +0000 Subject: [PATCH 2/6] add tests - Auto-generation of compute functions via piecewise-linear interpolation - Explicit compute functions take precedence over auto-generated ones - Serialize/deserialize roundtrip (including JSON string-key coercion) - Backwards compatibility when height_volume_data is None --- pylabrobot/resources/container.py | 12 ++-- pylabrobot/resources/container_tests.py | 74 +++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/pylabrobot/resources/container.py b/pylabrobot/resources/container.py index e0ca16dfff5..4d4bd3a0a7c 100644 --- a/pylabrobot/resources/container.py +++ b/pylabrobot/resources/container.py @@ -46,15 +46,19 @@ def __init__( model=model, ) self._material_z_thickness = material_z_thickness - self.height_volume_data = height_volume_data + self.height_volume_data = ( + {float(h): float(v) for h, v in height_volume_data.items()} + if height_volume_data is not None + else None + ) # Auto-generate volume/height functions from height_volume_data if not explicitly provided. - if height_volume_data is not None: - volume_height_data = {v: h for h, v in height_volume_data.items()} + if self.height_volume_data is not None: + volume_height_data = {v: h for h, v in self.height_volume_data.items()} if compute_volume_from_height is None: def compute_volume_from_height(h: float) -> float: - return interpolate_1d(h, height_volume_data, bounds_handling="error") + return interpolate_1d(h, self.height_volume_data, bounds_handling="error") if compute_height_from_volume is None: def compute_height_from_volume(v: float) -> float: diff --git a/pylabrobot/resources/container_tests.py b/pylabrobot/resources/container_tests.py index 056d3bc5dd4..c0b598c3788 100644 --- a/pylabrobot/resources/container_tests.py +++ b/pylabrobot/resources/container_tests.py @@ -1,3 +1,4 @@ +import json import unittest from pylabrobot.serializer import serialize @@ -52,3 +53,76 @@ def compute_height_from_volume(volume): self.assertEqual(c, d) self.assertEqual(d.compute_height_from_volume(10), 10) self.assertEqual(d.compute_volume_from_height(10), 10) + + def test_height_volume_data_auto_generates_functions(self): + c = Container( + name="c", + size_x=10, + size_y=10, + size_z=10, + height_volume_data={0: 0, 5: 50, 10: 200}, + ) + self.assertEqual(c.compute_volume_from_height(0), 0) + self.assertEqual(c.compute_volume_from_height(5), 50) + self.assertEqual(c.compute_volume_from_height(2.5), 25) + self.assertEqual(c.compute_height_from_volume(50), 5) + with self.assertRaises(ValueError): + c.compute_volume_from_height(11) + + def test_height_volume_data_does_not_override_explicit_functions(self): + c = Container( + name="c", + size_x=10, + size_y=10, + size_z=10, + height_volume_data={0: 0, 10: 100}, + compute_volume_from_height=lambda h: h * 999, + ) + self.assertEqual(c.compute_volume_from_height(5), 4995) + self.assertEqual(c.compute_height_from_volume(50), 5) + + def test_height_volume_data_serialize_deserialize_roundtrip(self): + c = Container( + name="c", + size_x=10, + size_y=10, + size_z=10, + height_volume_data={0: 0, 5: 50, 10: 200}, + ) + serialized = c.serialize() + self.assertIsNone(serialized["compute_volume_from_height"]) + self.assertIsNone(serialized["compute_height_from_volume"]) + self.assertEqual(serialized["height_volume_data"], {0: 0, 5: 50, 10: 200}) + + d = Container.deserialize(serialized) + self.assertEqual(d.compute_volume_from_height(2.5), 25) + self.assertEqual(d.compute_height_from_volume(50), 5) + + # True JSON roundtrip (keys become strings) + from_json = json.loads(json.dumps(serialized)) + d2 = Container.deserialize(from_json) + self.assertEqual(d2.compute_volume_from_height(2.5), 25) + self.assertEqual(d2.compute_height_from_volume(50), 5) + + def test_height_volume_data_none_preserves_existing_behaviour(self): + def compute_volume_from_height(height): + return height * 2 + + def compute_height_from_volume(volume): + return volume / 2 + + c = Container( + name="c", + size_x=10, + size_y=10, + size_z=10, + compute_volume_from_height=compute_volume_from_height, + compute_height_from_volume=compute_height_from_volume, + ) + serialized = c.serialize() + self.assertIsNotNone(serialized["compute_volume_from_height"]) + self.assertIsNone(serialized["height_volume_data"]) + + d = Container.deserialize(serialized, allow_marshal=True) + self.assertEqual(d.compute_volume_from_height(10), 20) + self.assertEqual(d.compute_height_from_volume(20), 10) From 75332fef32fab6e7f2f630663197d01a3233c1c9 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 16 Mar 2026 14:05:50 +0000 Subject: [PATCH 3/6] fix typing --- pylabrobot/resources/container.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pylabrobot/resources/container.py b/pylabrobot/resources/container.py index 4d4bd3a0a7c..bbeb19fff23 100644 --- a/pylabrobot/resources/container.py +++ b/pylabrobot/resources/container.py @@ -54,13 +54,16 @@ def __init__( # Auto-generate volume/height functions from height_volume_data if not explicitly provided. if self.height_volume_data is not None: - volume_height_data = {v: h for h, v in self.height_volume_data.items()} + hvd = self.height_volume_data + volume_height_data = {v: h for h, v in hvd.items()} if compute_volume_from_height is None: + def compute_volume_from_height(h: float) -> float: - return interpolate_1d(h, self.height_volume_data, bounds_handling="error") + return interpolate_1d(h, hvd, bounds_handling="error") if compute_height_from_volume is None: + def compute_height_from_volume(v: float) -> float: return interpolate_1d(v, volume_height_data, bounds_handling="error") @@ -83,10 +86,12 @@ def serialize(self) -> dict: **super().serialize(), "max_volume": serialize(self.max_volume), "material_z_thickness": self._material_z_thickness, - "compute_volume_from_height": None if self.height_volume_data is not None - else serialize(self._compute_volume_from_height), - "compute_height_from_volume": None if self.height_volume_data is not None - else serialize(self._compute_height_from_volume), + "compute_volume_from_height": None + if self.height_volume_data is not None + else serialize(self._compute_volume_from_height), + "compute_height_from_volume": None + if self.height_volume_data is not None + else serialize(self._compute_height_from_volume), "height_volume_data": self.height_volume_data, } From 790ddc7471391b87b6440caf2965954d3cce2e38 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 16 Mar 2026 14:23:26 +0000 Subject: [PATCH 4/6] validate height_volume_data monotonicity --- pylabrobot/resources/container.py | 6 ++++++ pylabrobot/resources/container_tests.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/pylabrobot/resources/container.py b/pylabrobot/resources/container.py index bbeb19fff23..633cc6c9653 100644 --- a/pylabrobot/resources/container.py +++ b/pylabrobot/resources/container.py @@ -55,6 +55,12 @@ def __init__( # Auto-generate volume/height functions from height_volume_data if not explicitly provided. if self.height_volume_data is not None: hvd = self.height_volume_data + sorted_heights = sorted(hvd.keys()) + sorted_volumes = [hvd[h] for h in sorted_heights] + if len(sorted_heights) < 2: + raise ValueError("height_volume_data must contain at least 2 points.") + if any(sorted_volumes[i] >= sorted_volumes[i + 1] for i in range(len(sorted_volumes) - 1)): + raise ValueError("height_volume_data volumes must be strictly increasing with height.") volume_height_data = {v: h for h, v in hvd.items()} if compute_volume_from_height is None: diff --git a/pylabrobot/resources/container_tests.py b/pylabrobot/resources/container_tests.py index c0b598c3788..f13590e9f88 100644 --- a/pylabrobot/resources/container_tests.py +++ b/pylabrobot/resources/container_tests.py @@ -126,3 +126,15 @@ def compute_height_from_volume(volume): d = Container.deserialize(serialized, allow_marshal=True) self.assertEqual(d.compute_volume_from_height(10), 20) self.assertEqual(d.compute_height_from_volume(20), 10) + + def test_height_volume_data_validates_monotonicity(self): + with self.assertRaises(ValueError): + Container( + name="c", size_x=10, size_y=10, size_z=10, height_volume_data={0: 0, 5: 100, 10: 50} + ) # non-monotonic volumes + + def test_height_volume_data_validates_minimum_points(self): + with self.assertRaises(ValueError): + Container( + name="c", size_x=10, size_y=10, size_z=10, height_volume_data={0: 0} + ) # only 1 point From fda53fdfd53594a89300c1d34816815a501e8cb0 Mon Sep 17 00:00:00 2001 From: Camillo Moschner <122165124+BioCam@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:27:57 +0000 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pylabrobot/resources/container_tests.py | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pylabrobot/resources/container_tests.py b/pylabrobot/resources/container_tests.py index f13590e9f88..e6793020c09 100644 --- a/pylabrobot/resources/container_tests.py +++ b/pylabrobot/resources/container_tests.py @@ -81,6 +81,36 @@ def test_height_volume_data_does_not_override_explicit_functions(self): self.assertEqual(c.compute_volume_from_height(5), 4995) self.assertEqual(c.compute_height_from_volume(50), 5) + def test_height_volume_data_with_explicit_functions_serialize_deserialize_roundtrip(self): + def compute_volume_from_height(height): + # Distinct behavior from height_volume_data interpolation to ensure explicit function is used. + return height * 999 + + def compute_height_from_volume(volume): + return volume / 999 + + c = Container( + name="c", + size_x=10, + size_y=10, + size_z=10, + height_volume_data={0: 0, 10: 100}, + compute_volume_from_height=compute_volume_from_height, + compute_height_from_volume=compute_height_from_volume, + ) + + serialized = c.serialize() + # Explicit functions should still be serialized when height_volume_data is present. + self.assertIsNotNone(serialized["compute_volume_from_height"]) + self.assertIsNotNone(serialized["compute_height_from_volume"]) + # height_volume_data should be preserved as well. + self.assertEqual(serialized["height_volume_data"], {0: 0, 10: 100}) + + d = Container.deserialize(serialized, allow_marshal=True) + # After roundtrip, explicit functions should still be used, not the data-derived ones. + self.assertEqual(d.compute_volume_from_height(5), 4995) + self.assertEqual(d.compute_height_from_volume(4995), 5) + def test_height_volume_data_serialize_deserialize_roundtrip(self): c = Container( name="c", From a4c8de5f3fc33616167a54e3b3d9ae99fb8dd97c Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Mon, 16 Mar 2026 14:31:27 +0000 Subject: [PATCH 6/6] fix tests --- pylabrobot/resources/container_tests.py | 29 +++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/pylabrobot/resources/container_tests.py b/pylabrobot/resources/container_tests.py index e6793020c09..7d737092c2c 100644 --- a/pylabrobot/resources/container_tests.py +++ b/pylabrobot/resources/container_tests.py @@ -82,12 +82,8 @@ def test_height_volume_data_does_not_override_explicit_functions(self): self.assertEqual(c.compute_height_from_volume(50), 5) def test_height_volume_data_with_explicit_functions_serialize_deserialize_roundtrip(self): - def compute_volume_from_height(height): - # Distinct behavior from height_volume_data interpolation to ensure explicit function is used. - return height * 999 - - def compute_height_from_volume(volume): - return volume / 999 + """Explicit functions win at construction time, but height_volume_data is the serialization + source of truth. After roundtrip, auto-generated interpolators replace explicit functions.""" c = Container( name="c", @@ -95,21 +91,22 @@ def compute_height_from_volume(volume): size_y=10, size_z=10, height_volume_data={0: 0, 10: 100}, - compute_volume_from_height=compute_volume_from_height, - compute_height_from_volume=compute_height_from_volume, + compute_volume_from_height=lambda h: h * 999, + compute_height_from_volume=lambda v: v / 999, ) + # Explicit functions win at construction time. + self.assertEqual(c.compute_volume_from_height(5), 4995) serialized = c.serialize() - # Explicit functions should still be serialized when height_volume_data is present. - self.assertIsNotNone(serialized["compute_volume_from_height"]) - self.assertIsNotNone(serialized["compute_height_from_volume"]) - # height_volume_data should be preserved as well. + # Closures are not serialized when height_volume_data is present. + self.assertIsNone(serialized["compute_volume_from_height"]) + self.assertIsNone(serialized["compute_height_from_volume"]) self.assertEqual(serialized["height_volume_data"], {0: 0, 10: 100}) - d = Container.deserialize(serialized, allow_marshal=True) - # After roundtrip, explicit functions should still be used, not the data-derived ones. - self.assertEqual(d.compute_volume_from_height(5), 4995) - self.assertEqual(d.compute_height_from_volume(4995), 5) + d = Container.deserialize(serialized) + # After roundtrip, auto-generated interpolators from height_volume_data take over. + self.assertEqual(d.compute_volume_from_height(5), 50) + self.assertEqual(d.compute_height_from_volume(50), 5) def test_height_volume_data_serialize_deserialize_roundtrip(self): c = Container(