Coverage for gpaw/response/df.py: 91%

307 statements  

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

1from __future__ import annotations 

2from abc import ABC, abstractmethod 

3from dataclasses import dataclass 

4import sys 

5 

6import numpy as np 

7from ase.units import Hartree 

8 

9import gpaw.mpi as mpi 

10 

11from gpaw.response.pw_parallelization import Blocks1D 

12from gpaw.response.coulomb_kernels import CoulombKernel 

13from gpaw.response.dyson import DysonEquation 

14from gpaw.response.density_kernels import DensityXCKernel 

15from gpaw.response.chi0 import Chi0Calculator, get_frequency_descriptor 

16from gpaw.response.chi0_data import Chi0Data 

17from gpaw.response.pair import get_gs_and_context 

18 

19from typing import TYPE_CHECKING 

20 

21if TYPE_CHECKING: 

22 from gpaw.response.groundstate import CellDescriptor 

23 from gpaw.response.frequencies import FrequencyDescriptor 

24 from gpaw.response.qpd import SingleQPWDescriptor 

25 

26 

27""" 

28On the notation in this module. 

29 

30When calculating properties such as the dielectric function, EELS spectrum and 

31polarizability there are many inherent subtleties relating to (ir)reducible 

32representations and inclusion of local-field effects. For the reciprocal space 

33representation of the Coulomb potential, we use the following notation 

34 

35v or v(q): The bare Coulomb interaction, 4π/|G+q|² 

36 

37V or V(q): The specified Coulomb interaction. Will usually be either the bare 

38 interaction or a truncated version hereof. 

39ˍ ˍ 

40V or V(q): The modified Coulomb interaction. Equal to V(q) for finite 

41 reciprocal wave vectors G > 0, but modified to exclude long-range 

42 interactions, that is, equal to 0 for G = 0. 

43""" 

44 

45 

46@dataclass 

47class Chi0DysonEquations: 

48 chi0: Chi0Data 

49 coulomb: CoulombKernel 

50 xc_kernel: DensityXCKernel | None 

51 cd: CellDescriptor 

52 

53 def __post_init__(self): 

54 if self.coulomb.truncation is None: 

55 self.bare_coulomb = self.coulomb 

56 else: 

57 self.bare_coulomb = self.coulomb.new(truncation=None) 

58 # When inverting the Dyson equation, we distribute frequencies globally 

59 blockdist = self.chi0.body.blockdist.new_distributor(nblocks='max') 

60 self.wblocks = Blocks1D(blockdist.blockcomm, len(self.chi0.wd)) 

61 

62 @staticmethod 

63 def _normalize(direction): 

64 if isinstance(direction, str): 

65 d_v = {'x': [1, 0, 0], 

66 'y': [0, 1, 0], 

67 'z': [0, 0, 1]}[direction] 

68 else: 

69 d_v = direction 

70 d_v = np.asarray(d_v) / np.linalg.norm(d_v) 

71 return d_v 

72 

73 def get_chi0_wGG(self, direction='x'): 

74 chi0 = self.chi0 

75 chi0_wGG = chi0.body.get_distributed_frequencies_array().copy() 

76 if chi0.qpd.optical_limit: 

77 # Project head and wings along the input direction 

78 d_v = self._normalize(direction) 

79 W_w = self.wblocks.myslice 

80 chi0_wGG[:, 0] = np.dot(d_v, chi0.chi0_WxvG[W_w, 0]) 

81 chi0_wGG[:, :, 0] = np.dot(d_v, chi0.chi0_WxvG[W_w, 1]) 

82 chi0_wGG[:, 0, 0] = np.dot(d_v, np.dot(chi0.chi0_Wvv[W_w], d_v).T) 

83 return chi0_wGG 

84 

85 def get_coulomb_scaled_kernel(self, modified=False, Kxc_GG=None): 

86 """Get the Hxc kernel rescaled by the bare Coulomb potential v(q). 

87 

88 Calculates 

89 ˷ 

90 K(q) = v^(-1/2)(q) K_Hxc(q) v^(-1/2)(q), 

91 

92 where v(q) is the bare Coulomb potential and 

93 

94 K_Hxc(q) = V(q) + K_xc(q). 

95 

96 When using the `modified` flag, the specified Coulomb kernel will be 

97 replaced with its modified analogue, 

98 ˍ 

99 V(q) -> V(q) 

100 """ 

101 qpd = self.chi0.qpd 

102 if self.coulomb is self.bare_coulomb: 

103 v_G = self.coulomb.V(qpd) # bare Coulomb interaction 

104 K_GG = np.eye(len(v_G), dtype=complex) 

105 else: 

106 v_G = self.bare_coulomb.V(qpd) 

107 V_G = self.coulomb.V(qpd) 

108 K_GG = np.diag(V_G / v_G) 

109 if modified: 

110 K_GG[0, 0] = 0. 

111 if Kxc_GG is not None: 

112 sqrtv_G = v_G**0.5 

113 K_GG += Kxc_GG / sqrtv_G / sqrtv_G[:, np.newaxis] 

