Source code for struphy.propagators.two_fluid_quasi_neutral_full

import logging
from dataclasses import dataclass
from typing import Callable, get_args
from warnings import warn

from feectools.api.essential_bc import apply_essential_bc_stencil
from feectools.ddm.mpi import mpi as MPI
from feectools.linalg.basic import IdentityOperator
from feectools.linalg.block import BlockLinearOperator, BlockVector, BlockVectorSpace
from feectools.linalg.solvers import inverse

from struphy.feec.basis_projection_ops import BasisProjectionOperators
from struphy.feec.mass import L2Projector, WeightedMassOperators
from struphy.io.options import LiteralOptions, OptionsBase
from struphy.linear_algebra.solver import 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 TwoFluidQuasiNeutralFull(Propagator): r""":ref:`FEEC <gempic>` discretization of the following equations: find :math:`\mathbf u \in H(\textnormal{div})`, :math:`\mathbf u_e \in H(\textnormal{div})` and :math:`\mathbf \phi \in L^2` such that .. math:: \int_{\Omega} \partial_t \mathbf{u}\cdot \mathbf{v} \, \textrm d\mathbf{x} &= \int_{\Omega} \phi \nabla \! \cdot \! \mathbf{v} \, \textrm d\mathbf{x} + \int_{\Omega} \mathbf{u}\! \times \! \mathbf{B}_0 \cdot \mathbf{v} \, \textrm d\mathbf{x} + \nu \int_{\Omega} \nabla \mathbf{u}\! : \! \nabla \mathbf{v} \, \textrm d\mathbf{x} + \int_{\Omega} f \mathbf{v} \, \textrm d\mathbf{x} \qquad \forall \, \mathbf{v} \in H(\textrm{div}) \,. \\[2mm] 0 &= - \int_{\Omega} \phi \nabla \! \cdot \! \mathbf{v_e} \, \textrm d\mathbf{x} - \int_{\Omega} \mathbf{u_e} \! \times \! \mathbf{B}_0 \cdot \mathbf{v_e} \, \textrm d\mathbf{x} + \nu_e \int_{\Omega} \nabla \mathbf{u_e} \!: \! \nabla \mathbf{v_e} \, \textrm d\mathbf{x} + \int_{\Omega} f_e \mathbf{v_e} \, \textrm d\mathbf{x} \qquad \forall \ \mathbf{v_e} \in H(\textrm{div}) \,. \\[2mm] 0 &= \int_{\Omega} \psi \nabla \cdot (\mathbf{u}-\mathbf{u_e}) \, \textrm d\mathbf{x} \qquad \forall \, \psi \in L^2 \,. :ref:`time_discret`: fully implicit. """ # ========================================================================= ### State variables (ion velocity u, electron velocity ue, pressure phi) # =========================================================================
[docs] class Variables: """Container for variables advanced by :class:`TwoFluidQuasiNeutralFull`. Attributes ---------- u : FEECVariable or None Ion velocity variable in ``"Hdiv"`` space. ue : FEECVariable or None Electron velocity variable in ``"Hdiv"`` space. phi : FEECVariable or None Electrostatic potential variable in ``"L2"`` space. """ def __init__(self) -> None: self._u: FEECVariable | None = None self._ue: FEECVariable | None = None self._phi: FEECVariable | None = None @property def u(self) -> FEECVariable | None: return self._u @u.setter def u(self, new): assert isinstance(new, FEECVariable) assert new.space == "Hdiv" self._u = new @property def ue(self) -> FEECVariable | None: return self._ue @ue.setter def ue(self, new): assert isinstance(new, FEECVariable) assert new.space == "Hdiv" self._ue = new @property def phi(self) -> FEECVariable | None: return self._phi @phi.setter def phi(self, new): assert isinstance(new, FEECVariable) assert new.space == "L2" self._phi = new
def __init__(self): self.variables = self.Variables() # ========================================================================= ### Options # =========================================================================
[docs] @dataclass(repr=False) class Options(OptionsBase): """Configuration options for :class:`TwoFluidQuasiNeutralFull`. Parameters ---------- nu : float, default=1.0 Ion viscosity coefficient. nu_e : float, default=1.0 Electron viscosity coefficient. eps_norm : float, default=1e-3 Normalization/scaling parameter in Lorentz coupling terms. boundary_data_u : dict[tuple[int, int], Callable] or None, default=None Inhomogeneous Dirichlet data for ion velocity faces. boundary_data_ue : dict[tuple[int, int], Callable] or None, default=None Inhomogeneous Dirichlet data for electron velocity faces. source_u : Callable or None, default=None Source term for ion momentum equation. source_ue : Callable or None, default=None Source term for electron momentum equation. stab_sigma : float or None, default=None Optional stabilization coefficient for electron block. solver : LiteralOptions.OptsGenSolver, default="gmres" Linear/saddle-point solver used for the global system. solver_params : SolverParameters or None, default=None Solver controls. """ nu: float = 1.0 nu_e: float = 1.0 eps_norm: float = 1e-3 boundary_data_u: dict[tuple[int, int], Callable] | None = None boundary_data_ue: dict[tuple[int, int], Callable] | None = None source_u: Callable | None = None source_ue: Callable | None = None stab_sigma: float | None = None solver: LiteralOptions.OptsGenSolver = "gmres" solver_params: SolverParameters | None = None def __post_init__(self): # --- physical parameter sanity checks --- if self.nu < 0: raise ValueError(f"nu must be non-negative, got {self.nu}") if self.nu_e < 0: raise ValueError(f"nu_e must be non-negative, got {self.nu_e}") if self.eps_norm <= 0: raise ValueError(f"eps_norm must be positive, got {self.eps_norm}") # --- warn if no source terms --- if self.source_u is None: warn("No source_u specified — defaulting to zero.") if self.source_ue is None: warn("No source_ue specified — defaulting to zero.") # --- defaults --- if self.stab_sigma is None: warn("stab_sigma not specified, defaulting to 0.0") self.stab_sigma = 0.0 check_option(self.solver, LiteralOptions.OptsGenSolver, LiteralOptions.OptsSaddlePointSolver) if self.solver_params is None: self.solver_params = SolverParameters()
@property def options(self) -> Options: assert hasattr(self, "_options"), "Options not set." 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}") # ========================================================================= ### Boundary condition helpers # ========================================================================= def _get_dirichlet_faces(self): """Infer which faces have Dirichlet BCs by comparing derham and derham_v0. A face is Dirichlet if it is unclamped in derham but clamped in derham_v0 (i.e. lifting is True there). """ faces = [] derham = self.derham derham_v0 = derham if derham_v0 is None: return faces bc = derham.dirichlet_bc bc_v0 = derham_v0.dirichlet_bc for d in range(3): if derham.spl_kind[d]: continue # periodic axis, no Dirichlet for s, side in enumerate((-1, 1)): # clamped in v0 but not in derham => this is a lifted (inhom Dirichlet) face unclamped = not bc[d][s] clamped_v0 = bc_v0[d][s] if bc_v0 is not None else False if unclamped and clamped_v0: faces.append((d, side)) # clamped in both => homogeneous Dirichlet, also need to zero DOFs elif bc[d][s] and clamped_v0: faces.append((d, side)) return faces def _apply_essential_bc(self, vec): """Zero out Dirichlet DOFs, inferred from derham vs derham_v0.""" for d, side in self._dirichlet_faces: apply_essential_bc_stencil(vec[0], axis=d, ext=side, order=0) # ========================================================================= ### Allocate # ========================================================================= def allocate(self): self._rank = self.derham.comm.Get_rank() if self.derham.comm is not None else 0 self._dt = None # ---- v0 de Rham complex (from derham.derham_v0) ---------------------- self._derham_v0 = self.derham self._mass_ops_v0 = WeightedMassOperators( self._derham_v0, self.domain, eq_mhd=self.mass_ops.eq_mhd, ) self._basis_ops_v0 = BasisProjectionOperators( self._derham_v0, self.domain, eq_mhd=self.basis_ops.weights["eq_mhd"], ) # ---- Dirichlet faces (inferred from derham vs derham_v0) ------------- self._dirichlet_faces = self._get_dirichlet_faces() # ---- unconstrained operators (for RHS assembly) ---------------------- self._M2 = self.mass_ops.M2 self._M2B = -self.mass_ops.M2B self._div = self.derham.div self._curl = self.derham.curl self._S21 = self.basis_ops.S21 self._lapl = ( self._div.T @ self.mass_ops.M3 @ self._div + self._S21.T @ self._curl.T @ self._M2 @ self._curl @ self._S21 ) self._A11 = -self._M2B / self.options.eps_norm + self.options.nu * self._lapl self._A22 = ( -self.options.stab_sigma * IdentityOperator(self.derham.V2) + self._M2B / self.options.eps_norm + self.options.nu_e * self._lapl ) # ---- constrained operators (for system matrix) ----------------------- self._M2_v0 = self._mass_ops_v0.M2 self._M3_v0 = self._mass_ops_v0.M3 self._M2B_v0 = -self._mass_ops_v0.M2B self._div_v0 = self._derham_v0.div self._curl_v0 = self._derham_v0.curl self._S21_v0 = self._basis_ops_v0.S21 self._lapl_v0 = ( self._div_v0.T @ self._M3_v0 @ self._div_v0 + self._S21_v0.T @ self._curl_v0.T @ self._M2_v0 @ self._curl_v0 @ self._S21_v0 ) self._A11_v0 = -self._M2B_v0 / self.options.eps_norm + self.options.nu * self._lapl_v0 self._A22_v0 = ( -self.options.stab_sigma * IdentityOperator(self._derham_v0.V2) + self._M2B_v0 / self.options.eps_norm + self.options.nu_e * self._lapl_v0 ) # ---- block saddle-point system ---------------------------------------- self._block_domain_v0 = BlockVectorSpace(self._derham_v0.V2, self._derham_v0.V2) self._block_codomain_v0 = self._block_domain_v0 self._block_codomain_B_v0 = self._derham_v0.V3 self._B1_v0 = -self._M3_v0 @ self._div_v0 self._B2_v0 = self._M3_v0 @ self._div_v0 self._B_v0 = BlockLinearOperator( self._block_domain_v0, self._block_codomain_B_v0, blocks=[[self._B1_v0, self._B2_v0]] ) self._block_domain_M = BlockVectorSpace(self._block_domain_v0, self._block_codomain_B_v0) _A_init = BlockLinearOperator( self._block_domain_v0, self._block_codomain_v0, blocks=[[self._A11_v0, None], [None, self._A22_v0]] ) _M_init = BlockLinearOperator( self._block_domain_M, self._block_domain_M, blocks=[[_A_init, self._B_v0.T], [self._B_v0, None]] ) if self.options.solver in get_args(LiteralOptions.OptsSaddlePointSolver): self._Minv = inverse( _M_init, self.options.solver, A11=self._A11_v0, A22=self._A22_v0, B1=self._B1_v0, B2=self._B2_v0, recycle=self.options.solver_params.recycle, tol=self.options.solver_params.tol, maxiter=self.options.solver_params.maxiter, verbose=self.options.solver_params.verbose, ) else: self._Minv = inverse( _M_init, self.options.solver, recycle=self.options.solver_params.recycle, tol=self.options.solver_params.tol, maxiter=self.options.solver_params.maxiter, verbose=self.options.solver_params.verbose, ) # ---- projector ------------------------------------------------------- self._projector = L2Projector(space_id="Hdiv", mass_ops=self.mass_ops) # ---- solution spline functions (unconstrained) ----------------------- self._u = self.derham.create_spline_function("u", space_id="Hdiv") self._ue = self.derham.create_spline_function("ue", space_id="Hdiv") self._phi = self.derham.create_spline_function("phi", space_id="L2") # ---- BC lifts (unconstrained) ---------------------------------------- self._u_prime = self.derham.create_spline_function("u_prime", space_id="Hdiv") self._ue_prime = self.derham.create_spline_function("ue_prime", space_id="Hdiv") for u_prime, boundary_data in [ (self._u_prime, self.options.boundary_data_u), (self._ue_prime, self.options.boundary_data_ue), ]: if boundary_data is None: continue for (d, side), f_bc in boundary_data.items(): if (d, side) in self._dirichlet_faces: bc_pulled = lambda *etas, f=f_bc: self.domain.pull( [ lambda x, y, z, f=f: f(x, y, z)[0], lambda x, y, z, f=f: f(x, y, z)[1], lambda x, y, z, f=f: f(x, y, z)[2], ], *etas, kind="2", ) _vec = self._projector( [ lambda *etas: bc_pulled(*etas)[0], lambda *etas: bc_pulled(*etas)[1], lambda *etas: bc_pulled(*etas)[2], ] ) for d2, side2 in self._dirichlet_faces: if (d2, side2) != (d, side): apply_essential_bc_stencil(_vec[0], axis=d2, ext=side2, order=0) u_prime.vector += _vec self._u_prime_v0 = self._derham_v0.create_spline_function("u_prime_v0", space_id="Hdiv") self._ue_prime_v0 = self._derham_v0.create_spline_function("ue_prime_v0", space_id="Hdiv") self._u_prime_v0.vector = self._u_prime.vector self._ue_prime_v0.vector = self._ue_prime.vector # ---- projected source terms (unconstrained) -------------------------- self._rhs_u = self.derham.create_spline_function("rhs_u", space_id="Hdiv") self._rhs_ue = self.derham.create_spline_function("rhs_ue", space_id="Hdiv") for rhs, source in [(self._rhs_u, self.options.source_u), (self._rhs_ue, self.options.source_ue)]: if source is not None: src_pulled = lambda *etas, f=source: self.domain.pull( [ lambda x, y, z, f=f: f(x, y, z)[0], lambda x, y, z, f=f: f(x, y, z)[1], lambda x, y, z, f=f: f(x, y, z)[2], ], *etas, kind="2", ) rhs.vector = self._projector.get_dofs( [ lambda *etas: src_pulled(*etas)[0], lambda *etas: src_pulled(*etas)[1], lambda *etas: src_pulled(*etas)[2], ] ) # ---- pre-allocated RHS vectors (v0, reused each time step) ----------- self._rhs_vec_u = self._derham_v0.create_spline_function("rhs_vec_u", space_id="Hdiv") self._rhs_vec_ue = self._derham_v0.create_spline_function("rhs_vec_ue", space_id="Hdiv") # ========================================================================= ### Time step # ========================================================================= def __call__(self, dt): # --- copy current state --- self._u.vector = self.variables.u.spline.vector self._ue.vector = self.variables.ue.spline.vector # --- rebuild system matrix if dt changed --- if dt != self._dt: # TODO change uzawa A11 block too self._dt = dt _A = BlockLinearOperator( self._block_domain_v0, self._block_codomain_v0, blocks=[[self._A11_v0 + self._M2_v0 / dt, None], [None, self._A22_v0]], ) _M = BlockLinearOperator( self._block_domain_M, self._block_domain_M, blocks=[[_A, self._B_v0.T], [self._B_v0, None]] ) self._Minv.linop = _M # --- assemble RHS in unconstrained space, then zero boundary DOFs --- # ion: F1 = rhs_u + M2/dt * u - (A11 + M2/dt) * u' # electron: F2 = rhs_ue - A22 * ue' self._rhs_vec_u.vector = ( self._rhs_u.vector # TODO boundary operator + self._M2.dot(self._u.vector) / dt - self._A11.dot(self._u_prime.vector) - self._M2.dot(self._u_prime.vector) / dt ) self._rhs_vec_ue.vector = self._rhs_ue.vector - self._A22.dot(self._ue_prime.vector) self._apply_essential_bc(self._rhs_vec_u.vector) self._apply_essential_bc(self._rhs_vec_ue.vector) # --- build block RHS and solve --- _F = BlockVector(self._block_domain_v0, blocks=[self._rhs_vec_u.vector, self._rhs_vec_ue.vector]) _RHS = BlockVector(self._block_domain_M, blocks=[_F, self._block_codomain_B_v0.zeros()]) _sol = self._Minv.dot(_RHS) info = self._Minv.get_info() # --- reconstruct full solution: u = u_0 + u' --- self._u.vector = _sol[0][0] + self._u_prime_v0.vector self._ue.vector = _sol[0][1] + self._ue_prime_v0.vector self._phi.vector = _sol[1] # --- update FEEC variables --- max_diffs = self.update_feec_variables(u=self._u.vector, ue=self._ue.vector, phi=self._phi.vector) if self.options.solver_params.info and self._rank == 0: logger.info(f"Status: {info['success']}, Iterations: {info['niter']}") logger.info(f"Max diffs: {max_diffs}") logger.info(f"Status: {info['success']}, Iterations: {info['niter']}") logger.info(f"Max diffs: {max_diffs}")