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.
@@ -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