Coverage for /builds/ase/ase/ase/calculators/emt.py: 98.90%

182 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-08-02 00:12 +0000

1# fmt: off 

2 

3"""Effective medium theory potential.""" 

4from collections import defaultdict 

5from math import log, sqrt 

6 

7import numpy as np 

8 

9from ase.calculators.calculator import ( 

10 Calculator, 

11 PropertyNotImplementedError, 

12 all_changes, 

13) 

14from ase.data import atomic_numbers, chemical_symbols 

15from ase.neighborlist import NeighborList 

16from ase.units import Bohr 

17 

18parameters = { 

19 # E0 s0 V0 eta2 kappa lambda n0 

20 # eV bohr eV bohr^-1 bohr^-1 bohr^-1 bohr^-3 

21 'Al': (-3.28, 3.00, 1.493, 1.240, 2.000, 1.169, 0.00700), 

22 'Cu': (-3.51, 2.67, 2.476, 1.652, 2.740, 1.906, 0.00910), 

23 'Ag': (-2.96, 3.01, 2.132, 1.652, 2.790, 1.892, 0.00547), 

24 'Au': (-3.80, 3.00, 2.321, 1.674, 2.873, 2.182, 0.00703), 

25 'Ni': (-4.44, 2.60, 3.673, 1.669, 2.757, 1.948, 0.01030), 

26 'Pd': (-3.90, 2.87, 2.773, 1.818, 3.107, 2.155, 0.00688), 

27 'Pt': (-5.85, 2.90, 4.067, 1.812, 3.145, 2.192, 0.00802), 

28 # extra parameters - just for fun ... 

29 'H': (-3.21, 1.31, 0.132, 2.652, 2.790, 3.892, 0.00547), 

30 'C': (-3.50, 1.81, 0.332, 1.652, 2.790, 1.892, 0.01322), 

31 'N': (-5.10, 1.88, 0.132, 1.652, 2.790, 1.892, 0.01222), 

32 'O': (-4.60, 1.95, 0.332, 1.652, 2.790, 1.892, 0.00850)} 

33 

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

35 

36 

37class EMT(Calculator): 

38 """Python implementation of the Effective Medium Potential. 

39 

40 Supports the following standard EMT metals: 

41 Al, Cu, Ag, Au, Ni, Pd and Pt. 

42 

43 In addition, the following elements are supported. 

44 They are NOT well described by EMT, and the parameters 

45 are not for any serious use: 

46 H, C, N, O 

47 

48 Parameters 

49 ---------- 

50 asap_cutoff : bool, default: False 

51 If True the cutoff mimics how ASAP does it; most importantly the global 

52 cutoff is chosen from the largest atom present in the simulation. 

53 If False it is chosen from the largest atom in the parameter table. 

54 True gives the behaviour of ASAP and older EMT implementations, 

55 although the results are not bitwise identical. 

56 

57 Notes 

58 ----- 

59 Formulation mostly follows Jacobsen *et al*. [1]_ 

60 `Documentation in ASAP can also be referred to <https://gitlab.com/asap/ 

61 asap/blob/master/docs/manual/potentials/emt.pdf>`_. 

62 

63 .. [1] K. W. Jacobsen, P. Stoltze, and J. K. Nørskov, 

64 Surf. Sci. 366, 394 (1996). 

65 """ 

66 implemented_properties = ['energy', 'free_energy', 'energies', 'forces', 

67 'stress', 'magmom', 'magmoms'] 

68 

69 nolabel = True 

70 

71 default_parameters = {'asap_cutoff': False} 

72 

73 def __init__(self, **kwargs): 

74 Calculator.__init__(self, **kwargs) 

75 

76 def initialize(self, atoms): 

77 self.rc, self.rc_list, self.acut = self._calc_cutoff(atoms) 

78 

79 numbers = atoms.get_atomic_numbers() 

80 

81 # ia2iz : map from idx of atoms to idx of atomic numbers in self.par 

82 unique_numbers, self.ia2iz = np.unique(numbers, return_inverse=True) 

83 self.par = defaultdict(lambda: np.empty(len(unique_numbers))) 

84 for i, Z in enumerate(unique_numbers): 

85 sym = chemical_symbols[Z] 

86 if sym not in parameters: 

