Skip to content

Commit 7b7a707

Browse files
authored
Merge branch 'main' into codex/make-pylibftdi-optional-import
2 parents 23cca11 + 09c196f commit 7b7a707

File tree

2 files changed

+93
-9
lines changed

2 files changed

+93
-9
lines changed

pylabrobot/liquid_handling/liquid_handler.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from pylabrobot.resources.errors import CrossContaminationError, HasTipError
6262
from pylabrobot.resources.liquid import Liquid
6363
from pylabrobot.resources.rotation import Rotation
64+
from pylabrobot.serializer import deserialize, serialize
6465
from pylabrobot.tilting.tilter import Tilter
6566

6667
from .backends import LiquidHandlerBackend
@@ -118,12 +119,18 @@ class LiquidHandler(Resource, Machine):
118119
defined in `pyhamilton.liquid_handling.backends`) to communicate with the liquid handler.
119120
"""
120121

121-
def __init__(self, backend: LiquidHandlerBackend, deck: Deck):
122+
def __init__(
123+
self,
124+
backend: LiquidHandlerBackend,
125+
deck: Deck,
126+
default_offset_head96: Optional[Coordinate] = None,
127+
):
122128
"""Initialize a LiquidHandler.
123129
124130
Args:
125131
backend: Backend to use.
126132
deck: Deck to use.
133+
default_offset_head96: Base offset applied to all 96-head operations.
127134
"""
128135

129136
Resource.__init__(
@@ -149,6 +156,10 @@ def __init__(self, backend: LiquidHandlerBackend, deck: Deck):
149156

150157
self._blow_out_air_volume: Optional[List[Optional[float]]] = None
151158

159+
# Default offset applied to all 96-head operations. Any offset passed to a 96-head method is
160+
# added to this value.
161+
self.default_offset_head96: Coordinate = default_offset_head96 or Coordinate.zero()
162+
152163
# assign deck as only child resource, and set location of self to origin.
153164
self.location = Coordinate.zero()
154165
super().assign_child_resource(deck, location=deck.location or Coordinate.zero())
@@ -1339,10 +1350,13 @@ async def pick_up_tips96(
13391350
13401351
Args:
13411352
tip_rack: The tip rack to pick up tips from.
1342-
offset: The offset to use when picking up tips, optional.
1353+
offset: Additional offset to use when picking up tips. This is added to
1354+
:attr:`default_offset_head96`.
13431355
backend_kwargs: Additional keyword arguments for the backend, optional.
13441356
"""
13451357

