Repository: PlasmaPy/PlasmaPy (python)

Issue:
Enable ionization and recombination of Particle objects
There may be occasions where it will be useful to be able to change a `Particle` object representing an ion into a higher or lower charge state.  For example, someone might want to loop through the different charge states of an ion to return something like the ionization energy for each one.

One possibility for the notation would be to create `Particle` class methods called `ionize` and `recombine`:
```Python
>>> from plasmapy.atomic import Particle
>>> deuteron = Particle('D 0+').ionize()
>>> protium = Particle('p+').recombine()
```
These methods would probably be straightforward to implement.

Another possibility would be to use magic methods in the `Particle` class, and enable electrons to be added to or subtracted from ions:
```Python
>>> electron = Particle('e-')
>>> deuteron + electron
Particle('D 0+')
>>> deuteron + 'e-'
Particle('D 0+') 
```
This would involve defining the `Particle.__add__` and `Particle.__sub__` magic methods.

I'm not sure how commonly this would be used, so this might be lower priority.  Alternatively, we could use these magic methods for getting nuclear reaction energies (e.g., `Be-8 > 2 * He-4`) also or instead.

Pull request:
Implement ionize and recombine methods in Particle class
These methods take a `Particle` object and run `__init__` again for the same element/isotope but for a different charge.  The `Particle.ionize` method will raise an `InvalidIonError` if a bare nucleus is attempted to be ionized.  The `recombine` method will issue a warning if the charge becomes negative; however, no exception will be raised in that case.

One behavior that I'm not certain about is both running `__init__` again and returning the new Particle instance.  We may wish to have just one behavior or the other.  If we have just the in-place re-initialization, then a disadvantage is that we would not be able to do comparisons like:
```Python
Particle("Fe 5+").recombine() == Particle("Fe 4+")
```
which require the new `Particle` instance to be returned.  I would appreciate more opinions on whether we should do both `__init__` and returning `self`, or just one of them.  Or, if there is even a better approach than I am taking here.  

I'd also like to check to make sure that there are no side effects from `__init__` again (e.g., if I have a list and add items to it such that every item in the list shows up twice).

Closes #287.

Patch:
diff --git a/plasmapy/atomic/particle_class.py b/plasmapy/atomic/particle_class.py
index 920faf6b..6f09b182 100644
--- a/plasmapy/atomic/particle_class.py
+++ b/plasmapy/atomic/particle_class.py
@@ -5,6 +5,7 @@
 from typing import (Union, Set, Tuple, List, Optional)
 import collections
 import plasmapy.utils.roman as roman
+import numbers
 
 import astropy.units as u
 import astropy.constants as const
