Coverage for gpaw/defects/__init__.py: 11%

231 statements  

« prev     ^ index     » next       coverage.py v7.7.1, created at 2025-07-14 00:18 +0000

1import numpy as np 

2import numbers 

3from scipy.optimize import minimize 

4from scipy.integrate import simpson 

5from gpaw import GPAW, PW 

6from ase.parallel import parprint 

7import scipy.integrate as integrate 

8from scipy.interpolate import InterpolatedUnivariateSpline 

9from ase.units import Hartree as Ha 

10from ase.units import Bohr 

11 

12 

13class ElectrostaticCorrections(): 

14 """ 

15 Calculate the electrostatic corrections for charged defects. 

16 """ 

17 def __init__(self, pristine, charged, 

18 q=None, sigma=None, r0=None, dimensionality='3d'): 

19 if isinstance(pristine, str): 

20 pristine = GPAW(pristine, txt=None, parallel={'domain': 1}) 

21 if isinstance(charged, str): 

22 charged = GPAW(charged, txt=None) 

23 calc = GPAW(mode=PW(500, force_complex_dtype=True), 

24 kpts={'size': (1, 1, 1), 

25 'gamma': True}, 

26 parallel={'domain': 1}, 

27 symmetry='off', 

28 txt=None) 

29 atoms = pristine.atoms.copy() 

30 calc.initialize(atoms) 

31 

32 self.pristine = pristine 

33 self.charged = charged 

34 self.calc = calc 

35 self.q = q 

36 self.sigma = sigma 

37 self.dimensionality = dimensionality 

38 

39 self.pd = self.calc.wfs.pd 

40 self.L = self.pd.gd.cell_cv[2, 2] 

41 if r0 is not None: 

42 self.r0 = np.array(r0) / Bohr 

43 elif dimensionality == '2d': 

44 self.r0 = np.array([0, 0, self.L / 2]) 

45 else: 

46 self.r0 = np.array([0, 0, 0]) 

47 self.z0 = -self.r0[2] 

48 self.G_Gv = self.pd.get_reciprocal_vectors(q=0, add_q=False) 

49 self.G2_G = self.pd.G2_qG[0] # |\vec{G}|^2 in Bohr^-2 

50 self.rho_G = self.calculate_gaussian_density() 

51 self.Omega = abs(np.linalg.det(self.calc.density.gd.cell_cv)) 

52 self.data = None 

53 self.El = None 

54 

55 # For the 2D case, we assume that the dielectric profile epsilon(z) 

56 # follows the electronic density of the pristine system n(z). 

57 density = self.pristine.get_pseudo_density(gridrefinement=2) 

58 density_1d = density.mean(axis=(0, 1)) 

59 coarse_z = find_z(self.pristine.density.gd.refine()) 

60 fine_z = find_z(self.pd.gd.refine()) 

61 transformer = InterpolatedUnivariateSpline(coarse_z, density_1d) 

62 self.density_1d = np.array([transformer(x) for x in fine_z]) 

63 

64 # We potentially have two z axes -- one on the original calculation, 

65 # and one on the calculator we just set up. 

66 self.z_g = fine_z 

67 self.density_z = coarse_z 

68 

69 self.G_z = find_G_z(self.G_Gv) 

70 self.G_parallel = np.unique(self.G_Gv[:, :2], axis=0) 

71 self.GG = np.outer(self.G_z, self.G_z) # G * Gprime 

72 

73 # We need the G_z vectors on the finely sampled grid, to calculate 

74 # epsilon(G - G'), which goes up to 2G_max in magnitude. 

75 self.G_z_fine = (np.fft.fftfreq(len(fine_z)) * 

76 2 * np.pi / (self.L) * len(fine_z)) 

77 self.index_array = self.get_index_array() 

78 

79 self.epsilons_z = None 

80 self.Eli = None 

81 self.Elp = None 

82 self.epsilon_GG = None 

83 

84 def calculate_gaussian_density(self): 

85 # Fourier transformed gaussian: 

86 return self.q * np.exp(-0.5 * self.G2_G * self.sigma ** 2) 

87 

88 def set_epsilons(self, epsilons, epsilon_bulk=1): 

