Source code for struphy.propagators.variational_linear_sound_evolve

import logging
from dataclasses import dataclass
from typing import Literal

from line_profiler import profile

from struphy.feec.mass import L2Projector
from struphy.feec.variational_utilities import Pressure_transport_operator, dot_normalized
from struphy.io.options import LiteralOptions, OptionsBase
from struphy.linear_algebra.solver import SolverParameters
from struphy.models.variables import FEECVariable
from struphy.polar.basic import PolarVector
from struphy.propagators.variational_linear_base import VariationalLinearEvolve
from struphy.utils.utils import check_option

logger = logging.getLogger("struphy")


[docs] class VariationalLinearSoundEvolve(VariationalLinearEvolve): r""":ref:`FEEC <gempic>` discretization of the coupling of the velocity and the thermodynamic variable (sound waves) of the linear variational MHD models, with one linear solve per time step: find :math:`\mathbf u \in (H^1)^3` and :math:`s \in L^2` (the perturbation of :math:`q = \sqrt p` or of :math:`p`) such that, with the transport operator :math:`\mathcal S^\kappa_{\bar s}(\mathbf v) = \nabla \cdot (\bar s \mathbf v) + (\kappa - 1) \bar s\, \nabla \cdot \mathbf v`, depending on the option ``model``: * ``"linear_q"`` (:math:`s = q`, :math:`\kappa = \gamma/2`): .. 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^\kappa_{q_0}(\mathbf v) + q_0\, \mathcal S^\kappa_q(\mathbf v)\right)\textrm d \mathbf x = 0 \qquad \forall \, \mathbf v \in (H^1)^3\,, \qquad \partial_t q + \mathcal S^\kappa_{q_0}(\mathbf u) = 0 \,. * ``"linear_p"`` (:math:`s = p`, :math:`\kappa = \gamma`): .. math:: \int_\Omega \partial_t (\rho_0 \mathbf u) \cdot \mathbf v\,\textrm d \mathbf x - \int_\Omega \frac{1}{\gamma -1} \mathcal S^\kappa_p(\mathbf v)\,\textrm d \mathbf x = 0 \qquad \forall \, \mathbf v \in (H^1)^3\,, \qquad \partial_t p + \mathcal S^\kappa_{p_0}(\mathbf u) = 0 \,. The magnetic field is not changed; together with :class:`~struphy.propagators.variational_linear_alfven_evolve.VariationalLinearAlfvenEvolve` this splits the linear MHD step into the shear-Alfvén/fast and the sound waves. The second-order term of the energy comes from the auxiliary variable :math:`\hat s` (``pt3``), which, if given, is advanced with :math:`\partial_t \hat s + \mathcal S^\kappa_s(\mathbf u) = 0`; it enters the conserved energy as :math:`\int 2 q_0 \hat q / (\gamma - 1)`, respectively :math:`\int \hat p / (\gamma - 1)`. Discretization: with :math:`\mathbb S[\bar s] = \mathbb D \hat\Pi^2[\hat{\bar s} \vec{\boldsymbol\Lambda}^v] + (\kappa - 1) \hat\Pi^3[\hat{\bar s}/\sqrt g\, \vec{\boldsymbol\Lambda}^3] \mathbb D \mathcal U^v` (:class:`~struphy.feec.variational_utilities.Pressure_transport_operator`), * ``"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[\mathbf q^n]^\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[\mathbf q^n]\, \mathbf u^{n+\frac12} = 0 \,, \end{align} solved with one linear solve (:class:`~struphy.propagators.variational_linear_base.VariationalLinearEvolve`); the Schur complement :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]` is constant in time and the sound waves are implicit. * ``"linear_p"``: only the integral of :math:`\hat p` enters the energy, and for :math:`\mathbf u \cdot \mathbf n = 0` on the boundary :math:`\int \mathcal S^\gamma_p(\mathbf v) = (\gamma - 1) \int p\, \nabla \cdot \mathbf v`. The second-order term is therefore discretized in this weak form, with the divergence :math:`\mathbb D \mathcal U^v` of the velocity and the mass matrix :math:`\mathbb M^3`: .. math:: \begin{align} &\mathbb M^v[\hat\rho_0] \frac{\mathbf u^{n+1} - \mathbf u^n}{\Delta t} - (\mathbb D \mathcal U^v)^\top \mathbb M^3 \mathbf p^w = 0 \,, \qquad \frac{\mathbf p^{n+1} - \mathbf p^n}{\Delta t} + \mathbb S[p_0]\, \mathbf u^{n+\frac12} = 0 \,, \\[2mm] &\frac{\hat{\mathbf p}^{n+1} - \hat{\mathbf p}^n}{\Delta t} + (\gamma - 1)\, \frac{(\mathbb D \mathcal U^v \mathbf u^{n+\frac12}) \odot (\mathbb M^3 \mathbf p^w)} {\hat l^3(1)} = 0 \,, \end{align} with :math:`\hat l^3(1)_{ijk} = \int_{\hat\Omega} \Lambda^3_{ijk} \,\textrm d \boldsymbol\eta` and elementwise product and division: the auxiliary variable is advanced with :math:`\partial_t \hat p + (\gamma - 1) p\, \nabla \cdot \mathbf u = 0` (lumped :math:`L^2` projection), and its energy :math:`\hat l^3(1) \cdot \hat{\mathbf p} / (\gamma - 1)` changes by exactly the work :math:`-\Delta t\, (\mathbb D \mathcal U^v \mathbf u^{n+\frac12})^\top \mathbb M^3 \mathbf p^w` of the force. The pressure force is the one of :class:`~struphy.propagators.magnetosonic.Magnetosonic`. The option ``weights`` sets :math:`\mathbf p^w`. With ``"midpoint"`` (default), :math:`\mathbf p^w = \mathbf p^{n+\frac12}`: one linear solve with the (not symmetric) Schur complement :math:`\mathbb M^v[\hat\rho_0] + \frac{\Delta t^2}{4} (\mathbb D \mathcal U^v)^\top \mathbb M^3 \mathbb S[p_0]`; the step is the implicit midpoint rule, energy conserving and second order in time, and the sound waves are implicit. With ``"frozen"``, :math:`\mathbf p^w = \mathbf p^n`: the velocity is advanced with one mass-matrix solve, then the pressure. The sound waves are then explicit and not stable for any :math:`\Delta t`: the amplitude of a sound wave of frequency :math:`\omega_s` grows by :math:`|\lambda|^2 = 1 + (\Delta t\, \omega_s)^2 / 2` per step (first order in time). For ``"linear_q"`` the weight :math:`\mathbf q^n` at :math:`t^n` is the same in the momentum equation and in the update of :math:`\hat{\mathbf q}`, which makes the step energy conserving; it is first order in time, stable for :math:`\gamma \leq 2`, with a numerical damping of order :math:`\Delta t\, \omega_s^2` for :math:`\gamma < 2` (only ``weights="frozen"``). 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 :math:`\mathbb M^v[\hat\rho_0] \to \mathbb M^{2,n}`, :math:`\mathcal U^v \to \mathbb I` and :math:`\mathbb S[\bar s] = \mathbb D \hat\Pi^2[\hat{\bar s} \vec{\boldsymbol\Lambda}^2/\sqrt g] + (\kappa - 1) \hat\Pi^3[\hat{\bar s}/\sqrt g\, \vec{\boldsymbol\Lambda}^3] \mathbb D`; for ``"linear_p"`` the equilibrium operator is :math:`\mathbb D \mathcal S^2 + (\gamma - 1) \mathcal K^3 \mathbb D` of :class:`~struphy.propagators.magnetosonic.Magnetosonic`. The energy is conserved in the same way. """
[docs] class Variables: """Container for variables advanced by :class:`VariationalLinearSoundEvolve`. Attributes ---------- u : FEECVariable Velocity variable in ``"H1vec"`` or ``"Hdiv"`` space. s : FEECVariable Perturbation of :math:`q = \\sqrt p` (``"linear_q"``) or of the pressure (``"linear_p"``) in ``"L2"`` space. """ def __init__(self): self._u: FEECVariable = None self._s: 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 s(self) -> FEECVariable: return self._s @s.setter def s(self, new): assert isinstance(new, FEECVariable) assert new.space == "L2" self._s = new
def __init__(self, div_u: FEECVariable = None, u2: FEECVariable = None, pt3: FEECVariable = None): r""" Parameters ---------- div_u : FEECVariable, default=None Diagnostic :math:`\nabla \cdot \mathbf u^{n+1/2}` (``"L2"`` space). u2 : FEECVariable, default=None Diagnostic velocity 2-form (``"Hdiv"`` space). pt3 : FEECVariable, default=None Auxiliary second-order variable :math:`\hat s` (``"L2"`` space), advanced with :math:`\partial_t \hat s + \mathcal S^\kappa_s(\mathbf u) = 0`. It enters only the energy. """ self.variables = self.Variables() self.div_u = div_u self.u2 = u2 self.pt3 = pt3
[docs] @dataclass(repr=False) class Options(OptionsBase): r"""Configuration options for :class:`VariationalLinearSoundEvolve`. Parameters ---------- model : {"linear_q", "linear_p"}, default="linear_q" Thermodynamic variable: :math:`q = \sqrt p` or :math:`p`. gamma : float, default=5/3 Adiabatic index. weights : {"frozen", "midpoint"}, default=None Weight of the second-order term: :math:`\mathbf s^n` (first order in time) or :math:`\mathbf s^{n+1/2}` (second order, only for ``"linear_p"``). None gives ``"midpoint"`` for ``"linear_p"`` and ``"frozen"`` for ``"linear_q"``. solver : {"pcg", "pcg_sr", "cg", "pbicgstab", "rpbicgstab"}, default=None Linear solver of the Schur system (``"linear_q"``, or ``"linear_p"`` with midpoint weights) or of the mass matrix (``"linear_p"``, frozen weight, not ``"rpbicgstab"``); 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 linear solve (of the 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 its norm. 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. """ # specific literals OptsModel = Literal["linear_q", "linear_p"] OptsPrecond = Literal["MassMatrixPreconditioner", None] OptsWeights = Literal["frozen", "midpoint"] OptsSolver = Literal["pcg", "pcg_sr", "cg", "pbicgstab", "rpbicgstab"] # propagator options model: OptsModel = "linear_q" gamma: float = 5.0 / 3.0 weights: OptsWeights = None solver: OptsSolver = None precond: OptsPrecond = "MassMatrixPreconditioner" solver_params: SolverParameters = None info: bool = False relative_tol: bool = True def __post_init__(self): # checks check_option(self.model, self.OptsModel) if self.weights is None: self.weights = "midpoint" if self.model == "linear_p" else "frozen" check_option(self.weights, self.OptsWeights) assert self.model == "linear_p" or self.weights == "frozen", ( 'weights="midpoint" is only available for model="linear_p".' ) 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}.' ) assert not (self.model == "linear_p" and self.weights == "frozen" and self.solver == "rpbicgstab"), ( '"rpbicgstab" is a solver of the Schur system, not of the mass matrix ("linear_p", frozen weight).' ) check_option(self.precond, self.OptsPrecond) assert self.gamma > 1.0, f"gamma must be larger than 1, got {self.gamma}." # 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._model = self.options.model self._gamma = self.options.gamma 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._pt3 = None if self.pt3 is None else self.pt3.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 s^n) if self._model == "linear_q": kappa = self._gamma / 2.0 s0 = self.projected_equil.q3 else: kappa = self._gamma s0 = self.projected_equil.p3 if self._u_space == "Hdiv" and self._model == "linear_p": # equilibrium term as in Magnetosonic div = self.derham.div self._S0 = div @ self.basis_ops.S2 + (kappa - 1.0) * (self.basis_ops.K3 @ div) else: self._S0 = Pressure_transport_operator( self.derham, self.domain, self.basis_ops.Uv, kappa, u_space=self._u_space ) self._S0.update_coeffs(s0) u = self.variables.u.spline.vector s = self.variables.s.spline.vector if self._model == "linear_q": # transport operator with the perturbation (weight q^n) and linear form of the equilibrium, -2/(gamma-1) M3 q0 self._Ss = Pressure_transport_operator( self.derham, self.domain, self.basis_ops.Uv, kappa, u_space=self._u_space ) self._SsT = self._Ss.T self._lf0 = -2.0 / (self._gamma - 1.0) * self.mass_ops.M3.dot(s0) self._div_u = self._Ss.div else: # weak form of the second-order term: the force int p div v, i.e. R p = -(D U^v)^T M3 p (U^v the identity # for a velocity in Hdiv), as in Magnetosonic self._div_u = self.derham.div if self._u_space == "Hdiv" else self.derham.div @ self.basis_ops.Uv # (the sign inside the composition: the Schur solver rescales an outer ScaledLinearOperator with dt) self._R = self._div_u.T @ (-1.0 * self.mass_ops.M3) # 1 / l3(1), for the lumped L2 projection of the auxiliary update l3 = L2Projector("L2", self.mass_ops).get_dofs(self.domain.jacobian_det) self._inv_l3 = l3.space.zeros() for a, inv in zip(_coeff_arrays(l3), _coeff_arrays(self._inv_l3)): nonzero = a != 0.0 inv[nonzero] = 1.0 / a[nonzero] self._midpoint = self.options.weights == "midpoint" if self._model == "linear_q": S0T = self._S0.T S0T.update_coeffs(s0) self._S0T = S0T self._allocate_schur_solver( self._S0, S0T @ self.mass_ops.M3, -2.0 / (self._gamma - 1.0), self.derham.V3pol, self.options.solver, self.options.solver_params, ) elif self._midpoint: # the pressure enters the momentum equation only through the second-order term self._allocate_schur_solver( self._S0, None, 1.0, self.derham.V3pol, self.options.solver, self.options.solver_params, R=self._R, ) else: self._allocate_mass_solver(self.options.solver, self.options.solver_params) # temporaries 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_sn1 = s.space.zeros() self._tmp_sn12 = s.space.zeros() self._tmp_fs = s.space.zeros() self._tmp_M3s = s.space.zeros() @profile def __call__(self, dt): # WMMnew is assembled in VariationalDensityEvolve: update the diagonal scaling of its preconditioner self._refresh_mass_pc() un = self.variables.u.spline.vector sn = self.variables.s.spline.vector if self._model == "linear_q": # weight of the second-order term at t^n self._Ss.update_coeffs(sn) self._SsT.update_coeffs(sn) if self._model == "linear_q" or self._midpoint: # residual at (u^n, s^n): the force -2/(gamma-1) (S0^T M3 q^n + S[q^n]^T M3 q0), respectively # -(D U^v)^T M3 p^n, and S0 u^n (times dt) if self._model == "linear_q": M3s = self.mass_ops.M3.dot(sn, out=self._tmp_M3s) M3s *= -2.0 / (self._gamma - 1.0) f_u = self._S0T.dot(M3s, out=self._tmp_fu) f_u += self._SsT.dot(self._lf0, out=self._tmp_fu2) else: f_u = self._R.dot(sn, out=self._tmp_fu) f_u *= dt f_s = self._S0.dot(un, out=self._tmp_fs) f_s *= dt incr = self._solve_increment(dt, f_u, f_s) un1 = un.copy(out=self._tmp_un1) un1 -= incr[0] sn1 = sn.copy(out=self._tmp_sn1) sn1 -= incr[1] un12 = un.copy(out=self._tmp_un12) un12 += un1 un12 *= 0.5 else: # velocity: M (u^{n+1} - u^n) = dt (D U^v)^T M3 p^n, with the pressure at t^n only f_u = self._R.dot(sn, out=self._tmp_fu) f_u *= dt # normalized right-hand side: the tolerance of the linear solver is relative to it if self.options.relative_tol: incr = dot_normalized(self._inv_Mrho, f_u, self._tmp_fu2) else: incr = self._inv_Mrho.dot(f_u, out=self._tmp_fu2) if self._info: logger.info(f"{self.__class__.__name__}, linear solver: {self._inv_Mrho._info}") un1 = un.copy(out=self._tmp_un1) un1 -= incr un12 = un.copy(out=self._tmp_un12) un12 += un1 un12 *= 0.5 # pressure with the midpoint velocity s_advection = self._S0.dot(un12, out=self._tmp_fs) s_advection *= dt sn1 = sn.copy(out=self._tmp_sn1) sn1 -= s_advection # diagnostics if self._divu is not None: self._div_u.dot(un12, out=self._divu) if self._u2 is not None: if self._u_space == "Hdiv": un1.copy(out=self._u2) else: self.basis_ops.Uv.dot(un1, out=self._u2) # auxiliary second-order variable, with the same weight as the momentum equation (s^n or s^{n+1/2}) if self._pt3 is not None: if self._model == "linear_q": s_advection = self._Ss.dot(un12, out=self._tmp_fs) else: if self._midpoint: sw = sn.copy(out=self._tmp_sn12) sw += sn1 sw *= 0.5 else: sw = sn # (gamma - 1) (D U^v u^{n+1/2}) * (M3 p^w) / l3(1): lumped L2 projection of (gamma - 1) p div u; the # energy l3(1) . p-hat / (gamma - 1) changes by exactly -dt (D U^v u)^T M3 p, the work of the force s_advection = self._div_u.dot(un12, out=self._tmp_fs) M3p = self.mass_ops.M3.dot(sw, out=self._tmp_M3s) for a, m, inv in zip(_coeff_arrays(s_advection), _coeff_arrays(M3p), _coeff_arrays(self._inv_l3)): a *= m a *= inv s_advection *= self._gamma - 1.0 s_advection *= dt self._pt3 -= s_advection self._pt3.update_ghost_regions() self.update_feec_variables(u=un1, s=sn1)
def _coeff_arrays(v) -> list: """The coefficient arrays of a scalar Stencil- or PolarVector, for elementwise operations.""" if isinstance(v, PolarVector): return [*v.pol, v.tp._data] return [v._data]