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:
JaydenR2305: Also, should the fundamental frequencies be calculated using the NRL equivalent or using the current method?
codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1703?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) Report
> Merging [#1703](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1703?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) (42414d0) into [main](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/6bf914737c71992ca55ccfb9f1fa2157ca847197?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) (6bf9147) will **decrease** coverage by `0.03%`.
> The diff coverage is `70.00%`.

```diff
@@            Coverage Diff             @@
##             main    #1703      +/-   ##
==========================================
- Coverage   97.66%   97.63%   -0.04%     
==========================================
  Files          85       85              
  Lines        8183     8191       +8     
==========================================
+ Hits         7992     7997       +5     
- Misses        191      194       +3     
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1703?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy) | Coverage Δ | |
|---|---|---|
| [plasmapy/formulary/collisions.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1703/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PlasmaPy#diff-cGxhc21hcHkvZm9ybXVsYXJ5L2NvbGxpc2lvbnMucHk=) | `98.47% <70.00%> (-1.13%)` | :arrow_down: |

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)

JaydenR2305: There seems to have been some discrepancy between the documentation for v_i and how it was actually calculated. The old implementation seems to have sqrt(8 / pi) whereas the documentation would suggest 8 * sqrt(1/pi).

I believe I recall reading somewhere that the formulary has some separate definition for v_i ? Which would explain why the value test I wrote for it is now failing
JaydenR2305: > There seems to have been some discrepancy between the documentation for v_i and how it was actually calculated. The old implementation seems to have sqrt(8 / pi) whereas the documentation would suggest 8 * sqrt(1/pi).
> 
> 
> 
> I believe I recall reading somewhere that the NRL formulary has some separate definition for v_i ? Which would explain why the value test I wrote for it is now failing

@pheuer any clue what's going on here?
pheuer: From Braginskii (expressions in CGS units, note tau = 1/freq): 

![image](https://user-images.githubusercontent.com/32618747/187249659-e46a60be-453a-4ea7-b4ac-819158b9763e.png)

From Callan (expressions in MKS units)
![image](https://user-images.githubusercontent.com/32618747/187249891-0400e173-ce6a-4339-88ef-b6cbd22257ab.png)
^ This expression agrees with the one in Braginskii 
![image](https://user-images.githubusercontent.com/32618747/187250067-a37fd745-bf15-4973-ad71-e08f1c6a4643.png)
^ v0 appears to be the Lorentz collision frequency (see Eq. 2.86 in Callan, and the following pages which closely correspond to the collisions sections of the NRL formulary). So, I would expect this frequency to be 4/(3 sqrt(pi) * the Lorentz frequency. 
![image](https://user-images.githubusercontent.com/32618747/187250182-808c534f-d5b4-448f-9409-b4fd9007805d.png)
^ This is also consistent with Braginskii (once you account for the 4piepsilon0 factor from CGS->MKS)

The expressions in the NRL formulary are
![image](https://user-images.githubusercontent.com/32618747/187251610-ba9935ef-0ad7-40d3-a3fb-5bb769b9b997.png)

The one for nu_e is identical to Callan when you take into account that Callan is MKS units (so n is in m^-3 rather than cm^-3 in NRL formulary, so you get a factor of 1E6)

I agree that the NRL formulary for nu_i seems wrong...I think the constant should be 6.8E-8, not 4.8E-8? Callan says that it should be nu_e / sqrt(m_p/m_e) = nu_e / sqrt(1836) so I think this formula in the NRL formulary may be incorrect? Try the test with 6.8e-8. 
pheuer: More generally, I object to calling this the 'fundamental collision frequency'. This is a very specific case for Maxwellian populations with Ti <~ Te. That may be a common case in fusion plasmas,  but it's by no means universally 'fundamental'. Users may be misguided into using this collision frequency in other contexts. I suggest: 

1) Renaming these "maxwellian_avg_ei_collision_freq" and "maxwellian_avg_ii_collision_freq"
2) Making the assumptions much more clear in the docstrings. Namely, 
a) Both populations are Maxwellian
b) Ti <~ Te
c) Any others I'm missing?

@namurphy what's your opinion. 
JaydenR2305: 
> I agree that the NRL formulary for nu_i seems wrong...I think the constant should be 6.8E-8, not 4.8E-8? Callan says that it should be nu_e / sqrt(m_p/m_e) = nu_e / sqrt(1836) so I think this formula in the NRL formulary may be incorrect? Try the test with 6.8e-8.
>

Oh nice, this makes the tests pass. I seem to have bad luck in programming these special cases from the formulary

> More generally, I object to calling this the 'fundamental collision frequency'. This is a very specific case for Maxwellian populations with Ti <~ Te. That may be a common case in fusion plasmas, but it's by no means universally 'fundamental'. Users may be misguided into using this collision frequency in other contexts. I suggest:
> 

Yeah that makes sense. The current documentation also specifies that the species are "slowly flowing"
JaydenR2305: > So maybe you were just missing a factor of sqrt(2)?
That seems to get it working as well 👍 

pheuer: > That seems to get it working as well 👍

That's because 6.8/1.4 = 4.8 ;) 
JaydenR2305: > > That seems to get it working as well 👍
> 
> That's because 6.8/sqrt(2) = 4.8 ;)

🤯
JaydenR2305: Why doesn't it like my documentation 
pheuer: @StanczakDominik Any what's up with this documentation error? 

```
/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
```

@JaydenR2305 try merging main to make sure it isn't a bug that's been fixed already.
JaydenR2305: @pheuer I think it has more to do with
`/home/runner/work/PlasmaPy/PlasmaPy/docs/whatsnew/dev.rst:3: CRITICAL: render_fragments() got multiple values for argument 'top_underline'`
I tried removing dev.rst from the index.rst in whatsnew but it still fails
pheuer: >The issue started popping up ~20 hours ago, which correlates with a new towncrier release. Coincidence?

Nice detective work! If that's the case, try changing the line in requirements/docs.txt 

```
towncrier >= 19.2.0
```
to 
```
towncrier == 21.9.0
```
Since it looks like 22.8.0 is the new version that dropped last night.

Or (not sure if this works...)
```
towncrier >= 19.2.0, <= 21.9.0
```

pheuer: Here's a more general comment repeating the review comment before (about v_a). In summary, I think we should replace "v_a" in the arguments with "v_drift" and then make the changes below 

If the test particles have no drift

v_a = v_T_a

If the thermal motion is small compared to the drift motion (e.g. v_T_a << v_drift) for example in the >fast limit of the original four frequencies we have

v_a = v_drift

But in the case where there is both thermal motion AND a drift velocity, we'd have to be careful. Some particles (in the drifting frame) are moving with the drift velocity, and some against it. I think this is (or is related to) the Maxwellian_avg frequencies. But, for the four main frequencies I'm not sure how to handle this so we should just raise an error if both v_drift and T_a are set.


Breaking it down case by case:
Below, v_T_a is always calculated from T_a

- momentum_loss, transverse_diffusion, parallel_diffusion, energy_loss, x, Lorentz collision frequency: 
If v_drift is set, then v_a = v_drift. Otherwise v_a = v_T_a. I would just raise an error for now if both are specified for this attribute? 

- mean_thermal_velocity: This always has to be v_T_a and v_T_b (since it's the thermal velocities specifically) so raise an error if T_a is not provided. 

- _is_slowly_flowing : This should be v_drift / _mean_thermal_velocity, since we want to measure the 'flow' relative to the thermal motion. 

- Maxwellian_avg_**_collision_freq: No changes other than those in the functions these call.


JaydenR2305: - [ ] Add new decorator to changelog
- [ ] Distinguish between v_drift, v_a, v_T_a, and where it's acceptable to convert the two former to the latter
- [ ] Finish up attribute error testing
JaydenR2305: >momentum_loss, transverse_diffusion, parallel_diffusion, energy_loss, x, Lorentz collision frequency:
>If v_drift is set, then v_a = v_drift. Otherwise v_a = v_T_a. I would just raise an error for now if both are specified for this attribute?
>

The issue with this is that the Maxwellian_avg functions use the Lorentz collision frequency method. Maybe we'll make an exception for that method be sure to enforce the requirement of one of v_drift or T_a for x and the other relaxation rate attributes?
JaydenR2305: I should also probably provide better documentation for `validate_class_attributes`
pheuer: Ok, so in the Lorentz collision frequency I think `v` is actually either v_drift of v_T_b. The whole derivation is specifically for a single test particle a, so it doesn't make sense to talk about a 'T_a' there. If v_drift is very small, then the relative velocity that dominates is v_T_b and that is the slow limit. 

The Maxwellian rates are derived explicitly assuming a population of species a and a population of species b, both with thermal velocities and maybe a relative drift v_drift (as long as its not too fast). In this version of the Lorentz collision frequency, it looks like v -> v_ss' (see Callen 2.118). That's just fundamentally different than what we had before. 

I wonder if the problem here is that we're actually putting two things together that shouldn't be. Maybe we need two classes, 
"SingleParticleCollisionFrequencies" and "MaxwellianCollisionFrequencies"?  Then those would have arguments:  

"SingleParticleCollisionFrequencies" 
v_a (the test particle velocity)
T_b
...


"MaxwellianCollisionFrequencies"
T_a
T_b
v_drift 
... 

Does that make sense? Sorry for the confusion - I've clearly never delved this deeply into the collision frequencies before!




JaydenR2305: >I wonder if the problem here is that we're actually putting two things together that shouldn't be. Maybe we need two classes,
>"SingleParticleCollisionFrequencies" and "MaxwellianCollisionFrequencies"?
>

Yeah this is probably the way to go
JaydenR2305: Actually, I don't think we need a new class. We just need to be more careful about how we calculate the value of v_a
JaydenR2305: Gotcha, it'll probably simplify things a bit too since we don't have all the confusing attribute checking. I'll keep the decorator there though just in case someone needs it later 👍 (it also seems to improve coverage quite a bit??)
JaydenR2305: looks like it's that time of day when `chemical_potential` wants to randomly break
JaydenR2305: @pheuer Is it fine if I just convert references of `CollisionFrequencies` to just the plain text reference in changelogs?
pheuer: > @pheuer Is it fine if I just convert references of `CollisionFrequencies` to just the plain text reference in changelogs?

You mean since the class no longer exists? Yeah I think thats ok in this case.
github-actions[bot]: [changelog guide]: https://docs.plasmapy.org/en/latest/contributing/changelog_guide.html
[coding guide]: https://docs.plasmapy.org/en/latest/contributing/coding_guide.html
[doc guide]: https://docs.plasmapy.org/en/latest/contributing/doc_guide.html
[testing guide]: https://docs.plasmapy.org/en/latest/contributing/testing_guide.html

Thank you for contributing to PlasmaPy! The project's future depends deeply on contributors like you, so we deeply appreciate it! 🌱 The following checklist will be used by the code reviewer to help guide the code review process.

- Overall
  - [ ] Does the PR do what it intends to do?
  - [ ] Except for very minor changes, is a changelog entry included and consistent with the [changelog guide]?
  - [ ] Are the continuous integration checks passing? (Most linter problems can be automagically fixed by commenting on this PR with `pre-commit.ci autofix`.)
- Code
  - [ ] Is new/updated code understandable and consistent with the [coding guide]?
  - [ ] Are there ways to greatly simplify the implementation?
  - [ ] Are there any large functions that should be split up into shorter functions?
- Tests
  - [ ] Are tests added/updated as required, and consistent with the [testing guide]?
  - [ ] Are the tests understandable?
  - [ ] Do the tests cover all important cases?
- Docs
  - [ ] Are docs added/updated as required, and consistent with the [doc guide]?
  - [ ] Are the docs understandable?
  - [ ] Do the docs show up correctly in the preview, including Jupyter notebooks?

pheuer: @JaydenR2305 I see most of the tests are passing now (except for the changelog, since you're modifying that old one). Let me know whenever you want me to do another review.
JaydenR2305: Ok. I think I'm ready at this point for another review
pheuer: > Ok. I think I'm ready for another review at this point

Sounds good - I'll try to take another pass in the next couple days :)
StanczakDominik: @pheuer if you can sign off on the theory, I can review this from the pure python/design side if you'd like :)
pheuer: @StanczakDominik It would be great to have your review! I think the theory should be right now.
StanczakDominik: All right, then I'll take over :)
pheuer: @StanczakDominik Do you still want to give this a brief look-over before we merge it?
StanczakDominik: Yes, sorry, I wanted to get it done over the weekend and didn't predict I'd be feeling off throughout it :( I'll sit down to it now.
pheuer: > Yes, sorry, I wanted to get it done over the weekend and didn't predict I'd be feeling off throughout it :( I'll sit down to it now.

No problem - take your time and hope you feel better!
pheuer: @PlasmaPy/peer-reviewers Git help please! I pushed a few minor changes to @JaydenR2305's fork prior to merging this, but now I'm getting a readthedocs error. It looks like it can't check out the latest commit, although I can see it just fine on the fork? Is there something special you need to do when pushing to someone elses's branch?
StanczakDominik: Oh wow, that one's new...

https://readthedocs.org/projects/plasmapy/builds/18143144/ :
```
fatal: reference is not a tree: 2c08848c7a29239036703eb67ffee0bbbc47a967
```
StanczakDominik: @pheuer out of curiosity, how did you do this part?

> I pushed a few minor changes to @JaydenR2305's fork prior to merging this
pheuer: > @pheuer out of curiosity, how did you do this part?
> 
> > I pushed a few minor changes to @JaydenR2305's fork prior to merging this

I just checked out the fork (by selecting this PR in GitHub Desktop) and then pushed to that. 


StanczakDominik: Okay, sorry, I'm going to have to revert this to [8af3866](https://github.com/PlasmaPy/PlasmaPy/pull/1703/commits/8af38662aca22e4e0af42f5fe010f40ae24be068), I have no idea what's going on here.
StanczakDominik: This was doubly weird because I couldn't see anything past the merge commit with `gh pr checkout -f 1703`, but when I cloned Jayden's repo separately, I could see those three commits. I have no idea what happened there.
StanczakDominik: @JaydenR2305 if you pulled Peter's changes while they were alive on those repo, I mean these three:
```
commit 8190d36ae2b10ef30e81259565066b09306f65df (HEAD -> fundamental-collision-frequencies, origin/fundamental-collision-frequencies)
Author: Peter Heuer <...>
Date:   Thu Sep 22 10:32:40 2022 -0400

    Minor changelog updates

commit 2c08848c7a29239036703eb67ffee0bbbc47a967
Author: Peter Heuer <...>
Date:   Thu Sep 22 10:10:56 2022 -0400

    Update Particle -> ParticleLike

commit 1ae35457e5a5cb36be5f70195146aaa732ce3bda
Merge: 8af38662 8ec56daf
Author: Peter Heuer <...>
Date:   Thu Sep 22 10:07:25 2022 -0400

    Merge branch 'main' into pr/1703
```

Then please do `git reset 8af38662`.

Peter, kudos, I think you've just pounded git into submission. How? No idea, but, kudos.
pheuer: > Okay, sorry, I'm going to have to revert this to [8af3866](https://github.com/PlasmaPy/PlasmaPy/pull/1703/commits/8af38662aca22e4e0af42f5fe010f40ae24be068), I have no idea what's going on here.

No problem - I'm sure I was just using git wrong! 

How do you usually go about pushing to someone else's PR branch like this?
StanczakDominik: I usually use https://cli.github.com/, then it's just a matter of checking out the branch with `gh pr checkout 1703`, commiting whatever's needed, then `git push` does the rest - nothing else necessary.

I think the merge commit you had in there was *somehow* the culprit, but how... no clue! :(

Patch:
diff --git a/changelog/1676.feature.rst b/changelog/1676.feature.rst
index a16f8b85..728360d0 100644
--- a/changelog/1676.feature.rst
+++ b/changelog/1676.feature.rst
@@ -1,3 +1,3 @@
-Added the `~plasmapy.formulary.collisions.CollisionFrequencies` class. This class can be used to calculate collision frequencies for two interacting plasmas.
+Added the ``plasmapy.formulary.collisions.CollisionFrequencies`` class. This class can be used to calculate collision frequencies for two interacting plasmas.
 
-Deprecated the `~plasmapy.formulary.collisions.collision_frequency` in favor of the ``Lorentz_collision_frequency`` attribute in the `~plasmapy.formulary.collisions.CollisionFrequencies` class.
+Deprecated the `~plasmapy.formulary.collisions.collision_frequency` in favor of the ``Lorentz_collision_frequency`` attribute in the ``plasmapy.formulary.collisions.CollisionFrequencies`` class.
diff --git a/changelog/1703.breaking.1.rst b/changelog/1703.breaking.1.rst
new file mode 100644
index 00000000..d993cdcf
--- /dev/null
+++ b/changelog/1703.breaking.1.rst
@@ -0,0 +1,7 @@
+Added `~plasmapy.formulary.collisions.MaxwellianCollisionFrequencies` for calculating relevant collision frequencies for Maxwellian populations.
+
+Deprecated ``plasmapy.formulary.collisions.fundamental_electron_collision_freq`` and replaced it
+with the ``Maxwellian_avg_ei_collision_freq`` attribute in `~plasmapy.formulary.collisions.MaxwellianCollisionFrequencies`.
+
+Deprecated ``plasmapy.formulary.collisions.fundamental_ion_collision_freq`` and replaced it
+with the ``Maxwellian_avg_ii_collision_freq`` attribute in `~plasmapy.formulary.collisions.MaxwellianCollisionFrequencies`.
diff --git a/changelog/1703.trivial.2.rst b/changelog/1703.trivial.2.rst
new file mode 100644
index 00000000..6cf10fb0
--- /dev/null
+++ b/changelog/1703.trivial.2.rst
@@ -0,0 +1,2 @@
+Added the `~plasmapy.utils.decorators.validators.validate_class_attributes` decorator to the `~plasmapy.utils.decorators` module.
+This decorator is useful for class methods that require optional parameters to be specified during class instantiation.
diff --git a/changelog/1705.trivial.rst b/changelog/1705.trivial.rst
index 7014549f..e9614665 100644
--- a/changelog/1705.trivial.rst
+++ b/changelog/1705.trivial.rst
@@ -1,1 +1,1 @@
-Minor improvements to `~plasmapy.formulary.collisions.CollisionFrequencies`
+Minor improvements to ``plasmapy.formulary.collisions.CollisionFrequencies``
diff --git a/plasmapy/formulary/collisions.py b/plasmapy/formulary/collisions.py
index 3687da6f..5fbbb42b 100644
--- 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
index f19f7da8..2cae6d9e 100644
--- 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
index e0027f79..655fb241 100644
--- 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
index d4ae6d1a..63fce937 100644
--- 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
index 051121e3..153167bb 100644
--- 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)