Repository: PlasmaPy/PlasmaPy

Issue:
Convert from energy to velocity in relativistic formulary
<!--

Thanks for opening an issue! To help us react appropriately to your report,
please first look through https://github.com/PlasmaPy/PlasmaPy/issues and check
whether no other issue already describes your problem.

For more general "how do I do X?" type questions, please speak to us in real
time on https://app.element.io/#/room/#plasmapy:openastronomy.org or ask on
GitHub Discussions: https://github.com/plasmapy/plasmapy/discussions . We'll be
more than happy to help! :)

-->

## Feature request
I can't find a good function in either plasmapy or astropy to convert from kinetic energy into relativistic velocity for a single particle. If it doesn't exist, I'm happy to add it to the relativistic formulary.

<!--

Please include details of the feature you would like to see, why you would
like to see it/the use case

-->



Discussion:
If this function doesn't exist somewhere I've overlooked I'm happy to help @phyzicist add this. This function would be a convenient refactor of these lines in `diagnostics.proton_radiography.SyntheticProtonRadiograph`:

```
# Calculate the velocity corresponding to the proton energy
ER = self.proton_energy * 1.6e-19 / (self.m * self._c ** 2)
v0 = self._c * np.sqrt(1 - 1 / (ER + 1) ** 2)
```
I propose adding a function:

relativistic_velocity(m, E, kinetic=False) | (default) Calculate the velocity (in meters/second) of an object with rest mass m and total relativistic energy E.
relativistic_velocity(m, E, kinetic=True) | Calculate the velocity (in meters/second) of an object with rest mass m and kinetic energy E.

And then the contents of this function would be basically what @pheuer has already written.
I could also see this being articulated as an **equivalency** between velocity and energy
Relativistic_velocity with an energy input sounds great and definitely more obvious than than any name with "equivalency" :)

One possible variant: speed rather than velocity? With an energy input we can't get the direction, and the way it's taught here in Poland speed implies the scalar magnitude of velocity. I don't actually know whether that distinction is common elsewhere.
Agreed, `relativistic_speed` would be better. It should probably take `energy` and a `particle` as required inputs?
> I propose adding a function:
>
> relativistic_velocity(m, E, kinetic=False) | (default) Calculate the velocity (in meters/second) of an object with rest mass m and total relativistic energy E.
> relativistic_velocity(m, E, kinetic=True) | Calculate the velocity (in meters/second) of an object with rest mass m and kinetic energy E.

My issue with just using `relativistic_speed` is that the speed could be calculated from total energy, kinetic energy, and momentum.  `relativistic_speed` is not descriptive enough to indicate which one it is.  We could possibly sidestep this if we queue off off the input units, something like...

```python
def relativistic_speed(value: [u.J, u.kg * u.m / u.s], mass):
    # if value.unit equivalent to u.J, convert from energy
    ...

    # if value.unit equivalent to u.kg * u.m / u.s, convert from momentum
    ...

    return speed
```

---

I got to admit, I kind of like the idea of incorporating this into an astropy equivalency.  However, it would be in addition to the functions defined here.  The "old school" astropy equivalencies is a function that returns a list of 2-4 element tuples.  A 4-element tuple is structure like `(unit1, unit2, func_u1_to_u2, func_u2_to_u1)`.  Astropy did incorporate an `Equivalency` class in `v3.2.1` but I have not played with this class yet.

How would I see this incorporated...

```python
from functools import partial

def relativistic(mass):
    equiv = [
        (
            u.m / u.s,
            u.J,
            partial(relativistic_energy, mass=mass),
            partial(relativistic_speed, mass=mass),
        ),
    ]
    return equiv
```

Then one could use it like...

```python
>>> E = 0.7 * u.MeV
>>> speed = E.to(u.m/u.s, equivalencies=relativistic(mass=Particle("e").mass)
<Quantity 2.0489278e+08 m / s>
```

---

I did procrastinate a little and tried to construct something like this and it did work relatively well.  The only issue I ran into is that the equivalencies mechanisms seemed to to strip units so I had to manually put them back in the converting functions.  ...

<img width="737" alt="image" src="https://user-images.githubusercontent.com/29869348/110168095-8a69c800-7dab-11eb-99d6-019ac520b58a.png">

> My issue with just using relativistic_speed is that the speed could be calculated from total energy, kinetic energy, and momentum. relativistic_speed is not descriptive enough to indicate which one it is. 

Just jumping in before looking at that code sample to point out a potential flaw in that premise: don't most modern editors (jupyter, pycharm, ipython shell, spyder, vscode, any sufficiently advanced vim configuration) show the signature when suggesting function calls?

 If this works as I think it does - and I'll try to get you some screenshots later - I think we can comfortably assume the signature to be part of the minimal information anyone will get while using the function.
