bfflearn 0.1.0.dev1__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.
Files changed (55) hide show
  1. bff/__init__.py +30 -0
  2. bff/bayes/__init__.py +11 -0
  3. bff/bayes/gaussian_process.py +285 -0
  4. bff/bayes/kernels.py +71 -0
  5. bff/bayes/learning.py +484 -0
  6. bff/bayes/likelihoods.py +142 -0
  7. bff/bayes/posterior.py +62 -0
  8. bff/bayes/priors.py +179 -0
  9. bff/bayes/results.py +327 -0
  10. bff/bayes/utils.py +333 -0
  11. bff/cli.py +254 -0
  12. bff/data.py +93 -0
  13. bff/domain/__init__.py +12 -0
  14. bff/domain/bias.py +149 -0
  15. bff/domain/specs.py +347 -0
  16. bff/domain/trainset.py +201 -0
  17. bff/io/__init__.py +12 -0
  18. bff/io/colvars.py +186 -0
  19. bff/io/commands.py +20 -0
  20. bff/io/cp2k.py +330 -0
  21. bff/io/logs.py +213 -0
  22. bff/io/mdp.py +98 -0
  23. bff/io/plumed.py +184 -0
  24. bff/io/progress.py +74 -0
  25. bff/io/schedulers.py +118 -0
  26. bff/io/utils.py +208 -0
  27. bff/mcmc/__init__.py +3 -0
  28. bff/mcmc/convergence.py +168 -0
  29. bff/mcmc/proposal.py +254 -0
  30. bff/mcmc/sampler.py +481 -0
  31. bff/plotting.py +362 -0
  32. bff/qoi/__init__.py +9 -0
  33. bff/qoi/analysis.py +209 -0
  34. bff/qoi/data.py +169 -0
  35. bff/qoi/hbonds.py +255 -0
  36. bff/qoi/rdf.py +108 -0
  37. bff/qoi/routines.py +272 -0
  38. bff/tools.py +168 -0
  39. bff/topology.py +581 -0
  40. bff/workflows/__init__.py +0 -0
  41. bff/workflows/configs.py +1147 -0
  42. bff/workflows/learn.py +77 -0
  43. bff/workflows/md.py +326 -0
  44. bff/workflows/prepare.py +788 -0
  45. bff/workflows/qoi.py +260 -0
  46. bff/workflows/runsims.py +620 -0
  47. bff/workflows/simulate.py +28 -0
  48. bff/workflows/train.py +69 -0
  49. bff/workflows/validate.py +29 -0
  50. bfflearn-0.1.0.dev1.dist-info/METADATA +913 -0
  51. bfflearn-0.1.0.dev1.dist-info/RECORD +55 -0
  52. bfflearn-0.1.0.dev1.dist-info/WHEEL +5 -0
  53. bfflearn-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  54. bfflearn-0.1.0.dev1.dist-info/licenses/LICENSE +674 -0
  55. bfflearn-0.1.0.dev1.dist-info/top_level.txt +1 -0
