Repository: PlasmaPy/PlasmaPy
Pull request #1003 (2021-02-09 12:42:37)

Expand development guide on pre-commit usage
Follow up to #961 that expands the development guide a little, putting pre-commit up-front above the style guide.

I am actually going to go against the new review system and ask for review from each of you (if you can find the time), because:

* @namurphy you did #961 and I would like to see what you think of the follow-up changes!
* @rocco8773 I would prefer to know if this suggested set-up works with your Windows PyCharm setup before we merge this in!
* also @pheuer I promised I'd do this in https://github.com/PlasmaPy/PlasmaPy/pull/895#issuecomment-770159685 and I wanted to know if this sounds reasonable :)

<!--
Thank you for contributing to PlasmaPy! Here's a bunch of pointers to
make things easier for all of us:

* If this PR will solve an issue tracked by GitHub, then please add
  "Closes #42" so the issue automatically closes once this pull request
  is merged.  In this example, issue #42 would have been closed.
  If your PR will not completely solve the issue, then please still reference
  still reference the issue like "issue #42".  (For more info see [GitHub's primer
  on linking issues](https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword).)

* Remember to add some description of your changes in this text box.

* If your pull request is not yet ready for review - either because
  you're just looking for feedback on a change or because you're not
  perfectly satisfied with your change - submit it as a draft pull request.
  Remember to change its' status once it's ready.

* If this is your first contribution, please add your name to the author
  list in `docs/about/credits.rst`.

* Feel free to chat with other developers on our Matrix channel at:
   https://app.element.io/#/room/#plasmapy:openastronomy.org

* We have a developer's guide to help answer some of your questions.
  http://docs.plasmapy.org/en/latest/development/index.html

Many thanks in advance for following these pointers and for being willing to contribute!

When submitting a pull request, please ensure that you can (eventually,
sometime before it is merged) check the following basic requirements:

-->

- [x] I have added a changelog entry for this pull request.

<!--

In short: A changelog entry is a short description of your PR's changes.
Each entry is written in a `<PULL REQUEST>.<TYPE>.rst` file and stored in
the `changelog` directory,  where `<PULL REQUEST>` is a pull request
number and `<TYPE>` is one of:

* `breaking`: A change which requires users to change code and is not backwards compatible. (Not to be used for removal of deprecated features.)
* `feature`: New user facing features and any new behavior.
* `bugfix`: Fixes a reported bug.
* `doc`: Documentation addition or improvement, like rewording an entire session or adding missing docs.
* `removal`: Feature deprecation and/or feature removal.
* `trivial`: A change which has no user facing effect or is tiny change.

A PR number is generated after you successfully submit a new PR, so the changelog
file can only be added after you open the PR.```

For more information, see:
https://github.com/PlasmaPy/PlasmaPy/blob/master/changelog/README.rst

--->

- [x] If adding new functionality, I have added tests and
      docstrings.

<!--
(Tests pop up at the bottom, in the checks box.)
-->

- [x] I have fixed any newly failing tests.

<!--
(If you're unsure why they're failing, ask!)
-->

---

EDIT:

I've gone ahead and prepared stuff for pre-commit.ci; it's looking straight up superior to our current solution.

StanczakDominik: I also just checked and https://pre-commit.ci/ actually got off the ground in the last few months. We could try that!
StanczakDominik: Well, I went ahead and tried pre-commit.ci on my fork; https://github.com/StanczakDominik/PlasmaPy/pull/5 is how it looks like. Kinda cool, no?
StanczakDominik: Given today's telecon and the intent to hold off on this a little, I'll revert to be926fe and we can discuss the wording on that version :)
codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003?src=pr&el=h1) Report
> Merging [#1003](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003?src=pr&el=desc) (64a132c) into [master](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/95aa647ca93279b286c9b22772690f9bf0f799e7?el=desc) (95aa647) will **increase** coverage by `0.33%`.
> The diff coverage is `92.69%`.

[![Impacted file tree graph](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/graphs/tree.svg?width=650&height=150&src=pr&token=LzZ5mnXVad)](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #1003      +/-   ##
==========================================
+ Coverage   96.29%   96.62%   +0.33%     
==========================================
  Files          62       64       +2     
  Lines        5715     6249     +534     
==========================================
+ Hits         5503     6038     +535     
+ Misses        212      211       -1     
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [plasmapy/formulary/braginskii.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvZm9ybXVsYXJ5L2JyYWdpbnNraWkucHk=) | `99.72% <ø> (ø)` | |
| [plasmapy/formulary/ionization.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvZm9ybXVsYXJ5L2lvbml6YXRpb24ucHk=) | `100.00% <ø> (ø)` | |
| [plasmapy/particles/parsing.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL3BhcnNpbmcucHk=) | `100.00% <ø> (ø)` | |
| [plasmapy/simulation/\_\_init\_\_.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvc2ltdWxhdGlvbi9fX2luaXRfXy5weQ==) | `100.00% <ø> (ø)` | |
| [plasmapy/utils/\_\_init\_\_.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvdXRpbHMvX19pbml0X18ucHk=) | `100.00% <ø> (ø)` | |
| [plasmapy/utils/decorators/validators.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvdXRpbHMvZGVjb3JhdG9ycy92YWxpZGF0b3JzLnB5) | `100.00% <ø> (ø)` | |
| [plasmapy/simulation/abstractions.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvc2ltdWxhdGlvbi9hYnN0cmFjdGlvbnMucHk=) | `76.04% <72.61%> (-1.74%)` | :arrow_down: |
| [plasmapy/particles/ionization\_state\_collection.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL2lvbml6YXRpb25fc3RhdGVfY29sbGVjdGlvbi5weQ==) | `91.22% <75.00%> (ø)` | |
| [plasmapy/particles/ionization\_state.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL2lvbml6YXRpb25fc3RhdGUucHk=) | `93.37% <90.69%> (+0.41%)` | :arrow_up: |
| [plasmapy/plasma/grids.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGxhc21hL2dyaWRzLnB5) | `94.67% <94.41%> (ø)` | |
| ... and [25 more](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003?src=pr&el=footer). Last update [ff18dd4...64a132c](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1003?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/changelog/1017.feature.rst b/changelog/1017.feature.rst
new file mode 100644
index 00000000..fd786dbe
--- /dev/null
+++ b/changelog/1017.feature.rst
@@ -0,0 +1,7 @@
+Create `plasmapy.particles.ParticleList` as a list-like collection for
+instances of `plasmapy.particles.Particle` and
+`plasmapy.particles.CustomParticle`.  Adding `~plasmapy.particles.Particle`
+and/or `~plasmapy.particles.CustomParticle` instances will now create a
+`~plasmapy.particles.ParticleList`.  The greater than (``>``) operator may
+now be used between `Particle` and/or `ParticleList` instances to get the
+nuclear reaction energy.
diff --git a/changelog/1036.trivial.rst b/changelog/1036.trivial.rst
new file mode 100644
index 00000000..388c1e16
--- /dev/null
+++ b/changelog/1036.trivial.rst
@@ -0,0 +1,5 @@
+Add unicode particle aliases for electrons (``"β-"``, ``"β⁻"``), muons
+(``"μ-"``, ``"μ⁻"``), anti-muons (``"μ+"``, ``"μ⁺"``), tau particles
+(``"τ"``, ``"τ-"``, ``"τ⁻"``), anti-tau particles (``"τ+"``, ``"τ⁺"``)
+electron neutrinos (``"ν_e"``), muon neutrinos (``"ν_μ"``), tau neutrinos
+(``"ν_τ"``), and alpha particles (``"α"``).
diff --git a/changelog/1039.doc.rst b/changelog/1039.doc.rst
new file mode 100644
index 00000000..8d670b5a
--- /dev/null
+++ b/changelog/1039.doc.rst
@@ -0,0 +1,2 @@
+Put the docstring for `plasmapy.particles.Particle.is_category` into
+numpydoc format.
diff --git a/changelog/1039.trivial.rst b/changelog/1039.trivial.rst
new file mode 100644
index 00000000..205ef135
--- /dev/null
+++ b/changelog/1039.trivial.rst
@@ -0,0 +1,2 @@
+A set containing all valid particle categories may now be accessed via
+`plasmapy.particles.Particle.is_category.valid_categories`.
diff --git a/docs/development/code_guide.rst b/docs/development/code_guide.rst
index e4091e25..7c5668a6 100644
--- a/docs/development/code_guide.rst
+++ b/docs/development/code_guide.rst
@@ -13,6 +13,42 @@ later.
 Coding Style
 ============
 
+TL;DR: use pre-commit
+---------------------
+
+PlasmaPy has a configuration for the `pre-commit framework
+<https://pre-commit.com/>`_ that takes care of style mostly automatically.
+Install it with `pip install pre-commit`, then use `pre-commit install` within
+the repository.
+
+This will cause pre-commit to download the right versions of linters we use,
+then run an automated style checking suite on every commit.  Do note that this
+works better with a `git add`, then `git commit` workflow than a `git commit
+-a` workflow — that way, you can check via `git diff` what the automated
+changes actually did.
+
+Note that the "Style linters / pre-commit (pull_request)" part of our
+Continuous Integration system can and will (metaphorically) shout at you if it
+finds you didn't apply the linters. Also note that the linters' output may vary
+with version, so, rather than apply `black` and `isort` manually, let
+pre-commit do the version management for you instead!
+
+Our pre-commit suite can be found in `.pre-commit-config.yaml
+<https://github.com/PlasmaPy/PlasmaPy/blob/master/.pre-commit-config.yaml>`_.
+It includes
+
+* `black <https://black.readthedocs.io/en/stable/>`_ to automatically
+  format code and ensure a consistent code style throughout the
+  package
+* `isort <https://pycqa.github.io/isort/>`_ to
+  automatically sort imports.
+* `nbqa <https://github.com/nbQA-dev/nbQA>`_ to automatically apply the above
+  to example notebooks as well.
+* a few tools for `requirements.txt`, `.yml` files and the like.
+
+PlasmaPy Code Style Guide, codified
+-----------------------------------
+
 * PlasmaPy follows the `PEP8 Style Guide for Python Code
   <http://www.python.org/dev/peps/pep-0008/>`_.  This style choice
   helps ensure that the code will be consistent and readable.
