Repository: PlasmaPy/PlasmaPy (python)

Issue:
Separate how Particle class treats ions and neutral atoms
Right now the `Particle` class has an attribute `Particle.ion` which returns the ionic symbol if the particle is either an ion or a neutral charged particle (see also #338 which changes `ion_symbol` to `ionic_symbol`).  This can lead to confusion since if we treat `Particle.ion` as a `bool` to check whether or not something is an ion, then it will return `True` for neutral atoms where the charge information is given.  

One possibility would be to create an `is_ion` attribute for `Particle` that returns a `bool` of whether or not the particle is a charged ion.  In that case, we could rename `Particle.ion` as `Particle.ionic_symbol` (and `Particle.isotope` as `Particle.isotope_symbol`, etc.) and also create a `Particle.is_isotope` attribute.  We should keep in mind that plasma physicists sometimes worry about electron-positron pair plasmas as we are doing this.

The `categories` and `is_category` attributes of `Particle` need to updated to reflect that `'H 0+'`, for example, is not classified as an `'ion'`.  This would also affect how the `@particle_input` decorator treats things (e.g., the `any_of`, `require`, and `exclude` keyword arguments).

I'm also wondering if the `atomic` subpackage should keep separating elements (e.g., `'H'`, `'He'`, etc.) from neutrally charged atoms (e.g., `'H 0+'`, `'He 0+'`, etc.).  I'm still inclined to keep them separate, since explicitly putting in the charge is better than implicitly assuming it, but this is worth thinking more about too.

Pull request description:
Improve treatment of neutral atoms in Particle class and add __invert__ attribute
This PR addresses improves the separation between neutral atoms with charge information and charged ions.  A new and exciting feature is the `is_ion` attribute for the `Particle` class, and I think I'm renaming the `Particle.ion` attribute to `Particle.ionic_symbol`.  I'm putting more detail in the commit log.  This mostly closes #343.

Patch:
diff --git a/docs/atomic/particle_class.rst b/docs/atomic/particle_class.rst
index 4de86de4..5f4267c0 100644
--- a/docs/atomic/particle_class.rst
+++ b/docs/atomic/particle_class.rst
@@ -4,7 +4,7 @@ Particle Class
 **************
 
 The `~plasmapy.atomic.Particle` class provides an object-oriented
-interface to particle information.
+interface for particle information.
 
 Creating a Particle Instance
 ============================
@@ -30,7 +30,7 @@ but element names and many aliases are not.
 >>> hydride = Particle('H-')
 
 An `int` may be used as the first positional argument to
-`plasmapy.atomic.Particle` to represent an atomic number.  For isotopes
+`~plasmapy.atomic.Particle` to represent an atomic number.  For isotopes
 and ions, the mass number may be represented with the `mass_numb`
 keyword and the integer charge may be represented with the `Z` keyword.
 
@@ -48,8 +48,8 @@ directly are: `proton`, `electron`, `neutron`, `positron`, `deuteron`,
 Accessing Particle Properties
 =============================
 
-The properties of each particle may be accessed using attributes of the
-`~plasmapy.atomic.Particle` instance.
+The properties of each particle may be accessed using the attributes of
+the `~plasmapy.atomic.Particle` instance.
 
 >>> proton.atomic_number
 1
@@ -71,7 +71,7 @@ SI units.
 <Quantity 0.49225958 GeV>
 
 Strings representing particles may be accessed using the `particle`,
-`element`, `isotope`, and `ion` attributes.
+`element`, `isotope`, and `ionic_symbol` attributes.
 
 >>> antimuon.particle
 'mu-'
@@ -79,7 +79,7 @@ Strings representing particles may be accessed using the `particle`,
 'H'
 >>> alpha.isotope
 'He-4'
->>> deuteron.ion
+>>> deuteron.ionic_symbol
 'D 1+'
 
 .. _particle-class-categories
@@ -139,19 +139,22 @@ True
 >>> alpha == 'He-4 1+'
 False
 
-The `is_electron` attribute provides a quick way to check whether or not
-a particle is an electron.
+The `is_electron` and `is_ion` attributes provide a quick way to check
+whether or not a particle is an electron or ion, respectively.
 
 >>> electron.is_electron
 True
 >>> hydride.is_electron
 False
+>>> deuteron.is_ion
+True
 
-The `element`, `isotope`, and `ion` return `None` when the particle is
-not the respective category.  Because non-empty strings evaluate to
-`True` and `None` evaluates to `False` when converted to a `bool`, these
-attributes may be used in conditional statements to test whether or not
-a particle is in one of these categories.
+The `element` and `isotope` attributes return `None` when the particle
+does not correspond to an element or isotope.  Because non-empty
+strings evaluate to `True` and `None` evaluates to `False` when
+converted to a `bool`, these attributes may be used in conditional
+statements to test whether or not a particle is in one of these
+categories.
 
 .. code-block:: python
 
@@ -162,5 +165,17 @@ a particle is in one of these categories.
             print(f"{particle} corresponds to element {particle.element}")
         if particle.isotope:
             print(f"{particle} corresponds to isotope {particle.isotope}")
-        if particle.ion:
-            print(f"{particle} corresponds to ion {particle.ion}")
+
+.. _particle-class-antiparticles
+
+Returning Antiparticles
+=======================
+
+The antiparticle of an elementary particle or antiparticle may be found
+by using the unary operator `~` to invert a `~plasmapy.atomic.Particle`
+instance.
+
+>>> ~electron
+Particle("e+")
+>>> ~antimuon
+Particle("mu-")
diff --git a/plasmapy/atomic/atomic.py b/plasmapy/atomic/atomic.py
index 514efcf5..e9714297 100644
--- a/plasmapy/atomic/atomic.py
+++ b/plasmapy/atomic/atomic.py
@@ -341,7 +341,7 @@ def ion_mass(particle: Particle, *, Z: int = None, mass_numb: int = None) -> u.Q
 
     # TODO: Remove deprecated functionality elsewhere in the code
 
-    if particle.ion or particle.particle in {'e+'}:
+    if particle.is_ion or particle.particle in {'e+'}:
         return particle.mass
     elif particle.particle == 'n':
         raise InvalidIonError
diff --git a/plasmapy/atomic/parsing.py b/plasmapy/atomic/parsing.py
index 0d302216..4e3a5bed 100644
--- a/plasmapy/atomic/parsing.py
+++ b/plasmapy/atomic/parsing.py
@@ -43,7 +43,8 @@ def _create_alias_dicts(Particles: dict) -> (Dict[str, str], Dict[str, str]):
     ]
 
     case_insensitive_aliases_for_a_symbol = [
-        (['antielectron'], 'e+'),
+        (['antielectron', 'anti_electron'], 'e+'),
+        (['antipositron', 'anti_positron'], 'e-'),
         (['muon-'], 'mu-'),
         (['muon+'], 'mu+'),
         (['tau particle'], 'tau-'),
diff --git a/plasmapy/atomic/particle_class.py b/plasmapy/atomic/particle_class.py
index a32c960b..1dbcf607 100644
--- a/plasmapy/atomic/particle_class.py
+++ b/plasmapy/atomic/particle_class.py
@@ -29,7 +29,12 @@
 from .elements import _Elements, _PeriodicTable
 from .isotopes import _Isotopes
 
-from .special_particles import (_Particles, ParticleZoo, _special_ion_masses)
+from .special_particles import (
+    _Particles,
+    ParticleZoo,
+    _special_ion_masses,
+    _antiparticles,
+)
 
 _classification_categories = {
     'lepton',
@@ -140,7 +145,8 @@ class Particle:
         not available.
 
     `~plasmapy.utils.AtomicError`
-        Raised for attempts at converting a Particle object to a `bool`.
+        Raised for attempts at converting a
+        `~plasmapy.atomic.Particle` object to a `bool`.
 
     Examples
     --------
@@ -154,34 +160,43 @@ class Particle:
     >>> positron = Particle('positron')
     >>> hydrogen = Particle(1)  # atomic number
 
-    The `particle` attribute returns the particle's symbol in
-    the standard form.
+    The `particle` attribute returns the particle's symbol in the
+    standard form.
 
     >>> positron.particle
     'e+'
 
-    The `element`, `isotope`, and `ion` attributes return the symbols
-    for each of these different types of particles.
+    The `atomic_symbol`, `isotope_symbol`, and `ionic_symbol` attributes
+    return the symbols for each of these different types of particles.
 
     >>> proton.element
     'H'
     >>> alpha.isotope
     'He-4'
-    >>> deuteron.ion
+    >>> deuteron.ionic_symbol
     'D 1+'
 
+    The `ionic_symbol` attribute works for neutral atoms if charge
+    information is available.
+
+    >>> deuterium = Particle("D", Z=0)
+    >>> deuterium.ionic_symbol
+    'D 0+'
+
     If the particle doesn't belong to one of those categories, then
     these attributes return `None`.
 
     >>> positron.element is None
     True
 
-    These attributes may therefore be used to test whether or not a
-    particle is an element, isotope, or ion.
+    The attributes of a `~plasmapy.atomic.Particle` instance may be used
+    to test whether or not a particle is an element, isotope, or ion.
 
-    >>> True if Particle('e-').element else False
+    >>> True if positron.element else False
     False
-    >>> True if Particle('alpha').ion else False
+    >>> True if deuterium.isotope else False
+    True
+    >>> True if Particle('alpha').is_ion else False
     True
 
     Many of the attributes return physical properties of a particle.
@@ -207,6 +222,17 @@ class Particle:
     >>> alpha.neutron_number
     2
 
+    If a `~plasmapy.atomic.Particle` instance represents an elementary
+    particle, then the unary `~` (invert) operator may be used to return
+    the particle's antiparticle.
+
+    >>> ~electron
+    Particle("e+")
+    >>> ~proton
+    Particle("p-")
+    >>> ~positron
+    Particle("e-")
+
     The `~plasmapy.atomic.particle_class.Particle.categories` attribute
     and `~plasmapy.atomic.particle_class.Particle.is_category` method
     may be used to find and test particle membership in categories.
@@ -304,7 +330,7 @@ def __init__(self, argument: Union[str, int], mass_numb: int = None, Z: int = No
                 categories.add('element')
             if isotope:
                 categories.add('isotope')
-            if ion:
+            if self.element and self._attributes['integer charge']:
                 categories.add('ion')
 
             # Element properties
@@ -415,7 +441,7 @@ def __eq__(self, other) -> bool:
             except InvalidParticleError as exc:
                 raise InvalidParticleError(
                     f"{other} is not a particle and cannot be "
-                    f"compared to {self}.")
+                    f"compared to {self}.") from exc
 
         if not isinstance(other, self.__class__):
             raise TypeError(
@@ -480,9 +506,18 @@ def __bool__(self):
         """
         raise AtomicError("The truthiness of a Particle object is not defined.")
 
+    def __invert__(self):
+        """
+        Return the corresponding antiparticle, or raise an
+        `~plasmapy.utils.AtomicError` if the particle is not an
+        elementary particle.
+        """
+        return self.antiparticle
+
     @property
-    def particle(self) -> str:
-        """Return the particle's symbol.
+    def particle(self) -> Optional[str]:
+        """
+        Return the particle's symbol.
 
         Examples
         --------
@@ -493,6 +528,34 @@ def particle(self) -> str:
         """
         return self._attributes['particle']
 
+    @property
+    def antiparticle(self):
+        """
+        Return the corresponding antiparticle, or raise an
+        `~plasmapy.utils.AtomicError` if the particle is not an
+        elementary particle.
+
+        This attribute may be accessed by using the unary operator `~`
+        acting on a `~plasma.atomic.Particle` instance.
+
+        Examples
+        --------
+        >>> electron = Particle('e-')
+        >>> electron.antiparticle
+        Particle("e+")
+
+        >>> antineutron = Particle('antineutron')
+        >>> ~antineutron
+        Particle("n")
+
+        """
+        if self.particle in _antiparticles.keys():
+            return Particle(_antiparticles[self.particle])
+        else:
+            raise AtomicError(
+                "The unary operator can only be used for elementary "
+                "particles and antiparticles.")
+
     @property
     def element(self) -> Optional[str]:
         """
@@ -524,16 +587,19 @@ def isotope(self) -> Optional[str]:
         return self._attributes['isotope']
 
     @property
-    def ion(self) -> Optional[str]:
+    def ionic_symbol(self) -> Optional[str]:
         """
-        Return the ion symbol if the particle corresponds to an ion,
-        and `None` otherwise.
+        Return the ionic symbol if the particle corresponds to an ion or
+        neutral atom, and `None` otherwise.
 
         Examples
         --------
         >>> deuteron = Particle('deuteron')
-        >>> deuteron.ion
+        >>> deuteron.ionic_symbol
         'D 1+'
+        >>> hydrogen_atom = Particle('H', Z=0)
+        >>> hydrogen_atom.ionic_symbol
+        'H 0+'
 
         """
         return self._attributes['ion']
@@ -616,7 +682,7 @@ def standard_atomic_weight(self) -> u.Quantity:
         <Quantity 2.65669641e-26 kg>
 
         """
-        if self.isotope or self.ion or not self.element:
+        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:  # coveralls: ignore
             raise MissingAtomicDataError(
@@ -642,14 +708,14 @@ def nuclide_mass(self) -> u.Quantity:
 
         """
 
-        if self.particle in ['H-1', 'p+']:
+        if self.isotope == 'H-1':
             return const.m_p
-        elif self.particle == 'n':
-            return const.m_n
-        elif self.particle in ['D', 'D 1+']:
+        elif self.isotope == 'D':
             return _special_ion_masses['D 1+']
-        elif self.particle in ['T', 'T 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'))
@@ -703,7 +769,7 @@ def mass(self) -> u.Quantity:
         if self._attributes['mass'] is not None:
             return self._attributes['mass'].to(u.kg)
 
-        if self.ion:
+        if self.is_ion:
 
             if self.isotope:
                 base_mass = self._attributes['isotope mass']
@@ -711,7 +777,9 @@ def mass(self) -> u.Quantity:
                 base_mass = self._attributes['standard atomic weight']
 
             if base_mass is None:
-                raise MissingAtomicDataError(f"The mass of ion '{self.ion}' is not available.")
+                raise MissingAtomicDataError(
+                    f"The mass of ion '{self.ionic_symbol}' is not available."
+                )
 
             mass = base_mass - self.integer_charge * const.m_e
 
@@ -821,7 +889,7 @@ def electron_number(self) -> int:
         """
         if self.particle == 'e-':
             return 1
-        elif self.ion:
+        elif self.ionic_symbol:
             return self.atomic_number - self.integer_charge
         else:  # coveralls: ignore
             raise InvalidIonError(_category_errmsg(self, 'ion'))
@@ -845,7 +913,7 @@ def isotopic_abundance(self) -> u.Quantity:
         """
         from .atomic import common_isotopes
 
-        if not self.isotope or self.ion:  # coveralls: ignore
+        if not self.isotope or self.is_ion:  # coveralls: ignore
             raise InvalidIsotopeError(_category_errmsg(self.particle, 'isotope'))
 
         abundance = self._attributes.get('isotopic abundance', 0.0)
@@ -998,7 +1066,7 @@ def spin(self) -> Union[int, float]:
         return self._attributes['spin']
 
     @property
-    def periodic_table(self):
+    def periodic_table(self) -> collections.namedtuple:
         """
         Return a `~collections.namedtuple` to access category, period,
         group, and block information about an element.
@@ -1142,3 +1210,21 @@ def is_electron(self) -> bool:
 
         """
         return self == "e-"
+
+    @property
+    def is_ion(self) -> bool:
+        """
+        Return `True` if the particle is an ion, and `False`
+        otherwise.
+
+        Examples
+        --------
+        >>> Particle('D+').is_ion
+        True
+        >>> Particle('H-1 0+').is_ion
+        False
+        >>> Particle('e+').is_ion
+        False
+
+        """
+        return self.is_category('ion')
diff --git a/plasmapy/atomic/particle_input.py b/plasmapy/atomic/particle_input.py
index a4126426..7e6a3b41 100644
--- a/plasmapy/atomic/particle_input.py
+++ b/plasmapy/atomic/particle_input.py
@@ -301,7 +301,7 @@ def wrapper(*args, **kwargs):
                 cat_table = [
                     ('element', particle.element, InvalidElementError),
                     ('isotope', particle.isotope, InvalidIsotopeError),
-                    ('ion', particle.ion, InvalidIonError),
+                    ('ion', particle.ionic_symbol, InvalidIonError),
                 ]
 
                 for category_name, category_symbol, CategoryError in cat_table:
diff --git a/plasmapy/atomic/special_particles.py b/plasmapy/atomic/special_particles.py
index 785303b9..880d6529 100644
--- a/plasmapy/atomic/special_particles.py
+++ b/plasmapy/atomic/special_particles.py
@@ -287,6 +287,24 @@ def _create_Particles_dict() -> Dict[str, dict]:
     'T 1+': 5.007356665e-27 * u.kg,
 }
 
+_antiparticles = {
+    '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+',
+    'antineutron': 'n',
+    'e+': 'e-',
+    'mu+': 'mu-',
+    'tau+': 'tau-',
+    'anti_nu_e': 'nu_e',
+    'anti_nu_mu': 'nu_mu',
+    'anti_nu_tau': 'nu_tau',
+}
 
 if __name__ == "__main__":  # coveralls: ignore
     from pprint import pprint
diff --git a/plasmapy/atomic/symbols.py b/plasmapy/atomic/symbols.py
index 6963a4a0..fc50af05 100644
--- a/plasmapy/atomic/symbols.py
+++ b/plasmapy/atomic/symbols.py
@@ -204,7 +204,9 @@ def ionic_symbol(particle: Particle, mass_numb: int = None, Z: int = None) -> st
     'H-1 0+'
 
     """
-    return particle.ion
+
+    return particle.ionic_symbol
+
 
 
 @particle_input


Test patch:
diff --git a/plasmapy/atomic/tests/test_particle_class.py b/plasmapy/atomic/tests/test_particle_class.py
index 26a4dde5..f3f4a524 100644
--- a/plasmapy/atomic/tests/test_particle_class.py
+++ b/plasmapy/atomic/tests/test_particle_class.py
@@ -21,6 +21,7 @@
 from ..atomic import known_isotopes
 from ..isotopes import _Isotopes
 from ..particle_class import Particle
+from ..special_particles import ParticleZoo
 
 # (arg, kwargs, results_dict
 test_Particle_table = [
@@ -29,7 +30,9 @@
      {'particle': 'n',
       'element': None,
       'isotope': None,
-      'ion': None,
+      'ionic_symbol': None,
+      'is_ion': False,
+      'is_electron': False,
       'integer_charge': 0,
       'atomic_number': InvalidElementError,
       'mass_number': InvalidIsotopeError,
@@ -46,7 +49,8 @@
       'element': 'H',
       'element_name': 'hydrogen',
       'isotope': 'H-1',
-      'ion': 'p+',
+      'ionic_symbol': 'p+',
+      'is_ion': True,
       'mass': m_p,
       'nuclide_mass': m_p,
       'integer_charge': 1,
@@ -78,7 +82,8 @@
       'element': None,
       'element_name': InvalidElementError,
       'isotope': None,
-      'ion': None,
+      'ionic_symbol': None,
+      'is_ion': False,
       'mass': m_p,
       'integer_charge': -1,
       'spin': 1 / 2,
@@ -97,7 +102,8 @@
       'element': None,
       'element_name': InvalidElementError,
       'isotope': None,
-      'ion': None,
+      'ionic_symbol': None,
+      'is_ion': False,
       'mass': m_e,
       'integer_charge': -1,
       'spin': 1 / 2,
@@ -118,7 +124,8 @@
      {'particle': 'e+',
       'element': None,
       'isotope': None,
-      'ion': None,
+      'ionic_symbol': None,
+      'is_ion': False,
       'mass': m_e,
       'nuclide_mass': InvalidIsotopeError,
       'integer_charge': 1,
@@ -130,6 +137,7 @@
       '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,
@@ -142,7 +150,8 @@
      {'particle': 'H',
       'element': 'H',
       'isotope': None,
-      'ion': None,
+      'ionic_symbol': None,
+      'is_ion': False,
       'charge': ChargeError,
       'integer_charge': ChargeError,
       'mass_number': InvalidIsotopeError,
@@ -157,12 +166,33 @@
       'is_category("proton")': False,
       }),
 
+    ('H-1 0+', {}, {
+        'particle': 'H-1 0+',
+        'element': 'H',
+        'isotope': 'H-1',
+        'ionic_symbol': 'H-1 0+',
+        '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',
-      'ion': 'D 1+',
+      'ionic_symbol': 'D 1+',
+      'is_ion': True,
       'integer_charge': 1,
       'atomic_number': 1,
       'mass_number': 2,
@@ -180,7 +210,8 @@
      {'particle': 'T 1+',
       'element': 'H',
       'isotope': 'T',
-      'ion': 'T 1+',
+      'ionic_symbol': 'T 1+',
+      'is_ion': True,
       'integer_charge': 1,
       'atomic_number': 1,
       'mass_number': 3,
@@ -197,7 +228,9 @@
       'element': 'Fe',
       'element_name': 'iron',
       'isotope': 'Fe-56',
-      'ion': 'Fe-56 17+',
+      'ionic_symbol': 'Fe-56 17+',
+      'is_electron': False,
+      'is_ion': True,
       'integer_charge': 17,
       'atomic_number': 26,
       'mass_number': 56,
@@ -214,7 +247,8 @@
       'element': 'He',
       'element_name': 'helium',
       'isotope': 'He-4',
-      'ion': 'He-4 2+',
+      'ionic_symbol': 'He-4 2+',
+      'is_ion': True,
       'integer_charge': 2,
       'atomic_number': 2,
       'mass_number': 4,
@@ -228,7 +262,8 @@
       'element': 'Li',
       'element_name': 'lithium',
       'isotope': 'Li-7',
-      'ion': None,
+      'ionic_symbol': None,
+      'is_ion': False,
       'integer_charge': ChargeError,
       'atomic_number': 3,
       'mass_number': 7,
@@ -242,7 +277,8 @@
      {'particle': 'Cn-276 22+',
       'element': 'Cn',
       'isotope': 'Cn-276',
-      'ion': 'Cn-276 22+',
+      'ionic_symbol': 'Cn-276 22+',
+      'is_ion': True,
       'element_name': 'copernicium',
       'integer_charge': 22,
       'atomic_number': 112,
@@ -255,7 +291,8 @@
      {'particle': 'mu-',
       'element': None,
       'isotope': None,
-      'ion': None,
+      'ionic_symbol': None,
+      'is_ion': False,
       'integer_charge': -1,
       'atomic_number': InvalidElementError,
       'mass_number': InvalidIsotopeError,
@@ -274,6 +311,8 @@
       '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,
@@ -323,9 +362,9 @@ def test_Particle_class(arg, kwargs, expected_dict):
             try:
                 with pytest.raises(expected):
                     exec(f"particle.{key}")
-            except pytest.fail.Exception as exc_failed_fail:
+            except pytest.fail.Exception:
                 errmsg += f"\n{call}[{key}] does not raise {expected}."
-            except Exception as exc_bad:
+            except Exception:
                 errmsg += (f"\n{call}[{key}] does not raise {expected} but "
                            f"raises a different exception.")
 
@@ -334,10 +373,10 @@ def test_Particle_class(arg, kwargs, expected_dict):
             try:
                 result = eval(f"particle.{key}")
                 assert result == expected
-            except AssertionError as exc_assert:
+            except AssertionError:
                 errmsg += (f"\n{call}.{key} returns {result} instead "
                            f"of the expected value of {expected}.")
-            except Exception as exc_general:
+            except Exception:
                 errmsg += f"\n{call}.{key} raises an unexpected exception."
 
     if len(errmsg) > 0:
@@ -449,6 +488,7 @@ def test_Particle_cmp():
     ('n', 'neutron'),
     ('p+', 'proton'),
     ('H-1', 'p+'),
+    ('H-1 0+', 'p+'),
     ('D', 'D+'),
     ('T', 'T+'),
     ('He-4', 'alpha'),
@@ -470,7 +510,8 @@ def test_particle_class_mass_nuclide_mass(isotope: str, ion: str):
     Isotope = Particle(isotope)
     Ion = Particle(ion)
 
-    if Isotope.particle == Ion.particle and Isotope.particle in ('n', 'p+'):
+    if Isotope.categories & {'isotope', 'baryon'} and Ion.categories & {'ion', 'baryon'}:
+
         particle = Isotope.particle
 
         assert Isotope.nuclide_mass == Ion.mass, (
@@ -524,3 +565,90 @@ def test_particle_is_electron(p, is_one):
 def test_particle_bool_error():
     with pytest.raises(AtomicError):
         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'),
+]
+
+
+@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}.")
+
+
+@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}.")
+
+
+@pytest.fixture(params=ParticleZoo.everything)
+def particle(request):
+    return Particle(request.param)
+
+
+@pytest.fixture()
+def opposite(particle):
+    try:
+        opposite_particle = ~particle
+    except Exception as exc:
+        raise InvalidParticleError(
+                f"The unary ~ (invert) operator is unable to find the "
+                f"antiparticle of {particle}.") from exc
+    return opposite_particle
+
+
+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)}.")
+
+    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.")
+
+    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.")
+
+    def test_antiparticle_attribute_and_operator(self, particle, opposite):
+        """
+        Test that the Particle.antiparticle attribute returns the same
+        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}.")