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 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)
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
+ )
File without changes