114 return v_G, K_GG 

115 

116 @staticmethod 

117 def invert_dyson_like_equation(in_wGG, K_GG, reuse_buffer=True): 

118 """Generalized Dyson equation invertion. 

119 

120 Calculates 

121 

122 B(q,ω) = [1 - A(q,ω) K(q)]⁻¹ A(q,ω) 

123 

124 while possibly storing the output B(q,ω) in the input A(q,ω) buffer. 

125 """ 

126 if reuse_buffer: 

127 out_wGG = in_wGG 

128 else: 

129 out_wGG = np.zeros_like(in_wGG) 

130 for w, in_GG in enumerate(in_wGG): 

131 out_wGG[w] = DysonEquation(in_GG, in_GG @ K_GG).invert() 

132 return out_wGG 

133 

134 def rpa_density_response(self, direction='x', qinf_v=None): 

135 """Calculate the RPA susceptibility for (semi-)finite q. 

136 

137 Currently this is only used by the QEH code, why we don't support a top 

138 level user interface. 

139 """ 

140 # Extract χ₀(q,ω) 

141 qpd = self.chi0.qpd 

142 chi0_wGG = self.get_chi0_wGG(direction=direction) 

143 if qpd.optical_limit: 

144 # Restore the q-dependence of the head and wings in the q→0 limit 

145 assert qinf_v is not None and np.linalg.norm(qinf_v) > 0. 

146 d_v = self._normalize(direction) 

147 chi0_wGG[:, 1:, 0] *= np.dot(qinf_v, d_v) 

148 chi0_wGG[:, 0, 1:] *= np.dot(qinf_v, d_v) 

149 chi0_wGG[:, 0, 0] *= np.dot(qinf_v, d_v)**2 

150 # Invert Dyson equation, χ(q,ω) = [1 - χ₀(q,ω) V(q)]⁻¹ χ₀(q,ω) 

151 V_GG = self.coulomb.kernel(qpd, q_v=qinf_v) 

152 chi_wGG = self.invert_dyson_like_equation(chi0_wGG, V_GG) 

153 return qpd, chi_wGG, self.wblocks 

154 

155 def inverse_dielectric_function(self, direction='x'): 

156 """Calculate v^(1/2) χ v^(1/2), from which ε⁻¹(q,ω) is constructed.""" 

157 return InverseDielectricFunction.from_chi0_dyson_eqs( 

158 self, *self.calculate_vchi_symm(direction=direction)) 

159 

160 def calculate_vchi_symm(self, direction='x', modified=False): 

161 """Calculate v^(1/2) χ v^(1/2). 

162 

163 Starting from the TDDFT Dyson equation 

164 

165 χ(q,ω) = χ₀(q,ω) + χ₀(q,ω) K_Hxc(q,ω) χ(q,ω), (1) 

166 

167 the Coulomb scaled susceptibility, 

168 ˷ 

169 χ(q,ω) = v^(1/2)(q) χ(q,ω) v^(1/2)(q) 

170 

171 can be calculated from the Dyson-like equation 

172 ˷ ˷ ˷ ˷ ˷ 

173 χ(q,ω) = χ₀(q,ω) + χ₀(q,ω) K(q,ω) χ(q,ω) (2) 

174 

175 where 

176 ˷ 

177 K(q,ω) = v^(-1/2)(q) K_Hxc(q,ω) v^(-1/2)(q). 

178 

179 Here v(q) refers to the bare Coulomb potential. It should be emphasized 

180 that invertion of (2) rather than (1) is not merely a rescaling 

181 excercise. In the optical q → 0 limit, the Coulomb kernel v(q) diverges 

182 as 1/|G+q|² while the Kohn-Sham susceptibility χ₀(q,ω) vanishes as 

183 |G+q|². Treating v^(1/2)(q) χ₀(q,ω) v^(1/2)(q) as a single variable, 

184 the effects of this cancellation can be treated accurately within k.p 

185 perturbation theory. 

186 """ 

187 chi0_wGG = self.get_chi0_wGG(direction=direction) 

188 Kxc_GG = self.xc_kernel(self.chi0.qpd, chi0_wGG=chi0_wGG) \ 

189 if self.xc_kernel else None 

190 v_G, K_GG = self.get_coulomb_scaled_kernel( 

191 modified=modified, Kxc_GG=Kxc_GG) 

192 # Calculate v^(1/2)(q) χ₀(q,ω) v^(1/2)(q) 

193 sqrtv_G = v_G**0.5 

194 vchi0_symm_wGG = chi0_wGG # reuse buffer 

195 for w, chi0_GG in enumerate(chi0_wGG): 

196 vchi0_symm_wGG[w] = chi0_GG * sqrtv_G * sqrtv_G[:, np.newaxis] 

197 # Invert Dyson equation 

198 vchi_symm_wGG = self.invert_dyson_like_equation( 

199 vchi0_symm_wGG, K_GG, reuse_buffer=False) 

200 return vchi0_symm_wGG, vchi_symm_wGG 

201 

202 def customized_dielectric_function(self, direction='x'): 

