Skip to content
Open
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
5 changes: 5 additions & 0 deletions pylabrobot/resources/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@ def __init__(
height_volume_data: Optional[Dict[float, float]] = None,
no_go_zones: Optional[List[Tuple[Coordinate, Coordinate]]] = None,
metadata: Optional[Mapping[str, Any]] = None,
nominal_volume: Optional[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.
nominal_volume: Rated working volume of the container (what it is sold and named as), in
uL. Defaults to `max_volume` when not given.
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
Expand Down Expand Up @@ -84,6 +87,7 @@ 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.nominal_volume = nominal_volume if nominal_volume is not None else self.max_volume
self.tracker = VolumeTracker(thing=f"{self.name}_volume_tracker", max_volume=self.max_volume)
# Notify state-update subscribers (e.g. the Visualizer) on volume changes; without this
# a bare Container like a Trough updates its volume internally but never broadcasts it.
Expand Down Expand Up @@ -144,6 +148,7 @@ def serialize(self) -> dict:
return {
**super().serialize(),
"max_volume": serialize(self.max_volume),
"nominal_volume": serialize(self.nominal_volume),
"material_z_thickness": self._material_z_thickness,
"compute_volume_from_height": None
if self.height_volume_data is not None
Expand Down
164 changes: 164 additions & 0 deletions pylabrobot/resources/container_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

from .container import Container
from .coordinate import Coordinate
from .petri_dish import PetriDish
from .trash import Trash
from .trough import Trough, TroughBottomType
from .tube import Tube, TubeBottomType
from .well import CrossSectionType, Well, WellBottomType


class TestContainer(unittest.TestCase):
Expand Down Expand Up @@ -37,6 +42,7 @@ def compute_height_from_volume(volume):
"type": "Container",
"material_z_thickness": 1,
"max_volume": 1000,
"nominal_volume": 1000,
"compute_volume_from_height": serialize(compute_volume_from_height),
"compute_height_from_volume": serialize(compute_height_from_volume),
"height_volume_data": None,
Expand Down Expand Up @@ -220,6 +226,164 @@ def test_tracker_change_notifies_state_update_callbacks(self):
self.assertGreaterEqual(len(received), 2) # at least set_volume and remove_liquid fired
self.assertEqual(c.tracker.get_used_volume(), 400)

def test_nominal_volume(self):
c = Container(name="c", size_x=10, size_y=10, size_z=10, max_volume=1000, nominal_volume=800)
self.assertEqual(c.nominal_volume, 800)
self.assertEqual(c.max_volume, 1000)
self.assertEqual(Container.deserialize(c.serialize()).nominal_volume, 800)

def test_nominal_volume_defaults_to_max_volume(self):
"""An unspecified nominal_volume falls back to max_volume (the physical capacity)."""
c = Container(name="c", size_x=10, size_y=10, size_z=10, max_volume=1000)
self.assertEqual(c.nominal_volume, 1000)

def test_deserialize_legacy_without_nominal_volume(self):
"""A payload predating nominal_volume deserializes, falling back to max_volume."""
legacy = Container(name="c", size_x=10, size_y=10, size_z=10, max_volume=1000).serialize()
del legacy["nominal_volume"]
self.assertEqual(Container.deserialize(legacy).nominal_volume, 1000)

def test_positional_constructor_arguments(self):
"""Positional arguments keep their bindings when nominal_volume is omitted."""

def compute_volume_from_height(height):
return height * 20

def compute_height_from_volume(volume):
return volume / 20

height_volume_data = {0: 0, 10: 1000}
zones = [(Coordinate(0, 0, 0), Coordinate(1, 1, 1))]
constructors = [
(
Container,
(
"container",
10,
10,
10,
1,
1000,
"custom",
"model",
compute_volume_from_height,
compute_height_from_volume,
height_volume_data,
zones,
{"source": "positional"},
),
),
(
Well,
(
"well",
10,
10,
10,
1,
WellBottomType.FLAT,
"custom",
"model",
1000,
compute_volume_from_height,
compute_height_from_volume,
CrossSectionType.RECTANGLE,
height_volume_data,
zones,
),
),
(
Tube,
(
"tube",
10,
10,
10,
1000,
1,
"custom",
"model",
TubeBottomType.FLAT,
compute_volume_from_height,
compute_height_from_volume,
height_volume_data,
zones,
),
),
(
Trough,
(
"trough",
10,
10,
10,
1000,
1,
2,
"custom",
"model",
TroughBottomType.FLAT,
compute_volume_from_height,
compute_height_from_volume,
height_volume_data,
zones,
),
),
(
PetriDish,
(
"petri_dish",
10,
10,
1,
"custom",
"model",
1000,
compute_volume_from_height,
compute_height_from_volume,
height_volume_data,
zones,
),
),
(
Trash,
(
"trash",
10,
10,
10,
1,
1000,
"custom",
"model",
compute_volume_from_height,
compute_height_from_volume,
height_volume_data,
zones,
),
),
]
for container_type, args in constructors:
with self.subTest(container_type=container_type.__name__):
c = container_type(*args)
self.assertEqual(c.max_volume, 1000)
self.assertEqual(c.nominal_volume, 1000)
self.assertEqual(c.material_z_thickness, 1)
self.assertEqual(c.category, "custom")
self.assertEqual(c.model, "model")
self.assertEqual(c.compute_volume_from_height(5), 100)
self.assertEqual(c.compute_height_from_volume(100), 5)
self.assertEqual(c.height_volume_data, height_volume_data)
self.assertEqual(c.no_go_zones, zones)
if isinstance(c, Well):
self.assertEqual(c.bottom_type, WellBottomType.FLAT)
self.assertEqual(c.cross_section_type, CrossSectionType.RECTANGLE)
if isinstance(c, Trough):
self.assertEqual(c.bottom_type, TroughBottomType.FLAT)
self.assertEqual(c.through_base_to_container_base, 2)
if container_type is Container:
self.assertEqual(c.metadata, {"source": "positional"})


class TestNoGoZoneCollision(unittest.TestCase):
def _make_container(self, size_y, no_go_zones=None):
Expand Down
1 change: 1 addition & 0 deletions pylabrobot/resources/nest/plates.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ def nest_96_wellplate_200uL_Fb(name: str) -> Plate:
cross_section_type=CrossSectionType.CIRCLE,
bottom_type=WellBottomType.FLAT,
max_volume=372.4, # computed from spec (brim-full, the last height_volume_data point)
nominal_volume=200, # from spec (recommended medium volume 0.1-0.2 mL)
height_volume_data=_nest_96_wellplate_200uL_Fb_height_volume_data,
name_prefix=name,
),
Expand Down
2 changes: 2 additions & 0 deletions pylabrobot/resources/petri_dish.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def __init__(
compute_height_from_volume: Optional[Callable[[float], float]] = None,
height_volume_data: Optional[Dict[float, float]] = None,
no_go_zones=None,
nominal_volume: Optional[float] = None,
):
super().__init__(
name=name,
Expand All @@ -31,6 +32,7 @@ def __init__(
category=category,
model=model,
max_volume=max_volume,
nominal_volume=nominal_volume,
compute_volume_from_height=compute_volume_from_height,
compute_height_from_volume=compute_height_from_volume,
height_volume_data=height_volume_data,
Expand Down
1 change: 1 addition & 0 deletions pylabrobot/resources/petri_dish_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def test_petri_dish_serialization(self):
"type": "PetriDish",
"category": "petri_dish",
"max_volume": 121500.0,
"nominal_volume": 121500.0,
"material_z_thickness": None,
"compute_volume_from_height": None,
"compute_height_from_volume": None,
Expand Down
2 changes: 2 additions & 0 deletions pylabrobot/resources/trash.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def __init__(
compute_height_from_volume=None,
height_volume_data=None,
no_go_zones=None,
nominal_volume=None,
):
super().__init__(
name=name,
Expand All @@ -26,6 +27,7 @@ def __init__(
size_z=size_z,
material_z_thickness=material_z_thickness,
max_volume=max_volume,
nominal_volume=nominal_volume,
category=category,
model=model,
compute_volume_from_height=compute_volume_from_height,
Expand Down
2 changes: 2 additions & 0 deletions pylabrobot/resources/trough.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def __init__(
compute_height_from_volume: Optional[Callable[[float], float]] = None,
height_volume_data: Optional[Dict[float, float]] = None,
no_go_zones=None,
nominal_volume: Optional[float] = None,
):
if isinstance(bottom_type, str):
bottom_type = TroughBottomType(bottom_type)
Expand All @@ -43,6 +44,7 @@ def __init__(
size_z=size_z,
material_z_thickness=material_z_thickness,
max_volume=max_volume,
nominal_volume=nominal_volume,
category=category,
model=model,
compute_volume_from_height=compute_volume_from_height,
Expand Down
4 changes: 4 additions & 0 deletions pylabrobot/resources/tube.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def __init__(
compute_height_from_volume: Optional[Callable[[float], float]] = None,
height_volume_data: Optional[Dict[float, float]] = None,
no_go_zones=None,
nominal_volume: Optional[float] = None,
):
"""Create a new tube.

Expand All @@ -49,6 +50,8 @@ def __init__(
size_z: Size of the tube in the z direction.
material_z_thickness: Tube base to cavity base.
max_volume: Maximum volume of the tube.
nominal_volume: Rated working volume of the tube (what it is sold and named as), in uL.
Defaults to `max_volume` when not given.
category: Category of the tube.
height_volume_data: Optional dict mapping height (mm) to volume (uL). See
:class:`Container` for details.
Expand All @@ -64,6 +67,7 @@ def __init__(
material_z_thickness=material_z_thickness,
category=category,
max_volume=max_volume,
nominal_volume=nominal_volume,
model=model,
compute_volume_from_height=compute_volume_from_height,
compute_height_from_volume=compute_height_from_volume,
Expand Down
4 changes: 4 additions & 0 deletions pylabrobot/resources/well.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def __init__(
cross_section_type: Union[CrossSectionType, str] = CrossSectionType.CIRCLE,
height_volume_data: Optional[Dict[float, float]] = None,
no_go_zones=None,
nominal_volume: Optional[float] = None,
):
"""Create a new well.

Expand All @@ -64,6 +65,8 @@ def __init__(
category: Category of the well.
max_volume: Maximum volume of the well. If not specified, the well will be seen as a cylinder
and the max volume will be computed based on size_x, size_y, and size_z.
nominal_volume: Rated working volume of the well (what it is sold and named as), in uL.
Defaults to `max_volume` when not given.
compute_volume_from_height: function to compute the volume from the height relative to the
bottom
cross_section_type: Type of the cross section of the well. If not specified, the well will be
Expand Down Expand Up @@ -95,6 +98,7 @@ def __init__(
size_z=size_z,
category=category,
max_volume=max_volume,
nominal_volume=nominal_volume,
model=model,
compute_volume_from_height=compute_volume_from_height,
compute_height_from_volume=compute_height_from_volume,
Expand Down
6 changes: 6 additions & 0 deletions pylabrobot/resources/well_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def test_serialize(self):
"category": "well",
"model": "model",
"max_volume": 10,
"nominal_volume": 10,
"material_z_thickness": None,
"compute_volume_from_height": None,
"compute_height_from_volume": None,
Expand All @@ -39,6 +40,11 @@ def test_serialize(self):

self.assertEqual(Well.deserialize(well.serialize()), well)

def test_nominal_volume_round_trip(self):
well = Well(name="well", size_x=1, size_y=1, size_z=3, max_volume=10, nominal_volume=8)
self.assertEqual(well.nominal_volume, 8)
self.assertEqual(Well.deserialize(well.serialize()).nominal_volume, 8)

def test_get_index_in_plate(self):
plate = Plate(
"plate",
Expand Down
Loading