Source code for dynasor.correlation_functions

import concurrent.futures
import functools
from itertools import combinations_with_replacement
from typing import Optional

import numba
import numpy as np
from ase import Atoms
from ase.units import fs
from numpy.typing import NDArray

from dynasor.logging_tools import logger
from dynasor.trajectory import Trajectory, WindowIterator
from dynasor.sample import DynamicSample, StaticSample

from dynasor.tools.acfs import psd_from_acf_2d
from dynasor.core.time_averager import TimeAverager, truncate_nans
from dynasor.core.reciprocal import calc_rho_q, calc_rho_j_q
from dynasor.qpoints.tools import is_qpoint_commensurate
from dynasor.tools.structures import get_offset_index
from dynasor.units import radians_per_fs_to_meV


def _validate_qpoints_commensurate(q_points: NDArray[float], cell: NDArray[float]) -> None:
    """Raise if any q-point is not commensurate with the simulation cell."""
    n_bad = sum(not is_qpoint_commensurate(q, cell) for q in q_points)
    if n_bad > 0:
        raise ValueError(
            f'{n_bad} of {len(q_points)} q-points are not commensurate with the cell; use '
            'get_spherical_qpoints or get_supercell_qpoints_along_path to generate '
            'commensurate q-points.')


[docs] def compute_dynamic_structure_factors( traj: Trajectory, q_points: NDArray[float], dt: float, window_size: int, window_step: Optional[int] = 1, calculate_currents: Optional[bool] = False, calculate_incoherent: Optional[bool] = False, logging_interval: Optional[int] = 1000, ) -> DynamicSample: r"""Compute the dynamic structure factors. The results are returned in the form of a :class:`DynamicSample <dynasor.sample.DynamicSample>` object. Parameters ---------- traj Input trajectory. q_points Array of q-points in units of rad/Å with shape ``(N_qpoints, 3)`` in Cartesian coordinates. dt Time difference in femtoseconds between two consecutive snapshots in the trajectory. Note that you should *not* change :attr:`dt` if you change :attr:`frame_step <dynasor.trajectory.Trajectory.frame_step>` in :attr:`traj`. window_size Maximum time lag, expressed as a number of frame intervals, for which to calculate correlations. Each calculation window therefore contains ``window_size + 1`` frames, including the frame at time lag zero. This parameter determines the smallest frequency resolved. window_step Window step (or stride) given as the number of frames between consecutive trajectory windows. This parameter does *not* affect the time between consecutive frames in the calculation. If :attr:`window_step` > :attr:`window_size` + 1, some frames will not be used. calculate_currents Calculate the current correlations. Requires velocities to be available in :attr:`traj`. calculate_incoherent Calculate the incoherent part (self-part) of :math:`F_\text{incoh}`. logging_interval Log progress at ``INFO`` level every this many windows. Set to ``0`` to disable progress logging. """ # sanity check input args if q_points.ndim != 2 or q_points.shape[1] != 3: raise ValueError('q-points array has the wrong shape.') if dt <= 0: raise ValueError(f'dt must be positive: dt= {dt}') if window_size <= 2: raise ValueError(f'window_size must be larger than 2: window_size= {window_size}') if window_step <= 0: raise ValueError(f'window_step must be positive: window_step= {window_step}') if calculate_currents and not traj.has_velocities: raise ValueError('calculate_currents=True requires velocities to be available in the ' 'trajectory, but traj does not provide velocities.') _validate_qpoints_commensurate(q_points, traj.cell) # define internal parameters n_qpoints = q_points.shape[0] delta_t = traj.frame_step * dt N_tc = window_size + 1 # log all setup information n_fft = 2 * window_size + 1 dw = 2 * np.pi / (n_fft * delta_t) w_max = window_size * dw w_N = np.pi / delta_t dw_mev = dw * radians_per_fs_to_meV w_max_mev = w_max * radians_per_fs_to_meV w_N_mev = w_N * radians_per_fs_to_meV logger.info(f'Spacing between samples (frame_step): {traj.frame_step}') logger.info(f'Time between consecutive frames in input trajectory (dt): {dt} fs') logger.info(f'Time between consecutive frames used (dt * frame_step): {delta_t} fs') logger.info(f'Time window size (dt * frame_step * window_size): {delta_t * window_size:.1f} fs') logger.info(f'Angular frequency resolution: dw = {dw:.6f} rad/fs = {dw_mev:.3f} meV') logger.info(f'Maximum angular frequency: {w_max:.6f} rad/fs = {w_max_mev:.3f} meV ' f'(Nyquist limit: {w_N:.6f} rad/fs = {w_N_mev:.3f} meV)') if calculate_currents: logger.info('Calculating current (velocity) correlations') if calculate_incoherent: logger.info('Calculating incoherent part (self-part) of correlations') # log some info regarding q-points logger.info(f'Number of q-points: {n_qpoints}') q_directions = q_points.astype(float, copy=True) q_distances = np.linalg.norm(q_points, axis=1) nonzero = q_distances > 0 q_directions[nonzero] /= q_distances[nonzero].reshape(-1, 1) # setup functions to process frames def f2_rho(frame): rho_qs_dict = dict() for atom_type in frame.positions_by_type.keys(): x = frame.positions_by_type[atom_type] rho_qs_dict[atom_type] = calc_rho_q(x, q_points) frame.rho_qs_dict = rho_qs_dict return frame def f2_rho_and_j(frame): rho_qs_dict = dict() jz_qs_dict = dict() jper_qs_dict = dict() for atom_type in frame.positions_by_type.keys(): x = frame.positions_by_type[atom_type] v = frame.velocities_by_type[atom_type] rho_qs, j_qs = calc_rho_j_q(x, v, q_points) jz_qs = np.sum(j_qs * q_directions, axis=1) jper_qs = j_qs - (jz_qs[:, None] * q_directions) rho_qs_dict[atom_type] = rho_qs jz_qs_dict[atom_type] = jz_qs jper_qs_dict[atom_type] = jper_qs frame.rho_qs_dict = rho_qs_dict frame.jz_qs_dict = jz_qs_dict frame.jper_qs_dict = jper_qs_dict return frame if calculate_currents: element_processor = f2_rho_and_j else: element_processor = f2_rho # setup window iterator window_iterator = WindowIterator(traj, width=N_tc, window_step=window_step, element_processor=element_processor) # define all atom types and pairs atom_types = traj.atom_types pairs = list(combinations_with_replacement(atom_types, r=2)) particle_counts = {key: len(val) for key, val in traj.atomic_indices.items()} logger.debug('Considering pairs:') for pair in pairs: logger.debug(f' {pair}') # set up all time averager instances F_q_t_averager = dict() for pair in pairs: F_q_t_averager[pair] = TimeAverager(N_tc, n_qpoints) if calculate_currents: Cl_q_t_averager = dict() Ct_q_t_averager = dict() for pair in pairs: Cl_q_t_averager[pair] = TimeAverager(N_tc, n_qpoints) Ct_q_t_averager[pair] = TimeAverager(N_tc, n_qpoints) if calculate_incoherent: F_s_q_t_averager = dict() for pair in atom_types: F_s_q_t_averager[pair] = TimeAverager(N_tc, n_qpoints) # define correlation function # # Note: calc_corr is dispatched concurrently across OS threads via # ThreadPoolExecutor.map() below, so it must never call into a numba # parallel=True/prange kernel (e.g. calc_rho_q, calc_rho_j_q). Numba's # default threading layer is not safe against concurrent entry from # multiple Python threads and aborts the process if this happens. Such # calls belong in calc_incoherent instead, which is always run strictly # sequentially in the main thread. def calc_corr(window, time_i): # Calculate correlations between two frames in the window without normalization 1/N f0 = window[0] fi = window[time_i] for s1, s2 in pairs: Fqt = np.real(f0.rho_qs_dict[s1] * fi.rho_qs_dict[s2].conjugate()) if s1 != s2: Fqt += np.real(f0.rho_qs_dict[s2] * fi.rho_qs_dict[s1].conjugate()) F_q_t_averager[(s1, s2)].add_sample(time_i, Fqt) if calculate_currents: for s1, s2 in pairs: Clqt = np.real(f0.jz_qs_dict[s1] * fi.jz_qs_dict[s2].conjugate()) Ctqt = 0.5 * np.real(np.sum(f0.jper_qs_dict[s1] * fi.jper_qs_dict[s2].conjugate(), axis=1)) if s1 != s2: Clqt += np.real(f0.jz_qs_dict[s2] * fi.jz_qs_dict[s1].conjugate()) Ctqt += 0.5 * np.real(np.sum(f0.jper_qs_dict[s2] * fi.jper_qs_dict[s1].conjugate(), axis=1)) Cl_q_t_averager[(s1, s2)].add_sample(time_i, Clqt) Ct_q_t_averager[(s1, s2)].add_sample(time_i, Ctqt) def calc_incoherent(window, time_i): # Calculate the incoherent (self) part between two frames in the window. # # calc_rho_q is backed by a numba parallel=True/prange kernel, which must # not be entered concurrently from multiple Python threads (see the note # on calc_corr above). This function is therefore always called from a # plain sequential loop in the main thread, never from within the # ThreadPoolExecutor used for calc_corr. f0 = window[0] fi = window[time_i] for atom_type in atom_types: xi = fi.positions_by_type[atom_type] x0 = f0.positions_by_type[atom_type] Fsqt = np.real(calc_rho_q(xi - x0, q_points)) F_s_q_t_averager[atom_type].add_sample(time_i, Fsqt) # run calculation with concurrent.futures.ThreadPoolExecutor() as tpe: # This is the "main loop" over the trajectory for window in window_iterator: if logging_interval and window[0].frame_index % logging_interval == 0: logger.info(f'Processing window {window[0].frame_index} to {window[-1].frame_index}') # noqa else: logger.debug(f'Processing window {window[0].frame_index} to {window[-1].frame_index}') # noqa # The map conveniently applies calc_corr to all time-lags. However, # as everything is done in place nothing gets returned so in order # to start and wait for the processes to finish we must iterate # over the None values returned for _ in tpe.map(functools.partial(calc_corr, window), range(len(window))): pass # Run the incoherent part strictly sequentially in the main thread # (see calc_incoherent for why it must not run inside the thread pool). if calculate_incoherent: for time_i in range(len(window)): calc_incoherent(window, time_i) # collect results into dict with numpy arrays (n_qpoints, N_tc) data_dict_corr = dict() data_dict_corr['q_points'] = q_points time = None for pair in pairs: key = '_'.join(pair) F_q_t = 1 / traj.n_atoms * truncate_nans(F_q_t_averager[pair].get_average_all()) w, S_q_w = psd_from_acf_2d(F_q_t, delta_t) S_q_w = np.array(S_q_w) if time is None: # Determine the actual length of time signal after truncation (if needed) time = delta_t * np.arange(F_q_t.shape[1], dtype=float) N_tc_actual = len(time) F_q_t_tot = np.zeros((n_qpoints, N_tc_actual)) S_q_w_tot = np.zeros((n_qpoints, N_tc_actual)) else: assert F_q_t.shape[1] == len(time) data_dict_corr['omega'] = w data_dict_corr[f'Fqt_coh_{key}'] = F_q_t data_dict_corr[f'Sqw_coh_{key}'] = S_q_w # sum all partials to the total F_q_t_tot += F_q_t S_q_w_tot += S_q_w if N_tc_actual < N_tc: logger.warning('Truncating ACF due to NaNs, likely time_window is longer than length of Trajectory') # noqa dw = float(w[1] - w[0]) w_max = float(w[-1]) data_dict_corr['time'] = time data_dict_corr['Fqt_coh'] = F_q_t_tot data_dict_corr['Sqw_coh'] = S_q_w_tot if calculate_currents: Cl_q_t_tot = np.zeros((n_qpoints, N_tc_actual)) Ct_q_t_tot = np.zeros((n_qpoints, N_tc_actual)) Cl_q_w_tot = np.zeros((n_qpoints, N_tc_actual)) Ct_q_w_tot = np.zeros((n_qpoints, N_tc_actual)) for pair in pairs: key = '_'.join(pair) Cl_q_t = 1 / traj.n_atoms * truncate_nans(Cl_q_t_averager[pair].get_average_all()) Ct_q_t = 1 / traj.n_atoms * truncate_nans(Ct_q_t_averager[pair].get_average_all()) _, Cl_q_w = psd_from_acf_2d(Cl_q_t, delta_t) _, Ct_q_w = psd_from_acf_2d(Ct_q_t, delta_t) data_dict_corr[f'Clqt_{key}'] = Cl_q_t data_dict_corr[f'Ctqt_{key}'] = Ct_q_t data_dict_corr[f'Clqw_{key}'] = Cl_q_w data_dict_corr[f'Ctqw_{key}'] = Ct_q_w # sum all partials to the total Cl_q_t_tot += Cl_q_t Ct_q_t_tot += Ct_q_t Cl_q_w_tot += Cl_q_w Ct_q_w_tot += Ct_q_w data_dict_corr['Clqt'] = Cl_q_t_tot data_dict_corr['Ctqt'] = Ct_q_t_tot data_dict_corr['Clqw'] = Cl_q_w_tot data_dict_corr['Ctqw'] = Ct_q_w_tot if calculate_incoherent: Fs_q_t_tot = np.zeros((n_qpoints, N_tc_actual)) Ss_q_w_tot = np.zeros((n_qpoints, N_tc_actual)) for atom_type in atom_types: Fs_q_t = 1 / traj.n_atoms * truncate_nans(F_s_q_t_averager[atom_type].get_average_all()) _, Ss_q_w = psd_from_acf_2d(Fs_q_t, delta_t) data_dict_corr[f'Fqt_incoh_{atom_type}'] = Fs_q_t data_dict_corr[f'Sqw_incoh_{atom_type}'] = Ss_q_w # sum all partials to the total Fs_q_t_tot += Fs_q_t Ss_q_w_tot += Ss_q_w data_dict_corr['Fqt_incoh'] = Fs_q_t_tot data_dict_corr['Sqw_incoh'] = Ss_q_w_tot # finalize results with additional metadata new_sample = DynamicSample( data_dict_corr, simulation_data=dict( atom_types=atom_types, pairs=pairs, particle_counts=particle_counts, cell=traj.cell, time_between_frames=delta_t, maximum_time_lag=float(time[-1]), angular_frequency_resolution=dw, maximum_angular_frequency=w_max, number_of_frames=traj.number_of_frames_read, )) new_sample._append_history( 'compute_dynamic_structure_factors', dict( dt=dt, window_size=window_size, window_step=window_step, calculate_currents=calculate_currents, calculate_incoherent=calculate_incoherent, )) return new_sample
[docs] def compute_static_structure_factors( traj: Trajectory, q_points: NDArray[float], logging_interval: Optional[int] = 1000, ) -> StaticSample: r"""Compute the static structure factors. The results are returned in the form of a :class:`StaticSample <dynasor.sample.StaticSample>` object. Parameters ---------- traj Input trajectory. q_points Array of q-points in units of rad/Å with shape ``(N_qpoints, 3)`` in Cartesian coordinates. logging_interval Log progress at ``INFO`` level every this many frames. Set to ``0`` to disable progress logging. """ # sanity check input args if q_points.ndim != 2 or q_points.shape[1] != 3: raise ValueError('q-points array has the wrong shape.') _validate_qpoints_commensurate(q_points, traj.cell) n_qpoints = q_points.shape[0] logger.info(f'Number of q-points: {n_qpoints}') # define all pairs pairs = list(combinations_with_replacement(traj.atom_types, r=2)) particle_counts = {key: len(val) for key, val in traj.atomic_indices.items()} logger.debug('Considering pairs:') for pair in pairs: logger.debug(f' {pair}') # processing function def f2_rho(frame): rho_qs_dict = dict() for atom_type in frame.positions_by_type.keys(): x = frame.positions_by_type[atom_type] rho_qs_dict[atom_type] = calc_rho_q(x, q_points) frame.rho_qs_dict = rho_qs_dict return frame # setup averager Sq_averager = dict() for pair in pairs: Sq_averager[pair] = TimeAverager(1, n_qpoints) # time average with only timelag=0 # main loop for frame in traj: # process_frame f2_rho(frame) if logging_interval and frame.frame_index % logging_interval == 0: logger.info(f'Processing frame {frame.frame_index}') else: logger.debug(f'Processing frame {frame.frame_index}') for s1, s2 in pairs: # compute correlation Sq_pair = np.real(frame.rho_qs_dict[s1] * frame.rho_qs_dict[s2].conjugate()) if s1 != s2: Sq_pair += np.real(frame.rho_qs_dict[s2] * frame.rho_qs_dict[s1].conjugate()) Sq_averager[(s1, s2)].add_sample(0, Sq_pair) # collect results data_dict = dict() data_dict['q_points'] = q_points S_q_tot = np.zeros((n_qpoints, 1)) for s1, s2 in pairs: Sq = 1 / traj.n_atoms * Sq_averager[(s1, s2)].get_average_at_timelag(0).reshape(-1, 1) data_dict[f'Sq_{s1}_{s2}'] = Sq S_q_tot += Sq data_dict['Sq'] = S_q_tot # finalize results new_sample = StaticSample( data_dict, simulation_data=dict( atom_types=traj.atom_types, pairs=pairs, particle_counts=particle_counts, cell=traj.cell, number_of_frames=traj.number_of_frames_read, )) new_sample._append_history('compute_static_structure_factors') return new_sample
[docs] def compute_spectral_energy_density( traj: Trajectory, ideal_supercell: Atoms, primitive_cell: Atoms, q_points: NDArray[float], dt: float, partial: Optional[bool] = False, logging_interval: Optional[int] = 1000, ) -> tuple[NDArray[float], NDArray[float]]: r""" Compute the spectral energy density (SED) at specific q-points. The results are returned in the form of a tuple, which comprises the angular frequencies in an array of length ``N_times`` in units of rad/fs and the SED in units of eV/(rad/fs) as an array of shape ``(N_qpoints, N_times)``. The normalization is chosen such that integrating the SED of a q-point together with the supplied angular frequencies omega (rad/fs) yields `1/2 kB T` * number of bands (where number of bands = `len(prim) * 3`) More details can be found in Thomas *et al.*, Physical Review B **81**, 081411 (2010), which should be cited when using this function along with the dynasor reference. **Note 1:** SED analysis is only suitable for crystalline materials without diffusion as atoms are assumed to move around fixed reference positions throughout the entire trajectory. **Note 2:** This implementation reads the full trajectory and can thus consume a lot of memory. Parameters ---------- traj Input trajectory. ideal_supercell Ideal structure defining the reference positions. Do not change the masses in the ASE :class:`Atoms` objects to dynasor internal units, this will be done internally. Its atom count is checked against :attr:`traj`; a mismatched cell only triggers a warning (some thermal expansion relative to the reference structure is normal), and the atom ordering is not checked at all, so it must match the trajectory's. primitive_cell Underlying primitive structure. Must be aligned correctly with :attr:`ideal_supercell`. q_points Array of q-points in units of rad/Å with shape ``(N_qpoints, 3)`` in Cartesian coordinates. dt Time difference in femtoseconds between two consecutive snapshots in the trajectory. Note that you should not change :attr:`dt` if you change :attr:`frame_step <dynasor.trajectory.Trajectory.frame_step>` in :attr:`traj`. partial If True the SED will be returned decomposed per basis and Cartesian direction. The shape is ``(N_qpoints, N_frequencies, len(primitive_cell), 3)``. logging_interval Log progress at ``INFO`` level every this many frames. Set to ``0`` to disable progress logging. """ if q_points.ndim != 2 or q_points.shape[1] != 3: raise ValueError('q-points array has the wrong shape.') if dt <= 0: raise ValueError(f'dt must be positive: dt= {dt}') delta_t = traj.frame_step * dt # logger logger.info('Running SED') logger.info(f'Time between consecutive frames (dt * frame_step): {delta_t} fs') logger.info(f'Number of atoms in primitive_cell: {len(primitive_cell)}') logger.info(f'Number of atoms in ideal_supercell: {len(ideal_supercell)}') logger.info(f'Number of q-points: {q_points.shape[0]}') # check that the ideal supercell agrees with traj if traj.n_atoms != len(ideal_supercell): raise ValueError('ideal_supercell must contain the same number of atoms as the trajectory.') if not np.allclose(traj.cell, ideal_supercell.cell, atol=1e-5, rtol=0.0): logger.warning('ideal_supercell cell does not match the trajectory cell.') if len(primitive_cell) > len(ideal_supercell): raise ValueError('primitive_cell contains more atoms than ideal_supercell.') if not traj.has_velocities: raise ValueError('compute_spectral_energy_density requires velocities to be available ' 'in the trajectory, but traj does not provide velocities.') # q-points must be commensurate with the ideal supercell (which defines the # phase factors below), not with the possibly thermally-expanded traj cell _validate_qpoints_commensurate(q_points, ideal_supercell.cell) # collect all velocities, and scale with sqrt(masses) masses = ideal_supercell.get_masses().reshape(-1, 1) / fs**2 # From Dalton to dmu velocities = [] for it, frame in enumerate(traj): if logging_interval and it % logging_interval == 0: logger.info(f'Reading frame {it}') else: logger.debug(f'Reading frame {it}') v = frame.get_velocities_as_array(traj.atomic_indices) # in Å/fs velocities.append(np.sqrt(masses) * v) logger.info(f'Number of snapshots: {len(velocities)}') # Perform the FFT on the last axis for extra speed (maybe not needed) N_samples = len(velocities) velocities = np.array(velocities) # places time index last and makes a copy for continuity velocities = velocities.transpose(1, 2, 0).copy() # #atoms in supercell x 3 directions x #frequencies velocities = np.fft.rfft(velocities, axis=2) # Calculate indices and offsets needed for the SED method offsets, indices = get_offset_index(primitive_cell, ideal_supercell) # Phase factor for use in FT. #qpoints x #atoms in supercell cell_positions = np.dot(offsets, primitive_cell.cell) phase = np.dot(q_points, cell_positions.T) # #qpoints x #unit cells phase_factors = np.exp(1.0j * phase) # This dict maps the offsets to an index so ndarrays can be over # offset,index instead of atoms in supercell offset_dict = {off: n for n, off in enumerate(set(tuple(offset) for offset in offsets))} # Pick out some shapes n_super, _, n_w = velocities.shape n_qpts = len(q_points) n_prim = len(primitive_cell) n_offsets = len(offset_dict) # This new array will be indexed by index and offset instead (and also transposed) new_velocities = np.zeros((n_w, 3, n_prim, n_offsets), dtype=velocities.dtype) for i in range(n_super): j = indices[i] # atom with index i in the supercell is of basis type j ... n = offset_dict[tuple(offsets[i])] # and its offset has index n new_velocities[:, :, j, n] = velocities[i].T velocities = new_velocities # Same story with the spatial phase factors new_phase_factors = np.zeros((n_qpts, n_prim, n_offsets), dtype=phase_factors.dtype) for i in range(n_super): j = indices[i] n = offset_dict[tuple(offsets[i])] new_phase_factors[:, j, n] = phase_factors[:, i] phase_factors = new_phase_factors # calculate the density in a numba function density = _sed_inner_loop(phase_factors, velocities) if not partial: density = np.sum(density, axis=(2, 3)) # units # make so that the velocities were originally in Angstrom / fs to be compatible with eV and Da # the time delta in the fourier transform density = density * delta_t**2 # Divide by the length of the time signal density = density / (N_samples * delta_t) # Divide by the number of primitive cells density = density / (n_super / n_prim) # Factor so the sed can be integrated together with the returned omega # numpy fft works with ordinary/linear frequencies and not angular freqs density = density / (2*np.pi) # angular frequencies w = 2 * np.pi * np.fft.rfftfreq(N_samples, delta_t) # rad/fs return w, density
@numba.njit(parallel=True, fastmath=True) def _sed_inner_loop(phase_factors, velocities): """This numba function calculates the spatial Fourier transform using precomputed phase factors. As the use case can be one or many q-points the parallelization is over the temporal frequency components instead. """ n_qpts = phase_factors.shape[0] # q-point index n_prim = phase_factors.shape[1] # basis atom index n_super = phase_factors.shape[2] # unit cell index n_freqs = velocities.shape[0] # frequency, direction, basis atom, unit cell density = np.zeros((n_qpts, n_freqs, n_prim, 3), dtype=np.float64) for w in numba.prange(n_freqs): for k in range(n_qpts): for a in range(3): for b in range(n_prim): tmp = 0.0j for n in range(n_super): tmp += phase_factors[k, b, n] * velocities[w, a, b, n] density[k, w, b, a] += np.abs(tmp)**2 return density