Source code for calorine.tools.refractive_index

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