Repository: PlasmaPy/PlasmaPy

Issue:
Extend `gyroradius` to the relativistic limit
I just noticed that [`gyroradius`](https://docs.plasmapy.org/en/latest/api/plasmapy.formulary.lengths.gyroradius.html?highlight=gyroradius#plasmapy.formulary.lengths.gyroradius) is presently only applicable in non-relativistic cases, but there are a lot of applications in astrophysics that involve relativistic particles in magnetic fields, and it would be helpful to be able to calculate the [gyroradius in the relativistic limit](https://en.wikipedia.org/wiki/Gyroradius#Relativistic_case) as well: 

<img src="https://render.githubusercontent.com/render/math?math=r_g = \frac{p_\perp}{|q|B} = \frac{\gamma m v_\perp}{|q|B}">

I'm also wondering if we could have the Lorentz factor γ be a keyword-only argument that could be used instead of the velocity.


Discussion:
I can do this as a first contribution to PlasmaPy :)
> I can do this as a first contribution to PlasmaPy :)

@chrisarridge — That would be great!  Thank you!
Why not just have the functionality check the speed and switch over to a relativistic form when `v/c > 0.1`? ... You might still want a `use_relativistic` boolean keyword to manually force the situation.  Of course, you'd then need to use a `PhysicsWarning` or `RelativisticWarning` when trying to force the incorrect regime.

I think that would be less error prone and more explicit than a Lorentz keyword.
My motivation for having an optional Lorentz factor keyword is that when the velocity gets really close to c, we could end up with roundoff error in the calculation of the Lorentz number, and thus the gyroradius too.  One of the use cases I'm thinking of is for ultra-high energy cosmic rays which have E ≲ 10<sup>21</sup> eV so that the velocity is ridiculously close to c.  ⚡  For a case like that, the Lorentz number (or perhaps the momentum?) seems a more natural input with a much lower chance of roundoff error.

I like the idea of having a `use_relativistic` keyword or perhaps just `relativistic`.
My problem with having this work as `relativistic_gyroradius if v/c > 0.1 else slow_gyroradius` is that it'd introduce an artificial, nonphysical discontinuity at the arbitrary `v/c = 0.1`...

Perhaps we should do it the other way around? Since for small velocities the relativistic gyroradius converges to the nonrelativistic one, we could have the relativistic one be the default and have a flag saying `no_really_ignore_Einstein_I_actually_want_the_nonrelativistic_one_for_comparison_purposes`. The naming's negotiable.
I have the same feeling about an artificial step at ``v/c=0.1``, especially if arrays of ``v`` were passed in, and I like the option of defaulting to the relativistic calculation but having the facility to set a ``nonrelativistic`` boolean.

If we set by the Lorentz factor then we would still need to calculate ``v`` from that for the calculation and could give us ``v>c`` with roundoff, albeit less of an issue that ``v>c`` if we directly computed the Lorentz factor from a given ``v`` as would could end up with a complex factor.  I wonder about providing ``beta=v/c`` as the independent variable, rather than the Lorentz factor.  It avoids roundoff if ``v~c`` but also allows us to trivially set ``v`` in the calculation?  I might run some tests against high precision mathematics to see how close we have to get in different scenarios before we hit such issues.
`v/c<0.1` is not an artificial step.  If you expand the Lorentz factor and take the common approximation `v/c<<1` (aka by common practice `v/c<0.1`), then you get back `1` (or the classical limit).

My "issue" with applying the Lorentz factor all the time, is that user will be expecting the classical version, and might unexpectedly get the relativistic version.  There will also need to be some kind of reconciliation with the documentation that includes notes about both versions.

I'm not keen on the idea of adding a `beta` or `Lorentz` keyword, since we're just asking for information that may conflict with the other input arguments.  I think a more appropriate step is to calculate `v` based on input arguments and throw errors and/warnings depending on what is calculated.

