Ejemplo n.º 1
0
 def __init__(self,
              ion: Particle,
              ionic_fraction=None,
              number_density=None):
     try:
         self._particle = ion
         self.ionic_fraction = ionic_fraction
         self.number_density = number_density
     except Exception as exc:
         raise ParticleError(
             "Unable to create IonicFraction object") from exc
    def test_that_ionic_fractions_are_set_correctly(self, test_name):

        errmsg = ""

        elements_actual = self.instances[test_name].base_particles
        inputs = tests[test_name]["inputs"]

        if isinstance(inputs, dict):
            input_keys = list(tests[test_name]["inputs"].keys())

            input_keys = sorted(
                input_keys,
                key=lambda k: (
                    atomic_number(k),
                    mass_number(k) if Particle(k).isotope else 0,
                ),
            )

            for element, input_key in zip(elements_actual, input_keys):
                expected = tests[test_name]["inputs"][input_key]

                if isinstance(expected, u.Quantity):
                    expected = np.array(expected.value /
                                        np.sum(expected.value))

                actual = self.instances[test_name].ionic_fractions[element]

                if not np.allclose(actual, expected):
                    errmsg += (
                        f"\n\nThere is a discrepancy in ionic fractions for "
                        f"({test_name}, {element}, {input_key})\n"
                        f"  expected = {expected}\n"
                        f"    actual = {actual}")

                if not isinstance(actual, np.ndarray) or isinstance(
                        actual, u.Quantity):
                    raise ParticleError(
                        f"\n\nNot a numpy.ndarray: ({test_name}, {element})")
        else:
            elements_expected = {
                particle_symbol(element)
                for element in inputs
            }

            assert set(
                self.instances[test_name].base_particles) == elements_expected

            for element in elements_expected:
                assert all(
                    np.isnan(
                        self.instances[test_name].ionic_fractions[element]))
        if errmsg:
            pytest.fail(errmsg)
Ejemplo n.º 3
0
 def log_abundances(self, value: Optional[Dict[str, Real]]):
     """
     Set the base 10 logarithm of the relative abundances.
     """
     if value is not None:
         try:
             new_abundances_input = {}
             for key in value.keys():
                 new_abundances_input[key] = 10 ** value[key]
             self.abundances = new_abundances_input
         except Exception:
             raise ParticleError("Invalid log_abundances.") from None
Ejemplo n.º 4
0
 def __init__(self,
              ion: Particle,
              ionic_fraction=None,
              number_density=None,
              T_i=None):
     try:
         self.ion = ion
         self.ionic_fraction = ionic_fraction
         self.number_density = number_density
         self.T_i = T_i
     except (ValueError, TypeError) as exc:
         raise ParticleError("Unable to create IonicLevel object") from exc
Ejemplo n.º 5
0
def test_list_annotation(particles: Union[Tuple, List]):
    try:
        resulting_particles = function_with_list_annotation(particles,
                                                            "ignore",
                                                            x="ignore")
    except Exception as exc2:
        raise ParticleError(
            f"Unable to evaluate a function decorated by particle_input"
            f" with an annotation of [Particle] for inputs of"
            f" {repr(particles)}.") from exc2

    function_to_test_annotations(particles, resulting_particles)
Ejemplo n.º 6
0
def test_Particle_class(arg, kwargs, expected_dict):
    """
    Test that `~plasmapy.particles.Particle` objects for different
    subatomic particles, elements, isotopes, and ions return the
    expected properties.  Provide a detailed error message that lists
    all of the inconsistencies with the expected results.
    """

    call = call_string(Particle, arg, kwargs)
    errmsg = ""

    try:
        particle = Particle(arg, **kwargs)
    except Exception as exc:
        raise ParticleError(f"Problem creating {call}") from exc

    for key in expected_dict.keys():
        expected = expected_dict[key]

        if inspect.isclass(expected) and issubclass(expected, Exception):

            # Exceptions are expected to be raised when accessing certain
            # attributes for some particles.  For example, accessing a
            # neutrino's mass should raise a MissingParticleDataError since
            # only upper limits of neutrino masses are presently available.
            # If expected_dict[key] is an exception, then check to make
            # sure that this exception is raised.

            try:
                with pytest.raises(expected):
                    exec(f"particle.{key}")
            except pytest.fail.Exception:
                errmsg += f"\n{call}[{key}] does not raise {expected}."
            except Exception:
                errmsg += (f"\n{call}[{key}] does not raise {expected} but "
                           f"raises a different exception.")

        else:

            try:
                result = eval(f"particle.{key}")
                assert result == expected or u.isclose(result, expected)
            except AssertionError:
                errmsg += (f"\n{call}.{key} returns {result} instead "
                           f"of the expected value of {expected}.")
            except Exception:
                errmsg += f"\n{call}.{key} raises an unexpected exception."

    if len(errmsg) > 0:
        raise Exception(f"Problems with {call}:{errmsg}")