Spyder:
![image](https://user-images.githubusercontent.com/11289391/110320909-437d0c00-8011-11eb-87fe-7cf5e4c3f13d.png)

Vscode:

![image](https://user-images.githubusercontent.com/11289391/110321041-70312380-8011-11eb-8117-fc766964fec2.png)

Yup, I'd say that confirms my suspicions! Without derailing the thread further, I'd say it should be fine to implement `relativistic_speed` as discussed, @phyzicist :)
> > My issue with just using relativistic_speed is that the speed could be calculated from total energy, kinetic energy, and momentum. relativistic_speed is not descriptive enough to indicate which one it is.
> 
> Just jumping in before looking at that code sample to point out a potential flaw in that premise: don't most modern editors (jupyter, pycharm, ipython shell, spyder, vscode, any sufficiently advanced vim configuration) show the signature when suggesting function calls?

I see your point about about IDE's having a display of parameters info, but ...

1. Not everyone uses (or knows) about this functionality and we should probably not make design decisions based on it.
2. If we're adding functionality like this then we should add the whole gambit of fundamental relativistic formulae: `relativistic_energy`, `relativistic_momentum`, `time_dilation`, and `relativistic_speed`.  Once that happens there's automatically an ambiguity with `relativistic_speed`.  To avoid this ambiguity we either need to make two `speed` functions for calculating from energy vs momentum, or combine the functionality into one.

I'll point out my suggestion above about queuing off off of units would not work with natural units `c=1`, but I don't think any of our functionality works with natural units.
Sure, but if you're aware that a function exists, you've either checked the docs, in which case you've seen either the signature or an example of usage, or you've used autocomplete, in which case you'll most likely get the pop-up anyway, no?
Sure, but that does not address my second point.  If we're going to add functionality that calculates speed from relativistic energy, then we should also add functionality to calculates speed from relativistic momentum.  We should do this to make the pkg as complete as possible.  Now, we have one of two approaches to accomplish this...

1. combine both calculations into one `relativistic_speed` function
2. split the functionality into two functions, say, `relativistic_speed_from_energy` and `relativistic_speed_from_momentum`

Now, there's pros and cons to both approaches.  I think my preference would be for option 1, but, if @phyzicist wants to take on this contribution, then I'd let him decide how he'd like to approach it.
Tbh I'm fine starting as is, from energy, and adding a dispatch-on-unit mechanism later on. It'd come in handy in a bunch of places but a general implementation is likely beyond the scope of this here issue.

But a simple implementation for both momentum -> speed and energy -> speed here would also be cool to see, of course :)

Sorry, I'm a little cranky today :D
What was the problem with just accepting velocity, momentum, or energy as the input and then calculating appropriately based on units? Like @rocco8773 described at the very beginning? It might not be the most pythonic choice, but it gets the function to fill the roles we want with the right name. Then we can worry about refactoring it using fancy python later?

The only ambiguity would be total energy vs. kinetic energy. I'd suggest assuming kinetic energy and making that clear in the function call.
> Tbh I'm fine starting as is, from energy, and adding a dispatch-on-unit mechanism later on. It'd come in handy in a bunch of places but a general implementation is likely beyond the scope of this here issue.

I'm fine with this too.  My only concern is the first part gets contributed and then the rest gets abandoned/forgotten.  The first PR contributes what was actually needed, so then there would be little to no motivation to complete the overall functionality.

> Sorry, I'm a little cranky today :D

No worries. It's a Monday so it's allowed. 😆

> What was the problem with just accepting velocity, momentum, or energy as the input and then calculating appropriately based on units? Like @rocco8773 described at the very beginning? It might not be the most pythonic choice, but it gets the function to fill the roles we want with the right name. Then we can worry about refactoring it using fancy python later?
>
> The only ambiguity would be total energy vs. kinetic energy. I'd suggest assuming kinetic energy and making that clear in the function call.

@StanczakDominik Correct me if I'm wrong, but I think this issue was mainly center with my suggestion involving some trickery that could be beyond the scope of the functionality originally proposed in this PR.  The `equivalencies` part is definitely beyond the original scope and should be contributed as its own PR.

In the final functionality I would incorporate the unit interpretation and a `kwarg` like...

```python
def relativistic_speed(value: [u.J, u.kg * u.m / u.s], particle: ParticleLike, from: str = None) -> u.m/u.s:
    ...
    return speed
```

