Coverage for strongcoca / response / utilities.py: 100%
85 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-07-25 16:26 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-07-25 16:26 +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__}'
19 def _repr_html_(self) -> str:
20 """HTML representation for Jupyter notebooks."""
21 return (
22 f'<h4>{self.__class__.__name__}</h4>'
23 )
25 def _get_time_weight(self, time_t: np.ndarray) -> np.ndarray:
26 raise ValueError(f'Unknown broadening: {self}')
28 def _get_frequency_weight(self, freq_i: np.ndarray, freq_w: np.ndarray) -> np.ndarray:
29 raise ValueError(f'Unknown broadening: {self}')
32class NoArtificialBroadening(Broadening):
33 """Class representing no artificial broadening."""
35 def _get_time_weight(self, time_t: np.ndarray) -> np.ndarray:
36 return np.ones_like(time_t)
38 def _get_frequency_weight(self, freq_i: np.ndarray, freq_w: np.ndarray) -> np.ndarray:
39 return 1. / (freq_i[:, np.newaxis]**2 - freq_w[np.newaxis, :]**2) # type: ignore
42class ArtificialBroadening(Broadening):
43 """Class representing artificial broadenings.
45 Parameters
46 ----------
47 width
48 Broadening width; meaning interpreted by the subclasses;
49 must be strictly positive.
51 Units are eV by default, atomic units can be selected via :attr:`units`.
52 units
53 `eV` to specify :attr:`width` in eV or `au` to specify :attr:`width` in
54 atomic units.
56 This parameter determines whether conversion should be performed during
57 initialization and has no effect on instance methods and variables.
58 """
59 def __init__(self, width: float, units: str = 'eV') -> None:
60 if width <= 0:
61 raise ValueError('width must be strictly positive')
62 if units == 'eV':
63 width = width * eV_to_au
64 elif units != 'au':
65 raise ValueError(f"units has to be 'eV' or 'au', not '{units}'")
66 self._width = width
68 def __repr__(self) -> str:
69 return f"{self.__class__.__name__}({self.width}, units='eV')"
71 def _repr_html_(self) -> str:
72 """HTML representation for Jupyter notebooks."""
73 rows = [
74 f'<tr><td style="text-align: left;">width (eV)</td><td>{self.width:.4f}</td></tr>',
75 ]
76 return (
77 f'<h4>{self.__class__.__name__}</h4>'
78 '<table>'
79 '<thead><tr>'
80 '<th style="text-align: left;">field</th>'
81 '<th style="text-align: left;">value</th>'
82 '</tr></thead>'
83 '<tbody>' + ''.join(rows) + '</tbody>'
84 '</table>'
85 )
87 @property
88 def width(self) -> float:
89 """Broadening width in units of eV."""
90 return self._width * au_to_eV
93class GaussianBroadening(ArtificialBroadening):
94 r"""Class for representing artificial Gaussian broadening of spectra.
96 Parameters
97 ----------
98 width
99 Broadening width (:math:`\sigma`);
100 must be strictly positive.
102 Units are eV by default, atomic units can be selected via :attr:`units`.
103 units
104 `eV` to specify :attr:`width` in eV or `au` to specify :attr:`width` in
105 atomic units.
107 This parameter determines whether conversion should be performed during
108 initialization and has no effect on instance methods and variables.
110 Examples
111 -------
112 >>> from strongcoca.response.utilities import GaussianBroadening
113 >>> GaussianBroadening(0.1)
114 GaussianBroadening(0.1, units='eV')
115 >>> GaussianBroadening(0.005, units='au')
116 GaussianBroadening(0.136..., units='eV')
117 """
118 def __init__(self, width: float, units: str = 'eV') -> None:
119 super().__init__(width=width, units=units)
121 def _get_time_weight(self, time_t: np.ndarray) -> np.ndarray:
122 return np.exp(-0.5 * self._width**2 * time_t**2)
124 def _get_frequency_weight(self, freq_i: np.ndarray, freq_w: np.ndarray) -> np.ndarray:
125 width = self._width
126 xm_iw = (freq_w[np.newaxis, :] - freq_i[:, np.newaxis]) / (np.sqrt(2) * width)
127 xp_iw = (freq_w[np.newaxis, :] + freq_i[:, np.newaxis]) / (np.sqrt(2) * width)
128 weight_iw = -np.pi / (2 * freq_i[:, np.newaxis]) * (
129 np.sqrt(2) / (np.pi * width) * (dawsn(xm_iw) - dawsn(xp_iw))
130 - 1j / (np.sqrt(2 * np.pi) * width) * (np.exp(-xm_iw**2) - np.exp(-xp_iw**2))
131 )
132 return weight_iw # type: ignore
135class LorentzianBroadening(ArtificialBroadening):
136 r"""Class for representing artificial Lorentzian broadening of spectra.
138 Parameters
139 ----------
140 width
141 Broadening width (:math:`\eta`);
142 must be strictly positive.
144 Units are eV by default, atomic units can be selected via :attr:`units`.
145 units
146 `eV` to specify :attr:`width` in eV or `au` to specify :attr:`width` in
147 atomic units.
149 This parameter determines whether conversion should be performed during
150 initialization and has no effect on instance methods and variables.
152 Examples
153 -------
154 >>> from strongcoca.response.utilities import LorentzianBroadening
155 >>> LorentzianBroadening(0.1)
156 LorentzianBroadening(0.1, units='eV')
157 >>> LorentzianBroadening(0.005, units='au')
158 LorentzianBroadening(0.136..., units='eV')
159 """
160 def __init__(self, width: float, units: str = 'eV') -> None:
161 super().__init__(width=width, units=units)
163 def _get_time_weight(self, time_t: np.ndarray) -> np.ndarray:
164 return np.exp(-self._width * time_t)
166 def _get_frequency_weight(self, freq_i: np.ndarray, freq_w: np.ndarray) -> np.ndarray:
167 width = self._width
168 return 1. / (freq_i[:, np.newaxis]**2 - (freq_w[np.newaxis, :] + 1j * width)**2)
171def fourier_transform(time_t: np.ndarray,
172 data_tX: np.ndarray,
173 freq_w: np.ndarray,
174 freq_axis: str = 'real',
175 broadening: Broadening = NoArtificialBroadening()) -> np.ndarray:
176 r"""Calculates the Fourier transform of a time-series data according to
178 .. math::
180 y_X(\omega) = \int_{t_\text{min}}^{t_\text{max}} y_X(t) e^{i \omega t} \mathrm{d}t
182 The integral is evaluated as such when broadening is not specified.
184 Lorentzian broadening corresponds to
186 .. math::
188 e^{i \omega t} \to e^{i (\omega + i \eta) t} = e^{i \omega t} e^{- \eta t}
190 Gaussian broadening corresponds to
192 .. math::
194 e^{i \omega t} \to e^{i \omega t} e^{- \frac{1}{2}\sigma^2 t^2}
196 This function does not carry out unit conversions, but all parameters are
197 assumed to be in mutually compatible units.
199 Parameters
200 ----------
201 time_t
202 Equally spaced time grid.
203 data_tX
204 Data to be transformed.
205 The first dimension must be of same size as `time_t`. Other
206 dimensions can be of any size.
207 freq_w
208 Frequency values for the transform.
209 freq_axis : `'real'` or `'imag'`
210 Interpretation of frequency values as real or imaginary frequencies.
211 broadening
212 Broadening to be used.
214 Notes
215 -----
216 When working with imaginary axes
217 `fourier_transform(..., freq_w=1j * freq_w, freq_axis='real')` and
218 `fourier_transform(..., freq_w=freq_w, freq_axis='imag')`
219 yield similar results, yet the latter is computationally
220 more efficient when `freq_w` is real.
222 Returns
223 -------
224 :class:`np.ndarray`
225 Transform of input data to frequency.
226 The first dimension is of the same size as `freq_w`. Other dimensions
227 are the same as in input data `data_tX`.
228 """
229 if time_t.ndim != 1:
230 raise ValueError('time_t must be one-dimensional array')
231 if data_tX.shape[0] != time_t.shape[0]:
232 raise ValueError('data_tX must have compatible shape with time_t')
234 if freq_axis == 'real':
235 prefactor = 1.0j
236 weight_t = broadening._get_time_weight(time_t)
237 elif freq_axis == 'imag':
238 prefactor = -1.0
239 weight_t = np.ones_like(time_t)
240 if not isinstance(broadening, NoArtificialBroadening):
241 raise ValueError('Artificial broadening is not applied to imaginary frequencies')
242 else:
243 raise ValueError(f'Unknown frequency axis: {freq_axis}')
245 # check time step
246 dt_t = time_t[1:] - time_t[:-1]
247 dt = dt_t[0]
248 if not np.allclose(dt_t, dt, rtol=1e-6, atol=0):
249 raise ValueError('Time grid must be equally spaced.')
251 # integration weights from Simpson's integration rule
252 weight_t *= dt / 3 * np.array([1] + [4, 2] * int((len(time_t) - 2) / 2)
253 + [4] * (len(time_t) % 2) + [1])
255 # transform
256 exp_tw = np.exp(np.outer(prefactor * time_t, freq_w))
257 data_wX = np.einsum('t...,tw,t->w...', data_tX, exp_tw, weight_t, optimize=True)
258 return data_wX # type: ignore
261def broaden(freq_i: np.ndarray,
262 data_iX: np.ndarray,
263 freq_w: np.ndarray,
264 freq_axis: str = 'real',
265 broadening: Broadening = NoArtificialBroadening()) -> np.ndarray:
266 r"""Broaden discrete data and returns it on a continuous frequency grid.
267 Specifically this function performs the summation
269 .. math::
271 y_{X}(\omega) = \sum_I \frac{y_{I}^{(X)}}{\omega_I^2 - \omega^2}
273 This sum is evaluated as such when no broadening is specified.
275 Lorentzian broadening corresponds to
277 .. math::
279 \frac{1}{\omega_I^2 - \omega^2} \to \frac{1}{\omega_I^2 - (\omega + i \eta)^2}
281 Gaussian broadening corresponds to
283 .. math::
285 \frac{1}{\omega_I^2 - \omega^2} \to
286 -\frac{\pi}{2 \omega_I}
287 \left\{
288 \frac{\sqrt{2}}{\pi\sigma}
289 \left[
290 D\left(\frac{\omega - \omega_I}{\sqrt{2}\sigma}\right)
291 - D\left(\frac{\omega + \omega_I}{\sqrt{2}\sigma}\right)
292 \right] \\
293 -\frac{i}{\sqrt{2\pi}\sigma}
294 \left[
295 G\left(\frac{\omega - \omega_I}{\sqrt{2}\sigma}\right)
296 - G\left(\frac{\omega + \omega_I}{\sqrt{2}\sigma}\right)
297 \right]
298 \right\}
300 where :math:`D(\omega)` and :math:`G(\omega)` are Dawson and Gaussian functions:
302 .. math::
304 D(\omega) &= e^{-\omega^2} \int_0^\omega e^{s^2} \mathrm{d}s \\
305 G(\omega) &= e^{-\omega^2}
308 Parameters
309 ----------
310 freq_i
311 Discrete frequency values of the data (:math:`\omega_I`)
312 data_iX
313 Data to be broadened (:math:`y_{I}^{(X)}`)
314 freq_w
315 Continuous frequency values for broadening (:math:`\omega`)
316 freq_axis : `'real'` or `'imag'`
317 Interpretation of frequency values as real or imaginary frequencies.
318 broadening
319 Broadening to be used.
321 Notes
322 -----
323 When working with imaginary axes
324 `broaden(..., freq_w=1j * freq_w, freq_axis='real')` and
325 `broaden(..., freq_w=freq_w, freq_axis='imag')`
326 yield similar results, yet the latter is computationally
327 more efficient when `freq_w` is real.
329 Returns
330 -------
331 :class:`np.ndarray`
332 Broadened data along continuous frequency axis.
333 The first dimension is of the same size as `freq_w`. Other dimensions
334 are the same as in input data `data_iX`.
335 """
336 if freq_i.ndim != 1:
337 raise ValueError('freq_i must be one-dimensional array')
338 if data_iX.shape[0] != freq_i.shape[0]:
339 raise ValueError('data_iX must have compatible shape with freq_i')
341 if freq_axis == 'real':
342 weight_iw = broadening._get_frequency_weight(freq_i, freq_w)
343 elif freq_axis == 'imag':
344 weight_iw = 1. / (freq_i[:, np.newaxis]**2 + freq_w[np.newaxis, :]**2)
345 if not isinstance(broadening, NoArtificialBroadening):
346 raise ValueError('Artificial broadening is not applied to imaginary frequencies')
347 else:
348 raise ValueError(f'Unknown frequency axis: {freq_axis}')
350 # transform
351 data_wX = np.einsum('i...,iw->w...', data_iX, weight_iw, optimize=True)
352 return data_wX # type: ignore