"""Propagators of the quasi-neutral gyrokinetic Darwin model of Remmerswaal, Hatzky and Sonnendrücker
(J. Plasma Phys. 91, E127, 2025, Sec. 6), see :class:`~struphy.models.gyrokinetic_darwin.GyrokineticDarwin`.
The unknowns are the potentials :math:`\\phi_1 \\in H^1` and :math:`\\mathbf A_1 \\in H(\\textnormal{curl})` in the
perpendicular Coulomb gauge (Eq. (6.12)), with the Lagrange multiplier :math:`\\lambda \\in H^1` of the gauge
condition. The field operators (polarization, magnetizing and flow operators, the gyro-averaged particle
couplings) are those of :mod:`struphy.propagators.gyrokinetic_maxwell`.
"""
import logging
from dataclasses import dataclass
import cunumpy as xp
from feectools.linalg.basic import LinearOperator
from feectools.linalg.solvers import inverse
from line_profiler import profile
from maybempi import MPI
from scope_profiler import ProfileManager
from struphy.feec import preconditioner
from struphy.feec.mass import WeightedAverageProjection
from struphy.io.options import LiteralOptions, OptionsBase
from struphy.linear_algebra.solver import SolverParameters
from struphy.models.variables import FEECVariable, PICVariable
from struphy.ode.utils import ButcherTableau
from struphy.pic.accumulation.kernels.gc_mag_density_0form import gc_mag_density_0form
from struphy.pic.accumulation.kernels.gk_drift_coupling import gk_drift_coupling
from struphy.pic.accumulation.kernels.gk_drift_gyro_matvec import gk_drift_gyro_matvec
from struphy.pic.accumulation.kernels.gk_drift_gyro_vector import gk_drift_gyro_vector
from struphy.pic.accumulation.kernels.gk_epar_coupling import gk_epar_coupling
from struphy.pic.accumulation.kernels.gk_epar_gyro_matvec import gk_epar_gyro_matvec
from struphy.pic.accumulation.kernels.gk_epar_gyro_vector import gk_epar_gyro_vector
from struphy.pic.accumulation.kernels.gk_mag_density_gyro import gk_mag_density_gyro
from struphy.pic.accumulation.particles_to_grid import Accumulator, AccumulatorVector
from struphy.pic.pushing.kernels.gk_push_drift_gyro import gk_push_drift_gyro
from struphy.pic.pushing.kernels.gk_push_vpar_efield import gk_push_vpar_efield
from struphy.pic.pushing.kernels.gk_push_vpar_gyro import gk_push_vpar_gyro
from struphy.pic.pushing.kernels.push_gc_bxEstar_explicit_multistage import push_gc_bxEstar_explicit_multistage
from struphy.pic.pushing.pusher import Pusher
from struphy.propagators.base import Propagator
from struphy.propagators.gyrokinetic_maxwell import (
BackgroundWeights,
GyroAverage,
GyroParticleOperator,
_PerturbedFieldMixin,
dielectric_matrix,
magnetizing_matrix,
polarization_matrix,
skin_preconditioner_matrix,
)
from struphy.utils.utils import check_option
logger = logging.getLogger("struphy")
#####################################
# Particle couplings of one species
#####################################
class _ParallelCoupling:
r"""The parallel coupling of one species: the matrix :math:`\mathbb S = \sum_p w_p \langle \vec \Lambda^1 \rangle_p \mathbf c_p \mathbf c_p^\top \langle \vec \Lambda^{1\top} \rangle_p`
(stencil matrix in the ZLR limit, matrix-free with finite Larmor radius), the current
:math:`\mathbf j = \sum_p w_p v_{\parallel,p} \mathbf c_p \langle \vec \Lambda^1 \rangle_p` and the pusher of
:math:`v_\parallel` in a given 1-form field (tensor-product coefficients ``e_tp``)."""
def __init__(self, prop: _PerturbedFieldMixin, var: PICVariable, gyro: GyroAverage, e_tp):
self.var = var
self.gyro = gyro
particles = var.particles
epsilon = var.species.equation_params.epsilon
self.epsilon = epsilon
e_args = (e_tp[0]._data, e_tp[1]._data, e_tp[2]._data)
if gyro.flr:
gyro_args = prop._gyro_args(gyro, epsilon, disc=False)
self._operator = GyroParticleOperator(
particles,
prop.derham,
prop.domain.args_domain,
gk_epar_gyro_matvec,
(*gyro_args, *prop._bstar_args()),
scale=1.0,
symmetric=True,
)
self._accum = AccumulatorVector(
particles, "Hcurl", gk_epar_gyro_vector, prop.mass_ops, prop.domain.args_domain
)
self._args = (*gyro_args, *prop._bstar_args())
args_kernel = (prop.derham.args_derham, *gyro_args, *prop._bstar_args(), *e_args)
kernel = gk_push_vpar_gyro
else:
self._accum = Accumulator(
particles,
"Hcurl",
gk_epar_coupling,
prop.mass_ops,
prop.domain.args_domain,
add_vector=True,
symmetry="symm",
)
self._args = (
epsilon,
prop._b2_full[0]._data,
prop._b2_full[1]._data,
prop._b2_full[2]._data,
prop._curl_unit_b2[0]._data,
prop._curl_unit_b2[1]._data,
prop._curl_unit_b2[2]._data,
prop._B_dot_b._data,
prop._curl_unit_b_dot_b0._data,
)
self._operator = None
args_kernel = (prop.derham.args_derham, *self._args, *e_args)
kernel = gk_push_vpar_efield
self.pusher = Pusher(
particles, kernel, args_kernel, prop.domain.args_domain, alpha_in_kernel=1.0, pushes_eta=False
)
def accumulate(self):
self._accum(*self._args)
@property
def operator(self):
"""The (unscaled) matrix :math:`\\mathbb S`."""
return self._operator if self.gyro.flr else self._accum.operators[0]
@property
def vector(self):
"""The (unscaled) current :math:`\\mathbf j`."""
return self._accum.vectors[0]
class _DriftCoupling:
r"""The drift coupling of one species: the anti-symmetric E x B operator :math:`\mathbb Q_s` and the grad-B
current :math:`\mathbf j_s` (both scaled by :math:`\alpha_s^2/\varepsilon_s`), and the explicit pusher of the
drifts in a given 1-form field (tensor-product coefficients ``e_tp``)."""
def __init__(self, prop: _PerturbedFieldMixin, var: PICVariable, gyro: GyroAverage, e_tp, butcher, mpi_sort):
self.var = var
self.gyro = gyro
particles = var.particles
alpha = var.species.equation_params.alpha
epsilon = var.species.equation_params.epsilon
scale = alpha**2 / epsilon
e_args = (e_tp[0]._data, e_tp[1]._data, e_tp[2]._data)
if gyro.flr:
ring_args = prop._gyro_args(gyro, epsilon, disc=False)
self._operator = GyroParticleOperator(
particles,
prop.derham,
prop.domain.args_domain,
gk_drift_gyro_matvec,
(scale, *ring_args, prop._B_tilde_par._data, prop._curl_unit_b_dot_b0._data),
scale=1.0,
symmetric=False,
)
self._accum = AccumulatorVector(
particles, "Hcurl", gk_drift_gyro_vector, prop.mass_ops, prop.domain.args_domain
)
flr_args = (
epsilon,
gyro.n_gyro,
gyro.ring_s,
gyro.ring_w,
gyro.disc_s,
gyro.disc_w,
*ring_args[4:],
*prop._gradb_args(),
)
self._args = (scale, *flr_args)
args_kernel = (prop.derham.args_derham, *flr_args, *e_args, butcher.a_stage, butcher.b, butcher.c)
kernel = gk_push_drift_gyro
else:
self._accum = Accumulator(
particles,
"Hcurl",
gk_drift_coupling,
prop.mass_ops,
prop.domain.args_domain,
add_vector=True,
symmetry="asym",
)
field_args = (
prop._unit_b1[0]._data,
prop._unit_b1[1]._data,
prop._unit_b1[2]._data,
prop._grad_b_full[0]._data,
prop._grad_b_full[1]._data,
prop._grad_b_full[2]._data,
prop._B_dot_b._data,
prop._curl_unit_b_dot_b0._data,
)
self._args = (epsilon, scale, *field_args)
self._operator = None
args_kernel = (
prop.derham.args_derham,
epsilon,
*field_args,
*e_args,
True,
butcher.a_stage,
butcher.b,
butcher.c,
)
kernel = push_gc_bxEstar_explicit_multistage
self.pusher = Pusher(
particles,
kernel,
args_kernel,
prop.domain.args_domain,
alpha_in_kernel=1.0,
n_stages=butcher.n_stages,
mpi_sort=mpi_sort,
pushes_eta=True,
)
def accumulate(self):
self._accum(*self._args)
@property
def operator(self):
"""The operator :math:`\\mathbb Q_s` (scaled)."""
return self._operator if self.gyro.flr else self._accum.operators[0]
@property
def vector(self):
"""The grad-B current :math:`\\mathbf j_s` (scaled)."""
return self._accum.vectors[0]
##############
# Propagators
##############
[docs]
class DarwinLongitudinalOperator(LinearOperator):
r"""The longitudinal polarization operator of the gyrokinetic Darwin model on 1-forms,
.. math::
\mathbb P_L = \mathbb P \mathbb G \left(\mathbb G^\top \mathbb P \mathbb G + \sigma \mathbb M^0 \Pi_{\textnormal{avg}}\right)^{-1} \mathbb G^\top \mathbb P\,,
the weak form of the Darwin displacement :math:`\mathbf D^{\textnormal{Dar}} = \Pi_{L,\perp}(\mathcal C(1) \mathbf E_\perp)`
(Eqs. (6.6), (6.9a) of the paper): the :math:`\mathbb P`-orthogonal projection of a 1-form on gradients, applied
matrix-free with an inner (preconditioned) conjugate-gradient solve of the perpendicular Laplacian.
The average projection :math:`\Pi_{\textnormal{avg}}` over the perpendicular directions regularizes the
kernel of the perpendicular Laplacian (the :math:`k_\perp = 0` modes, which carry no longitudinal
displacement). The operator is symmetric and positive semi-definite.
For the perpendicular fields that are constant in the perpendicular directions (:math:`k_\perp = 0`, including
the uniform field) the longitudinal projection is degenerate: they are both curl- and divergence-free in the
perpendicular plane, Eq. (6.6) assigns them no longitudinal part and the Darwin model gives them no inertia (a
:math:`k_\perp = 0` shear Alfvén wave would have no polarization current, and the net E x B current of a
non-neutral marker ensemble no equation at all). With ``perp_polarization=True`` the full (transversal)
polarization of these modes is retained, :math:`\mathbb P_L + \Pi_\perp \mathbb P \Pi_\perp` with
:math:`\Pi_\perp` the mean of the coefficients of each component over the averaged directions (a symmetric
projection, so the operator stays symmetric positive semi-definite and the energy
:math:`\frac12 \mathbf e^\top \mathbb P_L \mathbf e` includes these modes); their shear Alfvén frequency is
then the :math:`k_\perp \to 0` limit :math:`\omega = k_\parallel / \sqrt{\sum_s \alpha_s^2 n_{0,s}/B_0^2}`.
"""
def __init__(
self,
derham,
mass_ops,
P,
directions: tuple[int, ...],
stab_eps: float,
tol: float,
maxiter: int,
perp_polarization: bool = True,
):
self._derham = derham
self._P = P
self._grad = derham.grad
M0 = mass_ops.M0
self._avg = WeightedAverageProjection(derham, M0, directions)
self._laplace = self._grad.T @ P @ self._grad + stab_eps * (M0 @ self._avg)
pc = preconditioner.StiffnessPreconditioner(mass_ops, "grad", mid=P, sigma=stab_eps)
self._tol = tol
self._inv = inverse(self._laplace, "pcg", pc=pc, tol=tol, maxiter=maxiter)
self._space = P.domain
self._v0 = self._grad.domain.zeros()
self._phi = self._grad.domain.zeros()
self._v1 = self._space.zeros()
self._perp_polarization = perp_polarization
self._u = self._space.zeros()
comm = derham.comm
self._comm = comm if (comm is not None and comm.Get_size() > 1) else None
# the averaged and the kept directions, local interior slices and global sizes of the blocks
self._directions = tuple(sorted(set(directions)))
self._kept = tuple(d for d in range(3) if d not in self._directions)
self._interior = []
self._n_avg = []
self._kept_shape = []
self._kept_slices = []
for V in self._space.spaces:
self._interior += [
tuple(slice(p * m, p * m + e - s + 1) for p, m, s, e in zip(V.pads, V.shifts, V.starts, V.ends))
]
self._n_avg += [float(xp.prod(xp.asarray([V.npts[d] for d in self._directions])))]
self._kept_shape += [tuple(V.npts[d] for d in self._kept)]
self._kept_slices += [tuple(slice(V.starts[d], V.ends[d] + 1) for d in self._kept)]
@property
def domain(self):
return self._space
@property
def codomain(self):
return self._space
@property
def dtype(self):
return float
@property
def laplacian(self):
"""The regularized perpendicular Laplacian :math:`\\mathbb G^\\top \\mathbb P \\mathbb G + \\sigma \\mathbb M^0 \\Pi_{\\textnormal{avg}}`."""
return self._laplace
@property
def inverse_laplacian(self):
"""Its inverse (an iterative solver)."""
return self._inv
[docs]
def transpose(self, conjugate=False):
return self
[docs]
def tosparse(self):
raise NotImplementedError("matrix-free operator")
[docs]
def potential(self, v, out=None):
"""The potential :math:`\\phi = (\\mathbb G^\\top \\mathbb P \\mathbb G + \\ldots)^{-1} \\mathbb G^\\top \\mathbb P \\mathbf v` of a 1-form ``v``."""
self._P.dot(v, out=self._v1)
self._grad.T.dot(self._v1, out=self._v0)
return self.solve_laplacian(self._v0, out=out)
[docs]
def solve_laplacian(self, rhs, out=None):
"""Solve the regularized perpendicular Laplacian with the tolerance relative to the norm of ``rhs``."""
# relative tolerance with an absolute floor (a vanishing right-hand side gives the zero solution at once)
self._inv._options["tol"] = max(self._tol * rhs.inner(rhs) ** 0.5, 1e-150)
return self._inv.solve(rhs, out=out)
[docs]
def remove_average(self, phi):
r"""Remove (in place) the part of the 0-form ``phi`` that is constant in the averaged directions, i.e.
:math:`\phi \leftarrow (\mathbb I - \Pi_{\textnormal{avg}}) \phi`: the kernel of the perpendicular
Laplacian, whose gradient carries no longitudinal displacement. Returns the norm of the removed part."""
self._avg.dot(phi, out=self._v0)
phi -= self._v0
return self._v0.inner(self._v0) ** 0.5
[docs]
def perpendicular_average(self, v, out=None):
r"""The projection :math:`\Pi_\perp \mathbf v` on the modes constant in the averaged directions: each
component is replaced by the mean of its coefficients over these directions (an orthogonal projection)."""
if out is None:
out = self._space.zeros()
for b, ob, sl, n, shape, ksl in zip(
v.blocks, out.blocks, self._interior, self._n_avg, self._kept_shape, self._kept_slices
):
partial = xp.sum(b._data[sl], axis=self._directions)
# the sums over the averaged directions on the global grid of the kept directions
glob = xp.zeros(shape)
glob[ksl] = partial
if self._comm is not None:
self._comm.Allreduce(MPI.IN_PLACE, glob, op=MPI.SUM)
glob /= n
mean = glob[ksl]
for d in self._directions:
mean = xp.expand_dims(mean, d)
ob._data[sl] = mean
out.update_ghost_regions()
return out
[docs]
def longitudinal_part(self, v, out=None):
r"""The longitudinal part :math:`\Pi_L \mathbf v = \mathbb G \phi(\mathbf v)` of a 1-form (the gradient of its
potential, :math:`\mathbb P \Pi_L = \mathbb P_L` without the retained :math:`k_\perp = 0` polarization)."""
phi = self.potential(v, out=self._phi)
return self._grad.dot(phi, out=out)
[docs]
def longitudinal_transpose(self, w, out=None):
r"""The transpose :math:`\Pi_L^\top \mathbf w = \mathbb P \mathbb G (\ldots)^{-1} \mathbb G^\top \mathbf w`
on a dual vector (a current)."""
self._grad.T.dot(w, out=self._v0)
phi = self.solve_laplacian(self._v0, out=self._phi)
self._grad.dot(phi, out=self._v1)
if out is None:
out = self._space.zeros()
return self._P.dot(self._v1, out=out)
[docs]
def dot(self, v, out=None):
assert v.space == self._space
phi = self.potential(v, out=self._phi)
self._grad.dot(phi, out=self._v1)
if out is None:
out = self._space.zeros()
self._P.dot(self._v1, out=out)
if self._perp_polarization:
# Pi_perp P Pi_perp v
self.perpendicular_average(v, out=self._u)
self._P.dot(self._u, out=self._v1)
self.perpendicular_average(self._v1, out=self._u)
out += self._u
return out
class _LongitudinalDriftOperator(LinearOperator):
r"""The E x B operator acting on the longitudinal field only, :math:`\Pi_L^\top \mathbb Q \Pi_L` (anti-symmetric)."""
def __init__(self, PL: DarwinLongitudinalOperator, Q):
self._PL = PL
self._Q = Q
self._space = PL.domain
self._tmp1 = self._space.zeros()
self._tmp2 = self._space.zeros()
@property
def domain(self):
return self._space
@property
def codomain(self):
return self._space
@property
def dtype(self):
return float
def transpose(self, conjugate=False):
return -1.0 * self
def tosparse(self):
raise NotImplementedError("matrix-free operator")
def dot(self, v, out=None):
self._PL.longitudinal_part(v, out=self._tmp1)
self._tmp1.update_ghost_regions()
self._Q.dot(self._tmp1, out=self._tmp2)
return self._PL.longitudinal_transpose(self._tmp2, out=out)
[docs]
class DarwinFieldCoupling(Propagator, _PerturbedFieldMixin):
r"""The quasi-neutral gyrokinetic Darwin model (Sec. 6 of the paper) in the gauge-invariant form, coupled to
the gyrocentres of all species: find :math:`\mathbf E \in H(\textnormal{curl})`, :math:`\mathbf B \in H(\textnormal{div})`
and :math:`f_s` such that
.. math::
\frac{\partial \mathbf D^{\textnormal{Dar}}}{\partial t} &= \nabla \times \left(\mathbf B - \frac{p_{0,\parallel} - p_{0,\perp}}{B_0^2} \mathbf B_\perp
+ \sum_s \alpha_s^2 \int f_s\, \mu\, \mathbf b_0 \right) - \sum_s \frac{\alpha_s^2}{\varepsilon_s} \int f_s\, \dot{\mathbf R}\,,
\qquad \mathbf D^{\textnormal{Dar}} = \Pi_{L,\perp}\left(\sum_s \alpha_s^2 \frac{n_{0,s}}{B_0^2} \mathbf E_\perp\right)\,,
\\
\frac{\partial \mathbf B}{\partial t} &= - \nabla \times \mathbf E\,,
with :math:`\mathbf B = \mathbf B_0 + \tilde{\mathbf B}` (``background_current``) and the gyro-averaged
currents of the gyrocentre motion (parallel streaming along :math:`\mathbf B^*`, E x B and grad-B drifts) of
:class:`~struphy.propagators.gyrokinetic_maxwell.GyroEparCoupling` and
:class:`~struphy.propagators.gyrokinetic_maxwell.GyroDriftCoupling`. The quasi-neutrality
:math:`\nabla \cdot \mathbf D^{\textnormal{Dar}} = \rho_f` is the divergence of the first equation, and the
transversal part of the first equation (no displacement current) is the constraint of the
differential-algebraic system: the fields are the gauge-invariant :math:`\mathbf E = -\nabla \phi - \partial_t \mathbf A`
and :math:`\tilde{\mathbf B} = \nabla \times \mathbf A` of Eqs. (6.15)-(6.16), without the potentials.
In matrix form, with the longitudinal polarization operator :class:`DarwinLongitudinalOperator` :math:`\mathbb P_L`
in place of the dielectric matrix :math:`\mathbb A` of :class:`~struphy.propagators.gyrokinetic_maxwell.MaxwellGyroMagnetisation`:
.. math::
\mathbb P_L \dot{\mathbf e} = \mathbb C^\top \mathbb M^2_H (\mathbf b + \mathbf b_0) + \mathbb C^\top (\mathcal P^b)^\top \sum_s \alpha_s^2 \mathbf m_s
- \sum_s \frac{\alpha_s^2}{\varepsilon_s} \mathbf j_s(v_\parallel) - \sum_s \mathbb Q_s \mathbf e - \mathbf j_{\nabla B}\,,
\qquad \dot{\mathbf b} = -\mathbb C \mathbf e\,,
\qquad \dot v_{\parallel,p} = \frac{1}{\varepsilon_s} \mathbf c_p \cdot \langle \vec \Lambda^{1\top} \rangle_p \mathbf e\,,
discretized with the implicit mid-point rule in :math:`(\mathbf e, \mathbf b, v_\parallel)`, all couplings included
(:math:`\mathbf c_p` frozen at :math:`v_\parallel^n`, the markers at :math:`\boldsymbol \eta^n`):
.. math::
\left(\mathbb P_L + \frac{\Delta t^2}{4} (\mathbb K + \mathbb S) + \frac{\Delta t}{2} \mathbb Q\right) \mathbf e^{n+1}
= \left(\mathbb P_L - \frac{\Delta t^2}{4} (\mathbb K + \mathbb S) - \frac{\Delta t}{2} \mathbb Q\right) \mathbf e^{n}
+ \Delta t \left(\mathbb C^\top \mathbb M^2_H (\mathbf b^n + \mathbf b_0) + \mathbf s_m - \mathbf j^n_\parallel - \mathbf j_{\nabla B}\right)\,,
with :math:`\mathbb K = \mathbb C^\top \mathbb M^2_H \mathbb C`, :math:`\mathbb S = \sum_s \frac{\alpha_s^2}{\varepsilon_s^2} \mathbb S_s`,
:math:`\mathbb Q = \sum_s \mathbb Q_s` (stencil matrices in the ZLR limit, matrix-free with finite Larmor radius) and
the magnetisation source :math:`\mathbf s_m`; then :math:`\mathbf b^{n+1} = \mathbf b^n - \Delta t\, \mathbb C \bar{\mathbf e}`,
the parallel velocities are pushed with :math:`\bar{\mathbf e} = (\mathbf e^n + \mathbf e^{n+1})/2`,
the drifts with an explicit Runge-Kutta scheme in :math:`\mathbf b^n` and the longitudinal field
:math:`\Pi_L \mathbf e^n = -\mathbb G \boldsymbol \phi^n` before the field solve (the couplings are accumulated
at the new positions). The E x B operator and the grad-B current act on the longitudinal field as well,
:math:`\mathbb Q \to \Pi_L^\top \mathbb Q \Pi_L` and :math:`\mathbf j_{\nabla B} \to \Pi_L^\top \mathbf j_{\nabla B}`:
the transversal part of the Darwin field is the multiplier of the quasi-static transversal Ampère law, not a
dynamical field, and does not drive the drifts (as in the :math:`A_\parallel` gyrokinetic models, where the
E x B drift is electrostatic). The operator is positive definite
(longitudinal part from :math:`\mathbb P_L`, transversal part from :math:`\mathbb K + \mathbb S`), the system is solved
with ``pbicgstab`` (``pcg`` without drifts) preconditioned by the weighted mass matrix of the polarization
plus the continuum limit of the skin term. The mid-point rule conserves the Darwin energy
:math:`\frac12 \mathbf e^\top \mathbb P_L \mathbf e + \frac12 \mathbf b^\top \mathbb M^2_H \mathbf b + \sum_s \alpha_s^2 \sum_p w_p (v_\parallel^2/2 + \mu B_\parallel)`
exactly for the parallel and E x B couplings (the grad-B current exchange is second order).
"""
[docs]
class Variables:
"""Container for the variables advanced by :class:`DarwinFieldCoupling`.
Attributes
----------
e : FEECVariable
Electric field in H(curl) space.
b : FEECVariable
Magnetic perturbation in H(div) space.
"""
def __init__(self):
self._e: FEECVariable = None
self._b: FEECVariable = None
@property
def e(self) -> FEECVariable:
return self._e
@e.setter
def e(self, new):
assert isinstance(new, FEECVariable)
assert new.space == "Hcurl"
self._e = new
@property
def b(self) -> FEECVariable:
return self._b
@b.setter
def b(self, new):
assert isinstance(new, FEECVariable)
assert new.space == "Hdiv"
self._b = new
def __init__(
self,
species: tuple[PICVariable, ...] = (),
background: BackgroundWeights = None,
polarization=0.0,
background_current: bool = False,
gyro: GyroAverage = None,
with_drifts: bool = True,
):
"""
Parameters
----------
species : tuple[PICVariable]
The gyrocentre species (``"Particles5D"``), whose currents and magnetisation enter the field equations
and whose parallel velocities and drifts are pushed.
background : BackgroundWeights, default=None
The background moments of the species; ``None`` for the polarization ``polarization`` times the fluid
equilibrium density.
polarization : float | Callable
The polarization with ``background=None``.
background_current : bool
Whether :math:`\\nabla \\times \\mathbf B_0` enters Ampère's law.
gyro : GyroAverage, default=None
The gyro-averaging (``None``: zero-Larmor-radius limit).
with_drifts : bool
Whether the E x B and grad-B drifts and their currents are included.
"""
self.variables = self.Variables()
for var in species:
assert isinstance(var, PICVariable)
assert var.space == "Particles5D"
self.species = tuple(species)
self.background = background
self.polarization = polarization
self.background_current = background_current
self.gyro = gyro if gyro is not None else GyroAverage()
self.with_drifts = with_drifts
[docs]
@dataclass(repr=False)
class Options(OptionsBase):
"""Configuration options for :class:`DarwinFieldCoupling`.
Parameters
----------
solver : str, default=None
Iterative solver: a general one (``LiteralOptions.OptsGenSolver``) solves the full system including the
anti-symmetric E x B operator, a symmetric one (``LiteralOptions.OptsSymmSolver``) solves the symmetric
part with Picard iterations for the E x B operator (``picard_maxiter``, ``picard_tol``; they converge
only for small time steps). ``None``: ``"pbicgstab"`` with drifts, ``"pcg"`` without.
precond : LiteralOptions.OptsMassPrecond, default="MassMatrixPreconditioner"
Preconditioner, built from the weighted mass matrix of the polarization plus the continuum limit
of the skin term (:func:`~struphy.propagators.gyrokinetic_maxwell.skin_preconditioner_matrix`).
solver_params : SolverParameters, default=None
Iterative-solver controls. If ``None``, defaults to ``SolverParameters()``. The tolerance is
relative to the norm of the right-hand side (the system has a kernel, the uniform perpendicular
field, and its scale is set by the skin term).
inner_tol : float, default=1e-12
Tolerance of the inner solves of the perpendicular Laplacian (relative to the right-hand side).
inner_maxiter : int, default=1000
Maximum iterations of the inner solves.
kernel_average : str, default="eta1 eta2"
The directions of the average projection that regularizes the kernel of the perpendicular Laplacian
(the directions perpendicular to the background field; ``""`` for the global mean only).
stab_eps : float, default=1.0
The regularization :math:`\\sigma` of that kernel.
perp_polarization : bool, default=True
Whether the (transversal) polarization of the fields constant in the averaged directions
(:math:`k_\\perp = 0`, where the longitudinal projection is degenerate and the Darwin model has no
inertia) is retained, see :class:`DarwinLongitudinalOperator`.
picard_maxiter : int, default=20
Maximum number of Picard iterations for the E x B operator (symmetric solvers only).
picard_tol : float, default=1e-10
Relative tolerance of the Picard iterations.
butcher : ButcherTableau, default=None
Butcher tableau of the explicit drift push, default ``rk2``.
mpi_sort : LiteralOptions.OptsMPIsort, default="last"
MPI sorting policy of the drift push.
"""
solver: str = None
precond: LiteralOptions.OptsMassPrecond = "MassMatrixPreconditioner"
solver_params: SolverParameters = None
inner_tol: float = 1e-12
inner_maxiter: int = 1000
kernel_average: str = "eta1 eta2"
stab_eps: float = 1.0
perp_polarization: bool = True
picard_maxiter: int = 20
picard_tol: float = 1e-10
butcher: ButcherTableau = None
mpi_sort: LiteralOptions.OptsMPIsort = "last"
def __post_init__(self):
if self.solver is not None:
assert self.solver in LiteralOptions.OptsSymmSolver.__args__ + LiteralOptions.OptsGenSolver.__args__, (
f"Unknown solver {self.solver!r}."
)
check_option(self.precond, LiteralOptions.OptsMassPrecond)
check_option(self.mpi_sort, LiteralOptions.OptsMPIsort)
if self.solver_params is None:
self.solver_params = SolverParameters()
if self.butcher is None:
self.butcher = ButcherTableau(algo="rk2")
@property
def options(self) -> Options:
if not hasattr(self, "_options"):
self._options = self.Options()
return self._options
@options.setter
def options(self, new):
assert isinstance(new, self.Options)
self._options = new
logger.info(f"\nNew options for propagator '{self.__class__.__name__}':\n{self._options}")
@profile
def allocate(self):
self._info = self.options.solver_params.info
curl = self.derham.curl
polarization = self.background.polarization if self.background is not None else self.polarization
self._P = polarization_matrix(self.mass_ops, polarization)
aniso = None
if self.background is not None and self.background.has_anisotropy:
aniso = self.background.anisotropy
self._M2H = magnetizing_matrix(self.mass_ops, aniso)
self._CTMC = curl.T @ self._M2H @ curl
self._curlT = curl.T
directions = preconditioner._parse_directions(self.options.kernel_average)
if not directions:
directions = (0, 1, 2)
self._PL = DarwinLongitudinalOperator(
self.derham,
self.mass_ops,
self._P,
directions,
self.options.stab_eps,
self.options.inner_tol,
self.options.inner_maxiter,
perp_polarization=self.options.perp_polarization,
)
# perturbed field, couplings and magnetisation
self._allocate_b_tilde(self.variables.b)
e_space = self.variables.e.spline.vector.space
self._e_mid_tp = self.derham.V1.zeros()
self._parallel = []
self._drifts = []
self._skin_scales = []
self._accums = []
self._accum_args = []
for var in self.species:
params = var.species.equation_params
self._parallel += [_ParallelCoupling(self, var, self.gyro, self._e_mid_tp)]
self._skin_scales += [params.alpha**2 / params.epsilon**2]
if self.with_drifts:
self._drifts += [
_DriftCoupling(self, var, self.gyro, self._e_mid_tp, self.options.butcher, self.options.mpi_sort)
]
alpha2 = params.alpha**2
if self.gyro.flr:
kernel, args = gk_mag_density_gyro, (alpha2, *self._gyro_args(self.gyro, params.epsilon, disc=True))
else:
kernel, args = gc_mag_density_0form, (alpha2,)
self._accums += [AccumulatorVector(var.particles, "H1", kernel, self.mass_ops, self.domain.args_domain)]
self._accum_args += [args]
self._PBT = self.basis_ops.PB.T
# the constant background current C^T M2H b_0
if self.background_current:
self._s0 = self._curlT.dot(self._M2H.dot(self.projected_equil.b2))
else:
self._s0 = None
# buffers
self._m_pol = self._PBT.domain.zeros()
self._s2 = self._PBT.codomain.zeros()
self._s1 = e_space.zeros()
self._rhs = e_space.zeros()
self._tmp = e_space.zeros()
self._e_new = e_space.zeros()
self._e_picard = e_space.zeros()
self._e_L = e_space.zeros()
self._rhs2 = e_space.zeros()
self._e_mid = e_space.zeros()
self._b_new = self.variables.b.spline.vector.space.zeros()
# the solver (the operator and the preconditioner are set at each call, they depend on dt)
self._polarization_weight = polarization
self._A = dielectric_matrix(self.mass_ops, polarization)
self._pc_dt = None
solver = self.options.solver
if solver is None:
solver = "pbicgstab" if self.with_drifts else "pcg"
self._solver = solver
# a symmetric solver treats the E x B operator by Picard iterations
self._picard = self.with_drifts and solver in LiteralOptions.OptsSymmSolver.__args__
self._A_inv = inverse(
self._A,
solver,
pc=self._preconditioner(0.0),
tol=self.options.solver_params.tol,
maxiter=self.options.solver_params.maxiter,
verbose=self.options.solver_params.verbose,
)
def _preconditioner(self, dt: float):
"""The mass-matrix preconditioner of the implicit system at time step ``dt``: the polarization plus the
continuum limit of the skin term (``skin_preconditioner_matrix``), or the dielectric matrix without
species backgrounds."""
if self.options.precond is None:
return None
pc_class = getattr(preconditioner, self.options.precond)
if self.background is None or dt == 0.0:
return pc_class(self._A)
M = skin_preconditioner_matrix(
self.mass_ops, self._polarization_weight, self.background.skin, dt**2 / 4.0, vacuum=False
)
return pc_class(M)
@property
def longitudinal_operator(self) -> DarwinLongitudinalOperator:
"""The longitudinal polarization operator (available after :meth:`allocate`)."""
return self._PL
@profile
def __call__(self, dt):
en = self.variables.e.spline.vector
bn = self.variables.b.spline.vector
# perturbed magnetic field and particle couplings at eta^n, v^n
self._update_b_tilde()
# the drifts are pushed first, explicitly in the fields (e^n, b^n): the E x B motion does no work and the
# grad-B work mu (B(eta^{n+1}) - B(eta^n)) takes place in the frozen field b^n, which the explicit grad-B
# current (evaluated below at eta^{n+1}, in the same field) balances to second order. The magnetisation
# and the parallel coupling are then accumulated at eta^{n+1}, where the energies are evaluated, so that
# the magnetisation and parallel exchanges remain exact.
# The drifts see the longitudinal (electrostatic) field E_L = -grad phi only: the transversal part of the
# Darwin field is the multiplier of the quasi-static transversal Ampère law (it scales like 1/dt in the
# discrete constraint) and drives no E x B motion, as in the A_parallel gyrokinetic models.
if self._drifts:
self._PL.longitudinal_part(en, out=self._e_L)
e_tp = self._E1T.dot(self._e_L, out=self._e_mid_tp)
e_tp.update_ghost_regions()
for coupling in self._drifts:
coupling.pusher(dt)
if coupling.var.species.weights_params.control_variate:
coupling.var.particles.update_weights()
for coupling in self._parallel + self._drifts:
coupling.accumulate()
# left- and right-hand side operators of the mid-point rule (symmetric part)
lhs = self._PL + dt**2 / 4.0 * self._CTMC
rhs_op = self._PL - dt**2 / 4.0 * self._CTMC
for coupling, scale in zip(self._parallel, self._skin_scales):
S = scale * coupling.operator
lhs = lhs + dt**2 / 4.0 * S
rhs_op = rhs_op - dt**2 / 4.0 * S
Q = None
for coupling in self._drifts:
Q = coupling.operator if Q is None else Q + coupling.operator
if Q is not None:
Q = _LongitudinalDriftOperator(self._PL, Q)
rhs = rhs_op.dot(en, out=self._rhs)
# dt C^T M2H (b^n + b_0)
self._M2H.dot(bn, out=self._s2)
self._curlT.dot(self._s2, out=self._s1)
if self._s0 is not None:
self._s1 += self._s0
self._s1 *= dt
rhs += self._s1
# magnetisation source: dt C^T PB^T sum_s alpha_s^2 m_s
self._m_pol *= 0.0
for accum, args in zip(self._accums, self._accum_args):
accum(*args)
self._m_pol += accum.vectors[0]
self._PBT.dot(self._m_pol, out=self._s2)
self._curlT.dot(self._s2, out=self._s1)
self._s1 *= dt
rhs += self._s1
# - dt (parallel currents at v^n and grad-B currents) - dt/2 Q e^n
self._tmp *= 0.0
for coupling, scale in zip(self._parallel, self._skin_scales):
self._s1 *= 0.0
self._s1 += coupling.vector
self._s1 *= scale * coupling.epsilon
self._tmp += self._s1
if self._drifts:
self._s1 *= 0.0
for coupling in self._drifts:
self._s1 += coupling.vector
self._PL.longitudinal_transpose(self._s1, out=self._e_L)
self._tmp += self._e_L
self._tmp *= dt
rhs -= self._tmp
if Q is not None:
Q.dot(en, out=self._tmp)
self._tmp *= dt / 2.0
rhs -= self._tmp
# solve the full system, or the symmetric part with Picard iterations for the E x B operator:
# lhs e = rhs - dt/2 Q e
if Q is not None and not self._picard:
lhs = lhs + dt / 2.0 * Q
if self._pc_dt != dt:
pc = self._preconditioner(dt)
if pc is not None:
self._A_inv._options["pc"] = pc
self._pc_dt = dt
self._A_inv.linop = lhs
self._A_inv._options["tol"] = max(self.options.solver_params.tol * rhs.inner(rhs) ** 0.5, 1e-150)
en1 = self._e_new
en1 *= 0.0
en1 += en
n_picard = 0
with ProfileManager.profile_region(self._solve_region, functions=[self._A_inv.solve]):
while True:
self._rhs2 *= 0.0
self._rhs2 += rhs
if self._picard:
Q.dot(en1, out=self._tmp)
self._tmp *= dt / 2.0
self._rhs2 -= self._tmp
self._A_inv.solve(self._rhs2, out=self._e_picard)
n_picard += 1
self._tmp *= 0.0
self._tmp += self._e_picard
self._tmp -= en1
diff = self._tmp.inner(self._tmp) ** 0.5
norm = max(self._e_picard.inner(self._e_picard) ** 0.5, 1e-300)
en1 *= 0.0
en1 += self._e_picard
if (
not self._picard
or diff <= self.options.picard_tol * norm
or n_picard >= self.options.picard_maxiter
):
break
info = dict(self._A_inv._info)
info["picard"] = n_picard
# mid-point field (tensor-product coefficients for the kernels) and new b coeffs
e_mid = en.copy(out=self._e_mid)
e_mid += en1
e_mid *= 0.5
e_mid_tp = self._E1T.dot(e_mid, out=self._e_mid_tp)
e_mid_tp.update_ghost_regions()
bn1 = self.derham.curl.dot(e_mid, out=self._b_new)
bn1 *= -dt
bn1 += bn
# parallel velocities in the mid-point field
for coupling in self._parallel:
coupling.pusher(dt)
if coupling.var.species.weights_params.control_variate:
coupling.var.particles.update_weights()
diffs = self.update_feec_variables(e=en1, b=bn1)
if self._info and MPI.COMM_WORLD.Get_rank() == 0:
logger.info(f"Status for DarwinFieldCoupling: {info['success']}")
logger.info(f"Iterations for DarwinFieldCoupling: {info['niter']} (Picard: {info['picard']})")
logger.info(f"Maxdiff e for DarwinFieldCoupling: {diffs['e']}")
logger.info(f"Maxdiff b for DarwinFieldCoupling: {diffs['b']}")
logger.info("")