Coverage for rhodent/calculators/energy.py: 92%

143 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-08-01 16:57 +0000

1from __future__ import annotations 

2 

3from collections.abc import Sequence 

4import numpy as np 

5from typing import Any, Collection, Generator 

6from ase.units import Hartree, Bohr 

7from gpaw.tddft.units import as_to_au, au_to_eV, au_to_eA, eV_to_au 

8 

9from .base import BaseObservableCalculator 

10from ..density_matrices.base import WorkMetadata 

11from ..density_matrices.time import ConvolutionDensityMatrixMetadata 

12from ..density_matrices.distributed.pulse import PulsePerturbation 

13from ..perturbation import PerturbationLike 

14from ..response import BaseResponse 

15from ..voronoi import VoronoiWeights 

16from ..typing import Array1D 

17from ..utils import ResultKeys, Result, broaden_n2e 

18 

19 

20class EnergyCalculator(BaseObservableCalculator): 

21 

22 r""" Calculate energy contributions in the time domain. 

23 

24 The total energy can be written 

25 

26 .. math:: 

27 

28 E_\text{tot}(t) = E^{(0)}_\text{tot} + \sum_{ia}^\text{eh} E_{ia}(t) + E_\text{pulse}(t). 

29 

30 The contributions to the total energy are 

31 

32 .. math:: 

33 

34 E_{ia} = \frac{1}{2} \left[ 

35 p_{ia}\dot{q}_{ia} - q_{ia} \dot{p}_{ia} - v_{ia} q_{ia} \right], 

36 

37 the contributions to the Hartree-xc energy are 

38 

39 .. math:: 

40 

41 E_{ia}^\text{Hxc} = -\frac{1}{2} \left[ 

42 \omega_{ia} q_{ia}^2 - q_{ia} \dot{p}_{ia} - v_{ia} q_{ia} \right], 

43 

44 and the rate of energy change is 

45 

46 .. math:: 

47 

48 \dot{E}_{ia} = \frac{1}{2} \left[ 

49 p_{ia}\ddot{q}_{ia} - q_{ia} \ddot{p}_{ia} 

50 - v_{ia} \dot{q}_{ia} - \dot{v}_{ia} q_{ia} \right]. 

51 

52 The matrix element :math:`v_{ia}` can be computed from the dipole matrix element 

53 

54 .. math:: 

55 

56 \boldsymbol{\mu}_{ia} = \int \psi^{(0)}_i(\boldsymbol{r}) \boldsymbol{r} 

57 \psi^{(0)}_a(\boldsymbol{r}) \mathrm{d}\boldsymbol{r} 

58 

59 projected on the direction of the perturbation :math:`\hat{\boldsymbol{e}}`, 

60 the occupation number difference :math:`f_{ia}` and 

61 the pulse amplitude :math:`v_\text{pulse}(t)` 

62 

63 .. math:: 

64 

65 v_{ia} = \sqrt{2 f_{ia}} 

66 \boldsymbol{\mu}_{ia} \cdot\hat{\boldsymbol{e}} 

67 v_\text{pulse}. 

68 

69 

70 Parameters 

71 ---------- 

72 response 

73 Response object. 

74 voronoi 

75 Voronoi weights object. 

76 filter_pair 

77 Filter electron-hole pairs (occupied-unoccupied transitions) in summation of energies. 

78 Provide a tuples (low, high) to compute the sum of energies 

79 :math:`E_{ia}` and :math:`E_{ia}^\text{Hxc}` for pairs with transition energy 

80 :math:`\varepsilon_a-\varepsilon_{i}` in the interval low-high (in units of eV). 

81 energies_occ 

82 Energy grid in units of eV for occupied levels (holes). 

83 energies_unocc 

84 Energy grid in units of eV for unoccupied levels (electrons). 

85 sigma 

86 Gaussian broadening width for energy grid in units of eV. 

87 times 

88 Compute energies in the time domain, for these times (or as close to them as possible). 

89 In units of as. 

90 pulses 

91 Compute energies in the time domain, in response to these pulses. 

92 If none, then no pulse convolution is performed. 

93 """ 

94 def __init__(self, 

95 response: BaseResponse, 

96 voronoi: VoronoiWeights | None = None, 

97 *, 

98 filter_pair: tuple[float, float] = (0, np.inf), 

99 energies_occ: list[float] | Array1D[np.float64] | None = None, 

100 energies_unocc: list[float] | Array1D[np.float64] | None = None, 

101 sigma: float | None = None, 

102 times: list[float] | Array1D[np.float64] | None = None, 

103 pulses: Collection[PerturbationLike] | None = None, 

104 ): 