203 """Calculate Ε(q,ω) = 1 - V(q) P(q,ω).""" 

204 V_GG = self.coulomb.kernel(self.chi0.qpd) 

205 P_wGG = self.polarizability_operator(direction=direction) 

206 nG = len(V_GG) 

207 eps_wGG = P_wGG # reuse buffer 

208 for w, P_GG in enumerate(P_wGG): 

209 eps_wGG[w] = np.eye(nG) - V_GG @ P_GG 

210 return CustomizableDielectricFunction.from_chi0_dyson_eqs( 

211 self, eps_wGG) 

212 

213 def bare_dielectric_function(self, direction='x'): 

214 """Calculate v^(1/2) ̄χ v^(1/2), from which ̄ϵ=1-v ̄χ is constructed. 

215 

216 The unscreened susceptibility is given by the Dyson-like equation 

217 ˍ ˍ ˍ 

218 χ(q,ω) = P(q,ω) + P(q,ω) V(q) χ(q,ω), (3) 

219 

220 In the special case of RPA, where P(q,ω) = χ₀(q,ω), one may notice that 

221 the Dyson-like equation (3) is exactly identical to the TDDFT Dyson 

222 equation (1) when replacing the Hartree-exchange-correlation kernel 

223 with the modified Coulomb interaction: 

224 ˍ 

225 K_Hxc(q) -> V(q). 

226 ˍ 

227 We may thus reuse that functionality to calculate v^(1/2) χ v^(1/2). 

228 """ 

229 if self.xc_kernel: 

230 raise NotImplementedError( 

231 'Calculation of the bare dielectric function within TDDFT has ' 

232 'not yet been implemented. For TDDFT dielectric properties, ' 

233 'please calculate the inverse dielectric function.') 

234 vP_symm_wGG, vchibar_symm_wGG = self.calculate_vchi_symm( 

235 direction=direction, modified=True) 

236 return BareDielectricFunction.from_chi0_dyson_eqs( 

237 self, vP_symm_wGG, vchibar_symm_wGG) 

238 

239 def polarizability_operator(self, direction='x'): 

240 """Calculate the polarizability operator P(q,ω). 

241 

242 Depending on the theory (RPA, TDDFT, MBPT etc.), the polarizability 

243 operator is approximated in various ways see e.g. 

244 [Rev. Mod. Phys. 74, 601 (2002)]. 

245 

246 In RPA: 

247 P(q,ω) = χ₀(q,ω) 

248 

249 In TDDFT: 

250 P(q,ω) = [1 - χ₀(q,ω) K_xc(q,ω)]⁻¹ χ₀(q,ω) 

251 """ 

252 chi0_wGG = self.get_chi0_wGG(direction=direction) 

253 if not self.xc_kernel: # RPA 

254 return chi0_wGG 

255 # TDDFT (in adiabatic approximations to the kernel) 

256 if self.chi0.qpd.optical_limit: 

257 raise NotImplementedError( 

258 'Calculation of the TDDFT dielectric function via the ' 

259 'polarizability operator has not been implemented for the ' 

260 'optical limit. Please calculate the inverse dielectric ' 

261 'function instead.') 

262 # The TDDFT implementation here is invalid in the optical limit 

263 # since the chi0_wGG variable already contains Coulomb effects, 

264 # chi0_wGG ~ V^(1/2) χ₀ V^(1/2)/(4π). 

265 # Furthermore, a direct evaluation of V(q) P(q,ω) does not seem 

266 # sensible, since it does not account for the exact cancellation 

267 # of the q-dependences of the two functions. 

268 # In principle, one could treat the v(q) P(q,ω) product in 

269 # perturbation theory, similar to the χ₀(q,ω) v(q) product in the 

270 # Dyson equation for χ, but unless we need to calculate the TDDFT 

271 # polarizability using truncated kernels, this isn't really 

272 # necessary. 

273 Kxc_GG = self.xc_kernel(self.chi0.qpd, chi0_wGG=chi0_wGG) 

274 return self.invert_dyson_like_equation(chi0_wGG, Kxc_GG) 

275 

276 

277@dataclass 

278class DielectricFunctionData(ABC): 

279 cd: CellDescriptor 

280 qpd: SingleQPWDescriptor 

281 wd: FrequencyDescriptor 

282 wblocks: Blocks1D 

283 coulomb: CoulombKernel 

284 bare_coulomb: CoulombKernel 

285 

286 @classmethod 

287 def from_chi0_dyson_eqs(cls, chi0_dyson_eqs, *args, **kwargs): 

288 chi0 = chi0_dyson_eqs.chi0 

289 return cls(chi0_dyson_eqs.cd, chi0.qpd, 

290 chi0.wd, chi0_dyson_eqs.wblocks, 

291 chi0_dyson_eqs.coulomb, chi0_dyson_eqs.bare_coulomb, 

292 *args, **kwargs) 

293 

294 def _macroscopic_component(self, in_wGG): 

295 return self.wblocks.all_gather(in_wGG[:, 0, 0]) 

