Coverage for dynasor/correlation_functions.py: 98%

307 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-03 19:46 +0000

1from itertools import combinations_with_replacement 

2from typing import Optional 

3 

4import numba 

5import numpy as np 

6from ase import Atoms 

7from ase.units import fs 

8from numpy.typing import NDArray 

9 

10from dynasor.compute_config import get_compute_config 

11from dynasor.logging_tools import logger, warn_if_numba_threading_layer_is_slow 

12from dynasor.trajectory import Trajectory, WindowIterator 

13from dynasor.trajectory.prefetch import (_drop_frames_outside_windows, 

14 _prefetch_batched_iter) 

15from dynasor.sample import DynamicSample, StaticSample 

16 

17from dynasor.tools.acfs import psd_from_acf_2d 

18from dynasor.core.time_averager import TimeAverager, truncate_nans 

19from dynasor.core.reciprocal import _calc_rho_q_incoherent 

20from dynasor.core.frame_processors import get_frame_processor 

21from dynasor.qpoints.tools import is_qpoint_commensurate 

22from dynasor.tools.structures import get_offset_index 

23from dynasor.units import radians_per_fs_to_meV 

24 

25 

26def _validate_qpoints_commensurate(q_points: NDArray[float], cell: NDArray[float]) -> None: 

27 """Raise if any q-point is not commensurate with the simulation cell.""" 

28 n_bad = sum(not is_qpoint_commensurate(q, cell) for q in q_points) 

29 if n_bad > 0: 

30 raise ValueError( 

31 f'{n_bad} of {len(q_points)} q-points are not commensurate with the cell; use ' 

32 'get_spherical_qpoints or get_supercell_qpoints_along_path to generate ' 

33 'commensurate q-points.') 

34 

35 

36def compute_dynamic_structure_factors( 

37 traj: Trajectory, 

38 q_points: NDArray[float], 

39 dt: float, 

40 window_size: int, 

41 window_step: Optional[int] = 1, 

42 calculate_currents: Optional[bool] = False, 

43 calculate_incoherent: Optional[bool] = False, 

44 logging_interval: Optional[int] = 1000, 

45) -> DynamicSample: 

46 r"""Compute the dynamic structure factors. The results are returned in the 

47 form of a :class:`DynamicSample <dynasor.sample.DynamicSample>` 

48 object. 

49 

50 The computational backend and precision are set globally via 

51 :func:`set_compute_config <dynasor.set_compute_config>`, not as parameters of this 

52 function; see :ref:`the backends reference page <backends>` for details, including 

53 which parts of this calculation the GPU backends do (and do not) accelerate. 

54 

55 Parameters 

56 ---------- 

57 traj 

58 Input trajectory. 

59 q_points 

60 Array of q-points in units of rad/Å with shape ``(N_qpoints, 3)`` in Cartesian coordinates. 

61 dt 

62 Time difference in femtoseconds between two consecutive snapshots 

63 in the trajectory. Note that you should *not* change :attr:`dt` if you change 

64 :attr:`frame_step <dynasor.Trajectory.frame_step>` in :attr:`traj`. 

65 window_size 

66 Maximum time lag, expressed as a number of frame intervals, for which to calculate 

67 correlations. Each calculation window therefore contains ``window_size + 1`` frames, 

68 including the frame at time lag zero. This parameter determines the smallest frequency 

69 resolved. 

70 window_step 

71 Window step (or stride) given as the number of frames between consecutive trajectory 

72 windows. This parameter does *not* affect the time between consecutive frames in the 

73 calculation. If :attr:`window_step` > :attr:`window_size` + 1, some frames will not be 

74 used. 

75 calculate_currents 

76 Calculate the current correlations. Requires velocities to be available in :attr:`traj`. 

77 calculate_incoherent 

78 Calculate the incoherent part (self-part) of :math:`F_\text{incoh}`. Always evaluated on 

79 CPU via the Numba kernel, regardless of the configured backend (see 

80 :func:`set_compute_config <dynasor.set_compute_config>`). 

81 logging_interval 

82 Log progress at ``INFO`` level every this many windows. Set to ``0`` to disable 

83 progress logging. 

84 """ 

