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/workflows/configs.py
ADDED
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
"""Configuration models and YAML loaders for BFF workflows."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal, Mapping, Optional, Union
|
|
6
|
+
|
|
7
|
+
from ..domain.bias import BiasSpec
|
|
8
|
+
from ..io.utils import load_yaml
|
|
9
|
+
from ..qoi.routines import (
|
|
10
|
+
AnalysisRoutineConfig,
|
|
11
|
+
AnalysisRuntimeConfig,
|
|
12
|
+
normalize_analysis_runtime_config,
|
|
13
|
+
normalize_routine_list,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
PathLike = Union[str, Path]
|
|
17
|
+
SchedulerName = Literal["local", "slurm"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve_path(
|
|
21
|
+
base_dir: Path,
|
|
22
|
+
path: PathLike,
|
|
23
|
+
*,
|
|
24
|
+
must_exist: bool = True,
|
|
25
|
+
kind: str = "path",
|
|
26
|
+
) -> Path:
|
|
27
|
+
resolved = (base_dir / path).resolve()
|
|
28
|
+
if must_exist and not resolved.exists():
|
|
29
|
+
raise FileNotFoundError(f"{kind.capitalize()} not found: {resolved}")
|
|
30
|
+
return resolved
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_optional_path(
|
|
34
|
+
base_dir: Path,
|
|
35
|
+
path: PathLike | None,
|
|
36
|
+
*,
|
|
37
|
+
must_exist: bool = True,
|
|
38
|
+
kind: str = "file",
|
|
39
|
+
) -> Path | None:
|
|
40
|
+
if path is None:
|
|
41
|
+
return None
|
|
42
|
+
return _resolve_path(base_dir, path, must_exist=must_exist, kind=kind)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_simulation_asset_systems(
|
|
46
|
+
base_dir: Path,
|
|
47
|
+
systems_raw: Any,
|
|
48
|
+
) -> list["SimulationSystemConfig"]:
|
|
49
|
+
if not isinstance(systems_raw, list) or not systems_raw:
|
|
50
|
+
raise ValueError("'systems' must be a non-empty list.")
|
|
51
|
+
|
|
52
|
+
systems: list[SimulationSystemConfig] = []
|
|
53
|
+
for index, system in enumerate(systems_raw):
|
|
54
|
+
if not isinstance(system, dict):
|
|
55
|
+
raise ValueError(f"systems[{index}] must be a mapping.")
|
|
56
|
+
if "assets" not in system or "n_steps" not in system:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"systems[{index}] must define 'assets' and 'n_steps'."
|
|
59
|
+
)
|
|
60
|
+
training_assets = _resolve_path(
|
|
61
|
+
base_dir,
|
|
62
|
+
system["assets"],
|
|
63
|
+
kind=f"systems[{index}] assets directory",
|
|
64
|
+
)
|
|
65
|
+
systems.append(
|
|
66
|
+
_load_prepared_simulation_system(
|
|
67
|
+
training_assets,
|
|
68
|
+
int(system["n_steps"]),
|
|
69
|
+
system_id=f"{index:03d}",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
return systems
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_prepared_simulation_system(
|
|
76
|
+
training_assets: Path,
|
|
77
|
+
n_steps: int,
|
|
78
|
+
*,
|
|
79
|
+
system_id: str,
|
|
80
|
+
) -> "SimulationSystemConfig":
|
|
81
|
+
if n_steps <= 0:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Prepared system assets {training_assets} have invalid n_steps={n_steps}."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
topologies = sorted(training_assets.glob("window-*.top"))
|
|
87
|
+
if len(topologies) != 1:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"{training_assets} must contain exactly one prepared system, "
|
|
90
|
+
f"but found {len(topologies)} topology files."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
fn_topol = topologies[0]
|
|
94
|
+
window_label = fn_topol.stem
|
|
95
|
+
fn_coordinates = training_assets / f"{window_label}.gro"
|
|
96
|
+
fn_mdp_em = training_assets / f"{window_label}.em.mdp"
|
|
97
|
+
fn_mdp_prod = training_assets / f"{window_label}.mdp"
|
|
98
|
+
fn_ndx = training_assets / f"{window_label}.ndx"
|
|
99
|
+
fn_bias = None
|
|
100
|
+
for suffix in ("bias.colvars.dat", "bias.plumed.dat"):
|
|
101
|
+
candidate = training_assets / f"{window_label}.{suffix}"
|
|
102
|
+
if candidate.exists():
|
|
103
|
+
fn_bias = candidate
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
for path, kind in [
|
|
107
|
+
(fn_coordinates, "coordinate"),
|
|
108
|
+
(fn_mdp_em, "EM MDP"),
|
|
109
|
+
(fn_mdp_prod, "production MDP"),
|
|
110
|
+
(fn_ndx, "index"),
|
|
111
|
+
]:
|
|
112
|
+
if not path.exists():
|
|
113
|
+
raise FileNotFoundError(
|
|
114
|
+
f"Prepared {kind} file not found for {window_label}: {path}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return SimulationSystemConfig(
|
|
118
|
+
system_id=system_id,
|
|
119
|
+
fn_topol=fn_topol,
|
|
120
|
+
fn_coordinates=fn_coordinates,
|
|
121
|
+
fn_mdp_em=fn_mdp_em,
|
|
122
|
+
fn_mdp_prod=fn_mdp_prod,
|
|
123
|
+
fn_ndx=fn_ndx,
|
|
124
|
+
bias=BiasSpec.from_any(fn_bias, base_dir=training_assets),
|
|
125
|
+
n_steps=n_steps,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _normalize_store(value: Any) -> list[str]:
|
|
130
|
+
if value in (None, True):
|
|
131
|
+
return ["xtc"]
|
|
132
|
+
if value is False:
|
|
133
|
+
return []
|
|
134
|
+
if isinstance(value, str):
|
|
135
|
+
return [value]
|
|
136
|
+
if isinstance(value, (list, tuple)):
|
|
137
|
+
if not all(isinstance(item, str) for item in value):
|
|
138
|
+
raise ValueError("'store' entries must all be strings.")
|
|
139
|
+
return list(value)
|
|
140
|
+
raise ValueError("'store' must be a bool, string, or list of strings.")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _load_slurm_config(slurm_raw: Any) -> "SlurmConfig":
|
|
144
|
+
if not isinstance(slurm_raw, dict):
|
|
145
|
+
raise ValueError("Missing 'slurm' configuration for slurm scheduler.")
|
|
146
|
+
if "sbatch" not in slurm_raw:
|
|
147
|
+
raise ValueError("Scheduler 'slurm' must define 'sbatch'.")
|
|
148
|
+
if not isinstance(slurm_raw["sbatch"], dict):
|
|
149
|
+
raise ValueError("slurm.sbatch must be a mapping.")
|
|
150
|
+
|
|
151
|
+
setup = slurm_raw.get("setup", [])
|
|
152
|
+
teardown = slurm_raw.get("teardown", [])
|
|
153
|
+
if not isinstance(setup, list) or not all(isinstance(cmd, str) for cmd in setup):
|
|
154
|
+
raise ValueError("slurm.setup must be a list of shell commands.")
|
|
155
|
+
if not isinstance(teardown, list) or not all(
|
|
156
|
+
isinstance(cmd, str) for cmd in teardown
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("slurm.teardown must be a list of shell commands.")
|
|
159
|
+
|
|
160
|
+
max_parallel_jobs = int(slurm_raw.get("max_parallel_jobs", 1))
|
|
161
|
+
if max_parallel_jobs == 0 or max_parallel_jobs < -1:
|
|
162
|
+
raise ValueError("'slurm.max_parallel_jobs' must be positive or -1.")
|
|
163
|
+
|
|
164
|
+
return SlurmConfig(
|
|
165
|
+
max_parallel_jobs=max_parallel_jobs,
|
|
166
|
+
sbatch=dict(slurm_raw["sbatch"]),
|
|
167
|
+
setup=tuple(setup),
|
|
168
|
+
teardown=tuple(teardown),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _load_campaign_common(
|
|
173
|
+
fn_config: PathLike,
|
|
174
|
+
*,
|
|
175
|
+
asset_systems: bool,
|
|
176
|
+
) -> tuple[Path, Path, dict[str, Any], dict[str, Any]]:
|
|
177
|
+
fn_config = Path(fn_config).resolve()
|
|
178
|
+
base_dir = fn_config.parent
|
|
179
|
+
config = load_yaml(fn_config)
|
|
180
|
+
|
|
181
|
+
required = ["trainset_dir", "systems", "job_scheduler", "gmx_cmd"]
|
|
182
|
+
missing = [key for key in required if key not in config]
|
|
183
|
+
if missing:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
"Missing required configuration key(s): "
|
|
186
|
+
+ ", ".join(repr(key) for key in missing)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
scheduler = config["job_scheduler"]
|
|
190
|
+
if scheduler not in {"local", "slurm"}:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
f"Unsupported scheduler {scheduler!r}. Supported values are "
|
|
193
|
+
"'local' and 'slurm'."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if asset_systems:
|
|
197
|
+
systems = _load_simulation_asset_systems(base_dir, config["systems"])
|
|
198
|
+
else:
|
|
199
|
+
systems = _load_simulation_systems(base_dir, config["systems"], key="systems")
|
|
200
|
+
|
|
201
|
+
slurm = None
|
|
202
|
+
if scheduler == "slurm":
|
|
203
|
+
slurm = _load_slurm_config(config.get("slurm"))
|
|
204
|
+
|
|
205
|
+
common = dict(
|
|
206
|
+
fn_config=fn_config,
|
|
207
|
+
trainset_dir=_resolve_path(
|
|
208
|
+
base_dir,
|
|
209
|
+
config["trainset_dir"],
|
|
210
|
+
must_exist=False,
|
|
211
|
+
kind="trainset directory",
|
|
212
|
+
),
|
|
213
|
+
gmx_cmd=str(config["gmx_cmd"]),
|
|
214
|
+
job_scheduler=scheduler,
|
|
215
|
+
systems=systems,
|
|
216
|
+
dispatch=bool(config.get("dispatch", True)),
|
|
217
|
+
compress=bool(config.get("compress", False)),
|
|
218
|
+
cleanup=bool(config.get("cleanup", False)),
|
|
219
|
+
store=tuple(_normalize_store(config.get("store"))),
|
|
220
|
+
slurm=slurm,
|
|
221
|
+
)
|
|
222
|
+
return fn_config, base_dir, config, common
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _validate_bounds(bounds: Any) -> dict[str, tuple[float, float]]:
|
|
226
|
+
if not isinstance(bounds, dict):
|
|
227
|
+
raise ValueError("'bounds' must be a mapping of parameter names to bounds.")
|
|
228
|
+
|
|
229
|
+
normalized: dict[str, tuple[float, float]] = {}
|
|
230
|
+
for name, value in bounds.items():
|
|
231
|
+
if not (
|
|
232
|
+
isinstance(value, (list, tuple))
|
|
233
|
+
and len(value) == 2
|
|
234
|
+
and all(isinstance(x, (int, float)) for x in value)
|
|
235
|
+
):
|
|
236
|
+
raise ValueError(f"Invalid bounds for {name!r}: {value}")
|
|
237
|
+
lower, upper = float(value[0]), float(value[1])
|
|
238
|
+
if lower > upper:
|
|
239
|
+
raise ValueError(
|
|
240
|
+
f"Lower bound {lower} is greater than upper bound {upper} "
|
|
241
|
+
f"for parameter {name!r}."
|
|
242
|
+
)
|
|
243
|
+
normalized[name] = (lower, upper)
|
|
244
|
+
return normalized
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _normalize_implicit_atoms(value: Any) -> list[str]:
|
|
248
|
+
if isinstance(value, str):
|
|
249
|
+
return value.split()
|
|
250
|
+
if isinstance(value, (list, tuple)) and all(
|
|
251
|
+
isinstance(item, str) for item in value
|
|
252
|
+
):
|
|
253
|
+
return list(value)
|
|
254
|
+
raise ValueError("'implicit_atoms' must be a string or a list of strings.")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _load_model_paths(
|
|
258
|
+
base_dir: Path,
|
|
259
|
+
models_raw: Any,
|
|
260
|
+
) -> dict[str, Path]:
|
|
261
|
+
if not isinstance(models_raw, Mapping) or not models_raw:
|
|
262
|
+
raise ValueError("'models' must be a non-empty mapping.")
|
|
263
|
+
|
|
264
|
+
models: dict[str, Path] = {}
|
|
265
|
+
for name, path in models_raw.items():
|
|
266
|
+
if not isinstance(name, str):
|
|
267
|
+
raise ValueError("Model names in 'models' must be strings.")
|
|
268
|
+
if not isinstance(path, (str, Path)):
|
|
269
|
+
raise ValueError(f"Model path for {name!r} must be a file path.")
|
|
270
|
+
models[name] = _resolve_path(
|
|
271
|
+
base_dir,
|
|
272
|
+
path,
|
|
273
|
+
kind=f"model file for {name!r}",
|
|
274
|
+
)
|
|
275
|
+
return models
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass(frozen=True)
|
|
279
|
+
class SimulationSystemConfig:
|
|
280
|
+
system_id: str
|
|
281
|
+
fn_topol: Path
|
|
282
|
+
fn_coordinates: Path
|
|
283
|
+
fn_mdp_em: Path | None
|
|
284
|
+
fn_mdp_prod: Path
|
|
285
|
+
fn_ndx: Path
|
|
286
|
+
bias: BiasSpec
|
|
287
|
+
n_steps: int
|
|
288
|
+
|
|
289
|
+
def to_dict(self) -> dict[str, Any]:
|
|
290
|
+
bias_file = self.bias.input_file
|
|
291
|
+
return {
|
|
292
|
+
"system_id": self.system_id,
|
|
293
|
+
"topology": str(self.fn_topol),
|
|
294
|
+
"coordinates": str(self.fn_coordinates),
|
|
295
|
+
"mdp": {
|
|
296
|
+
"em": None if self.fn_mdp_em is None else str(self.fn_mdp_em),
|
|
297
|
+
"prod": str(self.fn_mdp_prod),
|
|
298
|
+
},
|
|
299
|
+
"index": str(self.fn_ndx),
|
|
300
|
+
"bias": None if bias_file is None else str(bias_file),
|
|
301
|
+
"n_steps": int(self.n_steps),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(frozen=True)
|
|
306
|
+
class SlurmConfig:
|
|
307
|
+
max_parallel_jobs: int = 1
|
|
308
|
+
sbatch: dict[str, Any] | None = None
|
|
309
|
+
setup: tuple[str, ...] = ()
|
|
310
|
+
teardown: tuple[str, ...] = ()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _load_simulation_systems(
|
|
314
|
+
base_dir: Path,
|
|
315
|
+
systems_raw: Any,
|
|
316
|
+
*,
|
|
317
|
+
key: str,
|
|
318
|
+
) -> list[SimulationSystemConfig]:
|
|
319
|
+
if not isinstance(systems_raw, list) or not systems_raw:
|
|
320
|
+
raise ValueError(f"'{key}' must be a non-empty list.")
|
|
321
|
+
|
|
322
|
+
systems: list[SimulationSystemConfig] = []
|
|
323
|
+
for i, system in enumerate(systems_raw):
|
|
324
|
+
if not isinstance(system, dict):
|
|
325
|
+
raise ValueError(f"{key}[{i}] must be a mapping.")
|
|
326
|
+
for required_key in ("topology", "coordinates", "mdp", "index", "n_steps"):
|
|
327
|
+
if required_key not in system:
|
|
328
|
+
raise ValueError(
|
|
329
|
+
f"{key}[{i}] is missing required key {required_key!r}."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
mdp = system["mdp"]
|
|
333
|
+
if not isinstance(mdp, dict):
|
|
334
|
+
raise ValueError(f"{key}[{i}].mdp must be a mapping.")
|
|
335
|
+
if "prod" not in mdp:
|
|
336
|
+
raise ValueError(f"{key}[{i}].mdp is missing required key 'prod'.")
|
|
337
|
+
|
|
338
|
+
n_steps = int(system["n_steps"])
|
|
339
|
+
if n_steps <= 0:
|
|
340
|
+
raise ValueError(f"{key}[{i}].n_steps must be a positive integer.")
|
|
341
|
+
|
|
342
|
+
systems.append(
|
|
343
|
+
SimulationSystemConfig(
|
|
344
|
+
system_id=f"{i:03d}",
|
|
345
|
+
fn_topol=_resolve_path(
|
|
346
|
+
base_dir,
|
|
347
|
+
system["topology"],
|
|
348
|
+
kind=f"{key}[{i}] topology file",
|
|
349
|
+
),
|
|
350
|
+
fn_coordinates=_resolve_path(
|
|
351
|
+
base_dir,
|
|
352
|
+
system["coordinates"],
|
|
353
|
+
kind=f"{key}[{i}] coordinate file",
|
|
354
|
+
),
|
|
355
|
+
fn_mdp_em=_resolve_optional_path(
|
|
356
|
+
base_dir,
|
|
357
|
+
mdp.get("em"),
|
|
358
|
+
kind=f"{key}[{i}] EM MDP file",
|
|
359
|
+
),
|
|
360
|
+
fn_mdp_prod=_resolve_path(
|
|
361
|
+
base_dir,
|
|
362
|
+
mdp["prod"],
|
|
363
|
+
kind=f"{key}[{i}] production MDP file",
|
|
364
|
+
),
|
|
365
|
+
fn_ndx=_resolve_path(
|
|
366
|
+
base_dir,
|
|
367
|
+
system["index"],
|
|
368
|
+
kind=f"{key}[{i}] index file",
|
|
369
|
+
),
|
|
370
|
+
bias=BiasSpec.from_any(system.get("bias"), base_dir=base_dir),
|
|
371
|
+
n_steps=n_steps,
|
|
372
|
+
)
|
|
373
|
+
)
|
|
374
|
+
return systems
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@dataclass(frozen=True, kw_only=True)
|
|
378
|
+
class SimulationCampaignConfig:
|
|
379
|
+
fn_config: Path
|
|
380
|
+
trainset_dir: Path
|
|
381
|
+
gmx_cmd: str
|
|
382
|
+
job_scheduler: SchedulerName
|
|
383
|
+
systems: list[SimulationSystemConfig]
|
|
384
|
+
dispatch: bool = True
|
|
385
|
+
compress: bool = False
|
|
386
|
+
cleanup: bool = False
|
|
387
|
+
store: tuple[str, ...] = ()
|
|
388
|
+
slurm: Optional[SlurmConfig] = None
|
|
389
|
+
|
|
390
|
+
@classmethod
|
|
391
|
+
def _load_common(
|
|
392
|
+
cls,
|
|
393
|
+
fn_config: PathLike,
|
|
394
|
+
) -> tuple[Path, Path, dict[str, Any], dict[str, Any]]:
|
|
395
|
+
return _load_campaign_common(fn_config, asset_systems=False)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@dataclass(frozen=True, kw_only=True)
|
|
399
|
+
class SimulateConfig(SimulationCampaignConfig):
|
|
400
|
+
mol_resname: str
|
|
401
|
+
bounds: dict[str, tuple[float, float]]
|
|
402
|
+
total_charge: float
|
|
403
|
+
implicit_atoms: list[str]
|
|
404
|
+
n_samples: int
|
|
405
|
+
|
|
406
|
+
@classmethod
|
|
407
|
+
def load(cls, fn_config: PathLike) -> "SimulateConfig":
|
|
408
|
+
_, base_dir, config, common = _load_campaign_common(
|
|
409
|
+
fn_config,
|
|
410
|
+
asset_systems=True,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
required = [
|
|
414
|
+
"mol_resname",
|
|
415
|
+
"bounds",
|
|
416
|
+
"total_charge",
|
|
417
|
+
"implicit_atoms",
|
|
418
|
+
"n_samples",
|
|
419
|
+
]
|
|
420
|
+
missing = [key for key in required if key not in config]
|
|
421
|
+
if missing:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
"Simulation mode requires configuration key(s): "
|
|
424
|
+
+ ", ".join(repr(key) for key in missing)
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
bounds = _validate_bounds(config["bounds"])
|
|
428
|
+
implicit_atoms = _normalize_implicit_atoms(config["implicit_atoms"])
|
|
429
|
+
bound_charge_groups = {
|
|
430
|
+
name.split(maxsplit=1)[1]
|
|
431
|
+
for name in bounds
|
|
432
|
+
if name.startswith("charge ")
|
|
433
|
+
}
|
|
434
|
+
implicit_group = " ".join(implicit_atoms)
|
|
435
|
+
if bound_charge_groups and implicit_group not in bound_charge_groups:
|
|
436
|
+
raise ValueError(
|
|
437
|
+
f"'implicit_atoms' ({implicit_group}) must match one of the "
|
|
438
|
+
"charge parameters defined in 'bounds'."
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
n_samples = int(config["n_samples"])
|
|
442
|
+
if n_samples <= 0:
|
|
443
|
+
raise ValueError("'n_samples' must be a positive integer.")
|
|
444
|
+
|
|
445
|
+
return cls(
|
|
446
|
+
**common,
|
|
447
|
+
mol_resname=str(config["mol_resname"]),
|
|
448
|
+
bounds=bounds,
|
|
449
|
+
total_charge=float(config["total_charge"]),
|
|
450
|
+
implicit_atoms=implicit_atoms,
|
|
451
|
+
n_samples=n_samples,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@dataclass(frozen=True, kw_only=True)
|
|
456
|
+
class ValidateConfig(SimulationCampaignConfig):
|
|
457
|
+
specs: Path
|
|
458
|
+
parameters: Path
|
|
459
|
+
|
|
460
|
+
@classmethod
|
|
461
|
+
def load(cls, fn_config: PathLike) -> "ValidateConfig":
|
|
462
|
+
_, base_dir, config, common = _load_campaign_common(
|
|
463
|
+
fn_config,
|
|
464
|
+
asset_systems=True,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if "specs" not in config:
|
|
468
|
+
raise ValueError("Validation mode requires 'specs'.")
|
|
469
|
+
if "parameters" not in config:
|
|
470
|
+
raise ValueError("Validation mode requires 'parameters'.")
|
|
471
|
+
|
|
472
|
+
return cls(
|
|
473
|
+
**common,
|
|
474
|
+
specs=_resolve_path(base_dir, config["specs"], kind="specs file"),
|
|
475
|
+
parameters=_resolve_path(
|
|
476
|
+
base_dir,
|
|
477
|
+
config["parameters"],
|
|
478
|
+
kind="parameter samples file",
|
|
479
|
+
),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@dataclass(frozen=True)
|
|
484
|
+
class PrepareSystemConfig:
|
|
485
|
+
fn_topol: Path
|
|
486
|
+
templates: dict[str, Path]
|
|
487
|
+
charge: int
|
|
488
|
+
mult: int
|
|
489
|
+
box: list[float] | None
|
|
490
|
+
bias: BiasSpec
|
|
491
|
+
nsteps_npt: int
|
|
492
|
+
nsteps_prod: int
|
|
493
|
+
fn_mdp_em: Path
|
|
494
|
+
fn_mdp_npt: Path
|
|
495
|
+
fn_mdp_prod: Path
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@dataclass(frozen=True)
|
|
499
|
+
class PrepareConfig:
|
|
500
|
+
fn_config: Path
|
|
501
|
+
project_dir: Path
|
|
502
|
+
gmx_cmd: str
|
|
503
|
+
fn_log: Optional[Path]
|
|
504
|
+
systems: list[PrepareSystemConfig]
|
|
505
|
+
n_single_point_snapshots: int = 1000
|
|
506
|
+
|
|
507
|
+
@classmethod
|
|
508
|
+
def load(cls, fn_config: PathLike) -> "PrepareConfig":
|
|
509
|
+
fn_config = Path(fn_config).resolve()
|
|
510
|
+
base_dir = fn_config.parent
|
|
511
|
+
config = load_yaml(fn_config)
|
|
512
|
+
|
|
513
|
+
required = ["project", "gromacs", "systems"]
|
|
514
|
+
missing = [key for key in required if key not in config]
|
|
515
|
+
if missing:
|
|
516
|
+
raise ValueError(
|
|
517
|
+
"Missing required preparation option(s): "
|
|
518
|
+
+ ", ".join(repr(key) for key in missing)
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
project = config["project"]
|
|
522
|
+
if isinstance(project, str):
|
|
523
|
+
project_dir = _resolve_path(
|
|
524
|
+
base_dir,
|
|
525
|
+
project,
|
|
526
|
+
must_exist=False,
|
|
527
|
+
kind="project directory",
|
|
528
|
+
)
|
|
529
|
+
fn_log_raw = config.get("fn_log")
|
|
530
|
+
elif isinstance(project, dict):
|
|
531
|
+
if "directory" not in project:
|
|
532
|
+
raise ValueError("project.directory is required.")
|
|
533
|
+
project_dir = _resolve_path(
|
|
534
|
+
base_dir,
|
|
535
|
+
project["directory"],
|
|
536
|
+
must_exist=False,
|
|
537
|
+
kind="project directory",
|
|
538
|
+
)
|
|
539
|
+
fn_log_raw = project.get("log")
|
|
540
|
+
else:
|
|
541
|
+
raise ValueError("'project' must be a string or mapping.")
|
|
542
|
+
|
|
543
|
+
gromacs = config["gromacs"]
|
|
544
|
+
if not isinstance(gromacs, dict):
|
|
545
|
+
raise ValueError("'gromacs' must be a mapping.")
|
|
546
|
+
if "command" not in gromacs:
|
|
547
|
+
raise ValueError("gromacs.command is required.")
|
|
548
|
+
defaults = config.get("defaults", {})
|
|
549
|
+
if defaults is None:
|
|
550
|
+
defaults = {}
|
|
551
|
+
if not isinstance(defaults, dict):
|
|
552
|
+
raise ValueError("'defaults' must be a mapping.")
|
|
553
|
+
default_steps = defaults.get("nsteps", {})
|
|
554
|
+
if default_steps is None:
|
|
555
|
+
default_steps = {}
|
|
556
|
+
if not isinstance(default_steps, dict):
|
|
557
|
+
raise ValueError("'defaults.nsteps' must be a mapping.")
|
|
558
|
+
nsteps_npt_default = int(default_steps.get("npt", 0))
|
|
559
|
+
nsteps_prod_default = int(default_steps.get("prod", 100000))
|
|
560
|
+
if nsteps_npt_default < 0 or nsteps_prod_default < 0:
|
|
561
|
+
raise ValueError("defaults.nsteps values must be non-negative.")
|
|
562
|
+
|
|
563
|
+
systems_raw = config["systems"]
|
|
564
|
+
if not isinstance(systems_raw, list) or not systems_raw:
|
|
565
|
+
raise ValueError("'systems' must be a non-empty list.")
|
|
566
|
+
|
|
567
|
+
systems: list[PrepareSystemConfig] = []
|
|
568
|
+
for i, system in enumerate(systems_raw):
|
|
569
|
+
if not isinstance(system, dict):
|
|
570
|
+
raise ValueError(f"System {i} must be a mapping.")
|
|
571
|
+
for key in ("topology", "templates", "charge", "multiplicity"):
|
|
572
|
+
if key not in system:
|
|
573
|
+
raise ValueError(f"System {i} is missing required key {key!r}.")
|
|
574
|
+
templates_raw = system["templates"]
|
|
575
|
+
if not isinstance(templates_raw, dict) or not templates_raw:
|
|
576
|
+
raise ValueError(f"System {i} templates must be a non-empty mapping.")
|
|
577
|
+
if not all(
|
|
578
|
+
isinstance(name, str) and isinstance(path, (str, Path))
|
|
579
|
+
for name, path in templates_raw.items()
|
|
580
|
+
):
|
|
581
|
+
raise ValueError(
|
|
582
|
+
f"System {i} templates must map residue names to file paths."
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
box = system.get("box")
|
|
586
|
+
if box is None:
|
|
587
|
+
box_values = None
|
|
588
|
+
else:
|
|
589
|
+
if not isinstance(box, list) or len(box) not in {3, 6}:
|
|
590
|
+
raise ValueError(
|
|
591
|
+
f"Invalid box dimensions at index {i}: {box}. "
|
|
592
|
+
"Expected 3 or 6 numeric values."
|
|
593
|
+
)
|
|
594
|
+
if not all(isinstance(value, (int, float)) for value in box):
|
|
595
|
+
raise ValueError(f"Invalid box dimensions at index {i}: {box}.")
|
|
596
|
+
if len(box) == 3:
|
|
597
|
+
box = [*box, 90.0, 90.0, 90.0]
|
|
598
|
+
box_values = [float(value) for value in box]
|
|
599
|
+
|
|
600
|
+
steps = system.get("nsteps", {})
|
|
601
|
+
if steps is None:
|
|
602
|
+
steps = {}
|
|
603
|
+
if not isinstance(steps, dict):
|
|
604
|
+
raise ValueError(f"System {i} nsteps must be a mapping.")
|
|
605
|
+
nsteps_npt = int(steps.get("npt", nsteps_npt_default))
|
|
606
|
+
nsteps_prod = int(steps.get("prod", nsteps_prod_default))
|
|
607
|
+
if nsteps_npt < 0 or nsteps_prod < 0:
|
|
608
|
+
raise ValueError(f"System {i} nsteps values must be non-negative.")
|
|
609
|
+
if (
|
|
610
|
+
box_values is not None
|
|
611
|
+
and len(box_values) == 6
|
|
612
|
+
and box_values[3:] == [90.0, 90.0, 90.0]
|
|
613
|
+
and len(system.get("box", [])) == 3
|
|
614
|
+
):
|
|
615
|
+
nsteps_npt = 0
|
|
616
|
+
|
|
617
|
+
mdp = system.get("mdp")
|
|
618
|
+
if not isinstance(mdp, dict):
|
|
619
|
+
raise ValueError(f"System {i} mdp must be a mapping.")
|
|
620
|
+
missing_mdp = [key for key in ("em", "npt", "prod") if key not in mdp]
|
|
621
|
+
if missing_mdp:
|
|
622
|
+
raise ValueError(
|
|
623
|
+
f"System {i} mdp is missing required key(s): "
|
|
624
|
+
+ ", ".join(repr(key) for key in missing_mdp)
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
systems.append(
|
|
628
|
+
PrepareSystemConfig(
|
|
629
|
+
fn_topol=_resolve_path(
|
|
630
|
+
base_dir,
|
|
631
|
+
system["topology"],
|
|
632
|
+
kind=f"system {i} topology file",
|
|
633
|
+
),
|
|
634
|
+
templates={
|
|
635
|
+
name: _resolve_path(
|
|
636
|
+
base_dir,
|
|
637
|
+
path,
|
|
638
|
+
kind=f"system {i} template file for {name!r}",
|
|
639
|
+
)
|
|
640
|
+
for name, path in templates_raw.items()
|
|
641
|
+
},
|
|
642
|
+
charge=int(system["charge"]),
|
|
643
|
+
mult=int(system["multiplicity"]),
|
|
644
|
+
box=box_values,
|
|
645
|
+
bias=BiasSpec.from_any(system.get("bias"), base_dir=base_dir),
|
|
646
|
+
nsteps_npt=nsteps_npt,
|
|
647
|
+
nsteps_prod=nsteps_prod,
|
|
648
|
+
fn_mdp_em=_resolve_path(
|
|
649
|
+
base_dir,
|
|
650
|
+
mdp["em"],
|
|
651
|
+
kind=f"system {i} em mdp file",
|
|
652
|
+
),
|
|
653
|
+
fn_mdp_npt=_resolve_path(
|
|
654
|
+
base_dir,
|
|
655
|
+
mdp["npt"],
|
|
656
|
+
kind=f"system {i} npt mdp file",
|
|
657
|
+
),
|
|
658
|
+
fn_mdp_prod=_resolve_path(
|
|
659
|
+
base_dir,
|
|
660
|
+
mdp["prod"],
|
|
661
|
+
kind=f"system {i} production mdp file",
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
reference = config.get("reference", {})
|
|
667
|
+
if reference is None:
|
|
668
|
+
reference = {}
|
|
669
|
+
if not isinstance(reference, dict):
|
|
670
|
+
raise ValueError("'reference' must be a mapping.")
|
|
671
|
+
|
|
672
|
+
resolved_log = None if fn_log_raw is None else _resolve_path(
|
|
673
|
+
base_dir,
|
|
674
|
+
fn_log_raw,
|
|
675
|
+
must_exist=False,
|
|
676
|
+
kind="log file",
|
|
677
|
+
)
|
|
678
|
+
n_single_point_snapshots = int(
|
|
679
|
+
reference.get("n_single_point_snapshots", 1000)
|
|
680
|
+
)
|
|
681
|
+
if n_single_point_snapshots <= 0:
|
|
682
|
+
raise ValueError(
|
|
683
|
+
"'reference.n_single_point_snapshots' must be a positive integer."
|
|
684
|
+
)
|
|
685
|
+
return cls(
|
|
686
|
+
fn_config=fn_config,
|
|
687
|
+
project_dir=project_dir,
|
|
688
|
+
gmx_cmd=str(gromacs["command"]),
|
|
689
|
+
fn_log=resolved_log,
|
|
690
|
+
systems=systems,
|
|
691
|
+
n_single_point_snapshots=n_single_point_snapshots,
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@dataclass(frozen=True)
|
|
696
|
+
class AnalyzeSystemConfig:
|
|
697
|
+
fn_coord: Path
|
|
698
|
+
fn_topol: Path
|
|
699
|
+
fn_trj: Path
|
|
700
|
+
routines: tuple[AnalysisRoutineConfig, ...]
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@dataclass(frozen=True)
|
|
704
|
+
class AnalyzeTrainsetConfig:
|
|
705
|
+
dir: Path
|
|
706
|
+
start: int = 1
|
|
707
|
+
stop: Optional[int] = None
|
|
708
|
+
step: int = 1
|
|
709
|
+
workers: int = -1
|
|
710
|
+
progress_stride: int = 10
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@dataclass(frozen=True)
|
|
714
|
+
class AnalyzeRefsetConfig:
|
|
715
|
+
systems: list[AnalyzeSystemConfig]
|
|
716
|
+
start: int = 0
|
|
717
|
+
stop: int = -1
|
|
718
|
+
step: int = 1
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@dataclass(frozen=True)
|
|
722
|
+
class AnalyzeOutputConfig:
|
|
723
|
+
path: Path = Path("./qoi")
|
|
724
|
+
log: Path = Path("./out.log")
|
|
725
|
+
write_raw: bool = False
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@dataclass(frozen=True)
|
|
729
|
+
class QoIConfig:
|
|
730
|
+
fn_config: Path
|
|
731
|
+
trainset: AnalyzeTrainsetConfig
|
|
732
|
+
refset: AnalyzeRefsetConfig
|
|
733
|
+
run: AnalysisRuntimeConfig = AnalysisRuntimeConfig()
|
|
734
|
+
output: AnalyzeOutputConfig = AnalyzeOutputConfig()
|
|
735
|
+
|
|
736
|
+
@classmethod
|
|
737
|
+
def load(cls, fn_config: PathLike) -> "QoIConfig":
|
|
738
|
+
fn_config = Path(fn_config).resolve()
|
|
739
|
+
base_dir = fn_config.parent
|
|
740
|
+
config = load_yaml(fn_config)
|
|
741
|
+
|
|
742
|
+
for key in ("trainset", "refset"):
|
|
743
|
+
if key not in config:
|
|
744
|
+
raise ValueError(f"Missing required configuration section: {key!r}.")
|
|
745
|
+
|
|
746
|
+
trainset = config["trainset"]
|
|
747
|
+
if not isinstance(trainset, Mapping):
|
|
748
|
+
raise ValueError("'trainset' must be a mapping.")
|
|
749
|
+
trainset_dir = trainset.get("dir")
|
|
750
|
+
if trainset_dir is None:
|
|
751
|
+
raise ValueError("Missing 'dir' in trainset configuration.")
|
|
752
|
+
|
|
753
|
+
refset = config["refset"]
|
|
754
|
+
if not isinstance(refset, Mapping):
|
|
755
|
+
raise ValueError("'refset' must be a mapping.")
|
|
756
|
+
systems_raw = refset.get("systems")
|
|
757
|
+
if not isinstance(systems_raw, list) or not systems_raw:
|
|
758
|
+
raise ValueError("'refset.systems' must be a non-empty list.")
|
|
759
|
+
|
|
760
|
+
systems: list[AnalyzeSystemConfig] = []
|
|
761
|
+
for i, system in enumerate(systems_raw):
|
|
762
|
+
if not isinstance(system, Mapping):
|
|
763
|
+
raise ValueError(f"refset.systems[{i}] must be a mapping.")
|
|
764
|
+
for key in ("coordinates", "topology", "trajectory"):
|
|
765
|
+
if key not in system:
|
|
766
|
+
raise ValueError(
|
|
767
|
+
f"refset.systems[{i}] is missing required key {key!r}."
|
|
768
|
+
)
|
|
769
|
+
routines = system.get("routines")
|
|
770
|
+
if not isinstance(routines, list) or not routines:
|
|
771
|
+
raise ValueError(
|
|
772
|
+
f"refset.systems[{i}].routines must be a non-empty list."
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
systems.append(
|
|
776
|
+
AnalyzeSystemConfig(
|
|
777
|
+
fn_coord=_resolve_path(
|
|
778
|
+
base_dir,
|
|
779
|
+
system["coordinates"],
|
|
780
|
+
kind=f"refset.systems[{i}] coordinates file",
|
|
781
|
+
),
|
|
782
|
+
fn_topol=_resolve_path(
|
|
783
|
+
base_dir,
|
|
784
|
+
system["topology"],
|
|
785
|
+
kind=f"refset.systems[{i}] topology file",
|
|
786
|
+
),
|
|
787
|
+
fn_trj=_resolve_path(
|
|
788
|
+
base_dir,
|
|
789
|
+
system["trajectory"],
|
|
790
|
+
kind=f"refset.systems[{i}] trajectory file",
|
|
791
|
+
),
|
|
792
|
+
routines=normalize_routine_list(routines, base_dir=base_dir),
|
|
793
|
+
)
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
run = normalize_analysis_runtime_config(config.get("run"))
|
|
797
|
+
if "analysis" in config:
|
|
798
|
+
raise ValueError("Use 'run' instead of 'analysis' in QoI config.")
|
|
799
|
+
output = config.get("output", {})
|
|
800
|
+
if not isinstance(output, Mapping):
|
|
801
|
+
raise ValueError("'output' must be a mapping.")
|
|
802
|
+
|
|
803
|
+
return cls(
|
|
804
|
+
fn_config=fn_config,
|
|
805
|
+
trainset=AnalyzeTrainsetConfig(
|
|
806
|
+
dir=_resolve_path(
|
|
807
|
+
base_dir,
|
|
808
|
+
trainset_dir,
|
|
809
|
+
kind="trainset directory",
|
|
810
|
+
),
|
|
811
|
+
start=int(trainset.get("start", 1)),
|
|
812
|
+
stop=trainset.get("stop"),
|
|
813
|
+
step=int(trainset.get("step", 1)),
|
|
814
|
+
workers=int(trainset.get("workers", -1)),
|
|
815
|
+
progress_stride=int(trainset.get("progress_stride", 10)),
|
|
816
|
+
),
|
|
817
|
+
refset=AnalyzeRefsetConfig(
|
|
818
|
+
systems=systems,
|
|
819
|
+
start=int(refset.get("start", 0)),
|
|
820
|
+
stop=refset.get("stop", -1),
|
|
821
|
+
step=int(refset.get("step", 1)),
|
|
822
|
+
),
|
|
823
|
+
run=run,
|
|
824
|
+
output=AnalyzeOutputConfig(
|
|
825
|
+
path=_resolve_path(
|
|
826
|
+
base_dir,
|
|
827
|
+
output.get("path", "./qoi"),
|
|
828
|
+
must_exist=False,
|
|
829
|
+
kind="QoI output path",
|
|
830
|
+
),
|
|
831
|
+
log=_resolve_path(
|
|
832
|
+
base_dir,
|
|
833
|
+
output.get("log", "./out.log"),
|
|
834
|
+
must_exist=False,
|
|
835
|
+
kind="log file",
|
|
836
|
+
),
|
|
837
|
+
write_raw=bool(output.get("write_raw", False)),
|
|
838
|
+
),
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
@dataclass(frozen=True)
|
|
843
|
+
class TrainDatasetConfig:
|
|
844
|
+
name: str
|
|
845
|
+
fn_data: Path
|
|
846
|
+
mean: Any = 0
|
|
847
|
+
nuisance: float | None = None
|
|
848
|
+
observation_scale: float = 1.0
|
|
849
|
+
fn_model: Path | None = None
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@dataclass(frozen=True)
|
|
853
|
+
class TrainOptionsConfig:
|
|
854
|
+
model_dir: Path
|
|
855
|
+
reuse_models: bool = True
|
|
856
|
+
n_hyper_max: int = 200
|
|
857
|
+
committee_size: int = 1
|
|
858
|
+
test_fraction: float = 0.2
|
|
859
|
+
device: str = "cuda"
|
|
860
|
+
opt_kwargs: dict[str, Any] = field(default_factory=dict)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
@dataclass(frozen=True)
|
|
864
|
+
class TrainConfig:
|
|
865
|
+
fn_config: Path
|
|
866
|
+
datasets: tuple[TrainDatasetConfig, ...]
|
|
867
|
+
training: TrainOptionsConfig
|
|
868
|
+
log: Path
|
|
869
|
+
|
|
870
|
+
@classmethod
|
|
871
|
+
def load(cls, fn_config: PathLike) -> "TrainConfig":
|
|
872
|
+
fn_config = Path(fn_config).resolve()
|
|
873
|
+
base_dir = fn_config.parent
|
|
874
|
+
config = load_yaml(fn_config)
|
|
875
|
+
|
|
876
|
+
for key in ("datasets", "training"):
|
|
877
|
+
if key not in config:
|
|
878
|
+
raise ValueError(f"Missing required configuration section: {key!r}.")
|
|
879
|
+
|
|
880
|
+
datasets_raw = config["datasets"]
|
|
881
|
+
if not isinstance(datasets_raw, Mapping) or not datasets_raw:
|
|
882
|
+
raise ValueError("'datasets' must be a non-empty mapping.")
|
|
883
|
+
|
|
884
|
+
training = config["training"]
|
|
885
|
+
if not isinstance(training, Mapping):
|
|
886
|
+
raise ValueError("'training' must be a mapping.")
|
|
887
|
+
|
|
888
|
+
model_dir = _resolve_path(
|
|
889
|
+
base_dir,
|
|
890
|
+
training.get("model_dir", "./models"),
|
|
891
|
+
must_exist=False,
|
|
892
|
+
kind="model directory",
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
training_known_keys = {
|
|
896
|
+
"model_dir",
|
|
897
|
+
"reuse_models",
|
|
898
|
+
"n_hyper_max",
|
|
899
|
+
"committee_size",
|
|
900
|
+
"test_fraction",
|
|
901
|
+
"device",
|
|
902
|
+
}
|
|
903
|
+
opt_kwargs = {
|
|
904
|
+
key: value
|
|
905
|
+
for key, value in training.items()
|
|
906
|
+
if key not in training_known_keys
|
|
907
|
+
}
|
|
908
|
+
training_config = TrainOptionsConfig(
|
|
909
|
+
model_dir=model_dir,
|
|
910
|
+
reuse_models=bool(training.get("reuse_models", True)),
|
|
911
|
+
n_hyper_max=int(training.get("n_hyper_max", 200)),
|
|
912
|
+
committee_size=int(training.get("committee_size", 1)),
|
|
913
|
+
test_fraction=float(training.get("test_fraction", 0.2)),
|
|
914
|
+
device=str(training.get("device", "cuda")),
|
|
915
|
+
opt_kwargs=opt_kwargs,
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
if not (0 < training_config.test_fraction < 1):
|
|
919
|
+
raise ValueError("'training.test_fraction' must be between 0 and 1.")
|
|
920
|
+
if training_config.n_hyper_max < 1:
|
|
921
|
+
raise ValueError("'training.n_hyper_max' must be positive.")
|
|
922
|
+
if training_config.committee_size < 1:
|
|
923
|
+
raise ValueError("'training.committee_size' must be positive.")
|
|
924
|
+
|
|
925
|
+
datasets: list[TrainDatasetConfig] = []
|
|
926
|
+
for name, dataset in datasets_raw.items():
|
|
927
|
+
if not isinstance(dataset, Mapping):
|
|
928
|
+
raise ValueError(f"Dataset {name!r} must be a mapping.")
|
|
929
|
+
if "data" not in dataset:
|
|
930
|
+
raise ValueError(f"Dataset {name!r} is missing required key 'data'.")
|
|
931
|
+
nuisance = dataset.get("nuisance")
|
|
932
|
+
if nuisance is not None:
|
|
933
|
+
nuisance = float(nuisance)
|
|
934
|
+
if nuisance <= 0:
|
|
935
|
+
raise ValueError(
|
|
936
|
+
"Dataset "
|
|
937
|
+
f"{name!r} nuisance must be a positive standard deviation."
|
|
938
|
+
)
|
|
939
|
+
observation_scale = float(dataset.get("observation_scale", 1.0))
|
|
940
|
+
if observation_scale <= 0:
|
|
941
|
+
raise ValueError(
|
|
942
|
+
f"Dataset {name!r} observation_scale must be positive."
|
|
943
|
+
)
|
|
944
|
+
fn_model = dataset.get("model")
|
|
945
|
+
if fn_model is None:
|
|
946
|
+
fn_model_resolved = model_dir / f"{name}.lgp"
|
|
947
|
+
else:
|
|
948
|
+
fn_model_resolved = _resolve_path(
|
|
949
|
+
base_dir,
|
|
950
|
+
fn_model,
|
|
951
|
+
must_exist=False,
|
|
952
|
+
kind=f"dataset {name!r} model file",
|
|
953
|
+
)
|
|
954
|
+
datasets.append(
|
|
955
|
+
TrainDatasetConfig(
|
|
956
|
+
name=str(name),
|
|
957
|
+
fn_data=_resolve_path(
|
|
958
|
+
base_dir,
|
|
959
|
+
dataset["data"],
|
|
960
|
+
kind=f"dataset {name!r} data file",
|
|
961
|
+
),
|
|
962
|
+
mean=dataset.get("mean", 0),
|
|
963
|
+
nuisance=nuisance,
|
|
964
|
+
observation_scale=observation_scale,
|
|
965
|
+
fn_model=fn_model_resolved,
|
|
966
|
+
)
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
log = _resolve_path(
|
|
970
|
+
base_dir,
|
|
971
|
+
config.get("log", "./out.log"),
|
|
972
|
+
must_exist=False,
|
|
973
|
+
kind="log file",
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
return cls(
|
|
977
|
+
fn_config=fn_config,
|
|
978
|
+
datasets=tuple(datasets),
|
|
979
|
+
training=training_config,
|
|
980
|
+
log=log,
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
@dataclass(frozen=True)
|
|
985
|
+
class LearnMCMCConfig:
|
|
986
|
+
priors_disttype: str = "normal"
|
|
987
|
+
total_steps: int = 1500
|
|
988
|
+
warmup: int = 500
|
|
989
|
+
thin: int = 1
|
|
990
|
+
progress_stride: int = 100
|
|
991
|
+
n_walkers: int | None = None
|
|
992
|
+
checkpoint: Path | None = None
|
|
993
|
+
posterior: Path = Path("./posterior.pt")
|
|
994
|
+
priors: Path | None = Path("./priors.pt")
|
|
995
|
+
restart: bool = True
|
|
996
|
+
device: str = "cuda"
|
|
997
|
+
rhat_tol: float = 1.01
|
|
998
|
+
ess_min: int = 100
|
|
999
|
+
include_implicit_charge: bool = False
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
@dataclass(frozen=True)
|
|
1003
|
+
class LearnConfig:
|
|
1004
|
+
fn_config: Path
|
|
1005
|
+
specs: Path
|
|
1006
|
+
models: dict[str, Path]
|
|
1007
|
+
mcmc: LearnMCMCConfig
|
|
1008
|
+
log: Path
|
|
1009
|
+
|
|
1010
|
+
@classmethod
|
|
1011
|
+
def load(cls, fn_config: PathLike) -> "LearnConfig":
|
|
1012
|
+
fn_config = Path(fn_config).resolve()
|
|
1013
|
+
base_dir = fn_config.parent
|
|
1014
|
+
config = load_yaml(fn_config)
|
|
1015
|
+
|
|
1016
|
+
for key in ("specs", "models", "mcmc"):
|
|
1017
|
+
if key not in config:
|
|
1018
|
+
raise ValueError(f"Missing required configuration section: {key!r}.")
|
|
1019
|
+
|
|
1020
|
+
mcmc = config["mcmc"]
|
|
1021
|
+
if not isinstance(mcmc, Mapping):
|
|
1022
|
+
raise ValueError("'mcmc' must be a mapping.")
|
|
1023
|
+
|
|
1024
|
+
total_steps = int(mcmc.get("total_steps", 1500))
|
|
1025
|
+
warmup = int(mcmc.get("warmup", 500))
|
|
1026
|
+
thin = int(mcmc.get("thin", 1))
|
|
1027
|
+
progress_stride = int(mcmc.get("progress_stride", 100))
|
|
1028
|
+
if total_steps < 1:
|
|
1029
|
+
raise ValueError("'mcmc.total_steps' must be positive.")
|
|
1030
|
+
if warmup < 0 or warmup >= total_steps:
|
|
1031
|
+
raise ValueError("'mcmc.warmup' must satisfy 0 <= warmup < total_steps.")
|
|
1032
|
+
if thin < 1:
|
|
1033
|
+
raise ValueError("'mcmc.thin' must be positive.")
|
|
1034
|
+
if progress_stride < 1:
|
|
1035
|
+
raise ValueError("'mcmc.progress_stride' must be positive.")
|
|
1036
|
+
|
|
1037
|
+
mcmc_config = LearnMCMCConfig(
|
|
1038
|
+
priors_disttype=str(mcmc.get("priors_disttype", "normal")),
|
|
1039
|
+
total_steps=total_steps,
|
|
1040
|
+
warmup=warmup,
|
|
1041
|
+
thin=thin,
|
|
1042
|
+
progress_stride=progress_stride,
|
|
1043
|
+
n_walkers=None
|
|
1044
|
+
if mcmc.get("n_walkers") is None
|
|
1045
|
+
else int(mcmc["n_walkers"]),
|
|
1046
|
+
checkpoint=_resolve_optional_path(
|
|
1047
|
+
base_dir,
|
|
1048
|
+
mcmc.get("checkpoint", "./mcmc-checkpoint.pt"),
|
|
1049
|
+
must_exist=False,
|
|
1050
|
+
kind="MCMC checkpoint file",
|
|
1051
|
+
),
|
|
1052
|
+
posterior=_resolve_path(
|
|
1053
|
+
base_dir,
|
|
1054
|
+
mcmc.get("posterior", "./posterior.pt"),
|
|
1055
|
+
must_exist=False,
|
|
1056
|
+
kind="posterior output file",
|
|
1057
|
+
),
|
|
1058
|
+
priors=_resolve_optional_path(
|
|
1059
|
+
base_dir,
|
|
1060
|
+
mcmc.get("priors", "./priors.pt"),
|
|
1061
|
+
must_exist=False,
|
|
1062
|
+
kind="priors output file",
|
|
1063
|
+
),
|
|
1064
|
+
restart=bool(mcmc.get("restart", True)),
|
|
1065
|
+
device=str(mcmc.get("device", "cuda")),
|
|
1066
|
+
rhat_tol=float(mcmc.get("rhat_tol", 1.01)),
|
|
1067
|
+
ess_min=int(mcmc.get("ess_min", 100)),
|
|
1068
|
+
include_implicit_charge=bool(
|
|
1069
|
+
mcmc.get("include_implicit_charge", False)
|
|
1070
|
+
),
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
log = _resolve_path(
|
|
1074
|
+
base_dir,
|
|
1075
|
+
config.get("log", "./out.log"),
|
|
1076
|
+
must_exist=False,
|
|
1077
|
+
kind="log file",
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
return cls(
|
|
1081
|
+
fn_config=fn_config,
|
|
1082
|
+
specs=_resolve_path(base_dir, config["specs"], kind="specs file"),
|
|
1083
|
+
models=_load_model_paths(base_dir, config["models"]),
|
|
1084
|
+
mcmc=mcmc_config,
|
|
1085
|
+
log=log,
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
@dataclass(frozen=True)
|
|
1090
|
+
class MDJobConfig:
|
|
1091
|
+
fn_config: Path
|
|
1092
|
+
sample_id: str
|
|
1093
|
+
params: list[float]
|
|
1094
|
+
trainset_dir: Path
|
|
1095
|
+
fn_specs: Optional[Path]
|
|
1096
|
+
gmx_cmd: str
|
|
1097
|
+
job_scheduler: SchedulerName
|
|
1098
|
+
store: tuple[str, ...]
|
|
1099
|
+
run: bool
|
|
1100
|
+
systems: list[SimulationSystemConfig]
|
|
1101
|
+
|
|
1102
|
+
@classmethod
|
|
1103
|
+
def load(cls, fn_config: PathLike) -> "MDJobConfig":
|
|
1104
|
+
fn_config = Path(fn_config).resolve()
|
|
1105
|
+
base_dir = fn_config.parent
|
|
1106
|
+
config = load_yaml(fn_config)
|
|
1107
|
+
|
|
1108
|
+
required = [
|
|
1109
|
+
"sample_id",
|
|
1110
|
+
"params",
|
|
1111
|
+
"trainset_dir",
|
|
1112
|
+
"gmx_cmd",
|
|
1113
|
+
"job_scheduler",
|
|
1114
|
+
"systems",
|
|
1115
|
+
]
|
|
1116
|
+
missing = [key for key in required if key not in config]
|
|
1117
|
+
if missing:
|
|
1118
|
+
raise ValueError(
|
|
1119
|
+
"Missing required MD job option(s): "
|
|
1120
|
+
+ ", ".join(repr(key) for key in missing)
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
job = cls(
|
|
1124
|
+
fn_config=fn_config,
|
|
1125
|
+
sample_id=str(config["sample_id"]),
|
|
1126
|
+
params=[float(value) for value in config["params"]],
|
|
1127
|
+
trainset_dir=_resolve_path(
|
|
1128
|
+
base_dir,
|
|
1129
|
+
config["trainset_dir"],
|
|
1130
|
+
kind="trainset directory",
|
|
1131
|
+
),
|
|
1132
|
+
fn_specs=_resolve_optional_path(
|
|
1133
|
+
base_dir,
|
|
1134
|
+
config.get("fn_specs"),
|
|
1135
|
+
kind="specs file",
|
|
1136
|
+
),
|
|
1137
|
+
gmx_cmd=str(config["gmx_cmd"]),
|
|
1138
|
+
job_scheduler=config["job_scheduler"],
|
|
1139
|
+
store=tuple(_normalize_store(config.get("store"))),
|
|
1140
|
+
run=bool(config.get("run", True)),
|
|
1141
|
+
systems=_load_simulation_systems(
|
|
1142
|
+
base_dir,
|
|
1143
|
+
config["systems"],
|
|
1144
|
+
key="systems",
|
|
1145
|
+
),
|
|
1146
|
+
)
|
|
1147
|
+
return job
|