Coverage for calorine/tools/refractive_index.py: 100%
81 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 12:50 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 12:50 +0000
1r"""
2Conversions between the complex dielectric function :math:`\epsilon = \epsilon_1 +
3i\epsilon_2` and the complex index of refraction :math:`N = n + i\kappa`, and between
4:math:`\kappa` and the absorption coefficient :math:`\alpha`.
6Public entry points: :func:`get_refractive_index` (:math:`\epsilon \to n,\kappa`),
7:func:`get_absorption_coefficient` (:math:`\kappa \to \alpha`), and
8:func:`get_dielectric_function_from_refractive_index` (the inverse of the above,
9accepting either :math:`n,\kappa` or :math:`n,\alpha`). All three operate on the same
10DataFrame convention as :func:`~calorine.tools.get_dielectric_function` and
11:func:`~calorine.tools.apply_kramers_kronig`: components are matched by column-name
12suffix (e.g. ``epsilon_real_xx``/``epsilon_imag_xx``), so both the full Voigt tensor and
13the bare unsuffixed (isotropic/molecular) case are handled uniformly.
14"""
16import numpy as np
17from pandas import DataFrame
18from ase.units import _c as c_SI
20from .spectra import _rad_s_to_thz
23def _eps_to_nk(eps_real, eps_imag):
24 """Complex dielectric function -> (n, kappa).
26 n is taken non-negative (the physical branch of the square root); kappa then takes
27 the sign of eps_imag, since eps_imag = 2*n*kappa with n >= 0. np.clip guards
28 against tiny negative values from floating-point roundoff before taking the square
29 root; mathematically ``eps_abs +/- eps_real`` is always non-negative.
30 """
31 eps_abs = np.sqrt(eps_real ** 2 + eps_imag ** 2)
32 n = np.sqrt(np.clip((eps_abs + eps_real) / 2, 0.0, None))
33 kappa = np.sqrt(np.clip((eps_abs - eps_real) / 2, 0.0, None))
34 kappa = np.copysign(kappa, eps_imag)
35 return n, kappa
38def get_refractive_index(df: DataFrame) -> DataFrame:
39 r"""Compute the complex index of refraction from the complex dielectric function.
41 Given the complex dielectric function :math:`\epsilon = \epsilon_1 + i\epsilon_2`,
42 the complex index of refraction :math:`N = n + i\kappa` is
44 .. math::
46 n = \sqrt{\frac{|\epsilon| + \epsilon_1}{2}}, \qquad
47 \kappa = \sqrt{\frac{|\epsilon| - \epsilon_1}{2}},
49 where :math:`|\epsilon| = \sqrt{\epsilon_1^2 + \epsilon_2^2}`, taking the physical
50 branch :math:`n \geq 0` and giving :math:`\kappa` the sign of :math:`\epsilon_2`
51 (since :math:`\epsilon_2 = 2n\kappa` with :math:`n \geq 0`). For a passive medium
52 at positive frequency :math:`\epsilon_2 \geq 0`, so :math:`\kappa \geq 0` as usual;
53 a negative :math:`\epsilon_2` (e.g. numerical noise near zero) carries through as a
54 negative :math:`\kappa` rather than being silently discarded. Both :math:`n` and
55 :math:`\kappa` are dimensionless.
57 Each ``epsilon_real{suffix}``/``epsilon_imag{suffix}`` column pair in :attr:`df`
58 (e.g. ``epsilon_real_xx``/``epsilon_imag_xx``, or the bare
59 ``epsilon_real``/``epsilon_imag``) produces a corresponding
60 ``refractive_index_real{suffix}``/``refractive_index_imag{suffix}`` column pair.
62 Parameters
63 ----------
64 df
65 DataFrame as returned by :func:`~calorine.tools.get_dielectric_function` (with
66 ``return_real_part=True``) or :func:`~calorine.tools.apply_kramers_kronig`;
67 must contain at least one ``epsilon_real{suffix}``/``epsilon_imag{suffix}``
68 column pair sharing the same suffix.
70 Returns
71 -------
72 DataFrame
73 Input DataFrame with additional ``refractive_index_real{suffix}`` (:math:`n`,
74 dimensionless) and ``refractive_index_imag{suffix}`` (:math:`\kappa`,
75 dimensionless) columns appended.
77 Raises
78 ------
79 ValueError
80 If :attr:`df` contains no ``epsilon_imag*`` columns, or an
81 ``epsilon_imag{suffix}`` column without a matching ``epsilon_real{suffix}``
82 column (e.g. output of :func:`~calorine.tools.get_dielectric_function` called
83 with ``return_real_part=False``).
84 """
85 imag_cols = [c for c in df.columns if c.startswith('epsilon_imag')]
86 if not imag_cols:
87 raise ValueError(
88 "df must contain at least one 'epsilon_imag*' column; "
89 f'got columns: {list(df.columns)}'
90 )
92 missing = [
93 'epsilon_real' + col[len('epsilon_imag'):]
94 for col in imag_cols
95 if 'epsilon_real' + col[len('epsilon_imag'):] not in df.columns
96 ]
97 if missing:
98 raise ValueError(
99 f'df is missing columns {missing!r} required to compute the refractive '
100 'index; call get_dielectric_function with return_real_part=True, or run '
101 'apply_kramers_kronig first.'
102 )
104 df = df.copy()
105 for col in imag_cols:
106 suffix = col[len('epsilon_imag'):]
107 n, kappa = _eps_to_nk(df['epsilon_real' + suffix].to_numpy(), df[col].to_numpy())
108 df['refractive_index_real' + suffix] = n
109 df['refractive_index_imag' + suffix] = kappa
110 return df
113def get_absorption_coefficient(df: DataFrame) -> DataFrame:
114 r"""Compute the absorption coefficient from the extinction coefficient.
116 .. math::
118 \alpha(\omega) = \frac{2\kappa\omega}{c},
120 where :math:`\kappa` is the extinction coefficient (imaginary part of the complex
121 index of refraction) and :math:`\omega` is the angular frequency. With
122 :math:`\omega` in rad/s and :math:`c` in m/s this gives :math:`\alpha` in
123 m\ :sup:`-1`; :math:`\alpha` is returned here in cm\ :sup:`-1` (matching the
124 ``wavenumber_invcm`` convention used elsewhere in this module, though note that
125 :math:`\alpha` is a physically distinct quantity from the spectroscopic wavenumber
126 :math:`\omega/2\pi c`):
128 .. math::
130 \alpha\,[\mathrm{cm}^{-1}] = \frac{2\kappa\omega}{100\,c}.
132 Each ``refractive_index_imag{suffix}`` column in :attr:`df` (as returned by
133 :func:`~calorine.tools.get_refractive_index`) produces a corresponding
134 ``absorption_coefficient{suffix}`` column. As a convenience, if :attr:`df` lacks
135 ``refractive_index_imag{suffix}`` but contains a matching ``epsilon_real{suffix}``/
136 ``epsilon_imag{suffix}`` pair, :math:`\kappa` is derived internally using the same
137 relation as :func:`~calorine.tools.get_refractive_index` (without adding the
138 corresponding ``refractive_index_real{suffix}``/``refractive_index_imag{suffix}``
139 columns to the output).
141 Parameters
142 ----------
143 df
144 DataFrame containing ``angular_frequency`` (THz) and at least one
145 ``refractive_index_imag{suffix}`` column (as returned by
146 :func:`~calorine.tools.get_refractive_index`), or, alternatively, a matching
147 ``epsilon_real{suffix}``/``epsilon_imag{suffix}`` column pair (as returned by
148 :func:`~calorine.tools.get_dielectric_function` with ``return_real_part=True``).
150 Returns
151 -------
152 DataFrame
153 Input DataFrame with additional ``absorption_coefficient{suffix}`` columns
154 (cm\ :sup:`-1`) appended.
156 Raises
157 ------
158 ValueError
159 If :attr:`df` lacks ``angular_frequency``, or contains neither
160 ``refractive_index_imag*`` columns nor a matching ``epsilon_real*``/
161 ``epsilon_imag*`` column pair for a given component.
162 """
163 if 'angular_frequency' not in df.columns:
164 raise ValueError("df must contain an 'angular_frequency' column")
165 omega = df['angular_frequency'].to_numpy() / _rad_s_to_thz
167 kappas = {}
168 for col in df.columns:
169 if col.startswith('refractive_index_imag'):
170 kappas[col[len('refractive_index_imag'):]] = df[col].to_numpy()
172 missing = []
173 for col in df.columns:
174 if not col.startswith('epsilon_imag'):
175 continue
176 suffix = col[len('epsilon_imag'):]
177 if suffix in kappas:
178 continue
179 real_col = 'epsilon_real' + suffix
180 if real_col not in df.columns:
181 missing.append(real_col)
182 continue
183 _, kappa = _eps_to_nk(df[real_col].to_numpy(), df[col].to_numpy())
184 kappas[suffix] = kappa
186 if missing:
187 raise ValueError(
188 f'df is missing columns {missing!r} required to compute the absorption '
189 "coefficient (no matching 'refractive_index_imag*' column and no matching "
190 "'epsilon_real*' column to derive kappa from)."
191 )
192 if not kappas:
193 raise ValueError(
194 "df must contain at least one 'refractive_index_imag*' column (as returned "
195 "by get_refractive_index) or a matching 'epsilon_real*'/'epsilon_imag*' "
196 'column pair'
197 )
199 df = df.copy()
200 for suffix, kappa in kappas.items():
201 df['absorption_coefficient' + suffix] = 2.0 * kappa * omega / (100.0 * c_SI)
202 return df
205def get_dielectric_function_from_refractive_index(df: DataFrame) -> DataFrame:
206 r"""Compute the complex dielectric function from the complex index of refraction.
208 Given the complex index of refraction :math:`N = n + i\kappa`,
210 .. math::
212 \epsilon_1 = n^2 - \kappa^2, \qquad \epsilon_2 = 2 n \kappa.
214 This is the exact inverse of :func:`~calorine.tools.get_refractive_index`.
216 Each ``refractive_index_real{suffix}`` column (:math:`n`) must be paired with
217 either a ``refractive_index_imag{suffix}`` column (:math:`\kappa`, as returned by
218 :func:`~calorine.tools.get_refractive_index`) or an ``absorption_coefficient{suffix}``
219 column (:math:`\alpha`, in cm\ :sup:`-1`, as returned by
220 :func:`~calorine.tools.get_absorption_coefficient`), from which :math:`\kappa` is
221 recovered via the inverse of :math:`\alpha = 2\kappa\omega/(100 c)`,
223 .. math::
225 \kappa = \frac{100\,c\,\alpha}{2\omega},
227 which additionally requires the ``angular_frequency`` column. If both a
228 ``refractive_index_imag{suffix}`` and an ``absorption_coefficient{suffix}`` column
229 are present for the same suffix, the former takes precedence.
231 Parameters
232 ----------
233 df
234 DataFrame containing at least one ``refractive_index_real{suffix}`` column
235 together with a matching ``refractive_index_imag{suffix}`` or
236 ``absorption_coefficient{suffix}`` column (in the latter case
237 ``angular_frequency`` in THz must also be present).
239 Returns
240 -------
241 DataFrame
242 Input DataFrame with additional ``epsilon_real{suffix}`` and
243 ``epsilon_imag{suffix}`` columns appended.
245 Raises
246 ------
247 ValueError
248 If a ``refractive_index_real{suffix}`` column has no matching
249 ``refractive_index_imag{suffix}`` or ``absorption_coefficient{suffix}``
250 column, or if the latter is used but ``angular_frequency`` is missing.
251 """
252 real_cols = [c for c in df.columns if c.startswith('refractive_index_real')]
253 if not real_cols:
254 raise ValueError("df must contain at least one 'refractive_index_real*' column")
256 have_omega = 'angular_frequency' in df.columns
257 omega = df['angular_frequency'].to_numpy() / _rad_s_to_thz if have_omega else None
259 kappas = {}
260 missing = []
261 for col in real_cols:
262 suffix = col[len('refractive_index_real'):]
263 kappa_col = 'refractive_index_imag' + suffix
264 alpha_col = 'absorption_coefficient' + suffix
265 if kappa_col in df.columns:
266 kappas[suffix] = df[kappa_col].to_numpy()
267 elif alpha_col in df.columns:
268 if not have_omega:
269 missing.append(
270 f"'angular_frequency' (required to convert {alpha_col!r} to kappa)"
271 )
272 continue
273 kappas[suffix] = 100.0 * c_SI * df[alpha_col].to_numpy() / (2.0 * omega)
274 else:
275 missing.append(f'{kappa_col!r} or {alpha_col!r}')
277 if missing:
278 raise ValueError(
279 f'df is missing required columns to invert refractive_index_real*: {missing}'
280 )
282 df = df.copy()
283 for suffix, kappa in kappas.items():
284 n = df['refractive_index_real' + suffix].to_numpy()
285 df['epsilon_real' + suffix] = n ** 2 - kappa ** 2
286 df['epsilon_imag' + suffix] = 2.0 * n * kappa
287 return df