85 # sanity check input args 

86 if q_points.ndim != 2 or q_points.shape[1] != 3: 

87 raise ValueError('q-points array has the wrong shape.') 

88 if dt <= 0: 

89 raise ValueError(f'dt must be positive: dt= {dt}') 

90 if window_size <= 2: 

91 raise ValueError(f'window_size must be larger than 2: window_size= {window_size}') 

92 if window_step <= 0: 

93 raise ValueError(f'window_step must be positive: window_step= {window_step}') 

94 if not traj.has_positions: 

95 raise ValueError('compute_dynamic_structure_factors requires positions to be available ' 

96 'in the trajectory, but traj does not provide positions.') 

97 if calculate_currents and not traj.has_velocities: 

98 raise ValueError('calculate_currents=True requires velocities to be available in the ' 

99 'trajectory, but traj does not provide velocities.') 

100 _validate_qpoints_commensurate(q_points, traj.cell) 

101 

102 # define internal parameters 

103 n_qpoints = q_points.shape[0] 

104 delta_t = traj.frame_step * dt 

105 N_tc = window_size + 1 

106 

107 # log all setup information 

108 n_fft = 2 * window_size + 1 

109 dw = 2 * np.pi / (n_fft * delta_t) 

110 w_max = window_size * dw 

111 w_N = np.pi / delta_t 

112 dw_mev = dw * radians_per_fs_to_meV 

113 w_max_mev = w_max * radians_per_fs_to_meV 

114 w_N_mev = w_N * radians_per_fs_to_meV 

115 logger.info(f'Spacing between samples (frame_step): {traj.frame_step}') 

116 logger.info(f'Time between consecutive frames in input trajectory (dt): {dt} fs') 

117 logger.info(f'Time between consecutive frames used (dt * frame_step): {delta_t} fs') 

118 logger.info(f'Time window size (dt * frame_step * window_size): {delta_t * window_size:.1f} fs') 

119 logger.info(f'Angular frequency resolution: dw = {dw:.6f} rad/fs = {dw_mev:.3f} meV') 

120 logger.info(f'Maximum angular frequency: {w_max:.6f} rad/fs = {w_max_mev:.3f} meV ' 

121 f'(Nyquist limit: {w_N:.6f} rad/fs = {w_N_mev:.3f} meV)') 

122 

123 if calculate_currents: 

124 logger.info('Calculating current (velocity) correlations') 

125 if calculate_incoherent: 

126 logger.info('Calculating incoherent part (self-part) of correlations') 

127 

128 # log some info regarding q-points 

129 logger.info(f'Number of q-points: {n_qpoints}') 

130 

131 q_directions = q_points.astype(float, copy=True) 

132 q_distances = np.linalg.norm(q_points, axis=1) 

133 nonzero = q_distances > 0 

134 q_directions[nonzero] /= q_distances[nonzero].reshape(-1, 1) 

135 

136 # setup functions to process frames 

137 compute_config = get_compute_config() 

138 backend = compute_config.backend 

139 precision = compute_config.precision 

140 gpu_batch_size = compute_config.batch_size 

141 

142 is_batched, processor = get_frame_processor( 

143 q_points, backend, precision, gpu_batch_size, 

144 calculate_currents=calculate_currents, q_directions=q_directions) 

145 

146 # Build the frame source and window iterator. 

147 # GPU: batching + background prefetch thread (processor reads B frames, 

148 # GPU-processes them, and queues results while the main thread runs 

149 # calc_corr on the previous window). 

150 # Numba: standard per-frame element_processor inside WindowIterator. 

151 if is_batched: 151 ↛ 156line 151 didn't jump to line 156 because the condition on line 151 was never true

152 # The batched processor runs ahead of the window iterator, so frames 

153 # that fall between two windows have to be dropped here; skipping them 

154 # in the window iterator would leave them already transformed. What 

155 # remains is one group of N_tc frames per window, hence the step. 

