File: emt.py

package info (click to toggle)
python-ase 3.24.0-1
  • links: PTS, VCS
  • area: main
  • in suites: trixie
  • size: 15,448 kB
  • sloc: python: 144,945; xml: 2,728; makefile: 113; javascript: 47
file content (339 lines) | stat: -rw-r--r-- 13,417 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
"""Effective medium theory potential."""
from collections import defaultdict
from math import log, sqrt

import numpy as np

from ase.calculators.calculator import (
    Calculator,
    PropertyNotImplementedError,
    all_changes,
)
from ase.data import atomic_numbers, chemical_symbols
from ase.neighborlist import NeighborList
from ase.units import Bohr

parameters = {
    #      E0     s0    V0     eta2    kappa   lambda  n0
    #      eV     bohr  eV     bohr^-1 bohr^-1 bohr^-1 bohr^-3
    'Al': (-3.28, 3.00, 1.493, 1.240, 2.000, 1.169, 0.00700),
    'Cu': (-3.51, 2.67, 2.476, 1.652, 2.740, 1.906, 0.00910),
    'Ag': (-2.96, 3.01, 2.132, 1.652, 2.790, 1.892, 0.00547),
    'Au': (-3.80, 3.00, 2.321, 1.674, 2.873, 2.182, 0.00703),
    'Ni': (-4.44, 2.60, 3.673, 1.669, 2.757, 1.948, 0.01030),
    'Pd': (-3.90, 2.87, 2.773, 1.818, 3.107, 2.155, 0.00688),
    'Pt': (-5.85, 2.90, 4.067, 1.812, 3.145, 2.192, 0.00802),
    # extra parameters - just for fun ...
    'H': (-3.21, 1.31, 0.132, 2.652, 2.790, 3.892, 0.00547),
    'C': (-3.50, 1.81, 0.332, 1.652, 2.790, 1.892, 0.01322),
    'N': (-5.10, 1.88, 0.132, 1.652, 2.790, 1.892, 0.01222),
    'O': (-4.60, 1.95, 0.332, 1.652, 2.790, 1.892, 0.00850)}

beta = 1.809  # (16 * pi / 3)**(1.0 / 3) / 2**0.5, preserve historical rounding


