From e94e945d1549299c274844ac4c5eb7f0a02a6347 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Thu, 29 Jan 2026 23:21:23 +0000 Subject: [PATCH 1/6] Fix aspirate96 missing List[Well] branch and add dispense96 well list tests aspirate96 accepted List[Well] in its type check but never assigned it to `containers`, causing an UnboundLocalError. Added the missing `else` branch to match dispense96. Added tests for dispense96 with a 384-well plate quadrant: backend call verification, wrong well count validation, mixed parent validation, and volume tracking. Co-Authored-By: Claude Opus 4.5 --- pylabrobot/liquid_handling/liquid_handler.py | 4 ++ .../liquid_handling/liquid_handler_tests.py | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index c9d031c06c0..dc7e02b14b1 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -1677,6 +1677,8 @@ async def aspirate96( containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] elif isinstance(resource, Container): containers = [resource] + else: # List[Well] + containers = resource if len(containers) == 1: # single container container = containers[0] @@ -1818,6 +1820,8 @@ async def dispense96( containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] elif isinstance(resource, Container): containers = [resource] + else: # List[Well] + containers = resource # if we have enough liquid in the tip, remove it from the tip tracker for accounting. # if we do not (for example because the plunger was up on tip pickup), and we diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index fa9f9664939..287af6f1ab5 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -31,6 +31,7 @@ no_tip_tracking, set_tip_tracking, ) +from pylabrobot.resources.revvity.plates import Revvity_384_wellplate_28ul_Ub from pylabrobot.resources.carrier import PlateHolder from pylabrobot.resources.errors import ( HasTipError, @@ -616,6 +617,42 @@ async def test_aspirate_dispense96(self): ) ) + async def test_dispense96_with_well_list(self): + plate_384 = Revvity_384_wellplate_28ul_Ub(name="plate_384") + self.deck.assign_child_resource(plate_384, location=Coordinate(400, 100, 0)) + quadrant_wells = plate_384.get_quadrant("tl") + + await self.lh.pick_up_tips96(self.tip_rack) + await self.lh.aspirate96(self.plate, volume=10) + + self.lh.backend.dispense96 = unittest.mock.AsyncMock() # type: ignore + await self.lh.dispense96(quadrant_wells, 10) + self.lh.backend.dispense96.assert_called_with( # type: ignore + dispense=MultiHeadDispensePlate( + wells=quadrant_wells, + offset=Coordinate.zero(), + tips=[self.lh.head96[i].get_tip() for i in range(96)], + volume=10, + flow_rate=None, + liquid_height=None, + blow_out_air_volume=None, + mix=None, + ) + ) + + async def test_dispense96_well_list_wrong_count(self): + await self.lh.pick_up_tips96(self.tip_rack) + with self.assertRaises(ValueError, msg="dispense96 expects 96 wells"): + await self.lh.dispense96(self.plate.get_all_items()[:48], 10) + + async def test_dispense96_well_list_mixed_parents(self): + plate2 = Cor_96_wellplate_360ul_Fb(name="plate2") + self.deck.assign_child_resource(plate2, location=Coordinate(400, 100, 0)) + mixed = self.plate.get_all_items()[:48] + plate2.get_all_items()[:48] + await self.lh.pick_up_tips96(self.tip_rack) + with self.assertRaises(ValueError, msg="All wells must be in the same plate"): + await self.lh.dispense96(mixed, 10) + async def test_transfer(self): t = self.tip_rack.get_item("A1").get_tip() self.lh.update_head_state({0: t}) @@ -1048,3 +1085,20 @@ async def test_96_head_volume_tracking_single_container(self): assert well.tracker.get_used_volume() == 10 * 96 await self.lh.return_tips96() + + async def test_96_head_volume_tracking_well_list(self): + plate_384 = Revvity_384_wellplate_28ul_Ub(name="plate_384") + self.deck.assign_child_resource(plate_384, location=Coordinate(600, 100, 0)) + quadrant_wells = plate_384.get_quadrant("tl") + for well in quadrant_wells: + well.tracker.set_volume(10) + + await self.lh.pick_up_tips96(self.tip_rack) + await self.lh.aspirate96(quadrant_wells, volume=10) + assert all(self.lh.head96[i].get_tip().tracker.get_used_volume() == 10 for i in range(96)) + assert all(w.tracker.get_used_volume() == 0 for w in quadrant_wells) + + await self.lh.dispense96(quadrant_wells, volume=10) + assert all(self.lh.head96[i].get_tip().tracker.get_used_volume() == 0 for i in range(96)) + assert all(w.tracker.get_used_volume() == 10 for w in quadrant_wells) + await self.lh.return_tips96() From d296d28c117dff2276e436fe1c038e1faed9ce87 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 30 Jan 2026 10:18:39 +0000 Subject: [PATCH 2/6] explicit is better than implicit --- pylabrobot/liquid_handling/liquid_handler.py | 9 +++++++-- pylabrobot/liquid_handling/liquid_handler_tests.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index dc7e02b14b1..2716a11bb45 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -1816,12 +1816,17 @@ async def dispense96( containers: Sequence[Container] if isinstance(resource, Plate): if resource.has_lid(): - raise ValueError("Dispensing to plate with lid") + raise ValueError("Dispensing to plate with lid is not possible. Remove the lid first.") containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] elif isinstance(resource, Container): containers = [resource] - else: # List[Well] + elif isinstance(resource, list) and all(isinstance(w, Well) for w in resource): containers = resource + else: + raise TypeError( + f"Resource must be a Plate, Container, or list of Wells, got {type(resource)} " + f" for {resource}" + ) # if we have enough liquid in the tip, remove it from the tip tracker for accounting. # if we do not (for example because the plunger was up on tip pickup), and we diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index 287af6f1ab5..f284c31fc1d 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -31,7 +31,6 @@ no_tip_tracking, set_tip_tracking, ) -from pylabrobot.resources.revvity.plates import Revvity_384_wellplate_28ul_Ub from pylabrobot.resources.carrier import PlateHolder from pylabrobot.resources.errors import ( HasTipError, @@ -42,6 +41,7 @@ hamilton_96_tiprack_300uL_filter, hamilton_96_tiprack_1000uL_filter, ) +from pylabrobot.resources.revvity.plates import Revvity_384_wellplate_28ul_Ub from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.volume_tracker import ( set_volume_tracking, From 9aaee78985bfa4421a355dcc82fd57c46d35ec7d Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 30 Jan 2026 10:21:14 +0000 Subject: [PATCH 3/6] remove dispense96 test that enforces 96 well count --- pylabrobot/liquid_handling/liquid_handler_tests.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index f284c31fc1d..1c1545a4200 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -617,7 +617,7 @@ async def test_aspirate_dispense96(self): ) ) - async def test_dispense96_with_well_list(self): + async def test_dispense96_with_quadrant_well_list(self): plate_384 = Revvity_384_wellplate_28ul_Ub(name="plate_384") self.deck.assign_child_resource(plate_384, location=Coordinate(400, 100, 0)) quadrant_wells = plate_384.get_quadrant("tl") @@ -640,11 +640,6 @@ async def test_dispense96_with_well_list(self): ) ) - async def test_dispense96_well_list_wrong_count(self): - await self.lh.pick_up_tips96(self.tip_rack) - with self.assertRaises(ValueError, msg="dispense96 expects 96 wells"): - await self.lh.dispense96(self.plate.get_all_items()[:48], 10) - async def test_dispense96_well_list_mixed_parents(self): plate2 = Cor_96_wellplate_360ul_Fb(name="plate2") self.deck.assign_child_resource(plate2, location=Coordinate(400, 100, 0)) From ae82f39ff5465a8739482e9f079e9f5f1a601160 Mon Sep 17 00:00:00 2001 From: Camillo Moschner <122165124+BioCam@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:27:19 +0000 Subject: [PATCH 4/6] Update pylabrobot/liquid_handling/liquid_handler_tests.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- 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 1c1545a4200..c1121e88b57 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -645,7 +645,7 @@ async def test_dispense96_well_list_mixed_parents(self): self.deck.assign_child_resource(plate2, location=Coordinate(400, 100, 0)) mixed = self.plate.get_all_items()[:48] + plate2.get_all_items()[:48] await self.lh.pick_up_tips96(self.tip_rack) - with self.assertRaises(ValueError, msg="All wells must be in the same plate"): + with self.assertRaises(ValueError): await self.lh.dispense96(mixed, 10) async def test_transfer(self): From 8e742443ffe159dac2973ea13d70330f90ed738a Mon Sep 17 00:00:00 2001 From: Camillo Moschner <122165124+BioCam@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:27:28 +0000 Subject: [PATCH 5/6] Update pylabrobot/liquid_handling/liquid_handler.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pylabrobot/liquid_handling/liquid_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 2716a11bb45..3750c87574f 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -1825,7 +1825,7 @@ async def dispense96( else: raise TypeError( f"Resource must be a Plate, Container, or list of Wells, got {type(resource)} " - f" for {resource}" + f"for {resource}" ) # if we have enough liquid in the tip, remove it from the tip tracker for accounting. From 9b2c864c7ab7daa0bfc8fadf79e6d9a0df549d89 Mon Sep 17 00:00:00 2001 From: Camillo Moschner Date: Fri, 30 Jan 2026 10:30:13 +0000 Subject: [PATCH 6/6] harmonise aspirate96 and dispense96 --- pylabrobot/liquid_handling/liquid_handler.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 2716a11bb45..c003e891329 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -1677,8 +1677,13 @@ async def aspirate96( containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] elif isinstance(resource, Container): containers = [resource] - else: # List[Well] + elif isinstance(resource, list) and all(isinstance(w, Well) for w in resource): containers = resource + else: + raise TypeError( + f"Resource must be a Plate, Container, or list of Wells, got {type(resource)} " + f" for {resource}" + ) if len(containers) == 1: # single container container = containers[0]