156 if window_step >= N_tc: 

157 raw_frames = _drop_frames_outside_windows(traj, N_tc, window_step) 

158 iterator_step = N_tc 

159 else: 

160 raw_frames = traj 

161 iterator_step = window_step 

162 _frame_source = _prefetch_batched_iter(raw_frames, processor, batch_size=gpu_batch_size) 

163 window_iterator = WindowIterator(_frame_source, width=N_tc, window_step=iterator_step) 

164 else: 

165 window_iterator = WindowIterator(traj, width=N_tc, window_step=window_step, 

166 element_processor=processor) 

167 

168 # define all atom types and pairs 

169 atom_types = traj.atom_types 

170 pairs = list(combinations_with_replacement(atom_types, r=2)) 

171 particle_counts = {key: len(val) for key, val in traj.atomic_indices.items()} 

172 logger.debug('Considering pairs:') 

173 for pair in pairs: 

174 logger.debug(f' {pair}') 

175 

176 # set up all time averager instances 

177 F_q_t_averager = dict() 

178 for pair in pairs: 

179 F_q_t_averager[pair] = TimeAverager(N_tc, n_qpoints) 

180 if calculate_currents: 

181 Cl_q_t_averager = dict() 

182 Ct_q_t_averager = dict() 

183 for pair in pairs: 

184 Cl_q_t_averager[pair] = TimeAverager(N_tc, n_qpoints) 

185 Ct_q_t_averager[pair] = TimeAverager(N_tc, n_qpoints) 

186 if calculate_incoherent: 

187 F_s_q_t_averager = dict() 

188 for pair in atom_types: 

189 F_s_q_t_averager[pair] = TimeAverager(N_tc, n_qpoints) 

190 incoherent_displacements = dict() 

191 incoherent_rho = dict() 

192 

193 # define correlation function 

194 def calc_corr(window, time_i): 

195 # Calculate correlations between two frames in the window without normalization 1/N 

196 f0 = window[0] 

197 fi = window[time_i] 

198 for s1, s2 in pairs: 

199 Fqt = np.real(f0.rho_qs_dict[s1] * fi.rho_qs_dict[s2].conjugate()) 

200 if s1 != s2: 

201 Fqt += np.real(f0.rho_qs_dict[s2] * fi.rho_qs_dict[s1].conjugate()) 

202 F_q_t_averager[(s1, s2)].add_sample(time_i, Fqt) 

203 

204 if calculate_currents: 

205 for s1, s2 in pairs: 

206 Clqt = np.real(f0.jz_qs_dict[s1] * fi.jz_qs_dict[s2].conjugate()) 

207 Ctqt = 0.5 * np.real(np.sum(f0.jper_qs_dict[s1] * 

208 fi.jper_qs_dict[s2].conjugate(), axis=1)) 

209 if s1 != s2: 

210 Clqt += np.real(f0.jz_qs_dict[s2] * fi.jz_qs_dict[s1].conjugate()) 

211 Ctqt += 0.5 * np.real(np.sum(f0.jper_qs_dict[s2] * 

212 fi.jper_qs_dict[s1].conjugate(), axis=1)) 

213 

214 Cl_q_t_averager[(s1, s2)].add_sample(time_i, Clqt) 

215 Ct_q_t_averager[(s1, s2)].add_sample(time_i, Ctqt) 

216 

217 def calc_incoherent(window): 

218 # Calculate the incoherent (self) part for all lags in the window at once. 

219 n_window = len(window) 

220 f0 = window[0] 

221 for atom_type in atom_types: 

222 x0 = f0.positions_by_type[atom_type] 

223 if atom_type not in incoherent_displacements: 

224 incoherent_displacements[atom_type] = np.empty( 

225 (n_window, 3, x0.shape[0]), dtype=x0.dtype) 

226 incoherent_rho[atom_type] = np.empty( 

227 (n_window, n_qpoints), dtype=np.float64) 

228 

229 displacements = incoherent_displacements[atom_type][:n_window] 

230 for time_i, frame in enumerate(window): 