@@ -49,25 +85,11 @@ Coding Style
 * Use ``Optional[type]`` for type hinted keyword arguments with a
   default value of ``None``.
 
+* There should be at least one pun per 1284 lines of code.
+
 * Avoid using `lambda` to define functions, as this notation may be
   unfamiliar to newcomers to Python.
 
-* There should be at most one pun per 1284 lines of code.
-
-pre-commit hooks
-----------------
-
-PlasmaPy has a configuration for `pre-commit` that takes care of style mostly
-automatically. Install it with `pip install pre-commit`, then use `pre-commit
-install` within the repository.
-
-This will cause git to run an automated style checking suite, mostly composed
-of `flake8` and `black`, on every commit. Do note that this works better with
-a `git add`, then `git commit` workflow than a `git commit -a` workflow - that
-way, you can check via `git diff` what the automated changes actually did.
-
-Note that `flake8` does not change your files automatically (help in setting
-that up, if possible, most welcome!).
 
 Branches, commits, and pull requests
 ====================================
diff --git a/plasmapy/particles/__init__.py b/plasmapy/particles/__init__.py
index 6ebaa356..d43f1412 100644
--- a/plasmapy/particles/__init__.py
+++ b/plasmapy/particles/__init__.py
@@ -26,11 +26,13 @@
 from plasmapy.particles.nuclear import nuclear_binding_energy, nuclear_reaction_energy
 from plasmapy.particles.particle_class import (
     AbstractParticle,
+    AbstractPhysicalParticle,
     CustomParticle,
     DimensionlessParticle,
     Particle,
     ParticleLike,
 )
