"""
Multi-ion container
"""
import astropy.units as u
import numpy as np
from astropy.convolution import convolve, Model1DKernel
from astropy.modeling.models import Gaussian1D
import fiasco
from fiasco.util import parse_ion_name
from fiasco.util.exceptions import MissingDatasetException
__all__ = ['IonCollection']
[docs]
class IonCollection:
"""
Container for holding multiple `~fiasco.Ion` instances.
This container is most useful when needing to group many ions together in
order to perform some aggregate calculation like a radiative loss curve or
a composite spectrum made up of ions from many different elements.
Parameters
----------
*ions: `fiasco.Ion` or `fiasco.IonCollection`
Entries can be either ions or collections of ion.
"""
def __init__(self, *args):
# Import here to avoid circular imports
from fiasco import log
self.log = log
self._ion_list = []
for item in args:
if isinstance(item, fiasco.Ion):
self._ion_list.append(item)
elif isinstance(item, fiasco.IonCollection):
self._ion_list += item._ion_list
else:
raise TypeError(f'{item} has unrecognized type {type(item)}',
'and cannot be added to collection.')
if not all([all(self[0].temperature == ion.temperature) for ion in self]):
raise ValueError('Temperatures for all ions in collection must be the same.')
def __getitem__(self, value):
ions = np.array(self._ion_list)[value]
if isinstance(ions, fiasco.Ion):
return ions
else:
return IonCollection(*ions)
def __contains__(self, value):
if isinstance(value, (str, tuple)): # NOQA: UP038
pair = parse_ion_name(value)
elif isinstance(value, fiasco.Ion):
pair = value._base_rep
return pair in [i._base_rep for i in self._ion_list]
def __add__(self, value):
return IonCollection(self, value)
def __radd__(self, value):
return IonCollection(value, self)
def __len__(self):
return len(self._ion_list)
def __repr__(self):
ion_name_list = '\n'.join([i.ion_name for i in self._ion_list])
return f"""Ion Collection
--------------
Number of ions: {len(self)}
Temperature range: [{self.temperature[0].to(u.MK):.3f}, {self.temperature[-1].to(u.MK):.3f}]
Available Ions
--------------
{ion_name_list}"""
@property
def temperature(self,):
# Temperatures for all ions must be the same
return self[0].temperature
[docs]
@u.quantity_input
def free_free(self, wavelength: u.angstrom):
r"""
Compute combined free-free continuum emission (bremsstrahlung).
.. note:: Both abundance and ionization fraction are included here
The combined free-free continuum is given by,
.. math::
P_{ff}(\lambda,T) = \sum_{X,k}\mathrm{Ab}(X)f(X_{k})C_{ff, X_k}(\lambda,T)
where :math:`\mathrm{Ab}(X)` is the abundance of element :math:`X`,
:math:`f(X_{k})` is the ionization equilibrium of the ion,
and :math:`C_{ff, X_k}(\lambda,T)` is the free-free emission of the ion
as computed by `fiasco.Ion.free_free`.
The sum is taken over all ions in the collection.
Parameters
----------
wavelength : `~astropy.units.Quantity`
See Also
--------
fiasco.Ion.free_free
"""
wavelength = np.atleast_1d(wavelength)
free_free = u.Quantity(np.zeros(self.temperature.shape + wavelength.shape),
'erg cm^3 s^-1 Angstrom^-1')
for ion in self:
ff = ion.free_free(wavelength)
try:
abundance = ion.abundance
ionization_fraction = ion.ionization_fraction
except MissingDatasetException as e:
self.log.warning(f'{ion.ion_name} not included in free-free emission. {e}')
continue
else:
free_free += ff * abundance * ionization_fraction[:, np.newaxis]
return free_free
[docs]
@u.quantity_input
def free_bound(self, wavelength: u.angstrom, **kwargs):
r"""
Compute combined free-bound continuum emission.
.. note:: Both abundance and ionization fraction are included here.
The combined free-bound continuum is given by,
.. math::
P_{fb}(\lambda,T) = \sum_{X,k}\mathrm{Ab}(X)f(X_{k+1})C_{fb, X_k}(\lambda,T)
where :math:`\mathrm{Ab}(X)` is the abundance of element :math:`X`,
:math:`f(X_{k+1})` is the ionization equilibrium of the recombining ion
:math:`X_{k+1}`,
and :math:`C_{fb, X_k}(\lambda,T)` is the free-bound emission of the recombined
ion :math:`X_k` as computed by `fiasco.Ion.free_bound`.
The sum is taken over all ions in the collection.
Parameters
----------
wavelength : `~astropy.units.Quantity`
See Also
--------
fiasco.Ion.free_bound
"""
wavelength = np.atleast_1d(wavelength)
free_bound = u.Quantity(np.zeros(self.temperature.shape + wavelength.shape),
'erg cm^3 s^-1 Angstrom^-1')
for ion in self:
try:
fb = ion.free_bound(wavelength, **kwargs)
abundance = ion.abundance
# NOTE: the free-bound emissivity gets multiplied by the population
# fraction of the recombining ion, that is, the ion with one higher
# charge state.
ionization_fraction = ion.next_ion().ionization_fraction
except MissingDatasetException as e:
self.log.warning(f'{ion.ion_name} not included in free-bound emission. {e}')
continue
else:
free_bound += fb * abundance * ionization_fraction[:, np.newaxis]
return free_bound
[docs]
@u.quantity_input
def two_photon(self, wavelength: u.angstrom, electron_density: u.cm**-3, **kwargs):
r"""
Compute the two-photon continuum emission.
.. note:: Both abundance and ionization equilibrium are included here.
The combined two-photon continuum is given by
.. math::
P_{2p}(\lambda,T,n_{e}) = \sum_{X,k}\mathrm{Ab}(X)f(X_{k})C_{2p, X_k}(\lambda,T,n_{e})
where :math:`\mathrm{Ab}(X)` is the abundance of element :math:`X`,
:math:`f(X_{k})` is the ionization equilibrium of the emitting ion :math:`X_{k}`,
and :math:`C_{fb, X_k}(\lambda,T)` is the two-photon emission of the
ion :math:`X_k` as computed by `fiasco.Ion.two_photon`.
The sum is taken over all ions in the collection.
Parameters
----------
wavelength : `~astropy.units.Quantity`
electron_density: `~astropy.units.Quantity`
See Also
--------
fiasco.Ion.two_photon
"""
wavelength = np.atleast_1d(wavelength)
electron_density = np.atleast_1d(electron_density)
two_photon = u.Quantity(np.zeros(self.temperature.shape + electron_density.shape + wavelength.shape),
'erg cm^3 s^-1 Angstrom^-1')
for ion in self:
try:
tp = ion.two_photon(wavelength, electron_density, **kwargs)
except MissingDatasetException as e:
self.log.warning(f'{ion.ion_name} not included in two-photon emission. {e}')
continue
else:
two_photon += tp * ion.abundance * ion.ionization_fraction[:, np.newaxis, np.newaxis]
return two_photon
[docs]
@u.quantity_input
def spectrum(self, density: u.cm**(-3), emission_measure: u.cm**(-5), wavelength_range=None,
bin_width=None, kernel=None, **kwargs):
"""
Calculate spectrum for multiple ions
.. warning:: This function is still experimental and may be removed or significantly
refactored in future releases.
Parameters
----------
density : `~astropy.units.Quantity`
Electron number density
emission_measure : `~astropy.units.Quantity`
Column emission measure
wavelength_range : `~astropy.units.Quantity`, optional
Tuple of bounds on which transitions to include. Default includes all
bin_width : `~astropy.units.Quantity`, optional
Wavelength resolution to bin intensity values. Default to 1/100 of range
kernel : `~astropy.convolution.kernels.Model1DKernel`, optional
Convolution kernel for computing spectrum. Default is gaussian kernel with thermal width
Returns
-------
wavelength : `~astropy.units.Quantity`
Continuous wavelength
spectrum : `~astropy.units.Quantity`
Continuous intensity distribution as a function of wavelength
See Also
--------
fiasco.Ion.spectrum : Compute spectrum for a single ion
"""
if wavelength_range is None:
wavelength_range = u.Quantity([0, np.inf], 'angstrom')
# Compute all intensities
intensity, wavelength = None, None
for ion in self:
# If no elvlc and wgfa data, cannot calculate spectra
try:
wave = ion.transitions.wavelength[ion.transitions.is_bound_bound]
except MissingDatasetException:
self.log.warning(f'No transition data available for {ion.ion_name}')
continue
else:
i_wavelength, = np.where(np.logical_and(wave >= wavelength_range[0],
wave <= wavelength_range[1]))
# Skip if no transitions in this range
if i_wavelength.shape[0] == 0:
continue
# If no scups data, cannot calculate spectra
try:
intens = ion.intensity(density, emission_measure, **kwargs)
except MissingDatasetException:
self.log.warning(f'No collision data available for {ion.ion_name}')
continue
if wavelength is None:
wavelength = wave[i_wavelength].value
intensity = intens[:, :, i_wavelength].value
else:
wavelength = np.concatenate((wavelength, wave[i_wavelength].value))
intensity = np.concatenate((intensity, intens[:, :, i_wavelength].value), axis=2)
if wavelength is None:
raise ValueError('No collision or transition data available for any ion in collection.')
if np.any(np.isinf(wavelength_range)):
wavelength_range = u.Quantity([wavelength.min(), wavelength.max()], wave.unit)
# Setup bins
if bin_width is None:
bin_width = np.diff(wavelength_range)[0]/100.
num_bins = int((np.diff(wavelength_range)[0]/bin_width).value)
wavelength_edges = np.linspace(*wavelength_range.value, num_bins+1)
# Setup convolution kernel
if kernel is None:
self.log.warning('Using 0.1 Angstroms (ie. not the actual thermal width) for thermal broadening')
std = 0.1*u.angstrom # FIXME: should default to thermal width
std_eff = (std/bin_width).value # Scale sigma by bin width
# Kernel size must be odd
x_size = int(8*std_eff)+1 if (int(8*std_eff) % 2) == 0 else int(8*std_eff)
m = Gaussian1D(amplitude=1./np.sqrt(2.*np.pi)/std.value, mean=0., stddev=std_eff)
kernel = Model1DKernel(m, x_size=x_size)
# FIXME: This is very inefficient! Vectorize somehow...
spectrum = np.zeros(intensity.shape[:2]+(num_bins,))
for i in range(spectrum.shape[0]):
for j in range(spectrum.shape[1]):
tmp, _ = np.histogram(wavelength, bins=wavelength_edges, weights=intensity[i, j, :])
spectrum[i, j, :] = convolve(tmp, kernel, normalize_kernel=False)
wavelength = (wavelength_edges[1:] + wavelength_edges[:-1])/2. * wave.unit
spectrum = spectrum * intens.unit / bin_width.unit
return wavelength, spectrum
[docs]
@u.quantity_input
def radiative_loss(self, density: u.cm**(-3), use_itoh=False, **kwargs) -> u.Unit('erg cm3 s-1'):
r"""
Calculate the total wavelength-integrated radiative loss rate including the
bound-bound, free-bound, and free-free emission contributions
.. note:: The calculation does not include two-photon continuum emission, which is also
neglected in the CHIANTI IDL routines.
Parameters
----------
density : `~astropy.units.Quantity`
Electron number density
use_itoh : `bool`, optional
Whether to use Gaunt factors taken from :cite:t:`itoh_radiative_2002` for the calculation
of free-free emission. Defaults to false.
Returns
-------
rad_loss_total : `~astropy.units.Quantity`
The total bolometric radiative loss rate
"""
rad_loss_bound_bound = self.bound_bound_radiative_loss(density, **kwargs)
rad_loss_free_free = self.free_free_radiative_loss(use_itoh=use_itoh)
rad_loss_free_bound = self.free_bound_radiative_loss()
rad_loss_total = (rad_loss_bound_bound
+ rad_loss_free_free[:, np.newaxis]
+ rad_loss_free_bound[:, np.newaxis])
return rad_loss_total
[docs]
@u.quantity_input
def bound_bound_radiative_loss(self, density, **kwargs) -> u.Unit('erg cm3 s-1'):
r"""
Calculate the radiative loss rate from bound-bound emission (line emission)
integrated over wavelength.
Parameters
----------
density : `~astropy.units.Quantity`
Electron number density
Returns
-------
rad_loss : `~astropy.units.Quantity`
The bolometric bound-bound radiative loss rate per unit emission measure
"""
density = np.atleast_1d(density)
if kwargs.get('couple_density_to_temperature', False):
shape = self.temperature.shape + (1,)
else:
shape = self.temperature.shape + density.shape
rad_loss = u.Quantity(np.zeros(shape), 'erg cm^3 s^-1')
for ion in self:
try:
g = ion.contribution_function(density, **kwargs)
except MissingDatasetException as e:
self.log.warning(f'{ion.ion_name} not included in the bound-bound emission. {e}')
continue
rad_loss += g.sum(axis=2)
return rad_loss
[docs]
@u.quantity_input
def free_free_radiative_loss(self, use_itoh=False) -> u.Unit('erg cm3 s-1'):
r"""
Calculate the radiative loss rate from free-free emission (bremsstrahlung)
integrated over wavelength.
Parameters
----------
use_itoh : `bool`, optional
Whether to use Gaunt factors taken from :cite:t:`itoh_radiative_2002`.
Defaults to false.
Returns
-------
rad_loss : `~astropy.units.Quantity`
The bolometric free-free radiative loss rate per unit emission measure
See Also
-------
fiasco.GauntFactor.free_free_integrated
"""
free_free = u.Quantity(np.zeros(self.temperature.shape), 'erg cm^3 s^-1')
for ion in self:
try:
ff = ion.free_free_radiative_loss(use_itoh=use_itoh)
abundance = ion.abundance
ionization_fraction = ion.ionization_fraction
except MissingDatasetException as e:
self.log.warning(f'{ion.ion_name} not included in free-free radiative loss. {e}')
continue
else:
free_free += ff * abundance * ionization_fraction
return free_free
[docs]
@u.quantity_input
def free_bound_radiative_loss(self) -> u.Unit('erg cm3 s-1'):
r"""
Calculate the radiative loss rate from free-bound emission (collisional recombination)
integrated over wavelength.
Returns
-------
rad_loss : `~astropy.units.Quantity`
The bolometric free-bound radiative loss rate per unit emission measure
"""
free_bound = u.Quantity(np.zeros(self.temperature.shape), 'erg cm^3 s^-1')
for ion in self:
try:
fb = ion.free_bound_radiative_loss()
abundance = ion.abundance
ionization_fraction = ion.ionization_fraction
except MissingDatasetException as e:
self.log.warning(f'{ion.ion_name} not included in free-bound radiative loss. {e}')
continue
else:
free_bound += fb * abundance * ionization_fraction
return free_bound