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/topology.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import MDAnalysis as mda
|
|
7
|
+
import numpy as np
|
|
8
|
+
from gmxtopology import MoleculeType, Topology
|
|
9
|
+
from MDAnalysis.guesser.tables import masses as MDA_MASSES
|
|
10
|
+
from MDAnalysis.lib.distances import distance_array
|
|
11
|
+
|
|
12
|
+
from .data import IONS, WATER_3SITE, WATER_4SITE, WATERS
|
|
13
|
+
from .tools import guess_box, random_placement
|
|
14
|
+
|
|
15
|
+
# 1) MDAnalysis DeprecationWarning from ITPParser (elements guessing transition)
|
|
16
|
+
warnings.filterwarnings(
|
|
17
|
+
"ignore",
|
|
18
|
+
category=DeprecationWarning,
|
|
19
|
+
module=r"MDAnalysis\.topology\.ITPParser",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# 2) MDAnalysis UserWarning about missing coordinate reader (topology-only file)
|
|
23
|
+
warnings.filterwarnings(
|
|
24
|
+
"ignore",
|
|
25
|
+
category=UserWarning,
|
|
26
|
+
message=r"No coordinate reader found for .*\.top\. Skipping this file\.",
|
|
27
|
+
module=r"MDAnalysis\.core\.universe",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
MASSES = np.array(list(MDA_MASSES.values()))
|
|
31
|
+
ELEMENTS = list(MDA_MASSES.keys())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ResidueTemplate:
|
|
36
|
+
positions: np.ndarray
|
|
37
|
+
real_mask: np.ndarray
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def guess_elements(u: mda.Universe) -> list:
|
|
41
|
+
elements = [
|
|
42
|
+
ELEMENTS[np.argmin(np.abs(MASSES - a.mass))]
|
|
43
|
+
for a in u.atoms if np.isclose(MASSES, a.mass, atol=1e-2).any()
|
|
44
|
+
]
|
|
45
|
+
u.add_TopologyAttr('elements', elements)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_unitcell(universe: mda.Universe, unitcell: list) -> None:
|
|
49
|
+
"""Ensure that the universe has a unitcell defined."""
|
|
50
|
+
if not universe.dimensions:
|
|
51
|
+
if not unitcell:
|
|
52
|
+
raise ValueError("Unitcell is not specified.")
|
|
53
|
+
for ts in universe.trajectory:
|
|
54
|
+
ts.dimensions = unitcell
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_topols(fn_topol_1: str, fn_topol_2: str) -> None:
|
|
58
|
+
"""Check if a pair of topologies is consistent."""
|
|
59
|
+
|
|
60
|
+
# Load universes
|
|
61
|
+
u1 = prepare_universe(fn_topol_1)
|
|
62
|
+
u2 = prepare_universe(fn_topol_2)
|
|
63
|
+
|
|
64
|
+
# Do not consider dummy atoms
|
|
65
|
+
atoms1 = u1.select_atoms('not mass -1 to 0.5')
|
|
66
|
+
atoms2 = u2.select_atoms('not mass -1 to 0.5')
|
|
67
|
+
|
|
68
|
+
# Check if the atomtypes are the same
|
|
69
|
+
at1 = np.unique(atoms1.types)
|
|
70
|
+
at2 = np.unique(atoms2.types)
|
|
71
|
+
|
|
72
|
+
if not len(at1) == len(at2):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
(
|
|
75
|
+
f'Number of atomtypes do not match between {fn_topol_1} '
|
|
76
|
+
f'and {fn_topol_2}.'
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not np.all(at1 == at2):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f'Atomtypes do not match between {fn_topol_1} and {fn_topol_2}.'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Check if the atom names are the same
|
|
86
|
+
names1 = np.unique(atoms1.names)
|
|
87
|
+
names2 = np.unique(atoms2.names)
|
|
88
|
+
if not np.all(names1 == names2):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f'Atom names do not match between {fn_topol_1} and {fn_topol_2}.'
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def prepare_universe(
|
|
95
|
+
fn_topol: str, fn_coord: str = None, dt: float = 1.0) -> mda.Universe:
|
|
96
|
+
"""
|
|
97
|
+
Prepares an MDAnalysis universe
|
|
98
|
+
from Gromacs .itp topology and coordinates.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
fn_topol : str
|
|
103
|
+
Path to the topology file.
|
|
104
|
+
fn_coord : str
|
|
105
|
+
Path to the coordinate file.
|
|
106
|
+
dt : float, optional
|
|
107
|
+
Time step in ps. Default is 1.0.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
universe : MDAnalysis.Universe
|
|
112
|
+
A universe object with the topology and coordinates loaded,
|
|
113
|
+
and guessed elements.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
if fn_coord is None:
|
|
117
|
+
universe = mda.Universe(
|
|
118
|
+
fn_topol,
|
|
119
|
+
topology_format='ITP', to_guess=('elements', 'masses'), dt=dt
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
universe = mda.Universe(
|
|
123
|
+
fn_topol, fn_coord,
|
|
124
|
+
topology_format='ITP', to_guess=('elements', 'masses'), dt=dt
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
guess_elements(universe)
|
|
128
|
+
|
|
129
|
+
return universe
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def create_box(
|
|
133
|
+
fn_topol: str,
|
|
134
|
+
templates: dict[str, str | Path],
|
|
135
|
+
fn_out: str,
|
|
136
|
+
box: np.ndarray = None,
|
|
137
|
+
min_dist: float = 1.5,
|
|
138
|
+
) -> tuple:
|
|
139
|
+
"""Fill a box from topology molecule counts and residue templates."""
|
|
140
|
+
|
|
141
|
+
topol = Topology(fn_topol)
|
|
142
|
+
universe = fill_universe(topol)
|
|
143
|
+
|
|
144
|
+
if box is None:
|
|
145
|
+
n_heavy = sum(a.mass > 1.1 for a in topol.atoms)
|
|
146
|
+
box = guess_box(n_heavy)
|
|
147
|
+
else:
|
|
148
|
+
box = np.array(box)
|
|
149
|
+
universe.dimensions = box
|
|
150
|
+
|
|
151
|
+
residue_templates = build_residue_templates(topol, templates)
|
|
152
|
+
coords = insert_molecules(
|
|
153
|
+
topol,
|
|
154
|
+
residue_templates,
|
|
155
|
+
box,
|
|
156
|
+
min_dist=min_dist,
|
|
157
|
+
)
|
|
158
|
+
universe.atoms.positions = coords
|
|
159
|
+
|
|
160
|
+
universe.atoms.write(fn_out)
|
|
161
|
+
|
|
162
|
+
return universe, topol
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_residue_templates(
|
|
166
|
+
topol: Topology,
|
|
167
|
+
templates: dict[str, str | Path],
|
|
168
|
+
) -> dict[tuple[str, int], ResidueTemplate]:
|
|
169
|
+
"""Load one centered placement template for each residue layout."""
|
|
170
|
+
residue_templates: dict[tuple[str, int], ResidueTemplate] = {}
|
|
171
|
+
for residue in topol.residues:
|
|
172
|
+
key = (residue.name, len(residue.atoms))
|
|
173
|
+
if key in residue_templates:
|
|
174
|
+
continue
|
|
175
|
+
residue_templates[key] = load_residue_template(residue, templates)
|
|
176
|
+
return residue_templates
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@lru_cache(maxsize=None)
|
|
180
|
+
def _load_positions_from_file(fn_template: str) -> tuple[np.ndarray, tuple[str, ...]]:
|
|
181
|
+
universe = mda.Universe(fn_template)
|
|
182
|
+
return (
|
|
183
|
+
np.asarray(universe.atoms.positions, dtype=float).copy(),
|
|
184
|
+
tuple(str(name) for name in universe.atoms.names),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@lru_cache(maxsize=None)
|
|
189
|
+
def _load_water_template(n_atoms: int) -> tuple[np.ndarray, tuple[str, ...]]:
|
|
190
|
+
if n_atoms == 3:
|
|
191
|
+
return WATER_3SITE.copy(), ("OW", "HW1", "HW2")
|
|
192
|
+
if n_atoms == 4:
|
|
193
|
+
return WATER_4SITE.copy(), ("OW", "HW1", "HW2", "IW")
|
|
194
|
+
raise ValueError(f"Unsupported water template with {n_atoms} atoms.")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def load_residue_template(
|
|
198
|
+
residue,
|
|
199
|
+
templates: dict[str, str | Path],
|
|
200
|
+
) -> ResidueTemplate:
|
|
201
|
+
"""Resolve one residue placement template from builtins or user input."""
|
|
202
|
+
residue_names = tuple(atom.name for atom in residue.atoms)
|
|
203
|
+
n_atoms = len(residue.atoms)
|
|
204
|
+
key = residue.name.lower()
|
|
205
|
+
|
|
206
|
+
if residue.name in WATERS:
|
|
207
|
+
positions, template_names = _load_water_template(n_atoms)
|
|
208
|
+
elif key in IONS:
|
|
209
|
+
if n_atoms != 1:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"Ion residue {residue.name!r} is expected to be monoatomic, "
|
|
212
|
+
f"but has {n_atoms} atoms."
|
|
213
|
+
)
|
|
214
|
+
positions = np.zeros((1, 3), dtype=float)
|
|
215
|
+
template_names = residue_names
|
|
216
|
+
else:
|
|
217
|
+
if residue.name not in templates:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"Missing template for non-standard residue {residue.name!r}."
|
|
220
|
+
)
|
|
221
|
+
positions, template_names = _load_positions_from_file(
|
|
222
|
+
str(Path(templates[residue.name]).resolve())
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if len(template_names) != n_atoms:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"Template for residue {residue.name!r} has {len(template_names)} atoms, "
|
|
228
|
+
f"expected {n_atoms}."
|
|
229
|
+
)
|
|
230
|
+
if residue_names != template_names:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"Template atom names for residue {residue.name!r} do not match the "
|
|
233
|
+
f"topology order: expected {residue_names}, got {template_names}."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
positions = np.asarray(positions, dtype=float)
|
|
237
|
+
real_mask = np.asarray([atom.mass > 0.5 for atom in residue.atoms], dtype=bool)
|
|
238
|
+
anchor = positions[real_mask].mean(axis=0) if np.any(real_mask) else positions[0]
|
|
239
|
+
return ResidueTemplate(
|
|
240
|
+
positions=positions - anchor,
|
|
241
|
+
real_mask=real_mask,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _neighboring_cells(
|
|
246
|
+
cell: tuple[int, int, int],
|
|
247
|
+
shape: np.ndarray,
|
|
248
|
+
) -> list[tuple[int, int, int]]:
|
|
249
|
+
neighbors: list[tuple[int, int, int]] = []
|
|
250
|
+
for dx in (-1, 0, 1):
|
|
251
|
+
for dy in (-1, 0, 1):
|
|
252
|
+
for dz in (-1, 0, 1):
|
|
253
|
+
neighbors.append(
|
|
254
|
+
(
|
|
255
|
+
(cell[0] + dx) % int(shape[0]),
|
|
256
|
+
(cell[1] + dy) % int(shape[1]),
|
|
257
|
+
(cell[2] + dz) % int(shape[2]),
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
return neighbors
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _cell_index(
|
|
264
|
+
position: np.ndarray,
|
|
265
|
+
cell_size: np.ndarray,
|
|
266
|
+
shape: np.ndarray,
|
|
267
|
+
) -> tuple[int, int, int]:
|
|
268
|
+
wrapped = np.mod(position, cell_size * shape)
|
|
269
|
+
index = np.floor(wrapped / cell_size).astype(int) % shape.astype(int)
|
|
270
|
+
return int(index[0]), int(index[1]), int(index[2])
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def insert_molecules(
|
|
274
|
+
topol: Topology,
|
|
275
|
+
templates: dict[tuple[str, int], ResidueTemplate],
|
|
276
|
+
box: np.ndarray,
|
|
277
|
+
min_dist: float = 1.5,
|
|
278
|
+
) -> np.ndarray:
|
|
279
|
+
"""Insert residue templates into the box with a simple spatial grid."""
|
|
280
|
+
|
|
281
|
+
coords = np.zeros((len(topol.atoms), 3))
|
|
282
|
+
occupied: list[np.ndarray] = []
|
|
283
|
+
cell_shape = np.maximum(1, np.floor(box[:3] / min_dist).astype(int))
|
|
284
|
+
cell_size = box[:3] / cell_shape
|
|
285
|
+
cells: dict[tuple[int, int, int], list[int]] = {}
|
|
286
|
+
atom_index = 0
|
|
287
|
+
|
|
288
|
+
for residue in topol.residues:
|
|
289
|
+
n_atoms = len(residue.atoms)
|
|
290
|
+
template = templates[(residue.name, n_atoms)]
|
|
291
|
+
displacement_limit = box[:3]
|
|
292
|
+
while True:
|
|
293
|
+
pos_trial = random_placement(template.positions.copy(), displacement_limit)
|
|
294
|
+
pos_trial = np.mod(pos_trial, box[:3])
|
|
295
|
+
trial_real = pos_trial[template.real_mask]
|
|
296
|
+
neighbor_ids: set[int] = set()
|
|
297
|
+
for point in trial_real:
|
|
298
|
+
cell = _cell_index(point, cell_size, cell_shape)
|
|
299
|
+
for neighbor in _neighboring_cells(cell, cell_shape):
|
|
300
|
+
neighbor_ids.update(cells.get(neighbor, []))
|
|
301
|
+
if neighbor_ids:
|
|
302
|
+
existing = np.asarray([occupied[i] for i in sorted(neighbor_ids)])
|
|
303
|
+
distances = distance_array(trial_real, existing, box=box)
|
|
304
|
+
if np.any(distances < min_dist):
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
coords[atom_index:atom_index + n_atoms] = pos_trial
|
|
308
|
+
for point in trial_real:
|
|
309
|
+
occupied.append(point.copy())
|
|
310
|
+
cell = _cell_index(point, cell_size, cell_shape)
|
|
311
|
+
cells.setdefault(cell, []).append(len(occupied) - 1)
|
|
312
|
+
break
|
|
313
|
+
atom_index += n_atoms
|
|
314
|
+
|
|
315
|
+
return coords
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def fill_universe(topol: Topology) -> mda.Universe:
|
|
319
|
+
"""Fill an empty universe with the topology information."""
|
|
320
|
+
atoms = topol.atoms
|
|
321
|
+
residues = topol.residues
|
|
322
|
+
resindices = np.array([i for i, r in enumerate(residues) for _ in r.atoms])
|
|
323
|
+
segindices = [0] * len(topol.residues)
|
|
324
|
+
|
|
325
|
+
# Create universe
|
|
326
|
+
universe = mda.Universe.empty(
|
|
327
|
+
n_atoms=len(atoms),
|
|
328
|
+
n_residues=len(residues),
|
|
329
|
+
atom_resindex=resindices,
|
|
330
|
+
residue_segindex=segindices,
|
|
331
|
+
trajectory=True)
|
|
332
|
+
|
|
333
|
+
universe.add_TopologyAttr('name', [a.name for a in atoms])
|
|
334
|
+
universe.add_TopologyAttr('type', [a.type.name for a in atoms])
|
|
335
|
+
universe.add_TopologyAttr('resname', [r.name for r in residues])
|
|
336
|
+
universe.add_TopologyAttr('resid', list(range(1, len(residues) + 1)))
|
|
337
|
+
|
|
338
|
+
universe.guess_TopologyAttrs(to_guess=['elements', 'masses'])
|
|
339
|
+
|
|
340
|
+
return universe
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class TopologyModifier(Topology if Topology is not None else object):
|
|
344
|
+
|
|
345
|
+
"""A class for modifying Gromacs topologies.
|
|
346
|
+
|
|
347
|
+
Parameters
|
|
348
|
+
----------
|
|
349
|
+
fn_topol : str | Path
|
|
350
|
+
Path to the topology file to be modified.
|
|
351
|
+
mol_resname : str
|
|
352
|
+
Residue name of the molecule to be modified.
|
|
353
|
+
implicit_atomnames : list[str] | str
|
|
354
|
+
Atom names or types to be treated as implicit
|
|
355
|
+
(i.e., their charges will be adjusted to satisfy a charge constraint).
|
|
356
|
+
Can be a single string if only one atom is implicit.
|
|
357
|
+
|
|
358
|
+
Properties
|
|
359
|
+
----------
|
|
360
|
+
mol : MoleculeType
|
|
361
|
+
The molecule type corresponding to `mol_resname`.
|
|
362
|
+
n_mol : int
|
|
363
|
+
The number of molecules of type `mol_resname` in the topology.
|
|
364
|
+
implicit_atoms : list[Atom]
|
|
365
|
+
List of Atom objects corresponding to the implicit atoms.
|
|
366
|
+
implicit_param : str
|
|
367
|
+
A string representing the charge parameter for the implicit atoms.
|
|
368
|
+
total_charge : float
|
|
369
|
+
The total charge of the molecule before applying any constraints.
|
|
370
|
+
|
|
371
|
+
Methods
|
|
372
|
+
-------
|
|
373
|
+
group_charge(atomnames: list[str]) -> float
|
|
374
|
+
Calculate the total charge of a group of atoms specified by their names.
|
|
375
|
+
resolve_parameter_names(params)
|
|
376
|
+
Resolve charge-parameter keys that may contain atom types into names.
|
|
377
|
+
apply_parameters(params, constraint_charge=None)
|
|
378
|
+
Update topology parameters and apply an optional charge constraint.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
def __init__(
|
|
382
|
+
self,
|
|
383
|
+
fn_topol: Path | str,
|
|
384
|
+
mol_resname: str,
|
|
385
|
+
implicit_atomnames: list[str] | str
|
|
386
|
+
) -> None:
|
|
387
|
+
|
|
388
|
+
self.source = fn_topol
|
|
389
|
+
self.mol_resname = mol_resname
|
|
390
|
+
if isinstance(implicit_atomnames, str):
|
|
391
|
+
self._implicit_atomnames = [implicit_atomnames]
|
|
392
|
+
else:
|
|
393
|
+
self._implicit_atomnames = implicit_atomnames
|
|
394
|
+
|
|
395
|
+
super().__init__(self.source)
|
|
396
|
+
self.universe = prepare_universe(self.source)
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def mol(self) -> MoleculeType:
|
|
400
|
+
return self.molecules[self.mol_resname][0]
|
|
401
|
+
|
|
402
|
+
@property
|
|
403
|
+
def n_mol(self) -> int:
|
|
404
|
+
return self.molecules[self.mol_resname][1]
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def implicit_atoms(self) -> list[str]:
|
|
408
|
+
implicit_atoms = []
|
|
409
|
+
for atom in self.mol.atoms:
|
|
410
|
+
if (atom.name in self._implicit_atomnames
|
|
411
|
+
or atom.type.name in self._implicit_atomnames):
|
|
412
|
+
implicit_atoms.append(atom)
|
|
413
|
+
|
|
414
|
+
return implicit_atoms
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def implicit_param(self) -> str:
|
|
418
|
+
atoms = " ".join([atom.name for atom in self.implicit_atoms])
|
|
419
|
+
return f"charge {atoms}"
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def total_charge(self) -> float:
|
|
423
|
+
return np.sum([atom.charge for atom in self.mol.atoms])
|
|
424
|
+
|
|
425
|
+
def _update_charge(self, atomname: str, value: float) -> None:
|
|
426
|
+
for atom in self.mol.atoms:
|
|
427
|
+
if atom.name == atomname:
|
|
428
|
+
atom.update(charge=value)
|
|
429
|
+
return
|
|
430
|
+
raise ValueError(f"Atom {atomname} not found in molecule {self.mol_resname}.")
|
|
431
|
+
|
|
432
|
+
def _update_sigma(self, atomtype: str, value: float) -> None:
|
|
433
|
+
for at in self.atomtypes:
|
|
434
|
+
if at.name == atomtype:
|
|
435
|
+
at.update(sigma=value)
|
|
436
|
+
return
|
|
437
|
+
raise ValueError(f"Atom type {atomtype} not found in topology.")
|
|
438
|
+
|
|
439
|
+
def _update_epsilon(self, atomtype: str, value: float) -> None:
|
|
440
|
+
for at in self.atomtypes:
|
|
441
|
+
if at.name == atomtype:
|
|
442
|
+
at.update(epsilon=value)
|
|
443
|
+
return
|
|
444
|
+
raise ValueError(f"Atom type {atomtype} not found in topology.")
|
|
445
|
+
|
|
446
|
+
def _update_dihedraltype9(
|
|
447
|
+
self,
|
|
448
|
+
atoms: str,
|
|
449
|
+
k: float,
|
|
450
|
+
phase: float,
|
|
451
|
+
multiplicity: int
|
|
452
|
+
) -> None:
|
|
453
|
+
|
|
454
|
+
# normalize white spaces
|
|
455
|
+
dihedraltype = " ".join(atoms.split())
|
|
456
|
+
|
|
457
|
+
for d in self.mol.dihedrals:
|
|
458
|
+
atoms = [d.ai, d.aj, d.ak, d.al]
|
|
459
|
+
if d.func == 9:
|
|
460
|
+
dt_str = " ".join([a.type.name for a in atoms])
|
|
461
|
+
if dt_str == dihedraltype:
|
|
462
|
+
d.update(kphi=k, phi_s=phase, mult=multiplicity)
|
|
463
|
+
return
|
|
464
|
+
raise ValueError(
|
|
465
|
+
f"Dihedral type '{dihedraltype}' not found in molecule {self.mol_resname}."
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def _update_define(self, directive: str, argument: float | int | str) -> None:
|
|
469
|
+
for define in self.defines:
|
|
470
|
+
if define.directive == directive:
|
|
471
|
+
define.update(argument=argument)
|
|
472
|
+
return
|
|
473
|
+
|
|
474
|
+
def _constraint_charge(self, target: float) -> None:
|
|
475
|
+
|
|
476
|
+
q_explicit = np.sum([
|
|
477
|
+
atom.charge for atom in self.mol.atoms
|
|
478
|
+
if atom not in self.implicit_atoms
|
|
479
|
+
])
|
|
480
|
+
|
|
481
|
+
n_implicit = len(self.implicit_atoms)
|
|
482
|
+
q_implicit = (target - q_explicit) / n_implicit
|
|
483
|
+
for atom in self.implicit_atoms:
|
|
484
|
+
self._update_charge(atom.name, q_implicit)
|
|
485
|
+
|
|
486
|
+
def group_charge(self, atomnames: list[str]) -> float:
|
|
487
|
+
return np.sum([
|
|
488
|
+
atom.charge for atom in self.mol.atoms
|
|
489
|
+
if atom.name in atomnames
|
|
490
|
+
])
|
|
491
|
+
|
|
492
|
+
def resolve_parameter_names(
|
|
493
|
+
self,
|
|
494
|
+
params: dict[str, float | list[float]]
|
|
495
|
+
) -> dict[str, float | list[float]]:
|
|
496
|
+
mol_atomnames = [atom.name for atom in self.mol.atoms]
|
|
497
|
+
mol_atomtypes = [atom.type.name for atom in self.mol.atoms]
|
|
498
|
+
|
|
499
|
+
resolved: dict[str, float | list[float]] = {}
|
|
500
|
+
for p in params.keys():
|
|
501
|
+
p_name, atoms = p.split(" ", maxsplit=1)
|
|
502
|
+
if p_name != 'charge':
|
|
503
|
+
resolved[p] = params[p]
|
|
504
|
+
continue
|
|
505
|
+
|
|
506
|
+
atomnames = " "
|
|
507
|
+
for name in atoms.split(" "):
|
|
508
|
+
if name in mol_atomnames:
|
|
509
|
+
atomnames += f"{name} "
|
|
510
|
+
elif name in mol_atomtypes:
|
|
511
|
+
for atom in self.mol.atoms:
|
|
512
|
+
if atom.type.name == name:
|
|
513
|
+
atomnames += f"{atom.name} "
|
|
514
|
+
else:
|
|
515
|
+
raise ValueError(
|
|
516
|
+
f"Atom name or type '{name}' not found "
|
|
517
|
+
f"in molecule {self.mol_resname}."
|
|
518
|
+
)
|
|
519
|
+
resolved[f"charge {atomnames.strip()}"] = params[p]
|
|
520
|
+
|
|
521
|
+
# check for duplicates
|
|
522
|
+
flat: list[str] = []
|
|
523
|
+
for r in resolved.keys():
|
|
524
|
+
if r.startswith('charge'):
|
|
525
|
+
flat.extend(r.split(" ")[1:])
|
|
526
|
+
if len(flat) != len(set(flat)):
|
|
527
|
+
raise ValueError("Duplicate atom names in resolved parameters.")
|
|
528
|
+
|
|
529
|
+
return resolved
|
|
530
|
+
|
|
531
|
+
def apply_parameters(
|
|
532
|
+
self,
|
|
533
|
+
params: dict[str, float | list[float]],
|
|
534
|
+
constraint_charge: float | None = None,
|
|
535
|
+
) -> None:
|
|
536
|
+
params_resolved = self.resolve_parameter_names(params)
|
|
537
|
+
|
|
538
|
+
for p, value in params_resolved.items():
|
|
539
|
+
if "dihedraltype9" in p:
|
|
540
|
+
p_name, *dihedraltypes, mult, phase = p.split()
|
|
541
|
+
for dt in dihedraltypes:
|
|
542
|
+
atoms = dt.replace("_", " ")
|
|
543
|
+
self._update_dihedraltype9(
|
|
544
|
+
atoms,
|
|
545
|
+
k=value,
|
|
546
|
+
phase=float(phase),
|
|
547
|
+
multiplicity=int(mult)
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
elif p.startswith("define"):
|
|
551
|
+
p_name, directive = p.split(" ")
|
|
552
|
+
self._update_define(directive, value)
|
|
553
|
+
|
|
554
|
+
else:
|
|
555
|
+
p_name, *atoms = p.split(" ")
|
|
556
|
+
if p_name == 'charge':
|
|
557
|
+
for atom in atoms:
|
|
558
|
+
self._update_charge(atom, value)
|
|
559
|
+
elif p_name == 'sigma':
|
|
560
|
+
for atom in atoms:
|
|
561
|
+
self._update_sigma(atom, value)
|
|
562
|
+
elif p_name == 'epsilon':
|
|
563
|
+
for atom in atoms:
|
|
564
|
+
self._update_epsilon(atom, value)
|
|
565
|
+
else:
|
|
566
|
+
raise ValueError(f"Unsupported parameter name '{p_name}'.")
|
|
567
|
+
|
|
568
|
+
if constraint_charge is not None:
|
|
569
|
+
# Ensure the implicit charge group stays implicit.
|
|
570
|
+
for atom in self.implicit_atoms:
|
|
571
|
+
updated = any(
|
|
572
|
+
key.startswith("charge ")
|
|
573
|
+
and atom.name in key.split()[1:]
|
|
574
|
+
for key in params_resolved
|
|
575
|
+
)
|
|
576
|
+
if updated:
|
|
577
|
+
raise ValueError(
|
|
578
|
+
f"Implicit atom '{atom.name}' charge was set manually, "
|
|
579
|
+
"cannot apply constraint charge."
|
|
580
|
+
)
|
|
581
|
+
self._constraint_charge(constraint_charge)
|
|
File without changes
|