Coverage for calorine/tools/spectra.py: 99%

162 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-18 13:01 +0000

1r""" 

2Tools for computing optical and vibrational spectra from MD trajectories. 

3 

4Covers three routes depending on the model and GPUMD output: 

5 

6* **Dielectric tensor** (:func:`get_dielectric_function`): full 3×3 tensor 

7 from ``dpdt.out`` (qNEP, ``column='dP'`` or ``column='P'``) or ``dipole.out`` 

8 (TNEP, ``column='mu'``). 

9* **IR spectrum** (:func:`get_ir_spectrum`): from ``dpdt.out`` 

10 (qNEP, ``column='dP'`` or ``column='P'``) or ``dipole.out`` 

11 (TNEP, ``column='mu'``). 

12* **Raman spectrum** (:func:`get_raman_spectrum`): from ``polarizability.out`` 

13 (TNEP polarizability/susceptibility model). 

14 

15Nomenclature 

16------------ 

17- **Molecules / nanoparticles (localized systems)**: dipole moment :math:`\mu` 

18 (e·Å) and polarizability :math:`\alpha` (bohr³ or training-data units). 

19- **Solids / liquids (extended systems)**: polarization :math:`P` (e·Å, total 

20 cell dipole) and susceptibility :math:`\chi` (same unit as training data per 

21 cell). 

22 

23Both cases use the same GPUMD output files and formulas; the distinction is 

24whether :attr:`volume` is supplied for normalization. 

25 

26Quantum corrections 

27------------------- 

28The spectra from classical MD can be corrected for quantum statistics via 

29:func:`apply_quantum_correction`. The correction factors differ by scattering 

30order (M. Cardona, *Topics in Applied Physics*, Vol. 50 (Springer, 1982); 

31Rosander *et al.*, PRB **111**, 064107 (2025)). 

32""" 

33 

34import numpy as np 

35from pandas import DataFrame 

36from ase.units import _k as kB_SI, _e as e_SI, _eps0 as eps0_SI, _c as c_SI, _hbar as hbar_SI 

37 

38_rad_s_to_thz = 1e-12 

39_rad_s_to_invcm = 1 / (2 * np.pi * c_SI * 100) 

40 

41 

42def _compute_correlation_function(Z1, Z2): 

43 """Normalized cross-correlation; normalization divides by the number of contributing pairs.""" 

44 N = len(Z1) 

45 return np.correlate(Z1, Z2, mode='full')[N - 1:] / np.arange(N, 0, -1) 

46 

47 

48def _gaussian_decay(t, t_sigma): 

49 r"""Gaussian envelope :math:`\exp(-t^2 / 2\sigma^2)` used as a window applied to the ACF.""" 

50 return np.exp(-0.5 * (t / t_sigma) ** 2) 

51 

52 

53def _psd_from_acf(acf, dt): 

54 """Power spectral density via even extension of the ACF followed by rfft.""" 

55 signal = np.hstack((acf, acf[:0:-1])) 

56 fft = dt * np.fft.rfft(signal) 

57 freqs = 2 * np.pi * np.fft.rfftfreq(len(signal), dt) 

58 return freqs, fft.real 

59 

60 

61def _parse_polarization_pair(polarization_in, polarization_out): 

62 """Validate and parse a pair of polarization unit vectors, or return (None, None).""" 

63 if (polarization_in is None) != (polarization_out is None): 

64 raise ValueError('polarization_in and polarization_out must be given together') 

65 if polarization_in is None: 

66 return None, None 

67 n_in = np.asarray(polarization_in, dtype=float) 

68 n_out = np.asarray(polarization_out, dtype=float) 

69 if n_in.shape != (3,) or n_out.shape != (3,): 

70 raise ValueError('polarization_in and polarization_out must have shape (3,)') 

71 return n_in, n_out 

72 

73 

74# ── Signal column mapping ────────────────────────────────────────────────────── 

75 

76_SIGNAL_COLS = { 

77 'dP': (['dPx', 'dPy', 'dPz'], True), 

78 'P': (['Px', 'Py', 'Pz'], False), 

79 'mu': (['mu_x', 'mu_y', 'mu_z'], False), 

80} 

81 

82 

83def _resolve_signal(signal, column, dt): 

84 """Select signal columns from a DataFrame, infer derivative flag, auto-extract dt.""" 

