Source code for struphy.propagators.curl_curl_solve

import logging
from dataclasses import dataclass
from typing import Callable, Literal

import cunumpy as xp
from feectools.linalg.basic import IdentityOperator, Vector
from feectools.linalg.solvers import inverse
from feectools.linalg.stencil import StencilVector
from line_profiler import profile
from maybempi import MPI
from scope_profiler import ProfileManager

from struphy.feec import preconditioner
from struphy.feec.mass import L2Projector, WeightedMassOperator
from struphy.io.options import LiteralOptions
from struphy.linear_algebra.multigrid.preconditioner import MultiGridOptions, MultiGridPreconditioner
from struphy.linear_algebra.solver import SolverParameters
from struphy.models.variables import FEECVariable
from struphy.pic.accumulation.particles_to_grid import AccumulatorVector
from struphy.pic.base import Particles
from struphy.propagators.base import Propagator
from struphy.utils.utils import check_option

logger = logging.getLogger("struphy")


[docs] class CurlCurlSolve(Propagator): r""" Weak discretization of the curl-curl problem. Find :math:`\mathbf E \in H(\textnormal{curl})` such that .. math:: \int_\Omega \nabla \times \mathbf F \cdot \nabla \times \mathbf E\,\textrm d \mathbf x + \sigma \int_\Omega \mathbf F \cdot \mathbf E\,\textrm d \mathbf x = \sum_i \int_\Omega \mathbf F \cdot \mathbf J _i\,\textrm d \mathbf x \qquad \forall \,\mathbf F \in H(\textnormal{curl})\,, where :math:`\mathbf J _i:\Omega \to \mathbb R^3` are real-valued and :math:`\sigma > 0` is a scalar (the operator is symmetric positive definite). Boundary terms from integration by parts are assumed to vanish. The equation is discretized as .. math:: \left( \mathbb C^\top \cdot \mathbb M^2 \cdot \mathbb C + \sigma \mathbb M^1 \right)\, \boldsymbol e^{n+1} =\sum_i \boldsymbol j_i\,, where :math:`\mathbb M^1` and :math:`\mathbb M^2` are :class:`WeightedMassOperators <struphy.feec.mass.WeightedMassOperators>` and :math:`\boldsymbol j_i` is the vector of coefficients of the projection of :math:`\mathbf J_i` into the discrete space :math:`V^1_h\subset H(\textnormal{curl})`. """
[docs] class Variables: """ Attributes ---------- e : FEECVariable Vector-valued solution in ``"Hcurl"`` space. """ def __init__(self): self._e: FEECVariable = None @property def e(self) -> FEECVariable: return self._e @e.setter def e(self, new): assert isinstance(new, FEECVariable) assert new.space == "Hcurl" self._e = new
def __init__( self, j: FEECVariable | tuple[Callable, Callable, Callable] | tuple[AccumulatorVector, Particles] | list = None, j_coeffs: float | list = None, ): """ Parameters ---------- j : FEECVariable or tuple of Callables or tuple or list, default=None Source term(s) on the right-hand side. Accepted entries are: - ``None``: zero source. - ``FEECVariable`` in ``"Hcurl"``. - ``tuple`` (or ``list``) of three ``Callable``s to be projected to ``"Hcurl"`` via ``L2Projector``. - ``AccumulatorVector``. - a ``list`` containing any mix of the entries above. The tuple form is accepted by typing for compatibility with other propagator interfaces that pair particle data with accumulators. j_coeffs : float or list, default=None Multiplicative coefficient(s) for ``j`` sources. If a scalar is provided, it is applied to a single source. If a sequence is provided, its length must match the number of collected sources. If ``None``, all coefficients default to ``1.0``. """ self.variables = self.Variables() self.j = j self.j_coeffs = j_coeffs
[docs] @dataclass class Options: """Configuration options for :class:`CurlCurlSolve`. Parameters ---------- sigma : float, default=1.0 Coefficient of the stabilization/mass term on the left-hand side, ``curl.T @ diffusion_mat @ curl + sigma * stab_mat``. Must be positive for the operator to be positive definite (values below ``1e-14`` are set to ``1e-14``). stab_mat : {"M1", "Id"}, default="M1" Stabilization operator used in the term weighted by ``sigma``. - ``"M1"``: standard weighted 1-form mass operator. - ``"Id"``: identity operator. diffusion_mat : "M2" or WeightedMassOperator, default="M2" Diffusion metric in the bilinear form ``curl.T @ diffusion_mat @ curl``. You can pass the name of a pre-built operator in ``mass_ops`` or a custom ``WeightedMassOperator`` compatible with the codomain of ``curl``. x0 : StencilVector, default=None Initial guess for the iterative linear solver. solver : LiteralOptions.OptsSymmSolver, default="pcg" Name of the symmetric iterative solver passed to :func:`psydac.linalg.solvers.inverse`. precond : LiteralOptions.OptsCurlCurlPrecond, default=None Name of the preconditioner (requires ``solver="pcg"``): - ``None``: no preconditioner. - ``"MassMatrixPreconditioner"``: Kronecker approximation of the inverse of the 1-form mass matrix ``M1`` (see :mod:`struphy.feec.preconditioner`). - ``"PolarMassMatrixPreconditioner"``: 2D x 1D Kronecker approximation of the inverse of ``M1`` on polar splines. - ``"StiffnessPreconditioner"``: Kronecker approximation of the inverse of ``curl.T @ diffusion_mat @ curl + sigma * stab_mat`` (:class:`~struphy.feec.preconditioner.StiffnessPreconditioner` with ``derivative="curl"``, ``mid=diffusion_mat`` and ``stab=stab_mat``): block Jacobi with fast diagonalization, plus a correction on the gradients (``kernel_correction``, default True). With polar splines, this is a :class:`~struphy.feec.preconditioner.PolarStiffnessPreconditioner`. - ``"HiptmairXu"``: auxiliary space preconditioner of Hiptmair and Xu (:class:`~struphy.feec.preconditioner.HiptmairXuPreconditioner` with ``mid=diffusion_mat`` and ``stab=stab_mat``): smoother plus corrections on the gradients and on vector-valued :math:`H^1` splines. - ``"MultiGrid"``: geometric multigrid V-cycle (:class:`~struphy.linear_algebra.multigrid.preconditioner.MultiGridPreconditioner`) with the hybrid smoother of Hiptmair (see ``multigrid``). precond_params : dict, default=None Keyword arguments passed to the constructor of the preconditioner, e.g. ``{"kernel_correction": False}`` for ``"StiffnessPreconditioner"`` or ``{"smoother": "jacobi"}`` for ``"HiptmairXu"``. multigrid : MultiGridOptions, default=None Options of the multigrid preconditioner (if ``precond="MultiGrid"``). If ``None``, defaults to ``MultiGridOptions(hiptmair=True, smoother_precond="jacobi")``: without the Hiptmair smoother, multigrid is not robust for the curl-curl operator. solver_params : SolverParameters, default=None Iterative-solver controls (for example ``tol``, ``maxiter``, ``verbose``, ``info``, ``recycle``). If ``None``, defaults to ``SolverParameters()``. """ # specific literals OptsStabMat = Literal["M1", "Id"] OptsDiffusionMat = Literal["M2"] # propagator options sigma: float = 1.0 stab_mat: OptsStabMat = "M1" diffusion_mat: OptsDiffusionMat = "M2" x0: StencilVector = None solver: LiteralOptions.OptsSymmSolver = "pcg" precond: LiteralOptions.OptsCurlCurlPrecond = None precond_params: dict = None multigrid: MultiGridOptions = None solver_params: SolverParameters = None def __post_init__(self): # checks check_option(self.stab_mat, self.OptsStabMat) check_option(self.diffusion_mat, self.OptsDiffusionMat) check_option(self.solver, LiteralOptions.OptsSymmSolver) check_option(self.precond, LiteralOptions.OptsCurlCurlPrecond) if self.precond is not None: assert self.solver == "pcg", f"precond={self.precond!r} requires solver='pcg'." # defaults if self.precond_params is None: self.precond_params = {} if self.multigrid is None: self.multigrid = MultiGridOptions(hiptmair=True, smoother_precond="jacobi") 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 @profile def allocate(self, verbose: bool = False): if self.options.sigma < 0.0: raise ValueError(f"CurlCurlSolve needs sigma >= 0 (SPD operator), got {self.options.sigma = }.") # always stabilize if self.options.sigma < 1e-14: self.options.sigma = 1e-14 if MPI.COMM_WORLD.Get_rank() == 0: logger.info(f"Running Curl-Curl solve with {self.options.sigma =}") # model parameters self._sigma = self.options.sigma e = self.variables.e.spline.vector # collect rhs def is_callable_triple(j) -> bool: """True if j is a tuple/list of three callables (components of a 1-form).""" return isinstance(j, (tuple, list)) and len(j) == 3 and all(callable(ji) for ji in j) def verify_rhs(j) -> Vector | FEECVariable | AccumulatorVector: """Perform preliminary operations on j to compute the rhs and return the result.""" if j is None: rhs = e.space.zeros() elif isinstance(j, FEECVariable): assert j.space == "Hcurl" rhs = j elif isinstance(j, AccumulatorVector): rhs = j elif is_callable_triple(j): rhs = L2Projector("Hcurl", self.mass_ops).get_dofs(j, apply_bc=True) else: raise TypeError(f"{type(j) =} is not accepted.") return rhs j = self.j if isinstance(j, list) and not is_callable_triple(j): self._sources = [] for ji in j: self._sources += [verify_rhs(ji)] else: self._sources = [verify_rhs(j)] # coeffs of rhs if self.j_coeffs is not None: if isinstance(self.j_coeffs, (list, tuple)): self._coeffs = self.j_coeffs else: self._coeffs = [self.j_coeffs] assert len(self._coeffs) == len(self._sources) else: self._coeffs = [1.0 for src in self.sources] # initial guess and solver params self._x0 = self.options.x0 self._info = self.options.solver_params.info if self.options.stab_mat == "Id": stab_mat = IdentityOperator(e.space) else: stab_mat = getattr(self.mass_ops, self.options.stab_mat) if isinstance(self.options.diffusion_mat, str): diffusion_mat = getattr(self.mass_ops, self.options.diffusion_mat) else: diffusion_mat = self.options.diffusion_mat assert isinstance(diffusion_mat, WeightedMassOperator) assert diffusion_mat.domain == self.derham.curl.codomain assert diffusion_mat.codomain == self.derham.curl.codomain # Set lhs matrices (without dt) self._stab_mat = stab_mat self._diffusion_op = self.derham.curl.T @ diffusion_mat @ self.derham.curl # preconditioner and solver for Ax=b lhs = self._diffusion_op + self._sigma * self._stab_mat precond = self.options.precond if precond is None: pc = None elif precond == "StiffnessPreconditioner": pc = preconditioner.StiffnessPreconditioner( self.mass_ops, "curl", sigma=self._sigma, mid=self.options.diffusion_mat, stab=self.options.stab_mat, **self.options.precond_params, ) elif precond == "MultiGrid": pc = MultiGridPreconditioner( lhs, self.derham, self.domain, self.options.multigrid, mass_ops=self.mass_ops, ) elif precond == "HiptmairXu": pc = preconditioner.HiptmairXuPreconditioner( lhs, self.mass_ops, self._sigma, mid=self.options.diffusion_mat, stab=self.options.stab_mat, **self.options.precond_params, ) else: pc_class = getattr(preconditioner, precond) pc = pc_class(self.mass_ops.M1, **self.options.precond_params) # (only "pcg" takes a preconditioner) pc_kwargs = {"pc": pc} if self.options.solver == "pcg" else {} self._solver = inverse( lhs, self.options.solver, **pc_kwargs, x0=self.x0, tol=self.options.solver_params.tol, maxiter=self.options.solver_params.maxiter, verbose=self.options.solver_params.verbose, recycle=self.options.solver_params.recycle, ) # allocate memory for solution self._tmp = e.space.zeros() self._rhs = e.space.zeros() self._tmp_src = e.space.zeros() @property def sources(self) -> list[Vector | FEECVariable | AccumulatorVector]: """ Right-hand side of the equation (sources). """ return self._sources @property def coeffs(self) -> list[float]: """ Same length as self.sources. Coefficients multiplied with sources before solve (default is 1.0). """ return self._coeffs @property def x0(self): """ feectools.linalg.stencil.StencilVector or struphy.polar.basic.PolarVector. First guess of the iterative solver. """ return self.options.x0 @x0.setter def x0(self, value: StencilVector): """In-place setter for StencilVector/PolarVector. First guess of the iterative solver.""" assert value.space == self.derham.V1 assert value.space.symbolic_space == "Hcurl", ( f"Right-hand side must be in Hcurl, but is in {value.space.symbolic_space}." ) if self.options.x0 is None: self.options.x0 = value else: self.options.x0[:] = value[:] @profile def __call__(self, dt): # compute rhs self._rhs *= 0.0 for src, coeff in zip(self.sources, self.coeffs): if isinstance(src, Vector): self._rhs += coeff * src elif isinstance(src, FEECVariable): v = src.spline.vector self._rhs += coeff * self.mass_ops.M1.dot(v, out=self._tmp_src) elif isinstance(src, AccumulatorVector): src() # accumulate self._rhs += coeff * src.vectors[0] # solve with ProfileManager.profile_region(self._solve_region, functions=[self._solver.solve]): out = self._solver.solve(self._rhs, out=self._tmp) info = self._solver._info if self._info: logger.info(info) self.update_feec_variables(e=out)