Repository: PlasmaPy/PlasmaPy

Issue:
Create custom particle subclass
As per discussion on #439 it'd be useful to have a method for generating custom particles as part of the `Particle` class. In particular, we want to be able to pass a mass and a charge to create custom dust grain particles.


Discussion:
Now that #404 has been merged, one possibility for this issue would be to create a composite `Particle` from an `IonizationState` instance.  From that we would be able to get the mean and rms integer charge.  I don't think this should be the only way to create a custom `Particle`, but it would provide at least one way.

Resolving patch:
diff --git a/plasmapy/particles/__init__.py b/plasmapy/particles/__init__.py
--- a/plasmapy/particles/__init__.py
+++ b/plasmapy/particles/__init__.py
@@ -3,12 +3,16 @@
 atoms, isotopes, ions, and other particles.
 """
 
-from .special_particles import ParticleZoo
-
-from .particle_class import Particle
-from .particle_input import particle_input
+from plasmapy.particles.special_particles import ParticleZoo
+from plasmapy.particles.particle_input import particle_input
+from plasmapy.particles.particle_class import (
+    Particle,
+    AbstractParticle,
+    DimensionlessParticle,
+    CustomParticle,
+)
 
-from .symbols import (
+from plasmapy.particles.symbols import (
     atomic_symbol,
     isotope_symbol,
     ionic_symbol,
@@ -16,7 +20,7 @@
     element_name,
 )
 
-from .atomic import (
+from plasmapy.particles.atomic import (
     atomic_number,
     is_stable,
     half_life,
@@ -32,13 +36,10 @@
     reduced_mass,
 )
 
-from .nuclear import (
-    nuclear_binding_energy,
-    nuclear_reaction_energy,
-)
+from plasmapy.particles.nuclear import nuclear_binding_energy, nuclear_reaction_energy
 
-from .ionization_state import IonizationState, State
-from .ionization_states import IonizationStates
+from plasmapy.particles.ionization_state import IonizationState, State
+from plasmapy.particles.ionization_states import IonizationStates
 
 # Create instances of the most commonly used particles
 
diff --git a/plasmapy/particles/particle_class.py b/plasmapy/particles/particle_class.py
--- a/plasmapy/particles/particle_class.py
+++ b/plasmapy/particles/particle_class.py
@@ -1,87 +1,77 @@
 """The Particle class."""
 
+__all__ = ["AbstractParticle", "Particle", "DimensionlessParticle", "CustomParticle"]
+
 import warnings
-from typing import (Union, Set, Tuple, List, Optional)
+from abc import ABC, abstractmethod
 from collections import defaultdict, namedtuple
-from numbers import Integral, Real
+from numbers import Complex, Integral, Real
+from typing import List, Optional, Set, Tuple, Union
 
-import numpy as np
-import astropy.units as u
 import astropy.constants as const
+import astropy.units as u
+import numpy as np
 
 import plasmapy.utils.roman as roman
 from plasmapy.particles.elements import _Elements, _PeriodicTable
-from plasmapy.particles.isotopes import _Isotopes
 from plasmapy.particles.exceptions import (
     AtomicError,
-    MissingAtomicDataError,
+    AtomicWarning,
     ChargeError,
+    InvalidElementError,
     InvalidIonError,
     InvalidIsotopeError,
-    InvalidElementError,
     InvalidParticleError,
-    AtomicWarning,
+    MissingAtomicDataError,
     MissingAtomicDataWarning,
-    )
+)
+from plasmapy.particles.isotopes import _Isotopes
 from plasmapy.particles.parsing import (
     _dealias_particle_aliases,
-    _parse_and_check_atomic_input,
     _invalid_particle_errmsg,
+    _parse_and_check_atomic_input,
 )
 from plasmapy.particles.special_particles import (
-    _Particles,
     ParticleZoo,
-    _special_ion_masses,
     _antiparticles,
+    _Particles,
+    _special_ion_masses,
 )
 
-__all__ = [
-    'Particle',
-]
-
 _classification_categories = {
-    'lepton',
-    'antilepton',
-    'fermion',
-    'boson',
-    'antibaryon',
-    'baryon',
-    'neutrino',
-    'antineutrino',
-    'matter',
-    'antimatter',
-    'stable',
-    'unstable',
-    'charged',
-    'uncharged',
+    "lepton",
+    "antilepton",
+    "fermion",
+    "boson",
+    "antibaryon",
+    "baryon",
+    "neutrino",
+    "antineutrino",
+    "matter",
+    "antimatter",
+    "stable",
+    "unstable",
+    "charged",
+    "uncharged",
 }
 
 _periodic_table_categories = {
-    'nonmetal',
-    'metal',
-    'alkali metal',
-    'alkaline earth metal',
-    'metalloid',
-    'transition metal',
-    'post-transition metal',
-    'halogen',
-    'noble gas',
-    'actinide',
-    'lanthanide',
+    "nonmetal",
+    "metal",
+    "alkali metal",
+    "alkaline earth metal",
+    "metalloid",
+    "transition metal",
+    "post-transition metal",
+    "halogen",
+    "noble gas",
+    "actinide",
+    "lanthanide",
 }
 
-_atomic_property_categories = {
-    'element',
-    'isotope',
-    'ion',
-}
+_atomic_property_categories = {"element", "isotope", "ion"}
 
-_specific_particle_categories = {
-    'electron',
-    'positron',
-    'proton',
-    'neutron',
-}
+_specific_particle_categories = {"electron", "positron", "proton", "neutron"}
 
 _valid_categories = (
     _periodic_table_categories
@@ -98,14 +88,38 @@ def _category_errmsg(particle, category: str) -> str:
     `~plasmapy.utils.InvalidIonError`, or
     `~plasmapy.utils.InvalidIsotopeError`.
     """
-    article = 'an' if category[0] in 'aeiouAEIOU' else 'a'
+    article = "an" if category[0] in "aeiouAEIOU" else "a"
     errmsg = (
         f"The particle {particle} is not {article} {category}, "
-        f"so this attribute is not available.")
+        f"so this attribute is not available."
+    )
     return errmsg
 
 
-class Particle:
+class AbstractParticle(ABC):
+    """
+    An abstract base class that defines the interface for particles.
+    """
+
+    @property
+    @abstractmethod
+    def mass(self) -> Union[u.Quantity, Real]:
+        raise NotImplementedError
+
+    @property
+    @abstractmethod
+    def charge(self) -> Union[u.Quantity, Real]:
+        raise NotImplementedError
+
+    def __bool__(self):
+        """
+        Raise an `~plasmapy.utils.AtomicError` because particles
+        do not have a truth value.
+        """
+        raise AtomicError("The truth value of a particle is not defined.")
+
+
+class Particle(AbstractParticle):
     """
     A class for an individual particle or antiparticle.
 
@@ -270,14 +284,14 @@ class Particle:
     ``'noble gas'``, ``'nonmetal'``, ``'positron'``,
     ``'post-transition metal'``, ``'proton'``, ``'stable'``,
     ``'transition metal'``, ``'uncharged'``, and ``'unstable'``.
-
     """
 
     def __init__(
-            self,
-            argument: Union[str, Integral],
-            mass_numb: Integral = None,
-            Z: Integral = None):
+        self,
+        argument: Union[str, Integral],
+        mass_numb: Integral = None,
+        Z: Integral = None,
+    ):
         """
         Instantiate a `~plasmapy.particles.Particle` object and set private
         attributes.
@@ -287,7 +301,8 @@ def __init__(
             raise TypeError(
                 "The first positional argument when creating a "
                 "Particle object must be either an integer, string, or "
-                "another Particle object.")
+                "another Particle object."
+            )
 
         # If argument is a Particle instance, then we will construct a
         # new Particle instance for the same Particle (essentially a
@@ -319,7 +334,7 @@ def __init__(
 
         if particle in _Particles.keys():  # special particles
 
-            attributes['particle'] = particle
+            attributes["particle"] = particle
 
             for attribute in _Particles[particle].keys():
                 attributes[attribute] = _Particles[particle][attribute]
@@ -331,25 +346,30 @@ def __init__(
                 if particle in particle_taxonomy[category]:
                     categories.add(category)
 
-            if attributes['name'] in _specific_particle_categories:
-                categories.add(attributes['name'])
+            if attributes["name"] in _specific_particle_categories:
+                categories.add(attributes["name"])
 
-            if particle == 'p+':
-                categories.update({'element', 'isotope', 'ion'})
+            if particle == "p+":
+                categories.update({"element", "isotope", "ion"})
 
             if mass_numb is not None or Z is not None:
-                if particle == 'p+' and (mass_numb == 1 or Z == 1):
-                    warnings.warn("Redundant mass number or charge information.", AtomicWarning)
+                if particle == "p+" and (mass_numb == 1 or Z == 1):
+                    warnings.warn(
+                        "Redundant mass number or charge information.", AtomicWarning
+                    )
                 else:
                     raise InvalidParticleError(
                         "The keywords 'mass_numb' and 'Z' cannot be used when "
                         "creating Particle objects for special particles. To "
                         f"create a Particle object for {attributes['name']}s, "
-                        f"use:  Particle({repr(attributes['particle'])})")
+                        f"use:  Particle({repr(attributes['particle'])})"
+                    )
 
         else:  # elements, isotopes, and ions (besides protons)
             try:
-                nomenclature = _parse_and_check_atomic_input(argument, mass_numb=mass_numb, Z=Z)
+                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
@@ -357,75 +377,75 @@ def __init__(
             for key in nomenclature.keys():
                 attributes[key] = nomenclature[key]
 
-            element = attributes['element']
-            isotope = attributes['isotope']
-            ion = attributes['ion']
+            element = attributes["element"]
+            isotope = attributes["isotope"]
+            ion = attributes["ion"]
 
             if element:
-                categories.add('element')
+                categories.add("element")
             if isotope:
-                categories.add('isotope')
-            if self.element and self._attributes['integer charge']:
-                categories.add('ion')
+                categories.add("isotope")
+            if self.element and self._attributes["integer charge"]:
+                categories.add("ion")
 
             # Element properties
 
             Element = _Elements[element]
 
-            attributes['atomic number'] = Element['atomic number']
-            attributes['element name'] = Element['element name']
+            attributes["atomic number"] = Element["atomic number"]
+            attributes["element name"] = Element["element name"]
 
             # Set the lepton number to zero for elements, isotopes, and
             # ions.  The lepton number will probably come up primarily
             # during nuclear reactions.
 
-            attributes['lepton number'] = 0
+            attributes["lepton number"] = 0
 
             if isotope:
 
                 Isotope = _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"] = Isotope["mass number"]
+                attributes["isotope mass"] = Isotope.get("mass", None)
+                attributes["isotopic abundance"] = Isotope.get("abundance", 0.0)
 
-                if Isotope['stable']:
-                    attributes['half-life'] = np.inf * u.s
+                if Isotope["stable"]:
+                    attributes["half-life"] = np.inf * u.s
                 else:
-                    attributes['half-life'] = Isotope.get('half-life', None)
+                    attributes["half-life"] = Isotope.get("half-life", None)
 
             if element and not isotope:
-                attributes['standard atomic weight'] = Element.get('atomic mass', None)
+                attributes["standard atomic weight"] = Element.get("atomic mass", None)
 
             if ion in _special_ion_masses.keys():
-                attributes['mass'] = _special_ion_masses[ion]
+                attributes["mass"] = _special_ion_masses[ion]
 
-            attributes['periodic table'] = _PeriodicTable(
-                group=Element['group'],
-                period=Element['period'],
-                block=Element['block'],
-                category=Element['category'],
+            attributes["periodic table"] = _PeriodicTable(
+                group=Element["group"],
+                period=Element["period"],
+                block=Element["block"],
+                category=Element["category"],
             )
 
-            categories.add(Element['category'])
+            categories.add(Element["category"])
 
-        if attributes['integer charge'] == 1:
-            attributes['charge'] = const.e.si
-        elif attributes['integer charge'] is not None:
-            attributes['charge'] = attributes['integer charge'] * const.e.si
+        if attributes["integer charge"] == 1:
+            attributes["charge"] = const.e.si
+        elif attributes["integer charge"] is not None:
+            attributes["charge"] = attributes["integer charge"] * const.e.si
 
-        if attributes['integer charge']:
-            categories.add('charged')
-        elif attributes['integer charge'] == 0:
-            categories.add('uncharged')
+        if attributes["integer charge"]:
+            categories.add("charged")
+        elif attributes["integer charge"] == 0:
+            categories.add("uncharged")
 
-        if attributes['half-life'] is not None:
-            if isinstance(attributes['half-life'], str):
-                categories.add('unstable')
-            elif attributes['half-life'] == np.inf * u.s:
-                categories.add('stable')
+        if attributes["half-life"] is not None:
+            if isinstance(attributes["half-life"], str):
+                categories.add("unstable")
+            elif attributes["half-life"] == np.inf * u.s:
+                categories.add("stable")
             else:
-                categories.add('unstable')
+                categories.add("unstable")
 
         self.__name__ = self.__repr__()
 
@@ -478,15 +498,18 @@ def __eq__(self, other) -> bool:
                 return self.particle == other_particle.particle
             except InvalidParticleError as exc:
                 raise InvalidParticleError(
-                    f"{other} is not a particle and cannot be "
-                    f"compared to {self}.") from exc
+                    f"{other} is not a particle and cannot be compared to {self}."
+                ) from exc
 
         if not isinstance(other, self.__class__):
             raise TypeError(
-                f"The equality of a Particle object with a {type(other)} is undefined.")
+                f"The equality of a Particle object with a {type(other)} is undefined."
+            )
 
-        no_particle_attr = 'particle' not in dir(self) or 'particle' not in dir(other)
-        no_attributes_attr = '_attributes' not in dir(self) or '_attributes' not in dir(other)
+        no_particle_attr = "particle" not in dir(self) or "particle" not in dir(other)
+        no_attributes_attr = "_attributes" not in dir(self) or "_attributes" not in dir(
+            other
+        )
 
         if no_particle_attr or no_attributes_attr:  # coverage: ignore
             raise TypeError(f"The equality of {self} with {other} is undefined.")
@@ -514,7 +537,8 @@ def __eq__(self, other) -> bool:
                 f"{self} and {other} should be the same Particle, but "
                 f"have differing attributes.\n\n"
                 f"The attributes of {self} are:\n\n{self._attributes}\n\n"
-                f"The attributes of {other} are:\n\n{other._attributes}\n")
+                f"The attributes of {other} are:\n\n{other._attributes}\n"
+            )
 
         return same_particle
 
@@ -544,13 +568,6 @@ def __hash__(self) -> int:
         """
         return hash(self.__repr__())
 
