Repository: PlasmaPy/PlasmaPy

Issue:
Add a class to represent a 1D radial Lundquist solution for a magnetic flux rope
### Feature description

We should add a class that represents the Lundquist solution for a force-free magnetic flux rope.


### Motivation

The Lundquist solution of a magnetic flux rope or helical magnetic field is given by $$\mathbf{B}(r) = B_0 J_1(αr)\hat{θ} + B_0 J_0(αr)\hat{z},$$ where $B_0$ is a constant and $J_0$ & $J_1$ are Bessel functions. This solution was derived from the relation $$∇×\mathbf{B} = α\mathbf{B}$$ which represents a force-free equilibrium (i.e., where the Lorentz force $\mathbf{J}×\mathbf{B}=0$).  For a linear force-free field, $α$ is a constant.  

The Lundquist solution is used quite frequently to model interplanetary coronal mass ejection (ICME) magnetic fields, and would be helpful to have here. 

### Implementation strategy

The implementation should be similar to what gets implemented #1978.  I'm not sure yet about how best to implement this, but the class would probably need to accept $B_0$ and then perhaps either $α$ (or perhaps $r_0\equiv 1/α$.  The class should probably also have methods that provide $B_\theta$, $B_z$, $J_\theta$, $J_z$, and maybe the magnetic flux.  


### Additional context

This issue should probably be addressed after #1979, and perhaps around the same time as #1978.

It would also be helpful for heliophysicists to have a way to provide $B_z(r)$ and $B_\theta(r)$, and then find the best fit Lundquist solution.  That functionality might be best located outside of the class, though, in a separate function.  This would be beyond the scope of this particular issue.


Discussion:
For the Bessel functions, we should be able to use [`scipy.special.j0`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.special.j0.html#scipy.special.j0) and [`scipy.special.j1`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.special.j1.html#scipy.special.j1).

Some references use $λ$ instead of $α$, but since `lambda` is a reserved word in Python, I was thinking it would be good to use $α$ to avoid any problems with that.

Resolving patch:
diff --git a/plasmapy/plasma/__init__.py b/plasmapy/plasma/__init__.py
--- a/plasmapy/plasma/__init__.py
+++ b/plasmapy/plasma/__init__.py
@@ -7,6 +7,7 @@
 """
 
 from plasmapy.plasma import exceptions, grids, sources
+from plasmapy.plasma.cylindrical_equilibria import ForceFreeFluxRope
 from plasmapy.plasma.equilibria1d import HarrisSheet
 from plasmapy.plasma.plasma_base import BasePlasma, GenericPlasma
 from plasmapy.plasma.plasma_factory import Plasma
diff --git a/plasmapy/plasma/cylindrical_equilibria.py b/plasmapy/plasma/cylindrical_equilibria.py
new file mode 100644
--- /dev/null
+++ b/plasmapy/plasma/cylindrical_equilibria.py
@@ -0,0 +1,101 @@
+"""Classes for representing cylindrical equilibria."""
+
+import numpy as np
+import scipy.special
+
+
+class ForceFreeFluxRope:
+    r"""
+    Representation of the analytical Lundquist solution for
+    force-free magnetic flux ropes :cite:p:`lundquist:1950`.
+
+    Parameters
+    ----------
+    B0 : `~astropy.units.Quantity`
+        Magnetic field strength in units convertible to tesla.
+
+    alpha : `~astropy.units.Quantity`
+        Eigenvalue to make :math:`\mathbf{J} × \mathbf{B} = 0`, in units
+        convertible to inverse length.
+
+    Notes
+    -----
+    The Lundquist solution [also known as the Bessel Function Model (BFM)]
+    is a cylindrically symmetric force-free equilibrium which is often used
+    to approximate the magnetic structure of interplanetary coronal mass
+    ejections (ICMEs).
+    """
+
+    def __init__(self, B0, alpha):
+        self.B0 = B0
+        self.alpha = alpha
+
+    def B_theta(self, r):
+        r"""
+        Compute the component of the magnetic field in the azimuthal
+        direction.
+
+        .. math::
+
+            B_θ(r) = B_0 J_1(α r)
+
+        where :math:`α` is the eigenvalue and :math:`J_1` is the Bessel
+        function of the first kind of order 1.
+
+        Parameters
+        ----------
+        r : `~astropy.units.Quantity`
+             Radial distance from flux rope axis in units convertible
+             to meters.
+
+        Returns
+        -------
+        `~astropy.units.Quantity`
+        """
+        return self.B0 * scipy.special.j1(self.alpha * r)
+
+    def B_z(self, r):
+        r"""
+        Compute the axial component of the magnetic field.
+
+        .. math::
+
+            B_z(r) = B_0 J_0(α r)
+
+        where :math:`α` is the eigenvalue and :math:`J_0` is the Bessel
+        function of the first kind of order 0.
+
+        Parameters
+        ----------
+        r : `~astropy.units.Quantity`
+             Radial distance from flux rope axis in units convertible
+             to meters.
+
+        Returns
+        -------
+         `~astropy.units.Quantity`
+
+        """
+        return self.B0 * scipy.special.j0(self.alpha * r)
+
+    def B_magnitude(self, r):
+        r"""
+        Compute the total magnetic field.
+
+        The magnitude of the magnetic field is given by
+
+        .. math::
+
+            B(r) = \sqrt{B_z(r)^2 + B_θ(r)^2}.
+
+        Parameters
+        ----------
+        r : `~astropy.units.Quantity`
+             Radial distance from flux rope axis in units convertible
+             to meters.
+
+        Returns
+        -------
+        `~astropy.units.Quantity`
+        """
+        return np.sqrt(self.B_z(r) ** 2 + self.B_theta(r) ** 2)


Test patch:
diff --git a/plasmapy/plasma/tests/test_lundquist.py b/plasmapy/plasma/tests/test_lundquist.py
new file mode 100644
--- /dev/null
+++ b/plasmapy/plasma/tests/test_lundquist.py
@@ -0,0 +1,37 @@
+import astropy.units as u
+
+from plasmapy.plasma import ForceFreeFluxRope
+
+
+def test_B_z():
+    B0 = 2 * u.T
+    alpha = 3 * (1 / u.m)
+    r = [0, 5] * u.m
+    flux_rope = ForceFreeFluxRope(B0=B0, alpha=alpha)
+    B_z = flux_rope.B_z(r=r)
+    assert u.allclose(B_z, [B0, -0.028448945653561195 * u.T], atol=1e-9 * u.T)
+
+
+def test_B_theta():
+    B0 = 2 * u.T
+    alpha = 3 * (1 / u.m)
+    r = [0, 5] * u.m
+    flux_rope = ForceFreeFluxRope(B0=B0, alpha=alpha)
+    B_theta = flux_rope.B_theta(r=r)
+    assert u.allclose(
+        B_theta,
+        [
+            0 * u.T,
+            0.41020807722704555 * u.T,
+        ],
+        atol=1e-9 * u.T,
+    )
+
+
+def test_B_magnitude():
+    B0 = 2 * u.T
+    alpha = 3 * (1 / u.m)
+    r = [0, 5] * u.m
+    flux_rope = ForceFreeFluxRope(B0=B0, alpha=alpha)
+    B_magnitude = flux_rope.B_magnitude(r=r)
+    assert u.allclose(B_magnitude, [B0, 0.4111933962639831 * u.T], atol=1e-9 * u.T)