87 raise NotImplementedError(f'No EMT-potential for {sym}') 

88 p = parameters[sym] 

89 s0 = p[1] * Bohr 

90 eta2 = p[3] / Bohr 

91 kappa = p[4] / Bohr 

92 gamma1, gamma2 = self._calc_gammas(s0, eta2, kappa) 

93 self.par['Z'][i] = Z 

94 self.par['E0'][i] = p[0] 

95 self.par['s0'][i] = s0 

96 self.par['V0'][i] = p[2] 

97 self.par['eta2'][i] = eta2 

98 self.par['kappa'][i] = kappa 

99 self.par['lambda'][i] = p[5] / Bohr 

100 self.par['n0'][i] = p[6] / Bohr**3 

101 self.par['inv12gamma1'][i] = 1.0 / (12.0 * gamma1) 

102 self.par['neghalfv0overgamma2'][i] = -0.5 * p[2] / gamma2 

103 

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

105 

106 self.energies = np.empty(len(atoms)) 

107 self.forces = np.empty((len(atoms), 3)) 

108 self.stress = np.empty((3, 3)) 

109 self.deds = np.empty(len(atoms)) 

110 

111 self.nl = NeighborList([0.5 * self.rc_list] * len(atoms), 

112 self_interaction=False, bothways=True) 

113 

114 def _calc_cutoff(self, atoms): 

115 """Calculate parameters of the logistic smoothing function etc. 

116 

117 The logistic smoothing function is given by 

118 

119 .. math: 

120 

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

122 

123 Returns 

124 ------- 

125 rc : float 

126 "Midpoint" of the logistic smoothing function, set to be the mean 

127 of the 3rd and the 4th nearest-neighbor distances in FCC. 

128 rc_list : float 

129 Cutoff radius for the neighbor search, set to be slightly larger 

130 than ``rc`` depending on ``asap_cutoff``. 

131 acut : float 

132 "Slope" of the smoothing function, set for the smoothing function 

133 value to be ``1e-4`` at the 4th nearest-neighbor distance in FCC. 

134 

135 Notes 

136 ----- 

137 ``maxseq`` is the present FCC Wigner-Seitz radius. ``beta * maxseq`` 

138 (`r1nn`) is the corresponding 1st nearest-neighbor distance in FCC. 

139 The 2nd, 3rd, 4th nearest-neighbor distances in FCC are given using 

140 ``r1nn`` by ``sqrt(2) * r1nn``, ``sqrt(3) * r1nn``, ``sqrt(4) * r1nn``, 

141 respectively. 

142 """ 

143 numbers = atoms.get_atomic_numbers() 

144 if self.parameters['asap_cutoff']: 

145 relevant_pars = { 

146 symb: p 

147 for symb, p in parameters.items() 

148 if atomic_numbers[symb] in numbers 

149 } 

150 else: 

151 relevant_pars = parameters 

152 maxseq = max(par[1] for par in relevant_pars.values()) * Bohr 

153 r1nn = beta * maxseq # 1st NN distance in FCC 

154 rc = r1nn * 0.5 * (sqrt(3.0) + 2.0) # mean of 3NN and 4NN dists. 

155 r4nn = r1nn * 2.0 # 4NN distance in FCC 

156 eps = 1e-4 # value at r4nn, should be small 

157 

158 # "slope" is set so that the function value becomes eps at r4nn 

159 acut = log(1.0 / eps - 1.0) / (r4nn - rc) 

160 

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

162 

163 return rc, rc_list, acut 

164 

165 def _calc_gammas(self, s0, eta2, kappa): 

166 n = np.array([12, 6, 24]) # numbers of 1, 2, 3NN sites in fcc 

167 r = beta * s0 * np.sqrt([1.0, 2.0, 3.0]) # distances of 1, 2, 3NNs 

168 w = 1.0 / (1.0 + np.exp(self.acut * (r - self.rc))) 

169 x = n * w / 12.0 

170 gamma1 = x @ np.exp(-eta2 * (r - beta * s0)) 

171 gamma2 = x @ np.exp(-kappa / beta * (r - beta * s0)) 

172 return gamma1, gamma2 

173 

174 def calculate(self, atoms=None, properties=['energy'], 

175 system_changes=all_changes): 

