"""
A class for storing ionization state data for multiple elements or
isotopes.
"""
from collections.abc import Iterator
__all__ = ["IonizationStateCollection"]
from numbers import Integral, Real
import astropy.units as u
import numpy as np
from plasmapy.particles.atomic import atomic_number
from plasmapy.particles.exceptions import (
ChargeError,
InvalidParticleError,
ParticleError,
)
from plasmapy.particles.ionization_state import IonicLevel, IonizationState
from plasmapy.particles.particle_class import CustomParticle, Particle, ParticleLike
from plasmapy.particles.particle_collections import ParticleList
from plasmapy.particles.symbols import particle_symbol
from plasmapy.utils.decorators import validate_quantities
def _atomic_number_and_mass_number(p: ParticleLike):
return p.atomic_number, p.mass_number if p.isotope else 0
[docs]
class IonizationStateCollection:
"""
Describe the ionization state distributions of multiple elements
or isotopes.
Parameters
----------
inputs : `list`, `tuple`, or `dict`
A `list` or `tuple` of elements or isotopes (if ``T_e`` is
provided); a `list` of `~plasmapy.particles.ionization_state.IonizationState`
instances; a `dict` with elements or isotopes as keys and
a `~numpy.ndarray` of ionic fractions as the values; or a `dict`
with elements or isotopes as keys and `~astropy.units.Quantity`
instances with units of number density.
abundances : `dict`, |keyword-only|, optional
A `dict` with `~plasmapy.particles.particle_class.ParticleLike`
objects used as the keys and the corresponding relative abundance as the
values. The values must be positive real numbers.
log_abundances : `dict`, |keyword-only|, optional
A `dict` with `~plasmapy.particles.particle_class.ParticleLike`
objects used as the keys and the corresponding base 10 logarithms of their
relative abundances as the values. The values must be real numbers.
n0 : `~astropy.units.Quantity`, |keyword-only|, optional
The number density normalization factor corresponding to the
abundances. The number density of each element is the product
of its abundance and ``n0``.
T_e : `~astropy.units.Quantity`, |keyword-only|, optional
The electron temperature in units of temperature or thermal
energy per particle.
kappa : `float`, |keyword-only|, optional
The value of kappa for a kappa distribution function.
tol : `float` or `integer`, |keyword-only|, default: ``1e-15``
The absolute tolerance used by `~numpy.isclose` when testing
normalizations and making comparisons.
Raises
------
`~plasmapy.particles.exceptions.ParticleError`
If this class cannot be instantiated.
See Also
--------
~plasmapy.particles.ionization_state.IonicLevel
~plasmapy.particles.ionization_state.IonizationState
Examples
--------
>>> import astropy.units as u
>>> from plasmapy.particles import IonizationStateCollection
>>> states = IonizationStateCollection(
... {"H": [0.5, 0.5], "He": [0.95, 0.05, 0]},
... T_e=1.2e4 * u.K,
... n0=1e15 * u.m**-3,
... abundances={"H": 1, "He": 0.08},
... )
>>> states.ionic_fractions
{'H': array([0.5, 0.5]), 'He': array([0.95, 0.05, 0. ])}
The number densities are given by the ionic fractions multiplied by
the abundance and the number density scaling factor ``n0``.
>>> states.number_densities["H"]
<Quantity [5.e+14, 5.e+14] 1 / m3>
>>> states["He"] = [0.4, 0.59, 0.01]
To change the ionic fractions for a single element, use item
assignment.
>>> states = IonizationStateCollection(["H", "He"])
>>> states["H"] = [0.1, 0.9]
Item assignment will also work if you supply number densities.
>>> states["He"] = [0.4, 0.6, 0.0] * u.m**-3
>>> states.ionic_fractions["He"]
array([0.4, 0.6, 0. ])
>>> states.number_densities["He"]
<Quantity [0.4, 0.6, 0. ] 1 / m3>
Notes
-----
No more than one of ``abundances`` and ``log_abundances`` may be
specified.
If the value provided during item assignment is a
`~astropy.units.Quantity` with units of number density that retains
the total element density, then the ionic fractions will be set
proportionately.
When making comparisons between
`~plasmapy.particles.ionization_state_collection.IonizationStateCollection`
instances, `~numpy.nan` values are treated as equal. Equality tests
are performed to within a tolerance of ``tol``.
"""
# TODO: Improve explanation of dunder methods in docstring
# TODO: Add functionality to equilibrate initial ionization states
@validate_quantities(T_e={"equivalencies": u.temperature_energy()})
def __init__(
self,
inputs: dict[str, np.ndarray] | list | tuple,
*,
T_e: u.Quantity[u.K] = np.nan * u.K,
abundances: dict[str, float] | None = None,
log_abundances: dict[str, float] | None = None,
n0: u.Quantity[u.m**-3] = np.nan * u.m**-3,
tol: float = 1e-15,
kappa: float = np.inf,
) -> None:
set_abundances = True
if isinstance(inputs, dict) and np.all(
[isinstance(fracs, u.Quantity) for fracs in inputs.values()]
):
right_units = np.all(
[fracs[0].si.unit == u.m**-3 for fracs in inputs.values()]
)
if not right_units:
raise ParticleError(
"Units must be inverse volume for number densities."
)
abundances_provided = abundances is not None or log_abundances is not None
if abundances_provided:
raise ParticleError(
"Abundances cannot be provided if inputs "
"provides number density information."
)
set_abundances = False
try:
self._pars = {}
self.T_e = T_e
self.n0 = n0
self.tol = tol
self.ionic_fractions = inputs
if set_abundances:
self.abundances = abundances
self.log_abundances = log_abundances
self.kappa = kappa
except (ValueError, TypeError) as exc:
raise ParticleError(
"Unable to create IonizationStateCollection object."
) from exc
def __len__(self) -> int:
return len(self._base_particles)
def __str__(self) -> str:
return f"<IonizationStateCollection for: {', '.join(self.base_particles)}>"
def __repr__(self) -> str:
return self.__str__()
def __getitem__(self, *values) -> IonizationState | IonicLevel:
errmsg = f"Invalid indexing for IonizationStateCollection instance: {values[0]}"
one_input = not isinstance(values[0], tuple)
two_inputs = len(values[0]) == 2
if not one_input and not two_inputs:
raise IndexError(errmsg)
try:
arg1 = values[0] if one_input else values[0][0]
int_charge = None if one_input else values[0][1]
particle = arg1 if arg1 in self.base_particles else particle_symbol(arg1)
if int_charge is None:
return IonizationState(
particle=particle,
ionic_fractions=self.ionic_fractions[particle],
T_e=self._pars["T_e"],
n_elem=np.sum(self.number_densities[particle]),
tol=self.tol,
)
if not isinstance(int_charge, Integral):
raise TypeError(f"{int_charge} is not a valid charge for {particle}.")
elif not 0 <= int_charge <= atomic_number(particle):
raise ChargeError(f"{int_charge} is not a valid charge for {particle}.")
except (ChargeError, KeyError, TypeError) as exc:
raise IndexError(errmsg) from exc
else:
return IonicLevel(
ion=particle_symbol(particle, Z=int_charge),
ionic_fraction=self.ionic_fractions[particle][int_charge],
number_density=self.number_densities[particle][int_charge],
)
def __setitem__(self, key, value) -> None: # noqa: C901, PLR0912
errmsg = (
f"Cannot set item for this IonizationStateCollection instance for "
f"key = {key!r} and value = {value!r}"
)
try:
particle = particle_symbol(key)
self.ionic_fractions[key]
except (ParticleError, TypeError):
raise KeyError(
f"{errmsg} because {key!r} is an invalid particle."
) from None
except KeyError:
raise KeyError(
f"{errmsg} because {key!r} is not one of the base "
f"particles whose ionization state is being kept track "
f"of."
) from None
if isinstance(value, u.Quantity) and value.unit != u.dimensionless_unscaled:
try:
new_number_densities = value.to(u.m**-3)
except u.UnitConversionError:
raise ValueError(
f"{errmsg} because the units of value do not "
f"correspond to a number density."
) from None
old_n_elem = np.sum(self.number_densities[particle])
new_n_elem = np.sum(new_number_densities)
density_was_nan = np.all(np.isnan(self.number_densities[particle]))
same_density = u.quantity.allclose(old_n_elem, new_n_elem, rtol=self.tol)
if not same_density and not density_was_nan:
raise ValueError(
f"{errmsg} because the old element number density "
f"of {old_n_elem} is not approximately equal to "
f"the new element number density of {new_n_elem}."
)
value = (new_number_densities / new_n_elem).to(u.dimensionless_unscaled)
# If the abundance of this particle has not been defined,
# then set the abundance if there is enough (but not too
# much) information to do so.
abundance_is_undefined = np.isnan(self.abundances[particle])
isnan_of_abundance_values = np.isnan(list(self.abundances.values()))
all_abundances_are_nan = np.all(isnan_of_abundance_values)
n_is_defined = not np.isnan(self.n0)
if abundance_is_undefined:
if n_is_defined:
self._pars["abundances"][particle] = new_n_elem / self.n0
elif all_abundances_are_nan:
self.n0 = new_n_elem
self._pars["abundances"][particle] = 1
else:
raise ParticleError(
f"Cannot set number density of {particle} to "
f"{value * new_n_elem} when the number density "
f"scaling factor is undefined, the abundance "
f"of {particle} is undefined, and some of the "
f"abundances of other elements/isotopes is "
f"defined."
)
try:
new_fractions = np.array(value, dtype=float)
except TypeError as exc:
raise TypeError(
f"{errmsg} because value cannot be converted into an "
f"array that represents ionic fractions."
) from exc
# TODO: Create a separate function that makes sure ionic
# TODO: fractions are valid to reduce code repetition. This
# TODO: would probably best go as a private function in
# TODO: ionization_state.py.
required_nstates = atomic_number(particle) + 1
new_nstates = len(new_fractions)
if new_nstates != required_nstates:
raise ValueError(
f"{errmsg} because value must have {required_nstates} "
f"ionization levels but instead corresponds to "
f"{new_nstates} levels."
)
all_nans = np.all(np.isnan(new_fractions))
if not all_nans and (new_fractions.min() < 0 or new_fractions.max() > 1):
raise ValueError(
f"{errmsg} because the new ionic fractions are not "
f"all between 0 and 1."
)
normalized = np.isclose(np.sum(new_fractions), 1, rtol=self.tol)
if not normalized and not all_nans:
raise ValueError(
f"{errmsg} because the ionic fractions are not normalized to one."
)
self._ionic_fractions[particle][:] = new_fractions.copy()
def __iter__(self) -> Iterator:
yield from [self[key] for key in self.ionic_fractions]
def __eq__(self, other) -> bool:
if not isinstance(other, IonizationStateCollection):
return False
if self.base_particles != other.base_particles:
return False
min_tol = np.min([self.tol, other.tol])
# Check any of a whole bunch of equality measures, recalling
# that np.nan == np.nan is False.
for attribute in ("T_e", "n_e", "kappa"):
this = getattr(self, attribute)
that = getattr(other, attribute)
this_equals_that = np.any(
[
this == that,
this is that,
np.isnan(this) and np.isnan(that),
np.isinf(this) and np.isinf(that),
u.quantity.allclose(this, that, rtol=min_tol),
]
)
if not this_equals_that:
return False
for attribute in ("ionic_fractions", "number_densities"):
this_dict = getattr(self, attribute)
that_dict = getattr(other, attribute)
for particle in self.base_particles:
this = this_dict[particle]
that = that_dict[particle]
this_equals_that = np.any(
[
this is that,
np.all(np.isnan(this)) and np.all(np.isnan(that)),
u.quantity.allclose(this, that, rtol=min_tol),
]
)
if not this_equals_that:
return False
return True
@property
def ionic_fractions(self) -> dict[str, np.array]:
"""
A `dict` containing the ionic fractions for each element and
isotope.
The keys of this `dict` are the symbols for each element or
isotope. The values will be `~numpy.ndarray` objects containing
the ionic fractions for each ionization level corresponding to
each element or isotope.
"""
return self._ionic_fractions
@ionic_fractions.setter
def ionic_fractions( # noqa: C901, PLR0912, PLR0915
self,
inputs: dict | list | tuple,
):
"""
Set the ionic fractions.
Notes
-----
The ionic fractions are initialized during instantiation of
`~plasmapy.particles.ionization_state_collection.IonizationStateCollection`.
After this, the only way to reset the ionic fractions via the
``ionic_fractions`` attribute is via a `dict` with elements or
isotopes that are a superset of the previous elements or
isotopes. However, you may use item assignment of the
`~plasmapy.particles.ionization_state.IonizationState`
instance to assign new ionic fractions one element or isotope
at a time.
Raises
------
`~plasmapy.particles.exceptions.ParticleError`
If the ionic fractions cannot be set.
"""
# A potential problem is that using item assignment on the
# ionic_fractions attribute could cause the original attributes
# to be overwritten without checks being performed. We might
# eventually want to create a new class or subclass of UserDict
# that goes through these checks. In the meantime, we should
# make it clear to users to set ionic_fractions by using item
# assignment on the IonizationStateCollection instance as a whole. An
# example of the problem is `s = IonizationStateCollection(["He"])` being
# followed by `s.ionic_fractions["He"] = 0.3`.
if hasattr(self, "_ionic_fractions"):
if not isinstance(inputs, dict):
raise TypeError(
"Can only reset ionic_fractions with a dict if "
"ionic_fractions has been set already."
)
old_particles = set(self.base_particles)
new_particles = {particle_symbol(key) for key in inputs}
missing_particles = old_particles - new_particles
if missing_particles:
raise ParticleError(
"Can only reset ionic fractions with a dict if "
"the new base particles are a superset of the "
"prior base particles. To change ionic fractions "
"for one base particle, use item assignment on the "
"IonizationStateCollection instance instead."
)
if isinstance(inputs, dict):
original_keys = inputs.keys()
ionfrac_types = {type(inputs[key]) for key in original_keys}
inputs_have_quantities = u.Quantity in ionfrac_types
if inputs_have_quantities and len(ionfrac_types) != 1:
raise TypeError(
"Ionic fraction information may only be inputted "
"as a Quantity object if all ionic fractions are "
"Quantity arrays with units of inverse volume."
)
try:
particles = {key: Particle(key) for key in original_keys}
except (InvalidParticleError, TypeError) as exc:
raise ParticleError(
"Unable to create IonizationStateCollection instance "
"because not all particles are valid."
) from exc
# The particles whose ionization states are to be recorded
# should be elements or isotopes but not ions or neutrals.
for key in particles:
is_element = particles[key].is_category("element")
has_charge_info = particles[key].is_category(
any_of=["charged", "uncharged"]
)
if not is_element or has_charge_info:
raise ParticleError(
f"{key} is not an element or isotope without "
f"charge information."
)
# We are sorting the elements/isotopes by atomic number and
# mass number since we will often want to plot and analyze
# things and this is the most sensible order.
def _sort_entries_by_atomic_and_mass_numbers(k):
return (
particles[k].atomic_number,
particles[k].mass_number if particles[k].isotope else 0,
)
sorted_keys = sorted(
original_keys, key=_sort_entries_by_atomic_and_mass_numbers
)
_elements_and_isotopes = []
_particle_instances = []
new_ionic_fractions = {}
if inputs_have_quantities:
n_elems = {}
for key in sorted_keys:
new_key = particles[key].symbol
_particle_instances.append(particles[key])
if new_key in _elements_and_isotopes:
raise ParticleError(
"Repeated particles in IonizationStateCollection."
)
nstates_input = len(inputs[key])
nstates = particles[key].atomic_number + 1
if nstates != nstates_input:
raise ParticleError(
f"The ionic fractions array for {key} must "
f"have a length of {nstates}."
)
_elements_and_isotopes.append(new_key)
if inputs_have_quantities:
try:
number_densities = inputs[key].to(u.m**-3)
n_elem = np.sum(number_densities)
new_ionic_fractions[new_key] = np.array(
number_densities / n_elem
)
n_elems[key] = n_elem
except u.UnitConversionError as exc:
raise ParticleError("Units are not inverse volume.") from exc
elif (
isinstance(inputs[key], np.ndarray)
and inputs[key].dtype.kind == "f"
):
new_ionic_fractions[particles[key].symbol] = inputs[key]
else:
try:
new_ionic_fractions[particles[key].symbol] = np.array(
inputs[key], dtype=float
)
except ValueError as exc:
raise ParticleError(
f"Inappropriate ionic fractions for {key}."
) from exc
for key in _elements_and_isotopes:
fractions = new_ionic_fractions[key]
if not np.all(np.isnan(fractions)):
if np.min(fractions) < 0 or np.max(fractions) > 1:
raise ParticleError(
f"Ionic fractions for {key} are not between 0 and 1."
)
if not np.isclose(np.sum(fractions), 1, atol=self.tol, rtol=0):
raise ParticleError(
f"Ionic fractions for {key} are not normalized to 1."
)
# When the inputs provide the densities, the abundances must
# not have been provided because that would be redundant
# or contradictory information. The number density scaling
# factor might or might not have been provided. Have the
# number density scaling factor default to the total number
# of neutrals and ions across all elements and isotopes, if
# it was not provided. Then go ahead and calculate the
# abundances based on that. However, we need to be careful
# that the abundances are not overwritten during the
# instantiation of the class.
if inputs_have_quantities:
if np.isnan(self.n0):
new_n = 0 * u.m**-3
for key in _elements_and_isotopes:
new_n += n_elems[key]
self.n0 = new_n
new_abundances = {}
for key in _elements_and_isotopes:
new_abundances[key] = float(n_elems[key] / self.n0)
self._pars["abundances"] = new_abundances
elif isinstance(inputs, list | tuple):
try:
_particle_instances = [Particle(particle) for particle in inputs]
except (InvalidParticleError, TypeError) as exc:
raise ParticleError(
"Invalid inputs to IonizationStateCollection."
) from exc
_particle_instances.sort(key=_atomic_number_and_mass_number)
_elements_and_isotopes = [
particle.symbol for particle in _particle_instances
]
new_ionic_fractions = {
particle.symbol: np.full(
particle.atomic_number + 1, fill_value=np.nan, dtype=float
)
for particle in _particle_instances
}
else:
raise TypeError("Incorrect inputs to set ionic_fractions.")
for i in range(1, len(_particle_instances)):
if (
_particle_instances[i - 1].element == _particle_instances[i].element
) and (
not _particle_instances[i - 1].isotope
and _particle_instances[i].isotope
):
raise ParticleError(
"Cannot have an element and isotopes of that element."
)
self._particle_instances = _particle_instances
self._base_particles = _elements_and_isotopes
self._ionic_fractions = new_ionic_fractions
[docs]
def normalize(self) -> None:
"""
Normalize the ionic fractions so that the sum for each element
equals one.
"""
for particle in self.base_particles:
tot = np.sum(self.ionic_fractions[particle])
self.ionic_fractions[particle] = self.ionic_fractions[particle] / tot
@property
@validate_quantities
def n_e(self) -> u.Quantity[u.m**-3]:
"""The electron number density under the assumption of quasineutrality."""
number_densities = self.number_densities
n_e = 0.0 * u.m**-3
for elem in self.base_particles:
atomic_numb = atomic_number(elem)
number_of_ionization_states = atomic_numb + 1
charge_numbers = np.linspace(0, atomic_numb, number_of_ionization_states)
n_e += np.sum(number_densities[elem] * charge_numbers)
return n_e
@property
@validate_quantities
def n0(self) -> u.Quantity[u.m**-3]:
"""The number density scaling factor."""
return self._pars["n"]
@n0.setter
@validate_quantities
def n0(self, n: u.Quantity[u.m**-3]):
"""Set the number density scaling factor."""
try:
n = n.to(u.m**-3)
except u.UnitConversionError as exc:
raise ParticleError("Units cannot be converted to u.m ** -3.") from exc
except ParticleError as exc:
raise ParticleError(f"{n} is not a valid number density.") from exc
if n < 0 * u.m**-3:
raise ParticleError("Number density cannot be negative.")
self._pars["n"] = n.to(u.m**-3)
@property
def number_densities(self) -> dict[str, u.Quantity]:
"""
A `dict` containing the number densities for the elements and/or
isotopes composing the collection.
"""
return {
elem: self.n0 * self.abundances[elem] * self.ionic_fractions[elem]
for elem in self.base_particles
}
@property
def abundances(self) -> dict[ParticleLike, float] | None:
"""The elemental abundances."""
return self._pars["abundances"]
@abundances.setter
def abundances(self, abundances_dict: dict[ParticleLike, float] | None):
"""
Set the elemental (or isotopic) abundances. The elements and
isotopes must be the same as or a superset of the elements whose
ionization states are being tracked.
"""
if abundances_dict is None:
self._pars["abundances"] = {elem: np.nan for elem in self.base_particles}
elif not isinstance(abundances_dict, dict):
raise TypeError(
"The abundances attribute must be a dict with "
"elements or isotopes as keys and real numbers "
"representing relative abundances as values."
)
else:
old_keys = abundances_dict.keys()
try:
new_keys_dict = {}
for old_key in old_keys:
new_keys_dict[particle_symbol(old_key)] = old_key
except ParticleError as ex:
raise ParticleError(
f"The key {old_key!r} in the abundances "
f"dictionary is not a valid element or isotope."
) from ex
new_elements = new_keys_dict.keys()
old_elements_set = set(self.base_particles)
new_elements_set = set(new_elements)
if old_elements_set - new_elements_set:
raise ParticleError(
f"The abundances of the following particles are "
f"missing: {old_elements_set - new_elements_set}"
)
new_abundances_dict = {}
for element in new_elements:
inputted_abundance = abundances_dict[new_keys_dict[element]]
try:
inputted_abundance = float(inputted_abundance)
except TypeError:
raise TypeError(
f"The abundance for {element} was provided as"
f"{inputted_abundance}, which cannot be "
f"converted to a real number."
) from None
if inputted_abundance < 0:
raise ParticleError(f"The abundance of {element} is negative.")
new_abundances_dict[element] = inputted_abundance
self._pars["abundances"] = new_abundances_dict
@property
def log_abundances(self) -> dict[str, float]:
"""
A `dict` with atomic or isotope symbols as keys and the base 10
logarithms of the relative abundances as the corresponding values.
"""
return {
atom: np.log10(abundance) for atom, abundance in self.abundances.items()
}
@log_abundances.setter
def log_abundances(self, value: dict[str, float] | None):
"""Set the base 10 logarithm of the relative abundances."""
if value is not None:
try:
new_abundances_input = {
atom: 10**log_abundance for atom, log_abundance in value.items()
}
self.abundances = new_abundances_input
except ParticleError:
raise ParticleError("Invalid log_abundances.") from None
@property
def T_e(self) -> u.Quantity[u.K]:
"""The electron temperature."""
return self._pars["T_e"]
@T_e.setter
@validate_quantities(electron_temperature={"equivalencies": u.temperature_energy()})
def T_e(self, electron_temperature: u.Quantity[u.K]):
"""Set the electron temperature."""
try:
temperature = electron_temperature.to(
u.K, equivalencies=u.temperature_energy()
)
except (AttributeError, u.UnitsError):
raise ParticleError(
f"{electron_temperature} is not a valid temperature."
) from None
if temperature < 0 * u.K:
raise ParticleError("The electron temperature cannot be negative.")
self._pars["T_e"] = temperature
@property
def kappa(self) -> np.real:
"""
The κ parameter for a kappa distribution function for electrons.
The value of ``kappa`` must be greater than ``1.5`` in order to
have a valid distribution function. If ``kappa`` equals
`~numpy.inf`, then the distribution function reduces to a
Maxwellian.
"""
return self._pars["kappa"]
@kappa.setter
def kappa(self, value: float):
"""
Set the kappa parameter for a kappa distribution function for
electrons. The value must be between ``1.5`` and `~numpy.inf`.
"""
kappa_errmsg = "kappa must be a real number greater than 1.5"
if not isinstance(value, Real):
raise TypeError(kappa_errmsg)
if value <= 1.5:
raise ValueError(kappa_errmsg)
self._pars["kappa"] = np.real(value)
@property
def base_particles(self) -> list[str]:
"""
A `list` of the elements and isotopes whose ionization states
are being kept track of.
"""
return self._base_particles
@property
def tol(self) -> np.real:
"""The absolute tolerance for comparisons."""
return self._tol
@tol.setter
def tol(self, atol: float):
"""Set the absolute tolerance for comparisons."""
if not isinstance(atol, Real):
raise TypeError("The attribute tol must be a real number.")
if 0 <= atol <= 1.0:
self._tol = np.real(atol)
else:
raise ValueError("Need 0 <= tol <= 1.")
[docs]
def average_ion(
self,
*,
include_neutrals: bool = True,
use_rms_charge: bool = False,
use_rms_mass: bool = False,
) -> CustomParticle:
"""
Return a |CustomParticle| representing the mean particle
included across all ionization states.
By default, this method will use the weighted mean to calculate
the properties of the |CustomParticle|, where the weights for
each ionic level is given by its ionic fraction multiplied by
the abundance of the base element or isotope. If
``use_rms_charge`` or ``use_rms_mass`` is `True`, then this
method will return the root mean square of the charge or mass,
respectively.
Parameters
----------
include_neutrals : `bool`, |keyword-only|, default: `True`
If `True`, include neutrals when calculating the mean values
of the different particles. If `False`, exclude neutrals.
use_rms_charge : `bool`, |keyword-only|, default: `False`
If `True`, use the root-mean-square charge instead of the
mean charge.
use_rms_mass : `bool`, |keyword-only|, default: `False`
If `True`, use the root-mean-square mass instead of the mean
mass.
Raises
------
`~plasmapy.particles.exceptions.ParticleError`
If the abundance of any of the elements or isotopes is not
defined and the |IonizationStateCollection| instance includes
more than one element or isotope.
Returns
-------
~plasmapy.particles.particle_class.CustomParticle
Examples
--------
>>> states = IonizationStateCollection(
... {"H": [0.1, 0.9], "He": [0, 0.1, 0.9]}, abundances={"H": 1, "He": 0.1}
... )
>>> states.average_ion()
CustomParticle(mass=2.12498...e-27 kg, charge=1.5876...e-19 C)
>>> states.average_ion(
... include_neutrals=False,
... use_rms_charge=True,
... use_rms_mass=True,
... )
CustomParticle(mass=2.633...e-27 kg, charge=1.805...e-19 C)
"""
min_charge = 0 if include_neutrals else 1
all_particles = ParticleList()
all_abundances = []
for base_particle in self.base_particles:
ionization_state = self[base_particle]
ionic_levels = ionization_state.to_list()[min_charge:]
all_particles.extend(ionic_levels)
base_particle_abundance = self.abundances[base_particle]
if np.isnan(base_particle_abundance):
if len(self) == 1:
base_particle_abundance = 1
else:
raise ParticleError(
"Unable to provide an average particle without abundances."
)
ionic_fractions = ionization_state.ionic_fractions[min_charge:]
ionic_abundances = base_particle_abundance * ionic_fractions
all_abundances.extend(ionic_abundances)
return all_particles.average_particle(
use_rms_charge=use_rms_charge,
use_rms_mass=use_rms_mass,
abundances=all_abundances,
)
[docs]
def summarize(
self, minimum_ionic_fraction: float = 0.01
) -> None: # coverage: ignore
"""
Print quicklook information.
Parameters
----------
minimum_ionic_fraction : `float`, default: ``0.01``
If the ionic fraction for a particular ionization state is
below this level, then information for it will not be
printed.
Examples
--------
>>> import astropy.units as u
>>> states = IonizationStateCollection(
... {"H": [0.1, 0.9], "He": [0.95, 0.05, 0.0]},
... T_e=12000 * u.K,
... n0=3e9 * u.cm**-3,
... abundances={"H": 1.0, "He": 0.1},
... kappa=3.4,
... )
>>> states.summarize()
IonizationStateCollection instance for: H, He
----------------------------------------------------------------
H 0+: 0.100 n_i = 3.00e+14 m**-3 T_i = 1.20e+04 K
H 1+: 0.900 n_i = 2.70e+15 m**-3 T_i = 1.20e+04 K
----------------------------------------------------------------
He 0+: 0.950 n_i = 2.85e+14 m**-3 T_i = 1.20e+04 K
He 1+: 0.050 n_i = 1.50e+13 m**-3 T_i = 1.20e+04 K
----------------------------------------------------------------
n_e = 2.71e+15 m**-3
T_e = 1.20e+04 K
kappa = 3.40
----------------------------------------------------------------
"""
separator_line = 64 * "-"
output = [
f"IonizationStateCollection instance for: {', '.join(self.base_particles)}"
]
# Get the ionic symbol with the corresponding ionic fraction and
# number density (if available), but only for the most abundant
# ionization levels for each element.
for ionization_state in self:
states_info = ionization_state._get_states_info( # noqa: SLF001
minimum_ionic_fraction,
)
if len(states_info) > 0:
output += states_info
output[-1] += "\n" + separator_line
attributes = []
if np.isfinite(self.n_e):
attributes.append(f"n_e = {self.n_e.value:.2e} m**-3")
if np.isfinite(self.T_e):
attributes.append(f"T_e = {self.T_e.value:.2e} K")
if np.isfinite(self.kappa):
attributes.append(f"kappa = {self.kappa:.2f}")
if attributes:
attributes.append(separator_line)
output.append("\n".join(attributes))
if len(output) > 1:
output[0] += "\n" + separator_line
output_string = "\n".join(output)
else:
output_string = output[0]
print(output_string.strip("\n")) # noqa: T201