296 

297 @property 

298 def v_G(self): 

299 return self.bare_coulomb.V(self.qpd) 

300 

301 @abstractmethod 

302 def macroscopic_dielectric_function(self) -> ScalarResponseFunctionSet: 

303 """Get the macroscopic dielectric function ε_M(q,ω).""" 

304 

305 def dielectric_constant(self): 

306 return self.macroscopic_dielectric_function().static_limit.real 

307 

308 def eels_spectrum(self): 

309 """Get the macroscopic EELS spectrum. 

310 

311 The spectrum is defined as 

312 

313 1 

314 EELS(q,ω) ≡ -Im ε⁻¹(q,ω) = -Im ‾‾‾‾‾‾‾. 

315 00 ε (q,ω) 

316 M 

317 

318 In addition to the many-body spectrum, we also calculate the 

319 EELS spectrum in the relevant independent-particle approximation, 

320 here defined as 

321 

322 1 

323 EELS₀(ω) = -Im ‾‾‾‾‾‾‾. 

324 IP 

325 ε (q,ω) 

326 M 

327 """ 

328 _, eps0_W, eps_W = self.macroscopic_dielectric_function().arrays 

329 eels0_W = -(1. / eps0_W).imag 

330 eels_W = -(1. / eps_W).imag 

331 return ScalarResponseFunctionSet(self.wd, eels0_W, eels_W) 

332 

333 def polarizability(self): 

334 """Get the macroscopic polarizability α_M(q,ω). 

335 

336 In the special case where dielectric properties are calculated 

337 solely based on the bare Coulomb interaction V(q) = v(q), the 

338 macroscopic part of the electronic polarizability α(q,ω) is related 

339 directly to the macroscopic dielectric function ε_M(q,ω). 

340 

341 In particular, we define the macroscroscopic polarizability so as to 

342 make it independent of the nonperiodic cell vector lengths. Namely, 

343 

344 α (q,ω) ≡ Λ α (q,ω) 

345 M 00 

346 

347 where Λ is the nonperiodic hypervolume of the unit cell. Thus, 

348 

349 α_M(q,ω) = Λ/(4π) (ϵ_M(q,ω) - 1) = Λ/(4π) (ε_M(q,ω) - 1), 

350 

351 where the latter equality only holds in the special case V(q) = v(q). 

352 

353 In addition to α_M(q,ω), we calculate also the macroscopic 

354 polarizability in the relevant independent-particle approximation by 

355 replacing ϵ_M with ε_M^(IP). 

356 """ 

357 if self.coulomb is not self.bare_coulomb: 

358 raise ValueError( 

359 'When using a truncated Coulomb kernel, the polarizability ' 

360 'cannot be calculated based on the macroscopic dielectric ' 

361 'function. Please calculate the bare dielectric function ' 

362 'instead.') 

363 _, eps0_W, eps_W = self.macroscopic_dielectric_function().arrays 

364 return self._polarizability(eps0_W, eps_W) 

365 

366 def _polarizability(self, eps0_W, eps_W): 

367 L = self.cd.nonperiodic_hypervolume 

368 alpha0_W = L / (4 * np.pi) * (eps0_W - 1) 

369 alpha_W = L / (4 * np.pi) * (eps_W - 1) 

370 return ScalarResponseFunctionSet(self.wd, alpha0_W, alpha_W) 

371 

372 

373@dataclass 

374class InverseDielectricFunction(DielectricFunctionData): 

375 """Data class for the inverse dielectric function ε⁻¹(q,ω). 

376 

377 The inverse dielectric function characterizes the longitudinal response 

378 

379 V (q,ω) = ε⁻¹(q,ω) V (q,ω), 

380 tot ext 

381 

382 where the induced potential due to the electronic system is given by vχ, 

383 

384 ε⁻¹(q,ω) = 1 + v(q) χ(q,ω). 

385 

386 In this data class, ε⁻¹ is cast in terms if its symmetrized representation 

387 ˷ 

388 ε⁻¹(q,ω) = v^(-1/2)(q) ε⁻¹(q,ω) v^(1/2)(q), 

389 

390 that is, in terms of v^(1/2)(q) χ(q,ω) v^(1/2)(q). 

391 

392 Please remark that v(q) here refers to the bare Coulomb potential 

393 irregardless of whether χ(q,ω) was determined using a truncated analogue. 

394 """ 

395 vchi0_symm_wGG: np.ndarray # v^(1/2)(q) χ₀(q,ω) v^(1/2)(q) 

396 vchi_symm_wGG: np.ndarray 

397 

398 def macroscopic_components(self): 

399 vchi0_W = self._macroscopic_component(self.vchi0_symm_wGG) 

400 vchi_W = self._macroscopic_component(self.vchi_symm_wGG) 

401 return vchi0_W, vchi_W 

402 

403 def macroscopic_dielectric_function(self): 

