Repository: PlasmaPy/PlasmaPy

Issue:
Refactoring of ParticleTracker, Plasma class
Closes #636 

This is going to be a pretty open-ended one. I've got a few ideas I'd like to tinker with:

- [x] Remove field interpolation code from ParticleTracker, as right now it's completely tied to Plasma3d on a uniform grid. That's completely idiotic and [those responsible should be sacked](https://github.com/PlasmaPy/PlasmaPy/blame/master/plasmapy/simulation/particletracker.py).
- [x] Put field interpolation in Plasma3d, because that's reasonable.
- [x] Replace `plasma3d` with draft AnalyticalPlasma in ParticleTracker, which probably shares a whole bunch of functionality with PlasmaBlob
- [x] reenable some of the tests
- [x] optimize ParticleTracker
- [ ] merge PlasmaBlob with AnalyticalPlasma as needed

Leftovers for future work:
   
- [x] Experiment with xarray for Plasma3D - working with the bare numpy arrays is proving to be a pain.
     - [ ] test field interpolation in there.
- [ ] Use notes from #569 


Discussion:
# [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675?src=pr&el=h1) Report
> Merging [#675](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675?src=pr&el=desc) into [master](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/b20e47b631b403242e56569ef69c4bf785e479b9&el=desc) will **decrease** coverage by `1.25%`.
> The diff coverage is `75.00%`.

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

```diff
@@            Coverage Diff             @@
##           master     #675      +/-   ##
==========================================
- Coverage   95.87%   94.61%   -1.26%     
==========================================
  Files          56       59       +3     
  Lines        5113     5353     +240     
==========================================
+ Hits         4902     5065     +163     
- Misses        211      288      +77     
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [plasmapy/simulation/particletracker.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675/diff?src=pr&el=tree#diff-cGxhc21hcHkvc2ltdWxhdGlvbi9wYXJ0aWNsZXRyYWNrZXIucHk=) | `71.05% <71.05%> (-27.26%)` | :arrow_down: |
| [plasmapy/classes/sources/plasma3d.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675/diff?src=pr&el=tree#diff-cGxhc21hcHkvY2xhc3Nlcy9zb3VyY2VzL3BsYXNtYTNkLnB5) | `96.36% <71.42%> (-3.64%)` | :arrow_down: |
| [plasmapy/classes/sources/coils.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675/diff?src=pr&el=tree#diff-cGxhc21hcHkvY2xhc3Nlcy9zb3VyY2VzL2NvaWxzLnB5) | `73.84% <73.84%> (ø)` | |
| [plasmapy/formulary/magnetostatics.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675/diff?src=pr&el=tree#diff-cGxhc21hcHkvZm9ybXVsYXJ5L21hZ25ldG9zdGF0aWNzLnB5) | `98.06% <86.95%> (-1.94%)` | :arrow_down: |
| [plasmapy/classes/sources/analyticalfields.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675/diff?src=pr&el=tree#diff-cGxhc21hcHkvY2xhc3Nlcy9zb3VyY2VzL2FuYWx5dGljYWxmaWVsZHMucHk=) | `95.00% <95.00%> (ø)` | |
| [plasmapy/classes/sources/\_\_init\_\_.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675/diff?src=pr&el=tree#diff-cGxhc21hcHkvY2xhc3Nlcy9zb3VyY2VzL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| [plasmapy/simulation/particle\_integrators.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675/diff?src=pr&el=tree#diff-cGxhc21hcHkvc2ltdWxhdGlvbi9wYXJ0aWNsZV9pbnRlZ3JhdG9ycy5weQ==) | `100.00% <100.00%> (ø)` | |
| [plasmapy/formulary/relativity.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675/diff?src=pr&el=tree#diff-cGxhc21hcHkvZm9ybXVsYXJ5L3JlbGF0aXZpdHkucHk=) | `100.00% <0.00%> (ø)` | |
| ... and [1 more](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675?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/675?src=pr&el=footer). Last update [b20e47b...aacb45d](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/675?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

Hello @StanczakDominik! Thanks for updating your pull request.



* In the file [`plasmapy/classes/sources/analyticalfields.py`](https://github.com/PlasmaPy/PlasmaPy/blob/71fe234f7554e16fdf75e21dfea0af3875224f01/plasmapy/classes/sources/analyticalfields.py):

> [Line 23:9](https://github.com/PlasmaPy/PlasmaPy/blob/71fe234f7554e16fdf75e21dfea0af3875224f01/plasmapy/classes/sources/analyticalfields.py#L23): [E731](https://duckduckgo.com/?q=pep8%20E731) do not assign a lambda expression, use a def







* In the file [`plasmapy/examples/plot_particle_stepper.py`](https://github.com/PlasmaPy/PlasmaPy/blob/71fe234f7554e16fdf75e21dfea0af3875224f01/plasmapy/examples/plot_particle_stepper.py):

> [Line 90:100](https://github.com/PlasmaPy/PlasmaPy/blob/71fe234f7554e16fdf75e21dfea0af3875224f01/plasmapy/examples/plot_particle_stepper.py#L90): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (103 > 99 characters)







* In the file [`plasmapy/simulation/particletracker.py`](https://github.com/PlasmaPy/PlasmaPy/blob/71fe234f7554e16fdf75e21dfea0af3875224f01/plasmapy/simulation/particletracker.py):

> [Line 202:100](https://github.com/PlasmaPy/PlasmaPy/blob/71fe234f7554e16fdf75e21dfea0af3875224f01/plasmapy/simulation/particletracker.py#L202): [E501](https://duckduckgo.com/?q=pep8%20E501) line too long (100 > 99 characters)















##### Comment last updated at 2020-04-30 13:05:13 UTC
I'm giving up on running calculations on `Quantities`. We'll have to live with setting velocities and positions via `_x`, `_v` for now.
This is about done; what's left is:

- [ ] writing tests and the basics for `AnalyticalPlasma`
- [ ] ... if we can't merge `AnalyticalPlasma` and `PlasmaBlob`, and we'll have to decide on whether we want to do that, including some spatial dependence into `PlasmaBlob`
I'm having a few doubts about the design here that I'll have to write down when I can muster some energy for it, right now, it's exhausting...
Progress! I should have something to share here soon-ish! :)
I wrote some basic viz for trajectories using mayavi, but that lib is proving to be difficult to actually install via CI. Ideally I'd be able to at least run readthedocs with it; that way we can show the visualization off in docs.

I was only able to get things working decently quickly after adding non quantity versions for most of the stuff I needed. This is not beautiful by any means, but it gets the job done. 

I also added the positions and velocities as private variables and provide access to the quantity ones via properties. This is because having those as the base data structures lets us do access faster than accessing quantity.si.value. I don't particularly enjoy doing it this way, but it is what it is.

I also commandeered the plasma class - brutally, at that - to add two new cases. AnalyticalPlasma, which was there in the first "sprint", takes functions of position for electric and magnetic fields. This lets you easily provide those to look at, say, particle drifts. I wanted these to generalize to multiparticle simulations, so they have a few hacks in there to ensure that can be done. This might be better renamed as "AnalyticalFields"...

I also added a Coils object that provides the same field interface and is internalized by a number of MagnetoStatics. This lets you use a few of those to construct a very basic model of a tokamak. Unfortunately to make this go sufficiently fast I had to do a few dirty tricks that result from numba jitting methods not really working right now - self does not compile to a basic numeric type, so I can't access class fields as parameters.

I also think it might make sense to have a CompositePlasma object that's the result of adding, say, AnalyticalFields and Coils - and then you could just iterate over its components in applications. Future work, though.
Things still to do (future issues):

- [x] rewrite `ParticleStepperSolution` using `xarray`

I think it's going to be a particularly good fit for that one and should make access much more comfortable.

- [ ] Adaptive time stepper

This is going to be a bit more complex:

* Position, velocity and time histories are going to have to become lists collected at each step. That's probably a good idea anyway, as with xarray + potentialy zarr support, we'll be able to run longer simulations by clearing out the history lists once they grow over some preset conditions and dump them to xarray/zarr/disk memory.
	* then we can also simplify mayavi plotting in terms of memory via sampling every N-th point.
* We'll need a clever criterion for actually changing the timestep
* Turns out it isn't at all easy to use adaptive timesteps with a symplectic integrator! Future work.

- [ ] Bring other magnetostatics in line with GeneralWire, CircularWire

I added `_magnetic_field` (the unit-less version) to those two, but didn't follow up with the rest. Thus, they're incompatible with ParticleTracker for now.
 Turns out I have no idea what's installing `hypothesis` the conda test envs, so I'm just xfailing the hypothesis test I added.
I replaced Mayavi with Pyvista, which is looking much healthier as a package, and ta-daa, https://mybinder.org/v2/gh/StanczakDominik/PlasmaPy/RefactorTracker?filepath=notebooks%2FTokamakParticles.ipynb ! I still have some tinkering and generalization to do with the visualize part, but at least it's going to show live. I also think I should now be able to get it to build as a sphinx-gallery example.
- [ ] Keep notebooks in examples dir, no plot_ in name so it doesn’t get computed on CI, store pre-rendered notebooks?
- [ ] move coils to plasmapy.plasma.fields
Okay. Phew. This is mostly done; inspect how it works at https://mybinder.org/v2/gh/StanczakDominik/PlasmaPy/RefactorTracker?filepath=notebooks%2FTokamakParticles.ipynb. I'm completely giving up on adding these to sphinx_gallery for now.
Fixes incorporated @namurphy :)
Astropy.units might work better with numba now, so some refactoring and simplification should be possible -> back to drafting board!
https://github.com/numba/numba/issues/4637#issuecomment-651422715 would be the thing to try out when coming back to this PR.

However...

I think this has become overly bloated and basically unmergeable. I think the way to go may be to close this one down and break it up in a few smaller, separate PRs, redoing the changes. I still think it was worthwhile, because I now know what to do for this.

@rocco8773, whatcha think?
@StanczakDominik I think that's a good idea. I agree with all of that.
RIP this one, then!
This pull request has been mentioned on **PlasmaPy Community Discourse Forum**. There might be relevant details there:

https://plasmapy.discourse.group/t/e-cross-b-drift/57/3


Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -49,6 +49,7 @@
     "nbsphinx",
     "sphinx_copybutton",
     "sphinx_gallery.load_style",
+    "IPython.sphinxext.ipython_console_highlighting",
 ]
 
 intersphinx_mapping = {
@@ -110,10 +111,8 @@
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
 # This patterns also effect to html_static_path and html_extra_path
-exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "**.ipynb_checkpoints"]
 
-# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = "sphinx"
 
 # If true, `todo` and `todoList` produce output, else they produce nothing.
 todo_include_todos = False
diff --git a/plasmapy/simulation/particle_integrators.py b/plasmapy/simulation/particle_integrators.py
new file mode 100644
--- /dev/null
+++ b/plasmapy/simulation/particle_integrators.py
@@ -0,0 +1,71 @@
+"""Particle movement integrators, for particle simulations.
+
+These do not have `astropy.units` support, choosing instead to
+limit overhead and increase performance.
+
+They act in-place on position and velocity arrays to reduce
+memory allocation.
+"""
+import math
+import numpy as np
+
+from astropy import constants
+
+
+def boris_push(x, v, B, E, q, m, dt):
+    r"""
+    The explicit Boris pusher.
+
+    Parameters
+    ----------
+    x : np.ndarray
+        particle position at full timestep, in SI (meter) units.
+    v : np.ndarray
+        particle velocity at half timestep, in SI (meter/second) units.
+    B : np.ndarray
+        magnetic field at full timestep, in SI (tesla) units.
+    E : float
+        electric field at full timestep, in SI (V/m) units.
+    q : float
+        particle charge, in SI (Coulomb) units.
+    m : float
+        particle mass, in SI (kg) units.
+    dt : float
+        timestep, in SI (second) units.
+
+    Notes
+    ----------
+    The Boris algorithm is the standard energy conserving algorithm for
+    particle movement in plasma physics. See [1]_ for more details, and
+    [2]_ for a nice overview.
+
+    Conceptually, the algorithm has three phases:
+
+    1. Add half the impulse from electric field.
+    2. Rotate the particle velocity about the direction of the magnetic
+       field.
+    3. Add the second half of the impulse from the electric field.
+
+    This ends up causing the magnetic field action to be properly "centered" in
+    time, and the algorithm, being a symplectic integrator, conserves energy.
+
+    References
+    ----------
+    .. [1] C. K. Birdsall, A. B. Langdon, "Plasma Physics via Computer
+           Simulation", 2004, p. 58-63
+    .. [2] L. Brieda, "Particle Push in Magnetic Field (Boris Method)",
+           https://www.particleincell.com/2011/vxb-rotation/
+    """
+    hqmdt = 0.5 * dt * q / m
+    vminus = v + hqmdt * E
+
+    # rotate to add magnetic field
+    t = B * hqmdt
+    s = 2 * t / (1 + (t * t).sum(axis=1, keepdims=True))
+    vprime = vminus + np.cross(vminus, t)
+    vplus = vminus + np.cross(vprime, s)
+
+    # add second half of electric impulse
+    v[...] = vplus + hqmdt * E
+
+    x += v * dt
diff --git a/plasmapy/simulation/particletracker.py b/plasmapy/simulation/particletracker.py
--- a/plasmapy/simulation/particletracker.py
+++ b/plasmapy/simulation/particletracker.py
@@ -12,6 +12,8 @@
 from plasmapy.particles import atomic
 from plasmapy.utils.decorators import validate_quantities
 
+from . import particle_integrators
+
 
 class ParticleTracker:
     """
@@ -58,6 +60,12 @@ class ParticleTracker:
     .. _`Particle Stepper Notebook`: ../notebooks/particle_stepper.ipynb
     """
 
+    integrators = {"explicit_boris": particle_integrators.boris_push}
+
+    _wip_integrators = {}
+
+    _all_integrators = dict(**integrators, **_wip_integrators)
+
     @validate_quantities(dt=u.s)
     def __init__(
         self,
@@ -67,6 +75,7 @@ def __init__(
         scaling=1,
         dt=np.inf * u.s,
         nt=np.inf,
+        integrator="explicit_boris",
     ):
 
         if np.isinf(dt) and np.isinf(nt):  # coverage: ignore
@@ -98,6 +107,8 @@ def __init__(
         _B = np.moveaxis(self.plasma.magnetic_field.si.value, 0, -1)
         _E = np.moveaxis(self.plasma.electric_field.si.value, 0, -1)
 
+        self.integrator = self._all_integrators[integrator]
+
         self._B_interpolator = interp.RegularGridInterpolator(
             (self.plasma.x.si.value, self.plasma.y.si.value, self.plasma.z.si.value),
             _B,
@@ -160,24 +171,16 @@ def boris_push(self, init=False):
         .. [1] C. K. Birdsall, A. B. Langdon, "Plasma Physics via Computer
                Simulation", 2004, p. 58-63
         """
-        dt = -self.dt / 2 if init else self.dt
         b, e = self._interpolate_fields()
 
-        # add first half of electric impulse
-        vminus = self.v + self.eff_q * e / self.eff_m * dt * 0.5
-
-        # rotate to add magnetic field
-        t = -b * self.eff_q / self.eff_m * dt * 0.5
-        s = 2 * t / (1 + (t * t).sum(axis=1, keepdims=True))
-        vprime = vminus + np.cross(vminus.si.value, t) * u.m / u.s
-        vplus = vminus + np.cross(vprime.si.value, s) * u.m / u.s
-
-        # add second half of electric impulse
-        v_new = vplus + self.eff_q * e / self.eff_m * dt * 0.5
-
-        self.v = v_new
-        if not init:
-            self.x += self.v * dt
+        if init:
+            self.integrator(
+                self.x.copy(), self.v, b, e, self.q, self.m, -0.5 * self.dt,
+            )  # we don't want to change position here
+        else:
+            self.integrator(
+                self.x, self.v, b, e, self.q, self.m, self.dt,
+            )
 
     def run(self):
         r"""


Test patch:
diff --git a/plasmapy/simulation/tests/test_particletracker.py b/plasmapy/simulation/tests/test_particletracker.py
--- a/plasmapy/simulation/tests/test_particletracker.py
+++ b/plasmapy/simulation/tests/test_particletracker.py
@@ -114,7 +114,7 @@ def test_particle_exb_drift(uniform_magnetic_field):
     """
     test_plasma = uniform_magnetic_field
     test_plasma.electric_field[1] = 1 * u.V / u.m
-    expected_drift_velocity = (
+    expected_drift_velocity = -(
         -(test_plasma.electric_field_strength / test_plasma.magnetic_field_strength)
         .mean()
         .to(u.m / u.s)