176 Calculator.calculate(self, atoms, properties, system_changes) 

177 

178 if 'numbers' in system_changes: 

179 self.initialize(self.atoms) 

180 

181 self.nl.update(self.atoms) 

182 

183 self.energies[:] = 0.0 

184 self.forces[:] = 0.0 

185 self.stress[:] = 0.0 

186 self.deds[:] = 0.0 

187 

188 natoms = len(self.atoms) 

189 

190 # store nearest neighbor info for all the atoms 

191 # suffixes 's' and 'o': contributions from self and the other atoms 

192 ps = {} 

193 for a1 in range(natoms): 

194 a2, d, r = self._get_neighbors(a1) 

195 if len(a2) == 0: 

196 continue 

197 w, dwdroverw = self._calc_theta(r) 

198 dsigma1s, dsigma1o = self._calc_dsigma1(a1, a2, r, w) 

199 dsigma2s, dsigma2o = self._calc_dsigma2(a1, a2, r, w) 

200 ps[a1] = { 

201 'a2': a2, 

202 'd': d, 

203 'r': r, 

204 'invr': 1.0 / r, 

205 'w': w, 

206 'dwdroverw': dwdroverw, 

207 'dsigma1s': dsigma1s, 

208 'dsigma1o': dsigma1o, 

209 'dsigma2s': dsigma2s, 

210 'dsigma2o': dsigma2o, 

211 } 

212 

213 # deds is computed in _calc_e_c_a2 

214 # since deds for all the atoms are used later in _calc_f_c_a2, 

215 # _calc_e_c_a2 must be called beforehand for all the atoms 

216 for a1, p in ps.items(): 

217 a2 = p['a2'] 

218 dsigma1s = p['dsigma1s'] 

219 self._calc_e_c_a2(a1, dsigma1s) 

220 

221 for a1, p in ps.items(): 

222 a2 = p['a2'] 

223 d = p['d'] 

224 invr = p['invr'] 

225 dwdroverw = p['dwdroverw'] 

226 dsigma1s = p['dsigma1s'] 

227 dsigma1o = p['dsigma1o'] 

228 dsigma2s = p['dsigma2s'] 

229 dsigma2o = p['dsigma2o'] 

230 self._calc_fs_c_a2(a1, a2, d, invr, dwdroverw, dsigma1s, dsigma1o) 

231 self._calc_efs_a1(a1, a2, d, invr, dwdroverw, dsigma2s, dsigma2o) 

232 

233 # subtract E0 (ASAP convention) 

234 self.energies -= self.par['E0'][self.ia2iz] 

235 

236 energy = np.add.reduce(self.energies, axis=0) 

237 self.results['energy'] = self.results['free_energy'] = energy 

238 self.results['energies'] = self.energies 

239 self.results['forces'] = self.forces 

240 

241 if self.atoms.cell.rank == 3: 

242 self.stress = (self.stress + self.stress.T) * 0.5 # symmetrize 

243 self.stress /= self.atoms.get_volume() 

244 self.results['stress'] = self.stress.flat[[0, 4, 8, 5, 2, 1]] 

245 elif 'stress' in properties: 

246 raise PropertyNotImplementedError 

247 

248 def _get_neighbors(self, a1): 

249 positions = self.atoms.positions 

250 cell = self.atoms.cell 

251 neighbors, offsets = self.nl.get_neighbors(a1) 

252 offsets = np.dot(offsets, cell) 

253 d = positions[neighbors] + offsets - positions[a1] 

254 r = np.sqrt(np.add.reduce(d**2, axis=1)) 

255 mask = r < self.rc_list 

256 return neighbors[mask], d[mask], r[mask] 

257 

258 def _calc_theta(self, r): 

259 """Calculate cutoff function and its r derivative""" 

260 w = 1.0 / (1.0 + np.exp(self.acut * (r - self.rc))) 

261 dwdroverw = self.acut * (w - 1.0) 

262 return w, dwdroverw 

263 

264 def _calc_dsigma1(self, a1, a2, r, w): 

265 """Calculate contributions of neighbors to sigma1""" 

266 s0s = self.par['s0'][self.ia2iz[a1]] 

267 s0o = self.par['s0'][self.ia2iz[a2]] 

268 eta2s = self.par['eta2'][self.ia2iz[a1]] 

