Source code for struphy.propagators.implicit_diffusion

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

import cunumpy as xp
from feectools.linalg.basic import IdentityOperator
from feectools.linalg.solvers import inverse
from feectools.linalg.stencil import StencilVector
from line_profiler import profile

from struphy.feec.mass import L2Projector, WeightedMassOperator
from struphy.io.options import LiteralOptions, OptionsBase
from struphy.linear_algebra.solver import SolverParameters
from struphy.models.variables import FEECVariable, PICVariable, SPHVariable
from struphy.pic.accumulation.filter import FilterParameters
from struphy.pic.accumulation.particles_to_grid import AccumulatorVector, ParticlesToGrid
from struphy.propagators.base import Propagator
from struphy.utils.utils import check_option

logger = logging.getLogger("struphy")

opts_feec_space = LiteralOptions.OptsFEECSpace


[docs] class ImplicitDiffusion(Propagator): r""" Weak, implicit discretization of the diffusion (or heat) equation (can be used as a Poisson solver too). Find :math:`\phi \in H^1` such that .. math:: \int_\Omega \psi\, n_0(\mathbf x)\frac{\partial \phi}{\partial t}\,\textrm d \mathbf x + \int_\Omega \nabla \psi^\top D_0(\mathbf x) \nabla \phi \,\textrm d \mathbf x = \sum_i \int_\Omega \psi\, \rho_i(\mathbf x)\,\textrm d \mathbf x \qquad \forall \ \psi \in H^1\,, where :math:`n_0, \rho_i:\Omega \to \mathbb R` are real-valued functions and :math:`D_0:\Omega \to \mathbb R^{3\times 3}` is a positive diffusion matrix. Boundary terms from integration by parts are assumed to vanish. The equation is discretized as .. math:: \left( \frac{\sigma_1}{\Delta t} \mathbb M^0_{n_0} + \mathbb G^\top \mathbb M^1_{D_0} \mathbb G \right)\, \boldsymbol\phi^{n+1} = \frac{\sigma_2}{\Delta t} \mathbb M^0_{n_0} \boldsymbol\phi^{n} + \frac{\sigma_3}{\Delta t} \sum_i(\Lambda^0, \rho_i )_{L^2}\,, where :math:`M^0_{n_0}` and :math:`M^1_{D_0}` are :class:`WeightedMassOperators <struphy.feec.mass.WeightedMassOperators>` and :math:`\sigma_1, \sigma_2, \sigma_3 \in \mathbb R` are artificial parameters that can be tuned to change the model (see Notes). Parameters ---------- rho : FEECVariable or Callable or ParticlesToGrid or list, default=None Source term(s) :math:`\rho_i` on the right-hand side. Several entries can be combined by passing a ``list``; each is weighted by the corresponding entry of ``rho_coeffs``. Accepted entries are: - ``None``: zero source. - ``FEECVariable`` in ``H1``: its coefficients are mass-multiplied by :math:`M^0` on the fly. - ``Callable`` :math:`\rho_i(\mathbf x)`, projected to ``H1`` via ``L2Projector``. - :class:`~struphy.pic.accumulation.particles_to_grid.ParticlesToGrid`, describing a particle-to-grid deposition. An :class:`~struphy.pic.accumulation.particles_to_grid.AccumulatorVector` is built from it at ``allocate()`` and re-accumulated at every call. Its ``accum_space`` selects what is deposited: - ``"H1"``: the kernel deposits a scalar density directly, used as-is. - ``"Hcurl"``: the kernel deposits a 1-form (vector) quantity :math:`\mathbf w`, e.g. :math:`n\mathbf u`; the propagator then applies the weak divergence :math:`-\mathbb G^\top` to it before adding it to the right-hand side. This is used in the Chorin-projection scheme for example. - a ``list`` containing any mix of the entries above. rho_coeffs : float or list, default=None Multiplicative coefficient(s) for ``rho`` 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``. diagnostic : FEECVariable, default=None If not None, updates at each call to the propagator, takes the value of the right-hand side. Otherwise does not provide diagnostic. Notes ----- * :math:`\sigma_1=\sigma_2=0` and :math:`\sigma_3 = \Delta t`: **Poisson solver** with a given charge density :math:`\sum_i\rho_i`. * :math:`\sigma_2=0` and :math:`\sigma_1 = \sigma_3 = \Delta t` : Poisson with **adiabatic electrons**. * :math:`\sigma_1=\sigma_2=1` and :math:`\sigma_3 = 0`: **Implicit heat equation**. """
[docs] class Variables: """ Attributes ---------- phi : FEECVariable Scalar solution field in H^1 space. """ def __init__(self): self._phi: FEECVariable = None @property def phi(self) -> FEECVariable: return self._phi @phi.setter def phi(self, new): assert isinstance(new, FEECVariable) assert new.space == "H1" self._phi = new
def __init__( self, rho: FEECVariable | ParticlesToGrid | Callable | list = None, rho_coeffs: float | list = None, diagnostic: FEECVariable | None = None, ): if isinstance(rho, list): for r in rho: if isinstance(r, ParticlesToGrid): assert r.accum_space is not None, "If rho contains a PICVariable, accum_space must be provided." assert r.accum_kernel is not None, "If rho contains a PICVariable, accum_kernel must be provided." elif isinstance(rho, ParticlesToGrid): assert rho.accum_space is not None, "If rho is a PICVariable, accum_space must be provided." assert rho.accum_kernel is not None, "If rho is a PICVariable, accum_kernel must be provided." self.variables = self.Variables() self.rho = rho self.rho_coeffs = rho_coeffs self.diagnostic = diagnostic
[docs] @dataclass(repr=False) class Options(OptionsBase): """Configuration options for :class:`ImplicitDiffusion`. Parameters ---------- sigma_1 : float, default=1.0 Coefficient multiplying the stabilization/mass contribution on the left-hand side. With ``divide_by_dt=True`` it is interpreted as ``sigma_1 / dt`` in the assembled linear system. sigma_2 : float, default=0.0 Coefficient multiplying the previous solution contribution ``stab_mat * phi^n`` on the right-hand side. With ``divide_by_dt=True`` it is interpreted as ``sigma_2 / dt``. sigma_3 : float, default=1.0 Coefficient multiplying the source terms ``rho`` on the right-hand side. With ``divide_by_dt=True`` it is interpreted as ``sigma_3 / dt``. divide_by_dt : bool, default=False If ``True``, divide ``sigma_1``, ``sigma_2`` and ``sigma_3`` by the time step passed to ``__call__(dt)`` before assembling the system. stab_mat : {"M0", "M0ad", "Id"}, default="M0" Stabilization operator used in the term weighted by ``sigma_1`` and ``sigma_2``. - ``"M0"``: standard weighted 0-form mass operator. - ``"M0ad"``: adiabatic-electron weighted 0-form mass operator. - ``"Id"``: identity operator. diffusion_mat : {"M1", "M1perp"} or WeightedMassOperator, default="M1" Diffusion metric in the bilinear form ``grad.T @ diffusion_mat @ grad``. You can pass the name of a pre-built operator in ``mass_ops`` or a custom ``WeightedMassOperator`` compatible with the codomain of ``grad``. 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.OptsMassPrecond, default="MassMatrixPreconditioner" Name of the preconditioner configuration. Currently this class sets ``pc=None`` internally, so this option is reserved for compatibility and future extensions. solver_params : SolverParameters, default=None Iterative-solver controls (for example ``tol``, ``maxiter``, ``verbose``, ``info``, ``recycle``). If ``None``, defaults to ``SolverParameters()``. filter_params : dict[PICVariable | SPHVariable, FilterParameters], default=None If not None, specifies a filter to the accumulation of a specific variable. Keyed by the ``pic_variable`` of the corresponding :class:`~struphy.pic.accumulation.particles_to_grid.ParticlesToGrid` source in ``rho``. Note ---- The accumulation kernel for a particle source is not configured here: it is carried by the :class:`~struphy.pic.accumulation.particles_to_grid.ParticlesToGrid` instance passed as (part of) ``rho``. """ # specific literals OptsStabMat = Literal["M0", "M0ad", "Id"] OptsDiffusionMat = Literal["M1", "M1perp"] # propagator options sigma_1: float = 1.0 sigma_2: float = 0.0 sigma_3: float = 1.0 divide_by_dt: bool = False stab_mat: OptsStabMat = "M0" diffusion_mat: OptsDiffusionMat = "M1" x0: StencilVector = None solver: LiteralOptions.OptsSymmSolver = "pcg" precond: LiteralOptions.OptsMassPrecond = "MassMatrixPreconditioner" solver_params: SolverParameters = None filter_params: dict[PICVariable | SPHVariable, FilterParameters] = 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.OptsMassPrecond) # 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): # always stabilize if xp.abs(self.options.sigma_1) < 1e-14: self.options.sigma_1 = 1e-14 logger.warning(f"Stabilizing Poisson solve with {self.options.sigma_1 =}") # model parameters self._sigma_1 = self.options.sigma_1 self._sigma_2 = self.options.sigma_2 self._sigma_3 = self.options.sigma_3 self._divide_by_dt = self.options.divide_by_dt phi = self.variables.phi.spline.vector # collect rhs def verify_rhs(rho) -> StencilVector | FEECVariable | AccumulatorVector: """Perform preliminary operations on rho to compute the rhs and return the result.""" if rho is None: rhs = phi.space.zeros() elif isinstance(rho, FEECVariable): assert rho.space == "H1" rhs = rho elif isinstance(rho, ParticlesToGrid): filter_params = None if self.options.filter_params is not None: filter_params = self.options.filter_params.get(rho.pic_variable) rhs = AccumulatorVector( rho.pic_variable.particles, rho.accum_space, rho.accum_kernel, Propagator.mass_ops, Propagator.domain.args_domain, filter_params=filter_params, ) if not rhs.particles.control_variate and rhs.space_id == "H1": l2_proj = L2Projector("H1", Propagator.mass_ops) f0e = -rho.pic_variable.species.charge_number * rhs.particles.f0 rho_eh = FEECVariable(space="H1") rho_eh.allocate(derham=Propagator.derham, domain=Propagator.domain) rho_eh.spline.vector = l2_proj.get_dofs(f0e.n) return [rhs, rho_eh] elif isinstance(rho, Callable): rhs = L2Projector("H1", self.mass_ops).get_dofs(rho, apply_bc=True) else: raise TypeError(f"{type(rho) =} is not accepted.") return [rhs] rho = self.rho if isinstance(rho, list): self._sources = [] for n, r in enumerate(rho): tmp = verify_rhs(r) if len(tmp) == 2 and self.rho_coeffs is not None: assert isinstance(self.rho_coeffs, list), "If rho is a list, rho_coeffs must be a list too." if len(self.rho_coeffs) < len(rho): self.rho_coeffs.insert(n + 1, self.rho_coeffs[n]) self._sources += tmp else: tmp = verify_rhs(rho) if len(tmp) == 2 and self.rho_coeffs is not None: if not isinstance(self.rho_coeffs, (list, tuple)): self.rho_coeffs = [self.rho_coeffs, self.rho_coeffs] self._sources = tmp # coeffs of rhs if self.rho_coeffs is not None: if isinstance(self.rho_coeffs, (list, tuple)): self._coeffs = self.rho_coeffs else: self._coeffs = [self.rho_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(phi.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.grad.codomain assert diffusion_mat.codomain == self.derham.grad.codomain # Set lhs matrices (without dt) self._stab_mat = stab_mat self._diffusion_op = self.derham.grad.T @ diffusion_mat @ self.derham.grad # preconditioner and solver for Ax=b if self.options.precond is None: pc = None else: # TODO: waiting for multigrid preconditioner pc = None # solver just with A_2, but will be set during call with dt self._solver = inverse( self._diffusion_op, self.options.solver, pc=pc, 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 = phi.space.zeros() self._rhs = phi.space.zeros() self._rhs2 = phi.space.zeros() self._tmp_src = phi.space.zeros() @property def sources(self) -> list[StencilVector | 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.V0 assert value.space.symbolic_space == "H1", ( f"Right-hand side must be in H1, 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): # set parameters if self._divide_by_dt: sig_1 = self._sigma_1 / dt sig_2 = self._sigma_2 / dt sig_3 = self._sigma_3 / dt else: sig_1 = self._sigma_1 sig_2 = self._sigma_2 sig_3 = self._sigma_3 # compute rhs phin = self.variables.phi.spline.vector rhs = self._stab_mat.dot(phin, out=self._rhs) rhs *= sig_2 self._rhs2 *= 0.0 for src, coeff in zip(self.sources, self.coeffs): if isinstance(src, StencilVector): self._rhs2 += sig_3 * coeff * src elif isinstance(src, FEECVariable): v = src.spline.vector self._rhs2 += sig_3 * coeff * self.mass_ops.M0.dot(v, out=self._tmp_src) elif isinstance(src, AccumulatorVector): if src.particles.control_variate: src.particles.update_weights() if src.space_id == "H1": src() # accumulate vec = src.vectors[0] elif src.space_id == "Hcurl": # 1. update density at marker positions eta = src.particles.positions valid_mks = src.particles.valid_mks first_free_idx = src.particles.first_free_idx density = src.particles.f0.n0(eta) src.particles.markers[valid_mks, first_free_idx] = density # 2. accumulate src() # 3. take weak divergence vec = -self.derham.grad.T.dot(src.vectors[0], out=self._tmp_src) else: raise ValueError(f"Unsupported source space {src.space_id}.") self._rhs2 += sig_3 * coeff * vec rhs += self._rhs2 if self.diagnostic is not None: proj = L2Projector("H1", self.mass_ops) self.diagnostic.spline.vector = proj.solve(rhs) # compute lhs self._solver.linop = sig_1 * self._stab_mat + self._diffusion_op # solve out = self._solver.solve(rhs, out=self._tmp) info = self._solver._info if self._info: logger.info(info) self.update_feec_variables(phi=out)