Source code for strongcoca.response.utilities

# This module contains components adapted from GPAW:
# gpaw.tddft.spectrum
# https://gitlab.com/gpaw/gpaw/-/blob/aca9ed6f520d6a855013247119b50c630f5eebc9/gpaw/tddft/spectrum.py

"""This module provides functionality for transforming time and
frequency-series including Lorentzian and Gaussian broadening.
"""
import numpy as np
from scipy.special import dawsn

from ..units import au_to_eV, eV_to_au


class Broadening:
    """Class representing broadening types of spectra."""
    def __repr__(self) -> str:
        return f'{self.__class__.__name__}'


[docs] class NoArtificialBroadening(Broadening): """Class representing no artificial broadening."""
[docs] class ArtificialBroadening(Broadening): """Class representing artificial broadenings. Parameters ---------- width Broadening width; meaning interpreted by the subclasses; must be strictly positive. Units are eV by default, atomic units can be selected via :attr:`units`. units `eV` to specify :attr:`width` in eV or `au` to specify :attr:`width` in atomic units. This parameter determines whether conversion should be performed during initialization and has no effect on instance methods and variables. """ def __init__(self, width: float, units: str = 'eV') -> None: if width <= 0: raise ValueError('width must be strictly positive') if units == 'eV': width = width * eV_to_au elif units != 'au': raise ValueError(f"units has to be 'eV' or 'au', not '{units}'") self._width = width def __repr__(self) -> str: return f"{self.__class__.__name__}({self.width}, units='eV')" @property def width(self) -> float: """Broadening width in units of eV.""" return self._width * au_to_eV
[docs] class GaussianBroadening(ArtificialBroadening): r"""Class for representing artificial Gaussian broadening of spectra. Parameters ---------- width Broadening width (:math:`\sigma`); must be strictly positive. Units are eV by default, atomic units can be selected via :attr:`units`. units `eV` to specify :attr:`width` in eV or `au` to specify :attr:`width` in atomic units. This parameter determines whether conversion should be performed during initialization and has no effect on instance methods and variables. Examples ------- >>> from strongcoca.response.utilities import GaussianBroadening >>> GaussianBroadening(0.1) GaussianBroadening(0.1, units='eV') >>> GaussianBroadening(0.005, units='au') GaussianBroadening(0.136..., units='eV') """ def __init__(self, width: float, units: str = 'eV') -> None: super().__init__(width=width, units=units)
[docs] class LorentzianBroadening(ArtificialBroadening): r"""Class for representing artificial Lorentzian broadening of spectra. Parameters ---------- width Broadening width (:math:`\eta`); must be strictly positive. Units are eV by default, atomic units can be selected via :attr:`units`. units `eV` to specify :attr:`width` in eV or `au` to specify :attr:`width` in atomic units. This parameter determines whether conversion should be performed during initialization and has no effect on instance methods and variables. Examples ------- >>> from strongcoca.response.utilities import LorentzianBroadening >>> LorentzianBroadening(0.1) LorentzianBroadening(0.1, units='eV') >>> LorentzianBroadening(0.005, units='au') LorentzianBroadening(0.136..., units='eV') """ def __init__(self, width: float, units: str = 'eV') -> None: super().__init__(width=width, units=units)
def fourier_transform(time_t: np.ndarray, data_tX: np.ndarray, freq_w: np.ndarray, freq_axis: str = 'real', broadening: Broadening = NoArtificialBroadening()) -> np.ndarray: r"""Calculates the Fourier transform of a time-series data according to .. math:: y_X(\omega) = \int_{t_\text{min}}^{t_\text{max}} y_X(t) e^{i \omega t} \mathrm{d}t The integral is evaluated as such when broadening is not specified. Lorentzian broadening corresponds to .. math:: e^{i \omega t} \to e^{i (\omega + i \eta) t} = e^{i \omega t} e^{- \eta t} Gaussian broadening corresponds to .. math:: e^{i \omega t} \to e^{i \omega t} e^{- \frac{1}{2}\sigma^2 t^2} This function does not carry out unit conversions, but all parameters are assumed to be in mutually compatible units. Parameters ---------- time_t Equally spaced time grid. data_tX Data to be transformed. The first dimension must be of same size as `time_t`. Other dimensions can be of any size. freq_w Frequency values for the transform. freq_axis : `'real'` or `'imag'` Interpretation of frequency values as real or imaginary frequencies. broadening Broadening to be used. Notes ----- When working with imaginary axes `fourier_transform(..., freq_w=1j * freq_w, freq_axis='real')` and `fourier_transform(..., freq_w=freq_w, freq_axis='imag')` yield similar results, yet the latter is computationally more efficient when `freq_w` is real. Returns ------- :class:`np.ndarray` Transform of input data to frequency. The first dimension is of the same size as `freq_w`. Other dimensions are the same as in input data `data_tX`. """ if time_t.ndim != 1: raise ValueError('time_t must be one-dimensional array') if data_tX.shape[0] != time_t.shape[0]: raise ValueError('data_tX must have compatible shape with time_t') if freq_axis == 'real': prefactor = 1.0j if isinstance(broadening, NoArtificialBroadening): weight_t = np.ones_like(time_t) elif isinstance(broadening, LorentzianBroadening): weight_t = np.exp(-broadening._width * time_t) elif isinstance(broadening, GaussianBroadening): weight_t = np.exp(-0.5 * broadening._width**2 * time_t**2) else: raise ValueError(f'Unknown broadening: {broadening}') elif freq_axis == 'imag': prefactor = -1.0 weight_t = np.ones_like(time_t) if not isinstance(broadening, NoArtificialBroadening): raise ValueError('Artificial broadening is not applied to imaginary frequencies') else: raise ValueError(f'Unknown frequency axis: {freq_axis}') # check time step dt_t = time_t[1:] - time_t[:-1] dt = dt_t[0] if not np.allclose(dt_t, dt, rtol=1e-6, atol=0): raise ValueError('Time grid must be equally spaced.') # integration weights from Simpson's integration rule weight_t *= dt / 3 * np.array([1] + [4, 2] * int((len(time_t) - 2) / 2) + [4] * (len(time_t) % 2) + [1]) # transform exp_tw = np.exp(np.outer(prefactor * time_t, freq_w)) data_wX = np.einsum('t...,tw,t->w...', data_tX, exp_tw, weight_t, optimize=True) return data_wX # type: ignore def broaden(freq_i: np.ndarray, data_iX: np.ndarray, freq_w: np.ndarray, freq_axis: str = 'real', broadening: Broadening = NoArtificialBroadening()) -> np.ndarray: r"""Broaden discrete data and returns it on a continuous frequency grid. Specifically this function performs the summation .. math:: y_{X}(\omega) = \sum_I \frac{y_{I}^{(X)}}{\omega_I^2 - \omega^2} This sum is evaluated as such when no broadening is specified. Lorentzian broadening corresponds to .. math:: \frac{1}{\omega_I^2 - \omega^2} \to \frac{1}{\omega_I^2 - (\omega + i \eta)^2} Gaussian broadening corresponds to .. math:: \frac{1}{\omega_I^2 - \omega^2} \to -\frac{\pi}{2 \omega_I} \left\{ \frac{\sqrt{2}}{\pi\sigma} \left[ D\left(\frac{\omega - \omega_I}{\sqrt{2}\sigma}\right) - D\left(\frac{\omega + \omega_I}{\sqrt{2}\sigma}\right) \right] \\ -\frac{i}{\sqrt{2\pi}\sigma} \left[ G\left(\frac{\omega - \omega_I}{\sqrt{2}\sigma}\right) - G\left(\frac{\omega + \omega_I}{\sqrt{2}\sigma}\right) \right] \right\} where :math:`D(\omega)` and :math:`G(\omega)` are Dawson and Gaussian functions: .. math:: D(\omega) &= e^{-\omega^2} \int_0^\omega e^{s^2} \mathrm{d}s \\ G(\omega) &= e^{-\omega^2} Parameters ---------- freq_i Discrete frequency values of the data (:math:`\omega_I`) data_iX Data to be broadened (:math:`y_{I}^{(X)}`) freq_w Continuous frequency values for broadening (:math:`\omega`) freq_axis : `'real'` or `'imag'` Interpretation of frequency values as real or imaginary frequencies. broadening Broadening to be used. Notes ----- When working with imaginary axes `broaden(..., freq_w=1j * freq_w, freq_axis='real')` and `broaden(..., freq_w=freq_w, freq_axis='imag')` yield similar results, yet the latter is computationally more efficient when `freq_w` is real. Returns ------- :class:`np.ndarray` Broadened data along continuous frequency axis. The first dimension is of the same size as `freq_w`. Other dimensions are the same as in input data `data_iX`. """ if freq_i.ndim != 1: raise ValueError('freq_i must be one-dimensional array') if data_iX.shape[0] != freq_i.shape[0]: raise ValueError('data_iX must have compatible shape with freq_i') if freq_axis == 'real': if isinstance(broadening, NoArtificialBroadening): weight_iw = 1. / (freq_i[:, np.newaxis]**2 - freq_w[np.newaxis, :]**2) elif isinstance(broadening, LorentzianBroadening): width = broadening._width weight_iw = 1. / (freq_i[:, np.newaxis]**2 - (freq_w[np.newaxis, :] + 1j * width)**2) elif isinstance(broadening, GaussianBroadening): width = broadening._width xm_iw = (freq_w[np.newaxis, :] - freq_i[:, np.newaxis]) / (np.sqrt(2) * width) xp_iw = (freq_w[np.newaxis, :] + freq_i[:, np.newaxis]) / (np.sqrt(2) * width) weight_iw = -np.pi / (2 * freq_i[:, np.newaxis]) * ( np.sqrt(2) / (np.pi * width) * (dawsn(xm_iw) - dawsn(xp_iw)) - 1j / (np.sqrt(2 * np.pi) * width) * (np.exp(-xm_iw**2) - np.exp(-xp_iw**2)) ) else: raise ValueError(f'Unknown broadening: {broadening}') elif freq_axis == 'imag': weight_iw = 1. / (freq_i[:, np.newaxis]**2 + freq_w[np.newaxis, :]**2) if not isinstance(broadening, NoArtificialBroadening): raise ValueError('Artificial broadening is not applied to imaginary frequencies') else: raise ValueError(f'Unknown frequency axis: {freq_axis}') # transform data_wX = np.einsum('i...,iw->w...', data_iX, weight_iw, optimize=True) return data_wX # type: ignore