-    def __bool__(self):
-        """
-        Raise an `~plasmapy.utils.AtomicError` because Particle objects
-        do not have a truth value.
-        """
-        raise AtomicError("The truthiness of a Particle instance is not defined.")
-
     def __invert__(self):
         """
         Return the corresponding antiparticle, or raise an
@@ -571,7 +588,7 @@ def particle(self) -> Optional[str]:
         'e-'
 
         """
-        return self._attributes['particle']
+        return self._attributes["particle"]
 
     @property
     def antiparticle(self):
@@ -599,7 +616,8 @@ def antiparticle(self):
         else:
             raise AtomicError(
                 "The unary operator can only be used for elementary "
-                "particles and antiparticles.")
+                "particles and antiparticles."
+            )
 
     @property
     def element(self) -> Optional[str]:
@@ -614,7 +632,7 @@ def element(self) -> Optional[str]:
         'He'
 
         """
-        return self._attributes['element']
+        return self._attributes["element"]
 
     @property
     def isotope(self) -> Optional[str]:
@@ -629,7 +647,7 @@ def isotope(self) -> Optional[str]:
         'He-4'
 
         """
-        return self._attributes['isotope']
+        return self._attributes["isotope"]
 
     @property
     def ionic_symbol(self) -> Optional[str]:
@@ -647,7 +665,7 @@ def ionic_symbol(self) -> Optional[str]:
         'H 0+'
 
         """
-        return self._attributes['ion']
+        return self._attributes["ion"]
 
     @property
     def roman_symbol(self) -> Optional[str]:
@@ -669,15 +687,15 @@ def roman_symbol(self) -> Optional[str]:
         'H I'
 
         """
-        if not self._attributes['element']:
+        if not self._attributes["element"]:
             return None
-        if self._attributes['integer charge'] is None:
+        if self._attributes["integer charge"] is None:
             raise ChargeError(f"The charge of particle {self} has not been specified.")
-        if self._attributes['integer charge'] < 0:
-            raise roman.OutOfRangeError('Cannot convert negative charges to Roman.')
+        if self._attributes["integer charge"] < 0:
+            raise roman.OutOfRangeError("Cannot convert negative charges to Roman.")
 
         symbol = self.isotope if self.isotope else self.element
-        integer_charge = self._attributes['integer charge']
+        integer_charge = self._attributes["integer charge"]
         roman_charge = roman.to_roman(integer_charge + 1)
         return f"{symbol} {roman_charge}"
 
@@ -696,8 +714,8 @@ def element_name(self) -> str:
 
         """
         if not self.element:
-            raise InvalidElementError(_category_errmsg(self, 'element'))
-        return self._attributes['element name']
+            raise InvalidElementError(_category_errmsg(self, "element"))
+        return self._attributes["element name"]
 
     @property
     def isotope_name(self) -> str:
@@ -722,9 +740,9 @@ def isotope_name(self) -> str:
 
         """
         if not self.element:
-            raise InvalidElementError(_category_errmsg(self.particle, 'element'))
+            raise InvalidElementError(_category_errmsg(self.particle, "element"))
         elif not self.isotope:
-            raise InvalidIsotopeError(_category_errmsg(self, 'isotope'))
+            raise InvalidIsotopeError(_category_errmsg(self, "isotope"))
 
         if self.isotope == "D":
             isotope_name = "deuterium"
@@ -750,9 +768,9 @@ def integer_charge(self) -> Integral:
         -1
 
         """
-        if self._attributes['integer charge'] is None:
+        if self._attributes["integer charge"] is None:
             raise ChargeError(f"The charge of particle {self} has not been specified.")
-        return self._attributes['integer charge']
+        return self._attributes["integer charge"]
 
     @property
     def charge(self) -> u.Quantity:
@@ -769,12 +787,12 @@ def charge(self) -> u.Quantity:
         <Quantity -1.60217662e-19 C>
 
         """
-        if self._attributes['charge'] is None:
+        if self._attributes["charge"] is None:
             raise ChargeError(f"The charge of particle {self} has not been specified.")
-        if self._attributes['integer charge'] == 1:
+        if self._attributes["integer charge"] == 1:
             return const.e.si
 
-        return self._attributes['charge']
+        return self._attributes["charge"]
 
     @property
     def standard_atomic_weight(self) -> u.Quantity:
@@ -796,11 +814,12 @@ def standard_atomic_weight(self) -> u.Quantity:
 
         """
         if self.isotope or self.is_ion or not self.element:
-            raise InvalidElementError(_category_errmsg(self, 'element'))
-        if self._attributes['standard atomic weight'] is None:  # coverage: ignore
+            raise InvalidElementError(_category_errmsg(self, "element"))
+        if self._attributes["standard atomic weight"] is None:  # coverage: ignore
             raise MissingAtomicDataError(
-                f"The standard atomic weight of {self} is unavailable.")
-        return self._attributes['standard atomic weight'].to(u.kg)
+                f"The standard atomic weight of {self} is unavailable."
+            )
+        return self._attributes["standard atomic weight"].to(u.kg)
 
     @property
     def nuclide_mass(self) -> u.Quantity:
@@ -821,24 +840,28 @@ def nuclide_mass(self) -> u.Quantity:
 
         """
 
-        if self.isotope == 'H-1':
+        if self.isotope == "H-1":
             return const.m_p
-        elif self.isotope == 'D':
-            return _special_ion_masses['D 1+']
-        elif self.isotope == 'T':
-            return _special_ion_masses['T 1+']
-        elif self.particle == 'n':
+        elif self.isotope == "D":
+            return _special_ion_masses["D 1+"]
+        elif self.isotope == "T":
+            return _special_ion_masses["T 1+"]
+        elif self.particle == "n":
             return const.m_n
 
         if not self.isotope:
-            raise InvalidIsotopeError(_category_errmsg(self, 'isotope'))
+            raise InvalidIsotopeError(_category_errmsg(self, "isotope"))
 
-        base_mass = self._attributes['isotope mass']
+        base_mass = self._attributes["isotope mass"]
 
         if base_mass is None:  # coverage: ignore
-            raise MissingAtomicDataError(f"The mass of a {self.isotope} nuclide is not available.")
+            raise MissingAtomicDataError(
+                f"The mass of a {self.isotope} nuclide is not available."
+            )
 
-        _nuclide_mass = self._attributes['isotope mass'] - self.atomic_number * const.m_e
+        _nuclide_mass = (
+            self._attributes["isotope mass"] - self.atomic_number * const.m_e
+        )
 
         return _nuclide_mass.to(u.kg)
 
@@ -879,15 +902,15 @@ def mass(self) -> u.Quantity:
 
         """
 
-        if self._attributes['mass'] is not None:
-            return self._attributes['mass'].to(u.kg)
+        if self._attributes["mass"] is not None:
+            return self._attributes["mass"].to(u.kg)
 
         if self.is_ion:
 
             if self.isotope:
-                base_mass = self._attributes['isotope mass']
+                base_mass = self._attributes["isotope mass"]
             else:
-                base_mass = self._attributes['standard atomic weight']
+                base_mass = self._attributes["standard atomic weight"]
 
             if base_mass is None:
                 raise MissingAtomicDataError(
@@ -901,9 +924,9 @@ def mass(self) -> u.Quantity:
         if self.element:
 
             if self.isotope:
-                mass = self._attributes['isotope mass']
+                mass = self._attributes["isotope mass"]
             else:
-                mass = self._attributes['standard atomic weight']
+                mass = self._attributes["standard atomic weight"]
 
             if mass is not None:
                 return mass.to(u.kg)
@@ -929,24 +952,28 @@ def nuclide_mass(self) -> u.Quantity:
 
         """
 
-        if self.isotope == 'H-1':
+        if self.isotope == "H-1":
             return const.m_p
