Skip to content

Magnetic Dipole

This example shows how to trace protons of a certain energy in a analytic Earth-like magnetic dipole field. There is a combination of grad-B drift, curvature drift, and the bounce motion between mirror points. It demonstrates the motions corresponding to the three adiabatic invariants.

julia
using TestParticle, OrdinaryDiffEq
import TestParticle as TP
using TestParticle: mᵢ, qᵢ, c, Rₑ
using LinearAlgebra:, norm
using CairoMakie

# Initial condition
stateinit = let
   # Initial particle energy
   Ek = 5e7 # [eV]
   # initial velocity, [m/s]
   v₀ = TP.sph2cart(c * sqrt(1 - 1 / (1 + Ek * qᵢ / (mᵢ * c^2))^2), π / 4, 0.0)
   # initial position, [m]
   r₀ = TP.sph2cart(2.5 * Rₑ, π / 2, 0.0)
   [r₀..., v₀...]
end
# obtain field
param = prepare(TP.DipoleField())
tspan = (0.0, 10.0)
(0.0, 10.0)

Full Trajectory Tracing

We first show the tracing of full proton trajectory.

julia
prob = ODEProblem(trace!, stateinit, tspan, param)
sol = solve(prob, Vern9());

Guiding Center Tracing

Next, we can trace the guiding center (GC) of the particle directly, either via trace_gc! or trace_gc_1st!. For more information about GC tracing, check out Demo: Guiding Center Approximation.

julia
stateinit_gc, param_gc = prepare_gc(stateinit, TP.getE_dipole, TP.getB_dipole)
prob_gc = ODEProblem(trace_gc_1st!, stateinit_gc, tspan, param_gc)
sol_gc = solve(prob_gc, Vern9())

# Analysis
function get_curvature_ratio(sol, param, tsample)
   q, m, μ, Efunc, Bfunc = param
   ratio = zeros(Float32, length(tsample))

   for i in eachindex(tsample)
      r = sol(tsample[i])[1:3]
      B, Bmag, b, ∇B, JB = TP.get_B_parameters(r, 0.0, Bfunc)

      # Gyroradius
      # v_perp^2 = 2 * μ * B / m
      # ρ = m * v_perp / (q * B) = m * sqrt(2μB/m) / (qB) = sqrt(2μm/B) / q
      ρ = sqrt(2 * μ * m / Bmag) / q

      # Curvature radius
      # κ = (b ⋅ ∇) b
      # κ = (JB * b + b * (-∇B ⋅ b)) / Bmag
      κ = (JB * b + b * (-∇B  b)) / Bmag
      Rc = 1 / norm(κ)

      ratio[i] = Rc / ρ
   end

   ratio
end;

Visualization

We show the full proton trajectory and the GC trajectory together with the background dipole field. The GC trajectory is colored by the ratio between the magnetic field curvature and the gyroradius at each location.

julia
f = Figure(fontsize = 18)
ax = Axis3(f[1, 1],
   title = "50 MeV Proton trajectory in Earth's dipole field",
   xlabel = "x [Re]",
   ylabel = "y [Re]",
   zlabel = "z [Re]",
   aspect = :data,
   azimuth = 1.42π,
   limits = (-2.5, 2.5, -2.5, 2.5, -1, 1)
)

nsample = 4000
tsample = range(tspan[1], tspan[2], length = nsample)
x = zeros(Float32, nsample)
y = zeros(Float32, nsample)
z = zeros(Float32, nsample)
for i in 1:nsample
   x[i], y[i], z[i] = sol(tsample[i])[1:3] ./ Rₑ
end

lines!(ax, x, y, z, label = "Full Orbit")

for ϕ in range(0, stop = 2 * π, length = 10)
   lines!(ax, TP.dipole_fieldline(ϕ)..., color = :tomato, alpha = 0.3)
end

# Plot GC trajectory
nsample = 1000
tsample = range(tspan[1], tspan[2], length = nsample)
xgc = zeros(Float32, nsample)
ygc = zeros(Float32, nsample)
zgc = zeros(Float32, nsample)
for i in 1:nsample
   xgc[i], ygc[i], zgc[i] = sol_gc(tsample[i])[1:3] ./ Rₑ