@@ -1247,17 +1248,17 @@ def is_category(self,
         """
 
         def become_set(arg: Union[str, Set, Tuple, List]) -> Set[str]:
-                """Change the argument into a `set`."""
-                if len(arg) == 0:
-                    return set()
-                if isinstance(arg, set):
-                    return arg
-                if isinstance(arg, str):
-                    return {arg}
-                if isinstance(arg[0], (tuple, list, set)):
-                    return set(arg[0])
-                else:
-                    return set(arg)
+            """Change the argument into a `set`."""
+            if len(arg) == 0:
+                return set()
+            if isinstance(arg, set):
+                return arg
+            if isinstance(arg, str):
+                return {arg}
+            if isinstance(arg[0], (tuple, list, set)):
+                return set(arg[0])
+            else:
+                return set(arg)
 
         if category_tuple != () and require != set():  # coveralls: ignore
             raise AtomicError(
@@ -1328,3 +1329,162 @@ def is_ion(self) -> bool:
 
         """
         return self.is_category('ion')
+
+    def ionize(self, n: numbers.Integral = 1, inplace: bool = False):
+        """
+        Create a new `~plasmapy.atomic.Particle` instance corresponding
+        to the current `~plasmapy.atomic.Particle` after being ionized
+        `n` times.
+
+        If `inplace` is `False` (default), then return the ionized
+        `~plasmapy.atomic.Particle`.
+
+        If `inplace` is `True`, then replace the current
+        `~plasmapy.atomic.Particle` with the newly ionized
+        `~plasmapy.atomic.Particle`.
+
+        Parameters
+        ----------
+        n : positive integer
+            The number of bound electrons to remove from the
+            `~plasmapy.atomic.Particle` object.  Defaults to `1`.
+
+        inplace : bool, optional
+            If `True`, then replace the current
+            `~plasmapy.atomic.Particle` instance with the newly ionized
+            `~plasmapy.atomic.Particle`.
+
+        Returns
+        -------
+        particle : ~plasmapy.atomic.Particle
+            A new `~plasmapy.atomic.Particle` object that has been
+            ionized `n` times relative to the original
+            `~plasmapy.atomic.Particle`.  If `inplace` is `False`,
+            instead return `None`.
+
+        Raises
+        ------
+        ~plasmapy.atomic.InvalidElementError
+            If the `~plasampy.atomic.Particle` is not an element.
+
+        ~plasmapy.atomic.ChargeError
+            If no charge information for the `~plasmapy.atomic.Particle`
+            object is specified.
+
+        ~plasmapy.atomic.InvalidIonError
+            If there are less than `n` remaining bound electrons.
+
+        ValueError
+            If `n` is not positive.
+
+        Examples
+        --------
+        >>> Particle("Fe 6+").ionize()
+        Particle("Fe 7+")
+        >>> helium_particle = Particle("He-4 0+")
+        >>> helium_particle.ionize(n=2, inplace=True)
+        >>> helium_particle
+        Particle("He-4 2+")
+
+        """
+        if not self.element:
+            raise InvalidElementError(
+                f"Cannot ionize {self.particle} because it is not a "
+                f"neutral atom or ion.")
+        if not self.is_category(any_of={"charged", "uncharged"}):
+            raise ChargeError(
+                f"Cannot ionize {self.particle} because its charge "
+                f"is not specified.")
+        if self.integer_charge == self.atomic_number:
+            raise InvalidIonError(
+                f"The particle {self.particle} is already fully "
+                f"ionized and cannot be ionized further.")
+        if not isinstance(n, numbers.Integral):
+            raise TypeError("n must be a positive integer.")
+        if n <= 0:
+            raise ValueError("n must be a positive number.")
+
+        base_particle = self.isotope if self.isotope else self.element
+        new_integer_charge = self.integer_charge + n
+
+        if inplace:
+            self.__init__(base_particle, Z=new_integer_charge)
+        else:
+            return Particle(base_particle, Z=new_integer_charge)
+
+    def recombine(self, n: numbers.Integral = 1, inplace=False):
+        """
+        Create a new `~plasmapy.atomic.Particle` instance corresponding
+        to the current `~plasmapy.atomic.Particle` after undergoing
+        recombination `n` times.
+
+        If `inplace` is `False` (default), then return the
+        `~plasmapy.atomic.Particle` that just underwent recombination.
+
+        If `inplace` is `True`, then replace the current
+        `~plasmapy.atomic.Particle` with the `~plasmapy.atomic.Particle`
+        that just underwent recombination.
+
+        Parameters
+        ----------
+        n : positive integer
+            The number of electrons to recombine into the
+            `~plasmapy.atomic.Particle` object.
+
+        inplace : bool, optional
+            If `True`, then replace the current
+            `~plasmapy.atomic.Particle` instance with the
+            `~plasmapy.atomic.Particle` that just underwent
+            recombination.
+
+        Returns
+        -------
+        particle : ~plasmapy.atomic.Particle
+            A new `~plasmapy.atomic.Particle` object that has undergone
+            recombination `n` times relative to the original
+            `~plasmapy.atomic.Particle`.  If `inplace` is `False`,
+            instead return `None`.
+
+        Raises
+        ------
+        ~plasmapy.atomic.InvalidElementError
+            If the `~plasampy.atomic.Particle` is not an element.
+
+        ~plasmapy.atomic.ChargeError
+            If no charge information for the `~plasmapy.atomic.Particle`
+            object is specified.
+
+        ValueError
+            If `n` is not positive.
+
+        Examples
+        --------
+        >>> Particle("Fe 6+").recombine()
+        Particle("Fe 5+")
+        >>> helium_particle = Particle("He-4 2+")
+        >>> helium_particle.recombine(n=2, inplace=True)
+        >>> helium_particle
+        Particle("He-4 0+")
+
+        """
+
+        if not self.element:
+            raise InvalidElementError(
+                f"{self.particle} cannot undergo recombination because "
+                f"it is not a neutral atom or ion.")
+        if not self.is_category(any_of={"charged", "uncharged"}):
+            raise ChargeError(
+                f"{self.particle} cannot undergo recombination because "
+                f"its charge is not specified.")
+        if not isinstance(n, numbers.Integral):
+            raise TypeError("n must be a positive integer.")
+        if n <= 0:
+            raise ValueError("n must be a positive number.")
+
+        base_particle = self.isotope if self.isotope else self.element
+        new_integer_charge = self.integer_charge - n
+
+        if inplace:
+            self.__init__(base_particle, Z=new_integer_charge)
+        else:
+            return Particle(base_particle, Z=new_integer_charge)


Test patch:
diff --git a/plasmapy/atomic/tests/test_particle_class.py b/plasmapy/atomic/tests/test_particle_class.py
index 0d37467b..df60a2c5 100644
--- a/plasmapy/atomic/tests/test_particle_class.py
+++ b/plasmapy/atomic/tests/test_particle_class.py
@@ -14,6 +14,7 @@
     InvalidParticleError,
     InvalidElementError,
     InvalidIsotopeError,
+    InvalidIonError,
     ChargeError,
     call_string,
     run_test_equivalent_calls,
@@ -80,6 +81,7 @@
       'periodic_table.period': 1,
       'periodic_table.category': 'nonmetal',
       'binding_energy': 0 * u.J,
+      'recombine()': 'H-1 0+',
       }),
 
     ('p-', {},
@@ -298,6 +300,7 @@
       'baryon_number': 4,
       'lepton_number': 0,
       'half_life': np.inf * u.s,
+      'recombine()': Particle('He-4 1+')
       }),
 
     ('Li', {'mass_numb': 7},
@@ -456,6 +459,8 @@ def test_Particle_class(arg, kwargs, expected_dict):
     ['n', 'n-1', 'neutron', 'NEUTRON'],
     ['muon', 'mu-', 'muon-'],
     ['tau', 'tau-'],
+    [Particle('Fe 5+'), Particle('Fe 4+').ionize()],
+    [Particle('He-4 0+'), Particle('alpha').recombine(2)]
 ]
 
 
@@ -492,6 +497,15 @@ def test_Particle_equivalent_cases(equivalent_particles):
     (Particle('C-14'), {'mass_numb': 13}, "", InvalidParticleError),
     (Particle('Au 1+'), {'Z': 2}, "", InvalidParticleError),
     ([], {}, "", TypeError),
+    ('Fe', {}, ".ionize()", ChargeError),
+    ('D', {}, ".recombine()", ChargeError),
+    ('Fe 26+', {}, ".ionize()", InvalidIonError),
+    ('Fe 6+', {}, ".ionize(-1)", ValueError),
+    ('Fe 25+', {}, ".recombine(0)", ValueError),
+    ('Fe 6+', {}, ".ionize(4.6)", TypeError),
+    ('Fe 25+', {}, ".recombine(8.2)", TypeError),
+    ('e-', {}, ".ionize()", InvalidElementError),
+    ('e+', {}, ".recombine()", InvalidElementError),
 ]