diff --git a/changelog/796.breaking.rst b/changelog/796.breaking.rst new file mode 100644 index 0000000000..98713037b3 --- /dev/null +++ b/changelog/796.breaking.rst @@ -0,0 +1,10 @@ +In `plasmapy.particles`, the ``IonizationStates`` class was renamed to +`~plasmapy.particles.IonizationStateCollection`. The argument ``n`` in +``IonizationStates`` was changed to ``n0`` in +`~plasmapy.particles.IonizationStateCollection`. The ``State`` namedtuple +was changed to the `~plasmapy.particles.IonicFraction` class. When the +`~plasmapy.particles.IonizationState` class is now provided with an ion, +the ionic fraction for that ion is set to 100% for the corresponding +element or isotope. ``AtomicError`` was renamed to +`~plasmapy.particles.exceptions.ParticleError` and ``MissingAtomicDataError`` +was renamed to `~plasmapy.particles.exceptions.MissingParticleDataError`. diff --git a/changelog/796.doc.rst b/changelog/796.doc.rst new file mode 100644 index 0000000000..4d4d412750 --- /dev/null +++ b/changelog/796.doc.rst @@ -0,0 +1 @@ +Add narrative documentation on ionization state functionality. diff --git a/docs/api_static/plasmapy.particles.ionization_state_collection.rst b/docs/api_static/plasmapy.particles.ionization_state_collection.rst new file mode 100644 index 0000000000..e12cba2ad8 --- /dev/null +++ b/docs/api_static/plasmapy.particles.ionization_state_collection.rst @@ -0,0 +1,10 @@ +:orphan: + +`plasmapy.particles.ionization_state_collection` +================================================ + +.. currentmodule:: plasmapy.particles.ionization_state_collection + +.. automodapi:: plasmapy.particles.ionization_state_collection + :include-all-objects: + :no-heading: diff --git a/docs/api_static/plasmapy.particles.ionization_states.rst b/docs/api_static/plasmapy.particles.ionization_states.rst deleted file mode 100644 index aacdeace5e..0000000000 --- a/docs/api_static/plasmapy.particles.ionization_states.rst +++ /dev/null @@ -1,10 +0,0 @@ -:orphan: - -`plasmapy.particles.ionization_states` -====================================== - -.. currentmodule:: plasmapy.particles.ionization_states - -.. automodapi:: plasmapy.particles.ionization_states - :include-all-objects: - :no-heading: diff --git a/docs/particles/decorators.rst b/docs/particles/decorators.rst index ad48009839..8b96776f4a 100644 --- a/docs/particles/decorators.rst +++ b/docs/particles/decorators.rst @@ -18,7 +18,7 @@ class. The arguments must be annotated with `~plasmapy.particles.Particle` so that the decorator knows to create the `~plasmapy.particles.Particle` instance. The function can then access particle properties by using `~plasmapy.particles.Particle` attributes. This decorator will raise an -`~plasmapy.utils.InvalidParticleError` if the input does not correspond +`~plasmapy.particles.exceptions.InvalidParticleError` if the input does not correspond to a valid particle. .. code-block:: python @@ -57,10 +57,10 @@ decorator enables several ways to allow this. If an annotated keyword is named ``element``, ``isotope``, or ``ion``; then `~plasmapy.particles.particle_input` will raise an -`~plasmapy.utils.InvalidElementError`, -`~plasmapy.utils.InvalidIsotopeError`, or -`~plasmapy.utils.InvalidIonError` if the particle is not associated with -an element, isotope, or ion; respectively. +`~plasmapy.particles.exceptions.InvalidElementError`, +`~plasmapy.particles.exceptions.InvalidIsotopeError`, or +`~plasmapy.particles.exceptions.InvalidIonError` if the particle is not +associated with an element, isotope, or ion; respectively. .. code-block:: python diff --git a/docs/particles/functional.rst b/docs/particles/functional.rst index a4ac28582b..e57c1a26a3 100644 --- a/docs/particles/functional.rst +++ b/docs/particles/functional.rst @@ -134,7 +134,7 @@ unstable), `~plasmapy.particles.half_life` returns infinity seconds. If the particle's half-life is not known to sufficient precision, then `~plasmapy.particles.half_life` returns a `str` with the estimated value -while issuing a `~plasmapy.particles.exceptions.MissingAtomicDataWarning`. +while issuing a `~plasmapy.particles.exceptions.MissingParticleDataWarning`. Additional Properties ===================== diff --git a/docs/particles/index.rst b/docs/particles/index.rst index 7ffd3d624e..41a9aaeab2 100644 --- a/docs/particles/index.rst +++ b/docs/particles/index.rst @@ -23,6 +23,7 @@ Submodules particle_class functional nuclear + ionization_states decorators See Also diff --git a/docs/particles/ionization_states.rst b/docs/particles/ionization_states.rst new file mode 100644 index 0000000000..65142dbe25 --- /dev/null +++ b/docs/particles/ionization_states.rst @@ -0,0 +1,163 @@ +.. _ionization-state-data-structures: + +Ionization state data structures +******************************** + +The ionization state (or charge state) of a plasma refers to the +fraction of an element that is at each ionization level. For example, +the ionization state of a pure helium plasma could be 5% He⁰⁺, 94% He¹⁺, +and 1% He²⁺. + +The ionization state of a single element +======================================== + +We may use the `~plasmapy.particles.IonizationState` class +to represent the ionization state of a single element, such as for this +example. + +>>> from plasmapy.particles import IonizationState +>>> ionization_state = IonizationState("He", [0.05, 0.94, 0.01]) + +The ionization state for helium may be accessed using the +``ionic_fractions`` attribute. These ionic fractions correspond to the +``integer_charges`` attribute. + +>>> ionization_state.ionic_fractions +array([0.05, 0.94, 0.01]) +>>> ionization_state.integer_charges +array([0, 1, 2]) + +The ``Z_mean`` attribute returns the mean integer charge averaged +over all particles in that element. + +>>> ionization_state.Z_mean +0.96 + +The ``Z_rms`` attribute returns the root mean square integer charge. + +>>> ionization_state.Z_rms +0.9899... + +The ``Z_most_abundant`` attribute returns a `list` of the most abundant +ion(s). The `list` may contain more than one integer charge in case of +a tie. + +>>> ionization_state.Z_most_abundant +[1] + +The ``summarize`` method prints out the ionic fraction for the ions with +an abundance of at least 1%. + +>>> ionization_state.summarize() +IonizationState instance for He with Z_mean = 0.96 +---------------------------------------------------------------- +He 0+: 0.050 +He 1+: 0.940 +He 2+: 0.010 +---------------------------------------------------------------- + +The number density of the element may be specified through the +``n_elem`` keyword argument. + +>>> import astropy.units as u +>>> ionization_state = IonizationState( +... "He", [0.05, 0.94, 0.01], n_elem = 1e19 * u.m ** -3, +... ) + +The ``n_e`` attribute provides the electron number density as a +`~astropy.units.Quantity`. + +>>> ionization_state.n_e + + +The ``number_densities`` attribute provides the number density of each +ion or neutral. + +>>> ionization_state.number_densities + + +Ionization states for multiple elements +======================================= + +The `~plasmapy.particles.IonizationStateCollection` class may be used to +represent the ionization state for multiple elements. This can be used, +for example, to describe the various impurities in a fusion plasma or +the charge state distributions of different elements in the solar wind. + +>>> from plasmapy.particles import IonizationStateCollection + +The minimal input to `~plasmapy.particles.IonizationStateCollection` is a `list` +of the elements or isotopes to represent. Integers in the `list` will +be treated as atomic numbers. + +>>> states = IonizationStateCollection(["H", 2]) + +To set the ionic fractions for hydrogen, we may do item assignment. + +>>> states["H"] = [0.9, 0.1] + +We may use indexing to retrieve an `~plasmapy.particles.IonizationState` +instance for an element. + +>>> states["H"] + + +The ionization states for all of the elements may be specified directly +as arguments to the class. + +>>> states = IonizationStateCollection( +... {"H": [0.01, 0.99], "He": [0.04, 0.95, 0.01]}, +... abundances={"H": 1, "He": 0.08}, +... n0 = 5e19 * u.m ** -3, +... ) + +The ionic fractions will be stored as a `dict`. + +>>> states.ionic_fractions +{'H': array([0.01, 0.99]), 'He': array([0.04, 0.95, 0.01])} + +The number density for each element is the product of the number +density scaling factor ``n0`` with that element's abundance. +The number density for each ion is the product of ``n0``, the +corresponding element's abundance, and the ionic fraction. + +>>> states.n0 + +>>> states.abundances +{'H': 1.0, 'He': 0.08} +>>> states.number_densities["H"] + + +The +corresponding element's abundance, and the ionic fraction. + +>>> states.n0 + +>>> states.abundances +{'H': 1.0, 'He': 0.08} +>>> states.number_densities["H"] + + +The +corresponding element's abundance, and the ionic fraction. + +>>> states.n + +>>> states.abundances +{'H': 1.0, 'He': 0.08} +>>> states.number_densities["H"] + + +The `~plasmapy.particles.IonizationStates.summarize` method may also be +used to get a summary of the ionization states. + +>>> states.summarize() +---------------------------------------------------------------- +H 1+: 0.990 n_i = 4.95e+19 m**-3 +---------------------------------------------------------------- +He 0+: 0.040 n_i = 1.60e+17 m**-3 +He 1+: 0.950 n_i = 3.80e+18 m**-3 +---------------------------------------------------------------- +n_e = 5.34e+19 m**-3 +T_e = 1.30e+04 K +---------------------------------------------------------------- diff --git a/plasmapy/particles/__init__.py b/plasmapy/particles/__init__.py index d7252d3320..467f8512cb 100644 --- a/plasmapy/particles/__init__.py +++ b/plasmapy/particles/__init__.py @@ -21,14 +21,15 @@ standard_atomic_weight, ) from plasmapy.particles.decorators import particle_input -from plasmapy.particles.ionization_state import IonizationState, State -from plasmapy.particles.ionization_states import IonizationStates +from plasmapy.particles.ionization_state import IonicFraction, IonizationState +from plasmapy.particles.ionization_state_collection import IonizationStateCollection from plasmapy.particles.nuclear import nuclear_binding_energy, nuclear_reaction_energy from plasmapy.particles.particle_class import ( AbstractParticle, CustomParticle, DimensionlessParticle, Particle, + particle_like, ) from plasmapy.particles.serialization import ( json_load_particle, diff --git a/plasmapy/particles/atomic.py b/plasmapy/particles/atomic.py index 5dd7762720..d9932de465 100644 --- a/plasmapy/particles/atomic.py +++ b/plasmapy/particles/atomic.py @@ -1,4 +1,5 @@ """Functions that retrieve or are related to elemental or isotopic data.""" + __all__ = [ "atomic_number", "mass_number", @@ -31,7 +32,7 @@ InvalidElementError, InvalidIsotopeError, InvalidParticleError, - MissingAtomicDataError, + MissingParticleDataError, ) from plasmapy.particles.isotopes import _Isotopes from plasmapy.particles.particle_class import Particle @@ -58,10 +59,10 @@ def atomic_number(element: Particle) -> Integral: Raises ------ - `~plasmapy.utils.InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` @@ -69,8 +70,8 @@ def atomic_number(element: Particle) -> Integral: See Also -------- - mass_number : returns the mass number (the total - number of protons and neutrons) of an isotope. + mass_number : returns the mass number (the total number of protons + and neutrons) of an isotope. Examples -------- @@ -82,7 +83,6 @@ def atomic_number(element: Particle) -> Integral: 2 >>> atomic_number("oganesson") 118 - """ return element.atomic_number @@ -105,10 +105,10 @@ def mass_number(isotope: Particle) -> Integral: Raises ------ - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. - `~plasmapy.utils.InvalidIsotopeError` + `~plasmapy.particles.exceptions.InvalidIsotopeError` If the argument does not correspond to a valid isotope. `TypeError` @@ -117,8 +117,7 @@ def mass_number(isotope: Particle) -> Integral: See Also -------- - atomic_number : returns the number of protons in - an isotope or element + atomic_number : returns the number of protons in an isotope or element Examples -------- @@ -130,7 +129,6 @@ def mass_number(isotope: Particle) -> Integral: 3 >>> mass_number("alpha") 4 - """ return isotope.mass_number @@ -155,10 +153,10 @@ def standard_atomic_weight(element: Particle) -> u.Quantity: Raises ------ - `~plasmapy.utils.InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` @@ -190,7 +188,6 @@ def standard_atomic_weight(element: Particle) -> u.Quantity: >>> standard_atomic_weight("lead") - """ # TODO: Put in ReST links into above docstring return element.standard_atomic_weight @@ -226,16 +223,16 @@ def particle_mass( `TypeError` The argument is not a string, integer, or Quantity. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. - `~plasmapy.utils.MissingAtomicDataError` + `~plasmapy.particles.exceptions.MissingParticleDataError` If the standard atomic weight, the isotope mass, or the particle mass is not available. See Also -------- - ~plasmapy.particles.standard_atomic_weight + standard_atomic_weight Notes ----- @@ -246,7 +243,6 @@ def particle_mass( The masses of neutrinos are not available because primarily upper limits are presently known. - """ return particle.mass @@ -258,7 +254,7 @@ def isotopic_abundance(isotope: Particle, mass_numb: Optional[Integral] = None) Parameters ---------- - argument: `str` or `int` + isotope: `str` or `int` A string representing an element or isotope, or an integer representing the atomic number of an element. @@ -273,10 +269,10 @@ def isotopic_abundance(isotope: Particle, mass_numb: Optional[Integral] = None) Raises ------ - `~plasmapy.utils.InvalidIsotopeError` + `~plasmapy.particles.exceptions.InvalidIsotopeError` If the argument is a valid particle but not a valid isotope. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle or contradictory information is provided. @@ -298,7 +294,6 @@ def isotopic_abundance(isotope: Particle, mass_numb: Optional[Integral] = None) 0.524 >>> isotopic_abundance('hydrogen', 1) 0.999885 - """ return isotope.isotopic_abundance @@ -319,14 +314,14 @@ def integer_charge(particle: Particle) -> Integral: Raises ------ - `~plasmapy.particles.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle or contradictory information is provided. - `~plasmapy.particles.ChargeError` + `~plasmapy.particles.exceptions.ChargeError` If charge information for the particle is not available. - `~plasmapy.particles.AtomicWarning` + `~plasmapy.particles.exceptions.AtomicWarning` If the input represents an ion with an integer charge that is less than or equal to ``-3``, which is unlikely to occur in nature. @@ -353,13 +348,12 @@ def integer_charge(particle: Particle) -> Integral: 1 >>> integer_charge('N-14++') 2 - """ return particle.integer_charge @particle_input(any_of={"charged", "uncharged"}) -def electric_charge(particle: Particle) -> u.Quantity: +def electric_charge(particle: Particle) -> u.C: """ Return the electric charge (in coulombs) of a particle. @@ -376,14 +370,14 @@ def electric_charge(particle: Particle) -> u.Quantity: Raises ------ - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle or contradictory information is provided. - `~plasmapy.utils.ChargeError` + `~plasmapy.particles.exceptions.ChargeError` If charge information for the particle is not available. - `~plasmapy.utils.AtomicWarning` + `~plasmapy.particles.exceptions.ParticleWarning` If the input represents an ion with an integer charge that is below ``-3``. @@ -408,7 +402,6 @@ def electric_charge(particle: Particle) -> u.Quantity: < name='Electron charge' ...> >>> electric_charge('H-') - """ return particle.charge @@ -435,17 +428,17 @@ def is_stable(particle: Particle, mass_numb: Optional[Integral] = None) -> bool: Raises ------ - `~plasmapy.utils.InvalidIsotopeError` + `~plasmapy.particles.exceptions.InvalidIsotopeError` If the arguments correspond to a valid element but not a valid isotope. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the arguments do not correspond to a valid particle. `TypeError` If the argument is not a `str` or `int`. - `~plasmapy.utils.MissingAtomicDataError` + `~plasmapy.particles.exceptions.MissingParticleDataError` If stability information is not available. Examples @@ -458,7 +451,6 @@ def is_stable(particle: Particle, mass_numb: Optional[Integral] = None) -> bool: True >>> is_stable("tau+") False - """ if particle.element and not particle.isotope: raise InvalidIsotopeError( @@ -471,7 +463,7 @@ def is_stable(particle: Particle, mass_numb: Optional[Integral] = None) -> bool: def half_life(particle: Particle, mass_numb: Optional[Integral] = None) -> u.Quantity: """ Return the half-life in seconds for unstable isotopes and particles, - and numpy.inf in seconds for stable isotopes and particles. + and `~numpy.inf` in seconds for stable isotopes and particles. Parameters ---------- @@ -490,11 +482,11 @@ def half_life(particle: Particle, mass_numb: Optional[Integral] = None) -> u.Qua Raises ------ - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle or contradictory information is provided. - `~plasmapy.utils.MissingAtomicDataError` + `~plasmapy.particles.exceptions.MissingParticleDataError` If no half-life data is available for the isotope. `TypeError` @@ -506,9 +498,9 @@ def half_life(particle: Particle, mass_numb: Optional[Integral] = None) -> u.Qua Accurate half-life data is not known for all isotopes. Some isotopes may have upper or lower limits on the half-life, in which case this function will return a string with that information and issue a - `~plasmapy.utils.MissingAtomicDataWarning`. When no isotope - information is available, then this function raises a - `~plasmapy.utils.MissingAtomicDataError`. + `~plasmapy.particles.exceptions.MissingParticleDataError`. When no + isotope information is available, then this function raises a + `~plasmapy.particles.exceptions.MissingParticleDataError`. Examples -------- @@ -518,14 +510,14 @@ def half_life(particle: Particle, mass_numb: Optional[Integral] = None) -> u.Qua >>> half_life('H-1') - """ return particle.half_life def known_isotopes(argument: Union[str, Integral] = None) -> List[str]: - """Return a list of all known isotopes of an element, or a list - of all known isotopes of every element if no input is provided. + """ + Return a list of all known isotopes of an element, or a list of all + known isotopes of every element if no input is provided. Parameters ---------- @@ -545,10 +537,10 @@ def known_isotopes(argument: Union[str, Integral] = None) -> List[str]: Raises ------ - `~plasmapy.utils.InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` @@ -560,11 +552,9 @@ def known_isotopes(argument: Union[str, Integral] = None) -> List[str]: See Also -------- - ~plasmapy.particles.common_isotopes : returns isotopes with non-zero - isotopic abundances. + common_isotopes : returns isotopes with non-zero isotopic abundances. - ~plasmapy.particles.stable_isotopes : returns isotopes that are - stable against radioactive decay. + stable_isotopes : returns isotopes that are stable against radioactive decay. Examples -------- @@ -576,7 +566,6 @@ def known_isotopes(argument: Union[str, Integral] = None) -> List[str]: ['H-1', 'D', 'T', 'H-4', 'H-5', 'H-6', 'H-7', 'He-3', 'He-4', 'He-5'] >>> len(known_isotopes()) # the number of known isotopes 3352 - """ # TODO: Allow Particle objects representing elements to be inputs @@ -647,10 +636,10 @@ def common_isotopes( Raises ------ - `~plasmapy.utils.InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` @@ -663,14 +652,11 @@ def common_isotopes( See Also -------- - ~plasmapy.utils.known_isotopes : returns a list of isotopes that - have been discovered. + known_isotopes : returns a list of isotopes that have been discovered. - ~plasmapy.utils.stable_isotopes : returns isotopes that are stable - against radioactive decay. + stable_isotopes : returns isotopes that are stable against radioactive decay. - ~plasmapy.utils.isotopic_abundance : returns the relative isotopic - abundance. + isotopic_abundance : returns the relative isotopic abundance. Examples -------- @@ -686,7 +672,6 @@ def common_isotopes( ['Fe-56'] >>> common_isotopes()[0:7] ['H-1', 'D', 'He-4', 'He-3', 'Li-7', 'Li-6', 'Be-9'] - """ # TODO: Allow Particle objects representing elements to be inputs @@ -764,10 +749,10 @@ def stable_isotopes( Raises ------ - `~plasmapy.utils.InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` @@ -784,11 +769,9 @@ def stable_isotopes( See Also -------- - ~plasmapy.particles.known_isotopes : returns a list of isotopes that - have been discovered + known_isotopes : returns a list of isotopes that have been discovered. - ~plasmapy.particles.common_isotopes : returns isotopes with non-zero - isotopic abundances + common_isotopes : returns isotopes with non-zero isotopic abundances. Examples -------- @@ -807,7 +790,6 @@ def stable_isotopes( >>> stable_isotopes('U', unstable=True)[:5] # only first five ['U-217', 'U-218', 'U-219', 'U-220', 'U-221'] - """ # TODO: Allow Particle objects representing elements to be inputs @@ -856,21 +838,21 @@ def reduced_mass(test_particle, target_particle) -> u.Quantity: object, or a `~astropy.units.Quantity` or `~astropy.constants.Constant` with units of mass. - Return + Returns ------- reduced_mass : `~astropy.units.Quantity` The reduced mass between the test particle and target particle. Raises ------ - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If either particle is invalid. `~astropy.units.UnitConversionError` If an argument is a `~astropy.units.Quantity` or `~astropy.units.Constant` but does not have units of mass. - `~plasmapy.utils.MissingAtomicDataError` + `~plasmapy.particles.exceptions.MissingParticleDataError` If the mass of either particle is not known. `TypeError` @@ -885,7 +867,6 @@ def reduced_mass(test_particle, target_particle) -> u.Quantity: >>> reduced_mass(5.4e-27 * u.kg, 8.6e-27 * u.kg) - """ # TODO: Add discussion on reduced mass and its importance to docstring @@ -907,8 +888,8 @@ def get_particle_mass(particle) -> u.Quantity: return particle.mass.to(u.kg) except u.UnitConversionError as exc1: raise u.UnitConversionError(f"Incorrect units in reduced_mass.") from exc1 - except MissingAtomicDataError: - raise MissingAtomicDataError( + except MissingParticleDataError: + raise MissingParticleDataError( f"Unable to find the reduced mass because the mass of " f"{particle} is not available." ) from None @@ -941,11 +922,9 @@ def periodic_table_period(argument: Union[str, Integral]) -> Integral: See Also -------- - ~plasmapy.particles.periodic_table_group : returns periodic table - group of element. + periodic_table_group : returns periodic table group of element. - ~plasmapy.particles.periodic_table_block : returns periodic table - block of element. + periodic_table_block : returns periodic table block of element. Examples -------- @@ -957,7 +936,6 @@ def periodic_table_period(argument: Union[str, Integral]) -> Integral: 6 >>> periodic_table_period("nitrogen") 2 - """ # TODO: Implement @particle_input if not isinstance(argument, (str, Integral)): @@ -994,14 +972,11 @@ def periodic_table_group(argument: Union[str, Integral]) -> Integral: See Also -------- - ~plasmapy.particles.periodic_table_period : returns periodic table - period of element. + periodic_table_period : returns periodic table period of element. - ~plasmapy.particles.periodic_table_block : returns periodic table - block of element. + periodic_table_block : returns periodic table block of element. - ~plasmapy.particles.periodic_table_category : returns periodic table - category of element. + periodic_table_category : returns periodic table category of element. Examples -------- @@ -1015,7 +990,6 @@ def periodic_table_group(argument: Union[str, Integral]) -> Integral: 18 >>> periodic_table_group("barium") 2 - """ # TODO: Implement @particle_input if not isinstance(argument, (str, Integral)): @@ -1052,14 +1026,11 @@ def periodic_table_block(argument: Union[str, Integral]) -> str: See Also -------- - ~plasmapy.particles.periodic_table_period : returns periodic table - period of element. + periodic_table_period : returns periodic table period of element. - ~plasmapy.particles.periodic_table_group : returns periodic table - group of element. + periodic_table_group : returns periodic table group of element. - ~plasmapy.particles.periodic_table_category : returns periodic table - category of element. + periodic_table_category : returns periodic table category of element. Examples -------- @@ -1073,7 +1044,6 @@ def periodic_table_block(argument: Union[str, Integral]) -> str: 'p' >>> periodic_table_block("francium") 's' - """ # TODO: Implement @particle_input if not isinstance(argument, (str, Integral)): @@ -1110,14 +1080,11 @@ def periodic_table_category(argument: Union[str, Integral]) -> str: See Also -------- - ~plasmapy.particles.periodic_table_period : returns periodic table - period of element. + periodic_table_period : returns periodic table period of element. - ~plasmapy.particles.periodic_table_group : returns periodic table - group of element. + periodic_table_group : returns periodic table group of element. - ~plasmapy.particles.periodic_table_block : returns periodic table - block of element. + periodic_table_block : returns periodic table block of element. Examples -------- @@ -1129,7 +1096,6 @@ def periodic_table_category(argument: Union[str, Integral]) -> str: 'alkaline earth metal' >>> periodic_table_category("rhodium") 'transition metal' - """ # TODO: Implement @particle_input if not isinstance(argument, (str, Integral)): diff --git a/plasmapy/particles/decorators.py b/plasmapy/particles/decorators.py index c65add4a9c..0ebc570a2c 100644 --- a/plasmapy/particles/decorators.py +++ b/plasmapy/particles/decorators.py @@ -13,12 +13,12 @@ from typing import Any, Callable, List, Optional, Set, Tuple, Union from plasmapy.particles.exceptions import ( - AtomicError, ChargeError, InvalidElementError, InvalidIonError, InvalidIsotopeError, InvalidParticleError, + ParticleError, ) from plasmapy.particles.particle_class import Particle @@ -32,7 +32,7 @@ def _particle_errmsg( ) -> str: """ Return a string with an appropriate error message for an - `~plasmapy.utils.InvalidParticleError`. + `~plasmapy.particles.exceptions.InvalidParticleError`. """ errmsg = f"In {funcname}, {argname} = {repr(argval)} " if mass_numb is not None or Z is not None: @@ -98,17 +98,17 @@ def particle_input( require : `str`, `set`, `list`, or `tuple`, optional Categories that a particle must be in. If a particle is not in - all of these categories, then an `~plasmapy.utils.AtomicError` + all of these categories, then an `~plasmapy.particles.exceptions.ParticleError` will be raised. any_of : `str`, `set`, `list`, or `tuple`, optional Categories that a particle may be in. If a particle is not in - any of these categories, then an `~plasmapy.utils.AtomicError` + any of these categories, then an `~plasmapy.particles.exceptions.ParticleError` will be raised. exclude : `str`, `set`, `list`, or `tuple`, optional Categories that a particle cannot be in. If a particle is in - any of these categories, then an `~plasmapy.utils.AtomicError` + any of these categories, then an `~plasmapy.particles.exceptions.ParticleError` will be raised. none_shall_pass : `bool`, optional @@ -121,10 +121,11 @@ def particle_input( Notes ----- If the annotated argument is named `element`, `isotope`, or `ion`, - then the decorator will raise an `~plasmapy.utils.InvalidElementError`, - `~plasmapy.utils.InvalidIsotopeError`, or `~plasmapy.utils.InvalidIonError` - if the particle does not correspond to an element, isotope, or ion, - respectively. + then the decorator will raise an + `~plasmapy.particles.exceptions.InvalidElementError`, + `~plasmapy.particles.exceptions.InvalidIsotopeError`, or + `~plasmapy.particles.exceptions.InvalidIonError` if the particle + does not correspond to an element, isotope, or ion, respectively. If exactly one argument is annotated with `~plasmapy.particles.Particle`, then the keywords ``Z`` and ``mass_numb`` may be used to specify the @@ -143,29 +144,29 @@ def particle_input( If the number of input elements in a collection do not match the number of expected elements. - `~plasmapy/utils/InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the annotated argument does not correspond to a valid particle. - `~plasmapy/utils/InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` If an annotated argument is named ``element``, and the input does not correspond to an element, isotope, or ion. - `~plasmapy/utils/InvalidIsotopeError` + `~plasmapy.particles.exceptions.InvalidIsotopeError` If an annotated argument is named ``isotope``, and the input does not correspond to an isotope or an ion of an isotope. - `~plasmapy/utils/InvalidIonError` + `~plasmapy.particles.exceptions.InvalidIonError` If an annotated argument is named ``ion``, and the input does not correspond to an ion. - `~plasmapy/utils/ChargeError` + `~plasmapy.particles.exceptions.ChargeError` If ``'charged'`` is in the ``require`` argument and the particle is not explicitly charged, or if ``any_of = {'charged', 'uncharged'}`` and the particle does not have charge information associated with it. - `~plasmapy/utils/AtomicError` + `~plasmapy.particles.exceptions.ParticleError` If an annotated argument does not meet the criteria set by the categories in the ``require``, ``any_of``, and ``exclude`` keywords; if more than one argument is annotated and ``Z`` or @@ -231,7 +232,6 @@ def decorated_method(self, particle: Particle): ) def selective_function(particle: Particle): return particle - """ if exclude is None: @@ -304,14 +304,14 @@ def wrapper(*args, **kwargs): args_to_become_particles.append(argname) if not args_to_become_particles: - raise AtomicError( + 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 AtomicError( + 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 " @@ -420,9 +420,10 @@ def wrapper(*args, **kwargs): def get_particle(argname, params, already_particle, funcname): argval, Z, mass_numb = params - - # Convert the argument to a Particle object if it is not - # already one. + """ + Convert the argument to a `~plasmapy.particles.Particle` object + if it is not already one. + """ if not already_particle: @@ -488,7 +489,7 @@ def get_particle(argname, params, already_particle, funcname): # maximally useful error message. if not particle.is_category(require=require, exclude=exclude, any_of=any_of): - raise AtomicError( + raise ParticleError( _category_errmsg(particle, require, exclude, any_of, funcname) ) diff --git a/plasmapy/particles/exceptions.py b/plasmapy/particles/exceptions.py index 2016ce7124..2ec7528f8c 100644 --- a/plasmapy/particles/exceptions.py +++ b/plasmapy/particles/exceptions.py @@ -1,41 +1,44 @@ """ -Collection of `Exceptions` and `Warnings` for PlasmaPy particles. +Collection of exceptions and warnings for `plasmapy.particles`. """ __all__ = [ - "AtomicError", - "AtomicWarning", + "ParticleError", + "ParticleWarning", "ChargeError", "InvalidElementError", "InvalidIonError", "InvalidIsotopeError", "InvalidParticleError", - "MissingAtomicDataError", - "MissingAtomicDataWarning", + "MissingParticleDataError", + "MissingParticleDataWarning", "UnexpectedParticleError", ] from plasmapy.utils import PlasmaPyError, PlasmaPyWarning -class AtomicError(PlasmaPyError): - """An exception for errors in the `~plasmapy.particles` subpackage.""" +class ParticleError(PlasmaPyError): + """Base exception for errors in the `~plasmapy.particles` subpackage.""" pass -class MissingAtomicDataError(AtomicError): - """An exception for missing atomic or particle data.""" +class MissingParticleDataError(ParticleError): + """ + An exception for missing atomic or particle data in the + `~plasmapy.particles` subpackage. + """ pass -class ChargeError(AtomicError): +class ChargeError(ParticleError): """An exception for incorrect or missing charge information.""" pass -class UnexpectedParticleError(AtomicError): +class UnexpectedParticleError(ParticleError): """An exception for when a particle is not of the expected category.""" pass @@ -68,19 +71,19 @@ class InvalidElementError(UnexpectedParticleError): pass -class InvalidParticleError(AtomicError): +class InvalidParticleError(ParticleError): """An exception for when a particle is invalid.""" pass -class AtomicWarning(PlasmaPyWarning): +class ParticleWarning(PlasmaPyWarning): """The base warning for the `~plasmapy.particles` subpackage.""" pass -class MissingAtomicDataWarning(AtomicWarning): +class MissingParticleDataWarning(ParticleWarning): """Warning for use when atomic or particle data is missing.""" pass diff --git a/plasmapy/particles/ionization_state.py b/plasmapy/particles/ionization_state.py index 7959b6c0b5..bfd385cf44 100644 --- a/plasmapy/particles/ionization_state.py +++ b/plasmapy/particles/ionization_state.py @@ -2,18 +2,22 @@ Objects for storing ionization state data for a single element or for a single ionization level. """ -__all__ = ["IonizationState", "State"] -import astropy.units as u -import collections +__all__ = ["IonizationState", "IonicFraction"] + import numpy as np import warnings +from astropy import units as u from numbers import Integral, Real from typing import List, Optional, Union from plasmapy.particles.decorators import particle_input -from plasmapy.particles.exceptions import AtomicError, ChargeError, InvalidParticleError +from plasmapy.particles.exceptions import ( + ChargeError, + InvalidParticleError, + ParticleError, +) from plasmapy.particles.particle_class import Particle from plasmapy.utils.decorators import validate_quantities @@ -21,13 +25,133 @@ "Number densities must be Quantity objects with units of inverse " "volume." ) -# TODO: Change `State` into a class with validations for all of the -# TODO: attributes. -#: Named tuple class for representing an ionization state (`collections.namedtuple`). -State = collections.namedtuple( - "State", ["integer_charge", "ionic_fraction", "ionic_symbol", "number_density"] -) +class IonicFraction: + """ + Representation of the ionic fraction for a single ion. + + Parameters + ---------- + ion: `~plasmapy.particles.particle_class.particle_like` + The ion for the corresponding ionic fraction. + + ionic_fraction: real number between 0 and 1, optional + The fraction of an element or isotope that is at this ionization + level. + + number_density: `~astropy.units.Quantity`, optional + The number density of this ion. + + See Also + -------- + IonizationState + plasmapy.particles.IonizationStateCollection + + Examples + -------- + >>> alpha_fraction = IonicFraction("alpha", ionic_fraction=0.31) + >>> alpha_fraction.ionic_symbol + 'He-4 2+' + >>> alpha_fraction.integer_charge + 2 + >>> alpha_fraction.ionic_fraction + 0.31 + """ + + def __eq__(self, other): + + try: + if self.ionic_symbol != other.ionic_symbol: + return False + + ionic_fraction_within_tolerance = np.isclose( + self.ionic_fraction, other.ionic_fraction, rtol=1e-15, + ) + + number_density_within_tolerance = u.isclose( + self.number_density, other.number_density, rtol=1e-15, + ) + + return all( + [ionic_fraction_within_tolerance, number_density_within_tolerance] + ) + + except Exception as exc: + raise TypeError( + "Unable to ascertain equality between the following objects:\n" + f" {self}\n" + f" {other}" + ) from exc + + @particle_input + 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 __repr__(self): + return ( + f"IonicFraction({repr(self.ionic_symbol)}, " + f"ionic_fraction={self.ionic_fraction})" + ) + + @property + def ionic_symbol(self) -> str: + """The symbol of the ion.""" + return self._particle.ionic_symbol + + @property + def integer_charge(self) -> Integral: + """The integer charge of the ion.""" + return self._particle.integer_charge + + @property + def ionic_fraction(self) -> Real: + r""" + The fraction of particles of an element that are at this + ionization level. + + Notes + ----- + An ionic fraction must be in the interval :math:`[0, 1]`. + + If no ionic fraction is specified, then this attribute will be + assigned the value of `~numpy.nan`. + """ + return self._ionic_fraction + + @ionic_fraction.setter + def ionic_fraction(self, ionfrac: Optional[Real]): + if ionfrac is None or np.isnan(ionfrac): + self._ionic_fraction = np.nan + else: + try: + out_of_range = ionfrac < 0 or ionfrac > 1 + except TypeError: + raise TypeError(f"Invalid ionic fraction: {ionfrac}") + else: + if out_of_range: + raise ValueError(f"The ionic fraction must be between 0 and 1.") + else: + self._ionic_fraction = ionfrac + + @property + def number_density(self) -> u.m ** -3: + """The number density of the ion.""" + return self._number_density.to(u.m ** -3) + + @number_density.setter + @validate_quantities( + n={"can_be_negative": False, "can_be_inf": False, "none_shall_pass": True}, + ) + def number_density(self, n: u.m ** -3): + if n is None: + self._number_density = np.nan * u.m ** -3 + else: + self._number_density = n class IonizationState: @@ -37,40 +161,46 @@ class IonizationState: Parameters ---------- - particle: str, integer, or ~plasmapy.particles.Particle + particle: `particle_like` A `str` or `~plasmapy.particles.Particle` instance representing - an element or isotope, or an integer representing the atomic - number of an element. + an element, isotope, or ion; or an integer representing the + atomic number of an element. - ionic_fractions: ~numpy.ndarray, list, tuple, or ~astropy.units.Quantity; optional + ionic_fractions: `~numpy.ndarray`, `list`, `tuple`, or `~astropy.units.Quantity`; optional The ionization fractions of an element, where the indices correspond to integer charge. This argument should contain the atomic number plus one items, and must sum to one within an absolute tolerance of ``tol`` if dimensionless. Alternatively, this argument may be a `~astropy.units.Quantity` that represents - the number densities of each neutral/ion. + the number densities of each neutral/ion. This argument cannot + be specified when ``particle`` is an ion. - T_e: ~astropy.units.Quantity, keyword-only, optional - The electron temperature or thermal energy per particle. + T_e: `~astropy.units.Quantity`, keyword-only, optional + The electron temperature or thermal energy per electron. - n_elem: ~astropy.units.Quantity, keyword-only, optional + n_elem: `~astropy.units.Quantity`, keyword-only, optional The number density of the element, including neutrals and all ions. - tol: float or integer, keyword-only, optional + tol: `float` or integer, keyword-only, optional The absolute tolerance used by `~numpy.isclose` when testing normalizations and making comparisons. Defaults to ``1e-15``. Raises ------ - ~plasmapy.utils.AtomicError + `~plasmapy.particles.exceptions..ParticleError` If the ionic fractions are not normalized or contain invalid values, or if number density information is provided through both ``ionic_fractions`` and ``n_elem``. - ~plasmapy.utils.InvalidParticleError + `~plasmapy.particles.exceptions.InvalidParticleError` If the particle is invalid. + See Also + -------- + IonicFraction + plasmapy.particles.IonizationStateCollection + Examples -------- >>> states = IonizationState('H', [0.6, 0.4], n_elem=1*u.cm**-3, T_e=11000*u.K) @@ -83,24 +213,25 @@ class IonizationState: >>> states.n_elem # element number density - Notes - ----- - Calculation of collisional ionization equilibrium has not yet been - implemented. - + If the input particle is an ion, then the ionization state for the + corresponding element or isotope will be set to ``1.0`` for that + ion. For example, when the input particle is an alpha particle, the + base particle will be He-4, and all He-4 particles will be set as + doubly charged. + + >>> states = IonizationState('alpha') + >>> states.base_particle + 'He-4' + >>> states.ionic_fractions + array([0., 0., 1.]) """ - # TODO: Allow this class to (optionally?) handle negatively charged - # TODO: ions. There are instances where singly negatively charged - # TODO: ions are important in astrophysical plasmas, such as H- in - # TODO: the atmospheres of relatively cool stars. There may be some - # TODO: rare situations where doubly negatively charged ions show up - # TODO: too, but triply negatively charged ions are very unlikely. + # TODO: Allow this class to handle negatively charged # TODO: Add in functionality to find equilibrium ionization states. @validate_quantities(T_e={"equivalencies": u.temperature_energy()}) - @particle_input(require="element", exclude="ion") + @particle_input(require="element") def __init__( self, particle: Particle, @@ -113,7 +244,20 @@ def __init__( ): """Initialize an `~plasmapy.particles.IonizationState` instance.""" - self._particle_instance = particle + if particle.is_ion or particle.is_category(require=("uncharged", "element")): + if ionic_fractions is None: + ionic_fractions = np.zeros(particle.atomic_number + 1) + ionic_fractions[particle.integer_charge] = 1.0 + particle = Particle( + particle.isotope if particle.isotope else particle.element + ) + else: + raise ParticleError( + "The ionic fractions must not be specified when " + "the input particle to IonizationState is an ion." + ) + + self._particle = particle try: self.tol = tol @@ -125,7 +269,7 @@ def __init__( and isinstance(ionic_fractions, u.Quantity) and ionic_fractions.si.unit == u.m ** -3 ): - raise AtomicError( + raise ParticleError( "Cannot simultaneously provide number density " "through both n_elem and ionic_fractions." ) @@ -141,9 +285,8 @@ def __init__( ) except Exception as exc: - raise AtomicError( - f"Unable to create IonizationState instance for " - f"{particle.particle}." + raise ParticleError( + f"Unable to create IonizationState object for {particle.particle}." ) from exc def __str__(self) -> str: @@ -152,17 +295,16 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() - def __getitem__(self, value) -> State: + 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 = State( - value, - self.ionic_fractions[value], - self.ionic_symbols[value], - self.number_densities[value], + 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): @@ -170,7 +312,7 @@ def __getitem__(self, value) -> State: value = Particle(value) except InvalidParticleError as exc: raise InvalidParticleError( - f"{value} is not a valid integer charge or " f"particle." + f"{value} is not a valid integer charge or particle." ) from exc same_element = value.element == self.element @@ -179,15 +321,14 @@ def __getitem__(self, value) -> State: if same_element and same_isotope and has_charge_info: Z = value.integer_charge - result = State( - Z, - self.ionic_fractions[Z], - self.ionic_symbols[Z], - self.number_densities[Z], + 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 AtomicError("Inconsistent element or isotope.") + raise ParticleError("Inconsistent element or isotope.") elif not has_charge_info: raise ChargeError("No integer charge provided.") return result @@ -207,15 +348,14 @@ def __iter__(self): def __next__(self): """ - Return a `~plasmapy.particles.State` instance that contains + Return a `~plasmapy.particles.IonicFraction` instance that contains information about a particular ionization level. """ if self._charge_index <= self.atomic_number: - result = State( - self._charge_index, - self._ionic_fractions[self._charge_index], - self.ionic_symbols[self._charge_index], - self.number_densities[self._charge_index], + result = IonicFraction( + ion=Particle(self.base_particle, Z=self._charge_index), + ionic_fraction=self.ionic_fractions[self._charge_index], + number_density=self.number_densities[self._charge_index], ) self._charge_index += 1 return result @@ -231,11 +371,11 @@ def __eq__(self, other): Raises ------ - TypeError + `TypeError` If ``other`` is not an `~plasmapy.particles.IonizationState` instance. - AtomicError + `ParticleError` If ``other`` corresponds to a different element or isotope. Examples @@ -256,7 +396,7 @@ def __eq__(self, other): same_isotope = self.isotope == other.isotope if not same_element or not same_isotope: - raise AtomicError( + 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." @@ -282,7 +422,7 @@ def __eq__(self, other): ) ) - # For the next line, recall that np.nan == np.nan is False (sigh) + # For the next line, recall that np.nan == np.nan is False same_fractions = np.any( [ @@ -327,10 +467,10 @@ def ionic_fractions(self, fractions): try: if np.min(fractions) < 0: - raise AtomicError("Cannot have negative ionic fractions.") + raise ParticleError("Cannot have negative ionic fractions.") if len(fractions) != self.atomic_number + 1: - raise AtomicError( + raise ParticleError( "The length of ionic_fractions must be " f"{self.atomic_number + 1}." ) @@ -346,16 +486,16 @@ def ionic_fractions(self, fractions): if not all_nans: if np.any(fractions < 0) or np.any(fractions > 1): - raise AtomicError("Ionic fractions must be between 0 and 1.") + raise ParticleError("Ionic fractions must be between 0 and 1.") if not np.isclose(sum_of_fractions, 1, rtol=0, atol=self.tol): - raise AtomicError("Ionic fractions must sum to one.") + raise ParticleError("Ionic fractions must sum to one.") self._ionic_fractions = fractions except Exception as exc: - raise AtomicError( - f"Unable to set ionic fractions of {self.element} " f"to {fractions}." + raise ParticleError( + f"Unable to set ionic fractions of {self.element} to {fractions}." ) from exc def _is_normalized(self, tol: Optional[Real] = None) -> bool: @@ -374,27 +514,12 @@ def _is_normalized(self, tol: Optional[Real] = None) -> bool: def normalize(self) -> None: """ Normalize the ionization state distribution (if set) so that the - sum becomes equal to one. - """ - self._ionic_fractions = self._ionic_fractions / np.sum(self._ionic_fractions) + sum of the ionic fractions becomes equal to one. - @property - def equil_ionic_fractions(self, T_e: u.K = None): - """ - Return the equilibrium ionic fractions for temperature ``T_e`` - or the temperature set in the IonizationState instance. Not - implemented. + This method may be used, for example, to correct for rounding + errors. """ - raise NotImplementedError - - @validate_quantities(equivalencies=u.temperature_energy()) - def equilibrate(self, T_e: u.K = np.nan * u.K): - """ - Set the ionic fractions to collisional ionization equilibrium - for temperature ``T_e``. Not implemented. - """ - # self.ionic_fractions = self.equil_ionic_fractions - raise NotImplementedError + self._ionic_fractions = self._ionic_fractions / np.sum(self._ionic_fractions) @property @validate_quantities @@ -416,7 +541,7 @@ def n_elem(self) -> u.m ** -3: def n_elem(self, value: u.m ** -3): """Set the number density of neutrals and all ions.""" if value < 0 * u.m ** -3: - raise AtomicError + raise ParticleError if 0 * u.m ** -3 < value <= np.inf * u.m ** -3: self._n_elem = value.to(u.m ** -3) elif np.isnan(value): @@ -436,10 +561,10 @@ def number_densities(self) -> u.m ** -3: def number_densities(self, value: u.m ** -3): """Set the number densities for each state.""" if np.any(value.value < 0): - raise AtomicError("Number densities cannot be negative.") + raise ParticleError("Number densities cannot be negative.") if len(value) != self.atomic_number + 1: - raise AtomicError( - f"Incorrect number of charge states for " f"{self.base_particle}" + raise ParticleError( + f"Incorrect number of charge states for {self.base_particle}" ) value = value.to(u.m ** -3) @@ -451,7 +576,7 @@ def number_densities(self, value: u.m ** -3): def T_e(self) -> u.K: """Return the electron temperature.""" if self._T_e is None: - raise AtomicError("No electron temperature has been specified.") + raise ParticleError("No electron temperature has been specified.") return self._T_e.to(u.K, equivalencies=u.temperature_energy()) @T_e.setter @@ -461,10 +586,10 @@ def T_e(self, value: u.K): try: value = value.to(u.K, equivalencies=u.temperature_energy()) except (AttributeError, u.UnitsError, u.UnitConversionError): - raise AtomicError("Invalid temperature.") from None + raise ParticleError("Invalid temperature.") from None else: if value < 0 * u.K: - raise AtomicError("T_e cannot be negative.") + raise ParticleError("T_e cannot be negative.") self._T_e = value @property @@ -497,7 +622,7 @@ def kappa(self, value: Real): @property def element(self) -> str: """Return the atomic symbol of the element.""" - return self._particle_instance.element + return self._particle.element @property def isotope(self) -> Optional[str]: @@ -505,7 +630,7 @@ def isotope(self) -> Optional[str]: Return the isotope symbol for an isotope, or `None` if the particle is not an isotope. """ - return self._particle_instance.isotope + return self._particle.isotope @property def base_particle(self) -> str: @@ -515,7 +640,7 @@ def base_particle(self) -> str: @property def atomic_number(self) -> int: """Return the atomic number of the element.""" - return self._particle_instance.atomic_number + return self._particle.atomic_number @property def _particle_instances(self) -> List[Particle]: @@ -524,7 +649,7 @@ def _particle_instances(self) -> List[Particle]: instances corresponding to each ion. """ return [ - Particle(self._particle_instance.particle, Z=i) + Particle(self._particle.particle, Z=i) for i in range(self.atomic_number + 1) ] @@ -572,7 +697,7 @@ def Z_most_abundant(self) -> List[Integral]: """ if np.any(np.isnan(self.ionic_fractions)): - raise AtomicError( + raise ParticleError( f"Cannot find most abundant ion of {self.base_particle} " f"because the ionic fractions have not been defined." ) @@ -600,12 +725,17 @@ def _get_states_info(self, minimum_ionic_fraction=0.01) -> List[str]: """ Return a `list` containing the ion symbol, ionic fraction, and (if available) the number density for that ion. + + Parameters + ---------- + minimum_ionic_fraction + The minimum ionic fraction to return state information for. """ states_info = [] for state in self: - if state.ionic_fraction > minimum_ionic_fraction: + if state.ionic_fraction >= minimum_ionic_fraction: state_info = "" symbol = state.ionic_symbol if state.integer_charge < 10: @@ -622,7 +752,7 @@ def _get_states_info(self, minimum_ionic_fraction=0.01) -> List[str]: return states_info - def info(self, minimum_ionic_fraction: Real = 0.01) -> None: + def summarize(self, minimum_ionic_fraction: Real = 0.01) -> None: """ Print quicklook information for an `~plasmapy.particles.IonizationState` instance. @@ -643,7 +773,7 @@ def info(self, minimum_ionic_fraction: Real = 0.01) -> None: ... kappa = 4.05, ... n_elem = 5.51e19 * u.m ** -3, ... ) - >>> He_states.info() + >>> He_states.summarize() IonizationState instance for He with Z_mean = 0.06 ---------------------------------------------------------------- He 0+: 0.941 n_i = 5.18e+19 m**-3 diff --git a/plasmapy/particles/ionization_states.py b/plasmapy/particles/ionization_state_collection.py similarity index 81% rename from plasmapy/particles/ionization_states.py rename to plasmapy/particles/ionization_state_collection.py index 60b2a74bf8..c7c03b9921 100644 --- a/plasmapy/particles/ionization_states.py +++ b/plasmapy/particles/ionization_state_collection.py @@ -2,7 +2,7 @@ A class for storing ionization state data for multiple elements or isotopes. """ -__all__ = ["IonizationStates"] +__all__ = ["IonizationStateCollection"] import astropy.units as u import collections @@ -12,14 +12,18 @@ from typing import Dict, List, Optional, Tuple, Union from plasmapy.particles.atomic import atomic_number -from plasmapy.particles.exceptions import AtomicError, ChargeError, InvalidParticleError -from plasmapy.particles.ionization_state import IonizationState, State -from plasmapy.particles.particle_class import Particle +from plasmapy.particles.exceptions import ( + ChargeError, + InvalidParticleError, + ParticleError, +) +from plasmapy.particles.ionization_state import IonicFraction, IonizationState +from plasmapy.particles.particle_class import Particle, particle_like from plasmapy.particles.symbols import particle_symbol from plasmapy.utils.decorators import validate_quantities -class IonizationStates: +class IonizationStateCollection: """ Describe the ionization state distributions of multiple elements or isotopes. @@ -34,52 +38,57 @@ class IonizationStates: with elements or isotopes as keys and `~astropy.units.Quantity` instances with units of number density. - abundances: `dict` or `str`, optional, keyword-only - The relative abundances of each element in the plasma. + abundances: `dict`, optional, keyword-only + A `dict` with `particle_like` elements or isotopes as keys and + the corresponding relative abundance as values. The values must + be positive real numbers. log_abundances: `dict`, optional, keyword-only - The base 10 logarithm of the relative abundances of each element - in the plasma. + A `dict` with `particle_like` elements or isotopes as keys and + the corresponding base 10 logarithms of their relative + abundances as values. The values must be real numbers. - n: ~astropy.units.Quantity, optional, keyword-only - The number density scaling factor. The number density of an - element will be the product of its abundance and ``n``. + n0: `~astropy.units.Quantity`, optional, keyword-only + 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`, optional, keyword-only The electron temperature in units of temperature or thermal energy per particle. - kappa: float, optional, keyword-only + kappa: `float`, optional, keyword-only The value of kappa for a kappa distribution function. - tol: float or integer, keyword-only, optional + tol: `float` or `integer`, optional, keyword-only The absolute tolerance used by `~numpy.isclose` when testing normalizations and making comparisons. Defaults to ``1e-15``. - equilibrate: `bool`, optional, keyword-only - Set the ionic fractions to the estimated collisional ionization - equilibrium. Not implemented. - Raises ------ - AtomicError - If `~plasmapy.particles.IonizationStates` cannot be instantiated. + `~plasmapy.particles.exceptions.ParticleError` + If `~plasmapy.particles.IonizationStateCollection` cannot be instantiated. + + See Also + -------- + ~plasmapy.particles.ionization_state.IonicFraction + ~plasmapy.particles.ionization_state.IonizationState Examples -------- >>> from astropy import units as u - >>> from plasmapy.particles import IonizationStates - >>> states = IonizationStates( + >>> from plasmapy.particles import IonizationStateCollection + >>> states = IonizationStateCollection( ... {'H': [0.5, 0.5], 'He': [0.95, 0.05, 0]}, ... T_e = 1.2e4 * u.K, - ... n = 1e15 * u.m ** -3, + ... 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 + the abundance and the number density scaling factor ``n0``. >>> states.number_densities['H'] @@ -88,7 +97,7 @@ class IonizationStates: To change the ionic fractions for a single element, use item assignment. - >>> states = IonizationStates(['H', 'He']) + >>> states = IonizationStateCollection(['H', 'He']) >>> states['H'] = [0.1, 0.9] Item assignment will also work if you supply number densities. @@ -101,25 +110,22 @@ class IonizationStates: Notes ----- - No more than one of ``abundances``, ``log_abundances``, and - ``number_densities`` may be specified. + 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.IonizationStates` + When making comparisons between `~plasmapy.particles.IonizationStateCollection` instances, `~numpy.nan` values are treated as equal. Equality tests are performed to within a tolerance of ``tol``. - - Collisional ionization equilibrium is based on atomic data that - has relative errors of order 20%. - """ - # TODO: The docstring above needs to be expanded and revised to - # TODO: better describe what the magic methods do. + # 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__( @@ -127,10 +133,9 @@ def __init__( inputs: Union[Dict[str, np.ndarray], List, Tuple], *, T_e: u.K = np.nan * u.K, - equilibrate: Optional[bool] = None, abundances: Optional[Dict[str, Real]] = None, log_abundances: Optional[Dict[str, Real]] = None, - n: u.m ** -3 = np.nan * u.m ** -3, + n0: u.m ** -3 = np.nan * u.m ** -3, tol: Real = 1e-15, kappa: Real = np.inf, ): @@ -147,11 +152,11 @@ def __init__( [fracs[0].si.unit == u.m ** -3 for fracs in inputs.values()] ) if not right_units: - raise AtomicError( + raise ParticleError( "Units must be inverse volume for number densities." ) if abundances_provided: - raise AtomicError( + raise ParticleError( "Abundances cannot be provided if inputs " "provides number density information." ) @@ -160,7 +165,7 @@ def __init__( try: self._pars = collections.defaultdict(lambda: None) self.T_e = T_e - self.n = n + self.n0 = n0 self.tol = tol self.ionic_fractions = inputs if set_abundances: @@ -168,20 +173,19 @@ def __init__( self.log_abundances = log_abundances self.kappa = kappa except Exception as exc: - raise AtomicError("Unable to create IonizationStates instance.") from exc - - if equilibrate: - self.equilibrate() # for now, this raises a NotImplementedError + raise ParticleError( + "Unable to create IonizationStateCollection object." + ) from exc def __str__(self) -> str: - return f"" + return f"" def __repr__(self) -> str: return self.__str__() - def __getitem__(self, *values) -> IonizationState: + def __getitem__(self, *values) -> Union[IonizationState, IonicFraction]: - errmsg = f"Invalid indexing for IonizationStates instance: {values[0]}" + errmsg = f"Invalid indexing for IonizationStateCollection instance: {values[0]}" one_input = not isinstance(values[0], tuple) two_inputs = len(values[0]) == 2 @@ -211,10 +215,9 @@ def __getitem__(self, *values) -> IonizationState: raise ChargeError( f"{int_charge} is not a valid charge for {particle}." ) - return State( - integer_charge=int_charge, + return IonicFraction( + ion=particle_symbol(particle, Z=int_charge), ionic_fraction=self.ionic_fractions[particle][int_charge], - ionic_symbol=particle_symbol(particle, Z=int_charge), number_density=self.number_densities[particle][int_charge], ) except Exception as exc: @@ -223,14 +226,14 @@ def __getitem__(self, *values) -> IonizationState: def __setitem__(self, key, value): errmsg = ( - f"Cannot set item for this IonizationStates instance for " + 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 (AtomicError, TypeError): + except (ParticleError, TypeError): raise KeyError( f"{errmsg} because {repr(key)} is an invalid particle." ) from None @@ -272,16 +275,16 @@ def __setitem__(self, key, value): 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.n) + n_is_defined = not np.isnan(self.n0) if abundance_is_undefined: if n_is_defined: - self._pars["abundances"][particle] = new_n_elem / self.n + self._pars["abundances"][particle] = new_n_elem / self.n0 elif all_abundances_are_nan: - self.n = new_n_elem + self.n0 = new_n_elem self._pars["abundances"][particle] = 1 else: - raise AtomicError( + 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 " @@ -322,30 +325,19 @@ def __setitem__(self, key, value): 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 " f"normalized to one." + f"{errmsg} because the ionic fractions are not normalized to one." ) self._ionic_fractions[particle][:] = new_fractions[:] def __iter__(self): """ - Prepare an `~plasmapy.particles.IonizationStates` instance for + Prepare an `~plasmapy.particles.IonizationStateCollection` instance for iteration. """ self._element_index = 0 return self - @property - def __ITER__(self): # coverage: ignore - """ - Recall that our code development guide states that there should - be at most one pun per 1284 lines of code. - """ - raise NotImplementedError( - "The International Thermonuclear Experimental Reactor " - "is still under construction." - ) - def __next__(self): if self._element_index < len(self.base_particles): particle = self.base_particles[self._element_index] @@ -364,15 +356,15 @@ def __next__(self): def __eq__(self, other): - if not isinstance(other, IonizationStates): + if not isinstance(other, IonizationStateCollection): raise TypeError( - "IonizationStates instance can only be compared with " - "other IonizationStates instances." + "IonizationStateCollection instance can only be compared with " + "other IonizationStateCollection instances." ) if self.base_particles != other.base_particles: - raise AtomicError( - "Two IonizationStates instances can be compared only " + raise ParticleError( + "Two IonizationStateCollection instances can be compared only " "if the base particles are the same." ) @@ -447,8 +439,8 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): Notes ----- The ionic fractions are initialized during instantiation of - `~plasmapy.particles.IonizationStates`. After this, the only way - to reset the ionic fractions via the ``ionic_fractions`` + `~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` @@ -457,10 +449,10 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): Raises ------ - AtomicError + `~plasmapy.particles.exceptions.ParticleError` If the ionic fractions cannot be set. - TypeError + `TypeError` If ``inputs`` is not a `list`, `tuple`, or `dict` during instantiation, or if ``inputs`` is not a `dict` when it is being set. @@ -473,8 +465,8 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): # 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 IonizationStates instance as a whole. An - # example of the problem is `s = IonizationStates(["He"])` being + # 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"): @@ -487,12 +479,12 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): new_particles = {particle_symbol(key) for key in inputs.keys()} missing_particles = old_particles - new_particles if missing_particles: - raise AtomicError( + 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 " - "IonizationStates instance instead." + "IonizationStateCollection instance instead." ) if isinstance(inputs, dict): @@ -510,8 +502,8 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): try: particles = {key: Particle(key) for key in original_keys} except (InvalidParticleError, TypeError) as exc: - raise AtomicError( - "Unable to create IonizationStates instance " + raise ParticleError( + "Unable to create IonizationStateCollection instance " "because not all particles are valid." ) from exc @@ -525,7 +517,7 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): ) if not is_element or has_charge_info: - raise AtomicError( + raise ParticleError( f"{key} is not an element or isotope without " f"charge information." ) @@ -553,12 +545,14 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): new_key = particles[key].particle _particle_instances.append(particles[key]) if new_key in _elements_and_isotopes: - raise AtomicError("Repeated particles in IonizationStates.") + raise ParticleError( + "Repeated particles in IonizationStateCollection." + ) nstates_input = len(inputs[key]) nstates = particles[key].atomic_number + 1 if nstates != nstates_input: - raise AtomicError( + raise ParticleError( f"The ionic fractions array for {key} must " f"have a length of {nstates}." ) @@ -573,7 +567,7 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): ) n_elems[key] = n_elem except u.UnitConversionError as exc: - raise AtomicError("Units are not inverse volume.") from exc + raise ParticleError("Units are not inverse volume.") from exc elif ( isinstance(inputs[key], np.ndarray) and inputs[key].dtype.kind == "f" @@ -585,7 +579,7 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): inputs[key], dtype=np.float ) except ValueError as exc: - raise AtomicError( + raise ParticleError( f"Inappropriate ionic fractions for {key}." ) from exc @@ -593,11 +587,11 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): fractions = new_ionic_fractions[key] if not np.all(np.isnan(fractions)): if np.min(fractions) < 0 or np.max(fractions) > 1: - raise AtomicError( + 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 AtomicError( + raise ParticleError( f"Ionic fractions for {key} are not normalized to 1." ) @@ -613,15 +607,15 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): # instantiation of the class. if inputs_have_quantities: - if np.isnan(self.n): + if np.isnan(self.n0): new_n = 0 * u.m ** -3 for key in _elements_and_isotopes: new_n += n_elems[key] - self.n = new_n + self.n0 = new_n new_abundances = {} for key in _elements_and_isotopes: - new_abundances[key] = np.float(n_elems[key] / self.n) + new_abundances[key] = np.float(n_elems[key] / self.n0) self._pars["abundances"] = new_abundances @@ -630,7 +624,9 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): try: _particle_instances = [Particle(particle) for particle in inputs] except (InvalidParticleError, TypeError) as exc: - raise AtomicError("Invalid inputs to IonizationStates.") from exc + raise ParticleError( + "Invalid inputs to IonizationStateCollection." + ) from exc _particle_instances.sort( key=lambda p: (p.atomic_number, p.mass_number if p.isotope else 0) @@ -653,7 +649,7 @@ def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): not _particle_instances[i - 1].isotope and _particle_instances[i].isotope ): - raise AtomicError( + raise ParticleError( "Cannot have an element and isotopes of that element." ) @@ -670,34 +666,6 @@ def normalize(self) -> None: tot = np.sum(self.ionic_fractions[particle]) self.ionic_fractions[particle] = self.ionic_fractions[particle] / tot - def equilibrate( - self, T_e: u.K = np.nan * u.K, particles: str = "all", kappa: Real = np.inf - ): - """ - Set the ionic fractions to collisional ionization equilibrium. - Not implemented. - - The electron temperature used to calculate the new equilibrium - ionic fractions will be the argument ``T_e`` to this method if - given, and otherwise the attribute ``T_e`` if no electon - temperature is provided to this method. - - Parameters - ---------- - T_e: ~astropy.units.Quantity, optional - The electron temperature. - - particles: `list`, `tuple`, or `str`, optional - The elements and isotopes to be equilibrated. If - ``particles`` is ``'all'`` (default), then all - elements and isotopes will be equilibrated. - - kappa: Real - The value of kappa for a kappa distribution for electrons. - - """ - raise NotImplementedError - @property @validate_quantities def n_e(self) -> u.m ** -3: @@ -716,22 +684,22 @@ def n_e(self) -> u.m ** -3: @property @validate_quantities - def n(self) -> u.m ** -3: + def n0(self) -> u.m ** -3: """Return the number density scaling factor.""" return self._pars["n"] - @n.setter + @n0.setter @validate_quantities - def n(self, n: u.m ** -3): + def n0(self, n: u.m ** -3): """Set the number density scaling factor.""" try: n = n.to(u.m ** -3) except u.UnitConversionError as exc: - raise AtomicError("Units cannot be converted to u.m ** -3.") from exc + raise ParticleError("Units cannot be converted to u.m ** -3.") from exc except Exception as exc: - raise AtomicError(f"{n} is not a valid number density.") from exc + raise ParticleError(f"{n} is not a valid number density.") from exc if n < 0 * u.m ** -3: - raise AtomicError("Number density cannot be negative.") + raise ParticleError("Number density cannot be negative.") self._pars["n"] = n.to(u.m ** -3) @property @@ -741,17 +709,17 @@ def number_densities(self) -> Dict[str, u.Quantity]: isotope. """ return { - elem: self.n * self.abundances[elem] * self.ionic_fractions[elem] + elem: self.n0 * self.abundances[elem] * self.ionic_fractions[elem] for elem in self.base_particles } @property - def abundances(self) -> Optional[Dict]: + def abundances(self) -> Optional[Dict[particle_like, Real]]: """Return the elemental abundances.""" return self._pars["abundances"] @abundances.setter - def abundances(self, abundances_dict: Optional[Dict]): + def abundances(self, abundances_dict: Optional[Dict[particle_like, Real]]): """ Set the elemental (or isotopic) abundances. The elements and isotopes must be the same as or a superset of the elements whose @@ -767,15 +735,15 @@ def abundances(self, abundances_dict: Optional[Dict]): ) else: old_keys = abundances_dict.keys() - new_keys_dict = {} - for old_key in old_keys: - try: + try: + new_keys_dict = {} + for old_key in old_keys: new_keys_dict[particle_symbol(old_key)] = old_key - except Exception: - raise AtomicError( - f"The key {repr(old_key)} in the abundances " - f"dictionary is not a valid element or isotope." - ) + except Exception: + raise ParticleError( + f"The key {repr(old_key)} in the abundances " + f"dictionary is not a valid element or isotope." + ) new_elements = new_keys_dict.keys() @@ -783,7 +751,7 @@ def abundances(self, abundances_dict: Optional[Dict]): new_elements_set = set(new_elements) if old_elements_set - new_elements_set: - raise AtomicError( + raise ParticleError( f"The abundances of the following particles are " f"missing: {old_elements_set - new_elements_set}" ) @@ -802,7 +770,7 @@ def abundances(self, abundances_dict: Optional[Dict]): ) from None if inputted_abundance < 0: - raise AtomicError(f"The abundance of {element} is negative.") + raise ParticleError(f"The abundance of {element} is negative.") new_abundances_dict[element] = inputted_abundance self._pars["abundances"] = new_abundances_dict @@ -831,7 +799,7 @@ def log_abundances(self, value: Optional[Dict[str, Real]]): new_abundances_input[key] = 10 ** value[key] self.abundances = new_abundances_input except Exception: - raise AtomicError("Invalid log_abundances.") from None + raise ParticleError("Invalid log_abundances.") from None @property @validate_quantities(equivalencies=u.temperature_energy()) @@ -848,11 +816,11 @@ def T_e(self, electron_temperature: u.K): u.K, equivalencies=u.temperature_energy() ) except (AttributeError, u.UnitsError): - raise AtomicError( + raise ParticleError( f"{electron_temperature} is not a valid temperature." ) from None if temperature < 0 * u.K: - raise AtomicError("The electron temperature cannot be negative.") + raise ParticleError("The electron temperature cannot be negative.") self._pars["T_e"] = temperature @property @@ -907,29 +875,29 @@ def tol(self, atol: Real): else: raise ValueError("Need 0 <= tol <= 1.") - def info(self, minimum_ionic_fraction: Real = 0.01) -> None: + def summarize(self, minimum_ionic_fraction: Real = 0.01) -> None: """ Print quicklook information for an - `~plasmapy.particles.IonizationStates` instance. + `~plasmapy.particles.IonizationStateCollection` instance. Parameters ---------- - minimum_ionic_fraction: Real + minimum_ionic_fraction: `Real` If the ionic fraction for a particular ionization state is below this level, then information for it will not be printed. Defaults to 0.01. Examples -------- - >>> states = IonizationStates( + >>> states = IonizationStateCollection( ... {'H': [0.1, 0.9], 'He': [0.95, 0.05, 0.0]}, ... T_e = 12000 * u.K, - ... n = 3e9 * u.cm ** -3, + ... n0 = 3e9 * u.cm ** -3, ... abundances = {'H': 1.0, 'He': 0.1}, ... kappa = 3.4, ... ) - >>> states.info() - IonizationStates instance for: H, He + >>> states.summarize() + IonizationStateCollection instance for: H, He ---------------------------------------------------------------- H 0+: 0.100 n_i = 3.00e+14 m**-3 H 1+: 0.900 n_i = 2.70e+15 m**-3 @@ -948,7 +916,7 @@ def info(self, minimum_ionic_fraction: Real = 0.01) -> None: output = [] output.append( - f"IonizationStates instance for: {', '.join(self.base_particles)}" + f"IonizationStateCollection instance for: {', '.join(self.base_particles)}" ) # Get the ionic symbol with the corresponding ionic fraction and diff --git a/plasmapy/particles/nuclear.py b/plasmapy/particles/nuclear.py index fe6e7034b3..d171b7b346 100644 --- a/plasmapy/particles/nuclear.py +++ b/plasmapy/particles/nuclear.py @@ -7,7 +7,7 @@ from typing import List, Optional, Union from plasmapy.particles.decorators import particle_input -from plasmapy.particles.exceptions import AtomicError, InvalidParticleError +from plasmapy.particles.exceptions import InvalidParticleError, ParticleError from plasmapy.particles.particle_class import Particle @@ -36,10 +36,10 @@ def nuclear_binding_energy( Raises ------ - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the inputs do not correspond to a valid particle. - `~plasmapy.utils.AtomicError` + `~plasmapy.particles.exceptions.ParticleError` If the inputs do not correspond to a valid isotope or nucleon. `TypeError` @@ -50,8 +50,8 @@ def nuclear_binding_energy( nuclear_reaction_energy : Return the change in binding energy during nuclear fusion or fission reactions. - mass_energy : Return the mass energy of a - nucleon or particle. + ~plasmapy.particles.nuclear.mass_energy : Return the mass energy of + a nucleon or particle. Examples -------- @@ -67,7 +67,6 @@ def nuclear_binding_energy( >>> after = nuclear_binding_energy("alpha") >>> (after - before).to(u.MeV) # released energy from D + T --> alpha + n - """ return particle.binding_energy.to(u.J) @@ -96,10 +95,10 @@ def mass_energy(particle: Particle, mass_numb: Optional[int] = None) -> u.Quanti Raises ------ - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the inputs do not correspond to a valid particle. - `~plasmapy.utils.AtomicError` + `~plasmapy.particles.exceptions.ParticleError` If the inputs do not correspond to a valid isotope or nucleon. `TypeError` @@ -110,7 +109,6 @@ def mass_energy(particle: Particle, mass_numb: Optional[int] = None) -> u.Quanti >>> mass_energy('He-4') - """ return particle.mass_energy @@ -146,7 +144,7 @@ def nuclear_reaction_energy(*args, **kwargs): Raises ------ - `AtomicError`: + `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. @@ -157,8 +155,7 @@ def nuclear_reaction_energy(*args, **kwargs): See Also -------- - nuclear_binding_energy : finds the binding energy - of an isotope + nuclear_binding_energy : finds the binding energy of an isotope Notes ----- @@ -185,7 +182,6 @@ def nuclear_reaction_energy(*args, **kwargs): >>> nuclear_reaction_energy(reactants=['n'], products=['p+', 'e-']) - """ # TODO: Allow for neutrinos, under the assumption that they have no mass. @@ -233,15 +229,15 @@ def process_particles_list( try: particle = Particle(item) except (InvalidParticleError) as exc: - raise AtomicError(errmsg) from exc + raise ParticleError(errmsg) from exc if particle.element and not particle.isotope: - raise AtomicError(errmsg) + raise ParticleError(errmsg) [particles.append(particle) for i in range(multiplier)] except Exception: - raise AtomicError( + raise ParticleError( f"{original_item} is not a valid reactant or " "product in a nuclear reaction." ) from None @@ -295,7 +291,7 @@ def add_mass_energy(particles: List[Particle]) -> u.Quantity: reactants_products_are_inputs = kwargs and not args and len(kwargs) == 2 if reaction_string_is_input == reactants_products_are_inputs: - raise AtomicError(input_err_msg) + raise ParticleError(input_err_msg) if reaction_string_is_input: @@ -304,7 +300,7 @@ def add_mass_energy(particles: List[Particle]) -> u.Quantity: if not isinstance(reaction, str): raise TypeError(input_err_msg) elif "->" not in reaction: - raise AtomicError( + raise ParticleError( f"The reaction '{reaction}' is missing a '->'" " or '-->' between the reactants and products." ) @@ -316,7 +312,7 @@ def add_mass_energy(particles: List[Particle]) -> u.Quantity: reactants = process_particles_list(LHS_list) products = process_particles_list(RHS_list) except Exception as ex: - raise AtomicError(f"{reaction} is not a valid nuclear reaction.") from ex + raise ParticleError(f"{reaction} is not a valid nuclear reaction.") from ex elif reactants_products_are_inputs: @@ -326,16 +322,16 @@ def add_mass_energy(particles: List[Particle]) -> u.Quantity: except TypeError as t: raise TypeError(input_err_msg) from t except Exception as e: - raise AtomicError(errmsg) from e + raise ParticleError(errmsg) from e if total_baryon_number(reactants) != total_baryon_number(products): - raise AtomicError( + raise ParticleError( "The baryon number is not conserved for " f"reactants = {reactants} and products = {products}." ) if total_charge(reactants) != total_charge(products): - raise AtomicError( + raise ParticleError( "Total charge is not conserved for reactants = " f"{reactants} and products = {products}." ) diff --git a/plasmapy/particles/parsing.py b/plasmapy/particles/parsing.py index 601fc1ecb6..3b81309915 100644 --- a/plasmapy/particles/parsing.py +++ b/plasmapy/particles/parsing.py @@ -20,9 +20,9 @@ _Elements, ) from plasmapy.particles.exceptions import ( - AtomicWarning, InvalidElementError, InvalidParticleError, + ParticleWarning, ) from plasmapy.particles.isotopes import _Isotopes from plasmapy.particles.special_particles import _Particles, ParticleZoo @@ -130,7 +130,7 @@ def _dealias_particle_aliases(alias: Union[str, Integral]) -> str: def _invalid_particle_errmsg(argument, mass_numb=None, Z=None): """ Return an appropriate error message for an - `~plasmapy.utils.InvalidParticleError`. + `~plasmapy.particles.exceptions.InvalidParticleError`. """ errmsg = f"The argument {repr(argument)} " if mass_numb is not None or Z is not None: @@ -179,28 +179,26 @@ def _parse_and_check_atomic_input( Raises ------ - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the arguments do not correspond to a valid particle or antiparticle. - `~plasmapy.utils.InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` If the particle is valid but does not correspond to an element, ion, or isotope. `TypeError` If the argument or any of the keyword arguments is not of the correct type. - """ def _atomic_number_to_symbol(atomic_numb: Integral): """ Return the atomic symbol associated with an integer representing an atomic number, or raises an - `~plasmapy.utils.InvalidParticleError` if the atomic number does + `~plasmapy.particles.exceptions.InvalidParticleError` if the atomic number does not represent a known element. """ - if atomic_numb in _atomic_numbers_to_symbols.keys(): return _atomic_numbers_to_symbols[atomic_numb] else: @@ -212,7 +210,7 @@ def _extract_charge(arg: str): Return a `tuple` containing a `str` that should represent an element or isotope, and either an `int` representing the charge or `None` if no charge information is provided. Raise - an `~plasmapy.utils.InvalidParticleError` if charge information + an `~plasmapy.particles.exceptions.InvalidParticleError` if charge information is inputted incorrectly. """ @@ -270,7 +268,7 @@ def _extract_mass_number(isotope_info: str): Return a tuple containing a string that should represent an element, and either an integer representing the mass number or None if no mass number is available. Raises an - `~plasmapy.utils.InvalidParticleError` if the mass number + `~plasmapy.particles.exceptions.InvalidParticleError` if the mass number information is inputted incorrectly. """ @@ -301,7 +299,6 @@ def _get_element(element_info: str) -> str: Receive a `str` representing an element's symbol or name, and returns a `str` representing the atomic symbol. """ - if element_info.lower() in _element_names_to_symbols.keys(): element = _element_names_to_symbols[element_info.lower()] elif element_info in _atomic_numbers_to_symbols.values(): @@ -311,7 +308,6 @@ def _get_element(element_info: str) -> str: f"The string '{element_info}' does not correspond to " f"a valid element." ) - return element def _reconstruct_isotope_symbol(element: str, mass_numb: Integral) -> str: @@ -319,7 +315,7 @@ def _reconstruct_isotope_symbol(element: str, mass_numb: Integral) -> str: Receive a `str` representing an atomic symbol and an `int` representing a mass number. Return the isotope symbol or `None` if no mass number information is available. Raises an - `~plasmapy.utils.InvalidParticleError` for isotopes that have + `~plasmapy.particles.exceptions.InvalidParticleError` for isotopes that have not yet been discovered. """ @@ -409,7 +405,7 @@ def _reconstruct_ion_symbol( warnings.warn( "Redundant mass number information for particle " f"'{argument}' with mass_numb = {mass_numb}.", - AtomicWarning, + ParticleWarning, ) if mass_numb_from_arg is not None: @@ -425,7 +421,7 @@ def _reconstruct_ion_symbol( warnings.warn( "Redundant charge information for particle " f"'{argument}' with Z = {Z}.", - AtomicWarning, + ParticleWarning, ) if Z_from_arg is not None: @@ -442,7 +438,7 @@ def _reconstruct_ion_symbol( f"Particle '{argument}' has an integer charge " f"of Z = {Z}, which is unlikely to occur in " f"nature.", - AtomicWarning, + ParticleWarning, ) isotope = _reconstruct_isotope_symbol(element, mass_numb) diff --git a/plasmapy/particles/particle_class.py b/plasmapy/particles/particle_class.py index 22403a3020..e0c0f241e9 100644 --- a/plasmapy/particles/particle_class.py +++ b/plasmapy/particles/particle_class.py @@ -5,6 +5,7 @@ "CustomParticle", "DimensionlessParticle", "Particle", + "particle_like", ] import astropy.constants as const @@ -21,15 +22,15 @@ from plasmapy.particles.elements import _Elements, _PeriodicTable from plasmapy.particles.exceptions import ( - AtomicError, - AtomicWarning, ChargeError, InvalidElementError, InvalidIonError, InvalidIsotopeError, InvalidParticleError, - MissingAtomicDataError, - MissingAtomicDataWarning, + MissingParticleDataError, + MissingParticleDataWarning, + ParticleError, + ParticleWarning, ) from plasmapy.particles.isotopes import _Isotopes from plasmapy.particles.parsing import ( @@ -91,9 +92,9 @@ def _category_errmsg(particle, category: str) -> str: """ Return an error message when an attribute raises an - `~plasmapy.utils.InvalidElementError`, - `~plasmapy.utils.InvalidIonError`, or - `~plasmapy.utils.InvalidIsotopeError`. + `~plasmapy.particles.exceptions.InvalidElementError`, + `~plasmapy.particles.exceptions.InvalidIonError`, or + `~plasmapy.particles.exceptions.InvalidIsotopeError`. """ article = "an" if category[0] in "aeiouAEIOU" else "a" errmsg = ( @@ -164,10 +165,10 @@ def json_dict(self) -> dict: def __bool__(self): """ - Raise an `~plasmapy.utils.AtomicError` because particles + Raise an `~plasmapy.particles.exceptions.ParticleError` because particles do not have a truth value. """ - raise AtomicError("The truth value of a particle is not defined.") + raise ParticleError("The truth value of a particle is not defined.") def json_dump(self, fp, **kwargs): """ @@ -224,27 +225,27 @@ class Particle(AbstractParticle): For when any of the arguments or keywords is not of the required type. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` Raised when the particle input does not correspond to a valid particle or is contradictory. - `~plasmapy.utils.InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` For when an attribute is being accessed that requires information about an element, but the particle is not an element, isotope, or ion. - `~plasmapy.utils.InvalidIsotopeError` + `~plasmapy.particles.exceptions.InvalidIsotopeError` For when an attribute is being accessed that requires information about an isotope or nuclide, but the particle is not an isotope (or an ion of an isotope). - `~plasmapy.utils.ChargeError` + `~plasmapy.particles.exceptions.ChargeError` For when either the `~plasmapy.particles.Particle.charge` or `~plasmapy.particles.Particle.integer_charge` attributes is being accessed but the charge information for the particle is not available. - `~plasmapy.utils.AtomicError` + `~plasmapy.particles.exceptions.ParticleError` Raised for attempts at converting a `~plasmapy.particles.Particle` object to a `bool`. @@ -437,7 +438,7 @@ def __init__( if mass_numb is not None or Z is not None: if particle == "p+" and (mass_numb == 1 or Z == 1): warnings.warn( - "Redundant mass number or charge information.", AtomicWarning + "Redundant mass number or charge information.", ParticleWarning ) else: raise InvalidParticleError( @@ -540,7 +541,6 @@ def __repr__(self) -> str: >>> lead = Particle('lead') >>> repr(lead) 'Particle("Pb")' - """ return f'Particle("{self.particle}")' @@ -562,7 +562,7 @@ def __eq__(self, other) -> bool: instance, then this method will raise a `TypeError`. If ``other.particle`` equals ``self.particle`` but the attributes differ, then this method will raise a - `~plasmapy.utils.AtomicError`. + `~plasmapy.particles.exceptions.ParticleError`. Examples -------- @@ -572,7 +572,6 @@ def __eq__(self, other) -> bool: False >>> electron == 'e-' True - """ if isinstance(other, str): try: @@ -615,7 +614,7 @@ def __eq__(self, other) -> bool: same_attributes = self._attributes == other._attributes if same_particle and not same_attributes: # coverage: ignore - raise AtomicError( + raise ParticleError( f"{self} and {other} should be the same Particle, but " f"have differing attributes.\n\n" f"The attributes of {self} are:\n\n{self._attributes}\n\n" @@ -638,8 +637,7 @@ def __ne__(self, other) -> bool: instance, then this method will raise a `TypeError`. If ``other.particle`` equals ``self.particle`` but the attributes differ, then this method will raise a - `~plasmapy.utils.AtomicError`. - + `~plasmapy.particles.exceptions.ParticleError`. """ return not self.__eq__(other) @@ -653,7 +651,7 @@ def __hash__(self) -> int: def __invert__(self): """ Return the corresponding antiparticle, or raise an - `~plasmapy.utils.AtomicError` if the particle is not an + `~plasmapy.particles.exceptions.ParticleError` if the particle is not an elementary particle. """ return self.antiparticle @@ -678,7 +676,6 @@ def json_dict(self) -> dict: 'module': 'plasmapy.particles.particle_class', 'date_created': '...', '__init__': {'args': ('e-',), 'kwargs': {}}}} - """ particle_dictionary = super().json_dict particle_dictionary["plasmapy_particle"]["__init__"]["args"] = (self.particle,) @@ -694,7 +691,6 @@ def particle(self) -> Optional[str]: >>> electron = Particle('electron') >>> electron.particle 'e-' - """ return self._attributes["particle"] @@ -702,7 +698,7 @@ def particle(self) -> Optional[str]: def antiparticle(self): """ Return the corresponding antiparticle, or raise an - `~plasmapy.utils.AtomicError` if the particle is not an + `~plasmapy.particles.exceptions.ParticleError` if the particle is not an elementary particle. This attribute may be accessed by using the unary operator ``~`` @@ -717,12 +713,11 @@ def antiparticle(self): >>> antineutron = Particle('antineutron') >>> ~antineutron Particle("n") - """ if self.particle in _antiparticles.keys(): return Particle(_antiparticles[self.particle]) else: - raise AtomicError( + raise ParticleError( "The unary operator can only be used for elementary " "particles and antiparticles." ) @@ -738,7 +733,6 @@ def element(self) -> Optional[str]: >>> alpha = Particle('alpha') >>> alpha.element 'He' - """ return self._attributes["element"] @@ -753,7 +747,6 @@ def isotope(self) -> Optional[str]: >>> alpha = Particle('alpha') >>> alpha.isotope 'He-4' - """ return self._attributes["isotope"] @@ -771,7 +764,6 @@ def ionic_symbol(self) -> Optional[str]: >>> hydrogen_atom = Particle('H', Z=0) >>> hydrogen_atom.ionic_symbol 'H 0+' - """ return self._attributes["ion"] @@ -793,7 +785,6 @@ def roman_symbol(self) -> Optional[str]: >>> hydrogen_atom = Particle('H', Z=0) >>> hydrogen_atom.roman_symbol 'H I' - """ if not self._attributes["element"]: return None @@ -811,7 +802,7 @@ def roman_symbol(self) -> Optional[str]: def element_name(self) -> str: """ Return the name of the element corresponding to this - particle, or raise an `~plasmapy.utils.InvalidElementError` if + particle, or raise an `~plasmapy.particles.exceptions.InvalidElementError` if the particle does not correspond to an element. Examples @@ -819,7 +810,6 @@ def element_name(self) -> str: >>> tritium = Particle('T') >>> tritium.element_name 'hydrogen' - """ if not self.element: raise InvalidElementError(_category_errmsg(self, "element")) @@ -833,9 +823,9 @@ def isotope_name(self) -> str: `None` otherwise. If the particle is not a valid element, then this - attribute will raise an `~plasmapy.utils.InvalidElementError`. + attribute will raise an `~plasmapy.particles.exceptions.InvalidElementError`. If it is not an isotope, then this attribute will raise an - `~plasmapy.utils.InvalidIsotopeError`. + `~plasmapy.particles.exceptions.InvalidIsotopeError`. Examples -------- @@ -845,7 +835,6 @@ def isotope_name(self) -> str: >>> iron_isotope = Particle("Fe-56", Z=16) >>> iron_isotope.isotope_name 'iron-56' - """ if not self.element: raise InvalidElementError(_category_errmsg(self.particle, "element")) @@ -866,7 +855,7 @@ def integer_charge(self) -> Integral: """ Return the particle's integer charge. - This attribute will raise a `~plasmapy.utils.ChargeError` if the + This attribute will raise a `~plasmapy.particles.exceptions.ChargeError` if the charge has not been specified. Examples @@ -874,7 +863,6 @@ def integer_charge(self) -> Integral: >>> muon = Particle('mu-') >>> muon.integer_charge -1 - """ if self._attributes["integer charge"] is None: raise ChargeError(f"The charge of particle {self} has not been specified.") @@ -885,7 +873,7 @@ def charge(self) -> u.Quantity: """ Return the particle's electron charge in coulombs. - This attribute will raise a `~plasmapy.utils.ChargeError` if the + This attribute will raise a `~plasmapy.particles.exceptions.ChargeError` if the charge has not been specified. Examples @@ -893,7 +881,6 @@ def charge(self) -> u.Quantity: >>> electron = Particle('e-') >>> electron.charge - """ if self._attributes["charge"] is None: raise ChargeError(f"The charge of particle {self} has not been specified.") @@ -908,23 +895,22 @@ def standard_atomic_weight(self) -> u.Quantity: Return an element's standard atomic weight in kg. If the particle is isotope or ion or not an element, this - attribute will raise an `~plasmapy.utils.InvalidElementError`. + attribute will raise an `~plasmapy.particles.exceptions.InvalidElementError`. If the element does not have a defined standard atomic weight, this attribute will raise a - `~plasmapy.utils.MissingAtomicDataError`. + `~plasmapy.particles.exceptions.MissingParticleDataError`. Examples -------- >>> oxygen = Particle('O') >>> oxygen.standard_atomic_weight - """ if self.isotope or self.is_ion or not self.element: raise InvalidElementError(_category_errmsg(self, "element")) if self._attributes["standard atomic weight"] is None: # coverage: ignore - raise MissingAtomicDataError( + raise MissingParticleDataError( f"The standard atomic weight of {self} is unavailable." ) return self._attributes["standard atomic weight"].to(u.kg) @@ -935,9 +921,9 @@ def nuclide_mass(self) -> u.Quantity: Return the mass of the bare nucleus of an isotope or a neutron. This attribute will raise a - `~plasmapy.utils.InvalidIsotopeError` if the particle is not an + `~plasmapy.particles.exceptions.InvalidIsotopeError` if the particle is not an isotope or neutron, or a - `~plasmapy.utils.MissingAtomicDataError` if the isotope mass is + `~plasmapy.particles.exceptions.MissingParticleDataError` if the isotope mass is not available. Examples @@ -945,7 +931,6 @@ def nuclide_mass(self) -> u.Quantity: >>> deuterium = Particle('D') >>> deuterium.nuclide_mass - """ if self.isotope == "H-1": @@ -963,7 +948,7 @@ def nuclide_mass(self) -> u.Quantity: base_mass = self._attributes["isotope mass"] if base_mass is None: # coverage: ignore - raise MissingAtomicDataError( + raise MissingParticleDataError( f"The mass of a {self.isotope} nuclide is not available." ) @@ -995,7 +980,7 @@ def mass(self) -> u.Quantity: If the mass is unavailable (e.g., for neutrinos or elements with no standard atomic weight), then this attribute will raise a - `~plasmapy.utils.MissingAtomicDataError`. + `~plasmapy.particles.exceptions.MissingParticleDataError`. Examples -------- @@ -1007,7 +992,6 @@ def mass(self) -> u.Quantity: >>> Particle('alpha').mass - """ if self._attributes["mass"] is not None: @@ -1021,7 +1005,7 @@ def mass(self) -> u.Quantity: base_mass = self._attributes["standard atomic weight"] if base_mass is None: - raise MissingAtomicDataError( + raise MissingParticleDataError( f"The mass of ion '{self.ionic_symbol}' is not available." ) @@ -1039,7 +1023,7 @@ def mass(self) -> u.Quantity: if mass is not None: return mass.to(u.kg) - raise MissingAtomicDataError(f"The mass of {self} is not available.") + raise MissingParticleDataError(f"The mass of {self} is not available.") @property def nuclide_mass(self) -> u.Quantity: @@ -1047,9 +1031,9 @@ def nuclide_mass(self) -> u.Quantity: Return the mass of the bare nucleus of an isotope or a neutron. This attribute will raise a - `~plasmapy.utils.InvalidIsotopeError` if the particle is not an + `~plasmapy.particles.exceptions.InvalidIsotopeError` if the particle is not an isotope or neutron, or a - `~plasmapy.utils.MissingAtomicDataError` if the isotope mass is + `~plasmapy.particles.exceptions.MissingParticleDataError` if the isotope mass is not available. Examples @@ -1057,7 +1041,6 @@ def nuclide_mass(self) -> u.Quantity: >>> deuterium = Particle('D') >>> deuterium.nuclide_mass - """ if self.isotope == "H-1": @@ -1075,7 +1058,7 @@ def nuclide_mass(self) -> u.Quantity: base_mass = self._attributes["isotope mass"] if base_mass is None: # coverage: ignore - raise MissingAtomicDataError( + raise MissingParticleDataError( f"The mass of a {self.isotope} nuclide is not available." ) @@ -1094,7 +1077,7 @@ def mass_energy(self) -> u.Quantity: of the nucleus only. If the mass of the particle is not known, then raise a - `~plasmapy.utils.MissingAtomicDataError`. + `~plasmapy.particles.exceptions.MissingParticleDataError`. Examples -------- @@ -1109,14 +1092,13 @@ def mass_energy(self) -> u.Quantity: >>> electron = Particle('electron') >>> electron.mass_energy.to('MeV') - """ try: mass = self.nuclide_mass if self.isotope else self.mass energy = mass * const.c ** 2 return energy.to(u.J) - except MissingAtomicDataError: - raise MissingAtomicDataError( + except MissingParticleDataError: + raise MissingParticleDataError( f"The mass energy of {self.particle} is not available " f"because the mass is unknown." ) from None @@ -1127,7 +1109,7 @@ def binding_energy(self) -> u.Quantity: Return the nuclear binding energy in joules. This attribute will raise an - `~plasmapy.utils.InvalidIsotopeError` if the particle is not a + `~plasmapy.particles.exceptions.InvalidIsotopeError` if the particle is not a nucleon or isotope. Examples @@ -1146,7 +1128,6 @@ def binding_energy(self) -> u.Quantity: >>> proton.binding_energy - """ if self._attributes["baryon number"] == 1: @@ -1176,7 +1157,7 @@ def atomic_number(self) -> Integral: Return the number of protons in an element, isotope, or ion. If the particle is not an element, then this attribute will - raise an `~plasmapy.utils.InvalidElementError`. + raise an `~plasmapy.particles.exceptions.InvalidElementError`. Examples -------- @@ -1186,7 +1167,6 @@ def atomic_number(self) -> Integral: >>> curium = Particle('Cm') >>> curium.atomic_number 96 - """ if not self.element: raise InvalidElementError(_category_errmsg(self, "element")) @@ -1201,14 +1181,13 @@ def mass_number(self) -> Integral: of neutrons in an isotope or nuclide. If the particle is not an isotope, then this attribute will - raise an `~plasmapy.utils.InvalidIsotopeError`. + raise an `~plasmapy.particles.exceptions.InvalidIsotopeError`. Examples -------- >>> alpha = Particle('helium-4 2+') >>> alpha.mass_number 4 - """ if not self.isotope: raise InvalidIsotopeError(_category_errmsg(self, "isotope")) @@ -1223,7 +1202,7 @@ def neutron_number(self) -> Integral: or ``1`` for a neutron. If this particle is not an isotope or neutron, then this - attribute will raise an `~plasmapy.utils.InvalidIsotopeError`. + attribute will raise an `~plasmapy.particles.exceptions.InvalidIsotopeError`. Examples -------- @@ -1232,7 +1211,6 @@ def neutron_number(self) -> Integral: 2 >>> Particle('n').neutron_number 1 - """ if self.particle == "n": return 1 @@ -1250,7 +1228,7 @@ def electron_number(self) -> Integral: ion, or ``1`` for an electron. If this particle is not an ion or electron, then this attribute - will raise an `~plasmapy.utils.InvalidIonError`. + will raise an `~plasmapy.particles.exceptions.InvalidIonError`. Examples -------- @@ -1258,7 +1236,6 @@ def electron_number(self) -> Integral: 3 >>> Particle('e-').electron_number 1 - """ if self.particle == "e-": return 1 @@ -1273,16 +1250,15 @@ def isotopic_abundance(self) -> u.Quantity: Return the isotopic abundance of an isotope. If the isotopic abundance is not available, this attribute will - raise a `~plasmapy.utils.MissingAtomicDataError`. If the + raise a `~plasmapy.particles.exceptions.MissingParticleDataError`. If the particle is not an isotope or is an ion of an isotope, then this - attribute will raise an `~plasmapy.utils.InvalidIsotopeError`. + attribute will raise an `~plasmapy.particles.exceptions.InvalidIsotopeError`. Examples -------- >>> D = Particle('deuterium') >>> D.isotopic_abundance 0.000115 - """ from .atomic import common_isotopes @@ -1295,7 +1271,7 @@ def isotopic_abundance(self) -> u.Quantity: warnings.warn( f"No isotopes of {self.element} have an isotopic abundance. " f"The isotopic abundance of {self.isotope} is being returned as 0.0", - AtomicWarning, + ParticleWarning, ) return abundance @@ -1310,17 +1286,16 @@ def baryon_number(self) -> Integral: number is equivalent to the mass number for isotopes. If the baryon number is unavailable, then this attribute will - raise a `~plasmapy.utils.MissingAtomicDataError`. + raise a `~plasmapy.particles.exceptions.MissingParticleDataError`. Examples -------- >>> alpha = Particle('alpha') >>> alpha.baryon_number 4 - """ if self._attributes["baryon number"] is None: # coverage: ignore - raise MissingAtomicDataError( + raise MissingParticleDataError( f"The baryon number for '{self.particle}' is not available." ) return self._attributes["baryon number"] @@ -1335,7 +1310,7 @@ def lepton_number(self) -> Integral: antileptons, excluding bound electrons in an atom or ion. If the lepton number is unavailable, then this attribute will - raise a `~plasmapy.utils.MissingAtomicDataError`. + raise a `~plasmapy.particles.exceptions.MissingParticleDataError`. Examples -------- @@ -1345,10 +1320,9 @@ def lepton_number(self) -> Integral: -1 >>> Particle('He-4 0+').lepton_number 0 - """ if self._attributes["lepton number"] is None: # coverage: ignore - raise MissingAtomicDataError( + raise MissingParticleDataError( f"The lepton number for {self.particle} is not available." ) return self._attributes["lepton number"] @@ -1362,14 +1336,13 @@ def half_life(self) -> Union[u.Quantity, str]: Particles that do not have sufficiently well-constrained half-lives will return a `str` containing the information that is available about the half-life and issue a - `~plasmapy.utils.MissingAtomicDataWarning`. + `~plasmapy.particles.exceptions.MissingParticleDataWarning`. Examples -------- >>> neutron = Particle('n') >>> neutron.half_life - """ if self.element and not self.isotope: raise InvalidIsotopeError(_category_errmsg(self.particle, "isotope")) @@ -1378,11 +1351,11 @@ def half_life(self) -> Union[u.Quantity, str]: warnings.warn( f"The half-life for {self.particle} is not known precisely; " "returning string with estimated value.", - MissingAtomicDataWarning, + MissingParticleDataWarning, ) if self._attributes["half-life"] is None: - raise MissingAtomicDataError( + raise MissingParticleDataError( f"The half-life of '{self.particle}' is not available." ) return self._attributes["half-life"] @@ -1393,17 +1366,16 @@ def spin(self) -> Real: Return the spin of the particle. If the spin is unavailable, then a - `~plasmapy.utils.MissingAtomicDataError` will be raised. + `~plasmapy.particles.exceptions.MissingParticleDataError` will be raised. Examples -------- >>> positron = Particle('e+') >>> positron.spin 0.5 - """ if self._attributes["spin"] is None: - raise MissingAtomicDataError( + raise MissingParticleDataError( f"The spin of particle '{self.particle}' is unavailable." ) @@ -1416,7 +1388,7 @@ def periodic_table(self) -> namedtuple: group, and block information about an element. If the particle is not an element, isotope, or ion, then this - attribute will raise an `~plasmapy.utils.InvalidElementError`. + attribute will raise an `~plasmapy.particles.exceptions.InvalidElementError`. Examples -------- @@ -1429,7 +1401,6 @@ def periodic_table(self) -> namedtuple: 11 >>> gold.periodic_table.block 'd' - """ if self.element: return self._attributes["periodic table"] @@ -1506,7 +1477,7 @@ def become_set(arg: Union[str, Set, Tuple, List]) -> Set[str]: return set(arg) if category_tuple and require: # coverage: ignore - raise AtomicError( + raise ParticleError( "No positional arguments are allowed if the `require` keyword " "is set in is_category." ) @@ -1530,7 +1501,7 @@ def become_set(arg: Union[str, Set, Tuple, List]) -> Set[str]: for problem_categories, adjective in categories_and_adjectives: if problem_categories: - raise AtomicError( + raise ParticleError( f"The following categories in {self.__repr__()}" f".is_category are {adjective}: {problem_categories}" ) @@ -1871,7 +1842,7 @@ def mass(self, m: Optional[Union[Real, u.Quantity]]): ) from None if self._mass is np.nan: warnings.warn( - "DimensionlessParticle mass set to NaN", MissingAtomicDataWarning + "DimensionlessParticle mass set to NaN", MissingParticleDataWarning ) @charge.setter @@ -1885,7 +1856,7 @@ def charge(self, q: Optional[Union[Real, u.Quantity]]): ) from None if self._charge is np.nan: warnings.warn( - "DimensionlessParticle charge set to NaN", MissingAtomicDataWarning + "DimensionlessParticle charge set to NaN", MissingParticleDataWarning ) @@ -1998,7 +1969,9 @@ def charge(self) -> u.C: def mass(self, m: u.kg): if m is None: m = np.nan * u.kg - warnings.warn("CustomParticle mass set to NaN kg", MissingAtomicDataWarning) + warnings.warn( + "CustomParticle mass set to NaN kg", MissingParticleDataWarning + ) elif isinstance(m, str): m = u.Quantity(m) elif not isinstance(m, u.Quantity): @@ -2028,7 +2001,7 @@ def charge(self, q: Optional[Union[u.Quantity, Real]]): if q is None: q = np.nan * u.C warnings.warn( - "CustomParticle charge set to NaN C", MissingAtomicDataWarning + "CustomParticle charge set to NaN C", MissingParticleDataWarning ) elif isinstance(q, str): q = u.Quantity(q) @@ -2061,3 +2034,9 @@ def charge(self, q: Optional[Union[u.Quantity, Real]]): "a real number that represents the ratio of the charge to " "the elementary charge." ) + + +# TODO: Describe valid particle representations in docstring of particle_like + +particle_like = Union[str, Integral, Particle] +"""A typing construct for valid representations of a particle.""" diff --git a/plasmapy/particles/symbols.py b/plasmapy/particles/symbols.py index c023cabdcc..d9763c3062 100644 --- a/plasmapy/particles/symbols.py +++ b/plasmapy/particles/symbols.py @@ -5,8 +5,9 @@ __all__ = [ "atomic_symbol", "element_name", - "isotope_symbol", "ionic_symbol", + "isotope_symbol", + "particle_symbol", ] from numbers import Integral @@ -43,10 +44,10 @@ def atomic_symbol(element: Particle) -> str: Raises ------ - `~plasmapy.utils.InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` @@ -54,7 +55,10 @@ def atomic_symbol(element: Particle) -> str: See Also -------- - element_name, isotope_symbol, ionic_symbol, particle_symbol + element_name + isotope_symbol + ionic_symbol + particle_symbol Notes ----- @@ -88,7 +92,6 @@ def atomic_symbol(element: Particle) -> str: 'N' >>> atomic_symbol('P'), atomic_symbol('p') # Phosphorus, proton ('P', 'H') - """ return element.element @@ -116,10 +119,10 @@ def isotope_symbol(isotope: Particle, mass_numb: Optional[Integral] = None) -> s Raises ------ - `~plasmapy.utils.InvalidIsotopeError` + `~plasmapy.particles.exceptions.InvalidIsotopeError` If the argument is a valid particle but not a valid isotope. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle or contradictory information is provided. @@ -128,12 +131,14 @@ def isotope_symbol(isotope: Particle, mass_numb: Optional[Integral] = None) -> s Warns ----- - `~plasmapy.utils.AtomicWarning` + `~plasmapy.particles.exceptions.ParticleWarning` If redundant isotope information is provided. See Also -------- - atomic_symbol, ionic_symbol, particle_symbol + atomic_symbol + ionic_symbol + particle_symbol Examples -------- @@ -147,7 +152,6 @@ def isotope_symbol(isotope: Particle, mass_numb: Optional[Integral] = None) -> s 'C-13' >>> isotope_symbol('alpha') 'He-4' - """ return isotope.isotope @@ -179,11 +183,11 @@ def ionic_symbol( Raises ------ - `~plasmapy.utils.InvalidIonError` + `~plasmapy.particles.exceptions.InvalidIonError` If the arguments correspond to a valid particle but not a valid ion or neutral charged particle. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If arguments do not correspond to a valid particle or contradictory information is provided. @@ -194,12 +198,14 @@ def ionic_symbol( Warns ----- - `~plasmapy.utils.AtomicWarning` + `~plasmapy.particles.exceptions.ParticleWarning` If redundant mass number or charge information is provided. See Also -------- - atomic_symbol, isotope_symbol, particle_symbol + atomic_symbol + isotope_symbol + particle_symbol Examples -------- @@ -213,7 +219,6 @@ def ionic_symbol( 'D 1+' >>> ionic_symbol('H-1', Z=0) 'H-1 0+' - """ return particle.ionic_symbol @@ -248,7 +253,7 @@ def particle_symbol( Raises ------ - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If arguments do not correspond to a valid particle or contradictory information is provided. @@ -259,12 +264,14 @@ def particle_symbol( Warns ----- - `~plasmapy.utils.AtomicWarning` + `~plasmapy.particles.exceptions.ParticleWarning` If redundant mass number or charge information is provided. See Also -------- - atomic_symbol, isotope_symbol, ionic_symbol + atomic_symbol + isotope_symbol + ionic_symbol Examples -------- @@ -276,7 +283,6 @@ def particle_symbol( 'He-4 2+' >>> particle_symbol('H-1', Z=-1) 'H-1 1-' - """ return particle.particle @@ -299,10 +305,10 @@ def element_name(element: Particle) -> str: Raises ------ - `~plasmapy.utils.InvalidElementError` + `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. - `~plasmapy.utils.InvalidParticleError` + `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` @@ -310,7 +316,10 @@ def element_name(element: Particle) -> str: See Also -------- - atomic_symbol, isotope_symbol, ionic_symbol, particle_symbol + atomic_symbol + isotope_symbol + ionic_symbol + particle_symbol Examples -------- @@ -324,6 +333,5 @@ def element_name(element: Particle) -> str: 'molybdenum' >>> element_name("C-12") 'carbon' - """ return element.element_name diff --git a/plasmapy/particles/tests/test_atomic.py b/plasmapy/particles/tests/test_atomic.py index 3bccb05fc9..635279d7e3 100644 --- a/plasmapy/particles/tests/test_atomic.py +++ b/plasmapy/particles/tests/test_atomic.py @@ -5,13 +5,13 @@ from astropy import units as u from plasmapy.particles.exceptions import ( - AtomicError, - AtomicWarning, ChargeError, InvalidElementError, InvalidIsotopeError, InvalidParticleError, - MissingAtomicDataError, + MissingParticleDataError, + ParticleError, + ParticleWarning, ) from plasmapy.utils.pytest_helpers import run_test @@ -154,13 +154,13 @@ ["h-3", {}, InvalidParticleError], ["h", {}, InvalidParticleError], ["d+", {}, InvalidParticleError], - ["H-1", {"mass_numb": 1}, AtomicWarning], - ["H-2", {"mass_numb": 2}, AtomicWarning], - ["T", {"mass_numb": 3}, AtomicWarning], - ["Li-6", {"mass_numb": 6}, AtomicWarning], - ["lithium-6", {"mass_numb": 6}, AtomicWarning], - ["alpha", {"mass_numb": 4}, AtomicWarning], - ["p", {"mass_numb": 1}, AtomicWarning], + ["H-1", {"mass_numb": 1}, ParticleWarning], + ["H-2", {"mass_numb": 2}, ParticleWarning], + ["T", {"mass_numb": 3}, ParticleWarning], + ["Li-6", {"mass_numb": 6}, ParticleWarning], + ["lithium-6", {"mass_numb": 6}, ParticleWarning], + ["alpha", {"mass_numb": 4}, ParticleWarning], + ["p", {"mass_numb": 1}, ParticleWarning], ] atomic_number_table = [ @@ -263,17 +263,17 @@ [1, (1.008 * u.u).to(u.kg)], ["Hydrogen", (1.008 * u.u).to(u.kg)], ["Au", u.kg], - ["H-1", AtomicError], + ["H-1", ParticleError], ["help i'm trapped in a unit test", InvalidParticleError], [1.1, TypeError], ["n", InvalidElementError], - ["p", AtomicError], - ["alpha", AtomicError], - ["deuteron", AtomicError], - ["tritium", AtomicError], - ["Au+", AtomicError], - ["Fe -2", AtomicError], - ["Og 2+", AtomicError], + ["p", ParticleError], + ["alpha", ParticleError], + ["deuteron", ParticleError], + ["tritium", ParticleError], + ["Au+", ParticleError], + ["Fe -2", ParticleError], + ["Og 2+", ParticleError], ["h", InvalidParticleError], ["fe", InvalidParticleError], ] @@ -287,16 +287,16 @@ ["hydrogen-1", {"Z": 1}, const.m_p], ["p+", const.m_p], ["F-19", {"Z": 3}, u.kg], - ["Og 1+", {}, MissingAtomicDataError], + ["Og 1+", {}, MissingParticleDataError], ["Fe-56", {"Z": 1.4}, TypeError], ["H-1 +1", {"Z": 0}, InvalidParticleError], [26, {"Z": 1, "mass_numb": "a"}, TypeError], [26, {"Z": 27, "mass_numb": 56}, InvalidParticleError], - ["Og", {"Z": 1}, MissingAtomicDataError], + ["Og", {"Z": 1}, MissingParticleDataError], ["Og", {"mass_numb": 696, "Z": 1}, InvalidParticleError], ["He 1+", {"mass_numb": 99}, InvalidParticleError], ["fe-56 1+", {}, InvalidParticleError], - ["H-1", {"mass_numb": 1, "Z": 1}, AtomicWarning], + ["H-1", {"mass_numb": 1, "Z": 1}, ParticleWarning], ["H", standard_atomic_weight("H")], ] @@ -363,9 +363,9 @@ ["d+", InvalidParticleError], ["Fe 29+", InvalidParticleError], ["H-1", ChargeError], - ["H---", AtomicWarning], - ["Fe -26", AtomicWarning], - ["Og 10-", AtomicWarning], + ["H---", ParticleWarning], + ["Fe -26", ParticleWarning], + ["Og 10-", ParticleWarning], ] electric_charge_table = [ @@ -377,8 +377,8 @@ ["badinput", InvalidParticleError], ["h+", InvalidParticleError], ["Au 81+", InvalidParticleError], - ["Au 81-", AtomicWarning], - ["H---", AtomicWarning], + ["Au 81-", ParticleWarning], + ["H---", ParticleWarning], ] half_life_table = [["H-1", u.s], ["tritium", u.s], ["H-1", np.inf * u.s]] @@ -628,7 +628,8 @@ def test_half_life_unstable_isotopes(): "half_life" not in _Isotopes[isotope].keys() and not _Isotopes[isotope].keys() ): - with pytest.raises(MissingAtomicDataError): + with pytest.raises(MissingParticleDataError): + half_life(isotope) @@ -638,7 +639,7 @@ def test_half_life_u_220(): isotope_without_half_life_data = "No-248" - with pytest.raises(MissingAtomicDataError): + with pytest.raises(MissingParticleDataError): half_life(isotope_without_half_life_data) pytest.fail( f"This test assumes that {isotope_without_half_life_data} does " @@ -714,7 +715,7 @@ def test_isotopic_abundance(): assert isotopic_abundance("Be-8") == 0.0, "Be-8" assert isotopic_abundance("Li-8") == 0.0, "Li-8" - with pytest.warns(AtomicWarning): + with pytest.warns(ParticleWarning): isotopic_abundance("Og", 294) with pytest.raises(InvalidIsotopeError): @@ -758,7 +759,7 @@ def test_incorrect_units(self): reduced_mass("N", 6e-26 * u.l) def test_missing_atomic_data(self): - with pytest.raises(MissingAtomicDataError): + with pytest.raises(MissingParticleDataError): reduced_mass("Og", "H") diff --git a/plasmapy/particles/tests/test_decorators.py b/plasmapy/particles/tests/test_decorators.py index 662988a574..77949a4704 100644 --- a/plasmapy/particles/tests/test_decorators.py +++ b/plasmapy/particles/tests/test_decorators.py @@ -3,12 +3,12 @@ from typing import List, Optional, Tuple, Union from plasmapy.particles.exceptions import ( - AtomicError, ChargeError, InvalidElementError, InvalidIonError, InvalidIsotopeError, InvalidParticleError, + ParticleError, ) from ..decorators import particle_input @@ -68,12 +68,12 @@ def test_particle_input_simple(func, args, kwargs, symbol): try: expected = Particle(symbol) except Exception as e: - raise AtomicError(f"Cannot create Particle class from symbol {symbol}") from e + raise ParticleError(f"Cannot create Particle class from symbol {symbol}") from e try: result = func(*args, **kwargs) except Exception as e: - raise AtomicError( + raise ParticleError( f"An exception was raised while trying to execute " f"{func} with args = {args} and kwargs = {kwargs}." ) from e @@ -130,12 +130,12 @@ def test_particle_input_classes(): try: result_noparens = instance.method_noparens(symbol) except Exception as e: - raise AtomicError("Problem with method_noparens") from e + raise ParticleError("Problem with method_noparens") from e try: result_parens = instance.method_parens(symbol) except Exception as e: - raise AtomicError("Problem with method_parens") from e + raise ParticleError("Problem with method_parens") from e assert result_parens == result_noparens == expected @@ -149,8 +149,8 @@ def function_with_no_annotations(): def test_no_annotations_exception(): """Test that a function decorated with particle_input that has no - annotated arguments will raise an AtomicError.""" - with pytest.raises(AtomicError): + annotated arguments will raise an ParticleError.""" + with pytest.raises(ParticleError): function_with_no_annotations() @@ -164,11 +164,11 @@ def ambiguous_keywords(p1: Particle, p2: Particle, Z=None, mass_numb=None): def test_function_with_ambiguity(): """Test that a function decorated with particle_input that has two annotated arguments""" - with pytest.raises(AtomicError): + with pytest.raises(ParticleError): ambiguous_keywords("H", "He", Z=1, mass_numb=4) - with pytest.raises(AtomicError): + with pytest.raises(ParticleError): ambiguous_keywords("H", "He", Z=1) - with pytest.raises(AtomicError): + with pytest.raises(ParticleError): ambiguous_keywords("H", "He", mass_numb=4) # TODO: should particle_input raise an exception when Z and mass_numb # are given as keyword arguments but are not explicitly set? @@ -207,7 +207,7 @@ def function_to_test_annotations(particles: Union[Tuple, List], resulting_partic ) if not returned_particle_instances: - raise AtomicError( + raise ParticleError( f"A function decorated by particle_input did not return " f"a collection of Particle instances for input of " f"{repr(particles)}, and instead returned" @@ -215,7 +215,7 @@ def function_to_test_annotations(particles: Union[Tuple, List], resulting_partic ) if not returned_correct_instances: - raise AtomicError( + raise ParticleError( f"A function decorated by particle_input did not return " f"{repr(expected)} as expected, and instead returned " f"{repr(resulting_particles)}." @@ -242,7 +242,7 @@ def test_tuple_annotation(particles: Union[Tuple, List]): particles, "ignore", x="ignore" ) except Exception as exc2: - raise AtomicError( + raise ParticleError( f"Unable to evaluate a function decorated by particle_input" f" with an annotation of (Particle, Particle) for inputs of" f" {repr(particles)}." @@ -271,7 +271,7 @@ def test_list_annotation(particles: Union[Tuple, List]): particles, "ignore", x="ignore" ) except Exception as exc2: - raise AtomicError( + raise ParticleError( f"Unable to evaluate a function decorated by particle_input" f" with an annotation of [Particle] for inputs of" f" {repr(particles)}." @@ -357,10 +357,10 @@ def take_particle(particle: Particle): # decorator_kwargs, particle, expected_exception decorator_categories_table = [ - ({"exclude": {"element"}}, "Fe", AtomicError), + ({"exclude": {"element"}}, "Fe", ParticleError), ({"any_of": {"lepton", "antilepton"}}, "tau-", None), ({"require": {"isotope", "ion"}}, "Fe-56+", None), - ({"require": {"isotope", "ion"}}, "Fe+", AtomicError), + ({"require": {"isotope", "ion"}}, "Fe+", ParticleError), ({"any_of": {"isotope", "ion"}}, "Fe+", None), ({"any_of": {"charged", "uncharged"}}, "Fe", ChargeError), ({"any_of": ["charged", "uncharged"]}, "Fe", ChargeError), @@ -391,7 +391,7 @@ def take_particle(particle: Particle): "exclude": ["matter"], }, "p+", - AtomicError, + ParticleError, ), ] @@ -401,8 +401,8 @@ def take_particle(particle: Particle): ) def test_decorator_categories(decorator_kwargs, particle, expected_exception): """Tests the require, any_of, and exclude categories lead to an - AtomicError being raised when an inputted particle does not meet - the required criteria, and do not lead to an AtomicError when the + ParticleError being raised when an inputted particle does not meet + the required criteria, and do not lead to an ParticleError when the inputted particle matches the criteria.""" @particle_input(**decorator_kwargs) diff --git a/plasmapy/particles/tests/test_ionization_states.py b/plasmapy/particles/tests/test_ionization_collection.py similarity index 87% rename from plasmapy/particles/tests/test_ionization_states.py rename to plasmapy/particles/tests/test_ionization_collection.py index 90dd748090..d1711adc2b 100644 --- a/plasmapy/particles/tests/test_ionization_states.py +++ b/plasmapy/particles/tests/test_ionization_collection.py @@ -9,14 +9,14 @@ from plasmapy.particles import ( atomic_number, + IonicFraction, IonizationState, - IonizationStates, + IonizationStateCollection, mass_number, Particle, particle_symbol, - State, ) -from plasmapy.particles.exceptions import AtomicError, InvalidIsotopeError +from plasmapy.particles.exceptions import InvalidIsotopeError, ParticleError from plasmapy.utils.pytest_helpers import run_test @@ -65,22 +65,22 @@ def has_attribute(attribute, tests_dict): }, "just H": {"inputs": {"H": [0.1, 0.9]}}, "H acceptable error": {"inputs": {"H": [1.0, 1e-6]}, "tol": 1e-5}, - "n": { + "n0": { "inputs": {"He": [1, 0, 0], "H": [1, 0]}, "abundances": {"H": 1, "He": 0.1}, - "n": 1e9 * u.cm ** -3, + "n0": 1e9 * u.cm ** -3, }, "T_e and n": { "inputs": {"H": [0.9, 0.1], "helium": [0.5, 0.3, 0.2]}, "abundances": {"H": 1, "He": 0.1}, "T_e": 1e4 * u.K, - "n": 1e15 * u.m ** -3, + "n0": 1e15 * u.m ** -3, "kappa": np.inf, }, "log_abundances": { "inputs": {"H": [1, 0], "He": [1, 0, 0]}, "log_abundances": {"H": 1, "He": 0}, - "n": 1e9 * u.cm ** -3, + "n0": 1e9 * u.cm ** -3, }, "elements & isotopes": { "inputs": { @@ -89,7 +89,7 @@ def has_attribute(attribute, tests_dict): "He-4": [0.29, 0.69, 0.02], }, "abundances": {"H": 1, "He-3": 1e-7, "He-4": 0.1}, - "n": 1e12 * u.m ** -3, + "n0": 1e12 * u.m ** -3, }, "ordered elements -> inputs": {"inputs": ["O", "C", "H", "Fe", "Ar"]}, "mixed and unordered elements and isotopes": { @@ -103,14 +103,14 @@ def has_attribute(attribute, tests_dict): }, "number densities and n are both inputs": { "inputs": {"H": [0.1, 0.3] * u.cm ** -3}, - "n": 1e-5 * u.mm ** -3, + "n0": 1e-5 * u.mm ** -3, }, } test_names = tests.keys() -class TestIonizationStates: +class TestIonizationStateCollection: @classmethod def setup_class(cls): cls.instances = {} @@ -118,10 +118,10 @@ def setup_class(cls): @pytest.mark.parametrize("test_name", test_names) def test_instantiation(self, test_name): try: - self.instances[test_name] = IonizationStates(**tests[test_name]) + self.instances[test_name] = IonizationStateCollection(**tests[test_name]) except Exception: pytest.fail( - f"Cannot create IonizationStates instance for test='{test_name}'" + f"Cannot create IonizationStateCollection instance for test='{test_name}'" ) @pytest.mark.parametrize("test_name", test_names) @@ -134,15 +134,17 @@ def test_no_exceptions_from_repr(self, test_name): @pytest.mark.parametrize("test_name", test_names) def test_no_exceptions_from_info(self, test_name): - self.instances[test_name].info() + self.instances[test_name].summarize() @pytest.mark.parametrize("test_name", test_names) def test_simple_equality(self, test_name): """Test that __eq__ is not extremely broken.""" - a = IonizationStates(**tests[test_name]) - b = IonizationStates(**tests[test_name]) - assert a == a, f"IonizationStates instance does not equal itself." - assert a == b, f"IonizationStates instance does not equal identical instance." + a = IonizationStateCollection(**tests[test_name]) + b = IonizationStateCollection(**tests[test_name]) + assert a == a, f"IonizationStateCollection instance does not equal itself." + assert ( + a == b + ), f"IonizationStateCollection instance does not equal identical instance." @pytest.mark.parametrize( "test_name", @@ -178,7 +180,7 @@ def test_that_abundances_kwarg_sets_abundances(self, test_name): if not elements.issubset(elements_from_abundances): pytest.fail( - f"The elements whose IonizationStates are being kept " + f"The elements whose IonizationStateCollection are being kept " f"track of ({elements}) are not a subset of the " f"elements whose abundances are being kept track of " f"({elements_from_abundances}) for test {test_name}." @@ -240,7 +242,7 @@ def test_that_ionic_fractions_are_set_correctly(self, test_name): ) if not isinstance(actual, np.ndarray) or isinstance(actual, u.Quantity): - raise AtomicError( + raise ParticleError( f"\n\nNot a numpy.ndarray: ({test_name}, {element})" ) else: @@ -343,8 +345,8 @@ def test_abundances_consistency(): log_abundances = {element: np.log10(abundances[element]) for element in elements} - instance_nolog = IonizationStates(inputs, abundances=abundances) - instance_log = IonizationStates(inputs, log_abundances=log_abundances) + instance_nolog = IonizationStateCollection(inputs, abundances=abundances) + instance_log = IonizationStateCollection(inputs, log_abundances=log_abundances) for element in elements: assert np.allclose( @@ -357,14 +359,16 @@ def test_abundances_consistency(): ), "log_abundances not consistent." -class TestIonizationStatesItemAssignment: +class TestIonizationStateCollectionItemAssignment: """ - Test IonizationStates.__setitem__ and exceptions. + Test IonizationStateCollection.__setitem__ and exceptions. """ @classmethod def setup_class(cls): - cls.states = IonizationStates({"H": [0.9, 0.1], "He": [0.5, 0.4999, 1e-4]}) + cls.states = IonizationStateCollection( + {"H": [0.9, 0.1], "He": [0.5, 0.4999, 1e-4]} + ) @pytest.mark.parametrize( "element, new_states", @@ -376,12 +380,12 @@ def setup_class(cls): ], ) def test_setitem(self, element, new_states): - """Test item assignment in an IonizationStates instance.""" + """Test item assignment in an IonizationStateCollection instance.""" try: self.states[element] = new_states except Exception: pytest.fail( - "Unable to change ionic fractions for an IonizationStates instance." + "Unable to change ionic fractions for an IonizationStateCollection instance." ) resulting_states = self.states[element].ionic_fractions @@ -408,7 +412,7 @@ def test_setitem_errors(self, base_particle, new_states, expected_exception): self.states[base_particle] = new_states -class TestIonizationStatesDensities: +class TestIonizationStateCollectionDensities: @classmethod def setup_class(cls): @@ -425,8 +429,8 @@ def setup_class(cls): } cls.expected_electron_density = 2.26025 * u.m ** -3 - cls.states = IonizationStates( - cls.initial_ionfracs, abundances=cls.abundances, n=cls.n + cls.states = IonizationStateCollection( + cls.initial_ionfracs, abundances=cls.abundances, n0=cls.n ) def test_electron_density(self): @@ -450,14 +454,14 @@ def test_number_densities(self, elem): ) -class TestIonizationStatesAttributes: +class TestIonizationStateCollectionAttributes: @classmethod def setup_class(cls): cls.elements = ["H", "He", "Li", "Fe"] - cls.instance = IonizationStates(cls.elements) + cls.instance = IonizationStateCollection(cls.elements) cls.new_n = 5.153 * u.cm ** -3 - @pytest.mark.parametrize("uninitialized_attribute", ["T_e", "n", "n_e"]) + @pytest.mark.parametrize("uninitialized_attribute", ["T_e", "n0", "n_e"]) def test_attribute_defaults_to_nan(self, uninitialized_attribute): command = f"self.instance.{uninitialized_attribute}" default_value = eval(command) @@ -502,13 +506,25 @@ def test_abundances_default_to_nans(self, uninitialized_attribute): "attribute, invalid_value, expected_exception", [ ("T_e", "5 * u.m", u.UnitsError), - ("T_e", "-1 * u.K", AtomicError), - ("n", "5 * u.m", u.UnitsError), - ("n", "-1 * u.m ** -3", AtomicError), - ("ionic_fractions", {"H": [0.3, 0.7], "He": [-0.1, 0.4, 0.7]}, AtomicError), - ("ionic_fractions", {"H": [0.3, 0.7], "He": [1.01, 0.0, 0.7]}, AtomicError), - ("ionic_fractions", {"H": [0.3, 0.6], "He": [1.0, 0.0, 0.0]}, AtomicError), - ("ionic_fractions", {"H": [1.0, 0.0]}, AtomicError), + ("T_e", "-1 * u.K", ParticleError), + ("n0", "5 * u.m", u.UnitsError), + ("n0", "-1 * u.m ** -3", ParticleError), + ( + "ionic_fractions", + {"H": [0.3, 0.7], "He": [-0.1, 0.4, 0.7]}, + ParticleError, + ), + ( + "ionic_fractions", + {"H": [0.3, 0.7], "He": [1.01, 0.0, 0.7]}, + ParticleError, + ), + ( + "ionic_fractions", + {"H": [0.3, 0.6], "He": [1.0, 0.0, 0.0]}, + ParticleError, + ), + ("ionic_fractions", {"H": [1.0, 0.0]}, ParticleError), ], ) def test_attribute_exceptions(self, attribute, invalid_value, expected_exception): @@ -557,7 +573,7 @@ def test_setting_ionic_fractions_for_single_element(self): def test_setting_invalid_ionfracs(self, key, invalid_fracs, expected_exception): errmsg = ( f"No {expected_exception} is raised when trying to assign " - f"{invalid_fracs} to {key} in an IonizationStates instance." + f"{invalid_fracs} to {key} in an IonizationStateCollection instance." ) with pytest.raises(expected_exception): self.instance[key] = invalid_fracs @@ -565,7 +581,7 @@ def test_setting_invalid_ionfracs(self, key, invalid_fracs, expected_exception): def test_setting_incomplete_abundances(self): new_abundances = {"H": 1, "He": 0.1, "Fe": 1e-5, "Au": 1e-8} # missing lithium - with pytest.raises(AtomicError): + with pytest.raises(ParticleError): self.instance.abundances = new_abundances def test_setting_abundances(self): @@ -636,7 +652,7 @@ def test_getitem_two_indices(self, indices): particle = indices[0] integer_charge = indices[1] - assert isinstance(result, State) + assert isinstance(result, IonicFraction) assert result.integer_charge == integer_charge expected_ionic_fraction = instance.ionic_fractions[particle][integer_charge] @@ -652,12 +668,12 @@ def test_getitem_two_indices(self, indices): def test_setting_n(self): try: - self.instance.n = self.new_n + self.instance.n0 = self.new_n except Exception: pytest.fail("Unable to set number density scaling factor attribute") - if not u.quantity.allclose(self.instance.n, self.new_n): + if not u.quantity.allclose(self.instance.n0, self.new_n): pytest.fail("Number density scaling factor was not set correctly.") - if not self.instance.n.unit == u.m ** -3: + if not self.instance.n0.unit == u.m ** -3: pytest.fail("Incorrect units for new number density.") def test_resetting_valid_densities(self): @@ -730,54 +746,56 @@ def test_base_particles_equal_ionic_fraction_particles(self): IE = collections.namedtuple("IE", ["inputs", "expected_exception"]) tests_for_exceptions = { - "wrong type": IE({"inputs": None}, AtomicError), - "not normalized": IE({"inputs": {"He": [0.4, 0.5, 0.0]}, "tol": 1e-9}, AtomicError), - "negative ionfrac": IE({"inputs": {"H": [-0.1, 1.1]}}, AtomicError), - "ion": IE({"inputs": {"H": [0.1, 0.9], "He+": [0.0, 0.9, 0.1]}}, AtomicError), + "wrong type": IE({"inputs": None}, ParticleError), + "not normalized": IE( + {"inputs": {"He": [0.4, 0.5, 0.0]}, "tol": 1e-9}, ParticleError + ), + "negative ionfrac": IE({"inputs": {"H": [-0.1, 1.1]}}, ParticleError), + "ion": IE({"inputs": {"H": [0.1, 0.9], "He+": [0.0, 0.9, 0.1]}}, ParticleError), "repeat elements": IE( - {"inputs": {"H": [0.1, 0.9], "hydrogen": [0.2, 0.8]}}, AtomicError + {"inputs": {"H": [0.1, 0.9], "hydrogen": [0.2, 0.8]}}, ParticleError ), "isotope of element": IE( - {"inputs": {"H": [0.1, 0.9], "D": [0.2, 0.8]}}, AtomicError + {"inputs": {"H": [0.1, 0.9], "D": [0.2, 0.8]}}, ParticleError ), "negative abundance": IE( { "inputs": {"H": [0.1, 0.9], "He": [0.4, 0.5, 0.1]}, "abundances": {"H": 1, "He": -0.1}, }, - AtomicError, + ParticleError, ), "imaginary abundance": IE( { "inputs": {"H": [0.1, 0.9], "He": [0.4, 0.5, 0.1]}, "abundances": {"H": 1, "He": 0.1j}, }, - AtomicError, + ParticleError, ), "wrong density units": IE( { "inputs": {"H": [10, 90] * u.m ** -3, "He": [0.1, 0.9, 0] * u.m ** -2}, "abundances": {"H": 1, "He": 0.1}, }, - AtomicError, + ParticleError, ), "abundance redundance": IE( { "inputs": {"H": [10, 90] * u.m ** -3, "He": [0.1, 0.9, 0] * u.m ** -3}, "abundances": {"H": 1, "He": 0.1}, }, - AtomicError, + ParticleError, ), "abundance contradiction": IE( { "inputs": {"H": [10, 90] * u.m ** -3, "He": [0.1, 0.9, 0] * u.m ** -3}, "abundances": {"H": 1, "He": 0.11}, }, - AtomicError, + ParticleError, ), - "kappa too small": IE({"inputs": ["H"], "kappa": 1.499999}, AtomicError), - "negative n": IE({"inputs": ["H"], "n": -1 * u.cm ** -3}, AtomicError), - "negative T_e": IE({"inputs": ["H-1"], "T_e": -1 * u.K}, AtomicError), + "kappa too small": IE({"inputs": ["H"], "kappa": 1.499999}, ParticleError), + "negative n": IE({"inputs": ["H"], "n0": -1 * u.cm ** -3}, ParticleError), + "negative T_e": IE({"inputs": ["H-1"], "T_e": -1 * u.K}, ParticleError), } @@ -785,18 +803,18 @@ def test_base_particles_equal_ionic_fraction_particles(self): def test_exceptions_upon_instantiation(test_name): """ Test that appropriate exceptions are raised for inappropriate inputs - to IonizationStates when first instantiated. + to IonizationStateCollection when first instantiated. """ run_test( - IonizationStates, + IonizationStateCollection, kwargs=tests_for_exceptions[test_name].inputs, expected_outcome=tests_for_exceptions[test_name].expected_exception, ) -class TestIonizationStatesDensityEqualities: +class TestIonizationStateCollectionDensityEqualities: """ - Test that IonizationStates instances are equal or not equal to each + Test that IonizationStateCollection instances are equal or not equal to each other as they should be for different combinations of inputs related to ionic_fractions, number densities, and abundances. """ @@ -804,7 +822,7 @@ class TestIonizationStatesDensityEqualities: @classmethod def setup_class(cls): - # Create arguments to IonizationStates that are all consistent + # Create arguments to IonizationStateCollection that are all consistent # with each other. cls.ionic_fractions = {"H": [0.9, 0.1], "He": [0.99, 0.01, 0.0]} @@ -823,16 +841,16 @@ def setup_class(cls): "ndens1": { "inputs": cls.ionic_fractions, "abundances": cls.abundances, - "n": cls.n, + "n0": cls.n, }, "ndens2": {"inputs": cls.number_densities}, "no_ndens3": {"inputs": cls.ionic_fractions}, "no_ndens4": {"inputs": cls.ionic_fractions, "abundances": cls.abundances}, - "no_ndens5": {"inputs": cls.ionic_fractions, "n": cls.n}, + "no_ndens5": {"inputs": cls.ionic_fractions, "n0": cls.n}, } cls.instances = { - key: IonizationStates(**cls.dict_of_kwargs[key]) + key: IonizationStateCollection(**cls.dict_of_kwargs[key]) for key in cls.dict_of_kwargs.keys() } @@ -862,7 +880,7 @@ def test_number_densities_undefined(self, test_key): ) def test_equality(self, this, that): """ - Test that the IonizationStates instances that should provide + Test that the IonizationStateCollection instances that should provide ``number_densities`` are all equal to each other. Test that the instances that should not provide ``number_densities`` are all equal to each other. Test that each instance that should @@ -873,10 +891,10 @@ def test_equality(self, this, that): are_equal = self.instances[this] == self.instances[that] if expect_equality != are_equal: print(f"{this} kwargs:\n {self.dict_of_kwargs[this]}\n") - self.instances[this].info() + self.instances[this].summarize() print() print(f"{that} kwargs:\n {self.dict_of_kwargs[that]}\n") - self.instances[that].info() + self.instances[that].summarize() descriptor = "equal" if expect_equality else "unequal" pytest.fail( f"Cases {this} and {that} should be {descriptor} but " f"are not." @@ -884,6 +902,6 @@ def test_equality(self, this, that): def test_number_density_assignment(): - instance = IonizationStates(["H", "He"]) + instance = IonizationStateCollection(["H", "He"]) number_densities = [2, 3, 5] * u.m ** -3 instance["He"] = number_densities diff --git a/plasmapy/particles/tests/test_ionization_state.py b/plasmapy/particles/tests/test_ionization_state.py index be5c82cebd..9eaf2b21a9 100644 --- a/plasmapy/particles/tests/test_ionization_state.py +++ b/plasmapy/particles/tests/test_ionization_state.py @@ -10,10 +10,87 @@ Particle, particle_symbol, ) -from plasmapy.particles.exceptions import AtomicError, InvalidIsotopeError -from plasmapy.particles.ionization_state import IonizationState +from plasmapy.particles.exceptions import InvalidIsotopeError, ParticleError +from plasmapy.particles.ionization_state import IonicFraction, IonizationState from plasmapy.utils.pytest_helpers import run_test +ionic_fraction_table = [ + ("Fe 6+", 0.52, 5.2e-6 * u.m ** -3), + ("He 1+", None, None), + ("H-2 0+", None, None), +] + + +@pytest.mark.parametrize("ion, ionic_fraction, number_density", ionic_fraction_table) +def test_ionic_fraction_attributes(ion, ionic_fraction, number_density): + + instance = IonicFraction( + ion=ion, ionic_fraction=ionic_fraction, number_density=number_density + ) + + # Prepare to check for the default values when they are not set + + if ionic_fraction is None: + ionic_fraction = np.nan + if number_density is None: + number_density = np.nan * u.m ** -3 + + assert Particle(ion) == Particle(instance.ionic_symbol) + assert u.isclose(instance.ionic_fraction, ionic_fraction, equal_nan=True) + assert u.isclose(instance.number_density, number_density, equal_nan=True) + + +@pytest.mark.parametrize( + "invalid_fraction, expected_exception", + [(-1e-9, ParticleError), (1.00000000001, ParticleError), ("...", ParticleError)], +) +def test_ionic_fraction_invalid_inputs(invalid_fraction, expected_exception): + """ + Test that IonicFraction raises exceptions when the ionic fraction + is out of the interval [0,1] or otherwise invalid. + """ + with pytest.raises(expected_exception): + IonicFraction(ion="Fe 6+", ionic_fraction=invalid_fraction) + + +@pytest.mark.parametrize("invalid_particle", ["H", "e-", "Fe-56"]) +def test_ionic_fraction_invalid_particles(invalid_particle): + """ + Test that `~plasmapy.particles.IonicFraction` raises the appropriate + exception when passed a particle that isn't a neutral or ion. + """ + with pytest.raises(ParticleError): + IonicFraction(invalid_particle, ionic_fraction=0) + + +@pytest.mark.parametrize("ion1, ion2", [("Fe-56 6+", "Fe-56 5+"), ("H 1+", "D 1+")]) +def test_ionic_fraction_comparison_with_different_ions(ion1, ion2): + """ + Test that a `TypeError` is raised when an `IonicFraction` object + is compared to an `IonicFraction` object of a different ion. + """ + fraction = 0.251 + + ionic_fraction_1 = IonicFraction(ion=ion1, ionic_fraction=fraction) + ionic_fraction_2 = IonicFraction(ion=ion2, ionic_fraction=fraction) + + assert (ionic_fraction_1 == ionic_fraction_2) is False + + +def test_ionization_state_ion_input_error(): + """ + Test that `~plasmapy.particles.IonizationState` raises the appropriate + exception when an ion is the base particle and ionic fractions are + specified + """ + + ion = "He 1+" + unnecessary_ionic_fractions = [0.0, 0.0, 1.0] + + with pytest.raises(ParticleError): + IonizationState(ion, ionic_fractions=unnecessary_ionic_fractions) + + test_cases = { "Li": { "particle": "Li", @@ -51,6 +128,9 @@ test_names = test_cases.keys() +# TODO: Refactor these tests using fixtures + + class Test_IonizationState: """Test instances of IonizationState.""" @@ -131,7 +211,7 @@ def test_equality_exception(self): Test that comparisons of `IonizationState` instances for different elements fail. """ - with pytest.raises(AtomicError): + with pytest.raises(ParticleError): self.instances["Li"] == self.instances["H"] @pytest.mark.parametrize("test_name", test_names) @@ -213,7 +293,7 @@ def test_indexing_error(self, index): Test that an `IonizationState` instance cannot be indexed outside of the bounds of allowed integer charges. """ - with pytest.raises(AtomicError): + with pytest.raises(ParticleError): self.instances["Li"][index] def test_normalization(self): @@ -244,11 +324,9 @@ def test_identifications(self, test_name): self.instances[test_name].atomic_number, ) - expected_element = self.instances[test_name]._particle_instance.element - expected_isotope = self.instances[test_name]._particle_instance.isotope - expected_atomic_number = self.instances[ - test_name - ]._particle_instance.atomic_number + expected_element = self.instances[test_name]._particle.element + expected_isotope = self.instances[test_name]._particle.isotope + expected_atomic_number = self.instances[test_name]._particle.atomic_number resulting_identifications = Identifications( expected_element, expected_isotope, expected_atomic_number @@ -335,11 +413,12 @@ def test_getitem(self, test_name): # (see Astropy issue #7901 on GitHub). for keys in zip(integer_charges, symbols, particles): + set_of_str_values = {str(instance[key]) for key in keys} if len(set_of_str_values) != 1: errors.append( f"\n\n" - f"The following keys in test {test_name} did not " + f"The following keys in test '{test_name}' did not " f"produce identical outputs as required: {keys}. " f"The set containing string representations of" f"the values is:\n\n{set_of_str_values}" @@ -374,18 +453,18 @@ def test_State_equality_and_getitem(self): IE = collections.namedtuple("IE", ["inputs", "expected_exception"]) tests_for_exceptions = { - "too few nstates": IE({"particle": "H", "ionic_fractions": [1.0]}, AtomicError), + "too few nstates": IE({"particle": "H", "ionic_fractions": [1.0]}, ParticleError), "too many nstates": IE( - {"particle": "H", "ionic_fractions": [1, 0, 0, 0]}, AtomicError + {"particle": "H", "ionic_fractions": [1, 0, 0, 0]}, ParticleError ), "ionic fraction < 0": IE( - {"particle": "He", "ionic_fractions": [-0.1, 0.1, 1]}, AtomicError + {"particle": "He", "ionic_fractions": [-0.1, 0.1, 1]}, ParticleError ), "ionic fraction > 1": IE( - {"particle": "He", "ionic_fractions": [1.1, 0.0, 0.0]}, AtomicError + {"particle": "He", "ionic_fractions": [1.1, 0.0, 0.0]}, ParticleError ), "invalid ionic fraction": IE( - {"particle": "He", "ionic_fractions": [1.0, 0.0, "a"]}, AtomicError + {"particle": "He", "ionic_fractions": [1.0, 0.0, "a"]}, ParticleError ), "bad n_elem units": IE( {"particle": "H", "ionic_fractions": [0, 1], "n_elem": 3 * u.m ** 3}, @@ -400,11 +479,11 @@ def test_State_equality_and_getitem(self): "ionic_fractions": [1.0, 0.0, 0.0], "n_elem": -1 * u.m ** -3, }, - AtomicError, + ParticleError, ), "negative T_e": IE( {"particle": "He", "ionic_fractions": [1.0, 0.0, 0.0], "T_e": -1 * u.K}, - AtomicError, + ParticleError, ), "redundant ndens": IE( { @@ -412,11 +491,55 @@ def test_State_equality_and_getitem(self): "ionic_fractions": np.array([3, 4]) * u.m ** -3, "n_elem": 4 * u.m ** -3, }, - AtomicError, + ParticleError, ), } +ions = ["Fe 6+", "p", "He-4 0+", "triton", "alpha", "Ne +0"] + + +@pytest.mark.parametrize("ion", ions) +def test_IonizationState_ionfracs_from_ion_input(ion): + + ionization_state = IonizationState(ion) + ion_particle = Particle(ion) + actual_ionic_fractions = ionization_state.ionic_fractions + + expected_ionic_fractions = np.zeros(ion_particle.atomic_number + 1) + expected_ionic_fractions[ion_particle.integer_charge] = 1.0 + + if not np.allclose(expected_ionic_fractions, actual_ionic_fractions, atol=1e-16): + pytest.fail( + f"The returned ionic fraction for IonizationState({repr(ion)}) " + f"should have entirely been in the Z = {ion_particle.integer_charge} " + f"level, but was instead: {ionization_state.ionic_fractions}." + ) + + +@pytest.mark.parametrize("ion", ions) +def test_IonizationState_base_particles_from_ion_input(ion): + """ + Test that supplying an ion to IonizationState will result in the + base particle being the corresponding isotope or ion and that the + ionic fraction of the corresponding charge level is 100%. + """ + + ionization_state = IonizationState(ion) + ion_particle = Particle(ion) + + if ion_particle.isotope: + expected_base_particle = ion_particle.isotope + else: + expected_base_particle = ion_particle.element + + if expected_base_particle != ionization_state.base_particle: + pytest.fail( + f"The expected base particle was {expected_base_particle}, " + f"but the returned base particle was {ionization_state.base_particle}. " + ) + + @pytest.mark.parametrize("test", tests_for_exceptions.keys()) def test_IonizationState_exceptions(test): """ @@ -499,6 +622,10 @@ def test_nans(): def test_setting_ionic_fractions(): + """ + Test the setter for the ``ionic_fractions`` attribute on + `~plasmapy.particles.IonizationState`. + """ instance = IonizationState("He") new_ionic_fractions = [0.2, 0.5, 0.3] instance.ionic_fractions = new_ionic_fractions @@ -559,11 +686,11 @@ def test_n_e(self): ), "IonizationState.n_e not set correctly after number_densities was set." def test_that_negative_density_raises_error(self): - with pytest.raises(AtomicError, match="cannot be negative"): + with pytest.raises(ParticleError, match="cannot be negative"): self.instance.number_densities = u.Quantity([-0.1, 0.2], unit=u.m ** -3) def test_incorrect_number_of_charge_states_error(self): - with pytest.raises(AtomicError, match="Incorrect number of charge states"): + with pytest.raises(ParticleError, match="Incorrect number of charge states"): self.instance.number_densities = u.Quantity([0.1, 0.2, 0.3], unit=u.m ** -3) def test_incorrect_units_error(self): diff --git a/plasmapy/particles/tests/test_nuclear.py b/plasmapy/particles/tests/test_nuclear.py index 3aa7d379c0..2931dfbd74 100644 --- a/plasmapy/particles/tests/test_nuclear.py +++ b/plasmapy/particles/tests/test_nuclear.py @@ -4,16 +4,19 @@ from astropy import constants as const from astropy import units as u -from plasmapy.particles.exceptions import AtomicError, InvalidParticleError +from plasmapy.particles.exceptions import InvalidParticleError, ParticleError +from plasmapy.particles.nuclear import ( + mass_energy, + nuclear_binding_energy, + nuclear_reaction_energy, +) from plasmapy.utils.pytest_helpers import run_test, run_test_equivalent_calls -from ..nuclear import mass_energy, nuclear_binding_energy, nuclear_reaction_energy - test_nuclear_table = [ [nuclear_binding_energy, "p", {}, 0 * u.J], [nuclear_binding_energy, "n", {}, 0 * u.J], [nuclear_binding_energy, "p", {}, 0 * u.J], - [nuclear_binding_energy, "H", {}, AtomicError], + [nuclear_binding_energy, "H", {}, ParticleError], [nuclear_binding_energy, "He-99", {}, InvalidParticleError], [nuclear_binding_energy, "He", {"mass_numb": 99}, InvalidParticleError], [nuclear_binding_energy, 3.1415926535j, {}, TypeError], @@ -27,54 +30,54 @@ nuclear_reaction_energy, (), {"reactants": ["n"], "products": ["He-4"]}, - AtomicError, + ParticleError, ], [ nuclear_reaction_energy, (), {"reactants": ["h"], "products": ["H-1"]}, - AtomicError, + ParticleError, ], [ nuclear_reaction_energy, (), {"reactants": ["e-", "n"], "products": ["p+"]}, - AtomicError, + ParticleError, ], [ nuclear_reaction_energy, (), {"reactants": ["e+", "n"], "products": ["p-"]}, - AtomicError, + ParticleError, ], [ nuclear_reaction_energy, (), {"reactants": ["ksdf"], "products": ["H-3"]}, - AtomicError, + ParticleError, ], [ nuclear_reaction_energy, (), {"reactants": ["H"], "products": ["H-1"]}, - AtomicError, + ParticleError, ], [ nuclear_reaction_energy, (), {"reactants": ["p"], "products": ["n", "n", "e-"]}, - AtomicError, + ParticleError, ], - [nuclear_reaction_energy, "H + H --> H", {}, AtomicError], - [nuclear_reaction_energy, "H + H", {}, AtomicError], + [nuclear_reaction_energy, "H + H --> H", {}, ParticleError], + [nuclear_reaction_energy, "H + H", {}, ParticleError], [nuclear_reaction_energy, 1, {}, TypeError], - [nuclear_reaction_energy, "H-1 + H-1 --> H-1", {}, AtomicError], - [nuclear_reaction_energy, "p --> n", {}, AtomicError], + [nuclear_reaction_energy, "H-1 + H-1 --> H-1", {}, ParticleError], + [nuclear_reaction_energy, "p --> n", {}, ParticleError], [ nuclear_reaction_energy, "p --> p", {"reactants": "p", "products": "p"}, - AtomicError, + ParticleError, ], ] diff --git a/plasmapy/particles/tests/test_parsing.py b/plasmapy/particles/tests/test_parsing.py index 612bb31688..627f7a83df 100644 --- a/plasmapy/particles/tests/test_parsing.py +++ b/plasmapy/particles/tests/test_parsing.py @@ -2,9 +2,9 @@ from plasmapy.particles import Particle from plasmapy.particles.exceptions import ( - AtomicWarning, InvalidElementError, InvalidParticleError, + ParticleWarning, ) from plasmapy.particles.parsing import ( # duplicate with utils.pytest_helpers.error_messages.call_string? _case_insensitive_aliases, @@ -364,7 +364,7 @@ def test_parse_AtomicWarnings(arg, kwargs, num_warnings): r"""Tests that _parse_and_check_atomic_input issues an AtomicWarning under the required conditions.""" - with pytest.warns(AtomicWarning) as record: + with pytest.warns(ParticleWarning) as record: _parse_and_check_atomic_input(arg, **kwargs) if not record: pytest.fail( diff --git a/plasmapy/particles/tests/test_particle_class.py b/plasmapy/particles/tests/test_particle_class.py index d545c536c3..a6049c4699 100644 --- a/plasmapy/particles/tests/test_particle_class.py +++ b/plasmapy/particles/tests/test_particle_class.py @@ -12,15 +12,15 @@ from plasmapy.particles import json_load_particle, json_loads_particle from plasmapy.particles.atomic import known_isotopes from plasmapy.particles.exceptions import ( - AtomicError, - AtomicWarning, ChargeError, InvalidElementError, InvalidIonError, InvalidIsotopeError, InvalidParticleError, - MissingAtomicDataError, - MissingAtomicDataWarning, + MissingParticleDataError, + MissingParticleDataWarning, + ParticleError, + ParticleWarning, ) from plasmapy.particles.isotopes import _Isotopes from plasmapy.particles.particle_class import ( @@ -195,7 +195,7 @@ "charge": ChargeError, "integer_charge": ChargeError, "mass_number": InvalidIsotopeError, - "baryon_number": AtomicError, + "baryon_number": ParticleError, "lepton_number": 0, "half_life": InvalidIsotopeError, "standard_atomic_weight": (1.008 * u.u).to(u.kg), @@ -219,7 +219,7 @@ "is_ion": True, "integer_charge": -1, "mass_number": InvalidIsotopeError, - "baryon_number": MissingAtomicDataError, + "baryon_number": MissingParticleDataError, "lepton_number": 0, "half_life": InvalidIsotopeError, "standard_atomic_weight": InvalidElementError, @@ -424,8 +424,8 @@ "element": None, "isotope": None, "isotope_name": InvalidElementError, - "mass": MissingAtomicDataError, - "mass_energy": MissingAtomicDataError, + "mass": MissingParticleDataError, + "mass_energy": MissingParticleDataError, "integer_charge": 0, "mass_number": InvalidIsotopeError, "element_name": InvalidElementError, @@ -443,10 +443,10 @@ 'is_category(any_of={"matter", "boson"})': True, 'is_category(any_of=["antimatter", "boson", "charged"])': False, 'is_category(["fermion", "lepton"], exclude="matter")': False, - 'is_category("lepton", "invalid")': AtomicError, - 'is_category(["boson"], exclude=["lepton", "invalid"])': AtomicError, - 'is_category("boson", exclude="boson")': AtomicError, - 'is_category(any_of="boson", exclude="boson")': AtomicError, + 'is_category("lepton", "invalid")': ParticleError, + 'is_category(["boson"], exclude=["lepton", "invalid"])': ParticleError, + 'is_category("boson", exclude="boson")': ParticleError, + 'is_category(any_of="boson", exclude="boson")': ParticleError, }, ), (Particle("C"), {}, {"particle": "C", "atomic_number": 6, "element": "C"}), @@ -478,7 +478,7 @@ def test_Particle_class(arg, kwargs, expected_dict): try: particle = Particle(arg, **kwargs) except Exception as exc: - raise AtomicError(f"Problem creating {call}") from exc + raise ParticleError(f"Problem creating {call}") from exc for key in expected_dict.keys(): expected = expected_dict[key] @@ -487,7 +487,7 @@ def test_Particle_class(arg, kwargs, expected_dict): # Exceptions are expected to be raised when accessing certain # attributes for some particles. For example, accessing a - # neutrino's mass should raise a MissingAtomicDataError since + # 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. @@ -564,9 +564,9 @@ def test_Particle_equivalent_cases(equivalent_particles): ("neutron", {}, ".mass_number", InvalidIsotopeError), ("He", {"mass_numb": 4}, ".charge", ChargeError), ("He", {"mass_numb": 4}, ".integer_charge", ChargeError), - ("Fe", {}, ".spin", MissingAtomicDataError), - ("nu_e", {}, ".mass", MissingAtomicDataError), - ("Og", {}, ".standard_atomic_weight", MissingAtomicDataError), + ("Fe", {}, ".spin", MissingParticleDataError), + ("nu_e", {}, ".mass", MissingParticleDataError), + ("Og", {}, ".standard_atomic_weight", MissingParticleDataError), (Particle("C-14"), {"mass_numb": 13}, "", InvalidParticleError), (Particle("Au 1+"), {"Z": 2}, "", InvalidParticleError), ([], {}, "", TypeError), @@ -599,9 +599,9 @@ def test_Particle_errors(arg, kwargs, attribute, exception): # arg, kwargs, attribute, exception test_Particle_warning_table = [ - ("H----", {}, "", AtomicWarning), - ("alpha", {"mass_numb": 4}, "", AtomicWarning), - ("alpha", {"Z": 2}, "", AtomicWarning), + ("H----", {}, "", ParticleWarning), + ("alpha", {"mass_numb": 4}, "", ParticleWarning), + ("alpha", {"Z": 2}, "", ParticleWarning), ] @@ -633,7 +633,7 @@ def test_Particle_cmp(): with pytest.raises(TypeError): electron == 1 - with pytest.raises(AtomicError): + with pytest.raises(ParticleError): electron == "dfasdf" @@ -704,7 +704,7 @@ def test_particle_half_life_string(): """ Find the first isotope where the half-life is stored as a string (because the uncertainties are too great), and tests that requesting - the half-life of that isotope causes a `MissingAtomicDataWarning` + the half-life of that isotope causes a `MissingParticleDataWarning` whilst returning a string. """ @@ -713,7 +713,7 @@ def test_particle_half_life_string(): if isinstance(half_life, str): break - with pytest.warns(MissingAtomicDataWarning): + with pytest.warns(MissingParticleDataWarning): assert isinstance(Particle(isotope).half_life, str) @@ -723,7 +723,7 @@ def test_particle_is_electron(p, is_one): def test_particle_bool_error(): - with pytest.raises(AtomicError): + with pytest.raises(ParticleError): bool(Particle("e-")) @@ -758,7 +758,7 @@ def test_antiparticle_inversion(particle, antiparticle): def test_unary_operator_for_elements(): - with pytest.raises(AtomicError): + with pytest.raises(ParticleError): Particle("C").antiparticle