class EMT(Calculator):
    """Python implementation of the Effective Medium Potential.

    Supports the following standard EMT metals:
    Al, Cu, Ag, Au, Ni, Pd and Pt.

    In addition, the following elements are supported.
    They are NOT well described by EMT, and the parameters
    are not for any serious use:
    H, C, N, O

    Parameters
    ----------
    asap_cutoff : bool, default: False
        If True the cutoff mimics how ASAP does it; most importantly the global
        cutoff is chosen from the largest atom present in the simulation.
        If False it is chosen from the largest atom in the parameter table.
        True gives the behaviour of ASAP and older EMT implementations,
        although the results are not bitwise identical.

    Notes
    -----
    Formulation mostly follows Jacobsen *et al*. [1]_
    `Documentation in ASAP can also be referred to <https://gitlab.com/asap/
    asap/blob/master/docs/manual/potentials/emt.pdf>`_.

    .. [1] K. W. Jacobsen, P. Stoltze, and J. K. Nørskov,
       Surf. Sci. 366, 394 (1996).
    """
    implemented_properties = ['energy', 'free_energy', 'energies', 'forces',
                              'stress', 'magmom', 'magmoms']

    nolabel = True

    default_parameters = {'asap_cutoff': False}

    def __init__(self, **kwargs):
        Calculator.__init__(self, **kwargs)

    def initialize(self, atoms):
        self.rc, self.rc_list, self.acut = self._calc_cutoff(atoms)

        numbers = atoms.get_atomic_numbers()

        # ia2iz : map from idx of atoms to idx of atomic numbers in self.par
        unique_numbers, self.ia2iz = np.unique(numbers, return_inverse=True)
        self.par = defaultdict(lambda: np.empty(len(unique_numbers)))
        for i, Z in enumerate(unique_numbers):
            sym = chemical_symbols[Z]
            if sym not in parameters:
                raise NotImplementedError(f'No EMT-potential for {sym}')
            p = parameters[sym]
            s0 = p[1] * Bohr
            eta2 = p[3] / Bohr
            kappa = p[4] / Bohr
            gamma1, gamma2 = self._calc_gammas(s0, eta2, kappa)
            self.par['Z'][i] = Z
            self.par['E0'][i] = p[0]
            self.par['s0'][i] = s0
            self.par['V0'][i] = p[2]
            self.par['eta2'][i] = eta2
            self.par['kappa'][i] = kappa
            self.par['lambda'][i] = p[5] / Bohr
            self.par['n0'][i] = p[6] / Bohr**3
            self.par['inv12gamma1'][i] = 1.0 / (12.0 * gamma1)
            self.par['neghalfv0overgamma2'][i] = -0.5 * p[2] / gamma2

        self.chi = self.par['n0'][None, :] / self.par['n0'][:, None]

        self.energies = np.empty(len(atoms))
        self.forces = np.empty((len(atoms), 3))
        self.stress = np.empty((3, 3))
        self.deds = np.empty(len(atoms))

        self.nl = NeighborList([0.5 * self.rc_list] * len(atoms),
                               self_interaction=False, bothways=True)

    def _calc_cutoff(self, atoms):
        """Calculate parameters of the logistic smoothing function etc.

        The logistic smoothing function is given by

        .. math:

            w(r) = \\frac{1}{1 + \\exp a (r - r_\\mathrm{c})}

        Returns
        -------
        rc : float
            "Midpoint" of the logistic smoothing function, set to be the mean
            of the 3rd and the 4th nearest-neighbor distances in FCC.
        rc_list : float
            Cutoff radius for the neighbor search, set to be slightly larger
            than ``rc`` depending on ``asap_cutoff``.
        acut : float
            "Slope" of the smoothing function, set for the smoothing function
            value to be ``1e-4`` at the 4th nearest-neighbor distance in FCC.

        Notes
        -----
        ``maxseq`` is the present FCC Wigner-Seitz radius. ``beta * maxseq``
        (`r1nn`) is the corresponding 1st nearest-neighbor distance in FCC.
        The 2nd, 3rd, 4th nearest-neighbor distances in FCC are given using
        ``r1nn`` by ``sqrt(2) * r1nn``, ``sqrt(3) * r1nn``, ``sqrt(4) * r1nn``,
        respectively.
        """
        numbers = atoms.get_atomic_numbers()
        if self.parameters['asap_cutoff']:
            relevant_pars = {
                symb: p
                for symb, p in parameters.items()
                if atomic_numbers[symb] in numbers
            }
        else:
            relevant_pars = parameters
        maxseq = max(par[1] for par in relevant_pars.values()) * Bohr
        r1nn = beta * maxseq  # 1st NN distance in FCC
        rc = r1nn * 0.5 * (sqrt(3.0) + 2.0)  # mean of 3NN and 4NN dists.
        r4nn = r1nn * 2.0  # 4NN distance in FCC
        eps = 1e-4  # value at r4nn, should be small

        # "slope" is set so that the function value becomes eps at r4nn
        acut = log(1.0 / eps - 1.0) / (r4nn - rc)

        rc_list = rc * 1.045 if self.parameters['asap_cutoff'] else rc + 0.5

        return rc, rc_list, acut

    def _calc_gammas(self, s0, eta2, kappa):
        n = np.array([12, 6, 24])  # numbers of 1, 2, 3NN sites in fcc
        r = beta * s0 * np.sqrt([1.0, 2.0, 3.0])  # distances of 1, 2, 3NNs
        w = 1.0 / (1.0 + np.exp(self.acut * (r - self.rc)))
        x = n * w / 12.0
        gamma1 = x @ np.exp(-eta2 * (r - beta * s0))
        gamma2 = x @ np.exp(-kappa / beta * (r - beta * s0))
        return gamma1, gamma2

    def calculate(self, atoms=None, properties=['energy'],
                  system_changes=all_changes):
        Calculator.calculate(self, atoms, properties, system_changes)

        if 'numbers' in system_changes:
            self.initialize(self.atoms)

        self.nl.update(self.atoms)

        self.energies[:] = 0.0
        self.forces[:] = 0.0
        self.stress[:] = 0.0
        self.deds[:] = 0.0

        natoms = len(self.atoms)

        # store nearest neighbor info for all the atoms
        # suffixes 's' and 'o': contributions from self and the other atoms
        ps = {}
        for a1 in range(natoms):
            a2, d, r = self._get_neighbors(a1)
            if len(a2) == 0:
                continue
            w, dwdroverw = self._calc_theta(r)
            dsigma1s, dsigma1o = self._calc_dsigma1(a1, a2, r, w)
            dsigma2s, dsigma2o = self._calc_dsigma2(a1, a2, r, w)
            ps[a1] = {
                'a2': a2,
                'd': d,
                'r': r,
                'invr': 1.0 / r,
                'w': w,
                'dwdroverw': dwdroverw,
                'dsigma1s': dsigma1s,
                'dsigma1o': dsigma1o,
                'dsigma2s': dsigma2s,
                'dsigma2o': dsigma2o,
            }

        # deds is computed in _calc_e_c_a2
        # since deds for all the atoms are used later in _calc_f_c_a2,
        # _calc_e_c_a2 must be called beforehand for all the atoms
        for a1, p in ps.items():
            a2 = p['a2']
            dsigma1s = p['dsigma1s']
            self._calc_e_c_a2(a1, dsigma1s)

        for a1, p in ps.items():
            a2 = p['a2']
            d = p['d']
            invr = p['invr']
            dwdroverw = p['dwdroverw']
            dsigma1s = p['dsigma1s']
            dsigma1o = p['dsigma1o']
            dsigma2s = p['dsigma2s']
            dsigma2o = p['dsigma2o']
            self._calc_fs_c_a2(a1, a2, d, invr, dwdroverw, dsigma1s, dsigma1o)
            self._calc_efs_a1(a1, a2, d, invr, dwdroverw, dsigma2s, dsigma2o)

        # subtract E0 (ASAP convention)
        self.energies -= self.par['E0'][self.ia2iz]

        energy = np.add.reduce(self.energies, axis=0)
        self.results['energy'] = self.results['free_energy'] = energy
        self.results['energies'] = self.energies
        self.results['forces'] = self.forces

        if self.atoms.cell.rank == 3:
            self.stress = (self.stress + self.stress.T) * 0.5  # symmetrize
            self.stress /= self.atoms.get_volume()
            self.results['stress'] = self.stress.flat[[0, 4, 8, 5, 2, 1]]
        elif 'stress' in properties:
            raise PropertyNotImplementedError

    def _get_neighbors(self, a1):
        positions = self.atoms.positions
        cell = self.atoms.cell
        neighbors, offsets = self.nl.get_neighbors(a1)
        offsets = np.dot(offsets, cell)
        d = positions[neighbors] + offsets - positions[a1]
        r = np.sqrt(np.add.reduce(d**2, axis=1))
        mask = r < self.rc_list
        return neighbors[mask], d[mask], r[mask]

    def _calc_theta(self, r):
        """Calculate cutoff function and its r derivative"""
        w = 1.0 / (1.0 + np.exp(self.acut * (r - self.rc)))
        dwdroverw = self.acut * (w - 1.0)
        return w, dwdroverw

    def _calc_dsigma1(self, a1, a2, r, w):
        """Calculate contributions of neighbors to sigma1"""
        s0s = self.par['s0'][self.ia2iz[a1]]
        s0o = self.par['s0'][self.ia2iz[a2]]
        eta2s = self.par['eta2'][self.ia2iz[a1]]
        eta2o = self.par['eta2'][self.ia2iz[a2]]
        chi = self.chi[self.ia2iz[a1], self.ia2iz[a2]]

        dsigma1s = np.exp(-eta2o * (r - beta * s0o)) * chi * w
        dsigma1o = np.exp(-eta2s * (r - beta * s0s)) / chi * w

        return dsigma1s, dsigma1o

    def _calc_dsigma2(self, a1, a2, r, w):
        """Calculate contributions of neighbors to sigma2"""
        s0s = self.par['s0'][self.ia2iz[a1]]
        s0o = self.par['s0'][self.ia2iz[a2]]
        kappas = self.par['kappa'][self.ia2iz[a1]]
        kappao = self.par['kappa'][self.ia2iz[a2]]
        chi = self.chi[self.ia2iz[a1], self.ia2iz[a2]]

        dsigma2s = np.exp(-kappao * (r / beta - s0o)) * chi * w
        dsigma2o = np.exp(-kappas * (r / beta - s0s)) / chi * w

        return dsigma2s, dsigma2o

    def _calc_e_c_a2(self, a1, dsigma1s):
        """Calculate E_c and the second term of E_AS and their s derivatives"""
        e0s = self.par['E0'][self.ia2iz[a1]]
        v0s = self.par['V0'][self.ia2iz[a1]]
        eta2s = self.par['eta2'][self.ia2iz[a1]]
        lmds = self.par['lambda'][self.ia2iz[a1]]
        kappas = self.par['kappa'][self.ia2iz[a1]]
        inv12gamma1s = self.par['inv12gamma1'][self.ia2iz[a1]]

        sigma1 = np.add.reduce(dsigma1s)
        ds = -1.0 * np.log(sigma1 * inv12gamma1s) / (beta * eta2s)

        lmdsds = lmds * ds
        expneglmdds = np.exp(-1.0 * lmdsds)
        self.energies[a1] += e0s * (1.0 + lmdsds) * expneglmdds
        self.deds[a1] += -1.0 * e0s * lmds * lmdsds * expneglmdds

        sixv0expnegkppds = 6.0 * v0s * np.exp(-1.0 * kappas * ds)
        self.energies[a1] += sixv0expnegkppds
        self.deds[a1] += -1.0 * kappas * sixv0expnegkppds

        self.deds[a1] /= -1.0 * beta * eta2s * sigma1  # factor from ds/dr

    def _calc_efs_a1(self, a1, a2, d, invr, dwdroverw, dsigma2s, dsigma2o):
        """Calculate the first term of E_AS and derivatives"""
        neghalfv0overgamma2s = self.par['neghalfv0overgamma2'][self.ia2iz[a1]]
        neghalfv0overgamma2o = self.par['neghalfv0overgamma2'][self.ia2iz[a2]]
        kappas = self.par['kappa'][self.ia2iz[a1]]
        kappao = self.par['kappa'][self.ia2iz[a2]]

        es = neghalfv0overgamma2s * dsigma2s
        eo = neghalfv0overgamma2o * dsigma2o
        self.energies[a1] += 0.5 * np.add.reduce(es + eo, axis=0)

        dedrs = es * (dwdroverw - kappao / beta)
        dedro = eo * (dwdroverw - kappas / beta)
        f = ((dedrs + dedro) * invr)[:, None] * d
        self.forces[a1] += np.add.reduce(f, axis=0)
        self.stress += 0.5 * np.dot(d.T, f)  # compensate double counting

    def _calc_fs_c_a2(self, a1, a2, d, invr, dwdroverw, dsigma1s, dsigma1o):
        """Calculate forces and stress from E_c and the second term of E_AS"""
        eta2s = self.par['eta2'][self.ia2iz[a1]]
        eta2o = self.par['eta2'][self.ia2iz[a2]]

        ddsigma1sdr = dsigma1s * (dwdroverw - eta2o)
        ddsigma1odr = dsigma1o * (dwdroverw - eta2s)
        dedrs = self.deds[a1] * ddsigma1sdr
        dedro = self.deds[a2] * ddsigma1odr
        f = ((dedrs + dedro) * invr)[:, None] * d
        self.forces[a1] += np.add.reduce(f, axis=0)
        self.stress += 0.5 * np.dot(d.T, f)  # compensate double counting