Here the `from` would have options like: `'kinetic_energy'` and/or `'KE'`, `'total_energy'` and/or `'Etotal'`, and `'momentum'` and/or `'p'`.  If `from is None` and `value` has units of energy, then total energy would be assumed.  If there's a clash between `from` and the interpreted units, then an `Exception` would be raised.  There might be cleaner ways of doing this, but this route is the first approach that comes to my mind.
But isn't the `kinetic_energy` vs. `total_energy` the only reason you need the `from` string? Otherwise units are sufficient. It seems to me that 

```python
def relativistic_speed(value: [u.J, u.kg * u.m / u.s, particle: ParticleLike) -> u.m/u.s:
    if energy units:
         momentum = expression
   return velocity(momenutm)
```

Works if you make clear in documentation that this takes the KINETIC energy?
Ok, I see you're point.  I think this route would be a little more concise then...

```python
def relativistic_speed(value: [u.J, u.kg * u.m / u.s], particle: ParticleLike, is_kinetic_energy: bool = False) -> u.m/u.s:
    if value.units.is_equivalent(u.J):
        if is_kinetic_energy:
            # convert KE to momentum
            value = ...
        else:
            # convert Etotal to momentum
            value = ...

    # convert mometum to speed
    speed = ...

    return speed
```

Yes, that seems like the way to go (at least for now) to me!
I thought functools.singledispatch could help us write that, but units are not types, so that's out of the question.

Resolving patch:
diff --git a/plasmapy/formulary/relativity.py b/plasmapy/formulary/relativity.py
--- a/plasmapy/formulary/relativity.py
+++ b/plasmapy/formulary/relativity.py
@@ -1,12 +1,18 @@
 r"""Functions for calculating relativistic quantities (:math:`v \to c`)."""
-__all__ = ["Lorentz_factor", "relativistic_energy"]
+__all__ = ["Lorentz_factor", "relativistic_energy", "RelativisticBody"]
 
 import astropy.units as u
 import numpy as np
 
 from astropy.constants import c
+from numbers import Integral, Real
+from numpy.typing import DTypeLike
+from typing import Dict, Optional, Union
 
 from plasmapy import utils
+from plasmapy.particles._factory import _physical_particle_factory
+from plasmapy.particles.particle_class import CustomParticle, Particle, ParticleLike
+from plasmapy.particles.particle_collections import ParticleList
 from plasmapy.utils.decorators import validate_quantities
 
 
@@ -66,7 +72,7 @@ def Lorentz_factor(V: u.m / u.s):
     if not np.all((np.abs(V) <= c) | (np.isnan(V))):
         raise utils.RelativityError(
             "The Lorentz factor cannot be calculated for "
-            "speeds faster than the speed of light. "
+            "speeds faster than the speed of light."
         )
 
     if V.size > 1:
@@ -150,3 +156,351 @@ def relativistic_energy(m: u.kg, v: u.m / u.s) -> u.Joule:
     """
     γ = Lorentz_factor(v)
     return γ * m * c**2
+
+
+class RelativisticBody:
+    r"""
+    A physical body that is moving at a velocity relative to the speed
+    of light.
+
+    Parameters
+    ----------
+    particle : |ParticleLike|, |CustomParticle|, |ParticleList|, or |Quantity|
+        A representation of a particle from which to get the mass
+        of the relativistic body. If it is a |Quantity|, then it must
+        have units of mass and describe the body's rest mass.
+
+    V : |Quantity|, optional
+        The velocity of the relativistic body in units convertible to
+        m/s. The absolute magnitude of ``V`` cannot be greater than
+        :math:`c`\ .
+
+    momentum : |Quantity|, optional
+        The momentum of the relativistic body in units convertible to
+        kg·m/s.
+
+    total_energy : |Quantity|, optional, |keyword-only|
+       The sum of the mass energy and the kinetic energy in units
+       convertible to joules. Must be non-negative.
+
+    kinetic_energy : |Quantity|, optional, |keyword-only|
+       The kinetic energy of the relativistic body in units convertible
+       to joules. Must be non-negative.
+
+    v_over_c : real number or |Quantity|, optional, |keyword-only|
+       The ratio of the velocity to the speed of light. Must have an
+       absolute magnitude :math:`≤ 1`\ .
+
+    lorentz_factor : real number or |Quantity|, optional, |keyword-only|
+       The Lorentz factor of the relativistic body. Must be
+       :math:`≥ 1`\ .
+
+    Z : integer, optional, |keyword-only|
+        The charge number associated with ``particle``.
+
+    mass_numb : integer, optional, |keyword-only|
+        The mass number associated with ``particle``.
+
+    dtype : |DTypeLike|, optional, |keyword-only|, default: `numpy.longdouble`
+        The `numpy` data type to use to store the inputs.
+
+    Notes
+    -----
+    At most one of ``V``, ``momentum``, ``total_energy``,
+    ``kinetic_energy``, ``v_over_c``, and ``lorentz_factor`` must be
+    provided.
+
+    .. caution::
+
+       For ultra-high-energy cosmic rays (UHECRs), the velocity may be
+       within roundoff error of :math:`c` and :math:`\frac{V}{c}` may be
+       within roundoff error of 1.
+
+    Examples
+    --------
+    >>> import astropy.units as u
+    >>> relativistic_proton = RelativisticBody("p+", total_energy = 1 * u.GeV)
+    >>> relativistic_proton.particle
+    Particle("p+")
+    >>> relativistic_proton.velocity
+    <Quantity 1.03697...e+08 m / s>
+    >>> relativistic_proton.v_over_c
+    0.3458980898746...
+    >>> relativistic_proton.lorentz_factor
+    1.0657889247888...
+    >>> relativistic_proton.mass_energy.to("GeV")
+    <Quantity 0.93827... GeV>
+    >>> relativistic_proton.total_energy.to("GeV")
+    <Quantity 1. GeV>
+    >>> relativistic_proton.mass
+    <Quantity 1.67262...e-27 kg>
+
+    |RelativisticBody| also works with multiple particles and/or
+    velocities.
+
+    >>> particles = ["p+", "e-"]
+    >>> velocities = [2e5, 2e8] * u.m / u.s
+    >>> relativistic_particles = RelativisticBody(particles, velocities)
+    >>> relativistic_particles.momentum
+    <Quantity [3.345244...e-22, 2.445659...e-22] kg m / s>
+    """
+
+    @staticmethod
+    def _get_speed_like_input(
+        velocity_like_arguments: Dict[str, Union[u.Quantity, Real]]
+    ):
+
+        not_none_arguments = {
+            key: value
+            for key, value in velocity_like_arguments.items()
+            if value is not None
+        }
+
+        if len(not_none_arguments) != 1:
+            raise ValueError(
+                "RelativisticBody can accept no more than one of the following "
+                "arguments: V, v_over_c, momentum, total_energy, kinetic_energy, "
+                "and lorentz_factor."
+            )
+
+        return not_none_arguments or {"velocity": np.nan * u.m / u.s}
+
+    def _store_velocity_like_argument(
+        self, speed_like_input: Dict[str, Union[u.Quantity, Real]]
+    ):
+        """
+        Take the velocity-like argument and store it via the setter for
+        the corresponding attribute.
+        """
+        name = list(speed_like_input.keys())[0]
+        value = speed_like_input[name]
+        if self._dtype:
+            value = u.Quantity(value, dtype=self._dtype)
+        setattr(self, name, value)
+
+    @validate_quantities(
+        V={"can_be_inf": False, "none_shall_pass": True},
+        momentum={"can_be_inf": False, "none_shall_pass": True},
+        total_energy={"can_be_negative": False, "none_shall_pass": True},
+        kinetic_energy={"can_be_negative": False, "none_shall_pass": True},
+    )
+    def __init__(
+        self,
+        particle: Union[ParticleLike, u.Quantity],
+        V: u.m / u.s = None,
+        momentum: u.kg * u.m / u.s = None,
+        *,
+        total_energy: u.J = None,
+        kinetic_energy: u.J = None,
+        v_over_c: Optional[Real] = None,
+        lorentz_factor: Optional[Real] = None,
+        Z: Optional[Integral] = None,
+        mass_numb: Optional[Integral] = None,
+        dtype: Optional[DTypeLike] = np.longdouble,
+    ):
+
+        self._particle = _physical_particle_factory(particle, Z=Z, mass_numb=mass_numb)
+        self._dtype = dtype
+
+        velocity_like_inputs = {
+            "velocity": V,
+            "momentum": momentum,
+            "total_energy": total_energy,
+            "kinetic_energy": kinetic_energy,
+            "v_over_c": v_over_c,
+            "lorentz_factor": lorentz_factor,
+        }
+
+        speed_like_input = self._get_speed_like_input(velocity_like_inputs)
+        self._store_velocity_like_argument(speed_like_input)
+
+    def __repr__(self):
+        return f"RelativisticBody({self.particle}, {self.velocity})"
+
+    @property
+    def particle(self) -> Union[CustomParticle, Particle, ParticleList]:
+        """
+        Representation of the particle(s).
+
+        Returns
+        -------
+        |Particle|, |CustomParticle|, or |ParticleList|
+        """
+        return self._particle
+
+    @property
+    @validate_quantities
+    def mass(self) -> u.kg:
+        r"""
+        The rest mass of the body, :math:`m_0`\ .
+
+        Returns
+        -------
+        ~astropy.units.Quantity
+        """
+        return u.Quantity(self.particle.mass, dtype=self._dtype)
+
+    @property
+    @validate_quantities
+    def mass_energy(self) -> u.J:
+        r"""
+        The rest mass energy of the body, :math:`m_0 c^2`\ .
+
+        Returns
+        -------
+        ~astropy.units.Quantity
+        """
+        return self.mass * c**2
+
+    @property
+    @validate_quantities
+    def total_energy(self) -> u.J:
+        r"""
+        The sum of the rest mass energy and the kinetic energy of the
+        body,
+
+        .. math::
+
+           E_\mathrm{tot} ≡ γ m_0 c^2.
+
+        Returns
+        -------
+        ~astropy.units.Quantity
+        """
+        return np.sqrt(self.momentum**2 * c**2 + self.mass_energy**2)
+
+    @property
+    @validate_quantities
+    def kinetic_energy(self) -> u.J:
+        """
+        The kinetic energy of the body,
+
+        .. math::
+
+           E_K ≡ m_0 c^2 (γ-1).
+
+        Returns
+        -------
+        ~astropy.units.Quantity
+        """
+        return self.total_energy - self.mass_energy
+
+    @property
+    @validate_quantities
+    def v_over_c(self) -> Real:
+        r"""
+        The velocity of the body divided by the velocity of light:
+        :math:`\frac{V}{c}`\ .
+
+        Returns
+        -------
+        float
+        """
+        return (self.velocity / c).to(u.dimensionless_unscaled).value
+
+    @property
+    @validate_quantities
+    def velocity(self) -> u.m / u.s:
+        r"""
+        The velocity of the body, :math:`V`\ .
+
+        Returns
+        -------
+        ~astropy.units.Quantity
+        """
+        velocity = self.momentum / np.sqrt(self.mass**2 + self.momentum**2 / c**2)
+        return velocity.to(u.m / u.s)
+
+    @property
+    @validate_quantities
+    def lorentz_factor(self) -> Real:
+        r"""
+        The Lorentz factor of the body,
+
+        .. math::
+
+           γ ≡ \frac{1}{\sqrt{1 - \frac{V^2}{c^2}}}.
+
+        Returns
+        -------
+        float
+        """
+        return Lorentz_factor(self.velocity)
+
+    @property
+    @validate_quantities
+    def momentum(self) -> u.kg * u.m / u.s:
+        r"""
+        The magnitude of the momentum of the body,
+
+        .. math::
+
+           p ≡ γ m_0 V.
+
+        Returns
+        -------
+        ~astropy.units.Quantity
+        """
+        return getattr(self, "_momentum")
+
+    @kinetic_energy.setter
+    @validate_quantities(E_K={"can_be_negative": False})
+    def kinetic_energy(self, E_K: u.J):
+        self.total_energy = E_K + self.mass_energy
+
+    @total_energy.setter
+    @validate_quantities(E_tot={"can_be_negative": False})
+    def total_energy(self, E_tot: u.J):
+        self._momentum = np.sqrt(E_tot**2 - self.mass_energy**2) / c
+
+    @v_over_c.setter
+    def v_over_c(self, v_over_c_: Real):
+        self.velocity = v_over_c_ * c
+
+    @velocity.setter
+    @validate_quantities
+    def velocity(self, V: u.m / u.s):
+        self._momentum = (Lorentz_factor(V) * self.mass * V).to(u.kg * u.m / u.s)
+
+    @lorentz_factor.setter
+    def lorentz_factor(self, γ: Union[Real, u.Quantity]):
+        if not isinstance(γ, (Real, u.Quantity)):
+            raise TypeError("Invalid type for Lorentz factor")
+
+        if isinstance(γ, u.Quantity):
+            try:
+                γ = γ.to(u.dimensionless_unscaled).value
+            except u.UnitConversionError as exc:
+                raise u.UnitConversionError(
+                    "The Lorentz factor must be dimensionless."
+                ) from exc
+
+        if γ < 1:
+            raise ValueError("The Lorentz factor must be ≥ 1")
+
+        self.velocity = c * np.sqrt(1 - γ**-2)
+
+    @momentum.setter
+    @validate_quantities
+    def momentum(self, p: u.kg * u.m / u.s):
+        self._momentum = p.to(u.kg * u.m / u.s)
+
+    def __eq__(self, other) -> bool:
+
+        _attributes_to_compare = (
+            "particle",
+            "kinetic_energy",
+            "mass_energy",
+            "total_energy",
+            "v_over_c",
+            "momentum",
+        )
+
+        for attr in _attributes_to_compare:
+            if not hasattr(other, attr):
+                return False
+            self_value = getattr(self, attr)
+            other_value = getattr(other, attr)
+            if self_value != other_value:
+                return False
+        return True
diff --git a/plasmapy/particles/_factory.py b/plasmapy/particles/_factory.py
--- a/plasmapy/particles/_factory.py
+++ b/plasmapy/particles/_factory.py
@@ -24,6 +24,12 @@ def _physical_particle_factory(
     This function will select the appropriate type among |Particle|,
     |CustomParticle|, and |ParticleList|.
 
+    .. caution::
+
+       If |Quantity| instances are provided to this function as
+       positional arguments, then they must presently be in the order
+       expected by |CustomParticle|.
+
     Parameters
     ----------
     *args
@@ -49,6 +55,12 @@ def _physical_particle_factory(
     ~plasmapy.particles.particle_class.CustomParticle
     ~plasmapy.particles.particle_class.ParticleList
 
+    Notes
+    -=---
+    If ``Z`` or ``mass_numb`` is provided as keyword arguments but are
+    equal to `None`, then they will not be provided to any of the calls.
+    This is to allow |CustomParticle| instances to be created.
+
     Examples
     --------
     >>> from plasmapy.particles._factory import _physical_particle_factory
@@ -60,6 +72,15 @@ def _physical_particle_factory(
     >>> _physical_particle_factory(["p+", "e-"])
     ParticleList(['p+', 'e-'])
     """
