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