Repository: PlasmaPy/PlasmaPy

Issue:
Replace fundamental collision frequencies with CollisionFrequencies
I think the functions `plasmapy.formulary.collisons.fundamental_ion_collision_freq` and `plasmapy.formulary.collisons.fundamental_electron_collision_freq` are closely related to the `collision_frequency` function, which is set to be deprecated in favor of the new `CollisionFrequencies` class introduced by #1676.  When that happens, these other functions will also need to be rewritten in terms of the new `CollisionFrequencies` class.


Discussion:
I think its natural that I should give this one a try 

Resolving patch:
diff --git a/plasmapy/formulary/collisions.py b/plasmapy/formulary/collisions.py
--- a/plasmapy/formulary/collisions.py
+++ b/plasmapy/formulary/collisions.py
@@ -27,7 +27,7 @@
 
 The module gathers a few functions helpful for calculating collision
 rates between particles. The most general of these is
-`~plasmapy.formulary.collisions.CollisionFrequencies`.
+`~plasmapy.formulary.collisions.SingleParticleCollisionFrequencies`.
 
 Macroscopic properties
 ======================
@@ -40,7 +40,8 @@
 * `~plasmapy.formulary.collisions.coupling_parameter`
 """
 __all__ = [
-    "CollisionFrequencies",
+    "SingleParticleCollisionFrequencies",
+    "MaxwellianCollisionFrequencies",
     "Coulomb_logarithm",
     "impact_parameter_perp",
     "impact_parameter",
@@ -77,7 +78,7 @@
 from plasmapy.formulary.speeds import thermal_speed
 from plasmapy.utils.decorators import deprecated, validate_quantities
 from plasmapy.utils.decorators.checks import _check_relativistic
-from plasmapy.utils.exceptions import PlasmaPyFutureWarning
+from plasmapy.utils.exceptions import PhysicsError, PlasmaPyFutureWarning
 
 
 @validate_quantities(
@@ -909,9 +910,9 @@ def collision_frequency(
     r"""
     .. note::
         The `~plasmapy.formulary.collisions.collision_frequency` function has been
-        replaced by the more general `~plasmapy.formulary.collisions.CollisionFrequencies` class.
+        replaced by the more general `~plasmapy.formulary.collisions.SingleParticleCollisionFrequencies` class.
         To replicate the functionality of `~plasmapy.formulary.collisions.collision_frequency`, create a
-        `~plasmapy.formulary.collisions.CollisionFrequencies` class and access the ``Lorentz_collision_frequency`` attribute.
+        `~plasmapy.formulary.collisions.SingleParticleCollisionFrequencies` class and access the ``Lorentz_collision_frequency`` attribute.
 
     Collision frequency of particles in a plasma.
 
