Repository: PlasmaPy/PlasmaPy

Issue:
Create a `ParticleCollection` class
The `Particle` class currently provides a means of representing a single particle, and we also have `CustomParticle` and `DimensionlessParticle`.  It would be helpful to have a `ParticleCollection` class that we could use to represent multiple particles together.  That class could have attributes like `mass` that would return an array with units of mass so that we can do array operations and the like...and the same for other properties like `charge` too.  

Here's a use case that came from a discussion on Element.  Suppose we want to find the thermal velocities of all of the ions in a multi-component plasma.  If we have a `ParticleCollection` class, we could do something like:
```Python
>>> particles = ParticleCollection(['p+', 'He-4 1+', 'O-16 1+'])
>>> v_th = thermal_speed(1 * u.MK, particles)
```
If we make it iterable then we could also do things like:
```Python
particles = ParticleCollection([...])
for particle in particles:
    print(thermal_speed(1 * u.MK, particle)
```
This class _shouldn't_ include transitory properties like temperature, since that's not what the `Particle` class does.  That should be in a different class...perhaps something like `MultiComponentPlasma` and/or we could have something like `Species` that keeps track of the plasma parameters for a single species in a plasma...but that is not for this issue.

Probably the best way to approach this class would be to split up development into multiple pull requests.  The first could create a prototype of the class, the next could add `__iter__` and `__next__` to make it iterable, and so on.  That'd make code review more manageable.

Discussion:
namurphy: As an attempt at computational efficiency, I'm trying to store retrieved attributes so that they can be used later, so long as the contents of the list does not change.  This could help with computationally intensive things where there are lots of particles, and the arrays of masses or charges get used repeatedly.
namurphy: I've been starting to think that caching this could lead to some weird and unexpected behaviors later on, so I'm going to drop the caching part here.  The issues are:

1. `ParticleList` is a mutable type
2. `ParticleList` can contain `CustomParticle` instances, which are also mutable.  

Suppose one element of `ParticleList` is a `CustomParticle`.  Then suppose someone modifies the mass of that `CustomParticle` directly, without modifying the identities of the particles within the list.   Then the cached values of mass would no longer be valid.  This will probably not be a common issue, but it could cause unexpected and difficult to track down problems.  It'll be safer and cleaner to avoid caching.  We can revisit caching if/when there's a situation where it ends up being needed.
StanczakDominik: True, though people changing particle masses after creation seems like a rare use case. Dusty plasmas, maybe... But for e.g. impurity studies I'd just want a bunch of completely static particle types. Then we can take advantage of caching without issues. But keeping PRs small is also a great idea :+1:
codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017?src=pr&el=h1) Report
> Merging [#1017](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017?src=pr&el=desc) (05c53b8) into [master](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/b342f39bc1829d9b0e8c3326a32b37f588c807b1?el=desc) (b342f39) will **decrease** coverage by `0.97%`.
> The diff coverage is `78.57%`.

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

```diff
@@            Coverage Diff             @@
##           master    #1017      +/-   ##
==========================================
- Coverage   96.67%   95.70%   -0.98%     
==========================================
  Files          64       65       +1     
  Lines        6279     6349      +70     
==========================================
+ Hits         6070     6076       +6     
- Misses        209      273      +64     
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [plasmapy/particles/particle\_collections.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL3BhcnRpY2xlX2NvbGxlY3Rpb25zLnB5) | `78.26% <78.26%> (ø)` | |
| [plasmapy/particles/\_\_init\_\_.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [plasmapy/simulation/particle\_integrators.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017/diff?src=pr&el=tree#diff-cGxhc21hcHkvc2ltdWxhdGlvbi9wYXJ0aWNsZV9pbnRlZ3JhdG9ycy5weQ==) | `33.33% <0.00%> (-66.67%)` | :arrow_down: |
| [plasmapy/simulation/particletracker.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017/diff?src=pr&el=tree#diff-cGxhc21hcHkvc2ltdWxhdGlvbi9wYXJ0aWNsZXRyYWNrZXIucHk=) | `36.20% <0.00%> (-62.07%)` | :arrow_down: |
| [plasmapy/plasma/sources/plasma3d.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGxhc21hL3NvdXJjZXMvcGxhc21hM2QucHk=) | `93.75% <0.00%> (-6.25%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017?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/1017?src=pr&el=footer). Last update [b342f39...05c53b8](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1017?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

namurphy: Ahhhhhhh scope creep on this pull request  🙀  but check this out 🐍 
```Python
>>> α = Particle("alpha")
>>> D = Particle("D+")
>>> T = Particle("T+")
>>> n = Particle("n")
>>> D + T > n + α
<Quantity 2.81810898e-12 J>
```
I've been meaning to implement this for bleeping years.  I think the idea originally came from @Cadair.  I hereby dedicate the use of unicode characters to @rocco8773.  😬 
namurphy: Later on I think it'd be better if the lines like in my last comment returned an instance of a `NuclearReaction` or `FusionReaction` class, as brought up in #1026 and as started in #978.
```Python
>>> fusion_reaction = D + T > n + α
```
For the moment, returning the nuclear reaction energy is still useful.

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/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..557f8230 100644
--- a/plasmapy/particles/particle_class.py
+++ b/plasmapy/particles/particle_class.py
@@ -4,6 +4,7 @@
 
 __all__ = [
     "AbstractParticle",
+    "AbstractPhysicalParticle",
     "CustomParticle",
     "DimensionlessParticle",
     "Particle",
@@ -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.
@@ -1569,14 +1608,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
@@ -1880,7 +1919,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_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]