import logging
from dataclasses import dataclass
from typing import Literal
import cunumpy as xp
from feectools.linalg.block import BlockVector
from feectools.linalg.solvers import inverse
from line_profiler import profile
from maybempi import MPI
from struphy.feec import preconditioner
from struphy.feec.basis_projection_ops import (
BasisProjectionOperator,
BasisProjectionOperatorLocal,
)
from struphy.feec.utilities import LocalRotationMatrix
from struphy.io.options import LiteralOptions, OptionsBase
from struphy.linear_algebra.schur_solver import SchurSolver
from struphy.linear_algebra.solver import SolverParameters
from struphy.models.variables import FEECVariable, PICVariable
from struphy.ode.solvers import ODEsolverFEEC
from struphy.ode.utils import ButcherTableau
from struphy.pic.accumulation.filter import FilterParameters
from struphy.pic.accumulation.kernels.gc_mag_density_0form import gc_mag_density_0form
from struphy.pic.accumulation.particles_to_grid import AccumulatorVector
from struphy.propagators.base import Propagator
from struphy.utils.utils import check_option
logger = logging.getLogger("struphy")
[docs]
class ShearAlfvenCurrentCoupling5D(Propagator):
r""":ref:`FEEC <gempic>` discretization of the following equations:
find :math:`\mathbf U \in \{H(\textnormal{curl}), H(\textnormal{div}), (H^1)^3\}` and :math:`\mathbf B \in H(\textnormal{div})` such that
.. math::
\left\{
\begin{aligned}
\int \rho_0 &\frac{\partial \tilde{\mathbf U}}{\partial t} \cdot \mathbf V \, \textnormal{d} \mathbf{x} = \int \left(\tilde{\mathbf B} - \frac{A_\textnormal{h}}{A_b} \iint f^\text{vol} \mu \mathbf{b}_0\textnormal{d} \mu \textnormal{d} v_\parallel \right) \cdot \nabla \times (\tilde{\mathbf B} \times \mathbf V) \, \textnormal{d} \mathbf{x} \quad \forall \, \mathbf V \in \{H(\textnormal{curl}), H(\textnormal{div}), (H^1)^3\}\,, \,,
\\
&\frac{\partial \tilde{\mathbf B}}{\partial t} = - \nabla \times (\tilde{\mathbf B} \times \tilde{\mathbf U}) \,.
\end{aligned}
\right.
:ref:`time_discret`: Crank-Nicolson (implicit mid-point). System size reduction via :class:`~struphy.linear_algebra.schur_solver.SchurSolver`:
.. math::
\begin{bmatrix}
\mathbf u^{n+1} - \mathbf u^n \\ \mathbf b^{n+1} - \mathbf b^n
\end{bmatrix}
= \frac{\Delta t}{2} \,.
\begin{bmatrix}
0 & (\mathbb M^{2,n})^{-1} \mathcal {T^2}^\top \mathbb C^\top \\ - \mathbb C \mathcal {T^2} (\mathbb M^{2,n})^{-1} & 0
\end{bmatrix}
\begin{bmatrix}
{\mathbb M^{2,n}}(\mathbf u^{n+1} + \mathbf u^n) \\ \mathbb M_2(\mathbf b^{n+1} + \mathbf b^n) + \sum_k^{N_p} \omega_k \mu_k \hat{\mathbf b}¹_0 (\boldsymbol \eta_k) \cdot \left(\frac{1}{\sqrt{g(\boldsymbol \eta_k)}} \vec \Lambda² (\boldsymbol \eta_k) \right)
\end{bmatrix} \,,
where
:math:`\mathcal{T}^2 = \hat \Pi^1 \left[\frac{\mathbf B^2}{\sqrt{g}} \times \vec \Lambda^2\right]` with the total
field :math:`\mathbf B = \mathbf B_0 + \tilde{\mathbf B}` (updated in each step) if ``nonlinear``, else the
equilibrium field :math:`\mathbf B_0`, and
:math:`\mathbb M^{2,n}` is a :class:`~struphy.feec.mass.WeightedMassOperators` being weighted with :math:`\rho_\text{eq}`, the MHD equilibirum density.
"""
[docs]
class Variables:
"""Container for variables advanced by :class:`ShearAlfvenCurrentCoupling5D`.
Attributes
----------
u : FEECVariable
Velocity variable in one of ``"Hcurl"``, ``"Hdiv"``, or
``"H1vec"``.
b : FEECVariable
Magnetic-field variable in ``"Hdiv"`` space.
"""
def __init__(self):
self._u: FEECVariable = None
self._b: FEECVariable = None
@property
def u(self) -> FEECVariable:
return self._u
@u.setter
def u(self, new):
assert isinstance(new, FEECVariable)
assert new.space in ("Hcurl", "Hdiv", "H1vec")
self._u = 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, energetic_ions: PICVariable = None):
"""
Parameters
----------
energetic_ions : PICVariable, default=None
Energetic-ion particle distribution (``"Particles5D"`` space) providing
the current source for the shear Alfvén wave coupling.
"""
self.variables = self.Variables()
self.energetic_ions = energetic_ions
[docs]
@dataclass(repr=False)
class Options(OptionsBase):
r"""Configuration options for :class:`ShearAlfvenCurrentCoupling5D`.
Parameters
----------
ep_scale : float, default=None
Factor of the energetic-particle contributions (accumulation): the mass ratio
:math:`A_\textnormal{h}/A_\textnormal{b}` of the energetic ions and the MHD bulk if ``None``.
u_space : LiteralOptions.OptsVecSpace, default="Hdiv"
FEEC space used for velocity variable ``u``.
algo : {"implicit", "explicit"}, default="implicit"
Time stepping algorithm.
solver : LiteralOptions.OptsSymmSolver, default="pcg"
Symmetric iterative solver.
precond : LiteralOptions.OptsShearAlfvenPrecond, default="StiffnessPreconditioner"
Preconditioner of the linear solve: of the mass-matrix block (``"MassMatrixPreconditioner"``,
``"PolarMassMatrixPreconditioner"``) or the Kronecker (fast diagonalization) preconditioner of the whole
Schur operator (``"StiffnessPreconditioner"``, the default,
:class:`~struphy.feec.preconditioner.ShearAlfvenSchurPreconditioner`, built from the equilibrium part
:math:`\mathbb T` of the basis projection operator; with ``nonlinear=True`` it approximates the Schur
operator with :math:`\mathbb T + \mathbb T^B`, since :math:`|\tilde{\mathbf B}| \ll |\mathbf B_0|`). The
Kronecker preconditioner needs ``algo="implicit"``, ``solver="pcg"``, ``u_space="Hdiv"``, tensor-product
splines (no polar splines) and the global commuting projectors; otherwise it falls back (with a warning) to
``"MassMatrixPreconditioner"`` (``"PolarMassMatrixPreconditioner"`` with polar splines). ``"MultiGrid"`` is
not available here.
exb_correction : bool, default=True
For ``precond="StiffnessPreconditioner"``: add the auxiliary space correction of the
:math:`\mathbf E \times \mathbf B` flows, see
:class:`~struphy.propagators.shear_alfven_propagator.ShearAlfvenPropagator`.
solver_params : SolverParameters, default=None
Solver controls.
filter_params : FilterParameters, default=None
Filter of the accumulated (particle-to-grid) vectors; defaults to ``FilterParameters()`` (no filter).
The three-point (binomial) filter ``FilterParameters(use_filter="three_point")`` reduces the noise,
but the propagator then no longer conserves the energy exactly (the filter is applied to the
accumulated vectors only, not to the fields seen by the particles).
butcher : ButcherTableau, default=None
Explicit Runge-Kutta tableau for ``algo="explicit"``.
nonlinear : bool, default=True
If ``True``, the basis projection operator :math:`\mathcal T^2` uses the total magnetic field
:math:`\mathbf B_0 + \tilde{\mathbf B}` (one operator, re-weighted in each step), else the equilibrium
field :math:`\mathbf B_0` only.
"""
# specific literals
OptsAlgo = Literal["implicit", "explicit"]
# propagator options
ep_scale: float = None
u_space: LiteralOptions.OptsVecSpace = "Hdiv"
algo: OptsAlgo = "implicit"
solver: LiteralOptions.OptsSymmSolver = "pcg"
precond: LiteralOptions.OptsShearAlfvenPrecond = "StiffnessPreconditioner"
exb_correction: bool = True
solver_params: SolverParameters = None
filter_params: FilterParameters = None
butcher: ButcherTableau = None
nonlinear: bool = True
def __post_init__(self):
# checks
check_option(self.u_space, LiteralOptions.OptsVecSpace)
check_option(self.algo, self.OptsAlgo)
check_option(self.solver, LiteralOptions.OptsSymmSolver)
check_option(self.precond, LiteralOptions.OptsShearAlfvenPrecond)
assert self.precond != "MultiGrid", 'precond="MultiGrid" is not available for ShearAlfvenCurrentCoupling5D.'
assert self.ep_scale is None or isinstance(self.ep_scale, float)
assert isinstance(self.nonlinear, bool)
# defaults
if self.solver_params is None:
self.solver_params = SolverParameters()
if self.filter_params is None:
self.filter_params = FilterParameters()
if self.algo == "explicit" and self.butcher is None:
self.butcher = ButcherTableau()
@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):
# factor of the energetic-particle contributions (default: mass ratio A_h/A_b)
self._ep_scale = self.options.ep_scale
if self._ep_scale is None:
self._ep_scale = self.energetic_ions.species.mass_number / self.variables.u.species.mass_number
logger.info(f"{self.__class__.__name__}: ep_scale = A_h/A_b = {self._ep_scale} (mass ratio, default).")
self._u_form = self.derham.space_to_form[self.options.u_space]
# call operatros
id_M = "M" + self._u_form + "n"
id_T = "T" + self._u_form
_A = getattr(self.mass_ops, id_M)
_T = getattr(self.basis_ops, id_T)
M2 = self.mass_ops.M2
curl = self.derham.curl
PB = getattr(self.basis_ops, "PB")
# define Accumulator and arguments
self._ACC = AccumulatorVector(
self.energetic_ions.particles,
"H1",
gc_mag_density_0form,
self.mass_ops,
self.domain.args_domain,
filter_params=self.options.filter_params,
)
# Preconditioner (the Kronecker preconditioner of the Schur operator depends on dt, built in prepare_time_step)
self._schur_precond_dt = None
# the requested preconditioner, or the fallback if the Kronecker one is not possible here
self._precond = preconditioner.ShearAlfvenSchurPreconditioner.resolve(
self.options.precond, self.derham, self.options.algo, self.options.solver, self.options.u_space
)
if self._precond == "StiffnessPreconditioner":
# Kronecker operators of the equilibrium part T (built once, independent of dt)
self._schur_precond = preconditioner.ShearAlfvenSchurPreconditioner(
self.derham, self.mass_ops, self.basis_ops, _T, id_M, exb_correction=self.options.exb_correction
)
if self._precond in (None, "StiffnessPreconditioner"):
pc = None
else:
pc_class = getattr(preconditioner, self._precond)
pc = pc_class(getattr(self.mass_ops, id_M))
if self.options.nonlinear:
# one basis projection operator of the total field B0 + b (and its transpose), updated in each step
self._initialize_projection_operator_Ttot()
_T = self._Ttot
_TT = self._TtotT
else:
_TT = _T.T
if self.options.algo == "implicit":
self._info = self.options.solver_params.info
# define block matrix [[A B], [C I]] (without time step size dt in the diagonals)
self._B = -1 / 2 * _TT @ curl.T @ M2
self._B2 = -1 / 2 * _TT @ curl.T @ PB.T
self._C = 1 / 2 * curl @ _T
# Instantiate Schur solver (constant in this case)
_BC = self._B @ self._C
self._schur_solver = SchurSolver(
_A,
_BC,
self.options.solver,
precond=pc,
solver_params=self.options.solver_params,
)
# allocate dummy vectors to avoid temporary array allocations
self._u_tmp1 = self.variables.u.spline.vector.space.zeros()
self._u_tmp2 = self.variables.u.spline.vector.space.zeros()
self._b_tmp1 = self.variables.b.spline.vector.space.zeros()
self._byn = self._B.codomain.zeros()
self._tmp_acc = self._B2.codomain.zeros()
else:
self._info = False
# define vector field
A_inv = inverse(
_A,
self.options.solver,
pc=pc,
tol=self.options.solver_params.tol,
maxiter=self.options.solver_params.maxiter,
verbose=self.options.solver_params.verbose,
)
_f1 = A_inv @ _TT @ curl.T @ M2
_f1_acc = A_inv @ _TT @ curl.T @ PB.T
_f2 = curl @ _T
# allocate output of vector field
out_acc = self.variables.u.spline.vector.space.zeros()
out1 = self.variables.u.spline.vector.space.zeros()
out2 = self.variables.b.spline.vector.space.zeros()
def f1(t, y1, y2, out: BlockVector = out1):
_f1.dot(y2, out=out)
_f1_acc.dot(self._ACC.vectors[0], out=out_acc)
out += out_acc
out.update_ghost_regions()
return out
def f2(t, y1, y2, out: BlockVector = out2):
_f2.dot(y1, out=out)
out *= -1.0
out.update_ghost_regions()
return out
vector_field = {self.variables.u.spline.vector: f1, self.variables.b.spline.vector: f2}
self._ode_solver = ODEsolverFEEC(vector_field, butcher=self.options.butcher)
[docs]
def prepare_time_step(self, dt: float):
"""Kronecker preconditioner of the Schur operator for the time step ``dt``: built in the setup, rebuilt when dt
changes."""
if self._precond == "StiffnessPreconditioner" and dt != self._schur_precond_dt:
self._schur_solver.precond = self._schur_precond.build(dt)
self._schur_precond_dt = dt
def __call__(self, dt):
# update time-dependent operator TB
if self.options.nonlinear:
self._update_weights_Ttot()
# current FE coeffs
un = self.variables.u.spline.vector
bn = self.variables.b.spline.vector
# accumulate
self._ACC(self._ep_scale)
if self.options.algo == "implicit":
# preconditioner for the current time step (no-op unless dt changed since the setup)
self.prepare_time_step(dt)
# solve for new u coeffs (no tmps created here)
byn = self._B.dot(bn, out=self._byn)
b2acc = self._B2.dot(self._ACC.vectors[0], out=self._tmp_acc)
byn += b2acc
un1, info = self._schur_solver(un, byn, dt, out=self._u_tmp1)
# new b coeffs (no tmps created here)
_u = un.copy(out=self._u_tmp2)
_u += un1
bn1 = self._C.dot(_u, out=self._b_tmp1)
bn1 *= -dt
bn1 += bn
diffs = self.update_feec_variables(u=un1, b=bn1)
else:
self._ode_solver(0.0, dt)
# update_weights
if self.energetic_ions.species.weights_params.control_variate:
self.energetic_ions.particles.update_weights()
if self._info and MPI.COMM_WORLD.Get_rank() == 0:
if self.options.algo == "implicit":
logger.info(f"Status for ShearAlfvenCurrentCoupling5D: {info['success']}")
logger.info(f"Iterations for ShearAlfvenCurrentCoupling5D: {info['niter']}")
logger.info(f"Maxdiff up for ShearAlfvenCurrentCoupling5D: {diffs['u']}")
logger.info(f"Maxdiff b2 for ShearAlfvenCurrentCoupling5D: {diffs['b']}")
logger.info("")
def _initialize_projection_operator_Ttot(self):
r"""Initialize the BasisProjectionOperator of the total magnetic field (time-varying weight), e.g. for
``u_space="Hdiv"``
.. math::
\mathcal{T}^{\textnormal{tot}}_{(\mu,ijk),(\nu,mno)} := \hat \Pi^1_{(\mu,ijk)} \left[ \epsilon_{\mu \alpha \nu}
\frac{B^2_{0,\alpha} + \tilde{B}^2_\alpha}{\sqrt{g}} \Lambda^2_{\nu,mno} \right] \,,
and its transpose. One operator of the total field (instead of the sum of the equilibrium operator
:math:`\mathcal T` and of the operator of the perturbation) needs one projection per application.
"""
P1 = self.derham.P1
Vh = self.derham.fem_spaces[self._u_form]
# spline function for the evaluation of the perturbed field
self._bf = self.derham.create_spline_function("bf", "Hdiv")
# the weights at the last evaluation points (the 3x3 weights of all blocks of a row share the points)
# the weights at the last evaluation points, and the equilibrium field (2-form components) at the evaluation
# points of the projector, which are the same in each step
self._weight_cache = _PointCache(maxsize=1)
self._b0_cache = _PointCache(maxsize=8)
eq = self.basis_ops.weights["eq_mhd"]
self._b0_funs = (eq.b2_1, eq.b2_2, eq.b2_3)
op_class = BasisProjectionOperatorLocal if self.derham._with_local_projectors else BasisProjectionOperator
ops = []
for transposed in (False, True):
ops += [
op_class(
P1,
Vh,
[[None, None, None], [None, None, None], [None, None, None]],
transposed=transposed,
use_cache=True,
polar_shift=True,
V_extraction_op=self.derham.extraction_ops[self._u_form],
V_boundary_op=self.derham.boundary_ops[self._u_form],
P_boundary_op=self.derham.boundary_ops["1"],
)
]
self._Ttot, self._TtotT = ops
def _total_field_weights(self, e1, e2, e3):
"""The 3x3 weights of the total-field operator at the points (e1, e2, e3): the rotation matrix of B0 + b (2-form
components), times the metric factor of the velocity space. Cached for the last points, since all blocks of
a row are evaluated at the same points."""
def compute():
b0 = self._b0_cache.get((e1, e2, e3), lambda: [fun(e1, e2, e3) for fun in self._b0_funs])
b = self._bf(e1, e2, e3, local=True)
rot = LocalRotationMatrix(*[(lambda *_, c=c: b0[c] + b[c]) for c in range(3)])(e1, e2, e3)
if self._u_form == "1":
rot = rot @ self.domain.metric_inv(e1, e2, e3, change_out_order=True, squeeze_out=False)
elif self._u_form == "2":
rot = rot / abs(self.domain.jacobian_det(e1, e2, e3, squeeze_out=False))[:, :, :, None, None]
return rot
return self._weight_cache.get((e1, e2, e3), compute)
def _update_weights_Ttot(self):
"""Update the time-dependent weights of the total-field operator and of its transpose (one assembly)."""
self.variables.b.spline.vector.copy(out=self._bf.vector)
self._weight_cache.clear()
fun = [
[(lambda e1, e2, e3, m=m, n=n: self._total_field_weights(e1, e2, e3)[:, :, :, m, n]) for n in range(3)]
for m in range(3)
]
self._Ttot.update_weights(fun)
if isinstance(self._Ttot, BasisProjectionOperator):
# the transpose re-uses the DOF matrix just assembled
self._TtotT.update_weights(fun, dof_mat=self._Ttot.dof_matrix)
else:
self._TtotT.update_weights(fun)
self._weight_cache.clear()
class _PointCache:
"""Values computed at evaluation points (e1, e2, e3), for the last ``maxsize`` point sets (first in, first out)."""
def __init__(self, maxsize: int):
self._maxsize = maxsize
self._entries = []
def clear(self):
self._entries = []
def get(self, pts: tuple, compute):
"""The cached value at the points ``pts``, or ``compute()`` (then cached)."""
for cached_pts, value in self._entries:
if all(a.shape == b.shape and xp.array_equal(a, b) for a, b in zip(cached_pts, pts)):
return value
value = compute()
self._entries = (self._entries + [(tuple(a.copy() for a in pts), value)])[-self._maxsize :]
return value