Skip to content

Commit 2878fc3

Browse files
Add to_dict and from_dict methods to Labware
1 parent 30712bc commit 2878fc3

3 files changed

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

373471
class Trough(Labware):
374472
"""Special type of labware that can be accessed by many pipette tips in parallel."""
@@ -428,3 +526,14 @@ def __init__(
428526
virtual_rows=virtual_rows,
429527
component_names=component_names,
430528
)
529+
530+
@classmethod
531+
def _from_dict_init_kwargs(cls, data: Mapping[str, Any]) -> Dict[str, Any]:
532+
"""Extract initializer parameters from a dict created by :meth:`Labware.to_dict()`.
533+
534+
Overrides the superclass method to account for the different initializer signature of troughs.
535+
"""
536+
lware_kwargs = Labware._from_dict_init_kwargs(data)
537+
lware_kwargs.pop("rows")
538+
lware_kwargs["virtual_rows"] = data["virtual_rows"]
539+
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+
with pytest.warns(UserWarning, match="downcasting"):
110+
restored = Labware.from_dict(data)
111+
assert isinstance(restored, Trough)
112+
113+
assert_labware_equal(orig, restored)
114+
pass
115+
116+
117+
def test_from_dict_warns_about_downcasting_trough():
118+
class MyTrough(Trough):
119+
pass
120+
121+
# create a labware and change it's state
122+
orig = MyTrough(
123+
"buffers",
124+
8,
125+
2,
126+
min_volume=30_000,
127+
max_volume=200_000,
128+
initial_volumes=[100_000, 50_000],
129+
column_names=["left", "right"],
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 = Labware.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)