Coverage for /builds/kinetik161/ase/ase/calculators/combine_mm.py: 87.92%
207 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-12-10 11:04 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-12-10 11:04 +0000
1import copy
3import numpy as np
5from ase import units
6from ase.calculators.calculator import Calculator
7from ase.calculators.qmmm import combine_lj_lorenz_berthelot
9k_c = units.Hartree * units.Bohr
12class CombineMM(Calculator):
13 implemented_properties = ['energy', 'forces']
15 def __init__(self, idx, apm1, apm2, calc1, calc2,
16 sig1, eps1, sig2, eps2, rc=7.0, width=1.0):
17 """A calculator that combines two MM calculators
18 (TIPnP, Counterions, ...)
20 parameters:
22 idx: List of indices of atoms belonging to calculator 1
23 apm1,2: atoms pr molecule of each subsystem (NB: apm for TIP4P is 3!)
24 calc1,2: calculator objects for each subsystem
25 sig1,2, eps1,2: LJ parameters for each subsystem. Should be a numpy
26 array of length = apm
27 rc = long range cutoff
28 width = width of cutoff region.
30 Currently the interactions are limited to being:
31 - Nonbonded
32 - Hardcoded to two terms:
33 - Coulomb electrostatics
34 - Lennard-Jones
36 It could of course benefit from being more like the EIQMMM class
37 where the interactions are switchable. But this is in princple
38 just meant for adding counter ions to a qmmm simulation to neutralize
39 the charge of the total systemn
41 Maybe it can combine n MM calculators in the future?
42 """
44 self.idx = idx
45 self.apm1 = apm1 # atoms per mol for LJ calculator
46 self.apm2 = apm2
48 self.rc = rc
49 self.width = width
51 self.atoms1 = None
52 self.atoms2 = None
53 self.mask = None
55 self.calc1 = calc1
56 self.calc2 = calc2
58 self.sig1 = sig1
59 self.eps1 = eps1
60 self.sig2 = sig2
61 self.eps2 = eps2
63 Calculator.__init__(self)
65 def initialize(self, atoms):
66 self.mask = np.zeros(len(atoms), bool)
67 self.mask[self.idx] = True
69 constraints = atoms.constraints
70 atoms.constraints = []
71 self.atoms1 = atoms[self.mask]
72 self.atoms2 = atoms[~self.mask]
74 atoms.constraints = constraints
76 self.atoms1.calc = self.calc1
77 self.atoms2.calc = self.calc2
79 self.cell = atoms.cell
80 self.pbc = atoms.pbc
82 self.sigma, self.epsilon =\
83 combine_lj_lorenz_berthelot(self.sig1, self.sig2,
84 self.eps1, self.eps2)
86 self.make_virtual_mask()
88 def calculate(self, atoms, properties, system_changes):
89 Calculator.calculate(self, atoms, properties, system_changes)
91 if self.atoms1 is None:
92 self.initialize(atoms)
94 pos1 = atoms.positions[self.mask]
95 pos2 = atoms.positions[~self.mask]
96 self.atoms1.set_positions(pos1)
97 self.atoms2.set_positions(pos2)
99 # positions and charges for the coupling term, which should
100 # include virtual charges and sites:
101 spm1 = self.atoms1.calc.sites_per_mol
102 spm2 = self.atoms2.calc.sites_per_mol
103 xpos1 = self.atoms1.calc.add_virtual_sites(pos1)
104 xpos2 = self.atoms2.calc.add_virtual_sites(pos2)
106 xc1 = self.atoms1.calc.get_virtual_charges(self.atoms1)
107 xc2 = self.atoms2.calc.get_virtual_charges(self.atoms2)
109 xpos1 = xpos1.reshape((-1, spm1, 3))
110 xpos2 = xpos2.reshape((-1, spm2, 3))
112 e_c, f_c = self.coulomb(xpos1, xpos2, xc1, xc2, spm1, spm2)
114 e_lj, f1, f2 = self.lennard_jones(self.atoms1, self.atoms2)
116 f_lj = np.zeros((len(atoms), 3))
117 f_lj[self.mask] += f1
118 f_lj[~self.mask] += f2
120 # internal energy, forces of each subsystem:
121 f12 = np.zeros((len(atoms), 3))
122 e1 = self.atoms1.get_potential_energy()
123 fi1 = self.atoms1.get_forces()
125 e2 = self.atoms2.get_potential_energy()
126 fi2 = self.atoms2.get_forces()
128 f12[self.mask] += fi1
129 f12[~self.mask] += fi2
131 self.results['energy'] = e_c + e_lj + e1 + e2
132 self.results['forces'] = f_c + f_lj + f12
134 def get_virtual_charges(self, atoms):
135 if self.atoms1 is None:
136 self.initialize(atoms)
138 vc1 = self.atoms1.calc.get_virtual_charges(atoms[self.mask])
139 vc2 = self.atoms2.calc.get_virtual_charges(atoms[~self.mask])
140 # Need to expand mask with possible new virtual sites.
141 # Virtual sites should ALWAYS be put AFTER actual atoms, like in
142 # TIP4P: OHHX, OHHX, ...
144 vc = np.zeros(len(vc1) + len(vc2))
145 vc[self.virtual_mask] = vc1
146 vc[~self.virtual_mask] = vc2
148 return vc
150 def add_virtual_sites(self, positions):
151 vs1 = self.atoms1.calc.add_virtual_sites(positions[self.mask])
152 vs2 = self.atoms2.calc.add_virtual_sites(positions[~self.mask])
153 vs = np.zeros((len(vs1) + len(vs2), 3))
155 vs[self.virtual_mask] = vs1
156 vs[~self.virtual_mask] = vs2
158 return vs
160 def make_virtual_mask(self):
161 virtual_mask = []
162 ct1 = 0
163 ct2 = 0
164 for i in range(len(self.mask)):
165 virtual_mask.append(self.mask[i])
166 if self.mask[i]:
167 ct1 += 1
168 if not self.mask[i]:
169 ct2 += 1
170 if ((ct2 == self.apm2) &
171 (self.apm2 != self.atoms2.calc.sites_per_mol)):
172 virtual_mask.append(False)
173 ct2 = 0
174 if ((ct1 == self.apm1) &
175 (self.apm1 != self.atoms1.calc.sites_per_mol)):
176 virtual_mask.append(True)
177 ct1 = 0
179 self.virtual_mask = np.array(virtual_mask)
181 def coulomb(self, xpos1, xpos2, xc1, xc2, spm1, spm2):
182 energy = 0.0
183 forces = np.zeros((len(xc1) + len(xc2), 3))
185 self.xpos1 = xpos1
186 self.xpos2 = xpos2
188 R1 = xpos1
189 R2 = xpos2
190 F1 = np.zeros_like(R1)
191 F2 = np.zeros_like(R2)
192 C1 = xc1.reshape((-1, np.shape(xpos1)[1]))
193 C2 = xc2.reshape((-1, np.shape(xpos2)[1]))
194 # Vectorized evaluation is not as trivial when spm1 != spm2.
195 # This is pretty inefficient, but for ~1-5 counter ions as region 1
196 # it should not matter much ..
197 # There is definitely room for improvements here.
198 cell = self.cell.diagonal()
199 for m1, (r1, c1) in enumerate(zip(R1, C1)):
200 for m2, (r2, c2) in enumerate(zip(R2, C2)):
201 r00 = r2[0] - r1[0]
202 shift = np.zeros(3)
203 for i, periodic in enumerate(self.pbc):
204 if periodic:
205 L = cell[i]
206 shift[i] = (r00[i] + L / 2.) % L - L / 2. - r00[i]
207 r00 += shift
209 d00 = (r00**2).sum()**0.5
210 t = 1
211 dtdd = 0
212 if d00 > self.rc:
213 continue
214 elif d00 > self.rc - self.width:
215 y = (d00 - self.rc + self.width) / self.width
216 t -= y**2 * (3.0 - 2.0 * y)
217 dtdd = r00 * 6 * y * (1.0 - y) / (self.width * d00)
219 for a1 in range(spm1):
220 for a2 in range(spm2):
221 r = r2[a2] - r1[a1] + shift
222 d2 = (r**2).sum()
223 d = d2**0.5
224 e = k_c * c1[a1] * c2[a2] / d
225 energy += t * e
227 F1[m1, a1] -= t * (e / d2) * r
228 F2[m2, a2] += t * (e / d2) * r
230 F1[m1, 0] -= dtdd * e
231 F2[m2, 0] += dtdd * e
233 F1 = F1.reshape((-1, 3))
234 F2 = F2.reshape((-1, 3))
236 # Redist forces but dont save forces in org calculators
237 atoms1 = self.atoms1.copy()
238 atoms1.calc = copy.copy(self.calc1)
239 atoms1.calc.atoms = atoms1
240 F1 = atoms1.calc.redistribute_forces(F1)
241 atoms2 = self.atoms2.copy()
242 atoms2.calc = copy.copy(self.calc2)
243 atoms2.calc.atoms = atoms2
244 F2 = atoms2.calc.redistribute_forces(F2)
246 forces = np.zeros((len(self.atoms), 3))
247 forces[self.mask] = F1
248 forces[~self.mask] = F2
249 return energy, forces
251 def lennard_jones(self, atoms1, atoms2):
252 pos1 = atoms1.get_positions().reshape((-1, self.apm1, 3))
253 pos2 = atoms2.get_positions().reshape((-1, self.apm2, 3))
255 f1 = np.zeros_like(atoms1.positions)
256 f2 = np.zeros_like(atoms2.positions)
257 energy = 0.0
259 cell = self.cell.diagonal()
260 for q, p1 in enumerate(pos1): # molwise loop
261 eps = self.epsilon
262 sig = self.sigma
264 R00 = pos2[:, 0] - p1[0, :]
266 # cutoff from first atom of each mol
267 shift = np.zeros_like(R00)
268 for i, periodic in enumerate(self.pbc):
269 if periodic:
270 L = cell[i]
271 shift[:, i] = (R00[:, i] + L / 2) % L - L / 2 - R00[:, i]
272 R00 += shift
274 d002 = (R00**2).sum(1)
275 d00 = d002**0.5
276 x1 = d00 > self.rc - self.width
277 x2 = d00 < self.rc
278 x12 = np.logical_and(x1, x2)
279 y = (d00[x12] - self.rc + self.width) / self.width
280 t = np.zeros(len(d00))
281 t[x2] = 1.0
282 t[x12] -= y**2 * (3.0 - 2.0 * y)
283 dt = np.zeros(len(d00))
284 dt[x12] -= 6.0 / self.width * y * (1.0 - y)
285 for qa in range(len(p1)):
286 if ~np.any(eps[qa, :]):
287 continue
288 R = pos2 - p1[qa, :] + shift[:, None]
289 d2 = (R**2).sum(2)
290 c6 = (sig[qa, :]**2 / d2)**3
291 c12 = c6**2
292 e = 4 * eps[qa, :] * (c12 - c6)
293 energy += np.dot(e.sum(1), t)
294 f = t[:, None, None] * (24 * eps[qa, :] *
295 (2 * c12 - c6) / d2)[:, :, None] * R
296 f00 = - (e.sum(1) * dt / d00)[:, None] * R00
297 f2 += f.reshape((-1, 3))
298 f1[q * self.apm1 + qa, :] -= f.sum(0).sum(0)
299 f1[q * self.apm1, :] -= f00.sum(0)
300 f2[::self.apm2, :] += f00
302 return energy, f1, f2
304 def redistribute_forces(self, forces):
305 f1 = self.calc1.redistribute_forces(forces[self.virtual_mask])
306 f2 = self.calc2.redistribute_forces(forces[~self.virtual_mask])
307 # and then they are back on the real atom centers so
308 f = np.zeros((len(self.atoms), 3))
309 f[self.mask] = f1
310 f[~self.mask] = f2
311 return f