Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions pylabrobot/resources/container.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,13 +23,18 @@ 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.

Args:
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__(
Expand All @@ -40,6 +46,33 @@ def __init__(
model=model,
)
self._material_z_thickness = material_z_thickness
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 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:

def compute_volume_from_height(h: float) -> float:
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")

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
Expand All @@ -59,8 +92,13 @@ 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]:
Expand Down
114 changes: 114 additions & 0 deletions pylabrobot/resources/container_tests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import unittest

from pylabrobot.serializer import serialize
Expand Down Expand Up @@ -39,6 +40,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",
Expand All @@ -51,3 +53,115 @@ 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_with_explicit_functions_serialize_deserialize_roundtrip(self):
"""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",
size_x=10,
size_y=10,
size_z=10,
height_volume_data={0: 0, 10: 100},
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()
# 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)
# 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(
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)

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
78 changes: 22 additions & 56 deletions pylabrobot/resources/eppendorf/tubes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
Loading
Loading