r"""
Conversions between the complex dielectric function :math:`\epsilon = \epsilon_1 +
i\epsilon_2` and the complex index of refraction :math:`N = n + i\kappa`, and between
:math:`\kappa` and the absorption coefficient :math:`\alpha`.
Public entry points: :func:`get_refractive_index` (:math:`\epsilon \to n,\kappa`),
:func:`get_absorption_coefficient` (:math:`\kappa \to \alpha`), and
:func:`get_dielectric_function_from_refractive_index` (the inverse of the above,
accepting either :math:`n,\kappa` or :math:`n,\alpha`). All three operate on the same
DataFrame convention as :func:`~calorine.tools.get_dielectric_function` and
:func:`~calorine.tools.apply_kramers_kronig`: components are matched by column-name
suffix (e.g. ``epsilon_real_xx``/``epsilon_imag_xx``), so both the full Voigt tensor and
the bare unsuffixed (isotropic/molecular) case are handled uniformly.
"""
import numpy as np
from pandas import DataFrame
from ase.units import _c as c_SI
from .spectra import _rad_s_to_thz
def _eps_to_nk(eps_real, eps_imag):
"""Complex dielectric function -> (n, kappa).
n is taken non-negative (the physical branch of the square root); kappa then takes
the sign of eps_imag, since eps_imag = 2*n*kappa with n >= 0. np.clip guards
against tiny negative values from floating-point roundoff before taking the square
root; mathematically ``eps_abs +/- eps_real`` is always non-negative.
"""
eps_abs = np.sqrt(eps_real ** 2 + eps_imag ** 2)
n = np.sqrt(np.clip((eps_abs + eps_real) / 2, 0.0, None))
kappa = np.sqrt(np.clip((eps_abs - eps_real) / 2, 0.0, None))
kappa = np.copysign(kappa, eps_imag)
return n, kappa
[docs]
def get_refractive_index(df: DataFrame) -> DataFrame:
r"""Compute the complex index of refraction from the complex dielectric function.
Given the complex dielectric function :math:`\epsilon = \epsilon_1 + i\epsilon_2`,
the complex index of refraction :math:`N = n + i\kappa` is
.. math::
n = \sqrt{\frac{|\epsilon| + \epsilon_1}{2}}, \qquad
\kappa = \sqrt{\frac{|\epsilon| - \epsilon_1}{2}},
where :math:`|\epsilon| = \sqrt{\epsilon_1^2 + \epsilon_2^2}`, taking the physical
branch :math:`n \geq 0` and giving :math:`\kappa` the sign of :math:`\epsilon_2`
(since :math:`\epsilon_2 = 2n\kappa` with :math:`n \geq 0`). For a passive medium
at positive frequency :math:`\epsilon_2 \geq 0`, so :math:`\kappa \geq 0` as usual;
a negative :math:`\epsilon_2` (e.g. numerical noise near zero) carries through as a
negative :math:`\kappa` rather than being silently discarded. Both :math:`n` and
:math:`\kappa` are dimensionless.
Each ``epsilon_real{suffix}``/``epsilon_imag{suffix}`` column pair in :attr:`df`
(e.g. ``epsilon_real_xx``/``epsilon_imag_xx``, or the bare
``epsilon_real``/``epsilon_imag``) produces a corresponding
``refractive_index_real{suffix}``/``refractive_index_imag{suffix}`` column pair.
Parameters
----------
df
DataFrame as returned by :func:`~calorine.tools.get_dielectric_function` (with
``return_real_part=True``) or :func:`~calorine.tools.apply_kramers_kronig`;
must contain at least one ``epsilon_real{suffix}``/``epsilon_imag{suffix}``
column pair sharing the same suffix.
Returns
-------
DataFrame
Input DataFrame with additional ``refractive_index_real{suffix}`` (:math:`n`,
dimensionless) and ``refractive_index_imag{suffix}`` (:math:`\kappa`,
dimensionless) columns appended.
Raises
------
ValueError
If :attr:`df` contains no ``epsilon_imag*`` columns, or an
``epsilon_imag{suffix}`` column without a matching ``epsilon_real{suffix}``
column (e.g. output of :func:`~calorine.tools.get_dielectric_function` called
with ``return_real_part=False``).
"""
imag_cols = [c for c in df.columns if c.startswith('epsilon_imag')]
if not imag_cols:
raise ValueError(
"df must contain at least one 'epsilon_imag*' column; "
f'got columns: {list(df.columns)}'
)
missing = [
'epsilon_real' + col[len('epsilon_imag'):]
for col in imag_cols
if 'epsilon_real' + col[len('epsilon_imag'):] not in df.columns
]
if missing:
raise ValueError(
f'df is missing columns {missing!r} required to compute the refractive '
'index; call get_dielectric_function with return_real_part=True, or run '
'apply_kramers_kronig first.'
)
df = df.copy()
for col in imag_cols:
suffix = col[len('epsilon_imag'):]
n, kappa = _eps_to_nk(df['epsilon_real' + suffix].to_numpy(), df[col].to_numpy())
df['refractive_index_real' + suffix] = n
df['refractive_index_imag' + suffix] = kappa
return df
[docs]
def get_absorption_coefficient(df: DataFrame) -> DataFrame:
r"""Compute the absorption coefficient from the extinction coefficient.
.. math::
\alpha(\omega) = \frac{2\kappa\omega}{c},
where :math:`\kappa` is the extinction coefficient (imaginary part of the complex
index of refraction) and :math:`\omega` is the angular frequency. With
:math:`\omega` in rad/s and :math:`c` in m/s this gives :math:`\alpha` in
m\ :sup:`-1`; :math:`\alpha` is returned here in cm\ :sup:`-1` (matching the
``wavenumber_invcm`` convention used elsewhere in this module, though note that
:math:`\alpha` is a physically distinct quantity from the spectroscopic wavenumber
:math:`\omega/2\pi c`):
.. math::
\alpha\,[\mathrm{cm}^{-1}] = \frac{2\kappa\omega}{100\,c}.
Each ``refractive_index_imag{suffix}`` column in :attr:`df` (as returned by
:func:`~calorine.tools.get_refractive_index`) produces a corresponding
``absorption_coefficient{suffix}`` column. As a convenience, if :attr:`df` lacks
``refractive_index_imag{suffix}`` but contains a matching ``epsilon_real{suffix}``/
``epsilon_imag{suffix}`` pair, :math:`\kappa` is derived internally using the same
relation as :func:`~calorine.tools.get_refractive_index` (without adding the
corresponding ``refractive_index_real{suffix}``/``refractive_index_imag{suffix}``
columns to the output).
Parameters
----------
df
DataFrame containing ``angular_frequency`` (THz) and at least one
``refractive_index_imag{suffix}`` column (as returned by
:func:`~calorine.tools.get_refractive_index`), or, alternatively, a matching
``epsilon_real{suffix}``/``epsilon_imag{suffix}`` column pair (as returned by
:func:`~calorine.tools.get_dielectric_function` with ``return_real_part=True``).
Returns
-------
DataFrame
Input DataFrame with additional ``absorption_coefficient{suffix}`` columns
(cm\ :sup:`-1`) appended.
Raises
------
ValueError
If :attr:`df` lacks ``angular_frequency``, or contains neither
``refractive_index_imag*`` columns nor a matching ``epsilon_real*``/
``epsilon_imag*`` column pair for a given component.
"""
if 'angular_frequency' not in df.columns:
raise ValueError("df must contain an 'angular_frequency' column")
omega = df['angular_frequency'].to_numpy() / _rad_s_to_thz
kappas = {}
for col in df.columns:
if col.startswith('refractive_index_imag'):
kappas[col[len('refractive_index_imag'):]] = df[col].to_numpy()
missing = []
for col in df.columns:
if not col.startswith('epsilon_imag'):
continue
suffix = col[len('epsilon_imag'):]
if suffix in kappas:
continue
real_col = 'epsilon_real' + suffix
if real_col not in df.columns:
missing.append(real_col)
continue
_, kappa = _eps_to_nk(df[real_col].to_numpy(), df[col].to_numpy())
kappas[suffix] = kappa
if missing:
raise ValueError(
f'df is missing columns {missing!r} required to compute the absorption '
"coefficient (no matching 'refractive_index_imag*' column and no matching "
"'epsilon_real*' column to derive kappa from)."
)
if not kappas:
raise ValueError(
"df must contain at least one 'refractive_index_imag*' column (as returned "
"by get_refractive_index) or a matching 'epsilon_real*'/'epsilon_imag*' "
'column pair'
)
df = df.copy()
for suffix, kappa in kappas.items():
df['absorption_coefficient' + suffix] = 2.0 * kappa * omega / (100.0 * c_SI)
return df
[docs]
def get_dielectric_function_from_refractive_index(df: DataFrame) -> DataFrame:
r"""Compute the complex dielectric function from the complex index of refraction.
Given the complex index of refraction :math:`N = n + i\kappa`,
.. math::
\epsilon_1 = n^2 - \kappa^2, \qquad \epsilon_2 = 2 n \kappa.
This is the exact inverse of :func:`~calorine.tools.get_refractive_index`.
Each ``refractive_index_real{suffix}`` column (:math:`n`) must be paired with
either a ``refractive_index_imag{suffix}`` column (:math:`\kappa`, as returned by
:func:`~calorine.tools.get_refractive_index`) or an ``absorption_coefficient{suffix}``
column (:math:`\alpha`, in cm\ :sup:`-1`, as returned by
:func:`~calorine.tools.get_absorption_coefficient`), from which :math:`\kappa` is
recovered via the inverse of :math:`\alpha = 2\kappa\omega/(100 c)`,
.. math::
\kappa = \frac{100\,c\,\alpha}{2\omega},
which additionally requires the ``angular_frequency`` column. If both a
``refractive_index_imag{suffix}`` and an ``absorption_coefficient{suffix}`` column
are present for the same suffix, the former takes precedence.
Parameters
----------
df
DataFrame containing at least one ``refractive_index_real{suffix}`` column
together with a matching ``refractive_index_imag{suffix}`` or
``absorption_coefficient{suffix}`` column (in the latter case
``angular_frequency`` in THz must also be present).
Returns
-------
DataFrame
Input DataFrame with additional ``epsilon_real{suffix}`` and
``epsilon_imag{suffix}`` columns appended.
Raises
------
ValueError
If a ``refractive_index_real{suffix}`` column has no matching
``refractive_index_imag{suffix}`` or ``absorption_coefficient{suffix}``
column, or if the latter is used but ``angular_frequency`` is missing.
"""
real_cols = [c for c in df.columns if c.startswith('refractive_index_real')]
if not real_cols:
raise ValueError("df must contain at least one 'refractive_index_real*' column")
have_omega = 'angular_frequency' in df.columns
omega = df['angular_frequency'].to_numpy() / _rad_s_to_thz if have_omega else None
kappas = {}
missing = []
for col in real_cols:
suffix = col[len('refractive_index_real'):]
kappa_col = 'refractive_index_imag' + suffix
alpha_col = 'absorption_coefficient' + suffix
if kappa_col in df.columns:
kappas[suffix] = df[kappa_col].to_numpy()
elif alpha_col in df.columns:
if not have_omega:
missing.append(
f"'angular_frequency' (required to convert {alpha_col!r} to kappa)"
)
continue
kappas[suffix] = 100.0 * c_SI * df[alpha_col].to_numpy() / (2.0 * omega)
else:
missing.append(f'{kappa_col!r} or {alpha_col!r}')
if missing:
raise ValueError(
f'df is missing required columns to invert refractive_index_real*: {missing}'
)
df = df.copy()
for suffix, kappa in kappas.items():
n = df['refractive_index_real' + suffix].to_numpy()
df['epsilon_real' + suffix] = n ** 2 - kappa ** 2
df['epsilon_imag' + suffix] = 2.0 * n * kappa
return df