89 """Set the bulk dielectric constant of the system corresponding to the 

90 atomic configuration of the pristine system. This is used to calculate 

91 the screened coulomb potential of the gaussian model distribution that 

92 we use. In 2D, the dielectric constant is poorly defined. However, when 

93 we do DFT calculations with periodic systems, we are always working 

94 with pseudo bulk-like structures, due to the periodic boundary 

95 conditions. It is therefore possible to calculate some dielectric 

96 constant. The screening of the system must come from where there is an 

97 electronic density, and we thus set 

98 

99 epsilon(z) = k * n(z) + epsilon_bulk, 

100 

101 for some constant of proprotionality k. This is determined by the 

102 constraint that the total screening int dz epsilon(z) gives the correct 

103 value. 

104 

105 For 3d systems, the trick is to set k = 0. In that case, we have the 

106 constant dielectric function that we require. 

107 

108 Parameters: 

109 epsilons: A float, or a list of two floats. If a single 

110 float is given, the screening in-plane and out-of-plane is assumed 

111 to be the same. If two floats are given, the first represents the 

112 in-plane response, and the second represents the out-of-plane 

113 response. 

114 epsilon_bulk: The value of the screening far away from the system, 

115 given in the same format as epsilons. In most cases, this will be 

116 1, corresponding to vacuum. 

117 

118 Returns: 

119 epsilons_z: dictionary containnig the normalized dielectric 

120 profiles in the in-plane and out-of-plane directions along the z 

121 axis. 

122 """ 

123 

124 # Reset the state of the object; the corrections need to be 

125 # recalculated 

126 self.Elp = None 

127 self.Eli = None 

128 self.epsilon_GG = None 

129 

130 def normalize(value): 

131 if isinstance(value, numbers.Number): 

132 value = [value, value] 

133 elif len(value) == 1: 

134 value = [value[0]] * 2 

135 return np.array(value) 

136 

137 epsilons = normalize(epsilons) 

138 self.epsilons = epsilons 

139 

140 if self.dimensionality == '2d': 

141 eb = normalize(epsilon_bulk) 

142 elif self.dimensionality == '3d': 

143 eb = normalize(epsilons) 

144 self.eb = eb 

145 

146 z = self.z_g 

147 L = self.L 

148 density_1d = self.density_1d 

149 N = simpson(density_1d, z) 

150 epsilons_z = np.zeros((2,) + np.shape(density_1d)) 

151 epsilons_z += density_1d 

152 

153 # In-plane 

154 epsilons_z[0] = epsilons_z[0] * (epsilons[0] - eb[0]) / N * L + eb[0] 

155 

156 # Out-of-plane 

157 def objective_function(k): 

158 k = k[0] 

159 integral = simpson(1 / (k * density_1d + eb[1]), z) / L 

160 return np.abs(integral - 1 / epsilons[1]) 

161 

162 test = minimize(objective_function, [1], method='Nelder-Mead') 

163 assert test.success, "Unable to normalize dielectric profile" 

164 k = test.x[0] 

165 epsilons_z[1] = epsilons_z[1] * k + eb[1] 

166 self.epsilons_z = {'in-plane': epsilons_z[0], 

167 'out-of-plane': epsilons_z[1]} 

168 self.calculate_epsilon_GG() 

169 

170 def get_index_array(self): 

171 """ 

172 Calculate the indices that map between the G vectors on the fine grid, 

173 and the matrix defined by M(G, Gprime) = G - Gprime. 

174 

175 We use this to generate the matrix epsilon_GG = epsilon(G - Gprime) 

176 based on knowledge of epsilon(G) on the fine grid. 

177 """ 

178 G, Gprime = np.meshgrid(self.G_z, self.G_z) 

179 difference_GG = G - Gprime 

180 index_array = np.zeros(np.shape(difference_GG)) 

181 index_array[:] = np.nan 

182 for idx, val in enumerate(self.G_z_fine): 

183 mask = np.isclose(difference_GG, val) 

184 index_array[mask] = idx 

185 if np.isnan(index_array).any(): 

186 print("Missing indices found in mapping between plane wave " 

187 "sets. Something is wrong with your G-vectors!") 

188 print(np.isnan(index_array).all()) 

189 print(np.where(np.isnan(index_array))) 