85 if column not in _SIGNAL_COLS: 

86 raise ValueError( 

87 f'column must be one of {list(_SIGNAL_COLS)!r}, got {column!r}' 

88 ) 

89 col_names, derivative = _SIGNAL_COLS[column] 

90 missing = [c for c in col_names if c not in signal.columns] 

91 if missing: 

92 raise ValueError( 

93 f'signal is missing columns {missing!r} for column={column!r}' 

94 ) 

95 if dt is None: 95 ↛ 102line 95 didn't jump to line 102 because the condition on line 95 was always true

96 if 'time' not in signal.columns: 

97 raise ValueError( 

98 'dt must be provided when signal has no time column ' 

99 f'(got columns: {list(signal.columns)!r})' 

100 ) 

101 dt = float(signal['time'].diff().dropna().iloc[0]) 

102 return signal[col_names].to_numpy(dtype=float), dt, derivative 

103 

104 

105# ── IR spectrum ──────────────────────────────────────────────────────────────── 

106 

107def _acf_to_psd(acf, dt, t_sigma, max_lag): 

108 """Truncate ACF, apply optional Gaussian window, and compute PSD. dt and t_sigma in ps.""" 

109 acf = np.real(acf[:int(len(acf) * max_lag)]) 

110 t_acf = dt * np.arange(len(acf)) # ps 

111 if t_sigma is not None: 

112 acf = acf * _gaussian_decay(t_acf, t_sigma=t_sigma) # both ps 

113 return _psd_from_acf(acf, dt * 1e-12) # _psd_from_acf uses seconds 

114 

115 

116def _ir_spectrum_1d(dt, signal, volume_SI, temperature, t_sigma, max_lag, derivative=False): 

117 """Compute IR spectrum for a single dipole/polarization component in SI units.""" 

118 D = e_SI * 1e-10 / (1e-15 if derivative else 1.0) 

119 signal_SI = (signal - np.mean(signal)) * D 

120 acf = _compute_correlation_function(signal_SI, signal_SI) 

121 w, S = _acf_to_psd(acf, dt, t_sigma, max_lag) 

122 # When input is the signal itself, multiply by omega**2 to get PSD of its derivative 

123 # (PSD[dx/dt] = omega**2 * PSD[x]). When input is already the derivative, use PSD directly. 

124 S_dpdt = S if derivative else w ** 2 * S 

125 

126 if volume_SI is not None: 

127 beta = 1.0 / (kB_SI * temperature) 

128 conductivity = (beta / (3.0 * volume_SI)) * S_dpdt 

129 with np.errstate(divide='ignore', invalid='ignore'): 

130 eps_imag = conductivity / (eps0_SI * w) 

131 return w, eps_imag, conductivity 

132 else: 

133 return w, S_dpdt 

134 

135 

136def get_ir_spectrum( 

137 signal: DataFrame, 

138 volume: float = None, 

139 temperature: float = None, 

140 column: str = 'dP', 

141 dt: float = None, 

142 polarization: np.ndarray = None, 

143 t_sigma: float = None, 

144 max_lag: float = 0.25, 

145) -> DataFrame: 

