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/io/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from . import colvars, cp2k, logs, mdp, plumed, progress, schedulers, utils
2
+
3
+ __all__ = [
4
+ "colvars",
5
+ "cp2k",
6
+ "logs",
7
+ "mdp",
8
+ "plumed",
9
+ "progress",
10
+ "schedulers",
11
+ "utils",
12
+ ]
bff/io/colvars.py ADDED
@@ -0,0 +1,186 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ import MDAnalysis as mda
6
+
7
+ from .mdp import patch_mdp
8
+
9
+ PathLike = str | Path
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ColvarsDistanceBias:
14
+ """Parsed distance bias from a COLVARS file.
15
+
16
+ Parameters
17
+ ----------
18
+ name
19
+ Colvar name.
20
+ atom_indices
21
+ One-based atom indices of the restrained pair.
22
+ center
23
+ Optional harmonic center in nm.
24
+ """
25
+
26
+ name: str
27
+ atom_indices: tuple[int, int]
28
+ center: float | None = None
29
+
30
+
31
+ def _extract_named_blocks(text: str, name: str) -> list[str]:
32
+ """Extract balanced top-level blocks of one COLVARS section type."""
33
+ pattern = re.compile(rf"\b{name}\s*\{{", re.MULTILINE)
34
+ blocks: list[str] = []
35
+ start = 0
36
+ while True:
37
+ match = pattern.search(text, start)
38
+ if match is None:
39
+ return blocks
40
+
41
+ i = match.end() - 1
42
+ depth = 0
43
+ end = i
44
+ while end < len(text):
45
+ if text[end] == "{":
46
+ depth += 1
47
+ elif text[end] == "}":
48
+ depth -= 1
49
+ if depth == 0:
50
+ blocks.append(text[i + 1:end])
51
+ start = end + 1
52
+ break
53
+ end += 1
54
+ else:
55
+ raise ValueError(f"Unbalanced '{name}' block in COLVARS file.")
56
+
57
+
58
+ def _extract_atom_numbers(block: str, group_name: str) -> tuple[int, ...]:
59
+ """Extract atom numbers from one COLVARS group block."""
60
+ groups = _extract_named_blocks(block, group_name)
61
+ if not groups:
62
+ return ()
63
+ match = re.search(r"\batomNumbers\s+([0-9\s]+)", groups[0])
64
+ if match is None:
65
+ return ()
66
+ return tuple(int(value) for value in match.group(1).split())
67
+
68
+
69
+ def parse_distance_biases(fn_colvars: PathLike) -> list[ColvarsDistanceBias]:
70
+ """Parse simple distance/harmonic restraints from a COLVARS file.
71
+
72
+ Parameters
73
+ ----------
74
+ fn_colvars
75
+ Path to the COLVARS input file.
76
+
77
+ Returns
78
+ -------
79
+ list of ColvarsDistanceBias
80
+ Parsed single-pair distance colvars. Only colvars with one atom in
81
+ ``group1`` and one atom in ``group2`` are returned.
82
+ """
83
+ text = Path(fn_colvars).read_text()
84
+
85
+ colvars: dict[str, tuple[int, int]] = {}
86
+ for block in _extract_named_blocks(text, "colvar"):
87
+ name_match = re.search(r"^\s*name\s+(\S+)", block, re.MULTILINE)
88
+ if name_match is None:
89
+ continue
90
+ distance_blocks = _extract_named_blocks(block, "distance")
91
+ if not distance_blocks:
92
+ continue
93
+ group_1 = _extract_atom_numbers(distance_blocks[0], "group1")
94
+ group_2 = _extract_atom_numbers(distance_blocks[0], "group2")
95
+ if len(group_1) != 1 or len(group_2) != 1:
96
+ continue
97
+ colvars[name_match.group(1)] = (group_1[0], group_2[0])
98
+
99
+ centers: dict[str, float] = {}
100
+ for block in _extract_named_blocks(text, "harmonic"):
101
+ name_match = re.search(r"^\s*colvars\s+(\S+)", block, re.MULTILINE)
102
+ center_match = re.search(r"^\s*centers\s+([-+0-9.eE]+)", block, re.MULTILINE)
103
+ if name_match is None or center_match is None:
104
+ continue
105
+ centers[name_match.group(1)] = float(center_match.group(1))
106
+
107
+ return [
108
+ ColvarsDistanceBias(
109
+ name=name,
110
+ atom_indices=indices,
111
+ center=centers.get(name),
112
+ )
113
+ for name, indices in colvars.items()
114
+ ]
115
+
116
+
117
+ def resolve_distance_bias_metadata(
118
+ fn_colvars: PathLike,
119
+ fn_system: PathLike,
120
+ ) -> tuple[tuple[str, ...], tuple[float, ...]]:
121
+ """Resolve atom-name pairs and centers from a COLVARS file.
122
+
123
+ Parameters
124
+ ----------
125
+ fn_colvars
126
+ Path to the COLVARS input file.
127
+ fn_system
128
+ Structure file used to map atom indices to atom names.
129
+
130
+ Returns
131
+ -------
132
+ tuple
133
+ Tuple of ``(pair_labels, centers)`` where labels are atom-name pairs
134
+ such as ``"C2 CAL"`` and centers are harmonic centers in nm.
135
+ """
136
+ universe = mda.Universe(str(fn_system))
137
+ distances = parse_distance_biases(fn_colvars)
138
+
139
+ labels: list[str] = []
140
+ centers: list[float] = []
141
+ for distance in distances:
142
+ index_1, index_2 = distance.atom_indices
143
+ atom_1 = universe.atoms[index_1 - 1].name
144
+ atom_2 = universe.atoms[index_2 - 1].name
145
+ labels.append(f"{atom_1} {atom_2}")
146
+ if distance.center is not None:
147
+ centers.append(float(distance.center))
148
+
149
+ return tuple(labels), tuple(centers)
150
+
151
+
152
+ def write_mdp_with_colvars(
153
+ fn_mdp: PathLike,
154
+ fn_colvars: PathLike,
155
+ fn_out: PathLike,
156
+ *,
157
+ seed: int | None = None,
158
+ ) -> None:
159
+ """Write an MDP file with GROMACS Colvars enabled.
160
+
161
+ Parameters
162
+ ----------
163
+ fn_mdp
164
+ Input MDP file.
165
+ fn_colvars
166
+ Colvars configuration file referenced from the generated MDP.
167
+ fn_out
168
+ Output MDP file with Colvars options injected.
169
+ seed
170
+ Optional seed passed to the Colvars module.
171
+ """
172
+ updates = {
173
+ "colvars-active": "yes",
174
+ "colvars-configfile": f"./{Path(fn_colvars).name}",
175
+ }
176
+ if seed is not None:
177
+ updates["colvars-seed"] = str(int(seed))
178
+ patch_mdp(fn_mdp, updates, fn_out)
179
+ with open(fn_out, "r+") as handle:
180
+ content = handle.read()
181
+ block = "\n; colvars\n"
182
+ if "colvars-active" in content:
183
+ content = content.replace("colvars-active", block + "colvars-active", 1)
184
+ handle.seek(0)
185
+ handle.write(content)
186
+ handle.truncate()
bff/io/commands.py ADDED
@@ -0,0 +1,20 @@
1
+ import shlex
2
+ from collections.abc import Sequence
3
+ from pathlib import Path
4
+
5
+
6
+ def split_command(command: str | Sequence[str]) -> list[str]:
7
+ """Normalize a shell-style command into a subprocess argument vector."""
8
+ if isinstance(command, str):
9
+ parts = shlex.split(command)
10
+ else:
11
+ parts = [str(part) for part in command]
12
+
13
+ if not parts:
14
+ raise ValueError("Command must not be empty.")
15
+ return parts
16
+
17
+
18
+ def build_command(command: str | Sequence[str], *args: str | Path) -> list[str]:
19
+ """Append stringified arguments to a normalized command vector."""
20
+ return [*split_command(command), *(str(arg) for arg in args)]
bff/io/cp2k.py ADDED
@@ -0,0 +1,330 @@
1
+ from copy import deepcopy
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import MDAnalysis as mda
6
+ import numpy as np
7
+
8
+ from ..data import CATIONS, CP2K_INPUT_TEMPLATE, CP2K_KIND_DEFAULTS
9
+
10
+
11
+ def format_cp2k_input(data: dict[str, Any], indent: int = 0) -> list[str]:
12
+ """Format a CP2K input tree as text.
13
+
14
+ Parameters
15
+ ----------
16
+ data
17
+ Nested mapping representing the CP2K input tree.
18
+ indent
19
+ Current indentation level.
20
+
21
+ Returns
22
+ -------
23
+ list of str
24
+ Formatted CP2K input lines.
25
+ """
26
+ lines: list[str] = []
27
+ prefix = " " * indent
28
+
29
+ for key, value in data.items():
30
+ section_name = key.upper()
31
+ section_end = key.split()[0].upper()
32
+
33
+ if isinstance(value, dict):
34
+ lines.append(f"{prefix}&{section_name}")
35
+ lines.extend(format_cp2k_input(value, indent + 2))
36
+ lines.append(f"{prefix}&END {section_end}")
37
+ continue
38
+
39
+ if isinstance(value, list):
40
+ for item in value:
41
+ if isinstance(item, dict):
42
+ lines.extend(format_cp2k_input({key: item}, indent))
43
+ elif isinstance(item, list):
44
+ lines.append(f"{prefix}{section_name} {' '.join(map(str, item))}")
45
+ else:
46
+ lines.append(f"{prefix}{section_name} {item}")
47
+ continue
48
+
49
+ if isinstance(value, bool):
50
+ value = ".TRUE." if value else ".FALSE."
51
+
52
+ lines.append(f"{prefix}{section_name} {value}")
53
+
54
+ return lines
55
+
56
+
57
+ def get_cp2k_elements(fn_pos: str | Path) -> np.ndarray:
58
+ """Return element symbols in coordinate-file order without duplicates."""
59
+ universe = mda.Universe(fn_pos)
60
+ elements, index = np.unique(universe.atoms.elements, return_index=True)
61
+ return elements[np.argsort(index)]
62
+
63
+
64
+ def build_cp2k_kinds(elements: np.ndarray) -> dict[str, dict[str, str]]:
65
+ """Build CP2K KIND sections for the elements present in a system."""
66
+ return {
67
+ f"kind {element.capitalize()}": CP2K_KIND_DEFAULTS[element.lower()]
68
+ for element in elements
69
+ }
70
+
71
+
72
+ def build_cp2k_d3_exclusions(elements: np.ndarray) -> list[list[int]]:
73
+ """Build D3 kind-pair exclusions for cation-containing systems."""
74
+ return [
75
+ [i + 1, j + 1]
76
+ for i, element in enumerate(elements)
77
+ if element.lower() in CATIONS
78
+ for j in range(len(elements))
79
+ ]
80
+
81
+
82
+ def configure_cp2k_restart(
83
+ tree: dict[str, Any],
84
+ *,
85
+ project: str,
86
+ restart: bool,
87
+ ) -> None:
88
+ """Configure CP2K SCF and restart sections."""
89
+ if restart:
90
+ tree["ext_restart"] = {"restart_file_name": f"{project}-1.restart"}
91
+ tree["force_eval"]["dft"]["scf"]["scf_guess"] = "RESTART"
92
+ tree["force_eval"]["dft"]["wfn_restart_file_name"] = (
93
+ f"{project}-RESTART.wfn"
94
+ )
95
+ return
96
+
97
+ tree["force_eval"]["dft"]["scf"]["scf_guess"] = "ATOMIC"
98
+
99
+
100
+ def configure_cp2k_ensemble(
101
+ tree: dict[str, Any],
102
+ *,
103
+ equilibration: bool,
104
+ ) -> None:
105
+ """Configure the MD ensemble for equilibration or production."""
106
+ tree["motion"]["md"]["ensemble"] = "LANGEVIN" if equilibration else "NVT"
107
+ if equilibration:
108
+ tree["motion"]["md"]["langevin"] = {"gamma": 0.02}
109
+ tree["motion"]["md"].pop("thermostat", None)
110
+ return
111
+
112
+ tree["motion"]["md"]["thermostat"] = {
113
+ "region": "GLOBAL",
114
+ "type": "CSVR",
115
+ "csvr": {"timecon": "[fs] 100"},
116
+ }
117
+ tree["motion"]["md"].pop("langevin", None)
118
+
119
+
120
+ def configure_cp2k_plumed(
121
+ tree: dict[str, Any],
122
+ *,
123
+ plumed_input_file: str | Path,
124
+ ) -> None:
125
+ """Configure CP2K to use a PLUMED input file for biased MD.
126
+
127
+ Parameters
128
+ ----------
129
+ tree
130
+ CP2K input tree to update.
131
+ plumed_input_file
132
+ PLUMED input filename referenced from the CP2K input.
133
+ """
134
+ tree["motion"]["free_energy"] = {
135
+ "metadyn": {
136
+ "use_plumed": True,
137
+ "plumed_input_file": Path(plumed_input_file).name,
138
+ }
139
+ }
140
+
141
+
142
+ def configure_cp2k_single_point(tree: dict[str, Any]) -> None:
143
+ """Configure a CP2K input tree for single-point energy/forces."""
144
+ tree["global"]["run_type"] = "ENERGY_FORCE"
145
+ tree.pop("motion", None)
146
+
147
+
148
+ def build_cp2k_tree(
149
+ *,
150
+ project: str,
151
+ charge: int,
152
+ multiplicity: int,
153
+ unitcell: list[float],
154
+ fn_pos: str | Path,
155
+ ) -> dict[str, Any]:
156
+ """Build the common CP2K input tree for one structure."""
157
+ fn_pos = Path(fn_pos)
158
+ elements = get_cp2k_elements(fn_pos)
159
+ tree = deepcopy(CP2K_INPUT_TEMPLATE)
160
+
161
+ tree["global"]["project_name"] = project
162
+ tree["force_eval"]["subsys"]["topology"] = {
163
+ "coord_file_name": fn_pos.name,
164
+ "coord_file_format": "XYZ",
165
+ }
166
+ tree["force_eval"]["subsys"]["cell"] = {
167
+ "ABC": f"[angstrom] {unitcell[0]} {unitcell[1]} {unitcell[2]}",
168
+ "periodic": "XYZ",
169
+ }
170
+ tree["force_eval"]["subsys"].update(build_cp2k_kinds(elements))
171
+
172
+ tree["force_eval"]["dft"]["charge"] = charge
173
+ tree["force_eval"]["dft"]["multiplicity"] = multiplicity
174
+ tree["force_eval"]["dft"]["uks"] = multiplicity != 1
175
+
176
+ if any(element.lower() in CATIONS for element in elements):
177
+ tree["force_eval"]["dft"]["xc"]["vdw_potential"]["pair_potential"][
178
+ "d3_exclude_kind_pair"
179
+ ] = build_cp2k_d3_exclusions(elements)
180
+
181
+ return tree
182
+
183
+
184
+ def make_cp2k_input(
185
+ project: str,
186
+ charge: int,
187
+ multiplicity: int,
188
+ unitcell: list[float],
189
+ fn_pos: str | Path,
190
+ equilibration: bool,
191
+ restart: bool,
192
+ fn_out: str | Path,
193
+ plumed_input_file: str | Path | None = None,
194
+ ) -> None:
195
+ """Generate a CP2K input file for one simulation window.
196
+
197
+ Parameters
198
+ ----------
199
+ project
200
+ Base project name.
201
+ charge
202
+ Total charge of the system.
203
+ multiplicity
204
+ Spin multiplicity.
205
+ unitcell
206
+ Unit-cell dimensions in Angstrom.
207
+ fn_pos
208
+ Path to the XYZ coordinate file.
209
+ equilibration
210
+ Whether to generate an equilibration input.
211
+ restart
212
+ Whether the CP2K run restarts from a previous step.
213
+ fn_out
214
+ Output CP2K input file.
215
+ plumed_input_file
216
+ Optional PLUMED input file used for biased CP2K MD.
217
+ """
218
+ tree = build_cp2k_tree(
219
+ project=project,
220
+ charge=charge,
221
+ multiplicity=multiplicity,
222
+ unitcell=unitcell,
223
+ fn_pos=fn_pos,
224
+ )
225
+ configure_cp2k_restart(tree, project=project, restart=restart)
226
+ configure_cp2k_ensemble(tree, equilibration=equilibration)
227
+ if plumed_input_file is not None:
228
+ configure_cp2k_plumed(tree, plumed_input_file=plumed_input_file)
229
+
230
+ with open(fn_out, "w") as handle:
231
+ handle.write("\n".join(format_cp2k_input(tree)) + "\n")
232
+
233
+
234
+ def make_cp2k_single_point_input(
235
+ project: str,
236
+ charge: int,
237
+ multiplicity: int,
238
+ unitcell: list[float],
239
+ fn_pos: str | Path,
240
+ fn_out: str | Path,
241
+ ) -> None:
242
+ """Generate a CP2K single-point input file."""
243
+ tree = build_cp2k_tree(
244
+ project=project,
245
+ charge=charge,
246
+ multiplicity=multiplicity,
247
+ unitcell=unitcell,
248
+ fn_pos=fn_pos,
249
+ )
250
+ configure_cp2k_single_point(tree)
251
+ tree["global"]["print_level"] = "MEDIUM"
252
+
253
+ with open(fn_out, "w") as handle:
254
+ handle.write("\n".join(format_cp2k_input(tree)) + "\n")
255
+
256
+
257
+ def write_cp2k_md_slurm_script(
258
+ fn_out: str | Path,
259
+ *,
260
+ uses_plumed: bool = False,
261
+ ) -> None:
262
+ """Write a restart-aware Slurm script for CP2K MD."""
263
+ plumed_block = ""
264
+ if uses_plumed:
265
+ plumed_block = """
266
+ if [ -f plumed.dat ]; then
267
+ echo "Using PLUMED input: plumed.dat"
268
+ fi
269
+ """
270
+
271
+ script = f"""#!/bin/bash
272
+ #SBATCH --job-name=cp2k-md
273
+ #SBATCH --nodes=1
274
+ #SBATCH --ntasks-per-node=8
275
+ #SBATCH --time=24:00:00
276
+ #SBATCH --output=slurm-%j.out
277
+
278
+ set -euo pipefail
279
+
280
+ CP2K_CMD="${{CP2K_CMD:-cp2k.psmp}}"
281
+ {plumed_block}
282
+
283
+ if [ ! -f .md-eq-start.done ]; then
284
+ ${{CP2K_CMD}} -i md-eq-start.inp -o md-eq-start.out
285
+ touch .md-eq-start.done
286
+ fi
287
+
288
+ if [ ! -f .md-eq-restart.done ]; then
289
+ ${{CP2K_CMD}} -i md-eq-restart.inp -o md-eq-restart.out
290
+ touch .md-eq-restart.done
291
+ fi
292
+
293
+ ${{CP2K_CMD}} -i md-prod.inp -o md-prod.out
294
+ """
295
+ Path(fn_out).write_text(script)
296
+
297
+
298
+ def write_cp2k_single_point_slurm_script(
299
+ fn_out: str | Path,
300
+ *,
301
+ snapshots_dirname: str = "snapshots",
302
+ ) -> None:
303
+ """Write a Slurm script for batched CP2K single-point evaluations."""
304
+ script = f"""#!/bin/bash
305
+ #SBATCH --job-name=cp2k-sp
306
+ #SBATCH --nodes=1
307
+ #SBATCH --ntasks-per-node=8
308
+ #SBATCH --time=24:00:00
309
+ #SBATCH --output=slurm-%j.out
310
+
311
+ set -euo pipefail
312
+
313
+ CP2K_CMD="${{CP2K_CMD:-cp2k.psmp}}"
314
+ SNAPSHOT_DIR="{snapshots_dirname}"
315
+ INPUT_TEMPLATE="single-point.inp"
316
+
317
+ for xyz in "${{SNAPSHOT_DIR}}"/snapshot-*.xyz; do
318
+ [ -e "${{xyz}}" ] || continue
319
+ stem=$(basename "${{xyz}}" .xyz)
320
+ run_dir="runs/${{stem}}"
321
+ mkdir -p "${{run_dir}}"
322
+ cp "${{INPUT_TEMPLATE}}" "${{run_dir}}/single-point.inp"
323
+ cp "${{xyz}}" "${{run_dir}}/snapshot.xyz"
324
+ (
325
+ cd "${{run_dir}}"
326
+ ${{CP2K_CMD}} -i single-point.inp -o single-point.out
327
+ )
328
+ done
329
+ """
330
+ Path(fn_out).write_text(script)