diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 78574f81406..214f5beefd8 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -1880,22 +1880,22 @@ async def test_move_lid_across_rotated_resources(self): self.STAR._write_and_read_command.assert_has_calls( [ _any_write_and_read_command_call( - "C0PPid0001xs04829xd0yj1142yd0zj2242zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0", + "C0PPid0001xs04829xd0yj1141yd0zj2242zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0", ), _any_write_and_read_command_call( - "C0PRid0002xs02318xd0yj1644yd0zj1983zd0th2800te2800gr4go1308ga0gc0", + "C0PRid0002xs02317xd0yj1644yd0zj1983zd0th2800te2800gr4go1308ga0gc0", ), _any_write_and_read_command_call( - "C0PPid0003xs02318xd0yj1644yd0zj1983zd0gr1th2800te2800gw4go0885gb0822gt20ga0gc0", + "C0PPid0003xs02317xd0yj1644yd0zj1983zd0gr1th2800te2800gw4go0885gb0822gt20ga0gc0", ), _any_write_and_read_command_call( - "C0PRid0004xs02315xd0yj3104yd0zj1983zd0th2800te2800gr3go0885ga0gc0", + "C0PRid0004xs02317xd0yj3104yd0zj1983zd0th2800te2800gr3go0885ga0gc0", ), _any_write_and_read_command_call( - "C0PPid0005xs02315xd0yj3104yd0zj1983zd0gr1th2800te2800gw4go0885gb0822gt20ga0gc0", + "C0PPid0005xs02317xd0yj3104yd0zj1983zd0gr1th2800te2800gw4go0885gb0822gt20ga0gc0", ), _any_write_and_read_command_call( - "C0PRid0006xs04829xd0yj1142yd0zj2242zd0th2800te2800gr4go0885ga0gc0", + "C0PRid0006xs04829xd0yj1141yd0zj2242zd0th2800te2800gr4go0885ga0gc0", ), ] ) diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index c81909a8890..869695be714 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -39,6 +39,7 @@ Coordinate, Deck, Lid, + Liddable, Plate, PlateAdapter, PlateHolder, @@ -91,6 +92,34 @@ class BlowOutVolumeError(Exception): pass +def _lidded_ancestor(resource: Resource) -> Optional[Liddable]: + """Return the nearest lidded resource at or above ``resource``, walking up the parent chain. + + A lid anywhere in the ancestry blocks pipetting, not just one on the direct parent: a well is + enclosed by its plate, but plates may in turn sit in lidded holders or nested containers. Returns + ``resource`` itself if it carries a lid, else the closest lidded ancestor, else ``None``. + """ + current: Optional[Resource] = resource + while current is not None: + if isinstance(current, Liddable) and current.has_lid(): + return current + current = current.parent + return None + + +def _check_no_lid(resource: Resource, action: str) -> None: + """Raise if ``resource`` or any ancestor carries a lid. ``action`` is a verb phrase for the error.""" + lidded = _lidded_ancestor(resource) + if lidded is None: + return + if lidded is resource: + raise ValueError(f"Cannot {action} {resource.name!r}: it has a lid. Remove the lid first.") + raise ValueError( + f"Cannot {action} {resource.name!r}: its enclosing resource {lidded.name!r} has a lid. " + "Remove the lid first." + ) + + class LiquidHandler(Resource, Machine): """ Front end for liquid handlers. @@ -946,8 +975,7 @@ async def aspirate( # Checks for resource in resources: - if isinstance(resource.parent, Plate) and resource.parent.has_lid(): - raise ValueError("Aspirating from a well with a lid is not supported.") + _check_no_lid(resource, "aspirate from") self._make_sure_channels_exist(use_channels) for n, p in [ @@ -1160,8 +1188,7 @@ async def dispense( raise BlowOutVolumeError("Blowout volume is larger than aspirated volume") for resource in resources: - if isinstance(resource.parent, Plate) and resource.parent.has_lid(): - raise ValueError("Dispensing to plate with lid") + _check_no_lid(resource, "dispense to") for n, p in [ ("resources", resources), @@ -1732,10 +1759,10 @@ async def aspirate96( # Convert Plate to either one Container (single well) or a list of Wells containers: Sequence[Container] if isinstance(resource, Plate): - if resource.has_lid(): - raise ValueError("Aspirating from plate with lid") + _check_no_lid(resource, "aspirate from") containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] elif isinstance(resource, Container): + _check_no_lid(resource, "aspirate from") containers = [resource] elif isinstance(resource, list) and all(isinstance(w, Well) for w in resource): containers = resource @@ -1882,10 +1909,10 @@ async def dispense96( # Convert Plate to either one Container (single well) or a list of Wells containers: Sequence[Container] if isinstance(resource, Plate): - if resource.has_lid(): - raise ValueError("Dispensing to plate with lid is not possible. Remove the lid first.") + _check_no_lid(resource, "dispense to") containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] elif isinstance(resource, Container): + _check_no_lid(resource, "dispense to") containers = [resource] elif isinstance(resource, list) and all(isinstance(w, Well) for w in resource): containers = resource @@ -2191,6 +2218,9 @@ async def drop_resource( ).rotated(destination.get_absolute_rotation()) elif isinstance(destination, Coordinate): to_location = destination + elif isinstance(destination, Trash): + # discarded, never seated - kept above the branches that place a resource on its destination + to_location = destination.get_location_wrt(self.deck) elif isinstance(destination, ResourceHolder): if destination.resource is not None and destination.resource is not resource: raise RuntimeError("Destination already has a plate") @@ -2206,13 +2236,13 @@ async def drop_resource( resource.rotated(z=resource_rotation_wrt_destination_wrt_local) ).rotated(destination.get_absolute_rotation()) to_location = destination.get_location_wrt(self.deck) + adjusted_plate_anchor - elif isinstance(destination, Plate) and isinstance(resource, Lid): + elif isinstance(destination, Liddable) and isinstance(resource, Lid): lid = resource - plate_location = destination.get_location_wrt(self.deck) + parent_location = destination.get_location_wrt(self.deck) child_wrt_parent = destination.get_lid_location( lid.rotated(z=resource_rotation_wrt_destination_wrt_local) ).rotated(destination.get_absolute_rotation()) - to_location = plate_location + child_wrt_parent + to_location = parent_location + child_wrt_parent else: to_location = destination.get_location_wrt(self.deck) @@ -2242,6 +2272,9 @@ async def drop_resource( if isinstance(destination, Coordinate): to_location -= self.deck.location # passed as an absolute location, but stored as relative self.deck.assign_child_resource(resource, location=to_location) + elif isinstance(destination, Trash): + # discarded: `resource.unassign()` above already detached it, so leave it detached + pass elif isinstance(destination, PlateHolder): # .zero() resources destination.assign_child_resource(resource) elif isinstance(destination, ResourceHolder): # .zero() resources @@ -2258,10 +2291,8 @@ async def drop_resource( destination.assign_child_resource( resource, location=destination.compute_plate_location(resource) ) - elif isinstance(destination, Plate) and isinstance(resource, Lid): + elif isinstance(destination, Liddable) and isinstance(resource, Lid): destination.assign_child_resource(resource) - elif isinstance(destination, Trash): - pass # don't assign to trash, resource will simply be unassigned else: destination.assign_child_resource(resource, location=to_location) @@ -2348,7 +2379,7 @@ async def move_resource( async def move_lid( self, lid: Lid, - to: Union[Plate, ResourceStack, Coordinate], + to: Union[Liddable, ResourceStack, Coordinate], intermediate_locations: Optional[List[Coordinate]] = None, pickup_offset: Coordinate = Coordinate.zero(), destination_offset: Coordinate = Coordinate.zero(), diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index 547d32209a0..ed88934d186 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -24,11 +24,14 @@ Coordinate, Deck, Lid, + PetriDish, Plate, + Resource, ResourceNotFoundError, ResourceStack, TipRack, cor_96_wellplate_360uL_Fb, + hamilton_1_trough_200mL_Vb, nest_1_troughplate_195000uL_Vb, no_tip_tracking, set_tip_tracking, @@ -310,6 +313,27 @@ async def test_move_lid(self): == lid.get_absolute_location().z ) + async def test_move_lid_to_trash(self): + # a lid moved to the trash is discarded, not seated on it + plate = Plate("plate", size_x=100, size_y=100, size_z=15, ordered_items={}) + self.deck.assign_child_resource(plate, location=Coordinate(0, 0, 100)) + lid = Lid(name="lid", size_x=100, size_y=100, size_z=10, nesting_z_height=10) + plate.assign_child_resource(lid) + trash = self.deck.get_trash_area() + + with unittest.mock.patch.object( + self.lh.backend, "drop_resource", wraps=self.lh.backend.drop_resource + ) as mock_drop: + await self.lh.move_lid(lid, trash) + + self.assertEqual( + mock_drop.call_args.kwargs["drop"].destination, + trash.get_location_wrt(self.deck), + ) + self.assertFalse(plate.has_lid()) + self.assertIsNone(lid.parent) + self.assertNotIn(lid, trash.children) + async def test_move_plate_onto_resource_stack_with_lid(self): plate = Plate("plate", size_x=100, size_y=100, size_z=15, ordered_items={}) lid = Lid( @@ -915,6 +939,57 @@ async def test_aspirate_with_lid(self): with self.assertRaises(ValueError): await self.lh.aspirate([well], vols=[10]) + async def test_aspirate_from_trough_with_lid(self): + trough = hamilton_1_trough_200mL_Vb(name="trough") + trough.lid = Lid( + "trough_lid", + size_x=trough.get_size_x() + 2, + size_y=trough.get_size_y() + 2, + size_z=10, + nesting_z_height=4, + ) + t = self.tip_rack.get_item("A1").get_tip() + self.lh.update_head_state({0: t}) + with self.assertRaises(ValueError): + await self.lh.aspirate([trough], vols=[10]) + + async def test_aspirate_from_petri_dish_with_lid(self): + dish = PetriDish("petri_dish", diameter=90, height=15) + dish.lid = Lid( + "petri_dish_lid", + size_x=dish.get_size_x() + 4, + size_y=dish.get_size_y() + 4, + size_z=10, + nesting_z_height=4, + ) + t = self.tip_rack.get_item("A1").get_tip() + self.lh.update_head_state({0: t}) + with self.assertRaises(ValueError): + await self.lh.aspirate([dish], vols=[10]) + + async def test_lidded_ancestor_walks_full_chain(self): + # A lid on any ancestor blocks pipetting, not just the direct parent: nest a target two levels + # under a lidded container and check the walk finds it. + from pylabrobot.liquid_handling.liquid_handler import _lidded_ancestor + + trough = hamilton_1_trough_200mL_Vb(name="trough") + trough.lid = Lid( + "trough_lid", + size_x=trough.get_size_x() + 2, + size_y=trough.get_size_y() + 2, + size_z=10, + nesting_z_height=4, + ) + middle = Resource("middle", size_x=1, size_y=1, size_z=1) + target = Resource("target", size_x=1, size_y=1, size_z=1) + trough.assign_child_resource(middle, location=Coordinate.zero()) + middle.assign_child_resource(target, location=Coordinate.zero()) + + self.assertIs(_lidded_ancestor(target), trough) + self.assertIs(_lidded_ancestor(middle), trough) + trough.lid = None + self.assertIsNone(_lidded_ancestor(target)) + @pytest.mark.filterwarnings("ignore:Extra arguments to backend") async def test_strictness(self): # Create a mock backend with a custom pick_up_tips that checks arguments diff --git a/pylabrobot/resources/__init__.py b/pylabrobot/resources/__init__.py index 4652e9a859b..ca92f0edc5b 100644 --- a/pylabrobot/resources/__init__.py +++ b/pylabrobot/resources/__init__.py @@ -28,12 +28,13 @@ from .greiner import * from .hamilton import * from .itemized_resource import ItemizedResource +from .lid import Lid, Liddable from .liquid import Liquid from .nest import * from .opentrons import * from .perkin_elmer import * from .petri_dish import PetriDish, PetriDishHolder -from .plate import Lid, Plate +from .plate import Plate from .plate_adapter import PlateAdapter from .porvair import * from .powder import Powder diff --git a/pylabrobot/resources/container.py b/pylabrobot/resources/container.py index 46b7aa0176a..03a0b080eef 100644 --- a/pylabrobot/resources/container.py +++ b/pylabrobot/resources/container.py @@ -4,12 +4,16 @@ from pylabrobot.utils.interpolation import interpolate_1d from .coordinate import Coordinate +from .lid import Liddable from .resource import Resource from .volume_tracker import VolumeTracker -class Container(Resource): - """A container is an abstract base class for a resource that can hold liquid.""" +class Container(Liddable, Resource): + """A container is an abstract base class for a resource that can hold liquid. + + Via the :class:`Liddable` mixin, a container (trough, tube, petri dish, well) can host a lid. + """ def __init__( self, diff --git a/pylabrobot/resources/lid.py b/pylabrobot/resources/lid.py new file mode 100644 index 00000000000..5dd3eaf8388 --- /dev/null +++ b/pylabrobot/resources/lid.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Optional + +from pylabrobot.resources.resource_holder import get_child_location + +from .resource import Coordinate, Resource + +# A lid may be modelled up to this much smaller than the resource it covers - its rim sits just +# inside the outer edge, so real plate lids run a few tenths of a mm under. A larger shortfall means +# the wrong lid. Used by Liddable.assign_child_resource. +LID_UNDERSIZE_TOLERANCE = 1.0 + + +class Lid(Resource): + """A removable cover seated on top of a :class:`Liddable` resource. + + Any liddable resource - a ``Plate`` or a ``Container`` (trough, tube, petri dish, well) - can carry + one. A lid is a standalone resource, moved with the gripper (``LiquidHandler.move_lid``) or + assigned as a child; when seated it is centred on the parent's top face and lowered by + ``nesting_z_height``, the vertical overlap between the lid and the parent it rests on. + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + nesting_z_height: float, + category: str = "lid", + model: Optional[str] = None, + ): + """Create a lid. + + Args: + name: Name of the lid. + size_x: Size of the lid in x-direction. + size_y: Size of the lid in y-direction. + size_z: Size of the lid in z-direction. + nesting_z_height: the overlap in mm between the lid and its parent (in the z-direction). + """ + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + model=model, + ) + self.nesting_z_height = nesting_z_height + if nesting_z_height == 0: + print(f"{self.name}: Are you certain that the lid nests 0 mm with its parent?") + + def serialize(self) -> dict: + return { + **super().serialize(), + "nesting_z_height": self.nesting_z_height, + } + + +class Liddable(Resource): + """Mixin: a resource that can host a single :class:`Lid` on its top face. + + Mixed into :class:`~pylabrobot.resources.plate.Plate` and + :class:`~pylabrobot.resources.container.Container` (so troughs, tubes, petri dishes, and wells + can carry a lid). The lid is seated centred on the parent's top face and sunk by its own + ``nesting_z_height``; a resource may hold at most one lid at a time. + """ + + def has_lid(self) -> bool: + return self.lid is not None + + @property + def lid(self) -> Optional[Lid]: + """The lid seated on this resource, or ``None``. Derived from the children.""" + return next((child for child in self.children if isinstance(child, Lid)), None) + + @lid.setter + def lid(self, lid: Optional[Lid]) -> None: + if lid is None: + current_lid = self.lid + if current_lid is not None: + self.unassign_child_resource(current_lid) + else: + self.assign_child_resource(lid) + + def get_lid_location(self, lid: Lid) -> Coordinate: + """Location of ``lid`` seated centred on this resource's top face, sunk by nesting_z_height. + + Centres the lid on the footprint - a no-op when the lid shares the footprint (e.g. plate lids), + so this is backwards-compatible - and drops its origin to ``size_z - nesting_z_height``. + ``get_child_location`` keeps a rotated lid's footprint aligned. + """ + return ( + get_child_location(lid) + + self.get_anchor(x="c", y="c", z="t") + - lid.get_anchor(x="c", y="c", z="b") + - Coordinate(0, 0, lid.nesting_z_height) + ) + + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True, + ): + if isinstance(resource, Lid): + if self.has_lid(): + raise ValueError(f"'{self.name}' already has a lid.") + if ( + resource.get_size_x() < self.get_size_x() - LID_UNDERSIZE_TOLERANCE + or resource.get_size_y() < self.get_size_y() - LID_UNDERSIZE_TOLERANCE + ): + raise ValueError( + f"Lid '{resource.name}' ({resource.get_size_x()} x {resource.get_size_y()} mm) is smaller " + f"than '{self.name}' ({self.get_size_x()} x {self.get_size_y()} mm) and cannot cover it." + ) + location = location or self.get_lid_location(resource) + return super().assign_child_resource(resource, location=location, reassign=reassign) diff --git a/pylabrobot/resources/lid_tests.py b/pylabrobot/resources/lid_tests.py new file mode 100644 index 00000000000..8cbf99a90e8 --- /dev/null +++ b/pylabrobot/resources/lid_tests.py @@ -0,0 +1,83 @@ +import unittest + +from pylabrobot.resources import ( + Container, + Lid, + Liddable, + Plate, + Resource, + hamilton_1_trough_200mL_Vb, +) + + +class LidTests(unittest.TestCase): + """The Liddable mixin: any Plate or Container can host a lid, seated centred on its top.""" + + def test_plate_lid_placement_unchanged(self): + """Non-breaking: a footprint-matched lid seats origin-aligned at ``top - nesting``.""" + plate = Plate("p", size_x=127, size_y=86, size_z=14, ordered_items={}) + lid = Lid("l", size_x=127, size_y=86, size_z=10, nesting_z_height=3) + loc = plate.get_lid_location(lid) + self.assertEqual( + (loc.x, loc.y, loc.z), (0, 0, 11) + ) # == get_child_location + (0,0,size_z-nesting) + + def test_container_lid_is_centred_and_sunk(self): + """A larger-footprint lid on a container centres on the top face and sinks by nesting.""" + c = Container("c", size_x=37, size_y=118, size_z=95, material_z_thickness=1.5) + lid = Lid("cl", size_x=44, size_y=125, size_z=10, nesting_z_height=4) + loc = c.get_lid_location(lid) + self.assertEqual((loc.x, loc.y, loc.z), (-3.5, -3.5, 91)) # (37-44)/2, (118-125)/2, 95-4 + + def test_hamilton_trough_larger_lid_is_centred(self): + """A real Hamilton trough with a directly-built lid 2 mm larger in x and y: the lid centres on + the top face, overhanging 1 mm each side.""" + trough = hamilton_1_trough_200mL_Vb(name="trough") + lid = Lid( + "trough_lid", + size_x=trough.get_size_x() + 2, + size_y=trough.get_size_y() + 2, + size_z=10, + nesting_z_height=4, + ) + loc = trough.get_lid_location(lid) + self.assertEqual((loc.x, loc.y, loc.z), (-1, -1, 91)) # (37-39)/2, (118-120)/2, 95-4 + trough.assign_child_resource(lid) + self.assertTrue(trough.has_lid()) + + def test_containers_are_liddable_lids_are_not(self): + self.assertIsInstance(Container("c", 10, 10, 10, material_z_thickness=1), Liddable) + self.assertIsInstance(Plate("p", 10, 10, 10, ordered_items={}), Liddable) + self.assertNotIsInstance(Lid("l", 10, 10, 5, nesting_z_height=1), Liddable) # can't lid a lid + + def test_double_lid_is_guarded(self): + c = Container("c", 40, 40, 20, material_z_thickness=1) + c.assign_child_resource(Lid("l1", 40, 40, 5, nesting_z_height=1)) + self.assertTrue(c.has_lid()) + with self.assertRaises(ValueError): + c.assign_child_resource(Lid("l2", 40, 40, 5, nesting_z_height=1)) + + def test_lid_round_trip_restores_state(self): + c = Container("c", 40, 40, 20, material_z_thickness=1) + c.assign_child_resource(Lid("l", 40, 40, 5, nesting_z_height=1)) + c2 = Resource.deserialize(c.serialize()) + assert isinstance(c2, Container) + self.assertTrue(c2.has_lid()) + assert c2.lid is not None + self.assertEqual(c2.lid.name, "l") + + def test_lid_size_check(self): + """Reject a lid clearly smaller than the parent; allow within-tolerance-under, equal, or larger.""" + c = Container("c", 40, 40, 20, material_z_thickness=1) + with self.assertRaises(ValueError): + c.assign_child_resource(Lid("sx", 35, 40, 5, nesting_z_height=1)) # x well under + with self.assertRaises(ValueError): + c.assign_child_resource(Lid("sy", 40, 35, 5, nesting_z_height=1)) # y well under + c.assign_child_resource( + Lid("ok", 39.5, 45, 5, nesting_z_height=1) + ) # 0.5mm under (x) + larger (y) + self.assertTrue(c.has_lid()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/resources/plate.py b/pylabrobot/resources/plate.py index bb62a598e13..70dfdf6edd2 100644 --- a/pylabrobot/resources/plate.py +++ b/pylabrobot/resources/plate.py @@ -15,57 +15,16 @@ ) from pylabrobot.resources.liquid import Liquid -from pylabrobot.resources.resource_holder import get_child_location from .itemized_resource import ItemizedResource +from .lid import Lid, Liddable from .resource import Coordinate, Resource if TYPE_CHECKING: from .well import Well -class Lid(Resource): - """Lid for plates.""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - nesting_z_height: float, - category: str = "lid", - model: Optional[str] = None, - ): - """Create a lid for a plate. - - Args: - name: Name of the lid. - size_x: Size of the lid in x-direction. - size_y: Size of the lid in y-direction. - size_z: Size of the lid in z-direction. - nesting_z_height: the overlap in mm between the lid and its parent plate (in the z-direction). - """ - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - model=model, - ) - self.nesting_z_height = nesting_z_height - if nesting_z_height == 0: - print(f"{self.name}: Are you certain that the lid nests 0 mm with its parent plate?") - - def serialize(self) -> dict: - return { - **super().serialize(), - "nesting_z_height": self.nesting_z_height, - } - - -class Plate(ItemizedResource["Well"]): +class Plate(Liddable, ItemizedResource["Well"]): """Base class for Plate resources.""" def __init__( @@ -106,50 +65,22 @@ def __init__( category=category, model=model, ) - self._lid: Optional[Lid] = None self.plate_type = plate_type self.stacking_z_height = stacking_z_height if lid is not None: self.assign_child_resource(lid) - @property - def lid(self) -> Optional[Lid]: - return self._lid - - @lid.setter - def lid(self, lid: Optional[Lid]) -> None: - if lid is None: - self.unassign_child_resource(self._lid) - else: - self.assign_child_resource(lid) - self._lid = lid - - def get_lid_location(self, lid: Lid) -> Coordinate: - """Get location of the lid when assigned to the plate. Takes into account sinking and rotation.""" - return get_child_location(lid) + Coordinate(0, 0, self.get_size_z() - lid.nesting_z_height) - def assign_child_resource( self, resource: Resource, location: Optional[Coordinate] = None, reassign: bool = True, ): - if isinstance(resource, Lid): - if self.has_lid(): - raise ValueError(f"Plate '{self.name}' already has a lid.") - self._lid = resource - default_location = self.get_lid_location(resource) - location = location or default_location - else: - assert location is not None, "Location must be specified for if resource is not a lid." + if not isinstance(resource, Lid) and location is None: + raise ValueError("Location must be specified if resource is not a lid.") return super().assign_child_resource(resource, location=location, reassign=reassign) - def unassign_child_resource(self, resource): - if isinstance(resource, Lid) and resource == self.lid: - self._lid = None - return super().unassign_child_resource(resource) - def serialize(self) -> dict: return { **super().serialize(), @@ -193,9 +124,6 @@ def get_wells(self, identifier: Union[str, Sequence[int]]) -> List["Well"]: return super().get_items(identifier) - def has_lid(self) -> bool: - return self.lid is not None - def set_well_volumes( self, volumes: List[float],