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/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]
|