146 r"""Compute the IR spectrum from a time series of dipole moments or polarizations. 

147 

148 Parameters 

149 ---------- 

150 signal 

151 ``DataFrame`` as returned by :func:`~calorine.gpumd.read_dpdt` or 

152 :func:`~calorine.gpumd.read_dipole`. The columns to use are selected 

153 via :attr:`column`. 

154 volume 

155 Simulation cell volume in ų. Required for extended systems; when 

156 provided the function returns the imaginary dielectric function and 

157 conductivity. Pass ``None`` for molecules to obtain an unnormalized 

158 line shape in arbitrary units. 

159 temperature 

160 Temperature in K. Required when :attr:`volume` is not ``None``. 

161 column 

162 Selects which columns of :attr:`signal` to use and implies whether 

163 the input is a time derivative: 

164 

165 ``'dP'`` (default) 

166 Uses ``dPx``, ``dPy``, ``dPz`` from :func:`~calorine.gpumd.read_dpdt` 

167 (qNEP); signal is :math:`\mathrm{d}P/\mathrm{d}t` in e·Å/fs. 

168 ``'P'`` 

169 Uses ``Px``, ``Py``, ``Pz`` from :func:`~calorine.gpumd.read_dpdt` 

170 (qNEP); signal is the polarization :math:`P(t)` in e·Å. 

171 ``'mu'`` 

172 Uses ``mu_x``, ``mu_y``, ``mu_z`` from 

173 :func:`~calorine.gpumd.read_dipole` (TNEP); signal is the dipole 

174 moment :math:`\mu(t)` in e·Å. 

175 

176 dt 

177 Time between consecutive frames in ps. Auto-extracted from the 

178 ``time`` column when present (i.e. for :func:`~calorine.gpumd.read_dpdt` 

179 output). Must be supplied explicitly for step-indexed inputs such as 

180 :func:`~calorine.gpumd.read_dipole` output. 

181 polarization 

182 Unit vector ``(3,)`` defining the electric-field polarization direction. 

183 When given, the spectrum is computed from the projected signal 

184 :math:`s(t) = \hat{n} \cdot \mathrm{signal}(t)` instead of the 

185 isotropic average. 

186 t_sigma 

187 Width of the Gaussian window applied to the ACF in ps. 

188 ``None`` uses no windowing. 

189 max_lag 

190 Maximum ACF length as a fraction of the trajectory length; must be 

191 in (0, 1]. Defaults to 0.25 (25 %). 

192 

193 Returns 

194 ------- 

195 DataFrame 

196 Always contains ``angular_frequency`` (THz) and 

197 ``wavenumber_invcm`` (:math:`\mathrm{cm}^{-1}`). 

198 When :attr:`volume` and :attr:`temperature` are given: 

199 ``epsilon_imag`` (dimensionless) and ``conductivity`` (S/m). 

200 When :attr:`volume` is ``None``: ``ir_intensity`` (arbitrary units, 

201 proportional to :math:`\mathrm{PSD}[\dot{\mu}]`). 

202 """ 

203 if volume is not None and temperature is None: 

204 raise ValueError('temperature must be provided when volume is given') 

205 

206 arr, dt, derivative = _resolve_signal(signal, column, dt) 

207 volume_SI = volume * 1e-30 if volume is not None else None 

208 

209 if polarization is not None: 

210 n = np.asarray(polarization, dtype=float) 

211 if n.shape != (3,): 

212 raise ValueError('polarization must have shape (3,)') 

213 components = [arr @ n] 

214 else: 

215 components = [arr[:, i] for i in range(3)] 

216 

217 results = [_ir_spectrum_1d(dt, c, volume_SI, temperature, t_sigma, max_lag, derivative) 

218 for c in components] 

219 

220 if volume_SI is not None: 

221 w = results[0][0] 

222 eps_imag = np.mean([r[1] for r in results], axis=0) 

223 conductivity = np.mean([r[2] for r in results], axis=0) 

224 mask = np.isfinite(eps_imag) 

225 cols = ['angular_frequency', 'wavenumber_invcm', 'epsilon_imag', 'conductivity'] 

226 data = np.column_stack((w[mask] * _rad_s_to_thz, w[mask] * _rad_s_to_invcm, 

227 eps_imag[mask], conductivity[mask])) 

228 return DataFrame(data, columns=cols) 

229 else: 

230 w = results[0][0] 

231 ir_intensity = np.mean([r[1] for r in results], axis=0) 

232 cols = ['angular_frequency', 'wavenumber_invcm', 'ir_intensity'] 

233 data = np.column_stack((w * _rad_s_to_thz, w * _rad_s_to_invcm, ir_intensity)) 

234 return DataFrame(data, columns=cols) 

235 

236 

237# ── Raman spectrum ───────────────────────────────────────────────────────────── 

238 

239_POL_COLS = ['xx', 'yy', 'zz', 'yz', 'xz', 'xy'] 

240 

241 

242def get_raman_spectrum( 

243 dt: float, 

244 polarizability: DataFrame, 

245 polarization_in: np.ndarray = None, 

246 polarization_out: np.ndarray = None, 

247 t_sigma: float = None, 

248 max_lag: float = 0.25, 

249) -> DataFrame: 

