diff --git a/pylabrobot/resources/container.py b/pylabrobot/resources/container.py index cbd6fc2fbbd..2d0e2e7ef27 100644 --- a/pylabrobot/resources/container.py +++ b/pylabrobot/resources/container.py @@ -30,6 +30,7 @@ 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. @@ -37,6 +38,8 @@ 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. + 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 @@ -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. @@ -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 diff --git a/pylabrobot/resources/container_tests.py b/pylabrobot/resources/container_tests.py index 13ef930d631..b2349694a78 100644 --- a/pylabrobot/resources/container_tests.py +++ b/pylabrobot/resources/container_tests.py @@ -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): @@ -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, @@ -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): diff --git a/pylabrobot/resources/nest/plates.py b/pylabrobot/resources/nest/plates.py index 4c9d284eb61..480efcd11cd 100644 --- a/pylabrobot/resources/nest/plates.py +++ b/pylabrobot/resources/nest/plates.py @@ -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, ), diff --git a/pylabrobot/resources/petri_dish.py b/pylabrobot/resources/petri_dish.py index 74d5f142e4b..0a9768c4f6e 100644 --- a/pylabrobot/resources/petri_dish.py +++ b/pylabrobot/resources/petri_dish.py @@ -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, @@ -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, diff --git a/pylabrobot/resources/petri_dish_tests.py b/pylabrobot/resources/petri_dish_tests.py index 4de5ca77088..4f7d377aca9 100644 --- a/pylabrobot/resources/petri_dish_tests.py +++ b/pylabrobot/resources/petri_dish_tests.py @@ -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, diff --git a/pylabrobot/resources/trash.py b/pylabrobot/resources/trash.py index 40c633d5072..520d14bdd17 100644 --- a/pylabrobot/resources/trash.py +++ b/pylabrobot/resources/trash.py @@ -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, @@ -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, diff --git a/pylabrobot/resources/trough.py b/pylabrobot/resources/trough.py index f92e74d35f5..77d01e8e0e8 100644 --- a/pylabrobot/resources/trough.py +++ b/pylabrobot/resources/trough.py @@ -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) @@ -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, diff --git a/pylabrobot/resources/tube.py b/pylabrobot/resources/tube.py index 0fcde5170f8..2a54e68f147 100644 --- a/pylabrobot/resources/tube.py +++ b/pylabrobot/resources/tube.py @@ -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. @@ -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. @@ -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, diff --git a/pylabrobot/resources/well.py b/pylabrobot/resources/well.py index ffca9aa0ee8..2a75bc9ba61 100644 --- a/pylabrobot/resources/well.py +++ b/pylabrobot/resources/well.py @@ -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. @@ -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 @@ -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, diff --git a/pylabrobot/resources/well_tests.py b/pylabrobot/resources/well_tests.py index a4a48d87a7e..697c81cb872 100644 --- a/pylabrobot/resources/well_tests.py +++ b/pylabrobot/resources/well_tests.py @@ -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, @@ -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",