404 """Get the macroscopic dielectric function ε_M(q,ω). 

405 

406 Calculates 

407 

408 1 

409 ε (q,ω) = ‾‾‾‾‾‾‾‾ 

410 M ε⁻¹(q,ω) 

411 00 

412 

413 along with the macroscopic dielectric function in the independent- 

414 particle random-phase approximation [Rev. Mod. Phys. 74, 601 (2002)], 

415 

416 IPRPA 

417 ε (q,ω) = 1 - v(q) χ⁰(q,ω) 

418 M 00 

419 

420 that is, neglecting local-field and exchange-correlation effects. 

421 """ 

422 vchi0_W, vchi_W = self.macroscopic_components() 

423 eps0_W = 1 - vchi0_W 

424 eps_W = 1 / (1 + vchi_W) 

425 return ScalarResponseFunctionSet(self.wd, eps0_W, eps_W) 

426 

427 def dynamic_susceptibility(self): 

428 """Get the macroscopic components of χ(q,ω) and χ₀(q,ω).""" 

429 vchi0_W, vchi_W = self.macroscopic_components() 

430 v0 = self.v_G[0] # Macroscopic Coulomb potential (4π/q²) 

431 return ScalarResponseFunctionSet(self.wd, vchi0_W / v0, vchi_W / v0) 

432 

433 

434@dataclass 

435class CustomizableDielectricFunction(DielectricFunctionData): 

436 """Data class for customized dielectric functions Ε(q,ω). 

437 

438 Ε(q,ω) is customizable in the sense that bare Coulomb interaction v(q) is 

439 replaced by an arbitrary interaction V(q) in the formula for ε(q,ω), 

440 

441 Ε(q,ω) = 1 - V(q) P(q,ω), 

442 

443 where P is the polarizability operator [Rev. Mod. Phys. 74, 601 (2002)]. 

444 Thus, for any truncated or otherwise cusomized interaction V(q) ≠ v(q), 

445 Ε(q,ω) ≠ ε(q,ω) and Ε⁻¹(q,ω) ≠ ε⁻¹(q,ω). 

446 """ 

447 eps_wGG: np.ndarray 

448 

449 def macroscopic_customized_dielectric_function(self): 

450 """Get the macroscopic customized dielectric function Ε_M(q,ω). 

451 

452 We define the macroscopic customized dielectric function as 

453 

454 1 

455 Ε (q,ω) = ‾‾‾‾‾‾‾‾, 

456 M Ε⁻¹(q,ω) 

457 00 

458 

459 and calculate also the macroscopic dielectric function in the 

460 customizable independent-particle approximation: 

461 

462 cIP 

463 ε (q,ω) = 1 - VP (q,ω) = Ε (q,ω). 

464 M 00 00 

465 """ 

466 eps0_W = self._macroscopic_component(self.eps_wGG) 

467 # Invert Ε(q,ω) one frequency at a time to compute Ε_M(q,ω) 

468 eps_w = np.zeros((self.wblocks.nlocal,), complex) 

469 for w, eps_GG in enumerate(self.eps_wGG): 

470 eps_w[w] = 1 / np.linalg.inv(eps_GG)[0, 0] 

471 eps_W = self.wblocks.all_gather(eps_w) 

472 return ScalarResponseFunctionSet(self.wd, eps0_W, eps_W) 

473 

474 def macroscopic_dielectric_function(self): 

475 """Get the macroscopic dielectric function ε_M(q,ω). 

476 

477 In the special case V(q) = v(q), Ε_M(q,ω) = ε_M(q,ω). 

478 """ 

479 if self.coulomb is not self.bare_coulomb: 

480 raise ValueError( 

481 'The macroscopic dielectric function is defined in terms of ' 

482 'the bare Coulomb interaction. To truncate the Hartree ' 

483 'electron-electron correlations, please calculate the inverse ' 

484 'dielectric function instead.') 

485 return self.macroscopic_customized_dielectric_function() 

486 

487 

488@dataclass 

489class BareDielectricFunction(DielectricFunctionData): 

490 """Data class for the bare (unscreened) dielectric function. 

491 

492 The bare dielectric function is defined in terms of the unscreened 

493 susceptibility, 

494 ˍ ˍ 

495 ϵ(q,ω) = 1 - v(q) χ(q,ω), 

496 ˍ 

497 here represented in terms of v^(1/2)(q) χ(q,ω) v^(1/2)(q). 

498 """ 

499 vP_symm_wGG: np.ndarray # v^(1/2) P v^(1/2) 

500 vchibar_symm_wGG: np.ndarray 

501 

502 def macroscopic_components(self): 

503 vP_W = self._macroscopic_component(self.vP_symm_wGG) 

504 vchibar_W = self._macroscopic_component(self.vchibar_symm_wGG) 

505 return vP_W, vchibar_W 

506 

507 def macroscopic_bare_dielectric_function(self): 

508 """Get the macroscopic bare dielectric function ϵ_M(q,ω). 

509 

510 Calculates 

511 ˍ 

512 ϵ_M(q,ω) = ϵ (q,ω) 

513 00 

514 

515 along with the macroscopic dielectric function in the independent- 

516 particle approximation: 

517 

518 IP 

519 ε (q,ω) = 1 - vP (q,ω). 

520 M 00 

521 """ 

