Source code for struphy.propagators.shear_alfven_propagator

import logging
from dataclasses import dataclass
from typing import Literal

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.io.options import LiteralOptions, OptionsBase
from struphy.linear_algebra.multigrid.preconditioner import MultiGridOptions, MultiGridPreconditioner
from struphy.linear_algebra.schur_solver import SchurSolver
from struphy.linear_algebra.solver import SolverParameters
from struphy.models.variables import FEECVariable
from struphy.ode.solvers import ODEsolverFEEC
from struphy.ode.utils import ButcherTableau
from struphy.propagators.base import Propagator
from struphy.utils.utils import check_option

logger = logging.getLogger("struphy")


[docs] class ShearAlfvenPropagator(Propagator): r"""FEEC discretization of the following equations: find :math:`\tilde{\mathbf{U}} \in \{H(\textnormal{curl}), H(\textnormal{div}), (H^1)^3\}` and :math:`\tilde{\mathbf{B}} \in H(\textnormal{div})` such that .. math:: \int_{\Omega} \rho_0\frac{\partial \tilde{\mathbf{U}}}{\partial t} \cdot \mathbf V\,\textnormal d \mathbf x &= \int_{\Omega} \tilde{\mathbf{B}} \cdot \nabla \times (\mathbf{B}_0 \times \mathbf V) \,\textnormal d \mathbf x \qquad \forall \,\mathbf V \in \{H(\textnormal{curl}), H(\textnormal{div}), (H^1)^3\} \\[2mm] \frac{\partial \tilde{\mathbf{B}}}{\partial t} &= \nabla\times(\tilde{\mathbf{U}} \times \mathbf{B}_0) Time discretization: - implicit: Crank-Nicolson (implicit mid-point) - explicit: explicit RK methods from ButcherTableau System size reduction via :class:`~struphy.linear_algebra.schur_solver.SchurSolver`. """
[docs] class Variables: """Container for variables advanced by :class:`ShearAlfvenPropagator`. 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): self.variables = self.Variables()
[docs] @dataclass(repr=False) class Options(OptionsBase): r"""Configuration options for :class:`ShearAlfvenPropagator`. Parameters ---------- u_space : LiteralOptions.OptsVecSpace, default="Hdiv" FEEC space used for the velocity variable. algo : {"implicit", "explicit"}, default="implicit" Time stepping algorithm. solver : LiteralOptions.OptsSymmSolver, default="pcg" Symmetric iterative solver used by implicit or explicit operators. precond : LiteralOptions.OptsShearAlfvenPrecond, default="StiffnessPreconditioner" Preconditioner of the linear solve: of the mass-matrix block (``"MassMatrixPreconditioner"``, ``"PolarMassMatrixPreconditioner"``), or of the whole Schur operator :math:`\mathbb M^{2,n} + \frac{\Delta t^2}{4} \mathbb T^\top \mathbb C^\top \mathbb M^2 \mathbb C \mathbb T`: a geometric multigrid V-cycle (``"MultiGrid"``, :class:`~struphy.linear_algebra.multigrid.preconditioner.MultiGridPreconditioner`) or the Kronecker (fast diagonalization) preconditioner (``"StiffnessPreconditioner"``, the default, :class:`~struphy.feec.preconditioner.ShearAlfvenSchurPreconditioner`: block Jacobi in the velocity components with the auxiliary space correction of the :math:`\mathbf E \times \mathbf B` flows if ``exb_correction``, see there). Both need the implicit algorithm with ``solver="pcg"``. The Kronecker preconditioner also needs ``u_space="Hdiv"``, tensor-product splines (no polar splines) and the global commuting projectors; otherwise ``"StiffnessPreconditioner"`` falls back (with a warning) to ``"MassMatrixPreconditioner"`` (``"PolarMassMatrixPreconditioner"`` with polar splines). The Schur operator depends on the time step, so these preconditioners are built in the setup (:meth:`prepare_time_step`) and updated if the time step changes. The multigrid preconditioner is experimental: on the TAE example (HollowTorus, 8x32x4) it needs more PCG iterations than the mass matrix preconditioner and is much slower (the smoother does not treat the large kernel of the curl term on 2-forms). The Kronecker preconditioner (with ``exb_correction``) needs about 11 instead of 31 PCG iterations per step there (dt = 0.1), with larger gains for larger time steps. exb_correction : bool, default=True For ``precond="StiffnessPreconditioner"``: add the auxiliary space correction :math:`\mathbb N P_\phi \mathbb N^\top` (:class:`~struphy.feec.preconditioner.AuxiliarySpacePreconditioner`) of the :math:`\mathbf E \times \mathbf B` flows :math:`\mathbf B_0 \times \nabla \phi`, :math:`\mathbb N = \hat \Pi^2[\mathbf B_0 \times \,\cdot\,] \, \mathbb G` with :math:`\phi \in V^0_h`. The block Jacobi preconditioner in the velocity components does not reduce the incompressible shear Alfven modes with small parallel wave number (their velocity components cancel in the compression), which this correction resolves; :math:`P_\phi` is the Kronecker (fast diagonalization) preconditioner of :math:`\mathbb N^\top S \mathbb N` on :math:`V^0_h`. multigrid : MultiGridOptions, default=None Options of the multigrid preconditioner (if ``precond="MultiGrid"``); defaults to ``MultiGridOptions()``. solver_params : SolverParameters, default=None Solver controls; defaults to ``SolverParameters()``. butcher : ButcherTableau, default=None Explicit Runge-Kutta tableau when ``algo="explicit"``. """ # specific literals OptsAlgo = Literal["implicit", "explicit"] # propagator options u_space: LiteralOptions.OptsVecSpace = "Hdiv" algo: OptsAlgo = "implicit" solver: LiteralOptions.OptsSymmSolver = "pcg" precond: LiteralOptions.OptsShearAlfvenPrecond = "StiffnessPreconditioner" exb_correction: bool = True multigrid: MultiGridOptions = None solver_params: SolverParameters = None butcher: ButcherTableau = None 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) if self.precond == "MultiGrid": assert self.algo == "implicit", f'precond="{self.precond}" requires algo="implicit".' assert self.solver == "pcg", f'precond="{self.precond}" requires solver="pcg".' if self.precond == "MultiGrid" and self.multigrid is None: self.multigrid = MultiGridOptions() # defaults if self.solver_params is None: self.solver_params = SolverParameters() 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): u_space = self.options.u_space # define block matrix [[A B], [C I]] (without time step size dt in the diagonals) id_M = "M" + self.derham.space_to_form[u_space] + "n" id_T = "T" + self.derham.space_to_form[u_space] # call operators _A = getattr(self.mass_ops, id_M) _T = getattr(self.basis_ops, id_T) _M2 = self.mass_ops.M2 curl = self.derham.curl # Preconditioner (the preconditioners of the Schur operator depend on dt and are built in prepare_time_step) self._id_M = id_M self._multigrid_dt = None 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 (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, "MultiGrid", "StiffnessPreconditioner"): pc = None else: pc_class = getattr(preconditioner, self._precond) pc = pc_class(getattr(self.mass_ops, id_M)) if self.options.algo == "implicit": self._info = self.options.solver_params.info self._B = -1 / 2 * _T.T @ curl.T @ _M2 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, ) # pre-allocate arrays self._byn = self._B.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 @ _T.T @ curl.T @ _M2 _f2 = curl @ _T # allocate output of vector field 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) 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) # 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()
[docs] def prepare_time_step(self, dt: float): """Preconditioner of the Schur operator for the time step ``dt`` (multigrid or Kronecker): built in the setup, updated or rebuilt when dt changes.""" if self.options.algo != "implicit": return # the multigrid preconditioner of the Schur operator A - dt^2 B C: updated (re-using the coarse mass and # derivative operators) when dt changes if self._precond == "MultiGrid" and dt != self._multigrid_dt: schur = self._schur_solver.A - dt**2 * self._schur_solver.BC if self._schur_solver.precond is None: self._schur_solver.precond = MultiGridPreconditioner( schur, self.derham, self.domain, self.options.multigrid, mass_ops=self.mass_ops ) else: self._schur_solver.precond.update(schur) self._multigrid_dt = dt # the Kronecker preconditioner of S = M + dt^2/4 T^T C^T M2 C T (plus the E x B correction), 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
@profile def __call__(self, dt): # current FE coeffs un = self.variables.u.spline.vector bn = self.variables.b.spline.vector 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 byn = self._B.dot(bn, out=self._byn) un1, info = self._schur_solver(un, byn, dt, out=self._u_tmp1) # new b coeffs _u = un.copy(out=self._u_tmp2) _u += self._u_tmp1 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) if self._info and MPI.COMM_WORLD.Get_rank() == 0: if self.options.algo == "implicit": logger.info(f"Status for ShearAlfven: {info['success']}") logger.info(f"Iterations for ShearAlfven: {info['niter']}") logger.info(f"Maxdiff up for ShearAlfven: {diffs['u']}") logger.info(f"Maxdiff b2 for ShearAlfven: {diffs['b']}") logger.info("")