My idea with `use_relativistic` is that it's three valued: (1) default to `None` which does the automatic switching; (2) `False` which always gives non-classical; and (3) `True` which always gives relativistic.  Of course all these scenarios would need to pair with appropriate exceptions and warnings to make the functionality work logically.  To me this seems like a have your cake and eat it too solution.

By no means do I think my idea is the only way forward, it's just the one that makes most sense to me on face value.  @chrisarridge I think you should just proceed with what makes the most logical sense to you, and then, as always, we'll refine in the PR review.
> `v/c<0.1` is not an artificial step. If you expand the Lorentz factor and take the common approximation `v/c<<1` (aka by common practice `v/c<0.1`), then you get back `1` (or the classical limit).

While this is true in a "common practice, conventional physics wisdom, generally unquestioned assumption" sense, it's still an approximation and not "true" in a strict, numerical sense. Here's an overview of the difference (for a proton at B = 1 T):

![gyroradius](https://user-images.githubusercontent.com/11289391/167799830-fc9060a7-b5c9-42fa-831a-7b27c72bd9e1.png)

An error of 0.5% (that's the Lorentz factor at v/c = 0.1) just below the cutoff is, of course, not "large", but I can promise you, as an inattentive and documentation-averse computationalist implementing code down the line I'd be concerned over where that's coming from! But we can get rid of it pretty easily, so why not? Of course, whether you'd treat is as an error comes down to your other point:

> My "issue" with applying the Lorentz factor all the time, is that **user will be expecting the classical version**, and might unexpectedly get the relativistic version.

When I imagine myself trying to analyse something and needing to calculate the gyroradius, I think I'd want the "closest value to the actual true gyroradius" first and any particular assumptions second. If my final output can be 1% more accurate for the low price (in developer-attention units) of using `gyroradius` rather than just `m * v / q / B`, I'd be sure to take it.

To put it another way, since the nonrelativistic worldview is a strict subset of the relativistic one, I can't imagine a situation where taking the relativistic assumption would harm you if what you're concerned is representing a physical system as close to the truth as possible.

Either your parameters are in the relativistic regime, in which case you'd need to pay attention that you're using the relativistic calculation anyway, or they aren't, in which case you lose nothing, except for a couple CPU cycles spent mostly on a square root (a 33% relative increase of 0.66 ms in my quick hackish implementation).

The relativistic correction is a great example of complexity that doesn't need to be exposed to the user, and thinking about it now I'd advocate for applying those assumptions everywhere, if they come essentially for free.

One caveat: if you have a bunch of parameters to calculate and some of them use relativistic assumptions and some of them don't, I could imagine possible worrisome issues, cancellations etc.
Hi, I've noticed that no-one has worked on this for a while, would it be ok if I tried doing this as my first commit?
> Hi, I've noticed that no-one has worked on this for a while, would it be ok if I tried doing this as my first commit?

Yes, that would be great!  Thank you!
Given that relativisticbody is now a thing, would it be a good idea make the updated gyroradius function work by accepting both particles and relativistic bodies and if not a relativistic body then converting the particle to a relativistic body to get the lorentz factor unless they specifically make it nonrelativistic? 
actually i think ill just provide an optional lorentz factor keyword
> actually i think ill just provide an optional lorentz factor keyword

That seems like a good approach.  We do want to have interoperability with `RelativisticBody`, but I'm not sure if it would be better for `gyroradius` to accept a `RelativisticBody`, or to have a `RelativisticBody.gyroradius(Vperp=...)` method.  
> I'm also wondering if we could have the Lorentz factor γ be a keyword-only argument that could be used instead of the velocity.

Wouldn't calculating velocity from lorentz factor result in some error or does it work out?
> Wouldn't calculating velocity from lorentz factor result in some error or does it work out?

`RelativisticBody` is able to get the Lorentz factor from the velocity, if I'm remembering right.  The main issue is that if $γ$ is really large, then the velocity you'd get would be within rounding error of $c$, which might lead to a floating point math warning.

I am also gonna edit part of the logic because right now it doesn't raise an error if both vperp or temperature are not passed and im pretty sure it should raise an error instead of just returning nan gyroradius
<img width="666" alt="Screenshot 2022-12-01 at 12 28 01 AM" src="https://user-images.githubusercontent.com/102393324/204936808-05de25b2-2ec9-42f1-a28a-f0c594638891.png">
> I am also gonna edit part of the logic because right now it doesn't raise an error if both vperp or temperature are not passed and im pretty sure it should raise an error instead of just returning nan gyroradius

Agreed!  That seems like a good situation for an exception with a descriptive error message.

Resolving patch:
diff --git a/plasmapy/formulary/lengths.py b/plasmapy/formulary/lengths.py
--- a/plasmapy/formulary/lengths.py
+++ b/plasmapy/formulary/lengths.py
@@ -9,6 +9,7 @@
 from astropy.constants.si import c, e, eps0, k_B
 
 from plasmapy.formulary import frequencies, speeds
+from plasmapy.formulary.relativity import RelativisticBody
 from plasmapy.particles import particle_input, ParticleLike
 from plasmapy.utils.decorators import validate_quantities
 from plasmapy.utils.exceptions import PlasmaPyFutureWarning
@@ -111,6 +112,8 @@ def gyroradius(
     Vperp: u.m / u.s = np.nan * u.m / u.s,
     T_i: u.K = None,
     T: u.K = None,
+    lorentzfactor=np.nan,
+    relativistic: bool = True,
 ) -> u.m:
     r"""Return the particle gyroradius.
 
@@ -138,6 +141,14 @@ def gyroradius(
         The particle temperature in units convertible to kelvin.
         Note: Deprecated. Use ``T`` instead.
 
+    lorentzfactor : `float` or `~numpy.ndarray`, optional, |keyword-only|
+        The Lorentz factor for the particles, use for high precision.
+
+    relativistic : `bool`, optional, |keyword-only|
+        Whether or not you want to use a relativistic approximation.
+        `True` by default.
+
+
     Returns
     -------
     r_Li : `~astropy.units.Quantity`
@@ -145,7 +156,7 @@ def gyroradius(
         `~astropy.units.Quantity` will be based on either the
         perpendicular component of particle velocity as inputted, or
         the most probable speed for a particle within a Maxwellian
-        distribution for the particle temperature.
+        distribution for the particle temperature. It is relativistically accurate.
 
     Raises
     ------
@@ -167,6 +178,9 @@ def gyroradius(
     -----
     One but not both of ``Vperp`` and ``T`` must be inputted.
 
+    ``lorentzfactor`` can be inferred from ``Vperp`` or ``T`` but
+    near the speed of light, this can lead to rounding errors.
+
     If any of ``B``, ``Vperp``, or ``T`` is a number rather than a
     `~astropy.units.Quantity`, then SI units will be assumed and a
     warning will be raised.
@@ -175,13 +189,15 @@ def gyroradius(
     radius and is given by
 
     .. math::
-        r_{Li} = \frac{V_{\perp}}{ω_{ci}}
+        r_{Li} = \frac{γ V_⟂}{ω_{ci}}
 
     where :math:`V_⟂` is the component of particle velocity that is
-    perpendicular to the magnetic field and :math:`ω_{ci}` is the
-    particle gyrofrequency.  If a temperature is provided, then
+    perpendicular to the magnetic field, :math:`ω_{ci}` is the
+    particle gyrofrequency, and :math:`γ` is the Lorentz factor.
+    If a temperature is provided, then
     :math:`V_⟂` will be the most probable thermal velocity of a
-    particle at that temperature.
+    particle at that temperature. The ``relativistic`` keyword can be
+    set to `False` to avoid the relativistic correction.
 
     Examples
     --------
@@ -193,16 +209,20 @@ def gyroradius(
     >>> gyroradius(5*u.uG, particle='alpha', T=1*u.eV)
     <Quantity 288002.38... m>
     >>> gyroradius(400*u.G, particle='Fe+++', Vperp=1e7*u.m/u.s)
-    <Quantity 48.23129... m>
+    <Quantity 48.25815... m>
     >>> gyroradius(B=0.01*u.T, particle='e-', T=1e6*u.K)
     <Quantity 0.003130... m>
     >>> gyroradius(0.01*u.T, 'e-', Vperp=1e6*u.m/u.s)
     <Quantity 0.000568... m>
     >>> gyroradius(0.2*u.T, 'e-', T=1e5*u.K)
-    <Quantity 4.94949...e-05 m>
+    <Quantity 4.94957...e-05 m>
     >>> gyroradius(5*u.uG, 'e-', T=1*u.eV)
-    <Quantity 6744.25... m>
+    <Quantity 6744.27... m>
     >>> gyroradius(400*u.G, 'e-', Vperp=1e7*u.m/u.s)
+    <Quantity 0.001422... m>
+    >>> gyroradius(400*u.G, 'e-', Vperp=1e7*u.m/u.s, lorentzfactor=1.0)
+    <Quantity 0.001421... m>
+    >>> gyroradius(400*u.G, 'e-', Vperp=1e7*u.m/u.s, relativistic=False)
     <Quantity 0.001421... m>
     """
 
@@ -220,11 +240,19 @@ def gyroradius(
                 "please use T only."
             )
 
+    if not relativistic:
+        if not np.isnan(lorentzfactor):
+            raise ValueError(
+                "Lorentz factor is provided but relativistic is set to false"
+            )
+        lorentzfactor = 1.0
+
     if T is None:
         T = np.nan * u.K
 
     isfinite_T = np.isfinite(T)
     isfinite_Vperp = np.isfinite(Vperp)
+    isfinite_lorentzfactor = np.isfinite(lorentzfactor)
 
     # check 1: ensure either Vperp or T invalid, keeping in mind that
     # the underlying values of the astropy quantity may be numpy arrays
@@ -232,6 +260,20 @@ def gyroradius(
         raise ValueError(
             "Must give Vperp or T, but not both, as arguments to gyroradius"
         )
+    elif np.any(np.logical_not(isfinite_T)) and np.any(np.logical_not(isfinite_Vperp)):
+        # any parts that are both nan, try to calc from lorentzfactor
+        if not np.isscalar(lorentzfactor):
+            raise ValueError(
+                "Inferring velocity(s) from more than one Lorentz factor is not currently supported"
+            )
+        if relativistic:
+            Vperp = np.copy(Vperp)
+            rbody = RelativisticBody(particle, lorentz_factor=lorentzfactor)
+            Vperp[~isfinite_Vperp] = rbody.velocity
+    elif np.any(isfinite_lorentzfactor) and relativistic is True:
+        warnings.warn(
+            "lorentzfactor is given along with Vperp or T, will lead to inaccurate predictions unless they correspond"
+        )
 
     # check 2: get Vperp as the thermal speed if is not already a valid input
     if np.isscalar(Vperp.value) and np.isscalar(
@@ -270,7 +312,21 @@ def gyroradius(
 
     omega_ci = frequencies.gyrofrequency(B, particle)
 
-    return np.abs(Vperp) / omega_ci
+    if np.all(isfinite_lorentzfactor):
+        return lorentzfactor * np.abs(Vperp) / omega_ci
+    elif not np.any(isfinite_lorentzfactor):
+        lorentzfactor = RelativisticBody(particle, V=Vperp).lorentz_factor
+        return lorentzfactor * np.abs(Vperp) / omega_ci
+    else:
+        # the lorentzfactor is neither completely valid nor completely invalid,
+        # so we have to correct the missing parts, note that we don't actually
+        # have to check if it is a scalar since scalars cannot be partially valid
+        rbody = RelativisticBody(particle, V=Vperp)
+        lorentzfactor = np.copy(lorentzfactor)
+        lorentzfactor[~isfinite_lorentzfactor] = rbody.lorentz_factor[
+            ~isfinite_lorentzfactor
+        ]
+        return lorentzfactor * np.abs(Vperp) / omega_ci
 
 
 rc_ = gyroradius


Test patch:
diff --git a/plasmapy/formulary/tests/test_lengths.py b/plasmapy/formulary/tests/test_lengths.py
--- a/plasmapy/formulary/tests/test_lengths.py
+++ b/plasmapy/formulary/tests/test_lengths.py
@@ -112,6 +112,17 @@ class TestGyroradius:
             ((B_arr, "e-"), {"Vperp": V, "T": T_nanarr}, ValueError),
             ((B_arr, "e-"), {"Vperp": V_nanarr, "T": T_i}, ValueError),
             ((0.4 * u.T, "e-"), {"T": 5 * u.eV, "T_i": 7 * u.eV}, ValueError),
+            ((B_arr, "e-"), {"lorentzfactor": [3.0, 2.0]}, ValueError),
+            (
+                (B_arr, "e-"),
+                {"Vperp": [25, np.nan] * u.m / u.s, "lorentzfactor": [3.0, 2.0]},
+                ValueError,
+            ),
+            (
+                (1 * u.T,),
+                {"particle": "e-", "lorentzfactor": 2.0, "relativistic": False},
+                ValueError,
+            ),
         ],
     )
     def test_raises(self, args, kwargs, _error):
@@ -198,6 +209,18 @@ def test_nan_values(self, args, kwargs, nan_mask):
                 (B_arr,),
                 {"particle": "e-", "T": T_arr},
                 [0.03130334, 0.02213481] * u.m,
+                1e-5,
+            ),
+            (
+                (B_arr,),
+                {"particle": "e-", "T": T_arr, "lorentzfactor": 1.0},
+                [0.03130334, 0.02213481] * u.m,
+                None,
+            ),
+            (
+                (B_arr,),
+                {"particle": "e-", "T": T_arr, "lorentzfactor": [1.0, 1.0]},
+                [0.03130334, 0.02213481] * u.m,
                 None,
             ),
             (
@@ -220,7 +243,7 @@ def test_nan_values(self, args, kwargs, nan_mask):
                 ([0.001, 0.002] * u.T, "e-"),
                 {"Vperp": [25, np.nan] * u.m / u.s, "T": [np.nan, 2e6] * u.K},
                 [1.42140753e-07, 2.21348073e-02] * u.m,
-                None,
+                1e-5,
             ),
             #
             # If either Vperp or T is a valid scalar and the other is a Qarray of
@@ -235,6 +258,30 @@ def test_nan_values(self, args, kwargs, nan_mask):
                 ([0.001, 0.002] * u.T, "e-"),
                 {"Vperp": [np.nan, np.nan] * u.m / u.s, "T": 1e6 * u.K},
                 [0.03130334, 0.01565167] * u.m,
+                1e-5,
+            ),
+            (
+                (1 * u.T,),
+                {"particle": "e-", "lorentzfactor": 2.0},
+                0.0029522962 * u.m,
+                None,
+            ),
+            (
+                ([0.001, 0.002] * u.T, "e-"),
+                {"Vperp": [20.5, 10.5] * u.m / u.s, "lorentzfactor": [2.0, 1.0]},
+                [2.33110834e-07, 2.98495580e-08] * u.m,
+                None,
+            ),
+            (
+                ([0.001, 0.002] * u.T, "e-"),
+                {"Vperp": [20.5, 10.5] * u.m / u.s, "lorentzfactor": [1.0, np.nan]},
+                [1.16555417e-07, 2.98495580e-08] * u.m,
+                None,
+            ),
+            (
+                (0.2 * u.T, "e-"),
+                {"T": 100000 * u.K, "relativistic": False},
+                4.94949337e-05 * u.m,
                 None,
             ),
         ],