250 r"""Compute the Raman spectrum from a time series of polarizability tensors. 

251 

252 Parameters 

253 ---------- 

254 dt 

255 Time between consecutive frames in ps. 

256 polarizability 

257 DataFrame with columns ``xx``, ``yy``, ``zz``, ``xy``, ``yz``, ``xz`` 

258 containing the polarizability :math:`\alpha` (molecules) or susceptibility 

259 :math:`\chi` (extended systems), as returned by 

260 :func:`~calorine.gpumd.read_polarizability`. Units are those of the 

261 TNEP training data (typically bohr³). 

262 polarization_in 

263 Unit vector ``(3,)`` for the polarization of the incoming light. If 

264 both :attr:`polarization_in` and :attr:`polarization_out` are given, 

265 the polarization-resolved intensity 

266 :math:`I(\omega) \propto \mathrm{FT}[\langle s(0)s(t)\rangle]` with 

267 :math:`s(t) = \hat{n}^\mathrm{out} \cdot \alpha(t) \cdot \hat{n}^\mathrm{in}` 

268 is added as the column ``raman_polarized``. 

269 polarization_out 

270 Unit vector ``(3,)`` for the polarization of the outgoing (scattered) 

271 light. 

272 t_sigma 

273 Width of the Gaussian window applied to the ACF in ps. ``None`` uses no 

274 windowing. 

275 max_lag 

276 Maximum ACF length as a fraction of the trajectory length; must be 

277 in (0, 1]. Defaults to 0.25 (25 %). 

278 

279 Returns 

280 ------- 

281 DataFrame 

282 Always contains ``angular_frequency`` (THz), 

283 ``wavenumber_invcm`` (:math:`\mathrm{cm}^{-1}`), 

284 ``raman_isotropic`` (proportional to 

285 :math:`\mathrm{FT}[\langle\gamma(0)\gamma(t)\rangle]`), and 

286 ``raman_anisotropic`` (proportional to 

287 :math:`\mathrm{FT}[\langle\mathrm{Tr}[\beta(0)\beta(t)]\rangle]`). 

288 If both polarization vectors are given, also contains 

289 ``raman_polarized``. 

290 """ 

291 missing = [c for c in _POL_COLS if c not in polarizability.columns] 

292 if missing: 

293 raise ValueError(f'polarizability is missing columns: {missing}') 

294 

295 # Remove mean (Rayleigh component) before computing fluctuation spectrum. 

296 pol = polarizability[_POL_COLS] - polarizability[_POL_COLS].mean() 

297 

298 n_in, n_out = _parse_polarization_pair(polarization_in, polarization_out) 

299 

300 # Isotropic component gamma(t) = Tr[alpha(t)] / 3. 

301 gamma = (pol['xx'] + pol['yy'] + pol['zz']).to_numpy() / 3.0 

302 

303 # Traceless part beta = alpha - gamma*I. 

304 beta_xx = pol['xx'].to_numpy() - gamma 

305 beta_yy = pol['yy'].to_numpy() - gamma 

306 beta_zz = pol['zz'].to_numpy() - gamma 

307 # Off-diagonal elements are unchanged: beta_xy = alpha_xy, etc. 

308 

309 with np.errstate(invalid='ignore', divide='ignore'): 

310 # Isotropic Raman: FT[<gamma(0)*gamma(t)>]. 

311 acf_iso = _compute_correlation_function(gamma, gamma) 

312 w, L_iso = _acf_to_psd(acf_iso, dt, t_sigma, max_lag) 

313 

314 # Anisotropic Raman: FT[<Tr[beta(0)*beta(t)]>]. 

315 # Tr[beta(0)*beta(t)] = sum_ij beta_ij(0)*beta_ij(t) — sum of per-element 

316 # ACFs with factor 2 for off-diagonal (since beta_ij = beta_ji). 

317 acf_aniso = ( 

318 _compute_correlation_function(beta_xx, beta_xx) 

319 + _compute_correlation_function(beta_yy, beta_yy) 

320 + _compute_correlation_function(beta_zz, beta_zz) 

321 + 2.0 * _compute_correlation_function(pol['yz'].to_numpy(), pol['yz'].to_numpy()) 

322 + 2.0 * _compute_correlation_function(pol['xz'].to_numpy(), pol['xz'].to_numpy()) 

323 + 2.0 * _compute_correlation_function(pol['xy'].to_numpy(), pol['xy'].to_numpy()) 

324 ) 

325 _, L_aniso = _acf_to_psd(acf_aniso, dt, t_sigma, max_lag) 

326 

327 cols = ['angular_frequency', 'wavenumber_invcm', 'raman_isotropic', 'raman_anisotropic'] 

