From edc18f6f9adfecf96e16cc7d524f986a1938b0db Mon Sep 17 00:00:00 2001 From: Eric <113262615+ericguan04@users.noreply.github.com> Date: Sun, 8 Jun 2025 01:03:08 -0400 Subject: [PATCH 1/7] Fix issue with liquid tracking in aspirate96, dispense96, and enable/disable tracking functions --- pylabrobot/liquid_handling/liquid_handler.py | 26 +++++++++++++------- pylabrobot/resources/plate.py | 10 ++++++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 04cb2f16b1f..4265e2e6958 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -1617,15 +1617,16 @@ async def aspirate96( for well, channel in zip(containers, self.head96.values()): # superfluous to have append in two places but the type checker is very angry and does not # understand that Optional[Liquid] (remove_liquid) is the same as None from the first case - if well.tracker.is_disabled or not does_volume_tracking(): + if well.tracker.is_disabled: liquids = [(None, volume)] all_liquids.append(liquids) else: + # tracker is enabled: update tracker liquid history liquids = well.tracker.remove_liquid(volume=volume) # type: ignore all_liquids.append(liquids) - for liquid, vol in reversed(liquids): - channel.get_tip().tracker.add_liquid(liquid=liquid, volume=vol) + for liquid, vol in reversed(liquids): + channel.get_tip().tracker.add_liquid(liquid=liquid, volume=vol) aspiration = MultiHeadAspirationPlate( wells=cast(List[Well], containers), @@ -1760,15 +1761,22 @@ async def dispense96( if not len(containers) == 96: raise ValueError(f"dispense96 expects 96 wells, got {len(containers)}") - for channel, well in zip(self.head96.values(), containers): + for well, channel in zip(containers, self.head96.values()): # even if the volume tracker is disabled, a liquid (None, volume) is added to the list # during the aspiration command - liquids = channel.get_tip().tracker.remove_liquid(volume=volume) - reversed_liquids = list(reversed(liquids)) - all_liquids.append(reversed_liquids) - for liquid, vol in reversed_liquids: - well.tracker.add_liquid(liquid=liquid, volume=vol) + # --> but why? tracking even when tracking is disabled causes errors since tracking can be turned off for aspirate96 + # added tracking condition below: + if well.tracker.is_disabled: + reversed_liquids = [(None, volume)] + all_liquids.append(reversed_liquids) + else: + liquids = channel.get_tip().tracker.remove_liquid(volume=volume) + reversed_liquids = list(reversed(liquids)) + all_liquids.append(reversed_liquids) + + for liquid, vol in reversed_liquids: + well.tracker.add_liquid(liquid=liquid, volume=vol) dispense = MultiHeadDispensePlate( wells=cast(List[Well], containers), diff --git a/pylabrobot/resources/plate.py b/pylabrobot/resources/plate.py index dc50bcafa4f..cb74a9c6036 100644 --- a/pylabrobot/resources/plate.py +++ b/pylabrobot/resources/plate.py @@ -214,13 +214,19 @@ def disable_volume_trackers(self) -> None: """Disable volume tracking for all wells in the plate.""" for well in self.get_all_items(): - well.tracker.disable() + # get_all_items() returns all items, including the lid + # ignore the lid since it does not have a tracker + if not isinstance(well, Lid): + well.tracker.disable() def enable_volume_trackers(self) -> None: """Enable volume tracking for all wells in the plate.""" for well in self.get_all_items(): - well.tracker.enable() + # get_all_items() returns all items, including the lid + # ignore the lid since it does not have a tracker + if not isinstance(well, Lid): + well.tracker.enable() def get_quadrant( self, From fa7a26cc89f68007b5383c0f7ba72e4c073913b5 Mon Sep 17 00:00:00 2001 From: Eric <113262615+ericguan04@users.noreply.github.com> Date: Mon, 9 Jun 2025 10:10:05 -0400 Subject: [PATCH 2/7] Add back global volume tracking condition --- pylabrobot/liquid_handling/liquid_handler.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 4265e2e6958..7134eab38f8 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -1617,7 +1617,7 @@ async def aspirate96( for well, channel in zip(containers, self.head96.values()): # superfluous to have append in two places but the type checker is very angry and does not # understand that Optional[Liquid] (remove_liquid) is the same as None from the first case - if well.tracker.is_disabled: + if well.tracker.is_disabled or not does_volume_tracking(): liquids = [(None, volume)] all_liquids.append(liquids) else: @@ -1762,12 +1762,8 @@ async def dispense96( raise ValueError(f"dispense96 expects 96 wells, got {len(containers)}") for well, channel in zip(containers, self.head96.values()): - # even if the volume tracker is disabled, a liquid (None, volume) is added to the list - # during the aspiration command - - # --> but why? tracking even when tracking is disabled causes errors since tracking can be turned off for aspirate96 - # added tracking condition below: - if well.tracker.is_disabled: + # check if volume tracking is disabled + if well.tracker.is_disabled or not does_volume_tracking(): reversed_liquids = [(None, volume)] all_liquids.append(reversed_liquids) else: From 80b25af82596a2f2f5c1f0e18c2902aac50350be Mon Sep 17 00:00:00 2001 From: Eric <113262615+ericguan04@users.noreply.github.com> Date: Mon, 9 Jun 2025 11:02:12 -0400 Subject: [PATCH 3/7] Fix issue with ItemizedResource --- pylabrobot/resources/itemized_resource.py | 8 ++++++-- pylabrobot/resources/plate.py | 10 ++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/pylabrobot/resources/itemized_resource.py b/pylabrobot/resources/itemized_resource.py index 2e07aa2d06d..061f52b62c6 100644 --- a/pylabrobot/resources/itemized_resource.py +++ b/pylabrobot/resources/itemized_resource.py @@ -16,6 +16,7 @@ import pylabrobot.utils +from . import plate from .resource import Resource if sys.version_info >= (3, 8): @@ -200,7 +201,10 @@ def get_item(self, identifier: Union[str, int, Tuple[int, int]]) -> T: ) # Cast child to item type. Children will always be `T`, but the type checker doesn't know that. - return cast(T, self.children[identifier]) + if not isinstance( + self.children[identifier], plate.Lid + ): # can add more unwanted types here if needed later on + return cast(T, self.children[identifier]) def get_items(self, identifiers: Union[str, Sequence[int], Sequence[str]]) -> List[T]: """Get the items with the given identifier. @@ -228,7 +232,7 @@ def get_items(self, identifiers: Union[str, Sequence[int], Sequence[str]]) -> Li if isinstance(identifiers, str): identifiers = pylabrobot.utils.expand_string_range(identifiers) - return [self.get_item(i) for i in identifiers] + return [self.get_item(i) for i in identifiers if self.get_item(i) is not None] @property def num_items(self) -> int: diff --git a/pylabrobot/resources/plate.py b/pylabrobot/resources/plate.py index cb74a9c6036..dc50bcafa4f 100644 --- a/pylabrobot/resources/plate.py +++ b/pylabrobot/resources/plate.py @@ -214,19 +214,13 @@ def disable_volume_trackers(self) -> None: """Disable volume tracking for all wells in the plate.""" for well in self.get_all_items(): - # get_all_items() returns all items, including the lid - # ignore the lid since it does not have a tracker - if not isinstance(well, Lid): - well.tracker.disable() + well.tracker.disable() def enable_volume_trackers(self) -> None: """Enable volume tracking for all wells in the plate.""" for well in self.get_all_items(): - # get_all_items() returns all items, including the lid - # ignore the lid since it does not have a tracker - if not isinstance(well, Lid): - well.tracker.enable() + well.tracker.enable() def get_quadrant( self, From 4b0070b35b41b850887434edd28902fc885133e8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 9 Jun 2025 17:34:07 -0700 Subject: [PATCH 4/7] revert itemized_resource --- pylabrobot/resources/itemized_resource.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pylabrobot/resources/itemized_resource.py b/pylabrobot/resources/itemized_resource.py index 061f52b62c6..2e07aa2d06d 100644 --- a/pylabrobot/resources/itemized_resource.py +++ b/pylabrobot/resources/itemized_resource.py @@ -16,7 +16,6 @@ import pylabrobot.utils -from . import plate from .resource import Resource if sys.version_info >= (3, 8): @@ -201,10 +200,7 @@ def get_item(self, identifier: Union[str, int, Tuple[int, int]]) -> T: ) # Cast child to item type. Children will always be `T`, but the type checker doesn't know that. - if not isinstance( - self.children[identifier], plate.Lid - ): # can add more unwanted types here if needed later on - return cast(T, self.children[identifier]) + return cast(T, self.children[identifier]) def get_items(self, identifiers: Union[str, Sequence[int], Sequence[str]]) -> List[T]: """Get the items with the given identifier. @@ -232,7 +228,7 @@ def get_items(self, identifiers: Union[str, Sequence[int], Sequence[str]]) -> Li if isinstance(identifiers, str): identifiers = pylabrobot.utils.expand_string_range(identifiers) - return [self.get_item(i) for i in identifiers if self.get_item(i) is not None] + return [self.get_item(i) for i in identifiers] @property def num_items(self) -> int: From 7f4086ac6316e3b22d54316fb69b78009e55da10 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 9 Jun 2025 21:26:34 -0700 Subject: [PATCH 5/7] revert some, add liquid to wells in lh.dispense96 when enabled --- pylabrobot/liquid_handling/liquid_handler.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index c4acd0e3df0..9a942a10ba2 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -1625,8 +1625,8 @@ async def aspirate96( liquids = well.tracker.remove_liquid(volume=volume) # type: ignore all_liquids.append(liquids) - for liquid, vol in reversed(liquids): - channel.get_tip().tracker.add_liquid(liquid=liquid, volume=vol) + for liquid, vol in reversed(liquids): + channel.get_tip().tracker.add_liquid(liquid=liquid, volume=vol) aspiration = MultiHeadAspirationPlate( wells=cast(List[Well], containers), @@ -1762,15 +1762,13 @@ async def dispense96( raise ValueError(f"dispense96 expects 96 wells, got {len(containers)}") for well, channel in zip(containers, self.head96.values()): - # check if volume tracking is disabled - if well.tracker.is_disabled or not does_volume_tracking(): - reversed_liquids = [(None, volume)] - all_liquids.append(reversed_liquids) - else: - liquids = channel.get_tip().tracker.remove_liquid(volume=volume) - reversed_liquids = list(reversed(liquids)) - all_liquids.append(reversed_liquids) + # even if the volume tracker is disabled, a liquid (None, volume) is added to the list + # during the aspiration command + liquids = channel.get_tip().tracker.remove_liquid(volume=volume) + reversed_liquids = list(reversed(liquids)) + all_liquids.append(reversed_liquids) + if not well.tracker.is_disabled and does_volume_tracking(): for liquid, vol in reversed_liquids: well.tracker.add_liquid(liquid=liquid, volume=vol) From e28d08904cb9ab73753d34d2f58dab0bf050def0 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 9 Jun 2025 21:30:17 -0700 Subject: [PATCH 6/7] add test --- pylabrobot/liquid_handling/liquid_handler_tests.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index ed62c846308..f1624459435 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -1090,6 +1090,19 @@ async def error_func(*args, **kwargs): await self.lh.dispense([well], vols=[60]) # test volume doens't change on failed dispense assert self.lh.head[0].get_tip().tracker.get_used_volume() == 200 + + async def test_96_head_volume_tracking(self): + for item in self.plate.get_all_items(): + item.tracker.set_liquids([(Liquid.WATER, 10)]) + await self.lh.pick_up_tips96(self.tip_rack) + await self.lh.aspirate96(self.plate, volume=10) + for i in range(96): + self.assertEqual(self.lh.head96[i].get_tip().tracker.get_used_volume(), 10) + self.plate.get_item(i).tracker.get_used_volume() == 0 + await self.lh.dispense96(self.plate, volume=10) + for i in range(96): + self.assertEqual(self.lh.head96[i].get_tip().tracker.get_used_volume(), 0) + self.plate.get_item(i).tracker.get_used_volume() == 10 class TestLiquidHandlerCrossContaminationTracking(unittest.IsolatedAsyncioTestCase): From 1e9ee15edfb294a224e6966b75ead1939177fb09 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 9 Jun 2025 21:30:30 -0700 Subject: [PATCH 7/7] format --- pylabrobot/liquid_handling/liquid_handler_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index f1624459435..2bccd622b17 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -1090,7 +1090,7 @@ async def error_func(*args, **kwargs): await self.lh.dispense([well], vols=[60]) # test volume doens't change on failed dispense assert self.lh.head[0].get_tip().tracker.get_used_volume() == 200 - + async def test_96_head_volume_tracking(self): for item in self.plate.get_all_items(): item.tracker.set_liquids([(Liquid.WATER, 10)])