+from plasmapy.particles.particle_collections import ParticleList
 from plasmapy.particles.serialization import (
     json_load_particle,
     json_loads_particle,
diff --git a/plasmapy/particles/nuclear.py b/plasmapy/particles/nuclear.py
index d171b7b3..d98bfac4 100644
--- a/plasmapy/particles/nuclear.py
+++ b/plasmapy/particles/nuclear.py
@@ -167,10 +167,8 @@ def nuclear_reaction_energy(*args, **kwargs):
     Examples
     --------
     >>> from astropy import units as u
-
     >>> nuclear_reaction_energy("D + T --> alpha + n")
     <Quantity 2.8181e-12 J>
-
     >>> triple_alpha1 = '2*He-4 --> Be-8'
     >>> triple_alpha2 = 'Be-8 + alpha --> carbon-12'
     >>> energy_triplealpha1 = nuclear_reaction_energy(triple_alpha1)
@@ -179,7 +177,6 @@ def nuclear_reaction_energy(*args, **kwargs):
     -1.471430e-14 J 1.1802573e-12 J
     >>> energy_triplealpha2.to(u.MeV)
     <Quantity 7.3665870 MeV>
-
     >>> nuclear_reaction_energy(reactants=['n'], products=['p+', 'e-'])
     <Quantity 1.25343e-13 J>
     """
diff --git a/plasmapy/particles/parsing.py b/plasmapy/particles/parsing.py
index 191b149f..b0266080 100644
--- a/plasmapy/particles/parsing.py
+++ b/plasmapy/particles/parsing.py
@@ -47,13 +47,21 @@ def _create_alias_dicts(Particles: dict) -> (Dict[str, str], Dict[str, str]):
         case_insensitive_aliases[name.lower()] = symbol
 
     case_sensitive_aliases_for_a_symbol = [
-        (["beta-", "e"], "e-"),
-        (["beta+"], "e+"),
-        (["p", "H-1+", "H-1 1+", "H-1 +1", "H-1 II"], "p+"),
-        (["n-1"], "n"),
+        (["beta-", "β-", "β⁻", "e", "e⁻"], "e-"),
+        (["beta+", "β+", "β⁺", "e⁺"], "e+"),
+        (["p", "p⁺", "H-1+", "H-1 1+", "H-1 +1", "H-1 II"], "p+"),
+        (["n-1", "n⁰"], "n"),
         (["H-2"], "D"),
         (["H-2+", "H-2 1+", "H-2 +1", "D+", "D II"], "D 1+"),
         (["H-3+", "H-3 1+", "H-3 +1", "T+", "T II"], "T 1+"),
+        (["α"], "He-4 2+"),
+        (["τ", "τ-", "τ⁻"], "tau-"),
+        (["τ+", "τ⁺"], "tau+"),
+        (["μ-", "μ⁻"], "mu-"),
+        (["μ+", "μ⁺"], "mu+"),
+        (["ν_e"], "nu_e"),
+        (["ν_μ"], "nu_mu"),
+        (["ν_τ"], "nu_tau"),
     ]
 
     case_insensitive_aliases_for_a_symbol = [
@@ -78,6 +86,8 @@ def _create_alias_dicts(Particles: dict) -> (Dict[str, str], Dict[str, str]):
         (["deuteron", "deuterium+", "deuterium 1+", "deuterium +1"], "D 1+"),
         (["tritium", "hydrogen-3"], "T"),
         (["triton", "tritium+", "tritium 1+", "tritium +1"], "T 1+"),
+        (["diproton"], "He-2 2+"),
+        (["helion"], "He-3 2+"),
         (["alpha"], "He-4 2+"),
     ]
 
diff --git a/plasmapy/particles/particle_class.py b/plasmapy/particles/particle_class.py
index 8e0feb24..9c37f05f 100644
--- a/plasmapy/particles/particle_class.py
+++ b/plasmapy/particles/particle_class.py
@@ -4,6 +4,7 @@
 
 __all__ = [
     "AbstractParticle",
+    "AbstractPhysicalParticle",
     "CustomParticle",
     "DimensionlessParticle",
     "Particle",
@@ -20,7 +21,7 @@
 from collections import defaultdict, namedtuple
 from datetime import datetime
 from numbers import Integral, Real
-from typing import List, Optional, Set, Tuple, Union
+from typing import Iterable, List, Optional, Set, Tuple, Union
 
 from plasmapy.particles.elements import _Elements, _PeriodicTable
 from plasmapy.particles.exceptions import (
@@ -204,7 +205,33 @@ def json_dumps(self, **kwargs) -> str:
         return json.dumps(self.json_dict, **kwargs)
 
 
-class Particle(AbstractParticle):
+class AbstractPhysicalParticle(AbstractParticle):
+    """Base class for particles that are defined with physical units."""
+
+    @property
+    def _as_particle_list(self):
+        # Avoid circular imports by importing here
+        from plasmapy.particles.particle_collections import ParticleList
+
+        return ParticleList([self])
+
+    def __add__(self, other):
+        return self._as_particle_list + other
+
+    def __radd__(self, other):
+        return other + self._as_particle_list
+
+    def __mul__(self, other):
+        return self._as_particle_list.__mul__(other)
+
+    def __rmul__(self, other):
+        return self._as_particle_list.__mul__(other)
+
+    def __gt__(self, other):
+        return self._as_particle_list.__gt__(other)
+
+
+class Particle(AbstractPhysicalParticle):
     """
     A class for an individual particle or antiparticle.
 
@@ -253,8 +280,9 @@ class Particle(AbstractParticle):
 
     See Also
     --------
-    ~plasmapy.particles.CustomParticle
-    ~plasmapy.particles.DimensionlessParticle
+    CustomParticle
+    DimensionlessParticle
+    ~plasmapy.particles.particle_collections.ParticleList
 
     Examples
     --------
@@ -264,6 +292,7 @@ class Particle(AbstractParticle):
     >>> electron = Particle('e-')
     >>> neutron = Particle('neutron')
     >>> deuteron = Particle('D', Z=1)
+    >>> triton = Particle('T+')
     >>> alpha = Particle('He', mass_numb=4, Z=2)
     >>> positron = Particle('positron')
     >>> hydrogen = Particle(1)  # atomic number
@@ -274,7 +303,9 @@ class Particle(AbstractParticle):
     >>> positron.symbol
     'e+'
 
-    The ``element``, ``isotope``, and ``ionic_symbol`` attributes return
+    The `~plasmapy.particles.Particle.element`,
+    `~plasmapy.particles.Particle.isotope`, and
+    `~plasmapy.particles.Particle.ionic_symbol` attributes provide
     the symbols for each of these different types of particles.
 
     >>> proton.element
@@ -292,7 +323,7 @@ class Particle(AbstractParticle):
     'D 0+'
 
     If the particle doesn't belong to one of those categories, then
-    these attributes return `None`.
+    these attributes are `None`.
 
     >>> positron.element is None
     True
@@ -307,7 +338,7 @@ class Particle(AbstractParticle):
     >>> True if Particle('alpha').is_ion else False
     True
 
-    Many of the attributes return physical properties of a particle.
+    Many of the attributes provide physical properties of a particle.
 
     >>> electron.integer_charge
     -1
@@ -331,13 +362,9 @@ class Particle(AbstractParticle):
     2
 
     If a `~plasmapy.particles.Particle` instance represents an elementary
-    particle, then the unary ``~`` (invert) operator may be used to
+    particle, then the unary `~` (invert) operator may be used to
     return the particle's antiparticle.
 
-    >>> ~electron
-    Particle("e+")
-    >>> ~proton
-    Particle("p-")
     >>> ~positron
     Particle("e-")
 
@@ -360,6 +387,18 @@ class Particle(AbstractParticle):
     >>> Particle(iron, mass_numb=56)
     Particle("Fe-56")
 
+    Adding particles together will create a `~plasmapy.particles.ParticleList`,
+    which is a list-like collection of particles.
+
+    >>> proton + 2 * electron
+    ParticleList(['p+', 'e-', 'e-'])
+
+    The ``>`` operator can be used with `Particle` and/or `ParticleList`
+    objects to return the nuclear reaction energy.
+
+    >>> deuteron + triton > alpha + neutron
+    <Quantity 2.81810898e-12 J>
+
     The `~plasmapy.particles.particle_class.Particle.categories` attribute
     and `~plasmapy.particles.particle_class.Particle.is_category` method
     may be used to find and test particle membership in categories.
@@ -1414,41 +1453,86 @@ def categories(self) -> Set[str]:
     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,
+        require: Union[str, Iterable[str]] = None,
+        any_of: Union[str, Iterable[str]] = None,
+        exclude: Union[str, Iterable[str]] = None,
     ) -> bool:
         """
         Determine if the particle meets categorization criteria.
 
-        Return `True` if the particle is in all of the inputted
-        categories, and `False` the particle is not.
+        Return `True` if the particle is consistent with the provided
+        categories, and `False` otherwise.
+
+        Parameters
+        ----------
+        *category_tuple
+            Required categories in the form of one or more `str` objects
+            or an iterable.
+
+        require : `str` or iterable providing `str` objects, optional, keyword-only
+            One or more categories.  This method will return `False` if
+            the `Particle` does not belong to all of these categories.
+
+        any_of : `str` or iterable providing `str` objects, optional, keyword-only
+            One or more categories. This method will return `False` if
+            the `Particle` does not belong to at least one of these
+            categories.
+
+        exclude : `str` or iterable providing `str` objects, optional, keyword-only
+            One or more categories.  This method will return `False` if
+            the `Particle` belongs to any of these categories.
 
+        Notes
+        -----
+        A `set` containing all valid categories may be accessed by the
+        ``valid_categories`` attribute of `~Particle.is_category`.
+
+        Examples
+        -----
         Required categories may be entered as positional arguments,
         including as a `list`, `set`, or `tuple` of required categories.
-        These may also be included using the ``require`` keyword
-        argument.  This method will return `False` if the particle is
-        not in all of the required categories.
 
-        If categories are inputted using the ``any_of`` keyword
-        argument, then this method will return `False` if the particle
-        is not of any of the categories in ``any_of``.
+        >>> electron = Particle("e-")
+        >>> electron.is_category("lepton")
+        True
+        >>> electron.is_category("lepton", "baryon")
+        False
+        >>> electron.is_category(["fermion", "matter"])
+        True
 
-        If the ``exclude`` keyword is set, then this method will return
-        `False` if the particle is in any of the excluded categories,
-        whether or not the particle matches the other criteria.
+        Required arguments may also be provided using the ``require``
+        keyword argument.
 
-        Examples
-        --------
-        >>> Particle('e-').is_category('lepton')
+        >>> electron.is_category(require="lepton")
         True
-        >>> Particle('p+').is_category('baryon', exclude='charged')
+        >>> electron.is_category(require=["lepton", "baryon"])
         False
-        >>> Particle('n').is_category({'matter', 'baryon'}, exclude={'charged'})
+
+        This method will return `False` if the particle does not belong
+        to at least one of the categories provided with the ``any_of``
+        keyword argument.
+
+        >>> electron.is_category(any_of=["lepton", "baryon"])
         True
-        >>> Particle('mu+').is_category('antilepton', exclude='baryon')
+        >>> electron.is_category(any_of=("noble gas", "lanthanide", "halogen"))
+        False
+
+        This method will return `False` if the particle belongs to any
+        of the categories provided in the ``exclude`` keyword argument.
+
+        >>> electron.is_category(exclude="baryon")
         True
+        >>> electron.is_category(exclude={"lepton", "baryon"})
+        False
+
+        The ``require``, ``any_of``, and ``exclude`` keywords may be
+        combined.  If the particle matches all of the provided criteria,
+        then `~Particle.is_category` will return `True`.
 
+        >>> electron.is_category(
+        ...     require="fermion", any_of={'lepton', 'baryon'}, exclude='charged',
+        ... )
+        False
         """
 
         def become_set(arg: Union[str, Set, Tuple, List]) -> Set[str]:
@@ -1471,13 +1555,9 @@ def become_set(arg: Union[str, Set, Tuple, List]) -> Set[str]:
             )
 
         require = become_set(category_tuple) if category_tuple else become_set(require)
-
         exclude = become_set(exclude)
         any_of = become_set(any_of)
 
-        if not require and not exclude and not any_of:
-            return _valid_categories
-
         invalid_categories = (require | exclude | any_of) - _valid_categories
 
         duplicate_categories = require & exclude | exclude & any_of | require & any_of
@@ -1569,14 +1649,14 @@ def ionize(self, n: Integral = 1, inplace: bool = False):
 
         Raises
         ------
-        ~plasmapy.particles.InvalidElementError
+        `~plasmapy.particles.exceptions.InvalidElementError`
             If the `~plasmapy.particles.Particle` is not an element.
 
-        ~plasmapy.particles.ChargeError
+        `~plasmapy.particles.exceptions.ChargeError`
             If no charge information for the `~plasmapy.particles.Particle`
             object is specified.
 
-        ~plasmapy.particles.InvalidIonError
+        `~plasmapy.particles.exceptions.InvalidIonError`
             If there are less than ``n`` remaining bound electrons.
 
         ValueError
@@ -1699,6 +1779,10 @@ def recombine(self, n: Integral = 1, inplace=False):
             return Particle(base_particle, Z=new_integer_charge)
 
 
+Particle.is_category.valid_categories = _valid_categories
+"""All valid particle categories."""
+
+
 class DimensionlessParticle(AbstractParticle):
     """
     A class to represent dimensionless custom particles.
@@ -1880,7 +1964,7 @@ def symbol(self, new_symbol: str):
             raise TypeError("symbol needs to be a string.")
 
 
-class CustomParticle(AbstractParticle):
+class CustomParticle(AbstractPhysicalParticle):
     """
     A class to represent custom particles.
 