328 data = np.column_stack((w * _rad_s_to_thz, w * _rad_s_to_invcm, L_iso, L_aniso)) 

329 df = DataFrame(data, columns=cols) 

330 

331 # Polarization-resolved spectrum: s(t) = n_out . alpha(t) . n_in. 

332 # s = sum_ij n_out_i * alpha_ij * n_in_j; use symmetry alpha_ij = alpha_ji. 

333 if n_in is not None: 

334 s = (n_in[0]*n_out[0] * pol['xx'].to_numpy() 

335 + n_in[1]*n_out[1] * pol['yy'].to_numpy() 

336 + n_in[2]*n_out[2] * pol['zz'].to_numpy() 

337 + (n_in[1]*n_out[2] + n_in[2]*n_out[1]) * pol['yz'].to_numpy() 

338 + (n_in[0]*n_out[2] + n_in[2]*n_out[0]) * pol['xz'].to_numpy() 

339 + (n_in[0]*n_out[1] + n_in[1]*n_out[0]) * pol['xy'].to_numpy()) 

340 with np.errstate(invalid='ignore', divide='ignore'): 

341 acf_pol = _compute_correlation_function(s, s) 

342 _, L_pol = _acf_to_psd(acf_pol, dt, t_sigma, max_lag) 

343 df['raman_polarized'] = L_pol 

344 

345 return df 

346 

347 

348# ── Quantum correction ───────────────────────────────────────────────────────── 

349 

350def apply_quantum_correction( 

351 df: DataFrame, 

352 temperature: float, 

353 column: str, 

354 order: str = 'first', 

355 force: bool = False, 

356) -> DataFrame: 

357 r"""Apply a quantum correction to a classically computed spectrum. 

358 

359 Classical MD underestimates spectral intensities because it samples the 

360 classical Boltzmann distribution rather than the Bose-Einstein distribution. 

361 The correction factor depends on the scattering order and mode type 

362 [Cardona1982]_ [Rosander2025]_. 

363 

364 Parameters 

365 ---------- 

366 df 

367 DataFrame with an ``angular_frequency`` column (THz) and the column 

368 to correct. 

369 temperature 

370 Temperature in K. 

371 column 

372 Name of the spectral column to correct (e.g. ``'raman_isotropic'``, 

373 ``'ir_intensity'``, ``'epsilon_imag'``). 

374 order 

375 Correction type: 

376 

377 ``'first'`` 

378 First-order scattering (IR absorption and first-order Raman): 

379 :math:`f = \beta\hbar\omega / (1 - e^{-\beta\hbar\omega})`. 

380 ``'overtone'`` 

381 Second-order overtone (same mode, :math:`\omega_1 = \omega/2`): 

382 :math:`f = [y/(1 - e^{-y})]^2 (2 - e^{-y})` with 

383 :math:`y = \beta\hbar\omega/2`. 

384 ``'combination'`` 

385 Second-order combination band upper bound 

386 (:math:`\omega_1 = \omega_2 = \omega/2`): 

387 :math:`f = [y/(1 - e^{-y})]^2` with :math:`y = \beta\hbar\omega/2`. 

388 force 

389 If ``True``, overwrite an existing ``column + '_qm'`` column. 

390 Default ``False`` raises :class:`ValueError` to prevent accidental 

391 double-correction. 

392 

393 Returns 

394 ------- 

395 DataFrame 

396 Copy of :attr:`df` with a new column ``column + '_qm'`` containing 

397 the quantum-corrected intensities. The input is not mutated. 

398 

399 References 

400 ---------- 

401 .. [Cardona1982] M. Cardona, *Resonance phenomena*, in *Topics in Applied 

402 Physics*, Vol. 50, edited by M. Cardona and G. Güntherodt 

403 (Springer, Berlin, 1982). 

404 .. [Rosander2025] Rosander *et al.*, Phys. Rev. B **111**, 064107 (2025), 

405 Supp. Eqs. S5, S7, S10. 

406 """ 

407 if order not in ('first', 'overtone', 'combination'): 

408 raise ValueError("order must be 'first', 'overtone', or 'combination'") 

409 

410 if not force and column + '_qm' in df.columns: 

411 raise ValueError( 

412 f"column '{column}_qm' already exists; pass force=True to overwrite" 

413 ) 

