Source code for calorine.tools.kramers_kronig
r"""
Kramers-Kronig relation:
.. math::
\chi_\mathrm{real}(\omega) = \frac{2}{\pi} \mathcal{P}
\int_0^\infty \frac{\omega' \chi_\mathrm{imag}(\omega')}
{\omega'^2 - \omega^2} \, d\omega'
Two private implementations are provided for validation purposes.
The public entry point is :func:`apply_kramers_kronig`, which defaults to the O(n²) vectorized
variant for accuracy; pass ``method='fft'`` for the O(n log n) approximation.
"""
import numpy as np
from pandas import DataFrame
def _hilbert(x):
""" Discrete Hilbert transform via FFT (one-sided spectrum approach). """
N = len(x)
Xf = np.fft.fft(x)
h = np.zeros(N)
if N % 2 == 0:
h[0] = h[N // 2] = 1
h[1:N // 2] = 2
else:
h[0] = 1
h[1:(N + 1) // 2] = 2
return np.fft.ifft(Xf * h)
def _kramers_kronig_vectorized(omega: np.ndarray, chi_imag: np.ndarray) -> np.ndarray:
""" Compute Re[chi] via a fully vectorized (n x n) matrix approach.
Parameters
----------
omega
Non-negative angular frequency grid in rad/s, uniform or non-uniform.
chi_imag
Imaginary part of the susceptibility at each point in :attr:`omega`.
Returns
-------
np.ndarray
Real part of the susceptibility at each point in :attr:`omega`.
Notes
-----
Complexity: O(n²) time, O(n²) memory.
Accuracy: exact trapezoid rule over the supplied angular frequency grid.
"""
omega = np.asarray(omega, dtype=float)
chi_imag = np.asarray(chi_imag, dtype=float)
w = omega[:, np.newaxis]
om = omega[np.newaxis, :]
denom = om ** 2 - w ** 2
np.fill_diagonal(denom, 1.0)
integrand = om * chi_imag[np.newaxis, :] / denom
np.fill_diagonal(integrand, 0.0)
return (2.0 / np.pi) * np.trapezoid(integrand, omega, axis=1)
def _kramers_kronig_fft(omega: np.ndarray, chi_imag: np.ndarray) -> np.ndarray:
""" Compute Re[chi] via an FFT-based Hilbert transform.
Parameters
----------
omega
Non-negative, uniformly spaced angular frequency grid in rad/s.
chi_imag
Imaginary part of the susceptibility at each point in :attr:`omega`.
Returns
-------
np.ndarray
Real part of the susceptibility at each point in :attr:`omega`.
Notes
-----
Complexity: O(n log n) time, O(n) memory.
Accuracy: approximation; differs from direct quadrature by roughly 5-10% of
peak amplitude on typical grids.
Requirement: :attr:`omega` must be uniformly spaced and :attr:`chi_imag` must be
negligibly small at both endpoints.
"""
chi_full = np.concatenate([-chi_imag[::-1], chi_imag]) # odd extension
return -_hilbert(chi_full).imag[len(omega):]
[docs]
def apply_kramers_kronig(df: DataFrame, method: str = 'vectorized') -> DataFrame:
r""" Apply the Kramers-Kronig relation to add the real part of the dielectric function.
Takes the output of :func:`~calorine.tools.get_dielectric_function`, computes
:math:`\epsilon_1(\omega)` from :math:`\epsilon_2(\omega)` via the Kramers-Kronig
relation, and returns the DataFrame with new ``epsilon_real*`` columns appended.
Each ``epsilon_imag{suffix}`` column in :attr:`df` produces a corresponding
``epsilon_real{suffix}`` column (e.g. ``epsilon_imag_xx`` → ``epsilon_real_xx``).
Parameters
----------
df
DataFrame as returned by :func:`~calorine.tools.get_dielectric_function` or
:func:`~calorine.tools.get_ir_spectrum`;
must contain ``angular_frequency`` and at least one column whose name starts
with ``epsilon_imag``.
method
Integration method. ``'vectorized'`` (default) uses an exact trapezoid rule
over an :math:`n \times n` matrix (:math:`\mathcal{O}(n^2)` time and memory);
``'fft'`` uses an :math:`\mathcal{O}(n \log n)` Hilbert transform and is faster
for large arrays but approximates the principal-value integral.
The two methods agree to within a few percent on typical grids.
Returns
-------
DataFrame
Input DataFrame with additional ``epsilon_real*`` columns.
"""
if method not in ('fft', 'vectorized'):
raise ValueError(f"method must be 'fft' or 'vectorized', got {method!r}")
kk = _kramers_kronig_fft if method == 'fft' else _kramers_kronig_vectorized
omega = df['angular_frequency'].to_numpy()
imag_cols = [c for c in df.columns if c.startswith('epsilon_imag')]
df = df.copy()
for col in imag_cols:
suffix = col[len('epsilon_imag'):]
df['epsilon_real' + suffix] = kk(omega, df[col].to_numpy())
return df