231 np.subtract(frame.positions_by_type[atom_type].T, x0.T, 

232 out=displacements[time_i]) 

233 

234 rho = incoherent_rho[atom_type][:n_window] 

235 _calc_rho_q_incoherent(displacements, q_points, out=rho) 

236 for time_i, sample in enumerate(rho): 

237 F_s_q_t_averager[atom_type].add_sample(time_i, sample) 

238 

239 # run calculation 

240 # This is the "main loop" over the trajectory 

241 for window in window_iterator: 

242 if logging_interval and window[0].frame_index % logging_interval == 0: 

243 logger.info(f'Processing window {window[0].frame_index} to {window[-1].frame_index}') # noqa 

244 else: 

245 logger.debug(f'Processing window {window[0].frame_index} to {window[-1].frame_index}') # noqa 

246 

247 for time_i in range(len(window)): 

248 calc_corr(window, time_i) 

249 

250 if calculate_incoherent: 

251 calc_incoherent(window) 

252 

253 # collect results into dict with numpy arrays (n_qpoints, N_tc) 

254 data_dict_corr = dict() 

255 data_dict_corr['q_points'] = q_points 

256 

257 time = None 

258 for pair in pairs: 

259 key = '_'.join(pair) 

260 F_q_t = 1 / traj.n_atoms * truncate_nans(F_q_t_averager[pair].get_average_all()) 

261 w, S_q_w = psd_from_acf_2d(F_q_t, delta_t) 

262 S_q_w = np.array(S_q_w) 

263 

264 if time is None: 

265 # Determine the actual length of time signal after truncation (if needed) 

266 time = delta_t * np.arange(F_q_t.shape[1], dtype=float) 

267 N_tc_actual = len(time) 

268 F_q_t_tot = np.zeros((n_qpoints, N_tc_actual)) 

269 S_q_w_tot = np.zeros((n_qpoints, N_tc_actual)) 

270 else: 

271 assert F_q_t.shape[1] == len(time) 

272 

273 data_dict_corr['omega'] = w 

274 data_dict_corr[f'Fqt_coh_{key}'] = F_q_t 

275 data_dict_corr[f'Sqw_coh_{key}'] = S_q_w 

276 

277 # sum all partials to the total 

278 F_q_t_tot += F_q_t 

279 S_q_w_tot += S_q_w 

280 

281 if N_tc_actual < N_tc: 

282 logger.warning('Truncating ACF due to NaNs, likely time_window is longer than length of Trajectory') # noqa 

283 

284 dw = float(w[1] - w[0]) 

285 w_max = float(w[-1]) 

286 

287 data_dict_corr['time'] = time 

288 data_dict_corr['Fqt_coh'] = F_q_t_tot 

289 data_dict_corr['Sqw_coh'] = S_q_w_tot 

290 

291 if calculate_currents: 

292 Cl_q_t_tot = np.zeros((n_qpoints, N_tc_actual)) 

293 Ct_q_t_tot = np.zeros((n_qpoints, N_tc_actual)) 

294 Cl_q_w_tot = np.zeros((n_qpoints, N_tc_actual)) 

295 Ct_q_w_tot = np.zeros((n_qpoints, N_tc_actual)) 

296 for pair in pairs: 

297 key = '_'.join(pair) 

298 Cl_q_t = 1 / traj.n_atoms * truncate_nans(Cl_q_t_averager[pair].get_average_all()) 

299 Ct_q_t = 1 / traj.n_atoms * truncate_nans(Ct_q_t_averager[pair].get_average_all()) 

300 _, Cl_q_w = psd_from_acf_2d(Cl_q_t, delta_t) 

301 _, Ct_q_w = psd_from_acf_2d(Ct_q_t, delta_t) 

302 data_dict_corr[f'Clqt_{key}'] = Cl_q_t 

303 data_dict_corr[f'Ctqt_{key}'] = Ct_q_t 

304 data_dict_corr[f'Clqw_{key}'] = Cl_q_w 

305 data_dict_corr[f'Ctqw_{key}'] = Ct_q_w 