269 eta2o = self.par['eta2'][self.ia2iz[a2]] 

270 chi = self.chi[self.ia2iz[a1], self.ia2iz[a2]] 

271 

272 dsigma1s = np.exp(-eta2o * (r - beta * s0o)) * chi * w 

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

274 

275 return dsigma1s, dsigma1o 

276 

277 def _calc_dsigma2(self, a1, a2, r, w): 

278 """Calculate contributions of neighbors to sigma2""" 

279 s0s = self.par['s0'][self.ia2iz[a1]] 

280 s0o = self.par['s0'][self.ia2iz[a2]] 

281 kappas = self.par['kappa'][self.ia2iz[a1]] 

282 kappao = self.par['kappa'][self.ia2iz[a2]] 

283 chi = self.chi[self.ia2iz[a1], self.ia2iz[a2]] 

284 

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

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

287 

288 return dsigma2s, dsigma2o 

289 

290 def _calc_e_c_a2(self, a1, dsigma1s): 

291 """Calculate E_c and the second term of E_AS and their s derivatives""" 

292 e0s = self.par['E0'][self.ia2iz[a1]] 

293 v0s = self.par['V0'][self.ia2iz[a1]] 

294 eta2s = self.par['eta2'][self.ia2iz[a1]] 

295 lmds = self.par['lambda'][self.ia2iz[a1]] 

296 kappas = self.par['kappa'][self.ia2iz[a1]] 

297 inv12gamma1s = self.par['inv12gamma1'][self.ia2iz[a1]] 

298 

299 sigma1 = np.add.reduce(dsigma1s) 

300 ds = -1.0 * np.log(sigma1 * inv12gamma1s) / (beta * eta2s) 

301 

302 lmdsds = lmds * ds 

303 expneglmdds = np.exp(-1.0 * lmdsds) 

304 self.energies[a1] += e0s * (1.0 + lmdsds) * expneglmdds 

305 self.deds[a1] += -1.0 * e0s * lmds * lmdsds * expneglmdds 

306 

307 sixv0expnegkppds = 6.0 * v0s * np.exp(-1.0 * kappas * ds) 

308 self.energies[a1] += sixv0expnegkppds 

309 self.deds[a1] += -1.0 * kappas * sixv0expnegkppds 

310 

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

312 

313 def _calc_efs_a1(self, a1, a2, d, invr, dwdroverw, dsigma2s, dsigma2o): 

314 """Calculate the first term of E_AS and derivatives""" 

315 neghalfv0overgamma2s = self.par['neghalfv0overgamma2'][self.ia2iz[a1]] 

316 neghalfv0overgamma2o = self.par['neghalfv0overgamma2'][self.ia2iz[a2]] 

317 kappas = self.par['kappa'][self.ia2iz[a1]] 

318 kappao = self.par['kappa'][self.ia2iz[a2]] 

319 

320 es = neghalfv0overgamma2s * dsigma2s 

321 eo = neghalfv0overgamma2o * dsigma2o 

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

323 

324 dedrs = es * (dwdroverw - kappao / beta) 

325 dedro = eo * (dwdroverw - kappas / beta) 

326 f = ((dedrs + dedro) * invr)[:, None] * d 

327 self.forces[a1] += np.add.reduce(f, axis=0) 

328 self.stress += 0.5 * np.dot(d.T, f) # compensate double counting 

329 

330 def _calc_fs_c_a2(self, a1, a2, d, invr, dwdroverw, dsigma1s, dsigma1o): 

331 """Calculate forces and stress from E_c and the second term of E_AS""" 

332 eta2s = self.par['eta2'][self.ia2iz[a1]] 

333 eta2o = self.par['eta2'][self.ia2iz[a2]] 

334 

335 ddsigma1sdr = dsigma1s * (dwdroverw - eta2o) 

336 ddsigma1odr = dsigma1o * (dwdroverw - eta2s) 

337 dedrs = self.deds[a1] * ddsigma1sdr 

338 dedro = self.deds[a2] * ddsigma1odr 

339 f = ((dedrs + dedro) * invr)[:, None] * d 

340 self.forces[a1] += np.add.reduce(f, axis=0) 

341 self.stress += 0.5 * np.dot(d.T, f) # compensate double counting