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.
- bff/__init__.py +30 -0
- bff/bayes/__init__.py +11 -0
- bff/bayes/gaussian_process.py +285 -0
- bff/bayes/kernels.py +71 -0
- bff/bayes/learning.py +484 -0
- bff/bayes/likelihoods.py +142 -0
- bff/bayes/posterior.py +62 -0
- bff/bayes/priors.py +179 -0
- bff/bayes/results.py +327 -0
- bff/bayes/utils.py +333 -0
- bff/cli.py +254 -0
- bff/data.py +93 -0
- bff/domain/__init__.py +12 -0
- bff/domain/bias.py +149 -0
- bff/domain/specs.py +347 -0
- bff/domain/trainset.py +201 -0
- bff/io/__init__.py +12 -0
- bff/io/colvars.py +186 -0
- bff/io/commands.py +20 -0
- bff/io/cp2k.py +330 -0
- bff/io/logs.py +213 -0
- bff/io/mdp.py +98 -0
- bff/io/plumed.py +184 -0
- bff/io/progress.py +74 -0
- bff/io/schedulers.py +118 -0
- bff/io/utils.py +208 -0
- bff/mcmc/__init__.py +3 -0
- bff/mcmc/convergence.py +168 -0
- bff/mcmc/proposal.py +254 -0
- bff/mcmc/sampler.py +481 -0
- bff/plotting.py +362 -0
- bff/qoi/__init__.py +9 -0
- bff/qoi/analysis.py +209 -0
- bff/qoi/data.py +169 -0
- bff/qoi/hbonds.py +255 -0
- bff/qoi/rdf.py +108 -0
- bff/qoi/routines.py +272 -0
- bff/tools.py +168 -0
- bff/topology.py +581 -0
- bff/workflows/__init__.py +0 -0
- bff/workflows/configs.py +1147 -0
- bff/workflows/learn.py +77 -0
- bff/workflows/md.py +326 -0
- bff/workflows/prepare.py +788 -0
- bff/workflows/qoi.py +260 -0
- bff/workflows/runsims.py +620 -0
- bff/workflows/simulate.py +28 -0
- bff/workflows/train.py +69 -0
- bff/workflows/validate.py +29 -0
- bfflearn-0.1.0.dev1.dist-info/METADATA +913 -0
- bfflearn-0.1.0.dev1.dist-info/RECORD +55 -0
- bfflearn-0.1.0.dev1.dist-info/WHEEL +5 -0
- bfflearn-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- bfflearn-0.1.0.dev1.dist-info/licenses/LICENSE +674 -0
- bfflearn-0.1.0.dev1.dist-info/top_level.txt +1 -0
bff/io/logs.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
from ..mcmc.sampler import Sampler
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Logger:
|
|
10
|
+
"""
|
|
11
|
+
A logging utility that supports logging to a file
|
|
12
|
+
or console without duplicate output.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
name: str,
|
|
18
|
+
fn_log: Optional[str] = None,
|
|
19
|
+
width: Optional[int] = 100,
|
|
20
|
+
verbose: bool = True,
|
|
21
|
+
mode: str = "a",
|
|
22
|
+
) -> None:
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
name : str
|
|
28
|
+
Name of the logger.
|
|
29
|
+
fn_log : Optional[str], optional
|
|
30
|
+
Filename to log messages to. If None, logs to console.
|
|
31
|
+
width : Optional[int], default=100
|
|
32
|
+
Width for message formatting.
|
|
33
|
+
verbose : bool, default=True
|
|
34
|
+
If False, suppresses logging output.
|
|
35
|
+
mode : str, default="a"
|
|
36
|
+
File open mode used when ``fn_log`` is provided.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
self.fn_log = fn_log
|
|
40
|
+
self.width = width
|
|
41
|
+
self.verbose = verbose
|
|
42
|
+
|
|
43
|
+
self.logger = logging.getLogger(name)
|
|
44
|
+
self.logger.setLevel(logging.INFO)
|
|
45
|
+
|
|
46
|
+
# Remove old handlers if re-running in Jupyter
|
|
47
|
+
if self.logger.hasHandlers():
|
|
48
|
+
self.logger.handlers.clear()
|
|
49
|
+
|
|
50
|
+
formatter = logging.Formatter("%(message)s")
|
|
51
|
+
|
|
52
|
+
if fn_log:
|
|
53
|
+
handler = logging.FileHandler(fn_log, mode=mode)
|
|
54
|
+
else:
|
|
55
|
+
handler = logging.StreamHandler()
|
|
56
|
+
handler.terminator = ""
|
|
57
|
+
handler.setFormatter(formatter)
|
|
58
|
+
self.logger.addHandler(handler)
|
|
59
|
+
|
|
60
|
+
self.logger.propagate = False # Prevent double logging
|
|
61
|
+
|
|
62
|
+
def info(self, message: str, level: int, overwrite: bool = False) -> None:
|
|
63
|
+
"""
|
|
64
|
+
Logs a message to a file or console, optionally overwriting the console output.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
message : str
|
|
69
|
+
The message to log.
|
|
70
|
+
overwrite : bool, default=False
|
|
71
|
+
If True, the message overwrites the previous console output (stdout only).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# determine width of the line automatically if not set
|
|
75
|
+
width = len(message) + 10 if self.width is None else self.width
|
|
76
|
+
|
|
77
|
+
if self.verbose:
|
|
78
|
+
if level < 1:
|
|
79
|
+
start = ''
|
|
80
|
+
elif level == 1:
|
|
81
|
+
start = '> '
|
|
82
|
+
else:
|
|
83
|
+
start = ' ' * 2 * (level - 1) + '- '
|
|
84
|
+
message = start + f"{message}".ljust(width)
|
|
85
|
+
if self.fn_log:
|
|
86
|
+
self.logger.info(message) # Always log to file if specified
|
|
87
|
+
|
|
88
|
+
else:
|
|
89
|
+
terminator = '\r' if overwrite else '\n'
|
|
90
|
+
self.logger.info(message + terminator) # Log to logger (console)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def print_progress_mcmc(
|
|
94
|
+
sampler: Sampler,
|
|
95
|
+
p0: np.ndarray,
|
|
96
|
+
*,
|
|
97
|
+
total_steps: int,
|
|
98
|
+
restart: bool = True,
|
|
99
|
+
fn_checkpoint: Optional[str] = None,
|
|
100
|
+
logger: Optional[Logger] = None,
|
|
101
|
+
**kwargs
|
|
102
|
+
) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Monitors and logs the convergence progress of an MCMC sampler.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
sampler : Sampler
|
|
109
|
+
The MCMC sampler with methods
|
|
110
|
+
`sample`, `get_autocorr_time`, and attribute `iteration`.
|
|
111
|
+
p0 : array-like
|
|
112
|
+
Initial parameter vector for the MCMC posterior sampling.
|
|
113
|
+
total_steps : int
|
|
114
|
+
Total number of MCMC iterations including warmup.
|
|
115
|
+
stride : int, default=100
|
|
116
|
+
Frequency of autocorrelation time checks and printing.
|
|
117
|
+
min_chain_length : int, default=100
|
|
118
|
+
Factor to multiply the autocorrelation time with in order to
|
|
119
|
+
get the minimum chain length to have proper sampling.
|
|
120
|
+
rtol : float, default=0.01
|
|
121
|
+
Relative tolerance of the autocorrelation time fluctuations
|
|
122
|
+
for the MCMC sampling to be considered converged.
|
|
123
|
+
logger : Logger, optional
|
|
124
|
+
Logger instance for logging progress messages.
|
|
125
|
+
**kwargs
|
|
126
|
+
Additional arguments passed to the sampler's `sample` method.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
logger = logger or Logger("mcmc-progress")
|
|
130
|
+
|
|
131
|
+
rhat_tol = kwargs.get("rhat_tol", 1.01)
|
|
132
|
+
ess_target = kwargs.get("ess_min", 100)
|
|
133
|
+
total_digits = len(str(total_steps))
|
|
134
|
+
warmup_digits = len(str(kwargs.get("warmup", total_steps)))
|
|
135
|
+
sampling_digits = len(str(max(total_steps - kwargs.get("warmup", 0), 0)))
|
|
136
|
+
ess_digits = max(len(str(int(ess_target))), 1)
|
|
137
|
+
|
|
138
|
+
def _fmt_progress(current: int, total: int, width: int, label: str) -> str:
|
|
139
|
+
return f"{label}: {current:>{width}d}/{total:<{width}d}"
|
|
140
|
+
|
|
141
|
+
if fn_checkpoint is not None:
|
|
142
|
+
logger.info(f"Checkpoint: {fn_checkpoint}", level=2)
|
|
143
|
+
|
|
144
|
+
logger.info(
|
|
145
|
+
(
|
|
146
|
+
f"Posterior sampling: it. {0:>{total_digits}d}/"
|
|
147
|
+
f"{total_steps:<{total_digits}d}"
|
|
148
|
+
),
|
|
149
|
+
level=1,
|
|
150
|
+
overwrite=True,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
line = ""
|
|
154
|
+
for state in sampler.run(
|
|
155
|
+
p0,
|
|
156
|
+
total_steps=total_steps,
|
|
157
|
+
restart=restart,
|
|
158
|
+
fn_checkpoint=fn_checkpoint,
|
|
159
|
+
**kwargs,
|
|
160
|
+
):
|
|
161
|
+
total_steps = state.total_steps
|
|
162
|
+
sampling_steps = total_steps - state.warmup
|
|
163
|
+
if state.phase == "warmup":
|
|
164
|
+
phase_progress = _fmt_progress(
|
|
165
|
+
state.step,
|
|
166
|
+
state.warmup,
|
|
167
|
+
warmup_digits,
|
|
168
|
+
"warmup",
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
sampling_step = max(state.step - state.warmup, 0)
|
|
172
|
+
phase_progress = _fmt_progress(
|
|
173
|
+
sampling_step,
|
|
174
|
+
sampling_steps,
|
|
175
|
+
sampling_digits,
|
|
176
|
+
"sampling",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if state.phase == "sampling" and state.convergence is not None:
|
|
180
|
+
line = (
|
|
181
|
+
f"Posterior sampling: it. "
|
|
182
|
+
f"{state.step:>{total_digits}d}/{total_steps:<{total_digits}d} | "
|
|
183
|
+
f"{phase_progress} | "
|
|
184
|
+
f"R-hat max: {state.convergence.max_rhat:.4f}/{rhat_tol:.4f} | "
|
|
185
|
+
f"ESS min: {state.convergence.min_ess:>{ess_digits}.0f}/"
|
|
186
|
+
f"{ess_target:<{ess_digits}.0f}"
|
|
187
|
+
)
|
|
188
|
+
if state.acceptance_rate is not None:
|
|
189
|
+
line += f" | acc: {state.acceptance_rate:.3f}"
|
|
190
|
+
if state.it_per_sec is not None:
|
|
191
|
+
line += f" | {state.it_per_sec:>3.0f} it/s"
|
|
192
|
+
else:
|
|
193
|
+
line = (
|
|
194
|
+
f"Posterior sampling: it. "
|
|
195
|
+
f"{state.step:>{total_digits}d}/{total_steps:<{total_digits}d} | "
|
|
196
|
+
f"{phase_progress}"
|
|
197
|
+
)
|
|
198
|
+
if state.acceptance_rate is not None:
|
|
199
|
+
line += f" | acc: {state.acceptance_rate:.3f}"
|
|
200
|
+
if state.it_per_sec is not None:
|
|
201
|
+
line += f" | {state.it_per_sec:>3.0f} it/s"
|
|
202
|
+
logger.info(line, level=1, overwrite=True)
|
|
203
|
+
|
|
204
|
+
if line and logger.fn_log is None:
|
|
205
|
+
logger.info(line, level=1)
|
|
206
|
+
logger.info("", level=0)
|
|
207
|
+
if sampler.converged:
|
|
208
|
+
logger.info("Posterior sampling: Done.", level=1)
|
|
209
|
+
else:
|
|
210
|
+
logger.info(
|
|
211
|
+
"Posterior sampling: Failed to converge within the maximum iterations.",
|
|
212
|
+
level=1,
|
|
213
|
+
)
|
bff/io/mdp.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from collections import OrderedDict
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Mapping, Union
|
|
4
|
+
|
|
5
|
+
PathLike = Union[str, Path]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def read_mdp(fn_mdp: PathLike) -> OrderedDict[str, str]:
|
|
9
|
+
"""Read a GROMACS MDP file while preserving comments and blank lines.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
fn_mdp
|
|
14
|
+
Path to the input MDP file.
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
collections.OrderedDict
|
|
19
|
+
Ordered mapping of MDP keys to values. Comment and blank lines are
|
|
20
|
+
stored under synthetic keys starting with ``C`` and ``B``.
|
|
21
|
+
"""
|
|
22
|
+
content: OrderedDict[str, str] = OrderedDict()
|
|
23
|
+
i_comment = 0
|
|
24
|
+
i_blank = 0
|
|
25
|
+
with open(fn_mdp, "r") as handle:
|
|
26
|
+
for line in handle:
|
|
27
|
+
if line.startswith(";"):
|
|
28
|
+
content[f"C{i_comment:03d}"] = line
|
|
29
|
+
i_comment += 1
|
|
30
|
+
elif line.strip() == "":
|
|
31
|
+
content[f"B{i_blank:03d}"] = line
|
|
32
|
+
i_blank += 1
|
|
33
|
+
else:
|
|
34
|
+
key, value = line.split("=", 1)
|
|
35
|
+
content[key.strip()] = value.strip()
|
|
36
|
+
return content
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_mdp(content: Mapping[str, str], fn_out: PathLike) -> None:
|
|
40
|
+
"""Write an MDP mapping back to disk.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
content
|
|
45
|
+
Ordered MDP mapping produced by :func:`read_mdp`.
|
|
46
|
+
fn_out
|
|
47
|
+
Output MDP path.
|
|
48
|
+
"""
|
|
49
|
+
with open(fn_out, "w") as handle:
|
|
50
|
+
for key, value in content.items():
|
|
51
|
+
if key.startswith("C") or key.startswith("B"):
|
|
52
|
+
handle.write(value)
|
|
53
|
+
else:
|
|
54
|
+
handle.write(f"{key:<25} = {value}\n")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def patch_mdp(
|
|
58
|
+
fn_mdp: PathLike,
|
|
59
|
+
updates: Mapping[str, str],
|
|
60
|
+
fn_out: PathLike,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Write a modified MDP file with a small set of updated parameters.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
fn_mdp
|
|
67
|
+
Input MDP file.
|
|
68
|
+
updates
|
|
69
|
+
Mapping of MDP keys to replacement values.
|
|
70
|
+
fn_out
|
|
71
|
+
Output MDP file.
|
|
72
|
+
"""
|
|
73
|
+
content = read_mdp(fn_mdp)
|
|
74
|
+
for key, value in updates.items():
|
|
75
|
+
content[str(key)] = str(value)
|
|
76
|
+
write_mdp(content, fn_out)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_n_frames_target(fn_mdp: PathLike) -> tuple[int | None, int | None]:
|
|
80
|
+
"""Extract the expected number of saved trajectory frames.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
fn_mdp
|
|
85
|
+
Input MDP file.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
tuple
|
|
90
|
+
Number of saved frames and trajectory stride. If ``nsteps`` is zero or
|
|
91
|
+
missing, both entries are returned as ``None``.
|
|
92
|
+
"""
|
|
93
|
+
mdp_data = read_mdp(fn_mdp)
|
|
94
|
+
n_steps = int(mdp_data.get("nsteps", 0))
|
|
95
|
+
if n_steps <= 0:
|
|
96
|
+
return None, None
|
|
97
|
+
stride = int(mdp_data["nstxout-compressed"])
|
|
98
|
+
return int(n_steps / stride), stride
|
bff/io/plumed.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import MDAnalysis as mda
|
|
9
|
+
|
|
10
|
+
PathLike = str | Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class PlumedDistanceBias:
|
|
15
|
+
"""Parsed distance bias from a PLUMED file.
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
name
|
|
20
|
+
Name of the distance collective variable.
|
|
21
|
+
atom_indices
|
|
22
|
+
One-based atom indices defining the distance.
|
|
23
|
+
center
|
|
24
|
+
Optional restraint center in nm.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
atom_indices: tuple[int, int]
|
|
29
|
+
center: float | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_arguments(text: str) -> dict[str, str]:
|
|
33
|
+
"""Parse key-value arguments from one PLUMED line."""
|
|
34
|
+
pairs = re.findall(r"([A-Za-z_]+)=([^\s]+)", text)
|
|
35
|
+
return {key.upper(): value for key, value in pairs}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_plumed_kernel() -> Path | None:
|
|
39
|
+
"""Locate a PLUMED kernel library if one is available.
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
pathlib.Path or None
|
|
44
|
+
Path to a loadable PLUMED kernel library, or ``None`` if one could
|
|
45
|
+
not be located from the current environment.
|
|
46
|
+
"""
|
|
47
|
+
env_value = os.environ.get("PLUMED_KERNEL")
|
|
48
|
+
if env_value:
|
|
49
|
+
env_path = Path(env_value).expanduser().resolve()
|
|
50
|
+
if env_path.exists():
|
|
51
|
+
return env_path
|
|
52
|
+
|
|
53
|
+
candidate_dirs = [
|
|
54
|
+
Path(sys.prefix) / "lib",
|
|
55
|
+
Path(sys.prefix).resolve().parent / "lib",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
plumed_executable = shutil.which("plumed")
|
|
59
|
+
if plumed_executable is not None:
|
|
60
|
+
candidate_dirs.append(Path(plumed_executable).resolve().parents[1] / "lib")
|
|
61
|
+
|
|
62
|
+
seen: set[Path] = set()
|
|
63
|
+
for lib_dir in candidate_dirs:
|
|
64
|
+
lib_dir = lib_dir.resolve()
|
|
65
|
+
if lib_dir in seen or not lib_dir.exists():
|
|
66
|
+
continue
|
|
67
|
+
seen.add(lib_dir)
|
|
68
|
+
candidates = sorted(lib_dir.glob("libplumedKernel*"))
|
|
69
|
+
if candidates:
|
|
70
|
+
return candidates[0]
|
|
71
|
+
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ensure_plumed_kernel() -> Path:
|
|
76
|
+
"""Ensure that a PLUMED kernel is available for biased MD runs.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
pathlib.Path
|
|
81
|
+
Path to the PLUMED kernel library.
|
|
82
|
+
|
|
83
|
+
Raises
|
|
84
|
+
------
|
|
85
|
+
RuntimeError
|
|
86
|
+
If no PLUMED kernel can be located.
|
|
87
|
+
"""
|
|
88
|
+
kernel = find_plumed_kernel()
|
|
89
|
+
if kernel is None:
|
|
90
|
+
raise RuntimeError(
|
|
91
|
+
"PLUMED biasing requires a loadable PLUMED kernel. "
|
|
92
|
+
"Set the PLUMED_KERNEL environment variable or install PLUMED "
|
|
93
|
+
"so that libplumedKernel is discoverable."
|
|
94
|
+
)
|
|
95
|
+
return kernel
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_distance_biases(fn_plumed: PathLike) -> list[PlumedDistanceBias]:
|
|
99
|
+
"""Parse simple distance restraints from a PLUMED input file.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
fn_plumed
|
|
104
|
+
Path to the PLUMED input file.
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
list of PlumedDistanceBias
|
|
109
|
+
Parsed distance collective variables with optional restraint centers.
|
|
110
|
+
"""
|
|
111
|
+
lines = Path(fn_plumed).read_text().splitlines()
|
|
112
|
+
distances: dict[str, tuple[int, int]] = {}
|
|
113
|
+
centers: dict[str, float] = {}
|
|
114
|
+
|
|
115
|
+
for raw_line in lines:
|
|
116
|
+
line = raw_line.split("#", maxsplit=1)[0].strip()
|
|
117
|
+
if not line or ":" not in line:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
name, expression = [part.strip() for part in line.split(":", maxsplit=1)]
|
|
121
|
+
keyword = expression.split(maxsplit=1)[0].upper()
|
|
122
|
+
args = _parse_arguments(expression)
|
|
123
|
+
|
|
124
|
+
if keyword == "DISTANCE" and "ATOMS" in args:
|
|
125
|
+
atom_indices = tuple(int(value) for value in args["ATOMS"].split(","))
|
|
126
|
+
if len(atom_indices) == 2:
|
|
127
|
+
distances[name] = atom_indices # type: ignore[assignment]
|
|
128
|
+
continue
|
|
129
|
+
|
|
130
|
+
if keyword in {"RESTRAINT", "UPPER_WALLS", "LOWER_WALLS"}:
|
|
131
|
+
arg_name = args.get("ARG")
|
|
132
|
+
if arg_name is None:
|
|
133
|
+
continue
|
|
134
|
+
center_value = args.get("AT")
|
|
135
|
+
if center_value is None:
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
centers[arg_name] = float(center_value.split(",")[0])
|
|
139
|
+
except ValueError:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
return [
|
|
143
|
+
PlumedDistanceBias(
|
|
144
|
+
name=name,
|
|
145
|
+
atom_indices=atom_indices,
|
|
146
|
+
center=centers.get(name),
|
|
147
|
+
)
|
|
148
|
+
for name, atom_indices in distances.items()
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def resolve_distance_bias_metadata(
|
|
153
|
+
fn_plumed: PathLike,
|
|
154
|
+
fn_system: PathLike,
|
|
155
|
+
) -> tuple[tuple[str, ...], tuple[float, ...]]:
|
|
156
|
+
"""Resolve atom-name pairs and centers from a PLUMED file.
|
|
157
|
+
|
|
158
|
+
Parameters
|
|
159
|
+
----------
|
|
160
|
+
fn_plumed
|
|
161
|
+
Path to the PLUMED input file.
|
|
162
|
+
fn_system
|
|
163
|
+
Structure file used to map atom indices to atom names.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
tuple
|
|
168
|
+
Tuple of ``(pair_labels, centers)`` where labels are atom-name pairs
|
|
169
|
+
and centers are restraint centers in nm.
|
|
170
|
+
"""
|
|
171
|
+
universe = mda.Universe(str(fn_system))
|
|
172
|
+
distances = parse_distance_biases(fn_plumed)
|
|
173
|
+
|
|
174
|
+
labels: list[str] = []
|
|
175
|
+
centers: list[float] = []
|
|
176
|
+
for distance in distances:
|
|
177
|
+
index_1, index_2 = distance.atom_indices
|
|
178
|
+
atom_1 = universe.atoms[index_1 - 1].name
|
|
179
|
+
atom_2 = universe.atoms[index_2 - 1].name
|
|
180
|
+
labels.append(f"{atom_1} {atom_2}")
|
|
181
|
+
if distance.center is not None:
|
|
182
|
+
centers.append(distance.center)
|
|
183
|
+
|
|
184
|
+
return tuple(labels), tuple(centers)
|
bff/io/progress.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Iterable, Iterator, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def format_time(seconds: float) -> str:
|
|
8
|
+
"""Format seconds as hours, minutes, and seconds."""
|
|
9
|
+
hours, remainder = divmod(seconds, 3600)
|
|
10
|
+
minutes, seconds = divmod(remainder, 60)
|
|
11
|
+
|
|
12
|
+
if hours > 0:
|
|
13
|
+
return f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
|
14
|
+
if minutes > 0:
|
|
15
|
+
return f"{int(minutes)}m {int(seconds)}s"
|
|
16
|
+
return f"{int(seconds)}s"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def iter_progress(
|
|
20
|
+
iterable: Iterable[T],
|
|
21
|
+
*,
|
|
22
|
+
total: int,
|
|
23
|
+
logger: Any,
|
|
24
|
+
label: str,
|
|
25
|
+
stride: int = 1,
|
|
26
|
+
) -> Iterator[T]:
|
|
27
|
+
"""Yield items while logging coarse progress updates."""
|
|
28
|
+
if stride < 1:
|
|
29
|
+
raise ValueError("'stride' must be a positive integer.")
|
|
30
|
+
if total < 0:
|
|
31
|
+
raise ValueError("'total' must be non-negative.")
|
|
32
|
+
if total == 0:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
start_time = time.time()
|
|
36
|
+
pad = len(str(total))
|
|
37
|
+
template = (
|
|
38
|
+
f"{label}: " + "{i:>{pad}}/{total} "
|
|
39
|
+
"({percent:3.0f}%) | {elapsed} < {eta}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger.info(
|
|
43
|
+
template.format(
|
|
44
|
+
i=0,
|
|
45
|
+
pad=pad,
|
|
46
|
+
total=total,
|
|
47
|
+
percent=0,
|
|
48
|
+
elapsed="0s",
|
|
49
|
+
eta="0s",
|
|
50
|
+
),
|
|
51
|
+
level=1,
|
|
52
|
+
overwrite=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
for i, item in enumerate(iterable, start=1):
|
|
56
|
+
yield item
|
|
57
|
+
|
|
58
|
+
if (i % stride != 0) and (i != total):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
elapsed_time = time.time() - start_time
|
|
62
|
+
eta = 0.0 if i == 0 else (elapsed_time / i) * max(total - i, 0)
|
|
63
|
+
logger.info(
|
|
64
|
+
template.format(
|
|
65
|
+
i=i,
|
|
66
|
+
pad=pad,
|
|
67
|
+
total=total,
|
|
68
|
+
percent=100 * i / total if total else 100,
|
|
69
|
+
elapsed=format_time(elapsed_time),
|
|
70
|
+
eta=format_time(eta),
|
|
71
|
+
),
|
|
72
|
+
level=1,
|
|
73
|
+
overwrite=True,
|
|
74
|
+
)
|
bff/io/schedulers.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Slurm:
|
|
6
|
+
"""
|
|
7
|
+
A class to generate and save SLURM job submission scripts.
|
|
8
|
+
|
|
9
|
+
Parameters
|
|
10
|
+
----------
|
|
11
|
+
job_name : str
|
|
12
|
+
Name of the SLURM job.
|
|
13
|
+
nodes : int
|
|
14
|
+
Number of nodes to allocate.
|
|
15
|
+
ntasks_per_node : int
|
|
16
|
+
Number of tasks per node.
|
|
17
|
+
cpus_per_task : int
|
|
18
|
+
Number of CPUs per task.
|
|
19
|
+
time : str
|
|
20
|
+
Maximum wall time in the format 'HH:MM:SS'.
|
|
21
|
+
partition : str
|
|
22
|
+
Partition or queue name.
|
|
23
|
+
mem : str
|
|
24
|
+
Memory allocation (e.g., '4G', '16G').
|
|
25
|
+
output : str, optional
|
|
26
|
+
Path to the output file, default is './slurm-files/slurm-%x.%j.out'.
|
|
27
|
+
|
|
28
|
+
Attributes
|
|
29
|
+
----------
|
|
30
|
+
commands : list
|
|
31
|
+
List of shell commands to execute in the SLURM script.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, **sbatch):
|
|
35
|
+
"""
|
|
36
|
+
Initialize the class with SBATCH parameters.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
**sbatch : dict
|
|
41
|
+
Arbitrary keyword arguments representing SBATCH parameters.
|
|
42
|
+
Each key-value pair will be formatted as '#SBATCH --key=value'.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
self.sbatch = [
|
|
46
|
+
f"#SBATCH --{key.replace('_', '-')}={value}"
|
|
47
|
+
for key, value in sbatch.items()
|
|
48
|
+
]
|
|
49
|
+
self.commands = []
|
|
50
|
+
self.fname = None
|
|
51
|
+
|
|
52
|
+
def add_command(self, command: str) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Add a shell command to the script.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
command : str
|
|
59
|
+
The shell command to add.
|
|
60
|
+
"""
|
|
61
|
+
self.commands.append(command)
|
|
62
|
+
|
|
63
|
+
def generate(self) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Generate the SLURM script content as a string.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
str
|
|
70
|
+
The complete SLURM script.
|
|
71
|
+
"""
|
|
72
|
+
script = "#!/bin/bash\n"
|
|
73
|
+
if self.sbatch:
|
|
74
|
+
script += "\n".join(self.sbatch) + "\n"
|
|
75
|
+
if self.commands:
|
|
76
|
+
script += "\n" + "\n".join(self.commands) + "\n"
|
|
77
|
+
return script
|
|
78
|
+
|
|
79
|
+
def save(self, filename: str | Path) -> None:
|
|
80
|
+
"""
|
|
81
|
+
Save the SLURM script to a file.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
filename : str
|
|
86
|
+
Path to save the SLURM script.
|
|
87
|
+
"""
|
|
88
|
+
self.fname = Path(filename)
|
|
89
|
+
with open(self.fname, "w") as file:
|
|
90
|
+
file.write(self.generate())
|
|
91
|
+
|
|
92
|
+
def submit(self, filename: str | Path) -> int:
|
|
93
|
+
"""
|
|
94
|
+
Submit the SLURM script to the queue.
|
|
95
|
+
"""
|
|
96
|
+
self.save(filename)
|
|
97
|
+
out = subprocess.run(
|
|
98
|
+
["sbatch", str(self.fname)],
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
check=False,
|
|
102
|
+
)
|
|
103
|
+
if out.returncode != 0:
|
|
104
|
+
message = out.stderr.strip() or out.stdout.strip() or "unknown sbatch error"
|
|
105
|
+
raise RuntimeError(f"SLURM submission failed: {message}")
|
|
106
|
+
|
|
107
|
+
tokens = out.stdout.strip().split()
|
|
108
|
+
if not tokens:
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"SLURM submission failed: could not parse job ID from sbatch output."
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
return int(tokens[-1])
|
|
114
|
+
except ValueError as exc:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
"SLURM submission failed: could not parse job ID from "
|
|
117
|
+
f"sbatch output {out.stdout!r}."
|
|
118
|
+
) from exc
|