Coverage for strongcoca/response/utilities.py: 97%
75 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-26 18:44 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-26 18:44 +0000
1# This module contains components adapted from GPAW:
2# gpaw.tddft.spectrum
3# https://gitlab.com/gpaw/gpaw/-/blob/aca9ed6f520d6a855013247119b50c630f5eebc9/gpaw/tddft/spectrum.py
5"""This module provides functionality for transforming time and
6frequency-series including Lorentzian and Gaussian broadening.
7"""
8import numpy as np
9from scipy.special import dawsn
11from ..units import au_to_eV, eV_to_au
14class Broadening:
15 """Class representing broadening types of spectra."""
16 def __repr__(self) -> str:
17 return f'{self.__class__.__name__}'
20class NoArtificialBroadening(Broadening):
21 """Class representing no artificial broadening."""
24class ArtificialBroadening(Broadening):
25 """Class representing artificial broadenings.
27 Parameters
28 ----------
29 width
30 Broadening width; meaning interpreted by the subclasses;
31 must be strictly positive.
33 Units are eV by default, atomic units can be selected via :attr:`units`.
34 units
35 `eV` to specify :attr:`width` in eV or `au` to specify :attr:`width` in
36 atomic units.
38 This parameter determines whether conversion should be performed during
39 initialization and has no effect on instance methods and variables.
40 """
41 def __init__(self, width: float, units: str = 'eV') -> None:
42 if width <= 0:
43 raise ValueError('width must be strictly positive')
44 if units == 'eV':
45 width = width * eV_to_au
46 elif units != 'au':
47 raise ValueError(f"units has to be 'eV' or 'au', not '{units}'")
48 self._width = width
50 def __repr__(self) -> str:
51 return f"{self.__class__.__name__}({self.width}, units='eV')"
53 @property
54 def width(self) -> float:
55 """Broadening width in units of eV."""
56 return self._width * au_to_eV
59class GaussianBroadening(ArtificialBroadening):
60 r"""Class for representing artificial Gaussian broadening of spectra.
62 Parameters
63 ----------
64 width
65 Broadening width (:math:`\sigma`);
66 must be strictly positive.
68 Units are eV by default, atomic units can be selected via :attr:`units`.
69 units
70 `eV` to specify :attr:`width` in eV or `au` to specify :attr:`width` in
71 atomic units.
73 This parameter determines whether conversion should be performed during
74 initialization and has no effect on instance methods and variables.
76 Examples
77 -------
78 >>> from strongcoca.response.utilities import GaussianBroadening
79 >>> GaussianBroadening(0.1)
80 GaussianBroadening(0.1, units='eV')
81 >>> GaussianBroadening(0.005, units='au')
82 GaussianBroadening(0.136..., units='eV')
83 """
84 def __init__(self, width: float, units: str = 'eV') -> None:
85 super().__init__(width=width, units=units)
88class LorentzianBroadening(ArtificialBroadening):
89 r"""Class for representing artificial Lorentzian broadening of spectra.
91 Parameters
92 ----------
93 width
94 Broadening width (:math:`\eta`);
95 must be strictly positive.
97 Units are eV by default, atomic units can be selected via :attr:`units`.
98 units
99 `eV` to specify :attr:`width` in eV or `au` to specify :attr:`width` in
100 atomic units.
102 This parameter determines whether conversion should be performed during
103 initialization and has no effect on instance methods and variables.
105 Examples
106 -------
107 >>> from strongcoca.response.utilities import LorentzianBroadening
108 >>> LorentzianBroadening(0.1)
109 LorentzianBroadening(0.1, units='eV')
110 >>> LorentzianBroadening(0.005, units='au')
111 LorentzianBroadening(0.136..., units='eV')
112 """
113 def __init__(self, width: float, units: str = 'eV') -> None:
114 super().__init__(width=width, units=units)
117def fourier_transform(time_t: np.ndarray,
118 data_tX: np.ndarray,
119 freq_w: np.ndarray,
120 freq_axis: str = 'real',
121 broadening: Broadening = NoArtificialBroadening()) -> np.ndarray:
122 r"""Calculates the Fourier transform of a time-series data according to
124 .. math::
126 y_X(\omega) = \int_{t_\text{min}}^{t_\text{max}} y_X(t) e^{i \omega t} \mathrm{d}t
128 The integral is evaluated as such when broadening is not specified.
130 Lorentzian broadening corresponds to
132 .. math::
134 e^{i \omega t} \to e^{i (\omega + i \eta) t} = e^{i \omega t} e^{- \eta t}
136 Gaussian broadening corresponds to
138 .. math::
140 e^{i \omega t} \to e^{i \omega t} e^{- \frac{1}{2}\sigma^2 t^2}
142 This function does not carry out unit conversions, but all parameters are
143 assumed to be in mutually compatible units.
145 Parameters
146 ----------
147 time_t
148 Equally spaced time grid.
149 data_tX
150 Data to be transformed.
151 The first dimension must be of same size as `time_t`. Other
152 dimensions can be of any size.
153 freq_w
154 Frequency values for the transform.
155 freq_axis : `'real'` or `'imag'`
156 Interpretation of frequency values as real or imaginary frequencies.
157 broadening
158 Broadening to be used.
160 Notes
161 -----
162 When working with imaginary axes
163 `fourier_transform(..., freq_w=1j * freq_w, freq_axis='real')` and
164 `fourier_transform(..., freq_w=freq_w, freq_axis='imag')`
165 yield similar results, yet the latter is computationally
166 more efficient when `freq_w` is real.
168 Returns
169 -------
170 :class:`np.ndarray`
171 Transform of input data to frequency.
172 The first dimension is of the same size as `freq_w`. Other dimensions
173 are the same as in input data `data_tX`.
174 """
175 if time_t.ndim != 1:
176 raise ValueError('time_t must be one-dimensional array')
177 if data_tX.shape[0] != time_t.shape[0]:
178 raise ValueError('data_tX must have compatible shape with time_t')
180 if freq_axis == 'real':
181 prefactor = 1.0j
182 if isinstance(broadening, NoArtificialBroadening):
183 weight_t = np.ones_like(time_t)
184 elif isinstance(broadening, LorentzianBroadening):
185 weight_t = np.exp(-broadening._width * time_t)
186 elif isinstance(broadening, GaussianBroadening): 186 ↛ 189line 186 didn't jump to line 189 because the condition on line 186 was always true
187 weight_t = np.exp(-0.5 * broadening._width**2 * time_t**2)
188 else:
189 raise ValueError(f'Unknown broadening: {broadening}')
190 elif freq_axis == 'imag':
191 prefactor = -1.0
192 weight_t = np.ones_like(time_t)
193 if not isinstance(broadening, NoArtificialBroadening):
194 raise ValueError('Artificial broadening is not applied to imaginary frequencies')
195 else:
196 raise ValueError(f'Unknown frequency axis: {freq_axis}')
198 # check time step
199 dt_t = time_t[1:] - time_t[:-1]
200 dt = dt_t[0]
201 if not np.allclose(dt_t, dt, rtol=1e-6, atol=0):
202 raise ValueError('Time grid must be equally spaced.')
204 # integration weights from Simpson's integration rule
205 weight_t *= dt / 3 * np.array([1] + [4, 2] * int((len(time_t) - 2) / 2)
206 + [4] * (len(time_t) % 2) + [1])
208 # transform
209 exp_tw = np.exp(np.outer(prefactor * time_t, freq_w))
210 data_wX = np.einsum('t...,tw,t->w...', data_tX, exp_tw, weight_t, optimize=True)
211 return data_wX # type: ignore
214def broaden(freq_i: np.ndarray,
215 data_iX: np.ndarray,
216 freq_w: np.ndarray,
217 freq_axis: str = 'real',
218 broadening: Broadening = NoArtificialBroadening()) -> np.ndarray:
219 r"""Broaden discrete data and returns it on a continuous frequency grid.
220 Specifically this function performs the summation
222 .. math::
224 y_{X}(\omega) = \sum_I \frac{y_{I}^{(X)}}{\omega_I^2 - \omega^2}
226 This sum is evaluated as such when no broadening is specified.
228 Lorentzian broadening corresponds to
230 .. math::
232 \frac{1}{\omega_I^2 - \omega^2} \to \frac{1}{\omega_I^2 - (\omega + i \eta)^2}
234 Gaussian broadening corresponds to
236 .. math::
238 \frac{1}{\omega_I^2 - \omega^2} \to
239 -\frac{\pi}{2 \omega_I}
240 \left\{
241 \frac{\sqrt{2}}{\pi\sigma}
242 \left[
243 D\left(\frac{\omega - \omega_I}{\sqrt{2}\sigma}\right)
244 - D\left(\frac{\omega + \omega_I}{\sqrt{2}\sigma}\right)
245 \right] \\
246 -\frac{i}{\sqrt{2\pi}\sigma}
247 \left[
248 G\left(\frac{\omega - \omega_I}{\sqrt{2}\sigma}\right)
249 - G\left(\frac{\omega + \omega_I}{\sqrt{2}\sigma}\right)
250 \right]
251 \right\}
253 where :math:`D(\omega)` and :math:`G(\omega)` are Dawson and Gaussian functions:
255 .. math::
257 D(\omega) &= e^{-\omega^2} \int_0^\omega e^{s^2} \mathrm{d}s \\
258 G(\omega) &= e^{-\omega^2}
261 Parameters
262 ----------
263 freq_i
264 Discrete frequency values of the data (:math:`\omega_I`)
265 data_iX
266 Data to be broadened (:math:`y_{I}^{(X)}`)
267 freq_w
268 Continuous frequency values for broadening (:math:`\omega`)
269 freq_axis : `'real'` or `'imag'`
270 Interpretation of frequency values as real or imaginary frequencies.
271 broadening
272 Broadening to be used.
274 Notes
275 -----
276 When working with imaginary axes
277 `broaden(..., freq_w=1j * freq_w, freq_axis='real')` and
278 `broaden(..., freq_w=freq_w, freq_axis='imag')`
279 yield similar results, yet the latter is computationally
280 more efficient when `freq_w` is real.
282 Returns
283 -------
284 :class:`np.ndarray`
285 Broadened data along continuous frequency axis.
286 The first dimension is of the same size as `freq_w`. Other dimensions
287 are the same as in input data `data_iX`.
288 """
289 if freq_i.ndim != 1:
290 raise ValueError('freq_i must be one-dimensional array')
291 if data_iX.shape[0] != freq_i.shape[0]:
292 raise ValueError('data_iX must have compatible shape with freq_i')
294 if freq_axis == 'real':
295 if isinstance(broadening, NoArtificialBroadening):
296 weight_iw = 1. / (freq_i[:, np.newaxis]**2 - freq_w[np.newaxis, :]**2)
297 elif isinstance(broadening, LorentzianBroadening):
298 width = broadening._width
299 weight_iw = 1. / (freq_i[:, np.newaxis]**2 - (freq_w[np.newaxis, :] + 1j * width)**2)
300 elif isinstance(broadening, GaussianBroadening): 300 ↛ 309line 300 didn't jump to line 309 because the condition on line 300 was always true
301 width = broadening._width
302 xm_iw = (freq_w[np.newaxis, :] - freq_i[:, np.newaxis]) / (np.sqrt(2) * width)
303 xp_iw = (freq_w[np.newaxis, :] + freq_i[:, np.newaxis]) / (np.sqrt(2) * width)
304 weight_iw = -np.pi / (2 * freq_i[:, np.newaxis]) * (
305 np.sqrt(2) / (np.pi * width) * (dawsn(xm_iw) - dawsn(xp_iw))
306 - 1j / (np.sqrt(2 * np.pi) * width) * (np.exp(-xm_iw**2) - np.exp(-xp_iw**2))
307 )
308 else:
309 raise ValueError(f'Unknown broadening: {broadening}')
310 elif freq_axis == 'imag':
311 weight_iw = 1. / (freq_i[:, np.newaxis]**2 + freq_w[np.newaxis, :]**2)
312 if not isinstance(broadening, NoArtificialBroadening):
313 raise ValueError('Artificial broadening is not applied to imaginary frequencies')
314 else:
315 raise ValueError(f'Unknown frequency axis: {freq_axis}')
317 # transform
318 data_wX = np.einsum('i...,iw->w...', data_iX, weight_iw, optimize=True)
319 return data_wX # type: ignore