306 

307 # sum all partials to the total 

308 Cl_q_t_tot += Cl_q_t 

309 Ct_q_t_tot += Ct_q_t 

310 Cl_q_w_tot += Cl_q_w 

311 Ct_q_w_tot += Ct_q_w 

312 data_dict_corr['Clqt'] = Cl_q_t_tot 

313 data_dict_corr['Ctqt'] = Ct_q_t_tot 

314 data_dict_corr['Clqw'] = Cl_q_w_tot 

315 data_dict_corr['Ctqw'] = Ct_q_w_tot 

316 

317 if calculate_incoherent: 

318 Fs_q_t_tot = np.zeros((n_qpoints, N_tc_actual)) 

319 Ss_q_w_tot = np.zeros((n_qpoints, N_tc_actual)) 

320 for atom_type in atom_types: 

321 Fs_q_t = 1 / traj.n_atoms * truncate_nans(F_s_q_t_averager[atom_type].get_average_all()) 

322 _, Ss_q_w = psd_from_acf_2d(Fs_q_t, delta_t) 

323 data_dict_corr[f'Fqt_incoh_{atom_type}'] = Fs_q_t 

324 data_dict_corr[f'Sqw_incoh_{atom_type}'] = Ss_q_w 

325 

326 # sum all partials to the total 

327 Fs_q_t_tot += Fs_q_t 

328 Ss_q_w_tot += Ss_q_w 

329 

330 data_dict_corr['Fqt_incoh'] = Fs_q_t_tot 

331 data_dict_corr['Sqw_incoh'] = Ss_q_w_tot 

332 

333 # finalize results with additional metadata 

334 new_sample = DynamicSample( 

335 data_dict_corr, 

336 simulation_data=dict( 

337 atom_types=atom_types, pairs=pairs, 

338 particle_counts=particle_counts, 

339 cell=traj.cell, 

340 time_between_frames=delta_t, 

341 maximum_time_lag=float(time[-1]), 

342 angular_frequency_resolution=dw, 

343 maximum_angular_frequency=w_max, 

344 number_of_frames=traj.number_of_frames_read, 

345 )) 

346 new_sample._append_history( 

347 'compute_dynamic_structure_factors', 

348 dict( 

349 dt=dt, 

350 window_size=window_size, 

351 window_step=window_step, 

352 calculate_currents=calculate_currents, 

353 calculate_incoherent=calculate_incoherent, 

354 )) 

355 

356 return new_sample 

357 

358 

359def compute_static_structure_factors( 

360 traj: Trajectory, 

361 q_points: NDArray[float], 

362 logging_interval: Optional[int] = 1000, 

363) -> StaticSample: 

364 r"""Compute the static structure factors. The results are returned in the 

365 form of a :class:`StaticSample <dynasor.sample.StaticSample>` 

366 object. 

367 

368 The computational backend and precision are set globally via 

369 :func:`set_compute_config <dynasor.set_compute_config>`, not as parameters of this 

370 function; see :ref:`the backends reference page <backends>` for details. 

371 

372 Parameters 

373 ---------- 

374 traj 

375 Input trajectory. 

376 q_points 

377 Array of q-points in units of rad/Å with shape ``(N_qpoints, 3)`` in Cartesian coordinates. 

378 logging_interval 

379 Log progress at ``INFO`` level every this many frames. Set to ``0`` to disable 

380 progress logging. 

381 """ 

382 # sanity check input args 

383 if q_points.ndim != 2 or q_points.shape[1] != 3: 

384 raise ValueError('q-points array has the wrong shape.') 

385 if not traj.has_positions: 

386 raise ValueError('compute_static_structure_factors requires positions to be available ' 

387 'in the trajectory, but traj does not provide positions.') 

388 _validate_qpoints_commensurate(q_points, traj.cell) 

389 

390 n_qpoints = q_points.shape[0] 

391 logger.info(f'Number of q-points: {n_qpoints}') 

392 

393 # define all pairs 

394 pairs = list(combinations_with_replacement(traj.atom_types, r=2)) 