diff --git a/plasmapy/particles/particle_collections.py b/plasmapy/particles/particle_collections.py
new file mode 100644
index 00000000..40bd3b31
--- /dev/null
+++ b/plasmapy/particles/particle_collections.py
@@ -0,0 +1,303 @@
+"""Collections of `~plasmapy.particles.particle_class.Particle` objects."""
+
+__all__ = ["ParticleList"]
+
+import astropy.units as u
+import collections
+import numpy as np
+
+from typing import *
+
+from plasmapy.particles.exceptions import *
+from plasmapy.particles.particle_class import (
+    CustomParticle,
+    DimensionlessParticle,
+    Particle,
+    ParticleLike,
+)
+
+
+class ParticleList(collections.UserList):
+    """
+    A `list` like collection of `Particle` and/or `CustomParticle` objects.
+
+    Parameters
+    ----------
+    particles : iterable
+        An iterable that provides a sequence of `ParticleLike` objects.
+        Objects that are not a `Particle` or `CustomParticle` instance
+        will be cast into a `Particle` instance.
+
+    Raises
+    ------
+    `~plasmapy.particles.exceptions.InvalidParticleError`
+        If an object supplied to `ParticleList` is not `ParticleLike`.
+
+    TypeError
+        If a `DimensionlessParticle` is provided.
+
+    Examples
+    --------
+    A `ParticleList` can be created by calling it with a `list`, `tuple`,
+    or other iterable that provides `~plasmapy.particles.ParticleLike`
+    objects.
+
+    >>> from plasmapy.particles import ParticleList
+    >>> particle_list = ParticleList(["e-", "e+"])
+    >>> particle_list[0]
+    Particle("e-")
+
+    Attributes such as `~plasmapy.particles.ParticleList.mass`
+    and `~plasmapy.particles.ParticleList.charge` will return a
+    `~astropy.units.Quantity` array containing the values of the
+    corresponding attribute for each particle in the `ParticleList`.
+
+    >>> particle_list.mass
+    <Quantity [9.1093...e-31, 9.1093...e-31] kg>
+    >>> particle_list.charge
+    <Quantity [-1.60217663e-19,  1.60217663e-19] C>
+    >>> particle_list.symbols
+    ['e-', 'e+']
+
+    `ParticleList` instances can also be created through addition and
+    multiplication with `Particle`, `CustomParticle`, and `ParticleList`
+    instances.
+
+    >>> from plasmapy.particles import Particle, CustomParticle
+    >>> import astropy.units as u
+    >>> proton = Particle("p+")
+    >>> custom_particle = CustomParticle(mass=1e-26*u.kg, charge=6e-19*u.C)
+    >>> 2 * proton + custom_particle
+    ParticleList(['p+', 'p+', 'CustomParticle(mass=1e-26 kg, charge=6e-19 C)'])
+
+    These operations may also be performed using `ParticleLike` objects.
+
+    >>> particle_list + "deuteron"
+    ParticleList(['e-', 'e+', 'D 1+'])
+
+    Normal `list` methods may also be used on `ParticleList` objects.
+    When a `ParticleLike` object is appended to a `ParticleList`, that
+    object will be cast into a `Particle`.
+
+    >>> noble_gases = ParticleList(["He", "Ar", "Kr", "Xe", "Rn"])
+    >>> noble_gases.append("Og")
+    >>> noble_gases[-1]
+    Particle("Og")
+
+    The ``>`` operator may be used with `Particle` and `ParticleList`
+    instances to access the nuclear reaction energy.
+
+    >>> reactants = ParticleList(["deuterium", "tritium"])
+    >>> products = ParticleList(["alpha", "neutron"])
+    >>> energy = reactants > products
+    >>> energy.to("MeV")
+    <Quantity 17.58925... MeV>
+    """
+
+    @staticmethod
+    def _list_of_particles_and_custom_particles(
+        particles: Iterable[ParticleLike],
+    ) -> List[Union[Particle, CustomParticle]]:  # TODO #687
+        """
+        Convert an iterable that provides `ParticleLike` objects into a
+        `list` containing `Particle` and `CustomParticle` instances.
+        """
+        new_particles = []
+        for obj in particles:
+            if isinstance(obj, (Particle, CustomParticle)):
+                new_particles.append(obj)
+            elif isinstance(obj, DimensionlessParticle):
+                raise TypeError(
+                    "ParticleList instances cannot include dimensionless particles."
+                )
+            else:
+                try:
+                    new_particles.append(Particle(obj))
+                except (TypeError, InvalidParticleError) as exc:
+                    raise InvalidParticleError(
+                        f"The object {obj} supplied to ParticleList is not a "
+                        f"particle-like object."
+                    ) from exc
+
+        return new_particles
+
+    def __init__(self, particles: Iterable):
+        self._data = self._list_of_particles_and_custom_particles(particles)
+
+    @staticmethod
+    def _cast_other_as_particle_list(other):
+        if isinstance(other, ParticleList):
+            return other
+
+        try:
+            return ParticleList(other)
+        except (InvalidParticleError, TypeError):
+            pass
+
+        try:
+            return ParticleList([other])
+        except (InvalidParticleError, TypeError):
+            raise InvalidParticleError(f"Cannot cast {other} into a ParticleList")
+
+    def __add__(self, other):
+        try:
+            other_as_particle_list = self._cast_other_as_particle_list(other)
+        except (TypeError, InvalidParticleError) as exc:
+            raise InvalidParticleError(
+                f"Cannot add {repr(other)} to a ParticleList."
+            ) from exc
+        return ParticleList(self.data + other_as_particle_list.data)
+
+    def __radd__(self, other):
+        other_as_particle_list = self._cast_other_as_particle_list(other)
+        return other_as_particle_list.__add__(self)
+
+    def __repr__(self):
+        return f"ParticleList({repr(self.symbols)})"
+
+    def __gt__(self, other):
+        from plasmapy.particles.nuclear import nuclear_reaction_energy
+
+        other_as_particle_list = self._cast_other_as_particle_list(other)
+        return nuclear_reaction_energy(
+            reactants=self.symbols, products=other_as_particle_list.symbols
+        )
+
+    def __str__(self):
+        return self.__repr__()
+
+    def _get_particle_attribute(self, attr, unit=None, default=None):
+        """
+        Get the values of a particular attribute from all of the particles.
+
+        If a ``unit`` is provided, then this function will return a
+        `~astropy.units.Quantity` array with that unit.
+        """
+        values = [getattr(particle, attr, default) for particle in self.data]
+        if unit:
+            values = u.Quantity(values)
+        return values
+
+    def append(self, particle: ParticleLike):
+        """Append a particle to the end of the `ParticleList`."""
+        # TODO: use particle_input when it works with CustomParticle and ParticleLike
+        if not isinstance(particle, (Particle, CustomParticle)):
+            particle = Particle(particle)
+        self.data.append(particle)
+
+    @property
+    def charge(self) -> u.C:
+        """
+        A `~astropy.units.Quantity` array of the electric charges of the
+        particles.
+        """
+        return self._get_particle_attribute("charge", unit=u.C, default=np.nan * u.C)
+
+    @property
+    def data(self) -> List[Union[Particle, CustomParticle]]:
+        """
+        A `list` containing the particles contained in the `ParticleList`
+        instance.
+
+        The `~plasmapy.particles.ParticleList.data` attribute should not
+        be modified directly.
+        """
+        return self._data
+
+    def extend(self, iterable: Iterable[ParticleLike]):
+        """
+        Extend the sequence by appending `ParticleLike` elements
+        from ``iterable``.
+        """
+        if isinstance(iterable, ParticleList):
+            self.data.extend(iterable)
+        else:
+            for obj in iterable:
+                self.append(obj)
+
+    @property
+    def half_life(self) -> u.s:
+        """A `~astropy.units.Quantity` array of the half-lives of the particles."""
+        return self._get_particle_attribute("half_life", unit=u.s, default=np.nan * u.s)
+
+    def insert(self, index, particle: ParticleLike):
+        """Insert a particle before an index."""
+        # TODO: use particle_input when it works with CustomParticle and ParticleLike
+        if not isinstance(particle, (Particle, CustomParticle)):
+            particle = Particle(particle)
+        self.data.insert(index, particle)
+
+    @property
+    def integer_charge(self) -> np.array:
+        """
+        An array of the quantized charges of the particles, as multiples
+        of the elementary charge.
+        """
+        return np.array(self._get_particle_attribute("integer_charge", default=np.nan))
+
+    @property
+    def mass(self) -> u.kg:
+        """A `~astropy.units.Quantity` array of the masses of the particles."""
+        return self._get_particle_attribute("mass", unit=u.kg, default=np.nan * u.J)
+
+    @property
+    def mass_energy(self) -> u.J:
+        """
+        A `~astropy.units.Quantity` array of the mass energies of the
+        particles.
+
+        If the particle is an isotope or nuclide, return the mass energy
+        of the nucleus only.
+        """
+        return self._get_particle_attribute(
+            "mass_energy", unit=u.J, default=np.nan * u.J,
+        )
+
+    def sort(self, key: Callable = None, reverse: bool = False):
+        """
+        Sort the `ParticleList` in-place.  For more information, refer
+        to the documentation for `list.sort`.
+        """
+        if key is None:
+            raise TypeError("Unable to sort a ParticleList without a key.")
+        else:
+            self._data.sort(key=key, reverse=reverse)
+
+    @property
+    def symbols(self) -> List[str]:
+        """A list of the symbols of the particles."""
+        return self._get_particle_attribute("symbol")
+
+
+# Override the docstrings for the parent class
+
+ParticleList.clear.__doc__ = """Remove all items from the `ParticleList`."""
+
+ParticleList.copy.__doc__ = """Return a shallow copy of the `ParticleList`."""
+
+ParticleList.count.__doc__ = """
+Return the number of occurrences of ``item``.  Here, ``item`` may be a
+`Particle`, `CustomParticle`, or `ParticleLike` representation of a particle.
+"""
+
+ParticleList.extend.__doc__ = """
+Extend `ParticleList` by casting `ParticleLike` items from ``iterable``
+into `Particle` or `CustomParticle` instances.
+"""
+
+ParticleList.index.__doc__ = """
+Return first index of a `ParticleLike` value. Raise `ValueError` if
+the value is not present.
+"""
+
+ParticleList.pop.__doc__ = """
+Remove and return item at index (default last).  Raise `IndexError` if
+the `ParticleList` is empty or the index is out of range.
+"""
+
+ParticleList.remove.__doc__ = """
+Remove the first occurrence of a `ParticleLike` item.  Raise
+`ValueError` if the value is not present.
+"""
+
+ParticleList.reverse.__doc__ = """Reverse the `ParticleList` in place."""


