PIC algorithm
3+1 Formalism¶
To facilitate both GR and non-GR equations in a single framework while still retaining the maximum level of generality, we employ the 3+1 formalism for projecting the space-time (see, e.g., Gourgoulhon, 2012).
In the most general 3+1 formulation, the metric and its inverse in arbitrary coordinate system can be represented in the following form:
where \(\alpha\) is the metric lapse function, and \(\beta_i\) is the metric shift vector. We will also denote \(h \equiv \mathrm{det}{(h_{ij})}\). Notice also, that \(g\equiv \mathrm{det}{(g_{\mu\nu})} = -\alpha^2 h\), and \(\sqrt{-g}=\alpha\sqrt{h}\).
In this system, we can now express the curl of an arbitrary contravariant vector:
where \(A_k = h_{kp} A^p\), and \(\varepsilon^{ijk}\) is the Levi-Civita symbol.
Maxwell's equations¶
In the general 3+1 formulation (special and general relativistic) there are two "physical" fields (the ones measured by fiducial observers, FIDOs), \(B^i\), and \(D^i\). Evolution equations on these two fields can be written as:
where \(H_i\), and \(E_i\) are the covariant auxiliary fields, defined as follows:
Flat space-time with diagonal metric
In flat space-time with diagonal metric, \(\alpha=1\), and \(\beta^i=0\), we have \(D_i = E_i = h_{ij} E^j\), and \(B_i = H_i = h_{ij} B^j\). Maxwell's equations then reduce to a closed form:
If we further assume that the coordinate system is also flat, \(h_{ij}=\delta_{ij}\), we get the more familiar form:
Equations of motion for particles¶
The equation of motion for relativistic particles in such a 3+1 formulation have the following form:
Here \(u_i\) are the three components of the particle's covariant four-velocity, \(x^i\) is its position, \(\gamma = \sqrt{\varepsilon+h^{ij}u_{i}u_j}\) is the energy of the particle (\(\varepsilon\equiv 1\) for massive particles, and \(\varepsilon\equiv 0\) for photons), \(h_{ij}\), \(h^{ij}\), \(\sqrt{h}\) as well as \(\alpha\), and \(\beta^i\) are the metric coefficients at particle's position. \(D^i\) and \(B^i\) are the contravariant field components also measured at particle's position.
Special-relativistic PIC¶
For the non-GR case we use an explicit leapfrog integrator for both fields and the particles. All the fields, as well as particle coordinates/velocities are defined in the general curvilinear (orthonormal) coordinate system.
Particle pusher in SR¶
The pseudocode below roughly illustrates the particle pusher algorithm in SR.
// em <-- 4D array of e/b-fields (encode 3 dimensions and the component of the field)
// species <-- 1D array of species
// metric <-- metric object
// dt <-- timestep
// prtl.x: coordinates in code units @ t^n (1)
// prtl.u: 4-velocities in the global Cartesian basis @ t^(n-1/2)
for spec := range species {
q_ovr_m := spec.charge / spec.mass
for prtl := range spec.prtls { //(2)!
if !prtl.is_alive {
continue
}
if spec.is_massive { //(3)!
// 1. interpolate contravariant fields to particle position
eU, bU := interpolate(em, prtl.x)
// 2. convert to global XYZ coordinates
e, b := metric.transform_xyz[Idx::U, Idx::XYZ](eU, bU) // (4)!
// 3. update particle momentum (e.g., using Boris algorithm)
prtl.u = updateMomentum(prtl.u, e, b, q_ovr_m, dt)
// 4. get 3-velocity
v = prtl.u / sqrt(1 + prtl.u**2)
} else {
// 4. get 3-velocity
v = prtl.u / sqrt(prtl.u**2)
}
// 5. record the old position
prtl.x_old = prtl.x
// 6. convert the coordinates to Cartesian basis
x = metric.convert_xyz[Crd::Cd, Crd::XYZ](prtl.x)
// 7. update the position
x += v * dt
// 8. convert back to code basis
prtl.x = metric.convert_xyz[Crd::XYZ, Crd::Cd](x)
// 9. apply boundary conditions
prtl.x, prtl.u, prtl.x_old = boundary_conditions(prtl.x, prtl.u, prtl.x_old) // (5)!
}
}
// at the end of the loop we have
// prtl.x_old: coordinates @ t^n
// prtl.x: coordinates @ t^(n+1)
// prtl.u: 4-velocities @ t^(n+1/2)
- In the actual code, we store particle coordinates as an index of the cell the particle is in plus a displacement.
- In reality, we use a structure of arrays, instead of an array of structures. Here we use a simplified notation for clarity.
- In the code, of course, we minimize the amount of runtime
if
statements by using compile-timeconstexpr if
-s and template arguments. - If there are external forces acting on the particle, or a drag force, we include them in the next step.
- Here we include absorption, reflection from the axis, periodic boundaries, etc. Communication with other domains is happeing at a different place.
General-relativistic PIC¶
Particle pusher in GR¶
Particle pusher in GR is slightly more complicated, as we can no longer transform into a global Cartesian frame, and thus unavoidably have to deal with the "geodesic" term in the equation of motion.
// em <-- 4D array of d/b-fields, with d defined @ t^n (1)
// em0 <-- 4D array of d/b-fields, with b defined @ t^n
// species <-- 1D array of species
// metric <-- metric object
// dt <-- timestep
// prtl.x: coordinates in code units @ t^n
// prtl.u: 4-velocities in code-covariant basis @ t^(n-1/2)
for spec := range species {
q_ovr_m := spec.charge / spec.mass
for prtl := range spec.prtls {
if !prtl.is_alive {
continue
}
if spec.is_massive {
// 1. interpolate contravariant fields to particle position
eU, bU := interpolate(em, em0, prtl.x)
// 2. convert to local tetrad basis
eT, bT := metric.transform[Idx::U, Idx::T](eU, bU)
// 3. get the velocity in tetrad basis
uT := metric.transform[Idx::D, Idx::T](prtl.u)
// 4. update particle momentum with electromagnetic-push (half step)
uT = updateMomentum(uT, eT, bT, q_ovr_m, dt / 2)
// 5. transform the velocity back to code-covariant basis
uD := metric.transform[Idx::T, Idx::D](uT)
// 6. update the momentum using a full geodesic push
uD = geodesicMomentumUpdate(prtl.x, uD, dt)
// 7. transform again to tetrad basis
uT = metric.transform[Idx::D, Idx::T](uD)
// 8. update with another half-step electromagnetic push
uT = updateMomentum(uT, eT, bT, q_ovr_m, dt / 2)
// 9. transform back to code-covariant basis
prtl.u = metric.transform[Idx::T, Idx::D](uT)
// 10. record the old position
prtl.x_old = prtl.x
// 11. update the coordinate using the geodesic equation
prtl.x = geodesicPositionUpdate(prtl.x, prtl.u, dt)
// 12. apply boundary conditions
prtl.x, prtl.u, prtl.x_old = boundary_conditions(prtl.x, prtl.u, prtl.x_old)
} else {
// 1. update the momentum using geodesic push
prtl.u = geodesicMomentumUpdate(prtl.x, prtl.u, dt)
// 2. update the coordinate using geodesic push
prtl.x = geodesicPositionUpdate(prtl.x, prtl.u, dt)
// 3. apply boundary conditions
prtl.x, prtl.u = boundary_conditions(prtl.x, prtl.u)
}
}
}
// at the end of the loop we have
// prtl.x_old: coordinates @ t^n
// prtl.x: coordinates @ t^(n+1)
// prtl.u: 4-velocities @ t^(n+1/2)
- To save memory, we store the fields at two time levels, and two different 4D arrays. Here we want to ensure we are passing the fields at time
t^n
. See step #2 in the diagram above.
Covariant current deposition¶
Charged particles deposit currents, \(\bm{J}^i\), that go into Maxwell's equations as source terms. In general \(\bm{J}^i = \rho \bm{\beta}^i c\), where \(\rho\) is the charge density measured in the lab frame. Coupled with the equations of motion and Maxwell's equations, this relation ensures exact charge conservation, i.e., \(\nabla\cdot \bm{J}^\mu\equiv \bm{J}^\mu_{;\mu} = 0\), where \(\bm{J}^\mu\) is the contravariant four-current with \(J^0 = \rho c\). Substituting \(\tilde{\rho}\equiv \sqrt{h}\rho\), and \(\bm{\mathcal{J}}^i\equiv \sqrt{h}\bm{J}^i\), we can rewrite the charge conservation in a more concise form:
Thus, depositing currents as \(\bm{\mathcal{J}}^i\) and then converting to \(\bm{J}^i=\bm{\mathcal{J}}^i/\sqrt{h}\) one can ensure that the exact charge conservation is maintained (see charge-conservative current deposition).