395 particle_counts = {key: len(val) for key, val in traj.atomic_indices.items()} 

396 logger.debug('Considering pairs:') 

397 for pair in pairs: 

398 logger.debug(f' {pair}') 

399 

400 # processing function / batch processor 

401 compute_config = get_compute_config() 

402 backend = compute_config.backend 

403 precision = compute_config.precision 

404 gpu_batch_size = compute_config.batch_size 

405 

406 is_batched, processor = get_frame_processor(q_points, backend, precision, gpu_batch_size) 

407 

408 # setup averager 

409 Sq_averager = dict() 

410 for pair in pairs: 

411 Sq_averager[pair] = TimeAverager(1, n_qpoints) # time average with only timelag=0 

412 

413 # main loop: GPU uses batched prefetch, numba uses per-frame map 

414 _frame_source = (_prefetch_batched_iter(traj, processor, batch_size=gpu_batch_size) 

415 if is_batched 

416 else map(processor, traj)) 

417 

418 for frame in _frame_source: 

419 

420 if logging_interval and frame.frame_index % logging_interval == 0: 

421 logger.info(f'Processing frame {frame.frame_index}') 

422 else: 

423 logger.debug(f'Processing frame {frame.frame_index}') 

424 

425 for s1, s2 in pairs: 

426 # compute correlation 

427 Sq_pair = np.real(frame.rho_qs_dict[s1] * frame.rho_qs_dict[s2].conjugate()) 

428 if s1 != s2: 

429 Sq_pair += np.real(frame.rho_qs_dict[s2] * frame.rho_qs_dict[s1].conjugate()) 

430 Sq_averager[(s1, s2)].add_sample(0, Sq_pair) 

431 

432 # collect results 

433 data_dict = dict() 

434 data_dict['q_points'] = q_points 

435 S_q_tot = np.zeros((n_qpoints, 1)) 

436 for s1, s2 in pairs: 

437 Sq = 1 / traj.n_atoms * Sq_averager[(s1, s2)].get_average_at_timelag(0).reshape(-1, 1) 

438 data_dict[f'Sq_{s1}_{s2}'] = Sq 

439 S_q_tot += Sq 

440 data_dict['Sq'] = S_q_tot 

441 

442 # finalize results 

443 new_sample = StaticSample( 

444 data_dict, 

445 simulation_data=dict( 

446 atom_types=traj.atom_types, 

447 pairs=pairs, 

448 particle_counts=particle_counts, 

449 cell=traj.cell, 

450 number_of_frames=traj.number_of_frames_read, 

451 )) 

452 new_sample._append_history('compute_static_structure_factors') 

453 

454 return new_sample 

455 

456 

457def compute_spectral_energy_density( 

458 traj: Trajectory, 

459 ideal_supercell: Atoms, 

460 primitive_cell: Atoms, 

461 q_points: NDArray[float], 

462 dt: float, 

463 partial: Optional[bool] = False, 

464 logging_interval: Optional[int] = 1000, 

465) -> tuple[NDArray[float], NDArray[float]]: 