414 

415 w = df['angular_frequency'].to_numpy() * 1e12 # THz → rad/s 

416 beta = 1.0 / (kB_SI * temperature) 

417 x = beta * hbar_SI * w # beta*hbar*omega (dimensionless) 

418 

419 with np.errstate(divide='ignore', invalid='ignore', over='ignore'): 

420 if order == 'first': 

421 # f = beta*hbar*omega / (1 - exp(-beta*hbar*omega)) 

422 factor = np.where(x > 0, x / (1.0 - np.exp(-x)), 1.0) 

423 elif order == 'overtone': 

424 # f = [y/(1-exp(-y))]**2 * (2-exp(-y)), y = beta*hbar*omega/2 

425 y = x / 2.0 

426 base = np.where(y > 0, y / (1.0 - np.exp(-y)), 1.0) 

427 factor = base ** 2 * np.where(y > 0, 2.0 - np.exp(-y), 1.0) 

428 else: # combination 

429 # f = [y/(1-exp(-y))]**2, y = beta*hbar*omega/2 

430 y = x / 2.0 

431 base = np.where(y > 0, y / (1.0 - np.exp(-y)), 1.0) 

432 factor = base ** 2 

433 

434 result = df.copy() 

435 result[column + '_qm'] = df[column].to_numpy() * factor 

436 return result 

437 

438 

439# ── Dielectric tensor ───────────────────────────────────────────────────────── 

440 

441_TENSOR_COMPONENTS = [(0, 0, 'xx'), (1, 1, 'yy'), (2, 2, 'zz'), 

442 (1, 2, 'yz'), (0, 2, 'xz'), (0, 1, 'xy')] 

443 

444 

445def get_dielectric_function( 

446 signal: DataFrame, 

447 volume: float, 

448 temperature: float, 

449 column: str = 'dP', 

450 dt: float = None, 

451 t_sigma: float = None, 

452 max_lag: float = 0.25, 

453 return_real_part: bool = True, 

454 kk_method: str = 'vectorized', 

455) -> DataFrame: 

456 r"""Compute the dielectric function from a three-component polarization time series. 

457 

458 Computes all six unique Voigt components of :math:`\epsilon_2^{\alpha\beta}(\omega)` 

459 (xx, yy, zz, yz, xz, xy) from the three-component time series written by GPUMD. 

460 The real part :math:`\epsilon_1^{\alpha\beta}(\omega)` is obtained via the 

461 Kramers-Kronig relation when :attr:`return_real_part` is ``True`` (the default). 

462 

463 The imaginary part is related to the symmetrized cross-correlation via 

464 

465 .. math:: 

466 

467 \epsilon_2^{\alpha\beta}(\omega) = \frac{\beta}{\epsilon_0 \omega V} 

468 \,\mathrm{Re}\!\left[\int_0^\infty 

469 \langle \dot{P}_\alpha(0)\,\dot{P}_\beta(t) \rangle 

470 e^{-i\omega t}\,dt\right], 

471 

472 where :math:`\beta = 1/(k_\mathrm{B}T)` and :math:`V` is the cell volume. 

473 

474 Parameters 

475 ---------- 

476 signal 

477 ``DataFrame`` as returned by :func:`~calorine.gpumd.read_dpdt` or 

478 :func:`~calorine.gpumd.read_dipole`. The columns to use are selected 

479 via :attr:`column`. 

480 volume 

481 Simulation cell volume in ų. 

482 temperature 

483 Temperature in K. 

484 column 

485 Selects which columns of :attr:`signal` to use and implies whether 

486 the input is a time derivative: 

487 

488 ``'dP'`` (default) 

489 Uses ``dPx``, ``dPy``, ``dPz`` from :func:`~calorine.gpumd.read_dpdt` 

490 (qNEP); input is :math:`\dot{\mathbf{P}}(t)` in e·Å/fs. 

491 ``'P'`` 

492 Uses ``Px``, ``Py``, ``Pz`` from :func:`~calorine.gpumd.read_dpdt` 

493 (qNEP); input is the polarization :math:`\mathbf{P}(t)` in e·Å. 

494 ``'mu'`` 

495 Uses ``mu_x``, ``mu_y``, ``mu_z`` from 

496 :func:`~calorine.gpumd.read_dipole` (TNEP); input is the dipole 

497 moment :math:`\boldsymbol{\mu}(t)` in e·Å. 

498 

499 dt 

500 Time between consecutive frames in ps. Auto-extracted from the 

501 ``time`` column when present (i.e. for :func:`~calorine.gpumd.read_dpdt` 

502 output). Must be supplied explicitly for step-indexed inputs such as 

503 :func:`~calorine.gpumd.read_dipole` output. 

504 t_sigma 

505 Width of the Gaussian window applied to the ACF in ps. 

506 ``None`` uses no windowing. 

507 max_lag 

508 Maximum ACF length as a fraction of the trajectory length; must be 

509 in (0, 1]. Defaults to 0.25 (25 %). 

510 return_real_part 

511 When ``True`` (default), the real part :math:`\epsilon_1^{\alpha\beta}(\omega)` 

512 is computed via the Kramers-Kronig relation and appended as 

513 ``epsilon_real_{xx,yy,zz,yz,xz,xy}`` columns. 

514 kk_method 

515 Integration method passed to :func:`~calorine.tools.apply_kramers_kronig` 

516 when :attr:`return_real_part` is ``True``. ``'vectorized'`` (default) uses 

517 an exact trapezoid rule; ``'fft'`` uses a faster Hilbert transform 

518 approximation. Ignored when :attr:`return_real_part` is ``False``. 

519 

520 Returns 

521 ------- 

522 DataFrame 

523 Contains ``angular_frequency`` (THz), ``wavenumber_invcm`` 

524 (:math:`\mathrm{cm}^{-1}`), 

525 ``epsilon_imag_{xx,yy,zz,yz,xz,xy}`` (dimensionless, imaginary dielectric 

526 tensor components), and ``conductivity_{xx,yy,zz,yz,xz,xy}`` (S/m). 

527 When :attr:`return_real_part` is ``True``, ``epsilon_real_{xx,yy,zz,yz,xz,xy}`` 

528 columns are appended. 

529 """ 

