Source code for struphy.propagators.variational_linear_alfven_evolve

import logging
from dataclasses import dataclass
from typing import Literal

import cunumpy as xp
from feectools.linalg.solvers import inverse
from line_profiler import profile

from struphy.feec.mass import L2Projector
from struphy.feec.preconditioner import MassMatrixPreconditioner
from struphy.feec.variational_utilities import Hdiv0_transport_operator, alfven_schur_preconditioner
from struphy.io.options import LiteralOptions, OptionsBase
from struphy.linear_algebra.solver import SolverParameters
from struphy.models.variables import FEECVariable
from struphy.propagators.variational_linear_base import VariationalLinearEvolve
from struphy.utils.utils import check_option

logger = logging.getLogger("struphy")


[docs] class VariationalLinearAlfvenEvolve(VariationalLinearEvolve): r""":ref:`FEEC <gempic>` discretization of the coupling of the velocity and the magnetic field (shear-Alfvén and fast magnetosonic waves) of the linear variational MHD models, with one linear solve per time step: find :math:`\mathbf u \in (H^1)^3` and :math:`\mathbf B \in H(\textnormal{div})` (the perturbation of the equilibrium :math:`\mathbf B_0`) such that, with the transport operator :math:`\mathcal T_{\bar{\mathbf B}}(\mathbf v) = \nabla \times (\bar{\mathbf B} \times \mathbf v)`, .. math:: \int_\Omega \partial_t (\rho_0 \mathbf u) \cdot \mathbf v\,\textrm d \mathbf x - \int_\Omega \left(\mathbf B \cdot \mathcal T_{\mathbf B_0}(\mathbf v) + \mathbf B_0 \cdot \mathcal T_{\mathbf B}(\mathbf v)\right)\textrm d \mathbf x = 0 \qquad \forall \, \mathbf v \in (H^1)^3\,, \qquad \partial_t \mathbf B + \mathcal T_{\mathbf B_0}(\mathbf u) = 0 \,. The thermodynamic variable is not changed, so this propagator serves the linear models in :math:`p` and in :math:`q = \sqrt p` alike; together with :class:`~struphy.propagators.variational_linear_sound_evolve.VariationalLinearSoundEvolve` it splits the linear MHD step into the shear-Alfvén/fast and the sound waves. The second-order term comes from the auxiliary second-order field :math:`\hat{\mathbf B} = \nabla \times \hat{\mathbf A}`, with :math:`\partial_t \hat{\mathbf A} = - \mathbf B \times \mathbf u` (``at1``), which enters the conserved energy as :math:`\int \mathbf B_0 \cdot \hat{\mathbf B} = \int \mathbf J_0 \cdot \hat{\mathbf A}`, with the equilibrium current :math:`\mathbf J_0 = \nabla \times \mathbf B_0` (the boundary term vanishes, since :math:`\mathbf u \cdot \mathbf n = \mathbf B \cdot \mathbf n = 0` gives :math:`\hat{\mathbf A} \times \mathbf n = 0`). Its variation gives the force :math:`\int \mathbf B_0 \cdot \mathcal T_{\mathbf B}(\mathbf v) = \int \mathbf v \cdot (\mathbf J_0 \times \mathbf B)`. Discretization: the equilibrium term with :math:`\mathbb T[\bar{\mathbf B}] = \mathbb C \hat\Pi^1[\hat{\bar{\mathbf B}} \times \vec{\boldsymbol\Lambda}^v]` (:class:`~struphy.feec.variational_utilities.Hdiv0_transport_operator`), and the second-order term with the equilibrium current :math:`\mathbf j_0` of the dual complex (no boundary conditions, see :meth:`equilibrium_current`) and the weighted mass matrix :math:`\mathbb W` of :math:`\int \mathbf v \cdot (\mathbf j_0 \times \mathbf B)`: .. math:: \begin{align} &\mathbb M^v[\hat\rho_0] \frac{\mathbf u^{n+1} - \mathbf u^n}{\Delta t} - \mathbb T[\mathbf B_0]^\top \mathbb M^2 \mathbf b^{n+\frac12} - \mathbb W \mathbf b^w = 0 \,, \qquad \frac{\mathbf b^{n+1} - \mathbf b^n}{\Delta t} + \mathbb T[\mathbf B_0]\, \mathbf u^{n+\frac12} = 0 \,, \\[2mm] &\frac{\hat{\mathbf a}^{n+1} - \hat{\mathbf a}^n}{\Delta t} + (\mathbb M^1_{\textnormal{free}})^{-1} \mathbf y(\mathbf b^w \times \mathbf u^{n+\frac12}) = 0 \,, \qquad \mathbf y(\mathbf w)_k = \int_\Omega \mathbf w \cdot \vec{\boldsymbol\Lambda}^1_k \,\textrm d \mathbf x \,, \end{align} where :math:`\mathbb M^1_{\textnormal{free}}` and :math:`\mathbf y` are taken over all basis functions of :math:`V^1_h`, without the boundary conditions. The energy :math:`\mathbf j_0^\top \mathbb M^1_{\textnormal{free}} \hat{\mathbf a}` then changes by exactly :math:`-\Delta t\, \mathbf j_0 \cdot \mathbf y = -\Delta t\, (\mathbf u^{n+\frac12})^\top \mathbb W \mathbf b^w`, the work of the force, which makes the step energy conserving (both forms are evaluated at the same quadrature points). The second-order field :math:`\hat{\mathbf b} = \mathbb C \hat{\mathbf a}` (``bt2``) is a diagnostic. Why the current of the dual complex: the magnetic energy is :math:`\frac12 \int \beta \wedge \star \beta` for the 2-form :math:`\beta`, and its second-order term is :math:`\int d\hat\alpha \wedge H_0 = \int \hat\alpha \wedge dH_0` with :math:`H_0 = \star \beta_0` (metric-free integration by parts, the boundary term vanishes with :math:`\hat{\mathbf A} \times \mathbf n = 0`). The current :math:`J_0 = dH_0` is the exterior derivative of the Hodge dual of the field, a quantity of the dual complex (:math:`H`, :math:`J`), which has no wall conditions. The vector potential, the electric field and the magnetic field live in the primal complex with the wall conditions. Writing the energy as :math:`\mathbf b_0^\top \mathbb M^2 \hat{\mathbf b}`, with :math:`\hat{\mathbf a}` subject to the boundary conditions, instead makes the current the weak curl :math:`\mathbb M_1^{-1} \mathbb C^\top \mathbb M_2 \mathbf b_0`, the codifferential of the primal complex: its tangential components vanish at a wall where those of :math:`\mathbf J_0` do not, a boundary layer one element wide that does not shrink in amplitude and causes a spurious instability localized at the wall. Both energies are the same in the continuum. The option ``weights`` sets :math:`\mathbf b^w`. With ``"midpoint"`` (default), :math:`\mathbf b^w = \mathbf b^{n+\frac12}`: one linear solve (:class:`~struphy.propagators.variational_linear_base.VariationalLinearEvolve`) with the (not symmetric) Schur complement :math:`\mathbb M^v[\hat\rho_0] + \frac{\Delta t^2}{4} (\mathbb T[\mathbf B_0]^\top \mathbb M^2 + \mathbb W)\, \mathbb T[\mathbf B_0]`; the step is the implicit midpoint rule, energy conserving and second order in time. With ``"frozen"``, :math:`\mathbf b^w = \mathbf b^n` (first order in time): the Schur complement is the shear-Alfvén operator :math:`\mathbb M^v[\hat\rho_0] + \frac{\Delta t^2}{4} \mathbb T[\mathbf B_0]^\top \mathbb M^2 \mathbb T[\mathbf B_0]`, symmetric and constant in time; the option ``schur_precond`` provides a Kronecker preconditioner of it (:func:`~struphy.feec.variational_utilities.alfven_schur_preconditioner`). The velocity can also be a 2-form, :math:`\mathbf u \in H(\textnormal{div})` (velocity variable in ``"Hdiv"`` space). The scheme is the same, with the operators of :class:`~struphy.propagators.shear_alfven_propagator.ShearAlfvenPropagator` for the equilibrium terms, :math:`\mathbb M^v[\hat\rho_0] \to \mathbb M^{2,n}` and :math:`\mathbb T[\mathbf B_0] \to \mathbb C\, \mathcal T^2` (:attr:`~struphy.feec.basis_projection_ops.BasisProjectionOperators.T2`). The energy is conserved in the same way. """
[docs] class Variables: """Container for variables advanced by :class:`VariationalLinearAlfvenEvolve`. Attributes ---------- u : FEECVariable Velocity variable in ``"H1vec"`` or ``"Hdiv"`` space. b : FEECVariable Magnetic-field perturbation 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 ("H1vec", "Hdiv") 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, at1: FEECVariable = None, bt2: FEECVariable = None): r""" Parameters ---------- at1 : FEECVariable, default=None Auxiliary second-order vector potential :math:`\hat{\mathbf A}` (``"Hcurl"`` space), advanced with :math:`\partial_t \hat{\mathbf A} = - \mathbf B \times \mathbf u`. It enters only the energy, :meth:`second_order_energy`. bt2 : FEECVariable, default=None Diagnostic second-order field :math:`\hat{\mathbf B} = \nabla \times \hat{\mathbf A}` (``"Hdiv"`` space). """ self.variables = self.Variables() self.at1 = at1 self.bt2 = bt2
[docs] @dataclass(repr=False) class Options(OptionsBase): r"""Configuration options for :class:`VariationalLinearAlfvenEvolve`. Parameters ---------- weights : {"frozen", "midpoint"}, default="midpoint" Weight of the second-order term: :math:`\mathbf b^n` (first order in time) or :math:`\mathbf b^{n+1/2}` (second order). solver : {"pcg", "pcg_sr", "cg", "pbicgstab", "rpbicgstab"}, default=None Linear solver of the Schur system; None gives ``"pcg"`` for ``weights="frozen"`` and ``"rpbicgstab"`` (BiCGStab with right preconditioning, see :class:`~struphy.linear_algebra.schur_solver.SchurSolverFull`) for ``weights="midpoint"``, whose Schur complement is not symmetric (only ``"rpbicgstab"`` or ``"pbicgstab"``). precond : {"MassMatrixPreconditioner", None}, default="MassMatrixPreconditioner" Preconditioner of the Schur system (of its velocity block). solver_params : SolverParameters, default=None Linear-solver controls. The system is solved for a normalized right-hand side, so ``tol`` is relative to the norm of the residual at :math:`t^n`. info : bool, default=False Log the information of the linear solver in every step. relative_tol : bool, default=True Whether the tolerance of ``solver_params`` is relative to the norm of the right-hand side (the residual at :math:`t^n`, normalized before the solve), or absolute, as in the non-variational propagators (e.g. :class:`~struphy.propagators.shear_alfven_propagator.ShearAlfvenPropagator`). The energy is conserved up to the residual of the linear solve. schur_precond : {"MassMatrixPreconditioner", "StiffnessPreconditioner"}, default="MassMatrixPreconditioner" Preconditioner of the Schur system: the one of ``precond`` (velocity block), or the Kronecker (fast diagonalization) preconditioner of the whole shear-Alfven Schur operator (:func:`~struphy.feec.variational_utilities.alfven_schur_preconditioner`, rebuilt when the time step changes). The latter needs ``solver="pcg"`` (or ``"rpbicgstab"``, ``"pbicgstab"``), tensor-product splines and the global commuting projectors; otherwise ``precond`` is used (with a warning). exb_correction : bool, default=True For ``schur_precond="StiffnessPreconditioner"``: add the auxiliary space correction of the :math:`\mathbf E \times \mathbf B` flows :math:`\mathbf B_0 \times \nabla \phi`. """ # specific literals OptsPrecond = Literal["MassMatrixPreconditioner", None] OptsSchurPrecond = Literal["MassMatrixPreconditioner", "StiffnessPreconditioner"] OptsWeights = Literal["frozen", "midpoint"] OptsSolver = Literal["pcg", "pcg_sr", "cg", "pbicgstab", "rpbicgstab"] # propagator options weights: OptsWeights = "midpoint" solver: OptsSolver = None precond: OptsPrecond = "MassMatrixPreconditioner" solver_params: SolverParameters = None info: bool = False relative_tol: bool = True schur_precond: OptsSchurPrecond = "MassMatrixPreconditioner" exb_correction: bool = True def __post_init__(self): # checks check_option(self.weights, self.OptsWeights) if self.solver is None: self.solver = "pcg" if self.weights == "frozen" else "rpbicgstab" check_option(self.solver, self.OptsSolver) assert self.weights == "frozen" or self.solver in ("pbicgstab", "rpbicgstab"), ( f'weights="midpoint" needs solver="rpbicgstab" or "pbicgstab" (not symmetric), got {self.solver!r}.' ) check_option(self.precond, self.OptsPrecond) check_option(self.schur_precond, self.OptsSchurPrecond) # defaults if self.solver_params is None: self.solver_params = SolverParameters()
@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._at1 = None if self.at1 is None else self.at1.spline.vector self._bt2 = None if self.bt2 is None else self.bt2.spline.vector self._info = self.options.info and (self.rank == 0) # assembly of WMMnew happens in VariationalDensityEvolve self._allocate_mass(self.options.precond) # transport operators with the equilibrium (constant) and with the perturbation (weight b^n) if self._u_space == "Hdiv": # equilibrium term as in ShearAlfvenPropagator self._T0 = self.derham.curl @ self.basis_ops.T2 self._T0T = self._T0.T else: self._T0 = Hdiv0_transport_operator(self.derham) self._T0T = self._T0.T self._T0.update_coeffs(self.projected_equil.b2) self._T0T.update_coeffs(self.projected_equil.b2) # second-order term: the force int v . (j0 x b) of the projected equilibrium current (R = -W below), and the # auxiliary vector potential a, the L2 projection of -b x u into V1 without boundary conditions self._midpoint = self.options.weights == "midpoint" self._init_second_order_term() self._allocate_schur_solver( self._T0, self._T0T @ self.mass_ops.M2, -1.0, self.derham.V2pol, self.options.solver, self.options.solver_params, R=self._R if self._midpoint else None, ) # Kronecker preconditioner of the Schur operator, built for the time step in prepare_time_step self._schur_pc = alfven_schur_preconditioner(self, [], u_space=self._u_space) self._schur_pc_dt = None # temporaries u = self.variables.u.spline.vector b = self.variables.b.spline.vector self._tmp_un1 = u.space.zeros() self._tmp_un12 = u.space.zeros() self._tmp_fu = u.space.zeros() self._tmp_fu2 = u.space.zeros() self._tmp_bn1 = b.space.zeros() self._tmp_bn12 = b.space.zeros() self._tmp_fb = b.space.zeros() self._tmp_M2b = b.space.zeros() def _init_second_order_term(self): r"""The matrix :math:`\mathbb W` of :math:`\int \mathbf v \cdot (\mathbf j_0 \times \mathbf B)` (``-self._R``) and the evaluation of :math:`\mathbf B \times \mathbf u` at the quadrature points for the auxiliary field. With :math:`\mathbf u = DF \tilde{\mathbf u}`, :math:`\tilde{\mathbf u} = \hat{\mathbf u}^2 / \sqrt g` (Hdiv) or :math:`\hat{\mathbf u}` (H1vec), both forms are integrals of :math:`\hat{\mathbf j}_0^\top G^{-1} (\hat{\mathbf B}^2 \times \tilde{\mathbf u}) \sqrt g` at the same quadrature points, so that :math:`\mathbf j_0 \cdot \mathbf y(\mathbf b \times \mathbf u) = \mathbf u^\top \mathbb W \mathbf b` exactly.""" # equilibrium current through the dual complex (no boundary conditions): h0 = *_h b0 in V1, J0 = curl h0 in # V2 and its representation j0 in V1, all over the full spaces M1 = self.mass_ops.M1 M1_free = M1.without_boundary_ops() pc = MassMatrixPreconditioner(M1, apply_bc=False) self._inv_M1 = inverse(M1_free, "pcg", pc=pc, tol=1e-14, maxiter=3000, recycle=True) j0 = self.equilibrium_current(self.projected_equil.b2) # spline evaluation at the quadrature points grid = [pts.flatten() for pts in self.derham.V3splines.quad_grid_pts[0]] self._spans, bn, bd = self.derham.prepare_eval_tp_fixed(grid) self._bases_1form = [[bd[0], bn[1], bn[2]], [bn[0], bd[1], bn[2]], [bn[0], bn[1], bd[2]]] self._bases_2form = [[bn[0], bd[1], bd[2]], [bd[0], bn[1], bd[2]], [bd[0], bd[1], bn[2]]] self._bases_u = self._bases_2form if self._u_space == "Hdiv" else [[bn[0], bn[1], bn[2]]] * 3 sqrt_g = self.domain.jacobian_det(*grid) # u-tilde / u-hat self._u_scale = 1.0 / sqrt_g if self._u_space == "Hdiv" else None j0f = self.derham.create_spline_function("j0", "Hcurl") j0f.vector = j0 j0_v = j0f.eval_tp_fixed_loc(self._spans, self._bases_1form) # c = G^{-1} j0 sqrt(g) times the factor of u-tilde: weights of W, u . (c x b) = u^T [c]_x b g_inv = self.domain.metric_inv(*grid) c = [sum(g_inv[a, e] * j0_v[e] for e in range(3)) for a in range(3)] if self._u_space == "H1vec": c = [ca * sqrt_g for ca in c] # R = -W, with the weights -[c]_x self._R = self.mass_ops.create_weighted_mass("Hdiv", self._u_space) self._R.assemble([[None, c[2], -c[1]], [-c[2], None, c[0]], [c[1], -c[0], None]]) if self._at1 is not None or self._bt2 is not None: # the linear form j0^T M1 of the energy self._M1j0 = M1_free.dot(j0) self._bf = self.derham.create_spline_function("b_aux", "Hdiv") self._uf = self.derham.create_spline_function("u_aux", self._u_space) self._b_values = [xp.zeros_like(sqrt_g) for _ in range(3)] self._u_values = [xp.zeros_like(sqrt_g) for _ in range(3)] self._bxu_values = [xp.zeros_like(sqrt_g) for _ in range(3)] self._get_dofs_1 = L2Projector("Hcurl", self.mass_ops).get_dofs self._tmp_y = self.derham.V1pol.zeros() self._tmp_a = self.derham.V1pol.zeros()
[docs] def equilibrium_current(self, b0): r"""The equilibrium current :math:`\mathbf j_0 \in V^1_h` of the second-order term, through the dual complex: .. math:: \mathbb M^1_{\textnormal{free}}\, \mathbf h_0 = \mathbb K \mathbf b_0 \,, \qquad \mathbf J_0 = \mathbb C\, \mathbf h_0 \,, \qquad \mathbb M^1_{\textnormal{free}}\, \mathbf j_0 = \mathbb K \mathbf J_0 \,, with the metric-free pairing :math:`\mathbb K_{ij} = \int_{\hat\Omega} \vec{\boldsymbol\Lambda}^1_i \cdot \vec{\boldsymbol\Lambda}^2_j \,\textrm d \boldsymbol\eta` of 1-forms and 2-forms, all operators over the full spaces (without boundary conditions). :math:`\mathbf h_0` is the field as a 1-form (Hodge star of the 2-form :math:`\mathbf b_0`) and :math:`\mathbf J_0` its curl, exactly divergence free. Collective: call on all ranks.""" K = self.mass_ops.create_weighted_mass("Hdiv", "Hcurl", weights=("Identity",), assemble=True) K = K.without_boundary_ops() h0 = self._inv_M1.dot(K.dot(b0)).copy() J0 = self.derham.curl_bcfree.dot(h0) return self._inv_M1.dot(K.dot(J0)).copy()
[docs] def second_order_energy(self) -> float: r"""The second-order magnetic energy :math:`\int \mathbf J_0 \cdot \hat{\mathbf A} \approx \mathbf j_0^\top \mathbb M^1_{\textnormal{free}} \hat{\mathbf a}` of the auxiliary vector potential ``at1`` (0 without it). Collective: call on all ranks.""" if self._at1 is None: return 0.0 return self._M1j0.inner(self._at1)
def _advance_aux(self, dt, un12, bw): r""":math:`\hat{\mathbf a} \leftarrow \hat{\mathbf a} - \Delta t\, (\mathbb M^1_{\textnormal{free}})^{-1} \mathbf y(\mathbf b^w \times \mathbf u^{n+1/2})`, with :math:`\mathbf y(\mathbf w)_k = \int \mathbf w \cdot \vec{\boldsymbol\Lambda}^1_k` over all basis functions: the energy :math:`\mathbf j_0^\top \mathbb M^1_{\textnormal{free}} \hat{\mathbf a}` changes by :math:`-\Delta t\, \mathbf j_0 \cdot \mathbf y = -\Delta t\, (\mathbf u^{n+1/2})^\top \mathbb W \mathbf b^w`; and the diagnostic :math:`\hat{\mathbf b} \leftarrow \hat{\mathbf b} + \mathbb C\, \delta \hat{\mathbf a}`.""" self._bf.vector = bw self._uf.vector = un12 b_v = self._bf.eval_tp_fixed_loc(self._spans, self._bases_2form, out=self._b_values) u_v = self._uf.eval_tp_fixed_loc(self._spans, self._bases_u, out=self._u_values) if self._u_scale is not None: for ui in u_v: ui *= self._u_scale bxu = self._bxu_values for i in range(3): j, k = (i + 1) % 3, (i + 2) % 3 bxu[i][:] = b_v[j] * u_v[k] bxu[i] -= b_v[k] * u_v[j] y = self._get_dofs_1(bxu, dofs=self._tmp_y, apply_bc=False) a = self._inv_M1.dot(y, out=self._tmp_a) a *= dt if self._at1 is not None: self._at1 -= a self._at1.update_ghost_regions() if self._bt2 is not None: ca = self.derham.curl.dot(a, out=self._tmp_fb) self._bt2 -= ca self._bt2.update_ghost_regions()
[docs] def prepare_time_step(self, dt: float): """Kronecker preconditioner of the Schur operator for the time step ``dt`` (with ``schur_precond="StiffnessPreconditioner"``): built in the first step, rebuilt when dt changes.""" if self._schur_pc is not None and dt != self._schur_pc_dt: self._inv_J.precond = self._schur_pc.build(dt) self._schur_pc_dt = dt
@profile def __call__(self, dt): # preconditioner for the current time step (no-op unless dt changed) self.prepare_time_step(dt) # WMMnew is assembled in VariationalDensityEvolve: update the diagonal scaling of its preconditioner self._refresh_mass_pc() un = self.variables.u.spline.vector bn = self.variables.b.spline.vector # residual at (u^n, b^n): the force -T0^T M2 b^n - T[b^n]^T M2 b0 and the induction T0 u^n (times dt) M2b = self.mass_ops.M2.dot(bn, out=self._tmp_M2b) M2b *= -1.0 f_u = self._T0T.dot(M2b, out=self._tmp_fu) f_u += self._R.dot(bn, out=self._tmp_fu2) f_u *= dt f_b = self._T0.dot(un, out=self._tmp_fb) f_b *= dt incr = self._solve_increment(dt, f_u, f_b) un1 = un.copy(out=self._tmp_un1) un1 -= incr[0] bn1 = bn.copy(out=self._tmp_bn1) bn1 -= incr[1] # auxiliary second-order variable, with the same weight as the momentum equation (b^n or b^{n+1/2}) if self._at1 is not None or self._bt2 is not None: if self._midpoint: bw = bn.copy(out=self._tmp_bn12) bw += bn1 bw *= 0.5 else: bw = bn un12 = un.copy(out=self._tmp_un12) un12 += un1 un12 *= 0.5 self._advance_aux(dt, un12, bw) self.update_feec_variables(u=un1, b=bn1)