Repository: PlasmaPy/PlasmaPy

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.


Discussion:
When I hear "looping over charge states", I think "okay, now I really need to read up on how iterators work in Python"... Perhaps those may be handy here! I don't know yet, but I'll report back soon.
Could you just make particle an iterable object over charge state objects via `__iter__` and then have most functions accept charge state objects?
Actually maybe we could have a method called `iter_charge_states` rather than making the object itself an iterable. It shouldn't be difficult to whip up an example. Will do.

Resolving patch:
diff --git a/plasmapy/atomic/particle_class.py b/plasmapy/atomic/particle_class.py
--- 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
--- 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),
 ]