Repository: PlasmaPy/PlasmaPy
Pull request #1046 (2021-02-26 13:30:44)

IonicFraction refactor
Starting to apply changes to `IonicFraction` from 
https://github.com/PlasmaPy/PlasmaPy/discussions/1020#discussioncomment-408683. Refactoring the implementation a little along the way. Part of #1059.

- [x] ~~docs for T_i~~ will go in at a future time, along with that feature!

codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046?src=pr&el=h1) Report
> Merging [#1046](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046?src=pr&el=desc) (f7a6db5) into [master](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/5ad555006bd5c9e99c17e82a6c46bd04a8809d18?el=desc) (5ad5550) will **decrease** coverage by `0.59%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046/graphs/tree.svg?width=650&height=150&src=pr&token=LzZ5mnXVad)](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #1046      +/-   ##
==========================================
- Coverage   96.86%   96.27%   -0.60%     
==========================================
  Files          70       70              
  Lines        6855     6865      +10     
==========================================
- Hits         6640     6609      -31     
- Misses        215      256      +41     
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [plasmapy/particles/ionization\_state.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL2lvbml6YXRpb25fc3RhdGUucHk=) | `93.73% <100.00%> (+0.20%)` | :arrow_up: |
| [plasmapy/simulation/particletracker.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046/diff?src=pr&el=tree#diff-cGxhc21hcHkvc2ltdWxhdGlvbi9wYXJ0aWNsZXRyYWNrZXIucHk=) | `36.20% <0.00%> (-62.07%)` | :arrow_down: |
| [plasmapy/plasma/sources/plasma3d.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGxhc21hL3NvdXJjZXMvcGxhc21hM2QucHk=) | `93.75% <0.00%> (-6.25%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046?src=pr&el=footer). Last update [5ad5550...f7a6db5](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1046?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

StanczakDominik: Gah, you're of course right; that's just overzealous grepping. Getting to it.
StanczakDominik: That should do it! Sorry I didn't pay enough attention to this.

Patch:
diff --git a/changelog/1046.breaking.rst b/changelog/1046.breaking.rst
new file mode 100644
index 00000000..83c7ef2d
--- /dev/null
+++ b/changelog/1046.breaking.rst
@@ -0,0 +1,1 @@
+Renamed `IonicFraction` to `IonicLevel`. This lays groundwork for future changes, where that class is going to become more than a fraction.
diff --git a/plasmapy/particles/__init__.py b/plasmapy/particles/__init__.py
index d43f1412..1ac3d57e 100644
--- a/plasmapy/particles/__init__.py
+++ b/plasmapy/particles/__init__.py
@@ -21,7 +21,7 @@
     standard_atomic_weight,
 )
 from plasmapy.particles.decorators import particle_input
-from plasmapy.particles.ionization_state import IonicFraction, IonizationState
+from plasmapy.particles.ionization_state import IonicLevel, 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 (
diff --git a/plasmapy/particles/ionization_state.py b/plasmapy/particles/ionization_state.py
index 82852d06..333b3e00 100644
--- a/plasmapy/particles/ionization_state.py
+++ b/plasmapy/particles/ionization_state.py
@@ -3,7 +3,7 @@
 a single ionization level.
 """
 
-__all__ = ["IonizationState", "IonicFraction"]
+__all__ = ["IonizationState", "IonicLevel"]
 
 import numpy as np
 import warnings
@@ -22,11 +22,11 @@
 from plasmapy.utils.decorators import validate_quantities
 
 _number_density_errmsg = (
-    "Number densities must be Quantity objects with units of inverse " "volume."
+    "Number densities must be Quantity objects with units of inverse volume."
 )
 
 
-class IonicFraction:
+class IonicLevel:
     """
     Representation of the ionic fraction for a single ion.
 
@@ -49,7 +49,7 @@ class IonicFraction:
 
     Examples
     --------
-    >>> alpha_fraction = IonicFraction("alpha", ionic_fraction=0.31)
+    >>> alpha_fraction = IonicLevel("alpha", ionic_fraction=0.31)
     >>> alpha_fraction.ionic_symbol
     'He-4 2+'
     >>> alpha_fraction.integer_charge
@@ -86,27 +86,28 @@ def __eq__(self, other):
     @particle_input
     def __init__(self, ion: Particle, ionic_fraction=None, number_density=None):
         try:
-            self._particle = ion
+            self.ion = ion
             self.ionic_fraction = ionic_fraction
             self.number_density = number_density
+
         except Exception as exc:
-            raise ParticleError("Unable to create IonicFraction object") from exc
+            raise ParticleError("Unable to create IonicLevel object") from exc
 
     def __repr__(self):
         return (
-            f"IonicFraction({repr(self.ionic_symbol)}, "
+            f"IonicLevel({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
+        return self.ion.ionic_symbol
 
     @property
     def integer_charge(self) -> Integral:
         """The integer charge of the ion."""
-        return self._particle.integer_charge
+        return self.ion.integer_charge
 
     @property
     def ionic_fraction(self) -> Real:
@@ -141,7 +142,7 @@ def ionic_fraction(self, ionfrac: Optional[Real]):
     @property
     def number_density(self) -> u.m ** -3:
         """The number density of the ion."""
-        return self._number_density.to(u.m ** -3)
+        return self._number_density
 
     @number_density.setter
     @validate_quantities(
@@ -198,7 +199,7 @@ class IonizationState:
 
     See Also
     --------
-    IonicFraction
+    IonicLevel
     plasmapy.particles.IonizationStateCollection
 
     Examples
@@ -230,7 +231,7 @@ class IonizationState:
 
     # TODO: Add in functionality to find equilibrium ionization states.
 
-    @validate_quantities(T_e={"equivalencies": u.temperature_energy()})
+    @validate_quantities(T_e={"unit": u.K, "equivalencies": u.temperature_energy()})
     @particle_input(require="element")
     def __init__(
         self,
@@ -295,13 +296,13 @@ def __str__(self) -> str:
     def __repr__(self) -> str:
         return self.__str__()
 
-    def __getitem__(self, value) -> IonicFraction:
+    def __getitem__(self, value) -> IonicLevel:
         """Return information for a single ionization level."""
         if isinstance(value, slice):
             raise TypeError("IonizationState instances cannot be sliced.")
 
         if isinstance(value, Integral) and 0 <= value <= self.atomic_number:
-            result = IonicFraction(
+            result = IonicLevel(
                 ion=Particle(self.base_particle, Z=value),
                 ionic_fraction=self.ionic_fractions[value],
                 number_density=self.number_densities[value],
@@ -321,7 +322,7 @@ def __getitem__(self, value) -> IonicFraction:
 
             if same_element and same_isotope and has_charge_info:
                 Z = value.integer_charge
-                result = IonicFraction(
+                result = IonicLevel(
                     ion=Particle(self.base_particle, Z=Z),
                     ionic_fraction=self.ionic_fractions[Z],
                     number_density=self.number_densities[Z],
@@ -553,7 +554,6 @@ def number_densities(self, value: u.m ** -3):
         self._ionic_fractions = value / self._n_elem
 
     @property
-    @validate_quantities(equivalencies=u.temperature_energy())
     def T_e(self) -> u.K:
         """Return the electron temperature."""
         if self._T_e is None:
@@ -561,7 +561,7 @@ def T_e(self) -> u.K:
         return self._T_e.to(u.K, equivalencies=u.temperature_energy())
 
     @T_e.setter
-    @validate_quantities(equivalencies=u.temperature_energy())
+    @validate_quantities(value=dict(equivalencies=u.temperature_energy()))
     def T_e(self, value: u.K):
         """Set the electron temperature."""
         try:
@@ -641,7 +641,7 @@ def ionic_symbols(self) -> List[str]:
     @property
     def integer_charges(self) -> np.ndarray:
         """Return an array with the integer charges."""
-        return np.arange(0, self.atomic_number + 1, dtype=np.int)
+        return np.arange(0, self.atomic_number + 1, dtype=int)
 
     @property
     def Z_mean(self) -> np.float64:
diff --git a/plasmapy/particles/ionization_state_collection.py b/plasmapy/particles/ionization_state_collection.py
index cbcb634a..abb55dd4 100644
--- a/plasmapy/particles/ionization_state_collection.py
+++ b/plasmapy/particles/ionization_state_collection.py
@@ -17,7 +17,7 @@
     InvalidParticleError,
     ParticleError,
 )
-from plasmapy.particles.ionization_state import IonicFraction, IonizationState
+from plasmapy.particles.ionization_state import IonicLevel, IonizationState
 from plasmapy.particles.particle_class import Particle, ParticleLike
 from plasmapy.particles.symbols import particle_symbol
 from plasmapy.utils.decorators import validate_quantities
@@ -75,7 +75,7 @@ class IonizationStateCollection:
 
     See Also
     --------
-    ~plasmapy.particles.ionization_state.IonicFraction
+    ~plasmapy.particles.ionization_state.IonicLevel
     ~plasmapy.particles.ionization_state.IonizationState
 
     Examples
@@ -187,7 +187,7 @@ def __str__(self) -> str:
     def __repr__(self) -> str:
         return self.__str__()
 
-    def __getitem__(self, *values) -> Union[IonizationState, IonicFraction]:
+    def __getitem__(self, *values) -> Union[IonizationState, IonicLevel]:
 
         errmsg = f"Invalid indexing for IonizationStateCollection instance: {values[0]}"
 
@@ -219,7 +219,7 @@ def __getitem__(self, *values) -> Union[IonizationState, IonicFraction]:
                     raise ChargeError(
                         f"{int_charge} is not a valid charge for {particle}."
                     )
-                return IonicFraction(
+                return IonicLevel(
                     ion=particle_symbol(particle, Z=int_charge),
                     ionic_fraction=self.ionic_fractions[particle][int_charge],
                     number_density=self.number_densities[particle][int_charge],
@@ -298,7 +298,7 @@ def __setitem__(self, key, value):
                     )
 
         try:
-            new_fractions = np.array(value, dtype=np.float64)
+            new_fractions = np.array(value, dtype=float)
         except Exception as exc:
             raise TypeError(
                 f"{errmsg} because value cannot be converted into an "
@@ -560,7 +560,7 @@ def _sort_entries_by_atomic_and_mass_numbers(k):
                 else:
                     try:
                         new_ionic_fractions[particles[key].symbol] = np.array(
-                            inputs[key], dtype=np.float
+                            inputs[key], dtype=float
                         )
                     except ValueError as exc:
                         raise ParticleError(
@@ -599,7 +599,7 @@ def _sort_entries_by_atomic_and_mass_numbers(k):
 
                 new_abundances = {}
                 for key in _elements_and_isotopes:
-                    new_abundances[key] = np.float(n_elems[key] / self.n0)
+                    new_abundances[key] = float(n_elems[key] / self.n0)
 
                 self._pars["abundances"] = new_abundances
 
@@ -619,7 +619,7 @@ def _sort_entries_by_atomic_and_mass_numbers(k):
             ]
             new_ionic_fractions = {
                 particle.symbol: np.full(
-                    particle.atomic_number + 1, fill_value=np.nan, dtype=np.float64
+                    particle.atomic_number + 1, fill_value=np.nan, dtype=float
                 )
                 for particle in _particle_instances
             }
@@ -783,13 +783,14 @@ def log_abundances(self, value: Optional[Dict[str, Real]]):
                 raise ParticleError("Invalid log_abundances.") from None
 
     @property
-    @validate_quantities(equivalencies=u.temperature_energy())
     def T_e(self) -> u.K:
         """Return the electron temperature."""
         return self._pars["T_e"]
 
     @T_e.setter
-    @validate_quantities(equivalencies=u.temperature_energy())
+    @validate_quantities(
+        electron_temperature=dict(equivalencies=u.temperature_energy())
+    )
     def T_e(self, electron_temperature: u.K):
         """Set the electron temperature."""
         try:


Test patch:
diff --git a/plasmapy/particles/tests/test_ionization_collection.py b/plasmapy/particles/tests/test_ionization_collection.py
index a69e4478..e96cdaad 100644
--- a/plasmapy/particles/tests/test_ionization_collection.py
+++ b/plasmapy/particles/tests/test_ionization_collection.py
@@ -9,7 +9,7 @@
 
 from plasmapy.particles import (
     atomic_number,
-    IonicFraction,
+    IonicLevel,
     IonizationState,
     IonizationStateCollection,
     mass_number,
@@ -649,7 +649,7 @@ def test_getitem_two_indices(self, indices):
         particle = indices[0]
         integer_charge = indices[1]
 
-        assert isinstance(result, IonicFraction)
+        assert isinstance(result, IonicLevel)
         assert result.integer_charge == integer_charge
 
         expected_ionic_fraction = instance.ionic_fractions[particle][integer_charge]
diff --git a/plasmapy/particles/tests/test_ionization_state.py b/plasmapy/particles/tests/test_ionization_state.py
index 6a652a6a..7f9298ad 100644
--- a/plasmapy/particles/tests/test_ionization_state.py
+++ b/plasmapy/particles/tests/test_ionization_state.py
@@ -13,7 +13,7 @@
     particle_symbol,
 )
 from plasmapy.particles.exceptions import InvalidIsotopeError, ParticleError
-from plasmapy.particles.ionization_state import IonicFraction, IonizationState
+from plasmapy.particles.ionization_state import IonicLevel, IonizationState
 from plasmapy.utils.pytest_helpers import run_test
 
 ionic_fraction_table = [
@@ -26,7 +26,7 @@
 @pytest.mark.parametrize("ion, ionic_fraction, number_density", ionic_fraction_table)
 def test_ionic_fraction_attributes(ion, ionic_fraction, number_density):
 
-    instance = IonicFraction(
+    instance = IonicLevel(
         ion=ion, ionic_fraction=ionic_fraction, number_density=number_density
     )
 
@@ -48,33 +48,33 @@ def test_ionic_fraction_attributes(ion, ionic_fraction, number_density):
 )
 def test_ionic_fraction_invalid_inputs(invalid_fraction, expected_exception):
     """
-    Test that IonicFraction raises exceptions when the ionic fraction
+    Test that IonicLevel 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)
+        IonicLevel(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
+    Test that `~plasmapy.particles.IonicLevel` 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)
+        IonicLevel(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.
+    Test that a `TypeError` is raised when an `IonicLevel` object
+    is compared to an `IonicLevel` 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)
+    ionic_fraction_1 = IonicLevel(ion=ion1, ionic_fraction=fraction)
+    ionic_fraction_2 = IonicLevel(ion=ion2, ionic_fraction=fraction)
 
     assert (ionic_fraction_1 == ionic_fraction_2) is False
 
@@ -101,7 +101,7 @@ def test_ionization_state_ion_input_error():
     },
     "Li ground state": {
         "particle": "Li",
-        "ionic_fractions": np.array([1, 0, 0, 0], dtype=np.int64),
+        "ionic_fractions": np.array([1, 0, 0, 0], dtype=int),
         "tol": 1e-15,
     },
     "H": {"particle": "H", "ionic_fractions": [0.6, 0.4], "tol": 1e-8},
@@ -555,14 +555,6 @@ def test_IonizationState_exceptions(test):
     )
 
 
-kwargs = {
-    "particle": "He-4",
-    "ionic_fractions": [0.2, 0.3, 0.5],
-    "T_e": 5.0 * u.kK,
-    "tol": 2e-14,
-    "n_elem": 1e13 * u.cm ** -3,
-}
-
 expected_properties = {
     "T_e": 5000.0 * u.K,
     "tol": 2e-14,
@@ -576,23 +568,33 @@ def test_IonizationState_exceptions(test):
     "integer_charges": [0, 1, 2],
     "ionic_fractions": np.array([0.2, 0.3, 0.5]),
     "ionic_symbols": ["He-4 0+", "He-4 1+", "He-4 2+"],
-    "_is_normalized()": True,
     "number_densities": np.array([2e18, 3e18, 5e18]) * u.m ** -3,
     "tol": 2e-14,
-    "__str__()": "<IonizationState instance for He-4>",
 }
 
-instance = IonizationState(**kwargs)
 
+@pytest.fixture
+def instance():
+    kwargs = {
+        "particle": "He-4",
+        "ionic_fractions": [0.2, 0.3, 0.5],
+        "T_e": 5.0 * u.kK,
+        "tol": 2e-14,
+        "n_elem": 1e13 * u.cm ** -3,
+    }
+
+    instance = IonizationState(**kwargs)
+    return instance
 
-@pytest.mark.parametrize("key", expected_properties.keys())
-def test_IonizationState_attributes(key):
+
+@pytest.mark.parametrize("key", expected_properties)
+def test_IonizationState_attributes(instance, key):
     """
     Test a specific case that the `IonizationState` attributes are
     working as expected.
     """
     expected = expected_properties[key]
-    actual = eval(f"instance.{key}")
+    actual = getattr(instance, key)
 
     if isinstance(expected, u.Quantity):
         assert expected.unit == actual.unit, f"Unit mismatch for IonizationState.{key}"
@@ -606,6 +608,11 @@ def test_IonizationState_attributes(key):
             assert np.allclose(expected, actual)
 
 
+def test_IonizationState_methods(instance):
+    assert instance._is_normalized()
+    assert str(instance) == "<IonizationState instance for He-4>"
+
+
 def test_nans():
     """
     Test that when no ionic fractions or temperature are inputted,
diff --git a/plasmapy/particles/tests/test_pickling.py b/plasmapy/particles/tests/test_pickling.py
index 3a685570..5f870797 100644
--- a/plasmapy/particles/tests/test_pickling.py
+++ b/plasmapy/particles/tests/test_pickling.py
@@ -5,7 +5,7 @@
 import pickle
 import pytest
 
-from plasmapy.particles.ionization_state import IonicFraction, IonizationState
+from plasmapy.particles.ionization_state import IonicLevel, IonizationState
 from plasmapy.particles.ionization_state_collection import IonizationStateCollection
 from plasmapy.particles.particle_class import (
     CustomParticle,
@@ -27,7 +27,7 @@ class TestPickling:
             CustomParticle(mass=1 * u.kg, charge=1 * u.C),
             DimensionlessParticle(mass=5, charge=5),
             pytest.param(Particle("p+"), marks=xfail),
-            pytest.param(IonicFraction("p+", 0.1, 1e9 * u.m ** -3), marks=xfail),
+            pytest.param(IonicLevel("p+", 0.1, 1e9 * u.m ** -3), marks=xfail),
             pytest.param(IonizationState("H", [0.5, 0.5]), marks=xfail),
             pytest.param(IonizationStateCollection({"H": [0.5, 0.5]}), marks=xfail),
         ],