Ejemplo n.º 7
0
    def __getitem__(self, value) -> IonicLevel:
        """Return information for a single ionization level."""
        if isinstance(value, slice):
            return [
                IonicLevel(
                    ion=Particle(self.base_particle, Z=val),
                    ionic_fraction=self.ionic_fractions[val],
                    number_density=self.number_densities[val],
                    T_i=self.T_i[val],
                ) for val in range(0, self._number_of_particles)[value]
            ]

        if isinstance(value, Integral) and 0 <= value <= self.atomic_number:
            result = IonicLevel(
                ion=Particle(self.base_particle, Z=value),
                ionic_fraction=self.ionic_fractions[value],
                number_density=self.number_densities[value],
                T_i=self.T_i[value],
            )
        else:
            if not isinstance(value, Particle):
                try:
                    value = Particle(value)
                except InvalidParticleError as exc:
                    raise InvalidParticleError(
                        f"{value} is not a valid charge number or particle."
                    ) from exc

            same_element = value.element == self.element
            same_isotope = value.isotope == self.isotope
            has_charge_info = value.is_category(
                any_of=["charged", "uncharged"])

            if same_element and same_isotope and has_charge_info:
                Z = value.charge_number
                result = IonicLevel(
                    ion=Particle(self.base_particle, Z=Z),
                    ionic_fraction=self.ionic_fractions[Z],
                    number_density=self.number_densities[Z],
                    T_i=self.T_i[Z],
                )
            else:
                if not same_element or not same_isotope:
                    raise ParticleError("Inconsistent element or isotope.")
                elif not has_charge_info:
                    raise ChargeError("No charge number provided.")
        return result
Ejemplo n.º 8
0
    def T_i(self, value: u.K):
        """Set the ion temperature."""
        if value is None:
            self._T_i = np.repeat(self._T_e, self._number_of_particles)
            return

        if value.size == 1:
            self._T_i = np.repeat(value, self._number_of_particles)
        elif value.size == self._number_of_particles:
            self._T_i = value
        else:
            error_str = (
                "T_i must be set with either one common temperature"
                f" for all ions, or a set of {self._number_of_particles} of them. "
            )

            if value.size == 5 and self._number_of_particles != 5:
                error_str += " For {self.base_particle}, five is right out."
            raise ParticleError(error_str)
Ejemplo n.º 9
0
    def Z_most_abundant(self) -> List[Integral]:
        """
        A `list` of the charge numbers with the highest ionic fractions.

        Examples
        --------
        >>> He = IonizationState('He', [0.2, 0.5, 0.3])
        >>> He.Z_most_abundant
        [1]
        >>> Li = IonizationState('Li', [0.4, 0.4, 0.2, 0.0])
        >>> Li.Z_most_abundant
        [0, 1]
        """
        if np.any(np.isnan(self.ionic_fractions)):
            raise ParticleError(
                f"Cannot find most abundant ion of {self.base_particle} "
                f"because the ionic fractions have not been defined.")

        return np.flatnonzero(
            self.ionic_fractions == self.ionic_fractions.max()).tolist()
