From e30341b0e44ea4ee5a93d120e4b0fd49fb6ec527 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 5 Feb 2026 01:56:22 +0000 Subject: [PATCH 1/3] Add multi-arm support, conditional head96, and enriched state serialization - LiquidHandler: replace single `_resource_pickup` with `_resource_pickups` dict keyed by arm index, with backward-compatible property for arm 0 - LiquidHandler: only create head96 trackers when `core96_head_installed` is True - LiquidHandler: serialize head96_state and arm_state (resource name, type, direction, dimensions) for downstream consumers (e.g. visualizer) - LiquidHandler: register _state_updated callback on head/head96 trackers, and fire it on pick_up_resource / drop_resource - LiquidHandler: guard pick_up_resource with RuntimeError if no arm installed - Backends: add `num_arms` attribute to STARBackend, ChatterboxBackend, STARChatterboxBackend (configurable `iswap_installed` param) - TipTracker: propagate state-update callback to tip's volume tracker on commit() Co-Authored-By: Claude Opus 4.5 --- .../liquid_handling/backends/chatterbox.py | 1 + .../backends/hamilton/STAR_backend.py | 1 + .../backends/hamilton/STAR_chatterbox.py | 17 +++-- .../backends/hamilton/STAR_tests.py | 5 ++ .../backends/hamilton/vantage_tests.py | 1 + pylabrobot/liquid_handling/liquid_handler.py | 70 +++++++++++++++++-- .../liquid_handling/liquid_handler_tests.py | 1 + pylabrobot/resources/tip_tracker.py | 4 ++ 8 files changed, 92 insertions(+), 8 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/chatterbox.py b/pylabrobot/liquid_handling/backends/chatterbox.py index 183eebaab60..ce95096c078 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox.py +++ b/pylabrobot/liquid_handling/backends/chatterbox.py @@ -43,6 +43,7 @@ def __init__(self, num_channels: int = 8): """Initialize a chatter box backend.""" super().__init__() self._num_channels = num_channels + self.num_arms = 1 async def setup(self): await super().setup() diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 42a63aa6cb6..7fefffb6786 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1448,6 +1448,7 @@ async def setup( self.autoload_installed = autoload_configuration_byte == "1" self.core96_head_installed = left_x_drive_configuration_byte_1[2] == "1" self.iswap_installed = left_x_drive_configuration_byte_1[1] == "1" + self.num_arms = 1 if self.iswap_installed else 0 self._head96_information: Optional[Head96Information] = None initialized = await self.request_instrument_initialization_status() diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index ade7567e232..b3f8443c213 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -10,17 +10,24 @@ class STARChatterboxBackend(STARBackend): """Chatterbox backend for 'STAR'""" - def __init__(self, num_channels: int = 8, core96_head_installed: bool = True): + def __init__( + self, + num_channels: int = 8, + core96_head_installed: bool = True, + iswap_installed: bool = True, + ): """Initialize a chatter box backend. Args: num_channels: Number of pipetting channels (default: 8) core96_head_installed: Whether the CoRe 96 head is installed (default: True) + iswap_installed: Whether the iSWAP robotic arm is installed (default: True) """ super().__init__() self._num_channels = num_channels self._iswap_parked = True self._core96_head_installed = core96_head_installed + self._iswap_installed = iswap_installed async def setup( self, @@ -53,6 +60,7 @@ async def setup( # Use bitwise operations to check specific bits self.iswap_installed = bool(xl_value & 0b10) # Check bit 1 self.core96_head_installed = bool(xl_value & 0b100) # Check bit 2 + self.num_arms = 1 if self.iswap_installed else 0 # Parse autoload from kb configuration byte configuration_data1 = bin(conf["kb"]).split("b")[-1].zfill(8) @@ -132,12 +140,13 @@ async def request_extended_configuration(self): """ # Calculate xl byte based on installed modules # Bit 0: (reserved) - # Bit 1: iSWAP (always True in this mock) + # Bit 1: iSWAP (based on __init__ parameter) # Bit 2: 96-head (based on __init__ parameter) - xl_value = 0b10 # iSWAP installed (bit 1) + xl_value = 0 + if self._iswap_installed: + xl_value |= 0b10 # Add iSWAP (bit 1) if self._core96_head_installed: xl_value |= 0b100 # Add 96-head (bit 2) - # Result: xl = 6 (0b110) if 96-head installed, 2 (0b10) if not self._extended_conf = { "ka": 65537, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 89ade38b7f1..92be623f6bb 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -178,6 +178,7 @@ def __init__(self): async def setup(self) -> None: # type: ignore self._num_channels = 8 self.iswap_installed = True + self.num_arms = 1 self.core96_head_installed = True self._core_parked = True @@ -262,6 +263,7 @@ def __init__(self, name: str): self.STAR._num_channels = 8 self.STAR.core96_head_installed = True self.STAR.iswap_installed = True + self.STAR.num_arms = 1 self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True @@ -1093,6 +1095,7 @@ async def asyncSetUp(self): self.STAR._num_channels = 8 self.STAR.core96_head_installed = True self.STAR.iswap_installed = True + self.STAR.num_arms = 1 self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True @@ -1222,6 +1225,7 @@ async def asyncSetUp(self): self.star._num_channels = 8 self.star.core96_head_installed = True self.star.iswap_installed = True + self.star.num_arms = 1 self.star.setup = unittest.mock.AsyncMock() self.star._core_parked = True self.star._iswap_parked = True @@ -1418,6 +1422,7 @@ async def asyncSetUp(self): self.backend._num_channels = 8 self.backend.core96_head_installed = True self.backend.iswap_installed = True + self.backend.num_arms = 1 self.backend.setup = unittest.mock.AsyncMock() self.backend._core_parked = True self.backend._iswap_parked = True diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py index 2c612016dfa..3ecbd3957cd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py @@ -217,6 +217,7 @@ async def setup(self) -> None: # type: ignore self.setup_finished = True self._num_channels = 8 self.iswap_installed = True + self.num_arms = 1 self.core96_head_installed = True async def send_command( diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index e3b96012ca1..209ddfefedf 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -145,7 +145,17 @@ def __init__( self.location = Coordinate.zero() super().assign_child_resource(deck, location=deck.location or Coordinate.zero()) - self._resource_pickup: Optional[ResourcePickup] = None + num_arms = getattr(self.backend, "num_arms", 0) + self._resource_pickups: Dict[int, Optional[ResourcePickup]] = {a: None for a in range(num_arms)} + + @property + def _resource_pickup(self) -> Optional[ResourcePickup]: + """Backward-compatible access to the first arm's pickup state.""" + return self._resource_pickups.get(0) + + @_resource_pickup.setter + def _resource_pickup(self, value: Optional[ResourcePickup]) -> None: + self._resource_pickups[0] = value async def setup(self, **backend_kwargs): """Prepare the robot for use.""" @@ -158,16 +168,55 @@ async def setup(self, **backend_kwargs): await super().setup(**backend_kwargs) self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)} - self.head96 = {c: TipTracker(thing=f"Channel {c}") for c in range(96)} - self._resource_pickup = None + has_head96 = getattr(self.backend, "core96_head_installed", True) + self.head96 = {c: TipTracker(thing=f"Channel {c}") for c in range(96)} if has_head96 else {} + + self.backend.set_heads(head=self.head, head96=self.head96 or None) + + for tracker in self.head.values(): + tracker.register_callback(self._state_updated) + for tracker in self.head96.values(): + tracker.register_callback(self._state_updated) + + num_arms = getattr(self.backend, "num_arms", 0) + self._resource_pickups = {a: None for a in range(num_arms)} def serialize_state(self) -> Dict[str, Any]: """Serialize the state of this liquid handler. Use :meth:`~Resource.serialize_all_states` to serialize the state of the liquid handler and all children (the deck).""" head_state = {channel: tracker.serialize() for channel, tracker in self.head.items()} - return {"head_state": head_state} + head96_state = ( + {channel: tracker.serialize() for channel, tracker in self.head96.items()} + if self.head96 + else None + ) + if self._resource_pickups: + arm_state: Optional[Dict[int, Any]] = {} + for arm_id, pickup in self._resource_pickups.items(): + if pickup is not None: + res = pickup.resource + arm_entry: Dict[str, Any] = { + "has_resource": True, + "resource_name": res.name, + "resource_type": type(res).__name__, + "direction": pickup.direction.name, + "pickup_distance_from_top": pickup.pickup_distance_from_top, + "size_x": res.get_size_x(), + "size_y": res.get_size_y(), + "size_z": res.get_size_z(), + } + if hasattr(res, "num_items_x"): + arm_entry["num_items_x"] = res.num_items_x + if hasattr(res, "num_items_y"): + arm_entry["num_items_y"] = res.num_items_y + arm_state[arm_id] = arm_entry + else: + arm_state[arm_id] = None + else: + arm_state = None + return {"head_state": head_state, "head96_state": head96_state, "arm_state": arm_state} def load_state(self, state: Dict[str, Any]): """Load the liquid handler state from a file. Use :meth:`~Resource.load_all_state` to load the @@ -177,6 +226,13 @@ def load_state(self, state: Dict[str, Any]): for channel, tracker_state in head_state.items(): self.head[channel].load_state(tracker_state) + head96_state = state.get("head96_state", {}) + for channel, tracker_state in head96_state.items(): + self.head96[channel].load_state(tracker_state) + + # arm_state is informational only (read via serialize_state); no load needed since + # _resource_pickup is set/cleared by pick_up_resource/drop_resource at runtime. + def update_head_state(self, state: Dict[int, Optional[Tip]]): """Update the state of the liquid handler head. @@ -1964,6 +2020,9 @@ async def pick_up_resource( direction=direction, ) + if not self._resource_pickups: + raise RuntimeError("No robotic arm is installed on this liquid handler.") + if pickup_distance_from_top is None: if resource.preferred_pickup_location is not None: logger.debug( @@ -2001,6 +2060,8 @@ async def pick_up_resource( self._resource_pickup = None raise e + self._state_updated() + async def move_picked_up_resource( self, to: Coordinate, @@ -2166,6 +2227,7 @@ async def drop_resource( result = await self.backend.drop_resource(drop=drop, **backend_kwargs) self._resource_pickup = None + self._state_updated() # we rotate the resource on top of its original rotation. So in order to set the new rotation, # we have to subtract its current rotation. diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index 41431412035..19bcc8f608c 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -66,6 +66,7 @@ def _create_mock_backend(num_channels: int = 8): """Create a mock LiquidHandlerBackend with the specified number of channels.""" mock = unittest.mock.create_autospec(LiquidHandlerBackend, instance=True) type(mock).num_channels = PropertyMock(return_value=num_channels) + mock.num_arms = 1 mock.can_pick_up_tip.return_value = True return mock diff --git a/pylabrobot/resources/tip_tracker.py b/pylabrobot/resources/tip_tracker.py index 95fbafd85a4..90da419da42 100644 --- a/pylabrobot/resources/tip_tracker.py +++ b/pylabrobot/resources/tip_tracker.py @@ -111,6 +111,10 @@ def remove_tip(self, commit: bool = False) -> None: def commit(self) -> None: """Commit the pending operations.""" self._tip = self._pending_tip + # Propagate state-update callback to the tip's volume tracker so that + # aspirate/dispense volume changes trigger a visualizer refresh. + if self._tip is not None and self._callback is not None: + self._tip.tracker.register_callback(self._callback) if self._callback is not None: self._callback() From 758fbc0ea6c12069561e7b20f129cb2f3221093e Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 5 Feb 2026 02:10:07 +0000 Subject: [PATCH 2/3] Revert "Add multi-arm support, conditional head96, and enriched state serialization" This reverts commit e30341b0e44ea4ee5a93d120e4b0fd49fb6ec527. --- .../liquid_handling/backends/chatterbox.py | 1 - .../backends/hamilton/STAR_backend.py | 1 - .../backends/hamilton/STAR_chatterbox.py | 17 ++--- .../backends/hamilton/STAR_tests.py | 5 -- .../backends/hamilton/vantage_tests.py | 1 - pylabrobot/liquid_handling/liquid_handler.py | 70 ++----------------- .../liquid_handling/liquid_handler_tests.py | 1 - pylabrobot/resources/tip_tracker.py | 4 -- 8 files changed, 8 insertions(+), 92 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/chatterbox.py b/pylabrobot/liquid_handling/backends/chatterbox.py index ce95096c078..183eebaab60 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox.py +++ b/pylabrobot/liquid_handling/backends/chatterbox.py @@ -43,7 +43,6 @@ def __init__(self, num_channels: int = 8): """Initialize a chatter box backend.""" super().__init__() self._num_channels = num_channels - self.num_arms = 1 async def setup(self): await super().setup() diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 7fefffb6786..42a63aa6cb6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1448,7 +1448,6 @@ async def setup( self.autoload_installed = autoload_configuration_byte == "1" self.core96_head_installed = left_x_drive_configuration_byte_1[2] == "1" self.iswap_installed = left_x_drive_configuration_byte_1[1] == "1" - self.num_arms = 1 if self.iswap_installed else 0 self._head96_information: Optional[Head96Information] = None initialized = await self.request_instrument_initialization_status() diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index b3f8443c213..ade7567e232 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -10,24 +10,17 @@ class STARChatterboxBackend(STARBackend): """Chatterbox backend for 'STAR'""" - def __init__( - self, - num_channels: int = 8, - core96_head_installed: bool = True, - iswap_installed: bool = True, - ): + def __init__(self, num_channels: int = 8, core96_head_installed: bool = True): """Initialize a chatter box backend. Args: num_channels: Number of pipetting channels (default: 8) core96_head_installed: Whether the CoRe 96 head is installed (default: True) - iswap_installed: Whether the iSWAP robotic arm is installed (default: True) """ super().__init__() self._num_channels = num_channels self._iswap_parked = True self._core96_head_installed = core96_head_installed - self._iswap_installed = iswap_installed async def setup( self, @@ -60,7 +53,6 @@ async def setup( # Use bitwise operations to check specific bits self.iswap_installed = bool(xl_value & 0b10) # Check bit 1 self.core96_head_installed = bool(xl_value & 0b100) # Check bit 2 - self.num_arms = 1 if self.iswap_installed else 0 # Parse autoload from kb configuration byte configuration_data1 = bin(conf["kb"]).split("b")[-1].zfill(8) @@ -140,13 +132,12 @@ async def request_extended_configuration(self): """ # Calculate xl byte based on installed modules # Bit 0: (reserved) - # Bit 1: iSWAP (based on __init__ parameter) + # Bit 1: iSWAP (always True in this mock) # Bit 2: 96-head (based on __init__ parameter) - xl_value = 0 - if self._iswap_installed: - xl_value |= 0b10 # Add iSWAP (bit 1) + xl_value = 0b10 # iSWAP installed (bit 1) if self._core96_head_installed: xl_value |= 0b100 # Add 96-head (bit 2) + # Result: xl = 6 (0b110) if 96-head installed, 2 (0b10) if not self._extended_conf = { "ka": 65537, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 92be623f6bb..89ade38b7f1 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -178,7 +178,6 @@ def __init__(self): async def setup(self) -> None: # type: ignore self._num_channels = 8 self.iswap_installed = True - self.num_arms = 1 self.core96_head_installed = True self._core_parked = True @@ -263,7 +262,6 @@ def __init__(self, name: str): self.STAR._num_channels = 8 self.STAR.core96_head_installed = True self.STAR.iswap_installed = True - self.STAR.num_arms = 1 self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True @@ -1095,7 +1093,6 @@ async def asyncSetUp(self): self.STAR._num_channels = 8 self.STAR.core96_head_installed = True self.STAR.iswap_installed = True - self.STAR.num_arms = 1 self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True @@ -1225,7 +1222,6 @@ async def asyncSetUp(self): self.star._num_channels = 8 self.star.core96_head_installed = True self.star.iswap_installed = True - self.star.num_arms = 1 self.star.setup = unittest.mock.AsyncMock() self.star._core_parked = True self.star._iswap_parked = True @@ -1422,7 +1418,6 @@ async def asyncSetUp(self): self.backend._num_channels = 8 self.backend.core96_head_installed = True self.backend.iswap_installed = True - self.backend.num_arms = 1 self.backend.setup = unittest.mock.AsyncMock() self.backend._core_parked = True self.backend._iswap_parked = True diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py index 3ecbd3957cd..2c612016dfa 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py @@ -217,7 +217,6 @@ async def setup(self) -> None: # type: ignore self.setup_finished = True self._num_channels = 8 self.iswap_installed = True - self.num_arms = 1 self.core96_head_installed = True async def send_command( diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 209ddfefedf..e3b96012ca1 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -145,17 +145,7 @@ def __init__( self.location = Coordinate.zero() super().assign_child_resource(deck, location=deck.location or Coordinate.zero()) - num_arms = getattr(self.backend, "num_arms", 0) - self._resource_pickups: Dict[int, Optional[ResourcePickup]] = {a: None for a in range(num_arms)} - - @property - def _resource_pickup(self) -> Optional[ResourcePickup]: - """Backward-compatible access to the first arm's pickup state.""" - return self._resource_pickups.get(0) - - @_resource_pickup.setter - def _resource_pickup(self, value: Optional[ResourcePickup]) -> None: - self._resource_pickups[0] = value + self._resource_pickup: Optional[ResourcePickup] = None async def setup(self, **backend_kwargs): """Prepare the robot for use.""" @@ -168,55 +158,16 @@ async def setup(self, **backend_kwargs): await super().setup(**backend_kwargs) self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)} + self.head96 = {c: TipTracker(thing=f"Channel {c}") for c in range(96)} - has_head96 = getattr(self.backend, "core96_head_installed", True) - self.head96 = {c: TipTracker(thing=f"Channel {c}") for c in range(96)} if has_head96 else {} - - self.backend.set_heads(head=self.head, head96=self.head96 or None) - - for tracker in self.head.values(): - tracker.register_callback(self._state_updated) - for tracker in self.head96.values(): - tracker.register_callback(self._state_updated) - - num_arms = getattr(self.backend, "num_arms", 0) - self._resource_pickups = {a: None for a in range(num_arms)} + self._resource_pickup = None def serialize_state(self) -> Dict[str, Any]: """Serialize the state of this liquid handler. Use :meth:`~Resource.serialize_all_states` to serialize the state of the liquid handler and all children (the deck).""" head_state = {channel: tracker.serialize() for channel, tracker in self.head.items()} - head96_state = ( - {channel: tracker.serialize() for channel, tracker in self.head96.items()} - if self.head96 - else None - ) - if self._resource_pickups: - arm_state: Optional[Dict[int, Any]] = {} - for arm_id, pickup in self._resource_pickups.items(): - if pickup is not None: - res = pickup.resource - arm_entry: Dict[str, Any] = { - "has_resource": True, - "resource_name": res.name, - "resource_type": type(res).__name__, - "direction": pickup.direction.name, - "pickup_distance_from_top": pickup.pickup_distance_from_top, - "size_x": res.get_size_x(), - "size_y": res.get_size_y(), - "size_z": res.get_size_z(), - } - if hasattr(res, "num_items_x"): - arm_entry["num_items_x"] = res.num_items_x - if hasattr(res, "num_items_y"): - arm_entry["num_items_y"] = res.num_items_y - arm_state[arm_id] = arm_entry - else: - arm_state[arm_id] = None - else: - arm_state = None - return {"head_state": head_state, "head96_state": head96_state, "arm_state": arm_state} + return {"head_state": head_state} def load_state(self, state: Dict[str, Any]): """Load the liquid handler state from a file. Use :meth:`~Resource.load_all_state` to load the @@ -226,13 +177,6 @@ def load_state(self, state: Dict[str, Any]): for channel, tracker_state in head_state.items(): self.head[channel].load_state(tracker_state) - head96_state = state.get("head96_state", {}) - for channel, tracker_state in head96_state.items(): - self.head96[channel].load_state(tracker_state) - - # arm_state is informational only (read via serialize_state); no load needed since - # _resource_pickup is set/cleared by pick_up_resource/drop_resource at runtime. - def update_head_state(self, state: Dict[int, Optional[Tip]]): """Update the state of the liquid handler head. @@ -2020,9 +1964,6 @@ async def pick_up_resource( direction=direction, ) - if not self._resource_pickups: - raise RuntimeError("No robotic arm is installed on this liquid handler.") - if pickup_distance_from_top is None: if resource.preferred_pickup_location is not None: logger.debug( @@ -2060,8 +2001,6 @@ async def pick_up_resource( self._resource_pickup = None raise e - self._state_updated() - async def move_picked_up_resource( self, to: Coordinate, @@ -2227,7 +2166,6 @@ async def drop_resource( result = await self.backend.drop_resource(drop=drop, **backend_kwargs) self._resource_pickup = None - self._state_updated() # we rotate the resource on top of its original rotation. So in order to set the new rotation, # we have to subtract its current rotation. diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index 19bcc8f608c..41431412035 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -66,7 +66,6 @@ def _create_mock_backend(num_channels: int = 8): """Create a mock LiquidHandlerBackend with the specified number of channels.""" mock = unittest.mock.create_autospec(LiquidHandlerBackend, instance=True) type(mock).num_channels = PropertyMock(return_value=num_channels) - mock.num_arms = 1 mock.can_pick_up_tip.return_value = True return mock diff --git a/pylabrobot/resources/tip_tracker.py b/pylabrobot/resources/tip_tracker.py index 90da419da42..95fbafd85a4 100644 --- a/pylabrobot/resources/tip_tracker.py +++ b/pylabrobot/resources/tip_tracker.py @@ -111,10 +111,6 @@ def remove_tip(self, commit: bool = False) -> None: def commit(self) -> None: """Commit the pending operations.""" self._tip = self._pending_tip - # Propagate state-update callback to the tip's volume tracker so that - # aspirate/dispense volume changes trigger a visualizer refresh. - if self._tip is not None and self._callback is not None: - self._tip.tracker.register_callback(self._callback) if self._callback is not None: self._callback() From afe2dfbb7727006d56a7f26416065f777f4ca67c Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 5 Feb 2026 02:21:04 +0000 Subject: [PATCH 3/3] Add multi-arm support, conditional head96, and enriched state serialization - LiquidHandler: replace single `_resource_pickup` with `_resource_pickups` dict keyed by arm index, with backward-compatible property for arm 0 - LiquidHandler: only create head96 trackers when `core96_head_installed` is True - LiquidHandler: serialize head96_state and arm_state (resource name, type, direction, dimensions) for downstream consumers (e.g. visualizer) - LiquidHandler: register _state_updated callback on head/head96 trackers, and fire it on pick_up_resource / drop_resource - LiquidHandler: guard pick_up_resource with RuntimeError if no arm installed - Backends: add `num_arms` attribute to STARBackend, ChatterboxBackend, STARChatterboxBackend (configurable `iswap_installed` param) - TipTracker: propagate state-update callback to tip's volume tracker on commit() Co-Authored-By: Claude Opus 4.5 --- .../liquid_handling/backends/backend.py | 2 + .../liquid_handling/backends/chatterbox.py | 2 + .../backends/hamilton/STAR_backend.py | 7 +- .../backends/hamilton/STAR_chatterbox.py | 17 ++++- .../backends/hamilton/STAR_tests.py | 5 ++ .../backends/hamilton/vantage_tests.py | 1 + .../backends/serializing_backend.py | 2 + pylabrobot/liquid_handling/liquid_handler.py | 73 ++++++++++++++++++- .../liquid_handling/liquid_handler_tests.py | 2 + pylabrobot/resources/tip_tracker.py | 4 + .../server/liquid_handling_api_tests.py | 2 + 11 files changed, 106 insertions(+), 11 deletions(-) diff --git a/pylabrobot/liquid_handling/backends/backend.py b/pylabrobot/liquid_handling/backends/backend.py index fec754bba4e..9098bfc9413 100644 --- a/pylabrobot/liquid_handling/backends/backend.py +++ b/pylabrobot/liquid_handling/backends/backend.py @@ -37,6 +37,8 @@ class LiquidHandlerBackend(MachineBackend, metaclass=ABCMeta): def __init__(self): super().__init__() self.setup_finished = False + self.num_arms: int = 0 + self.core96_head_installed: bool = False self._deck: Optional[Deck] = None self._head: Optional[Dict[int, TipTracker]] = None self._head96: Optional[Dict[int, TipTracker]] = None diff --git a/pylabrobot/liquid_handling/backends/chatterbox.py b/pylabrobot/liquid_handling/backends/chatterbox.py index 183eebaab60..1a339918fed 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox.py +++ b/pylabrobot/liquid_handling/backends/chatterbox.py @@ -43,6 +43,8 @@ def __init__(self, num_channels: int = 8): """Initialize a chatter box backend.""" super().__init__() self._num_channels = num_channels + self.num_arms = 1 + self.core96_head_installed = True async def setup(self): await super().setup() diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py index 42a63aa6cb6..5d25a961d0a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py @@ -1187,9 +1187,9 @@ def __init__( serial_number=serial_number, ) - self.iswap_installed: Optional[bool] = None - self.autoload_installed: Optional[bool] = None - self.core96_head_installed: Optional[bool] = None + self.iswap_installed: bool = False + self.autoload_installed: bool = False + self.core96_head_installed: bool = False self._iswap_parked: Optional[bool] = None self._num_channels: Optional[int] = None @@ -1448,6 +1448,7 @@ async def setup( self.autoload_installed = autoload_configuration_byte == "1" self.core96_head_installed = left_x_drive_configuration_byte_1[2] == "1" self.iswap_installed = left_x_drive_configuration_byte_1[1] == "1" + self.num_arms = 1 if self.iswap_installed else 0 self._head96_information: Optional[Head96Information] = None initialized = await self.request_instrument_initialization_status() diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index ade7567e232..b3f8443c213 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -10,17 +10,24 @@ class STARChatterboxBackend(STARBackend): """Chatterbox backend for 'STAR'""" - def __init__(self, num_channels: int = 8, core96_head_installed: bool = True): + def __init__( + self, + num_channels: int = 8, + core96_head_installed: bool = True, + iswap_installed: bool = True, + ): """Initialize a chatter box backend. Args: num_channels: Number of pipetting channels (default: 8) core96_head_installed: Whether the CoRe 96 head is installed (default: True) + iswap_installed: Whether the iSWAP robotic arm is installed (default: True) """ super().__init__() self._num_channels = num_channels self._iswap_parked = True self._core96_head_installed = core96_head_installed + self._iswap_installed = iswap_installed async def setup( self, @@ -53,6 +60,7 @@ async def setup( # Use bitwise operations to check specific bits self.iswap_installed = bool(xl_value & 0b10) # Check bit 1 self.core96_head_installed = bool(xl_value & 0b100) # Check bit 2 + self.num_arms = 1 if self.iswap_installed else 0 # Parse autoload from kb configuration byte configuration_data1 = bin(conf["kb"]).split("b")[-1].zfill(8) @@ -132,12 +140,13 @@ async def request_extended_configuration(self): """ # Calculate xl byte based on installed modules # Bit 0: (reserved) - # Bit 1: iSWAP (always True in this mock) + # Bit 1: iSWAP (based on __init__ parameter) # Bit 2: 96-head (based on __init__ parameter) - xl_value = 0b10 # iSWAP installed (bit 1) + xl_value = 0 + if self._iswap_installed: + xl_value |= 0b10 # Add iSWAP (bit 1) if self._core96_head_installed: xl_value |= 0b100 # Add 96-head (bit 2) - # Result: xl = 6 (0b110) if 96-head installed, 2 (0b10) if not self._extended_conf = { "ka": 65537, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 89ade38b7f1..92be623f6bb 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -178,6 +178,7 @@ def __init__(self): async def setup(self) -> None: # type: ignore self._num_channels = 8 self.iswap_installed = True + self.num_arms = 1 self.core96_head_installed = True self._core_parked = True @@ -262,6 +263,7 @@ def __init__(self, name: str): self.STAR._num_channels = 8 self.STAR.core96_head_installed = True self.STAR.iswap_installed = True + self.STAR.num_arms = 1 self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True @@ -1093,6 +1095,7 @@ async def asyncSetUp(self): self.STAR._num_channels = 8 self.STAR.core96_head_installed = True self.STAR.iswap_installed = True + self.STAR.num_arms = 1 self.STAR.setup = unittest.mock.AsyncMock() self.STAR._core_parked = True self.STAR._iswap_parked = True @@ -1222,6 +1225,7 @@ async def asyncSetUp(self): self.star._num_channels = 8 self.star.core96_head_installed = True self.star.iswap_installed = True + self.star.num_arms = 1 self.star.setup = unittest.mock.AsyncMock() self.star._core_parked = True self.star._iswap_parked = True @@ -1418,6 +1422,7 @@ async def asyncSetUp(self): self.backend._num_channels = 8 self.backend.core96_head_installed = True self.backend.iswap_installed = True + self.backend.num_arms = 1 self.backend.setup = unittest.mock.AsyncMock() self.backend._core_parked = True self.backend._iswap_parked = True diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py index 2c612016dfa..3ecbd3957cd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py @@ -217,6 +217,7 @@ async def setup(self) -> None: # type: ignore self.setup_finished = True self._num_channels = 8 self.iswap_installed = True + self.num_arms = 1 self.core96_head_installed = True async def send_command( diff --git a/pylabrobot/liquid_handling/backends/serializing_backend.py b/pylabrobot/liquid_handling/backends/serializing_backend.py index 07212c4a20b..4e0b6e1debb 100644 --- a/pylabrobot/liquid_handling/backends/serializing_backend.py +++ b/pylabrobot/liquid_handling/backends/serializing_backend.py @@ -30,6 +30,8 @@ class SerializingBackend(LiquidHandlerBackend, metaclass=ABCMeta): def __init__(self, num_channels: int): LiquidHandlerBackend.__init__(self) self._num_channels = num_channels + self.num_arms = 1 + self.core96_head_installed = True @property def num_channels(self) -> int: diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index e3b96012ca1..5bf818a69a6 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -145,7 +145,16 @@ def __init__( self.location = Coordinate.zero() super().assign_child_resource(deck, location=deck.location or Coordinate.zero()) - self._resource_pickup: Optional[ResourcePickup] = None + self._resource_pickups: Dict[int, Optional[ResourcePickup]] = {} + + @property + def _resource_pickup(self) -> Optional[ResourcePickup]: + """Backward-compatible access to the first arm's pickup state.""" + return self._resource_pickups.get(0) + + @_resource_pickup.setter + def _resource_pickup(self, value: Optional[ResourcePickup]) -> None: + self._resource_pickups[0] = value async def setup(self, **backend_kwargs): """Prepare the robot for use.""" @@ -158,16 +167,58 @@ async def setup(self, **backend_kwargs): await super().setup(**backend_kwargs) self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)} - self.head96 = {c: TipTracker(thing=f"Channel {c}") for c in range(96)} - self._resource_pickup = None + self.head96 = ( + {c: TipTracker(thing=f"Channel {c}") for c in range(96)} + if self.backend.core96_head_installed + else {} + ) + + self.backend.set_heads(head=self.head, head96=self.head96 or None) + + for tracker in self.head.values(): + tracker.register_callback(self._state_updated) + for tracker in self.head96.values(): + tracker.register_callback(self._state_updated) + + self._resource_pickups = {a: None for a in range(self.backend.num_arms)} def serialize_state(self) -> Dict[str, Any]: """Serialize the state of this liquid handler. Use :meth:`~Resource.serialize_all_states` to serialize the state of the liquid handler and all children (the deck).""" head_state = {channel: tracker.serialize() for channel, tracker in self.head.items()} - return {"head_state": head_state} + head96_state = ( + {channel: tracker.serialize() for channel, tracker in self.head96.items()} + if self.head96 + else None + ) + arm_state: Optional[Dict[int, Any]] + if self._resource_pickups: + arm_state = {} + for arm_id, pickup in self._resource_pickups.items(): + if pickup is not None: + res = pickup.resource + arm_entry: Dict[str, Any] = { + "has_resource": True, + "resource_name": res.name, + "resource_type": type(res).__name__, + "direction": pickup.direction.name, + "pickup_distance_from_top": pickup.pickup_distance_from_top, + "size_x": res.get_size_x(), + "size_y": res.get_size_y(), + "size_z": res.get_size_z(), + } + if hasattr(res, "num_items_x"): + arm_entry["num_items_x"] = res.num_items_x + if hasattr(res, "num_items_y"): + arm_entry["num_items_y"] = res.num_items_y + arm_state[arm_id] = arm_entry + else: + arm_state[arm_id] = None + else: + arm_state = None + return {"head_state": head_state, "head96_state": head96_state, "arm_state": arm_state} def load_state(self, state: Dict[str, Any]): """Load the liquid handler state from a file. Use :meth:`~Resource.load_all_state` to load the @@ -177,6 +228,14 @@ def load_state(self, state: Dict[str, Any]): for channel, tracker_state in head_state.items(): self.head[channel].load_state(tracker_state) + head96_state = state.get("head96_state", {}) + if head96_state and self.head96: + for channel, tracker_state in head96_state.items(): + self.head96[channel].load_state(tracker_state) + + # arm_state is informational only (read via serialize_state); no load needed since + # _resource_pickup is set/cleared by pick_up_resource/drop_resource at runtime. + def update_head_state(self, state: Dict[int, Optional[Tip]]): """Update the state of the liquid handler head. @@ -1964,6 +2023,9 @@ async def pick_up_resource( direction=direction, ) + if self.setup_finished and not self._resource_pickups: + raise RuntimeError("No robotic arm is installed on this liquid handler.") + if pickup_distance_from_top is None: if resource.preferred_pickup_location is not None: logger.debug( @@ -2001,6 +2063,8 @@ async def pick_up_resource( self._resource_pickup = None raise e + self._state_updated() + async def move_picked_up_resource( self, to: Coordinate, @@ -2166,6 +2230,7 @@ async def drop_resource( result = await self.backend.drop_resource(drop=drop, **backend_kwargs) self._resource_pickup = None + self._state_updated() # we rotate the resource on top of its original rotation. So in order to set the new rotation, # we have to subtract its current rotation. diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index 41431412035..f97d7d1eeae 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -66,6 +66,8 @@ def _create_mock_backend(num_channels: int = 8): """Create a mock LiquidHandlerBackend with the specified number of channels.""" mock = unittest.mock.create_autospec(LiquidHandlerBackend, instance=True) type(mock).num_channels = PropertyMock(return_value=num_channels) + mock.num_arms = 1 + mock.core96_head_installed = True mock.can_pick_up_tip.return_value = True return mock diff --git a/pylabrobot/resources/tip_tracker.py b/pylabrobot/resources/tip_tracker.py index 95fbafd85a4..90da419da42 100644 --- a/pylabrobot/resources/tip_tracker.py +++ b/pylabrobot/resources/tip_tracker.py @@ -111,6 +111,10 @@ def remove_tip(self, commit: bool = False) -> None: def commit(self) -> None: """Commit the pending operations.""" self._tip = self._pending_tip + # Propagate state-update callback to the tip's volume tracker so that + # aspirate/dispense volume changes trigger a visualizer refresh. + if self._tip is not None and self._callback is not None: + self._tip.tracker.register_callback(self._callback) if self._callback is not None: self._callback() diff --git a/pylabrobot/server/liquid_handling_api_tests.py b/pylabrobot/server/liquid_handling_api_tests.py index e8df482ace4..5e565de5f55 100644 --- a/pylabrobot/server/liquid_handling_api_tests.py +++ b/pylabrobot/server/liquid_handling_api_tests.py @@ -26,6 +26,8 @@ def _create_mock_backend(num_channels: int = 8): """Create a mock LiquidHandlerBackend with the specified number of channels.""" mock = unittest.mock.create_autospec(LiquidHandlerBackend, instance=True) type(mock).num_channels = PropertyMock(return_value=num_channels) + mock.num_arms = 1 + mock.core96_head_installed = True mock.can_pick_up_tip.return_value = True return mock