molbuilder 1.0.0__py3-none-any.whl → 1.1.0__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.
- molbuilder/__init__.py +1 -1
- molbuilder/cli/demos.py +73 -1
- molbuilder/cli/menu.py +2 -0
- molbuilder/dynamics/__init__.py +49 -0
- molbuilder/dynamics/forcefield.py +607 -0
- molbuilder/dynamics/integrator.py +275 -0
- molbuilder/dynamics/mechanism_choreography.py +216 -0
- molbuilder/dynamics/mechanisms.py +552 -0
- molbuilder/dynamics/simulation.py +209 -0
- molbuilder/dynamics/trajectory.py +215 -0
- molbuilder/gui/app.py +114 -0
- molbuilder/visualization/__init__.py +2 -1
- molbuilder/visualization/electron_density_viz.py +246 -0
- molbuilder/visualization/interaction_controls.py +211 -0
- molbuilder/visualization/interaction_viz.py +615 -0
- molbuilder/visualization/theme.py +7 -0
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/METADATA +1 -1
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/RECORD +22 -12
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/WHEEL +0 -0
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/entry_points.txt +0 -0
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
"""Force field engine for molecular dynamics.
|
|
2
|
+
|
|
3
|
+
Implements a classical mechanics force field with five energy terms:
|
|
4
|
+
|
|
5
|
+
E_total = E_bond + E_angle + E_torsion + E_LJ + E_coulomb
|
|
6
|
+
|
|
7
|
+
Scientific basis:
|
|
8
|
+
- OPLS-AA (Jorgensen et al., J. Am. Chem. Soc. 1996) for torsion
|
|
9
|
+
- UFF (Rappe et al., J. Am. Chem. Soc. 1992) for Lennard-Jones
|
|
10
|
+
- Harmonic approximation for bond stretching and angle bending
|
|
11
|
+
- Electronegativity-based partial charges (Gasteiger-like)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import math
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from molbuilder.core.bond_data import (
|
|
23
|
+
bond_length as ref_bond_length,
|
|
24
|
+
BDE_TABLE,
|
|
25
|
+
TORSION_BARRIERS,
|
|
26
|
+
)
|
|
27
|
+
from molbuilder.core.element_properties import (
|
|
28
|
+
electronegativity,
|
|
29
|
+
PAULING_ELECTRONEGATIVITY,
|
|
30
|
+
)
|
|
31
|
+
from molbuilder.core.elements import ELEMENTS, SYMBOL_TO_Z
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from molbuilder.molecule.graph import Molecule
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ===================================================================
|
|
38
|
+
# UFF Lennard-Jones parameters (sigma in Angstroms, epsilon in kJ/mol)
|
|
39
|
+
# Source: Rappe et al., J. Am. Chem. Soc. 1992, 114, 10024-10035
|
|
40
|
+
# ===================================================================
|
|
41
|
+
|
|
42
|
+
UFF_LJ: dict[str, tuple[float, float]] = {
|
|
43
|
+
# symbol: (sigma_A, epsilon_kJ_mol)
|
|
44
|
+
"H": (2.886, 0.184),
|
|
45
|
+
"He": (2.362, 0.056),
|
|
46
|
+
"Li": (2.451, 0.025),
|
|
47
|
+
"Be": (2.745, 0.085),
|
|
48
|
+
"B": (4.083, 0.389),
|
|
49
|
+
"C": (3.851, 0.439),
|
|
50
|
+
"N": (3.660, 0.447),
|
|
51
|
+
"O": (3.500, 0.460),
|
|
52
|
+
"F": (3.364, 0.050),
|
|
53
|
+
"Ne": (3.243, 0.042),
|
|
54
|
+
"Na": (2.983, 0.030),
|
|
55
|
+
"Mg": (3.021, 0.111),
|
|
56
|
+
"Al": (4.499, 0.505),
|
|
57
|
+
"Si": (4.295, 0.402),
|
|
58
|
+
"P": (4.147, 0.305),
|
|
59
|
+
"S": (4.035, 1.046),
|
|
60
|
+
"Cl": (3.947, 0.950),
|
|
61
|
+
"Ar": (3.868, 0.849),
|
|
62
|
+
"K": (3.812, 0.035),
|
|
63
|
+
"Ca": (3.399, 0.119),
|
|
64
|
+
"Br": (4.189, 1.220),
|
|
65
|
+
"I": (4.500, 1.581),
|
|
66
|
+
"Fe": (2.912, 0.013),
|
|
67
|
+
"Cu": (3.495, 0.005),
|
|
68
|
+
"Zn": (2.763, 0.124),
|
|
69
|
+
"Se": (4.205, 0.291),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Coulomb constant in kJ*A / (mol * e^2)
|
|
73
|
+
# k_e = 1389.354578 kJ*A/mol for charges in elementary charge units
|
|
74
|
+
_COULOMB_KJ_A = 1389.354578
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ===================================================================
|
|
78
|
+
# Helper: estimate partial charges from electronegativity
|
|
79
|
+
# ===================================================================
|
|
80
|
+
|
|
81
|
+
def _estimate_partial_charges(symbols: list[str],
|
|
82
|
+
bond_pairs: list[tuple[int, int, int]],
|
|
83
|
+
) -> np.ndarray:
|
|
84
|
+
"""Gasteiger-like partial charge estimation from electronegativity.
|
|
85
|
+
|
|
86
|
+
Iteratively equalizes electronegativity across bonds. A simplified
|
|
87
|
+
single-pass version is used here for speed.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
symbols : list[str]
|
|
92
|
+
Element symbols for each atom.
|
|
93
|
+
bond_pairs : list[tuple[int, int, int]]
|
|
94
|
+
(atom_i, atom_j, bond_order) for each bond.
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
charges : ndarray of shape (n_atoms,)
|
|
99
|
+
Partial charges in elementary charge units.
|
|
100
|
+
"""
|
|
101
|
+
n = len(symbols)
|
|
102
|
+
charges = np.zeros(n)
|
|
103
|
+
en = np.array([electronegativity(s) for s in symbols])
|
|
104
|
+
|
|
105
|
+
for i, j, order in bond_pairs:
|
|
106
|
+
if en[i] == 0.0 or en[j] == 0.0:
|
|
107
|
+
continue
|
|
108
|
+
delta = (en[j] - en[i]) / (en[i] + en[j])
|
|
109
|
+
transfer = 0.15 * order * delta
|
|
110
|
+
charges[i] += transfer
|
|
111
|
+
charges[j] -= transfer
|
|
112
|
+
|
|
113
|
+
return charges
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ===================================================================
|
|
117
|
+
# Force field spring constant estimation
|
|
118
|
+
# ===================================================================
|
|
119
|
+
|
|
120
|
+
def _estimate_bond_k(sym_a: str, sym_b: str, order: int) -> float:
|
|
121
|
+
"""Estimate harmonic bond force constant in kJ/(mol*A^2).
|
|
122
|
+
|
|
123
|
+
Uses BDE as a rough guide: k ~ 2 * BDE / r0^2 (very approximate).
|
|
124
|
+
Falls back to a generic value if BDE is unavailable.
|
|
125
|
+
"""
|
|
126
|
+
a, b = sorted([sym_a, sym_b])
|
|
127
|
+
bde = BDE_TABLE.get((a, b, order))
|
|
128
|
+
r0 = ref_bond_length(sym_a, sym_b, order)
|
|
129
|
+
if bde is not None and r0 > 0:
|
|
130
|
+
return 2.0 * bde / (r0 * r0)
|
|
131
|
+
# Generic fallback: ~500 kJ/(mol*A^2) for single bond
|
|
132
|
+
return 500.0 * order
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _estimate_angle_k() -> float:
|
|
136
|
+
"""Generic harmonic angle force constant in kJ/(mol*rad^2).
|
|
137
|
+
|
|
138
|
+
Typical values range from 300-600 kJ/(mol*rad^2).
|
|
139
|
+
"""
|
|
140
|
+
return 400.0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ===================================================================
|
|
144
|
+
# Data classes
|
|
145
|
+
# ===================================================================
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class ForceFieldParams:
|
|
149
|
+
"""Per-atom and bonded-term parameters for the force field.
|
|
150
|
+
|
|
151
|
+
Attributes
|
|
152
|
+
----------
|
|
153
|
+
n_atoms : int
|
|
154
|
+
Number of atoms.
|
|
155
|
+
masses : ndarray of shape (n_atoms,)
|
|
156
|
+
Atomic masses in AMU.
|
|
157
|
+
symbols : list[str]
|
|
158
|
+
Element symbol per atom.
|
|
159
|
+
sigma : ndarray of shape (n_atoms,)
|
|
160
|
+
LJ sigma in Angstroms.
|
|
161
|
+
epsilon : ndarray of shape (n_atoms,)
|
|
162
|
+
LJ epsilon in kJ/mol.
|
|
163
|
+
charges : ndarray of shape (n_atoms,)
|
|
164
|
+
Partial charges in elementary charge units.
|
|
165
|
+
bond_indices : ndarray of shape (n_bonds, 2)
|
|
166
|
+
Atom index pairs for each bond.
|
|
167
|
+
bond_r0 : ndarray of shape (n_bonds,)
|
|
168
|
+
Equilibrium bond length in Angstroms.
|
|
169
|
+
bond_k : ndarray of shape (n_bonds,)
|
|
170
|
+
Bond force constant in kJ/(mol*A^2).
|
|
171
|
+
angle_indices : ndarray of shape (n_angles, 3)
|
|
172
|
+
Atom index triples (i, j, k) where j is the central atom.
|
|
173
|
+
angle_theta0 : ndarray of shape (n_angles,)
|
|
174
|
+
Equilibrium angle in radians.
|
|
175
|
+
angle_k : ndarray of shape (n_angles,)
|
|
176
|
+
Angle force constant in kJ/(mol*rad^2).
|
|
177
|
+
torsion_indices : ndarray of shape (n_torsions, 4)
|
|
178
|
+
Atom index quads (i, j, k, l).
|
|
179
|
+
torsion_V : ndarray of shape (n_torsions, 3)
|
|
180
|
+
V1, V2, V3 torsion parameters in kJ/mol.
|
|
181
|
+
exclusion_14 : set[frozenset[int]]
|
|
182
|
+
Atom pairs connected by <= 3 bonds (excluded from LJ/Coulomb).
|
|
183
|
+
"""
|
|
184
|
+
n_atoms: int
|
|
185
|
+
masses: np.ndarray
|
|
186
|
+
symbols: list[str]
|
|
187
|
+
sigma: np.ndarray
|
|
188
|
+
epsilon: np.ndarray
|
|
189
|
+
charges: np.ndarray
|
|
190
|
+
bond_indices: np.ndarray
|
|
191
|
+
bond_r0: np.ndarray
|
|
192
|
+
bond_k: np.ndarray
|
|
193
|
+
angle_indices: np.ndarray
|
|
194
|
+
angle_theta0: np.ndarray
|
|
195
|
+
angle_k: np.ndarray
|
|
196
|
+
torsion_indices: np.ndarray
|
|
197
|
+
torsion_V: np.ndarray
|
|
198
|
+
exclusion_14: set = field(default_factory=set)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class ForceResult:
|
|
203
|
+
"""Result of a force computation.
|
|
204
|
+
|
|
205
|
+
Attributes
|
|
206
|
+
----------
|
|
207
|
+
forces : ndarray of shape (n_atoms, 3)
|
|
208
|
+
Force on each atom in kJ/(mol*A).
|
|
209
|
+
energy_bond : float
|
|
210
|
+
energy_angle : float
|
|
211
|
+
energy_torsion : float
|
|
212
|
+
energy_lj : float
|
|
213
|
+
energy_coulomb : float
|
|
214
|
+
energy_total : float
|
|
215
|
+
All energies in kJ/mol.
|
|
216
|
+
"""
|
|
217
|
+
forces: np.ndarray
|
|
218
|
+
energy_bond: float = 0.0
|
|
219
|
+
energy_angle: float = 0.0
|
|
220
|
+
energy_torsion: float = 0.0
|
|
221
|
+
energy_lj: float = 0.0
|
|
222
|
+
energy_coulomb: float = 0.0
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def energy_total(self) -> float:
|
|
226
|
+
return (self.energy_bond + self.energy_angle + self.energy_torsion
|
|
227
|
+
+ self.energy_lj + self.energy_coulomb)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ===================================================================
|
|
231
|
+
# ForceField
|
|
232
|
+
# ===================================================================
|
|
233
|
+
|
|
234
|
+
class ForceField:
|
|
235
|
+
"""Vectorized classical force field.
|
|
236
|
+
|
|
237
|
+
Computes forces and energies for bond stretching (harmonic),
|
|
238
|
+
angle bending (harmonic), torsional rotation (OPLS-AA cosine),
|
|
239
|
+
Lennard-Jones van der Waals, and Coulomb electrostatics.
|
|
240
|
+
|
|
241
|
+
Parameters
|
|
242
|
+
----------
|
|
243
|
+
params : ForceFieldParams
|
|
244
|
+
Pre-built parameter set.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def __init__(self, params: ForceFieldParams):
|
|
248
|
+
self.params = params
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def from_molecule(cls, mol: Molecule) -> ForceField:
|
|
252
|
+
"""Auto-parameterize a ForceField from a Molecule instance.
|
|
253
|
+
|
|
254
|
+
Reads atom types, bond connectivity, and geometry from the
|
|
255
|
+
Molecule's atoms, bonds, and adjacency information, then builds
|
|
256
|
+
angle and torsion lists by traversing the molecular graph.
|
|
257
|
+
|
|
258
|
+
Parameters
|
|
259
|
+
----------
|
|
260
|
+
mol : Molecule
|
|
261
|
+
A molbuilder Molecule with atoms and bonds defined.
|
|
262
|
+
|
|
263
|
+
Returns
|
|
264
|
+
-------
|
|
265
|
+
ForceField
|
|
266
|
+
Parameterized force field ready for ``compute()``.
|
|
267
|
+
"""
|
|
268
|
+
n = len(mol.atoms)
|
|
269
|
+
symbols = [a.symbol for a in mol.atoms]
|
|
270
|
+
|
|
271
|
+
# Masses
|
|
272
|
+
masses = np.array([
|
|
273
|
+
ELEMENTS[SYMBOL_TO_Z.get(s, 1)][2] for s in symbols
|
|
274
|
+
])
|
|
275
|
+
|
|
276
|
+
# LJ parameters
|
|
277
|
+
default_lj = (3.5, 0.3)
|
|
278
|
+
sigma = np.array([UFF_LJ.get(s, default_lj)[0] for s in symbols])
|
|
279
|
+
epsilon = np.array([UFF_LJ.get(s, default_lj)[1] for s in symbols])
|
|
280
|
+
|
|
281
|
+
# Bonds
|
|
282
|
+
bond_list = [(b.atom_i, b.atom_j, b.order) for b in mol.bonds]
|
|
283
|
+
bond_indices = np.array(
|
|
284
|
+
[(b.atom_i, b.atom_j) for b in mol.bonds],
|
|
285
|
+
dtype=int,
|
|
286
|
+
).reshape(-1, 2)
|
|
287
|
+
bond_r0 = np.array([
|
|
288
|
+
ref_bond_length(symbols[b.atom_i], symbols[b.atom_j], b.order)
|
|
289
|
+
for b in mol.bonds
|
|
290
|
+
])
|
|
291
|
+
bond_k = np.array([
|
|
292
|
+
_estimate_bond_k(symbols[b.atom_i], symbols[b.atom_j], b.order)
|
|
293
|
+
for b in mol.bonds
|
|
294
|
+
])
|
|
295
|
+
|
|
296
|
+
# Charges
|
|
297
|
+
charges = _estimate_partial_charges(symbols, bond_list)
|
|
298
|
+
|
|
299
|
+
# Build adjacency for angle/torsion enumeration
|
|
300
|
+
adj: dict[int, list[int]] = {i: [] for i in range(n)}
|
|
301
|
+
for b in mol.bonds:
|
|
302
|
+
adj[b.atom_i].append(b.atom_j)
|
|
303
|
+
adj[b.atom_j].append(b.atom_i)
|
|
304
|
+
|
|
305
|
+
# Angles: all i-j-k where j is the central atom
|
|
306
|
+
angles: list[tuple[int, int, int]] = []
|
|
307
|
+
for j in range(n):
|
|
308
|
+
nbrs = adj[j]
|
|
309
|
+
for ii in range(len(nbrs)):
|
|
310
|
+
for kk in range(ii + 1, len(nbrs)):
|
|
311
|
+
angles.append((nbrs[ii], j, nbrs[kk]))
|
|
312
|
+
|
|
313
|
+
angle_indices = np.array(angles, dtype=int).reshape(-1, 3)
|
|
314
|
+
angle_theta0_list = []
|
|
315
|
+
for i_a, j_a, k_a in angles:
|
|
316
|
+
vi = mol.atoms[i_a].position - mol.atoms[j_a].position
|
|
317
|
+
vk = mol.atoms[k_a].position - mol.atoms[j_a].position
|
|
318
|
+
ni = np.linalg.norm(vi)
|
|
319
|
+
nk = np.linalg.norm(vk)
|
|
320
|
+
if ni > 1e-12 and nk > 1e-12:
|
|
321
|
+
cos_a = np.clip(np.dot(vi, vk) / (ni * nk), -1.0, 1.0)
|
|
322
|
+
angle_theta0_list.append(math.acos(cos_a))
|
|
323
|
+
else:
|
|
324
|
+
angle_theta0_list.append(math.radians(109.47))
|
|
325
|
+
angle_theta0 = np.array(angle_theta0_list)
|
|
326
|
+
angle_k = np.full(len(angles), _estimate_angle_k())
|
|
327
|
+
|
|
328
|
+
# Torsions: all i-j-k-l where j-k is a bond
|
|
329
|
+
torsions: list[tuple[int, int, int, int]] = []
|
|
330
|
+
torsion_params: list[tuple[float, float, float]] = []
|
|
331
|
+
|
|
332
|
+
def _hyb_str(idx: int) -> str:
|
|
333
|
+
h = mol.atoms[idx].hybridization
|
|
334
|
+
if h is not None:
|
|
335
|
+
return h.name.lower()
|
|
336
|
+
return "sp3"
|
|
337
|
+
|
|
338
|
+
for b in mol.bonds:
|
|
339
|
+
j, k = b.atom_i, b.atom_j
|
|
340
|
+
j_nbrs = [x for x in adj[j] if x != k]
|
|
341
|
+
k_nbrs = [x for x in adj[k] if x != j]
|
|
342
|
+
for i_t in j_nbrs:
|
|
343
|
+
for l_t in k_nbrs:
|
|
344
|
+
torsions.append((i_t, j, k, l_t))
|
|
345
|
+
# Look up torsion parameters
|
|
346
|
+
si = symbols[i_t]
|
|
347
|
+
sl = symbols[l_t]
|
|
348
|
+
a, b_s = si, sl
|
|
349
|
+
ha, hb = _hyb_str(j), _hyb_str(k)
|
|
350
|
+
if a > b_s:
|
|
351
|
+
a, b_s = b_s, a
|
|
352
|
+
ha, hb = hb, ha
|
|
353
|
+
key = f"{a}_{ha}_{hb}_{b_s}"
|
|
354
|
+
p = TORSION_BARRIERS.get(key, TORSION_BARRIERS["default"])
|
|
355
|
+
torsion_params.append((p["V1"], p["V2"], p["V3"]))
|
|
356
|
+
|
|
357
|
+
torsion_indices = np.array(torsions, dtype=int).reshape(-1, 4)
|
|
358
|
+
torsion_V = np.array(torsion_params).reshape(-1, 3)
|
|
359
|
+
|
|
360
|
+
# Build 1-2, 1-3, 1-4 exclusion set
|
|
361
|
+
exclusions: set[frozenset[int]] = set()
|
|
362
|
+
for b in mol.bonds:
|
|
363
|
+
exclusions.add(frozenset((b.atom_i, b.atom_j)))
|
|
364
|
+
for _, j_e, k_e in angles:
|
|
365
|
+
for nb_j in adj[j_e]:
|
|
366
|
+
exclusions.add(frozenset((nb_j, k_e)))
|
|
367
|
+
for nb_k in adj[k_e]:
|
|
368
|
+
exclusions.add(frozenset((nb_j, nb_k)))
|
|
369
|
+
# Also exclude 1-2 pairs found in angles
|
|
370
|
+
for i_e, j_e, k_e in angles:
|
|
371
|
+
exclusions.add(frozenset((i_e, k_e)))
|
|
372
|
+
|
|
373
|
+
params = ForceFieldParams(
|
|
374
|
+
n_atoms=n,
|
|
375
|
+
masses=masses,
|
|
376
|
+
symbols=symbols,
|
|
377
|
+
sigma=sigma,
|
|
378
|
+
epsilon=epsilon,
|
|
379
|
+
charges=charges,
|
|
380
|
+
bond_indices=bond_indices,
|
|
381
|
+
bond_r0=bond_r0,
|
|
382
|
+
bond_k=bond_k,
|
|
383
|
+
angle_indices=angle_indices,
|
|
384
|
+
angle_theta0=angle_theta0,
|
|
385
|
+
angle_k=angle_k,
|
|
386
|
+
torsion_indices=torsion_indices,
|
|
387
|
+
torsion_V=torsion_V,
|
|
388
|
+
exclusion_14=exclusions,
|
|
389
|
+
)
|
|
390
|
+
return cls(params)
|
|
391
|
+
|
|
392
|
+
def compute(self, positions: np.ndarray) -> ForceResult:
|
|
393
|
+
"""Compute forces and energies at the given atomic positions.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
positions : ndarray of shape (n_atoms, 3)
|
|
398
|
+
Atomic positions in Angstroms.
|
|
399
|
+
|
|
400
|
+
Returns
|
|
401
|
+
-------
|
|
402
|
+
ForceResult
|
|
403
|
+
Forces in kJ/(mol*A) and energies in kJ/mol.
|
|
404
|
+
"""
|
|
405
|
+
p = self.params
|
|
406
|
+
forces = np.zeros_like(positions)
|
|
407
|
+
|
|
408
|
+
e_bond = self._compute_bonds(positions, forces)
|
|
409
|
+
e_angle = self._compute_angles(positions, forces)
|
|
410
|
+
e_torsion = self._compute_torsions(positions, forces)
|
|
411
|
+
e_lj, e_coul = self._compute_nonbonded(positions, forces)
|
|
412
|
+
|
|
413
|
+
return ForceResult(
|
|
414
|
+
forces=forces,
|
|
415
|
+
energy_bond=e_bond,
|
|
416
|
+
energy_angle=e_angle,
|
|
417
|
+
energy_torsion=e_torsion,
|
|
418
|
+
energy_lj=e_lj,
|
|
419
|
+
energy_coulomb=e_coul,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def _compute_bonds(self, pos: np.ndarray, forces: np.ndarray) -> float:
|
|
423
|
+
"""Harmonic bond stretching: E = 0.5 * k * (r - r0)^2."""
|
|
424
|
+
p = self.params
|
|
425
|
+
if p.bond_indices.size == 0:
|
|
426
|
+
return 0.0
|
|
427
|
+
|
|
428
|
+
energy = 0.0
|
|
429
|
+
for b_idx in range(len(p.bond_r0)):
|
|
430
|
+
i, j = p.bond_indices[b_idx]
|
|
431
|
+
rij = pos[j] - pos[i]
|
|
432
|
+
r = np.linalg.norm(rij)
|
|
433
|
+
if r < 1e-12:
|
|
434
|
+
continue
|
|
435
|
+
dr = r - p.bond_r0[b_idx]
|
|
436
|
+
e = 0.5 * p.bond_k[b_idx] * dr * dr
|
|
437
|
+
energy += e
|
|
438
|
+
# Force: -dE/dr * r_hat
|
|
439
|
+
f_mag = -p.bond_k[b_idx] * dr
|
|
440
|
+
f_vec = f_mag * (rij / r)
|
|
441
|
+
forces[i] -= f_vec
|
|
442
|
+
forces[j] += f_vec
|
|
443
|
+
|
|
444
|
+
return energy
|
|
445
|
+
|
|
446
|
+
def _compute_angles(self, pos: np.ndarray, forces: np.ndarray) -> float:
|
|
447
|
+
"""Harmonic angle bending: E = 0.5 * k * (theta - theta0)^2."""
|
|
448
|
+
p = self.params
|
|
449
|
+
if p.angle_indices.size == 0:
|
|
450
|
+
return 0.0
|
|
451
|
+
|
|
452
|
+
energy = 0.0
|
|
453
|
+
for a_idx in range(len(p.angle_theta0)):
|
|
454
|
+
i, j, k = p.angle_indices[a_idx]
|
|
455
|
+
rji = pos[i] - pos[j]
|
|
456
|
+
rjk = pos[k] - pos[j]
|
|
457
|
+
nji = np.linalg.norm(rji)
|
|
458
|
+
njk = np.linalg.norm(rjk)
|
|
459
|
+
if nji < 1e-12 or njk < 1e-12:
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
cos_theta = np.clip(np.dot(rji, rjk) / (nji * njk), -1.0, 1.0)
|
|
463
|
+
theta = math.acos(cos_theta)
|
|
464
|
+
d_theta = theta - p.angle_theta0[a_idx]
|
|
465
|
+
e = 0.5 * p.angle_k[a_idx] * d_theta * d_theta
|
|
466
|
+
energy += e
|
|
467
|
+
|
|
468
|
+
# Gradient of angle w.r.t. positions
|
|
469
|
+
sin_theta = math.sin(theta)
|
|
470
|
+
if abs(sin_theta) < 1e-12:
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
dE_dtheta = p.angle_k[a_idx] * d_theta
|
|
474
|
+
|
|
475
|
+
# Force on atom i
|
|
476
|
+
rji_hat = rji / nji
|
|
477
|
+
rjk_hat = rjk / njk
|
|
478
|
+
fi = (dE_dtheta / (nji * sin_theta)) * (
|
|
479
|
+
cos_theta * rji_hat - rjk_hat)
|
|
480
|
+
fk = (dE_dtheta / (njk * sin_theta)) * (
|
|
481
|
+
cos_theta * rjk_hat - rji_hat)
|
|
482
|
+
|
|
483
|
+
forces[i] -= fi
|
|
484
|
+
forces[k] -= fk
|
|
485
|
+
forces[j] += fi + fk # Newton's third law
|
|
486
|
+
|
|
487
|
+
return energy
|
|
488
|
+
|
|
489
|
+
def _compute_torsions(self, pos: np.ndarray,
|
|
490
|
+
forces: np.ndarray) -> float:
|
|
491
|
+
"""OPLS-AA torsional: E = sum V_n/2 * (1 + cos(n*phi - delta))."""
|
|
492
|
+
p = self.params
|
|
493
|
+
if p.torsion_indices.size == 0:
|
|
494
|
+
return 0.0
|
|
495
|
+
|
|
496
|
+
energy = 0.0
|
|
497
|
+
for t_idx in range(len(p.torsion_V)):
|
|
498
|
+
i, j, k, l = p.torsion_indices[t_idx]
|
|
499
|
+
V1, V2, V3 = p.torsion_V[t_idx]
|
|
500
|
+
|
|
501
|
+
b1 = pos[j] - pos[i]
|
|
502
|
+
b2 = pos[k] - pos[j]
|
|
503
|
+
b3 = pos[l] - pos[k]
|
|
504
|
+
|
|
505
|
+
n1 = np.cross(b1, b2)
|
|
506
|
+
n2 = np.cross(b2, b3)
|
|
507
|
+
n1_norm = np.linalg.norm(n1)
|
|
508
|
+
n2_norm = np.linalg.norm(n2)
|
|
509
|
+
if n1_norm < 1e-12 or n2_norm < 1e-12:
|
|
510
|
+
continue
|
|
511
|
+
n1 /= n1_norm
|
|
512
|
+
n2 /= n2_norm
|
|
513
|
+
|
|
514
|
+
b2_hat = b2 / np.linalg.norm(b2)
|
|
515
|
+
x = float(np.dot(n1, n2))
|
|
516
|
+
y = float(np.dot(np.cross(n1, b2_hat), n2))
|
|
517
|
+
phi = math.atan2(y, x)
|
|
518
|
+
|
|
519
|
+
e = ((V1 / 2.0) * (1.0 + math.cos(phi))
|
|
520
|
+
+ (V2 / 2.0) * (1.0 - math.cos(2.0 * phi))
|
|
521
|
+
+ (V3 / 2.0) * (1.0 + math.cos(3.0 * phi)))
|
|
522
|
+
energy += e
|
|
523
|
+
|
|
524
|
+
# Numerical gradient for torsional forces (analytical is complex)
|
|
525
|
+
eps = 1e-5
|
|
526
|
+
for atom_idx in (i, j, k, l):
|
|
527
|
+
for dim in range(3):
|
|
528
|
+
pos_p = pos.copy()
|
|
529
|
+
pos_p[atom_idx, dim] += eps
|
|
530
|
+
phi_p = self._dihedral(pos_p, i, j, k, l)
|
|
531
|
+
e_p = ((V1 / 2.0) * (1.0 + math.cos(phi_p))
|
|
532
|
+
+ (V2 / 2.0) * (1.0 - math.cos(2.0 * phi_p))
|
|
533
|
+
+ (V3 / 2.0) * (1.0 + math.cos(3.0 * phi_p)))
|
|
534
|
+
|
|
535
|
+
pos_m = pos.copy()
|
|
536
|
+
pos_m[atom_idx, dim] -= eps
|
|
537
|
+
phi_m = self._dihedral(pos_m, i, j, k, l)
|
|
538
|
+
e_m = ((V1 / 2.0) * (1.0 + math.cos(phi_m))
|
|
539
|
+
+ (V2 / 2.0) * (1.0 - math.cos(2.0 * phi_m))
|
|
540
|
+
+ (V3 / 2.0) * (1.0 + math.cos(3.0 * phi_m)))
|
|
541
|
+
|
|
542
|
+
forces[atom_idx, dim] -= (e_p - e_m) / (2.0 * eps)
|
|
543
|
+
|
|
544
|
+
return energy
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def _dihedral(pos: np.ndarray, i: int, j: int, k: int, l: int) -> float:
|
|
548
|
+
"""Compute dihedral angle for four atom indices."""
|
|
549
|
+
b1 = pos[j] - pos[i]
|
|
550
|
+
b2 = pos[k] - pos[j]
|
|
551
|
+
b3 = pos[l] - pos[k]
|
|
552
|
+
n1 = np.cross(b1, b2)
|
|
553
|
+
n2 = np.cross(b2, b3)
|
|
554
|
+
n1n = np.linalg.norm(n1)
|
|
555
|
+
n2n = np.linalg.norm(n2)
|
|
556
|
+
if n1n < 1e-12 or n2n < 1e-12:
|
|
557
|
+
return 0.0
|
|
558
|
+
n1 /= n1n
|
|
559
|
+
n2 /= n2n
|
|
560
|
+
b2h = b2 / np.linalg.norm(b2)
|
|
561
|
+
x = float(np.dot(n1, n2))
|
|
562
|
+
y = float(np.dot(np.cross(n1, b2h), n2))
|
|
563
|
+
return math.atan2(y, x)
|
|
564
|
+
|
|
565
|
+
def _compute_nonbonded(self, pos: np.ndarray,
|
|
566
|
+
forces: np.ndarray) -> tuple[float, float]:
|
|
567
|
+
"""Lennard-Jones and Coulomb non-bonded interactions."""
|
|
568
|
+
p = self.params
|
|
569
|
+
e_lj = 0.0
|
|
570
|
+
e_coul = 0.0
|
|
571
|
+
|
|
572
|
+
for i in range(p.n_atoms):
|
|
573
|
+
for j in range(i + 1, p.n_atoms):
|
|
574
|
+
if frozenset((i, j)) in p.exclusion_14:
|
|
575
|
+
continue
|
|
576
|
+
|
|
577
|
+
rij = pos[j] - pos[i]
|
|
578
|
+
r = np.linalg.norm(rij)
|
|
579
|
+
if r < 0.5: # Prevent singularity
|
|
580
|
+
r = 0.5
|
|
581
|
+
|
|
582
|
+
rij_hat = rij / r
|
|
583
|
+
|
|
584
|
+
# Lennard-Jones (Lorentz-Berthelot combining rules)
|
|
585
|
+
sig_ij = 0.5 * (p.sigma[i] + p.sigma[j])
|
|
586
|
+
eps_ij = math.sqrt(p.epsilon[i] * p.epsilon[j])
|
|
587
|
+
|
|
588
|
+
if eps_ij > 0:
|
|
589
|
+
sr6 = (sig_ij / r) ** 6
|
|
590
|
+
sr12 = sr6 * sr6
|
|
591
|
+
e_lj += 4.0 * eps_ij * (sr12 - sr6)
|
|
592
|
+
f_lj = 4.0 * eps_ij * (12.0 * sr12 - 6.0 * sr6) / r
|
|
593
|
+
f_vec = f_lj * rij_hat
|
|
594
|
+
forces[i] -= f_vec
|
|
595
|
+
forces[j] += f_vec
|
|
596
|
+
|
|
597
|
+
# Coulomb
|
|
598
|
+
qi, qj = p.charges[i], p.charges[j]
|
|
599
|
+
if abs(qi) > 1e-10 and abs(qj) > 1e-10:
|
|
600
|
+
e_c = _COULOMB_KJ_A * qi * qj / r
|
|
601
|
+
e_coul += e_c
|
|
602
|
+
f_c = _COULOMB_KJ_A * qi * qj / (r * r)
|
|
603
|
+
f_vec_c = f_c * rij_hat
|
|
604
|
+
forces[i] -= f_vec_c
|
|
605
|
+
forces[j] += f_vec_c
|
|
606
|
+
|
|
607
|
+
return e_lj, e_coul
|