Repository: PlasmaPy/PlasmaPy

Issue:
Remove upper limit on version of `towncrier`
Version 22.9.0 of [towncrier](https://towncrier.readthedocs.io/en/latest/) was just released, and it started causing some documentation build errors like:

```
/home/runner/work/PlasmaPy/PlasmaPy/docs/whatsnew/dev.rst:3: CRITICAL: render_fragments() got multiple values for argument 'top_underline'
/home/runner/work/PlasmaPy/PlasmaPy/docs/whatsnew/index.rst:11: WARNING: toctree contains reference to document 'whatsnew/dev' that doesn't have a title: no link will be generated
```

In #1710, there was an upper limit placed on towncrier.  We should update PlasmaPy so that it becomes compatible with the newest release of towncrier, in particular since upper limits on versions increase the likelihood of a version conflict.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1717?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) Report
> Merging [#1717](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1717?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) (9008d56) into [main](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/f617aff6fbe7c0764496f4b615631d644373bbfc?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) (f617aff) will **not change** coverage.
> The diff coverage is `n/a`.

> :exclamation: Current head 9008d56 differs from pull request most recent head 4bb6fcc. Consider uploading reports for the commit 4bb6fcc to get more accurate results

```diff
@@           Coverage Diff           @@
##             main    #1717   +/-   ##
=======================================
  Coverage   97.67%   97.67%           
=======================================
  Files          87       87           
  Lines        8204     8204           
=======================================
  Hits         8013     8013           
  Misses        191      191           
```



Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy)


Patch:
diff --git a/changelog/1700.feature.1.rst b/changelog/1700.feature.1.rst
new file mode 100644
index 00000000..e1a1debb
--- /dev/null
+++ b/changelog/1700.feature.1.rst
@@ -0,0 +1,3 @@
+Moved the ``is_category`` method of |Particle| to
+`~plasmapy.particles.particle_class.AbstractPhysicalParticle`. This
+method is now inherited by both |Particle| and |CustomParticle|.
diff --git a/changelog/1700.feature.2.rst b/changelog/1700.feature.2.rst
new file mode 100644
index 00000000..4955834d
--- /dev/null
+++ b/changelog/1700.feature.2.rst
@@ -0,0 +1,3 @@
+Added the `~plasmapy.particles.particle_class.CustomParticle.categories`
+attribute to |CustomParticle|, and added the ``"custom"`` particle
+category.
diff --git a/changelog/1710.trivial.rst b/changelog/1710.trivial.rst
index 3545173c..36aab18f 100644
--- a/changelog/1710.trivial.rst
+++ b/changelog/1710.trivial.rst
@@ -1,1 +1,2 @@
-Change towncrier version to 21.9.0
+Changed the towncrier_ version to ``>= 19.2.0, < 22.8.0``. Superseded by
+:pr:`1717`\ .
diff --git a/changelog/1717.trivial.rst b/changelog/1717.trivial.rst
new file mode 100644
index 00000000..f30dd2ae
--- /dev/null
+++ b/changelog/1717.trivial.rst
@@ -0,0 +1,2 @@
+Changed the minimum version of towncrier_ to 22.8.0 and the minimum
+version of |sphinx_changelog|_ to 1.2.0.
diff --git a/docs/common_links.rst b/docs/common_links.rst
index 6b03dc15..b859e341 100644
--- a/docs/common_links.rst
+++ b/docs/common_links.rst
@@ -100,6 +100,7 @@
 .. |glossary| replace:: :ref:`glossary`\
 .. |keyword-only| replace:: :term:`keyword-only`\
 .. |minpython| replace:: 3.8
+.. |particle-like| replace:: :term:`particle-like`\
 .. |plasma-calculator| replace:: :ref:`plasmapy-calculator`\
 .. |release guide| replace:: :ref:`release guide`\
 .. |testing guide| replace:: :ref:`testing guide`\
diff --git a/docs/particles/particle_class.rst b/docs/particles/particle_class.rst
index 94eaef71..29fa6079 100644
--- a/docs/particles/particle_class.rst
+++ b/docs/particles/particle_class.rst
@@ -126,15 +126,15 @@ True
 Calling the `~plasmapy.particles.particle_class.Particle.is_category`
 method with no arguments returns a set containing all of the valid
 categories for any particle.  Valid categories include:
-``'actinide'``, ``'alkali metal'``, ``'alkaline earth metal'``,
-``'antibaryon'``, ``'antilepton'``, ``'antimatter'``,
-``'antineutrino'``, ``'baryon'``, ``'boson'``, ``'charged'``,
-``'electron'``, ``'element'``, ``'fermion'``, ``'halogen'``,
-``'ion'``, ``'isotope'``, ``'lanthanide'``, ``'lepton'``,
-``'matter'``, ``'metal'``, ``'metalloid'``, ``'neutrino'``,
-``'neutron'``, ``'noble gas'``, ``'nonmetal'``, ``'positron'``,
-``'post-transition metal'``, ``'proton'``, ``'stable'``,
-``'transition metal'``, ``'uncharged'``, and ``'unstable'``.
+``"actinide"``, ``"alkali metal"``, ``"alkaline earth metal"``,
+``"antibaryon"``, ``"antilepton"``, ``"antimatter"``,
+``"antineutrino"``, ``"baryon"``, ``"boson"``, ``"charged"``,
+``"custom"``, ``"electron"``, ``"element"``, ``"fermion"``,
+``"halogen"``, ``"ion"``, ``"isotope"``, ``"lanthanide"``, ``"lepton"``,
+``"matter"``, ``"metal"``, ``"metalloid"``, ``"neutrino"``,
+``"neutron"``, ``"noble gas"``, ``"nonmetal"``, ``"positron"``,
+``"post-transition metal"``, ``"proton"``, ``"stable"``,
+``"transition metal"``, ``"uncharged"``, and ``"unstable"``.
 
 .. _particle-class-conditionals-equality:
 
diff --git a/plasmapy/particles/particle_class.py b/plasmapy/particles/particle_class.py
index de34ca87..c55cd596 100644
--- a/plasmapy/particles/particle_class.py
+++ b/plasmapy/particles/particle_class.py
@@ -1,4 +1,4 @@
-"""The Particle class."""
+"""Classes to represent particles."""
 
 from __future__ import annotations
 
@@ -22,7 +22,7 @@
 from collections import defaultdict, namedtuple
 from datetime import datetime
 from numbers import Integral, Real
-from typing import Iterable, List, Optional, Set, Tuple, Union
+from typing import Iterable, Optional, Sequence, Set, Union
 
 from plasmapy.particles import _elements, _isotopes, _parsing, _special_particles
 from plasmapy.particles.exceptions import (
@@ -37,8 +37,6 @@
     ParticleWarning,
 )
 from plasmapy.utils import roman
-from plasmapy.utils.decorators import deprecated
-from plasmapy.utils.exceptions import PlasmaPyFutureWarning
 
 _classification_categories = {
     "lepton",
@@ -55,6 +53,7 @@
     "unstable",
     "charged",
     "uncharged",
+    "custom",
 }
 
 _periodic_table_categories = {
@@ -206,6 +205,141 @@ def _as_particle_list(self):
 
         return ParticleList([self])
 
+    @property
+    @abstractmethod
+    def categories(self) -> Set[str]:
+        """Provide the particle's categories."""
+        ...
+
+    def is_category(
+        self,
+        *category_tuple,
+        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 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 ``is_category``.
+
+        Examples
+        --------
+        Required categories may be entered as positional arguments,
+        including as a `list`, `set`, or `tuple` of required categories.
+
+        >>> electron = Particle("e-")
+        >>> electron.is_category("lepton")
+        True
+        >>> electron.is_category("lepton", "baryon")
+        False
+        >>> electron.is_category(["fermion", "matter"])
+        True
+
+        Required arguments may also be provided using the ``require``
+        keyword argument.
+
+        >>> electron.is_category(require="lepton")
+        True
+        >>> electron.is_category(require=["lepton", "baryon"])
+        False
+
+        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
+        >>> 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 this method will return `True`.
+
+        >>> electron.is_category(
+        ...     require="fermion", any_of={'lepton', 'baryon'}, exclude='charged',
+        ... )
+        False
+        """
+
+        def become_set(arg: Union[str, Set, Sequence]) -> Set[str]:
+            """Change the argument into a `set`."""
+            if arg is None:
+                return set()
+            if isinstance(arg, set):
+                return arg
+            if isinstance(arg, str):
+                return {arg}
+            return set(arg[0]) if isinstance(arg[0], (tuple, list, set)) else set(arg)
+
+        if category_tuple and require:  # coverage: ignore
+            raise ParticleError(
+                "No positional arguments are allowed if the `require` keyword "
+                "is set in is_category."
+            )
+
+        require = become_set(category_tuple) if category_tuple else become_set(require)
+        exclude = become_set(exclude)
+        any_of = become_set(any_of)
+
+        invalid_categories = (require | exclude | any_of) - _valid_categories
+
+        duplicate_categories = require & exclude | exclude & any_of | require & any_of
+
+        categories_and_adjectives = [
+            (invalid_categories, "invalid"),
+            (duplicate_categories, "duplicated"),
+        ]
+
+        for problem_categories, adjective in categories_and_adjectives:
+            if problem_categories:
+                raise ParticleError(
+                    f"The following categories in {self.__repr__()}"
+                    f".is_category are {adjective}: {problem_categories}"
+                )
+
+        if exclude and exclude & self.categories:
+            return False
+
+        if any_of and not any_of & self.categories:
+            return False
+
+        return require <= self.categories
+
     def __add__(self, other):
         return self._as_particle_list + other
 
@@ -222,6 +356,10 @@ def __gt__(self, other):
         return self._as_particle_list.__gt__(other)
 
 
+AbstractPhysicalParticle.is_category.valid_categories = _valid_categories
+"""All valid particle categories."""
+
+
 class Particle(AbstractPhysicalParticle):
     """
     A class for an individual particle or antiparticle.
@@ -1493,137 +1631,6 @@ def categories(self) -> Set[str]:
         """
         return self._categories
 
-    def is_category(
-        self,
-        *category_tuple,
-        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 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
-        `~plasmapy.particles.particle_class.Particle.is_category`.
-
-        Examples
-        --------
-        Required categories may be entered as positional arguments,
-        including as a `list`, `set`, or `tuple` of required categories.
-
-        >>> electron = Particle("e-")
-        >>> electron.is_category("lepton")
-        True
-        >>> electron.is_category("lepton", "baryon")
-        False
-        >>> electron.is_category(["fermion", "matter"])
-        True
-
-        Required arguments may also be provided using the ``require``
-        keyword argument.
-
-        >>> electron.is_category(require="lepton")
-        True
-        >>> electron.is_category(require=["lepton", "baryon"])
-        False
-
-        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
-        >>> 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 `~plasmapy.particles.particle_class.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]:
-            """Change the argument into a `set`."""
-            if arg is None:
-                return set()
-            if isinstance(arg, set):
-                return arg
-            if isinstance(arg, str):
-                return {arg}
-            return set(arg[0]) if isinstance(arg[0], (tuple, list, set)) else set(arg)
-
-        if category_tuple and require:  # coverage: ignore
-            raise ParticleError(
-                "No positional arguments are allowed if the `require` keyword "
-                "is set in is_category."
-            )
-
-        require = become_set(category_tuple) if category_tuple else become_set(require)
-        exclude = become_set(exclude)
-        any_of = become_set(any_of)
-
-        invalid_categories = (require | exclude | any_of) - _valid_categories
-
-        duplicate_categories = require & exclude | exclude & any_of | require & any_of
-
-        categories_and_adjectives = [
-            (invalid_categories, "invalid"),
-            (duplicate_categories, "duplicated"),
-        ]
-
-        for problem_categories, adjective in categories_and_adjectives:
-            if problem_categories:
-                raise ParticleError(
-                    f"The following categories in {self.__repr__()}"
-                    f".is_category are {adjective}: {problem_categories}"
-                )
-
-        if exclude and exclude & self._categories:
-            return False
-
-        if any_of and not any_of & self._categories:
-            return False
-
-        return require <= self._categories
-
     @property
     def is_electron(self) -> bool:
         """
@@ -1811,10 +1818,6 @@ def recombine(self, n: Integral = 1, inplace=False):
             return Particle(base_particle, Z=new_charge_number)
 
 
-Particle.is_category.valid_categories = _valid_categories
-"""All valid particle categories."""
-
-
 class DimensionlessParticle(AbstractParticle):
     """
     A class to represent dimensionless custom particles.
@@ -2225,6 +2228,18 @@ def symbol(self, new_symbol: str):
         else:
             raise TypeError("symbol needs to be a string.")
 
+    @property
+    def categories(self) -> Set[str]:
+        """Categories for the |CustomParticle|."""
+        categories_ = {"custom"}
+
+        if self.charge == 0 * u.C:
+            categories_ |= {"uncharged"}
+        elif not np.isnan(self.charge):
+            categories_ |= {"charged"}
+
+        return categories_
+
     def __eq__(self, other) -> bool:
         """
         Determine if two objects correspond to the same particle.
diff --git a/requirements/docs.txt b/requirements/docs.txt
index 838138e2..3373cffa 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -12,7 +12,7 @@ numpydoc
 pillow
 pygments >= 2.11.0
 sphinx >= 4.4, != 5.1
-sphinx-changelog
+sphinx-changelog >= 1.2.0
 sphinx-copybutton
 sphinx-gallery < 0.11.0
 sphinx-hoverxref >= 1.1.1
@@ -21,4 +21,4 @@ sphinx-notfound-page >= 0.8
 sphinx-reredirects
 sphinx_rtd_theme >= 1.0.0
 sphinxcontrib-bibtex
-towncrier >= 19.2.0, < 22.8.0
+towncrier >= 22.8.0
diff --git a/setup.cfg b/setup.cfg
index 591d1759..73c03ffa 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -97,7 +97,7 @@ docs =
     pillow
     pygments >= 2.11.0
     sphinx >= 4.4, != 5.1
-    sphinx-changelog
+    sphinx-changelog >= 1.2.0
     sphinx-copybutton
     sphinx-gallery < 0.11.0
     sphinx-hoverxref >= 1.1.1
@@ -106,7 +106,7 @@ docs =
     sphinx-reredirects
     sphinx_rtd_theme >= 1.0.0
     sphinxcontrib-bibtex
-    towncrier >= 19.2.0, < 22.8.0
+    towncrier >= 22.8.0
 developer =
     # install everything for developers
     # ought to functionally mirror requirements.txt
@@ -301,6 +301,7 @@ exclude_lines =
     @jit
     @numba.njit
     @njit
+    @abstractmethod
 
 [codespell]
 skip = *.png,*cache*,*egg*,.git,.hypothesis,.idea,.tox,_build,*charged_particle*.ipynb,venv


Test patch:
diff --git a/plasmapy/particles/tests/test_particle_class.py b/plasmapy/particles/tests/test_particle_class.py
index f1f52d90..718dfb85 100644
--- a/plasmapy/particles/tests/test_particle_class.py
+++ b/plasmapy/particles/tests/test_particle_class.py
@@ -214,6 +214,7 @@
             'is_category(any_of={"positron"})': True,
             'is_category(exclude="positron")': False,
             'is_category("ion")': False,
+            'is_category("invalid_category")': ParticleError,
             "__str__()": "e+",
             "__repr__()": 'Particle("e+")',
             "periodic_table.group": InvalidElementError,
@@ -619,6 +620,8 @@ def test_Particle_equivalent_cases(equivalent_particles):
     (["Fe 25+"], {}, ".recombine(8.2)", TypeError),
     (["e-"], {}, ".ionize()", InvalidElementError),
     (["e+"], {}, ".recombine()", InvalidElementError),
+    (["e+"], {}, ".is_category('invalid_category')", ParticleError),
+    (["e+"], {}, ".is_category(require='element', exclude='element')", ParticleError),
     (["H", 1], {}, "", TypeError),
     (["H", 1, 1], {}, "", TypeError),
 ]
@@ -998,7 +1001,49 @@ def test_custom_particle_symbol(cls, symbol, expected):
     assert instance.symbol == expected
 
 
-customized_particle_errors = [
+custom_particle_categories_table = [
+    ({"charge": 0.0 * u.C}, {"custom", "uncharged"}),
+    ({"charge": 1.0 * u.C}, {"custom", "charged"}),
+    ({}, {"custom"}),
+]
+
+
+@pytest.mark.parametrize("kwargs, expected", custom_particle_categories_table)
+def test_custom_particle_categories(kwargs, expected):
+    """Test that CustomParticle.categories behaves as expected."""
+    custom_particle = CustomParticle(**kwargs)
+    assert custom_particle.categories == expected
+
+
+custom_particle_is_category_table = [
+    ({"charge": 0 * u.C}, {"require": "charged"}, False),
+    ({"charge": 0 * u.C}, {"exclude": "charged"}, True),
+    ({"charge": 0 * u.C}, {"require": "uncharged"}, True),
+    ({"charge": 0 * u.C}, {"exclude": "uncharged"}, False),
+    ({"charge": 1 * u.C}, {"require": "charged"}, True),
+    ({"charge": 1 * u.C}, {"exclude": "charged"}, False),
+    ({"charge": 1 * u.C}, {"require": "uncharged"}, False),
+    ({"charge": 1 * u.C}, {"exclude": "uncharged"}, True),
+    ({}, {"any_of": {"charged", "uncharged"}}, False),
+]
+
+
+@pytest.mark.parametrize(
+    "kwargs_to_custom_particle, kwargs_to_is_category, expected",
+    custom_particle_is_category_table,
+)
+def test_custom_particle_is_category(
+    kwargs_to_custom_particle,
+    kwargs_to_is_category,
+    expected,
+):
+    """Test that CustomParticle.is_category works as expected."""
+    custom_particle = CustomParticle(**kwargs_to_custom_particle)
+    actual = custom_particle.is_category(**kwargs_to_is_category)
+    assert actual == expected
+
+
+custom_particle_errors = [
     (DimensionlessParticle, {"mass": -1e-36}, InvalidParticleError),
     (DimensionlessParticle, {"mass": [1, 1]}, InvalidParticleError),
     (DimensionlessParticle, {"charge": [-1, 1]}, InvalidParticleError),
@@ -1036,7 +1081,7 @@ def test_custom_particle_symbol(cls, symbol, expected):
 ]
 
 
-@pytest.mark.parametrize("cls, kwargs, exception", customized_particle_errors)
+@pytest.mark.parametrize("cls, kwargs, exception", custom_particle_errors)
 def test_customized_particles_errors(cls, kwargs, exception):
     """
     Test that attempting to create invalid dimensionless or custom particles