Repository: PlasmaPy/PlasmaPy
Pull request #836 (2020-06-06 07:29:48)

Issue #759 Serializing and Deserializing particles
Related to Issue #759 
Extension on to functionality introduced in PR #833 

This introduces the `from_json` method for the Particle, CustomParticle, and DimensionlessParticle classes that converts from a JSON representation to the appropriate particle object.

<!--
Thank you for contributing to PlasmaPy! Here's a bunch of pointers to
make things easier for all of us:

* If this PR will solve an issue tracked by GitHub, please add a phrase like
  "Closes #404." to automatically close the issue once the pull request is
  merged.  If your PR will not completely solve the issue, please still
  reference the issue. For an overview of this functionality, see
  https://help.github.com/articles/closing-issues-using-keywords/

* Remember to add some description of your changes in this text box.

* Tests and docstrings are required for new or changed functionality.
  Please make sure tests are passing before requesting a review - they
  will pop up at the bottom, in the Checks box. If you're unsure why
  they're failing, ask!
  
* If your pull request is not yet ready for review - either because
  you're just looking for feedback on a change or because you're not
  perfectly satisfied with your change - submit it as a draft pull request.
  Remember to change its' status once it's ready.

* If this is your first contribution, please add your name to the author
  list in `docs/about/credits.rst`.
  
* Feel free to chat with other developers on our Matrix channel at:
   https://riot.im/app/#/room/#plasmapy:openastronomy.org
  
* We have a developer's guide, where some answers to your questions may
  possibly be found, at
  http://docs.plasmapy.org/en/master/development/index.html

Many thanks in advance for following these pointers and for being willing to contribute!

When submitting a pull request, please ensure that you can (eventually,
sometime before it is merged) check the following basic requirements:

-->

- [x] I have added a changelog entry for this pull request (please see
  `changelog/README.rst` for instructions, and if you need help with picking the PR type, ask!)
- [x] If adding new functionality, I have added (passing) tests and
      docstrings. (Tests pop up at the bottom, in the checks box).
- [x] I have fixed any new failing tests (if you're unsure why
  they're failing, ask!).

Suggestions and advice is appreciated :)

Thank you!

codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836?src=pr&el=h1) Report
> Merging [#836](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836?src=pr&el=desc) into [master](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/eb463877194207b7e1fde025a52fef609a21ed57&el=desc) will **decrease** coverage by `0.13%`.
> The diff coverage is `82.92%`.

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

```diff
@@            Coverage Diff             @@
##           master     #836      +/-   ##
==========================================
- Coverage   95.88%   95.74%   -0.14%     
==========================================
  Files          56       56              
  Lines        5123     5127       +4     
==========================================
- Hits         4912     4909       -3     
- Misses        211      218       +7     
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [plasmapy/particles/particle\_class.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL3BhcnRpY2xlX2NsYXNzLnB5) | `94.74% <82.92%> (-1.00%)` | :arrow_down: |
| [plasmapy/classes/plasma\_base.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree#diff-cGxhc21hcHkvY2xhc3Nlcy9wbGFzbWFfYmFzZS5weQ==) | `67.74% <0.00%> (-4.49%)` | :arrow_down: |
| [plasmapy/simulation/abstractions.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree#diff-cGxhc21hcHkvc2ltdWxhdGlvbi9hYnN0cmFjdGlvbnMucHk=) | `75.00% <0.00%> (-2.78%)` | :arrow_down: |
| [plasmapy/classes/sources/openpmd\_hdf5.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree#diff-cGxhc21hcHkvY2xhc3Nlcy9zb3VyY2VzL29wZW5wbWRfaGRmNS5weQ==) | `78.40% <0.00%> (-1.17%)` | :arrow_down: |
| [plasmapy/simulation/particletracker.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree#diff-cGxhc21hcHkvc2ltdWxhdGlvbi9wYXJ0aWNsZXRyYWNrZXIucHk=) | `98.27% <0.00%> (-0.03%)` | :arrow_down: |
| [plasmapy/particles/ionization\_state.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL2lvbml6YXRpb25fc3RhdGUucHk=) | `92.91% <0.00%> (-0.03%)` | :arrow_down: |
| [plasmapy/utils/pytest\_helpers/pytest\_helpers.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree#diff-cGxhc21hcHkvdXRpbHMvcHl0ZXN0X2hlbHBlcnMvcHl0ZXN0X2hlbHBlcnMucHk=) | `93.38% <0.00%> (-0.03%)` | :arrow_down: |
| [plasmapy/formulary/braginskii.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree#diff-cGxhc21hcHkvZm9ybXVsYXJ5L2JyYWdpbnNraWkucHk=) | `99.72% <0.00%> (-0.01%)` | :arrow_down: |
| [plasmapy/utils/decorators/checks.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree#diff-cGxhc21hcHkvdXRpbHMvZGVjb3JhdG9ycy9jaGVja3MucHk=) | `100.00% <0.00%> (ø)` | |
| ... and [5 more](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/836/diff?src=pr&el=tree-more) | |

------

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

DarkAEther: Codecov/patch test isn't looking too good :(
I'd love some advice on how I could improve that score :)
StanczakDominik: Looks like it's mostly exceptions. Look into `pytest.raises` - it's a context manager that lets you check whether your code does actually raise your expected exception on invalid inputs, and if not, fails the test.
DarkAEther: > One concern I have about this design is that I have to know in advance the type of `Particle` I'm going to be restoring from stringified JSON - or I have to manually implement a check for each type. Whereas, since we're storing the class name in the JSON, the dream here would be something like this:
> 
> ```python
> >>> particle_dict = {"type": "Particle", "symbol": "e-"}
> >>> plasmapy.particles.from_json(particle_dict)
> Particle('e-')
> ```
> 
> I thought initially about having it be a class method on the abstract base class - an alternative constructor, if you will, but access through `plasmapy.particles.AbstractParticle.from_json` seems awkward. Having all the subclasses refer to a common method, on the other hand, could lead to `plasmapy.particles.DimensionlessParticle.from_json` returning `CustomParticle`, which may be confusing. I don't know what the best design here would be, to be frank.

Potentially, we could make the `from_json` a method inside `plasmapy.particles` as you've suggested. This method can check the particle type and call the appropriate particle class's to_json method internally and return the particle object. 

This solves the fact that we may not know the particle beforehand as well as developers knowing that they have to implement the method since it is an abstractmethod for `AbstractParticle`.

What do you think?
StanczakDominik: Sounds good!
rocco8773: *Disclaimer*: Again, I'm no expert in json and have limited experience with it.

What's the advantage of the current approach over utilizing the [`JSONEncoder`](https://docs.python.org/3/library/json.html#json.JSONEncoder) and [`JSONDecoder`](https://docs.python.org/3/library/json.html#json.JSONDecoder) classes.  As I understand the encoder/decoder framework, an implementation would look something like...

```python
# this does all the translation form Particle to JSON object
class ParticleJSONEncoder(JSONEncoder):
    def default(self, obj):
        # this overrides the JSONEncoder.default method
        # this knows how to convert the Particle class into a JSON format
        return json_particle

# this does all the translation form JSON object to Particle
class ParticleJSONDecoder(JSONDecoder):
    def __init__(self, *args, **kwargs):
        json.JSONDecoder.__init__(self, object_hook=self.object_hook, **kwargs)

    def object_hook(self, dictionary):
        if 'Particle' in dictionary:
            # gather all the relevant info from the dictionary
            return Particle(*args, **kwargs)
        return dictionary
```

Then a use case would look like...

```python
>>> # to save a Particle
>>> p = Particle("e-")
>>> with open('myparticle.json', 'w') as fp:
...     json.dump(p, fp, cls=ParticleJSONEncoder)
...
...     # or define a member that does the above line
...     p.dump(fp)

>>> # to read a particle
>>> with open('myparticle.json', 'r') as fp:
...     p = json.load(fp, cls=ParticleJSONDecoder)
```

Now, the loading is a little trickier than the saving.  I don't think there should be a member on `Particle` that loads; just does not make sense  to me.  We could define a `json_load()` function in `plasmapy/particles/__init__.py`.

----

Now, minus all the effort it takes to figure out the translation (not trivial by any means!), this seems like a logical approach to me.  It allows for expand-ability as the Particle class (and it's sisters) evolve while maintaining a constant UI.

Additionally, this encoder and decoder should be designed to work with any subclass of `AbstractParticle` (i.e. `Particle`, `CustomParticle`, etc.).
DarkAEther: Thank you for your feedback and suggestions @rocco8773 :)
> What's the advantage of the current approach over utilizing the JSONEncoder and JSONDecoder classes.

The reasoning I had in mind to use the current method was that in future, if another class inheriting `AbstractParticle` was to be created, the developer would have to implement the `to_json` method for it specifically (since the fields needed in JSON could be different for the new type of particle). In that case, they could implement it as method in the class. 

For the conversion of JSON to particle, I followed a similar reasoning - the developer will implement the `from_json` method in the class and then add it to a dictionary in the implementation of `plasmapy.particles.from_json` which will internally call the appropriate class (using the dictionary mentioned earlier as a hash table of sorts) method to return the appropriate particle object. Something like shown below:

```python
particles = {"Particle":Particle,
                  "CustomParticle": CustomParticle,
                  "DimensionlessParticle": DimensionlessParticle
                }
particles['Particle'].from_json(string)
```
While I did explain my reasoning, I'd like to say that I definitely agree that the code can be simplified (maintaining the interfaces) and made easier to maintain by using a custom `JSONEncoder` and `JSONDecoder` class instead of the current method. I'd definitely like to change the implementation accordingly.

There is one question I have though - is it possible to inform future developers that they need to go to the JSONEncoder and JSONDecoder classes and add the implementation for encode and decode for their new type of Particle? If I am not mistaken, the current approach will throw a `NotImplementedError` Exception if a developer forgets to add `to_json` and `for_json` into the class definition. I have basic OOPS knowledge and don't have much experience with it. Any advice here would be appreciated :)

I'm not sure if I've understood the requirements thoroughly. I've been assuming that every class that inherits from `AbstractParticle` (i.e every type of particle) will have to have implementations of `to_json` and `from_json` (i.e every particle can be represented as JSON and be created from a JSON). If the serialization of a particle can be "opt-in" so to speak, we can probably remove the class methods altogether, thereby keeping the UI consistent for the particles.

Furthermore, I just realised that any subclass of Particle, CustomParticle and DimensionlessParticle may not work with the current approach (the methods of `to_json` and `from_json` are already implemented in the super class). This defeats the purpose of the current approach, which will only work for subclasses of the `AbstractParticle`.

What do you think?

Thank you!
rocco8773: > The reasoning I had in mind to use the current method was that in future, if another class inheriting `AbstractParticle` was to be created, the developer would have to implement the `to_json` method for it specifically (since the fields needed in JSON could be different for the new type of particle). In that case, they could implement it as method in the class.

This is a good point.  Unfortunately all the particle classes do not share the same `__init__` so it's difficult to make something that is completely universal.  It will also be significantly more difficult to generalize loading versus dumping.

I think there could be a hybrid approach to this.  Something like...

```python
class AbstraceParticle(ABC):

    @property
    def json_dict(self):
        # this has default values for all particle types but can be overwritten by subclasses
        json_particle = {
            "type": type(self).__name__,
            "module": self.__module__,
            "mass": p.mass.__str__(),
            "charge: p.charge.__str__(),
        }
        return json_particle

    def json_dump(self, fp):
        json.dump({'plasmapy_particle": self.json_particle})
        # this is where we could apply a JSONEncoder, but if we've already
        # generated a json friendly dictionary then I don't see the point

class Particle(AbstractParticle):
    @property
    def json_particle(self):
        json_particle = super().json_particle
        json_particle["symbol"] = self.particle
        json_particle["init_params"] = {
            "argument": self.particle,
        }
     return json_particle
```
So, that would handle the saving.  Loading would be more difficult and the `JSONDecoder` might be handy.

```python
class ParticleJSONDecoder(JSONDecoder):
    _particle_classes = {
        "CustomParticle": CustomParticle,
        "DimensionlessParticle": DimentionlessParticle,
        "Particle": Particle,
    }

    def object_hook(self, dictionary):
        if 'plasmapy_particle' in dictionary:
            json_particle = dictionary['plasmapy_particle']
            cls_name = json_particle["type"]
            cls = _particle_classes[cls_name]

            if 'init_params' in json_particle:
                # this would not work as cleanly if init parameters 
                # were custom objects (i.e. not a default decoding)
                params = json_particle["init_params"]
                return cls(**params)
            else:
                p = cls()
                p.mass = u.Quantity(json_particle['mass'])
                p.charge = u.Quantity(json_particle['charge'])
                return p

        return dictionary

# In plasmapy/particles/__init__.py define
def json_load_particle(fp):
    return json.load(fp, cls=ParticleJSONDecoder)
```

Sorry for throwing a bunch of code your way.  It's just easier to write it out than try to explain it in a paragraph.

In this approach, any subclassed particle of `AbstractParticle` should be JSON serializable.  If a programmer wants to change the serialized values, then he or she would only need to override the `json_particle` property.
rocco8773: > I'm not sure if I've understood the requirements thoroughly. I've been assuming that every class that inherits from `AbstractParticle` (i.e every type of particle) will have to have implementations of `to_json` and `from_json` (i.e every particle can be represented as JSON and be created from a JSON).

Yes, every subclass will inherit the members defined in its parent class.
DarkAEther: > I think there could be a hybrid approach to this. Something like...
> 
> ```python
> class AbstraceParticle(ABC):
> 
>     @property
>     def json_dict(self):
>         # this has default values for all particle types but can be overwritten by subclasses
>         json_particle = {
>             "type": type(self).__name__,
>             "module": self.__module__,
>             "mass": p.mass.__str__(),
>             "charge: p.charge.__str__(),
>         }
>         return json_particle
> 
>     def json_dump(self, fp):
>         json.dump({'plasmapy_particle": self.json_particle})
>         # this is where we could apply a JSONEncoder, but if we've already
>         # generated a json friendly dictionary then I don't see the point
> 
> class Particle(AbstractParticle):
>     @property
>     def json_particle(self):
>         json_particle = super().json_particle
>         json_particle["symbol"] = self.particle
>         json_particle["init_params"] = {
>             "argument": self.particle,
>         }
>      return json_particle
> ```

Thank you for the suggestion! This looks like a really good approach. I shall adopt this technique accordingly. 

> Sorry for throwing a bunch of code your way. It's just easier to write it out than try to explain it in a paragraph.

No issues at all. Infact, it helps a lot. As developers, speaking in code is often more efficient that putting it out in a natural language xD

> So, that would handle the saving. Loading would be more difficult and the JSONDecoder might be handy.

I agree, the `JSONDecoder` approach seems to be the best for converting from JSON to particle. It is elegant and easier to maintain. 

Thank you for all the suggestions!
rocco8773: I look forward to seeing how this turns out!  Thank you for putting in the time to tackle this.  I probably wouldn't have thought about it until someone brought it up and I'm learning the new stuff now. :)

I did make a typo in my example code, which I have fixed.  The overriding property in `Particle` should have been `json_dict` not `json_particle`.
pep8speaks: Hello @DarkAEther! Thanks for updating your pull request.



* In the file [`plasmapy/particles/particle_class.py`](https://github.com/PlasmaPy/PlasmaPy/blob/1f81bf2c5587bdce22111b33e3f61b71825120b3/plasmapy/particles/particle_class.py):

> [Line 114:100](https://github.com/PlasmaPy/PlasmaPy/blob/1f81bf2c5587bdce22111b33e3f61b71825120b3/plasmapy/particles/particle_class.py#L114): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (104 > 99 characters)
> [Line 117:100](https://github.com/PlasmaPy/PlasmaPy/blob/1f81bf2c5587bdce22111b33e3f61b71825120b3/plasmapy/particles/particle_class.py#L117): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (130 > 99 characters)
> [Line 120:100](https://github.com/PlasmaPy/PlasmaPy/blob/1f81bf2c5587bdce22111b33e3f61b71825120b3/plasmapy/particles/particle_class.py#L120): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (131 > 99 characters)

* In the file [`plasmapy/particles/tests/test_particle_class.py`](https://github.com/PlasmaPy/PlasmaPy/blob/1f81bf2c5587bdce22111b33e3f61b71825120b3/plasmapy/particles/tests/test_particle_class.py):

> [Line 1007:100](https://github.com/PlasmaPy/PlasmaPy/blob/1f81bf2c5587bdce22111b33e3f61b71825120b3/plasmapy/particles/tests/test_particle_class.py#L1007): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (115 > 99 characters)
> [Line 1012:100](https://github.com/PlasmaPy/PlasmaPy/blob/1f81bf2c5587bdce22111b33e3f61b71825120b3/plasmapy/particles/tests/test_particle_class.py#L1012): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (115 > 99 characters)
> [Line 1017:100](https://github.com/PlasmaPy/PlasmaPy/blob/1f81bf2c5587bdce22111b33e3f61b71825120b3/plasmapy/particles/tests/test_particle_class.py#L1017): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (114 > 99 characters)
> [Line 1022:100](https://github.com/PlasmaPy/PlasmaPy/blob/1f81bf2c5587bdce22111b33e3f61b71825120b3/plasmapy/particles/tests/test_particle_class.py#L1022): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (114 > 99 characters)


DarkAEther: This PR will now include the code for serialization of particles carried from PR #833 
DarkAEther: Any hints on the flake8 and the pre-commit errors? It is the first time I'm encountering them.
StanczakDominik: Please ignore them, they're #845 gone wrong. :laughing: 
DarkAEther: > Please ignore them, they're #845 gone wrong. laughing
> And I did just add them yesterday!

No issues! Also, I'd like to apologise. I think I accidentally committed the same changes over your commits. Oops xD. Thanks for adding in the changes though!
StanczakDominik: OK, if you merge current master in, it's gonna fix the linters :)
DarkAEther: One left :D The precommit Style. Looks like I'll be making some style changes xD. Any way to integrate this into `pycodestyle` or another checker tool that runs locally? Would be helpful to have these checks done before commits are made and tested out.
DarkAEther: > Any way to integrate this into pycodestyle or another checker tool that runs locally?

Yes, yes there is. Use the black formatter https://black.readthedocs.io/en/stable/
DarkAEther: Awesome! The new checks passed too!
Any further feedback is welcome :)

StanczakDominik: > > Any way to integrate this into pycodestyle or another checker tool that runs locally?
> 
> Yes, yes there is. Use the black formatter https://black.readthedocs.io/en/stable/

The checks have a message in logs that says using pre-commit will run black automatically, with instructions on installing. I'm guessing it'd be better to put that in the PR template as well? :D
DarkAEther: > > > Any way to integrate this into pycodestyle or another checker tool that runs locally?
> > 
> > 
> > Yes, yes there is. Use the black formatter https://black.readthedocs.io/en/stable/
> 
> The checks have a message in logs that says using pre-commit will run black automatically, with instructions on installing. I'm guessing it'd be better to put that in the PR template as well? :D

True. I didn't realise that black was actually a tool that we could download and run locally. I initially thought it was a GitHub specific thing. Adding it into the PR template would definitely help clarify :) 
DarkAEther: @StanczakDominik I added my details to `credits.rst` in the last commit. I hope that's alright. Let me know if there are any further suggestions.

Thank you!
rocco8773: @DarkAEther Sorry for the slow review on this, I've been pretty busy the last week or so.  You should see me review on this in the next day or so.
DarkAEther: @rocco8773 Thank you so much for all the suggestions. I realised just how much time and effort goes into making them. I introduced a bunch of conflicts while working without knowing about the direct way of making commits from suggestions. For this set of commits, I've decided to work locally. From the next set of suggestions, I'll be sure to use the suggestions to commit feature. 

The last remaining suggestions that I need to work on are the `json.dumps` vs `json.dump` and the long lines in  `test_particle_class.py`. I will try and directly use your suggestions for the `test_particle_class.py` file.

Thank you!
DarkAEther: > I will try and directly use your suggestions for the test_particle_class.py file.

Okay, looks like I completely messed up. It won't let me use suggestions which show up as outdated :( 
I tried copy pasting the suggestion as it appears into my editor, but it throws `SyntaxError: EOL while scanning string literal`.

@rocco8773 I'd like to apologise for taking up more of your time for any repeat suggestions. I'll use them directly from the PR from now on. 
DarkAEther: I'd love some advice regarding the `codecov/patch` check. Couldn't figure out how to fix that.
According to the details, the lines of code in `particle_class.py` that were highlighted in red were:

```python

except KeyError:
--
  | 127 | + | raise InvalidElementError(
  | 128 | + | f"json file does not define a valid plasmapy particle"
  | 129 | + | )

```

Thank you!
StanczakDominik: The `raise InvalidParticleError` can be tested using the `pytest.raises` context manager (grep for other uses in the code base here!) with an invalid particle input - for example, try to have it parse a particle with the symbol "potato".

DarkAEther: An interesting thing popped up. For reference, here is the code for the `ParticleJSONDecoder`:
```python
if "plasmapy_particle" in json_dict:
            try:
                pardict = json_dict["plasmapy_particle"]
                partype = pardict["type"]
                args = pardict["__init__"]["args"]
                kwargs = pardict["__init__"]["kwargs"]
                particle = particle_types[partype](*args, **kwargs)
                return particle
            except KeyError:
                raise InvalidElementError(
                    f"json file does not define a valid plasmapy particle"
                )
else:
            return json_dict
```
The reason I need to check for `if "plasmapy_particle" in json_dict:` is because, apparently, `JSONDecoder` builds up its strings recursively in bottom up fashion, which means that this section of code is actually executed multiple times until the highest level key `'plasmapy_particle'` is found. This defeats the purpose of the exception handling part. Any thoughts on this?
DarkAEther: > An interesting thing popped up. For reference, here is the code for the `ParticleJSONDecoder`:
> 
> ```python
> if "plasmapy_particle" in json_dict:
>             try:
>                 pardict = json_dict["plasmapy_particle"]
>                 partype = pardict["type"]
>                 args = pardict["__init__"]["args"]
>                 kwargs = pardict["__init__"]["kwargs"]
>                 particle = particle_types[partype](*args, **kwargs)
>                 return particle
>             except KeyError:
>                 raise InvalidElementError(
>                     f"json file does not define a valid plasmapy particle"
>                 )
> else:
>             return json_dict
> ```
> 
> The reason I need to check for `if "plasmapy_particle" in json_dict:` is because, apparently, `JSONDecoder` builds up its strings recursively in bottom up fashion, which means that this section of code is actually executed multiple times until the highest level key `'plasmapy_particle'` is found. This defeats the purpose of the exception handling part. Any thoughts on this?

This unfortunately makes it rather challenging to do the intended task of checking whether the input is a valid particle JSON and not something else.
rocco8773: > The reason I need to check for `if "plasmapy_particle" in json_dict:` is because, apparently, `JSONDecoder` builds up its strings recursively in bottom up fashion, which means that this section of code is actually executed multiple times until the highest level key `'plasmapy_particle'` is found. This defeats the purpose of the exception handling part. Any thoughts on this?

No...no...the way this works is that the error is only raised if a particle is in the json file and it's json object is not properly defined.  This is why the try-statement is nested in the if-statement.  An error is never raised if `'plasmapy_particle'` is not in the json file, it is only thrown if `'plasmapy_particle'` is in the json file and the associated json object is not structured properly.

If `'plasmapy_particle'` is not in the json file, then the normal json decoder is used to generate the object.
DarkAEther: > > The reason I need to check for `if "plasmapy_particle" in json_dict:` is because, apparently, `JSONDecoder` builds up its strings recursively in bottom up fashion, which means that this section of code is actually executed multiple times until the highest level key `'plasmapy_particle'` is found. This defeats the purpose of the exception handling part. Any thoughts on this?
> 
> No...no...the way this works is that the error is only raised if a particle is in the json file and it's json object is not properly defined. This is why the try-statement is nested in the if-statement. An error is never raised if `'plasmapy_particle'` is not in the json file, it is only thrown if `'plasmapy_particle'` is in the json file and the associated json object is not structured properly.
> 
> If `'plasmapy_particle'` is not in the json file, then the normal json decoder is used to generate the object.

Ahh, I see. Thank you! I'll add some testcases for that exception.
DarkAEther: @rocco8773  Regarding JSON dumps vs dump and loads vs load, I think a possible way forward (based on your suggestion), could be:
Dumps and Dump:
```python
def json_dumps(self): #from your suggestion

def json_dump(self,dest_file_path):
        return json.dump(self.json_dict,dest_file_path) #Since we are working with only one particle per file at the moment
```
Loads and Load:
```python
def from_json(json_string):
    """
    Returns the appropriate object from input JSON string representation

    Parameters
    --------
    `json_string` is the JSON string that represents a particle
    """
    return json.loads(json_string, cls=ParticleJSONDecoder)

def from_json_file(json_file_path):
    """
    Returns the appropriate object from input JSON file

    Parameters
    --------
    `json_file_path` is the file path to the JSON file that contains the JSON
    string describing the particle
    """
    json_string = None
    with open(json_file_path,'r') as json_file:
        json_string = json_file.read()
    return from_json(json_string)
```
I'd love to hear your thoughts on this potential approach.
rocco8773: I think the json functionality we provide should match the naming scheme and signatures of the `json` functionality.  I say this because it make the functionality more intuitive to the user.  Thus, ...

* `json_dumps` should be the method we already have on the particle class.

* `json_dump` should be another method on the particle class, but its signature should reflect `json.dump`.  Thus, it would be used like...

    ```python
    par = Particle("e-")
    with open("my_particle.json", "w") as fp:
        par.json_dump(fp)
    ```

* `from_json` -> `json_loads_particle` and the signature can remain as is.  I like `json_loads_particle` because it matches the `json` naming scheme and it's more explicit. @StanczakDominik What do you think?

* `from_json_file` -> `json_load_particle` and it should match the signature of `json.load`.  Thus, it would be used like ...

    ```python
    with open("my_particle.json", "r") as fp:
        par = json_load_particle(fp)
    ```
StanczakDominik: Naming scheme looks okay; I don't really mind either way.

Would you mind if I added a few changes to this, via a separate pull request to your repo? I've got a few ideas that would make the json file opener more useful than just doing single particle stuff, and it'll be faster to code them up, honestly!
DarkAEther: > Naming scheme looks okay; I don't really mind either way.
> 
> Would you mind if I added a few changes to this, via a separate pull request to your repo? I've got a few ideas that would make the json file opener more useful than just doing single particle stuff, and it'll be faster to code them up, honestly!

This sounds awesome! Please go ahead. 
Thank you :)
DarkAEther: > I think the json functionality we provide should match the naming scheme and signatures of the `json` functionality. I say this because it make the functionality more intuitive to the user. Thus, ...
> 
> * `json_dumps` should be the method we already have on the particle class.
> * `json_dump` should be another method on the particle class, but its signature should reflect `json.dump`.  Thus, it would be used like...
>   ```python
>   par = Particle("e-")
>   with open("my_particle.json", "w") as fp:
>       par.json_dump(fp)
>   ```
> * `from_json` -> `json_loads_particle` and the signature can remain as is.  I like `json_loads_particle` because it matches the `json` naming scheme and it's more explicit. @StanczakDominik What do you think?
> * `from_json_file` -> `json_load_particle` and it should match the signature of `json.load`.  Thus, it would be used like ...
>   ```python
>   with open("my_particle.json", "r") as fp:
>       par = json_load_particle(fp)
>   ```

Thank you for your inputs. I'll follow this approach and make changes.

Patch:
diff --git a/changelog/836.feature.rst b/changelog/836.feature.rst
new file mode 100644
index 00000000..1028ce71
--- /dev/null
+++ b/changelog/836.feature.rst
@@ -0,0 +1,10 @@
+Add `json_dumps` method to `~plasmapy.particles.particle_class.AbstractParticle` to
+convert a particle object into a JSON string. Add `json_dump` method to
+`~plasmapy.particles.particle_class.AbstractParticle` to serialize a particle
+object and writes it to a file.  Add JSON decoder
+`~plasmapy.particles.serialization.ParticleJSONDecoder` to deserialize JSON objects
+into particle objects.  Add `plasmapy.particles.serialization.json_loads_particle`
+function to convert JSON strings to particle objects (using
+`~plasmapy.particles.serialization.ParticleJSONDecoder`). Add
+`plasmapy.particles.json_load_particle` function to deserialize a JSON file into a
+particle object (using `~plasmapy.particles.serialization.ParticleJSONDecoder`).
diff --git a/changelog/860.feature.rst b/changelog/860.feature.rst
new file mode 100644
index 00000000..e26088d2
--- /dev/null
+++ b/changelog/860.feature.rst
@@ -0,0 +1,1 @@
+ Create :func:`~plasmapy.formulary.ionization.Saha` function.
diff --git a/changelog/865.feature.rst b/changelog/865.feature.rst
new file mode 100644
index 00000000..c2fa45f1
--- /dev/null
+++ b/changelog/865.feature.rst
@@ -0,0 +1,27 @@
+Added aliases (with trailing underscores) for parameters in the formulary:
+
+    * `plasmapy.formulary.dimensionless.Reynolds_number` -> `~plasmapy.formulary.dimensionless.Re_`
+    * `plasmapy.formulary.dimensionless.Mag_Reynolds` -> `~plasmapy.formulary.dimensionless.Rm_`
+    * `plasmapy.formulary.drifts.ExB_drift` -> `~plasmapy.formulary.drifts.veb_`
+    * `plasmapy.formulary.drifts.force_drift` -> `~plasmapy.formulary.drifts.vfd_`
+    * `plasmapy.formulary.parameters.mass_density` -> `~plasmapy.formulary.parameters.rho_`
+    * `plasmapy.formulary.parameters.Afven_speed` -> `~plasmapy.formulary.parameters.va_`
+    * `plasmapy.formulary.parameters.ion_sound_speed` -> `~plasmapy.formulary.parameters.cs_`
+    * `plasmapy.formulary.parameters.thermal_speed` -> `~plasmapy.formulary.parameters.vth_`
+    * `plasmapy.formulary.parameters.thermal_pressure` -> `~plasmapy.formulary.parameters.pth_`
+    * `plasmapy.formulary.parameters.kappa_thermal_speed` -> `~plasmapy.formulary.parameters.vth_kappa_`
+    * `plasmapy.formulary.parameters.inertial_length` -> `~plasmapy.formulary.parameters.cwp_`
+    * `plasmapy.formulary.parameters.Hall_parameter` -> `~plasmapy.formulary.parameters.betaH_`
+    * `plasmapy.formulary.parameters.gyrofrequency` -> `~plasmapy.formulary.parameters.oc_`, `~plasmapy.formulary.parameters.wc_`
+    * `plasmapy.formulary.parameters.gyroradius` -> `~plasmapy.formulary.parameters.rc_`, `~plasmapy.formulary.parameters.rhoc_`
+    * `plasmapy.formulary.parameters.plasma_frequency` -> `~plasmapy.formulary.parameters.wp_`
+    * `plasmapy.formulary.parameters.Debye_length` -> `~plasmapy.formulary.parameters.lambdaD_`
+    * `plasmapy.formulary.parameters.Debye_number` -> `~plasmapy.formulary.parameters.nD_`
+    * `plasmapy.formulary.parameters.magnetic_pressure` -> `~plasmapy.formulary.parameters.pmag_`
+    * `plasmapy.formulary.parameters.magnetic_energy_density` -> `~plasmapy.formulary.parameters.ub_`
+    * `plasmapy.formulary.parameters.upper_hybrid_frequency` -> `~plasmapy.formulary.parameters.wuh_`
+    * `plasmapy.formulary.parameters.lower_hybrid_frequency` -> `~plasmapy.formulary.parameters.wlh_`
+    * `plasmapy.formulary.parameters.Bohm_diffusion` -> `~plasmapy.formulary.parameters.DB_`
+    * `plasmapy.formulary.quantum.deBroglie_wavelength` -> `~plasmapy.formulary.quantum.lambdaDB_`
+    * `plasmapy.formulary.quantum.thermal_deBroglie_wavelength` -> `~plasmapy.formulary.quantum.lambdaDB_th_`
+    * `plasmapy.formulary.quantum.Fermi_energy` -> `~plasmapy.formulary.quantum.Ef_`
diff --git a/changelog/867.doc.rst b/changelog/867.doc.rst
new file mode 100644
index 00000000..789e9aff
--- /dev/null
+++ b/changelog/867.doc.rst
@@ -0,0 +1,2 @@
+Improve readability of the `plasmapy.formulary` page by replacing the `toctree`
+list with a cleaner reST table.
diff --git a/docs/about/credits.rst b/docs/about/credits.rst
index 604b47f6..3e232b2a 100644
--- a/docs/about/credits.rst
+++ b/docs/about/credits.rst
@@ -81,6 +81,7 @@ in parentheses are `ORCID author identifiers <https://orcid.org>`__.
 * `Sixue Xu <https://github.com/hzxusx>`__
 * `Carol Zhang <https://github.com/carolyz>`__
 * `Ankur Chattopadhyay <https://github.com/chttrjeankr>`__
+* `Vishwas Rajashekar <https://github.com/DarkAEther>`__ (`0000-0002-4914-6612 <https://orcid.org/0000-0002-4914-6612>`__)
 
 This list contains contributors to PlasmaPy's core package and vision
 statement, including a few people who do not show up as `PlasmaPy
diff --git a/docs/formulary/index.rst b/docs/formulary/index.rst
index 86ac44dd..42cf1252 100644
--- a/docs/formulary/index.rst
+++ b/docs/formulary/index.rst
@@ -11,19 +11,52 @@ physical quantities helpful for plasma physics.
 
 .. toctree::
    :maxdepth: 1
+   :hidden:
 
-   mathematics
-   dielectric
-   dimensionless
-   dispersionfunction
-   distribution
-   drifts
-   magnetostatics
-   parameters
-   quantum
-   relativity
-   braginskii
-   collisions
+   Classical Transport <braginskii>
+   Collisions <collisions>
+   Dielectrics <dielectric>
+   Dimensionless <dimensionless>
+   Dispersion Relations <dispersionfunction>
+   Distribution Functions <distribution>
+   Drifts <drifts>
+   Ionization <ionization>
+   Magnetostatics <magnetostatics>
+   Mathematics <mathematics>
+   Plasma Parameters <parameters>
+   Quantum Relations <quantum>
+   Relativistic Relations <relativity>
+
+.. table::
+   :widths: 5 16
+
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Classical Transport <./braginskii>`          | `plasmapy.formulary.braginskii`         |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Collisions <./collisions>`                   | `plasmapy.formulary.collisions`         |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Dielectrics <./dielectric>`                  | `plasmapy.formulary.dielectric`         |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Dimensionless <./dimensionless>`             | `plasmapy.formulary.dimensionless`      |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Dispersion Relations <./dispersionfunction>` | `plasmapy.formulary.dispersionfunction` |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Distribution Functions <./distribution>`     | `plasmapy.formulary.distribution`       |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Drifts <./drifts>`                           | `plasmapy.formulary.drifts`             |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Ionization <./ionization>`                   | `plasmapy.formulary.ionization`         |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Magnetostatics <./magnetostatics>`           | `plasmapy.formulary.magnetostatics`     |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Mathematics <./mathematics>`                 | `plasmapy.formulary.mathematics`        |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Plasma Parameters <./parameters>`            | `plasmapy.formulary.parameters`         |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Quantum Relations <./quantum>`               | `plasmapy.formulary.quantum`            |
+   +----------------------------------------------------+-----------------------------------------+
+   | :doc:`Relativistic Relations <./relativity>`       | `plasmapy.formulary.relativity`         |
+   +----------------------------------------------------+-----------------------------------------+
 
 The subpackage makes heavy use of `astropy.units.Quantity` for handling
 conversions between different unit systems. This is especially important
@@ -42,13 +75,16 @@ For a general overview of how unit-based input works, take a look at the
 following example:
 
 
+Examples
+========
+
 .. nbgallery::
-    :caption: Examples
 
     /notebooks/physics
 
 Notes for developers
 ====================
+
 Values should be returned as an Astropy Quantity in SI units.
 
 If a quantity has several names, then the function name should be the
diff --git a/docs/formulary/ionization.rst b/docs/formulary/ionization.rst
new file mode 100644
index 00000000..4de60666
--- /dev/null
+++ b/docs/formulary/ionization.rst
@@ -0,0 +1,10 @@
+.. _ionization:
+
+******************************************************************
+Ionization related functionality (`plasmapy.formulary.ionization`)
+******************************************************************
+
+.. currentmodule:: plasmapy.formulary.ionization
+
+.. automodapi:: plasmapy.formulary.ionization
+   :no-heading:
diff --git a/docs/notebooks/langmuir_analysis.ipynb b/docs/notebooks/langmuir_analysis.ipynb
index 17560d04..bd7985c6 100644
--- a/docs/notebooks/langmuir_analysis.ipynb
+++ b/docs/notebooks/langmuir_analysis.ipynb
@@ -1,19 +1,5 @@
 {
  "cells": [
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {
-    "collapsed": false,
-    "jupyter": {
-     "outputs_hidden": false
-    }
-   },
-   "outputs": [],
-   "source": [
-    "%matplotlib inline"
-   ]
-  },
   {
    "cell_type": "markdown",
    "metadata": {},
@@ -38,12 +24,16 @@
    },
    "outputs": [],
    "source": [
-    "from plasmapy.diagnostics.langmuir import Characteristic, swept_probe_analysis\n",
-    "import matplotlib.pyplot as plt\n",
+    "%matplotlib inline\n",
+    "\n",
     "import astropy.units as u\n",
+    "import matplotlib.pyplot as plt\n",
     "import numpy as np\n",
     "import os\n",
-    "from pprint import pprint"
+    "\n",
+    "from pprint import pprint\n",
+    "\n",
+    "from plasmapy.diagnostics.langmuir import Characteristic, swept_probe_analysis"
    ]
   },
   {
@@ -56,8 +46,7 @@
     "a low (ion) temperature, low density plasma with a cylindrical probe. This\n",
     "allows us to utilize OML theory implemented in :func:`~plasmapy.diagnostics.langmuir.swept_probe_analysis`.\n",
     "The data has been preprocessed with some smoothing, which allows us to obtain\n",
-    "a Electron Energy Distribution Function (EEDF) as well.\n",
-    "\n"
+    "a Electron Energy Distribution Function (EEDF) as well."
    ]
   },
   {
@@ -105,7 +94,7 @@
      "outputs_hidden": false
     },
     "nbsphinx-thumbnail": {
-     "output-index": 1
+     "output-index": 2
     }
    },
    "outputs": [],
diff --git a/docs/particles/index.rst b/docs/particles/index.rst
index 42eda70a..e09e5a8c 100644
--- a/docs/particles/index.rst
+++ b/docs/particles/index.rst
@@ -38,3 +38,4 @@ Reference/API
 .. automodapi:: plasmapy.particles
    :no-heading:
    :no-main-docstr:
+   :inherited-members:
diff --git a/plasmapy/formulary/__init__.py b/plasmapy/formulary/__init__.py
index 82a77298..ef4f4efc 100644
--- a/plasmapy/formulary/__init__.py
+++ b/plasmapy/formulary/__init__.py
@@ -15,3 +15,4 @@
 from .parameters import *
 from .quantum import *
 from .relativity import *
+from .ionization import *
diff --git a/plasmapy/formulary/dimensionless.py b/plasmapy/formulary/dimensionless.py
index 4c3f5a5e..f4d297a9 100644
--- a/plasmapy/formulary/dimensionless.py
+++ b/plasmapy/formulary/dimensionless.py
@@ -8,7 +8,14 @@
 highly turbulent, while turbulence is negligible at low Reynolds
 numbers.
 """
-__all__ = ["beta", "quantum_theta", "Reynolds_number", "Mag_Reynolds"]
+__all__ = [
+    "beta",
+    "Mag_Reynolds",
+    "quantum_theta",
+    "Re_",
+    "Reynolds_number",
+    "Rm_",
+]
 
 from astropy import constants
 from astropy import units as u
@@ -109,6 +116,8 @@ def Reynolds_number(
 
         Re = \frac{\rho U L }{\mu}
 
+    **Aliases:** `Re_`
+
     Parameters
     ----------
     rho : `~astropy.units.Quantity`
@@ -163,6 +172,10 @@ def Reynolds_number(
     return Re
 
 
+Re_ = Reynolds_number
+""" Alias to :func:`Reynolds_number`. """
+
+
 @validate_quantities(U={"can_be_negative": True})
 def Mag_Reynolds(U: u.m / u.s, L: u.m, sigma: u.S / u.m) -> u.dimensionless_unscaled:
     r"""
@@ -177,6 +190,8 @@ def Mag_Reynolds(U: u.m / u.s, L: u.m, sigma: u.S / u.m) -> u.dimensionless_unsc
     where :math:`\eta = \frac{1}{\mu_0 \sigma}`
     and :math:`\mu_0` is the permeability of free space.
 
+    **Aliases:** `Rm_`
+
     Parameters
     ----------
     U : `~astropy.units.Quantity`
@@ -225,3 +240,7 @@ def Mag_Reynolds(U: u.m / u.s, L: u.m, sigma: u.S / u.m) -> u.dimensionless_unsc
     eta = 1 / (mu0 * sigma)
     Rm = abs(U * L / eta)
     return Rm
+
+
+Rm_ = Mag_Reynolds
+""" Alias to :func:`Mag_Reynolds`. """
diff --git a/plasmapy/formulary/drifts.py b/plasmapy/formulary/drifts.py
index a518d1bc..f40dd094 100644
--- a/plasmapy/formulary/drifts.py
+++ b/plasmapy/formulary/drifts.py
@@ -1,7 +1,12 @@
 """
 Formulas for calculating particle drifts.
 """
-__all__ = ["ExB_drift", "force_drift"]
+__all__ = [
+    "ExB_drift",
+    "force_drift",
+    "veb_",
+    "vfd_",
+]
 
 import astropy.units as u
 import numpy as np
@@ -14,6 +19,8 @@ def ExB_drift(E: u.V / u.m, B: u.T) -> u.m / u.s:
     r"""
     Calculate the "electric cross magnetic" particle drift.
 
+    **Aliases:** `veb_`
+
     Parameters
     ----------
     E : ~astropy.units.Quantity
@@ -61,11 +68,17 @@ def ExB_drift(E: u.V / u.m, B: u.T) -> u.m / u.s:
     return cross / (B * B).sum(-1)
 
 
+veb_ = ExB_drift
+""" Alias to :func:`ExB_drift`. """
+
+
 @validate_quantities
 def force_drift(F: u.N, B: u.T, q: u.C) -> u.m / u.s:
     r"""
     Calculate the general force drift for a particle in a magnetic field.
 
+    **Aliases:** `vfd_`
+
     Parameters
     ----------
     F : ~astropy.units.Quantity
@@ -110,3 +123,7 @@ def force_drift(F: u.N, B: u.T, q: u.C) -> u.m / u.s:
     """
     cross = np.cross(F.si.value, B.si.value) * F.unit * B.unit
     return cross / (q * (B * B).sum(-1))
+
+
+vfd_ = force_drift
+""" Alias to :func:`force_drift`. """
diff --git a/plasmapy/formulary/ionization.py b/plasmapy/formulary/ionization.py
index 5909b861..d999fc29 100644
--- a/plasmapy/formulary/ionization.py
+++ b/plasmapy/formulary/ionization.py
@@ -1,15 +1,16 @@
 """ This module gathers functions relating to ionization states and the properties thereof.
 """
-__all__ = ["ionization_balance"]
+__all__ = ["ionization_balance", "Saha", "Z_bal_"]
+
+import astropy.units as u
+
+from astropy.constants import a0, k_B
+from numpy import pi, exp, sqrt, log
 
-from astropy import units as u
 from plasmapy.utils.decorators import (
-    angular_freq_to_hz,
     check_relativistic,
     validate_quantities,
 )
-from astropy.constants import c, a0, k_B
-from numpy import pi, exp, sqrt, log
 
 
 @validate_quantities(
@@ -36,6 +37,8 @@ def ionization_balance(n: u.m ** -3, T_e: u.K) -> u.dimensionless_unscaled:
     :math:`a_0` is the Bohr radius, and
     :math:`E_H` is the ionization energy of Hydrogen
 
+    **Aliases:** `Z_bal_`
+
     Parameters
     ----------
     T_e : `~astropy.units.Quantity`
@@ -46,7 +49,7 @@ def ionization_balance(n: u.m ** -3, T_e: u.K) -> u.dimensionless_unscaled:
 
     Warns
     -----
-    ~astropy.units.UnitsWarning
+    : `~astropy.units.UnitsWarning`
         If units are not provided, SI units are assumed.
 
     Raises
@@ -64,11 +67,11 @@ def ionization_balance(n: u.m ** -3, T_e: u.K) -> u.dimensionless_unscaled:
     >>> T_e = 5000 * u.K
     >>> n = 1e19 * u.m ** -3
     >>> ionization_balance(n, T_e)
-    <Quantity 0.27478417>
+    <Quantity 0.274...>
     >>> T_e = 50 * u.eV
     >>> n = 1e10 * u.m ** -3
     >>> ionization_balance(n, T_e)
-    <Quantity 12.61576334>
+    <Quantity 12.615...>
 
     Returns
     -------
@@ -86,5 +89,96 @@ def ionization_balance(n: u.m ** -3, T_e: u.K) -> u.dimensionless_unscaled:
     return Z
 
 
-Z_bal = ionization_balance
-"""alias for :func:`ionization_balance`"""
+Z_bal_ = ionization_balance
+"""Alias for :func:`ionization_balance`."""
+
+
+@validate_quantities(
+    T_e={"can_be_negative": False, "equivalencies": u.temperature_energy()}
+)
+def Saha(g_j, g_k, n_e: u.m ** -3, E_jk: u.J, T_e: u.K) -> u.dimensionless_unscaled:
+    r"""
+    The Saha equation, derived in statistical mechanics, gives an approximation of
+    the ratio of population of ions in two different ionization states in a plasma.
+    This approximation applies to plasmas in thermodynamic equilibrium where ionization
+    and recombination of ions with electrons are balanced.
+
+    .. math::
+        \frac{N_j}{N_k} = \frac{1}{n_e} \frac{g_j}{4 g_k a_0^{3}} \left( \frac{k_B T_e}{\pi E_H} \right)^{\frac{3}{2}}
+         \exp\left( \frac{-E_{jk}}{k_B T_e} \right)
+
+    Where :math:`k_B` is the Boltzmann constant,
+    :math:`a_0` is the Bohr radius,
+    :math:`E_H` is the ionization energy of Hydrogen,
+    :math:`N_j` and :math:`N_k` are the population of ions in the j and k states respectively.
+    This function is equivalent to Eq. 3.47 in `Drake`_.
+
+    Parameters
+    ----------
+    T_e : `~astropy.units.Quantity`
+        The electron temperature.
+
+    g_j : `int`
+        The degeneracy of the 'j'th ionization state.
+
+    g_k : `int`
+        The degeneracy of the 'k'th ionization state.
+
+    E_jk : `~astropy.units.Quantity`
+        The energy difference between ionization states j and k.
+
+    n_e : `~astropy.units.Quantity`
+        The electron number density of the plasma.
+
+    Warns
+    -----
+    : `~astropy.units.UnitsWarning`
+        If units are not provided, SI units are assumed.
+
+    Raises
+    ------
+    TypeError
+        The `T_e`, `E_jk`, or `n_e` is not a `~astropy.units.Quantity` and cannot be
+        converted into a ~astropy.units.Quantity.
+
+    ~astropy.units.UnitConversionError
+        If the `T_e`, `E_jk`, or `n` not in appropriate units.
+
+    Returns
+    -------
+    ratio: `~astropy.units.Quantity`
+        The ratio of population of ions in ionization state j to state k.
+
+    Examples
+    --------
+    >>> import astropy.units as u
+    >>> T_e = 5000 * u.K
+    >>> n = 1e19 * u.m ** -3
+    >>> g_j = 2
+    >>> g_k = 2
+    >>> E_jk = 1 * u.Ry
+    >>> Saha(g_j, g_k, n, E_jk, T_e)
+    <Quantity 3.299...e-06>
+    >>> T_e = 1 * u.Ry
+    >>> n = 1e23 * u.m ** -3
+    >>> Saha(g_j, g_k, n, E_jk, T_e)
+    <Quantity 1114595.586...>
+
+    Notes
+    -----
+    For reference to this function and for more information regarding the Saha equation,
+    see chapter 3 of R Paul Drake's book, "High-Energy-Density Physics: Foundation of
+    Inertial Fusion and Experimental Astrophysics" (`DOI: 10.1007/978-3-319-67711-8_3`_).
+
+    .. _`Drake`: https://doi.org/10.1007/978-3-319-67711-8
+    .. _`DOI: 10.1007/978-3-319-67711-8_3`: https://doi.org/10.1007/978-3-319-67711-8_3
+    """
+    E_h = 1 * u.Ry
+
+    degeneracy_factor = (1 / n_e) * g_j / (4 * g_k * a0 ** 3)
+    physical_constants = (k_B * T_e / (pi * E_h)) ** (3 / 2)
+    boltzmann_factor = exp(-E_jk / (k_B * T_e))
+
+    ratio = degeneracy_factor * physical_constants * boltzmann_factor
+
+    return ratio
diff --git a/plasmapy/formulary/parameters.py b/plasmapy/formulary/parameters.py
index e1db4115..d8fc296a 100644
--- a/plasmapy/formulary/parameters.py
+++ b/plasmapy/formulary/parameters.py
@@ -3,24 +3,44 @@
 plasma frequency or Debye length.
 """
 __all__ = [
-    "mass_density",
     "Alfven_speed",
-    "ion_sound_speed",
-    "thermal_speed",
-    "thermal_pressure",
-    "kappa_thermal_speed",
-    "Hall_parameter",
-    "gyrofrequency",
-    "gyroradius",
-    "plasma_frequency",
+    "Bohm_diffusion",
+    "betaH_",
+    "cs_",
+    "cwp_",
+    "DB_",
     "Debye_length",
     "Debye_number",
+    "gyrofrequency",
+    "gyroradius",
+    "Hall_parameter",
     "inertial_length",
-    "magnetic_pressure",
+    "ion_sound_speed",
+    "kappa_thermal_speed",
+    "lambdaD_",
+    "lower_hybrid_frequency",
     "magnetic_energy_density",
+    "magnetic_pressure",
+    "mass_density",
+    "nD_",
+    "oc_",
+    "plasma_frequency",
+    "pmag_",
+    "pth_",
+    "rc_",
+    "rho_",
+    "rhoc_",
+    "thermal_pressure",
+    "thermal_speed",
+    "ub_",
     "upper_hybrid_frequency",
-    "lower_hybrid_frequency",
-    "Bohm_diffusion",
+    "va_",
+    "vth_",
+    "vth_kappa_",
+    "wc_",
+    "wp_",
+    "wlh_",
+    "wuh_",
 ]
 
 import numbers
@@ -80,6 +100,8 @@ def mass_density(
 ) -> u.kg / u.m ** 3:
     """Utility function to merge two possible inputs for particle charge.
 
+    **Aliases:** `rho_`
+
     Parameters
     ----------
     density : ~astropy.units.Quantity
@@ -132,6 +154,10 @@ def mass_density(
     return rho
 
 
+rho_ = mass_density
+""" Alias to :func:`mass_density`. """
+
+
 @check_relativistic
 @validate_quantities(density={"can_be_negative": False})
 def Alfven_speed(
@@ -140,6 +166,8 @@ def Alfven_speed(
     r"""
     Return the Alfvén speed.
 
+    **Aliases:** `va_`
+
     Parameters
     ----------
     B : ~astropy.units.Quantity
@@ -229,6 +257,10 @@ def Alfven_speed(
     return V_A
 
 
+va_ = Alfven_speed
+""" Alias to :func:`Alfven_speed`. """
+
+
 @check_relativistic
 @validate_quantities(
     T_i={"can_be_negative": False, "equivalencies": u.temperature_energy()},
@@ -249,6 +281,8 @@ def ion_sound_speed(
     r"""
     Return the ion sound speed for an electron-ion plasma.
 
+    **Aliases:** `cs_`
+
     Parameters
     ----------
     T_e : ~astropy.units.Quantity
@@ -412,6 +446,10 @@ def ion_sound_speed(
     return V_S
 
 
+cs_ = ion_sound_speed
+""" Alias to :func:`ion_sound_speed`. """
+
+
 # This dictionary defines coefficients for thermal speeds
 # calculated for different methods and values of ndim.
 # Created here to avoid re-instantiating on each call
@@ -439,6 +477,8 @@ def thermal_speed(
     Return the most probable speed for a particle within a Maxwellian
     distribution.
 
+    **Aliases:** `vth_`
+
     Parameters
     ----------
     T : ~astropy.units.Quantity
@@ -560,6 +600,10 @@ def thermal_speed(
     return np.sqrt(coef * k_B * T / m)
 
 
+vth_ = thermal_speed
+""" Alias to :func:`thermal_speed`. """
+
+
 @validate_quantities(
     T={"can_be_negative": False, "equivalencies": u.temperature_energy()},
     n={"can_be_negative": False},
@@ -568,6 +612,8 @@ def thermal_pressure(T: u.K, n: u.m ** -3) -> u.Pa:
     r"""
     Return the thermal pressure for a Maxwellian distribution.
 
+    **Aliases:** `pth_`
+
     Parameters
     ----------
     T : ~astropy.units.Quantity
@@ -609,6 +655,10 @@ def thermal_pressure(T: u.K, n: u.m ** -3) -> u.Pa:
     return n * k_B * T
 
 
+pth_ = thermal_pressure
+""" Alias to :func:`thermal_pressure`. """
+
+
 @check_relativistic
 @validate_quantities(
     T={"can_be_negative": False, "equivalencies": u.temperature_energy()}
@@ -619,6 +669,8 @@ def kappa_thermal_speed(
     r"""Return the most probable speed for a particle within a Kappa
     distribution.
 
+    **Aliases:** `vth_kappa_`
+
     Parameters
     ----------
     T : ~astropy.units.Quantity
@@ -716,6 +768,10 @@ def kappa_thermal_speed(
     return vTh * coeff
 
 
+vth_kappa_ = kappa_thermal_speed
+""" Alias to :func:`kappa_thermal_speed`. """
+
+
 @validate_quantities(
     n={"can_be_negative": False},
     T={"can_be_negative": False, "equivalencies": u.temperature_energy()},
@@ -735,6 +791,8 @@ def Hall_parameter(
 
     All parameters apply to `particle`.
 
+    **Aliases:** `betaH_`
+
     Parameters
     ----------
     n : ~astropy.units.quantity.Quantity
@@ -789,6 +847,10 @@ def Hall_parameter(
     return gyro_frequency / coll_rate
 
 
+betaH_ = Hall_parameter
+""" Alias to :func:`Hall_parameter`. """
+
+
 @validate_quantities(
     validations_on_return={
         "units": [u.rad / u.s, u.Hz],
@@ -799,6 +861,8 @@ def Hall_parameter(
 def gyrofrequency(B: u.T, particle="e-", signed=False, Z=None) -> u.rad / u.s:
     r"""Calculate the particle gyrofrequency in units of radians per second.
 
+    **Aliases:** `oc_`, `wc_`
+
     Parameters
     ----------
     B : ~astropy.units.Quantity
@@ -897,6 +961,13 @@ def gyrofrequency(B: u.T, particle="e-", signed=False, Z=None) -> u.rad / u.s:
     return omega_ci
 
 
+oc_ = gyrofrequency
+""" Alias to :func:`gyrofrequency`. """
+
+wc_ = gyrofrequency
+""" Alias to :func:`gyrofrequency`. """
+
+
 @validate_quantities(
     Vperp={"can_be_nan": True},
     T_i={"can_be_nan": True, "equivalencies": u.temperature_energy()},
@@ -911,6 +982,8 @@ def gyroradius(
 ) -> u.m:
     r"""Return the particle gyroradius.
 
+    **Aliases:** `rc_`, `rhoc_`
+
     Parameters
     ----------
     B : ~astropy.units.Quantity
@@ -1051,6 +1124,13 @@ def gyroradius(
     return r_Li
 
 
+rc_ = gyroradius
+""" Alias to :func:`gyroradius`. """
+
+rhoc_ = gyroradius
+""" Alias to :func:`gyroradius`. """
+
+
 @validate_quantities(
     n={"can_be_negative": False},
     validations_on_return={
@@ -1062,6 +1142,8 @@ def gyroradius(
 def plasma_frequency(n: u.m ** -3, particle="e-", z_mean=None) -> u.rad / u.s:
     r"""Calculate the particle plasma frequency.
 
+    **Aliases:** `wp_`
+
     Parameters
     ----------
     n : ~astropy.units.Quantity
@@ -1155,6 +1237,10 @@ def plasma_frequency(n: u.m ** -3, particle="e-", z_mean=None) -> u.rad / u.s:
     return omega_p.si
 
 
+wp_ = plasma_frequency
+""" Alias to :func:`plasma_frequency`. """
+
+
 @validate_quantities(
     T_e={"can_be_negative": False, "equivalencies": u.temperature_energy()},
     n_e={"can_be_negative": False},
@@ -1163,6 +1249,8 @@ def Debye_length(T_e: u.K, n_e: u.m ** -3) -> u.m:
     r"""Calculate the characteristic decay length for electric fields,
      due to charge screening.
 
+    **Aliases:** `lambdaD_`
+
     Parameters
     ----------
     T_e: ~astropy.units.Quantity
@@ -1223,6 +1311,10 @@ def Debye_length(T_e: u.K, n_e: u.m ** -3) -> u.m:
     return lambda_D
 
 
+lambdaD_ = Debye_length
+""" Alias to :func:`Debye_length`. """
+
+
 @validate_quantities(
     T_e={"can_be_negative": False, "equivalencies": u.temperature_energy()},
     n_e={"can_be_negative": False},
@@ -1231,6 +1323,8 @@ def Debye_number(T_e: u.K, n_e: u.m ** -3) -> u.dimensionless_unscaled:
     r"""Return the number of electrons within a sphere with a radius
     of the Debye length.
 
+    **Aliases:** `nD_`
+
     Parameters
     ----------
     T_e : ~astropy.units.Quantity
@@ -1290,6 +1384,10 @@ def Debye_number(T_e: u.K, n_e: u.m ** -3) -> u.dimensionless_unscaled:
     return N_D
 
 
+nD_ = Debye_number
+""" Alias to :func:`Debye_number`. """
+
+
 @validate_quantities(
     n={"can_be_negative": False},
     validations_on_return={"equivalencies": u.dimensionless_angles()},
@@ -1299,6 +1397,8 @@ def inertial_length(n: u.m ** -3, particle: particles.Particle) -> u.m:
     r"""
     Calculate a charged particle's inertial length.
 
+    **Aliases:** `cwp_`
+
     Parameters
     ----------
     n : ~astropy.units.Quantity
@@ -1356,11 +1456,21 @@ def inertial_length(n: u.m ** -3, particle: particles.Particle) -> u.m:
     return c / omega_p
 
 
+cwp_ = inertial_length
+"""
+Alias to :func:`inertial_length`.
+
+* Name is shorthand for :math:`c / \\omega_p`.
+"""
+
+
 @validate_quantities
 def magnetic_pressure(B: u.T) -> u.Pa:
     r"""
     Calculate the magnetic pressure.
 
+    **Aliases:** `pmag_`
+
     Parameters
     ----------
     B : ~astropy.units.Quantity
@@ -1415,11 +1525,17 @@ def magnetic_pressure(B: u.T) -> u.Pa:
     return (B ** 2) / (2 * mu0)
 
 
+pmag_ = magnetic_pressure
+""" Alias to :func:`magnetic_pressure`. """
+
+
 @validate_quantities
 def magnetic_energy_density(B: u.T) -> u.J / u.m ** 3:
     r"""
     Calculate the magnetic energy density.
 
+    **Aliases:** `ub_`
+
     Parameters
     ----------
     B : ~astropy.units.Quantity
@@ -1474,6 +1590,10 @@ def magnetic_energy_density(B: u.T) -> u.J / u.m ** 3:
     return magnetic_pressure(B)
 
 
+ub_ = magnetic_energy_density
+""" Alias to :func:`magnetic_energy_density`. """
+
+
 @validate_quantities(
     n_e={"can_be_negative": False},
     validations_on_return={
@@ -1486,6 +1606,8 @@ def upper_hybrid_frequency(B: u.T, n_e: u.m ** -3) -> u.rad / u.s:
     r"""
     Return the upper hybrid frequency.
 
+    **Aliases:** `wuh_`
+
     Parameters
     ----------
     B : ~astropy.units.Quantity
@@ -1542,6 +1664,10 @@ def upper_hybrid_frequency(B: u.T, n_e: u.m ** -3) -> u.rad / u.s:
     return omega_uh
 
 
+wuh_ = upper_hybrid_frequency
+""" Alias to :func:`upper_hybrid_frequency`. """
+
+
 @validate_quantities(
     n_i={"can_be_negative": False},
     validations_on_return={
@@ -1554,6 +1680,8 @@ def lower_hybrid_frequency(B: u.T, n_i: u.m ** -3, ion="p+") -> u.rad / u.s:
     r"""
     Return the lower hybrid frequency.
 
+    **Aliases:** `wlh_`
+
     Parameters
     ----------
     B : ~astropy.units.Quantity
@@ -1631,6 +1759,10 @@ def lower_hybrid_frequency(B: u.T, n_i: u.m ** -3, ion="p+") -> u.rad / u.s:
     return omega_lh
 
 
+wlh_ = lower_hybrid_frequency
+""" Alias to :func:`lower_hybrid_frequency`. """
+
+
 @validate_quantities(
     T_e={"can_be_negative": False, "equivalencies": u.temperature_energy()},
     B={"can_be_negative": False},
@@ -1651,6 +1783,8 @@ def Bohm_diffusion(T_e: u.K, B: u.T) -> u.m ** 2 / u.s:
     where :math:`k_B` is the Boltzmann constant
     and :math:`e` is the fundamental charge.
 
+    **Aliases:** `DB_`
+
     Parameters
     ----------
     T_e: `~astropy.units.Quantity`
@@ -1692,3 +1826,7 @@ def Bohm_diffusion(T_e: u.K, B: u.T) -> u.m ** 2 / u.s:
     """
     D_B = k_B * T_e / (16 * e * B)
     return D_B
+
+
+DB_ = Bohm_diffusion
+""" Alias to :func:`Bohm_diffusion`. """
diff --git a/plasmapy/formulary/quantum.py b/plasmapy/formulary/quantum.py
index bca445d2..b36bc7ac 100644
--- a/plasmapy/formulary/quantum.py
+++ b/plasmapy/formulary/quantum.py
@@ -4,12 +4,15 @@
 
 """
 __all__ = [
+    "chemical_potential",
     "deBroglie_wavelength",
-    "thermal_deBroglie_wavelength",
+    "Ef_",
     "Fermi_energy",
+    "lambdaDB_",
+    "lambdaDB_th_",
     "Thomas_Fermi_length",
+    "thermal_deBroglie_wavelength",
     "Wigner_Seitz_radius",
-    "chemical_potential",
 ]
 
 import numpy as np
@@ -31,6 +34,8 @@ def deBroglie_wavelength(V: u.m / u.s, particle) -> u.m:
     r"""
     Calculates the de Broglie wavelength.
 
+    **Aliases:** `lambdaDB_`
+
     Parameters
     ----------
     V : ~astropy.units.Quantity
@@ -128,6 +133,10 @@ def deBroglie_wavelength(V: u.m / u.s, particle) -> u.m:
     return lambda_dBr
 
 
+lambdaDB_ = deBroglie_wavelength
+""" Alias to :func:`deBroglie_wavelength`. """
+
+
 @validate_quantities(
     T_e={"can_be_negative": False, "equivalencies": u.temperature_energy()},
     validations_on_return={"can_be_negative": False},
@@ -136,6 +145,8 @@ def thermal_deBroglie_wavelength(T_e: u.K) -> u.m:
     r"""
     Calculate the thermal deBroglie wavelength for electrons.
 
+    **Aliases:** `lambdaDB_th_`
+
     Parameters
     ----------
     T_e: ~astropy.units.Quantity
@@ -181,6 +192,10 @@ def thermal_deBroglie_wavelength(T_e: u.K) -> u.m:
     return lambda_dbTh
 
 
+lambdaDB_th_ = thermal_deBroglie_wavelength
+""" Alias to :func:`thermal_deBroglie_wavelength`. """
+
+
 @validate_quantities(
     n_e={"can_be_negative": False}, validations_on_return={"can_be_negative": False}
 )
@@ -188,6 +203,8 @@ def Fermi_energy(n_e: u.m ** -3) -> u.J:
     r"""
     Calculate the kinetic energy in a degenerate electron gas.
 
+    **Aliases:** `Ef_`
+
     Parameters
     ----------
     n_e : ~astropy.units.Quantity
@@ -242,6 +259,10 @@ def Fermi_energy(n_e: u.m ** -3) -> u.J:
     return energy_F
 
 
+Ef_ = Fermi_energy
+""" Alias to :func:`Fermi_energy`. """
+
+
 @validate_quantities(
     n_e={"can_be_negative": False}, validations_on_return={"can_be_negative": False}
 )
diff --git a/plasmapy/particles/__init__.py b/plasmapy/particles/__init__.py
index 0cac0dd8..57c830fc 100644
--- a/plasmapy/particles/__init__.py
+++ b/plasmapy/particles/__init__.py
@@ -2,6 +2,15 @@
 The `plasmapy.particles` subpackage provides access to information about
 atoms, isotopes, ions, and other particles.
 """
+__all__ = [
+    "json_load_particle",
+    "json_loads_particle",
+    "AbstractParticle",
+    "CustomParticle",
+    "DimensionlessParticle",
+    "Particle",
+    "ParticleJSONDecoder",
+]
 
 from plasmapy.particles.atomic import (
     atomic_number,
@@ -28,6 +37,11 @@
     Particle,
 )
 from plasmapy.particles.particle_input import particle_input
+from plasmapy.particles.serialization import (
+    json_load_particle,
+    json_loads_particle,
+    ParticleJSONDecoder,
+)
 from plasmapy.particles.special_particles import ParticleZoo
 from plasmapy.particles.symbols import (
     atomic_symbol,
diff --git a/plasmapy/particles/particle_class.py b/plasmapy/particles/particle_class.py
index 60fc1452..5d9f6f20 100644
--- a/plasmapy/particles/particle_class.py
+++ b/plasmapy/particles/particle_class.py
@@ -1,18 +1,24 @@
 """The Particle class."""
 
-__all__ = ["AbstractParticle", "Particle", "DimensionlessParticle", "CustomParticle"]
+__all__ = [
+    "AbstractParticle",
+    "CustomParticle",
+    "DimensionlessParticle",
+    "Particle",
+]
 
+import astropy.constants as const
+import astropy.units as u
+import json
+import numpy as np
 import warnings
+
 from abc import ABC, abstractmethod
 from collections import defaultdict, namedtuple
+from datetime import datetime
 from numbers import Complex, Integral, Real
 from typing import List, Optional, Set, Tuple, Union
 
-import astropy.constants as const
-import astropy.units as u
-import numpy as np
-
-import plasmapy.utils.roman as roman
 from plasmapy.particles.elements import _Elements, _PeriodicTable
 from plasmapy.particles.exceptions import (
     AtomicError,
@@ -37,6 +43,7 @@
     _Particles,
     _special_ion_masses,
 )
+from plasmapy.utils import roman
 
 _classification_categories = {
     "lepton",
@@ -111,6 +118,50 @@ def mass(self) -> Union[u.Quantity, Real]:
     def charge(self) -> Union[u.Quantity, Real]:
         raise NotImplementedError
 
+    @property
+    def json_dict(self) -> dict:
+        """
+        A dictionary representation of the particle object that is JSON friendly
+        (i.e. convertible to a JSON object).
+
+        The dictionary should maintain the following format so
+        `~plasmapy.particles.ParticleJSONDecoder` knows how to decoded the resulting
+        JSON object.
+
+        .. code-block:: python
+
+            {"plasmapy_particle": {
+                # string representation of the particle class
+                "type": "Particle",
+
+                # string representation of the module contains the particle class
+                "module": "plasmapy.particles.particle_class",
+
+                # date stamp of when the object was creaed
+                "date_created": "2020-07-20 17:46:13 UTC",
+
+                # parameters used to initialized the particle class
+                "__init__": {
+                    # tuple of positional arguments
+                    "args": (),
+
+                    # dictionary of keyword arguments
+                    "kwargs": {},
+                },
+            }}
+
+        Only the `"__init__"` entry should be modified by the subclass.
+        """
+        json_dictionary = {
+            "plasmapy_particle": {
+                "type": type(self).__name__,
+                "module": self.__module__,
+                "date_created": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
+                "__init__": {"args": (), "kwargs": {}},
+            }
+        }
+        return json_dictionary
+
     def __bool__(self):
         """
         Raise an `~plasmapy.utils.AtomicError` because particles
@@ -118,6 +169,37 @@ def __bool__(self):
         """
         raise AtomicError("The truth value of a particle is not defined.")
 
+    def json_dump(self, fp, **kwargs):
+        """
+        Writes the particle's `json_dict` to the `fp` file object using `json.dump`.
+
+        Parameters
+        ----------
+        fp: `file object <https://docs.python.org/3/glossary.html#term-file-object>`_
+            Destination file object to write the JSON serialized `json_dict`.
+
+        **kwargs:
+            Any keyword accepted by `json.dump`.
+        """
+        return json.dump(self.json_dict, fp, **kwargs)
+
+    def json_dumps(self, **kwargs) -> str:
+        """
+        Serialize the particle's `json_dict` into a JSON formatted `str` using
+        `json.dumps`.
+
+        Parameters
+        ----------
+        **kwargs:
+            Any keyword accepted by `json.dumps`.
+
+        Returns
+        -------
+        str
+            JSON formatted `str`.
+        """
+        return json.dumps(self.json_dict, **kwargs)
+
 
 class Particle(AbstractParticle):
     """
@@ -576,6 +658,32 @@ def __invert__(self):
         """
         return self.antiparticle
 
+    @property
+    def json_dict(self) -> dict:
+        """
+        A `json` friendly dictionary representation of the particle. (see
+        `AbstractParticle.json_dict` for more details)
+
+        Examples
+        --------
+        >>> lead = Particle('lead')
+        >>> lead.json_dict
+        {'plasmapy_particle': {'type': 'Particle',
+            'module': 'plasmapy.particles.particle_class',
+            'date_created': '...',
+            '__init__': {'args': ('Pb',), 'kwargs': {}}}}
+        >>> electron = Particle('e-')
+        >>> electron.json_dict
+        {'plasmapy_particle': {'type': 'Particle',
+            'module': 'plasmapy.particles.particle_class',
+            'date_created': '...',
+            '__init__': {'args': ('e-',), 'kwargs': {}}}}
+
+        """
+        particle_dictionary = super().json_dict
+        particle_dictionary["plasmapy_particle"]["__init__"]["args"] = (self.particle,)
+        return particle_dictionary
+
     @property
     def particle(self) -> Optional[str]:
         """
@@ -1713,6 +1821,35 @@ def _validate_parameter(obj, can_be_negative=True) -> np.float64:
 
         return new_obj
 
+    @property
+    def json_dict(self) -> dict:
+        """
+        A `json` friendly dictionary representation of the particle. (see
+        `AbstractParticle.json_dict` for more details)
+
+        Examples
+        --------
+        >>> from plasmapy.particles import DimensionlessParticle
+        >>> dimensionless_particle = DimensionlessParticle(mass=1.0, charge=-1.0)
+        >>> dimensionless_particle.json_dict
+        {'plasmapy_particle': {'type': 'DimensionlessParticle',
+            'module': 'plasmapy.particles.particle_class',
+            'date_created': '...',
+            '__init__': {'args': (), 'kwargs': {'mass': 1.0, 'charge': -1.0}}}}
+        >>> dimensionless_particle = DimensionlessParticle(mass=1.0)
+        >>> dimensionless_particle.json_dict
+        {'plasmapy_particle': {'type': 'DimensionlessParticle',
+            'module': 'plasmapy.particles.particle_class',
+            'date_created': '...',
+            '__init__': {'args': (), 'kwargs': {'mass': 1.0, 'charge': nan}}}}
+        """
+        particle_dictionary = super().json_dict
+        particle_dictionary["plasmapy_particle"]["__init__"]["kwargs"] = {
+            "mass": self.mass,
+            "charge": self.charge,
+        }
+        return particle_dictionary
+
     @property
     def mass(self) -> np.float64:
         """Return the dimensionless mass of the particle."""
@@ -1819,6 +1956,34 @@ def __repr__(self):
         """
         return f"CustomParticle(mass={self.mass}, charge={self.charge})"
 
+    @property
+    def json_dict(self) -> dict:
+        """
+        A `json` friendly dictionary representation of the particle. (see
+        `AbstractParticle.json_dict` for more details)
+
+        Examples
+        --------
+        >>> custom_particle = CustomParticle(mass=5.12 * u.kg, charge=6.2 * u.C)
+        >>> custom_particle.json_dict
+        {'plasmapy_particle': {'type': 'CustomParticle',
+            'module': 'plasmapy.particles.particle_class',
+            'date_created': '...',
+            '__init__': {'args': (), 'kwargs': {'mass': '5.12 kg', 'charge': '6.2 C'}}}}
+        >>> custom_particle = CustomParticle(mass=1.5e-26 * u.kg)
+        >>> custom_particle.json_dict
+        {'plasmapy_particle': {'type': 'CustomParticle',
+            'module': 'plasmapy.particles.particle_class',
+            'date_created': '...',
+            '__init__': {'args': (), 'kwargs': {'mass': '1.5e-26 kg', 'charge': 'nan C'}}}}
+        """
+        particle_dictionary = super().json_dict
+        particle_dictionary["plasmapy_particle"]["__init__"]["kwargs"] = {
+            "mass": str(self.mass),
+            "charge": str(self.charge),
+        }
+        return particle_dictionary
+
     @property
     def mass(self) -> u.kg:
         """Return the custom particle's mass."""
diff --git a/plasmapy/particles/serialization.py b/plasmapy/particles/serialization.py
new file mode 100644
index 00000000..5be307a6
--- /dev/null
+++ b/plasmapy/particles/serialization.py
@@ -0,0 +1,101 @@
+__all__ = [
+    "json_load_particle",
+    "json_loads_particle",
+    "ParticleJSONDecoder",
+]
+
+import json
+from plasmapy.particles.exceptions import InvalidElementError
+from plasmapy.particles.particle_class import (
+    AbstractParticle,
+    CustomParticle,
+    DimensionlessParticle,
+    Particle,
+)
+
+
+class ParticleJSONDecoder(json.JSONDecoder):
+    """
+    A custom `~json.JSONDecoder` class to deserialize JSON objects into
+    PlasmaPy particle objects.
+
+    Parameters
+    ----------
+    object_hook :
+        If specified, will be called with the result of every JSON object
+        decoded and its return value will be used in place of the given `dict`.
+        This can be used to provide custom deserializations (e.g. to support
+        JSON-RPC class hinting)
+        (If not specified, then defaults to `particle_hook`.).
+    **kwargs :
+        Any keyword accepted by `~json.JSONDecoder`.
+    """
+
+    def __init__(self, *, object_hook=None, **kwargs):
+        if object_hook is None:
+            object_hook = self.particle_hook
+        json.JSONDecoder.__init__(self, object_hook=object_hook, **kwargs)
+
+    @staticmethod
+    def particle_hook(json_dict):
+        """
+        An `object_hook` utilized by the `json` deserialization processes to decode
+        json strings into a `plasmapy` particle class (`AbstractParticle`,
+        `CustomParticle`, `DimensionlessParticle`, `Particle`).
+        """
+        particle_types = {
+            "AbstractParticle": AbstractParticle,
+            "CustomParticle": CustomParticle,
+            "DimensionlessParticle": DimensionlessParticle,
+            "Particle": Particle,
+        }
+        if "plasmapy_particle" in json_dict:
+            try:
+                pardict = json_dict["plasmapy_particle"]
+                partype = pardict["type"]
+                args = pardict["__init__"]["args"]
+                kwargs = pardict["__init__"]["kwargs"]
+                particle = particle_types[partype](*args, **kwargs)
+                return particle
+            except KeyError:
+                raise InvalidElementError(
+                    f"json file does not define a valid plasmapy particle"
+                )
+        else:
+            return json_dict
+
+
+def json_load_particle(fp, *, cls=ParticleJSONDecoder, **kwargs):
+    """
+    A convenient form of `json.load` to deserialize a JSON document into a
+    PlasmaPy particle object. (Mirrors `json.load` with `cls` defaulting to
+    `ParticleJSONDecoder`.).
+
+    Parameters
+    ----------
+    fp : `file object <https://docs.python.org/3/glossary.html#term-file-object>`_
+        A file object containing a JSON document.
+    cls : `json.JSONDecoder` class
+        A `~json.JSONDecoder` class. (Default `ParticleJSONDecoder`).
+    **kwargs :
+        Any keyword accepted by `json.load`.
+    """
+    return json.load(fp, cls=cls, **kwargs)
+
+
+def json_loads_particle(s, *, cls=ParticleJSONDecoder, **kwargs):
+    """
+    A convenient form of `json.loads` to deserialize a JSON string into a
+    PlasmaPy particle object. (Mirrors `json.loads` with `cls` defaulting to
+    `ParticleJSONDecoder`.).
+
+    Parameters
+    ----------
+    s : str
+        A JSON string.
+    cls : `json.JSONDecoder` class
+        A `~json.JSONDecoder` class. (Default `ParticleJSONDecoder`).
+    **kwargs :
+        Any keyword accepted by `json.loads`.
+    """
+    return json.loads(s, cls=cls, **kwargs)


Test patch:
diff --git a/plasmapy/formulary/tests/test_dimensionless.py b/plasmapy/formulary/tests/test_dimensionless.py
index 34567ae6..910b8f72 100644
--- a/plasmapy/formulary/tests/test_dimensionless.py
+++ b/plasmapy/formulary/tests/test_dimensionless.py
@@ -8,6 +8,8 @@
     Reynolds_number,
     beta,
     quantum_theta,
+    Re_,
+    Rm_,
 )
 from plasmapy.utils import RelativityError
 
@@ -70,3 +72,10 @@ def test_Mag_Reynolds():
 
     with pytest.raises(u.UnitTypeError):
         Mag_Reynolds(2.2 * u.kg, L, sigma)
+
+
+def test_dimensionless_aliases():
+    r"""Test all aliases defined in dimensionless.py"""
+
+    assert Re_ is Reynolds_number
+    assert Rm_ is Mag_Reynolds
diff --git a/plasmapy/formulary/tests/test_drifts.py b/plasmapy/formulary/tests/test_drifts.py
index 48c8231e..11517501 100644
--- a/plasmapy/formulary/tests/test_drifts.py
+++ b/plasmapy/formulary/tests/test_drifts.py
@@ -38,6 +38,9 @@ def test_ExB_3d_array(self):
             result, (2 / 3) * u.Quantity([[[0, 0, 1]]], unit=u.m / u.s)
         )
 
+    def test_alias(self):
+        assert drifts.veb_ is drifts.ExB_drift
+
 
 class Test_force_drift:
     def test_force_x_B_1d_arrays(self):
@@ -75,3 +78,6 @@ def test_nonsensical_units(self):
 
         with pytest.raises(u.UnitTypeError):
             drifts.force_drift(F, B, q)
+
+    def test_alias(self):
+        assert drifts.vfd_ is drifts.force_drift
diff --git a/plasmapy/formulary/tests/test_ionization.py b/plasmapy/formulary/tests/test_ionization.py
index 3df0aa45..1d87c4e3 100644
--- a/plasmapy/formulary/tests/test_ionization.py
+++ b/plasmapy/formulary/tests/test_ionization.py
@@ -1,19 +1,41 @@
 import astropy.units as u
 import numpy as np
 import pytest
-from astropy.constants import c
 
-from plasmapy.formulary.ionization import Z_bal
+from plasmapy.formulary.ionization import Saha, ionization_balance, Z_bal_
 
 
-def test_Z_bal():
+def test_ionization_balance():
     n = 1e19 * u.m ** -3
     T_e = 5000 * u.K
 
-    assert (Z_bal(n, T_e) * u.dimensionless_unscaled).unit == u.dimensionless_unscaled
+    val = ionization_balance(n, T_e)
+    assert val.unit == u.dimensionless_unscaled
+    assert np.isclose(val.value, 0.27478417234035346)
+
+    # aliases must be the same function
+    assert Z_bal_ is ionization_balance
+
+    with pytest.warns(u.UnitsWarning):
+        ionization_balance(1e19, T_e)
+
+    with pytest.raises(u.UnitTypeError):
+        ionization_balance(2e19 * u.kg, T_e)
+
+
+def test_Saha():
+    g_j = 2
+    g_k = 1
+    n_e = 1e19 * u.m ** -3
+    E_jk = 10 * u.Ry
+    T_e = 5000 * u.K
+
+    val = Saha(g_j, g_k, n_e, E_jk, T_e)
+    assert val.unit == u.dimensionless_unscaled
+    assert np.isclose(val.value, 2.4775608756800435e-129)
 
     with pytest.warns(u.UnitsWarning):
-        Z_bal(1e19, T_e)
+        Saha(g_j, g_k, 1e19, E_jk, T_e)
 
     with pytest.raises(u.UnitTypeError):
-        Z_bal(2e19 * u.kg, T_e)
+        Saha(g_j, g_k, 1e19 * u.kg, E_jk, T_e)
diff --git a/plasmapy/formulary/tests/test_parameters.py b/plasmapy/formulary/tests/test_parameters.py
index bc5435f8..8d2af7d2 100644
--- a/plasmapy/formulary/tests/test_parameters.py
+++ b/plasmapy/formulary/tests/test_parameters.py
@@ -9,21 +9,42 @@
 from plasmapy.formulary.parameters import (
     Alfven_speed,
     Bohm_diffusion,
+    betaH_,
+    cs_,
+    cwp_,
+    DB_,
     Debye_length,
     Debye_number,
     gyrofrequency,
     gyroradius,
+    Hall_parameter,
     inertial_length,
     ion_sound_speed,
     kappa_thermal_speed,
+    lambdaD_,
     lower_hybrid_frequency,
     magnetic_energy_density,
     magnetic_pressure,
     mass_density,
+    nD_,
+    oc_,
     plasma_frequency,
+    pmag_,
+    pth_,
+    rc_,
+    rho_,
+    rhoc_,
     thermal_pressure,
     thermal_speed,
+    ub_,
     upper_hybrid_frequency,
+    va_,
+    vth_,
+    vth_kappa_,
+    wc_,
+    wp_,
+    wlh_,
+    wuh_,
 )
 from plasmapy.particles.exceptions import InvalidParticleError
 from plasmapy.utils.exceptions import (
@@ -1102,3 +1123,28 @@ def test_Bohm_diffusion():
 
     with pytest.raises(u.UnitTypeError):
         Bohm_diffusion(2.2 * u.kg, B)
+
+
+def test_parameters_aliases():
+    r"""Test all aliases defined in parameters.py"""
+
+    assert rho_ is mass_density
+    assert va_ is Alfven_speed
+    assert cs_ is ion_sound_speed
+    assert vth_ is thermal_speed
+    assert pth_ is thermal_pressure
+    assert vth_kappa_ is kappa_thermal_speed
+    assert betaH_ is Hall_parameter
+    assert oc_ is gyrofrequency
+    assert wc_ is gyrofrequency
+    assert rc_ is gyroradius
+    assert rhoc_ is gyroradius
+    assert wp_ is plasma_frequency
+    assert lambdaD_ is Debye_length
+    assert nD_ is Debye_number
+    assert cwp_ is inertial_length
+    assert pmag_ is magnetic_pressure
+    assert ub_ is magnetic_energy_density
+    assert wuh_ is upper_hybrid_frequency
+    assert wlh_ is lower_hybrid_frequency
+    assert DB_ is Bohm_diffusion
diff --git a/plasmapy/formulary/tests/test_quantum.py b/plasmapy/formulary/tests/test_quantum.py
index ea3e38d0..a968b75a 100644
--- a/plasmapy/formulary/tests/test_quantum.py
+++ b/plasmapy/formulary/tests/test_quantum.py
@@ -13,6 +13,9 @@
     chemical_potential,
     deBroglie_wavelength,
     thermal_deBroglie_wavelength,
+    Ef_,
+    lambdaDB_,
+    lambdaDB_th_,
 )
 
 
@@ -226,3 +229,11 @@ def test_fail1(self):
             f"and should not be equal to {fail1}."
         )
         assert testTrue, errStr
+
+
+def test_quantum_aliases():
+    r"""Test all aliases defined in quantum.py"""
+
+    assert Ef_ is Fermi_energy
+    assert lambdaDB_ is deBroglie_wavelength
+    assert lambdaDB_th_ is thermal_deBroglie_wavelength
diff --git a/plasmapy/particles/tests/test_particle_class.py b/plasmapy/particles/tests/test_particle_class.py
index 89dd7c16..117eed23 100644
--- a/plasmapy/particles/tests/test_particle_class.py
+++ b/plasmapy/particles/tests/test_particle_class.py
@@ -1,8 +1,10 @@
 import collections
 import inspect
-
+import io
+import json
 import numpy as np
 import pytest
+
 from astropy import constants as const
 from astropy import units as u
 from astropy.constants import c, e, m_e, m_n, m_p
@@ -29,6 +31,8 @@
 from plasmapy.utils import call_string, roman
 from plasmapy.utils.pytest_helpers import run_test_equivalent_calls
 
+from plasmapy.particles import json_load_particle, json_loads_particle
+
 # (arg, kwargs, results_dict)
 test_Particle_table = [
     (
@@ -1001,3 +1005,316 @@ def test_customized_particle_repr(cls, kwargs, expected_repr):
             f"from_str: {from_str}"
             f"from_repr: {from_repr}"
         )
+
+
+custom_particles_from_json_tests = [
+    (
+        DimensionlessParticle,
+        {"mass": 5.2, "charge": 6.3},
+        '{"plasmapy_particle": {"type": "DimensionlessParticle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": { \
+            "args": [], \
+            "kwargs": {"mass": 5.2, "charge": 6.3}}}}',
+        None,
+    ),
+    (
+        DimensionlessParticle,
+        {"mass": 5.2},
+        '{"plasmapy_particle": {"type": "DimensionlessParticle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": { \
+            "args": [], \
+            "kwargs": {"mass": 5.2, "charge": NaN}}}}',
+        None,
+    ),
+    (
+        CustomParticle,
+        {"mass": 5.12 * u.kg, "charge": 6.2 * u.C},
+        '{"plasmapy_particle": {"type": "CustomParticle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": { \
+            "args": [], \
+            "kwargs": {"mass": "5.12 kg", "charge": "6.2 C"}}}}',
+        None,
+    ),
+    (
+        CustomParticle,
+        {"mass": 5.12 * u.kg},
+        '{"plasmapy_particle": {"type": "CustomParticle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": { \
+            "args": [], \
+            "kwargs": {"mass": "5.12 kg", "charge": "nan C"}}}}',
+        None,
+    ),
+    (
+        DimensionlessParticle,
+        {"mass": 5.2, "charge": 6.3},
+        '{"plasmapy_particle": {"notatype": "DimensionlessParticle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": { \
+            "args": [], \
+            "kwargs": {"mass": 5.2, "charge": 6.3}}}}',
+        InvalidElementError,
+    ),
+    (
+        CustomParticle,
+        {"mass": 5.12 * u.kg},
+        '{"plasmapy_particle": {"notatype": "CustomParticle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": { \
+            "args": [], \
+            "kwargs": {"mass": "5.12 kg", "charge": "nan C"}}}}',
+        InvalidElementError,
+    ),
+    (
+        DimensionlessParticle,
+        {"mass": 5.2, "charge": 6.3},
+        '{"plasmapy_particle": {"type": "DimensionlessParticle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "fake__init__": { \
+            "args": [], \
+            "kwargs": {"mass": 5.2, "charge": 6.3}}}}',
+        InvalidElementError,
+    ),
+    (
+        CustomParticle,
+        {"mass": 5.12 * u.kg},
+        '{"plasmapy_particle": {"type": "CustomParticle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "fake__init__": { \
+            "args": [], \
+            "kwargs": {"mass": "5.12 kg", "charge": "nan C"}}}}',
+        InvalidElementError,
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "cls, kwargs, json_string, expected_exception", custom_particles_from_json_tests
+)
+def test_custom_particles_from_json_string(
+    cls, kwargs, json_string, expected_exception
+):
+    """Test the attributes of dimensionless and custom particles generated from
+    JSON representation"""
+    if expected_exception is None:
+        instance = cls(**kwargs)
+        instance_from_json = json_loads_particle(json_string)
+        assert u.isclose(
+            instance.mass, instance_from_json.mass, equal_nan=True
+        ), pytest.fail(
+            f"Expected a mass value of {instance.mass}\n"
+            f"Received a mass value of {instance_from_json.mass}"
+        )
+        assert u.isclose(
+            instance.charge, instance_from_json.charge, equal_nan=True
+        ), pytest.fail(
+            f"Expected a charge value of {instance.charge}\n"
+            f"Received a charge value of {instance_from_json.charge}"
+        )
+    else:
+        with pytest.raises(expected_exception):
+            instance_from_json = json_loads_particle(json_string)
+            pytest.fail(
+                f"{cls.__name__} with ({json_string})"
+                f" did not raise: {expected_exception.__name__}."
+            )
+
+
+@pytest.mark.parametrize(
+    "cls, kwargs, json_string, expected_exception", custom_particles_from_json_tests
+)
+def test_custom_particles_from_json_file(cls, kwargs, json_string, expected_exception):
+    """Test the attributes of dimensionless and custom particles generated from
+    JSON representation"""
+    if expected_exception is None:
+        instance = cls(**kwargs)
+        test_file_object = io.StringIO(json_string)
+        instance_from_json = json_load_particle(test_file_object)
+        assert u.isclose(
+            instance.mass, instance_from_json.mass, equal_nan=True
+        ), pytest.fail(
+            f"Expected a mass value of {instance.mass}\n"
+            f"Received a mass value of {instance_from_json.mass}"
+        )
+        assert u.isclose(
+            instance.charge, instance_from_json.charge, equal_nan=True
+        ), pytest.fail(
+            f"Expected a charge value of {instance.charge}\n"
+            f"Received a charge value of {instance_from_json.charge}"
+        )
+    else:
+        with pytest.raises(expected_exception):
+            test_file_object = io.StringIO(json_string)
+            instance_from_json = json_load_particle(test_file_object)
+            pytest.fail(
+                f"{cls.__name__} with ({json_string})"
+                f" did not raise: {expected_exception.__name__}."
+            )
+
+
+particles_from_json_tests = [
+    (
+        Particle,
+        {"argument": "Pb"},
+        '{"plasmapy_particle": {"type": "Particle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": {"args": ["Pb"], "kwargs": {}}}}',
+        None,
+    ),
+    (
+        Particle,
+        {"argument": "e-"},
+        '{"plasmapy_particle": {"type": "Particle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": {"args": ["e-"], "kwargs": {}}}}',
+        None,
+    ),
+    (
+        Particle,
+        {"argument": "e-"},
+        '{"plasmapy_particle": {"notatype": "Particle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": {"args": ["e-"], "kwargs": {}}}}',
+        InvalidElementError,
+    ),
+    (
+        Particle,
+        {"argument": "e-"},
+        '{"plasmapy_particle": {"type": "Particle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "fake__init__": {"args": ["e-"], "kwargs": {}}}}',
+        InvalidElementError,
+    ),
+]
+
+
+@pytest.mark.parametrize(
+    "cls, kwargs, json_string, expected_exception", particles_from_json_tests
+)
+def test_particles_from_json_string(cls, kwargs, json_string, expected_exception):
+    """Test the attributes of Particle objects created from JSON representation."""
+    if expected_exception is None:
+        instance = cls(**kwargs)
+        instance_from_json = json_loads_particle(json_string)
+        expected_particle = instance.particle
+        actual_particle = instance_from_json.particle
+        assert expected_particle == actual_particle, pytest.fail(
+            f"Expected {expected_particle}\n" f"Got {actual_particle}"
+        )
+    else:
+        with pytest.raises(expected_exception):
+            instance_from_json = json_loads_particle(json_string)
+            pytest.fail(
+                f"{cls.__name__} with ({json_string})"
+                f" did not raise: {expected_exception.__name__}."
+            )
+
+
+@pytest.mark.parametrize(
+    "cls, kwargs, json_string, expected_exception", particles_from_json_tests
+)
+def test_particles_from_json_file(cls, kwargs, json_string, expected_exception):
+    """Test the attributes of Particle objects created from JSON representation."""
+    if expected_exception is None:
+        instance = cls(**kwargs)
+        test_file_object = io.StringIO(json_string)
+        test_file_object.seek(0, io.SEEK_SET)
+        instance_from_json = json_load_particle(test_file_object)
+        expected_particle = instance.particle
+        actual_particle = instance_from_json.particle
+        assert expected_particle == actual_particle, pytest.fail(
+            f"Expected {expected_particle}\n" f"Got {actual_particle}"
+        )
+    else:
+        with pytest.raises(expected_exception):
+            test_file_object = io.StringIO(json_string)
+            instance_from_json = json_load_particle(test_file_object)
+            pytest.fail(
+                f"{cls.__name__} with ({json_string})"
+                f" did not raise: {expected_exception.__name__}."
+            )
+
+
+particle_json_repr_table = [
+    (
+        Particle,
+        {"argument": "lead"},
+        '{"plasmapy_particle": {"type": "Particle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", \
+        "__init__": {"args": ["Pb"], "kwargs": {}}}}',
+    ),
+    (
+        Particle,
+        {"argument": "lead"},
+        '{"plasmapy_particle": {"type": "Particle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": {"args": ["Pb"], "kwargs": {}}}}',
+    ),
+    (
+        CustomParticle,
+        {"mass": 5.12 * u.kg, "charge": 6.2 * u.C},
+        '{"plasmapy_particle": {"type": "CustomParticle", \
+        "module": "plasmapy.particles.particle_class", \
+        "date_created": "...", "__init__": {\
+            "args": [], \
+            "kwargs": {"mass": "5.12 kg", "charge": "6.2 C"}}}}',
+    ),
+    (
+        DimensionlessParticle,
+        {"mass": 5.2, "charge": 6.3},
+        '{"plasmapy_particle": {"type": "DimensionlessParticle",\
+        "module": "plasmapy.particles.particle_class",\
+        "date_created": "...", "__init__": {\
+            "args": [], \
+            "kwargs": {"mass": 5.2, "charge": 6.3}}}}',
+    ),
+]
+
+
+@pytest.mark.parametrize("cls, kwargs, expected_repr", particle_json_repr_table)
+def test_particle_to_json_string(cls, kwargs, expected_repr):
+    """Test the JSON representations of normal, dimensionless and custom particles."""
+    instance = cls(**kwargs)
+    json_repr = instance.json_dumps()
+    test_dict = json.loads(json_repr)["plasmapy_particle"]
+    expected_repr = json.loads(expected_repr)["plasmapy_particle"]
+    assert test_dict["type"] == expected_repr["type"], pytest.fail(
+        f"Problem with JSON representation of {cls.__name__} "
+        f"with kwargs = {kwargs}.\n\n"
+        f"expected type = {expected_repr['type']}\n\n"
+        f"got type: {test_dict['type']}"
+    )
+    assert expected_repr["__init__"] == test_dict["__init__"], pytest.fail(
+        f"Problem with JSON representation of {cls.__name__} "
+        f"with kwargs = {kwargs}.\n\n"
+        f"expected_repr = {expected_repr['__init__']}.\n\n"
+        f"json_repr: {test_dict['__init__']}"
+    )
+
+
+@pytest.mark.parametrize("cls, kwargs, expected_repr", particle_json_repr_table)
+def test_particle_to_json_file(cls, kwargs, expected_repr):
+    """Test the JSON representations of normal, dimensionless and custom particles."""
+    instance = cls(**kwargs)
+    test_file_object = io.StringIO("")
+    instance.json_dump(test_file_object)
+    test_file_object.seek(0, io.SEEK_SET)
+    json_repr = test_file_object.read()
+    test_dict = json.loads(json_repr)["plasmapy_particle"]
+    expected_repr = json.loads(expected_repr)["plasmapy_particle"]
+    assert test_dict["type"] == expected_repr["type"], pytest.fail(
+        f"Problem with JSON representation of {cls.__name__} "
+        f"with kwargs = {kwargs}.\n\n"
+        f"expected type = {expected_repr['type']}\n\n"
+        f"got type: {test_dict['type']}"
+    )
+    assert expected_repr["__init__"] == test_dict["__init__"], pytest.fail(
+        f"Problem with JSON representation of {cls.__name__} "
+        f"with kwargs = {kwargs}.\n\n"
+        f"expected_repr = {expected_repr['__init__']}.\n\n"
+        f"json_repr: {test_dict['__init__']}"
+    )