Test patch:
diff --git a/plasmapy/particles/tests/test_nuclear.py b/plasmapy/particles/tests/test_nuclear.py
index 2931dfbd..ca6f607e 100644
--- a/plasmapy/particles/tests/test_nuclear.py
+++ b/plasmapy/particles/tests/test_nuclear.py
@@ -170,3 +170,11 @@ def test_nuclear_reaction_energy_kwargs(reactants, products, expectedMeV, tol):
     energy = nuclear_reaction_energy(reactants=reactants, products=products).si
     expected = (expectedMeV * u.MeV).si
     assert np.isclose(expected.value, energy.value, atol=tol)
+
+
+def test_nuclear_reaction_energy():
+    reactants = ["D", "T"]
+    products = ["alpha", "n"]
+    expected = 17.6 * u.MeV
+    actual = nuclear_reaction_energy(reactants=reactants, products=products)
+    assert u.isclose(actual, expected, rtol=1e-3)
diff --git a/plasmapy/particles/tests/test_parsing.py b/plasmapy/particles/tests/test_parsing.py
index abfbc35f..54098a83 100644
--- a/plasmapy/particles/tests/test_parsing.py
+++ b/plasmapy/particles/tests/test_parsing.py
@@ -1,12 +1,11 @@
 import pytest
 
-from plasmapy.particles import Particle
 from plasmapy.particles.exceptions import (
     InvalidElementError,
     InvalidParticleError,
     ParticleWarning,
 )