@@ -1013,17 +1014,17 @@ def collision_frequency(
 
     See Also
     --------
-    ~plasmapy.formulary.collisions.CollisionFrequencies
+    ~plasmapy.formulary.collisions.SingleParticleCollisionFrequencies
 
     """
 
     deprecated(
-        since="0.8.0",
+        since="0.9.0",
         warning_type=PlasmaPyFutureWarning,
         message=(
             "The collision_frequency function has been replaced by the more general "
-            "CollisionFrequencies class. To replicate the functionality of collision_frequency, "
-            "create a CollisionFrequencies class and access the `Lorentz_collision_frequency` "
+            "SingleParticleCollisionFrequencies class. To replicate the functionality of collision_frequency, "
+            "create a SingleParticleCollisionFrequencies class and access the `Lorentz_collision_frequency` "
             "attribute."
         ),
     )
@@ -1123,12 +1124,19 @@ def fundamental_electron_collision_freq(
     Average momentum relaxation rate for a slowly flowing Maxwellian
     distribution of electrons.
 
+    .. note::
+        The `~plasmapy.formulary.collisions.fundamental_electron_collision_freq` function has been
+        replaced by the more general `~plasmapy.formulary.collisions.MaxwellianCollisionFrequencies` class.
+        To replicate the functionality of `~plasmapy.formulary.collisions.fundamental_electron_collision_freq`, create a
+        `~plasmapy.formulary.collisions.MaxwellianCollisionFrequencies` class and access the ``Maxwellian_avg_ei_collision_freq`` attribute.
+
+
     :cite:t:`braginskii:1965` provides a derivation of this as an
     average collision frequency between electrons and ions for a
     Maxwellian distribution. It is thus a special case of the collision
     frequency with an averaging factor, and is on many occasions
     in transport theory the most relevant collision frequency that has
-    to be considered. It is heavily related to diffusion and resistivity
+    to be considered. It commonly occurs in relation to diffusion and resistivity
     in plasmas.
 
     Parameters
@@ -1210,6 +1218,18 @@ def fundamental_electron_collision_freq(
     ~plasmapy.formulary.collisions.collision_frequency
     ~plasmapy.formulary.collisions.fundamental_ion_collision_freq
     """
+
+    deprecated(
+        since="0.9.0",
+        warning_type=PlasmaPyFutureWarning,
+        message=(
+            "The `fundamental_electron_collision_freq` function has been"
+            "replaced by the more general `MaxwellianCollisionFrequencies` class."
+            "To replicate the functionality of `fundamental_electron_collision_freq`, create a"
+            "`MaxwellianCollisionFrequencies` class and access the `Maxwellian_avg_ei_collision_freq` attribute."
+        ),
+    )
+
     # specify to use electron thermal velocity (most probable), not based on reduced mass
     V = _replace_nan_velocity_with_thermal_velocity(V, T_e, m_e)
 
@@ -1251,6 +1271,13 @@ def fundamental_ion_collision_freq(
     Average momentum relaxation rate for a slowly flowing Maxwellian
     distribution of ions.
 
+    .. note::
+        The `~plasmapy.formulary.collisions.fundamental_ion_collision_freq` function has been
+        replaced by the more general `~plasmapy.formulary.collisions.MaxwellianCollisionFrequencies` class.
+        To replicate the functionality of `~plasmapy.formulary.collisions.fundamental_ion_collision_freq`, create a
+        `~plasmapy.formulary.collisions.MaxwellianCollisionFrequencies` class and access the ``Maxwellian_avg_ii_collision_freq`` attribute.
+
+
     :cite:t:`braginskii:1965` provides a derivation of this as an
     average collision frequency between ions and ions for a Maxwellian
     distribution. It is thus a special case of the collision frequency
@@ -1343,6 +1370,18 @@ def fundamental_ion_collision_freq(
     ~plasmapy.formulary.collisions.collision_frequency
     ~plasmapy.formulary.collisions.fundamental_electron_collision_freq
     """
+
+    deprecated(
+        since="0.9.0",
+        warning_type=PlasmaPyFutureWarning,
+        message=(
+            "The `fundamental_ion_collision_freq` function has been"
+            "replaced by the more general `MaxwellianCollisionFrequencies` class."
+            "To replicate the functionality of `fundamental_ion_collision_freq`, create a"
+            "`MaxwellianCollisionFrequencies` class and access the `Maxwellian_avg_ii_collision_freq` attribute."
+        ),
+    )
+
     m_i = particles.particle_mass(ion)
     species = [ion, ion]
 
@@ -2066,63 +2105,53 @@ def coupling_parameter(
     return coulomb_energy / kinetic_energy
 
 
-class CollisionFrequencies:
+class SingleParticleCollisionFrequencies:
     @particles.particle_input
     @validate_quantities(
-        v_a={"none_shall_pass": True, "can_be_negative": False},
-        T_a={
-            "none_shall_pass": True,
+        v_drift={"can_be_negative": False},
+        T_b={
             "can_be_negative": False,
             "equivalencies": u.temperature_energy(),
         },
         n_b={"can_be_negative": False},
-        T_b={"can_be_negative": False, "equivalencies": u.temperature_energy()},
     )
     def __init__(
         self,
-        test_particle: particles.Particle,
-        field_particle: particles.Particle,
+        test_particle: particles.ParticleLike,
+        field_particle: particles.ParticleLike,
         *,
-        v_a: u.m / u.s = None,
-        T_a: u.K = None,
-        n_b: u.m**-3,
+        v_drift: u.m / u.s,
         T_b: u.K,
+        n_b: u.m**-3,
         Coulomb_log: u.dimensionless_unscaled,
     ):
         r"""
-        Compute collision frequencies between test particles (labeled 'a') and field particles (labeled 'b')
+        Compute collision frequencies between test particles (labeled 'a') and field particles (labeled 'b').
 
         Parameters
         ----------
-        test_particle : |Particle|
+        test_particle : `~plasmapy.particles.ParticleLike`
             The test particle streaming through a background of field particles.
 
-        field_particle : |Particle|
+        field_particle : `~plasmapy.particles.ParticleLike`
             The background particle being interacted with.
 
-        v_a : `~astropy.units.Quantity`, optional
-            The relative speed between particles. If not provided, T_a must be specified and
-            thermal velocity is assumed: :math:`μ v_a^2 \sim 2 k_B T_a` where
-            :math:`μ` is the reduced mass. Cannot be negative.
-
-        T_a : `~astropy.units.Quantity`, optional
-            The temperature of the test species. Only necessary if :math:`v_a` is not provided.
-
-        n_b : `~astropy.units.Quantity`
-            The number density of the background field particles in units convertible to :math:`\frac{1}{m^{3}}`
+        v_drift : `~astropy.units.Quantity`
+            The relative drift between the test and field particles. Cannot be negative.
 
         T_b : `~astropy.units.Quantity`
             The temperature of the background field particles in units convertible to degrees Kelvin.
 
+        n_b : `~astropy.units.Quantity`
+            The number density of the background field particles in units convertible to :math:`\frac{1}{m^{3}}`.
+
         Coulomb_log : `~astropy.units.Quantity`
-            The value of the Coulomb logarithm evaluated for the two interacting particles.
+            The value of the Coulomb logarithm for the interaction.
 
         Raises
         ------
         `ValueError`
-            If both :math:`v_a` and :math:`T_a` are specified, neither
-            :math:`v_a` nor :math:`T_a` are specified, or specified arrays
-            don't have equal size.
+            If the specified v_drift and n_b arrays don't have equal size.
 
         Notes
         -----
@@ -2170,21 +2199,14 @@ def __init__(
         Examples
         --------
         >>> import astropy.units as u
-        >>> T_a = 1 * u.eV
+        >>> v_drift = 1e5 * u.m / u.s
         >>> n_b = 1e26 * u.m**-3
         >>> T_b = 1e3 * u.eV
         >>> Coulomb_log = 10 * u.dimensionless_unscaled
-        >>> frequencies_using_temperature = CollisionFrequencies(
-        ...     "e-", "e-", T_a=T_a, n_b=n_b, T_b=T_b, Coulomb_log=Coulomb_log
-        ... )
-        >>> frequencies_using_temperature.momentum_loss
-        <Quantity 1.83701432e+11 Hz>
-
-        >>> v_a = 1e5 * u.m / u.s
-        >>> frequencies_using_velocity = CollisionFrequencies(
-        ...     "e-", "e-", v_a=v_a, n_b=n_b, T_b=T_b, Coulomb_log=Coulomb_log
+        >>> frequencies = SingleParticleCollisionFrequencies(
+        ...     "e-", "e-", v_drift=v_drift, n_b=n_b, T_b=T_b, Coulomb_log=Coulomb_log
         ... )
-        >>> frequencies_using_velocity.energy_loss
+        >>> frequencies.energy_loss
         <Quantity -9.69828719e+15 Hz>
 
         See Also
@@ -2196,31 +2218,18 @@ def __init__(
         # Note: This function uses CGS units internally to coincide with our references.
         # Input is taken in MKS units and then converted as necessary. Output is in MKS units.
 
-        if v_a is None:
-            if T_a is not None:
-                v_a = thermal_speed(T_a, test_particle)
-            else:
-                raise ValueError("Please specify either v_a or T_a.")
-        elif T_a is not None:
-            raise ValueError("Please specify either v_a or T_a, not both.")
-
         if (
-            isinstance(v_a, np.ndarray)
+            isinstance(v_drift, np.ndarray)
             and isinstance(n_b, np.ndarray)
-            and v_a.shape != n_b.shape
+            and v_drift.shape != n_b.shape
         ):
             raise ValueError("Please specify arrays of equal length.")
 
         self.test_particle = test_particle
         self.field_particle = field_particle
-        self.v_a = v_a
-        self.T_a = (
-            T_a
-            if T_a is not None
-            else (0.5 * test_particle.mass * v_a**2 / k_B).to(u.K)
-        )
-        self.n_b = n_b
+        self.v_drift = v_drift
         self.T_b = T_b
+        self.n_b = n_b
         self.Coulomb_log = (
             Coulomb_log
             if isinstance(Coulomb_log, u.Quantity)
@@ -2280,14 +2289,17 @@ def Lorentz_collision_frequency(self):
             ν = n σ v \ln{Λ}
 
         where :math:`n` is the particle density, :math:`σ` is the
-        collisional cross-section, :math:`v` is the inter-particle velocity
-        (typically taken as the thermal velocity), and :math:`\ln{Λ}` is the
-        Coulomb logarithm accounting for small angle collisions.
+        collisional cross-section, :math:`v` is the inter-particle velocity,
+        and :math:`\ln{Λ}` is the Coulomb logarithm accounting for small angle collisions.
 
-        See Equation (2.14) in :cite:t:`callen:unpublished`.
+        See Equation (2.86) in :cite:t:`callen:unpublished`.
 
         The Lorentz collision frequency is equivalent to the variable
         :math:`\nu_0^{\alpha/\beta}` on p. 31 of :cite:t:`nrlformulary:2019`.
+
+        This form of the Lorentz collision frequency differs from the form found in `~plasmapy.formulary.collisions.MaxwellianCollisionFrequencies`
+        in that :math:`v` is the drift velocity (as opposed to the mean thermal
+        velocity between species).
         """
 
         return (
@@ -2297,18 +2309,19 @@ def Lorentz_collision_frequency(self):
             * (self.field_particle.charge_number * e.esu) ** 2
             * self.Coulomb_log
             * self.n_b
-            / (self.test_particle.mass**2 * self.v_a**3)
+            / (self.test_particle.mass**2 * self.v_drift**3)
         ).to(u.Hz)
 
     @cached_property
     def x(self) -> u.dimensionless_unscaled:
         """
         The ratio of kinetic energy in the test particle to the thermal energy of the field particle.
-        This parameter determines the regime in which the collision falls
-        (see documentation for the `~plasmapy.formulary.collisions.CollisionFrequencies` class for details).
+        This parameter determines the regime in which the collision falls.
+
+        (see documentation for the `~plasmapy.formulary.collisions.SingleParticleCollisionFrequencies` class for details)
         """
 
-        x = self.field_particle.mass * self.v_a**2 / (2 * k_B.cgs * self.T_b)
+        x = self.field_particle.mass * self.v_drift**2 / (2 * k_B.cgs * self.T_b)
         return x.to(u.dimensionless_unscaled)
 
     @staticmethod
@@ -2319,7 +2332,7 @@ def _phi_integrand(t: u.dimensionless_unscaled):
 
         return t**0.5 * np.exp(-t)
 
-    def _phi_explicit(self, x: float) -> float:
+    def _phi_explicit(self, x: float):
         """
         The non-vectorized method for evaluating the integral for phi
         """
@@ -2346,3 +2359,262 @@ def _phi_prime(self):
         """
 
         return 2 / np.pi**0.5 * self._phi_integrand(self.x)
+
+
+class MaxwellianCollisionFrequencies:
+    @particles.particle_input
+    @validate_quantities(
+        v_drift={"can_be_negative": False},
+        T_a={
+            "can_be_negative": False,
+            "equivalencies": u.temperature_energy(),
+        },
+        n_a={"can_be_negative": False},
+        T_b={
+            "can_be_negative": False,
+            "equivalencies": u.temperature_energy(),
+        },
+        n_b={"can_be_negative": False},
+    )
+    def __init__(
+        self,
+        test_particle: particles.ParticleLike,
+        field_particle: particles.ParticleLike,
+        *,
+        v_drift: u.m / u.s = 0 * u.m / u.s,
+        T_a: u.K,
+        n_a: u.m**-3,
+        T_b: u.K,
+        n_b: u.m**-3,
+        Coulomb_log: u.dimensionless_unscaled,
+    ):
+        r"""Compute collision frequencies between two slowly flowing
+        Maxwellian populations.
+
+        The condition of "slowly flowing", outlined by Eq. 2.133 in :cite:t:`callen:unpublished`
+        requires that
+
+        .. math::
+
+            v_{drift} << \sqrt{v_{T_a}^{2}+v_{T_b}^{2}}
+
+        where :math:`v_{drift}` is the relative drift between the two species, :math:`v_{T_a}`
+        is the thermal velocity of species "a", and :math:`v_{T_b}` is the thermal
+        velocity of species "b".
+
+        Parameters
+        ----------
+        test_particle : `~plasmapy.particles.ParticleLike`
+            The test particle streaming through a background of field particles.
+
+        field_particle : `~plasmapy.particles.ParticleLike`
+            The background particle being interacted with.
+
+        v_drift : `~astropy.units.Quantity`, optional
+            The relative drift between the test and field particles. Defaults to zero.
+
+        T_a : `~astropy.units.Quantity`
+            The temperature of the test particles in units convertible to degrees Kelvin.
+
+        n_a : `~astropy.units.Quantity`
+            The number density of the test particles in units convertible to :math:`\frac{1}{m^{3}}`.
+
+        T_b : `~astropy.units.Quantity`
+            The temperature of the background field particles in units convertible to degrees Kelvin.
+
+        n_b : `~astropy.units.Quantity`
+            The number density of the background field particles in units convertible to :math:`\frac{1}{m^{3}}`.
+
+        Coulomb_log : `~astropy.units.Quantity`
+            The value of the Coulomb logarithm for the interaction.
+
+        Raises
+        ------
+        `ValueError`
+            If the specified v_drift and T_a arrays don't have equal size.
+
+        See Also
+        --------
+        ~plasmapy.formulary.collisions.Coulomb_logarithm : Evaluates the Coulomb
+            logarithm for two interacting electron species.
+        """
+
+        if (
+            isinstance(v_drift, np.ndarray)
+            and isinstance(T_a, np.ndarray)
+            and v_drift.shape != T_a.shape
+        ):
+            raise ValueError("Please specify arrays of equal length.")
+
+        self.test_particle = test_particle
+        self.field_particle = field_particle
+        self.v_drift = v_drift
+        self.T_a = T_a
+        self.n_a = n_a
+        self.T_b = T_b
+        self.n_b = n_b
+        self.Coulomb_log = (
+            Coulomb_log
+            if isinstance(Coulomb_log, u.Quantity)
+            else Coulomb_log * u.dimensionless_unscaled
+        )
+
+        self.v_T_a = thermal_speed(self.T_a, self.test_particle)
+        self.v_T_b = thermal_speed(self.T_b, self.field_particle)
+
+    @cached_property
+    def _mean_thermal_velocity(self):
+        """
+        Parameter used in enforcing the definition of "slowly flowing" Maxwellian
+        particles. See Eq. 2.133 in :cite:t:`callen:unpublished`.
+        """
+
+        return (self.v_T_a**2 + self.v_T_b**2) ** 0.5
+
+    @cached_property
+    def _is_slowly_flowing(self):
+        """
+        Criteria used in determining whether `Maxwellian_avg_ei_collision_freq` and
+        `Maxwellian_avg_ii_collision_freq` can be applied to the specified species.
+        """
+
+        return self.v_drift / self._mean_thermal_velocity < 0.1
+
+    @cached_property
+    def Lorentz_collision_frequency(self):
+        r"""
+        The Lorentz collision frequency.
+
+        The Lorentz collision frequency (see Ch. 5 of :cite:t:`chen:2016`) is given
+        by
+
+        .. math::
+
+            ν = n σ v \ln{Λ}
+
+        where :math:`n` is the particle density, :math:`σ` is the
+        collisional cross-section, :math:`v` is the mean thermal velocity between particle species
+        (see Equation 2.133 in :cite:t:`callen:unpublished`), and :math:`\ln{Λ}` is the
+        Coulomb logarithm accounting for small angle collisions.
+
+        See Equation (2.86) in :cite:t:`callen:unpublished`.
+
+        This form of the Lorentz collision frequency differs from the form found in `~plasmapy.formulary.collisions.SingleParticleCollisionFrequencies`
+        in that :math:`v` is the mean thermal velocity between particle species
+        in this method (as opposed to the drift velocity between species).
+        """
+
+        return (
+            4
+            * np.pi
+            * (self.test_particle.charge_number * e.esu) ** 2
+            * (self.field_particle.charge_number * e.esu) ** 2
+            * self.Coulomb_log
+            * self.n_b
+            / (self.test_particle.mass**2 * self._mean_thermal_velocity**3)
+        ).to(u.Hz)
+
+    @cached_property
+    def Maxwellian_avg_ei_collision_freq(self):
+        r"""Average momentum relaxation rate for a slowly flowing Maxwellian
+        distribution of electrons, relative to a population of stationary ions.
+
+        This function assumes that both populations are Maxwellian, and :math:`T_{i} \lesssim T_{e}`.
+
+        :cite:t:`callen:unpublished` provides a derivation of this as an
+        average collision frequency between electrons and ions for a
+        Maxwellian distribution. It is thus a special case of the collision
+        frequency with an averaging factor, and is on many occasions
+        in transport theory the most relevant collision frequency that has
+        to be considered. It commonly occurs in relation to diffusion and resistivity
+        in plasmas.
+
+        Raises
+        ------
+        `~plasmapy.utils.exceptions.PhysicsError`
+            The test particles are not 'slowly flowing' relative to the field particles (see notes).
+
+        `ValueError`
+            If the specified interaction isn't electron-ion.
+
+        Examples
+        --------
+        >>> import astropy.units as u
+        >>> v_drift = 1 * u.m / u.s
+        >>> n_a = 1e26 * u.m**-3
+        >>> T_a = 1 * u.eV
+        >>> n_b = 1e26 * u.m**-3
+        >>> T_b = 1e3 * u.eV
+        >>> Coulomb_log = 10 * u.dimensionless_unscaled
+        >>> electron_ion_collisions = MaxwellianCollisionFrequencies(
+        ...     "e-", "Na+", v_drift=v_drift, n_a=n_a, T_a=T_a, n_b=n_b, T_b=T_b, Coulomb_log=Coulomb_log
+        ... )
+        >>> electron_ion_collisions.Maxwellian_avg_ei_collision_freq
+        <Quantity 2906316911556553.5 Hz>
+        """
+
+        if not self.test_particle.is_electron or not self.field_particle.is_ion:
+            raise ValueError(
+                "Please specify an electron-ion interaction to use the Maxwellian_avg_ei_collision_freq attribute."
+            )
+
+        if not self._is_slowly_flowing:
+            raise PhysicsError(
+                "This frequency is only defined for slowly flowing species."
+                "(see MaxwellianCollisionFrequencies class documentation for further details)"
+            )
+
+        coeff = 4 / (3 * np.sqrt(np.pi))
+
+        return coeff * self.Lorentz_collision_frequency
+
+    @cached_property
+    def Maxwellian_avg_ii_collision_freq(self):
+        r"""Average momentum relaxation rate for a slowly flowing Maxwellian
+        distribution of ions, relative to a population of stationary ions.
+
+        This function assumes that both populations are Maxwellian, and :math:`T_{i} \lesssim T_{e}`.
+
+        :cite:t:`callen:unpublished` provides a derivation of this as an
+        average collision frequency between ions and ions for a Maxwellian
+        distribution. It is thus a special case of the collision frequency
+        with an averaging factor.
+
+        Raises
+        ------
+        `~plasmapy.utils.exceptions.PhysicsError`
+            The test particles are not 'slowly flowing' relative to the field particles (see notes).
+
+        `ValueError`
+            If the specified interaction isn't ion-ion.
+
+        Examples
+        --------
+        >>> import astropy.units as u
+        >>> v_drift = 1 * u.m / u.s
+        >>> n_a = 1e26 * u.m**-3
+        >>> T_a = 1e3 * u.eV
+        >>> n_b = 1e26 * u.m**-3
+        >>> T_b = 1e3 * u.eV
+        >>> Coulomb_log = 10 * u.dimensionless_unscaled
+        >>> ion_ion_collisions = MaxwellianCollisionFrequencies(
+        ...     "Na+", "Na+", v_drift=v_drift, n_a=n_a, T_a=T_a, n_b=n_b, T_b=T_b, Coulomb_log=Coulomb_log
+        ... )
+        >>> ion_ion_collisions.Maxwellian_avg_ii_collision_freq
+        <Quantity 79364412.21510696 Hz>
+        """
+
+        if not self.test_particle.is_ion or not self.field_particle.is_ion:
+            raise ValueError(
+                "Please specify an ion-ion interaction to use the Maxwellian_avg_ii_collision_freq attribute"
+            )
+
+        if not self._is_slowly_flowing:
+            raise PhysicsError(
+                "This frequency is only defined for slowly flowing species."
+                "(see MaxwellianCollisionFrequencies class documentation for further details)"
+            )
+
+        coeff = 4 / (3 * np.sqrt(2 * np.pi))
+
+        return coeff * self.Lorentz_collision_frequency
diff --git a/plasmapy/utils/decorators/__init__.py b/plasmapy/utils/decorators/__init__.py
--- a/plasmapy/utils/decorators/__init__.py
+++ b/plasmapy/utils/decorators/__init__.py
@@ -10,6 +10,7 @@
     "bind_lite_func",
     "modify_docstring",
     "preserve_signature",
+    "validate_class_attributes",
     "validate_quantities",
     "CheckBase",
     "CheckUnits",
@@ -29,4 +30,8 @@
 from plasmapy.utils.decorators.deprecation import deprecated
 from plasmapy.utils.decorators.helpers import modify_docstring, preserve_signature
 from plasmapy.utils.decorators.lite_func import bind_lite_func
-from plasmapy.utils.decorators.validators import validate_quantities, ValidateQuantities
+from plasmapy.utils.decorators.validators import (
+    validate_class_attributes,
+    validate_quantities,
+    ValidateQuantities,
+)
diff --git a/plasmapy/utils/decorators/validators.py b/plasmapy/utils/decorators/validators.py
--- a/plasmapy/utils/decorators/validators.py
+++ b/plasmapy/utils/decorators/validators.py
@@ -1,14 +1,14 @@
 """
 Various decorators to validate input/output arguments to functions.
 """
-__all__ = ["validate_quantities", "ValidateQuantities"]
+__all__ = ["validate_class_attributes", "validate_quantities", "ValidateQuantities"]
 
 import astropy.units as u
 import functools
 import inspect
 import warnings
 
-from typing import Any, Dict
+from typing import Any, Dict, Iterable, List, Optional
 
 from plasmapy.utils.decorators.checks import CheckUnits, CheckValues
 from plasmapy.utils.decorators.helpers import preserve_signature
@@ -536,3 +536,82 @@ def foo(arg1):
     else:
         # `validate_quantities` called as a decorator "sugar-syntax"
         return ValidateQuantities(**validations)
+
+
+def get_attributes_not_provided(
+    self,
+    expected_attributes: Optional[List[str]] = None,
+    both_or_either_attributes: Optional[List[Iterable[str]]] = None,
+    mutually_exclusive_attributes: Optional[List[Iterable[str]]] = None,
+):
+    """
+    Collect attributes that weren't provided during instantiation needed
+    to access a method.
+    """
+
+    attributes_not_provided = []
+
+    if expected_attributes is not None:
+        for attribute in expected_attributes:
+            if getattr(self, attribute) is None:
+                attributes_not_provided.append(attribute)
+
+    if both_or_either_attributes is not None:
+        for attribute_tuple in both_or_either_attributes:
+            number_of_attributes_provided = 0
+
+            for attribute in attribute_tuple:
+                if getattr(self, attribute) is not None:
+                    number_of_attributes_provided += 1
+
+            if number_of_attributes_provided == 0:
+                attributes_not_provided.append(
+                    f"at least one of {' or '.join(attribute_tuple)}"
+                )
+
+    if mutually_exclusive_attributes is not None:
+        for attribute_tuple in mutually_exclusive_attributes:
+            number_of_attributes_provided = 0
+
+            for attribute in attribute_tuple:
+                if getattr(self, attribute) is not None:
+                    number_of_attributes_provided += 1
+
+            if number_of_attributes_provided != 1:
+                attributes_not_provided.append(
+                    f"exactly one of {' or '.join(attribute_tuple)}"
+                )
+
+    return attributes_not_provided
+
+
+def validate_class_attributes(
+    expected_attributes: Optional[List[str]] = None,
+    both_or_either_attributes: Optional[List[Iterable[str]]] = None,
+    mutually_exclusive_attributes: Optional[List[Iterable[str]]] = None,
+):
+    """
+    A decorator responsible for raising errors if the expected arguments weren't
+    provided during class instantiation.
+    """
+
+    def decorator(attribute):
+        def wrapper(self, *args, **kwargs):
+            arguments_not_provided = get_attributes_not_provided(
+                self,
+                expected_attributes,
+                both_or_either_attributes,
+                mutually_exclusive_attributes,
+            )
+
+            if len(arguments_not_provided) > 0:
+                raise ValueError(
+                    f"{attribute.__name__} expected the following "
+                    f"additional arguments: {', '.join(arguments_not_provided)}"
+                )
+
+            return attribute(self, *args, **kwargs)
+
+        return wrapper
+
+    return decorator


Test patch:
diff --git a/plasmapy/formulary/tests/test_collisions.py b/plasmapy/formulary/tests/test_collisions.py
--- a/plasmapy/formulary/tests/test_collisions.py
+++ b/plasmapy/formulary/tests/test_collisions.py
@@ -10,20 +10,21 @@
 from plasmapy.formulary.braginskii import Coulomb_logarithm
 from plasmapy.formulary.collisions import (
     collision_frequency,
-    CollisionFrequencies,
     coupling_parameter,
     fundamental_electron_collision_freq,
     fundamental_ion_collision_freq,
     impact_parameter,
     impact_parameter_perp,
     Knudsen_number,
+    MaxwellianCollisionFrequencies,
     mean_free_path,
     mobility,
+    SingleParticleCollisionFrequencies,
     Spitzer_resistivity,
 )
 from plasmapy.particles import Particle
 from plasmapy.utils import exceptions
-from plasmapy.utils.exceptions import CouplingWarning
+from plasmapy.utils.exceptions import CouplingWarning, PhysicsError
 from plasmapy.utils.pytest_helpers import assert_can_handle_nparray
 
 
@@ -1704,13 +1705,13 @@ def test_kwarg_method_error(self):
             coupling_parameter(self.T, self.n_e, self.particles, method="not a method")
 
 
-class TestCollisionFrequencies:
-    """Test the CollisionFrequencies class in collisions.py."""
+class TestSingleParticleCollisionFrequencies:
+    """Test the SingleParticleCollisionFrequencies class in collisions.py."""
 
-    attribute_units_test_case = CollisionFrequencies(
+    attribute_units_test_case = SingleParticleCollisionFrequencies(
         Particle("e-"),
         Particle("e-"),
-        v_a=1 * u.m / u.s,
+        v_drift=1 * u.m / u.s,
         T_b=1 * u.K,
         n_b=1 * u.m**-3,
         Coulomb_log=1,
@@ -1719,13 +1720,13 @@ class TestCollisionFrequencies:
     MKS_unit_conversion_test_constructor_arguments = {
         "test_particle": Particle("e-"),
         "field_particle": Particle("e-"),
-        "v_a": 1e5 * u.m / u.s,
+        "v_drift": 1e5 * u.m / u.s,
         "T_b": 1e3 * u.eV,
         "n_b": 1e26 * u.m**-3,
         "Coulomb_log": 10 * u.dimensionless_unscaled,
     }
 
-    arguments_to_convert = ["v_a", "n_b"]
+    arguments_to_convert = ["v_drift", "n_b"]
 
     CGS_unit_conversion_test_constructor_arguments = (
         MKS_unit_conversion_test_constructor_arguments
@@ -1736,10 +1737,10 @@ class TestCollisionFrequencies:
             argument_to_convert
         ] = CGS_unit_conversion_test_constructor_arguments[argument_to_convert].cgs
 
-    MKS_test_case = CollisionFrequencies(
+    MKS_test_case = SingleParticleCollisionFrequencies(
         **MKS_unit_conversion_test_constructor_arguments
     )
-    CGS_test_case = CollisionFrequencies(
+    CGS_test_case = SingleParticleCollisionFrequencies(
         **CGS_unit_conversion_test_constructor_arguments
     )
 
@@ -1799,7 +1800,7 @@ def get_limit_value(interaction_type, limit_type, cases):
         These formulae are taken from page 31 of the NRL Formulary.
         """
 
-        v_a = (cases.T_a * k_B).to(u.eV).value
+        v_a = (0.5 * cases.test_particle.mass * cases.v_drift**2).to(u.eV).value
         T_b = (cases.T_b * k_B).to(u.eV).value
 
         limit_values = []
@@ -1902,7 +1903,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "slow",
                 (Particle("e-"), Particle("e-")),
                 {
-                    "T_a": 1 * u.eV,
+                    "v_drift": 1 * u.cm / u.s,
                     "T_b": 1e4 * u.eV,
                     "n_b": 1e15 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -1913,7 +1914,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "slow",
                 (Particle("e-"), Particle("Na+")),
                 {
-                    "T_a": 1e-4 * u.eV,
+                    "v_drift": 1 * u.cm / u.s,
                     "T_b": 1e4 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -1924,7 +1925,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "slow",
                 (Particle("e-"), Particle("Ba 2+")),
                 {
-                    "T_a": 1e-4 * u.eV,
+                    "v_drift": 1 * u.cm / u.s,
                     "T_b": 1e4 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -1935,7 +1936,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "slow",
                 (Particle("Na+"), Particle("e-")),
                 {
-                    "T_a": 1 * u.eV,
+                    "v_drift": 1 * u.cm / u.s,
                     "T_b": 1e2 * u.eV,
                     "n_b": 1e10 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -1946,7 +1947,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "slow",
                 (Particle("Be 2+"), Particle("e-")),
                 {
-                    "T_a": 1 * u.eV,
+                    "v_drift": 1 * u.cm / u.s,
                     "T_b": 1e2 * u.eV,
                     "n_b": 1e10 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -1957,7 +1958,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "slow",
                 (Particle("Na+"), Particle("Cl-")),
                 {
-                    "T_a": 1e2 * u.eV,
+                    "v_drift": 1 * u.cm / u.s,
                     "T_b": 1e4 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -1968,7 +1969,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "slow",
                 (Particle("Na+"), Particle("S 2-")),
                 {
-                    "T_a": 1e2 * u.eV,
+                    "v_drift": 1 * u.cm / u.s,
                     "T_b": 1e4 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -1980,7 +1981,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "fast",
                 (Particle("e-"), Particle("e-")),
                 {
-                    "v_a": 6e8 * u.cm / u.s,
+                    "v_drift": 6e8 * u.cm / u.s,
                     "T_b": 1e-1 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -1991,7 +1992,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "fast",
                 (Particle("e-"), Particle("Na+")),
                 {
-                    "v_a": 6e5 * u.cm / u.s,
+                    "v_drift": 6e5 * u.cm / u.s,
                     "T_b": 1e-3 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -2002,7 +2003,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "fast",
                 (Particle("e-"), Particle("Zn 2+")),
                 {
-                    "v_a": 6e5 * u.cm / u.s,
+                    "v_drift": 6e5 * u.cm / u.s,
                     "T_b": 1e-3 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -2013,7 +2014,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "fast",
                 (Particle("Na+"), Particle("e-")),
                 {
-                    "v_a": 3e7 * u.cm / u.s,
+                    "v_drift": 3e7 * u.cm / u.s,
                     "T_b": 1e-3 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -2024,7 +2025,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "fast",
                 (Particle("Ca 2+"), Particle("e-")),
                 {
-                    "v_a": 3e7 * u.cm / u.s,
+                    "v_drift": 3e7 * u.cm / u.s,
                     "T_b": 1e-3 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -2035,7 +2036,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "fast",
                 (Particle("Na+"), Particle("Cl-")),
                 {
-                    "v_a": 3e7 * u.cm / u.s,
+                    "v_drift": 3e7 * u.cm / u.s,
                     "T_b": 1e2 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -2046,7 +2047,7 @@ def get_limit_value(interaction_type, limit_type, cases):
                 "fast",
                 (Particle("Be 2+"), Particle("Cl-")),
                 {
-                    "v_a": 3e7 * u.cm / u.s,
+                    "v_drift": 3e7 * u.cm / u.s,
                     "T_b": 1e2 * u.eV,
                     "n_b": 1e20 * u.cm**-3,
                     "Coulomb_log": 10 * u.dimensionless_unscaled,
@@ -2063,7 +2064,7 @@ def test_limit_values(
     ):
         """Test the return values"""
 
-        value_test_case = CollisionFrequencies(
+        value_test_case = SingleParticleCollisionFrequencies(
             *constructor_arguments, **constructor_keyword_arguments
         )
 
@@ -2105,34 +2106,12 @@ def test_limit_values(
     @pytest.mark.parametrize(
         "expected_error, constructor_arguments, constructor_keyword_arguments",
         [
-            # Both T_a and v_a specified error
-            (
-                ValueError,
-                (Particle("e-"), Particle("e-")),
-                {
-                    "v_a": 1 * u.cm / u.s,
-                    "T_a": 1 * u.eV,
-                    "T_b": 1 * u.eV,
-                    "n_b": 1 * u.cm**-3,
-                    "Coulomb_log": 1 * u.dimensionless_unscaled,
-                },
-            ),
-            # Neither T_a nor v_a specified error
-            (
-                ValueError,
-                (Particle("e-"), Particle("e-")),
-                {
-                    "T_b": 1 * u.eV,
-                    "n_b": 1 * u.cm**-3,
-                    "Coulomb_log": 1 * u.dimensionless_unscaled,
-                },
-            ),
             # Arrays of unequal shape error
             (
                 ValueError,
                 (Particle("e-"), Particle("e-")),
                 {
-                    "v_a": np.ndarray([1, 1]) * u.cm / u.s,
+                    "v_drift": np.ndarray([1, 1]) * u.cm / u.s,
                     "T_b": 1 * u.eV,
                     "n_b": ones_array * u.cm**-3,
                     "Coulomb_log": 1 * u.dimensionless_unscaled,
@@ -2140,13 +2119,13 @@ def test_limit_values(
             ),
         ],
     )
-    def test_errors(
+    def test_init_errors(
         self, expected_error, constructor_arguments, constructor_keyword_arguments
     ):
-        """Test errors raised in the function body"""
+        """Test errors raised in the __init__ function body"""
 
         with pytest.raises(expected_error):
-            CollisionFrequencies(
+            SingleParticleCollisionFrequencies(
                 *constructor_arguments, **constructor_keyword_arguments
             )
 
@@ -2156,7 +2135,7 @@ def test_errors(
             {
                 "test_particle": Particle("e-"),
                 "field_particle": Particle("e-"),
-                "T_a": ones_array * u.eV,
+                "v_drift": ones_array * u.cm / u.s,
                 "T_b": ones_array * u.eV,
                 "n_b": ones_array * u.cm**-3,
                 "Coulomb_log": ones_array * u.dimensionless_unscaled,
@@ -2164,7 +2143,7 @@ def test_errors(
             {
                 "test_particle": Particle("e-"),
                 "field_particle": Particle("e-"),
-                "T_a": ones_array2d * u.eV,
+                "v_drift": ones_array2d * u.m / u.s,
                 "T_b": ones_array2d * u.eV,
                 "n_b": ones_array2d * u.cm**-3,
                 "Coulomb_log": ones_array2d * u.dimensionless_unscaled,
@@ -2174,4 +2153,180 @@ def test_errors(
     def test_handle_ndarrays(self, constructor_keyword_arguments):
         """Test for ability to handle numpy array quantities"""
 
-        CollisionFrequencies(**constructor_keyword_arguments)
+        SingleParticleCollisionFrequencies(**constructor_keyword_arguments)
+
+
+class TestMaxwellianCollisionFrequencies:
+    ones_array = np.ones(5)
+
+    @staticmethod
+    def get_fundamental_frequency(species, n, T_a, Coulomb_log):
+        """
+        This special case for computing the fundamental frequencies comes from page 33 of the NRL Formulary.
+        The formulary provides limiting cases for the `Maxwellian_avg_##_collision_freq` family of attributes
+        in the case that T_a ~ T_b.
+        """
+
+        # Strip the units from these quantities and ensure they are in CGS units
+        n = n.to(u.cm**-3).value
+        T_a = T_a.to(u.eV).value
+
+        if species.is_electron:
+            return (2.9e-6 * n * Coulomb_log * T_a**-1.5) * u.Hz
+        elif species.is_ion:
+            mu = (species.mass / m_p).value
+
+            return (4.8e-8 * n * Coulomb_log * T_a**-1.5 * mu**-0.5) * u.Hz
+
+    @pytest.mark.parametrize(
+        "expected_error, constructor_arguments, constructor_keyword_arguments",
+        [
+            # Arrays of unequal shape error
+            (
+                ValueError,
+                (Particle("e-"), Particle("e-")),
+                {
+                    "v_drift": np.array([1, 1]) * u.m / u.s,
+                    "T_a": 1 * u.K,
+                    "n_a": 1 * u.m**-3,
+                    "T_b": 1 * u.K,
+                    "n_b": ones_array * u.m**-3,
+                    "Coulomb_log": 1 * u.dimensionless_unscaled,
+                },
+            ),
+        ],
+    )
+    def test_init_errors(
+        self, expected_error, constructor_arguments, constructor_keyword_arguments
+    ):
+        """Test errors raised in the __init__ function body"""
+
+        with pytest.raises(expected_error):
+            MaxwellianCollisionFrequencies(
+                *constructor_arguments, **constructor_keyword_arguments
+            )
+
+    @pytest.mark.parametrize(
+        "expected_error, constructor_arguments, constructor_keyword_arguments, attribute_name",
+        [
+            # Specified interaction isn't electron-ion
+            (
+                ValueError,
+                ("e-", "e-"),
+                {
+                    "v_drift": 1e-5 * u.cm / u.s,
+                    "T_a": 1e3 * u.eV,
+                    "n_a": 1e10 * u.cm**-3,
+                    "T_b": 1e3 * u.eV,
+                    "n_b": 1e10 * u.cm**-3,
+                    "Coulomb_log": 10 * u.dimensionless_unscaled,
+                },
+                "Maxwellian_avg_ei_collision_freq",
+            ),
+            # Specified interaction isn't ion-ion
+            (
+                ValueError,
+                ("Na+", "e-"),
+                {
+                    "v_drift": 1e-5 * u.cm / u.s,
+                    "T_a": 1e3 * u.eV,
+                    "n_a": 1e10 * u.cm**-3,
+                    "T_b": 1e3 * u.eV,
+                    "n_b": 1e10 * u.cm**-3,
+                    "Coulomb_log": 10 * u.dimensionless_unscaled,
+                },
+                "Maxwellian_avg_ii_collision_freq",
+            ),
+            # Populations are not slowly flowing error
+            (
+                PhysicsError,
+                ("e-", "Na+"),
+                {
+                    "v_drift": 1e10 * u.cm / u.s,
+                    "T_a": 1e3 * u.eV,
+                    "n_a": 1e10 * u.cm**-3,
+                    "T_b": 1e3 * u.eV,
+                    "n_b": 1e10 * u.cm**-3,
+                    "Coulomb_log": 10 * u.dimensionless_unscaled,
+                },
+                "Maxwellian_avg_ei_collision_freq",
+            ),
+            # Populations are not slowly flowing error
+            (
+                PhysicsError,
+                ("Na+", "Na+"),
+                {
+                    "v_drift": 1e10 * u.cm / u.s,
+                    "T_a": 1e3 * u.eV,
+                    "n_a": 1e10 * u.cm**-3,
+                    "T_b": 1e3 * u.eV,
+                    "n_b": 1e10 * u.cm**-3,
+                    "Coulomb_log": 10 * u.dimensionless_unscaled,
+                },
+                "Maxwellian_avg_ii_collision_freq",
+            ),
+        ],
+    )
+    def test_attribute_errors(
+        self,
+        expected_error,
+        constructor_arguments,
+        constructor_keyword_arguments,
+        attribute_name,
+    ):
+        """Test errors raised in attribute bodies"""
+
+        test_case = MaxwellianCollisionFrequencies(
+            *constructor_arguments, **constructor_keyword_arguments
+        )
+
+        with pytest.raises(expected_error):
+            getattr(test_case, attribute_name)
+
+    @pytest.mark.parametrize(
+        "frequency_to_test, constructor_keyword_arguments",
+        [
+            (
+                "Maxwellian_avg_ei_collision_freq",
+                {
+                    "test_particle": Particle("e-"),
+                    "field_particle": Particle("Li+"),
+                    "v_drift": 1e-5 * u.cm / u.s,
+                    "T_a": 1e3 * u.eV,
+                    "n_a": 1e10 * u.cm**-3,
+                    "T_b": 1e3 * u.eV,
+                    "n_b": 1e10 * u.cm**-3,
+                    "Coulomb_log": 10 * u.dimensionless_unscaled,
+                },
+            ),
+            (
+                "Maxwellian_avg_ii_collision_freq",
+                {
+                    "test_particle": Particle("Li+"),
+                    "field_particle": Particle("Cl-"),
+                    "v_drift": 1 * u.cm / u.s,
+                    "T_a": 1e3 * u.eV,
+                    "n_a": 1e10 * u.cm**-3,
+                    "T_b": 1 * u.eV,
+                    "n_b": 1e10 * u.cm**-3,
+                    "Coulomb_log": 10 * u.dimensionless_unscaled,
+                },
+            ),
+        ],
+    )
+    def test_fundamental_frequency_values(
+        self, frequency_to_test, constructor_keyword_arguments
+    ):
+        value_test_case = MaxwellianCollisionFrequencies(
+            **constructor_keyword_arguments
+        )
+
+        calculated_value = getattr(value_test_case, frequency_to_test)
+        expected_value = self.get_fundamental_frequency(
+            constructor_keyword_arguments["test_particle"],
+            constructor_keyword_arguments["n_a"],
+            constructor_keyword_arguments["T_a"],
+            constructor_keyword_arguments["Coulomb_log"],
+        )
+
+        assert np.allclose(calculated_value, expected_value, rtol=5e-3, atol=0)
diff --git a/plasmapy/utils/decorators/tests/test_validators.py b/plasmapy/utils/decorators/tests/test_validators.py
--- a/plasmapy/utils/decorators/tests/test_validators.py
+++ b/plasmapy/utils/decorators/tests/test_validators.py
@@ -6,11 +6,16 @@
 import pytest
 
 from astropy import units as u
+from functools import cached_property
 from typing import Any, Dict, List
 from unittest import mock
 
 from plasmapy.utils.decorators.checks import CheckUnits, CheckValues
-from plasmapy.utils.decorators.validators import validate_quantities, ValidateQuantities
+from plasmapy.utils.decorators.validators import (
+    validate_class_attributes,
+    validate_quantities,
+    ValidateQuantities,
+)
 
 
 # ----------------------------------------------------------------------------------------
@@ -555,3 +560,76 @@ def test_decorator_func_def(self, mock_vq_class):
                 # reset
                 mock_vq_class.reset_mock()
                 mock_foo.reset_mock()
+
+
+class TestValidateClassAttributes:
+    class SampleCase:
+        def __init__(self, x: int = None, y: int = None, z: int = None):
+            self.x = x
+            self.y = y
+            self.z = z
+
+        @cached_property
+        @validate_class_attributes(expected_attributes=["x"])
+        def require_x(self):
+            return 0
+
+        @cached_property
+        @validate_class_attributes(expected_attributes=["x", "y"])
+        def require_x_and_y(self):
+            return 0
+
+        @cached_property
+        @validate_class_attributes(both_or_either_attributes=[("x", "y")])
+        def require_x_or_y(self):
+            return 0
+
+        @cached_property
+        @validate_class_attributes(
+            expected_attributes=["x"], both_or_either_attributes=[("y", "z")]
+        )
+        def require_x_and_either_y_or_z(self):
+            return 0
+
+        @cached_property
+        @validate_class_attributes(mutually_exclusive_attributes=[("x", "y")])
+        def require_only_either_x_or_y(self):
+            return 0
+
+    @pytest.mark.parametrize(
+        "test_case_constructor_keyword_arguments",
+        [
+            {"x": 0},
+            {"y": 0},
+            {"z": 0},
+            {"x": 0, "y": 0},
+            {"x": 0, "z": 0},
+            {"y": 0, "z": 0},
+        ],
+    )
+    def test_method_errors(self, test_case_constructor_keyword_arguments):
+        """
+        Test errors raised by the validate_class_attributes decorator.
+        """
+
+        test_case = self.SampleCase(**test_case_constructor_keyword_arguments)
+
+        has_x = "x" in test_case_constructor_keyword_arguments.keys()
+        has_y = "y" in test_case_constructor_keyword_arguments.keys()
+        has_z = "z" in test_case_constructor_keyword_arguments.keys()
+
+        method_return_dictionary = {
+            "require_x": has_x,
+            "require_x_and_y": has_x and has_y,
+            "require_x_or_y": has_x or has_y,
+            "require_x_and_either_y_or_z": has_x and (has_y or has_z),
+            "require_only_either_x_or_y": (has_x and not has_y)
+            or (has_y and not has_x),
+        }
+
+        for method, expected_to_return in method_return_dictionary.items():
+            if expected_to_return:
+                getattr(test_case, method)
+            else:
+                with pytest.raises(ValueError):
+                    getattr(test_case, method)