105 super().__init__(response=response, 

106 voronoi=voronoi, 

107 energies_occ=energies_occ, 

108 energies_unocc=energies_unocc, 

109 sigma=sigma, 

110 times=times, 

111 pulses=pulses) 

112 if len(filter_pair) != 2: 

113 raise ValueError('filter_pair must be tuple of two floats') 

114 self._filter_pair_low = float(filter_pair[0]) * eV_to_au 

115 self._filter_pair_high = float(filter_pair[1]) * eV_to_au 

116 

117 def get_result_keys(self, 

118 yield_total_E_ia: bool = False, 

119 yield_proj_E_ia: bool = False, 

120 yield_total_E_ou: bool = False, 

121 yield_total_dists: bool = False, 

122 direction: int | Sequence[int] = 2, 

123 ) -> ResultKeys: 

124 r""" Get the keys that each result will contain, and dimensions thereof. 

125 

126 Parameters 

127 ---------- 

128 yield_total_E_ia 

129 The results should include the contributions in the electron-hole basis to 

130 the total energy :math:`E_{ia}` and Hartree-xc energy :math:`E_{ia}^\text{Hxc}` 

131 yield_proj_E_ia 

132 The results should include the contributions in the electron-hole basis to 

133 the total energy projected on the occupied and unoccupied Voronoi weights 

134 :math:`E_{ia} w_i` and :math:`E_{ia} w_a`. 

135 yield_total_E_ou 

136 The results should include the contributions the total energy broadened on the 

137 occupied and unoccupied energy grids 

138 :math:`\sum_{ia} E_{ia}\delta(\varepsilon_\text{occ}-\varepsilon_{i}) 

139 \delta(\varepsilon_\text{unocc}-\varepsilon_{a})` and 

140 yield_total_dists 

141 The results should include the contributions the total energy and Hartree-xc energy 

142 broadened by electronic transition energy onto the unoccupied energies grid 

143 :math:`\sum_{ia} E_{ia} \delta(\varepsilon-\omega_{ia})` and 

144 :math:`\sum_{ia} E_{ia}^\text{Hxc} \delta(\varepsilon-\omega_{ia})` 

145 direction 

146 Direction :math:`\hat{\boldsymbol{e}}` of the polarization of the 

147 pulse. Integer 0, 1 or 2 to specify x, y or z, or the direction vector specified as 

148 a list of three values. Default: polarization along z. 

149 """ 

150 nI = self.nproj 

151 imin, imax, amin, amax = self.ksd.ialims() 

152 ni = int(imax - imin + 1) 

153 na = int(amax - amin + 1) 

154 no = len(self.energies_occ) 

155 nu = len(self.energies_unocc) 

156 

157 assert direction in [0, 1, 2] or (isinstance(direction, Sequence) and len(direction) == 3) 

158 

159 resultkeys = ResultKeys('dm', 'total', 'total_Hxc', 'field', 

160 'total_resonant', 'total_resonant_Hxc', 'Epulse') 

161 

162 if yield_total_E_ia: 

163 resultkeys.add_key('E_ia', (ni, na)) 

164 resultkeys.add_key('Ec_ia', (ni, na)) 

165 if yield_total_dists: 

166 resultkeys.add_key('E_transition_u', nu) 

167 resultkeys.add_key('Ec_transition_u', nu) 

168 if yield_total_E_ou: 

169 resultkeys.add_key('E_ou', (no, nu)) 

170 resultkeys.add_key('Ec_ou', (no, nu)) 

171 

172 resultkeys.add_key('total_proj_II', (nI, nI)) 

173 resultkeys.add_key('total_Hxc_proj_II', (nI, nI)) 

174 if yield_proj_E_ia: 

175 resultkeys.add_key('E_occ_proj_Iia', (nI, ni, na)) 

176 resultkeys.add_key('E_unocc_proj_Iia', (nI, ni, na)) 

177 

178 return resultkeys 

179 

180 def icalculate(self, 

181 yield_total_E_ia: bool = False, 

182 yield_proj_E_ia: bool = False, 

183 yield_total_E_ou: bool = False, 

184 yield_total_dists: bool = False, 

185 direction: int | Sequence[int] = 2, 

186 ) -> Generator[tuple[WorkMetadata, Result], None, None]: 

