Skip to content

Commit 00aa3a7

Browse files
Add to_dict and from_dict methods to Labware
1 parent 30712bc commit 00aa3a7

3 files changed

Lines changed: 265 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "robotools"
7-
version = "1.14.0"
7+
version = "1.15.0"
88
description = "Pythonic in-silico liquid handling and creation of Tecan FreedomEVO worklists."
99
readme = "README.md"
1010
requires-python = ">=3.10"

robotools/liquidhandling/labware.py

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import logging
55
import warnings
6-
from typing import Dict, List, Literal, Mapping, Optional, Sequence, Tuple, Union
6+
from typing import Any, Dict, List, Literal, Mapping, Optional, Sequence, Tuple, Union
77

88
import numpy as np
99

@@ -369,6 +369,105 @@ def __repr__(self) -> str:
369369
def __str__(self) -> str:
370370
return self.__repr__()
371371

372+
def to_dict(self) -> dict[str, Any]:
373+
"""Serialize to a dict of only atomic data types.
374+
375+
Use :meth:`Labware.from_dict()` to recreate a labware from the dict produced by this method.
376+
"""
377+
return {
378+
"name": self.name,
379+
"rows": self.n_rows,
380+
"columns": self.n_columns,
381+
"min_volume": self.min_volume,
382+
"max_volume": self.max_volume,
383+
"volumes": self.volumes.tolist(),
384+
"history": [h.tolist() for h in self._history],
385+
"labels": self._labels,
386+
"composition": {k: v.tolist() for k, v in self.composition.items()},
387+
"is_trough": self.is_trough,
388+
"virtual_rows": self.virtual_rows,
389+
}
390+
391+
@classmethod
392+
def _from_dict_init_kwargs(cls, data: Mapping[str, Any]) -> Dict[str, Any]:
393+
"""Extract initializer parameters from a dict created by :meth:`Labware.to_dict()`."""
394+
return {
395+
"name": data["name"],
396+
"rows": data["rows"],
397+
"columns": data["columns"],
398+
"min_volume": data["min_volume"],
399+
"max_volume": data["max_volume"],
400+
}
401+
402+
@classmethod
403+
def _from_dict_attributes(cls, data: Mapping[str, Any], vshape: tuple[int, int]) -> Dict[str, Any]:
404+
"""Extracts state attributes from a dict created by :meth:`Labware.to_dict()`.
405+
406+
Parameters
407+
----------
408+
data
409+
Dict created by :meth:`Labware.to_dict()`.
410+
vshape
411+
Shape of the volumes array.
412+
413+
Returns
414+
-------
415+
Dict of attribute names and values to be assigned to the labware after initialization.
416+
"""
417+
418+
def as_2d(arr):
419+
return np.asarray(arr, dtype=float).reshape(vshape)
420+
421+
return {
422+
"_volumes": as_2d(data["volumes"]),
423+
"_history": [as_2d(h) for h in data["history"]],
424+
"_labels": data["labels"],
425+
"_composition": {k: as_2d(v) for k, v in data["composition"].items()},
426+
}
427+
428+
@classmethod
429+
def from_dict(cls, data: Mapping[str, Any], cls_trough: type | None = None) -> "Labware | Trough":
430+
"""Creates a labware from a dict produced by :meth:`Labware.to_dict()`.
431+
432+
Parameters
433+
----------
434+
data
435+
Dict created by :meth:`Labware.to_dict()`.
436+
cls_trough
437+
Optional trough type to use in case the data dict corresponds to a trough labware.
438+
Defaults to :class:`Trough` with a warning if the main class is not a `Trough` itself.
439+
"""
440+
# First we must determine the type to be instantiated.
441+
# If the data corresponds to a trough, we want to create a trough object, which is OK with the return type hint,
442+
# because Trough is a subclass of Labware. Therefore Labware.from_dict can be used to restore Troughs as well.
443+
# We allow the caller to specify a derived trough class, or default to the Trough class in this module.
444+
# This is useful when the actual type used by the caller is a custom subclass of Labware/Trough.
445+
_cls: type[Labware] | type[Trough]
446+
if data.get("is_trough", False) and not issubclass(cls, Trough):
447+
if cls_trough is None:
448+
if cls is not Labware:
449+
warnings.warn(
450+
"Data corresponds to a trough, but no trough type was given."
451+
" Defaulting to `robotools.Trough`."
452+
f" Specify a trough type to avoid downcasting {cls} to {Trough}.",
453+
UserWarning,
454+
stacklevel=2,
455+
)
456+
_cls = Trough
457+
else:
458+
_cls = cls_trough
459+
else:
460+
_cls = cls
461+
462+
# Now that we have the type we can proceed with extraction of initializer parameters and state attributes from the dict.
463+
init_kwargs = _cls._from_dict_init_kwargs(data)
464+
lware = _cls(**init_kwargs)
465+
attributes = _cls._from_dict_attributes(data, lware.volumes.shape)
466+
# assign state attributes
467+
for aname, avalue in attributes.items():
468+
setattr(lware, aname, avalue)
469+
return lware
470+
372471