-        elif self.isotope == 'D':
-            return _special_ion_masses['D 1+']
-        elif self.isotope == 'T':
-            return _special_ion_masses['T 1+']
-        elif self.particle == 'n':
+        elif self.isotope == "D":
+            return _special_ion_masses["D 1+"]
+        elif self.isotope == "T":
+            return _special_ion_masses["T 1+"]
+        elif self.particle == "n":
             return const.m_n
 
         if not self.isotope:
-            raise InvalidIsotopeError(_category_errmsg(self, 'isotope'))
+            raise InvalidIsotopeError(_category_errmsg(self, "isotope"))
 
-        base_mass = self._attributes['isotope mass']
+        base_mass = self._attributes["isotope mass"]
 
         if base_mass is None:  # coverage: ignore
-            raise MissingAtomicDataError(f"The mass of a {self.isotope} nuclide is not available.")
+            raise MissingAtomicDataError(
+                f"The mass of a {self.isotope} nuclide is not available."
+            )
 
-        _nuclide_mass = self._attributes['isotope mass'] - self.atomic_number * const.m_e
+        _nuclide_mass = (
+            self._attributes["isotope mass"] - self.atomic_number * const.m_e
+        )
 
         return _nuclide_mass.to(u.kg)
 
@@ -983,7 +1010,8 @@ def mass_energy(self) -> u.Quantity:
         except MissingAtomicDataError:
             raise MissingAtomicDataError(
                 f"The mass energy of {self.particle} is not available "
-                f"because the mass is unknown.") from None
+                f"because the mass is unknown."
+            ) from None
 
     @property
     def binding_energy(self) -> u.Quantity:
@@ -1013,12 +1041,13 @@ def binding_energy(self) -> u.Quantity:
 
         """
 
-        if self._attributes['baryon number'] == 1:
+        if self._attributes["baryon number"] == 1:
             return 0 * u.J
 
         if not self.isotope:
             raise InvalidIsotopeError(
-                f"The nuclear binding energy may only be calculated for nucleons and isotopes.")
+                f"The nuclear binding energy may only be calculated for nucleons and isotopes."
+            )
 
         number_of_protons = self.atomic_number
         number_of_neutrons = self.mass_number - self.atomic_number
@@ -1052,8 +1081,8 @@ def atomic_number(self) -> Integral:
 
         """
         if not self.element:
-            raise InvalidElementError(_category_errmsg(self, 'element'))
-        return self._attributes['atomic number']
+            raise InvalidElementError(_category_errmsg(self, "element"))
+        return self._attributes["atomic number"]
 
     @property
     def mass_number(self) -> Integral:
@@ -1074,8 +1103,8 @@ def mass_number(self) -> Integral:
 
         """
         if not self.isotope:
-            raise InvalidIsotopeError(_category_errmsg(self, 'isotope'))
-        return self._attributes['mass number']
+            raise InvalidIsotopeError(_category_errmsg(self, "isotope"))
+        return self._attributes["mass number"]
 
     @property
     def neutron_number(self) -> Integral:
@@ -1097,12 +1126,12 @@ def neutron_number(self) -> Integral:
         1
 
         """
-        if self.particle == 'n':
+        if self.particle == "n":
             return 1
         elif self.isotope:
             return self.mass_number - self.atomic_number
         else:  # coverage: ignore
-            raise InvalidIsotopeError(_category_errmsg(self, 'isotope'))
+            raise InvalidIsotopeError(_category_errmsg(self, "isotope"))
 
     @property
     def electron_number(self) -> Integral:
@@ -1123,12 +1152,12 @@ def electron_number(self) -> Integral:
         1
 
         """
-        if self.particle == 'e-':
+        if self.particle == "e-":
             return 1
         elif self.ionic_symbol:
             return self.atomic_number - self.integer_charge
         else:  # coverage: ignore
-            raise InvalidIonError(_category_errmsg(self, 'ion'))
+            raise InvalidIonError(_category_errmsg(self, "ion"))
 
     @property
     def isotopic_abundance(self) -> u.Quantity:
@@ -1150,15 +1179,16 @@ def isotopic_abundance(self) -> u.Quantity:
         from .atomic import common_isotopes
 
         if not self.isotope or self.is_ion:  # coverage: ignore
-            raise InvalidIsotopeError(_category_errmsg(self.particle, 'isotope'))
+            raise InvalidIsotopeError(_category_errmsg(self.particle, "isotope"))
 
-        abundance = self._attributes.get('isotopic abundance', 0.0)
+        abundance = self._attributes.get("isotopic abundance", 0.0)
 
         if not common_isotopes(self.element):
             warnings.warn(
-                f'No isotopes of {self.element} have an isotopic abundance. '
-                f'The isotopic abundance of {self.isotope} is being returned as 0.0',
-                AtomicWarning)
+                f"No isotopes of {self.element} have an isotopic abundance. "
+                f"The isotopic abundance of {self.isotope} is being returned as 0.0",
+                AtomicWarning,
+            )
 
         return abundance
 
@@ -1181,10 +1211,11 @@ def baryon_number(self) -> Integral:
         4
 
         """
-        if self._attributes['baryon number'] is None:  # coverage: ignore
+        if self._attributes["baryon number"] is None:  # coverage: ignore
             raise MissingAtomicDataError(
-                f"The baryon number for '{self.particle}' is not available.")
-        return self._attributes['baryon number']
+                f"The baryon number for '{self.particle}' is not available."
+            )
+        return self._attributes["baryon number"]
 
     @property
     def lepton_number(self) -> Integral:
@@ -1208,10 +1239,11 @@ def lepton_number(self) -> Integral:
         0
 
         """
-        if self._attributes['lepton number'] is None:  # coverage: ignore
+        if self._attributes["lepton number"] is None:  # coverage: ignore
             raise MissingAtomicDataError(
-                f"The lepton number for {self.particle} is not available.")
-        return self._attributes['lepton number']
+                f"The lepton number for {self.particle} is not available."
+            )
+        return self._attributes["lepton number"]
 
     @property
     def half_life(self) -> Union[u.Quantity, str]:
@@ -1232,16 +1264,20 @@ def half_life(self) -> Union[u.Quantity, str]:
 
         """
         if self.element and not self.isotope:
-            raise InvalidIsotopeError(_category_errmsg(self.particle, 'isotope'))
+            raise InvalidIsotopeError(_category_errmsg(self.particle, "isotope"))
 
-        if isinstance(self._attributes['half-life'], str):
+        if isinstance(self._attributes["half-life"], str):
             warnings.warn(
                 f"The half-life for {self.particle} is not known precisely; "
-                "returning string with estimated value.", MissingAtomicDataWarning)
+                "returning string with estimated value.",
+                MissingAtomicDataWarning,
+            )
 
-        if self._attributes['half-life'] is None:
-            raise MissingAtomicDataError(f"The half-life of '{self.particle}' is not available.")
-        return self._attributes['half-life']
+        if self._attributes["half-life"] is None:
+            raise MissingAtomicDataError(
+                f"The half-life of '{self.particle}' is not available."
+            )
+        return self._attributes["half-life"]
 
     @property
     def spin(self) -> Real:
@@ -1258,10 +1294,12 @@ def spin(self) -> Real:
         0.5
 
         """
-        if self._attributes['spin'] is None:
-            raise MissingAtomicDataError(f"The spin of particle '{self.particle}' is unavailable.")
+        if self._attributes["spin"] is None:
+            raise MissingAtomicDataError(
+                f"The spin of particle '{self.particle}' is unavailable."
+            )
 
-        return self._attributes['spin']
+        return self._attributes["spin"]
 
     @property
     def periodic_table(self) -> namedtuple:
@@ -1286,9 +1324,9 @@ def periodic_table(self) -> namedtuple:
 
         """
         if self.element:
-            return self._attributes['periodic table']
+            return self._attributes["periodic table"]
         else:  # coverage: ignore
-            raise InvalidElementError(_category_errmsg(self.particle, 'element'))
+            raise InvalidElementError(_category_errmsg(self.particle, "element"))
 
     @property
     def categories(self) -> Set[str]:
@@ -1306,12 +1344,13 @@ def categories(self) -> Set[str]:
         """
         return self._categories
 
-    def is_category(self,
-                    *category_tuple,
-                    require: Union[str, Set, Tuple, List] = None,
-                    any_of: Union[str, Set, Tuple, List] = None,
-                    exclude: Union[str, Set, Tuple, List] = None,
-                    ) -> bool:
+    def is_category(
+        self,
+        *category_tuple,
+        require: Union[str, Set, Tuple, List] = None,
+        any_of: Union[str, Set, Tuple, List] = None,
+        exclude: Union[str, Set, Tuple, List] = None,
+    ) -> bool:
         """
         Determine if the particle meets categorization criteria.
 
@@ -1361,7 +1400,8 @@ def become_set(arg: Union[str, Set, Tuple, List]) -> Set[str]:
         if category_tuple and require:  # coverage: ignore
             raise AtomicError(
                 "No positional arguments are allowed if the `require` keyword "
-                "is set in is_category.")
+                "is set in is_category."
+            )
 
         require = become_set(category_tuple) if category_tuple else become_set(require)
 
@@ -1376,15 +1416,16 @@ def become_set(arg: Union[str, Set, Tuple, List]) -> Set[str]:
         duplicate_categories = require & exclude | exclude & any_of | require & any_of
 
         categories_and_adjectives = [
-            (invalid_categories, 'invalid'),
-            (duplicate_categories, 'duplicated'),
+            (invalid_categories, "invalid"),
+            (duplicate_categories, "duplicated"),
         ]
 
         for problem_categories, adjective in categories_and_adjectives:
             if problem_categories:
                 raise AtomicError(
                     f"The following categories in {self.__repr__()}"
-                    f".is_category are {adjective}: {problem_categories}")
+                    f".is_category are {adjective}: {problem_categories}"
+                )
 
         if exclude and exclude & self._categories:
             return False
@@ -1425,7 +1466,7 @@ def is_ion(self) -> bool:
         False
 
         """
-        return self.is_category('ion')
+        return self.is_category("ion")
 
     def ionize(self, n: Integral = 1, inplace: bool = False):
         """
@@ -1487,15 +1528,18 @@ def ionize(self, n: Integral = 1, inplace: bool = False):
         if not self.element:
             raise InvalidElementError(
                 f"Cannot ionize {self.particle} because it is not a "
-                f"neutral atom or ion.")
+                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.")
+                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.")
+                f"ionized and cannot be ionized further."
+            )
         if not isinstance(n, Integral):
             raise TypeError("n must be a positive integer.")
         if n <= 0:
@@ -1568,11 +1612,13 @@ def recombine(self, n: Integral = 1, inplace=False):
         if not self.element:
             raise InvalidElementError(
                 f"{self.particle} cannot undergo recombination because "
-                f"it is not a neutral atom or ion.")
+                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.")
+                f"its charge is not specified."
+            )
         if not isinstance(n, Integral):
             raise TypeError("n must be a positive integer.")
         if n <= 0:
@@ -1585,3 +1631,252 @@ def recombine(self, n: Integral = 1, inplace=False):
             self.__init__(base_particle, Z=new_integer_charge)
         else:
             return Particle(base_particle, Z=new_integer_charge)