522 vP_W, vchibar_W = self.macroscopic_components() 

523 eps0_W = 1. - vP_W 

524 eps_W = 1. - vchibar_W 

525 return ScalarResponseFunctionSet(self.wd, eps0_W, eps_W) 

526 

527 def macroscopic_dielectric_function(self): 

528 """Get the macroscopic dielectric function ε_M(q,ω). 

529 

530 In the special case where the unscreened susceptibility is calculated 

531 using the bare Coulomb interaction, 

532 ˍ ˍ ˍ 

533 χ(q,ω) = P(q,ω) + P(q,ω) v(q) χ(q,ω), 

534 

535 it holds identically that [Rev. Mod. Phys. 74, 601 (2002)]: 

536 

537 ε_M(q,ω) = ϵ_M(q,ω). 

538 """ 

539 if self.coulomb is not self.bare_coulomb: 

540 raise ValueError( 

541 'The macroscopic dielectric function cannot be obtained from ' 

542 'the bare dielectric function calculated based on a truncated ' 

543 'Coulomb interaction. Please calculate the inverse dielectric ' 

544 'function instead') 

545 return self.macroscopic_bare_dielectric_function() 

546 

547 def polarizability(self): 

548 """Get the macroscopic polarizability α_M(q,ω). 

549 

550 In the most general case, the electronic polarizability can be defined 

551 in terms of the unscreened susceptibility 

552 ˍ ˍ 

553 α(q,ω) ≡ - 1/(4π) v(q) χ(q,ω) = 1/(4π) (ϵ(q,ω) - 1). 

554 

555 This is especially useful in systems of reduced dimensionality, where 

556 χ(q,ω) needs to be calculated using a truncated Coulomb kernel in order 

557 to achieve convergence in a feasible way. 

558 """ 

559 _, eps0_W, eps_W = self.macroscopic_bare_dielectric_function().arrays 

560 return self._polarizability(eps0_W, eps_W) 

561 

562 

563class DielectricFunctionCalculator: 

564 def __init__(self, chi0calc: Chi0Calculator): 

565 self.chi0calc = chi0calc 

566 self.gs = chi0calc.gs 

567 self.context = chi0calc.context 

568 

569 self._chi0cache: dict[tuple[str, ...], Chi0Data] = {} 

570 

571 def get_chi0(self, q_c: list | np.ndarray) -> Chi0Data: 

572 """Get the Kohn-Sham susceptibility χ₀(q,ω) for input wave vector q. 

573 

574 Keeps a cache of χ₀ for the latest calculated wave vector, thus 

575 allowing for investigation of multiple dielectric properties, 

576 Coulomb truncations, xc kernels etc. without recalculating χ₀. 

577 """ 

578 # As cache key, we round off and use a string representation. 

579 # Not very elegant, but it should work almost always. 

580 q_key = [f'{q:.10f}' for q in q_c] 

581 key = tuple(q_key) 

582 if key not in self._chi0cache: 

583 self._chi0cache.clear() 

584 self._chi0cache[key] = self.chi0calc.calculate(q_c) 

585 self.context.write_timer() 

586 return self._chi0cache[key] 

587 

588 def get_chi0_dyson_eqs(self, 

589 q_c: list | np.ndarray = [0, 0, 0], 

590 truncation: str | None = None, 

591 xc: str = 'RPA', 

592 **xckwargs 

593 ) -> Chi0DysonEquations: 

594 """Set up the Dyson equation for χ(q,ω) at given wave vector q. 

595 

596 Parameters 

597 ---------- 

598 truncation : str or None 

599 Truncation of the Hartree kernel. 

600 xc : str 

601 Exchange-correlation kernel for LR-TDDFT calculations. 

602 If xc == 'RPA', the dielectric response is treated in the random 

603 phase approximation. 

604 **xckwargs 

605 Additional parameters for the chosen xc kernel. 

606 """ 

607 chi0 = self.get_chi0(q_c) 

608 coulomb = CoulombKernel.from_gs(self.gs, truncation=truncation) 

609 if xc == 'RPA': 

610 xc_kernel = None 

611 else: 

612 xc_kernel = DensityXCKernel.from_functional( 

613 self.gs, self.context, functional=xc, **xckwargs) 

614 return Chi0DysonEquations(chi0, coulomb, xc_kernel, self.gs.cd) 

615 

616 def get_bare_dielectric_function(self, direction='x', **kwargs 

617 ) -> BareDielectricFunction: 

618 """Calculate the bare dielectric function ̄ϵ(q,ω) = 1 - v(q) ̄χ(q,ω). 

619 

620 Here v(q) is the bare Coulomb potential while ̄χ is the unscreened 

621 susceptibility calculated based on the modified (and possibly 

622 truncated) Coulomb potential. 

623 """ 

624 return self.get_chi0_dyson_eqs( 

625 **kwargs).bare_dielectric_function(direction=direction) 

626 

627 def get_literal_dielectric_function(self, direction='x', **kwargs 

628 ) -> CustomizableDielectricFunction: 

