Source code for struphy.propagators.variational_sound_evolve

import logging
from dataclasses import dataclass
from typing import Literal

from feectools.linalg.basic import IdentityOperator
from feectools.linalg.block import BlockLinearOperator, BlockVectorSpace
from feectools.linalg.solvers import inverse
from line_profiler import profile

from struphy.feec import preconditioner
from struphy.feec.preconditioner import MassMatrixPreconditioner
from struphy.feec.variational_utilities import (
    NewtonConvergence,
    Pressure_transport_operator,
    dot_normalized,
    inv_norm2,
)
from struphy.io.options import LiteralOptions, OptionsBase
from struphy.linear_algebra.schur_solver import SchurSolverFull
from struphy.linear_algebra.solver import NonlinearSolverParameters, SolverParameters
from struphy.models.variables import FEECVariable
from struphy.propagators.base import Propagator
from struphy.utils.utils import check_option

logger = logging.getLogger("struphy")


[docs] class VariationalSoundEvolve(Propagator): r""":ref:`FEEC <gempic>` discretization of the coupling of the velocity and :math:`q = \sqrt p` (sound waves) of the variational MHD models in :math:`q`: find :math:`\mathbf u \in (H^1)^3` and :math:`q \in L^2` such that, with the transport operator :math:`\mathcal S_{\bar q}(\mathbf v) = \nabla \cdot (\bar q \mathbf v) + \left(\frac \gamma 2 - 1\right) \bar q\, \nabla \cdot \mathbf v`, depending on the option ``model``: * ``"full_q"`` (full MHD): .. math:: \int_\Omega \partial_t (\rho \mathbf u) \cdot \mathbf v\,\textrm d \mathbf x - \int_\Omega \frac{2 q}{\gamma -1} \mathcal S_q(\mathbf v)\,\textrm d \mathbf x = 0 \qquad \forall \, \mathbf v \in (H^1)^3\,, \qquad \partial_t q + \mathcal S_q(\mathbf u) = 0 \,. * ``"linear_q"`` (:math:`q` is the perturbation of the equilibrium :math:`q_0`): .. math:: \int_\Omega \partial_t (\rho_0 \mathbf u) \cdot \mathbf v\,\textrm d \mathbf x - \int_\Omega \frac{2}{\gamma -1} \left(q\, \mathcal S_{q_0}(\mathbf v) + q_0\, \mathcal S_q(\mathbf v)\right)\textrm d \mathbf x = 0\,, \qquad \partial_t q + \mathcal S_{q_0}(\mathbf u) = 0 \,. * ``"perturbative_q"`` (:math:`q` is the perturbation, all nonlinear terms are kept): .. math:: \int_\Omega \partial_t (\rho \mathbf u) \cdot \mathbf v\,\textrm d \mathbf x - \int_\Omega \frac{2}{\gamma -1} \left(q\, \mathcal S_{q_0 + q}(\mathbf v) + q_0\, \mathcal S_q(\mathbf v)\right)\textrm d \mathbf x = 0\,, \qquad \partial_t q + \mathcal S_{q_0 + q}(\mathbf u) = 0 \,. The magnetic field is not changed; together with :class:`~struphy.propagators.variational_alfven_evolve.VariationalAlfvenEvolve` this splits the coupled step of :class:`~struphy.propagators.variational_qb_evolve.VariationalQBEvolve` into the shear-Alfvén/fast and the sound waves. The second-order term of the linear and perturbative models comes from the auxiliary variable :math:`\hat q` (``qt3``), which, if given, is advanced with :math:`\partial_t \hat q + \mathcal S_q(\mathbf u) = 0`; it enters the conserved energy as :math:`\int 2 q_0 \hat q / (\gamma - 1)`. For ``"perturbative_q"``, :math:`\rho` is :math:`\rho_0` plus the variable of :class:`~struphy.propagators.variational_density_evolve.VariationalDensityEvolve`. Discretization: with :math:`\mathbb S[\bar q] = \mathbb D \hat\Pi^2[\hat{\bar q} \vec{\boldsymbol\Lambda}^v] + (\gamma/2 - 1) \hat\Pi^3[\hat{\bar q}/\sqrt g\, \vec{\boldsymbol\Lambda}^3] \mathbb D \mathcal U^v` (:class:`~struphy.feec.variational_utilities.Pressure_transport_operator`) and, for ``"linear_q"``, .. math:: \begin{align} &\mathbb M^v[\hat\rho_0] \frac{\mathbf u^{n+1} - \mathbf u^n}{\Delta t} - \frac{2}{\gamma - 1}\left(\mathbb S[q_0]^\top \mathbb M^3 \mathbf q^{n+\frac12} + \mathbb S[\bar{\mathbf q}]^\top \mathbb M^3 \mathbf q_0\right) = 0 \,, \\[2mm] &\frac{\mathbf q^{n+1} - \mathbf q^n}{\Delta t} + \mathbb S[q_0]\, \mathbf u^{n+\frac12} = 0 \,, \qquad \frac{\hat{\mathbf q}^{n+1} - \hat{\mathbf q}^n}{\Delta t} + \mathbb S[\bar{\mathbf q}]\, \mathbf u^{n+\frac12} = 0 \,, \end{align} and correspondingly for the other models. The weight :math:`\bar{\mathbf q}` is taken at the same time in all equations, which makes the step energy conserving: at :math:`t^n` (first order in time, one Newton step solves the linear system), or at the midpoint :math:`t^{n+1/2}` with the option ``implicit_weights`` (second order in time, a Picard iteration). The Newton system is solved with :class:`~struphy.linear_algebra.schur_solver.SchurSolverFull`; for ``"linear_q"`` its Schur complement is :math:`\mathbb M^v[\hat\rho_0] + \frac{\Delta t^2}{2(\gamma - 1)} \mathbb S[q_0]^\top \mathbb M^3 \mathbb S[q_0]`. """
[docs] class Variables: """Container for variables advanced by :class:`VariationalSoundEvolve`. Attributes ---------- u : FEECVariable Velocity variable in ``"H1vec"`` space. q : FEECVariable Square root of the pressure in ``"L2"`` space. """ def __init__(self): self._u: FEECVariable = None self._q: FEECVariable = None @property def u(self) -> FEECVariable: return self._u @u.setter def u(self, new): assert isinstance(new, FEECVariable) assert new.space == "H1vec" self._u = new @property def q(self) -> FEECVariable: return self._q @q.setter def q(self, new): assert isinstance(new, FEECVariable) assert new.space == "L2" self._q = new
def __init__(self, div_u: FEECVariable = None, u2: FEECVariable = None, qt3: FEECVariable = None): r""" Parameters ---------- div_u : FEECVariable, default=None Divergence diagnostic (``"L2"`` space) of the velocity at the midpoint. u2 : FEECVariable, default=None Velocity as 2-form, :math:`\mathcal U^v \mathbf u` (``"Hdiv"`` space), diagnostic. qt3 : FEECVariable, default=None Auxiliary second-order variable :math:`\hat q` (``"L2"`` space) of the linear and perturbative models, advanced with :math:`\partial_t \hat q + \mathcal S_q(\mathbf u) = 0`. It enters only the energy, not the momentum equation. """ self.variables = self.Variables() self.div_u = div_u self.u2 = u2 self.qt3 = qt3
[docs] @dataclass(repr=False) class Options(OptionsBase): r"""Configuration options for :class:`VariationalSoundEvolve`. Parameters ---------- model : {"full_q", "linear_q", "perturbative_q"}, default="full_q" Model variant. gamma : float, default=5/3 Adiabatic index. solver : LiteralOptions.OptsSymmSolver, default="pcg" Linear solver of the Schur system. precond : LiteralOptions.OptsMassPrecond, default="MassMatrixPreconditioner" Preconditioner used in linear solves. solver_params : SolverParameters, default=None Linear-solver controls. The Newton systems are solved for a normalized right-hand side, so ``tol`` is relative to the norm of the residual. nonlin_solver : NonlinearSolverParameters, default=None Nonlinear/Picard iteration controls. The iteration stops when the norm of the residual is below ``tol`` times the norm of the state at :math:`t^n` if that is smaller than one (absolute otherwise), or when it stagnates (with a warning). implicit_weights : bool, default=None If ``True``, the weight of the transport operators is taken at the midpoint :math:`t^{n+1/2}` and updated in every Picard iteration (second order in time, several iterations per step); if ``False``, it is taken at :math:`t^n` (first order in time, one linear solve). Both conserve the energy. ``None`` means ``True`` for ``"linear_q"`` and ``"perturbative_q"`` and ``False`` for ``"full_q"``. """ # specific literals OptsModel = Literal["full_q", "linear_q", "perturbative_q"] # propagator options model: OptsModel = "full_q" gamma: float = 5.0 / 3.0 solver: LiteralOptions.OptsSymmSolver = "pcg" precond: LiteralOptions.OptsMassPrecond = "MassMatrixPreconditioner" solver_params: SolverParameters = None nonlin_solver: NonlinearSolverParameters = None implicit_weights: bool | None = None def __post_init__(self): # checks check_option(self.model, self.OptsModel) check_option(self.solver, LiteralOptions.OptsSymmSolver) check_option(self.precond, LiteralOptions.OptsMassPrecond) # defaults if self.solver_params is None: self.solver_params = SolverParameters() if self.nonlin_solver is None: self.nonlin_solver = NonlinearSolverParameters() if self.implicit_weights is None: self.implicit_weights = self.model in ("linear_q", "perturbative_q")
@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._model = self.options.model self._lin_solver = self.options.solver_params self._nonlin_solver = self.options.nonlin_solver self._linearize = self.options.nonlin_solver.linearize self._gamma = self.options.gamma self._implicit_weights = self.options.implicit_weights self._divu = None if self.div_u is None else self.div_u.spline.vector self._u2 = None if self.u2 is None else self.u2.spline.vector self._qt3 = None if self.qt3 is None else self.qt3.spline.vector self._rank = self.derham.comm.Get_rank() if self.derham.comm is not None else 0 self._info = self._nonlin_solver.info and (self.rank == 0) # assembly of WMMnew happens in VariationalDensityEvolve self._Mrho = self.mass_ops.WMMnew self._Mrho_pc = MassMatrixPreconditioner(self._Mrho, dim_reduce=None) self._initialize_operators() # temporaries u = self.variables.u.spline.vector q = self.variables.q.spline.vector self._tmp_un1 = u.space.zeros() self._tmp_un12 = u.space.zeros() self._tmp_un_diff = u.space.zeros() self._tmp_mn = u.space.zeros() self._tmp_mn1 = u.space.zeros() self._tmp_mn_diff = u.space.zeros() self._tmp_advection = u.space.zeros() self._tmp_advection2 = u.space.zeros() self._tmp_qn1 = q.space.zeros() self._tmp_qn12 = q.space.zeros() self._tmp_qn_diff = q.space.zeros() self._tmp_q_advection = q.space.zeros() self._tmp_q_advection2 = q.space.zeros() self._linear_form_dl_dq = q.space.zeros() if self._linearize: self._extracted_q3 = self.derham.extraction_ops["3"].dot(self.projected_equil.q3) @profile def __call__(self, dt): # WMMnew is re-assembled in VariationalDensityEvolve: update the diagonal scaling of its preconditioner self._Mrho_pc.refresh() if self._nonlin_solver.type != "Picard": raise ValueError("Only the Picard solver is implemented for VariationalSoundEvolve") if self._info: logger.info("") logger.info("Newton iteration in VariationalSoundEvolve") un = self.variables.u.spline.vector qn = self.variables.q.spline.vector # with implicit weights the operators are updated at the midpoint inside the iteration if not self._implicit_weights: self._update_weights(qn) mn = self._Mrho.dot(un, out=self._tmp_mn) qn1 = qn.copy(out=self._tmp_qn1) qn1 += self._tmp_qn_diff un1 = un.copy(out=self._tmp_un1) un1 += self._tmp_un_diff mn1 = self._Mrho.dot(un1, out=self._tmp_mn1) tol = self._nonlin_solver.tol # stopping criterion relative to the state at t^n, with a check for stagnation conv = NewtonConvergence("VariationalSoundEvolve", tol, self._get_error(mn, qn)) for it in range(self._nonlin_solver.maxiter): un12 = un.copy(out=self._tmp_un12) un12 += un1 un12 *= 0.5 self._update_linear_form_dl_dq(qn, qn1) if self._implicit_weights: self._update_weights(self._tmp_qn12) advection, q_advection = self._advection(un12) advection *= dt q_advection *= dt qn_diff = qn1.copy(out=self._tmp_qn_diff) qn_diff -= qn qn_diff += q_advection mn_diff = mn1.copy(out=self._tmp_mn_diff) mn_diff -= mn mn_diff += advection stop = conv.stop(self._get_error(mn_diff, qn_diff), it) if self._info: logger.info(f"iteration : {it} relative error : {conv.err}") if stop: break # Newton step for the normalized residual (the tolerance of the linear solver is relative to it) self._get_jacobian(dt) self._tmp_f[0] = mn_diff self._tmp_f[1] = qn_diff incr = dot_normalized(self._inv_Jacobian, self._tmp_f, self._tmp_incr) if self._info: logger.info(f"information on the linear solver : {self._inv_Jacobian._solver._info}") un1 -= incr[0] qn1 -= incr[1] mn1 = self._Mrho.dot(un1, out=self._tmp_mn1) conv.warn_if_not_converged(it, self._nonlin_solver.maxiter) if not conv.converged: # the last iteration applied an update after the residual: midpoint velocity (and weights) of the final # iterate, such that the update of the auxiliary variable below is consistent with it un12 = un.copy(out=self._tmp_un12) un12 += un1 un12 *= 0.5 if self._implicit_weights: self._update_linear_form_dl_dq(qn, qn1) self._update_weights(self._tmp_qn12) self._tmp_un_diff = un1 - un self._tmp_qn_diff = qn1 - qn self.update_feec_variables(q=qn1, u=un1) # diagnostics if self._divu is not None: self._transop_q.div.dot(un12, out=self._divu) if self._u2 is not None: self._transop_q._Uv.dot(un1, out=self._u2) # auxiliary second-order variable, with the same weight if self._qt3 is not None: q_advection = self._transop_q.dot(un12, out=self._tmp_q_advection) q_advection *= dt self._qt3 -= q_advection def _advection(self, un12): """The force of q on the velocity (without dt) and the transport of q.""" advection = self._tmp_advection advection *= 0.0 q_advection = self._tmp_q_advection q_advection *= 0.0 if self._model == "linear_q": advection += self._transop_q0T.dot(self._linear_form_dl_dq, out=self._tmp_advection2) advection += self._transop_qT.dot(self._linear_form_dl_dq0, out=self._tmp_advection2) q_advection += self._transop_q0.dot(un12, out=self._tmp_q_advection2) elif self._model == "perturbative_q": advection += self._transop_qT.dot(self._linear_form_dl_dq, out=self._tmp_advection2) advection += self._transop_q0T.dot(self._linear_form_dl_dq, out=self._tmp_advection2) advection += self._transop_qT.dot(self._linear_form_dl_dq0, out=self._tmp_advection2) q_advection += self._transop_q0.dot(un12, out=self._tmp_q_advection2) q_advection += self._transop_q.dot(un12, out=self._tmp_q_advection2) else: advection += self._transop_qT.dot(self._linear_form_dl_dq, out=self._tmp_advection2) q_advection += self._transop_q.dot(un12, out=self._tmp_q_advection2) return advection, q_advection def _initialize_operators(self): """Transport operators, the Jacobian of the Newton step and its Schur solver.""" self._transop_q = Pressure_transport_operator(self.derham, self.domain, self.basis_ops.Uv, self._gamma / 2.0) self._transop_qT = self._transop_q.T # inverse mass matrix for the error norm self.pc_Mv = preconditioner.MassMatrixPreconditioner(self.mass_ops.Mv, dim_reduce=None) self._inv_Mv = inverse(self.mass_ops.Mv, "pcg", pc=self.pc_Mv, tol=1e-10, maxiter=1000, verbose=False) if self._model in ("linear_q", "perturbative_q"): self._transop_q0 = Pressure_transport_operator( self.derham, self.domain, self.basis_ops.Uv, self._gamma / 2.0 ) self._transop_q0T = self._transop_q0.T self._transop_q0.update_coeffs(self.projected_equil.q3) self._transop_q0T.update_coeffs(self.projected_equil.q3) self._linear_form_dl_dq0 = -2 / (self._gamma - 1.0) * self.mass_ops.M3.dot(self.projected_equil.q3) if self._model == "linear_q": S, ST = self._transop_q0, self._transop_q0T elif self._model == "perturbative_q": S, ST = self._transop_q0 + self._transop_q, self._transop_q0T + self._transop_qT else: S, ST = self._transop_q, self._transop_qT # dt-dependent scalars set in _get_jacobian self._mdt_ST_M3 = 2 * (ST @ self.mass_ops.M3) self._dt2_S = 2 * S Jacs = BlockVectorSpace(self.derham.Vvpol, self.derham.V3pol) self._tmp_f = Jacs.zeros() self._tmp_incr = Jacs.zeros() self._Jacobian = BlockLinearOperator(Jacs, Jacs) self._Jacobian[0, 0] = self._Mrho self._Jacobian[0, 1] = self._mdt_ST_M3 self._Jacobian[1, 0] = self._dt2_S self._Jacobian[1, 1] = IdentityOperator(self.derham.V3pol) self._inv_Jacobian = SchurSolverFull( self._Jacobian, self.options.solver, # fixed (linear) preconditioner: an inner iterative solve is expensive and not a linear operator pc=self._Mrho_pc, tol=self._lin_solver.tol, maxiter=self._lin_solver.maxiter, verbose=self._lin_solver.verbose, recycle=True, ) def _update_weights(self, q): """Weights of the transport operator with the (perturbed) q.""" self._transop_q.update_coeffs(q) self._transop_qT.update_coeffs(q) def _update_linear_form_dl_dq(self, qn, qn1): """The linear form -2/(gamma-1) M3 q^{n+1/2} (derivative of the thermal energy).""" qn12 = qn.copy(out=self._tmp_qn12) qn12 += qn1 qn12 *= 0.5 if self._linearize: wq = self.mass_ops.M3.dot(qn12 - self._extracted_q3, out=self._linear_form_dl_dq) else: wq = self.mass_ops.M3.dot(qn12, out=self._linear_form_dl_dq) wq *= -2 / (self._gamma - 1) def _get_error(self, mn_diff, qn_diff): # the inverse mass matrix is applied to the normalized residual (relative solver tolerance) err_u = inv_norm2(self._inv_Mv, self.derham.boundary_ops["v"].dot(mn_diff)) err_q = self.mass_ops.M3.dot_inner(qn_diff, qn_diff) return max(err_q, err_u) def _get_jacobian(self, dt): self._mdt_ST_M3._scalar = -dt / (self._gamma - 1.0) self._dt2_S._scalar = dt / 2