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/domain/bias.py ADDED
@@ -0,0 +1,149 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Any, Mapping
4
+
5
+ PathLike = str | Path
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class BiasSpec:
10
+ """Bias specification for one simulation window.
11
+
12
+ Parameters
13
+ ----------
14
+ kind
15
+ Bias type. Supported values are ``"none"``, ``"colvars"``, and
16
+ ``"plumed"``.
17
+ colvars_file
18
+ Path to the user-supplied COLVARS input file.
19
+ plumed_file
20
+ Path to the user-supplied PLUMED input file.
21
+ """
22
+
23
+ kind: str = "none"
24
+ colvars_file: Path | None = None
25
+ plumed_file: Path | None = None
26
+
27
+ def __post_init__(self) -> None:
28
+ colvars_file = None if self.colvars_file is None else Path(self.colvars_file)
29
+ plumed_file = None if self.plumed_file is None else Path(self.plumed_file)
30
+
31
+ if self.kind not in {"none", "colvars", "plumed"}:
32
+ raise ValueError(
33
+ f"Unsupported bias kind {self.kind!r}. "
34
+ "Supported values are 'none', 'colvars', and 'plumed'."
35
+ )
36
+ if colvars_file is not None and plumed_file is not None:
37
+ raise ValueError(
38
+ "Bias specification must define at most one of 'colvars_file' "
39
+ "and 'plumed_file'."
40
+ )
41
+ if self.kind == "colvars" and colvars_file is None:
42
+ raise ValueError("COLVARS bias requires 'colvars_file'.")
43
+ if self.kind == "plumed" and plumed_file is None:
44
+ raise ValueError("PLUMED bias requires 'plumed_file'.")
45
+ if self.kind == "none" and (
46
+ colvars_file is not None or plumed_file is not None
47
+ ):
48
+ raise ValueError("Unbiased systems cannot define bias input files.")
49
+ if colvars_file is not None and not colvars_file.exists():
50
+ raise FileNotFoundError(f"COLVARS file not found: {colvars_file}")
51
+ if plumed_file is not None and not plumed_file.exists():
52
+ raise FileNotFoundError(f"PLUMED file not found: {plumed_file}")
53
+
54
+ object.__setattr__(self, "colvars_file", colvars_file)
55
+ object.__setattr__(self, "plumed_file", plumed_file)
56
+
57
+ @classmethod
58
+ def from_any(
59
+ cls,
60
+ value: "BiasSpec | Mapping[str, Any] | PathLike | None",
61
+ *,
62
+ base_dir: Path | None = None,
63
+ ) -> "BiasSpec":
64
+ """Normalize a raw config value into a ``BiasSpec``.
65
+
66
+ Parameters
67
+ ----------
68
+ value
69
+ Existing ``BiasSpec`` instance, raw mapping, or ``None``.
70
+ base_dir
71
+ Base directory used to resolve relative COLVARS file paths.
72
+
73
+ Returns
74
+ -------
75
+ BiasSpec
76
+ Normalized bias specification.
77
+ """
78
+ if isinstance(value, cls):
79
+ return value
80
+ if value is None:
81
+ return cls()
82
+ if isinstance(value, (str, Path)):
83
+ if base_dir is not None:
84
+ value = Path(value)
85
+ if not value.is_absolute():
86
+ value = (base_dir / value).resolve()
87
+ return cls.load(value)
88
+ if not isinstance(value, Mapping):
89
+ raise ValueError(f"Invalid bias specification: {value!r}")
90
+
91
+ colvars_raw = value.get("colvars_file", value.get("fn_colvars"))
92
+ plumed_raw = value.get("plumed_file", value.get("fn_plumed"))
93
+ colvars_file = None
94
+ plumed_file = None
95
+ if colvars_raw is not None:
96
+ colvars_file = Path(colvars_raw)
97
+ if base_dir is not None and not colvars_file.is_absolute():
98
+ colvars_file = (base_dir / colvars_file).resolve()
99
+ if plumed_raw is not None:
100
+ plumed_file = Path(plumed_raw)
101
+ if base_dir is not None and not plumed_file.is_absolute():
102
+ plumed_file = (base_dir / plumed_file).resolve()
103
+
104
+ if value.get("kind") is not None:
105
+ kind = str(value["kind"])
106
+ elif colvars_file is not None:
107
+ kind = "colvars"
108
+ elif plumed_file is not None:
109
+ kind = "plumed"
110
+ else:
111
+ kind = "none"
112
+ return cls(
113
+ kind=kind,
114
+ colvars_file=colvars_file,
115
+ plumed_file=plumed_file,
116
+ )
117
+
118
+ @property
119
+ def is_biased(self) -> bool:
120
+ """Whether this specification defines any simulation bias."""
121
+ return self.kind != "none"
122
+
123
+ @property
124
+ def input_file(self) -> Path | None:
125
+ """Bias input file for the configured engine, if any."""
126
+ return self.colvars_file if self.kind == "colvars" else self.plumed_file
127
+
128
+ @property
129
+ def input_filename(self) -> str | None:
130
+ """Canonical filename used when copying a bias input file."""
131
+ if self.kind == "colvars":
132
+ return "bias.colvars.dat"
133
+ if self.kind == "plumed":
134
+ return "bias.plumed.dat"
135
+ return None
136
+
137
+ @classmethod
138
+ def load(cls, fn_in: PathLike) -> "BiasSpec":
139
+ """Load a bias specification from a direct bias-input file."""
140
+ fn_in = Path(fn_in).resolve()
141
+ name = fn_in.name
142
+ if name.endswith(".colvars.dat"):
143
+ return cls(kind="colvars", colvars_file=fn_in)
144
+ if name.endswith(".plumed.dat"):
145
+ return cls(kind="plumed", plumed_file=fn_in)
146
+ raise ValueError(
147
+ f"Unsupported bias file {fn_in}. Expected '.colvars.dat' "
148
+ "or '.plumed.dat'."
149
+ )
bff/domain/specs.py ADDED
@@ -0,0 +1,347 @@
1
+ from dataclasses import InitVar, dataclass, field
2
+ from pathlib import Path
3
+ from typing import Any, Callable, Mapping, Optional, Sequence, Tuple, Union
4
+
5
+ import numpy as np
6
+ from scipy.stats.qmc import LatinHypercube
7
+
8
+ from ..io.utils import load_yaml, save_yaml
9
+
10
+ try:
11
+ import torch
12
+ except ModuleNotFoundError:
13
+ torch = None
14
+
15
+
16
+ PathLike = Union[str, Path]
17
+ ArrayLike = Union[np.ndarray, Any]
18
+
19
+
20
+ def _is_torch_tensor(values: object) -> bool:
21
+ return torch is not None and isinstance(values, torch.Tensor)
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Bounds:
26
+ """Named parameter bounds with a stable sorted order."""
27
+
28
+ by_name: Mapping[str, Tuple[float, float]]
29
+ _items: tuple[tuple[str, tuple[float, float]], ...] = field(
30
+ init=False,
31
+ repr=False,
32
+ )
33
+
34
+ def __post_init__(self) -> None:
35
+ items = tuple(sorted(self.by_name.items()))
36
+ for name, (lower, upper) in items:
37
+ if lower > upper:
38
+ raise ValueError(
39
+ f"Lower bound {lower} is greater than upper bound {upper} "
40
+ f"for parameter {name!r}."
41
+ )
42
+ object.__setattr__(self, "_items", items)
43
+
44
+ @property
45
+ def names(self) -> np.ndarray:
46
+ return np.array([name for name, _ in self._items], dtype=str)
47
+
48
+ @property
49
+ def array(self) -> np.ndarray:
50
+ return np.asarray([bounds for _, bounds in self._items], dtype=float)
51
+
52
+ @property
53
+ def lower(self) -> np.ndarray:
54
+ return self.array[:, 0]
55
+
56
+ @property
57
+ def upper(self) -> np.ndarray:
58
+ return self.array[:, 1]
59
+
60
+ @property
61
+ def n_params(self) -> int:
62
+ return len(self._items)
63
+
64
+ def get(self, name: str) -> tuple[float, float]:
65
+ return self.by_name[name]
66
+
67
+ def index(self, name: str) -> int:
68
+ names = self.names
69
+ mask = names == name
70
+ if not np.any(mask):
71
+ raise ValueError(f"Parameter {name!r} not found in bounds.")
72
+ return int(np.argwhere(mask).ravel()[0])
73
+
74
+ def without(self, name: str) -> "Bounds":
75
+ items = {
76
+ key: value for key, value in self.by_name.items() if key != name
77
+ }
78
+ return Bounds(items)
79
+
80
+ def to_dict(self) -> dict[str, list[float]]:
81
+ return {
82
+ name: [float(lower), float(upper)]
83
+ for name, (lower, upper) in self._items
84
+ }
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class Specs:
89
+ """Force-field specification data."""
90
+
91
+ source: InitVar[dict[str, Any] | PathLike]
92
+
93
+ mol_resname: str = field(init=False)
94
+ bounds: Bounds = field(init=False)
95
+ total_charge: float = field(init=False)
96
+ constraint_charge: float = field(init=False)
97
+ implicit_atoms: tuple[str, ...] = field(init=False)
98
+
99
+ def __post_init__(self, source: dict[str, Any] | PathLike) -> None:
100
+ if isinstance(source, dict):
101
+ data = dict(source)
102
+ elif isinstance(source, (str, Path)):
103
+ data = load_yaml(source)
104
+ else:
105
+ raise TypeError(f"Unsupported source type: {type(source)}")
106
+
107
+ required = {"bounds", "implicit_atoms", "charge_target"}
108
+ missing = required - set(data)
109
+ if missing:
110
+ missing_list = ", ".join(sorted(repr(key) for key in missing))
111
+ raise ValueError(f"Missing required specs field(s): {missing_list}")
112
+
113
+ implicit_atoms = data["implicit_atoms"]
114
+ if isinstance(implicit_atoms, str):
115
+ implicit_atoms = implicit_atoms.split()
116
+
117
+ object.__setattr__(self, "mol_resname", str(data.get("mol_resname", "")))
118
+ object.__setattr__(self, "bounds", Bounds(data["bounds"]))
119
+ object.__setattr__(self, "total_charge", float(data.get("total_charge", 0.0)))
120
+ object.__setattr__(self, "constraint_charge", float(data["charge_target"]))
121
+ object.__setattr__(self, "implicit_atoms", tuple(implicit_atoms))
122
+
123
+ @classmethod
124
+ def load(cls, source: PathLike) -> "Specs":
125
+ return cls(source)
126
+
127
+ def to_dict(self) -> dict[str, Any]:
128
+ return {
129
+ "mol_resname": self.mol_resname,
130
+ "bounds": self.bounds.to_dict(),
131
+ "total_charge": self.total_charge,
132
+ "charge_target": self.constraint_charge,
133
+ "implicit_atoms": list(self.implicit_atoms),
134
+ }
135
+
136
+ def write(self, fn_out: PathLike) -> None:
137
+ save_yaml(self.to_dict(), fn_out)
138
+
139
+ @property
140
+ def implicit_param(self) -> str:
141
+ return f"charge {' '.join(self.implicit_atoms)}"
142
+
143
+ @property
144
+ def implicit_param_index(self) -> int:
145
+ return self.bounds.index(self.implicit_param)
146
+
147
+ @property
148
+ def explicit_bounds(self) -> Bounds:
149
+ return self.bounds.without(self.implicit_param)
150
+
151
+ def parameter_names(self, *, explicit_only: bool = False) -> tuple[str, ...]:
152
+ bounds = self.explicit_bounds if explicit_only else self.bounds
153
+ return tuple(str(name) for name in bounds.names)
154
+
155
+ def parameter_dict(
156
+ self,
157
+ values: Sequence[float] | np.ndarray | Any,
158
+ *,
159
+ explicit_only: bool = False,
160
+ ) -> dict[str, float]:
161
+ array = (
162
+ values.detach().cpu().numpy()
163
+ if _is_torch_tensor(values)
164
+ else np.asarray(values, dtype=float)
165
+ ).reshape(-1)
166
+ names = self.parameter_names(explicit_only=explicit_only)
167
+ if array.size != len(names):
168
+ raise ValueError(
169
+ f"Expected {len(names)} parameter values, got {array.size}."
170
+ )
171
+ return {name: float(value) for name, value in zip(names, array)}
172
+
173
+ @property
174
+ def explicit_charge_coefficients(self) -> np.ndarray:
175
+ coeffs: list[int] = []
176
+ for param in self.explicit_bounds.names:
177
+ atoms = param.split()[1:]
178
+ coeffs.append(len(atoms) if param.startswith("charge") else 0)
179
+ return np.asarray(coeffs, dtype=int)
180
+
181
+ @property
182
+ def constraint_matrix(self) -> np.ndarray:
183
+ return self.explicit_charge_coefficients
184
+
185
+ def implicit_charge(
186
+ self,
187
+ values: Sequence[float] | np.ndarray | Any,
188
+ ) -> np.ndarray:
189
+ array = (
190
+ values.detach().cpu().numpy()
191
+ if _is_torch_tensor(values)
192
+ else np.asarray(values, dtype=float)
193
+ )
194
+ array = np.atleast_2d(array)
195
+ n_explicit = self.explicit_bounds.n_params
196
+ if array.shape[1] < n_explicit:
197
+ raise ValueError(
198
+ f"Expected at least {n_explicit} explicit parameter columns, "
199
+ f"got {array.shape[1]}."
200
+ )
201
+ explicit = array[:, :n_explicit]
202
+ explicit_charge = explicit @ self.constraint_matrix
203
+ return (self.constraint_charge - explicit_charge) / len(self.implicit_atoms)
204
+
205
+ def with_implicit_charge(
206
+ self,
207
+ values: Sequence[float] | np.ndarray | Any,
208
+ ) -> np.ndarray:
209
+ array = (
210
+ values.detach().cpu().numpy()
211
+ if _is_torch_tensor(values)
212
+ else np.asarray(values, dtype=float)
213
+ )
214
+ array = np.atleast_2d(array)
215
+ n_explicit = self.explicit_bounds.n_params
216
+ if array.shape[1] < n_explicit:
217
+ raise ValueError(
218
+ f"Expected at least {n_explicit} explicit parameter columns, "
219
+ f"got {array.shape[1]}."
220
+ )
221
+
222
+ implicit = self.implicit_charge(array)[:, None]
223
+ insert_at = self.implicit_param_index
224
+ return np.concatenate(
225
+ [
226
+ array[:, :insert_at],
227
+ implicit,
228
+ array[:, insert_at:],
229
+ ],
230
+ axis=1,
231
+ )
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class ChargeConstraint:
236
+ """Charge-aware validity check derived from ``Specs``."""
237
+
238
+ specs: Specs
239
+
240
+ def __init__(self, specs: Specs | dict[str, Any] | PathLike) -> None:
241
+ object.__setattr__(
242
+ self,
243
+ "specs",
244
+ specs if isinstance(specs, Specs) else Specs(specs),
245
+ )
246
+
247
+ def __getattr__(self, name: str) -> Any:
248
+ return getattr(self.specs, name)
249
+
250
+ @property
251
+ def explicit_bounds(self) -> np.ndarray:
252
+ return self.specs.explicit_bounds.array
253
+
254
+ @property
255
+ def implicit_bounds(self) -> tuple[float, float]:
256
+ return self.specs.bounds.get(self.specs.implicit_param)
257
+
258
+ @property
259
+ def n_params(self) -> int:
260
+ return self.specs.explicit_bounds.n_params
261
+
262
+ @staticmethod
263
+ def _to_2d_array(values: ArrayLike) -> np.ndarray:
264
+ if _is_torch_tensor(values):
265
+ arr = values.detach().cpu().numpy()
266
+ else:
267
+ arr = np.asarray(values, dtype=float)
268
+ return np.atleast_2d(arr)
269
+
270
+ def is_valid(self, values: ArrayLike) -> np.ndarray:
271
+ x = self._to_2d_array(values)
272
+ if x.shape[1] != self.n_params:
273
+ raise ValueError(
274
+ f"Input array must have shape (n_samples, {self.n_params}), "
275
+ f"but got {x.shape}."
276
+ )
277
+
278
+ explicit_lower, explicit_upper = self.explicit_bounds.T
279
+ implicit_lower, implicit_upper = self.implicit_bounds
280
+
281
+ in_explicit_bounds = ((x >= explicit_lower) & (x <= explicit_upper)).all(axis=1)
282
+ explicit_total_charge = np.sum(
283
+ x * self.specs.explicit_charge_coefficients,
284
+ axis=1,
285
+ )
286
+ implicit_charge = self.specs.constraint_charge - explicit_total_charge
287
+ in_implicit_bounds = (
288
+ (implicit_charge >= implicit_lower) & (implicit_charge <= implicit_upper)
289
+ )
290
+ return in_explicit_bounds & in_implicit_bounds
291
+
292
+ def __call__(self, values: ArrayLike) -> np.ndarray:
293
+ return self.is_valid(values)
294
+
295
+
296
+ class RandomParamsGenerator:
297
+ """Latin-hypercube parameter generator with optional validity filter."""
298
+
299
+ def __init__(
300
+ self,
301
+ bounds: np.ndarray,
302
+ constraint: Optional[Callable[[ArrayLike], np.ndarray]] = None,
303
+ ) -> None:
304
+ if bounds.ndim != 2 or bounds.shape[1] != 2:
305
+ raise ValueError("Bounds must have shape (n_params, 2).")
306
+
307
+ self.bounds = np.asarray(bounds, dtype=float)
308
+ self.constraint = constraint
309
+ self.n_generated = 0
310
+ self.sampler = LatinHypercube(self.bounds.shape[0])
311
+
312
+ def __call__(self, n: int) -> np.ndarray:
313
+ if n < 0:
314
+ raise ValueError("Number of samples must be non-negative.")
315
+ if n == 0:
316
+ return np.empty((0, self.bounds.shape[0]), dtype=float)
317
+
318
+ lower, upper = self.bounds.T
319
+ if self.constraint is None:
320
+ unit_samples = self.sampler.random(n)
321
+ self.n_generated += n
322
+ return unit_samples * (upper - lower) + lower
323
+
324
+ collected: list[np.ndarray] = []
325
+ n_valid = 0
326
+ attempts = 0
327
+ while n_valid < n:
328
+ batch_size = max(2 * (n - n_valid), 1)
329
+ unit_samples = self.sampler.random(batch_size)
330
+ self.n_generated += batch_size
331
+ attempts += 1
332
+
333
+ samples = unit_samples * (upper - lower) + lower
334
+ mask = np.asarray(self.constraint(samples), dtype=bool).reshape(-1)
335
+ valid = samples[mask]
336
+ if valid.size > 0:
337
+ collected.append(valid)
338
+ n_valid += len(valid)
339
+ attempts = 0
340
+
341
+ if attempts >= 1000:
342
+ raise RuntimeError(
343
+ "Failed to generate valid parameter samples within 1000 "
344
+ "consecutive Latin-hypercube batches."
345
+ )
346
+
347
+ return np.vstack(collected)[:n]
bff/domain/trainset.py ADDED
@@ -0,0 +1,201 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from typing import Union
4
+
5
+ import numpy as np
6
+
7
+ from ..io.utils import extract_train_dir, prepare_path
8
+ from .specs import Specs
9
+
10
+ PathLike = Union[str, Path]
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class SimulationSystem:
15
+ """One staged simulation system inside a trainset directory."""
16
+
17
+ system_id: str
18
+ fn_topol: Path
19
+ fn_coord: Path
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class TrajectorySet:
24
+ """One analyzed sample or reference set spanning one or more trajectories."""
25
+
26
+ sample_id: str
27
+ fn_topol: tuple[Path, ...]
28
+ fn_coord: tuple[Path, ...]
29
+ fn_trj: tuple[Path, ...]
30
+ params: np.ndarray | None = None
31
+
32
+ def __post_init__(self) -> None:
33
+ if not (len(self.fn_topol) == len(self.fn_coord) == len(self.fn_trj)):
34
+ raise ValueError(
35
+ "TrajectorySet requires the same number of topology, coordinate, "
36
+ "and trajectory files."
37
+ )
38
+ if self.params is not None:
39
+ object.__setattr__(
40
+ self,
41
+ "params",
42
+ np.asarray(self.params, dtype=float).reshape(-1),
43
+ )
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class TrainSetInfo:
48
+ """Completed simulation samples and staged systems for one trainset."""
49
+
50
+ train_dir: Path
51
+ specs: Specs
52
+ systems: list[SimulationSystem]
53
+ samples: list[TrajectorySet]
54
+
55
+ @classmethod
56
+ def from_dir(
57
+ cls,
58
+ train_dir: PathLike,
59
+ *,
60
+ strict: bool = True,
61
+ ) -> "TrainSetInfo":
62
+ prepared_dir = prepare_path(Path(train_dir).resolve())
63
+ specs_data, systems_data, samples_data = extract_train_dir(prepared_dir)
64
+ if specs_data is None:
65
+ raise ValueError("Training set is missing specs information.")
66
+
67
+ specs = Specs(specs_data)
68
+ systems = [
69
+ SimulationSystem(
70
+ system_id=str(system["system_id"]),
71
+ fn_topol=prepared_dir / system["topology"],
72
+ fn_coord=prepared_dir / system["coordinates"],
73
+ )
74
+ for system in systems_data
75
+ ]
76
+
77
+ completed_samples: list[TrajectorySet] = []
78
+ sample_issues: list[str] = []
79
+ for sample_id in sorted((samples_data or {})):
80
+ sample = samples_data[sample_id]
81
+ if not isinstance(sample, dict):
82
+ sample_issues.append(
83
+ f"sample {sample_id!r}: record must be a mapping, got "
84
+ f"{type(sample).__name__}."
85
+ )
86
+ continue
87
+
88
+ if sample.get("status") != "completed":
89
+ continue
90
+ outputs = sample.get("outputs", [])
91
+ if "params" not in sample:
92
+ sample_issues.append(
93
+ f"sample {sample_id!r}: completed sample is missing 'params'."
94
+ )
95
+ continue
96
+ if not isinstance(outputs, list):
97
+ sample_issues.append(
98
+ f"sample {sample_id!r}: 'outputs' must be a list."
99
+ )
100
+ continue
101
+ if any(not isinstance(output, dict) for output in outputs):
102
+ sample_issues.append(
103
+ f"sample {sample_id!r}: all outputs must be mappings."
104
+ )
105
+ continue
106
+ if any("system_id" not in output for output in outputs):
107
+ sample_issues.append(
108
+ f"sample {sample_id!r}: each output must define 'system_id'."
109
+ )
110
+ continue
111
+ outputs = sorted(outputs, key=lambda item: item["system_id"])
112
+ if len(outputs) != len(systems):
113
+ sample_issues.append(
114
+ f"sample {sample_id!r}: expected {len(systems)} outputs, "
115
+ f"found {len(outputs)}."
116
+ )
117
+ continue
118
+ if any(
119
+ output.get("trajectory") is None
120
+ for output in outputs
121
+ ):
122
+ sample_issues.append(
123
+ f"sample {sample_id!r}: completed outputs must all define "
124
+ "trajectory files."
125
+ )
126
+ continue
127
+
128
+ fn_trj = tuple(prepared_dir / output["trajectory"] for output in outputs)
129
+ if not all(path.exists() for path in fn_trj):
130
+ missing = [
131
+ str(path.relative_to(prepared_dir))
132
+ for path in fn_trj
133
+ if not path.exists()
134
+ ]
135
+ sample_issues.append(
136
+ f"sample {sample_id!r}: missing trajectory file(s): "
137
+ + ", ".join(missing)
138
+ )
139
+ continue
140
+
141
+ completed_samples.append(
142
+ TrajectorySet(
143
+ sample_id=str(sample_id),
144
+ fn_topol=tuple(system.fn_topol for system in systems),
145
+ fn_coord=tuple(system.fn_coord for system in systems),
146
+ fn_trj=fn_trj,
147
+ params=np.asarray(sample["params"], dtype=float),
148
+ )
149
+ )
150
+
151
+ if strict and sample_issues:
152
+ issues = "\n".join(f"- {issue}" for issue in sample_issues)
153
+ raise ValueError(
154
+ "Training set contains invalid completed sample records:\n"
155
+ f"{issues}"
156
+ )
157
+ if strict and not completed_samples:
158
+ raise ValueError(
159
+ f"No completed training samples found in {prepared_dir}. "
160
+ "Check that validation/simulation finished and samples.yaml "
161
+ "contains completed outputs."
162
+ )
163
+
164
+ return cls(
165
+ train_dir=prepared_dir,
166
+ specs=specs,
167
+ systems=systems,
168
+ samples=completed_samples,
169
+ )
170
+
171
+ @property
172
+ def sample_ids(self) -> list[str]:
173
+ return [sample.sample_id for sample in self.samples]
174
+
175
+ @property
176
+ def hashes(self) -> list[str]:
177
+ return self.sample_ids
178
+
179
+ @property
180
+ def inputs(self) -> np.ndarray:
181
+ if not self.samples:
182
+ return np.empty((0, 0), dtype=float)
183
+ return np.asarray([sample.params for sample in self.samples], dtype=float)
184
+
185
+ @property
186
+ def n_samples(self) -> int:
187
+ return len(self.samples)
188
+
189
+ @property
190
+ def fn_topol(self) -> list[Path]:
191
+ return [system.fn_topol for system in self.systems]
192
+
193
+ @property
194
+ def fn_coord(self) -> list[Path]:
195
+ return [system.fn_coord for system in self.systems]
196
+
197
+ @property
198
+ def fn_trj(self) -> list[list[Path]]:
199
+ if not self.samples:
200
+ return []
201
+ return [list(sample.fn_trj) for sample in self.samples]