Coverage for /builds/kinetik161/ase/ase/calculators/qmmm.py: 90.97%
454 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
1from typing import Sequence
3import numpy as np
5from ase.calculators.calculator import Calculator
6from ase.cell import Cell
7from ase.data import atomic_numbers
8from ase.geometry import get_distances
9from ase.utils import IOContext
12class SimpleQMMM(Calculator):
13 """Simple QMMM calculator."""
15 implemented_properties = ['energy', 'forces']
17 def __init__(self, selection, qmcalc, mmcalc1, mmcalc2, vacuum=None):
18 """SimpleQMMM object.
20 The energy is calculated as::
22 _ _ _
23 E = E (R ) - E (R ) + E (R )
24 QM QM MM QM MM all
26 parameters:
28 selection: list of int, slice object or list of bool
29 Selection out of all the atoms that belong to the QM part.
30 qmcalc: Calculator object
31 QM-calculator.
32 mmcalc1: Calculator object
33 MM-calculator used for QM region.
34 mmcalc2: Calculator object
35 MM-calculator used for everything.
36 vacuum: float or None
37 Amount of vacuum to add around QM atoms. Use None if QM
38 calculator doesn't need a box.
40 """
41 self.selection = selection
42 self.qmcalc = qmcalc
43 self.mmcalc1 = mmcalc1
44 self.mmcalc2 = mmcalc2
45 self.vacuum = vacuum
47 self.qmatoms = None
48 self.center = None
50 Calculator.__init__(self)
52 def _get_name(self):
53 return f'{self.qmcalc.name}-{self.mmcalc1.name}+{self.mmcalc1.name}'
55 def initialize_qm(self, atoms):
56 constraints = atoms.constraints
57 atoms.constraints = []
58 self.qmatoms = atoms[self.selection]
59 atoms.constraints = constraints
60 self.qmatoms.pbc = False
61 if self.vacuum:
62 self.qmatoms.center(vacuum=self.vacuum)
63 self.center = self.qmatoms.positions.mean(axis=0)
65 def calculate(self, atoms, properties, system_changes):
66 Calculator.calculate(self, atoms, properties, system_changes)
68 if self.qmatoms is None:
69 self.initialize_qm(atoms)
71 self.qmatoms.positions = atoms.positions[self.selection]
72 if self.vacuum:
73 self.qmatoms.positions += (self.center -
74 self.qmatoms.positions.mean(axis=0))
76 energy = self.qmcalc.get_potential_energy(self.qmatoms)
77 qmforces = self.qmcalc.get_forces(self.qmatoms)
78 energy += self.mmcalc2.get_potential_energy(atoms)
79 forces = self.mmcalc2.get_forces(atoms)
81 if self.vacuum:
82 qmforces -= qmforces.mean(axis=0)
83 forces[self.selection] += qmforces
85 energy -= self.mmcalc1.get_potential_energy(self.qmatoms)
86 forces[self.selection] -= self.mmcalc1.get_forces(self.qmatoms)
88 self.results['energy'] = energy
89 self.results['forces'] = forces
92class EIQMMM(Calculator, IOContext):
93 """Explicit interaction QMMM calculator."""
94 implemented_properties = ['energy', 'forces']
96 def __init__(self, selection, qmcalc, mmcalc, interaction,
97 vacuum=None, embedding=None, output=None):
98 """EIQMMM object.
100 The energy is calculated as::
102 _ _ _ _
103 E = E (R ) + E (R ) + E (R , R )
104 QM QM MM MM I QM MM
106 parameters:
108 selection: list of int, slice object or list of bool
109 Selection out of all the atoms that belong to the QM part.
110 qmcalc: Calculator object
111 QM-calculator.
112 mmcalc: Calculator object
113 MM-calculator.
114 interaction: Interaction object
115 Interaction between QM and MM regions.
116 vacuum: float or None
117 Amount of vacuum to add around QM atoms. Use None if QM
118 calculator doesn't need a box.
119 embedding: Embedding object or None
120 Specialized embedding object. Use None in order to use the
121 default one.
122 output: None, '-', str or file-descriptor.
123 File for logging information - default is no logging (None).
125 """
127 self.selection = selection
129 self.qmcalc = qmcalc
130 self.mmcalc = mmcalc
131 self.interaction = interaction
132 self.vacuum = vacuum
133 self.embedding = embedding
135 self.qmatoms = None
136 self.mmatoms = None
137 self.mask = None
138 self.center = None # center of QM atoms in QM-box
140 self.output = self.openfile(output)
142 Calculator.__init__(self)
144 def _get_name(self):
145 return f'{self.qmcalc.name}+{self.interaction.name}+{self.mmcalc.name}'
147 def initialize(self, atoms):
148 self.mask = np.zeros(len(atoms), bool)
149 self.mask[self.selection] = True
151 constraints = atoms.constraints
152 atoms.constraints = [] # avoid slicing of constraints
153 self.qmatoms = atoms[self.mask]
154 self.mmatoms = atoms[~self.mask]
155 atoms.constraints = constraints
157 self.qmatoms.pbc = False
159 if self.vacuum:
160 self.qmatoms.center(vacuum=self.vacuum)
161 self.center = self.qmatoms.positions.mean(axis=0)
162 print('Size of QM-cell after centering:',
163 self.qmatoms.cell.diagonal(), file=self.output)
165 self.qmatoms.calc = self.qmcalc
166 self.mmatoms.calc = self.mmcalc
168 if self.embedding is None:
169 self.embedding = Embedding()
171 self.embedding.initialize(self.qmatoms, self.mmatoms)
172 print('Embedding:', self.embedding, file=self.output)
174 def calculate(self, atoms, properties, system_changes):
175 Calculator.calculate(self, atoms, properties, system_changes)
177 if self.qmatoms is None:
178 self.initialize(atoms)
180 self.mmatoms.set_positions(atoms.positions[~self.mask])
181 self.qmatoms.set_positions(atoms.positions[self.mask])
183 if self.vacuum:
184 shift = self.center - self.qmatoms.positions.mean(axis=0)
185 self.qmatoms.positions += shift
186 else:
187 shift = (0, 0, 0)
189 self.embedding.update(shift)
191 ienergy, iqmforces, immforces = self.interaction.calculate(
192 self.qmatoms, self.mmatoms, shift)
194 qmenergy = self.qmatoms.get_potential_energy()
195 mmenergy = self.mmatoms.get_potential_energy()
196 energy = ienergy + qmenergy + mmenergy
198 print('Energies: {:12.3f} {:+12.3f} {:+12.3f} = {:12.3f}'
199 .format(ienergy, qmenergy, mmenergy, energy), file=self.output)
201 qmforces = self.qmatoms.get_forces()
202 mmforces = self.mmatoms.get_forces()
204 mmforces += self.embedding.get_mm_forces()
206 forces = np.empty((len(atoms), 3))
207 forces[self.mask] = qmforces + iqmforces
208 forces[~self.mask] = mmforces + immforces
210 self.results['energy'] = energy
211 self.results['forces'] = forces
214def wrap(D, cell, pbc):
215 """Wrap distances to nearest neighbor (minimum image convention)."""
216 for i, periodic in enumerate(pbc):
217 if periodic:
218 d = D[:, i]
219 L = cell[i]
220 d[:] = (d + L / 2) % L - L / 2 # modify D inplace
223class Embedding:
224 def __init__(self, molecule_size=3, **parameters):
225 """Point-charge embedding."""
226 self.qmatoms = None
227 self.mmatoms = None
228 self.molecule_size = molecule_size
229 self.virtual_molecule_size = None
230 self.parameters = parameters
232 def __repr__(self):
233 return f'Embedding(molecule_size={self.molecule_size})'
235 def initialize(self, qmatoms, mmatoms):
236 """Hook up embedding object to QM and MM atoms objects."""
237 self.qmatoms = qmatoms
238 self.mmatoms = mmatoms
239 charges = mmatoms.calc.get_virtual_charges(mmatoms)
240 self.pcpot = qmatoms.calc.embed(charges, **self.parameters)
241 self.virtual_molecule_size = (self.molecule_size *
242 len(charges) // len(mmatoms))
244 def update(self, shift):
245 """Update point-charge positions."""
246 # Wrap point-charge positions to the MM-cell closest to the
247 # center of the the QM box, but avoid ripping molecules apart:
248 qmcenter = self.qmatoms.positions.mean(axis=0)
249 # if counter ions are used, then molecule_size has more than 1 value
250 if self.mmatoms.calc.name == 'combinemm':
251 mask1 = self.mmatoms.calc.mask
252 mask2 = ~mask1
253 vmask1 = self.mmatoms.calc.virtual_mask
254 vmask2 = ~vmask1
255 apm1 = self.mmatoms.calc.apm1
256 apm2 = self.mmatoms.calc.apm2
257 spm1 = self.mmatoms.calc.atoms1.calc.sites_per_mol
258 spm2 = self.mmatoms.calc.atoms2.calc.sites_per_mol
259 pos = self.mmatoms.positions
260 pos1 = pos[mask1].reshape((-1, apm1, 3))
261 pos2 = pos[mask2].reshape((-1, apm2, 3))
262 pos = (pos1, pos2)
263 else:
264 pos = (self.mmatoms.positions, )
265 apm1 = self.molecule_size
266 apm2 = self.molecule_size
267 # This is only specific to calculators where apm != spm,
268 # i.e. TIP4P. Non-native MM calcs do not have this attr.
269 if hasattr(self.mmatoms.calc, 'sites_per_mol'):
270 spm1 = self.mmatoms.calc.sites_per_mol
271 spm2 = self.mmatoms.calc.sites_per_mol
272 else:
273 spm1 = self.molecule_size
274 spm2 = spm1
275 mask1 = np.ones(len(self.mmatoms), dtype=bool)
276 mask2 = mask1
278 wrap_pos = np.zeros_like(self.mmatoms.positions)
279 com_all = []
280 apm = (apm1, apm2)
281 mask = (mask1, mask2)
282 spm = (spm1, spm2)
283 for p, n, m, vn in zip(pos, apm, mask, spm):
284 positions = p.reshape((-1, n, 3)) + shift
286 # Distances from the center of the QM box to the first atom of
287 # each molecule:
288 distances = positions[:, 0] - qmcenter
290 wrap(distances, self.mmatoms.cell.diagonal(), self.mmatoms.pbc)
291 offsets = distances - positions[:, 0]
292 positions += offsets[:, np.newaxis] + qmcenter
294 # Geometric center positions for each mm mol for LR cut
295 com = np.array([p.mean(axis=0) for p in positions])
296 # Need per atom for C-code:
297 com_pv = np.repeat(com, vn, axis=0)
298 com_all.append(com_pv)
300 wrap_pos[m] = positions.reshape((-1, 3))
302 positions = wrap_pos.copy()
303 positions = self.mmatoms.calc.add_virtual_sites(positions)
305 if self.mmatoms.calc.name == 'combinemm':
306 com_pv = np.zeros_like(positions)
307 for ii, m in enumerate((vmask1, vmask2)):
308 com_pv[m] = com_all[ii]
310 # compatibility with gpaw versions w/o LR cut in PointChargePotential
311 if 'rc2' in self.parameters:
312 self.pcpot.set_positions(positions, com_pv=com_pv)
313 else:
314 self.pcpot.set_positions(positions)
316 def get_mm_forces(self):
317 """Calculate the forces on the MM-atoms from the QM-part."""
318 f = self.pcpot.get_forces(self.qmatoms.calc)
319 return self.mmatoms.calc.redistribute_forces(f)
322def combine_lj_lorenz_berthelot(sigmaqm, sigmamm,
323 epsilonqm, epsilonmm):
324 """Combine LJ parameters according to the Lorenz-Berthelot rule"""
325 sigma = []
326 epsilon = []
327 # check if input is tuple of vals for more than 1 mm calc, or only for 1.
328 if isinstance(sigmamm, Sequence):
329 numcalcs = len(sigmamm)
330 else:
331 numcalcs = 1 # if only 1 mm calc, eps and sig are simply np arrays
332 sigmamm = (sigmamm, )
333 epsilonmm = (epsilonmm, )
334 for cc in range(numcalcs):
335 sigma_c = np.zeros((len(sigmaqm), len(sigmamm[cc])))
336 epsilon_c = np.zeros_like(sigma_c)
338 for ii in range(len(sigmaqm)):
339 sigma_c[ii, :] = (sigmaqm[ii] + sigmamm[cc]) / 2
340 epsilon_c[ii, :] = (epsilonqm[ii] * epsilonmm[cc])**0.5
341 sigma.append(sigma_c)
342 epsilon.append(epsilon_c)
344 if numcalcs == 1: # retain original, 1 calc function
345 sigma = np.array(sigma[0])
346 epsilon = np.array(epsilon[0])
348 return sigma, epsilon
351class LJInteractionsGeneral:
352 name = 'LJ-general'
354 def __init__(self, sigmaqm, epsilonqm, sigmamm, epsilonmm,
355 qm_molecule_size, mm_molecule_size=3,
356 rc=np.Inf, width=1.0):
357 """General Lennard-Jones type explicit interaction.
359 sigmaqm: array
360 Array of sigma-parameters which should have the length of the QM
361 subsystem
362 epsilonqm: array
363 As sigmaqm, but for epsilon-paramaters
364 sigmamm: Either array (A) or tuple (B)
365 A (no counterions):
366 Array of sigma-parameters with the length of the smallests
367 repeating atoms-group (i.e. molecule) of the MM subsystem
368 B (counterions):
369 Tuple: (arr1, arr2), where arr1 is an array of sigmas with
370 the length of counterions in the MM subsystem, and
371 arr2 is the array from A.
372 epsilonmm: array or tuple
373 As sigmamm but for epsilon-parameters.
374 qm_molecule_size: int
375 number of atoms of the smallest repeating atoms-group (i.e.
376 molecule) in the QM subsystem (often just the number of atoms
377 in the QM subsystem)
378 mm_molecule_size: int
379 as qm_molecule_size but for the MM subsystem. Will be overwritten
380 if counterions are present in the MM subsystem (via the CombineMM
381 calculator)
383 """
384 self.sigmaqm = sigmaqm
385 self.epsilonqm = epsilonqm
386 self.sigmamm = sigmamm
387 self.epsilonmm = epsilonmm
388 self.qms = qm_molecule_size
389 self.mms = mm_molecule_size
390 self.rc = rc
391 self.width = width
392 self.combine_lj()
394 def combine_lj(self):
395 self.sigma, self.epsilon = combine_lj_lorenz_berthelot(
396 self.sigmaqm, self.sigmamm, self.epsilonqm, self.epsilonmm)
398 def calculate(self, qmatoms, mmatoms, shift):
399 epsilon = self.epsilon
400 sigma = self.sigma
402 # loop over possible multiple mm calculators
403 # currently 1 or 2, but could be generalized in the future...
404 apm1 = self.mms
405 mask1 = np.ones(len(mmatoms), dtype=bool)
406 mask2 = mask1
407 apm = (apm1, )
408 sigma = (sigma, )
409 epsilon = (epsilon, )
410 if hasattr(mmatoms.calc, 'name'):
411 if mmatoms.calc.name == 'combinemm':
412 mask1 = mmatoms.calc.mask
413 mask2 = ~mask1
414 apm1 = mmatoms.calc.apm1
415 apm2 = mmatoms.calc.apm2
416 apm = (apm1, apm2)
417 sigma = sigma[0] # Was already loopable 2-tuple
418 epsilon = epsilon[0]
420 mask = (mask1, mask2)
421 e_all = 0
422 qmforces_all = np.zeros_like(qmatoms.positions)
423 mmforces_all = np.zeros_like(mmatoms.positions)
425 # zip stops at shortest tuple so we dont double count
426 # cases of no counter ions.
427 for n, m, eps, sig in zip(apm, mask, epsilon, sigma):
428 mmpositions = self.update(qmatoms, mmatoms[m], n, shift)
429 qmforces = np.zeros_like(qmatoms.positions)
430 mmforces = np.zeros_like(mmatoms[m].positions)
431 energy = 0.0
433 qmpositions = qmatoms.positions.reshape((-1, self.qms, 3))
435 for q, qmpos in enumerate(qmpositions): # molwise loop
436 # cutoff from first atom of each mol
437 R00 = mmpositions[:, 0] - qmpos[0, :]
438 d002 = (R00**2).sum(1)
439 d00 = d002**0.5
440 x1 = d00 > self.rc - self.width
441 x2 = d00 < self.rc
442 x12 = np.logical_and(x1, x2)
443 y = (d00[x12] - self.rc + self.width) / self.width
444 t = np.zeros(len(d00))
445 t[x2] = 1.0
446 t[x12] -= y**2 * (3.0 - 2.0 * y)
447 dt = np.zeros(len(d00))
448 dt[x12] -= 6.0 / self.width * y * (1.0 - y)
449 for qa in range(len(qmpos)):
450 if ~np.any(eps[qa, :]):
451 continue
452 R = mmpositions - qmpos[qa, :]
453 d2 = (R**2).sum(2)
454 c6 = (sig[qa, :]**2 / d2)**3
455 c12 = c6**2
456 e = 4 * eps[qa, :] * (c12 - c6)
457 energy += np.dot(e.sum(1), t)
458 f = t[:, None, None] * (24 * eps[qa, :] *
459 (2 * c12 - c6) / d2)[:, :, None] * R
460 f00 = - (e.sum(1) * dt / d00)[:, None] * R00
461 mmforces += f.reshape((-1, 3))
462 qmforces[q * self.qms + qa, :] -= f.sum(0).sum(0)
463 qmforces[q * self.qms, :] -= f00.sum(0)
464 mmforces[::n, :] += f00
466 e_all += energy
467 qmforces_all += qmforces
468 mmforces_all[m] += mmforces
470 return e_all, qmforces_all, mmforces_all
472 def update(self, qmatoms, mmatoms, n, shift):
473 # Wrap point-charge positions to the MM-cell closest to the
474 # center of the the QM box, but avoid ripping molecules apart:
475 qmcenter = qmatoms.cell.diagonal() / 2
476 positions = mmatoms.positions.reshape((-1, n, 3)) + shift
478 # Distances from the center of the QM box to the first atom of
479 # each molecule:
480 distances = positions[:, 0] - qmcenter
482 wrap(distances, mmatoms.cell.diagonal(), mmatoms.pbc)
483 offsets = distances - positions[:, 0]
484 positions += offsets[:, np.newaxis] + qmcenter
486 return positions
489class LJInteractions:
490 name = 'LJ'
492 def __init__(self, parameters):
493 """Lennard-Jones type explicit interaction.
495 parameters: dict
496 Mapping from pair of atoms to tuple containing epsilon and sigma
497 for that pair.
499 Example:
501 lj = LJInteractions({('O', 'O'): (eps, sigma)})
503 """
504 self.parameters = {}
505 for (symbol1, symbol2), (epsilon, sigma) in parameters.items():
506 Z1 = atomic_numbers[symbol1]
507 Z2 = atomic_numbers[symbol2]
508 self.parameters[(Z1, Z2)] = epsilon, sigma
509 self.parameters[(Z2, Z1)] = epsilon, sigma
511 def calculate(self, qmatoms, mmatoms, shift):
512 qmforces = np.zeros_like(qmatoms.positions)
513 mmforces = np.zeros_like(mmatoms.positions)
514 species = set(mmatoms.numbers)
515 energy = 0.0
516 for R1, Z1, F1 in zip(qmatoms.positions, qmatoms.numbers, qmforces):
517 for Z2 in species:
518 if (Z1, Z2) not in self.parameters:
519 continue
520 epsilon, sigma = self.parameters[(Z1, Z2)]
521 mask = (mmatoms.numbers == Z2)
522 D = mmatoms.positions[mask] + shift - R1
523 wrap(D, mmatoms.cell.diagonal(), mmatoms.pbc)
524 d2 = (D**2).sum(1)
525 c6 = (sigma**2 / d2)**3
526 c12 = c6**2
527 energy += 4 * epsilon * (c12 - c6).sum()
528 f = 24 * epsilon * ((2 * c12 - c6) / d2)[:, np.newaxis] * D
529 F1 -= f.sum(0)
530 mmforces[mask] += f
531 return energy, qmforces, mmforces
534class RescaledCalculator(Calculator):
535 """Rescales length and energy of a calculators to match given
536 lattice constant and bulk modulus
538 Useful for MM calculator used within a :class:`ForceQMMM` model.
539 See T. D. Swinburne and J. R. Kermode, Phys. Rev. B 96, 144102 (2017)
540 for a derivation of the scaling constants.
541 """
542 implemented_properties = ['forces', 'energy', 'stress']
544 def __init__(self, mm_calc,
545 qm_lattice_constant, qm_bulk_modulus,
546 mm_lattice_constant, mm_bulk_modulus):
547 Calculator.__init__(self)
548 self.mm_calc = mm_calc
549 self.alpha = qm_lattice_constant / mm_lattice_constant
550 self.beta = mm_bulk_modulus / qm_bulk_modulus / (self.alpha**3)
552 def calculate(self, atoms, properties, system_changes):
553 Calculator.calculate(self, atoms, properties, system_changes)
555 # mm_pos = atoms.get_positions()
556 scaled_atoms = atoms.copy()
558 # scaled_atoms.positions = mm_pos/self.alpha
559 mm_cell = atoms.get_cell()
560 scaled_atoms.set_cell(mm_cell / self.alpha, scale_atoms=True)
562 results = {}
564 if 'energy' in properties:
565 energy = self.mm_calc.get_potential_energy(scaled_atoms)
566 results['energy'] = energy / self.beta
568 if 'forces' in properties:
569 forces = self.mm_calc.get_forces(scaled_atoms)
570 results['forces'] = forces / (self.beta * self.alpha)
572 if 'stress' in properties:
573 stress = self.mm_calc.get_stress(scaled_atoms)
574 results['stress'] = stress / (self.beta * self.alpha**3)
576 self.results = results
579class ForceConstantCalculator(Calculator):
580 """
581 Compute forces based on provided force-constant matrix
583 Useful with `ForceQMMM` to do harmonic QM/MM using force constants
584 of QM method.
585 """
586 implemented_properties = ['forces', 'energy']
588 def __init__(self, D, ref, f0):
589 """
590 Parameters:
592 D: matrix or sparse matrix, shape `(3*len(ref), 3*len(ref))`
593 Force constant matrix.
594 Sign convention is `D_ij = d^2E/(dx_i dx_j), so
595 `force = -D.dot(displacement)`
596 ref: ase.atoms.Atoms
597 Atoms object for reference configuration
598 f0: array, shape `(len(ref), 3)`
599 Value of forces at reference configuration
600 """
601 assert D.shape[0] == D.shape[1]
602 assert D.shape[0] // 3 == len(ref)
603 self.D = D
604 self.ref = ref
605 self.f0 = f0
606 self.size = len(ref)
607 Calculator.__init__(self)
609 def calculate(self, atoms, properties, system_changes):
610 Calculator.calculate(self, atoms, properties, system_changes)
611 u = atoms.positions - self.ref.positions
612 f = -self.D.dot(u.reshape(3 * self.size))
613 forces = np.zeros((len(atoms), 3))
614 forces[:, :] = f.reshape(self.size, 3)
615 self.results['forces'] = forces + self.f0
616 self.results['energy'] = 0.0
619class ForceQMMM(Calculator):
620 """
621 Force-based QM/MM calculator
623 QM forces are computed using a buffer region and then mixed abruptly
624 with MM forces:
626 F^i_QMMM = { F^i_QM if i in QM region
627 { F^i_MM otherwise
629 cf. N. Bernstein, J. R. Kermode, and G. Csanyi,
630 Rep. Prog. Phys. 72, 026501 (2009)
631 and T. D. Swinburne and J. R. Kermode, Phys. Rev. B 96, 144102 (2017).
632 """
633 implemented_properties = ['forces', 'energy']
635 def __init__(self,
636 atoms,
637 qm_selection_mask,
638 qm_calc,
639 mm_calc,
640 buffer_width,
641 vacuum=5.,
642 zero_mean=True,
643 qm_cell_round_off=3,
644 qm_radius=None):
645 """
646 ForceQMMM calculator
648 Parameters:
650 qm_selection_mask: list of ints, slice object or bool list/array
651 Selection out of atoms that belong to the QM region.
652 qm_calc: Calculator object
653 QM-calculator.
654 mm_calc: Calculator object
655 MM-calculator (should be scaled, see :class:`RescaledCalculator`)
656 Can use `ForceConstantCalculator` based on QM force constants, if
657 available.
658 vacuum: float or None
659 Amount of vacuum to add around QM atoms.
660 zero_mean: bool
661 If True, add a correction to zero the mean force in each direction
662 qm_cell_round_off: float
663 Tolerance value in Angstrom to round the qm cluster cell
664 qm_radius: 3x1 array of floats qm_radius for [x, y, z]
665 3d qm radius for calculation of qm cluster cell. default is None
666 and the radius is estimated from maximum distance between the atoms
667 in qm region.
668 """
670 if len(atoms[qm_selection_mask]) == 0:
671 raise ValueError("no QM atoms selected!")
673 self.qm_selection_mask = qm_selection_mask
674 self.qm_calc = qm_calc
675 self.mm_calc = mm_calc
676 self.vacuum = vacuum
677 self.buffer_width = buffer_width
678 self.zero_mean = zero_mean
679 self.qm_cell_round_off = qm_cell_round_off
680 self.qm_radius = qm_radius
682 self.qm_buffer_mask = None
684 Calculator.__init__(self)
686 def initialize_qm_buffer_mask(self, atoms):
687 """
688 Initialises system to perform qm calculation
689 """
690 # calculate the distances between all atoms and qm atoms
691 # qm_distance_matrix is a [N_QM_atoms x N_atoms] matrix
692 _, qm_distance_matrix = get_distances(
693 atoms.positions[self.qm_selection_mask],
694 atoms.positions,
695 atoms.cell, atoms.pbc)
697 self.qm_buffer_mask = np.zeros(len(atoms), dtype=bool)
699 # every r_qm is a matrix of distances
700 # from an atom in qm region and all atoms with size [N_atoms]
701 for r_qm in qm_distance_matrix:
702 self.qm_buffer_mask[r_qm < self.buffer_width] = True
704 def get_qm_cluster(self, atoms):
706 if self.qm_buffer_mask is None:
707 self.initialize_qm_buffer_mask(atoms)
709 qm_cluster = atoms[self.qm_buffer_mask]
710 del qm_cluster.constraints
712 round_cell = False
713 if self.qm_radius is None:
714 round_cell = True
715 # get all distances between qm atoms.
716 # Treat all X, Y and Z directions independently
717 # only distance between qm atoms is calculated
718 # in order to estimate qm radius in thee directions
719 R_qm, _ = get_distances(atoms.positions[self.qm_selection_mask],
720 cell=atoms.cell, pbc=atoms.pbc)
721 # estimate qm radius in three directions as 1/2
722 # of max distance between qm atoms
723 self.qm_radius = np.amax(np.amax(R_qm, axis=1), axis=0) * 0.5
725 if atoms.cell.orthorhombic:
726 cell_size = np.diagonal(atoms.cell)
727 else:
728 raise RuntimeError("NON-orthorhombic cell is not supported!")
730 # check if qm_cluster should be left periodic
731 # in periodic directions of the cell (cell[i] < qm_radius + buffer
732 # otherwise change to non pbc
733 # and make a cluster in a vacuum configuration
734 qm_cluster_pbc = (atoms.pbc &
735 (cell_size <
736 2.0 * (self.qm_radius + self.buffer_width)))
738 # start with the original orthorhombic cell
739 qm_cluster_cell = cell_size.copy()
740 # create a cluster in a vacuum cell in non periodic directions
741 qm_cluster_cell[~qm_cluster_pbc] = (
742 2.0 * (self.qm_radius[~qm_cluster_pbc] +
743 self.buffer_width +
744 self.vacuum))
746 if round_cell:
747 # round the qm cell to the required tolerance
748 qm_cluster_cell[~qm_cluster_pbc] = (np.round(
749 (qm_cluster_cell[~qm_cluster_pbc]) /
750 self.qm_cell_round_off) *
751 self.qm_cell_round_off)
753 qm_cluster.set_cell(Cell(np.diag(qm_cluster_cell)))
754 qm_cluster.pbc = qm_cluster_pbc
756 qm_shift = (0.5 * qm_cluster.cell.diagonal() -
757 qm_cluster.positions.mean(axis=0))
759 if 'cell_origin' in qm_cluster.info:
760 del qm_cluster.info['cell_origin']
762 # center the cluster only in non pbc directions
763 qm_cluster.positions[:, ~qm_cluster_pbc] += qm_shift[~qm_cluster_pbc]
765 return qm_cluster
767 def calculate(self, atoms, properties, system_changes):
768 Calculator.calculate(self, atoms, properties, system_changes)
770 qm_cluster = self.get_qm_cluster(atoms)
772 forces = self.mm_calc.get_forces(atoms)
773 qm_forces = self.qm_calc.get_forces(qm_cluster)
775 forces[self.qm_selection_mask] = \
776 qm_forces[self.qm_selection_mask[self.qm_buffer_mask]]
778 if self.zero_mean:
779 # Target is that: forces.sum(axis=1) == [0., 0., 0.]
780 forces[:] -= forces.mean(axis=0)
782 self.results['forces'] = forces
783 self.results['energy'] = 0.0
785 def get_region_from_masks(self, atoms=None, print_mapping=False):
786 """
787 creates region array from the masks of the calculators. The tags in
788 the array are:
789 QM - qm atoms
790 buffer - buffer atoms
791 MM - atoms treated with mm calculator
792 """
793 if atoms is None:
794 if self.atoms is None:
795 raise ValueError('Calculator has no atoms')
796 else:
797 atoms = self.atoms
799 region = np.full_like(atoms, "MM")
801 region[self.qm_selection_mask] = (
802 np.full_like(region[self.qm_selection_mask], "QM"))
804 buffer_only_mask = self.qm_buffer_mask & ~self.qm_selection_mask
806 region[buffer_only_mask] = np.full_like(region[buffer_only_mask],
807 "buffer")
809 if print_mapping:
811 print(f"Mapping of {len(region):5d} atoms in total:")
812 for region_id in np.unique(region):
813 n_at = np.count_nonzero(region == region_id)
814 print(f"{n_at:16d} {region_id}")
816 qm_atoms = atoms[self.qm_selection_mask]
817 symbol_counts = qm_atoms.symbols.formula.count()
818 print("QM atoms types:")
819 for symbol, count in symbol_counts.items():
820 print(f"{count:16d} {symbol}")
822 return region
824 def set_masks_from_region(self, region):
825 """
826 Sets masks from provided region array
827 """
828 self.qm_selection_mask = region == "QM"
829 buffer_mask = region == "buffer"
831 self.qm_buffer_mask = self.qm_selection_mask ^ buffer_mask
833 def export_extxyz(self, atoms=None, filename="qmmm_atoms.xyz"):
834 """
835 exports the atoms to extended xyz file with additional "region"
836 array keeping the mapping between QM, buffer and MM parts of
837 the simulation
838 """
839 if atoms is None:
840 if self.atoms is None:
841 raise ValueError('Calculator has no atoms')
842 else:
843 atoms = self.atoms
845 region = self.get_region_from_masks(atoms=atoms)
847 atoms_copy = atoms.copy()
848 atoms_copy.new_array("region", region)
850 atoms_copy.calc = self # to keep the calculation results
852 atoms_copy.write(filename, format='extxyz')
854 @classmethod
855 def import_extxyz(cls, filename, qm_calc, mm_calc):
856 """
857 A static method to import the the mapping from an estxyz file saved by
858 export_extxyz() function
859 Parameters
860 ----------
861 filename: string
862 filename with saved configuration
864 qm_calc: Calculator object
865 QM-calculator.
866 mm_calc: Calculator object
867 MM-calculator (should be scaled, see :class:`RescaledCalculator`)
868 Can use `ForceConstantCalculator` based on QM force constants, if
869 available.
871 Returns
872 -------
873 New object of ForceQMMM calculator with qm_selection_mask and
874 qm_buffer_mask set according to the region array in the saved file
875 """
877 from ase.io import read
878 atoms = read(filename, format='extxyz')
880 if "region" in atoms.arrays:
881 region = atoms.get_array("region")
882 else:
883 raise RuntimeError("Please provide extxyz file with 'region' array")
885 dummy_qm_mask = np.full_like(atoms, False, dtype=bool)
886 dummy_qm_mask[0] = True
888 self = cls(atoms, dummy_qm_mask,
889 qm_calc, mm_calc, buffer_width=1.0)
891 self.set_masks_from_region(region)
893 return self