+
+    # We need to remove Z and mass_numb from kwargs when they are `None`
+    # because they are not allowed as arguments to `CustomParticle`, and
+    # are not needed in kwargs if they are their default values. Note
+    # that this affects `not kwargs` below.
+    for parameter in ["Z", "mass_numb"]:
+        if parameter in kwargs and kwargs[parameter] is None:
+            kwargs.pop(parameter)
+
     if (
         len(args) == 1
         and not kwargs


Test patch:
diff --git a/plasmapy/formulary/tests/test_relativity.py b/plasmapy/formulary/tests/test_relativity.py
--- a/plasmapy/formulary/tests/test_relativity.py
+++ b/plasmapy/formulary/tests/test_relativity.py
@@ -5,8 +5,14 @@
 
 from astropy import units as u
 from astropy.constants import c
+from astropy.tests.helper import assert_quantity_allclose
 
-from plasmapy.formulary.relativity import Lorentz_factor, relativistic_energy
+from plasmapy.formulary.relativity import (
+    Lorentz_factor,
+    relativistic_energy,
+    RelativisticBody,
+)
+from plasmapy.particles import CustomParticle, electron, proton
 from plasmapy.utils.exceptions import RelativityError
 
 
@@ -92,3 +98,213 @@ def test_relativistic_energy():
 
     with pytest.raises(ValueError):
         relativistic_energy(-m, v)
+
+
+proton_at_half_c_inputs = [
+    ("v_over_c", 0.5),
+    ("velocity", 0.5 * c),
+    ("lorentz_factor", 1.1547005383792517),
+    ("total_energy", 1.7358354725115025e-10 * u.J),
+    ("kinetic_energy", 2.3255785652637692e-11 * u.J),
+    ("momentum", 2.8950619440057805e-19 * u.kg * u.m / u.s),
+]
+
+
+@pytest.mark.parametrize("attribute, expected", proton_at_half_c_inputs)
+@pytest.mark.parametrize("parameter, argument", proton_at_half_c_inputs)
+def test_relativistic_body(parameter, argument, attribute, expected):
+    """
+    Test that when we create `RelativisticBody` instances for each of
+    the different velocity-like arguments, that each of the resulting
+    `RelativisticBody` attributes end up providing the correct value.
+    """
+
+    if parameter == "velocity":
+        parameter = "V"
+
+    kwargs = {"particle": proton, parameter: argument}
+
+    relativistic_body = RelativisticBody(**kwargs)
+    actual = getattr(relativistic_body, attribute)
+
+    assert_quantity_allclose(actual, expected, rtol=1e-9)
+
+
+@pytest.mark.parametrize("attr_to_set, set_value", proton_at_half_c_inputs)
+@pytest.mark.parametrize("attr_to_test, expected", proton_at_half_c_inputs)
+def test_relativistic_body_setters(attr_to_set, set_value, attr_to_test, expected):
+    """Test setting RelativisticBody attributes."""
+    relativistic_body = RelativisticBody(proton, v_over_c=0.1)
+    setattr(relativistic_body, attr_to_set, set_value)
+
+    actual = getattr(relativistic_body, attr_to_test)
+
+    assert u.isclose(expected, actual, rtol=1e-8), (
+        f"When setting {attr_to_set} to {set_value!r} in a "
+        f"RelativisticBody instance, the value of {attr_to_test} was "
+        f"expected to be {expected!r}, but was instead {actual!r}."
+    )
+
+
+@pytest.mark.parametrize("particle", [electron, proton])
+def test_relativistic_body_mass_energy(particle):
+    """Test `RelativisticBody.mass_energy`."""
+    relativistic_body = RelativisticBody(particle, v_over_c=0)
+    expected = particle.mass * c**2
+
+    actual = relativistic_body.mass_energy
+
+    assert u.isclose(actual, expected, rtol=1e-9)
+
+
+@pytest.mark.parametrize(
+    "kwargs, exception",
+    [
+        ({"V": 299792459 * (u.m / u.s)}, RelativityError),
+        ({"v_over_c": 1.00001}, RelativityError),
+        ({"total_energy": -1 * u.J}, ValueError),
+        ({"kinetic_energy": -1 * u.J}, ValueError),
+        ({"lorentz_factor": 0.99}, ValueError),
+        ({"lorentz_factor": 0}, ValueError),
+        ({"lorentz_factor": -1}, ValueError),
+        ({"lorentz_factor": -1}, ValueError),
+        ({"lorentz_factor": -1, "v_over_c": 0.5}, ValueError),
+        ({"total_energy": 1 * u.J, "momentum": 1 * u.kg * u.m / u.s}, ValueError),
+        ({}, ValueError),
+        ({"lorentz_factor": "wrong type"}, TypeError),
+        ({"lorentz_factor": 3 * u.m / u.s}, u.UnitConversionError),
+    ],
+)
+def test_relativistic_body_init_exceptions(kwargs, exception):
+    """
+    Test that `RelativisticBody` raises the appropriate exceptions
+    during instantiation.
+    """
+    with pytest.raises(exception):
+        RelativisticBody(proton, **kwargs)
+
+
+def test_relativistic_body_equality():
+    """Test that a `RelativisticBody` instance equals itself."""
+    relativistic_body = RelativisticBody(particle=proton, v_over_c=0.34)
+    assert relativistic_body == relativistic_body
+
+
+@pytest.mark.parametrize(
+    "this, that",
+    [
+        (RelativisticBody("p+", v_over_c=0.2), RelativisticBody("p+", v_over_c=0.5)),
+        (RelativisticBody("p+", v_over_c=0.2), RelativisticBody("e-", v_over_c=0.2)),
+        (RelativisticBody("p+", V=c / 2), "different type"),
+    ],
+)
+def test_relativistic_body_inequalities(this, that):
+    """Test the inequality properties of `RelativisticBody`."""
+    assert this != that
+
+
+def test_relativistic_body_inequality_with_different_velocities():
+    """
+    Test that `RelativisticBody` instances are not equal when the
+    velocity provided to them is different.
+    """
+    slower_body = RelativisticBody(particle=proton, v_over_c=0.23)
+    faster_body = RelativisticBody(particle=proton, v_over_c=0.24)
+    assert slower_body != faster_body
+
+
+def test_relativistic_body_inequality_with_different_particles():
+    """
+    Test that `RelativisticBody` instances are not equal when the
+    particles are different.
+    """
+    relativistic_proton = RelativisticBody(particle=proton, v_over_c=0.23)
+    relativistic_electron = RelativisticBody(particle=electron, v_over_c=0.23)
+    assert relativistic_proton != relativistic_electron
+
+
+@pytest.mark.parametrize(
+    "attr",
+    [
+        "lorentz_factor",
+        "v_over_c",
+        "velocity",
+        "momentum",
+        "total_energy",
+        "kinetic_energy",
+    ],
+)
+def test_relativistic_body_defined_using_mass(attr):
+    """Test that a RelativisticBody can be provided a mass as the particle."""
+    V = c / 2
+
+    relativistic_proton = RelativisticBody(proton, V=V)
+    expected = getattr(relativistic_proton, attr)
+
+    relativistic_custom_particle = RelativisticBody(proton.mass, V=V)
+    actual = getattr(relativistic_custom_particle, attr)
+
+    assert u.isclose(actual, expected, rtol=1e-9)
+
+
+@pytest.mark.xfail(reason="RelativisticBody does not yet accept nan velocities")
+def test_relativistic_body_nan_velocity():
+    """
+    Test that RelativisticBody can be created with no velocity defined,
+    and then have the velocity be nan.
+    """
+    relativistic_body = RelativisticBody("p+")
+    assert np.isnan(relativistic_body.velocity)
+
+
+def test_relativistic_body_for_custom_particle():
+    """Test that `RelativisticBody` can be created using a `CustomParticle`."""
+    mass = 1e-27 * u.kg
+    custom_particle = CustomParticle(mass=mass)
+    velocity = 0 * u.m / u.s
+    relativistic_custom_particle = RelativisticBody(
+        particle=custom_particle, V=velocity
+    )
+    assert u.isclose(relativistic_custom_particle.lorentz_factor, 1, rtol=1e-9)
+
+
+def test_relativistic_body_with_particle_list():
+    """
+    Test that `RelativisticBody` can be instantiated with multiple
+    particles.
+    """
+    particles = ["p+", "e-"]
+    relativistic_particles = RelativisticBody(particle=particles, V=0 * u.m / u.s)
+    np.testing.assert_allclose(relativistic_particles.lorentz_factor, 1)
+
+
+def test_relativistic_body_with_multiple_velocities():
+    """
+    Test that `RelativisticBody` can be instantiated with an array of
+    velocities.
+    """
+    velocities = np.array([0, 0.5]) * c
+    relativistic_particles = RelativisticBody("p+", V=velocities)
+    np.testing.assert_allclose(
+        relativistic_particles.lorentz_factor, [1, 1.1547005383792517]
+    )
+
+
+def test_relativistic_body_with_multiple_particles_and_velocities():
+    """
+    Test that `RelativisticBody` can be instantiated with multiple
+    particles and multiple velocities.
+    """
+    velocities = np.array([0.5, 0.7]) * c
+    particles = ["p+", "e-"]
+    relativistic_particles = RelativisticBody(particle=particles, V=velocities)
+    np.testing.assert_allclose(relativistic_particles.velocity, velocities)
+
+
+@pytest.mark.parametrize("function", [repr, str])
+def test_relativistic_body_into_string(function):
+    """Test that `repr` and `str` work on RelativisticBody."""
+    relativistic_body = RelativisticBody("p+", V=5.0 * u.m / u.s)
+    expected = "RelativisticBody(p+, 5.0 m / s)"
+    actual = function(relativistic_body)
+    assert actual == expected
diff --git a/plasmapy/particles/tests/test_factory.py b/plasmapy/particles/tests/test_factory.py
--- a/plasmapy/particles/tests/test_factory.py
+++ b/plasmapy/particles/tests/test_factory.py
@@ -9,7 +9,7 @@
 
 mass = 1e-26 * u.kg
 charge = 1e-29 * u.C
-custom_particle = CustomParticle(mass, charge)
+custom_particle = CustomParticle(mass=mass, charge=charge)
 
 test_cases = [
     ([[]], {}, ParticleList()),
@@ -18,7 +18,7 @@
     (["H"], {"Z": 1, "mass_numb": 2}, deuteron),
     (["muon"], {}, Particle("muon")),
     pytest.param([charge, mass], {}, custom_particle, marks=[pytest.mark.xfail]),
-    ([mass, charge], {}, custom_particle),
+    ([mass, charge], {"Z": None, "mass_numb": None}, custom_particle),
     ([], {"symbol": "ξ"}, CustomParticle(symbol="ξ")),
     ([[proton, electron]], {}, ParticleList([proton, electron])),
     ([], {"mass": mass}, CustomParticle(mass=mass)),
@@ -28,6 +28,8 @@
     (["H"], {"Z": 1, "mass_numb": 1}, Particle("H", Z=1, mass_numb=1)),
     ([custom_particle], {}, custom_particle),
     ([ParticleList(["p+", "e-"])], {}, ParticleList(["p+", "e-"])),
+    ([mass, charge], {}, custom_particle),
+    ([mass], {}, CustomParticle(mass=mass)),
 ]
 
 
@@ -49,3 +51,14 @@ def test_physical_particle_factory(args, kwargs, expected):
 def test_particle_factory_exceptions(args, kwargs, expected):
     with pytest.raises(expected):
         _physical_particle_factory(*args, **kwargs)
+
+
+def test_particle_factory_custom_particle_with_none_kwargs():
+    """
+    Test that when `_physical_particle_factory` is provided with a
+    `CustomParticle` along with ``Z=None`` and ``mass_numb=None`` as
+    keyword arguments, then it will return the `CustomParticle`.
+    """
+    expected = CustomParticle(mass=1.27 * u.kg, charge=1 * u.C)
+    actual = _physical_particle_factory(expected, Z=None, mass_numb=None)
+    assert expected is actual