190 print(self.G_z_fine) 

191 print(difference_GG) 

192 assert False 

193 return index_array.astype(int) 

194 

195 def calculate_epsilon_GG(self): 

196 assert self.epsilons_z is not None, ("You provide the dielectric " 

197 "constant first!") 

198 N = len(self.density_1d) 

199 epsilon_par_G = np.fft.fft(self.epsilons_z['in-plane']) / N 

200 epsilon_perp_G = np.fft.fft(self.epsilons_z['out-of-plane']) / N 

201 epsilon_par_GG = epsilon_par_G[self.index_array] 

202 epsilon_perp_GG = epsilon_perp_G[self.index_array] 

203 

204 self.epsilon_GG = {'in-plane': epsilon_par_GG, 

205 'out-of-plane': epsilon_perp_GG} 

206 

207 def calculate_periodic_correction(self): 

208 G_z = self.G_z 

209 if self.Elp is not None: 

210 return self.Elp 

211 if self.epsilon_GG is None: 

212 self.calculate_epsilon_GG() 

213 Elp = 0.0 

214 

215 for vector in self.G_parallel: 

216 G2 = (np.dot(vector, vector) + G_z * G_z) 

217 rho_G = self.q * np.exp(- G2 * self.sigma ** 2 / 2, dtype=complex) 

218 if self.dimensionality == '2d': 

219 rho_G *= np.exp(1j * self.z0 * G_z) 

220 A_GG = (self.GG * self.epsilon_GG['out-of-plane'] 

221 + np.dot(vector, vector) * self.epsilon_GG['in-plane']) 

222 if np.allclose(vector, 0): 

223 A_GG[0, 0] = 1 # The d.c. potential is poorly defined 

224 V_G = np.linalg.solve(A_GG, rho_G) 

225 if np.allclose(vector, 0): 

226 parprint('Skipping G^2=0 contribution to Elp') 

227 V_G[0] = 0 

228 Elp += (rho_G * V_G).sum().real 

229 

230 Elp *= 2.0 * np.pi * Ha / self.Omega 

231 

232 self.Elp = Elp 

233 return Elp 

234 

235 def calculate_isolated_correction(self): 

236 if self.Eli is not None: 

237 return self.Eli 

238 

239 L = self.L 

240 G_z = self.G_z 

241 

242 G, Gprime = np.meshgrid(G_z, G_z) 

243 Delta_GG = G - Gprime 

244 phase = Delta_GG * self.z0 

245 gaussian = (Gprime ** 2 + G ** 2) * self.sigma * self.sigma / 2 

246 

247 prefactor = np.exp(1j * phase - gaussian) 

248 

249 if self.epsilon_GG is None: 

250 self.calculate_epsilon_GG() 

251 

252 dE_GG_par = (self.epsilon_GG['in-plane'] 

253 - self.eb[0] * np.eye(len(self.G_z))) 

254 dE_GG_perp = (self.epsilon_GG['out-of-plane'] 

255 - self.eb[1] * np.eye(len(self.G_z))) 

256 

257 def integrand(k): 

258 K_inv_G = ((self.eb[0] * k ** 2 + self.eb[1] * G_z ** 2) / 

259 (1 - np.exp(-k * L / 2) * np.cos(L * G_z / 2))) 

260 K_inv_GG = np.diag(K_inv_G) 

261 D_GG = L * (K_inv_GG + dE_GG_perp * self.GG + dE_GG_par * k ** 2) 

262 return (k * np.exp(-k ** 2 * self.sigma ** 2) * 

263 (prefactor * np.linalg.inv(D_GG)).sum()).real 

264 

265 I = integrate.quad(integrand, 0, np.inf, limit=500) 

266 Eli = self.q * self.q * I[0] * Ha 

267 self.Eli = Eli 

268 return Eli 

269 

270 def calculate_potential_alignment(self): 

271 V_neutral = -np.mean(self.pristine.get_electrostatic_potential(), 

272 (0, 1)) 

273 V_charged = -np.mean(self.charged.get_electrostatic_potential(), 

274 (0, 1)) 

275 

276 z = self.density_z 

277 z_model, V_model = self.calculate_z_avg_model_potential() 