187 """ Iteratively calculate energies. 

188 

189 Parameters 

190 ---------- 

191 yield_total_E_ia 

192 The results should include the contributions in the electron-hole basis to 

193 the total energy :math:`E_{ia}` and Hartree-xc energy :math:`E_{ia}^\\text{Hxc}` 

194 yield_proj_E_ia 

195 The results should include the contributions in the electron-hole basis to 

196 the total energy projected on the occupied and unoccupied Voronoi weights 

197 :math:`E_{ia} w_i` and :math:`E_{ia} w_a`. 

198 yield_total_E_ou 

199 The results should include the contributions the total energy broadened on the 

200 occupied and unoccupied energy grids 

201 :math:`\\sum_{ia} E_{ia}\\delta(\\varepsilon_\\text{occ}-\\varepsilon_{i}) 

202 \\delta(\\varepsilon_\\text{unocc}-\\varepsilon_{a})` and 

203 yield_total_dists 

204 The results should include the contributions the total energy and Hartree-xc energy 

205 broadened by electronic transition energy onto the unoccupied energies grid 

206 :math:`\\sum_{ia} E_{ia} \\delta(\\varepsilon-\\omega_{ia})` and 

207 :math:`\\sum_{ia} E_{ia}^\\text{Hxc} \\delta(\\varepsilon-\\omega_{ia})` 

208 direction 

209 Direction :math:`\\hat{\\boldsymbol{e}}` of the polarization of the 

210 pulse. Integer 0, 1 or 2 to specify x, y or z, or the direction vector specified as 

211 a list of three values. Default: polarization along z. 

212 

213 Yields 

214 ------ 

215 Tuple (work, result) on the root rank of the calculation communicator. \ 

216 Does not yield on non-root ranks of the calculation communicator. 

217 

218 work 

219 An object representing the metadata (time or pulse) for the work done. 

220 result 

221 Object containg the calculation results for this time and pulse. 

222 """ 

223 include_energy_dists = (yield_total_dists or yield_total_E_ou) 

224 if include_energy_dists: 

225 assert self.sigma is not None 

226 

227 assert direction in [0, 1, 2] or (isinstance(direction, Sequence) and len(direction) == 3) 

228 direction_v = np.zeros(3) 

229 if direction in [0, 1, 2]: 

230 direction_v[direction] = 1 

231 else: 

232 direction_v[:] = direction 

233 direction_v /= np.linalg.norm(direction_v) 

234 

235 dm_p = direction_v @ self.ksd.dm_vp 

236 v0_p = dm_p * np.sqrt(2 * self.ksd.f_p) 

237 dm_ia = self.ksd.M_ia_from_M_p(dm_p) 

238 v0_ia = self.ksd.M_ia_from_M_p(v0_p) 

239 w_ia = self.ksd.M_ia_from_M_p(self.ksd.w_p) 

240 

241 # List all keys that this method computes 

242 # This is necessary to safely send and receive data across ranks 

243 resultkeys = self.get_result_keys(yield_total_dists=yield_total_dists, 

244 yield_total_E_ia=yield_total_E_ia, 

245 yield_proj_E_ia=yield_proj_E_ia, 

246 yield_total_E_ou=yield_total_E_ou, 

247 ) 

248 

249 self._read_weights_diagonal() 

250 

251 # Iterate over the pulses and times 

252 for work, dm in self.density_matrices: 

253 if dm.rank > 0: 

254 continue 

255 self.log.start('calculate') 

256 

257 assert isinstance(work, ConvolutionDensityMatrixMetadata) 

258 assert isinstance(work.pulse, PulsePerturbation) 

259 pulsestr = work.pulse.pulse.strength(work.time * as_to_au) 

260 

261 dipmom = - 2 * np.sum(dm_ia * dm.rho_ia.real) 

262 resonant_filter_ia = (w_ia > self._filter_pair_low) & (w_ia < self._filter_pair_high) 

263 

264 Epulse = dipmom * pulsestr * au_to_eV 

265 

266 # Calculate v_ia 

267 v_ia = v0_ia * pulsestr 

268 

269 E_ia = -v_ia * dm.Q_ia 

270 E_ia -= dm.Q_ia * dm.dP_ia 

271 

272 Ec_ia = E_ia.copy() 

273 

274 E_ia += dm.P_ia * dm.dQ_ia 

275 E_ia *= 0.5 * au_to_eV 

276 

277 Ec_ia -= w_ia * dm.Q_ia ** 2 

278 Ec_ia *= 0.5 * au_to_eV 

279 

280 result = Result() 

281 if yield_total_E_ia: 

282 result['E_ia'] = E_ia 

283 result['Ec_ia'] = Ec_ia 

284 

285 result['dm'] = dipmom * au_to_eA 

286 result['Epulse'] = Epulse 

287 result['field'] = pulsestr * Hartree / Bohr # V/Å 

288 result['total'] = np.sum(E_ia) 

289 result['total_Hxc'] = np.sum(Ec_ia) 

290 result['total_resonant'] = E_ia.ravel() @ resonant_filter_ia.ravel() 

291 result['total_resonant_Hxc'] = Ec_ia.ravel() @ resonant_filter_ia.ravel() 

292 

293 # (Optional) Broaden transitions by transition energy 

294 if yield_total_dists: 