+
+
+class DimensionlessParticle(AbstractParticle):
+    """
+    A class to represent dimensionless custom particles.
+
+    This class may be used, for example, to represent a particle in a
+    dimensionless particle-in-cell simulation.
+
+    Parameters
+    ----------
+    mass : positive real number, keyword-only, optional
+        The mass of the dimensionless particle.
+
+    charge : real number, keyword-only, optional
+        The electric charge of the dimensionless particle.
+
+    Notes
+    -----
+    If the charge or mass is not specified, then the corresponding value
+    will be set to ``numpy.nan``.
+
+    Examples
+    --------
+    >>> from plasmapy.particles import DimensionlessParticle
+    >>> dimensionless_particle = DimensionlessParticle(mass=1.0, charge=-1.0)
+    >>> dimensionless_particle.mass
+    1.0
+    >>> dimensionless_particle.charge
+    -1.0
+    """
+
+    def __init__(self, *, mass: Real = None, charge: Real = None):
+        try:
+            self.mass = mass
+            self.charge = charge
+        except Exception as exc:
+            raise InvalidParticleError(
+                f"Unable to create a custom particle with a mass of "
+                f"{mass} and a charge of {charge}."
+            ) from exc
+
+    def __repr__(self):
+        """
+        Return a string representation of a dimensionless particle.
+
+        Examples
+        --------
+        >>> dimensionless_particle = DimensionlessParticle(mass=1.45, charge=1.23)
+        >>> repr(dimensionless_particle)
+        'DimensionlessParticle(mass=1.45, charge=1.23)'
+        """
+        return f"DimensionlessParticle(mass={self.mass}, charge={self.charge})"
+
+    @staticmethod
+    def _validate_parameter(obj, can_be_negative=True) -> np.float64:
+        """Verify that the argument corresponds to a valid real number."""
+
+        # TODO: Replace with validator? Use an equivalency between coulombs and reals.
+
+        if obj is None or obj is np.nan:
+            return np.nan
+        elif np.isinf(obj):
+            return obj
+        elif isinstance(obj, bool):
+            raise TypeError("Expecting a real number, not a bool.")
+        elif isinstance(obj, u.Quantity) and not isinstance(obj.value, Real):
+            raise ValueError("The value of a Quantity must be a real number.")
+
+        try:
+            new_obj = np.float64(obj)
+        except Exception:
+            raise TypeError(f"Cannot convert {obj} to numpy.float64.")
+
+        if hasattr(new_obj, "__len__"):
+            raise TypeError("Expecting a real number, not a collection.")
+
+        if not can_be_negative and new_obj < 0:
+            raise ValueError("Expecting a nonnegative number.")
+
+        return new_obj
+
+    @property
+    def mass(self) -> np.float64:
+        """Return the dimensionless mass of the particle."""
+        return self._mass
+
+    @property
+    def charge(self) -> np.float64:
+        """Return the dimensionless charge of the particle."""
+        return self._charge
+
+    @mass.setter
+    def mass(self, m: Optional[Union[Real, u.Quantity]]):
+        try:
+            self._mass = self._validate_parameter(m, can_be_negative=False)
+        except (TypeError, ValueError):
+            raise InvalidParticleError(
+                f"The mass of a dimensionless particle must be a real "
+                f"number that is greater than or equal to zero, not: {m}"
+            ) from None
+        if self._mass is np.nan:
+            warnings.warn("DimensionlessParticle mass set to NaN", MissingAtomicDataWarning)
+
+    @charge.setter
+    def charge(self, q: Optional[Union[Real, u.Quantity]]):
+        try:
+            self._charge = self._validate_parameter(q, can_be_negative=True)
+        except (TypeError, ValueError):
+            raise InvalidParticleError(
+                f"The charge of a dimensionless particle must be a real "
+                f"number, not: {q}"
+            ) from None
+        if self._charge is np.nan:
+            warnings.warn("DimensionlessParticle charge set to NaN", MissingAtomicDataWarning)
+
+
+class CustomParticle(AbstractParticle):
+    """
+    A class to represent custom particles.
+
+    Example use cases for this class include representing an average
+    ion in a multi-component plasma, molecules, or dust grains.
+
+    Parameters
+    ----------
+    mass : ~astropy.units.Quantity, optional
+        The mass of the custom particle in units of mass.
+
+    charge : ~astropy.units.Quantity or ~numbers.Real
+        The electric charge of the custom particle.  If provided as a
+        `~astropy.units.Quantity`, then it must be in units of electric
+        charge.  If provided as a real number, then it is treated as the
+        ratio of the charge to the elementary charge.
+
+    Raises
+    ------
+    InvalidParticleError
+        If the charge or mass provided is invalid so that the custom
+        particle cannot be created.
+
+    See Also
+    --------
+    ~plasmapy.particles.Particle
+    ~plasmapy.particles.DimensionlessParticle
+
+    Notes
+    -----
+    If the charge or mass is not specified, then the corresponding value
+    will be set to ``numpy.nan`` in the appropriate units.
+
+    Examples
+    --------
+    >>> from astropy import units as u
+    >>> from plasmapy.particles import CustomParticle
+    >>> custom_particle = CustomParticle(mass=1.5e-26 * u.kg, charge=-1)
+    >>> custom_particle.mass
+    <Quantity 1.5e-26 kg>
+    >>> custom_particle.charge
+    <Quantity -1.60217...e-19 C>
+    """
+
+    def __init__(self, mass: u.kg = None, charge: (u.C, Real) = None):
+        try:
+            self.mass = mass
+            self.charge = charge
+        except Exception as exc:
+            raise InvalidParticleError(
+                f"Unable to create a custom particle with a mass of "
+                f"{mass} and a charge of {charge}."
+            ) from exc
+
+    def __repr__(self):
+        """
+        Return a string representation of a custom particle.
+
+        Examples
+        --------
+        >>> custom_particle = CustomParticle(mass=1.2e-26 * u.kg, charge=9.2e-19 * u.C)
+        >>> repr(custom_particle)
+        'CustomParticle(mass=1.2...e-26 kg, charge=9.2...e-19 C)'
+        """
+        return f"CustomParticle(mass={self.mass}, charge={self.charge})"
+
+    @property
+    def mass(self) -> u.kg:
+        """Return the custom particle's mass."""
+        return self._mass
+
+    @property
+    def charge(self) -> u.C:
+        """Return the custom particle's electric charge in coulombs."""
+        return self._charge
+
+    @mass.setter
+    def mass(self, m: u.kg):
+        if m is None:
+            self._mass = np.nan * u.kg
+            warnings.warn("CustomParticle mass set to NaN kg", MissingAtomicDataWarning)
+        elif not isinstance(m, u.Quantity):
+            raise TypeError(
+                "The mass of a custom particle must be a nonnegative Quantity "
+                "with units of mass."
+            )
+        else:
+
+            if not isinstance(m.value, Real):
+                raise TypeError(
+                    "The mass of a custom particle must be a real number "
+                    "with units of mass."
+                )
+            try:
+                self._mass = m.to(u.kg)
+                if self.mass < 0 * u.kg:
+                    raise ValueError("The mass of a particle must be nonnegative.")
+            except u.UnitsError as exc:
+                raise u.UnitsError(
+                    "The mass of a custom particle must have units of mass."
+                ) from exc
+
+    @charge.setter
+    def charge(self, q: Optional[Union[u.Quantity, Real]]):
+        if q is None:
+            self._charge = np.nan * u.C
+            warnings.warn("CustomParticle charge set to NaN C", MissingAtomicDataWarning)
+        elif isinstance(q, Real):
+            self._charge = q * const.e.si
+            warnings.warn(f"CustomParticle charge set to {q} times the elementary charge.")
+        elif isinstance(q, u.Quantity):
+            if not isinstance(q.value, Real):
+                raise InvalidParticleError(
+                    "The charge of a custom particle can only be a real "
+                    "number or a quantity representing a real number with "
+                    "units of charge."
+                )
+            try:
+                self._charge = q.to(u.C)
+            except u.UnitsError as exc:
+                raise InvalidParticleError(
+                    "The charge of a custom particle can only have units "
+                    "that are compatible with coulombs."
+                ) from exc
+        else:
+            raise TypeError(
+                "The charge of a custom particle must be provided either "
+                "as a Quantity with units compatible with coulombs or as "
+                "a real number that represents the ratio of the charge to "
+                "the elementary charge."
+            )


Test patch:
diff --git a/plasmapy/particles/tests/test_particle_class.py b/plasmapy/particles/tests/test_particle_class.py
--- a/plasmapy/particles/tests/test_particle_class.py
+++ b/plasmapy/particles/tests/test_particle_class.py
@@ -3,13 +3,17 @@
 import collections
 
 import numpy as np
-from astropy import units as u
+from astropy import units as u, constants as const
 
 from plasmapy.utils import roman
 from astropy.constants import m_p, m_e, m_n, e, c
 from plasmapy.particles.atomic import known_isotopes
 from plasmapy.particles.isotopes import _Isotopes
-from plasmapy.particles.particle_class import Particle
+from plasmapy.particles.particle_class import (
+    Particle,
+    CustomParticle,
+    DimensionlessParticle,
+)
 from plasmapy.particles.special_particles import ParticleZoo
 
 from plasmapy.utils import call_string