1358+
offset = self.default_offset_head96 + offset
1359+
13461360
self._log_command(
13471361
"pick_up_tips96",
13481362
tip_rack=tip_rack,
@@ -1406,13 +1420,16 @@ async def drop_tips96(
14061420
14071421
Args:
14081422
resource: The tip rack to drop tips to.
1409-
offset: The offset to use when dropping tips.
1423+
offset: Additional offset to use when dropping tips. This is added to
1424+
:attr:`default_offset_head96`.
14101425
allow_nonzero_volume: If `True`, the tip will be dropped even if its volume is not zero (there
14111426
is liquid in the tip). If `False`, a RuntimeError will be raised if the tip has nonzero
14121427
volume.
14131428
backend_kwargs: Additional keyword arguments for the backend, optional.
14141429
"""
14151430

1431+
offset = self.default_offset_head96 + offset
1432+
14161433
self._log_command(
14171434
"drop_tips96",
14181435
resource=resource,
@@ -1566,7 +1583,8 @@ async def aspirate96(
15661583
resource (Union[Plate, Container, List[Well]]): Resource object or list of wells.
15671584
volume (float): The volume to aspirate through each channel
15681585
offset (Coordinate): Adjustment to where the 96 head should go to aspirate relative to where
1569-
the plate or container is defined to be. Defaults to Coordinate.zero().
1586+
the plate or container is defined to be. Added to :attr:`default_offset_head96`.
1587+
Defaults to :func:`Coordinate.zero`.
15701588
flow_rate ([Optional[float]]): The flow rate to use when aspirating, in ul/s. If `None`, the
15711589
backend default will be used.
15721590
liquid_height ([Optional[float]]): The height of the liquid in the well wrt the bottom, in
@@ -1576,6 +1594,8 @@ async def aspirate96(
15761594
backend_kwargs: Additional keyword arguments for the backend, optional.
15771595
"""
15781596

1597+
offset = self.default_offset_head96 + offset
1598+
15791599
self._log_command(
15801600
"aspirate96",
15811601
resource=resource,
@@ -1684,7 +1704,7 @@ async def aspirate96(
16841704

16851705
try:
16861706
await self.backend.aspirate96(aspiration=aspiration, **backend_kwargs)
1687-
except Exception as error:
1707+
except Exception:
16881708
for channel in self.head96.values():
16891709
channel.get_tip().tracker.rollback()
16901710
for container in containers:
@@ -1719,7 +1739,8 @@ async def dispense96(
17191739
resource (Union[Plate, Container, List[Well]]): Resource object or list of wells.
17201740
volume (float): The volume to dispense through each channel
17211741
offset (Coordinate): Adjustment to where the 96 head should go to aspirate relative to where
1722-
the plate or container is defined to be. Defaults to Coordinate.zero().
1742+
the plate or container is defined to be. Added to :attr:`default_offset_head96`.
1743+
Defaults to :func:`Coordinate.zero`.
17231744
flow_rate ([Optional[float]]): The flow rate to use when dispensing, in ul/s. If `None`, the
17241745
backend default will be used.
17251746
liquid_height ([Optional[float]]): The height of the liquid in the well wrt the bottom, in
@@ -1729,6 +1750,8 @@ async def dispense96(
17291750
backend_kwargs: Additional keyword arguments for the backend, optional.
17301751
"""
17311752

1753+
offset = self.default_offset_head96 + offset
1754+
17321755
self._log_command(
17331756
"dispense96",
17341757
resource=resource,
@@ -1829,7 +1852,7 @@ async def dispense96(
18291852

18301853
try:
18311854
await self.backend.dispense96(dispense=dispense, **backend_kwargs)
1832-
except Exception as error:
1855+
except Exception:
18331856
for channel in self.head96.values():
18341857
channel.get_tip().tracker.rollback()
18351858
for container in containers:
@@ -2325,7 +2348,11 @@ async def move_plate(
23252348
)
23262349

23272350
def serialize(self):
2328-
return {**Resource.serialize(self), **Machine.serialize(self)}
2351+
return {
2352+
**Resource.serialize(self),
2353+
**Machine.serialize(self),
2354+
"default_offset_head96": serialize(self.default_offset_head96),
2355+
}
23292356

23302357
@classmethod
23312358
def deserialize(cls, data: dict, allow_marshal: bool = False) -> LiquidHandler:
@@ -2338,7 +2365,18 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> LiquidHandler:
23382365
deck_data = data["children"][0]
23392366
deck = Deck.deserialize(data=deck_data, allow_marshal=allow_marshal)
23402367
backend = LiquidHandlerBackend.deserialize(data=data["backend"])
2341-
return cls(deck=deck, backend=backend)
2368+
2369+
if "default_offset_head96" in data:
2370+
default_offset = deserialize(data["default_offset_head96"], allow_marshal=allow_marshal)
2371+
assert isinstance(default_offset, Coordinate)
2372+
else:
2373+
default_offset = Coordinate.zero()
2374+
2375+
return cls(
2376+
deck=deck,
2377+
backend=backend,
2378+
default_offset_head96=default_offset,
2379+
)
23422380

23432381
@classmethod
23442382
def load(cls, path: str) -> LiquidHandler:

pylabrobot/liquid_handling/liquid_handler_tests.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
set_volume_tracking,
4343
)
4444
from pylabrobot.resources.well import Well
45+
from pylabrobot.serializer import serialize
4546

4647
from . import backends
4748
from .liquid_handler import LiquidHandler
@@ -491,6 +492,51 @@ async def test_offsets_tips(self):
491492
},
492493
)
493494

495+
async def test_default_offset_head96(self):
496+
self.lh.default_offset_head96 = Coordinate(1, 2, 3)
497+
498+
await self.lh.pick_up_tips96(self.tip_rack)
499+
cmd = self.get_first_command("pick_up_tips96")
500+
self.assertIsNotNone(cmd)
501+
self.assertEqual(cmd["kwargs"]["pickup"].offset, Coordinate(1, 2, 3)) # type: ignore
502+
self.backend.clear()
503+
504+
# aspirate with extra offset; effective offset should be default + provided
505+
await self.lh.aspirate96(self.plate, volume=10, offset=Coordinate(1, 0, 0))
506+
cmd = self.get_first_command("aspirate96")
507+
self.assertIsNotNone(cmd)
508+
self.assertEqual(cmd["kwargs"]["aspiration"].offset, Coordinate(2, 2, 3)) # type: ignore
509+
self.backend.clear()
510+
511+
# dispense without providing offset uses default
512+
await self.lh.dispense96(self.plate, volume=10)
513+
cmd = self.get_first_command("dispense96")
514+
self.assertIsNotNone(cmd)
515+
self.assertEqual(cmd["kwargs"]["dispense"].offset, Coordinate(1, 2, 3)) # type: ignore
516+
self.backend.clear()
517+
518+
await self.lh.drop_tips96(self.tip_rack, offset=Coordinate(0, 1, 0))
519+
cmd = self.get_first_command("drop_tips96")
520+
self.assertIsNotNone(cmd)
521+
self.assertEqual(cmd["kwargs"]["drop"].offset, Coordinate(1, 3, 3)) # type: ignore
522+
523+
async def test_default_offset_head96_initializer(self):
524+
backend = backends.SaverBackend(num_channels=8)
525+
deck = STARLetDeck()
526+
lh = LiquidHandler(
527+
backend=backend,
528+
deck=deck,
529+
default_offset_head96=Coordinate(1, 2, 3),
530+
)
531+
self.assertEqual(lh.default_offset_head96, Coordinate(1, 2, 3))
532+
533+
async def test_default_offset_head96_serialization(self):
534+
self.lh.default_offset_head96 = Coordinate(1, 2, 3)
535+
data = self.lh.serialize()
536+
self.assertEqual(data["default_offset_head96"], serialize(Coordinate(1, 2, 3)))
537+
new_lh = LiquidHandler.deserialize(data)
538+
self.assertEqual(new_lh.default_offset_head96, Coordinate(1, 2, 3))
539+
494540
async def test_with_use_channels(self):
495541
tip_spot = self.tip_rack.get_item("A1")
496542
tip = tip_spot.get_tip()

0 commit comments

Comments
 (0)