Ejemplo n.º 10
0
    def __getitem__(self, value) -> IonicFraction:
        """Return information for a single ionization level."""
        if isinstance(value, slice):
            raise TypeError("IonizationState instances cannot be sliced.")

        if isinstance(value, Integral) and 0 <= value <= self.atomic_number:
            result = IonicFraction(
                ion=Particle(self.base_particle, Z=value),
                ionic_fraction=self.ionic_fractions[value],
                number_density=self.number_densities[value],
            )
        else:
            if not isinstance(value, Particle):
                try:
                    value = Particle(value)
                except InvalidParticleError as exc:
                    raise InvalidParticleError(
                        f"{value} is not a valid integer charge or particle."
                    ) from exc

            same_element = value.element == self.element
            same_isotope = value.isotope == self.isotope
            has_charge_info = value.is_category(
                any_of=["charged", "uncharged"])

            if same_element and same_isotope and has_charge_info:
                Z = value.integer_charge
                result = IonicFraction(
                    ion=Particle(self.base_particle, Z=Z),
                    ionic_fraction=self.ionic_fractions[Z],
                    number_density=self.number_densities[Z],
                )
            else:
                if not same_element or not same_isotope:
                    raise ParticleError("Inconsistent element or isotope.")
                elif not has_charge_info:
                    raise ChargeError("No integer charge provided.")
        return result
