"""
Gaunt factor object.
"""
import astropy.constants as const
import astropy.units as u
import numpy as np
from scipy.interpolate import PchipInterpolator
from scipy.ndimage import map_coordinates
from scipy.special import polygamma
import fiasco
from fiasco.io.datalayer import DataIndexer
from fiasco.util import check_database, needs_dataset
from fiasco.util.exceptions import MissingDatasetException
__all__ = ['GauntFactor']
[docs]
class GauntFactor:
"""
Class for calculating the Gaunt factor for various continuum processes.
The Gaunt factor is defined as the ratio of the true cross-section to the
semi-classical Kramers cross-section, and thus is essentially a multiplicative
correction for quantum mechanical effects. It is a unitless quantity.
Parameters
------------
hdf5_dbase_root: path-like, optional
Path to built database
kwargs:
All keyword arguments to `fiasco.util.check_database` are also supported here
"""
def __init__(self, hdf5_dbase_root=None, **kwargs):
if hdf5_dbase_root is None:
self.hdf5_dbase_root = fiasco.defaults['hdf5_dbase_root']
else:
self.hdf5_dbase_root = hdf5_dbase_root
check_database(self.hdf5_dbase_root, **kwargs)
def __repr__(self):
return f"""Gaunt factor object
---------------------
HDF5 Database: {self.hdf5_dbase_root}"""
@property
def _gffgu(self):
data_path = '/'.join(['continuum', 'gffgu'])
return DataIndexer.create_indexer(self.hdf5_dbase_root, data_path)
@property
def _gffint(self):
data_path = '/'.join(['continuum', 'gffint'])
return DataIndexer.create_indexer(self.hdf5_dbase_root, data_path)
@property
def _itoh(self):
data_path = '/'.join(['continuum', 'itoh'])
return DataIndexer.create_indexer(self.hdf5_dbase_root, data_path)
@property
def _itoh_integrated_gaunt(self):
data_path = '/'.join(['continuum', 'itoh_integrated_gaunt'])
return DataIndexer.create_indexer(self.hdf5_dbase_root, data_path)
@property
def _itoh_integrated_gaunt_nonrel(self):
data_path = '/'.join(['continuum', 'itoh_integrated_gaunt_nonrel'])
return DataIndexer.create_indexer(self.hdf5_dbase_root, data_path)
@property
def _klgfb(self):
data_path = '/'.join(['continuum', 'klgfb'])
return DataIndexer.create_indexer(self.hdf5_dbase_root, data_path)
[docs]
@u.quantity_input
def free_free(self, temperature: u.K, wavelength: u.angstrom, atomic_number, charge_state) -> u.dimensionless_unscaled:
r"""
The Gaunt factor for free-free emission as a function of temperature and wavelength.
The free-free Gaunt factor is calculated from a lookup table of temperature averaged
free-free Gaunt factors from Table 2 of :cite:t:`sutherland_accurate_1998` as a function
of :math:`\log{\gamma^2},\log{u}`, where :math:`\gamma^2=Z^2\mathrm{Ry}/k_BT`
and :math:`u=hc/\lambda k_BT`.
For the regime, :math:`6<\log_{10}(T)< 8.5` and :math:`-4<\log_{10}(u)<1`, the above
prescription is replaced with the fitting formula of :cite:t:`itoh_relativistic_2000`
for the relativistic free-free Gaunt factor. This is given by Eq. 4 of
:cite:t:`itoh_relativistic_2000`,
.. math::
g_{ff} = \sum_{i,j=0}^{10} a_{ij}t^iU^j,
where :math:`t=(\log{T} - 7.25)/1.25` and :math:`U=(\log{u} + 1.5)/2.5`.
Parameters
----------
temperature : `~astropy.units.Quantity`
The temperature(s) for which to calculate the Gaunt factor
wavelength : `~astropy.units.Quantity`
The wavelength(s) at which to calculate the Gaunt factor
atomic_number : `int`
The atomic number of the emitting element
charge_state : `int`
The charge state of the emitting ion
See Also
--------
fiasco.Ion.free_free
"""
gf_itoh = self._free_free_itoh(temperature, wavelength, atomic_number)
gf_sutherland = self._free_free_sutherland(temperature, wavelength, charge_state)
gf = np.where(np.isnan(gf_itoh), gf_sutherland, gf_itoh)
return gf
@needs_dataset('itoh')
@u.quantity_input
def _free_free_itoh(self, temperature: u.K, wavelength: u.angstrom, atomic_number) -> u.dimensionless_unscaled:
log10_temperature = np.log10(temperature.to(u.K).value)
# calculate scaled energy and temperature
tmp = np.outer(temperature, wavelength)
lower_u = const.h * const.c / const.k_B / tmp
upper_u = 1. / 2.5 * (np.log10(lower_u) + 1.5)
t = 1. / 1.25 * (log10_temperature - 7.25)
itoh_coefficients = self._itoh['a'][self._itoh['Z']==atomic_number].squeeze()
# calculate Gaunt factor
gf = u.Quantity(np.zeros(upper_u.shape))
for i in range(itoh_coefficients.shape[0]):
for j in range(itoh_coefficients.shape[1]):
gf += (itoh_coefficients[i, j] * (t**i))[:, np.newaxis] * (upper_u**j)
# apply NaNs where Itoh approximation is not valid
gf = np.where(np.logical_and(np.log10(lower_u) >= -4., np.log10(lower_u) <= 1.0), gf, np.nan)
gf[np.where(np.logical_or(log10_temperature <= 6.0, log10_temperature >= 8.5)), :] = np.nan
return gf
@needs_dataset('gffgu')
@u.quantity_input
def _free_free_sutherland(self, temperature: u.K, wavelength: u.angstrom, charge_state) -> u.dimensionless_unscaled:
Ry = const.h * const.c * const.Ryd
tmp = np.outer(temperature, wavelength)
lower_u = const.h * const.c / const.k_B / tmp
gamma_squared = ((charge_state**2) * Ry / const.k_B / temperature[:, np.newaxis]
* np.ones(lower_u.shape))
# NOTE: This escape hatch avoids a divide-by-zero warning as we cannot take log10
# of 0. This does not matter as the free-free continuum will be 0 for zero charge
# state anyway.
if charge_state == 0:
return u.Quantity(np.zeros(lower_u.shape))
# convert to index coordinates
i_lower_u = (np.log10(lower_u) + 4.) * 10.
i_gamma_squared = (np.log10(gamma_squared) + 4.) * 5.
indices = [i_gamma_squared.flatten(), i_lower_u.flatten()]
# interpolate data to scaled quantities
# FIXME: interpolate without reshaping?
gf_data = self._gffgu['gaunt_factor'].reshape(
np.unique(self._gffgu['u']).shape[0],
np.unique(self._gffgu['gamma_squared']).shape[0],
)
gf = map_coordinates(gf_data, indices, order=1, mode='nearest').reshape(lower_u.shape)
return u.Quantity(np.where(gf < 0., 0., gf))
[docs]
@u.quantity_input
def free_free_integrated(self, temperature: u.K, charge_state, use_itoh=False) -> u.dimensionless_unscaled:
r"""
The wavelength-integrated Gaunt factor for free-free emission as a function of temperature.
The wavelength-integrated Gaunt factor is primarily used for calculating the total radiative losses from
free-free emission.
By default, this calculation is done with the form specified in :cite:t:`sutherland_accurate_1998`,
which is valid over a wide range of temperatures.
The ``use_itoh`` option substitutes the form specified by :cite:t:`itoh_radiative_2002`, which is more
accurate but has a more limited range of validity.
The difference between the two forms is small, as shown in :cite:t:`young_chianti_2019-1`.
The CHIANTI atomic database only uses the :cite:t:`sutherland_accurate_1998` form as a result, but
includes the data sets for both forms.
.. note:: The Gaunt factor calculation of :cite:t:`itoh_radiative_2002` includes both a relativistic
(Eq. 5) and non-relativistic (Eq. 13) form.
The relativistic form is valid over the temperature range :math:`6.0\leq\log_{10}T\leq8.5`
and for charge states :math:`1\le z\le 28`.
The nonrelativistic form is valid over :math:`-3\leq\log_{10}\gamma^{2}\leq 2` where
:math:`\gamma^2=z^2\mathrm{Ry}/k_BT`.
Outside of these ranges, the form of :cite:t:`sutherland_accurate_1998` is used.
Parameters
----------
temperature : `~astropy.units.Quantity`
The temperature(s) for which to calculate the Gaunt factor
charge_state : `int`
The charge state of the ion
use_itoh : `bool`, optional
If true, use the :cite:t:`itoh_radiative_2002` Gaunt factors over valid ranges.
If false (default), use the :cite:t:`sutherland_accurate_1998` Gaunt factors instead.
See Also
--------
fiasco.Ion.free_free_radiative_loss
"""
if charge_state == 0:
return u.Quantity(np.zeros(temperature.shape))
gf = self._free_free_sutherland_integrated(temperature, charge_state)
if use_itoh:
gf_itoh = self._free_free_itoh_integrated(temperature, charge_state)
gf = np.where(np.isnan(gf_itoh), gf, gf_itoh)
return gf
@needs_dataset('gffint')
@u.quantity_input
def _free_free_sutherland_integrated(self, temperature: u.K, charge_state) -> u.dimensionless_unscaled:
"""
The wavelength-integrated free-free Gaunt factor, as specified by :cite:t:`sutherland_accurate_1998`,
in Section 2.4 of that work.
Parameters
----------
temperature : `~astropy.units.Quantity`
The temperature(s) for which to calculate the Gaunt factor
charge_state : `int`
The charge state of the ion
"""
temperature = np.atleast_1d(temperature)
Ry = const.h * const.c * const.Ryd
log_gamma_squared = np.log10((charge_state**2 * Ry) / (const.k_B * temperature))
index = [np.abs(self._gffint['log_gamma_squared'] - x).argmin() for x in log_gamma_squared]
delta = log_gamma_squared - self._gffint['log_gamma_squared'][index]
# The spline fit was pre-calculated by Sutherland 1998:
return self._gffint['gaunt_factor'][index] + delta * (self._gffint['s1'][index] + delta * (self._gffint['s2'][index] + delta * self._gffint['s3'][index]))
@u.quantity_input
def _free_free_itoh_integrated(self, temperature: u.K, charge_state) -> u.dimensionless_unscaled:
r"""
The wavelength-integrated free-free Gaunt factor, as specified by :cite:t:`itoh_radiative_2002`.
Parameters
----------
temperature : `~astropy.units.Quantity`
The temperature(s) for which to calculate the Gaunt factor
charge_state : `int`
The charge state of the ion
"""
temperature = np.atleast_1d(temperature)
try:
gf_relativistic = self._free_free_itoh_integrated_relativistic(temperature, charge_state)
except MissingDatasetException:
gf_relativistic = np.full(len(temperature), np.nan)
try:
gf_nonrelativistic = self._free_free_itoh_integrated_nonrelativistic(temperature, charge_state)
except MissingDatasetException:
gf_nonrelativistic = np.full(len(temperature), np.nan)
return np.where(np.isnan(gf_nonrelativistic), gf_relativistic, gf_nonrelativistic)
@needs_dataset('itoh_integrated_gaunt_nonrel')
@u.quantity_input
def _free_free_itoh_integrated_nonrelativistic(self, temperature: u.K, charge_state) -> u.dimensionless_unscaled:
r"""
The wavelength-integrated non-relativistic free-free Gaunt factor, as specified by
:cite:t:`itoh_radiative_2002`.
This form is only valid between :math:`-3.0 \leq \log_{10} \gamma^{2} \leq 2.0`. We use the form
specified by :cite:t:`sutherland_accurate_1998` outside of this range.
Parameters
----------
temperature : `~astropy.units.Quantity`
The temperature(s) for which to calculate the Gaunt factor
charge_state : `int`
The charge state of the ion
"""
temperature = np.atleast_1d(temperature)
Ry = const.h * const.c * const.Ryd
gamma_squared = (charge_state**2) * Ry / (const.k_B * temperature)
gf = u.Quantity(np.zeros(temperature.shape))
Gamma = (np.log10(gamma_squared) + 0.5) / 2.5
b_array = self._itoh_integrated_gaunt_nonrel['b_i']
for i in range(b_array.shape[0]):
gf += b_array[i] * Gamma**i
not_valid = np.logical_or(np.log10(gamma_squared) < -3.0, np.log10(gamma_squared) > 2.0)
return np.where(not_valid, np.nan, gf)
@needs_dataset('itoh_integrated_gaunt')
@u.quantity_input
def _free_free_itoh_integrated_relativistic(self, temperature: u.K, charge_state) -> u.dimensionless_unscaled:
r"""
The wavelength-integrated relativistic free-free Gaunt factor, as specified by
:cite:t:`itoh_radiative_2002`.
The relativistic approximation is only valid between :math:`6.0 \leq \log_{10} T_{e} \leq 8.5`, and charges between 1 and 28.
At cooler temperatures, the calculation uses the non-relativistic form, while at higher temperatures it defaults back to the
expressions from :cite:t:`sutherland_accurate_1998`.
Parameters
----------
temperature : `~astropy.units.Quantity`
The temperature(s) for which to calculate the Gaunt factor
charge_state : `int`
The charge state of the ion
"""
temperature = np.atleast_1d(temperature)
log_temperature = np.log10(temperature.to_value('K'))
z = (charge_state - 14.5) / 13.5
t = (log_temperature-7.25)/1.25
gf = u.Quantity(np.zeros(temperature.shape))
a_matrix = self._itoh_integrated_gaunt['a_ik']
for i in range(a_matrix.shape[0]):
for k in range(a_matrix.shape[1]):
gf += a_matrix[i,k] * z**i * t**k
not_valid = np.logical_or(log_temperature < 6.0, log_temperature > 8.5)
return np.where(not_valid, np.nan, gf)
[docs]
@needs_dataset('klgfb')
@u.quantity_input
def free_bound(self, E_scaled, n, l) -> u.dimensionless_unscaled:
r"""
The Gaunt factor for free-bound emission as a function of scaled energy.
The empirical fits are taken from Table 1 of :cite:t:`karzas_electron_1961`.
In CHIANTI, this is used to compute the cross-sections in the free-bound continuum.
Parameters
----------
E_scaled : array-like
Ratio of photon energy to the ionization energy.
n : `int`
The principal quantum number
l : `int`
The azimuthal quantum number
See Also
--------
fiasco.Ion.free_bound
"""
log_E_scaled = np.log(np.atleast_1d(E_scaled))
index_nl = np.where(np.logical_and(self._klgfb['n']==n, self._klgfb['l']==l))
# If there is no Gaunt factor for n, l, set it to 1
if index_nl[0].shape == (0,):
gf = np.ones(log_E_scaled.shape)
else:
log_pe = self._klgfb.get('log_pe', None)
log_gf = self._klgfb.get('log_gaunt_factor', None)
# NOTE: The reason for this conditional is that the format for the K&L gaunt factor
# data changed in v10.1 of the CHIANTI database such that (1) the data are not
# log-scaled and (2) the photon energies are in Ry such that they need to be scaled
# by the ionization potential (the minimum energy in that file) prior to taking the
# log.
if log_pe is not None and log_gf is not None:
log_pe = log_pe[index_nl]
log_gf = log_gf[index_nl]
else:
pe = self._klgfb['photon_energy'][index_nl]
log_pe = np.log((pe/pe.min()).decompose())
log_gf = np.log(self._klgfb['gaunt_factor'][index_nl])
# Sort for interpolation
log_gf = log_gf[np.argsort(log_pe)]
log_pe = log_pe[np.argsort(log_pe)]
f_interp = PchipInterpolator(log_pe, log_gf, extrapolate=False)
log_gf_interp = f_interp(log_E_scaled)
# For points above the interpolation range, apply linear interpolation using last 5 points
idx_above = np.where(log_E_scaled>log_pe.max())
log_gf_interp[idx_above] = np.interp(log_E_scaled[idx_above], log_pe[-5:], log_gf[-5:])
gf = np.exp(log_gf_interp)
# For points below the interpolation range (i.e. below the ionization threshold), set to zero
gf[log_E_scaled<log_pe.min()] = 0.0
return gf
[docs]
@u.quantity_input
def free_bound_integrated(self, temperature: u.K, atomic_number, charge_state, n_0,
ionization_potential: u.eV, ground_state=True) -> u.dimensionless_unscaled:
r"""
The wavelength-integrated Gaunt factor for free-bound emission as a function of temperature.
The wavelength-integrated free-bound Gaunt factor is calculated using the approach of
:cite:t:`mewe_calculated_1986`.
The Gaunt factor is not calculated for individual levels, except that the ground state has
been specified to be :math:`g_{fb}(n_{0}) = 0.9` following :cite:t:`mewe_calculated_1986`.
For more details on this calculation, see :ref:`fiasco-topic-guide-freebound-gaunt-factor`.
Parameters
----------
temperature : `~astropy.units.Quantity`
The temperature(s) for which to calculate the Gaunt factor
atomic_number : `int`
The atomic number of the element
charge_state : `int`
The charge state of the ion
n_0 : `int`
The principal quantum number n of the ground state of the recombined ion
ionization_potential : `~astropy.units.Quantity`
The ionization potential of the recombined ion
ground_state : `bool`, optional
If True (default), calculate the Gaunt factor for recombination onto the ground state :math:`n = 0`.
Otherwise, calculate for recombination onto higher levels with :math:`n > 1`. See Equation 16 of
:cite:t:`mewe_calculated_1986`.
See Also
--------
fiasco.Ion.free_bound_radiative_loss
"""
z = charge_state
if z == 0:
return u.Quantity(np.zeros(temperature.shape))
else:
Ry = const.h * const.c * const.Ryd
prefactor = (Ry / (const.k_B * temperature)).decompose()
if ground_state:
g_n_0 = 0.9 # The ground state Gaunt factor, g_fb(n_0), prescribed by Mewe et al 1986
z_0 = n_0 * np.sqrt(ionization_potential / Ry).decompose()
# NOTE: Low temperatures can lead to very large terms in the exponential and in some
# cases, the exponential term can exceed the maximum number expressible in double precision.
# These terms are eventually set to zero anyway since the ionization fraction is so small
# at these temperatures.
with np.errstate(over='ignore'):
f_2 = g_n_0 * self._zeta_0(atomic_number, charge_state) * (z_0**4 / n_0**5) * np.exp((prefactor * z_0**2)/(n_0**2))
else:
f_1 = -0.5 * polygamma(2, n_0 + 1)
with np.errstate(over='ignore'):
f_2 = 2.0 * f_1 * z**4 * np.exp((prefactor * z**2)/((n_0+1)**2))
# Large terms in the exponential can lead to infs in the f_2 term. These will be zero'd
# out when multiplied by the ionization equilibrium anyway
f_2 = np.where(np.isinf(f_2), 0.0, f_2)
return (prefactor * f_2)
def _zeta_0(self, atomic_number, charge_state) -> u.dimensionless_unscaled:
r"""
:math:`\zeta_{0}`, the number of vacancies in an ion, which is used to calculate
the wavelength-integrated free-bound Gaunt factor of that ion.
See Section 2.2 and Table 1 of :cite:t:`mewe_calculated_1986`.
"""
difference = atomic_number - (charge_state + 1)
if difference <= 0:
max_vacancies = 1
elif difference <= 8 and difference > 0:
max_vacancies = 9
elif difference <= 22 and difference > 8:
max_vacancies = 27
else:
max_vacancies = 55
return max_vacancies - difference