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
@@ -0,0 +1,788 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import warnings
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Dict, List, Optional, Sequence, Union
9
+
10
+ import MDAnalysis as mda
11
+ import numpy as np
12
+ from gmxtopology import Topology
13
+ from MDAnalysis import transformations as trans
14
+ from MDAnalysis.analysis.distances import distance_array
15
+ from MDAnalysis.selections.gromacs import SelectionWriter
16
+
17
+ from ..domain.bias import BiasSpec
18
+ from ..io.colvars import (
19
+ resolve_distance_bias_metadata as resolve_colvars_distance_bias_metadata,
20
+ )
21
+ from ..io.colvars import (
22
+ write_mdp_with_colvars,
23
+ )
24
+ from ..io.commands import build_command
25
+ from ..io.cp2k import (
26
+ make_cp2k_input,
27
+ make_cp2k_single_point_input,
28
+ write_cp2k_md_slurm_script,
29
+ write_cp2k_single_point_slurm_script,
30
+ )
31
+ from ..io.logs import Logger
32
+ from ..io.plumed import (
33
+ ensure_plumed_kernel,
34
+ )
35
+ from ..io.plumed import (
36
+ resolve_distance_bias_metadata as resolve_plumed_distance_bias_metadata,
37
+ )
38
+ from ..topology import create_box
39
+ from .configs import PrepareConfig
40
+
41
+ PathLike = Union[str, Path]
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class EquilibratedTopology:
46
+ """Shared topology state reused across simulation windows."""
47
+
48
+ fn_topol_processed: Path
49
+ fn_coord: Path
50
+ universe: mda.Universe
51
+ box: np.ndarray
52
+ maxwarn: int
53
+
54
+
55
+ def check_gmx_available(gmx_cmd: str = 'gmx') -> None:
56
+ """Check if the 'gmx' command is available in the system PATH."""
57
+ try:
58
+ subprocess.run(
59
+ build_command(gmx_cmd, '--version'),
60
+ stdout=subprocess.DEVNULL,
61
+ stderr=subprocess.DEVNULL,
62
+ check=True
63
+ )
64
+ except (subprocess.CalledProcessError, FileNotFoundError):
65
+ raise RuntimeError(
66
+ "GROMACS is not available.\n"
67
+ "Please make sure that your Gromacs executable is available "
68
+ "from the command line via e.g. gmx command."
69
+ )
70
+
71
+
72
+ def setup_directories(main_dir: PathLike = Path('./')) -> tuple[Path, Path, Path, Path]:
73
+ """Create required directories for the preparation workflow."""
74
+ main_dir = Path(main_dir).resolve()
75
+ equilibration_dir = main_dir / "equilibration"
76
+ training_dir = main_dir / "training"
77
+ reference_dir = main_dir / "reference"
78
+ reference_md_dir = reference_dir / "md"
79
+ reference_sp_dir = reference_dir / "single-points"
80
+
81
+ for directory in [
82
+ equilibration_dir,
83
+ training_dir,
84
+ reference_dir,
85
+ reference_md_dir,
86
+ reference_sp_dir,
87
+ ]:
88
+ directory.mkdir(parents=True, exist_ok=True)
89
+
90
+ return equilibration_dir, training_dir, reference_md_dir, reference_sp_dir
91
+
92
+
93
+ def make_ndx(universe: mda.Universe, selections: List[str], fn_out: str) -> None:
94
+ with SelectionWriter(fn_out, mode='w') as ndx:
95
+ ndx.write(universe.atoms, name='System')
96
+ if selections is not None:
97
+ for sel in selections:
98
+ for atom in sel.split():
99
+ group = universe.select_atoms('name ' + atom)
100
+ ndx.write(group, name=atom)
101
+
102
+
103
+ def determine_maxwarn(topol: Topology) -> int:
104
+ """Determine whether charged systems need relaxed ``grompp`` warnings."""
105
+ total_charge = sum(atom.charge for atom in topol.atoms)
106
+ return 1 if not np.isclose(total_charge, 0, atol=1e-4) else 0
107
+
108
+
109
+ def run_md(
110
+ name: str,
111
+ fn_mdp: PathLike,
112
+ fn_topol: PathLike,
113
+ fn_coord: PathLike,
114
+ fn_ndx: Optional[PathLike],
115
+ bias: BiasSpec | None = None,
116
+ gmx_cmd: str = "gmx",
117
+ n_steps: int = -2,
118
+ maxwarn: int = 0,
119
+ fn_log: PathLike = 'gmx.log'
120
+ ) -> Path:
121
+
122
+ fn_mdp_path = Path(fn_mdp).resolve()
123
+ fn_topol = str(Path(fn_topol).resolve())
124
+ fn_coord = str(Path(fn_coord).resolve())
125
+ fn_ndx = str(Path(fn_ndx).resolve()) if fn_ndx else None
126
+ fn_tpr = str(name) + '.tpr'
127
+ fn_mdp_run = fn_mdp_path
128
+ run_cwd = fn_mdp_path.parent
129
+ run_env = None
130
+ mdrun_cmd = [
131
+ *build_command(
132
+ gmx_cmd,
133
+ 'mdrun',
134
+ '-deffnm',
135
+ name,
136
+ '-nsteps',
137
+ str(n_steps),
138
+ ),
139
+ ]
140
+
141
+ if bias is not None and bias.kind == "colvars" and bias.input_file is not None:
142
+ fn_bias_local = Path(fn_mdp_path.parent) / Path(bias.input_file).name
143
+ if Path(bias.input_file).resolve() != fn_bias_local.resolve():
144
+ shutil.copy2(bias.input_file, fn_bias_local)
145
+ fn_mdp_run = Path(f"{name}-colvars.mdp").resolve()
146
+ run_cwd = fn_mdp_run.parent
147
+ write_mdp_with_colvars(fn_mdp_path, fn_bias_local, fn_mdp_run)
148
+ elif bias is not None and bias.kind == "plumed" and bias.input_file is not None:
149
+ kernel = ensure_plumed_kernel()
150
+ run_env = dict(os.environ)
151
+ run_env.setdefault("PLUMED_KERNEL", str(kernel))
152
+ mdrun_cmd.extend(["-plumed", str(bias.input_file)])
153
+
154
+ maxwarn = str(maxwarn)
155
+
156
+ grompp_cmd = [
157
+ *build_command(
158
+ gmx_cmd,
159
+ 'grompp',
160
+ '-f',
161
+ str(fn_mdp_run),
162
+ '-c',
163
+ fn_coord,
164
+ '-p',
165
+ fn_topol,
166
+ '-o',
167
+ fn_tpr,
168
+ '-maxwarn',
169
+ maxwarn,
170
+ ),
171
+ ]
172
+ if fn_ndx:
173
+ grompp_cmd.extend(['-n', fn_ndx])
174
+
175
+ with open(fn_log, "a") as f:
176
+ subprocess.run(grompp_cmd, stdout=f, stderr=f, check=True, cwd=run_cwd)
177
+ subprocess.run(
178
+ mdrun_cmd,
179
+ stdout=f,
180
+ stderr=f,
181
+ check=True,
182
+ env=run_env,
183
+ cwd=run_cwd,
184
+ )
185
+
186
+ return fn_mdp_run
187
+
188
+
189
+ def get_average_box(
190
+ universe: mda.Universe,
191
+ start: int = 0,
192
+ stop: int = None,
193
+ step: int = 1
194
+ ) -> np.ndarray:
195
+ """Compute the average box size over a trajectory."""
196
+ sl = slice(start, stop or universe.trajectory.n_frames, step)
197
+
198
+ box = np.zeros((len(universe.trajectory[sl]), 6))
199
+ for i, ts in enumerate(universe.trajectory[sl]):
200
+ box[i] = ts.dimensions
201
+ box_avg = np.round(np.mean(box, axis=0), 4)
202
+
203
+ return box_avg
204
+
205
+
206
+ def create_bias_window(
207
+ universe: mda.Universe,
208
+ pair_labels: List[str],
209
+ target_distances: List[float],
210
+ box: np.ndarray,
211
+ fn_out: PathLike,
212
+ ) -> None:
213
+ """Write the frame closest to the requested bias-window distances.
214
+
215
+ Parameters
216
+ ----------
217
+ universe
218
+ Equilibrated universe from which the best-matching frame is selected.
219
+ pair_labels
220
+ Atom-name pairs whose distances define the bias window.
221
+ target_distances
222
+ Target distances in nm corresponding to ``pair_labels``.
223
+ box
224
+ Unit-cell dimensions assigned to the output frame.
225
+ fn_out
226
+ Output coordinate file.
227
+ """
228
+ target_distances = np.array(target_distances, dtype=float)
229
+ ags = [[universe.select_atoms('name ' + x)
230
+ for x in a.split()] for a in pair_labels]
231
+ distances = np.array([
232
+ [distance_array(ag_pair[0], ag_pair[1], box=ts.dimensions)[0, 0]
233
+ for ag_pair in ags]
234
+ for ts in universe.trajectory
235
+ ])
236
+
237
+ target_distances_scaled = np.array(target_distances) * 10
238
+ idx = int(np.argmin(np.sum(np.abs(distances - target_distances_scaled), axis=1)))
239
+ with mda.Writer(str(fn_out), 'w') as w:
240
+ ts = universe.trajectory[idx]
241
+ ts.dimensions = box
242
+ w.write(universe.atoms)
243
+
244
+
245
+ def strip_topol(
246
+ fn_topol: PathLike,
247
+ fn_coords: PathLike,
248
+ fn_out_topol: PathLike,
249
+ *fn_out_coords: PathLike
250
+ ) -> None:
251
+ top = Topology(fn_topol)
252
+ u = mda.Universe(fn_topol, fn_coords, topology_format='ITP')
253
+
254
+ # Remove virtual sites from topology
255
+ for mol, _ in top.molecules.values():
256
+ mol.remove_vsites()
257
+ top.write(fn_out_topol, overwrite=True)
258
+
259
+ # Remove virtual sites from coordinates
260
+ atoms = u.select_atoms('not mass -1 to 0.5')
261
+ for fn_out in fn_out_coords:
262
+ atoms.write(fn_out, frames=u.trajectory[[-1]])
263
+
264
+
265
+ def prepare_bias_file(
266
+ bias: BiasSpec,
267
+ fn_out: PathLike,
268
+ ) -> Path | None:
269
+ """Copy a user-provided bias file for one simulation window.
270
+
271
+ Parameters
272
+ ----------
273
+ bias
274
+ Bias specification for the current window.
275
+ fn_out
276
+ Output bias file path.
277
+
278
+ Returns
279
+ -------
280
+ pathlib.Path or None
281
+ Path to the prepared bias file, or ``None`` for unbiased systems.
282
+ """
283
+ if not bias.is_biased or bias.input_file is None:
284
+ return None
285
+
286
+ fn_out = Path(fn_out).resolve()
287
+ shutil.copy2(bias.input_file, fn_out)
288
+ return fn_out
289
+
290
+
291
+ def localize_bias_spec(
292
+ bias: BiasSpec,
293
+ fn_input: Path | None,
294
+ ) -> BiasSpec:
295
+ """Return a bias spec that points to a prepared local bias input file."""
296
+ if fn_input is None or not bias.is_biased:
297
+ return bias
298
+ if bias.kind == "colvars":
299
+ return BiasSpec(kind="colvars", colvars_file=fn_input)
300
+ if bias.kind == "plumed":
301
+ return BiasSpec(kind="plumed", plumed_file=fn_input)
302
+ return bias
303
+
304
+
305
+ def resolve_bias_window_definition(
306
+ bias: BiasSpec,
307
+ fn_system: PathLike,
308
+ ) -> tuple[tuple[str, ...], tuple[float, ...]]:
309
+ """Resolve distance-based window metadata from a simple bias file.
310
+
311
+ Returns empty tuples for unbiased systems or for bias files that do not
312
+ expose simple distance centers.
313
+ """
314
+ if not bias.is_biased or bias.input_file is None:
315
+ return (), ()
316
+
317
+ if bias.kind == "colvars":
318
+ return resolve_colvars_distance_bias_metadata(bias.input_file, fn_system)
319
+ if bias.kind == "plumed":
320
+ return resolve_plumed_distance_bias_metadata(bias.input_file, fn_system)
321
+ return (), ()
322
+
323
+
324
+ def window_name(window_index: int) -> str:
325
+ """Return the canonical zero-padded window name."""
326
+ return f"window-{window_index:03d}"
327
+
328
+
329
+ def topology_name(topology_index: int) -> str:
330
+ """Return the canonical zero-padded topology name."""
331
+ return f"topology-{topology_index:03d}"
332
+
333
+
334
+ def sample_snapshot_indices(n_frames: int, n_snapshots: int) -> np.ndarray:
335
+ """Choose evenly spaced snapshot indices from a trajectory."""
336
+ if n_frames <= 0:
337
+ raise ValueError("Cannot sample snapshots from an empty trajectory.")
338
+ count = min(int(n_snapshots), int(n_frames))
339
+ return np.unique(np.linspace(0, n_frames - 1, num=count, dtype=int))
340
+
341
+
342
+ def write_snapshot_xyz_files(
343
+ universe: mda.Universe,
344
+ *,
345
+ snapshots_dir: Path,
346
+ n_snapshots: int,
347
+ ) -> list[Path]:
348
+ """Write evenly spaced XYZ snapshots from a trajectory."""
349
+ snapshots_dir.mkdir(parents=True, exist_ok=True)
350
+ indices = sample_snapshot_indices(universe.trajectory.n_frames, n_snapshots)
351
+ atoms = universe.select_atoms("not mass -1 to 0.5")
352
+ written: list[Path] = []
353
+
354
+ for output_index, frame_index in enumerate(indices):
355
+ fn_snapshot = snapshots_dir / f"snapshot-{output_index:04d}.xyz"
356
+ with warnings.catch_warnings():
357
+ warnings.filterwarnings(
358
+ "ignore",
359
+ message=r".*Reader has no dt information, set to 1.0 ps.*",
360
+ category=UserWarning,
361
+ )
362
+ atoms.write(fn_snapshot, frames=universe.trajectory[[frame_index]])
363
+ written.append(fn_snapshot)
364
+
365
+ return written
366
+
367
+
368
+ def write_window_coordinates(
369
+ universe: mda.Universe,
370
+ *,
371
+ box: np.ndarray,
372
+ fn_out: PathLike,
373
+ pair_labels: Sequence[str],
374
+ target_distances: Sequence[float],
375
+ ) -> None:
376
+ """Write coordinates for one NVT window."""
377
+ if pair_labels and target_distances:
378
+ create_bias_window(
379
+ universe,
380
+ list(pair_labels),
381
+ list(target_distances),
382
+ box,
383
+ fn_out,
384
+ )
385
+ return
386
+
387
+ with mda.Writer(fn_out, "w") as writer:
388
+ ts = universe.trajectory[-1]
389
+ ts.dimensions = box
390
+ writer.write(universe.atoms)
391
+
392
+
393
+ def window_run_name(window_index: int) -> str:
394
+ """Return the canonical filename stem for the prepared production run."""
395
+ return f"{window_name(window_index)}-prod"
396
+
397
+
398
+ def prepare_equilibrated_topology(
399
+ *,
400
+ topol_index: int,
401
+ fn_topol: Path,
402
+ templates: dict[str, Path],
403
+ box: list[float] | None,
404
+ fn_mdp_em: Path,
405
+ fn_mdp_npt: Path,
406
+ nsteps_npt: int,
407
+ equilibration_dir: Path,
408
+ gmx_cmd: str,
409
+ fn_gmx_log: Path,
410
+ logger: Logger,
411
+ ) -> EquilibratedTopology:
412
+ """Create, minimize, and optionally NpT-equilibrate one unique topology."""
413
+ topology_label = topology_name(topol_index)
414
+ fn_coord_box = equilibration_dir / f"{topology_label}-box.gro"
415
+
416
+ logger.info("Creating box: in progress...", overwrite=True, level=2)
417
+ universe, topol = create_box(
418
+ fn_topol,
419
+ templates,
420
+ fn_out=fn_coord_box,
421
+ box=box,
422
+ )
423
+ logger.info("Creating box: Done.", level=2)
424
+
425
+ fn_topol_processed = fn_coord_box.with_suffix(".top")
426
+ topol.write(fn_topol_processed, overwrite=True)
427
+ maxwarn = determine_maxwarn(topol)
428
+
429
+ deffnm_em = equilibration_dir / f"{topology_label}-em"
430
+ logger.info("Energy minimization: in progress...", overwrite=True, level=2)
431
+ run_md(
432
+ deffnm_em,
433
+ fn_mdp_em,
434
+ fn_topol_processed,
435
+ fn_coord_box,
436
+ fn_ndx=None,
437
+ gmx_cmd=gmx_cmd,
438
+ n_steps=-2,
439
+ maxwarn=maxwarn,
440
+ fn_log=fn_gmx_log,
441
+ )
442
+ logger.info("Energy minimization: Done.", level=2)
443
+
444
+ if nsteps_npt <= 0:
445
+ logger.info("NpT equilibration: skipped (box defined)", level=2)
446
+ return EquilibratedTopology(
447
+ fn_topol_processed=fn_topol_processed,
448
+ fn_coord=deffnm_em.with_suffix(".gro"),
449
+ universe=mda.Universe(
450
+ deffnm_em.with_suffix(".gro"),
451
+ to_guess=("elements", "masses"),
452
+ ),
453
+ box=np.asarray(universe.dimensions, dtype=float),
454
+ maxwarn=maxwarn,
455
+ )
456
+
457
+ deffnm_npt = equilibration_dir / f"{topology_label}-npt"
458
+ logger.info("NpT equilibration: in progress...", overwrite=True, level=2)
459
+ run_md(
460
+ deffnm_npt,
461
+ fn_mdp_npt,
462
+ fn_topol_processed,
463
+ deffnm_em.with_suffix(".gro"),
464
+ fn_ndx=None,
465
+ gmx_cmd=gmx_cmd,
466
+ n_steps=nsteps_npt,
467
+ maxwarn=maxwarn,
468
+ fn_log=fn_gmx_log,
469
+ )
470
+ universe = mda.Universe(
471
+ deffnm_npt.with_suffix(".gro"),
472
+ deffnm_npt.with_suffix(".xtc"),
473
+ to_guess=("elements", "masses"),
474
+ )
475
+ universe.trajectory.add_transformations(trans.unwrap(universe.atoms))
476
+ discard = int(universe.trajectory.n_frames * 0.2)
477
+ box_avg = get_average_box(universe, start=discard)
478
+ logger.info("NpT equilibration: Done.", level=2)
479
+
480
+ return EquilibratedTopology(
481
+ fn_topol_processed=fn_topol_processed,
482
+ fn_coord=deffnm_npt.with_suffix(".gro"),
483
+ universe=universe,
484
+ box=box_avg,
485
+ maxwarn=maxwarn,
486
+ )
487
+
488
+
489
+ def save_training_artifacts(
490
+ *,
491
+ window_index: int,
492
+ training_dir: Path,
493
+ fn_mdp_em: Path,
494
+ fn_mdp_npt: Path,
495
+ fn_mdp_prod: Path,
496
+ fn_coord: Path,
497
+ fn_ndx: Path,
498
+ fn_topol: Path,
499
+ fn_bias_input: Path | None,
500
+ bias: BiasSpec,
501
+ ) -> None:
502
+ """Save all training artifacts produced for one window."""
503
+ window_label = window_name(window_index)
504
+ asset_dir = training_dir / window_label
505
+ asset_dir.mkdir(parents=True, exist_ok=True)
506
+ for src, dst in [
507
+ (fn_mdp_em, asset_dir / f"{window_label}.em.mdp"),
508
+ (fn_mdp_npt, asset_dir / f"{window_label}.npt.mdp"),
509
+ (fn_mdp_prod, asset_dir / f"{window_label}.mdp"),
510
+ (fn_coord, asset_dir / f"{window_label}.gro"),
511
+ (fn_ndx, asset_dir / f"{window_label}.ndx"),
512
+ (fn_topol, asset_dir / f"{window_label}.top"),
513
+ ]:
514
+ shutil.copy2(src, dst)
515
+
516
+ for suffix in ("bias.colvars.dat", "bias.plumed.dat"):
517
+ stale_file = asset_dir / f"{window_label}.{suffix}"
518
+ if stale_file.exists():
519
+ stale_file.unlink()
520
+
521
+ if fn_bias_input is not None and bias.input_filename is not None:
522
+ fn_bias_train = asset_dir / f"{window_label}.{bias.input_filename}"
523
+ shutil.copy2(fn_bias_input, fn_bias_train)
524
+
525
+
526
+ def write_reference_md_assets(
527
+ *,
528
+ reference_md_dir: Path,
529
+ window_index: int,
530
+ project_name: str,
531
+ charge: int,
532
+ mult: int,
533
+ box: np.ndarray,
534
+ fn_topol_processed: Path,
535
+ fn_coord_nvt: Path,
536
+ bias: BiasSpec,
537
+ logger: Logger,
538
+ ) -> None:
539
+ """Write CP2K MD assets for one simulation window."""
540
+ logger.info("Reference MD assets: in progress...", overwrite=True, level=2)
541
+ window_dir = reference_md_dir / window_name(window_index)
542
+ window_dir.mkdir(parents=True, exist_ok=True)
543
+
544
+ strip_topol(
545
+ fn_topol_processed,
546
+ fn_coord_nvt,
547
+ window_dir / "system.top",
548
+ window_dir / "system.gro",
549
+ window_dir / "system.xyz",
550
+ )
551
+ fn_plumed = None
552
+ stale_plumed = window_dir / "plumed.dat"
553
+ if stale_plumed.exists():
554
+ stale_plumed.unlink()
555
+ if bias.kind == "plumed" and bias.input_file is not None:
556
+ fn_plumed = window_dir / "plumed.dat"
557
+ shutil.copy2(bias.input_file, fn_plumed)
558
+
559
+ for cp2k_specs in [
560
+ {"eq": True, "restart": False, "fn": "md-eq-start.inp"},
561
+ {"eq": True, "restart": True, "fn": "md-eq-restart.inp"},
562
+ {"eq": False, "restart": True, "fn": "md-prod.inp"},
563
+ ]:
564
+ make_cp2k_input(
565
+ project_name,
566
+ charge,
567
+ mult,
568
+ box[:3].astype(float).tolist(),
569
+ window_dir / "system.xyz",
570
+ cp2k_specs["eq"],
571
+ cp2k_specs["restart"],
572
+ window_dir / cp2k_specs["fn"],
573
+ plumed_input_file=fn_plumed,
574
+ )
575
+
576
+ fn_submit = window_dir / "submit-slurm.sh"
577
+ write_cp2k_md_slurm_script(fn_submit, uses_plumed=fn_plumed is not None)
578
+ fn_submit.chmod(0o755)
579
+ logger.info("Reference MD assets: Done.", level=2)
580
+
581
+
582
+ def write_reference_single_point_assets(
583
+ *,
584
+ reference_sp_dir: Path,
585
+ window_index: int,
586
+ project_name: str,
587
+ charge: int,
588
+ mult: int,
589
+ box: np.ndarray,
590
+ fn_topol_processed: Path,
591
+ fn_coord_nvt: Path,
592
+ fn_trj_nvt: Path,
593
+ n_snapshots: int,
594
+ logger: Logger,
595
+ ) -> None:
596
+ """Write CP2K single-point assets for one simulation window."""
597
+ logger.info(
598
+ "Reference single-point assets: in progress...",
599
+ overwrite=True,
600
+ level=2,
601
+ )
602
+ window_dir = reference_sp_dir / window_name(window_index)
603
+ snapshots_dir = window_dir / "snapshots"
604
+ runs_dir = window_dir / "runs"
605
+ window_dir.mkdir(parents=True, exist_ok=True)
606
+ runs_dir.mkdir(exist_ok=True)
607
+
608
+ with warnings.catch_warnings():
609
+ warnings.filterwarnings(
610
+ "ignore",
611
+ message=r".*Reader has no dt information, set to 1.0 ps.*",
612
+ category=UserWarning,
613
+ )
614
+ universe = mda.Universe(
615
+ fn_topol_processed,
616
+ fn_coord_nvt,
617
+ topology_format="ITP",
618
+ )
619
+ universe.load_new(fn_trj_nvt, dt=1)
620
+ snapshot_files = write_snapshot_xyz_files(
621
+ universe,
622
+ snapshots_dir=snapshots_dir,
623
+ n_snapshots=n_snapshots,
624
+ )
625
+ fn_template_xyz = window_dir / "snapshot.xyz"
626
+ shutil.copy2(snapshot_files[0], fn_template_xyz)
627
+ make_cp2k_single_point_input(
628
+ project_name,
629
+ charge,
630
+ mult,
631
+ box[:3].astype(float).tolist(),
632
+ fn_template_xyz,
633
+ window_dir / "single-point.inp",
634
+ )
635
+ fn_submit = window_dir / "submit-slurm.sh"
636
+ write_cp2k_single_point_slurm_script(fn_submit)
637
+ fn_submit.chmod(0o755)
638
+ logger.info("Reference single-point assets: Done.", level=2)
639
+
640
+
641
+ def main(fn_config: PathLike) -> None:
642
+ config = PrepareConfig.load(fn_config)
643
+ check_gmx_available(config.gmx_cmd)
644
+ if any(system.bias.kind == "plumed" for system in config.systems):
645
+ ensure_plumed_kernel()
646
+
647
+ fn_gmx_log = config.project_dir / "gmx.log"
648
+ fn_gmx_log.parent.mkdir(parents=True, exist_ok=True)
649
+
650
+ logger = Logger("prepare", str(config.fn_log) if config.fn_log else None)
651
+ logger.info("", level=0)
652
+ logger.info(f"=== Preparing project: {config.project_dir.name} ===\n", level=0)
653
+
654
+ (
655
+ equilibration_dir,
656
+ training_dir,
657
+ reference_md_dir,
658
+ reference_sp_dir,
659
+ ) = setup_directories(config.project_dir)
660
+
661
+ equilibrated_topologies: Dict[str, EquilibratedTopology] = {}
662
+
663
+ n_total = len(config.systems)
664
+ for i, system in enumerate(config.systems):
665
+ logger.info(f"System: {i + 1}/{n_total}", level=1)
666
+
667
+ topology_key = str(system.fn_topol)
668
+ if topology_key not in equilibrated_topologies:
669
+ topol_index = next(
670
+ j
671
+ for j, candidate in enumerate(config.systems)
672
+ if candidate.fn_topol == system.fn_topol
673
+ )
674
+ equilibrated_topologies[topology_key] = prepare_equilibrated_topology(
675
+ topol_index=topol_index,
676
+ fn_topol=system.fn_topol,
677
+ templates=system.templates,
678
+ box=system.box,
679
+ fn_mdp_em=system.fn_mdp_em,
680
+ fn_mdp_npt=system.fn_mdp_npt,
681
+ nsteps_npt=system.nsteps_npt,
682
+ equilibration_dir=equilibration_dir,
683
+ gmx_cmd=config.gmx_cmd,
684
+ fn_gmx_log=fn_gmx_log,
685
+ logger=logger,
686
+ )
687
+
688
+ topology_state = equilibrated_topologies[topology_key]
689
+
690
+ window_label = window_name(i)
691
+ fn_mdp_prod = system.fn_mdp_prod
692
+ fn_prod_local = equilibration_dir / f"{window_label}.mdp"
693
+ fn_ndx = equilibration_dir / f"{window_label}.ndx"
694
+ fn_coord = equilibration_dir / f"{window_label}.gro"
695
+ fn_bias_local = (
696
+ None
697
+ if system.bias.input_filename is None
698
+ else equilibration_dir / f"{window_label}.{system.bias.input_filename}"
699
+ )
700
+ fn_topol_local = equilibration_dir / f"{window_label}.top"
701
+ fn_topol_local.write_text(topology_state.fn_topol_processed.read_text())
702
+ fn_bias_input = None if fn_bias_local is None else prepare_bias_file(
703
+ system.bias,
704
+ fn_bias_local,
705
+ )
706
+ bias_run = localize_bias_spec(system.bias, fn_bias_input)
707
+ pair_labels, target_distances = resolve_bias_window_definition(
708
+ system.bias,
709
+ topology_state.fn_coord,
710
+ )
711
+
712
+ write_window_coordinates(
713
+ topology_state.universe,
714
+ box=topology_state.box,
715
+ fn_out=fn_coord,
716
+ pair_labels=pair_labels,
717
+ target_distances=target_distances,
718
+ )
719
+ make_ndx(topology_state.universe, None, fn_out=fn_ndx)
720
+ shutil.copy2(fn_mdp_prod, fn_prod_local)
721
+
722
+ deffnm_prod = equilibration_dir / window_run_name(i)
723
+ logger.info(
724
+ "Production preparation run: in progress...",
725
+ overwrite=True,
726
+ level=2,
727
+ )
728
+ run_md(
729
+ deffnm_prod,
730
+ fn_prod_local,
731
+ fn_topol_local,
732
+ fn_coord,
733
+ fn_ndx,
734
+ bias=bias_run,
735
+ gmx_cmd=config.gmx_cmd,
736
+ n_steps=system.nsteps_prod,
737
+ maxwarn=topology_state.maxwarn,
738
+ fn_log=fn_gmx_log,
739
+ )
740
+ logger.info("Production preparation run: Done.", level=2)
741
+
742
+ logger.info("Training MD assets: in progress...", overwrite=True, level=2)
743
+ save_training_artifacts(
744
+ window_index=i,
745
+ training_dir=training_dir,
746
+ fn_mdp_em=system.fn_mdp_em,
747
+ fn_mdp_npt=system.fn_mdp_npt,
748
+ fn_mdp_prod=system.fn_mdp_prod,
749
+ fn_coord=fn_coord,
750
+ fn_ndx=fn_ndx,
751
+ fn_topol=fn_topol_local,
752
+ fn_bias_input=fn_bias_input,
753
+ bias=system.bias,
754
+ )
755
+ logger.info("Training MD assets: Done.", level=2)
756
+ write_reference_md_assets(
757
+ reference_md_dir=reference_md_dir,
758
+ window_index=i,
759
+ project_name=config.project_dir.name,
760
+ charge=system.charge,
761
+ mult=system.mult,
762
+ box=topology_state.box,
763
+ fn_topol_processed=topology_state.fn_topol_processed,
764
+ fn_coord_nvt=deffnm_prod.with_suffix(".gro"),
765
+ bias=system.bias,
766
+ logger=logger,
767
+ )
768
+ write_reference_single_point_assets(
769
+ reference_sp_dir=reference_sp_dir,
770
+ window_index=i,
771
+ project_name=config.project_dir.name,
772
+ charge=system.charge,
773
+ mult=system.mult,
774
+ box=topology_state.box,
775
+ fn_topol_processed=topology_state.fn_topol_processed,
776
+ fn_coord_nvt=deffnm_prod.with_suffix(".gro"),
777
+ fn_trj_nvt=deffnm_prod.with_suffix(".xtc"),
778
+ n_snapshots=config.n_single_point_snapshots,
779
+ logger=logger,
780
+ )
781
+ logger.info("", level=0)
782
+
783
+
784
+ if __name__ == "__main__":
785
+ parser = argparse.ArgumentParser(description="Prepare simulation assets.")
786
+ parser.add_argument('fn_config', help='Path to the config file [YAML].')
787
+ args = parser.parse_args()
788
+ main(args.fn_config)