@@ -29,383 +33,432 @@
 
 # (arg, kwargs, results_dict)
 test_Particle_table = [
-
-    ('neutron', {},
-     {'particle': 'n',
-      'element': None,
-      'isotope': None,
-      'isotope_name': InvalidElementError,
-      'ionic_symbol': None,
-      'roman_symbol': None,
-      'is_ion': False,
-      'is_electron': False,
-      'integer_charge': 0,
-      'atomic_number': InvalidElementError,
-      'mass_number': InvalidIsotopeError,
-      'baryon_number': 1,
-      'lepton_number': 0,
-      'mass': m_n,
-      'nuclide_mass': m_n,
-      'binding_energy': 0 * u.J,
-      'periodic_table.group': InvalidElementError,
-      }),
-
-    ('p+', {},
-     {'particle': '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,
-      'integer_charge': 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-', {},
-     {'particle': 'p-',
-      'element': None,
-      'element_name': InvalidElementError,
-      'isotope': None,
-      'isotope_name': InvalidElementError,
-      'ionic_symbol': None,
-      'roman_symbol': None,
-      'is_ion': False,
-      'mass': m_p,
-      'integer_charge': -1,
-      'spin': 1 / 2,
-      'half_life': np.inf * u.s,
-      'atomic_number': InvalidElementError,
-      'mass_number': InvalidIsotopeError,
-      'lepton_number': 0,
-      'baryon_number': -1,
-      '__str__()': 'p-',
-      '__repr__()': 'Particle("p-")',
-      'periodic_table.group': InvalidElementError,
-      }),
-
-    ('e-', {},
-     {'particle': 'e-',
-      'element': None,
-      'element_name': InvalidElementError,
-      'isotope': None,
-      'isotope_name': InvalidElementError,
-      'ionic_symbol': None,
-      'roman_symbol': None,
-      'is_ion': False,
-      'mass': m_e,
-      'integer_charge': -1,
-      'spin': 1 / 2,
-      'half_life': np.inf * u.s,
-      'atomic_number': InvalidElementError,
-      'lepton_number': 1,
-      'baryon_number': 0,
-      '__str__()': 'e-',
-      '__repr__()': 'Particle("e-")',
-      'binding_energy': InvalidIsotopeError,
-      'periodic_table.group': InvalidElementError,
-      'periodic_table.block': InvalidElementError,
-      'periodic_table.period': InvalidElementError,
-      'periodic_table.category': InvalidElementError,
-      }),
-
-    ('e+', {},
-     {'particle': 'e+',
-      'element': None,
-      'isotope': None,
-      'isotope_name': InvalidElementError,
-      'ionic_symbol': None,
-      'roman_symbol': None,
-      'is_ion': False,
-      'mass': m_e,
-      'mass_energy': (m_e * c ** 2).to('J'),
-      'nuclide_mass': InvalidIsotopeError,
-      'integer_charge': 1,
-      'spin': 1 / 2,
-      'half_life': np.inf * u.s,
-      'atomic_number': InvalidElementError,
-      'lepton_number': -1,
-      'baryon_number': 0,
-      'is_category(require="positron")': True,
-      'is_category(any_of={"positron"})': True,
-      'is_category(exclude="positron")': False,
-      'is_category("ion")': False,
-      '__str__()': 'e+',
-      '__repr__()': 'Particle("e+")',
-      'periodic_table.group': InvalidElementError,
-      'periodic_table.block': InvalidElementError,
-      'periodic_table.period': InvalidElementError,
-      'periodic_table.category': InvalidElementError,
-      }),
-
-    ('H', {},
-     {'particle': 'H',
-      'element': 'H',
-      'isotope': None,
-      'isotope_name': InvalidIsotopeError,
-      'ionic_symbol': None,
-      'roman_symbol': ChargeError,
-      'is_ion': False,
-      'charge': ChargeError,
-      'integer_charge': ChargeError,
-      'mass_number': InvalidIsotopeError,
-      'baryon_number': AtomicError,
-      'lepton_number': 0,
-      'half_life': InvalidIsotopeError,
-      'standard_atomic_weight': (1.008 * u.u).to(u.kg),
-      'mass': (1.008 * u.u).to(u.kg),
-      'nuclide_mass': InvalidIsotopeError,
-      'is_category("charged")': False,
-      'is_category("nonmetal")': True,
-      'is_category("proton")': False,
-      }),
-
-    ('H 1-', {},
-     {'particle': 'H 1-',
-      'element': 'H',
-      'isotope': None,
-      'isotope_name': InvalidIsotopeError,
-      'ionic_symbol': 'H 1-',
-      'roman_symbol': roman.OutOfRangeError,
-      'is_ion': True,
-      'integer_charge': -1,
-      'mass_number': InvalidIsotopeError,
-      'baryon_number': MissingAtomicDataError,
-      'lepton_number': 0,
-      'half_life': InvalidIsotopeError,
-      'standard_atomic_weight': InvalidElementError,
-      'nuclide_mass': InvalidIsotopeError,
-      'is_category("charged")': True,
-      'is_category("nonmetal")': True,
-      'is_category("proton")': False,
-      }),
-
-    ('H-1 0+', {}, {
-        'particle': 'H-1 0+',
-        'element': 'H',
-        'isotope': 'H-1',
-        'isotope_name': 'hydrogen-1',
-        'ionic_symbol': 'H-1 0+',
-        'roman_symbol': 'H-1 I',
-        'is_ion': False,
-        'charge': 0 * u.C,
-        'integer_charge': 0,
-        'mass_number': 1,
-        'baryon_number': 1,
-        'lepton_number': 0,
-        'half_life': np.inf * u.s,
-        'nuclide_mass': m_p,
-        'is_category("charged")': False,
-        'is_category("uncharged")': True,
-        'is_category("ion")': False,
-        'is_category("nonmetal")': True,
-        'is_category("proton")': False,
-    }),
-
-    ('D+', {},
-     {'particle': 'D 1+',
-      'element': 'H',
-      'element_name': 'hydrogen',
-      'isotope': 'D',
-      'isotope_name': 'deuterium',
-      'ionic_symbol': 'D 1+',
-      'roman_symbol': 'D II',
-      'is_ion': True,
-      'integer_charge': 1,
-      'atomic_number': 1,
-      'mass_number': 2,
-      'baryon_number': 2,
-      'lepton_number': 0,
-      'neutron_number': 1,
-      'is_category(require=("ion", "isotope"))': True,
-      'periodic_table.group': 1,
-      'periodic_table.block': 's',
-      'periodic_table.period': 1,
-      'periodic_table.category': 'nonmetal',
-      }),
-
-    ('tritium', {'Z': 1},
-     {'particle': 'T 1+',
-      'element': 'H',
-      'isotope': 'T',
-      'isotope_name': 'tritium',
-      'ionic_symbol': 'T 1+',
-      'roman_symbol': 'T II',
-      'is_ion': True,
-      'integer_charge': 1,
-      'atomic_number': 1,
-      'mass_number': 3,
-      'baryon_number': 3,
-      'lepton_number': 0,
-      'neutron_number': 2,
-      'is_category("ion", "isotope")': True,
-      'is_category(require="uncharged")': False,
-      'periodic_table.group': 1,
-      }),
-
-    ('Fe', {'Z': 17, 'mass_numb': 56},
-     {'particle': 'Fe-56 17+',
-      'element': 'Fe',
-      'element_name': 'iron',
-      'isotope': 'Fe-56',
-      'isotope_name': 'iron-56',
-      'ionic_symbol': 'Fe-56 17+',
-      'roman_symbol': 'Fe-56 XVIII',
-      'is_electron': False,
-      'is_ion': True,
-      'integer_charge': 17,
-      'atomic_number': 26,
-      'mass_number': 56,
-      'baryon_number': 56,
-      '__str__()': 'Fe-56 17+',
-      '__repr__()': 'Particle("Fe-56 17+")',
-      'is_category("element")': True,
-      'is_category("ion")': True,
-      'is_category("isotope")': True,
-      }),
-
-    ('alpha', {},
-     {'particle': 'He-4 2+',
-      'element': 'He',
-      'element_name': 'helium',
-      'isotope': 'He-4',
-      'isotope_name': 'helium-4',
-      'ionic_symbol': 'He-4 2+',
-      'roman_symbol': 'He-4 III',
-      'mass_energy': 5.971919969131517e-10 * u.J,
-      'is_ion': True,
-      'integer_charge': 2,
-      'atomic_number': 2,
-      'mass_number': 4,
-      'baryon_number': 4,
-      'lepton_number': 0,
-      'half_life': np.inf * u.s,
-      'recombine()': Particle('He-4 1+')
-      }),
-
-    ('He-4 0+', {},
-     {'particle': 'He-4 0+',
-      'element': 'He',
-      'isotope': 'He-4',
-      'mass_energy': 5.971919969131517e-10 * u.J,
-      }),
-
-    ('Li', {'mass_numb': 7},
-     {'particle': 'Li-7',
-      'element': 'Li',
-      'element_name': 'lithium',
-      'isotope': 'Li-7',
-      'isotope_name': 'lithium-7',
-      'ionic_symbol': None,
-      'roman_symbol': ChargeError,
-      'is_ion': False,
-      'integer_charge': ChargeError,
-      'atomic_number': 3,
-      'mass_number': 7,
-      'neutron_number': 4,
-      'baryon_number': 7,
-      'half_life': np.inf * u.s,
-      'nuclide_mass': 1.1647614796180463e-26 * u.kg,
-      }),
-
-    ('Cn-276', {"Z": 22},
-     {'particle': 'Cn-276 22+',
-      'element': 'Cn',
-      'isotope': 'Cn-276',
-      'isotope_name': 'copernicium-276',
-      'ionic_symbol': 'Cn-276 22+',
-      'roman_symbol': 'Cn-276 XXIII',
-      'is_ion': True,
-      'element_name': 'copernicium',
-      'integer_charge': 22,
-      'atomic_number': 112,
-      'mass_number': 276,
-      'neutron_number': 164,
-      'baryon_number': 276,
-      'lepton_number': 0,
-      }),
-
-    ('muon', {},
-     {'particle': 'mu-',
-      'element': None,
-      'isotope': None,
-      'isotope_name': InvalidElementError,
-      'ionic_symbol': None,
-      'roman_symbol': None,
-      'is_ion': False,
-      'integer_charge': -1,
-      'atomic_number': InvalidElementError,
-      'mass_number': InvalidIsotopeError,
-      'baryon_number': 0,
-      'lepton_number': 1,
-      }),
-
-    ('nu_tau', {},
-     {'particle': 'nu_tau',
-      'element': None,
-      'isotope': None,
-      'isotope_name': InvalidElementError,
-      'mass': MissingAtomicDataError,
-      'mass_energy': MissingAtomicDataError,
-      'integer_charge': 0,
-      'mass_number': InvalidIsotopeError,
-      'element_name': InvalidElementError,
-      'baryon_number': 0,
-      'lepton_number': 1,
-      'half_life': np.inf * u.s,
-      'is_electron': False,
-      'is_ion': False,
-      'is_category("fermion")': True,
-      'is_category("neutrino")': True,
-      'is_category("boson")': False,
-      'is_category("matter", exclude={"antimatter"})': True,
-      'is_category("matter", exclude=["antimatter"])': True,
-      'is_category("matter", exclude="antimatter")': True,
-      'is_category(any_of={"matter", "boson"})': True,
-      'is_category(any_of=["antimatter", "boson", "charged"])': False,
-      'is_category(["fermion", "lepton"], exclude="matter")': False,
-      'is_category("lepton", "invalid")': AtomicError,
-      'is_category(["boson"], exclude=["lepton", "invalid"])': AtomicError,
-      'is_category("boson", exclude="boson")': AtomicError,
-      'is_category(any_of="boson", exclude="boson")': AtomicError,
-      }),
-
-    (Particle('C'), {},
-     {'particle': 'C',
-      'atomic_number': 6,
-      'element': 'C',
-      }),
-
-    (Particle('C'), {'Z': 3, 'mass_numb': 14},
-     {'particle': 'C-14 3+',
-      'element': 'C',
-      'isotope': 'C-14',
-      'ionic_symbol': 'C-14 3+',
-      }),
+    (
+        "neutron",
+        {},
+        {
+            "particle": "n",
+            "element": None,
+            "isotope": None,
+            "isotope_name": InvalidElementError,
+            "ionic_symbol": None,
+            "roman_symbol": None,
+            "is_ion": False,
+            "is_electron": False,
+            "integer_charge": 0,
+            "atomic_number": InvalidElementError,
+            "mass_number": InvalidIsotopeError,
+            "baryon_number": 1,
+            "lepton_number": 0,
+            "mass": m_n,
+            "nuclide_mass": m_n,
+            "binding_energy": 0 * u.J,
+            "periodic_table.group": InvalidElementError,
+        },
+    ),
+    (
+        "p+",
+        {},
+        {
+            "particle": "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,
+            "integer_charge": 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-",
+        {},
+        {
+            "particle": "p-",
+            "element": None,
+            "element_name": InvalidElementError,
+            "isotope": None,
+            "isotope_name": InvalidElementError,
+            "ionic_symbol": None,
+            "roman_symbol": None,
+            "is_ion": False,
+            "mass": m_p,
+            "integer_charge": -1,
+            "spin": 1 / 2,
+            "half_life": np.inf * u.s,
+            "atomic_number": InvalidElementError,
+            "mass_number": InvalidIsotopeError,
+            "lepton_number": 0,
+            "baryon_number": -1,
+            "__str__()": "p-",
+            "__repr__()": 'Particle("p-")',
+            "periodic_table.group": InvalidElementError,
+        },
+    ),
+    (
+        "e-",
+        {},
+        {
+            "particle": "e-",
+            "element": None,
+            "element_name": InvalidElementError,
+            "isotope": None,
+            "isotope_name": InvalidElementError,
+            "ionic_symbol": None,
+            "roman_symbol": None,
+            "is_ion": False,
+            "mass": m_e,
+            "integer_charge": -1,
+            "spin": 1 / 2,
+            "half_life": np.inf * u.s,
+            "atomic_number": InvalidElementError,
+            "lepton_number": 1,
+            "baryon_number": 0,
+            "__str__()": "e-",
+            "__repr__()": 'Particle("e-")',
+            "binding_energy": InvalidIsotopeError,
+            "periodic_table.group": InvalidElementError,
+            "periodic_table.block": InvalidElementError,
+            "periodic_table.period": InvalidElementError,
+            "periodic_table.category": InvalidElementError,
+        },
+    ),
+    (
+        "e+",
+        {},
+        {
+            "particle": "e+",
+            "element": None,
+            "isotope": None,
+            "isotope_name": InvalidElementError,
+            "ionic_symbol": None,
+            "roman_symbol": None,
+            "is_ion": False,
+            "mass": m_e,
+            "mass_energy": (m_e * c ** 2).to("J"),
+            "nuclide_mass": InvalidIsotopeError,
+            "integer_charge": 1,
+            "spin": 1 / 2,
+            "half_life": np.inf * u.s,
+            "atomic_number": InvalidElementError,
+            "lepton_number": -1,
+            "baryon_number": 0,
+            'is_category(require="positron")': True,
+            'is_category(any_of={"positron"})': True,
+            'is_category(exclude="positron")': False,
+            'is_category("ion")': False,
+            "__str__()": "e+",
+            "__repr__()": 'Particle("e+")',
+            "periodic_table.group": InvalidElementError,
+            "periodic_table.block": InvalidElementError,
+            "periodic_table.period": InvalidElementError,
+            "periodic_table.category": InvalidElementError,
+        },
+    ),
+    (
+        "H",
+        {},
+        {
+            "particle": "H",
+            "element": "H",
+            "isotope": None,
+            "isotope_name": InvalidIsotopeError,
+            "ionic_symbol": None,
+            "roman_symbol": ChargeError,
+            "is_ion": False,
+            "charge": ChargeError,
+            "integer_charge": ChargeError,
+            "mass_number": InvalidIsotopeError,
+            "baryon_number": AtomicError,
+            "lepton_number": 0,
+            "half_life": InvalidIsotopeError,
+            "standard_atomic_weight": (1.008 * u.u).to(u.kg),
+            "mass": (1.008 * u.u).to(u.kg),
+            "nuclide_mass": InvalidIsotopeError,
+            'is_category("charged")': False,
+            'is_category("nonmetal")': True,
+            'is_category("proton")': False,
+        },
+    ),
+    (
+        "H 1-",
+        {},
+        {
+            "particle": "H 1-",
+            "element": "H",
+            "isotope": None,
+            "isotope_name": InvalidIsotopeError,
+            "ionic_symbol": "H 1-",
+            "roman_symbol": roman.OutOfRangeError,
+            "is_ion": True,
+            "integer_charge": -1,
+            "mass_number": InvalidIsotopeError,
+            "baryon_number": MissingAtomicDataError,
+            "lepton_number": 0,
+            "half_life": InvalidIsotopeError,
+            "standard_atomic_weight": InvalidElementError,
+            "nuclide_mass": InvalidIsotopeError,
+            'is_category("charged")': True,
+            'is_category("nonmetal")': True,
+            'is_category("proton")': False,
+        },
+    ),
+    (
+        "H-1 0+",
+        {},
+        {
+            "particle": "H-1 0+",
+            "element": "H",
+            "isotope": "H-1",
+            "isotope_name": "hydrogen-1",
+            "ionic_symbol": "H-1 0+",
+            "roman_symbol": "H-1 I",
+            "is_ion": False,
+            "charge": 0 * u.C,
+            "integer_charge": 0,
+            "mass_number": 1,
+            "baryon_number": 1,
+            "lepton_number": 0,
+            "half_life": np.inf * u.s,
+            "nuclide_mass": m_p,
+            'is_category("charged")': False,
+            'is_category("uncharged")': True,
+            'is_category("ion")': False,
+            'is_category("nonmetal")': True,
+            'is_category("proton")': False,
+        },
+    ),
+    (
+        "D+",
+        {},
+        {
+            "particle": "D 1+",
+            "element": "H",
+            "element_name": "hydrogen",
+            "isotope": "D",
+            "isotope_name": "deuterium",
+            "ionic_symbol": "D 1+",
+            "roman_symbol": "D II",
+            "is_ion": True,
+            "integer_charge": 1,
+            "atomic_number": 1,
+            "mass_number": 2,
+            "baryon_number": 2,
+            "lepton_number": 0,
+            "neutron_number": 1,
+            'is_category(require=("ion", "isotope"))': True,
+            "periodic_table.group": 1,
+            "periodic_table.block": "s",
+            "periodic_table.period": 1,
+            "periodic_table.category": "nonmetal",
+        },
+    ),
+    (
+        "tritium",
+        {"Z": 1},
+        {
+            "particle": "T 1+",
+            "element": "H",
+            "isotope": "T",
+            "isotope_name": "tritium",
+            "ionic_symbol": "T 1+",
+            "roman_symbol": "T II",
+            "is_ion": True,
+            "integer_charge": 1,
+            "atomic_number": 1,
+            "mass_number": 3,
+            "baryon_number": 3,
+            "lepton_number": 0,
+            "neutron_number": 2,
+            'is_category("ion", "isotope")': True,
+            'is_category(require="uncharged")': False,
+            "periodic_table.group": 1,
+        },
+    ),
+    (
+        "Fe",
+        {"Z": 17, "mass_numb": 56},
+        {
+            "particle": "Fe-56 17+",
+            "element": "Fe",
+            "element_name": "iron",
+            "isotope": "Fe-56",
+            "isotope_name": "iron-56",
+            "ionic_symbol": "Fe-56 17+",
+            "roman_symbol": "Fe-56 XVIII",
+            "is_electron": False,
+            "is_ion": True,
+            "integer_charge": 17,
+            "atomic_number": 26,
+            "mass_number": 56,
+            "baryon_number": 56,
+            "__str__()": "Fe-56 17+",
+            "__repr__()": 'Particle("Fe-56 17+")',
+            'is_category("element")': True,
+            'is_category("ion")': True,
+            'is_category("isotope")': True,
+        },
+    ),
+    (
+        "alpha",
+        {},
+        {
+            "particle": "He-4 2+",
+            "element": "He",
+            "element_name": "helium",
+            "isotope": "He-4",
+            "isotope_name": "helium-4",
+            "ionic_symbol": "He-4 2+",
+            "roman_symbol": "He-4 III",
+            "mass_energy": 5.971919969131517e-10 * u.J,
+            "is_ion": True,
+            "integer_charge": 2,
+            "atomic_number": 2,
+            "mass_number": 4,
+            "baryon_number": 4,
+            "lepton_number": 0,
+            "half_life": np.inf * u.s,
+            "recombine()": Particle("He-4 1+"),
+        },
+    ),
+    (
+        "He-4 0+",
+        {},
+        {
+            "particle": "He-4 0+",
+            "element": "He",
+            "isotope": "He-4",
+            "mass_energy": 5.971919969131517e-10 * u.J,
+        },
+    ),
+    (
+        "Li",
+        {"mass_numb": 7},
+        {
+            "particle": "Li-7",
+            "element": "Li",
+            "element_name": "lithium",
+            "isotope": "Li-7",
+            "isotope_name": "lithium-7",
+            "ionic_symbol": None,
+            "roman_symbol": ChargeError,
+            "is_ion": False,
+            "integer_charge": ChargeError,
+            "atomic_number": 3,
+            "mass_number": 7,
+            "neutron_number": 4,
+            "baryon_number": 7,
+            "half_life": np.inf * u.s,
+            "nuclide_mass": 1.1647614796180463e-26 * u.kg,
+        },
+    ),
+    (
+        "Cn-276",
+        {"Z": 22},
+        {
+            "particle": "Cn-276 22+",
+            "element": "Cn",
+            "isotope": "Cn-276",
+            "isotope_name": "copernicium-276",
+            "ionic_symbol": "Cn-276 22+",
+            "roman_symbol": "Cn-276 XXIII",
+            "is_ion": True,
+            "element_name": "copernicium",
+            "integer_charge": 22,
+            "atomic_number": 112,
+            "mass_number": 276,
+            "neutron_number": 164,
+            "baryon_number": 276,
+            "lepton_number": 0,
+        },
+    ),
+    (
+        "muon",
+        {},
+        {
+            "particle": "mu-",
+            "element": None,
+            "isotope": None,
+            "isotope_name": InvalidElementError,
+            "ionic_symbol": None,
+            "roman_symbol": None,
+            "is_ion": False,
+            "integer_charge": -1,
+            "atomic_number": InvalidElementError,
+            "mass_number": InvalidIsotopeError,
+            "baryon_number": 0,
+            "lepton_number": 1,
+        },
+    ),
+    (
+        "nu_tau",
+        {},
+        {
+            "particle": "nu_tau",
+            "element": None,
+            "isotope": None,
+            "isotope_name": InvalidElementError,
+            "mass": MissingAtomicDataError,
+            "mass_energy": MissingAtomicDataError,
+            "integer_charge": 0,
+            "mass_number": InvalidIsotopeError,
+            "element_name": InvalidElementError,
+            "baryon_number": 0,
+            "lepton_number": 1,
+            "half_life": np.inf * u.s,
+            "is_electron": False,
+            "is_ion": False,
+            'is_category("fermion")': True,
+            'is_category("neutrino")': True,
+            'is_category("boson")': False,
+            'is_category("matter", exclude={"antimatter"})': True,
+            'is_category("matter", exclude=["antimatter"])': True,
+            'is_category("matter", exclude="antimatter")': True,
+            'is_category(any_of={"matter", "boson"})': True,
+            'is_category(any_of=["antimatter", "boson", "charged"])': False,
+            'is_category(["fermion", "lepton"], exclude="matter")': False,
+            'is_category("lepton", "invalid")': AtomicError,
+            'is_category(["boson"], exclude=["lepton", "invalid"])': AtomicError,
+            'is_category("boson", exclude="boson")': AtomicError,
+            'is_category(any_of="boson", exclude="boson")': AtomicError,
+        },
+    ),
+    (Particle("C"), {}, {"particle": "C", "atomic_number": 6, "element": "C"}),
+    (
+        Particle("C"),
+        {"Z": 3, "mass_numb": 14},
+        {
+            "particle": "C-14 3+",
+            "element": "C",
+            "isotope": "C-14",
+            "ionic_symbol": "C-14 3+",
+        },
+    ),
 ]
 
 
@@ -444,8 +497,10 @@ def test_Particle_class(arg, kwargs, expected_dict):
             except pytest.fail.Exception:
                 errmsg += f"\n{call}[{key}] does not raise {expected}."
             except Exception:
-                errmsg += (f"\n{call}[{key}] does not raise {expected} but "
-                           f"raises a different exception.")
+                errmsg += (
+                    f"\n{call}[{key}] does not raise {expected} but "
+                    f"raises a different exception."
+                )
 
         else:
 
@@ -453,8 +508,10 @@ def test_Particle_class(arg, kwargs, expected_dict):
                 result = eval(f"particle.{key}")
                 assert result == expected or u.isclose(result, expected)
             except AssertionError:
-                errmsg += (f"\n{call}.{key} returns {result} instead "
-                           f"of the expected value of {expected}.")
+                errmsg += (
+                    f"\n{call}.{key} returns {result} instead "
+                    f"of the expected value of {expected}."
+                )
             except Exception:
                 errmsg += f"\n{call}.{key} raises an unexpected exception."
 
@@ -463,19 +520,19 @@ def test_Particle_class(arg, kwargs, expected_dict):
 
 
 equivalent_particles_table = [
-    ['H', 'hydrogen', 'hYdRoGeN'],
-    ['p+', 'proton', 'H-1+', 'H-1 1+', 'H-1 +1'],
-    ['D', 'H-2', 'Hydrogen-2', 'deuterium'],
-    ['T', 'H-3', 'Hydrogen-3', 'tritium'],
-    ['alpha', 'He-4++', 'He-4 2+', 'He-4 +2', 'He-4 III'],
-    ['e-', 'electron', 'e'],
-    ['e+', 'positron'],
-    ['p-', 'antiproton'],
-    ['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)]
+    ["H", "hydrogen", "hYdRoGeN"],
+    ["p+", "proton", "H-1+", "H-1 1+", "H-1 +1"],
+    ["D", "H-2", "Hydrogen-2", "deuterium"],
+    ["T", "H-3", "Hydrogen-3", "tritium"],
+    ["alpha", "He-4++", "He-4 2+", "He-4 +2", "He-4 III"],
+    ["e-", "electron", "e"],
+    ["e+", "positron"],
+    ["p-", "antiproton"],
+    ["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)],
 ]
 
 
@@ -487,87 +544,87 @@ def test_Particle_equivalent_cases(equivalent_particles):
 
 # arg, kwargs, attribute, exception
 test_Particle_error_table = [
-    ('a', {}, "", InvalidParticleError),
-    ('d+', {'mass_numb': 9}, "", InvalidParticleError),
-    ('H', {'mass_numb': 99}, "", InvalidParticleError),
-    ('Au-818', {}, "", InvalidParticleError),
-    ('Au-12', {}, "", InvalidParticleError),
-    ('Au', {'mass_numb': 13}, "", InvalidParticleError),
-    ('Au', {'mass_numb': 921}, "", InvalidParticleError),
-    ('e-', {'Z': -1}, "", InvalidParticleError),
-    ('e-', {}, '.atomic_number', InvalidElementError),
-    ('alpha', {}, '.standard_atomic_weight', InvalidElementError),
-    ('Fe-56', {}, '.standard_atomic_weight', InvalidElementError),
-    ('e-', {}, '.standard_atomic_weight', InvalidElementError),
-    ('tau-', {}, '.element_name', InvalidElementError),
-    ('tau+', {}, '.atomic_number', InvalidElementError),
-    ('neutron', {}, '.atomic_number', InvalidElementError),
-    ('H', {'Z': 0}, '.mass_number', InvalidIsotopeError),
-    ('neutron', {}, '.mass_number', InvalidIsotopeError),
-    ('He', {'mass_numb': 4}, '.charge', ChargeError),
-    ('He', {'mass_numb': 4}, '.integer_charge', ChargeError),
-    ('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),
+    ("a", {}, "", InvalidParticleError),
+    ("d+", {"mass_numb": 9}, "", InvalidParticleError),
+    ("H", {"mass_numb": 99}, "", InvalidParticleError),
+    ("Au-818", {}, "", InvalidParticleError),
+    ("Au-12", {}, "", InvalidParticleError),
+    ("Au", {"mass_numb": 13}, "", InvalidParticleError),
+    ("Au", {"mass_numb": 921}, "", InvalidParticleError),
+    ("e-", {"Z": -1}, "", InvalidParticleError),
+    ("e-", {}, ".atomic_number", InvalidElementError),
+    ("alpha", {}, ".standard_atomic_weight", InvalidElementError),
+    ("Fe-56", {}, ".standard_atomic_weight", InvalidElementError),
+    ("e-", {}, ".standard_atomic_weight", InvalidElementError),
+    ("tau-", {}, ".element_name", InvalidElementError),
+    ("tau+", {}, ".atomic_number", InvalidElementError),
+    ("neutron", {}, ".atomic_number", InvalidElementError),
+    ("H", {"Z": 0}, ".mass_number", InvalidIsotopeError),
+    ("neutron", {}, ".mass_number", InvalidIsotopeError),
+    ("He", {"mass_numb": 4}, ".charge", ChargeError),
+    ("He", {"mass_numb": 4}, ".integer_charge", ChargeError),
+    ("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),
-    ('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),
+    ("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),
 ]
 
 
-@pytest.mark.parametrize(
-    "arg, kwargs, attribute, exception", test_Particle_error_table)
+@pytest.mark.parametrize("arg, kwargs, attribute, exception", test_Particle_error_table)
 def test_Particle_errors(arg, kwargs, attribute, exception):
     """
     Test that the appropriate exceptions are raised during the creation
     and use of a `~plasmapy.particles.Particle` object.
     """
     with pytest.raises(exception):
-        exec(f'Particle(arg, **kwargs){attribute}')
+        exec(f"Particle(arg, **kwargs){attribute}")
         pytest.fail(
             f"The following command: "
             f"\n\n  {call_string(Particle, arg, kwargs)}{attribute}\n\n"
-            f"did not raise a {exception.__name__} as expected")
+            f"did not raise a {exception.__name__} as expected"
+        )
 
 
 # arg, kwargs, attribute, exception
 test_Particle_warning_table = [
-    ('H----', {}, "", AtomicWarning),
-    ('alpha', {'mass_numb': 4}, "", AtomicWarning),
-    ('alpha', {'Z': 2}, "", AtomicWarning)
+    ("H----", {}, "", AtomicWarning),
+    ("alpha", {"mass_numb": 4}, "", AtomicWarning),
+    ("alpha", {"Z": 2}, "", AtomicWarning),
 ]
 
 
-@pytest.mark.parametrize(
-    "arg, kwargs, attribute, warning", test_Particle_warning_table)
+@pytest.mark.parametrize("arg, kwargs, attribute, warning", test_Particle_warning_table)
 def test_Particle_warnings(arg, kwargs, attribute, warning):
     """
     Test that the appropriate warnings are issued during the creation
     and use of a `~plasmapy.particles.Particle` object.
     """
     with pytest.warns(warning) as record:
-        exec(f'Particle(arg, **kwargs){attribute}')
+        exec(f"Particle(arg, **kwargs){attribute}")
         if not record:
             pytest.fail(
                 f"The following command: "
                 f"\n\n >>> {call_string(Particle, arg, kwargs)}{attribute}\n\n"
-                f"did not issue a {warning.__name__} as expected")
+                f"did not issue a {warning.__name__} as expected"
+            )
 
 
 def test_Particle_cmp():
     """Test ``__eq__`` and ``__ne__`` in the Particle class."""
-    proton1 = Particle('p+')
-    proton2 = Particle('proton')
-    electron = Particle('e-')
+    proton1 = Particle("p+")
+    proton2 = Particle("proton")
+    electron = Particle("e-")
 
     assert proton1 == proton2, "Particle('p+') == Particle('proton') is False."
     assert proton1 != electron, "Particle('p+') == Particle('e-') is True."
@@ -576,22 +633,22 @@ def test_Particle_cmp():
         electron == 1
 
     with pytest.raises(AtomicError):
-        electron == 'dfasdf'
+        electron == "dfasdf"
 
 
 nuclide_mass_and_mass_equiv_table = [
-    ('n', 'neutron'),
-    ('p+', 'proton'),
-    ('H-1', 'p+'),
-    ('H-1 0+', 'p+'),
-    ('D', 'D+'),
-    ('T', 'T+'),
-    ('He-4', 'alpha'),
-    ('Fe-56', 'Fe-56 26+'),
+    ("n", "neutron"),
+    ("p+", "proton"),
+    ("H-1", "p+"),
+    ("H-1 0+", "p+"),
+    ("D", "D+"),
+    ("T", "T+"),
+    ("He-4", "alpha"),
+    ("Fe-56", "Fe-56 26+"),
 ]
 
 
-@pytest.mark.parametrize('isotope, ion', nuclide_mass_and_mass_equiv_table)
+@pytest.mark.parametrize("isotope, ion", nuclide_mass_and_mass_equiv_table)
 def test_particle_class_mass_nuclide_mass(isotope: str, ion: str):
     """
     Test that the ``mass`` and ``nuclide_mass`` attributes return
@@ -605,13 +662,17 @@ def test_particle_class_mass_nuclide_mass(isotope: str, ion: str):
     Isotope = Particle(isotope)
     Ion = Particle(ion)
 
-    if Isotope.categories & {'isotope', 'baryon'} and Ion.categories & {'ion', 'baryon'}:
+    if Isotope.categories & {"isotope", "baryon"} and Ion.categories & {
+        "ion",
+        "baryon",
+    }:
 
         particle = Isotope.particle
 
         assert Isotope.nuclide_mass == Ion.mass, (
             f"Particle({repr(particle)}).nuclide_mass does not equal "
-            f"Particle({repr(particle)}).mass")
+            f"Particle({repr(particle)}).mass"
+        )
 
     else:
 
@@ -621,7 +682,8 @@ def test_particle_class_mass_nuclide_mass(isotope: str, ion: str):
             f"an isotope with no charge information, and a fully "
             f"ionized ion of that isotope, in order to make sure "
             f"that the nuclide mass of the isotope equals the mass "
-            f"of the ion.")
+            f"of the ion."
+        )
 
         assert Isotope.isotope and not Isotope.ion, inputerrmsg
         assert Isotope.isotope == Ion.isotope, inputerrmsg
@@ -633,7 +695,8 @@ def test_particle_class_mass_nuclide_mass(isotope: str, ion: str):
             f"the test are:\n\n"
             f"Particle({repr(ion)}).mass = {Ion.mass}\n"
             f"Particle({repr(isotope)}).nuclide_mass = {Isotope.nuclide_mass}"
-            "\n")
+            "\n"
+        )
 
 
 def test_particle_half_life_string():
@@ -645,7 +708,7 @@ def test_particle_half_life_string():
     """
 
     for isotope in known_isotopes():
-        half_life = _Isotopes[isotope].get('half-life', None)
+        half_life = _Isotopes[isotope].get("half-life", None)
         if isinstance(half_life, str):
             break
 
@@ -653,47 +716,49 @@ def test_particle_half_life_string():
         assert isinstance(Particle(isotope).half_life, str)
 
 
-@pytest.mark.parametrize("p, is_one", [(Particle('e-'), True), (Particle('p+'), False)])
+@pytest.mark.parametrize("p, is_one", [(Particle("e-"), True), (Particle("p+"), False)])
 def test_particle_is_electron(p, is_one):
     assert p.is_electron == is_one
 
 
 def test_particle_bool_error():
     with pytest.raises(AtomicError):
-        bool(Particle('e-'))
+        bool(Particle("e-"))
 
 
 particle_antiparticle_pairs = [
-    ('p+', 'p-'),
-    ('n', 'antineutron'),
-    ('e-', 'e+'),
-    ('mu-', 'mu+'),
-    ('tau-', 'tau+'),
-    ('nu_e', 'anti_nu_e'),
-    ('nu_mu', 'anti_nu_mu'),
-    ('nu_tau', 'anti_nu_tau'),
+    ("p+", "p-"),
+    ("n", "antineutron"),
+    ("e-", "e+"),
+    ("mu-", "mu+"),
+    ("tau-", "tau+"),
+    ("nu_e", "anti_nu_e"),
+    ("nu_mu", "anti_nu_mu"),
+    ("nu_tau", "anti_nu_tau"),
 ]
 
 
 @pytest.mark.parametrize("particle, antiparticle", particle_antiparticle_pairs)
 def test_particle_inversion(particle, antiparticle):
     """Test that particles have the correct antiparticles."""
-    assert Particle(particle).antiparticle == Particle(antiparticle), \
-        (f"The antiparticle of {particle} is found to be "
-         f"{~Particle(particle)} instead of {antiparticle}.")
+    assert Particle(particle).antiparticle == Particle(antiparticle), (
+        f"The antiparticle of {particle} is found to be "
+        f"{~Particle(particle)} instead of {antiparticle}."
+    )
 
 
 @pytest.mark.parametrize("particle, antiparticle", particle_antiparticle_pairs)
 def test_antiparticle_inversion(particle, antiparticle):
     """Test that antiparticles have the correct antiparticles."""
-    assert Particle(antiparticle).antiparticle == Particle(particle), \
-        (f"The antiparticle of {antiparticle} is found to be "
-         f"{~Particle(antiparticle)} instead of {particle}.")
+    assert Particle(antiparticle).antiparticle == Particle(particle), (
+        f"The antiparticle of {antiparticle} is found to be "
+        f"{~Particle(antiparticle)} instead of {particle}."
+    )
 
 
 def test_unary_operator_for_elements():
     with pytest.raises(AtomicError):
-        Particle('C').antiparticle
+        Particle("C").antiparticle
 
 
 @pytest.fixture(params=ParticleZoo.everything)
@@ -708,7 +773,8 @@ def opposite(particle):
     except Exception as exc:
         raise InvalidParticleError(
             f"The unary ~ (invert) operator is unable to find the "
-            f"antiparticle of {particle}.") from exc
+            f"antiparticle of {particle}."
+        ) from exc
     return opposite_particle
 
 
@@ -717,31 +783,35 @@ class Test_antiparticle_properties_inversion:
     Test particle and antiparticle inversion and properties for Particle
     instances.
     """
+
     def test_inverted_inversion(self, particle):
         """
         Test that the antiparticle of the antiparticle of a particle is
         the original particle.
         """
-        assert particle == ~~particle, \
-            (f"~~{repr(particle)} equals {repr(~~particle)} instead of "
-             f"{repr(particle)}.")
+        assert particle == ~~particle, (
+            f"~~{repr(particle)} equals {repr(~~particle)} instead of "
+            f"{repr(particle)}."
+        )
 
     def test_opposite_charge(self, particle, opposite):
         """
         Test that a particle and its antiparticle have the opposite
         charge.
         """
-        assert particle.integer_charge == -opposite.integer_charge, \
-            (f"The charges of {particle} and {opposite} are not "
-             f"opposites, as expected of a particle/antiparticle pair.")
+        assert particle.integer_charge == -opposite.integer_charge, (
+            f"The charges of {particle} and {opposite} are not "
+            f"opposites, as expected of a particle/antiparticle pair."
+        )
 
     def test_equal_mass(self, particle, opposite):
         """
         Test that a particle and its antiparticle have the same mass.
         """
-        assert particle._attributes['mass'] == opposite._attributes['mass'], \
-            (f"The masses of {particle} and {opposite} are not equal, "
-             f"as expected of a particle/antiparticle pair.")
+        assert particle._attributes["mass"] == opposite._attributes["mass"], (
+            f"The masses of {particle} and {opposite} are not equal, "
+            f"as expected of a particle/antiparticle pair."
+        )
 
     def test_antiparticle_attribute_and_operator(self, particle, opposite):
         """
@@ -749,13 +819,14 @@ def test_antiparticle_attribute_and_operator(self, particle, opposite):
         value as the unary ~ (invert) operator acting on the same
         Particle instance.
         """
-        assert particle.antiparticle == ~particle, \
-            (f"{repr(particle)}.antiparticle returned "
-             f"{particle.antiparticle}, whereas ~{repr(particle)} "
-             f"returned {~particle}.")
+        assert particle.antiparticle == ~particle, (
+            f"{repr(particle)}.antiparticle returned "
+            f"{particle.antiparticle}, whereas ~{repr(particle)} "
+            f"returned {~particle}."
+        )
 
 
-@pytest.mark.parametrize('arg', ['e-', 'D+', 'Fe 25+', 'H-', 'mu+'])
+@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
@@ -764,20 +835,22 @@ def test_particleing_a_particle(arg):
     particle = Particle(arg)
 
     assert particle == Particle(particle), (
-        f"Particle({repr(arg)}) does not equal "
-        f"Particle(Particle({repr(arg)}).")
+        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)})).")
+        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).")
+        f"create a new object in memory (e.g., a copy)."
+    )
 
 
-@pytest.mark.parametrize("key", [Particle('H'), Particle('e+')])
+@pytest.mark.parametrize("key", [Particle("H"), Particle("e+")])
 def test_that_object_can_be_dict_key(key):
     """
     Test that ``key`` can be used as the key of a `dict`.
@@ -813,3 +886,114 @@ def test_that_object_can_be_dict_key(key):
         raise TypeError(error_message) from exc
 
     assert dictionary[key] is value
+
+
+# TODO: These tests may be refactored using forthcoming functionality in
+#       plasmapy.tests.helpers.  It may be necessary to case the expected
+#       results as certain types (e.g., numpy.float64).
+
+customized_particle_tests = [
+    (DimensionlessParticle, {"mass": 1.0, "charge": -1.0}, "mass", 1.0),
+    (DimensionlessParticle, {"mass": 0.0, "charge": -1.0}, "charge", -1.0),
+    (DimensionlessParticle, {}, "mass", np.nan),
+    (DimensionlessParticle, {}, "charge", np.nan),
+    (DimensionlessParticle, {"mass": np.inf}, "mass", np.inf),
+    (DimensionlessParticle, {"charge": np.inf}, "charge", np.inf),
+    (DimensionlessParticle, {"charge": 1.0 * u.dimensionless_unscaled}, "charge", 1.0),
+    (DimensionlessParticle, {"mass": 1.0 * u.dimensionless_unscaled}, "mass", 1.0),
+    (CustomParticle, {}, "mass", np.nan * u.kg),
+    (CustomParticle, {}, "charge", np.nan * u.C),
+    (CustomParticle, {"mass": 1.1 * u.kg, "charge": -0.1 * u.C}, "mass", 1.1 * u.kg),
+    (CustomParticle, {"charge": -0.1 * u.C}, "charge", -0.1 * u.C),
+    (CustomParticle, {"charge": -2}, "charge", -2 * const.e.si),
+    (CustomParticle, {"mass": np.inf * u.g}, "mass", np.inf * u.kg),
+    (CustomParticle, {"charge": -np.inf * u.kC}, "charge", -np.inf * u.C),
+]
+
+
+@pytest.mark.parametrize("cls, kwargs, attr, expected", customized_particle_tests)
+def test_customized_particles(cls, kwargs, attr, expected):
+    """Test the attributes of dimensionless and custom particles."""
+    instance = cls(**kwargs)
+    value = getattr(instance, attr)
+
+    if not u.isclose(value, expected, equal_nan=True):
+        pytest.fail(
+            f"{call_string(cls, kwargs=kwargs)}.{attr} should return a value "
+            f"of {expected}, but instead returned a value of {value}."
+        )
+
+
+customized_particle_errors = [
+    (DimensionlessParticle, {"mass": -1e-36}, InvalidParticleError),
+    (DimensionlessParticle, {"mass": [1, 1]}, InvalidParticleError),
+    (DimensionlessParticle, {"charge": [-1, 1]}, InvalidParticleError),
+    (DimensionlessParticle, {"mass": True}, InvalidParticleError),
+    (
+        DimensionlessParticle,
+        {"mass": np.array([1, 2]) * u.dimensionless_unscaled},
+        InvalidParticleError,
+    ),
+    (
+        DimensionlessParticle,
+        {"charge": np.array([1, 2]) * u.dimensionless_unscaled},
+        InvalidParticleError,
+    ),
+    (
+        DimensionlessParticle,
+        {"charge": (1 + 2j) * u.dimensionless_unscaled},
+        InvalidParticleError,
+    ),
+    (CustomParticle, {"charge": 5 + 2j}, InvalidParticleError),
+    (CustomParticle, {"mass": 5 + 2j}, InvalidParticleError),
+    (CustomParticle, {"charge": np.complex128(5 + 2j)}, InvalidParticleError),
+    (CustomParticle, {"mass": np.complex128(5 + 2j)}, InvalidParticleError),
+    (CustomParticle, {"mass": -1e-36 * u.kg}, InvalidParticleError),
+    (CustomParticle, {"mass": np.array([1, 1]) * u.kg}, InvalidParticleError),
+    (CustomParticle, {"charge": np.array([1, 1]) * u.C}, InvalidParticleError),
+    (CustomParticle, {"charge": (5 + 2j) * u.C}, InvalidParticleError),
+    (CustomParticle, {"mass": (5 + 2j) * u.kg}, InvalidParticleError),
+    (CustomParticle, {"charge": np.complex128(5 + 2j) * u.C}, InvalidParticleError),
+    (CustomParticle, {"mass": np.complex128(5 + 2j) * u.kg}, InvalidParticleError),
+]
+
+
+@pytest.mark.parametrize("cls, kwargs, exception", customized_particle_errors)
+def test_customized_particles_errors(cls, kwargs, exception):
+    """
+    Test that attempting to create invalid dimensionless or custom particles
+    results in an InvalidParticleError.
+    """
+    with pytest.raises(exception):
+        cls(**kwargs)
+        pytest.fail(f"{cls.__name__}(**{kwargs}) did not raise: {exception.__name__}.")
+
+
+customized_particle_repr_table = [
+    (
+        CustomParticle,
+        {"mass": 5.12 * u.kg, "charge": 6.2 * u.C},
+        "CustomParticle(mass=5.12 kg, charge=6.2 C)",
+    ),
+    (
+        DimensionlessParticle,
+        {"mass": 5.2, "charge": 6.3},
+        "DimensionlessParticle(mass=5.2, charge=6.3)",
+    ),
+]
+
+
+@pytest.mark.parametrize("cls, kwargs, expected_repr", customized_particle_repr_table)
+def test_customized_particle_repr(cls, kwargs, expected_repr):
+    """Test the string representations of dimensionless and custom particles."""
+    instance = cls(**kwargs)
+    from_repr = repr(instance)
+    from_str = str(instance)
+    if not expected_repr == from_repr == from_str:
+        pytest.fail(
+            f"Problem with a string representation of {cls.__name__} "
+            f"with kwargs = {kwargs}.\n\n"
+            f"expected_repr = {expected_repr}"
+            f"from_str: {from_str}"
+            f"from_repr: {from_repr}"
+        )