295 assert self.sigma is not None 

296 result['E_transition_u'] = broaden_n2e(E_ia.ravel(), w_ia.ravel() * au_to_eV, 

297 self.energies_unocc, self.sigma) 

298 result['Ec_transition_u'] = broaden_n2e(Ec_ia.ravel(), w_ia.ravel() * au_to_eV, 

299 self.energies_unocc, self.sigma) 

300 

301 # (Optional) Compute energy contribution matrix 

302 if yield_total_E_ou: 

303 result['E_ou'] = self.broaden_ia2ou(E_ia) 

304 result['Ec_ou'] = self.broaden_ia2ou(Ec_ia) 

305 

306 # Initialize the remaining empty arrays 

307 result.create_all_empty(resultkeys) 

308 

309 # Iterate over projections 

310 for iI, weight_n in enumerate(self._iterate_weights_diagonal): 

311 assert weight_n is not None 

312 weight_i = weight_n[self.flti] 

313 weight_a = weight_n[self.flta] 

314 

315 for iI2, weight2_n in enumerate(self._iterate_weights_diagonal): 

316 assert weight2_n is not None 

317 weight2_a = weight2_n[self.flta] 

318 

319 result['total_proj_II'][iI, iI2] = np.einsum( 

320 'ia,i,a->', E_ia, weight_i, weight2_a, optimize=True) 

321 result['total_Hxc_proj_II'][iI, iI2] = np.einsum( 

322 'ia,i,a->', Ec_ia, weight_i, weight2_a, optimize=True) 

323 

324 if yield_proj_E_ia: 

325 E_occ_proj_ia = E_ia * weight_i[:, None] 

326 E_unocc_proj_ia = E_ia * weight_a[None, :] 

327 

328 result['E_occ_proj_Iia'][iI] = E_occ_proj_ia 

329 result['E_unocc_proj_Iia'][iI] = E_unocc_proj_ia 

330 

331 self.log_parallel(f'Calculated and broadened energies in {self.log.elapsed("calculate"):.2f}s ' 

332 f'for {work.desc}', flush=True) 

333 

334 yield work, result 

335 

336 if self.calc_comm.rank == 0: 

337 self.log_parallel('Finished calculating energies', flush=True) 

338 

339 @property 

340 def _need_derivatives_real_imag(self) -> tuple[list[int], bool, bool]: 

341 return ([0, 1], True, True) 

342 

343 @property 

344 def _voronoi_shapes(self) -> dict[str, tuple[int, ...]]: 

345 nI = self.voronoi.nproj 

346 if nI == 0: 

347 return {} 

348 Nn = self.voronoi.nn 

349 # Diagonal weights only 

350 return {'diagonal': (nI, Nn)} 

351 

352 def calculate_and_write(self, 

353 out_fname: str, 

354 write_extra: dict[str, Any] = dict(), 

355 include_tcm: bool = False, 

356 save_dist: bool = False, 

357 only_one_pulse: bool = True): 

358 """ Calculate energy contributions. 

359 

360 Energies are saved in a numpy archive if the file extension is `.npz` 

361 or in an ULM file if the file extension is `.ulm`. 

362 

363 Parameters 

364 ---------- 

365 out_fname 

366 File name of the resulting data file. 

367 write_extra 

368 Dictionary of extra key-value pairs to write to the data file. 

369 include_tcm 

370 Whether the transition contribution map (TCM) to the energies should be computed and saved. 

371 save_dist 

372 Whether the transition energy distributions should be computed and saved. 

373 only_one_pulse 

374 If False, group arrays by pulse. 

375 """ 

376 from ..writers.energy import EnergyWriter 

377 from ..writers.writer import TimeResultsCollector, PulseConvolutionResultsCollector 

378 

379 out_fname = str(out_fname) 

380 if out_fname[-4:] == '.npz': 

381 calc_kwargs = dict(yield_total_E_ou=include_tcm, yield_total_dists=save_dist) 

382 cls = TimeResultsCollector if only_one_pulse else PulseConvolutionResultsCollector 

383 writer = EnergyWriter(cls(self, calc_kwargs), only_one_pulse=only_one_pulse) 

384 writer.calculate_and_save_npz(out_fname, write_extra=write_extra) 

385 elif out_fname[-4:] == '.ulm': 

386 assert only_one_pulse 

387 calc_kwargs = dict(yield_total_E_ou=include_tcm, yield_total_dists=save_dist) 

388 writer = EnergyWriter(TimeResultsCollector(self, calc_kwargs, exclude=['E_ou']), only_one_pulse=True) 

389 writer.calculate_and_save_ulm(out_fname, write_extra=write_extra) 

390 else: 

391 raise ValueError(f'output-file must have ending .npz or .ulm, is {out_fname}')