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/routines.py ADDED
@@ -0,0 +1,272 @@
1
+ import hashlib
2
+ import importlib
3
+ import importlib.util
4
+ import sys
5
+ from dataclasses import dataclass, field
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+ from typing import Any, Callable, Mapping, Sequence
9
+
10
+ from .data import QoI
11
+ from .hbonds import compute_all_hbonds
12
+ from .rdf import compute_all_rdfs
13
+
14
+
15
+ def _split_routine_spec(spec: str) -> tuple[str, str]:
16
+ if ":" not in spec:
17
+ raise ValueError(
18
+ "Imported analysis routines must be specified as "
19
+ "'module:function' or 'path/to/file.py:function'."
20
+ )
21
+ return spec.split(":", maxsplit=1)
22
+
23
+
24
+ def _builtin_callable(name: str) -> Callable[..., Any]:
25
+ try:
26
+ return BUILTIN_ROUTINES[name]
27
+ except KeyError as exc:
28
+ known = ", ".join(sorted(BUILTIN_ROUTINES))
29
+ raise ValueError(
30
+ f"Unknown builtin analysis routine {name!r}. "
31
+ f"Known routines are: {known}."
32
+ ) from exc
33
+
34
+
35
+ def _user_module_name(module_path: Path) -> str:
36
+ digest = hashlib.sha1(str(module_path).encode("utf-8")).hexdigest()[:12]
37
+ return f"bff_user_routine_{module_path.stem}_{digest}"
38
+
39
+
40
+ def _load_module_from_path(module_path: Path) -> Any:
41
+ if not module_path.exists():
42
+ raise ValueError(f"Analysis routine file not found: {module_path}")
43
+
44
+ module_name = _user_module_name(module_path)
45
+ existing = sys.modules.get(module_name)
46
+ if existing is not None:
47
+ return existing
48
+
49
+ module_spec = importlib.util.spec_from_file_location(module_name, module_path)
50
+ if module_spec is None or module_spec.loader is None:
51
+ raise ValueError(f"Could not load analysis routine module {module_path}.")
52
+
53
+ module = importlib.util.module_from_spec(module_spec)
54
+ sys.modules[module_name] = module
55
+ module_spec.loader.exec_module(module)
56
+ return module
57
+
58
+
59
+ def _import_module(module_name: str) -> Any:
60
+ if module_name.endswith(".py") or "/" in module_name:
61
+ return _load_module_from_path(Path(module_name).resolve())
62
+ return importlib.import_module(module_name)
63
+
64
+
65
+ @lru_cache(maxsize=None)
66
+ def _load_routine(spec: str) -> Callable[..., Any]:
67
+ """Resolve a builtin or import-string routine specification."""
68
+ if ":" not in spec:
69
+ return _builtin_callable(spec)
70
+
71
+ module_name, attr_name = _split_routine_spec(spec)
72
+ module = _import_module(module_name)
73
+ try:
74
+ fn = getattr(module, attr_name)
75
+ except AttributeError as exc:
76
+ raise ValueError(
77
+ f"Callable {attr_name!r} was not found in module {module_name!r}."
78
+ ) from exc
79
+ if not callable(fn):
80
+ raise ValueError(f"Resolved object {spec!r} is not callable.")
81
+ return fn
82
+
83
+
84
+ BUILTIN_ROUTINES: dict[str, Callable[..., QoI]] = {
85
+ "rdf": compute_all_rdfs,
86
+ "hb": compute_all_hbonds,
87
+ }
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class AnalysisRoutineConfig:
92
+ """Configuration for a single-trajectory analysis routine.
93
+
94
+ Attributes
95
+ ----------
96
+ routine
97
+ Builtin selector or importable routine specification.
98
+ options
99
+ Keyword arguments forwarded to the analysis routine.
100
+ """
101
+ routine: str
102
+ options: dict[str, Any] = field(default_factory=dict)
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class AnalysisRuntimeConfig:
107
+ """Runtime settings for the QoI analysis workflow."""
108
+
109
+ in_memory: bool = False
110
+ gc_collect: bool = False
111
+ maxtasksperchild: int = 100
112
+
113
+
114
+ def _normalize_qoi_mapping(result: Mapping[str, Any]) -> dict[str, QoI]:
115
+ """Validate and normalize a mapping of QoI outputs."""
116
+ normalized: dict[str, QoI] = {}
117
+ for name, value in result.items():
118
+ if not isinstance(value, QoI):
119
+ raise TypeError(
120
+ "Mapping-valued analysis routines must return QoI objects."
121
+ )
122
+ if value.name != name:
123
+ raise ValueError(
124
+ f"QoI mapping key {name!r} does not match QoI.name "
125
+ f"{value.name!r}."
126
+ )
127
+ normalized[name] = value
128
+ return normalized
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class RuntimeRoutine:
133
+ fn: Callable[..., Any]
134
+ options: dict[str, Any] = field(default_factory=dict)
135
+
136
+
137
+ def _normalize_routine_config(
138
+ config: Mapping[str, Any],
139
+ *,
140
+ base_dir: Path | None = None,
141
+ ) -> AnalysisRoutineConfig:
142
+ """Validate one routine entry from the QoI configuration."""
143
+ if not isinstance(config, Mapping):
144
+ raise ValueError(
145
+ "Each analysis routine specification must be a mapping with a "
146
+ "'routine' field."
147
+ )
148
+ if "routine" not in config:
149
+ raise ValueError("Each analysis routine must define 'routine'.")
150
+ routine = config["routine"]
151
+ if not isinstance(routine, str):
152
+ raise ValueError("Analysis routine 'routine' must be a string.")
153
+ if "kwargs" in config:
154
+ raise ValueError(
155
+ "Routine options must be provided as flat keys. "
156
+ "Nested 'kwargs' are no longer supported."
157
+ )
158
+ extra_options = {
159
+ key: value
160
+ for key, value in config.items()
161
+ if key != "routine"
162
+ }
163
+ options = dict(extra_options)
164
+
165
+ if ":" in routine:
166
+ module_name, attr_name = _split_routine_spec(routine)
167
+ if module_name.endswith(".py") or "/" in module_name:
168
+ if base_dir is None:
169
+ module_path = Path(module_name).resolve()
170
+ else:
171
+ module_path = (base_dir / module_name).resolve()
172
+ routine = f"{module_path}:{attr_name}"
173
+
174
+ return AnalysisRoutineConfig(
175
+ routine=routine,
176
+ options=options,
177
+ )
178
+
179
+
180
+ def normalize_routine_list(
181
+ routines: Sequence[Mapping[str, Any]],
182
+ *,
183
+ base_dir: Path | None = None,
184
+ ) -> tuple[AnalysisRoutineConfig, ...]:
185
+ if not isinstance(routines, Sequence) or isinstance(routines, (str, bytes)):
186
+ raise ValueError("Analysis routines must be provided as a non-empty list.")
187
+ if not routines:
188
+ raise ValueError("Analysis routines must be provided as a non-empty list.")
189
+ return tuple(
190
+ _normalize_routine_config(routine, base_dir=base_dir)
191
+ for routine in routines
192
+ )
193
+
194
+
195
+ def normalize_analysis_runtime_config(
196
+ config: Mapping[str, Any] | None,
197
+ ) -> AnalysisRuntimeConfig:
198
+ """Validate and normalize the runtime-only QoI ``run`` section."""
199
+ if config is None:
200
+ config = {}
201
+ if not isinstance(config, Mapping):
202
+ raise ValueError("'run' must be a mapping.")
203
+ if "routines" in config or "systems" in config:
204
+ raise ValueError(
205
+ "'run' only accepts runtime settings. "
206
+ "Define routines under each entry in 'refset.systems'."
207
+ )
208
+
209
+ maxtasksperchild = int(config.get("maxtasksperchild", 100))
210
+ if maxtasksperchild <= 0:
211
+ raise ValueError("'run.maxtasksperchild' must be a positive integer.")
212
+
213
+ return AnalysisRuntimeConfig(
214
+ in_memory=bool(config.get("in_memory", False)),
215
+ gc_collect=bool(config.get("gc_collect", False)),
216
+ maxtasksperchild=maxtasksperchild,
217
+ )
218
+
219
+
220
+ def run_analysis_routines(
221
+ routines: Sequence[RuntimeRoutine],
222
+ *,
223
+ universe: Any,
224
+ start: int,
225
+ stop: int | None,
226
+ step: int,
227
+ ) -> dict[str, QoI]:
228
+ """Run all configured routines for one trajectory and merge QoI outputs."""
229
+ result: dict[str, QoI] = {}
230
+ for routine in routines:
231
+ qoi_result = routine.fn(
232
+ universe=universe,
233
+ start=start,
234
+ stop=stop,
235
+ step=step,
236
+ **routine.options,
237
+ )
238
+ if isinstance(qoi_result, QoI):
239
+ qoi_mapping = {qoi_result.name: qoi_result}
240
+ elif isinstance(qoi_result, Mapping):
241
+ qoi_mapping = _normalize_qoi_mapping(qoi_result)
242
+ else:
243
+ raise TypeError(
244
+ "Analysis routine must return a QoI object or a mapping of "
245
+ "QoI objects."
246
+ )
247
+ duplicates = set(result) & set(qoi_mapping)
248
+ if duplicates:
249
+ names = ", ".join(sorted(duplicates))
250
+ raise ValueError(
251
+ f"Analysis routines produced duplicate QoI output(s): {names}."
252
+ )
253
+ result.update(qoi_mapping)
254
+ return result
255
+
256
+
257
+ def build_analysis_routines(
258
+ systems: Sequence[Sequence[AnalysisRoutineConfig]],
259
+ ) -> list[tuple[RuntimeRoutine, ...]]:
260
+ """Build runtime analysis routines for all trajectory positions."""
261
+ routines_by_system: list[tuple[RuntimeRoutine, ...]] = []
262
+ for system in systems:
263
+ runtime_routines: list[RuntimeRoutine] = []
264
+ for routine in system:
265
+ runtime_routines.append(
266
+ RuntimeRoutine(
267
+ fn=_load_routine(routine.routine),
268
+ options=dict(routine.options),
269
+ )
270
+ )
271
+ routines_by_system.append(tuple(runtime_routines))
272
+ return routines_by_system
bff/tools.py ADDED
@@ -0,0 +1,168 @@
1
+ import inspect
2
+ from typing import Callable
3
+
4
+ import MDAnalysis as mda
5
+ import numpy as np
6
+ from MDAnalysis.guesser.tables import masses as MDA_MASSES
7
+ from scipy.constants import atomic_mass
8
+ from scipy.spatial.transform import Rotation as R
9
+
10
+ MASSES = np.array(list(MDA_MASSES.values()))
11
+ ELEMENTS = list(MDA_MASSES.keys())
12
+
13
+
14
+ def _normalized_dimensions(dimensions: np.ndarray | None) -> np.ndarray | None:
15
+ """Return a usable unit-cell array or ``None`` when box data is missing."""
16
+ if dimensions is None:
17
+ return None
18
+
19
+ arr = np.asarray(dimensions, dtype=float).reshape(-1)
20
+ if arr.size < 3:
21
+ return None
22
+ if not np.all(np.isfinite(arr[:3])):
23
+ return None
24
+ if np.any(arr[:3] <= 0):
25
+ return None
26
+ return arr
27
+
28
+
29
+ def modify_dcd_frc_header(fn: str) -> None:
30
+ """
31
+ Modify the header of a DCD file to include the 'CORD' flag.
32
+
33
+ Parameters
34
+ ----------
35
+ fn : str
36
+ Path to the DCD file to be modified.
37
+
38
+ Notes
39
+ -----
40
+ This function opens the file in binary mode and writes the 'CORD' flag
41
+ at the appropriate position in the header.
42
+ """
43
+ flag = 'CORD'.encode()
44
+ with open(fn, 'rb+') as f:
45
+ f.seek(4)
46
+ f.write(flag)
47
+
48
+
49
+ def compute_distances(
50
+ universe: mda.Universe, ag1: mda.AtomGroup, ag2: mda.AtomGroup,
51
+ start: int = None, stop: int = None, step: int = None,
52
+ pbc: bool = True
53
+ ) -> np.ndarray:
54
+ """Compute distances between two AtomGroups over a trajectory."""
55
+
56
+ start = start or 0
57
+ stop = stop or len(universe.trajectory)
58
+ step = step or 1
59
+ displacements = [
60
+ ag1.positions[:, np.newaxis] - ag2.positions
61
+ for ts in universe.trajectory[start:stop:step]
62
+ ]
63
+ if not displacements:
64
+ shape = (0, len(ag1), len(ag2))
65
+ return np.empty(shape, dtype=float)
66
+
67
+ displacements = np.asarray(displacements, dtype=float)
68
+ if pbc:
69
+ box = np.asarray(get_unitcell(universe)[:3], dtype=float)
70
+ displacements -= np.round(displacements / box) * box
71
+ return np.linalg.norm(displacements, axis=-1)
72
+
73
+
74
+ def get_unitcell(
75
+ universe: mda.Universe,
76
+ ts: mda.coordinates.base.Timestep | None = None,
77
+ ) -> np.ndarray:
78
+ """Return the current or fallback unit cell for a universe.
79
+
80
+ Parameters
81
+ ----------
82
+ universe
83
+ MDAnalysis universe that provides the default box information.
84
+ ts
85
+ Optional timestep whose box should be preferred.
86
+
87
+ Returns
88
+ -------
89
+ numpy.ndarray
90
+ Unit-cell vector of length 6.
91
+
92
+ Raises
93
+ ------
94
+ ValueError
95
+ If neither the timestep nor the universe provides box dimensions.
96
+ """
97
+ ts_dimensions = None if ts is None else _normalized_dimensions(ts.dimensions)
98
+ if ts_dimensions is not None:
99
+ return ts_dimensions
100
+
101
+ dimensions = _normalized_dimensions(universe.dimensions)
102
+ if dimensions is not None:
103
+ return dimensions
104
+
105
+ default = _normalized_dimensions(getattr(universe, "_bff_default_dimensions", None))
106
+ if default is None:
107
+ raise ValueError(
108
+ "Trajectory frames do not define box dimensions and no fallback "
109
+ "unit cell is available."
110
+ )
111
+ if ts is not None and ts.dimensions is None:
112
+ ts.dimensions = default
113
+ return default
114
+
115
+
116
+ def random_placement(coords: np.ndarray, box: np.ndarray) -> np.ndarray:
117
+ """Randomly place a molecule within a box."""
118
+ displacement = np.random.rand(3) * box
119
+ rotation = R.random().as_matrix()
120
+ coords = coords @ rotation.T
121
+ coords += displacement
122
+ return coords
123
+
124
+
125
+ def guess_box(n_mol: int) -> np.ndarray:
126
+ """Approximates a cubic box size based on density of neat water."""
127
+ mass = float(n_mol) * 18.015 * atomic_mass # kg
128
+ density = 1000 # kg/m^3
129
+ length = np.cbrt(mass / density) * 1e10 # Angstroms
130
+ return np.array([length] * 3 + [90, 90, 90])
131
+
132
+
133
+ def sigmoid(x: np.ndarray, x0: float = 3, scale: float = 5) -> np.ndarray:
134
+ """Smoothly transitions from 0 to 1 around x=3."""
135
+ x = np.asarray(x)
136
+ arg = (x - x0) * scale
137
+ return 1 / (1 + np.exp(- arg))
138
+
139
+
140
+ def rdf_sigmoid_mean(n_bins: int, r_range: tuple, ref_rdf: np.ndarray) -> np.ndarray:
141
+ """Create a sigmoid function for concatenated RDFs."""
142
+ r0, r1 = r_range
143
+ dr_half = (r1 - r0) / (2 * n_bins)
144
+ r = np.linspace(r0, r1, n_bins, endpoint=False) + dr_half
145
+ n_rdf = ref_rdf.size // n_bins
146
+ return np.tile(sigmoid(r), n_rdf)
147
+
148
+
149
+ def extract_defaults(fn: Callable) -> dict[str, object]:
150
+ """
151
+ Extract default values from the function signature.
152
+
153
+ Parameters
154
+ ----------
155
+ fn : callable
156
+ The function from which to extract default values.
157
+
158
+ Returns
159
+ -------
160
+ dict
161
+ A dictionary with parameter names as keys and their default values.
162
+ """
163
+ sig = inspect.signature(fn)
164
+ return {
165
+ k: v.default
166
+ for k, v in sig.parameters.items()
167
+ if v.default is not inspect.Parameter.empty
168
+ }