629 """Calculate the dielectric function ε(q,ω) = 1 - v(q) P(q,ω).""" 

630 return self.get_chi0_dyson_eqs( 

631 truncation=None, **kwargs).customized_dielectric_function( 

632 direction=direction) 

633 

634 def get_customized_dielectric_function(self, direction='x', *, 

635 truncation: str, **kwargs 

636 ) -> CustomizableDielectricFunction: 

637 """Calculate the customized dielectric function Ε(q,ω) = 1 -V(q)P(q,ω). 

638 

639 In comparison with the literal dielectric function, the bare Coulomb 

640 interaction has here been replaced with a truncated analogue v(q)→V(q). 

641 """ 

642 return self.get_chi0_dyson_eqs( 

643 truncation=truncation, **kwargs).customized_dielectric_function( 

644 direction=direction) 

645 

646 def get_inverse_dielectric_function(self, direction='x', **kwargs 

647 ) -> InverseDielectricFunction: 

648 """Calculate the inverse dielectric function ε⁻¹(q,ω) = v(q) χ(q,ω). 

649 """ 

650 return self.get_chi0_dyson_eqs( 

651 **kwargs).inverse_dielectric_function(direction=direction) 

652 

653 

654class DielectricFunction(DielectricFunctionCalculator): 

655 """This class defines dielectric function related physical quantities.""" 

656 

657 def __init__(self, calc, *, 

658 frequencies=None, 

659 ecut=50, 

660 hilbert=True, 

661 nbands=None, eta=0.2, 

662 intraband=True, nblocks=1, world=mpi.world, txt=sys.stdout, 

663 truncation=None, 

664 qsymmetry=True, 

665 integrationmode='point integration', rate=0.0, 

666 eshift: float | None = None): 

667 """Creates a DielectricFunction object. 

668 

669 calc: str 

670 The ground-state calculation file that the linear response 

671 calculation is based on. 

672 frequencies: 

673 Input parameters for frequency_grid. 

674 Can be an array of frequencies to evaluate the response function at 

675 or dictionary of parameters for build-in nonlinear grid 

676 (see :ref:`frequency grid`). 

677 ecut: float | dict 

678 Plane-wave cut-off or dictionary for anoptional planewave 

679 descriptor. See response/qpd.py for details. 

680 hilbert: bool 

681 Use hilbert transform. 

682 nbands: int 

683 Number of bands from calculation. 

684 eta: float 

685 Broadening parameter. 

686 intraband: bool 

687 Include intraband transitions. 

688 world: comm 

689 mpi communicator. 

690 nblocks: int 

691 Split matrices in nblocks blocks and distribute them G-vectors or 

692 frequencies over processes. 

693 txt: str 

694 Output file. 

695 truncation: str or None 

696 None for no truncation. 

697 '2D' for standard analytical truncation scheme. 

698 Non-periodic directions are determined from k-point grid 

699 integrationmode: str 

700 if == 'tetrahedron integration' then tetrahedron 

701 integration is performed 

702 if == 'point integration' then point integration is used 

703 eshift: float 

704 Shift unoccupied bands 

705 """ 

706 gs, context = get_gs_and_context(calc, txt, world, timer=None) 

707 wd = get_frequency_descriptor(frequencies, gs=gs, nbands=nbands) 

708 

709 if integrationmode is None: 

710 raise DeprecationWarning( 

711 "Please use `integrationmode='point integration'` instead") 

712 integrationmode = 'point integration' 

713 

714 chi0calc = Chi0Calculator( 

715 gs, context, nblocks=nblocks, 

716 wd=wd, 

717 ecut=ecut, nbands=nbands, eta=eta, 

718 hilbert=hilbert, 

719 intraband=intraband, 

720 qsymmetry=qsymmetry, 

721 integrationmode=integrationmode, 

722 rate=rate, eshift=eshift 

723 ) 

724 super().__init__(chi0calc) 

725 self.truncation = truncation 

726 

727 def get_frequencies(self) -> np.ndarray: 

728 """Return frequencies (in eV) that the χ is evaluated on.""" 

729 return self.chi0calc.wd.omega_w * Hartree 

730 

731 def get_dynamic_susceptibility(self, *args, xc='ALDA', 

732 filename='chiM_w.csv', 

733 **kwargs): 

734 dynsus = self.get_inverse_dielectric_function( 

735 *args, xc=xc, truncation=self.truncation, 

736 **kwargs).dynamic_susceptibility() 

737 if filename: 

738 dynsus.write(filename) 

739 return dynsus.unpack() 

740 

741 def get_dielectric_function(self, *args, filename='df.csv', **kwargs): 

742 """Calculate the dielectric function. 

743 

744 Generates a file 'df.csv', unless filename is set to None. 

745 

746 Returns 

747 ------- 

748 df_NLFC_w: np.ndarray 

749 Dielectric function without local field corrections. 

750 df_LFC_w: np.ndarray 

751 Dielectric function with local field corrections. 

752 """ 