530 arr, dt, derivative = _resolve_signal(signal, column, dt) 

531 

532 D = e_SI * 1e-10 / (1e-15 if derivative else 1.0) 

533 volume_SI = volume * 1e-30 

534 beta = 1.0 / (kB_SI * temperature) 

535 

536 signal_SI = (arr - arr.mean(axis=0)) * D 

537 

538 def _psd(z1, z2=None): 

539 if z2 is None: 

540 acf = _compute_correlation_function(z1, z1) 

541 else: 

542 acf = (_compute_correlation_function(z1, z2) 

543 + _compute_correlation_function(z2, z1)) / 2 

544 w, S = _acf_to_psd(acf, dt, t_sigma, max_lag) 

545 # PSD[dx/dt] = omega**2 * PSD[x] when input is the signal itself. 

546 return w, S if derivative else w ** 2 * S 

547 

548 w = None 

549 conductivities = {} 

550 eps_imags = {} 

551 for i, j, lbl in _TENSOR_COMPONENTS: 

552 zi = signal_SI[:, i] 

553 zj = signal_SI[:, j] 

554 w, S_dpdt = _psd(zi) if i == j else _psd(zi, zj) 

555 conductivity = (beta / volume_SI) * S_dpdt 

556 with np.errstate(divide='ignore', invalid='ignore'): 

557 eps_imag = conductivity / (eps0_SI * w) 

558 conductivities[lbl] = conductivity 

559 eps_imags[lbl] = eps_imag 

560 

561 mask = w > 0 

562 cols = (['angular_frequency', 'wavenumber_invcm'] 

563 + [f'epsilon_imag_{lbl}' for _, _, lbl in _TENSOR_COMPONENTS] 

564 + [f'conductivity_{lbl}' for _, _, lbl in _TENSOR_COMPONENTS]) 

565 arrays = ([w[mask] * _rad_s_to_thz, w[mask] * _rad_s_to_invcm] 

566 + [eps_imags[lbl][mask] for _, _, lbl in _TENSOR_COMPONENTS] 

567 + [conductivities[lbl][mask] for _, _, lbl in _TENSOR_COMPONENTS]) 

568 result = DataFrame(np.column_stack(arrays), columns=cols) 

569 if return_real_part: 

570 from .kramers_kronig import apply_kramers_kronig 

571 result = apply_kramers_kronig(result, method=kk_method) 

572 return result