466 r""" 

467 Compute the spectral energy density (SED) at specific q-points. The results 

468 are returned in the form of a tuple, which comprises the angular 

469 frequencies in an array of length ``N_times`` in units of rad/fs and the 

470 SED in units of eV/(rad/fs) as an array of shape ``(N_qpoints, N_times)``. 

471 The normalization is chosen such that integrating the SED of a q-point 

472 together with the supplied angular frequencies omega (rad/fs) yields 

473 `1/2 kB T` * number of bands (where number of bands = `len(prim) * 3`) 

474 

475 More details can be found in Thomas *et al.*, Physical Review B **81**, 081411 (2010), 

476 which should be cited when using this function along with the dynasor reference. 

477 

478 **Note 1:** 

479 SED analysis is only suitable for crystalline materials without diffusion as 

480 atoms are assumed to move around fixed reference positions throughout the entire trajectory. 

481 

482 **Note 2:** 

483 This implementation reads the full trajectory and can thus consume a lot of memory. 

484 

485 Parameters 

486 ---------- 

487 traj 

488 Input trajectory. 

489 ideal_supercell 

490 Ideal structure defining the reference positions. Do not change the masses 

491 in the ASE :class:`~ase.Atoms` objects to dynasor internal units, this will be 

492 done internally. Its atom count is checked against :attr:`traj`; a mismatched 

493 cell only triggers a warning (some thermal expansion relative to the reference 

494 structure is normal), and the atom ordering is not checked at all, so it must 

495 match the trajectory's. 

496 primitive_cell 

497 Underlying primitive structure. Must be aligned correctly with :attr:`ideal_supercell`. 

498 q_points 

499 Array of q-points in units of rad/Å with shape ``(N_qpoints, 3)`` in Cartesian coordinates. 

500 dt 

501 Time difference in femtoseconds between two consecutive snapshots in 

502 the trajectory. Note that you should not change :attr:`dt` if you change 

503 :attr:`frame_step <dynasor.Trajectory.frame_step>` in :attr:`traj`. 

504 partial 

505 If True the SED will be returned decomposed per basis and Cartesian direction. 

506 The shape is ``(N_qpoints, N_frequencies, len(primitive_cell), 3)``. 

507 logging_interval 

508 Log progress at ``INFO`` level every this many frames. Set to ``0`` to disable 

509 progress logging. 

510 """ 

511 

512 if q_points.ndim != 2 or q_points.shape[1] != 3: 

513 raise ValueError('q-points array has the wrong shape.') 

514 if dt <= 0: 

515 raise ValueError(f'dt must be positive: dt= {dt}') 

516 

517 delta_t = traj.frame_step * dt 

518 

519 # logger 

520 logger.info('Running SED') 

521 logger.info(f'Time between consecutive frames (dt * frame_step): {delta_t} fs') 

522 logger.info(f'Number of atoms in primitive_cell: {len(primitive_cell)}') 

523 logger.info(f'Number of atoms in ideal_supercell: {len(ideal_supercell)}') 

524 logger.info(f'Number of q-points: {q_points.shape[0]}') 

525 

526 # check that the ideal supercell agrees with traj 

527 if traj.n_atoms != len(ideal_supercell): 

528 raise ValueError('ideal_supercell must contain the same number of atoms as the trajectory.') 

529 if not np.allclose(traj.cell, ideal_supercell.cell, atol=1e-5, rtol=0.0): 

530 logger.warning('ideal_supercell cell does not match the trajectory cell.') 

531 

532 if len(primitive_cell) > len(ideal_supercell): 

533 raise ValueError('primitive_cell contains more atoms than ideal_supercell.') 

534 

535 if not traj.has_velocities: 

536 raise ValueError('compute_spectral_energy_density requires velocities to be available ' 

537 'in the trajectory, but traj does not provide velocities.') 

538 

539 # q-points must be commensurate with the ideal supercell (which defines the 

540 # phase factors below), not with the possibly thermally-expanded traj cell 

541 _validate_qpoints_commensurate(q_points, ideal_supercell.cell) 

542 

543 # collect all velocities, and scale with sqrt(masses) 

544 masses = ideal_supercell.get_masses().reshape(-1, 1) / fs**2 # From Dalton to dmu 

545 velocities = [] 

546 for it, frame in enumerate(traj): 

547 if logging_interval and it % logging_interval == 0: 

548 logger.info(f'Reading frame {it}') 

549 else: 

550 logger.debug(f'Reading frame {it}') 

551 v = frame.get_velocities_as_array(traj.atomic_indices) # in Å/fs 

552 velocities.append(np.sqrt(masses) * v) 

553 logger.info(f'Number of snapshots: {len(velocities)}') 

554 

555 # Perform the FFT on the last axis for extra speed (maybe not needed) 

556 N_samples = len(velocities) 

557 velocities = np.array(velocities) 

558 # places time index last and makes a copy for continuity 

559 velocities = velocities.transpose(1, 2, 0).copy() 

560 # #atoms in supercell x 3 directions x #frequencies 

