Coverage for calorine/tools/kramers_kronig.py: 100%
36 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-18 13:01 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-18 13:01 +0000
1r"""
2Kramers-Kronig relation:
4.. math::
6 \chi_\mathrm{real}(\omega) = \frac{2}{\pi} \mathcal{P}
7 \int_0^\infty \frac{\omega' \chi_\mathrm{imag}(\omega')}
8 {\omega'^2 - \omega^2} \, d\omega'
10Two private implementations are provided for validation purposes.
11The public entry point is :func:`apply_kramers_kronig`, which defaults to the O(n²) vectorized
12variant for accuracy; pass ``method='fft'`` for the O(n log n) approximation.
13"""
15import numpy as np
16from pandas import DataFrame
19def _hilbert(x):
20 """ Discrete Hilbert transform via FFT (one-sided spectrum approach). """
21 N = len(x)
22 Xf = np.fft.fft(x)
23 h = np.zeros(N)
24 if N % 2 == 0:
25 h[0] = h[N // 2] = 1
26 h[1:N // 2] = 2
27 else:
28 h[0] = 1
29 h[1:(N + 1) // 2] = 2
30 return np.fft.ifft(Xf * h)
33def _kramers_kronig_vectorized(omega: np.ndarray, chi_imag: np.ndarray) -> np.ndarray:
34 """ Compute Re[chi] via a fully vectorized (n x n) matrix approach.
36 Parameters
37 ----------
38 omega
39 Non-negative angular frequency grid in rad/s, uniform or non-uniform.
40 chi_imag
41 Imaginary part of the susceptibility at each point in :attr:`omega`.
43 Returns
44 -------
45 np.ndarray
46 Real part of the susceptibility at each point in :attr:`omega`.
48 Notes
49 -----
50 Complexity: O(n²) time, O(n²) memory.
51 Accuracy: exact trapezoid rule over the supplied angular frequency grid.
52 """
53 omega = np.asarray(omega, dtype=float)
54 chi_imag = np.asarray(chi_imag, dtype=float)
56 w = omega[:, np.newaxis]
57 om = omega[np.newaxis, :]
59 denom = om ** 2 - w ** 2
60 np.fill_diagonal(denom, 1.0)
62 integrand = om * chi_imag[np.newaxis, :] / denom
63 np.fill_diagonal(integrand, 0.0)
65 return (2.0 / np.pi) * np.trapezoid(integrand, omega, axis=1)
68def _kramers_kronig_fft(omega: np.ndarray, chi_imag: np.ndarray) -> np.ndarray:
69 """ Compute Re[chi] via an FFT-based Hilbert transform.
71 Parameters
72 ----------
73 omega
74 Non-negative, uniformly spaced angular frequency grid in rad/s.
75 chi_imag
76 Imaginary part of the susceptibility at each point in :attr:`omega`.
78 Returns
79 -------
80 np.ndarray
81 Real part of the susceptibility at each point in :attr:`omega`.
83 Notes
84 -----
85 Complexity: O(n log n) time, O(n) memory.
86 Accuracy: approximation; differs from direct quadrature by roughly 5-10% of
87 peak amplitude on typical grids.
88 Requirement: :attr:`omega` must be uniformly spaced and :attr:`chi_imag` must be
89 negligibly small at both endpoints.
90 """
91 chi_full = np.concatenate([-chi_imag[::-1], chi_imag]) # odd extension
92 return -_hilbert(chi_full).imag[len(omega):]
95def apply_kramers_kronig(df: DataFrame, method: str = 'vectorized') -> DataFrame:
96 r""" Apply the Kramers-Kronig relation to add the real part of the dielectric function.
98 Takes the output of :func:`~calorine.tools.get_dielectric_function`, computes
99 :math:`\epsilon_1(\omega)` from :math:`\epsilon_2(\omega)` via the Kramers-Kronig
100 relation, and returns the DataFrame with new ``epsilon_real*`` columns appended.
102 Each ``epsilon_imag{suffix}`` column in :attr:`df` produces a corresponding
103 ``epsilon_real{suffix}`` column (e.g. ``epsilon_imag_xx`` → ``epsilon_real_xx``).
105 Parameters
106 ----------
107 df
108 DataFrame as returned by :func:`~calorine.tools.get_dielectric_function` or
109 :func:`~calorine.tools.get_ir_spectrum`;
110 must contain ``angular_frequency`` and at least one column whose name starts
111 with ``epsilon_imag``.
112 method
113 Integration method. ``'vectorized'`` (default) uses an exact trapezoid rule
114 over an :math:`n \times n` matrix (:math:`\mathcal{O}(n^2)` time and memory);
115 ``'fft'`` uses an :math:`\mathcal{O}(n \log n)` Hilbert transform and is faster
116 for large arrays but approximates the principal-value integral.
117 The two methods agree to within a few percent on typical grids.
119 Returns
120 -------
121 DataFrame
122 Input DataFrame with additional ``epsilon_real*`` columns.
123 """
124 if method not in ('fft', 'vectorized'):
125 raise ValueError(f"method must be 'fft' or 'vectorized', got {method!r}")
127 kk = _kramers_kronig_fft if method == 'fft' else _kramers_kronig_vectorized
128 omega = df['angular_frequency'].to_numpy()
129 imag_cols = [c for c in df.columns if c.startswith('epsilon_imag')]
131 df = df.copy()
132 for col in imag_cols:
133 suffix = col[len('epsilon_imag'):]
134 df['epsilon_real' + suffix] = kk(omega, df[col].to_numpy())
135 return df