end

ratio = get_curvature_ratio(sol_gc, param_gc, tsample)

l_gc = lines!(ax, xgc, ygc, zgc, color = ratio, colormap = :plasma,
   linewidth = 3, label = "Guiding Center")
Colorbar(f[1, 2], l_gc, label = "Gyroradius / Curvature Radius")

Solver algorithm matters in terms of energy conservation. In the above we used Verner's “Most Efficient” 9/8 Runge-Kutta method. Let's check other algorithms.

julia
function get_energy_ratio(sol)
   vx = @view sol[4, :]
   vy = @view sol[5, :]
   vz = @view sol[6, :]

   Einit = vx[1]^2 + vy[1]^2 + vz[1]^2
   Eend = vx[end]^2 + vy[end]^2 + vz[end]^2

   (Eend - Einit) / Einit
end

using Markdown
using Printf

results = Tuple{String, Float64}[]

# OrdinaryDiffEq solvers
ode_solvers = [
   ("ImplicitMidpoint, dt=1e-3", ImplicitMidpoint(), Dict(:dt => 1e-3)),
   ("ImplicitMidpoint, dt=1e-4", ImplicitMidpoint(), Dict(:dt => 1e-4)),
   ("Vern9", Vern9(), Dict()),
   ("Trapezoid", Trapezoid(), Dict()),
   ("Vern6", Vern6(), Dict()),
   ("Tsit5", Tsit5(), Dict()),
   # Default stepsize settings may not be enough for our problem. By using a smaller `abstol` and `reltol`, we can guarantee much better conservation at a higher cost:
   # This is roughly equivalent in accuracy and performance with Vern9() and `reltol=1e-3` (default)
   ("Tsit5, reltol=1e-4", Tsit5(), Dict(:reltol => 1e-4))
]

for (name, alg, kwargs) in ode_solvers
   local sol = solve(prob, alg; kwargs...)
   push!(results, (name, get_energy_ratio(sol)))
end

Or, for adaptive time step algorithms like Vern9(), with the help of callbacks, we can enforce a largest time step smaller than 1/10 of the local gyroperiod:

julia
using DiffEqCallbacks

# p = (charge_mass_ratio, m, E, B)
dtFE(u, p, t) = / (abs(p[1]) * sqrt(sum(x -> x^2, p[4](u, t))))

cb = StepsizeLimiter(dtFE; safety_factor = 1 // 10, max_step = true)

sol = solve(prob, Vern9(); callback = cb, dt = 0.1) # dt=0.1 is a dummy value
push!(results, ("Vern9 with StepsizeLimiter", get_energy_ratio(sol)));

This is much more accurate, at the cost of more iterations. In terms of accuracy, this is roughly equivalent to solve(prob, Vern9(); reltol=1e-7); in terms of performance, it is 2x slower (0.04s v.s. 0.02s) and consumes about the same amount of memory 42 MiB. We can also use the classical Boris method implemented within the package:

julia
dt = 1e-4
prob_boris = TraceProblem(stateinit, tspan, param)
sol_boris = TestParticle.solve(prob_boris; dt)[1]
push!(results, ("Boris method, dt=1e-4", get_energy_ratio(sol_boris)));

Comparison of energy conservation:

SolverEnergy Ratio
ImplicitMidpoint, dt=1e-3-4.5982e-04
ImplicitMidpoint, dt=1e-4-4.9401e-09
Vern9-7.0779e-03
Trapezoid-5.5139e-02
Vern6-6.5781e-02
Tsit55.3520e-01
Tsit5, reltol=1e-46.3709e-03
Vern9 with StepsizeLimiter-5.3994e-07
Boris method, dt=1e-4-3.9618e-14

The Boris method requires a fixed time step. It takes about 0.05s and consumes 53 MiB memory. In this specific case, the time step is determined empirically. If we increase the time step to 1e-2 seconds, the trajectory becomes completely off (but the energy is still conserved). Therefore, as a rule of thumb, we should not use the default Tsit5() scheme without decreasing reltol. Use adaptive Vern9() for an unfamiliar field configuration, then switch to more accurate schemes if needed. A more thorough test can be found here.