pyhte 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hte/__init__.py +46 -0
- hte/chemistry/__init__.py +0 -0
- hte/chemistry/composition.py +192 -0
- hte/chemistry/substances.py +142 -0
- hte/designs/__init__.py +0 -0
- hte/designs/base.py +184 -0
- hte/designs/titrations.py +106 -0
- hte/devices/__init__.py +0 -0
- hte/devices/base.py +82 -0
- hte/devices/jasco.py +216 -0
- hte/devices/ukrobotics.py +156 -0
- hte/exceptions.py +33 -0
- hte/execution/__init__.py +0 -0
- hte/execution/records.py +286 -0
- hte/execution/states.py +15 -0
- hte/labware/__init__.py +0 -0
- hte/labware/containers.py +63 -0
- hte/labware/layouts.py +76 -0
- hte/labware/plates.py +268 -0
- hte/measurements/__init__.py +0 -0
- hte/measurements/models.py +106 -0
- hte/operations/__init__.py +0 -0
- hte/operations/plans.py +96 -0
- hte/operations/state.py +200 -0
- hte/operations/transfers.py +67 -0
- hte/storage/__init__.py +0 -0
- hte/storage/database.py +711 -0
- hte/storage/schema.py +147 -0
- hte/tolerances.py +21 -0
- hte/units.py +36 -0
- pyhte-0.1.0.dist-info/METADATA +111 -0
- pyhte-0.1.0.dist-info/RECORD +35 -0
- pyhte-0.1.0.dist-info/WHEEL +5 -0
- pyhte-0.1.0.dist-info/licenses/LICENSE +674 -0
- pyhte-0.1.0.dist-info/top_level.txt +1 -0
hte/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""High-throughput experiments and laboratory automation.
|
|
2
|
+
|
|
3
|
+
The most used classes and functions can be imported directly from ``hte``, e.g. ``from hte import Plate``.
|
|
4
|
+
"""
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from hte.chemistry.composition import Composition
|
|
8
|
+
from hte.chemistry.substances import Substance
|
|
9
|
+
from hte.designs.titrations import TitrationDesign, TitrationSeries
|
|
10
|
+
from hte.devices.jasco import V750
|
|
11
|
+
from hte.devices.ukrobotics import D2
|
|
12
|
+
from hte.exceptions import HTEError, ValidationError
|
|
13
|
+
from hte.execution.records import Run, actual_state
|
|
14
|
+
from hte.execution.states import TransferStatus
|
|
15
|
+
from hte.labware.containers import Container
|
|
16
|
+
from hte.labware.layouts import PlateLayout
|
|
17
|
+
from hte.labware.plates import Plate, PlateType, Well
|
|
18
|
+
from hte.measurements.models import Measurement
|
|
19
|
+
from hte.operations.plans import Plan
|
|
20
|
+
from hte.operations.state import LabState
|
|
21
|
+
from hte.operations.transfers import Transfer
|
|
22
|
+
from hte.storage.database import Database
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Composition",
|
|
26
|
+
"Container",
|
|
27
|
+
"D2",
|
|
28
|
+
"Database",
|
|
29
|
+
"HTEError",
|
|
30
|
+
"LabState",
|
|
31
|
+
"Measurement",
|
|
32
|
+
"Plan",
|
|
33
|
+
"Plate",
|
|
34
|
+
"PlateLayout",
|
|
35
|
+
"PlateType",
|
|
36
|
+
"Run",
|
|
37
|
+
"Substance",
|
|
38
|
+
"TitrationDesign",
|
|
39
|
+
"TitrationSeries",
|
|
40
|
+
"Transfer",
|
|
41
|
+
"TransferStatus",
|
|
42
|
+
"V750",
|
|
43
|
+
"ValidationError",
|
|
44
|
+
"Well",
|
|
45
|
+
"actual_state",
|
|
46
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Contents of containers as solvent volumes and solute amounts."""
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import math
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from hte.chemistry.substances import Substance
|
|
9
|
+
from hte.exceptions import InsufficientVolumeError, ValidationError
|
|
10
|
+
from hte.tolerances import exceeds
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _add(first: Mapping[Substance, float], second: Mapping[Substance, float]) -> dict[Substance, float]:
|
|
14
|
+
total = dict(first)
|
|
15
|
+
for substance, quantity in second.items():
|
|
16
|
+
total[substance] = total.get(substance, 0.0) + quantity
|
|
17
|
+
return total
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, repr=False)
|
|
21
|
+
class Composition:
|
|
22
|
+
"""Contents of a container: the volume of each solvent (L) and the amount of each solute (mol).
|
|
23
|
+
|
|
24
|
+
Solvent volumes add up on mixing. Solutes, including solids, are regarded as fully dissolved in the total solvent
|
|
25
|
+
volume. Compositions are immutable: mixing and splitting return new compositions.
|
|
26
|
+
|
|
27
|
+
Attributes
|
|
28
|
+
----------
|
|
29
|
+
solvents
|
|
30
|
+
Volume of each solvent in L. Read-only; zero volumes are dropped.
|
|
31
|
+
solutes
|
|
32
|
+
Amount of each solute in mol. Read-only; zero amounts are dropped.
|
|
33
|
+
|
|
34
|
+
Raises
|
|
35
|
+
------
|
|
36
|
+
ValidationError
|
|
37
|
+
If a volume or amount is negative or not finite.
|
|
38
|
+
"""
|
|
39
|
+
solvents: Mapping[Substance, float] = field(default_factory=dict)
|
|
40
|
+
solutes: Mapping[Substance, float] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
for name, quantities in (("Solvent volumes", self.solvents), ("Solute amounts", self.solutes)):
|
|
44
|
+
if any(not math.isfinite(quantity) or quantity < 0 for quantity in quantities.values()):
|
|
45
|
+
raise ValidationError(f"{name} must be finite and non-negative")
|
|
46
|
+
# zero quantities are dropped, so an empty composition has no entries
|
|
47
|
+
object.__setattr__(self, "solvents", MappingProxyType({s: v for s, v in self.solvents.items() if v}))
|
|
48
|
+
object.__setattr__(self, "solutes", MappingProxyType({s: a for s, a in self.solutes.items() if a}))
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def solution(
|
|
52
|
+
cls,
|
|
53
|
+
solvent: Substance,
|
|
54
|
+
volume: float,
|
|
55
|
+
concentrations: Mapping[Substance, float]|None = None,
|
|
56
|
+
) -> Self:
|
|
57
|
+
"""Create a solution in a single solvent from the concentrations of its solutes.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
solvent
|
|
62
|
+
The solvent.
|
|
63
|
+
volume
|
|
64
|
+
Volume of the solvent in L.
|
|
65
|
+
concentrations
|
|
66
|
+
Concentration of each solute in mol/L, relative to ``volume``. Without concentrations, the composition
|
|
67
|
+
contains the pure solvent.
|
|
68
|
+
|
|
69
|
+
Returns
|
|
70
|
+
-------
|
|
71
|
+
Composition
|
|
72
|
+
The solution.
|
|
73
|
+
|
|
74
|
+
Raises
|
|
75
|
+
------
|
|
76
|
+
ValidationError
|
|
77
|
+
If the volume or a concentration is negative or not finite.
|
|
78
|
+
"""
|
|
79
|
+
concentrations = concentrations or {}
|
|
80
|
+
if any(not math.isfinite(concentration) or concentration < 0 for concentration in concentrations.values()):
|
|
81
|
+
raise ValidationError("Concentrations must be finite and non-negative")
|
|
82
|
+
return cls(
|
|
83
|
+
solvents={solvent: volume},
|
|
84
|
+
solutes={substance: concentration*volume for substance, concentration in concentrations.items()},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def volume(self) -> float:
|
|
89
|
+
"""Total volume of all solvents in L."""
|
|
90
|
+
return sum(self.solvents.values())
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def is_empty(self) -> bool:
|
|
94
|
+
"""Whether the composition contains neither solvent nor solute."""
|
|
95
|
+
return not self.solvents and not self.solutes
|
|
96
|
+
|
|
97
|
+
def concentration(self, substance: Substance) -> float:
|
|
98
|
+
"""Return the concentration of a solute in the total solvent volume.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
substance
|
|
103
|
+
The solute.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
float
|
|
108
|
+
Concentration in mol/L; 0 if the substance is not a solute of the composition.
|
|
109
|
+
|
|
110
|
+
Raises
|
|
111
|
+
------
|
|
112
|
+
ValidationError
|
|
113
|
+
If the substance is a solute but there is no solvent.
|
|
114
|
+
"""
|
|
115
|
+
amount = self.solutes.get(substance, 0.0)
|
|
116
|
+
if not amount:
|
|
117
|
+
return 0.0
|
|
118
|
+
if not self.volume:
|
|
119
|
+
raise ValidationError(f"The concentration of {substance.name} is undefined without solvent")
|
|
120
|
+
return amount / self.volume
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def concentrations(self) -> dict[Substance, float]:
|
|
124
|
+
"""Concentration of each solute in mol/L (see `concentration`)."""
|
|
125
|
+
return {substance: self.concentration(substance) for substance in self.solutes}
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def solvent_fractions(self) -> dict[Substance, float]:
|
|
129
|
+
"""Volume fraction of each solvent (between 0 and 1)."""
|
|
130
|
+
volume = self.volume
|
|
131
|
+
return {solvent: solvent_volume/volume for solvent, solvent_volume in self.solvents.items()}
|
|
132
|
+
|
|
133
|
+
def mix(self, other: Self) -> Self:
|
|
134
|
+
"""Combine both compositions, conserving the volume of each solvent and the amount of each solute.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
other
|
|
139
|
+
The composition to add.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
Composition
|
|
144
|
+
The mixture.
|
|
145
|
+
"""
|
|
146
|
+
return type(self)(solvents=_add(self.solvents, other.solvents), solutes=_add(self.solutes, other.solutes))
|
|
147
|
+
|
|
148
|
+
def split(self, volume: float) -> tuple[Self, Self]:
|
|
149
|
+
"""Divide off a volume of the homogeneous liquid.
|
|
150
|
+
|
|
151
|
+
Solvents and solutes are divided in proportion to the volume. If the volume is within floating point error of
|
|
152
|
+
the whole volume (see `.exceeds`), everything is taken, so that no residue is left behind.
|
|
153
|
+
|
|
154
|
+
Parameters
|
|
155
|
+
----------
|
|
156
|
+
volume
|
|
157
|
+
Volume to divide off in L.
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
tuple[Composition, Composition]
|
|
162
|
+
The portion and the remainder.
|
|
163
|
+
|
|
164
|
+
Raises
|
|
165
|
+
------
|
|
166
|
+
ValidationError
|
|
167
|
+
If the volume is negative or not finite.
|
|
168
|
+
InsufficientVolumeError
|
|
169
|
+
If the volume exceeds the volume of the composition.
|
|
170
|
+
"""
|
|
171
|
+
if not math.isfinite(volume) or volume < 0:
|
|
172
|
+
raise ValidationError("Volume must be finite and non-negative")
|
|
173
|
+
if exceeds(volume, self.volume):
|
|
174
|
+
raise InsufficientVolumeError(f"Cannot take {volume} L from {self.volume} L")
|
|
175
|
+
if not volume:
|
|
176
|
+
return type(self)(), self
|
|
177
|
+
if not exceeds(self.volume, volume):
|
|
178
|
+
# within floating point error of the whole volume: take everything rather than leave a residue
|
|
179
|
+
return self, type(self)()
|
|
180
|
+
fraction = volume/self.volume
|
|
181
|
+
return self._scale(fraction), self._scale(1.0 - fraction)
|
|
182
|
+
|
|
183
|
+
def _scale(self, factor: float) -> Self:
|
|
184
|
+
return type(self)(
|
|
185
|
+
solvents={solvent: volume*factor for solvent, volume in self.solvents.items()},
|
|
186
|
+
solutes={solute: amount*factor for solute, amount in self.solutes.items()},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def __repr__(self) -> str:
|
|
190
|
+
solvents = ", ".join(f"{solvent.name}: {volume:g} L" for solvent, volume in self.solvents.items())
|
|
191
|
+
solutes = ", ".join(f"{solute.name}: {amount:g} mol" for solute, amount in self.solutes.items())
|
|
192
|
+
return f"Composition(volume={self.volume:g} L, solvents={{{solvents}}}, solutes={{{solutes}}})"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Chemical substances and their identity."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
import math
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
from uuid import UUID, uuid4
|
|
7
|
+
|
|
8
|
+
from hte.exceptions import ValidationError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from rdkit.Chem import Mol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, eq=False, repr=False)
|
|
15
|
+
class Substance:
|
|
16
|
+
"""A chemical substance.
|
|
17
|
+
|
|
18
|
+
Substances with a structure (``mol``, requires RDKit) are compared by their InChIKey.
|
|
19
|
+
Substances without a structure are compared by their name (case-insensitive).
|
|
20
|
+
The two kinds never compare equal to each other.
|
|
21
|
+
|
|
22
|
+
The molar mass (g/mol) is inferred from the structure. Substances without a structure need an explicit molar mass
|
|
23
|
+
to convert between mass and amount.
|
|
24
|
+
|
|
25
|
+
Attributes
|
|
26
|
+
----------
|
|
27
|
+
name
|
|
28
|
+
Name of the substance.
|
|
29
|
+
id
|
|
30
|
+
Unique id.
|
|
31
|
+
mol
|
|
32
|
+
Structure as RDKit molecule, or None.
|
|
33
|
+
molar_mass
|
|
34
|
+
Molar mass in g/mol for substances without a structure, or None.
|
|
35
|
+
|
|
36
|
+
Raises
|
|
37
|
+
------
|
|
38
|
+
ValidationError
|
|
39
|
+
If no InChIKey can be generated for the structure, or if a molar mass is given together with a structure or is
|
|
40
|
+
not finite and positive.
|
|
41
|
+
"""
|
|
42
|
+
name: str
|
|
43
|
+
id: UUID = field(default_factory=uuid4)
|
|
44
|
+
mol: "Mol|None" = None
|
|
45
|
+
molar_mass: float|None = None
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
if self.mol is not None and not self.key[1]:
|
|
49
|
+
raise ValidationError(f"No InChIKey can be generated for the structure of {self.name}")
|
|
50
|
+
if self.molar_mass is not None:
|
|
51
|
+
if self.mol is not None:
|
|
52
|
+
raise ValidationError(f"The molar mass of {self.name} is inferred from its structure and cannot be given")
|
|
53
|
+
if not math.isfinite(self.molar_mass) or self.molar_mass <= 0:
|
|
54
|
+
raise ValidationError(f"The molar mass of {self.name} must be finite and positive")
|
|
55
|
+
|
|
56
|
+
def get_molar_mass(self) -> float:
|
|
57
|
+
"""Return the molar mass, either given or inferred from the structure.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
float
|
|
62
|
+
Molar mass in g/mol. For a structure, the average molar mass including implicit hydrogens.
|
|
63
|
+
|
|
64
|
+
Raises
|
|
65
|
+
------
|
|
66
|
+
ValidationError
|
|
67
|
+
If the substance has neither a structure nor a molar mass.
|
|
68
|
+
"""
|
|
69
|
+
if self.molar_mass is not None:
|
|
70
|
+
return self.molar_mass
|
|
71
|
+
if self.mol is not None:
|
|
72
|
+
# average molar mass including implicit hydrogens; defined dynamically, so missing from RDKit's stubs
|
|
73
|
+
from rdkit.Chem.Descriptors import MolWt # pyright: ignore[reportAttributeAccessIssue]
|
|
74
|
+
return MolWt(self.mol)
|
|
75
|
+
raise ValidationError(f"The molar mass of {self.name} is unknown: give a structure or a molar mass")
|
|
76
|
+
|
|
77
|
+
def amount(self, mass: float) -> float:
|
|
78
|
+
"""Convert a mass into an amount.
|
|
79
|
+
|
|
80
|
+
Parameters
|
|
81
|
+
----------
|
|
82
|
+
mass
|
|
83
|
+
Mass in g.
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
float
|
|
88
|
+
Amount in mol.
|
|
89
|
+
|
|
90
|
+
Raises
|
|
91
|
+
------
|
|
92
|
+
ValidationError
|
|
93
|
+
If the molar mass is unknown (see `get_molar_mass`).
|
|
94
|
+
"""
|
|
95
|
+
return mass / self.get_molar_mass()
|
|
96
|
+
|
|
97
|
+
def mass(self, amount: float) -> float:
|
|
98
|
+
"""Convert an amount into a mass.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
amount
|
|
103
|
+
Amount in mol.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
float
|
|
108
|
+
Mass in g.
|
|
109
|
+
|
|
110
|
+
Raises
|
|
111
|
+
------
|
|
112
|
+
ValidationError
|
|
113
|
+
If the molar mass is unknown (see `get_molar_mass`).
|
|
114
|
+
"""
|
|
115
|
+
return amount * self.get_molar_mass()
|
|
116
|
+
|
|
117
|
+
@cached_property
|
|
118
|
+
def key(self) -> tuple[str, str]:
|
|
119
|
+
"""Chemical identity used for comparison.
|
|
120
|
+
|
|
121
|
+
``("inchikey", <InChIKey>)`` for substances with a structure, ``("name", <normalised name>)`` otherwise.
|
|
122
|
+
"""
|
|
123
|
+
if self.mol is not None:
|
|
124
|
+
from rdkit.Chem import MolToInchiKey
|
|
125
|
+
return ("inchikey", MolToInchiKey(self.mol))
|
|
126
|
+
return ("name", self.name.strip().casefold())
|
|
127
|
+
|
|
128
|
+
def __repr__(self) -> str:
|
|
129
|
+
details = [repr(self.name)]
|
|
130
|
+
if self.mol is not None:
|
|
131
|
+
details.append(f"inchikey={self.key[1]!r}")
|
|
132
|
+
if self.molar_mass is not None:
|
|
133
|
+
details.append(f"molar_mass={self.molar_mass!r}")
|
|
134
|
+
return f"Substance({', '.join(details)})"
|
|
135
|
+
|
|
136
|
+
def __eq__(self, other: object) -> bool:
|
|
137
|
+
if not isinstance(other, Substance):
|
|
138
|
+
return NotImplemented
|
|
139
|
+
return self.key == other.key
|
|
140
|
+
|
|
141
|
+
def __hash__(self) -> int:
|
|
142
|
+
return hash(self.key)
|
hte/designs/__init__.py
ADDED
|
File without changes
|
hte/designs/base.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Building blocks shared by experiment designs."""
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
import math
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from hte.chemistry.composition import Composition
|
|
7
|
+
from hte.exceptions import ValidationError
|
|
8
|
+
from hte.labware.containers import Container
|
|
9
|
+
from hte.labware.plates import Plate
|
|
10
|
+
from hte.operations.plans import Plan
|
|
11
|
+
from hte.operations.state import LabState
|
|
12
|
+
from hte.tolerances import exceeds
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Design(Protocol):
|
|
16
|
+
"""An experiment design: it creates the plan that prepares a plate, starting from an initial state.
|
|
17
|
+
|
|
18
|
+
Attributes
|
|
19
|
+
----------
|
|
20
|
+
plate
|
|
21
|
+
The plate that the design prepares.
|
|
22
|
+
"""
|
|
23
|
+
plate: Plate
|
|
24
|
+
|
|
25
|
+
def create_plan(self, initial: LabState) -> Plan:
|
|
26
|
+
"""Create the plan that prepares the plate.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
initial
|
|
31
|
+
State of the stocks and the plate before the plan.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
Plan
|
|
36
|
+
The plan.
|
|
37
|
+
"""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def stock_volume(final_concentration: float, final_volume: float, stock_concentration: float) -> float:
|
|
42
|
+
"""Return the volume of a stock that gives the final concentration in the final volume.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
final_concentration
|
|
47
|
+
Concentration in the final volume in mol/L.
|
|
48
|
+
final_volume
|
|
49
|
+
Final volume in L.
|
|
50
|
+
stock_concentration
|
|
51
|
+
Concentration of the stock in mol/L.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
float
|
|
56
|
+
Volume of the stock in L.
|
|
57
|
+
"""
|
|
58
|
+
return final_volume * final_concentration/stock_concentration
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stock_concentration(stock: Container, contents: Composition) -> float:
|
|
62
|
+
"""Return the concentration of the single solute of a stock solution.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
stock
|
|
67
|
+
The stock container, used in error messages.
|
|
68
|
+
contents
|
|
69
|
+
Contents of the stock.
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
float
|
|
74
|
+
Concentration in mol/L; always positive.
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
ValidationError
|
|
79
|
+
If the stock does not contain a solution with exactly one solute.
|
|
80
|
+
"""
|
|
81
|
+
# zero amounts are dropped from compositions, so the single solute has a positive concentration
|
|
82
|
+
if len(contents.solutes) != 1 or not contents.volume:
|
|
83
|
+
raise ValidationError(f"Stock {stock.describe()} must contain a solution with exactly one solute.")
|
|
84
|
+
(solute,) = contents.solutes
|
|
85
|
+
return contents.concentration(solute)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_empty(initial: LabState, plate: Plate) -> None:
|
|
89
|
+
"""Check that all wells of a plate are empty in a state.
|
|
90
|
+
|
|
91
|
+
Parameters
|
|
92
|
+
----------
|
|
93
|
+
initial
|
|
94
|
+
The state.
|
|
95
|
+
plate
|
|
96
|
+
The plate.
|
|
97
|
+
|
|
98
|
+
Raises
|
|
99
|
+
------
|
|
100
|
+
ValidationError
|
|
101
|
+
If a well contains liquid.
|
|
102
|
+
"""
|
|
103
|
+
filled = [well.describe() for well in plate.wells if not initial[well].is_empty]
|
|
104
|
+
if filled:
|
|
105
|
+
raise ValidationError(f"Wells must be empty ({', '.join(filled)} already contain liquid).")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def dilution_plan(
|
|
109
|
+
initial: LabState,
|
|
110
|
+
plate: Plate,
|
|
111
|
+
stocks: Sequence[Container],
|
|
112
|
+
concentrations: Sequence[Sequence[float]],
|
|
113
|
+
solvent: Container,
|
|
114
|
+
volume: float,
|
|
115
|
+
) -> Plan:
|
|
116
|
+
"""Plan to bring every well to a final volume with given concentrations of the stocks' solutes.
|
|
117
|
+
|
|
118
|
+
Each stock contains a single solute; its concentrations give the target in each well (row-major order). The
|
|
119
|
+
remaining volume of each well is filled with solvent. Wells must be empty, since existing contents would change
|
|
120
|
+
the final concentrations. Transfers are source-major: each stock across the plate, the solvent last.
|
|
121
|
+
|
|
122
|
+
Parameters
|
|
123
|
+
----------
|
|
124
|
+
initial
|
|
125
|
+
State of the stocks and the plate before the plan.
|
|
126
|
+
plate
|
|
127
|
+
The plate to fill.
|
|
128
|
+
stocks
|
|
129
|
+
Stock solutions, each with a single solute.
|
|
130
|
+
concentrations
|
|
131
|
+
For each stock, the target concentration of its solute in each well in mol/L.
|
|
132
|
+
solvent
|
|
133
|
+
Container that fills each well up to the final volume.
|
|
134
|
+
volume
|
|
135
|
+
Final volume of each well in L.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
Plan
|
|
140
|
+
The plan. It is not validated: whether the stocks contain enough liquid is checked by `.Plan.simulate`.
|
|
141
|
+
|
|
142
|
+
Raises
|
|
143
|
+
------
|
|
144
|
+
ValidationError
|
|
145
|
+
If the final volume is not finite and positive, the concentrations do not match the stocks or the wells or
|
|
146
|
+
are negative or not finite, a stock does not contain exactly one solute, a well is not empty, or the stock
|
|
147
|
+
volumes of a well exceed the final volume.
|
|
148
|
+
"""
|
|
149
|
+
if not math.isfinite(volume) or volume <= 0:
|
|
150
|
+
raise ValidationError("Final volume must be finite and positive.")
|
|
151
|
+
if len(stocks) != len(concentrations):
|
|
152
|
+
raise ValidationError(f"{len(concentrations)} concentration sequences given for {len(stocks)} stocks.")
|
|
153
|
+
check_empty(initial, plate)
|
|
154
|
+
|
|
155
|
+
volumes = []
|
|
156
|
+
for stock, concentration_sequence in zip(stocks, concentrations, strict=True):
|
|
157
|
+
if len(concentration_sequence) != plate.size:
|
|
158
|
+
raise ValidationError(f"Concentrations of stock {stock.describe()} must be given for {plate.size} wells.")
|
|
159
|
+
if any(not math.isfinite(concentration) or concentration < 0 for concentration in concentration_sequence):
|
|
160
|
+
raise ValidationError(f"Concentrations of stock {stock.describe()} must be finite and non-negative.")
|
|
161
|
+
concentration_of_stock = stock_concentration(stock, initial[stock])
|
|
162
|
+
volumes.append([
|
|
163
|
+
stock_volume(
|
|
164
|
+
final_concentration=concentration,
|
|
165
|
+
final_volume=volume,
|
|
166
|
+
stock_concentration=concentration_of_stock,
|
|
167
|
+
) for concentration in concentration_sequence
|
|
168
|
+
])
|
|
169
|
+
|
|
170
|
+
solvent_volumes = []
|
|
171
|
+
for index, well in enumerate(plate.wells):
|
|
172
|
+
total_volume = sum(volume_sequence[index] for volume_sequence in volumes)
|
|
173
|
+
if exceeds(total_volume, volume):
|
|
174
|
+
raise ValidationError(
|
|
175
|
+
f"Stock volumes for well {well.describe()} ({total_volume}) exceed the final volume ({volume})."
|
|
176
|
+
)
|
|
177
|
+
solvent_volumes.append(max(volume - total_volume, 0.0))
|
|
178
|
+
|
|
179
|
+
return Plan.create_plan(
|
|
180
|
+
initial=initial,
|
|
181
|
+
plate=plate,
|
|
182
|
+
sources=[*stocks, solvent],
|
|
183
|
+
volumes=[*volumes, solvent_volumes],
|
|
184
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Titrations: a constant analyte with varying titrant concentrations across a plate."""
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
from hte.designs.base import dilution_plan
|
|
7
|
+
from hte.exceptions import ValidationError
|
|
8
|
+
from hte.labware.containers import Container
|
|
9
|
+
from hte.labware.plates import Plate
|
|
10
|
+
from hte.operations.plans import Plan
|
|
11
|
+
from hte.operations.state import LabState
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class TitrationSeries:
|
|
16
|
+
"""Target concentrations (mol/L) per well, in row-major order.
|
|
17
|
+
|
|
18
|
+
Attributes
|
|
19
|
+
----------
|
|
20
|
+
analyte_concentration
|
|
21
|
+
Concentration of the analyte in each well.
|
|
22
|
+
titrant_concentration
|
|
23
|
+
Concentration of the titrant in each well.
|
|
24
|
+
additive_concentration
|
|
25
|
+
Concentration of an additive in each well, or None without additive.
|
|
26
|
+
"""
|
|
27
|
+
analyte_concentration: Sequence[float]
|
|
28
|
+
titrant_concentration: Sequence[float]
|
|
29
|
+
additive_concentration: Sequence[float]|None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TitrationDesign:
|
|
34
|
+
"""A titration on a plate.
|
|
35
|
+
|
|
36
|
+
Every well receives analyte, titrant and, optionally, additive from stock solutions with a single solute each, and
|
|
37
|
+
is filled up to the final volume with solvent (see `.dilution_plan`).
|
|
38
|
+
|
|
39
|
+
Attributes
|
|
40
|
+
----------
|
|
41
|
+
series
|
|
42
|
+
Target concentrations per well.
|
|
43
|
+
analyte
|
|
44
|
+
Stock solution of the analyte.
|
|
45
|
+
titrant
|
|
46
|
+
Stock solution of the titrant.
|
|
47
|
+
solvent
|
|
48
|
+
Container that fills each well up to the final volume.
|
|
49
|
+
volume
|
|
50
|
+
Final volume of each well in L.
|
|
51
|
+
plate
|
|
52
|
+
The plate to fill.
|
|
53
|
+
additive
|
|
54
|
+
Stock solution of an additive, or None without additive.
|
|
55
|
+
|
|
56
|
+
Raises
|
|
57
|
+
------
|
|
58
|
+
ValidationError
|
|
59
|
+
If only one of the additive and its concentrations is given, or the final volume is not finite and positive.
|
|
60
|
+
"""
|
|
61
|
+
series: TitrationSeries
|
|
62
|
+
analyte: Container
|
|
63
|
+
titrant: Container
|
|
64
|
+
solvent: Container
|
|
65
|
+
volume: float
|
|
66
|
+
plate: Plate
|
|
67
|
+
additive: Container|None = None
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
if (self.additive is None) != (self.series.additive_concentration is None):
|
|
71
|
+
raise ValidationError("Additive and additive concentrations must be given together.")
|
|
72
|
+
if not math.isfinite(self.volume) or self.volume <= 0:
|
|
73
|
+
raise ValidationError("Final volume must be finite and positive.")
|
|
74
|
+
|
|
75
|
+
def create_plan(self, initial: LabState) -> Plan:
|
|
76
|
+
"""Create the plan for the given initial state of the stocks and the plate.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
initial
|
|
81
|
+
State of the stocks and the plate before the plan. The wells must be empty.
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
Plan
|
|
86
|
+
The plan: analyte, titrant and additive across the plate, then the solvent. It is not validated; see
|
|
87
|
+
`.Plan.simulate`.
|
|
88
|
+
|
|
89
|
+
Raises
|
|
90
|
+
------
|
|
91
|
+
ValidationError
|
|
92
|
+
If the plan cannot be created (see `.dilution_plan`).
|
|
93
|
+
"""
|
|
94
|
+
stocks = [self.analyte, self.titrant]
|
|
95
|
+
concentrations = [self.series.analyte_concentration, self.series.titrant_concentration]
|
|
96
|
+
if self.additive is not None and self.series.additive_concentration is not None:
|
|
97
|
+
stocks.append(self.additive)
|
|
98
|
+
concentrations.append(self.series.additive_concentration)
|
|
99
|
+
return dilution_plan(
|
|
100
|
+
initial=initial,
|
|
101
|
+
plate=self.plate,
|
|
102
|
+
stocks=stocks,
|
|
103
|
+
concentrations=concentrations,
|
|
104
|
+
solvent=self.solvent,
|
|
105
|
+
volume=self.volume,
|
|
106
|
+
)
|
hte/devices/__init__.py
ADDED
|
File without changes
|