561 velocities = np.fft.rfft(velocities, axis=2) 

562 

563 # Calculate indices and offsets needed for the SED method 

564 offsets, indices = get_offset_index(primitive_cell, ideal_supercell) 

565 

566 # Phase factor for use in FT. #qpoints x #atoms in supercell 

567 cell_positions = np.dot(offsets, primitive_cell.cell) 

568 phase = np.dot(q_points, cell_positions.T) # #qpoints x #unit cells 

569 phase_factors = np.exp(1.0j * phase) 

570 

571 # This dict maps the offsets to an index so ndarrays can be over 

572 # offset,index instead of atoms in supercell 

573 offset_dict = {off: n for n, off in enumerate(set(tuple(offset) for offset in offsets))} 

574 

575 # Pick out some shapes 

576 n_super, _, n_w = velocities.shape 

577 n_qpts = len(q_points) 

578 n_prim = len(primitive_cell) 

579 n_offsets = len(offset_dict) 

580 

581 # This new array will be indexed by index and offset instead (and also transposed) 

582 new_velocities = np.zeros((n_w, 3, n_prim, n_offsets), dtype=velocities.dtype) 

583 

584 for i in range(n_super): 

585 j = indices[i] # atom with index i in the supercell is of basis type j ... 

586 n = offset_dict[tuple(offsets[i])] # and its offset has index n 

587 new_velocities[:, :, j, n] = velocities[i].T 

588 

589 velocities = new_velocities 

590 

591 # Same story with the spatial phase factors 

592 new_phase_factors = np.zeros((n_qpts, n_prim, n_offsets), dtype=phase_factors.dtype) 

593 

594 for i in range(n_super): 

595 j = indices[i] 

596 n = offset_dict[tuple(offsets[i])] 

597 new_phase_factors[:, j, n] = phase_factors[:, i] 

598 

599 phase_factors = new_phase_factors 

600 

601 # calculate the density in a numba function 

602 density = _sed_inner_loop(phase_factors, velocities) 

603 warn_if_numba_threading_layer_is_slow() 

604 

605 if not partial: 

606 density = np.sum(density, axis=(2, 3)) 

607 

608 # units 

609 # make so that the velocities were originally in Angstrom / fs to be compatible with eV and Da 

610 

611 # the time delta in the fourier transform 

612 density = density * delta_t**2 

613 

614 # Divide by the length of the time signal 

615 density = density / (N_samples * delta_t) 

616 

617 # Divide by the number of primitive cells 

618 density = density / (n_super / n_prim) 

619 

620 # Factor so the sed can be integrated together with the returned omega 

621 # numpy fft works with ordinary/linear frequencies and not angular freqs 

622 density = density / (2*np.pi) 

623 

624 # angular frequencies 

625 w = 2 * np.pi * np.fft.rfftfreq(N_samples, delta_t) # rad/fs 

626 

627 return w, density 

628 

629 

630@numba.njit(parallel=True, fastmath=True) 

631def _sed_inner_loop(phase_factors, velocities): 

632 """This numba function calculates the spatial 

633 Fourier transform using precomputed phase factors. 

634 

635 As the use case can be one or many q-points the parallelization is over the 

636 temporal frequency components instead. 

637 """ 

638 

639 n_qpts = phase_factors.shape[0] # q-point index 

640 n_prim = phase_factors.shape[1] # basis atom index 

641 n_super = phase_factors.shape[2] # unit cell index 

642 

643 n_freqs = velocities.shape[0] # frequency, direction, basis atom, unit cell 

644 

645 density = np.zeros((n_qpts, n_freqs, n_prim, 3), dtype=np.float64) 

646 

647 for w in numba.prange(n_freqs): 

648 for k in range(n_qpts): 

649 for a in range(3): 

650 for b in range(n_prim): 

651 tmp = 0.0j 

652 for n in range(n_super): 

653 tmp += phase_factors[k, b, n] * velocities[w, a, b, n] 

654 density[k, w, b, a] += np.abs(tmp)**2 

655 return density