373472
class Trough(Labware):
374473
"""Special type of labware that can be accessed by many pipette tips in parallel."""
@@ -428,3 +527,14 @@ def __init__(
428527
virtual_rows=virtual_rows,
429528
component_names=component_names,
430529
)
530+
531+
@classmethod
532+
def _from_dict_init_kwargs(cls, data: Mapping[str, Any]) -> Dict[str, Any]:
533+
"""Extract initializer parameters from a dict created by :meth:`Labware.to_dict()`.
534+
535+
Overrides the superclass method to account for the different initializer signature of troughs.
536+
"""
537+
lware_kwargs = Labware._from_dict_init_kwargs(data)
538+
lware_kwargs.pop("rows")
539+
lware_kwargs["virtual_rows"] = data["virtual_rows"]
540+
return lware_kwargs
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import warnings
2+
3+
import numpy as np
4+
import pytest
5+
6+
from robotools import Labware, Trough
7+
8+
9+
def assert_labware_equal(original: Labware, restored: Labware):
10+
assert isinstance(restored, Labware)
11+
assert len(restored.history) == len(original.history)
12+
# compare history entries
13+
for (olabel, ovols), (rlabel, rvols) in zip(original.history, restored.history, strict=True):
14+
assert rlabel == olabel
15+
np.testing.assert_array_equal(ovols, rvols)
16+
# compare composition of all wells
17+
for w in original.wells.flatten():
18+
assert original.get_well_composition(w) == restored.get_well_composition(w)
19+
return
20+
21+
22+
def test_labware_to_dict_from_dict():
23+
# create a labware and change it's state
24+
mtp = Labware(
25+
"emtepe",
26+
2,
27+
3,
28+
min_volume=30,
29+
max_volume=240,
30+
initial_volumes=50,
31+
)
32+
mtp.add("B02", 50, label="add toxic stuff", compositions=[{"toxin": 0.5, "water": 0.5}])
33+
34+
# encode and recreate
35+
mtp_dict = mtp.to_dict()
36+
# the dict must not contain numpy arrays
37+
# that's why we can equals compare it here.
38+
assert mtp_dict == {
39+
"name": "emtepe",
40+
"rows": 2,
41+
"columns": 3,
42+
"min_volume": 30,
43+
"max_volume": 240,
44+
"volumes": [[50.0, 50.0, 50.0], [50.0, 100.0, 50.0]],
45+
"history": [[[50.0, 50.0, 50.0], [50.0, 50.0, 50.0]], [[50.0, 50.0, 50.0], [50.0, 100.0, 50.0]]],
46+
"labels": ["initial", "add toxic stuff"],
47+
"composition": {
48+
"emtepe.A01": [[1.0, 0.0, 0.0], [0.0, 0.0, 0.0]],
49+
"emtepe.A02": [[0.0, 1.0, 0.0], [0.0, 0.0, 0.0]],
50+
"emtepe.A03": [[0.0, 0.0, 1.0], [0.0, 0.0, 0.0]],
51+
"emtepe.B01": [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]],
52+
"emtepe.B02": [[0.0, 0.0, 0.0], [0.0, 0.5, 0.0]],
53+
"emtepe.B03": [[0.0, 0.0, 0.0], [0.0, 0.0, 1.0]],
54+
"toxin": [[0.0, 0.0, 0.0], [0.0, 0.25, 0.0]],
55+
"water": [[0.0, 0.0, 0.0], [0.0, 0.25, 0.0]],
56+
},
57+
"is_trough": False,
58+
"virtual_rows": None,
59+
}
60+
61+
restored = Labware.from_dict(mtp_dict)
62+
63+
assert_labware_equal(mtp, restored)
64+
assert isinstance(mtp, Labware)
65+
pass
66+
67+
68+
def test_trough_to_dict_from_dict():
69+
# create a labware and change it's state
70+
orig = Trough(
71+
"buffers",
72+
8,
73+
2,
74+
min_volume=30_000,
75+
max_volume=200_000,
76+
initial_volumes=[100_000, 50_000],
77+
column_names=["left", "right"],
78+
)
79+
orig.add("A01", 50, label="add toxic stuff", compositions=[{"toxin": 0.5, "water": 0.5}])
80+
81+
# encode and recreate
82+
assert orig.is_trough
83+
data = orig.to_dict()
84+
assert data["is_trough"] is True
85+
restored = Trough.from_dict(data)
86+
assert isinstance(restored, Trough)
87+
88+
assert_labware_equal(orig, restored)
89+
pass
90+
91+
92+
def test_labware_from_dict_can_return_troughs():
93+
# create a labware and change it's state
94+
orig = Trough(
95+
"buffers",
96+
8,
97+
2,
98+
min_volume=30_000,
99+
max_volume=200_000,
100+
initial_volumes=[100_000, 50_000],
101+
column_names=["left", "right"],
102+
)
103+
orig.add("A01", 50, label="add toxic stuff", compositions=[{"toxin": 0.5, "water": 0.5}])
104+
105+
# encode and recreate
106+
assert orig.is_trough
107+
data = orig.to_dict()
108+
assert data["is_trough"] is True
109+
restored = Labware.from_dict(data)
110+
assert isinstance(restored, Trough)
111+
112+
assert_labware_equal(orig, restored)
113+
pass
114+
115+
116+
def test_from_dict_warns_about_downcasting_trough():
117+
class MyLabware(Labware):
118+
pass
119+
120+
class MyTrough(MyLabware, Trough):
121+
pass
122+
123+
# create a labware and change it's state
124+
orig = MyTrough(
125+
"buffers",
126+
8,
127+
2,
128+
min_volume=30_000,
129+
max_volume=200_000,
130+
)
131+
orig.add("A01", 50, label="add toxic stuff", compositions=[{"toxin": 0.5, "water": 0.5}])
132+
133+
# encode and recreate
134+
assert orig.is_trough
135+
data = orig.to_dict()
136+
assert data["is_trough"] is True
137+
138+
# robotools doesn't know about MyTrough, so we'll get a Trough with a warning
139+
with pytest.warns(UserWarning, match="downcasting"):
140+
restored = MyLabware.from_dict(data)
141+
# instantiation worked, but the type was downcasted
142+
assert isinstance(restored, Trough)
143+
# we can avoid downcasting by passing the trough type explicitly
144+
with warnings.catch_warnings():
145+
warnings.simplefilter("error")
146+
restored2 = Labware.from_dict(data, cls_trough=MyTrough)
147+
assert isinstance(restored2, MyTrough)
148+
# or by calling through the custom type
149+
with warnings.catch_warnings():
150+
warnings.simplefilter("error")
151+
restored3 = MyTrough.from_dict(data)
152+
assert isinstance(restored3, MyTrough)
153+
pass

0 commit comments

Comments
 (0)