Repository: PlasmaPy/PlasmaPy

Issue:
Bug in `Particle.__eq__` for protons
I'm finding differences between protons created by `Particle("p+")` and `Particle("H", Z=1, mass_numb=1)`, as follows.

```Python
>>> from plasmapy.particles import Particle
>>> proton = Particle("p+")
>>> should_be_proton = Particle("H", Z=1, mass_numb=1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/namurphy/.miniconda/lib/python3.9/site-packages/plasmapy-0.0.0-py3.9.egg/plasmapy/particles/particle_class.py", line 665, in __eq__
    raise ParticleError(
plasmapy.particles.exceptions.ParticleError: p+ and p+ should be the same Particle, but have differing attributes.

The attributes of p+ are:

defaultdict(<class 'NoneType'>, {'symbol': 'p+', 'element': 'H', 'atomic number': 1, 'mass number': 1, 'element name': 'hydrogen', 'isotope': 'H-1', 'ion': 'p+', 'mass': <<class 'astropy.constants.codata2018.CODATA2018'> name='Proton mass' value=1.67262192369e-27 uncertainty=5.1e-37 unit='kg' reference='CODATA 2018'>, 'charge number': 1, 'periodic table': _PeriodicTable(group=1, period=1, block='s', category='nonmetal'), 'name': 'proton', 'spin': 0.5, 'class': 'baryon', 'lepton number': 0, 'baryon number': 1, 'half-life': <Quantity inf s>, 'antimatter': False, 'charge': <<class 'astropy.constants.codata2018.EMCODATA2018'> name='Electron charge' value=1.602176634e-19 uncertainty=0.0 unit='C' reference='CODATA 2018'>, 'isotope mass': None, 'isotopic abundance': None})

The attributes of p+ are:

defaultdict(<class 'NoneType'>, {'symbol': 'p+', 'element': 'H', 'isotope': 'H-1', 'ion': 'p+', 'mass number': 1, 'charge number': 1, 'atomic number': 1, 'element name': 'hydrogen', 'lepton number': 0, 'baryon number': 1, 'isotope mass': <Quantity 1.00782503 u>, 'isotopic abundance': 0.999885, 'half-life': <Quantity inf s>, 'mass': <<class 'astropy.constants.codata2018.CODATA2018'> name='Proton mass' value=1.67262192369e-27 uncertainty=5.1e-37 unit='kg' reference='CODATA 2018'>, 'periodic table': _PeriodicTable(group=1, period=1, block='s', category='nonmetal'), 'charge': <<class 'astropy.constants.codata2018.EMCODATA2018'> name='Electron charge' value=1.602176634e-19 uncertainty=0.0 unit='C' reference='CODATA 2018'>, 'name': None, 'spin': None, 'class': None, 'antimatter': None})
>>> proton._attributes == should_be_proton._attributes
False
>>> for key, val in proton._attributes.items():
...     new_val = should_be_proton._attributes[key]
...     if val != new_val:
...         print(f"{key}: {val}, {new_val}")
name: proton, None
spin: 0.5, None
class: baryon, None
antimatter: False, None
isotope mass: None, 1.00782503223 u
isotopic abundance: None, 0.999885
```
So far I've only found this discrepancy for protons, but it's good practice to test exhaustively for bugs, so the fix should include similar tests for protons, deuterons, tritons, and alphas.  Yay for special cases...

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) Report
> Merging [#1366](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) (c995b13) into [main](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/672be92ae5a61f5362cc0a610d75737ddb08f033?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) (672be92) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366/graphs/tree.svg?width=650&height=150&src=pr&token=LzZ5mnXVad&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy)](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy)

```diff
@@           Coverage Diff           @@
##             main    #1366   +/-   ##
=======================================
  Coverage   97.09%   97.10%           
=======================================
  Files          75       75           
  Lines        7235     7253   +18     
=======================================
+ Hits         7025     7043   +18     
  Misses        210      210           
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) | Coverage Δ | |
|---|---|---|
| [plasmapy/particles/parsing.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy#diff-cGxhc21hcHkvcGFydGljbGVzL3BhcnNpbmcucHk=) | `100.00% <100.00%> (ø)` | |
| [plasmapy/particles/particle\_class.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy#diff-cGxhc21hcHkvcGFydGljbGVzL3BhcnRpY2xlX2NsYXNzLnB5) | `99.16% <100.00%> (+0.02%)` | :arrow_up: |
| [plasmapy/particles/special\_particles.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy#diff-cGxhc21hcHkvcGFydGljbGVzL3NwZWNpYWxfcGFydGljbGVzLnB5) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy). Last update [672be92...c995b13](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1366?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy).

namurphy: The issue with the proton equality bug was that protons need to use the initialization for both special particles and for atoms.  When it was getting input like `Particle("H", Z=1, mass_numb=1)`, it was only doing the atom initialization.

I ended up doing more refactoring than I expected in this.  I think the only real change in functionality was the fixing of that bug.  The main changes are in `particle_class.py`, with changes in most other `plasmapy.particles` files being renaming of various things.

I also dropped the creation of `Particle.__name__` which didn't seem like it was needed.    
namurphy:  - [ ] Look into `Particle.__name__` getting removed, but it is still (I think) being used in tests.  Need to make sure that the tests don't depend on `Particle.__name__`

Patch:
diff --git a/changelog/1366.bugfix.rst b/changelog/1366.bugfix.rst
new file mode 100644
index 00000000..bf676e60
--- /dev/null
+++ b/changelog/1366.bugfix.rst
@@ -0,0 +1,2 @@
+Fixed a bug with |Particle| where ``Particle("p+") == Particle("H", Z=1,
+mass_numb=1)`` led to a |ParticleError|.
diff --git a/changelog/1366.trivial.rst b/changelog/1366.trivial.rst
new file mode 100644
index 00000000..67e4f5a6
--- /dev/null
+++ b/changelog/1366.trivial.rst
@@ -0,0 +1,2 @@
+Used the extract method refactoring pattern on the initialization of
+|Particle| objects.
diff --git a/plasmapy/particles/atomic.py b/plasmapy/particles/atomic.py
index 12d5892c..4eb14d3e 100644
--- a/plasmapy/particles/atomic.py
+++ b/plasmapy/particles/atomic.py
@@ -28,14 +28,14 @@
 from typing import Any, List, Optional, Union
 
 from plasmapy.particles.decorators import particle_input
-from plasmapy.particles.elements import _elements
+from plasmapy.particles.elements import _data_about_elements
 from plasmapy.particles.exceptions import (
     InvalidElementError,
     InvalidIsotopeError,
     InvalidParticleError,
     MissingParticleDataError,
 )
-from plasmapy.particles.isotopes import _isotopes
+from plasmapy.particles.isotopes import _data_about_isotopes
 from plasmapy.particles.particle_class import Particle
 from plasmapy.particles.symbols import atomic_symbol
 from plasmapy.utils.decorators.deprecation import deprecated
@@ -590,7 +590,7 @@ def known_isotopes(argument: Union[str, Integral] = None) -> List[str]:
     def known_isotopes_for_element(argument):
         element = atomic_symbol(argument)
         isotopes = []
-        for isotope in _isotopes.keys():
+        for isotope in _data_about_isotopes:
             if element + "-" in isotope and isotope[0 : len(element)] == element:
                 isotopes.append(isotope)
         if element == "H":
@@ -616,7 +616,7 @@ def known_isotopes_for_element(argument):
             raise InvalidParticleError("Invalid particle in known_isotopes.")
     elif argument is None:
         isotopes_list = []
-        for atomic_numb in range(1, len(_elements.keys()) + 1):
+        for atomic_numb in range(1, len(_data_about_elements) + 1):
             isotopes_list += known_isotopes_for_element(atomic_numb)
 
     return isotopes_list
@@ -700,11 +700,13 @@ def common_isotopes_for_element(
         isotopes = known_isotopes(argument)
 
         CommonIsotopes = [
-            isotope for isotope in isotopes if "abundance" in _isotopes[isotope].keys()
+            isotope
+            for isotope in isotopes
+            if "abundance" in _data_about_isotopes[isotope]
         ]
 
         isotopic_abundances = [
-            _isotopes[isotope]["abundance"] for isotope in CommonIsotopes
+            _data_about_isotopes[isotope]["abundance"] for isotope in CommonIsotopes
         ]
 
         sorted_isotopes = [
@@ -818,7 +820,7 @@ def stable_isotopes_for_element(
         StableIsotopes = [
             isotope
             for isotope in KnownIsotopes
-            if _isotopes[isotope]["stable"] == stable_only
+            if _data_about_isotopes[isotope]["stable"] == stable_only
         ]
         return StableIsotopes
 
@@ -962,7 +964,7 @@ def periodic_table_period(argument: Union[str, Integral]) -> Integral:
             "integer representing its atomic number."
         )
     symbol = atomic_symbol(argument)
-    period = _elements[symbol]["period"]
+    period = _data_about_elements[symbol]["period"]
     return period
 
 
@@ -1016,7 +1018,7 @@ def periodic_table_group(argument: Union[str, Integral]) -> Integral:
             "symbol, or an integer representing its atomic number."
         )
     symbol = atomic_symbol(argument)
-    group = _elements[symbol]["group"]
+    group = _data_about_elements[symbol]["group"]
     return group
 
 
@@ -1070,7 +1072,7 @@ def periodic_table_block(argument: Union[str, Integral]) -> str:
             "symbol, or an integer representing its atomic number."
         )
     symbol = atomic_symbol(argument)
-    block = _elements[symbol]["block"]
+    block = _data_about_elements[symbol]["block"]
     return block
 
 
@@ -1122,7 +1124,7 @@ def periodic_table_category(argument: Union[str, Integral]) -> str:
             "symbol, or an integer representing its atomic number."
         )
     symbol = atomic_symbol(argument)
-    category = _elements[symbol]["category"]
+    category = _data_about_elements[symbol]["category"]
     return category
 
 
diff --git a/plasmapy/particles/elements.py b/plasmapy/particles/elements.py
index 8c229074..d291ed63 100644
--- a/plasmapy/particles/elements.py
+++ b/plasmapy/particles/elements.py
@@ -45,16 +45,16 @@ def _element_obj_hook(obj):
 #    json.dump(_Elements, f, default=plasma_default, indent=2)
 
 
-_elements = json.loads(
+_data_about_elements = json.loads(
     pkgutil.get_data("plasmapy", "particles/data/elements.json"),
     object_hook=_element_obj_hook,
 )
 
 
 _atomic_numbers_to_symbols = {
-    elemdict["atomic number"]: symb for (symb, elemdict) in _elements.items()
+    elemdict["atomic number"]: symb for (symb, elemdict) in _data_about_elements.items()
 }
 
 _element_names_to_symbols = {
-    elemdict["element name"]: symb for (symb, elemdict) in _elements.items()
+    elemdict["element name"]: symb for (symb, elemdict) in _data_about_elements.items()
 }
diff --git a/plasmapy/particles/isotopes.py b/plasmapy/particles/isotopes.py
index 39aa8dc8..bc81f8cb 100644
--- a/plasmapy/particles/isotopes.py
+++ b/plasmapy/particles/isotopes.py
@@ -32,7 +32,7 @@ def _isotope_obj_hook(obj):
 
 
 #: Dictionary of isotope data.
-_isotopes = json.loads(
+_data_about_isotopes = json.loads(
     pkgutil.get_data("plasmapy", "particles/data/isotopes.json"),
     object_hook=_isotope_obj_hook,
 )
diff --git a/plasmapy/particles/parsing.py b/plasmapy/particles/parsing.py
index fa09c23f..93f588a1 100644
--- a/plasmapy/particles/parsing.py
+++ b/plasmapy/particles/parsing.py
@@ -16,20 +16,23 @@
 
 from plasmapy.particles.elements import (
     _atomic_numbers_to_symbols,
+    _data_about_elements,
     _element_names_to_symbols,
-    _elements,
 )
 from plasmapy.particles.exceptions import (
     InvalidElementError,
     InvalidParticleError,
     ParticleWarning,
 )
-from plasmapy.particles.isotopes import _isotopes
-from plasmapy.particles.special_particles import _Particles, ParticleZoo
+from plasmapy.particles.isotopes import _data_about_isotopes
+from plasmapy.particles.special_particles import (
+    _data_about_special_particles,
+    ParticleZoo,
+)
 from plasmapy.utils import roman
 
 
-def _create_alias_dicts(Particles: dict) -> (Dict[str, str], Dict[str, str]):
+def _create_alias_dicts(particles: dict) -> (Dict[str, str], Dict[str, str]):
     """
     Create dictionaries for case sensitive aliases and case
     insensitive aliases of special particles and antiparticles.
@@ -42,8 +45,8 @@ def _create_alias_dicts(Particles: dict) -> (Dict[str, str], Dict[str, str]):
     case_sensitive_aliases = {}
     case_insensitive_aliases = {}
 
-    for symbol in Particles.keys():
-        name = Particles[symbol]["name"]
+    for symbol in particles:
+        name = particles[symbol]["name"]
         case_insensitive_aliases[name.lower()] = symbol
 
     case_sensitive_aliases_for_a_symbol = [
@@ -111,7 +114,9 @@ def _create_alias_dicts(Particles: dict) -> (Dict[str, str], Dict[str, str]):
     return case_sensitive_aliases, case_insensitive_aliases
 
 
-_case_sensitive_aliases, _case_insensitive_aliases = _create_alias_dicts(_Particles)
+_case_sensitive_aliases, _case_insensitive_aliases = _create_alias_dicts(
+    _data_about_special_particles
+)
 
 
 def _dealias_particle_aliases(alias: Union[str, Integral]) -> str:
@@ -129,9 +134,9 @@ def _dealias_particle_aliases(alias: Union[str, Integral]) -> str:
         or alias in _case_insensitive_aliases.values()
     ):
         symbol = alias
-    elif alias in _case_sensitive_aliases.keys():
+    elif alias in _case_sensitive_aliases:
         symbol = _case_sensitive_aliases[alias]
-    elif alias.lower() in _case_insensitive_aliases.keys():
+    elif alias.lower() in _case_insensitive_aliases:
         symbol = _case_insensitive_aliases[alias.lower()]
     else:
         symbol = alias
@@ -269,7 +274,7 @@ def _atomic_number_to_symbol(atomic_numb: Integral):
         `~plasmapy.particles.exceptions.InvalidParticleError` if the atomic number does
         not represent a known element.
         """
-        if atomic_numb in _atomic_numbers_to_symbols.keys():
+        if atomic_numb in _atomic_numbers_to_symbols:
             return _atomic_numbers_to_symbols[atomic_numb]
         else:
             raise InvalidParticleError(f"{atomic_numb} is not a valid atomic number.")
@@ -311,7 +316,7 @@ 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():
+        if element_info.lower() in _element_names_to_symbols:
             element = _element_names_to_symbols[element_info.lower()]
         elif element_info in _atomic_numbers_to_symbols.values():
             element = element_info
@@ -339,7 +344,7 @@ def _reconstruct_isotope_symbol(element: str, mass_numb: Integral) -> str:
             elif isotope == "H-3":
                 isotope = "T"
 
-            if isotope not in _isotopes.keys():
+            if isotope not in _data_about_isotopes:
                 raise InvalidParticleError(
                     f"The string '{isotope}' does not correspond to "
                     f"a valid isotope."
@@ -440,10 +445,10 @@ def _reconstruct_ion_symbol(
         Z = Z_from_arg
 
     if isinstance(Z, Integral):
-        if Z > _elements[element]["atomic number"]:
+        if Z > _data_about_elements[element]["atomic number"]:
             raise InvalidParticleError(
                 f"The charge number Z = {Z} cannot exceed the atomic number "
-                f"of {element}, which is {_elements[element]['atomic number']}."
+                f"of {element}, which is {_data_about_elements[element]['atomic number']}."
             )
         elif Z <= -3:
             warnings.warn(
@@ -463,7 +468,7 @@ def _reconstruct_ion_symbol(
     else:
         symbol = element
 
-    nomenclature_dict = {
+    return {
         "symbol": symbol,
         "element": element,
         "isotope": isotope,
@@ -472,8 +477,6 @@ def _reconstruct_ion_symbol(
         "charge number": Z,
     }
 
-    return nomenclature_dict
-
 
 def _parse_and_check_molecule_input(argument: str, Z: Integral = None):
     """
diff --git a/plasmapy/particles/particle_class.py b/plasmapy/particles/particle_class.py
index 990932e7..dd60aa23 100644
--- a/plasmapy/particles/particle_class.py
+++ b/plasmapy/particles/particle_class.py
@@ -24,7 +24,8 @@
 from numbers import Integral, Real
 from typing import Iterable, List, Optional, Set, Tuple, Union
 
-from plasmapy.particles.elements import _elements, _PeriodicTable
+from plasmapy.particles import parsing
+from plasmapy.particles.elements import _data_about_elements, _PeriodicTable
 from plasmapy.particles.exceptions import (
     ChargeError,
     InvalidElementError,
@@ -36,16 +37,10 @@
     ParticleError,
     ParticleWarning,
 )
-from plasmapy.particles.isotopes import _isotopes
-from plasmapy.particles.parsing import (
-    _dealias_particle_aliases,
-    _invalid_particle_errmsg,
-    _parse_and_check_atomic_input,
-    _parse_and_check_molecule_input,
-)
+from plasmapy.particles.isotopes import _data_about_isotopes
 from plasmapy.particles.special_particles import (
     _antiparticles,
-    _Particles,
+    _data_about_special_particles,
     _special_ion_masses,
     ParticleZoo,
 )
@@ -423,9 +418,42 @@ class Particle(AbstractPhysicalParticle):
     ``'transition metal'``, ``'uncharged'``, and ``'unstable'``.
     """
 
-    @staticmethod
-    def _validate_arguments(argument, Z, mass_numb):
+    def __init__(
+        self,
+        argument: ParticleLike,
+        mass_numb: Integral = None,
+        Z: Integral = None,
+    ):
+
+        # If argument is a Particle instance, then construct a new
+        # Particle instance for the same particle.
+
+        if isinstance(argument, Particle):
+            argument = argument.symbol
+
+        self.__inputs = argument, mass_numb, Z
+
+        self._initialize_attributes_and_categories()
+        self._store_particle_identity()
+        self._assign_particle_attributes()
+        self._add_charge_information()
+        self._add_half_life_information()
+
+        # If __name__ is not defined here, then problems with the doc
+        # build arise related to the Particle instances that are
+        # defined in plasmapy/particles/__init__.py.
+
+        self.__name__ = self.__repr__()
+
+    def _initialize_attributes_and_categories(self):
+        """Create empty collections for attributes and categories."""
+        self._attributes = defaultdict(type(None))
+        self._categories = set()
+
+    def _validate_inputs(self):
         """Raise appropriate exceptions when inputs are invalid."""
+        argument, mass_numb, Z = self.__inputs
+
         if not isinstance(argument, (Integral, np.integer, str, Particle)):
             raise TypeError(
                 "The first positional argument when creating a "
@@ -439,37 +467,77 @@ def _validate_arguments(argument, Z, mass_numb):
         if Z is not None and not isinstance(Z, Integral):
             raise TypeError("Z is not an integer.")
 
-    def _initialize_attrs_categories(self):
-        """Create empty collections for attributes & categories."""
+    def _store_particle_identity(self):
+        """Store the particle's symbol and identifying information."""
+        self._validate_inputs()
+        argument, mass_numb, Z = self.__inputs
+        symbol = parsing._dealias_particle_aliases(argument)
+        if symbol in _data_about_special_particles:
+            self._attributes["symbol"] = symbol
+        else:
+            self._store_identity_of_atom(argument)
 
-        self._attributes = defaultdict(type(None))
-        self._categories = set()
+    def _store_identity_of_atom(self, argument):
+        """
+        Store the particle's symbol, element, isotope, ion, mass number,
+        and charge number.
+        """
+        _, mass_numb, Z = self.__inputs
+
+        try:
+            information_about_atom = parsing._parse_and_check_atomic_input(
+                argument,
+                mass_numb=mass_numb,
+                Z=Z,
+            )
+        except Exception as exc:
+            errmsg = parsing._invalid_particle_errmsg(
+                argument, mass_numb=mass_numb, Z=Z
+            )
+            raise InvalidParticleError(errmsg) from exc
 
-    def _initialize_special_particle(self, particle_symbol, Z, mass_numb):
+        self._attributes["symbol"] = information_about_atom["symbol"]
+
+        for key in information_about_atom:
+            self._attributes[key] = information_about_atom[key]
+
+    def _assign_particle_attributes(self):
+        """Assign particle attributes and categories."""
+        if self.symbol in _data_about_special_particles:
+            self._assign_special_particle_attributes()
+        else:
+            self._assign_atom_attributes()
+
+    def _assign_special_particle_attributes(self):
         """Initialize special particles."""
         attributes = self._attributes
         categories = self._categories
 
-        attributes["symbol"] = particle_symbol
-
-        for attribute in _Particles[particle_symbol].keys():
-            attributes[attribute] = _Particles[particle_symbol][attribute]
+        for attribute in _data_about_special_particles[self.symbol]:
+            attributes[attribute] = _data_about_special_particles[self.symbol][
+                attribute
+            ]
 
         particle_taxonomy = ParticleZoo._taxonomy_dict
         all_categories = particle_taxonomy.keys()
 
         for category in all_categories:
-            if particle_symbol in particle_taxonomy[category]:
+            if self.symbol in particle_taxonomy[category]:
                 categories.add(category)
 
         if attributes["name"] in _specific_particle_categories:
             categories.add(attributes["name"])
 
-        if particle_symbol == "p+":
+        # Protons are treated specially because they can be considered
+        # both as special particles and atomic particles.
+
+        if self.symbol == "p+":
             categories.update({"element", "isotope", "ion"})
 
+        _, mass_numb, Z = self.__inputs
+
         if mass_numb is not None or Z is not None:
-            if particle_symbol == "p+" and (mass_numb == 1 or Z == 1):
+            if self.symbol == "p+" and (mass_numb == 1 or Z == 1):
                 warnings.warn(
                     "Redundant mass number or charge information.", ParticleWarning
                 )
@@ -481,22 +549,11 @@ def _initialize_special_particle(self, particle_symbol, Z, mass_numb):
                     f"use:  Particle({repr(attributes['particle'])})"
                 )
 
-    def _initialize_atom(self, argument, Z, mass_numb):
-        """Assign attributes and categories to atoms."""
+    def _assign_atom_attributes(self):
+        """Assign attributes and categories to elements, isotopes, and ions."""
         attributes = self._attributes
         categories = self._categories
 
-        try:
-            nomenclature = _parse_and_check_atomic_input(
-                argument, mass_numb=mass_numb, Z=Z
-            )
-        except Exception as exc:
-            errmsg = _invalid_particle_errmsg(argument, mass_numb=mass_numb, Z=Z)
-            raise InvalidParticleError(errmsg) from exc
-
-        for key in nomenclature.keys():
-            attributes[key] = nomenclature[key]
-
         element = attributes["element"]
         isotope = attributes["isotope"]
         ion = attributes["ion"]
@@ -510,10 +567,10 @@ def _initialize_atom(self, argument, Z, mass_numb):
 
         # Element properties
 
-        Element = _elements[element]
+        this_element = _data_about_elements[element]
 
-        attributes["atomic number"] = Element["atomic number"]
-        attributes["element name"] = Element["element name"]
+        attributes["atomic number"] = this_element["atomic number"]
+        attributes["element name"] = this_element["element name"]
 
         # Set the lepton number to zero for elements, isotopes, and
         # ions.  The lepton number will probably come up primarily
@@ -523,31 +580,31 @@ def _initialize_atom(self, argument, Z, mass_numb):
 
         if isotope:
 
-            Isotope = _isotopes[isotope]
+            this_isotope = _data_about_isotopes[isotope]
 
-            attributes["baryon number"] = Isotope["mass number"]
-            attributes["isotope mass"] = Isotope.get("mass", None)
-            attributes["isotopic abundance"] = Isotope.get("abundance", 0.0)
+            attributes["baryon number"] = this_isotope["mass number"]
+            attributes["isotope mass"] = this_isotope.get("mass", None)
+            attributes["isotopic abundance"] = this_isotope.get("abundance", 0.0)
 
-            if Isotope["stable"]:
+            if this_isotope["stable"]:
                 attributes["half-life"] = np.inf * u.s
             else:
-                attributes["half-life"] = Isotope.get("half-life", None)
+                attributes["half-life"] = this_isotope.get("half-life", None)
 
         if element and not isotope:
-            attributes["standard atomic weight"] = Element.get("atomic mass", None)
+            attributes["standard atomic weight"] = this_element.get("atomic mass", None)
 
-        if ion in _special_ion_masses.keys():
+        if ion in _special_ion_masses:
             attributes["mass"] = _special_ion_masses[ion]
 
         attributes["periodic table"] = _PeriodicTable(
-            group=Element["group"],
-            period=Element["period"],
-            block=Element["block"],
-            category=Element["category"],
+            group=this_element["group"],
+            period=this_element["period"],
+            block=this_element["block"],
+            category=this_element["category"],
         )
 
-        categories.add(Element["category"])
+        categories.add(this_element["category"])
 
     def _add_charge_information(self):
         """Assign attributes and categories related to charge information."""
@@ -571,38 +628,6 @@ def _add_half_life_information(self):
             else:
                 self._categories.add("unstable")
 
-    def __init__(
-        self,
-        argument: ParticleLike,
-        mass_numb: Integral = None,
-        Z: Integral = None,
-    ):
-        """Instantiate a |Particle| object and set private attributes."""
-
-        # 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.symbol
-
-        self._validate_arguments(argument, Z=Z, mass_numb=mass_numb)
-        self._initialize_attrs_categories()
-
-        particle_symbol = _dealias_particle_aliases(argument)
-
-        is_special_particle = particle_symbol in _Particles.keys()
-
-        if is_special_particle:
-            self._initialize_special_particle(particle_symbol, Z=Z, mass_numb=mass_numb)
-        else:
-            self._initialize_atom(particle_symbol, Z=Z, mass_numb=mass_numb)
-
-        self._add_charge_information()
-        self._add_half_life_information()
-
-        self.__name__ = self.__repr__()
-
     def __repr__(self) -> str:
         """
         Return a call string that would recreate this object.
@@ -665,10 +690,10 @@ def __eq__(self, other) -> bool:
         # TODO: create function in utils to account for equality between
         # defaultdicts, and implement it here
 
-        for attribute in self._attributes.keys():
+        for attribute in self._attributes:
             other._attributes[attribute]
 
-        for attribute in other._attributes.keys():
+        for attribute in other._attributes:
             self._attributes[attribute]
 
         same_attributes = self._attributes == other._attributes
@@ -770,7 +795,7 @@ def antiparticle(self) -> Particle:
         >>> ~antineutron
         Particle("n")
         """
-        if self.symbol in _antiparticles.keys():
+        if self.symbol in _antiparticles:
             return Particle(_antiparticles[self.symbol])
         else:
             raise ParticleError(
@@ -2301,7 +2326,9 @@ def molecule(
     try:
         return Particle(symbol, Z=Z)
     except ParticleError:
-        element_dict, bare_symbol, Z = _parse_and_check_molecule_input(symbol, Z)
+        element_dict, bare_symbol, Z = parsing._parse_and_check_molecule_input(
+            symbol, Z
+        )
         mass = 0 * u.kg
         for element_symbol, amount in element_dict.items():
             try:
diff --git a/plasmapy/particles/special_particles.py b/plasmapy/particles/special_particles.py
index f2fe842f..82a1b857 100644
--- a/plasmapy/particles/special_particles.py
+++ b/plasmapy/particles/special_particles.py
@@ -251,11 +251,11 @@ def _create_Particles_dict() -> Dict[str, dict]:
         },
     }
 
-    for particle in special_attributes.keys():
+    for particle in special_attributes:
         Particles[particle] = {**special_attributes[particle], **Particles[particle]}
 
     for particle in ParticleZoo.everything:
-        if "half-life" not in Particles[particle].keys():
+        if "half-life" not in Particles[particle]:
             Particles[particle]["half-life"] = np.inf * u.s
 
     for particle in ParticleZoo.particles:
@@ -267,7 +267,7 @@ def _create_Particles_dict() -> Dict[str, dict]:
     return Particles
 
 
-_Particles = _create_Particles_dict()
+_data_about_special_particles = _create_Particles_dict()
 
 _special_ion_masses = {
     "p+": const.m_p,
@@ -298,4 +298,4 @@ def _create_Particles_dict() -> Dict[str, dict]:
     from pprint import pprint
 
     print("Particles:")
-    pprint(_Particles)
+    pprint(_data_about_special_particles)


Test patch:
diff --git a/plasmapy/particles/tests/test_atomic.py b/plasmapy/particles/tests/test_atomic.py
index dae299f7..de46457a 100644
--- a/plasmapy/particles/tests/test_atomic.py
+++ b/plasmapy/particles/tests/test_atomic.py
@@ -38,7 +38,7 @@
     stable_isotopes,
     standard_atomic_weight,
 )
-from ..isotopes import _isotopes
+from ..isotopes import _data_about_isotopes
 from ..nuclear import nuclear_binding_energy, nuclear_reaction_energy
 from ..symbols import atomic_symbol, element_name, isotope_symbol
 
@@ -383,10 +383,10 @@ def test_half_life():
 def test_half_life_unstable_isotopes():
     """Test that `half_life` returns `None` and raises an exception for
     all isotopes that do not yet have half-life data."""
-    for isotope in _isotopes.keys():
+    for isotope in _data_about_isotopes:
         if (
-            "half_life" not in _isotopes[isotope].keys()
-            and not _isotopes[isotope].keys()
+            "half_life" not in _data_about_isotopes[isotope]
+            and not _data_about_isotopes[isotope]
         ):
             with pytest.raises(MissingParticleDataError):
 
diff --git a/plasmapy/particles/tests/test_ionization_collection.py b/plasmapy/particles/tests/test_ionization_collection.py
index 0ffc2176..a3da177a 100644
--- a/plasmapy/particles/tests/test_ionization_collection.py
+++ b/plasmapy/particles/tests/test_ionization_collection.py
@@ -35,8 +35,7 @@ def check_abundances_consistency(
         f"log_abundances.keys(): {log_abundances.keys()}"
     )
 
-    for element in abundances.keys():
-        abundance_from_abundances = abundances[element]
+    for element, abundance_from_abundances in abundances.items():
         abundance_from_log_abundances = 10 ** log_abundances[element]
         assert np.isclose(
             abundance_from_abundances, abundance_from_log_abundances
@@ -44,7 +43,7 @@ def check_abundances_consistency(
 
 
 def has_attribute(attribute, tests_dict):
-    cases = [test for test in tests_dict.keys() if attribute in tests_dict[test].keys()]
+    cases = [test for test in tests_dict if attribute in tests_dict[test]]
     if cases:
         return cases
     else:
@@ -162,7 +161,7 @@ def test_that_particles_were_set_correctly(self, test_name):
         particles = [Particle(input_particle) for input_particle in input_particles]
         expected_particles = {p.symbol for p in particles}
         actual_particles = {
-            particle for particle in self.instances[test_name].ionic_fractions.keys()
+            particle for particle in self.instances[test_name].ionic_fractions
         }
 
         assert actual_particles == expected_particles, (
@@ -548,7 +547,7 @@ def test_setting_ionic_fractions_for_single_element(self):
         assert np.allclose(
             self.new_fractions, resulting_fractions
         ), "Ionic fractions for H not set using __setitem__."
-        assert "He" in self.instance.ionic_fractions.keys(), (
+        assert "He" in self.instance.ionic_fractions, (
             "He is missing in ionic_fractions after __setitem__ was "
             "used to set H ionic fractions."
         )
@@ -588,8 +587,7 @@ def test_setting_abundances(self):
         new_abundances = {"H": 1, "He": 0.1, "Li": 1e-4, "Fe": 1e-5, "Au": 1e-8}
 
         log_new_abundances = {
-            element: np.log10(new_abundances[element])
-            for element in new_abundances.keys()
+            element: np.log10(new_abundances[element]) for element in new_abundances
         }
 
         try:
@@ -724,7 +722,7 @@ def test_ionic_fractions_not_quantities(self, element):
             )
 
     def test_that_iron_ionic_fractions_are_still_undefined(self):
-        assert "Fe" in self.instance.ionic_fractions.keys()
+        assert "Fe" in self.instance.ionic_fractions
         iron_fractions = self.instance.ionic_fractions["Fe"]
         assert len(iron_fractions) == atomic_number("Fe") + 1
         assert np.all(np.isnan(iron_fractions))
@@ -761,7 +759,7 @@ def setup_class(cls):
         cls.n = 5.3 * u.m ** -3
         cls.number_densities = {
             element: cls.ionic_fractions[element] * cls.n * cls.abundances[element]
-            for element in cls.ionic_fractions.keys()
+            for element in cls.ionic_fractions
         }
 
         # The keys that begin with 'ndens' have enough information to
@@ -782,7 +780,7 @@ def setup_class(cls):
 
         cls.instances = {
             key: IonizationStateCollection(**cls.dict_of_kwargs[key])
-            for key in cls.dict_of_kwargs.keys()
+            for key in cls.dict_of_kwargs
         }
 
     @pytest.mark.parametrize("test_key", ["ndens1", "ndens2"])
diff --git a/plasmapy/particles/tests/test_ionization_state.py b/plasmapy/particles/tests/test_ionization_state.py
index 2a79409f..9dba251a 100644
--- a/plasmapy/particles/tests/test_ionization_state.py
+++ b/plasmapy/particles/tests/test_ionization_state.py
@@ -17,7 +17,6 @@
 from plasmapy.particles.particle_class import Particle
 from plasmapy.particles.particle_collections import ionic_levels, ParticleList
 from plasmapy.utils.exceptions import PlasmaPyFutureWarning
-from plasmapy.utils.pytest_helpers import run_test
 
 ionic_fraction_table = [
     ("Fe 6+", 0.52, 5.2e-6 * u.m ** -3),
@@ -181,7 +180,7 @@ def test_integer_charges(self, test_name):
 
     @pytest.mark.parametrize(
         "test_name",
-        [name for name in test_names if "ionic_fractions" in test_cases[name].keys()],
+        [name for name in test_names if "ionic_fractions" in test_cases[name]],
     )
     def test_ionic_fractions(self, test_name):
         """
@@ -371,7 +370,7 @@ def test_as_particle_list(self, test_name):
 
     @pytest.mark.parametrize(
         "test_name",
-        [name for name in test_names if "n_elem" in test_cases[name].keys()],
+        [name for name in test_names if "n_elem" in test_cases[name]],
     )
     def test_electron_density_from_n_elem_ionic_fractions(self, test_name):
         instance = self.instances[test_name]
diff --git a/plasmapy/particles/tests/test_particle_class.py b/plasmapy/particles/tests/test_particle_class.py
index f190fb4d..a5881c94 100644
--- a/plasmapy/particles/tests/test_particle_class.py
+++ b/plasmapy/particles/tests/test_particle_class.py
@@ -22,7 +22,7 @@
     ParticleError,
     ParticleWarning,
 )
-from plasmapy.particles.isotopes import _isotopes
+from plasmapy.particles.isotopes import _data_about_isotopes
 from plasmapy.particles.particle_class import (
     CustomParticle,
     DimensionlessParticle,
@@ -98,6 +98,45 @@
             "recombine()": "H-1 0+",
         },
     ),
+    (
+        "H",
+        {"Z": 1, "mass_numb": 1},
+        {
+            "symbol": "p+",
+            "element": "H",
+            "element_name": "hydrogen",
+            "isotope": "H-1",
+            "isotope_name": "hydrogen-1",
+            "ionic_symbol": "p+",
+            "roman_symbol": "H-1 II",
+            "is_ion": True,
+            "mass": m_p,
+            "nuclide_mass": m_p,
+            "charge_number": 1,
+            "charge.value": e.si.value,
+            "spin": 1 / 2,
+            "half_life": np.inf * u.s,
+            "atomic_number": 1,
+            "mass_number": 1,
+            "lepton_number": 0,
+            "baryon_number": 1,
+            "__str__()": "p+",
+            "__repr__()": 'Particle("p+")',
+            'is_category("fermion")': True,
+            'is_category(["fermion"])': True,
+            'is_category({"fermion"})': True,
+            'is_category(any_of=("boson", "fermion"))': True,
+            'is_category(require=["boson", "fermion"])': False,
+            'is_category(("element", "isotope", "ion"))': True,
+            'is_category("charged")': True,
+            "periodic_table.group": 1,
+            "periodic_table.block": "s",
+            "periodic_table.period": 1,
+            "periodic_table.category": "nonmetal",
+            "binding_energy": 0 * u.J,
+            "recombine()": "H-1 0+",
+        },
+    ),
     (
         "p-",
         {},
@@ -518,7 +557,7 @@ def test_Particle_class(arg, kwargs, expected_dict):
             except Exception:
                 errmsg += f"\n{call}.{key} raises an unexpected exception."
 
-    if len(errmsg) > 0:
+    if errmsg:
         raise Exception(f"Problems with {call}:{errmsg}")
 
 
@@ -637,6 +676,18 @@ def test_Particle_cmp():
     assert electron != "dfasdf"
 
 
+@pytest.mark.parametrize("particle", ["p+", "D+", "T+", "alpha"])
+def test_particle_equality_special_nuclides(particle):
+    particle_from_string = Particle(particle)
+    particle_from_numbers = Particle(
+        particle_from_string.element_name,
+        Z=particle_from_string.charge_number,
+        mass_numb=particle_from_string.mass_number,
+    )
+    assert particle_from_string == particle_from_numbers
+    assert particle_from_string._attributes == particle_from_numbers._attributes
+
+
 nuclide_mass_and_mass_equiv_table = [
     ("n", "neutron"),
     ("p+", "proton"),
@@ -710,7 +761,7 @@ def test_particle_half_life_string():
     """
 
     for isotope in known_isotopes():
-        half_life = _isotopes[isotope].get("half-life", None)
+        half_life = _data_about_isotopes[isotope].get("half-life", None)
         if isinstance(half_life, str):
             break
 
diff --git a/plasmapy/particles/tests/test_special_particles.py b/plasmapy/particles/tests/test_special_particles.py
index 9c1e95da..7a8b600f 100644
--- a/plasmapy/particles/tests/test_special_particles.py
+++ b/plasmapy/particles/tests/test_special_particles.py
@@ -1,6 +1,9 @@
 import pytest
 
-from plasmapy.particles.special_particles import _Particles, ParticleZoo
+from plasmapy.particles.special_particles import (
+    _data_about_special_particles,
+    ParticleZoo,
+)
 
 particle_antiparticle_pairs = [
     ("e-", "e+"),
@@ -19,11 +22,11 @@ def test_particle_antiparticle_pairs(particle, antiparticle):
     """Test that particles and antiparticles have the same or exact
     opposite properties in the _Particles dictionary."""
 
-    assert not _Particles[particle][
+    assert not _data_about_special_particles[particle][
         "antimatter"
     ], f"{particle} is incorrectly marked as antimatter."
 
-    assert _Particles[antiparticle][
+    assert _data_about_special_particles[antiparticle][
         "antimatter"
     ], f"{antiparticle} is incorrectly marked as matter."
 
@@ -39,16 +42,20 @@ def test_particle_antiparticle_pairs(particle, antiparticle):
 
     for key in identical_keys:
         assert (
-            _Particles[particle][key] == _Particles[antiparticle][key]
+            _data_about_special_particles[particle][key]
+            == _data_about_special_particles[antiparticle][key]
         ), f"{particle} and {antiparticle} do not have identical {key}."
 
     for key in opposite_keys:
         assert (
-            _Particles[particle][key] == -_Particles[antiparticle][key]
+            _data_about_special_particles[particle][key]
+            == -_data_about_special_particles[antiparticle][key]
         ), f"{particle} and {antiparticle} do not have exact opposite {key}."
 
     if particle not in ["e-", "n"]:
-        assert _Particles[particle]["name"] == _Particles[antiparticle]["name"].replace(
+        assert _data_about_special_particles[particle][
+            "name"
+        ] == _data_about_special_particles[antiparticle]["name"].replace(
             "anti", ""
         ), f"{particle} and {antiparticle} do not have same name except for 'anti'."
 
@@ -74,7 +81,7 @@ def test__Particles_required_keys(particle):
 
     for key in required_keys:
         try:
-            _Particles[particle][key]
+            _data_about_special_particles[particle][key]
         except KeyError:
             missing_keys.append(key)