753 df = self.get_inverse_dielectric_function( 

754 *args, truncation=self.truncation, 

755 **kwargs).macroscopic_dielectric_function() 

756 if filename: 

757 df.write(filename) 

758 return df.unpack() 

759 

760 def get_eels_spectrum(self, *args, filename='eels.csv', **kwargs): 

761 """Calculate the macroscopic EELS spectrum. 

762 

763 Generates a file 'eels.csv', unless filename is set to None. 

764 

765 Returns 

766 ------- 

767 eels0_w: np.ndarray 

768 Spectrum in the independent-particle random-phase approximation. 

769 eels_w: np.ndarray 

770 Fully screened EELS spectrum. 

771 """ 

772 eels = self.get_inverse_dielectric_function( 

773 *args, truncation=self.truncation, **kwargs).eels_spectrum() 

774 if filename: 

775 eels.write(filename) 

776 return eels.unpack() 

777 

778 def get_polarizability(self, q_c: list | np.ndarray = [0, 0, 0], 

779 direction='x', filename='polarizability.csv', 

780 **xckwargs): 

781 """Calculate the macroscopic polarizability. 

782 

783 Generate a file 'polarizability.csv', unless filename is set to None. 

784 

785 Returns: 

786 -------- 

787 alpha0_w: np.ndarray 

788 Polarizability calculated without local-field corrections 

789 alpha_w: np.ndarray 

790 Polarizability calculated with local-field corrections. 

791 """ 

792 chi0_dyson_eqs = self.get_chi0_dyson_eqs( 

793 q_c, truncation=self.truncation, **xckwargs) 

794 if self.truncation: 

795 # eps: BareDielectricFunction 

796 method = chi0_dyson_eqs.bare_dielectric_function 

797 else: 

798 # eps: CustomizableDielectricFunction 

799 method = chi0_dyson_eqs.customized_dielectric_function 

800 eps = method(direction=direction) 

801 pol = eps.polarizability() 

802 if filename: 

803 pol.write(filename) 

804 return pol.unpack() 

805 

806 def get_macroscopic_dielectric_constant(self, xc='RPA', direction='x'): 

807 """Calculate the macroscopic dielectric constant. 

808 

809 The macroscopic dielectric constant is defined as the real part of the 

810 dielectric function in the static limit. 

811 

812 Returns: 

813 -------- 

814 eps0: float 

815 Dielectric constant without local field corrections. 

816 eps: float 

817 Dielectric constant with local field correction. (RPA, ALDA) 

818 """ 

819 return self.get_inverse_dielectric_function( 

820 xc=xc, direction=direction).dielectric_constant() 

821 

822 

823# ----- Serialized dataclasses and IO ----- # 

824 

825 

826@dataclass 

827class ScalarResponseFunctionSet: 

828 """A set of scalar response functions rf₀(ω) and rf(ω).""" 

829 wd: FrequencyDescriptor 

830 rf0_w: np.ndarray 

831 rf_w: np.ndarray 

832 

833 @property 

834 def arrays(self): 

835 return self.wd.omega_w * Hartree, self.rf0_w, self.rf_w 

836 

837 def unpack(self): 

838 # Legacy feature to support old DielectricFunction output format 

839 # ... to be deprecated ... 

840 return self.rf0_w, self.rf_w 

841 

842 def write(self, filename): 

843 if mpi.rank == 0: 

844 write_response_function(filename, *self.arrays) 

845 

846 @property 

847 def static_limit(self): 

848 """Return the value of the response functions in the static limit.""" 

849 w0 = np.argmin(np.abs(self.wd.omega_w)) 

850 assert abs(self.wd.omega_w[w0]) < 1e-8 

851 return np.array([self.rf0_w[w0], self.rf_w[w0]]) 

852 

853 

854def write_response_function(filename, omega_w, rf0_w, rf_w): 

855 with open(filename, 'w') as fd: 

856 for omega, rf0, rf in zip(omega_w, rf0_w, rf_w): 

857 if rf0_w.dtype == complex: 

858 print('%.6f, %.6f, %.6f, %.6f, %.6f' % 

859 (omega, rf0.real, rf0.imag, rf.real, rf.imag), 

860 file=fd) 

861 else: 

862 print(f'{omega:.6f}, {rf0:.6f}, {rf:.6f}', file=fd) 

863 

864 

865def read_response_function(filename): 

866 """Read a stored response function file""" 

867 d = np.loadtxt(filename, delimiter=',') 

868 omega_w = np.array(d[:, 0], float) 

869 

870 if d.shape[1] == 3: 

871 # Real response function 

872 rf0_w = np.array(d[:, 1], float) 

873 rf_w = np.array(d[:, 2], float) 

874 elif d.shape[1] == 5: 

875 rf0_w = np.array(d[:, 1], complex) 

876 rf0_w.imag = d[:, 2] 

877 rf_w = np.array(d[:, 3], complex) 

878 rf_w.imag = d[:, 4] 

879 else: 

880 raise ValueError(f'Unexpected array dimension {d.shape}') 

881 

882 return omega_w, rf0_w, rf_w