bff/qoi/data.py ADDED
@@ -0,0 +1,169 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any, Mapping
3
+
4
+ import numpy as np
5
+
6
+ from ..io.utils import load_pt, save_pt
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class QoI:
11
+ """One named quantity of interest produced for a single trajectory."""
12
+
13
+ name: str
14
+ values: np.ndarray
15
+ labels: tuple[str, ...] | None = None
16
+ values_per_label: int = 1
17
+ settings: dict[str, Any] = field(default_factory=dict)
18
+ metadata: dict[str, Any] = field(default_factory=dict)
19
+
20
+ def __post_init__(self) -> None:
21
+ values = np.asarray(self.values, dtype=float).reshape(-1)
22
+ labels = None if self.labels is None else tuple(self.labels)
23
+ values_per_label = int(self.values_per_label)
24
+
25
+ if values_per_label <= 0:
26
+ raise ValueError("'values_per_label' must be a positive integer.")
27
+ if labels is not None and len(values) != len(labels) * values_per_label:
28
+ raise ValueError(
29
+ "Number of values does not match labels * values_per_label in QoI."
30
+ )
31
+
32
+ self.values = values
33
+ self.labels = labels
34
+ self.values_per_label = values_per_label
35
+ self.settings = dict(self.settings)
36
+ self.metadata = dict(self.metadata)
37
+
38
+ @property
39
+ def n_values(self) -> int:
40
+ """Total number of numeric values stored in the QoI."""
41
+ return int(self.values.size)
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ """Serialize the QoI to a JSON/PT-friendly mapping."""
45
+ return {
46
+ "name": self.name,
47
+ "values": self.values.tolist(),
48
+ "labels": None if self.labels is None else list(self.labels),
49
+ "values_per_label": self.values_per_label,
50
+ "settings": dict(self.settings),
51
+ "metadata": dict(self.metadata),
52
+ }
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: Mapping[str, Any]) -> "QoI":
56
+ """Rebuild a serialized QoI object."""
57
+ return cls(
58
+ name=str(data["name"]),
59
+ values=np.asarray(data["values"], dtype=float),
60
+ labels=None if data.get("labels") is None else tuple(data["labels"]),
61
+ values_per_label=int(data.get("values_per_label", 1)),
62
+ settings=dict(data.get("settings", {})),
63
+ metadata=dict(data.get("metadata", {})),
64
+ )
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class QoIDataset:
69
+ """Training-ready dataset for one named quantity of interest."""
70
+
71
+ name: str
72
+ inputs: np.ndarray
73
+ outputs: np.ndarray
74
+ outputs_ref: np.ndarray
75
+ labels: tuple[str, ...] | None = None
76
+ values_per_label: int = 1
77
+ nuisance: float | None = None
78
+ settings: dict[str, Any] | None = None
79
+ metadata: dict[str, Any] | None = None
80
+
81
+ def __post_init__(self) -> None:
82
+ self.inputs = np.asarray(self.inputs, dtype=float)
83
+ self.outputs = np.asarray(self.outputs, dtype=float)
84
+ self.outputs_ref = np.asarray(self.outputs_ref, dtype=float).reshape(-1)
85
+ self.settings = self._coerce_mapping(self.settings)
86
+ self.metadata = self._coerce_mapping(self.metadata)
87
+ self.labels = None if self.labels is None else tuple(self.labels)
88
+ self.values_per_label = int(self.values_per_label)
89
+
90
+ if self.inputs.shape[0] != self.outputs.shape[0]:
91
+ raise ValueError(
92
+ f"Number of input samples ({self.inputs.shape[0]}) does not match "
93
+ f"number of output samples ({self.outputs.shape[0]})."
94
+ )
95
+
96
+ if self.outputs.shape[1] != self.outputs_ref.shape[0]:
97
+ raise ValueError(
98
+ f"Output dimension ({self.outputs.shape[1]}) does not match "
99
+ f"reference dimension ({self.outputs_ref.shape[0]})."
100
+ )
101
+ if self.values_per_label <= 0:
102
+ raise ValueError("'values_per_label' must be a positive integer.")
103
+ if (
104
+ self.labels is not None
105
+ and self.outputs_ref.size != len(self.labels) * self.values_per_label
106
+ ):
107
+ raise ValueError(
108
+ "Reference output size does not match labels * values_per_label."
109
+ )
110
+
111
+ @staticmethod
112
+ def _coerce_mapping(value: Any) -> dict[str, Any]:
113
+ return dict(value or {})
114
+
115
+ @property
116
+ def n_samples(self) -> int:
117
+ return int(self.inputs.shape[0])
118
+
119
+ @property
120
+ def n_observations(self) -> int:
121
+ if self.outputs_ref.size % self.values_per_label != 0:
122
+ raise ValueError(
123
+ "Reference output size must be divisible by "
124
+ "'values_per_label'."
125
+ )
126
+ return int(self.outputs_ref.size // self.values_per_label)
127
+
128
+ def to_dict(self) -> dict[str, Any]:
129
+ data = {
130
+ "name": self.name,
131
+ "inputs": self.inputs.tolist(),
132
+ "outputs": self.outputs.tolist(),
133
+ "outputs_ref": self.outputs_ref.tolist(),
134
+ "labels": None if self.labels is None else list(self.labels),
135
+ "values_per_label": self.values_per_label,
136
+ "settings": dict(self.settings),
137
+ "metadata": dict(self.metadata),
138
+ }
139
+ if self.nuisance is not None:
140
+ data["nuisance"] = self.nuisance
141
+ return data
142
+
143
+ @classmethod
144
+ def from_dict(cls, data: Mapping[str, Any]) -> "QoIDataset":
145
+ return cls(
146
+ name=str(data["name"]),
147
+ inputs=np.asarray(data["inputs"], dtype=float),
148
+ outputs=np.asarray(data["outputs"], dtype=float),
149
+ outputs_ref=np.asarray(data["outputs_ref"], dtype=float),
150
+ labels=None if data.get("labels") is None else tuple(data["labels"]),
151
+ values_per_label=int(data.get("values_per_label", 1)),
152
+ nuisance=data.get("nuisance"),
153
+ settings=dict(data.get("settings", {})),
154
+ metadata=dict(data.get("metadata", {})),
155
+ )
156
+
157
+ def write(self, fn_out: str) -> None:
158
+ save_pt(self.to_dict(), fn_out)
159
+
160
+ @classmethod
161
+ def load(cls, fn_in: str) -> "QoIDataset":
162
+ data = load_pt(fn_in)
163
+ return cls.from_dict(data)
164
+
165
+ def __repr__(self) -> str:
166
+ return (
167
+ f"QoIDataset(n_samples={self.n_samples}, "
168
+ f"n_observations={self.n_observations}, name={self.name!r})"
169
+ )
bff/qoi/hbonds.py ADDED
@@ -0,0 +1,255 @@
1
+ from typing import Dict
2
+
3
+ import MDAnalysis as mda
4
+ import numpy as np
5
+ from MDAnalysis.lib.distances import calc_angles, capped_distance
6
+
7
+ from ..tools import get_unitcell
8
+ from .data import QoI
9
+
10
+ __all__ = ["compute_all_hbonds"]
11
+
12
+
13
+ def count_hbonds(
14
+ universe: mda.Universe,
15
+ donor_indices: np.ndarray,
16
+ acceptor_indices: np.ndarray,
17
+ n_frames: int
18
+ ) -> Dict[str, float]:
19
+ """
20
+ Counts the average number of hydrogen bonds per hydrogen bond type.
21
+
22
+ Parameters
23
+ ----------
24
+ universe : MDAnalysis.Universe
25
+ donor_indices : np.ndarray
26
+ acceptor_indices : np.ndarray
27
+ n_frames : int
28
+ Number of trajectory frames used for the analysis.
29
+ threshold : float, optional
30
+ Minimum frequency threshold for a hydrogen bond type. Default is 0.1.
31
+
32
+ Returns
33
+ -------
34
+ results : dict
35
+ Dictionary containing the hydrogen bond types and their frequencies.
36
+ """
37
+
38
+ if n_frames <= 0 or donor_indices.size == 0 or acceptor_indices.size == 0:
39
+ return {}
40
+
41
+ hbonds = np.column_stack(
42
+ (
43
+ universe.atoms[donor_indices].resnames,
44
+ universe.atoms[donor_indices].types,
45
+ universe.atoms[acceptor_indices].resnames,
46
+ universe.atoms[acceptor_indices].types,
47
+ )
48
+ ).astype("str")
49
+
50
+ hbond_types, counts = np.unique(hbonds, return_counts=True, axis=0)
51
+
52
+ # Filter and format results
53
+ results = {}
54
+ for hb, count in zip(hbond_types, counts):
55
+ name = f"{hb[0]}({hb[1]}) to {hb[2]}({hb[3]})"
56
+ results[name] = count / n_frames
57
+
58
+ return results
59
+
60
+
61
+ def _paired_donors_and_hydrogens(
62
+ universe: mda.Universe,
63
+ selection: str,
64
+ *,
65
+ hb_elements: set[str],
66
+ ) -> tuple[mda.AtomGroup, mda.AtomGroup]:
67
+ """Return donor and hydrogen groups with one-to-one indexing."""
68
+ hydrogens_all = universe.select_atoms(f"{selection} and element H")
69
+ donor_indices: list[int] = []
70
+ hydrogen_indices: list[int] = []
71
+
72
+ for hydrogen in hydrogens_all:
73
+ if not hydrogen.bonded_atoms:
74
+ continue
75
+ donor = hydrogen.bonded_atoms[0]
76
+ if donor.element not in hb_elements:
77
+ continue
78
+ donor_indices.append(donor.index)
79
+ hydrogen_indices.append(hydrogen.index)
80
+
81
+ if not donor_indices:
82
+ empty = universe.atoms[np.asarray([], dtype=int)]
83
+ return empty, empty
84
+
85
+ donors = universe.atoms[np.asarray(donor_indices, dtype=int)]
86
+ hydrogens = universe.atoms[np.asarray(hydrogen_indices, dtype=int)]
87
+ return donors, hydrogens
88
+
89
+
90
+ def _all_possible_hbond_labels(
91
+ donors: mda.AtomGroup,
92
+ acceptors: mda.AtomGroup,
93
+ ) -> set[str]:
94
+ """Enumerate all donor/acceptor type combinations possible for this system."""
95
+ if len(donors) == 0 or len(acceptors) == 0:
96
+ return set()
97
+
98
+ donor_types = np.unique(
99
+ np.column_stack((donors.resnames, donors.types)).astype(str),
100
+ axis=0,
101
+ )
102
+ acceptor_types = np.unique(
103
+ np.column_stack((acceptors.resnames, acceptors.types)).astype(str),
104
+ axis=0,
105
+ )
106
+
107
+ return {
108
+ f"{d_res}({d_type}) to {a_res}({a_type})"
109
+ for d_res, d_type in donor_types
110
+ for a_res, a_type in acceptor_types
111
+ }
112
+
113
+
114
+ def compute_hbonds(
115
+ universe: mda.Universe,
116
+ hydrogens: mda.AtomGroup,
117
+ donors: mda.AtomGroup,
118
+ acceptors: mda.AtomGroup,
119
+ distance_cutoff: float = 3.5,
120
+ angle_cutoff: float = 150,
121
+ start: int = 0,
122
+ step: int = 1,
123
+ stop: int = None,
124
+ ) -> tuple[np.ndarray, np.ndarray, int]:
125
+ """
126
+ Computes hydrogen bonds between donor and acceptor atoms.
127
+
128
+ Parameters
129
+ ----------
130
+ universe : MDAnalysis.Universe
131
+ hydrogens : MDAnalysis.AtomGroup
132
+ donors : MDAnalysis.AtomGroup
133
+ acceptors : MDAnalysis.AtomGroup
134
+ distance_cutoff : float, optional
135
+ Maximum distance between donor and acceptor atoms. Default is 3.0A.
136
+ angle_cutoff : float, optional
137
+ Minimum angle between donor-hydrogen-acceptor atoms.
138
+ Default is 150 degrees.
139
+ start : int, optional
140
+ Start frame for the analysis. Default is 0.
141
+ step : int, optional
142
+ Frame stride for the analysis. Default is 1.
143
+ stop : int, optional
144
+ End frame for the analysis. Default is None.
145
+
146
+ Returns
147
+ -------
148
+ donor_indices : np.ndarray
149
+ acceptor_indices : np.ndarray
150
+ n_frames : int
151
+ """
152
+
153
+ sl = slice(start, stop or len(universe.trajectory), step)
154
+ angle_cutoff = np.deg2rad(angle_cutoff)
155
+
156
+ donor_indices, acceptor_indices = [], []
157
+ n_frames = 0
158
+ for ts in universe.trajectory[sl]:
159
+ n_frames += 1
160
+ box = get_unitcell(universe, ts)
161
+ da_indices = capped_distance(
162
+ donors,
163
+ acceptors,
164
+ max_cutoff=distance_cutoff,
165
+ min_cutoff=1.0,
166
+ box=box,
167
+ return_distances=False,
168
+ )
169
+ if da_indices.size == 0:
170
+ continue
171
+
172
+ d = donors[da_indices[:, 0]]
173
+ h = hydrogens[da_indices[:, 0]]
174
+ a = acceptors[da_indices[:, 1]]
175
+
176
+ dha_angles = calc_angles(d, h, a, box=box)
177
+ hbond_indices = np.where(dha_angles > angle_cutoff)[0]
178
+ if hbond_indices.size == 0:
179
+ continue
180
+
181
+ donor_indices.extend(d[hbond_indices].indices)
182
+ acceptor_indices.extend(a[hbond_indices].indices)
183
+
184
+ return (
185
+ np.asarray(donor_indices, dtype=int),
186
+ np.asarray(acceptor_indices, dtype=int),
187
+ n_frames,
188
+ )
189
+
190
+
191
+ def compute_all_hbonds(
192
+ universe: mda.Universe,
193
+ *,
194
+ mol_resname: str,
195
+ water_resname: str = "SOL",
196
+ distance_cutoff: float = 3.5,
197
+ angle_cutoff: float = 150,
198
+ hb_elements: set[str] = {"O", "N", "S"},
199
+ start: int = 0,
200
+ stop: int | None = None,
201
+ step: int = 1,
202
+ ) -> QoI:
203
+ """Compute all solute-water hydrogen-bond QoIs for one trajectory."""
204
+ selection_1 = f"resname {mol_resname}"
205
+ selection_2 = f"resname {water_resname}"
206
+
207
+ hb_elements = set(hb_elements)
208
+ hb_elements_str = " ".join(sorted(hb_elements))
209
+
210
+ selection_pairs = ((selection_1, selection_2), (selection_2, selection_1))
211
+ possible_labels: set[str] = set()
212
+ hbonds: dict[str, float] = {}
213
+ for sel_donors, sel_acceptors in selection_pairs:
214
+ donors, hydrogens = _paired_donors_and_hydrogens(
215
+ universe,
216
+ sel_donors,
217
+ hb_elements=hb_elements,
218
+ )
219
+ acceptors = universe.select_atoms(
220
+ f"{sel_acceptors} and element {hb_elements_str}"
221
+ )
222
+
223
+ possible_labels.update(_all_possible_hbond_labels(donors, acceptors))
224
+
225
+ if len(donors) == 0 or len(acceptors) == 0:
226
+ continue
227
+
228
+ donor_indices, acceptor_indices, n_frames = compute_hbonds(
229
+ universe,
230
+ hydrogens,
231
+ donors,
232
+ acceptors,
233
+ distance_cutoff=distance_cutoff,
234
+ angle_cutoff=angle_cutoff,
235
+ start=start,
236
+ stop=stop,
237
+ step=step,
238
+ )
239
+ hbonds.update(count_hbonds(universe, donor_indices, acceptor_indices, n_frames))
240
+
241
+ labels = tuple(sorted(possible_labels))
242
+ values = np.asarray([hbonds.get(label, 0.0) for label in labels], dtype=float)
243
+ metadata = {
244
+ "mol_resname": mol_resname,
245
+ "water_resname": water_resname,
246
+ "distance_cutoff": float(distance_cutoff),
247
+ "angle_cutoff": float(angle_cutoff),
248
+ }
249
+ return QoI(
250
+ name="hb",
251
+ values=values,
252
+ labels=labels,
253
+ values_per_label=1,
254
+ settings=metadata,
255
+ )
bff/qoi/rdf.py ADDED
@@ -0,0 +1,108 @@
1
+ from typing import Tuple
2
+
3
+ import MDAnalysis as mda
4
+ import numpy as np
5
+ from scipy.ndimage import gaussian_filter
6
+
7
+ from ..tools import compute_distances, get_unitcell
8
+ from .data import QoI
9
+
10
+
11
+ def compute_rdf(
12
+ universe: mda.Universe,
13
+ atoms_ref: mda.AtomGroup,
14
+ atoms_sel: mda.AtomGroup,
15
+ r_range: Tuple[float, float] = (0, 10),
16
+ n_bins: int = 200,
17
+ pbc: bool = True,
18
+ start: int = None,
19
+ stop: int = None,
20
+ step: int = None,
21
+ smooth: bool = True,
22
+ ) -> Tuple[np.ndarray, np.ndarray]:
23
+ """Compute the radial distribution function between two atom groups."""
24
+ distances = compute_distances(
25
+ universe,
26
+ atoms_ref,
27
+ atoms_sel,
28
+ start=start,
29
+ stop=stop,
30
+ step=step,
31
+ pbc=pbc,
32
+ )
33
+ g, edges = np.histogram(distances.reshape(-1), range=r_range, bins=n_bins)
34
+ g = g.astype(np.float64)
35
+ r = 0.5 * (edges[1:] + edges[:-1])
36
+
37
+ shell_volumes = 4.0 / 3.0 * np.pi * (edges[1:] ** 3 - edges[:-1] ** 3)
38
+ if pbc:
39
+ volume = float(np.prod(np.asarray(get_unitcell(universe)[:3], dtype=float)))
40
+ norm = distances.size * (1.0 / volume) * shell_volumes
41
+ g /= norm
42
+ else:
43
+ g /= distances.size * shell_volumes
44
+
45
+ if smooth:
46
+ g = gaussian_filter(g, sigma=3)
47
+
48
+ return r, g
49
+
50
+
51
+ def compute_all_rdfs(
52
+ universe: mda.Universe,
53
+ *,
54
+ mol_resname: str,
55
+ solvent_sel: str = "resname SOL HOH WAT and name O*",
56
+ r_range: Tuple[float, float] = (0, 10),
57
+ n_bins: int = 200,
58
+ pbc: bool = True,
59
+ start: int = 0,
60
+ stop: int | None = None,
61
+ step: int = 1,
62
+ smooth: bool = True,
63
+ ) -> QoI:
64
+ """Compute solvent RDF QoIs around all solute atom types."""
65
+ mol = universe.select_atoms(f"resname {mol_resname}")
66
+ mask = mol.masses > 0.5
67
+ mol_atomtypes = np.unique(mol[mask].types)
68
+
69
+ rdf_results: dict[str, np.ndarray] = {}
70
+ atoms_solvent = universe.select_atoms(solvent_sel)
71
+ for atomtype in mol_atomtypes:
72
+ atoms_reference = universe.select_atoms(f"type {atomtype}")
73
+ r, g = compute_rdf(
74
+ universe,
75
+ atoms_reference,
76
+ atoms_solvent,
77
+ r_range=r_range,
78
+ n_bins=n_bins,
79
+ pbc=pbc,
80
+ start=start,
81
+ stop=stop,
82
+ step=step,
83
+ smooth=smooth,
84
+ )
85
+ rdf_results[atomtype] = np.array([r, g])
86
+
87
+ atomtypes = tuple(sorted(rdf_results))
88
+ values = np.concatenate(
89
+ [
90
+ np.asarray(rdf_results[atomtype][1], dtype=float).reshape(-1)
91
+ for atomtype in atomtypes
92
+ ]
93
+ )
94
+ metadata = {
95
+ "mol_resname": mol_resname,
96
+ "solvent_sel": solvent_sel,
97
+ "r_range": tuple(r_range),
98
+ "n_bins": int(n_bins),
99
+ "pbc": bool(pbc),
100
+ "smooth": bool(smooth),
101
+ }
102
+ return QoI(
103
+ name="rdf",
104
+ values=values,
105
+ labels=atomtypes,
106
+ values_per_label=int(n_bins),
107
+ settings=metadata,
108
+ )