Ejemplo n.º 11
0
    def ionic_fractions(self, inputs: Union[Dict, List, Tuple]):
        """
        Set the ionic fractions.

        Notes
        -----
        The ionic fractions are initialized during instantiation of
        `~plasmapy.particles.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.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.

        `TypeError`
            If ``inputs`` is not a `list`, `tuple`, or `dict` during
            instantiation, or if ``inputs`` is not a `dict` when it is
            being 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.keys()}
            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.keys():
                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=np.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] = np.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=np.float64
                )
                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:
                if (
                    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
Ejemplo n.º 12
0
    def __eq__(self, other):

        if not isinstance(other, IonizationStateCollection):
            raise TypeError(
                "IonizationStateCollection instance can only be compared with "
                "other IonizationStateCollection instances."
            )

        if self.base_particles != other.base_particles:
            raise ParticleError(
                "Two IonizationStateCollection instances can be compared only "
                "if the base particles are the same."
            )

        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 = eval(f"self.{attribute}")
            that = eval(f"other.{attribute}")

            # TODO: Maybe create a function in utils called same_enough
            # TODO: that would take care of all of these disparate
            # TODO: equality measures.

            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 = eval(f"self.{attribute}")
            that_dict = eval(f"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
Ejemplo n.º 13
0
    def __setitem__(self, key, value):

        errmsg = (
            f"Cannot set item for this IonizationStateCollection instance for "
            f"key = {repr(key)} and value = {repr(value)}"
        )

        try:
            particle = particle_symbol(key)
            self.ionic_fractions[key]
        except (ParticleError, TypeError):
            raise KeyError(
                f"{errmsg} because {repr(key)} is an invalid particle."
            ) from None
        except KeyError:
            raise KeyError(
                f"{errmsg} because {repr(key)} 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=np.float64)
        except Exception 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[:]
Ejemplo n.º 14
0
    def get_particle(argname, params, already_particle, funcname):
        argval, Z, mass_numb = params
        """
        Convert the argument to a
        `~plasmapy.particles.particle_class.Particle` object if it is
        not already one.
        """

        if not already_particle:

            if not isinstance(argval, (numbers.Integral, str, tuple, list)):
                raise TypeError(
                    f"The argument {argname} to {funcname} must be "
                    f"a string, an integer or a tuple or list of them "
                    f"corresponding to an atomic number, or a "
                    f"Particle object.")

            try:
                particle = Particle(argval, Z=Z, mass_numb=mass_numb)
            except InvalidParticleError as e:
                raise InvalidParticleError(
                    _particle_errmsg(argname, argval, Z, mass_numb,
                                     funcname)) from e

        # We will need to do the same error checks whether or not the
        # argument is already an instance of the Particle class.

        if already_particle:
            particle = argval

        # If the name of the argument annotated with Particle in the
        # decorated function is element, isotope, or ion; then this
        # decorator should raise the appropriate exception when the
        # particle ends up not being an element, isotope, or ion.

        cat_table = [
            ("element", particle.element, InvalidElementError),
            ("isotope", particle.isotope, InvalidIsotopeError),
            ("ion", particle.ionic_symbol, InvalidIonError),
        ]

        for category_name, category_symbol, CategoryError in cat_table:
            if argname == category_name and not category_symbol:
                raise CategoryError(
                    f"The argument {argname} = {repr(argval)} to "
                    f"{funcname} does not correspond to a valid "
                    f"{argname}.")

        # Some functions require that particles be charged, or
        # at least that particles have charge information.

        _charge_number = particle._attributes["charge number"]

        must_be_charged = "charged" in require
        must_have_charge_info = set(any_of) == {"charged", "uncharged"}

        uncharged = _charge_number == 0
        lacks_charge_info = _charge_number is None

        if must_be_charged and (uncharged or must_have_charge_info):
            raise ChargeError(
                f"A charged particle is required for {funcname}.")

        if must_have_charge_info and lacks_charge_info:
            raise ChargeError(
                f"Charge information is required for {funcname}.")

        # Some functions require particles that belong to more complex
        # classification schemes.  Again, be sure to provide a
        # maximally useful error message.

        if not particle.is_category(
                require=require, exclude=exclude, any_of=any_of):
            raise ParticleError(
                _category_errmsg(particle, require, exclude, any_of, funcname))

        return particle
Ejemplo n.º 15
0
        def wrapper(*args, **kwargs):
            annotations = wrapped_function.__annotations__
            bound_args = wrapped_signature.bind(*args, **kwargs)

            default_arguments = bound_args.signature.parameters
            arguments = bound_args.arguments
            argnames = bound_args.signature.parameters.keys()

            # Handle optional-only arguments in function declaration
            for default_arg in default_arguments:
                # The argument is not contained in `arguments` if the
                # user does not explicitly pass an optional argument.
                # In such cases, manually add it to `arguments` with
                # the default value of parameter.
                if default_arg not in arguments:
                    arguments[default_arg] = default_arguments[
                        default_arg].default

            funcname = wrapped_function.__name__

            args_to_become_particles = []
            for argname in annotations.keys():
                if isinstance(annotations[argname], tuple):
                    if argname == "return":
                        continue
                    annotated_argnames = annotations[argname]
                    expected_params = len(annotated_argnames)
                    received_params = len(arguments[argname])
                    if expected_params != received_params:
                        raise ValueError(
                            f"Number of parameters allowed in the tuple "
                            f"({expected_params} parameters) are "
                            f"not equal to number of parameters passed in "
                            f"the tuple ({received_params} parameters).")
                elif isinstance(annotations[argname], list):
                    annotated_argnames = annotations[argname]
                    expected_params = len(annotated_argnames)
                    if expected_params > 1:
                        raise TypeError(
                            "Put in [Particle] as the annotation to "
                            "accept arbitrary number of Particle arguments.")
                else:
                    annotated_argnames = (annotations[argname], )

                for annotated_argname in annotated_argnames:
                    is_particle = (annotated_argname is Particle
                                   or annotated_argname is Optional[Particle])
                    if is_particle and argname != "return":
                        args_to_become_particles.append(argname)

            if not args_to_become_particles:
                raise ParticleError(
                    f"None of the arguments or keywords to {funcname} "
                    f"have been annotated with Particle, as required "
                    f"by the @particle_input decorator.")
            elif len(args_to_become_particles) > 1:
                if "Z" in argnames or "mass_numb" in argnames:
                    raise ParticleError(
                        f"The arguments Z and mass_numb in {funcname} are not "
                        f"allowed when more than one argument or keyword is "
                        f"annotated with Particle in functions decorated "
                        f"with @particle_input.")

            for x in args_to_become_particles:
                if (annotations[x] is Particle
                        and isinstance(arguments[x], (tuple, list))
                        and len(arguments[x]) > 1):
                    raise TypeError(
                        f"You cannot pass a tuple or list containing "
                        f"Particles when only single Particle was "
                        f"expected, instead found {arguments[x]}. If you "
                        f"intend to pass more than 1 Particle instance, "
                        f"use a tuple or a list type. "
                        f"That is use (Particle, Particle, ...) or "
                        f"[Particle] in function declaration.")

            # If the number of arguments and keywords annotated with
            # Particle is exactly one, then the Z and mass_numb keywords
            # can be used without potential for ambiguity.

            Z = arguments.get("Z", None)
            mass_numb = arguments.get("mass_numb", None)

            # Go through the argument names and check whether or not they are
            # annotated with Particle.  If they aren't, include the name and
            # value of the argument as an item in the new keyword arguments
            # dictionary unchanged.  If they are annotated with Particle, then
            # either convert the representation of a Particle to a Particle if
            # it is not already a Particle and then do error checks.

            new_kwargs = {}

            for argname in argnames:
                raw_argval = arguments[argname]
                if isinstance(raw_argval, (tuple, list)):
                    # Input argument value is a tuple or list
                    # of corresponding particles or atomic values.
                    argval_tuple = raw_argval
                    particles = []
                else:
                    # Otherwise convert it to tuple anyway so it can work
                    # with loops too.
                    argval_tuple = (raw_argval, )

                for pos, argval in enumerate(argval_tuple):
                    should_be_particle = argname in args_to_become_particles
                    # If the argument is not annotated with Particle, then we just
                    # pass it through to the new keywords without doing anything.

                    if not should_be_particle:
                        new_kwargs[argname] = raw_argval
                        continue

                    # Occasionally there will be functions where it will be
                    # useful to allow None as an argument.

                    # In case annotations[argname] is a collection (which looks
                    # like (Particle, Optional[Particle], ...) or [Particle])
                    if isinstance(annotations[argname], tuple):
                        optional_particle = (annotations[argname][pos] is
                                             Optional[Particle])
                    elif isinstance(annotations[argname], list):
                        optional_particle = annotations[argname] == [
                            Optional[Particle]
                        ]
                    else:
                        # Otherwise annotations[argname] must be a Particle itself
                        optional_particle = annotations[argname] is Optional[
                            Particle]

                    if (optional_particle
                            or none_shall_pass) and argval is None:
                        particle = None
                    else:
                        params = (argval, Z, mass_numb)
                        already_particle = isinstance(argval, Particle)

                        particle = get_particle(argname, params,
                                                already_particle, funcname)

                    if isinstance(raw_argval, (tuple, list)):
                        # If passed argument is a tuple or list, keep
                        # appending them.
                        particles.append(particle)
                        # Set appended values if current iteration is the
                        # last iteration.
                        if (pos + 1) == len(argval_tuple):
                            new_kwargs[argname] = tuple(particles)
                            del particles
                    else:
                        # Otherwise directly set values
                        new_kwargs[argname] = particle

            return wrapped_function(**new_kwargs)
Ejemplo n.º 16
0
    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`, optional, keyword-only
            If `True`, include neutrals when calculating the mean values
            of the different particles.  If `False`, exclude neutrals.
            Defaults to `True`.

        use_rms_charge : `bool`, optional, keyword-only
            If `True`, use the root mean square charge instead of the
            mean charge. Defaults to `False`.

        use_rms_mass : `bool`, optional, keyword-only
            If `True`, use the root mean square mass instead of the mean
            mass. Defaults to `False`.

        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,
        )
Ejemplo n.º 17
0
 def T_e(self) -> u.K:
     """Return the electron temperature."""
     if self._T_e is None:
         raise ParticleError("No electron temperature has been specified.")
     return self._T_e.to(u.K, equivalencies=u.temperature_energy())
Ejemplo n.º 18
0
    def __eq__(self, other):
        """
        Return `True` if the ionic fractions, number density scaling
        factor (if set), and electron temperature (if set) are all
        equal, and `False` otherwise.

        Raises
        ------
        `TypeError`
            If ``other`` is not an `~plasmapy.particles.IonizationState`
            instance.

        `ParticleError`
            If ``other`` corresponds to a different element or isotope.

        Examples
        --------
        >>> IonizationState('H', [1, 0], tol=1e-6) == IonizationState('H', [1, 1e-6], tol=1e-6)
        True
        >>> IonizationState('H', [1, 0], tol=1e-8) == IonizationState('H', [1, 1e-6], tol=1e-5)
        False

        """
        if not isinstance(other, IonizationState):
            raise TypeError(
                "An instance of the IonizationState class may only be "
                "compared with another IonizationState instance.")

        same_element = self.element == other.element
        same_isotope = self.isotope == other.isotope

        if not same_element or not same_isotope:
            raise ParticleError(
                "An instance of the IonizationState class may only be "
                "compared with another IonizationState instance if "
                "both correspond to the same element and/or isotope.")

        # Use the tighter of the two tolerances. For thermodynamic
        # quantities, use it as a relative tolerance because the values
        # may substantially depart from order unity.

        min_tol = np.min([self.tol, other.tol])

        same_T_e = (np.isnan(self.T_e) and np.isnan(other.T_e) or u.allclose(
            self.T_e, other.T_e, rtol=min_tol * u.K, atol=0 * u.K))

        same_n_elem = (np.isnan(self.n_elem) and np.isnan(other.n_elem)
                       or u.allclose(self.n_elem,
                                     other.n_elem,
                                     rtol=min_tol * u.m**-3,
                                     atol=0 * u.m**-3))

        # For the next line, recall that np.nan == np.nan is False

        same_fractions = np.any([
            np.allclose(self.ionic_fractions,
                        other.ionic_fractions,
                        rtol=0,
                        atol=min_tol),
            np.all(np.isnan(self.ionic_fractions))
            and np.all(np.isnan(other.ionic_fractions)),
        ])

        return np.all([
            same_element, same_isotope, same_T_e, same_n_elem, same_fractions
        ])
Ejemplo n.º 19
0
def nuclear_reaction_energy(*args, **kwargs):
    """
    Return the released energy from a nuclear reaction.

    Parameters
    ----------
    reaction: `str` (optional, positional argument only)
        A string representing the reaction, like
        ``"D + T --> alpha + n"`` or ``"Be-8 --> 2 * He-4"``.

    reactants: `list`, `tuple`, or `str`, optional, keyword-only
        A `list` or `tuple` containing the reactants of a nuclear
        reaction (e.g., ``['D', 'T']``), or a string representing the
        sole reactant.

    products: `list`, `tuple`, or `str`, optional, keyword-only
        A list or tuple containing the products of a nuclear reaction
        (e.g., ``['alpha', 'n']``), or a string representing the sole
        product.

    Returns
    -------
    energy: `~astropy.units.Quantity`
        The difference between the mass energy of the reactants and
        the mass energy of the products in a nuclear reaction.  This
        quantity will be positive if the reaction is exothermic
        (releases energy) and negative if the reaction is endothermic
        (absorbs energy).

    Raises
    ------
    `ParticleError`:
        If the reaction is not valid, there is insufficient
        information to determine an isotope, the baryon number is
        not conserved, or the charge is not conserved.

    `TypeError`:
        If the positional input for the reaction is not a string, or
        reactants and/or products is not of an appropriate type.

    See Also
    --------
    nuclear_binding_energy : finds the binding energy of an isotope

    Notes
    -----
    This function requires either a string containing the nuclear
    reaction, or reactants and products as two keyword-only lists
    containing strings representing the isotopes and other particles
    participating in the reaction.

    Examples
    --------
    >>> from astropy import units as u
    >>> nuclear_reaction_energy("D + T --> alpha + n")
    <Quantity 2.8181e-12 J>
    >>> triple_alpha1 = '2*He-4 --> Be-8'
    >>> triple_alpha2 = 'Be-8 + alpha --> carbon-12'
    >>> energy_triplealpha1 = nuclear_reaction_energy(triple_alpha1)
    >>> energy_triplealpha2 = nuclear_reaction_energy(triple_alpha2)
    >>> print(energy_triplealpha1, energy_triplealpha2)
    -1.471430e-14 J 1.1802573e-12 J
    >>> energy_triplealpha2.to(u.MeV)
    <Quantity 7.3665870 MeV>
    >>> nuclear_reaction_energy(reactants=['n'], products=['p+', 'e-'])
    <Quantity 1.25343e-13 J>
    """

    # TODO: Allow for neutrinos, under the assumption that they have no mass.

    # TODO: Add check for lepton number conservation; however, we might wish
    # to have violation of lepton number issuing a warning since these are
    # often omitted from nuclear reactions when calculating the energy since
    # the mass is tiny.

    errmsg = "Invalid nuclear reaction."

    def process_particles_list(
        unformatted_particles_list: List[Union[str,
                                               Particle]]) -> List[Particle]:
        """
        Take an unformatted list of particles and puts each
        particle into standard form, while allowing an integer and
        asterisk immediately preceding a particle to act as a
        multiplier.  A string argument will be treated as a list
        containing that string as its sole item.
        """

        if isinstance(unformatted_particles_list, str):
            unformatted_particles_list = [unformatted_particles_list]

        if not isinstance(unformatted_particles_list, (list, tuple)):
            raise TypeError("The input to process_particles_list should be a "
                            "string, list, or tuple.")

        particles = []

        for original_item in unformatted_particles_list:

            try:
                item = original_item.strip()

                if item.count("*") == 1 and item[0].isdigit():
                    multiplier_str, item = item.split("*")
                    multiplier = int(multiplier_str)
                else:
                    multiplier = 1

                try:
                    particle = Particle(item)
                except (InvalidParticleError) as exc:
                    raise ParticleError(errmsg) from exc

                if particle.element and not particle.isotope:
                    raise ParticleError(errmsg)

                [particles.append(particle) for i in range(multiplier)]

            except Exception:
                raise ParticleError(
                    f"{original_item} is not a valid reactant or "
                    "product in a nuclear reaction.") from None

        return particles

    def total_baryon_number(particles: List[Particle]) -> int:
        """
        Find the total number of baryons minus the number of
        antibaryons in a list of particles.
        """
        total_baryon_number = 0
        for particle in particles:
            total_baryon_number += particle.baryon_number
        return total_baryon_number

    def total_charge(particles: List[Particle]) -> int:
        """
        Find the total integer charge in a list of nuclides
        (excluding bound electrons) and other particles.
        """
        total_charge = 0
        for particle in particles:
            if particle.isotope:
                total_charge += particle.atomic_number
            elif not particle.element:
                total_charge += particle.integer_charge
        return total_charge

    def add_mass_energy(particles: List[Particle]) -> u.Quantity:
        """
        Find the total mass energy from a list of particles, while
        taking the masses of the fully ionized isotopes.
        """
        total_mass_energy = 0.0 * u.J
        for particle in particles:
            total_mass_energy += particle.mass_energy
        return total_mass_energy.to(u.J)

    input_err_msg = ("The inputs to nuclear_reaction_energy should be either "
                     "a string representing a nuclear reaction (e.g., "
                     "'D + T -> He-4 + n') or the keywords 'reactants' and "
                     "'products' as lists with the nucleons or particles "
                     "involved in the reaction (e.g., reactants=['D', 'T'] "
                     "and products=['He-4', 'n'].")

    reaction_string_is_input = args and not kwargs and len(args) == 1

    reactants_products_are_inputs = kwargs and not args and len(kwargs) == 2

    if reaction_string_is_input == reactants_products_are_inputs:
        raise ParticleError(input_err_msg)

    if reaction_string_is_input:

        reaction = args[0]

        if not isinstance(reaction, str):
            raise TypeError(input_err_msg)
        elif "->" not in reaction:
            raise ParticleError(
                f"The reaction '{reaction}' is missing a '->'"
                " or '-->' between the reactants and products.")

        try:
            LHS_string, RHS_string = re.split("-+>", reaction)
            LHS_list = re.split(r" \+ ", LHS_string)
            RHS_list = re.split(r" \+ ", RHS_string)
            reactants = process_particles_list(LHS_list)
            products = process_particles_list(RHS_list)
        except Exception as ex:
            raise ParticleError(
                f"{reaction} is not a valid nuclear reaction.") from ex

    elif reactants_products_are_inputs:

        try:
            reactants = process_particles_list(kwargs["reactants"])
            products = process_particles_list(kwargs["products"])
        except TypeError as t:
            raise TypeError(input_err_msg) from t
        except Exception as e:
            raise ParticleError(errmsg) from e

    if total_baryon_number(reactants) != total_baryon_number(products):
        raise ParticleError(
            "The baryon number is not conserved for "
            f"reactants = {reactants} and products = {products}.")

    if total_charge(reactants) != total_charge(products):
        raise ParticleError("Total charge is not conserved for reactants = "
                            f"{reactants} and products = {products}.")

    released_energy = add_mass_energy(reactants) - add_mass_energy(products)

    return released_energy