Repository: PlasmaPy/PlasmaPy (python)

Issue:
Have Particle class return corresponding Particle instance for Particle arguments
I've run into a bunch of situations where I have some objects that are either `Particle` class instances or `str`/`int` representations of particles, and I want to make sure that they are all `Particle` class instances.  I have usually written code like this:
```Python
>>> orig_particles = ['H', Particle('He')]
>>> particles = [
        particle if isinstance(particle, Particle) else Particle(particle) 
        for particle in orig_particles
    ]
```
This code is rather obfuscating since it checks on whether or not `particle` is a `Particle` class instance.  It would be simpler to have `Particle` return the corresponding `Particle` instance which would allow code like this:
```Python
>>> particles = [Particle(particle) for particle in particles]
```
@StanczakDominik suggested that this be a copy of the `Particle` instance rather than returning self, which is like how `numpy.array` behaves.

A good test would be to check that `Particle(something) == Particle(Particle(something))`, e.g., something like:
```Python
from plasmapy.atomic import Particle
def test_particle_of_particle(particle_representation):
    particle = Particle(particle_representation)
    particle_of_particle = Particle(particle)
    assert particle == particle_of_particle
    assert particle is not particle_of_particle  # to make sure it is a copy rather than self
```
Thanks! 
-Nick
    

Pull request description:
Make Particle(particle) == Particle(Particle(particle))
This makes `Particle(particle) == Particle(Particle(particle))` while making sure that `Particle(particle) is not Particle(Particle(particle))`. 

Closes #486, using the suggestion made by @colbych.  See #486 and the commit log for details.



Patch:
diff --git a/plasmapy/atomic/particle_class.py b/plasmapy/atomic/particle_class.py
index b7e7ed5c..1a73b294 100644
--- a/plasmapy/atomic/particle_class.py
+++ b/plasmapy/atomic/particle_class.py
@@ -113,9 +113,10 @@ class Particle:
 
     Parameters
     ----------
-    argument : `str` or `int`
-        A string representing a particle, element, isotope, or ion; or
-        an integer representing the atomic number of an element.
+    argument : `str`, `int`, or `~plasmapy.atomic.Particle`
+        A string representing a particle, element, isotope, or ion; an
+        integer representing the atomic number of an element; or a
+        `Particle` instance.
 
     mass_numb : `int`, optional
         The mass number of an isotope or nuclide.
@@ -238,6 +239,25 @@ class Particle:
     >>> ~positron
     Particle("e-")
 
+    A `~plasmapy.atomic.Particle` instance may be used as the first
+    argument to `~plasmapy.atomic.Particle`.
+
+    >>> iron = Particle('Fe')
+    >>> iron == Particle(iron)
+    True
+    >>> Particle(iron, mass_numb=56, Z=6)
+    Particle("Fe-56 6+")
+
+    If the previously constructed `~plasmapy.atomic.Particle` instance
+    represents an element, then the `Z` and `mass_numb` arguments may be
+    used to specify an ion or isotope.
+
+    >>> iron = Particle('Fe')
+    >>> Particle(iron, Z=1)
+    Particle("Fe 1+")
+    >>> Particle(iron, mass_numb=56)
+    Particle("Fe-56")
+
     The `~plasmapy.atomic.particle_class.Particle.categories` attribute
     and `~plasmapy.atomic.particle_class.Particle.is_category` method
     may be used to find and test particle membership in categories.
@@ -257,14 +277,22 @@ class Particle:
 
     def __init__(self, argument: Union[str, int], mass_numb: int = None, Z: int = None):
         """
-        Initialize a `~plasmapy.atomic.Particle` object and set private
+        Instantiate a `~plasmapy.atomic.Particle` object and set private
         attributes.
         """
 
-        if not isinstance(argument, (int, str)):
+        if not isinstance(argument, (int, np.integer, str, Particle)):
             raise TypeError(
                 "The first positional argument when creating a Particle "
-                "object must be either an integer or string.")
+                "object must be either an integer, string, or another"
+                "Particle object.")
+
+        # If argument is a Particle instance, then we will construct a
+        # new Particle instance for the same Particle (essentially a
+        # copy).
+
+        if isinstance(argument, Particle):
+            argument = argument.particle
 
         if mass_numb is not None and not isinstance(mass_numb, int):
             raise TypeError("mass_numb is not an integer")


Test patch:
diff --git a/plasmapy/atomic/tests/test_particle_class.py b/plasmapy/atomic/tests/test_particle_class.py
index 9d3308e0..1cb727c4 100644
--- a/plasmapy/atomic/tests/test_particle_class.py
+++ b/plasmapy/atomic/tests/test_particle_class.py
@@ -377,6 +377,17 @@
       'is_category("boson", exclude="boson")': AtomicError,
       'is_category(any_of="boson", exclude="boson")': AtomicError,
       }),
+
+    (Particle('C'), {},
+     {'particle': 'C',
+      }),
+
+    (Particle('C'), {'Z': 3, 'mass_numb': 14},
+     {'particle': 'C-14 3+',
+      'element': 'C',
+      'isotope': 'C-14',
+      'ionic_symbol': 'C-14 3+',
+     }),
 ]
 
 
@@ -478,6 +489,8 @@ def test_Particle_equivalent_cases(equivalent_particles):
     ('Fe', {}, '.spin', MissingAtomicDataError),
     ('nu_e', {}, '.mass', MissingAtomicDataError),
     ('Og', {}, '.standard_atomic_weight', MissingAtomicDataError),
+    (Particle('C-14'), {'mass_numb': 13}, "", InvalidParticleError),
+    (Particle('Au 1+'), {'Z': 2}, "", InvalidParticleError),
     ([], {}, "", TypeError),
 ]
 
@@ -707,3 +720,25 @@ def test_antiparticle_attribute_and_operator(self, particle, opposite):
             (f"{repr(particle)}.antiparticle returned "
              f"{particle.antiparticle}, whereas ~{repr(particle)} "
              f"returned {~particle}.")
+
+
+@pytest.mark.parametrize('arg', ['e-', 'D+', 'Fe 25+', 'H-', 'mu+'])
+def test_particleing_a_particle(arg):
+    """
+    Test that Particle(arg) is equal to Particle(Particle(arg)), but is
+    not the same object in memory.
+    """
+    particle = Particle(arg)
+
+    assert particle == Particle(particle), (
+        f"Particle({repr(arg)}) does not equal "
+        f"Particle(Particle({repr(arg)}).")
+
+    assert particle == Particle(Particle(Particle(particle))), (
+        f"Particle({repr(arg)}) does not equal "
+        f"Particle(Particle(Particle({repr(arg)})).")
+
+    assert particle is not Particle(particle), (
+        f"Particle({repr(arg)}) is the same object in memory as "
+        f"Particle(Particle({repr(arg)})), when it is intended to "
+        f"create a new object in memory (e.g., a copy).")