-from plasmapy.particles.parsing import (  # duplicate with utils.pytest_helpers.error_messages.call_string?
+from plasmapy.particles.parsing import (
     _case_insensitive_aliases,
     _case_sensitive_aliases,
     _dealias_particle_aliases,
@@ -15,18 +14,6 @@
 from plasmapy.particles.special_particles import ParticleZoo
 from plasmapy.utils.code_repr import call_string
 
-
-def _particle_call_string(arg, kwargs=None) -> str:
-    """
-    Return a `str` that recreates the call to create a particular
-    `~plasmapy.particles.Particle` instance from the input.
-    """
-    if kwargs is None:
-        kwargs = {}
-
-    return call_string(Particle, arg, kwargs)
-
-
 aliases_and_symbols = [
     ("electron", "e-"),
     ("beta-", "e-"),
@@ -52,6 +39,12 @@ def _particle_call_string(arg, kwargs=None) -> str:
     ("H-1+", "p+"),
     ("H-1 +1", "p+"),
     ("hydrogen-1+", "p+"),
+    ("α", "He-4 2+"),
+    ("β-", "e-"),
+    ("β⁻", "e-"),
+    ("β+", "e+"),
+    ("τ", "tau-"),
+    ("τ+", "tau+"),
 ]
 
 
@@ -332,7 +325,8 @@ def test_parse_InvalidParticleErrors(arg, kwargs):
         _parse_and_check_atomic_input(arg, **kwargs)
         pytest.fail(
             "An InvalidParticleError was expected to be raised by "
-            f"{_particle_call_string(arg, kwargs)}, but no exception was raised."
+            f"{call_string(_parse_and_check_atomic_input, arg, kwargs)}, "
+            f"but no exception was raised."
         )
 
 
@@ -345,7 +339,8 @@ def test_parse_InvalidElementErrors(arg):
         _parse_and_check_atomic_input(arg)
         pytest.fail(
             "An InvalidElementError was expected to be raised by "
-            f"{_particle_call_string(arg)}, but no exception was raised."
+            f"{call_string(_parse_and_check_atomic_input, arg)}, "
+            f"but no exception was raised."
         )
 
 
@@ -369,12 +364,13 @@ def test_parse_AtomicWarnings(arg, kwargs, num_warnings):
         if not record:
             pytest.fail(
                 f"No AtomicWarning was issued by "
-                f"{_particle_call_string(arg, kwargs)} but the expected number "
+                f"{call_string(_parse_and_check_atomic_input, arg, kwargs)} but the expected number "
                 f"of warnings was {num_warnings}"
             )
 
     assert len(record) == num_warnings, (
-        f"The number of AtomicWarnings issued by {_particle_call_string(arg, kwargs)} "
+        f"The number of AtomicWarnings issued by "
+        f"{call_string(_parse_and_check_atomic_input, arg, kwargs)} "
         f"was {len(record)}, which differs from the expected number "
         f"of {num_warnings} warnings."
     )
diff --git a/plasmapy/particles/tests/test_particle_class.py b/plasmapy/particles/tests/test_particle_class.py
index 3a0fde47..1301cecd 100644
--- a/plasmapy/particles/tests/test_particle_class.py
+++ b/plasmapy/particles/tests/test_particle_class.py
@@ -1344,3 +1344,20 @@ def test_particle_to_json_file(cls, kwargs, expected_repr):
         f"expected_repr = {expected_repr['__init__']}.\n\n"
         f"json_repr: {test_dict['__init__']}"
     )
+
+
+def test_particle_is_category_valid_categories():
+    """Test the location where valid categories may be accessed."""
+    assert hasattr(Particle.is_category, "valid_categories")
+    some_valid_categories = {
+        "lepton",
+        "fermion",
+        "matter",
+        "nonmetal",
+        "electron",
+        "ion",
+        "isotope",
+        "charged",
+        "uncharged",
+    }
+    assert some_valid_categories.issubset(Particle.is_category.valid_categories)
diff --git a/plasmapy/particles/tests/test_particle_collections.py b/plasmapy/particles/tests/test_particle_collections.py
new file mode 100644
index 00000000..be0e1201
--- /dev/null
+++ b/plasmapy/particles/tests/test_particle_collections.py
@@ -0,0 +1,324 @@
+"""Tests for particle collections."""
+
+import astropy.units as u
+import pytest
+
+from plasmapy.particles import alpha, electron, neutron, proton
+from plasmapy.particles.atomic import atomic_number
+from plasmapy.particles.exceptions import InvalidParticleError
+from plasmapy.particles.nuclear import nuclear_reaction_energy
+from plasmapy.particles.particle_class import (
+    CustomParticle,
+    DimensionlessParticle,
+    Particle,
+)
+from plasmapy.particles.particle_collections import ParticleList
+
+custom_particle = CustomParticle(mass=1e-25 * u.kg, charge=1e-18 * u.C)
+dimensionless_particle = DimensionlessParticle(mass=1.25, charge=1.58)
+
+
+attributes = [
+    "charge",
+    "half_life",
+    "integer_charge",
+    "mass",
+    "mass_energy",
+]
+
+
+@pytest.fixture
+def various_particles():
+    """A sample `ParticleList` with several different valid particles."""
+    return ParticleList(
+        [
+            "H",
+            "He",
+            "e-",
+            "alpha",
+            "tau neutrino",
+            CustomParticle(mass=3 * u.kg, charge=5 * u.C),
+            CustomParticle(),
+            CustomParticle(mass=7 * u.kg),
+            CustomParticle(charge=11 * u.C),
+        ]
+    )
+
+
+def _everything_is_particle_or_custom_particle(iterable):
+    """
+    Test that every object in an iterable is either a `Particle` instance
+    or a `CustomParticle` instance.
+    """
+    return all(isinstance(p, (Particle, CustomParticle)) for p in iterable)
+
+
+@pytest.mark.parametrize(
+    "args",
+    [
+        (),
+        ([electron]),
+        ([electron, proton]),
+        ([electron, proton, alpha]),
+        (["e-", "e+"]),
+        ([electron, "e-"]),
+        ([custom_particle]),
+        ([custom_particle, electron, "e-"]),
+    ],
+)
+def test_particle_list_membership(args):
+    """
+    Test that the particles in the `ParticleList` match the particles
+    (or particle-like objects) that are passed to it.
+    """
+    particle_list = ParticleList(args)
+    for arg, particle in zip(args, particle_list):
+        assert particle == arg
+    assert _everything_is_particle_or_custom_particle(particle_list)
+    assert _everything_is_particle_or_custom_particle(particle_list.data)
+
+
+@pytest.mark.parametrize("attribute", attributes)
+def test_particle_list_attributes(attribute, various_particles):
+    """
+    Test that the attributes of ParticleList correspond to the
+    attributes of the listed particles.
+
+    This class does not test ParticleList instances that include
+    CustomParticle instances inside of them.
+    """
+    particle_list_arguments = (electron, "e+", proton, neutron, alpha)
+    particle_list = ParticleList(particle_list_arguments)
+    expected_particles = [Particle(arg) for arg in particle_list_arguments]
+    actual = getattr(particle_list, attribute)
+    expected = [getattr(particle, attribute) for particle in expected_particles]
+    assert u.allclose(actual, expected, equal_nan=True)
+
+
+@pytest.mark.parametrize("attribute", attributes)
+def test_particle_list_no_redefining_attributes(various_particles, attribute):
+    """
+    Test that attributes of `ParticleList` cannot be manually redefined.
+
+    This test may fail if `@cached_property` is used instead of `@property`
+    because `@cached_property` allows reassignment while `@property` does
+    not.
+    """
+    with pytest.raises(AttributeError):
+        various_particles.__setattr__(attribute, 42)
+
+
+def test_particle_list_len():
+    """Test that using `len` on a `ParticleList` returns the expected number."""
+    original_list = ["n", "p", "e-"]
+    particle_list = ParticleList(original_list)
+    assert len(particle_list) == len(original_list)
+
+
+def test_particle_list_append(various_particles):
+    """Test that a particle-like object can get appended to a `ParticleList`."""
+    original_length = len(various_particles)
+    various_particles.append("Li")
+    appended_item = various_particles[-1]
+    assert len(various_particles) == original_length + 1
+    assert isinstance(appended_item, Particle)
+    assert appended_item == Particle("Li")
+    assert various_particles.data[-1] is appended_item
+
+
+def test_particle_list_pop(various_particles):
+    """Test that the last item in the `ParticleList` is removed when"""
+    expected = various_particles[:-1]
+    particle_that_should_be_removed = various_particles[-1]
+    removed_particle = various_particles.pop()
+    assert various_particles == expected
+    assert removed_particle == particle_that_should_be_removed
+
+
+def test_particle_list_extend(various_particles):
+    """
+    Test that a `ParticleList` can be extended when provided with an
+    iterable that yields particle-like objects.
+    """
+    new_particles = ["Fe", Particle("e-"), CustomParticle()]
+    various_particles.extend(new_particles)
+    assert _everything_is_particle_or_custom_particle(various_particles)
+    assert various_particles[-3:] == new_particles
+
+
+def test_particle_list_extended_with_particle_list(various_particles):
+    """Test that a `ParticleList` can be extended with another `ParticleList`."""
+    particle_list = ParticleList(["D", "T", CustomParticle()])
+    various_particles.extend(particle_list)
+    assert various_particles[-3:] == particle_list
+
+
+def test_particle_list_insert(various_particles):
+    """Test insertion of particle-like objects into """
+    various_particles.insert(0, "tau neutrino")
+    assert various_particles[0] == "tau neutrino"
+    assert _everything_is_particle_or_custom_particle(various_particles)
+
+
+invalid_particles = (0, "not a particle", DimensionlessParticle())
+
+
+def test_particle_list_instantiate_with_invalid_particles():
+    """
+    Test that a `ParticleList` instance cannot be created when it is
+    provided with invalid particles.
+    """
+    with pytest.raises(InvalidParticleError):
+        ParticleList(invalid_particles)
+
+
+@pytest.mark.parametrize("invalid_particle", invalid_particles)
+def test_particle_list_append_invalid_particle(various_particles, invalid_particle):
+    """
+    Test that objects that are not particle-like cannot be appended to
+    a `ParticleList` instance.
+    """
+    with pytest.raises((InvalidParticleError, TypeError)):
+        various_particles.append(invalid_particle)
+
+
+def test_particle_list_extend_with_invalid_particles(various_particles):
+    """
+    Test that a `ParticleList` instance cannot be extended with any
+    objects that are not particle-like.
+    """
+    with pytest.raises(InvalidParticleError):
+        various_particles.extend(invalid_particles)
+
+
+@pytest.mark.parametrize("invalid_particle", invalid_particles)
+def test_particle_list_insert_invalid_particle(various_particles, invalid_particle):
+    """
+    Test that objects that are not particle-like cannot be inserted into
+    a `ParticleList` instance.
+    """
+    with pytest.raises((InvalidParticleError, TypeError)):
+        various_particles.insert(1, invalid_particle)
+
+
+def test_particle_list_sort_with_key_and_reverse():
+    """
+    Test that a `ParticleList` instance can be sorted if a key is
+    provided, and that the ``reverse`` keyword argument works too.
+    """
+    elements = ["He", "H", "Fe", "U"]
+    particle_list = ParticleList(elements)
+    particle_list.sort(key=atomic_number, reverse=True)
+    assert particle_list.symbols == ["U", "Fe", "He", "H"]
+
+
+def test_particle_list_sort_without_key(various_particles):
+    """Test that a `ParticleList` cannot be sorted if a key is not provided."""
+    with pytest.raises(TypeError):
+        various_particles.sort()
+
+
+def test_particle_list_dimensionless_particles():
+    """
+    Test that a `ParticleList` cannot be instantiated with a
+    `DimensionlessParticle`.
+    """
+    with pytest.raises(TypeError):
+        ParticleList([DimensionlessParticle()])
+
+
+def test_particle_list_adding_particle_list(various_particles):
+    """Test that a `ParticleList` can be added to another `ParticleList`."""
+    extra_particles = ParticleList(["H", "D", "T"])
+    new_particles_list = various_particles + extra_particles
+    assert new_particles_list[-3:] == extra_particles
+    assert isinstance(new_particles_list, ParticleList)
+
+
+def test_add_particle_list_and_particle(various_particles):
+    """
+    Test that a `ParticleList` can be added to a `Particle` on the right
+    and then return a `ParticleList`.
+    """
+    new_particle_list = various_particles + electron
+    assert new_particle_list[-1] == electron
+    assert new_particle_list[0:-1] == various_particles
+    assert isinstance(new_particle_list, ParticleList)
+
+
+def test_add_particle_and_particle_list(various_particles):
+    """
+    Test that a `Particle` can be added to a `ParticleList` on the right
+    and then return a `ParticleList`.
+    """
+    new_particle_list = electron + various_particles
+    assert new_particle_list[0] == electron
+    assert new_particle_list[1:] == various_particles
+    assert isinstance(new_particle_list, ParticleList)
+
+
+def test_add_particle_and_particle_like():
+    """
+    Test that a `Particle` can be added to a particle-like object on the
+    right and then return a `ParticleList`.
+    """
+    heavy_isotopes_of_hydrogen = Particle("D") + "T"
+    assert isinstance(heavy_isotopes_of_hydrogen, ParticleList)
+    assert heavy_isotopes_of_hydrogen[0] == "D"
+    assert heavy_isotopes_of_hydrogen[1] == "T"
+
+
+def test_add_particle_like_and_particle():
+    """
+    Test that a particle-like object on the left can be added to a
+    `Particle` instance on the right and then return a `ParticleList`.
+    """
+    heavy_isotopes_of_hydrogen = "D" + Particle("T")
+    assert isinstance(heavy_isotopes_of_hydrogen, ParticleList)
+    assert heavy_isotopes_of_hydrogen[0] == "D"
+    assert heavy_isotopes_of_hydrogen[1] == "T"
+
+
+def test_particle_list_gt_as_nuclear_reaction_energy():
+    """
+    Test that `ParticleList.__gt__` can be used to get the same result
+    as `nuclear_reaction_energy`.
+    """
+    reactants = ParticleList(["D+", "T+"])
+    products = ParticleList(["alpha", "n"])
+    expected_energy = nuclear_reaction_energy("D + T --> alpha + n")
+    actual_energy = reactants > products
+    assert u.allclose(expected_energy, actual_energy)
+
+
+def test_particle_gt_as_radioactive_decay():
+    """
+    Test a nuclear reaction where a `Particle` instance is the sole
+    reactant on the left side.
+    """
+    tritium = Particle("T")
+    expected_energy = nuclear_reaction_energy("T -> He-3 + e")
+    actual_energy = tritium > Particle("He-3") + "e"
+    assert u.allclose(expected_energy, actual_energy)
+
+
+@pytest.mark.parametrize("method", ["append", "__add__", "__radd__"])
+@pytest.mark.parametrize("invalid_particle", invalid_particles)
+def test_particle_list_invalid_ops(various_particles, invalid_particle, method):
+    """
+    Test that operations with invalid particles raise the appropriate
+    exceptions.
+    """
+    with pytest.raises((InvalidParticleError, TypeError)):
+        getattr(various_particles, method)(invalid_particle)
+
+
+@pytest.mark.parametrize("particle", [electron, CustomParticle()])
+@pytest.mark.parametrize("method", ["__mul__", "__rmul__"])
+def test_particle_multiplication(method, particle):
+    """
+    Test that multiplying a `Particle` or `CustomParticle` with an
+    integer returns a `ParticleList` that contains the correct values.
+    """
+    particle_list = getattr(particle, method)(3)
+    assert particle_list == [particle, particle, particle]