278 V_model = InterpolatedUnivariateSpline(z_model[:-1], V_model[:-1]) 

279 V_model = V_model(z) 

280 self.V_model = V_model 

281 Delta_V = self.average(V_model - V_charged + V_neutral, z) 

282 return Delta_V 

283 

284 def calculate_z_avg_model_potential(self): 

285 

286 vox3 = self.calc.density.gd.cell_cv[2, :] / len(self.z_g) 

287 

288 # The grid is arranged with z increasing fastest, then y 

289 # then x (like a cube file) 

290 

291 G_z = self.G_z[1:] 

292 rho_Gz = self.q * np.exp(-0.5 * G_z * G_z * self.sigma * self.sigma) 

293 

294 zs = [] 

295 Vs = [] 

296 if self.dimensionality == '2d': 

297 phase = np.exp(1j * (self.G_z * self.z0)) 

298 A_GG = (self.GG * self.epsilon_GG['out-of-plane']) 

299 A_GG[0, 0] = 1 

300 V_G = np.linalg.solve(A_GG, 

301 phase * np.array([0] + list(rho_Gz)))[1:] 

302 elif self.dimensionality == '3d': 

303 phase = np.exp(1j * (G_z * self.z0)) 

304 V_G = phase * rho_Gz / self.eb[1] / G_z ** 2 

305 

306 for z in self.z_g: 

307 phase_G = np.exp(1j * (G_z * z)) 

308 V = (np.sum(phase_G * V_G).real 

309 * Ha * 4.0 * np.pi / (self.Omega)) 

310 Vs.append(V) 

311 

312 V = (np.sum(V_G.real) * Ha * 4.0 * np.pi / (self.Omega)) 

313 zs = list(self.z_g) + [vox3[2]] 

314 Vs.append(V) 

315 return np.array(zs), np.array(Vs) 

316 

317 def average(self, V, z): 

318 N = len(V) 

319 if self.dimensionality == '3d': 

320 middle = np.argmin(np.abs(z + self.z0)) + N // 2 

321 middle = middle % len(z) 

322 points = range(middle - N // 8, middle + N // 8 + 1) 

323 restricted = V[points] 

324 elif self.dimensionality == '2d': 

325 points = list(range(0, N // 8)) + list(range(7 * N // 8, N)) 

326 restricted = V[points] 

327 V_mean = np.mean(restricted) 

328 return V_mean 

329 

330 def calculate_corrected_formation_energy(self): 

331 E_0 = self.pristine.get_potential_energy() 

332 E_X = self.charged.get_potential_energy() 

333 Eli = self.calculate_isolated_correction() 

334 Elp = self.calculate_periodic_correction() 

335 Delta_V = self.calculate_potential_alignment() 

336 return E_X - E_0 - (Elp - Eli) + Delta_V * self.q 

337 

338 def calculate_uncorrected_formation_energy(self): 

339 E_0 = self.pristine.get_potential_energy() 

340 E_X = self.charged.get_potential_energy() 

341 return E_X - E_0 

342 

343 def collect_electrostatic_data(self): 

344 V_neutral = -np.mean(self.pristine.get_electrostatic_potential(), 

345 (0, 1)), 

346 V_charged = -np.mean(self.charged.get_electrostatic_potential(), 

347 (0, 1)), 

348 data = {'epsilon': self.eb[0], 

349 'z': self.density_z, 

350 'V_0': V_neutral, 

351 'V_X': V_charged, 

352 'Elc': self.Elp - self.Eli, 

353 'D_V_mean': self.calculate_potential_alignment(), 

354 'V_model': self.V_model, 

355 'D_V': (self.V_model 

356 + V_neutral 

357 - V_charged)} 

358 self.data = data 

359 return data 

360 

361 

362def find_G_z(G_Gv): 

363 mask = (G_Gv[:, 0] == 0) & (G_Gv[:, 1] == 0) 

364 G_z = G_Gv[mask][:, 2] # qG_z vectors in Bohr^{-1} 

365 return G_z 

366 

367 

368def find_z(gd): 

369 r3_xyz = gd.get_grid_point_coordinates() 

370 nrz = r3_xyz.shape[3] 

371 return r3_xyz[2].flatten()[:nrz]