import inspect
import logging
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Callable
import cunumpy as xp
from feectools.api.settings import PSYDAC_BACKEND_GPYCCEL
from feectools.fem.tensor import FemSpace, TensorFemSpace
from feectools.fem.vector import VectorFemSpace
from feectools.linalg.basic import IdentityOperator, InverseLinearOperator, LinearOperator, Vector
from feectools.linalg.block import BlockLinearOperator, BlockVector
from feectools.linalg.solvers import inverse
from feectools.linalg.stencil import StencilDiagonalMatrix, StencilMatrix, StencilVector
from maybempi import MPI, SerialComm
from scope_profiler import ProfileManager
from struphy import equils
from struphy.feec.kernels.kernel_1d_eval import kernel_1d_eval
from struphy.feec.kernels.kernel_1d_mat import kernel_1d_mat
from struphy.feec.kernels.kernel_2d_eval import kernel_2d_eval
from struphy.feec.kernels.kernel_2d_mat import kernel_2d_mat
from struphy.feec.kernels.kernel_3d_diag import kernel_3d_diag
from struphy.feec.kernels.kernel_3d_eval import kernel_3d_eval
from struphy.feec.kernels.kernel_3d_mat import kernel_3d_mat
from struphy.feec.kernels.kernel_3d_matrixfree import kernel_3d_matrixfree
from struphy.feec.kernels.kernel_3d_vec import kernel_3d_vec
from struphy.feec.linear_operators import BoundaryOperator
from struphy.feec.psydac_derham import Derham, SplineFunction
from struphy.feec.utilities import LocalProjectionMatrix, LocalRotationMatrix, get_quad_grids
from struphy.fields_background.base import MHDequilibrium
from struphy.geometry.base import Domain
from struphy.io.options import LiteralOptions
from struphy.linear_algebra.solver import SolverParameters
from struphy.polar.basic import PolarVector
from struphy.polar.linear_operators import PolarExtractionOperator
from struphy.utils.docstring_converter import info
from struphy.utils.utils import __class_with_params_repr_no_defaults__
logger = logging.getLogger("struphy")
# assembly kernels by dimension of the problem (1d, 2d or 3d)
_MAT_KERNELS = {1: kernel_1d_mat, 2: kernel_2d_mat, 3: kernel_3d_mat}
_EVAL_KERNELS = {1: kernel_1d_eval, 2: kernel_2d_eval, 3: kernel_3d_eval}
_MATRIXFREE_KERNELS = {3: kernel_3d_matrixfree}
_DIAG_KERNELS = {3: kernel_3d_diag}
# space identifiers with scalar-valued and vector-valued elements
_SCALAR_SPACES = ("H1", "L2")
_VECTOR_SPACES = ("Hcurl", "Hdiv", "H1vec")
@dataclass
class _ClassifiedWeights:
"""Factors of a 1D weights tuple (Case 3 in :meth:`WeightedMassOperators.create_weighted_mass`), sorted by kind.
All callables take the evaluation points (e1, e2, e3) on a sparse meshgrid of shape (n1, n2, n3).
"""
# callables returning shape (n1, n2, n3)
scalars: list = field(default_factory=list)
# matrix factors in multiplication order: callables returning shape (n1, n2, n3, 3, 3)
# or constant arrays of shape (3, 3)
matrices: list = field(default_factory=list)
# callable returning shape (n1, n2, n3, 3), for maps from a scalar to a vector space
col_vector: Callable | None = None
# callable returning shape (n1, n2, n3, 3), for maps from a vector to a scalar space
row_vector: Callable | None = None
# SplineFunction factors, keyed by name; evaluated during assembly in WeightedMassOperator
spline_functions: dict = field(default_factory=dict)
def _log_weight_stats(label: str, values):
"""Log shape and range of evaluated weights; the reductions are only computed if debug logging is enabled."""
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f"Evaluated {label} with {values.shape = }, max = {xp.max(values)}, min = {xp.min(values)}")
[docs]
class WeightedMassOperators:
r"""
Collection of pre-defined :class:`struphy.feec.mass.WeightedMassOperator`.
Parameters
----------
derham : Derham
Discrete de Rham sequence on the logical unit cube.
domain : :ref:`avail_mappings`
Mapping from logical unit cube to physical domain and corresponding metric coefficients.
eq_mhd : MHDequilibrium | None
MHD equilibrium object.
matrix_free : bool
If set to true will not compute the matrix associated with the operator but directly compute the product when called.
"""
def __init__(
self,
derham: Derham,
domain: Domain,
eq_mhd: MHDequilibrium | None = None,
matrix_free: bool = False,
):
self._derham = derham
self._domain = domain
self._matrix_free = matrix_free
self._eq_mhd = eq_mhd
self._dry_run = False
if self._eq_mhd is None:
self._eq_mhd = equils.HomogenSlab()
if not hasattr(self.eq_mhd, "_domain"):
self._eq_mhd.domain = self._domain
# only for M1 Mac users
PSYDAC_BACKEND_GPYCCEL["flags"] = "-O3 -march=native -mtune=native -ffast-math -ffree-line-length-none"
@property
def derham(self) -> Derham:
"""Discrete de Rham sequence on the logical unit cube."""
return self._derham
@property
def domain(self) -> Domain:
"""Mapping from the logical unit cube to the physical domain with corresponding metric coefficients."""
return self._domain
@property
def eq_mhd(self) -> MHDequilibrium | None:
"""MHD equilibrium object."""
return self._eq_mhd
@property
def matrix_free(self) -> bool:
"""If set to true will not compute the matrix associated with the operators but directly compute the dot product when called."""
return self._matrix_free
@property
def dry_run(self) -> bool:
"""If True, mass operators created from now on do not allocate (nor assemble) their
stencil matrices; only their sizes are computed. Set temporarily by :meth:`estimate_mem`."""
return self._dry_run
[docs]
def estimate_mem(
self,
names: tuple[str] = ("M0", "M1", "M2", "M3", "Mv"),
print_report: bool = False,
) -> dict[str, int]:
"""Estimate the local (per-MPI-rank) memory footprint of mass matrices, in bytes,
without allocating them.
Each requested operator is created exactly as by the corresponding property (same weights,
hence the same zero-block detection), but with ``dry_run=True``, so that only the sizes of
its stencil matrices are computed, see
:attr:`~struphy.feec.mass.WeightedMassOperator.nbytes`. Operators that have already been
created (and hence allocated) report their actual size instead; dry-run operators are not
kept in the cache.
Parameters
----------
names : tuple[str]
Names of the mass operator properties to estimate, e.g. ``("M0", "M1")``.
print_report : bool
Whether to print the breakdown on MPI rank 0.
Returns
-------
dict
Mapping ``{name: local_bytes}``.
"""
mem = {}
cached_before = set(vars(self))
self._dry_run = True
try:
for name in names:
assert isinstance(getattr(type(self), name, None), property), (
f"'{name}' is not a mass operator property of {type(self).__name__}."
)
mem[name] = getattr(self, name).nbytes
finally:
self._dry_run = False
# do not keep dry-run (unusable) operators in the cache, including the pieces
# created for composite operators (e.g. M1 and M1para for M1perp)
for attr in set(vars(self)) - cached_before:
delattr(self, attr)
if print_report and (self.derham.comm is None or self.derham.comm.Get_rank() == 0):
print("\nESTIMATED MASS MATRIX MEMORY (local, rank 0):")
for name, nbytes in mem.items():
print(f" {name}: {nbytes / 1e6:.2f} MB")
return mem
[docs]
def allocated_mem(self) -> dict[str, int]:
"""Local (per-MPI-rank) memory footprint, in bytes, of the mass matrices that have
actually been created so far (i.e. those whose property has been accessed)."""
mem = {}
for name, method in inspect.getmembers(type(self), predicate=inspect.isdatadescriptor):
if isinstance(method, property) and hasattr(self, "_" + name):
op = getattr(self, "_" + name)
if isinstance(op, WeightedMassOperator):
mem[name] = op.nbytes
return mem
def info(self):
print("The mass matrices of the Derham complex are:")
self.M0.info()
self.M1.info()
self.M2.info()
self.M3.info()
print("Available mass operators as properties:")
li = ""
for name, method in inspect.getmembers(self.__class__, predicate=inspect.isdatadescriptor):
if name.startswith("M") and isinstance(getattr(self.__class__, name), property):
li += f"{name}, "
li = li.rstrip(", ")
print(f"{li}")
print("\nTo see more details on a mass operator, call the info() method on it, e.g. mass_ops.M0.info().")
#######################################################################
# Mass matrices related to L2-scalar products in all 3d derham spaces #
#######################################################################
@property
def M0(self):
r"""
Standard mass matrix for 0-forms (H1 space):
.. math::
\mathbb M^0_{ijk, mno} = \int \Lambda^0_{ijk} \Lambda^0_{mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M0"):
self._M0 = self.create_weighted_mass(
"H1",
"H1",
weights=("sqrt_g",),
name="M0",
assemble=True,
)
return self._M0
@property
def M1(self):
r"""
Standard mass matrix for 1-forms (Hcurl space) as 3x3 block matrix indexed by :math:`(\mu, \nu)`:
.. math::
\mathbb M^1_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^1_{\mu,ijk}\, G^{-1} \vec{\Lambda}^1_{\nu, mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M1"):
self._M1 = self.create_weighted_mass(
"Hcurl",
"Hcurl",
weights=("Ginv", "sqrt_g"),
name="M1",
assemble=True,
)
return self._M1
@property
def M2(self):
r"""
Standard mass matrix for 2-forms (Hdiv space) as 3x3 block matrix indexed by :math:`(\mu, \nu)`:
.. math::
\mathbb M^2_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^2_{\mu,ijk} G \vec{\Lambda}^2_{\nu, mno} \frac{1}{\sqrt{g}} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M2"):
self._M2 = self.create_weighted_mass(
"Hdiv",
"Hdiv",
weights=("G", "1/sqrt_g"),
name="M2",
assemble=True,
)
return self._M2
@property
def M3(self):
r"""
Standard mass matrix for 3-forms (L2 space):
.. math::
\mathbb M^3_{ijk, mno} = \int \Lambda^3_{ijk}\, \Lambda^3_{mno} \frac{1}{\sqrt{g}}\, \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M3"):
self._M3 = self.create_weighted_mass(
"L2",
"L2",
weights=("1/sqrt_g",),
name="M3",
assemble=True,
)
return self._M3
@property
def M3p_inv(self):
r"""
Pressure-weighted mass matrix for 3-forms:
.. math::
\mathbb M^{3,p^{-1}}_{ijk,mno} =
\int \Lambda^3_{ijk}\,\Lambda^3_{mno}
\frac{1}{p_0\sqrt{g}}\,\mathrm d\boldsymbol\eta.
Here :math:`p_0` is the equilibrium pressure. This operator is used
for the quadratic pressure energy of linear MHD perturbations.
"""
if not hasattr(self, "_M3p_inv"):
assert self.eq_mhd is not None, "M3p_inv requires an MHD equilibrium with positive pressure."
def inv_p0(e1, e2, e3):
return 1.0 / self.eq_mhd.p0(e1, e2, e3)
self._M3p_inv = self.create_weighted_mass(
"L2",
"L2",
weights=(inv_p0, "1/sqrt_g"),
name="M3p_inv",
assemble=True,
)
return self._M3p_inv
@property
def Mv(self):
r"""
Standard mass matrix for vector 0-forms (H1vec space) as 3x3 block matrix indexed by :math:`(\mu, \nu)`:
.. math::
\mathbb M^v_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^v_{\mu,ijk} G \vec{\Lambda}^v_{\nu, mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_Mv"):
self._Mv = self.create_weighted_mass(
"H1vec",
"H1vec",
weights=("G", "sqrt_g"),
name="Mv",
assemble=True,
)
return self._Mv
######################################
# Predefined weighted mass operators #
######################################
@property
def M2stab_for_rot(self):
r"""
Stabilization matrix for the rot-problem u2 + B2 x u2 = G*f2 as 3x3 block matrix indexed by :math:`(\mu, \nu)`:
.. math::
\mathbb M^2_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^2_{\mu,ijk} \vec{\Lambda}^2_{\nu, mno} \frac{1}{\sqrt{g}} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M2stab_for_rot"):
self._M2stab_for_rot = self.create_weighted_mass(
"Hdiv",
"Hdiv",
weights=("Identity", "1/sqrt_g"),
name="M2stab_for_rot",
assemble=True,
)
return self._M2stab_for_rot
@property
def M1n(self):
r"""
Mass matrix
.. math::
\mathbb M^{1,n}_{(\mu,ijk), (\nu,mno)} = \int n^0_{\textnormal{eq}}(\boldsymbol{\eta}) \vec{\Lambda}^1_{\mu,ijk} G^{-1} \vec{\Lambda}^1_{\nu,mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
where :math:`n^0_{\textnormal{eq}}(\boldsymbol{\eta})` is an MHD equilibrium density (0-form).
"""
if not hasattr(self, "_M1n"):
self._M1n = self.create_weighted_mass(
"Hcurl",
"Hcurl",
weights=(
"Ginv",
"sqrt_g",
lambda *etas: self.eq_mhd.n0(*etas),
),
name="M1n",
assemble=True,
)
return self._M1n
@property
def M2n(self):
r"""
Mass matrix
.. math::
\mathbb M^{2,n}_{(\mu,ijk), (\nu,mno)} = \int n^0_{\textnormal{eq}}(\boldsymbol{\eta}) \vec{\Lambda}^2_{\mu,ijk} G \vec{\Lambda}^2_{\nu,mno} \frac{1}{\sqrt{g}} \textnormal{d}\boldsymbol{\eta}.
where :math:`n^0_{\textnormal{eq}}(\boldsymbol{\eta})` is an MHD equilibrium density (0-form).
"""
if not hasattr(self, "_M2n"):
self._M2n = self.create_weighted_mass(
"Hdiv",
"Hdiv",
weights=(
"G",
"1/sqrt_g",
lambda *etas: self.eq_mhd.n0(*etas),
),
name="M2n",
assemble=True,
)
return self._M2n
@property
def M2gyro(self):
r"""
Mass matrix
.. math::
\mathbb M^{2,\textnormal{gyro}}_{(\mu,ijk), (\nu,mno)} = \int \frac{\|B_0(\boldsymbol{\eta})\|^2}{n^0_{\textnormal{eq}}(\boldsymbol{\eta})} \vec{\Lambda}^2_{\mu,ijk} \left(G - \hat b^1_0 (\hat b^1_0)^\top \right) \vec{\Lambda}^2_{\nu,mno} \frac{1}{\sqrt{g}} \textnormal{d}\boldsymbol{\eta},
where :math:`\hat b^1_0` are the covariant (1-form) components of the unit vector along :math:`\mathbf B_0`. For
2-forms :math:`\mathbf j, \mathbf j'` it is the discrete
:math:`\int (\mathbf J \times \mathbf B_0) \cdot (\mathbf J' \times \mathbf B_0) / n^0_{\textnormal{eq}} \,\textnormal d \mathbf x`,
the counterpart of :attr:`M1gyro` on :math:`H(\textnormal{div})`.
"""
if not hasattr(self, "_M2gyro"):
assert self.eq_mhd is not None, (
"M2gyro requires an MHD equilibrium to be provided when initializing the WeightedMassOperators object."
)
bb = LocalProjectionMatrix(self.eq_mhd.unit_b1_1, self.eq_mhd.unit_b1_2, self.eq_mhd.unit_b1_3)
weight = lambda *etas: self.eq_mhd.absB0(*etas) ** 2 / self.eq_mhd.n0(*etas)
M2_B = self.create_weighted_mass(
"Hdiv",
"Hdiv",
weights=("G", "1/sqrt_g", weight),
name="M2_Bn",
assemble=True,
)
M2para_B = self.create_weighted_mass(
"Hdiv",
"Hdiv",
weights=(bb, "1/sqrt_g", weight),
name="M2para_Bn",
assemble=True,
)
self._M2gyro = M2_B - M2para_B
return self._M2gyro
@property
def Mvn(self):
r"""
Mass matrix
.. math::
\mathbb M^{v,n}_{(\mu,ijk), (\nu,mno)} = \int n^0_{\textnormal{eq}}(\boldsymbol{\eta}) \vec{\Lambda}^v_{\mu,ijk} G \vec{\Lambda}^v_{\nu,mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
where :math:`n^0_{\textnormal{eq}}(\boldsymbol{\eta})` is an MHD equilibrium density (0-form).
"""
if not hasattr(self, "_Mvn"):
self._Mvn = self.create_weighted_mass(
"H1vec",
"H1vec",
weights=(
"G",
"sqrt_g",
lambda *etas: self.eq_mhd.n0(*etas),
),
name="Mvn",
assemble=True,
)
return self._Mvn
@property
def M1ninv(self):
r"""
Mass matrix
.. math::
\mathbb M^{1,\frac{1}{n}}_{(\mu,ijk), (\nu,mno)} = \int \frac{1}{n^0_{\textnormal{eq}}(\boldsymbol{\eta})} \vec{\Lambda}^1_{\mu,ijk} G^{-1} \vec{\Lambda}^1_{\nu,mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
where :math:`n^0_{\textnormal{eq}}(\boldsymbol{\eta})` is an MHD equilibrium density (0-form).
"""
if not hasattr(self, "_M1ninv"):
self._M1ninv = self.create_weighted_mass(
"Hcurl",
"Hcurl",
weights=(
"Ginv",
"sqrt_g",
lambda *etas: 1 / self.eq_mhd.n0(*etas),
),
name="M1ninv",
assemble=True,
)
return self._M1ninv
@property
def M1J(self):
r"""
Mass matrix
.. math::
\mathbb M^{1,J}_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^1_{\mu,ijk} G^{-1} \mathcal{R}(J) \vec{\Lambda}^2_{\nu,mno} \textnormal{d}\boldsymbol{\eta}.
with the rotation matrix
.. math::
\mathcal{R}(J)_{\alpha,\nu} := \epsilon_{\alpha\beta\nu} J^2_{\textnormal{eq},\beta},\qquad s.t. \qquad \mathcal{R}(J) \vec{v} = \vec{J}^2_{\textnormal{eq}} \times \vec{v},
where :math:`\epsilon_{\alpha \beta \nu}` stands for the Levi-Civita tensor and :math:`J^2_{\textnormal{eq}, \beta}` is the :math:`\beta`-component of the MHD equilibrium current density (2-form).
"""
if not hasattr(self, "_M1J"):
assert self.eq_mhd is not None, (
"M1J requires an MHD equilibrium to be provided when initializing the WeightedMassOperators object."
)
rot_J = LocalRotationMatrix(
self.eq_mhd.j2_1,
self.eq_mhd.j2_2,
self.eq_mhd.j2_3,
)
self._M1J = self.create_weighted_mass(
"Hdiv",
"Hcurl",
weights=("Ginv", rot_J),
name="M1J",
assemble=True,
)
return self._M1J
@property
def M2J(self):
r"""
Mass matrix
.. math::
\mathbb M^{2,J}_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^2_{\mu,ijk} \mathcal{R}(J) \vec{\Lambda}^2_{\nu,mno} \frac{1}{\sqrt{g}} \textnormal{d}\boldsymbol{\eta}.
with the rotation matrix
.. math::
\mathcal{R}(J)_{\alpha,\nu} := \epsilon_{\alpha\beta\nu} J^2_{\textnormal{eq},\beta},\qquad s.t. \qquad \mathcal{R}(J) \vec{v} = \vec{J}^2_{\textnormal{eq}} \times \vec{v},
where :math:`\epsilon_{\alpha \beta \nu}` stands for the Levi-Civita tensor and :math:`J^2_{\textnormal{eq}, \beta}` is the :math:`\beta`-component of the MHD equilibrium current density (2-form).
"""
if not hasattr(self, "_M2J"):
assert self.eq_mhd is not None, (
"M2J requires an MHD equilibrium to be provided when initializing the WeightedMassOperators object."
)
rot_J = LocalRotationMatrix(
self.eq_mhd.j2_1,
self.eq_mhd.j2_2,
self.eq_mhd.j2_3,
)
self._M2J = self.create_weighted_mass(
"Hdiv",
"Hdiv",
weights=(rot_J, "1/sqrt_g"),
name="M2J",
assemble=True,
)
return self._M2J
@property
def M21J(self):
r"""
Mixed mass matrix from 1-forms to 2-forms
.. math::
\mathbb M^{21,J}_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^2_{\mu,ijk} \mathcal{R}(J) G^{-1} \vec{\Lambda}^1_{\nu,mno} \textnormal{d}\boldsymbol{\eta},
with the rotation matrix :math:`\mathcal{R}(J)` of :attr:`M2J`, such that
:math:`\mathbf v^\top \mathbb M^{21,J} \mathbf b = \int \mathbf V \cdot (\mathbf J_\textnormal{eq} \times \mathbf B)\, \textnormal d \mathbf x`
for a 2-form :math:`\mathbf V` and a 1-form :math:`\mathbf B`. The 1-form counterpart of :attr:`M2J` (which acts
on 2-forms).
"""
if not hasattr(self, "_M21J"):
assert self.eq_mhd is not None, (
"M21J requires an MHD equilibrium to be provided when initializing the WeightedMassOperators object."
)
rot_J = LocalRotationMatrix(
self.eq_mhd.j2_1,
self.eq_mhd.j2_2,
self.eq_mhd.j2_3,
)
self._M21J = self.create_weighted_mass(
"Hcurl",
"Hdiv",
weights=(rot_J, "Ginv"),
name="M21J",
assemble=True,
)
return self._M21J
@property
def MvJ(self):
r"""
Mass matrix
.. math::
\mathbb M^{v,J}_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^v_{\mu,ijk} \mathcal{R}(J) \vec{\Lambda}^v_{\nu,mno} \textnormal{d}\boldsymbol{\eta}.
with the rotation matrix
.. math::
\mathcal{R}(J)_{\alpha,\nu} := \epsilon_{\alpha\beta\nu} J^2_{\textnormal{eq},\beta},\qquad s.t. \qquad \mathcal{R}(J) \vec{v} = \vec{J}^2_{\textnormal{eq}} \times \vec{v},
where :math:`\epsilon_{\alpha \beta \nu}` stands for the Levi-Civita tensor and :math:`J^2_{\textnormal{eq}, \beta}` is the :math:`\beta`-component of the MHD equilibrium current density (2-form).
"""
if not hasattr(self, "_MvJ"):
assert self.eq_mhd is not None, (
"MvJ requires an MHD equilibrium to be provided when initializing the WeightedMassOperators object."
)
rot_J = LocalRotationMatrix(
self.eq_mhd.j2_1,
self.eq_mhd.j2_2,
self.eq_mhd.j2_3,
)
self._MvJ = self.create_weighted_mass(
"Hdiv",
"H1vec",
weights=(rot_J,),
name="MvJ",
assemble=True,
)
return self._MvJ
@property
def M2B_div0(self):
r"""
Mass matrix
.. math::
\mathbb M^{2,B}_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^2_{\mu,ijk} \mathcal{R}(B) \vec{\Lambda}^2_{\nu,mno} \frac{1}{\sqrt{g}} \textnormal{d}\boldsymbol{\eta}.
with the rotation matrix
.. math::
\mathcal{R}(B)_{\alpha,\nu} := \epsilon_{\alpha\beta\nu} B^2_{\textnormal{eq},\beta},\qquad s.t. \qquad \mathcal{R}(B) \vec{v} = \vec{B}^2_{\textnormal{eq}} \times \vec{v},
where :math:`\epsilon_{\alpha \beta \nu}` stands for the Levi-Civita tensor and :math:`B^2_{\textnormal{eq}, \beta}` is the :math:`\beta`-component of the MHD equilibrium magnetic field (2-form).
"""
if not hasattr(self, "_M2B_div0"):
assert self.eq_mhd is not None, (
"M2B_div0 requires an MHD equilibrium to be provided when initializing the WeightedMassOperators object."
)
a_eq = self.derham.P1(
[
self.eq_mhd.a1_1,
self.eq_mhd.a1_2,
self.eq_mhd.a1_3,
],
)
tmp_b2 = self.derham.curl.dot(a_eq)
b02fun = self.derham.create_spline_function("b02", "Hdiv")
b02fun.vector = tmp_b2
def b02funx(x, y, z):
return b02fun(
x,
y,
z,
local=True,
)[0]
def b02funy(x, y, z):
return b02fun(
x,
y,
z,
local=True,
)[1]
def b02funz(x, y, z):
return b02fun(
x,
y,
z,
local=True,
)[2]
rot_B = LocalRotationMatrix(
b02funx,
b02funy,
b02funz,
)
self._M2B_div0 = self.create_weighted_mass(
"Hdiv",
"Hdiv",
weights=(rot_B, "1/sqrt_g"),
name="M2B_div0",
assemble=True,
)
return self._M2B_div0
@property
def M2B(self):
r"""
Mass matrix
.. math::
\mathbb M^{2,B}_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^2_{\mu,ijk} \mathcal{R}(B) \vec{\Lambda}^2_{\nu,mno} \frac{1}{\sqrt{g}} \textnormal{d}\boldsymbol{\eta}.
with the rotation matrix
.. math::
\mathcal{R}(B)_{\alpha,\nu} := \epsilon_{\alpha\beta\nu} B^2_{\textnormal{eq},\beta},\qquad s.t. \qquad \mathcal{R}(B) \vec{v} = \vec{B}^2_{\textnormal{eq}} \times \vec{v},
where :math:`\epsilon_{\alpha \beta \nu}` stands for the Levi-Civita tensor and :math:`B^2_{\textnormal{eq}, \beta}` is the :math:`\beta`-component of the MHD equilibrium magnetic field (2-form).
"""
if not hasattr(self, "_M2B"):
assert self.eq_mhd is not None, (
"M2B requires an MHD equilibrium to be provided when initializing the WeightedMassOperators object."
)
rot_B = LocalRotationMatrix(
self.eq_mhd.b2_1,
self.eq_mhd.b2_2,
self.eq_mhd.b2_3,
)
self._M2B = self.create_weighted_mass(
"Hdiv",
"Hdiv",
weights=(rot_B, "1/sqrt_g"),
name="M2B",
assemble=True,
)
return self._M2B
@property
def M2Bn(self):
r"""
Mass matrix
.. math::
\mathbb M^{2,BN}_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^2_{\mu,ijk} \mathcal{R}(B) \vec{\Lambda}^2_{\nu,mno} \frac{1}{n^0_{\textnormal{eq}}(\boldsymbol{\eta})} \frac{1}{\sqrt{g}} \textnormal{d}\boldsymbol{\eta}.
with the rotation matrix
.. math::
\mathcal{R}(B)_{\alpha,\nu} := \epsilon_{\alpha\beta\nu} B^2_{\textnormal{eq},\beta},\qquad s.t. \qquad \mathcal{R}(B) \vec{v} = \vec{B}^2_{\textnormal{eq}} \times \vec{v},
where :math:`\epsilon_{\alpha \beta \nu}` stands for the Levi-Civita tensor and :math:`B^2_{\textnormal{eq}, \beta}` is the :math:`\beta`-component of the MHD equilibrium magnetic field (2-form).
"""
if not hasattr(self, "_M2Bn"):
assert self.eq_mhd is not None, (
"M2Bn requires an MHD equilibrium to be provided when initializing the WeightedMassOperators object."
)
# The equilibrium field itself, as in M2B: the curl of the projected vector potential (M2B_div0)
# loses a uniform field in periodic directions, where the potential is a ramp.
rot_B = LocalRotationMatrix(
self.eq_mhd.b2_1,
self.eq_mhd.b2_2,
self.eq_mhd.b2_3,
)
self._M2Bn = self.create_weighted_mass(
"Hdiv",
"Hdiv",
weights=(
rot_B,
"1/sqrt_g",
lambda *etas: 1 / self.eq_mhd.n0(*etas),
),
name="M2Bn",
assemble=True,
)
return self._M2Bn
@property
def M1Bninv(self):
r"""
Mass matrix
.. math::
\mathbb M^{1,B\frac{1}{n}}_{(\mu,ijk), (\nu,mno)} = \int \frac{1}{n^0_{\textnormal{eq}}(\boldsymbol{\eta})} \vec{\Lambda}^1_{\mu,ijk} G^{-1} \mathcal{R}(B)_{\alpha,\gamma} G^{-1}_{\gamma,\nu} \vec{\Lambda}^1_{\nu,mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
with the rotation matrix
.. math::
\mathcal{R}(B)_{\alpha,\nu} := \epsilon_{\alpha\beta\nu} B^2_{\textnormal{eq},\beta},\qquad s.t. \qquad \mathcal{R}(B) \vec{v} = \vec{B}^2_{\textnormal{eq}} \times \vec{v},
where :math:`\epsilon_{\alpha \beta \nu}` stands for the Levi-Civita tensor and :math:`B^2_{\textnormal{eq}, \beta}` is the :math:`\beta`-component of the MHD equilibrium magnetic field (2-form).
"""
if not hasattr(self, "_M1Bninv"):
assert self.eq_mhd is not None, (
"M1Bninv requires an MHD equilibrium to be provided when initializing the WeightedMassOperators object."
)
rot_B = LocalRotationMatrix(
self.eq_mhd.b2_1,
self.eq_mhd.b2_2,
self.eq_mhd.b2_3,
)
self._M1Bninv = self.create_weighted_mass(
"Hcurl",
"Hcurl",
weights=(
"Ginv",
rot_B,
"Ginv",
"sqrt_g",
lambda *etas: 1 / self.eq_mhd.n0(*etas),
),
name="M1Bninv",
assemble=True,
)
return self._M1Bninv
@property
def M1para(self):
r"""
Mass matrix
.. math::
\mathbb M^{1,\parallel}_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^1_{\mu,ijk} b_0 b_0^\top \vec{\Lambda}^1_{\nu,mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M1para"):
bb = LocalProjectionMatrix(self.eq_mhd.unit_bv_1, self.eq_mhd.unit_bv_2, self.eq_mhd.unit_bv_3)
self._M1para = self.create_weighted_mass(
"Hcurl",
"Hcurl",
weights=(
bb,
"sqrt_g",
),
name="M1para",
assemble=True,
)
return self._M1para
@property
def M1perp(self):
r"""
Mass matrix
.. math::
\mathbb M^{1,\perp}_{(\mu,ijk), (\nu,mno)} = \int \vec{\Lambda}^1_{\mu,ijk} \left(G^{-1} - b_0 b_0^\top \right) \vec{\Lambda}^1_{\nu,mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M1perp"):
self._M1perp = self.M1 - self.M1para
return self._M1perp
@property
def M1para_MHDeq(self):
r"""
Mass matrix
.. math::
\mathbb M^{1,\parallel}_{(\mu,ijk), (\nu,mno)} = \int \frac{n^0_{\textnormal{eq}}(\boldsymbol{\eta})}{\|B_0(\boldsymbol{\eta})\|^2} \vec{\Lambda}^1_{\mu,ijk} b_0 b_0^\top \vec{\Lambda}^1_{\nu,mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M1para_MHDeq"):
bb = LocalProjectionMatrix(self.eq_mhd.unit_bv_1, self.eq_mhd.unit_bv_2, self.eq_mhd.unit_bv_3)
self._M1para_MHDeq = self.create_weighted_mass(
"Hcurl",
"Hcurl",
weights=(
bb,
lambda *etas: self.eq_mhd.n0(*etas) / self.eq_mhd.absB0(*etas) ** 2,
"sqrt_g",
),
name="M1para_MHDeq",
assemble=True,
)
return self._M1para_MHDeq
@property
def M1_MHDeq(self):
r"""
Mass matrix
.. math::
\mathbb M^{1}_{(\mu,ijk), (\nu,mno)} = \int \frac{n^0_{\textnormal{eq}}(\boldsymbol{\eta})}{\|B_0(\boldsymbol{\eta})\|^2} \vec{\Lambda}^1_{\mu,ijk} G^{-1} \vec{\Lambda}^1_{\nu,mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M1_MHDeq"):
self._M1_MHDeq = self.create_weighted_mass(
"Hcurl",
"Hcurl",
weights=(
"Ginv",
lambda *etas: self.eq_mhd.n0(*etas) / self.eq_mhd.absB0(*etas) ** 2,
"sqrt_g",
),
name="M1_MHDeq",
assemble=True,
)
return self._M1_MHDeq
@property
def M1gyro(self):
r"""
Mass matrix
.. math::
\mathbb M^{1,\perp}_{(\mu,ijk), (\nu,mno)} = \int \frac{n^0_{\textnormal{eq}}(\boldsymbol{\eta})}{\|B_0(\boldsymbol{\eta})\|^2} \vec{\Lambda}^1_{\mu,ijk} \left(G^{-1} - b_0 b_0^\top \right) \vec{\Lambda}^1_{\nu,mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
"""
if not hasattr(self, "_M1gyro"):
self._M1gyro = self.M1_MHDeq - self.M1para_MHDeq
return self._M1gyro
@property
def M0ad(self):
r"""
Mass matrix
.. math::
\mathbb M^0_{ijk, mno} = \int n^0_{\textnormal{eq}}(\boldsymbol{\eta}) \Lambda^0_{ijk} \Lambda^0_{mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
where :math:`n^0_{\textnormal{eq}}(\boldsymbol{\eta})` is an MHD equilibrium density (0-form).
"""
if not hasattr(self, "_M0ad"):
self._M0ad = self.create_weighted_mass(
"H1",
"H1",
weights=(
lambda *etas: self.eq_mhd.n0(*etas),
"sqrt_g",
),
name="M0ad",
assemble=True,
)
return self._M0ad
@property
def M0ad_withT(self):
r"""
Mass matrix
.. math::
\mathbb M^0_{ijk, mno} = \int \frac{n^0_{\textnormal{eq}}(\boldsymbol{\eta})}{T^0_{\textnormal{eq}}(\boldsymbol{\eta})} \Lambda^0_{ijk} \Lambda^0_{mno} \sqrt{g} \textnormal{d}\boldsymbol{\eta}.
where :math:`n^0_{\textnormal{eq}}(\boldsymbol{\eta})` and :math:`T^0_{\textnormal{eq}}(\boldsymbol{\eta})` are MHD equilibrium density and electron temperature (0-forms), respectively.
"""
if not hasattr(self, "_M0ad_withT"):
self._M0ad_withT = self.create_weighted_mass(
"H1",
"H1",
weights=(
lambda *etas: self.eq_mhd.n0(*etas) / self.eq_mhd.t0(*etas),
"sqrt_g",
),
name="M0ad_withT",
assemble=True,
)
return self._M0ad_withT
@property
def WMM(self):
if not hasattr(self, "_WMM"):
self._WMM = self.H1vecMassMatrix_density(self.derham, self, self.domain)
return self._WMM
@property
def WMMnew(self):
if not hasattr(self, "_WMMnew"):
spline = self.derham.create_spline_function("l2_field", "L2")
self._WMMnew = self.create_weighted_mass(
"H1vec",
"H1vec",
weights=("G", spline),
name="WMMnew",
assemble=False,
)
return self._WMMnew
@property
def WMM3new(self):
r"""Density-weighted mass matrix of :math:`L^2` (3-forms)
.. math::
\mathbb M^{3,\rho}_{ijk, mno} = \int \frac{\hat\rho^3}{g} \Lambda^3_{ijk} \Lambda^3_{mno} \, \textnormal d \boldsymbol \eta
= \int \rho \, \frac{\Lambda^3_{ijk}}{\sqrt g} \frac{\Lambda^3_{mno}}{\sqrt g} \, \textnormal d \mathbf x \,,
where :math:`\hat\rho^3` is the density 3-form of :attr:`WMMnew` (the same spline function), i.e. the
:math:`L^2` inner product of the push-forwards weighted with the physical density :math:`\rho`.
It enters the div-div term of the regularized kinetic energy, see
:func:`~struphy.feec.variational_utilities.kinetic_metric`, and is assembled together with :attr:`WMMnew`.
"""
if not hasattr(self, "_WMM3new"):
spline = self.WMMnew.spline_functions["l2_field"]
self._WMM3new = self.create_weighted_mass(
"L2",
"L2",
weights=("1/sqrt_g", "1/sqrt_g", spline),
name="WMM3new",
assemble=False,
)
return self._WMM3new
#######################################
# Wrapper around WeightedMassOperator #
#######################################
[docs]
def create_weighted_mass(
self,
V_id: str,
W_id: str,
*,
name: str = None,
weights: tuple | list | str | None = None,
assemble: bool = False,
transposed: bool = False,
dry_run: bool = None,
):
r"""Weighted mass matrix :math:`V^\alpha_h \to V^\beta_h` with given (matrix-valued) weight function :math:`W(\boldsymbol \eta)`:
.. math::
\mathbb M_{(\mu, ijk), (\nu, mno)}(W) = \int \Lambda^\beta_{\mu, ijk}\, W_{\mu,\nu}(\boldsymbol \eta)\, \Lambda^\alpha_{\nu, mno} \, \textnormal d \boldsymbol\eta.
Here, :math:`\alpha \in \{0, 1, 2, 3, v\}` indicates the domain and :math:`\beta \in \{0, 1, 2, 3, v\}` indicates the co-domain
of the operator.
Parameters
----------
V_id : str
Specifier for the domain of the operator ('H1', 'Hcurl', 'Hdiv', 'L2' or 'H1vec').
W_id : str
Specifier for the co-domain of the operator ('H1', 'Hcurl', 'Hdiv', 'L2' or 'H1vec').
name: str
Name of the operator.
weights : None | str | tuple | list
Information about the weights/block structure of the operator.
Four cases are possible:
1. ``None`` : all blocks are allocated, disregarding zero-blocks or any symmetry.
2. ``str`` : for square block matrices (V=W), a symmetry can be set in order to accelerate the assembly process.
Possible strings are ``symm`` (symmetric), ``asym`` (anti-symmetric) and ``diag`` (diagonal).
3. ``1D tuple`` : most common and recommended input format.
Entries are processed from left to right and multiplied together.
Supported tuple entries are:
- Strings (predefined names) for metric and Jacobian-related weights:
``'G'``, ``'Ginv'``, ``'DFinv'``, ``'DFinvT'``, ``'sqrt_g'``, ``'1/sqrt_g'``, ``'Identity'``.
- Callables (including objects such as local rotation matrices) returning
either scalar values or ``3x3`` matrix values at quadrature points.
- Nested ``3x3`` Python lists (constant matrix entries).
- :class:`~struphy.feec.psydac_derham.SplineFunction` instances.
Example:
``weights=('Ginv', 'sqrt_g')``
4. ``2D list`` : 2d list with the same number of rows/columns as the number of components of the domain/codomain spaces.
The entries can be either a) callables or b) xp.ndarrays representing the weights at the quadrature points.
If an entry is zero or ``None``, the corresponding block is set to ``None`` to accelerate the dot product.
assemble: bool
Whether to assemble the weighted mass matrix, i.e. computes the integrals with
:class:`~struphy.feec.mass.WeightedMassOperators.assemble`
transposed: bool
Whether to assemble the transposed operator.
dry_run: bool
Whether to create the operator without allocating (and assembling) its stencil matrices,
for memory estimation only. If None (default), the value of the ``dry_run`` attribute of
this :class:`WeightedMassOperators` object is used, see :meth:`estimate_mem`.
Returns
-------
out : A WeightedMassOperator object.
"""
if dry_run is None:
dry_run = self.dry_run
logger.debug(f"\nCreating weighted mass matrix {name} from {V_id} to {W_id} ({dry_run = }).")
with ProfileManager.profile_region(f"weights eval for {name}"):
if isinstance(weights, tuple):
# Case 3 (1D tuple): evaluate the product of all factors at the quadrature points
weights_values, spline_functions = self._eval_tuple_weights(weights, V_id, W_id)
else:
# Cases 1, 2 and 4 are passed on unchanged to WeightedMassOperator
logger.debug(f"Processing weights of type {type(weights)}.")
weights_values, spline_functions = weights, {}
out = WeightedMassOperator(
self.derham,
self.derham.fem_spaces[V_id],
self.derham.fem_spaces[W_id],
name=name,
V_extraction_op=self.derham.extraction_ops[V_id],
W_extraction_op=self.derham.extraction_ops[W_id],
V_boundary_op=self.derham.boundary_ops[V_id],
W_boundary_op=self.derham.boundary_ops[W_id],
weights_info=weights_values,
spline_functions=spline_functions,
transposed=transposed,
matrix_free=self.matrix_free,
dry_run=dry_run,
)
# weights given at quadrature points or as spline functions are bound to this Derham
grid_bound = len(spline_functions) > 0 or (
isinstance(weights, list) and any(isinstance(w, xp.ndarray) for row in weights for w in row)
)
out._creation_info = (
None
if grid_bound
else {
"V_id": V_id,
"W_id": W_id,
"name": name,
"weights": weights,
"transposed": transposed,
"is_transpose": False,
}
)
if assemble and not dry_run:
with ProfileManager.profile_region(f"assemble {name}"):
out.assemble()
return out
##################################################################
# Evaluation of tuple weights (Case 3 in create_weighted_mass) #
##################################################################
def _eval_tuple_weights(self, weights: tuple, V_id: str, W_id: str):
"""Evaluate a 1D tuple of weights at the quadrature points of the codomain ``W_id``.
The tuple entries are factors which are multiplied together. They are first sorted
into scalar, vector and matrix factors (see :meth:`_classify_weights`). On the quadrature
grid of each component of ``W_id`` (= block row), the matrix factors are multiplied
together (or the single vector factor is evaluated), and the result is then multiplied
by the product of all scalar factors. SplineFunction factors are not evaluated here;
they are passed on to :class:`WeightedMassOperator`, which evaluates them during assembly.
The factors are evaluated once per distinct quadrature grid: if all components of ``W_id``
share the same grid (the usual case), the evaluation is reused for all block rows.
Returns
-------
weights_values : list[list[xp.ndarray | None]]
Weights at the quadrature points, one C-contiguous block of shape (n1, n2, n3) per
(component of ``W_id``, component of ``V_id``). ``None`` marks a zero block.
spline_functions : dict
SplineFunction factors, keyed by their name.
"""
classified = self._classify_weights(weights, V_id, W_id)
self._check_weight_compatibility(classified, V_id, W_id)
# number of block columns = number of components of the domain space V_id
if V_id in _SCALAR_SPACES:
n_cols = 1
elif V_id in _VECTOR_SPACES:
n_cols = 3
else:
raise ValueError(f"Unknown space identifier {V_id} for the domain.")
assert W_id in self.derham.spline_attributes, (
f"Spline attributes for the codomain space {W_id} not found in the Derham object !!"
)
# one tuple of 1d quadrature grids per component of W_id (= block row)
quad_grid_pts = self.derham.spline_attributes[W_id].quad_grid_pts
logger.debug(f"{len(quad_grid_pts) = }")
weights_values = []
evaluated_grid = None
for m, component in enumerate(quad_grid_pts):
grids_1d = [pts.flatten() for pts in component]
grid_shape = tuple(g.size for g in grids_1d)
# evaluate the factors only if the grid differs from the one of the previous row
reuse = evaluated_grid is not None and all(
xp.array_equal(g, g_prev) for g, g_prev in zip(grids_1d, evaluated_grid)
)
if not reuse:
logger.debug(f"Evaluating weights for block row {m} of {W_id} on grid of shape {grid_shape}.")
mv, scalar = self._eval_factors(classified, grids_1d)
evaluated_grid = grids_1d
# a scalar array reused from a previous row must not be shared between blocks
weights_values.append(
self._extract_row(classified, mv, scalar, m, n_cols, grid_shape, copy_scalar=reuse),
)
return weights_values, classified.spline_functions
def _classify_weights(self, weights: tuple, V_id: str, W_id: str) -> "_ClassifiedWeights":
"""Sort the entries of a 1D weights tuple into scalar, vector and matrix factors and SplineFunctions.
Strings are converted to callables (or constant matrices), nested lists to constant matrices;
for general callables the kind is determined from the number of dimensions of their output
(see :meth:`_callable_output_dim`).
"""
classified = _ClassifiedWeights()
for n, f in enumerate(weights):
logger.debug(f"Processing weight #{n}: {f}")
if isinstance(f, str):
# predefined metric/Jacobian weight
f_call, kind = self._string_weight_callable(f)
if kind == "matrix":
classified.matrices.append(f_call)
else:
classified.scalars.append(f_call)
elif isinstance(f, list):
# constant 3x3 matrix given as nested list (copied, such that later changes to the list have no effect)
if len(f) != 3 or any(not isinstance(row, list) or len(row) != 3 for row in f):
raise ValueError(f"Nested list weight must be of shape 3x3, got {f}.")
classified.matrices.append(xp.array(f, dtype=float))
elif isinstance(f, SplineFunction):
# evaluated during assembly in WeightedMassOperator
classified.spline_functions[f.name] = f
elif callable(f):
# general callable (e.g. a function or a LocalRotationMatrix)
out_dim = self._callable_output_dim(f)
if out_dim == 3:
classified.scalars.append(f)
elif out_dim == 4:
# a column vector maps a scalar space to a vector space, a row vector vice versa
if classified.col_vector is not None or classified.row_vector is not None:
raise ValueError(f"At most one vector-valued weight is allowed, got a second one {f}.")
if V_id in _SCALAR_SPACES and W_id in _VECTOR_SPACES:
classified.col_vector = f
elif V_id in _VECTOR_SPACES and W_id in _SCALAR_SPACES:
classified.row_vector = f
else:
raise ValueError(
f"Vector weight {f} is only supported for scalar<->vector maps; got {V_id}->{W_id}."
)
elif out_dim == 5:
classified.matrices.append(f)
else:
raise ValueError(f"Callable {f} has wrong output dimension {out_dim}.")
else:
raise TypeError(f"Unsupported weight {f} of type {type(f)}.")
return classified
def _string_weight_callable(self, key: str):
"""Return the weight and its kind (``'matrix'`` or ``'scalar'``) for a predefined string weight.
Matrix callables return arrays of shape (n1, n2, n3, 3, 3), scalar callables of shape (n1, n2, n3).
``'Identity'`` is returned as constant matrix of shape (3, 3).
"""
if key == "G":
return lambda e1, e2, e3: self.domain.metric(e1, e2, e3, change_out_order=True), "matrix"
elif key == "Ginv":
return lambda e1, e2, e3: self.domain.metric_inv(e1, e2, e3, change_out_order=True), "matrix"
elif key == "DFinv":
return lambda e1, e2, e3: self.domain.jacobian_inv(e1, e2, e3, change_out_order=True), "matrix"
elif key == "DFinvT":
return (
lambda e1, e2, e3: self.domain.jacobian_inv(e1, e2, e3, change_out_order=True, transposed=True),
"matrix",
)
elif key == "Identity":
return xp.eye(3), "matrix"
elif key == "sqrt_g":
return lambda e1, e2, e3: abs(self.domain.jacobian_det(e1, e2, e3)), "scalar"
elif key == "1/sqrt_g":
return lambda e1, e2, e3: 1.0 / abs(self.domain.jacobian_det(e1, e2, e3)), "scalar"
else:
raise NotImplementedError(f"The option {key} is not available.")
@staticmethod
def _callable_output_dim(f: Callable) -> int:
"""Number of dimensions of the output of ``f`` on a small 3d test grid.
3 means scalar-valued, 4 vector-valued and 5 matrix-valued.
"""
xx, yy, zz = xp.meshgrid(
xp.linspace(0, 1, 1),
xp.linspace(0, 1, 2),
xp.linspace(0, 1, 3),
indexing="ij",
)
return f(xx, yy, zz).ndim
@staticmethod
def _check_weight_compatibility(classified: "_ClassifiedWeights", V_id: str, W_id: str):
"""Check that the kinds of the weight factors fit to the domain ``V_id`` and the codomain ``W_id``."""
if classified.col_vector is not None and not (
V_id in _SCALAR_SPACES and W_id in _VECTOR_SPACES and len(classified.matrices) == 0
):
raise ValueError("Column vector weight requires a scalar->vector map without matrix factors.")
if classified.row_vector is not None and not (
V_id in _VECTOR_SPACES and W_id in _SCALAR_SPACES and len(classified.matrices) == 0
):
raise ValueError("Row vector weight requires a vector->scalar map without matrix factors.")
if len(classified.matrices) > 0 and not (V_id in _VECTOR_SPACES and W_id in _VECTOR_SPACES):
raise ValueError(f"Matrix weights require a vector->vector map, got {V_id}->{W_id}.")
@staticmethod
def _eval_matrix_product(matrices: list, E1, E2, E3):
"""Matrix product (left to right) of the matrix factors at the points (E1, E2, E3) of a sparse meshgrid.
Constant factors of shape (3, 3) are multiplied as such, without being expanded to the grid.
Returns an array of shape (n1, n2, n3, 3, 3), or of shape (3, 3) if all factors are constant.
"""
out = None
for f in matrices:
factor = f if isinstance(f, xp.ndarray) else f(E1, E2, E3)
# batched product over the grid; the 3x3 part is in the last two axes
out = factor if out is None else out @ factor
return out
@staticmethod
def _eval_factors(classified: "_ClassifiedWeights", grids_1d: list):
"""Evaluate the matrix/vector factor and the product of the scalar factors on the grid ``grids_1d``.
Returns
-------
mv : xp.ndarray | None
Matrix product of shape (n1, n2, n3, 3, 3) or (3, 3) (if constant), vector of shape
(n1, n2, n3, 3), or None if there is neither a matrix nor a vector factor.
scalar : xp.ndarray | None
Product of the scalar factors, of shape (n1, n2, n3), or None if there are none.
"""
E1, E2, E3, _ = Domain.prepare_eval_pts(*grids_1d)
if classified.matrices:
mv = WeightedMassOperators._eval_matrix_product(classified.matrices, E1, E2, E3)
_log_weight_stats("matrix weight", mv)
elif classified.col_vector is not None:
mv = classified.col_vector(E1, E2, E3)
_log_weight_stats("column vector weight", mv)
elif classified.row_vector is not None:
mv = classified.row_vector(E1, E2, E3)
_log_weight_stats("row vector weight", mv)
else:
mv = None
scalar = None
for f in classified.scalars:
val = f(E1, E2, E3)
scalar = val if scalar is None else scalar * val
if scalar is not None:
_log_weight_stats("scalar weight", scalar)
return mv, scalar
@staticmethod
def _extract_row(
classified: "_ClassifiedWeights",
mv,
scalar,
m: int,
n_cols: int,
grid_shape: tuple,
copy_scalar: bool = False,
) -> list:
"""Blocks of row ``m`` of the weights, from the evaluated factors (see :meth:`_eval_factors`).
Returns a list of ``n_cols`` new C-contiguous arrays of shape ``grid_shape``; ``None`` marks a zero block.
If ``copy_scalar`` is True, a purely scalar block is a copy of ``scalar`` instead of ``scalar`` itself.
"""
row = []
for n in range(n_cols):
# entry (m, n) of the matrix or vector factor; None if there is no such factor
if classified.matrices:
if mv.ndim == 2:
# constant matrix: exactly zero entries give zero blocks
if mv[m, n] == 0.0:
row.append(None)
continue
factor = float(mv[m, n])
else:
factor = mv[:, :, :, m, n]
elif classified.col_vector is not None:
factor = mv[:, :, :, m]
elif classified.row_vector is not None:
factor = mv[:, :, :, n]
else:
factor = None
if factor is None:
# purely scalar weight: only the diagonal blocks are non-zero
if scalar is not None and m == n:
row.append(scalar.copy() if copy_scalar else scalar)
else:
row.append(None)
elif scalar is None:
row.append(xp.ascontiguousarray(xp.broadcast_to(factor, grid_shape), dtype=float))
else:
row.append(xp.ascontiguousarray(factor * scalar))
return row
#######################################
# Aux classes (to be removed in TODO) #
#######################################
[docs]
class H1vecMassMatrix_density:
"""Wrapper around a Weighted mass operator from H1vec to H1vec whose weights are given by a 3 form"""
def __init__(self, derham, mass_ops, domain):
self._massop = mass_ops.create_weighted_mass("H1vec", "H1vec")
self.field = derham.create_spline_function("field", "L2")
integration_grid = [grid_1d.flatten() for grid_1d in derham.V0splines.quad_grid_pts[0]]
self.integration_grid_spans, self.integration_grid_bn, self.integration_grid_bd = (
derham.prepare_eval_tp_fixed(
integration_grid,
)
)
grid_shape = tuple([len(loc_grid) for loc_grid in integration_grid])
self._f_values = xp.zeros(grid_shape, dtype=float)
metric = domain.metric(*integration_grid)
self._mass_metric_term = deepcopy(metric)
self._full_term_mass = deepcopy(metric)
self.domain_symbolic_name = self.massop.domain_symbolic_name
@property
def massop(
self,
):
"""The WeightedMassOperator"""
return self._massop
@property
def inv(
self,
):
"""The inverse WeightedMassOperator"""
if not hasattr(self, "_inv"):
self._create_inv()
return self._inv
[docs]
def update_weight(self, coeffs):
"""Update the weighted mass matrix operator"""
self.field.vector = coeffs
f_values = self.field.eval_tp_fixed_loc(
self.integration_grid_spans,
self.integration_grid_bd,
out=self._f_values,
)
for i in range(3):
for j in range(3):
self._full_term_mass[i, j] = f_values * self._mass_metric_term[i, j]
self._massop.assemble(
[
[self._full_term_mass[0, 0], self._full_term_mass[0, 1], self._full_term_mass[0, 2]],
[
self._full_term_mass[1, 0],
self._full_term_mass[
1,
1,
],
self._full_term_mass[1, 2],
],
[self._full_term_mass[2, 0], self._full_term_mass[2, 1], self._full_term_mass[2, 2]],
],
)
if hasattr(self, "_inv") and self.inv._options["pc"] is not None:
self.inv._options["pc"].update_mass_operator(self.massop)
def _create_inv(self, type="pcg", tol=1e-16, maxiter=500):
"""Inverse the weighted mass matrix, preconditioner must be set outside
via self._inv._options['pc'] = ..."""
self._inv = inverse(
self.massop,
type,
pc=None,
tol=tol,
maxiter=maxiter,
recycle=True,
)
def _zero_weight(*etas):
"""Zero default weight of an allocated block of a :class:`WeightedMassOperator`; keeps the block
structure until actual weights are passed to :meth:`WeightedMassOperator.assemble`."""
return 0 * etas[0]
[docs]
class WeightedMassOperator(LinearOperator):
r"""
Class for assembling weighted mass matrices in 3d.
Weighted mass matrices :math:`\mathbb M^{\beta\alpha}: \mathbb R^{N_\alpha} \to \mathbb R^{N_\beta}`
are of the general form
.. math::
\mathbb M^{\beta \alpha}_{(\mu,ijk),(\nu,mno)} = \int_{[0, 1]^3} \Lambda^\beta_{\mu,ijk} \, A_{\mu,\nu} \, \Lambda^\alpha_{\nu,mno} \, \textnormal d^3 \boldsymbol\eta\,,
where the weight fuction :math:`A` is a tensor of rank 0, 1 or 2,
depending on domain and co-domain of the operator,
and :math:`\Lambda^\alpha_{\nu, mno}` is the B-spline basis function
with tensor-product index :math:`mno` of the
:math:`\nu`-th component in the space :math:`V^\alpha_h`.
These matrices are sparse and stored in StencilMatrix format.
Finally, :math:`\mathbb M^{\beta\alpha}` can be multiplied by
:class:`~struphy.polar.linear_operators.PolarExtractionOperator`
and :class:`~struphy.feec.linear_operators.BoundaryOperator`,
:math:`\mathbb B\, \mathbb E\, \mathbb M^{\beta\alpha} \mathbb E^T \mathbb B^T`,
to account for :ref:`polar_splines` and/or :ref:`feec_bcs`, respectively.
Parameters
----------
derham : Derham
Struphy Derham object.
V : TensorFemSpace | VectorFemSpace
Tensor product spline space from feectools.fem.tensor (domain, input space).
W : TensorFemSpace | VectorFemSpace
Tensor product spline space from feectools.fem.tensor (codomain, output space).
name : str
Name of the operator.
V_extraction_op : PolarExtractionOperator, optional
Extraction operator to polar sub-space of V.
W_extraction_op : PolarExtractionOperator, optional
Extraction operator to polar sub-space of W.
V_boundary_op : BoundaryOperator, optional
Boundary operator that sets essential boundary conditions.
W_boundary_op : BoundaryOperator, optional
Boundary operator that sets essential boundary conditions.
weights_info : NoneType | str | list
Information about the weights/block structure of the operator.
Three cases are possible:
1. ``None`` : all blocks are allocated, disregarding zero-blocks or any symmetry.
2. ``str`` : for square block matrices (V=W), a symmetry can be set in order to accelerate the assembly process. Possible strings are ``symm`` (symmetric), ``asym`` (anti-symmetric) and ``diag`` (diagonal).
3. ``list`` : 2d list with the same number of rows/columns as the number of components of the domain/codomain spaces. The entries can be either a) callables or b) xp.ndarrays representing the weights at the quadrature points. If an entry is zero or ``None``, the corresponding block is set to ``None`` to accelerate the dot product.
spline_functions : dict[str, SplineFunction]
Dictionary of spline functions that are used as weights in the operator.
The keys must be the names of the spline functions and the values the SplineFunction objects.
transposed : bool
Whether to assemble the transposed operator.
matrix_free : bool
If set to true will not compute the matrix associated with the operator but directly compute the product when called
dry_run : bool
If True, the (potentially large) stencil matrices of the operator are not allocated;
only their sizes are computed, see :attr:`nbytes`. The block structure (which blocks are
non-zero) is determined in exactly the same way as for a regular operator, but the operator
can neither be assembled nor applied. Used to estimate the memory footprint of the FEEC
matrices before allocating them, see
:meth:`~struphy.feec.mass.WeightedMassOperators.estimate_mem`.
nquads : tuple | list, optional
Number of quadrature points per direction; defaults to ``derham.nquads``.
"""
def __init__(
self,
derham: Derham,
V: TensorFemSpace | VectorFemSpace,
W: TensorFemSpace | VectorFemSpace,
name: str = None,
V_extraction_op: PolarExtractionOperator | IdentityOperator = None,
W_extraction_op: PolarExtractionOperator | IdentityOperator = None,
V_boundary_op: BoundaryOperator | IdentityOperator = None,
W_boundary_op: BoundaryOperator | IdentityOperator = None,
weights_info: str | list = None,
spline_functions: dict[str, SplineFunction] | None = None,
transposed: bool = False,
matrix_free: bool = False,
nquads: tuple | list = None,
dry_run: bool = False,
):
logger.debug(
f"__init__: {name = }, {transposed = }, {matrix_free = }, {nquads = }, {dry_run = }, "
f"spline_functions={list(spline_functions or {})}"
)
if dry_run and transposed:
raise ValueError("dry_run=True is not supported for transposed operators.")
# only for M1 Mac users
PSYDAC_BACKEND_GPYCCEL["flags"] = "-O3 -march=native -mtune=native -ffast-math -ffree-line-length-none"
self._derham = derham
self._nquads = nquads
self._V = V
self._W = W
self._name = name
self._weights_info = weights_info
self._transposed = transposed
self._matrix_free = matrix_free
self._dry_run = dry_run
self._dtype = V.coeff_space.dtype
# number of calls of assemble(), lets dependent objects (e.g. preconditioners) detect new data
self._assembly_count = 0
# recipe for re-creating the operator with WeightedMassOperators.create_weighted_mass, see to_dict()
self._creation_info: dict | None = None
# spline functions that are used as weights in the operator, to be evaluated at quadrature points
self._spline_functions = spline_functions if spline_functions is not None else {}
self._init_projection_ops(V_extraction_op, W_extraction_op, V_boundary_op, W_boundary_op)
self._init_domain_codomain_spaces()
self._prepare_spline_weights()
# allocate the (block) matrix M (V -> W) and set its weights (zero blocks are None)
if isinstance(weights_info, str):
self._symmetry = weights_info
blocks, self._weights = self._init_blocks_from_symmetry(weights_info)
else:
self._symmetry = None
blocks, self._weights = self._init_blocks_from_weights(weights_info)
self._mat = self._wrap_blocks(blocks)
# the weights are stored in the block order of the (possibly transposed) operator
if transposed:
self._mat = self._mat.transpose()
self._weights = self._transpose_weights(self._weights)
self._init_domain_codomain()
if self._dry_run:
# memory estimation only (see the nbytes property): skip the .dot() temporaries
# and the assembly kernel; none of them is needed for sizing and both would allocate memory.
return
self._allocate_dot_temporaries()
if not self._matrix_free:
self._load_assembly_kernel()
# ------------------------------------------------------------------
# Helpers for __init__ (and assemble/transpose)
# ------------------------------------------------------------------
@staticmethod
def _component_spaces(space: TensorFemSpace | VectorFemSpace) -> tuple[TensorFemSpace, ...]:
"""Scalar component spaces of ``space``, i.e. ``(space,)`` for a TensorFemSpace
and ``space.spaces`` for a VectorFemSpace."""
if isinstance(space, TensorFemSpace):
return (space,)
return space.spaces
@staticmethod
def _transpose_weights(weights: list) -> list:
"""Transpose a 2d list of block weights (``None`` entries are kept)."""
return [[weights[n][m] for n in range(len(weights))] for m in range(len(weights[0]))]
@staticmethod
def _evaluate_weight(weight, pts: list, copy: bool = False):
"""Evaluate a block weight at the quadrature points.
Parameters
----------
weight : callable | xp.ndarray | None
Weight function of the logical coordinates, or its values at the quadrature points.
pts : list[xp.ndarray]
1d arrays of the quadrature points in each direction.
copy : bool
Whether to return a copy of an xp.ndarray weight (callables are always evaluated into a new array).
Returns
-------
mat_w : xp.ndarray | None
The weight on the tensor-product quadrature grid, or ``None`` if ``weight`` is ``None``.
"""
if weight is None:
return None
if callable(weight):
PTS = xp.meshgrid(*pts, indexing="ij")
mat_w = weight(*PTS).copy()
elif isinstance(weight, xp.ndarray):
mat_w = weight.copy() if copy else weight
else:
raise TypeError(f"Weights must be callable, xp.ndarray or None, but are {type(weight)}.")
grid_shape = tuple(pt.size for pt in pts)
if mat_w.shape != grid_shape:
raise ValueError(f"Weight has shape {mat_w.shape}, but the quadrature grid is {grid_shape}.")
return mat_w
def _quad_pts(self, space_name: str, component: int) -> list:
"""1d arrays of the (local) quadrature points of the given component of the space ``space_name``."""
return [points.flatten() for points in self.derham.spline_attributes[space_name].quad_grid_pts[component]]
def _is_globally_nonzero(self, local_nonzero: bool) -> bool:
"""Logical OR of ``local_nonzero`` over all MPI ranks.
This is a collective call: it must be reached on all ranks, such that all ranks take
the same decision on whether a block is allocated/assembled."""
flag = xp.array(bool(local_nonzero), dtype=bool)
if self.derham.comm is not None:
self.derham.comm.Allreduce(MPI.IN_PLACE, flag, op=MPI.LOR)
return bool(flag)
def _init_projection_ops(self, V_extraction_op, W_extraction_op, V_boundary_op, W_boundary_op):
"""Set the polar extraction operators :math:`\\mathbb E_V, \\mathbb E_W` and the boundary
operators :math:`\\mathbb B_V, \\mathbb B_W` (identities if not given), and their transposes."""
# basis extraction operators
if V_extraction_op is not None:
if not V_extraction_op.domain == self._V.coeff_space:
raise ValueError("The domain of V_extraction_op must be the coefficient space of V.")
self._V_extraction_op = V_extraction_op
else:
self._V_extraction_op = IdentityOperator(self._V.coeff_space)
if W_extraction_op is not None:
if not W_extraction_op.domain == self._W.coeff_space:
raise ValueError("The domain of W_extraction_op must be the coefficient space of W.")
self._W_extraction_op = W_extraction_op
else:
self._W_extraction_op = IdentityOperator(self._W.coeff_space)
# boundary operators (act on the codomain of the extraction operators)
if V_boundary_op is not None:
if not V_boundary_op.domain == self._V_extraction_op.codomain:
raise ValueError("The domain of V_boundary_op must be the codomain of the V extraction operator.")
self._V_boundary_op = V_boundary_op
else:
self._V_boundary_op = IdentityOperator(self._V_extraction_op.codomain)
if W_boundary_op is not None:
if not W_boundary_op.domain == self._W_extraction_op.codomain:
raise ValueError("The domain of W_boundary_op must be the codomain of the W extraction operator.")
self._W_boundary_op = W_boundary_op
else:
self._W_boundary_op = IdentityOperator(self._W_extraction_op.codomain)
self._V_extraction_op_T = self._V_extraction_op.T
self._W_extraction_op_T = self._W_extraction_op.T
self._V_boundary_op_T = self._V_boundary_op.T
self._W_boundary_op_T = self._W_boundary_op.T
def _init_domain_codomain_spaces(self):
"""Set the domain/codomain FEM spaces and their symbolic names (V and W swapped for transposed
operators), and whether both spaces are scalar (then ``_mat`` is a single block)."""
V_name = self._V.symbolic_space
W_name = self._W.symbolic_space
logger.debug(f"{V_name = }, {W_name = }")
for space_name in (V_name, W_name):
if space_name not in self.derham.spline_attributes:
raise ValueError(f"Spline attributes for the space {space_name} not found in the Derham object.")
if self._transposed:
self._domain_femspace, self._domain_symbolic_name = self._W, W_name
self._codomain_femspace, self._codomain_symbolic_name = self._V, V_name
else:
self._domain_femspace, self._domain_symbolic_name = self._V, V_name
self._codomain_femspace, self._codomain_symbolic_name = self._W, W_name
self._is_scalar = isinstance(self._V, TensorFemSpace) and isinstance(self._W, TensorFemSpace)
def _new_block(self, vspace: TensorFemSpace, wspace: TensorFemSpace, weights=None):
"""Allocate a single (non-assembled) block mapping the scalar space ``vspace`` to ``wspace``.
Returns a :class:`StencilMatrixFreeMassOperator` with the given ``weights`` for matrix-free
operators, and a StencilMatrix otherwise (then ``weights`` is ignored; they are passed
to the assembly kernel in :meth:`assemble`)."""
if self._matrix_free:
return StencilMatrixFreeMassOperator(self.derham, vspace, wspace, weights=weights, nquads=self.nquads)
return StencilMatrix(
vspace.coeff_space,
wspace.coeff_space,
backend=PSYDAC_BACKEND_GPYCCEL,
precompiled=True,
dry_run=self._dry_run,
)
def _init_blocks_from_symmetry(self, symmetry: str) -> tuple[list, list]:
"""Allocate the blocks of a square block operator (V = W) according to a given symmetry.
Only the blocks allowed by the symmetry (all for ``symm``, off-diagonal for ``asym``, diagonal
for ``diag``) are allocated, the others are ``None``. Allocated blocks get a zero default weight,
the actual weights are passed later to :meth:`assemble`.
Returns
-------
blocks, weights : list, list
2d lists of the blocks and their weights, in the block order of the non-transposed operator.
"""
V_name = self._V.symbolic_space
W_name = self._W.symbolic_space
if V_name != W_name:
raise ValueError(
f"A symmetry can only be given for square operators (V=W), but {V_name = } and {W_name = }."
)
if not isinstance(self._V, VectorFemSpace):
raise ValueError(
f"A symmetry can only be given for vector-valued spaces (Hcurl, Hdiv, H1vec), but {V_name = }."
)
# which blocks (i, j) are allocated for the given symmetry
block_masks = {
"symm": lambda i, j: True,
"asym": lambda i, j: i != j,
"diag": lambda i, j: i == j,
}
if symmetry not in block_masks:
raise NotImplementedError(f"given symmetry {symmetry} is not implemented!")
is_allocated = block_masks[symmetry]
blocks = [
[self._new_block(Vs, Ws) if is_allocated(i, j) else None for j, Vs in enumerate(self._V.spaces)]
for i, Ws in enumerate(self._W.spaces)
]
weights = [[_zero_weight if block is not None else None for block in row] for row in blocks]
return blocks, weights
def _init_blocks_from_weights(self, weights_info: list | None) -> tuple[list, list]:
"""Allocate the blocks according to the given weights.
For ``weights_info=None`` all blocks are allocated with zero default weights. Otherwise a block
is allocated only if its weight is non-zero on at least one MPI rank; globally zero blocks are
set to ``None`` to accelerate the dot product.
Returns
-------
blocks, weights : list, list
2d lists of the blocks and their weights, in the block order of the non-transposed operator.
"""
W_name = self._W.symbolic_space
blocks = []
weights = []
# loop over codomain spaces (rows)
for a, wspace in enumerate(self._component_spaces(self._W)):
blocks += [[]]
weights += [[]]
pts = self._quad_pts(W_name, a)
# loop over domain spaces (columns)
for b, vspace in enumerate(self._component_spaces(self._V)):
if weights_info is None:
blocks[-1] += [self._new_block(vspace, wspace)]
weights[-1] += [_zero_weight]
continue
# A block can be locally zero on this MPI rank but non-zero on another rank.
# We therefore check whether the block is globally non-zero before deciding
# whether to allocate it. All ranks must make the same block-allocation decision,
# otherwise exchange_assembly_data() will communicate incompatible block structures.
loc_weight = weights_info[a][b]
mat_w = self._evaluate_weight(loc_weight, pts)
local_nonzero = mat_w is not None and bool(xp.any(xp.abs(mat_w) > 1e-14))
if self._is_globally_nonzero(local_nonzero):
if loc_weight is None:
# The block is globally non-zero, but this rank has a locally zero weight.
# We still allocate the block and use a zero local weight array, such that
# the local matrix has the same structure as on the other MPI ranks.
loc_weight = xp.zeros(tuple(pt.size for pt in pts), dtype=float)
blocks[-1] += [self._new_block(vspace, wspace, weights=loc_weight)]
weights[-1] += [loc_weight]
else:
blocks[-1] += [None]
weights[-1] += [None]
return blocks, weights
def _wrap_blocks(self, blocks: list):
"""Return the single block of a 1x1 operator, or a BlockLinearOperator (V -> W) otherwise.
A zero 1x1 block is still allocated, such that the matrix is never ``None``."""
if len(blocks) == len(blocks[0]) == 1:
if blocks[0][0] is None:
return self._new_block(self._component_spaces(self._V)[0], self._component_spaces(self._W)[0])
return blocks[0][0]
return BlockLinearOperator(self._V.coeff_space, self._W.coeff_space, blocks=blocks)
def _prepare_spline_weights(self):
"""Prepare the evaluation of :attr:`spline_functions` at the quadrature points: knot spans,
basis function values and output buffers, keyed by spline name. In :meth:`assemble`,
the block weights are multiplied by the evaluated spline functions.
The (local) quadrature grid is the same for all components of all spaces, since it is
determined by the elements of the domain decomposition (this is also used in
:mod:`struphy.feec.preconditioner`). Hence the spline functions are evaluated once,
on the grid of the first codomain component, and used for all blocks."""
pts = self._quad_pts(self._codomain_symbolic_name, 0)
grid_shape = tuple(len(pt) for pt in pts)
self._spline_values = {}
self._spline_spans = {}
self._spline_bases = {}
for name, spline in self.spline_functions.items():
if not isinstance(spline, SplineFunction):
raise TypeError(f"The entry {name} in spline_functions must be a SplineFunction object.")
self._spline_values[name] = xp.zeros(grid_shape, dtype=float)
self._spline_spans[name], bns, bds = self.derham.prepare_eval_tp_fixed(pts)
if spline.space_id == "H1":
self._spline_bases[name] = bns
elif spline.space_id == "L2":
self._spline_bases[name] = bds
else:
raise NotImplementedError(
f"Spline functions in spline_functions must be defined on H1 or L2 spaces, but {spline.space_id} was given for the spline function {name}.",
)
def _init_domain_codomain(self):
"""Set domain and codomain of the operator, i.e. the codomains of the extraction operators
of V and W (swapped for transposed operators). These are the domain and codomain of :attr:`M`."""
if self._transposed:
self._domain = self._W_extraction_op.codomain
self._codomain = self._V_extraction_op.codomain
else:
self._domain = self._V_extraction_op.codomain
self._codomain = self._W_extraction_op.codomain
def _allocate_dot_temporaries(self):
"""Allocate the intermediate vectors used in :meth:`dot`."""
self._temp_WB = self._W_boundary_op.domain.zeros()
self._temp_WE = self._W_extraction_op.domain.zeros()
self._temp_VB = self._V_boundary_op.domain.zeros()
self._temp_VE = self._V_extraction_op.domain.zeros()
self._temp_mat = self._mat.domain.zeros()
def _load_assembly_kernel(self):
"""Load the assembly kernel for the dimension of the problem (1d, 2d or 3d)."""
self._assembly_kernel = _MAT_KERNELS[self._V.ldim]
@property
def derham(self):
return self._derham
@property
def domain(self):
return self._domain
@property
def domain_symbolic_name(self) -> LiteralOptions.OptsFEECSpace:
return self._domain_symbolic_name
@property
def codomain(self):
return self._codomain
@property
def codomain_symbolic_name(self) -> LiteralOptions.OptsFEECSpace:
return self._codomain_symbolic_name
@property
def name(self):
return self._name
@property
def domain_femspace(self):
return self._domain_femspace
@property
def codomain_femspace(self):
return self._codomain_femspace
@property
def spline_functions(self):
return self._spline_functions
@property
def dry_run(self) -> bool:
"""Whether the operator was created for memory estimation only, i.e. without allocating
its stencil matrices (in which case it can neither be assembled nor applied)."""
return self._dry_run
@property
def nbytes(self) -> int:
"""Local (per-MPI-rank) memory footprint of the stencil matrices of this operator, in bytes.
Also available for operators created with ``dry_run=True``, i.e. before/without allocation.
Matrix-free operators do not store a matrix and return 0."""
return int(getattr(self._mat, "nbytes", 0))
@property
def dtype(self):
return self._dtype
def _check_identity_extraction(self, method: str):
"""Raise if the operator has non-trivial (polar) extraction operators, which
:meth:`to_sparse_mat_only` and :meth:`to_array_mat_only` do not support."""
if not all(isinstance(op, IdentityOperator) for op in (self._W_extraction_op, self._V_extraction_op)):
raise NotImplementedError(f".{method}() is not implemented for polar extraction operators.")
[docs]
def to_sparse_mat_only(self):
"""Sparse matrix of the (block) stencil matrix, without extraction and boundary operators.
In contrast to :meth:`tosparse` (inherited from LinearOperator, computed column by column
with :meth:`dot`), this is cheap but does not account for boundary conditions."""
self._check_identity_extraction("to_sparse_mat_only")
return self._mat.tosparse()
[docs]
def to_array_mat_only(self):
"""Dense array of the (block) stencil matrix, without extraction and boundary operators.
In contrast to :meth:`toarray` (inherited from LinearOperator, computed column by column
with :meth:`dot`), this is cheap but does not account for boundary conditions."""
self._check_identity_extraction("to_array_mat_only")
return self._mat.toarray()
@property
def M(self):
"""Composite operator :math:`\\mathbb E_W \\mathbb M \\mathbb E_V^T` (V and W swapped if transposed),
built on first access (allocates temporaries). Note that :meth:`dot` does not use it."""
if not hasattr(self, "_M"):
if self._transposed:
self._M = self._V_extraction_op @ self._mat @ self._W_extraction_op_T
else:
self._M = self._W_extraction_op @ self._mat @ self._V_extraction_op_T
return self._M
@property
def M0(self):
"""Composite operator :math:`\\mathbb B_W \\mathbb E_W \\mathbb M \\mathbb E_V^T \\mathbb B_V^T`
(V and W swapped if transposed), built on first access (allocates temporaries)."""
if not hasattr(self, "_M0"):
if self._transposed:
self._M0 = self._V_boundary_op @ self.M @ self._W_boundary_op_T
else:
self._M0 = self._W_boundary_op @ self.M @ self._V_boundary_op_T
return self._M0
@property
def matrix(self):
return self._mat
@property
def nquads(self):
if self._nquads is None:
return self.derham.nquads
else:
return self._nquads
@property
def symmetry(self):
return self._symmetry
@property
def weights(self):
return self._weights
[docs]
def dot(self, v, out=None, apply_bc=True):
"""Dot product of the operator with a vector.
Parameters
----------
v : feectools.linalg.basic.Vector
The input (domain) vector.
out : feectools.linalg.basic.Vector, optional
If given, the output will be written in-place into this vector.
apply_bc : bool
Whether to apply the boundary operators (True) or not (False).
Returns
-------
out : feectools.linalg.basic.Vector
The output (codomain) vector.
"""
assert isinstance(v, Vector)
assert v.space == self.domain
# newly created output vector
if out is None:
out = self.codomain.zeros()
else:
assert isinstance(out, Vector)
assert out.space == self.codomain
if apply_bc:
if self._transposed:
self._W_boundary_op_T.dot(v, out=self._temp_WB)
self._W_extraction_op_T.dot(self._temp_WB, out=self._temp_mat)
self._mat.dot(self._temp_mat, out=self._temp_VE)
self._V_extraction_op.dot(self._temp_VE, out=self._temp_VB)
out = self._V_boundary_op.dot(self._temp_VB, out=out)
else:
self._V_boundary_op_T.dot(v, out=self._temp_VB)
self._V_extraction_op_T.dot(self._temp_VB, out=self._temp_mat)
self._mat.dot(self._temp_mat, out=self._temp_WE)
self._W_extraction_op.dot(self._temp_WE, out=self._temp_WB)
out = self._W_boundary_op.dot(self._temp_WB, out=out)
else:
if self._transposed:
self._W_extraction_op_T.dot(v, out=self._temp_mat)
self._mat.dot(self._temp_mat, out=self._temp_VE)
out = self._V_extraction_op.dot(self._temp_VE, out=out)
else:
self._V_extraction_op_T.dot(v, out=self._temp_mat)
self._mat.dot(self._temp_mat, out=self._temp_WE)
out = self._W_extraction_op.dot(self._temp_WE, out=out)
return out
[docs]
def transpose(self, conjugate=False):
"""
Returns the transposed operator.
"""
# bring weights back in "right" (not transposed order)
if self._transposed:
weights = self._transpose_weights(self._weights)
else:
weights = self._weights
name = self.name + "T" if self.name is not None else None
M = WeightedMassOperator(
self.derham,
self._V,
self._W,
name=name,
V_extraction_op=self._V_extraction_op,
W_extraction_op=self._W_extraction_op,
V_boundary_op=self._V_boundary_op,
W_boundary_op=self._W_boundary_op,
weights_info=weights if self._symmetry is None else self._symmetry,
spline_functions=self._spline_functions,
transposed=not self._transposed,
matrix_free=self._matrix_free,
nquads=self._nquads,
)
# weights of M in its own (transposed) block order
M._weights = self._transpose_weights(self._weights)
if self._creation_info is not None:
M._creation_info = dict(self._creation_info, is_transpose=not self._creation_info["is_transpose"])
if self._matrix_free:
if self._symmetry is not None:
M.assemble(weights=M._weights)
else:
# transpose the assembled data instead of re-assembling from the weights: the data need not
# stem from self._weights (e.g. accumulation matrices, which are filled by the particles)
self._mat.transpose(out=M._mat)
# remove blocks of M that are zero in self
if isinstance(self._mat, BlockLinearOperator):
for a, b in M._mat.nonzero_block_indices:
if self._mat[b, a] is None:
M._mat[a, b] = None
return M
[docs]
def assemble(self, weights=None, clear=True):
r"""
Assembles the weighted mass matrix, i.e. computes the integrals
.. math::
\mathbb M^{\beta \alpha}_{(\mu,ijk),(\nu,mno)} = \int_{[0, 1]^3} \Lambda^\beta_{\mu,ijk} \, A_{\mu,\nu} \, \Lambda^\alpha_{\nu,mno} \, \textnormal d^3 \boldsymbol\eta\,.
The integration is performed with Gauss-Legendre quadrature over the logical domain.
Parameters
----------
weights : list | NoneType
Weight function(s) (callables or xp.ndarrays) in a 2d list of shape corresponding to
number of components of domain/codomain.
If ``weights=None``, the weight is taken from the given weights in the
instantiation of the object, else it will be overriden.
clear : bool
Whether to first set all data to zero before assembly. If False,
the new contributions are added to existing ones.
"""
assert not self._dry_run, (
"A dry-run operator has no matrix data and cannot be assembled (memory estimation only)."
)
if weights is not None or not clear:
# the data no longer stems from the creation recipe
self._creation_info = None
self._assembly_count += 1
if self._matrix_free:
if weights is not None:
if self._is_scalar:
self._mat.weights = weights[0][0]
else:
for a, weights_row in enumerate(weights):
for b, weight in enumerate(weights_row):
if weight is not None:
assert callable(weight) or isinstance(
weight,
xp.ndarray,
)
self._mat[a, b].weights = weight
self._weights = weights
else:
# clear data
if clear:
if isinstance(self._mat, StencilMatrix):
self._mat._data[:] = 0.0
else:
for block_row in self._mat.blocks:
for block in block_row:
if block is not None:
block._data[:] = 0.0
logger.debug(
f'\nAssembling matrix of WeightedMassOperator "{self.name}" with V={self._domain_symbolic_name}, W={self._codomain_symbolic_name}.',
)
# collect domain/codomain TensorFemSpaces for each component in tuple
domain_spaces = self._component_spaces(self._domain_femspace)
codomain_spaces = self._component_spaces(self._codomain_femspace)
# set new weights and check for compatibility
if weights is not None:
assert isinstance(weights, list)
self._weights = weights
V_name = self.domain_symbolic_name
W_name = self.codomain_symbolic_name
spline_attr = self.derham.spline_attributes
# loop over codomain spaces (rows)
for a, codomain_space in enumerate(codomain_spaces):
# knot span indices of elements of local domain
codomain_spans = spline_attr[W_name].quad_grid_spans[a]
# global start spline index on process
codomain_starts = [int(start) for start in codomain_space.coeff_space.starts]
# pads (ghost regions)
codomain_pads = codomain_space.coeff_space.pads
# quadrature points
pts = self._quad_pts(W_name, a)
# global quadrature weights in format (local element, local weight)
wts = spline_attr[W_name].quad_grid_wts[a]
# evaluated basis functions at quadrature points of codomain space
codomain_basis = spline_attr[W_name].quad_grid_bases[a]
# loop over domain spaces (columns)
for b, domain_space in enumerate(domain_spaces):
# skip None and redundant blocks (lower half for symmetric and anti-symmetric)
if not self._is_scalar:
if self._symmetry is not None and a > b:
continue
loc_weight = self._weights[a][b]
# evaluate weight at quadrature points (copy: spline factors below must not
# modify the stored geometric weight, assembly can be repeated as density changes)
mat_w = self._evaluate_weight(loc_weight, pts, copy=True)
if loc_weight is not None:
# evalute splines and multiply
for name, spline in self.spline_functions.items():
logger.debug(
f"Maximum coefficient of spline {name}: {xp.max(xp.abs(spline.vector.toarray()))}"
)
values = spline.eval_tp_fixed_loc(
self._spline_spans[name],
self._spline_bases[name],
out=self._spline_values[name],
)
if xp.all(xp.abs(values) < 1e-14):
logger.warning(
f"The spline weight {name} is close to zero at all quadrature points in the assembly of the weighted mass matrix {self.name}. Weights are not multiplied."
)
continue
mat_w *= values
else:
logger.debug(f"No weight for block {a, b}, setting mat_w to None.")
not_weight_zero = self._is_globally_nonzero(
loc_weight is not None and bool(xp.any(xp.abs(mat_w) > 1e-14)),
)
# evaluated basis functions at quadrature points of domain space
domain_basis = spline_attr[V_name].quad_grid_bases[b]
# assemble matrix (if mat_w is not zero) by calling the appropriate kernel (1d, 2d or 3d)
if not_weight_zero or self._is_scalar:
# get cell of block matrix (don't instantiate if all zeros)
if self._is_scalar:
mat = self._mat
if loc_weight is None:
# not_weight_zero is global after the MPI reduction. Hence this rank may
# enter the assembly branch even when its own local weight is None.
# In that case we assemble a zero local contribution, but we must still
# provide a correctly shaped array to the pyccel kernel.
mat_w = xp.zeros(
tuple([pt.size for pt in pts]),
)
else:
mat = self._mat[a, b]
# block case: after the MPI Allreduce, this block may be globally
# non-zero even if it is locally zero on this rank.
if mat_w is None:
mat_w = xp.zeros(tuple([pt.size for pt in pts]))
# This can happen for block matrices if the block was previously considered
# zero locally, but is now required because it is non-zero on at least one
# MPI rank. The block must exist on all ranks before assembly/exchange.
if mat is None:
# Maybe in a previous iteration we had more zeros
# Can only happen in the Block case
self._mat[a, b] = self._new_block(domain_space, codomain_space)
mat = self._mat[a, b]
logger.debug(f"Assemble block {a, b}")
self._assembly_kernel(
*codomain_spans,
*codomain_space.degree,
*domain_space.degree,
*codomain_starts,
*codomain_pads,
*wts,
*codomain_basis,
*domain_basis,
mat_w,
mat._data,
)
else:
if clear:
self._mat[a, b] = None
else:
continue
# exchange assembly data (accumulate ghost regions)
self._mat.exchange_assembly_data()
# copy data for symmetric/anti-symmetric block matrices
if self.symmetry == "symm":
self._mat.update_ghost_regions()
self._mat[1, 0]._data[:] = self._mat[0, 1].T._data
self._mat[2, 0]._data[:] = self._mat[0, 2].T._data
self._mat[2, 1]._data[:] = self._mat[1, 2].T._data
elif self.symmetry == "asym":
self._mat.update_ghost_regions()
self._mat[1, 0]._data[:] = -self._mat[0, 1].T._data
self._mat[2, 0]._data[:] = -self._mat[0, 2].T._data
self._mat[2, 1]._data[:] = -self._mat[1, 2].T._data
logger.debug("Done.")
@property
def assembly_count(self) -> int:
"""Number of calls of :meth:`assemble` (changes whenever the matrix data may have changed)."""
return self._assembly_count
@property
def is_reconstructible(self) -> bool:
"""Whether the operator can be re-created from :meth:`to_dict` (e.g. on another Derham).
True for operators created by :meth:`WeightedMassOperators.create_weighted_mass` (and their transposes)
whose data has not been modified afterwards (by ``assemble(weights=...)``, in-place arithmetic, ...).
"""
return self._creation_info is not None
[docs]
def to_dict(self) -> dict:
"""Recipe for re-creating the operator with :meth:`WeightedMassOperators.create_weighted_mass`.
The weights are stored as given at creation. The dictionary is JSON serializable if they are
strings (``'Ginv'``, ``'sqrt_g'``, ...) or nested lists of numbers; callables are kept as objects.
Re-create the operator (on any Derham) with :meth:`from_dict`.
"""
if self._creation_info is None:
raise ValueError(
f"WeightedMassOperator {self.name!r} cannot be serialized: it was not created by "
"WeightedMassOperators.create_weighted_mass or its data was modified afterwards."
)
params = dict(self._creation_info)
# tuple (1D product of weights) and 2D list (block weights) are different formats; store the
# tuple as a list (JSON) and record its type, since its entries may themselves be (3x3) lists
params["weights_is_tuple"] = isinstance(params["weights"], tuple)
if params["weights_is_tuple"]:
params["weights"] = list(params["weights"])
return {
"type": self.__class__.__name__,
"params": params,
}
[docs]
@classmethod
def from_dict(cls, dct: dict, mass_ops: "WeightedMassOperators") -> "WeightedMassOperator":
"""Re-create a :class:`WeightedMassOperator` from :meth:`to_dict` with the given collection.
Parameters
----------
dct : dict
Output of :meth:`to_dict`.
mass_ops : WeightedMassOperators
Collection providing the Derham, domain and matrix_free option of the new operator.
"""
assert dct["type"] == cls.__name__
params = dct["params"]
name = params["name"]
weights = params["weights"]
if params["weights_is_tuple"]:
weights = tuple(weights)
out = mass_ops.create_weighted_mass(
params["V_id"],
params["W_id"],
name=name,
weights=weights,
assemble=True,
transposed=params["transposed"],
)
return out.T if params["is_transpose"] else out
[docs]
def copy(self, out=None):
"""Create a copy of self, that can potentially be stored in a given WeightedMassOperator.
Parameters
----------
out : WeightedMassOperator(optional)
The existing WeightedMassOperator in which we want to copy self.
"""
if out is not None:
assert isinstance(out, WeightedMassOperator)
assert out.domain is self.domain
assert out.codomain is self.codomain
else:
out = WeightedMassOperator(
self.derham,
V=self._V,
W=self._W,
name=self.name,
V_extraction_op=self._V_extraction_op,
W_extraction_op=self._W_extraction_op,
V_boundary_op=self._V_boundary_op,
W_boundary_op=self._W_boundary_op,
weights_info=self._weights_info,
spline_functions=self._spline_functions,
transposed=self._transposed,
matrix_free=self._matrix_free,
nquads=self._nquads,
)
# current weights (they may have been changed by assemble(weights=...))
out._weights = [list(row) for row in self._weights]
self._mat.copy(out=out._mat)
if self._creation_info is None:
out._creation_info = None
else:
out._creation_info = dict(self._creation_info) # to create a separate dictionary
return out
def __imul__(self, a):
self._mat *= a
self._creation_info = None
return self
def __iadd__(self, M):
assert M.domain is self.domain
assert M.codomain is self.codomain
if isinstance(M, WeightedMassOperator):
self._creation_info = None
self._mat += M._mat
return self
elif isinstance(M, LinearOperator):
self._creation_info = None
self._mat += M
return self
else:
return LinearOperator.__add__(self, M)
def __isub__(self, M):
assert M.domain is self.domain
assert M.codomain is self.codomain
if isinstance(M, WeightedMassOperator):
self._creation_info = None
self._mat -= M._mat
return self
elif isinstance(M, LinearOperator):
self._creation_info = None
self._mat -= M
return self
else:
return LinearOperator.__sub__(self, M)
[docs]
def eval_quad(self, W, coeffs, out=None):
"""
Evaluates a given FEM field defined by its coefficients at the L2 quadrature points.
Parameters
----------
W : TensorFemSpace | VectorFemSpace
Tensor product spline space from feectools.fem.tensor.
coeffs : StencilVector | BlockVector
The coefficient vector corresponding to the FEM field. Ghost regions must be up-to-date!
out : xp.ndarray | list/tuple of xp.ndarrays, optional
If given, the result will be written into these arrays in-place. Number of outs must be compatible with number of components of FEM field.
Returns
-------
out : xp.ndarray | list/tuple of xp.ndarrays
The values of the FEM field at the quadrature points.
"""
assert isinstance(W, (TensorFemSpace, VectorFemSpace))
assert isinstance(coeffs, (StencilVector, BlockVector))
assert W.coeff_space == coeffs.space
# collect TensorFemSpaces for each component in tuple
if isinstance(W, TensorFemSpace):
Wspaces = (W,)
else:
Wspaces = W.spaces
# prepare output
if out is None:
out = ()
if isinstance(W, TensorFemSpace):
out += (
xp.zeros(
[
q_grid[nquad].points.size
for q_grid, nquad in zip(get_quad_grids(W, nquads=self.nquads), self.nquads)
],
dtype=float,
),
)
else:
for space in W.spaces:
out += (
xp.zeros(
[
q_grid[nquad].points.size
for q_grid, nquad in zip(
get_quad_grids(space, nquads=self.nquads),
self.nquads,
)
],
dtype=float,
),
)
else:
if isinstance(W, TensorFemSpace):
assert isinstance(out, xp.ndarray)
out = (out,)
else:
assert isinstance(out, (list, tuple))
# load assembly kernel
kernel = _EVAL_KERNELS[W.ldim]
# loop over components
for a, wspace in enumerate(Wspaces):
# knot span indices of elements of local domain
spans = [
quad_grid[nquad].spans
for quad_grid, nquad in zip(get_quad_grids(wspace, nquads=self.nquads), self.nquads)
]
# global start spline index on process
starts = [int(start) for start in wspace.coeff_space.starts]
# pads (ghost regions)
pads = wspace.coeff_space.pads
# global quadrature points (flattened) and weights in format (local element, local weight)
pts = [
quad_grid[nquad].points.flatten()
for quad_grid, nquad in zip(get_quad_grids(wspace, nquads=self.nquads), self.nquads)
]
wts = [
quad_grid[nquad].weights
for quad_grid, nquad in zip(get_quad_grids(wspace, nquads=self.nquads), self.nquads)
]
# evaluated basis functions at quadrature points of codomain space
basis = [
quad_grid[nquad].basis
for quad_grid, nquad in zip(get_quad_grids(wspace, nquads=self.nquads), self.nquads)
]
if isinstance(coeffs, StencilVector):
kernel(
*spans,
*wspace.degree,
*starts,
*pads,
*basis,
coeffs._data,
out[a],
)
else:
kernel(
*spans,
*wspace.degree,
*starts,
*pads,
*basis,
coeffs[a]._data,
out[a],
)
if len(out) == 1:
return out[0]
else:
return out
def info(self, use_rst=False):
return info(self, use_rst=use_rst)
[docs]
class StencilMatrixFreeMassOperator(LinearOperator):
r"""Class implementing matrix-free weighted mass operators between StencilVectorSpaces.
The result of the dot product with a spline function :math:`S_h` is computed as
.. math::
w^\mu_{ijk} = \int \Lambda_{\mu,ijk}\, S_h\, w(\boldsymbol\eta)\,\textrm d \boldsymbol \eta \,,
where :math:`w(\boldsymbol\eta)` is a weight function (including the geometric weights).
Should only be instanciated via `WeightedMassOperator`, where it's used to replace `StencilMatrix` when one does not want to assemble the matrix for cost reasons
Parameters
----------
V : TensorFemSpace
Domain of the mass operator
W : TensorFemSpace
Codomain of the mass operator
weights : callable | numpy.ndarry | None
The weights of the mass operator
"""
def __init__(self, derham, V, W, weights=None, nquads=None):
self._V = V
self._W = W
self._domain = V.coeff_space
self._codomain = W.coeff_space
self._weights = weights
self._derham = derham
self._nquads = nquads
self._dtype = V.coeff_space.dtype
self._dot_kernel = _MATRIXFREE_KERNELS[self._V.ldim]
self._diag_kernel = _DIAG_KERNELS[self._V.ldim]
# temporary with ghost regions for the diagonal (contributions to other processes are exchanged)
self._diag_tmp = W.coeff_space.zeros()
# knot span indices of elements of local domain
self._codomain_spans = [
quad_grid[nquad].spans for quad_grid, nquad in zip(get_quad_grids(self._W, nquads=self.nquads), self.nquads)
]
# global start spline index on process
self._codomain_starts = [int(start) for start in self._W.coeff_space.starts]
# pads (ghost regions)
self._codomain_pads = self._W.coeff_space.pads
# evaluated basis functions at quadrature points of codomain space
self._codomain_basis = [
quad_grid[nquad].basis for quad_grid, nquad in zip(get_quad_grids(self._W, nquads=self.nquads), self.nquads)
]
# knot span indices of elements of local domain
self._domain_spans = [
quad_grid[nquad].spans for quad_grid, nquad in zip(get_quad_grids(self._V, nquads=self.nquads), self.nquads)
]
# global start spline index on process
self._domain_starts = [int(start) for start in self._V.coeff_space.starts]
# pads (ghost regions)
self._domain_pads = self._V.coeff_space.pads
# evaluated basis functions at quadrature points of domain space
self._domain_basis = [
quad_grid[nquad].basis for quad_grid, nquad in zip(get_quad_grids(self._V, nquads=self.nquads), self.nquads)
]
# global quadrature points (flattened) and weights in format (local element, local weight)
self._pts = [
quad_grid[nquad].points.flatten()
for quad_grid, nquad in zip(get_quad_grids(self._W, nquads=self.nquads), self.nquads)
]
self._wts = [
quad_grid[nquad].weights
for quad_grid, nquad in zip(
get_quad_grids(self._W, nquads=self.nquads),
self.nquads,
)
]
@property
def domain(self):
return self._domain
@property
def codomain(self):
return self._codomain
@property
def dtype(self):
return self._dtype
@property
def nquads(self):
if self._nquads is None:
return self.derham.nquads
else:
return self._nquads
@property
def derham(self):
"""Discrete de Rham sequence on the logical unit cube."""
return self._derham
[docs]
def transpose(self, conjugate=False):
return StencilMatrixFreeMassOperator(
self._derham,
self._W,
self._V,
self._weights,
nquads=self._nquads,
)
@property
def weights(self):
return self._weights
@weights.setter
def weights(self, new):
self._weights = new
[docs]
def dot(self, v, out=None):
"""
Dot product of the operator with a vector. Direct computation (not using a StencilMatrix).
Parameters
----------
v : feectools.linalg.basic.Vector
The input (domain) vector.
out : feectools.linalg.basic.Vector, optional
If given, the output will be written in-place into this vector.
apply_bc : bool
Whether to apply the boundary operators (True) or not (False).
Returns
-------
out : feectools.linalg.basic.Vector
The output (codomain) vector.
"""
if out is None:
out = self.codomain.zeros()
else:
assert isinstance(out, Vector)
assert out.space == self.codomain
out._data[:] = 0.0
v.update_ghost_regions()
# evaluate weight at quadrature points
if callable(self._weights):
PTS = xp.meshgrid(*self._pts, indexing="ij")
mat_w = self._weights(*PTS).copy()
elif isinstance(self._weights, xp.ndarray):
mat_w = self._weights
if self._weights is not None:
assert mat_w.shape == tuple([pt.size for pt in self._pts])
# call kernel (if mat_w is not zero) by calling the appropriate kernel (1d, 2d or 3d)
if xp.any(xp.abs(mat_w) > 1e-14):
self._dot_kernel(
*self._codomain_spans,
*self._domain_spans,
*self._W.degree,
*self._V.degree,
*self._codomain_starts,
*self._domain_starts,
*self._codomain_pads,
*self._domain_pads,
*self._wts,
*self._codomain_basis,
*self._domain_basis,
mat_w,
out._data,
v._data,
)
out.exchange_assembly_data()
return out
[docs]
def diagonal(self, inverse=False, sqrt=False, out=None):
"""
Get the coefficients on the main diagonal as a StencilDiagonalMatrix object.
Parameters
----------
inverse : bool
If True, get the inverse of the diagonal. (Default: False).
sqrt : bool
If True, get the square root of the diagonal. (Default: False).
Can be combined with inverse to get the inverse square root
out : StencilDiagonalMatrix
If provided, write the diagonal entries into this matrix. (Default: None).
Returns
-------
StencilDiagonalMatrix
The matrix which contains the main diagonal of self (or its inverse).
"""
# Check `inverse` argument
assert isinstance(inverse, bool)
# Only if domain == codomain
assert self.domain == self.codomain
# Determine domain and codomain of the StencilDiagonalMatrix
V, W = self.domain, self.codomain
# Check `out` argument
if out is not None:
assert isinstance(out, StencilDiagonalMatrix)
assert out.domain is V
assert out.codomain is W
# evaluate weight at quadrature points
if callable(self._weights):
PTS = xp.meshgrid(*self._pts, indexing="ij")
mat_w = self._weights(*PTS).copy()
elif isinstance(self._weights, xp.ndarray):
mat_w = self._weights
diag_tmp = self._diag_tmp
diag_tmp._data[:] = 0.0
if self._weights is not None:
self._diag_kernel(
*self._codomain_spans,
*self._W.degree,
*self._codomain_starts,
*self._codomain_pads,
*self._wts,
*self._codomain_basis,
mat_w,
diag_tmp._data,
)
diag_tmp.exchange_assembly_data()
# entries owned by this process (without ghost regions)
idx = tuple(slice(p * m, -p * m) if p != 0 else slice(None) for p, m in zip(W.pads, W.shifts))
diag = diag_tmp._data[idx]
data = out._data if out else None
# Calculate entries of StencilDiagonalMatrix
if sqrt:
diag = xp.sqrt(diag)
if inverse:
data = xp.divide(1, diag, out=data)
elif out:
xp.copyto(data, diag)
else:
data = diag.copy()
# If needed create a new StencilDiagonalMatrix object
if out is None:
out = StencilDiagonalMatrix(V, W, data)
return out
[docs]
class L2Projector:
r"""
An orthogonal projection into a discrete :class:`~struphy.feec.psydac_derham.Derham` space
based on the L2-scalar product.
It solves the following system for the FE-coefficients :math:`\mathbf f = (f_{lmn}) \in \mathbb R^{N_\alpha}`:
.. math::
\mathbb M^\alpha_{ijk, lmn} f_{lmn} = (f^\alpha, \Lambda^\alpha_{ijk})_{L^2}\,,
where :math:`\mathbb M^\alpha` denotes the :ref:`mass matrix <weighted_mass>` of space :math:`\alpha \in \{0,1,2,3,v\}` and :math:`f^\alpha` is a :math:`\alpha`-form proxy function.
Parameters:
-----------
space_id : str
One of "H1", "Hcurl", "Hdiv", "L2" or "H1vec".
mass_ops : struphy.mass.WeighteMassOperators
Mass operators object, see :ref:`mass_ops`.
solver : LiteralOptions.OptsSymmSolver, default="pcg"
Symmetric iterative solver used by implicit or explicit operators.
precond : LiteralOptions.OptsMassPrecond, default="MassMatrixPreconditioner"
Preconditioner for the mass-matrix block.
solver_params : SolverParameters, default=None
Solver controls; defaults to ``SolverParameters()``.
"""
def __init__(
self,
space_id: str,
mass_ops: WeightedMassOperators,
solver_name: LiteralOptions.OptsSymmSolver = "pcg",
precond_name: LiteralOptions.OptsMassPrecond = "MassMatrixPreconditioner",
solver_params: SolverParameters = None,
):
assert space_id in ("H1", "Hcurl", "Hdiv", "L2", "H1vec")
# TODO: enable serialization of WeightedMassOperators
# self.params = copy.deepcopy(locals())
# TODO: move L2projector to its own file and avoid circular imports
from struphy.feec import preconditioner
if solver_params is None:
solver_params = SolverParameters()
self._space_id = space_id
self._mass_ops = mass_ops
self._space_key = mass_ops.derham.space_to_form[self.space_id]
self._space = mass_ops.derham.fem_spaces[self.space_key]
# mass matrix
self._Mmat = getattr(self.mass_ops, "M" + self.space_key)
# basis extraction operator (tensor-product --> polar dofs) and tensor-product vector for assembly
self._extraction_op = self.mass_ops.derham.extraction_ops[self.space_key]
if self.mass_ops.derham.polar_splines:
self._dofs_tp = self.space.coeff_space.zeros()
# quadrature grid
self._quad_grid_pts = self.mass_ops.derham.spline_attributes[self.space_key].quad_grid_pts
if space_id in ("H1", "L2"):
self._quad_grid_mesh = xp.meshgrid(
*[pt.flatten() for pt in self.quad_grid_pts[0]],
indexing="ij",
)
self._geom_weights = self.Mmat.weights[0][0] # (*self.quad_grid_mesh)
else:
self._quad_grid_mesh = []
self._tmp = [] # tmp for matrix-vector product of geom_weights with fun
for pts in self.quad_grid_pts:
self._quad_grid_mesh += [
xp.meshgrid(
*[pt.flatten() for pt in pts],
indexing="ij",
),
]
self._tmp += [xp.zeros_like(self.quad_grid_mesh[-1][0])]
# geometric weights evaluated at quadrature grid
self._geom_weights = []
# loop over rows (different meshes)
for mesh, row_weights in zip(self.quad_grid_mesh, self.Mmat.weights):
self._geom_weights += [[]]
# loop over columns (differnt geometric coeffs)
for weight in row_weights:
if weight is not None:
self._geom_weights[-1] += [weight] # (*mesh)]
else:
self._geom_weights[-1] += [xp.zeros_like(mesh[0])]
# other quad grid info
self._tensor_fem_spaces = self.mass_ops.derham.spline_attributes[self.space_key].tensor_spaces
self._wts_l = self.mass_ops.derham.spline_attributes[self.space_key].quad_grid_wts
self._spans_l = self.mass_ops.derham.spline_attributes[self.space_key].quad_grid_spans
self._bases_l = self.mass_ops.derham.spline_attributes[self.space_key].quad_grid_bases
# Preconditioner
if precond_name is None:
pc = None
else:
pc_class = getattr(preconditioner, precond_name)
pc = pc_class(self.Mmat)
# solver
self._solver = inverse(
self.Mmat,
solver_name,
pc=pc,
tol=solver_params.tol,
maxiter=solver_params.maxiter,
verbose=solver_params.verbose,
)
@property
def params(self) -> dict:
"""Parameters passed to __init__(), as dictionary."""
if not hasattr(self, "_params"):
self._params = {}
return self._params
@params.setter
def params(self, new):
assert isinstance(new, dict)
if "self" in new:
new.pop("self")
if "__class__" in new:
new.pop("__class__")
self._params = new
@property
def mass_ops(self) -> WeightedMassOperators:
"""Struphy mass operators object, see :ref:`mass_ops`.."""
return self._mass_ops
@property
def space_id(self) -> str:
"""The ID of the space (H1, Hcurl, Hdiv, L2 or H1vec)."""
return self._space_id
@property
def space_key(self) -> str:
"""The key of the space (0, 1, 2, 3 or v)."""
return self._space_key
@property
def space(self) -> FemSpace:
"""The Derham finite element space (from ``Derham.fem_spaces``)."""
return self._space
@property
def solver(self) -> InverseLinearOperator:
"""The iterative solver for the mass matrix."""
return self._solver
@property
def Mmat(self) -> WeightedMassOperator:
"""The mass matrix of space."""
return self._Mmat
@property
def quad_grid_pts(self) -> tuple[tuple[xp.ndarray]]:
"""List of quadrature points in each direction for integration over grid cells in format (ni, nq) = (cell, quadrature point)."""
return self._quad_grid_pts
@property
def quad_grid_mesh(self) -> list[tuple[xp.ndarray]]:
"""Mesh grids of quad_grid_pts."""
return self._quad_grid_mesh
@property
def geom_weights(self) -> list[list[xp.ndarray]]:
"""Geometric coefficients (e.g. Jacobians) evaluated at quad_grid_mesh, stored as list[list] either 1x1 or 3x3."""
return self._geom_weights
[docs]
def __repr__(self):
out = f"{self.__class__.__name__}(\n"
for k, v in self.params.items():
out += " " * 4
out += f"{k}={v},\n"
out += ")"
return out
def __repr_no_defaults__(self):
return __class_with_params_repr_no_defaults__(self)
[docs]
def solve(
self,
rhs: StencilVector | BlockVector | PolarVector,
out=None,
) -> StencilVector | BlockVector | PolarVector:
"""
Solves the linear system M * x = rhs, where M is the mass matrix.
Parameters
----------
rhs : feectools.linalg.basic.vector
The right-hand side of the linear system.
out : feectools.linalg.basic.vector, optional
If given, the result will be written into this vector in-place.
Returns
-------
out : feectools.linalg.basic.vector
Output vector (result of linear system).
"""
assert isinstance(rhs, (StencilVector, BlockVector, PolarVector))
assert rhs.space == self.Mmat.domain
if out is None:
out = self.solver.dot(rhs)
else:
self.solver.dot(rhs, out=out)
return out
[docs]
def get_dofs(
self,
fun: Callable | xp.ndarray | list[Callable | xp.ndarray] | tuple[Callable | xp.ndarray],
dofs: StencilVector | BlockVector | PolarVector = None,
apply_bc: bool = False,
clear: bool = True,
) -> StencilVector | BlockVector | PolarVector:
r"""
Assembles (in 3d) the Stencil-/Block-/PolarVector
.. math::
V_{ijk} = \int f * w_\textrm{geom} * \Lambda^\alpha_{ijk}\,\textrm d \boldsymbol \eta = \left( f\,, \Lambda^\alpha_{ijk}\right)_{L^2}\,,
where :math:`\Lambda^\alpha_{ijk}` are the basis functions of :math:`V_h^\alpha`,
:math:`f` is an :math:`\alpha`-form proxy function and :math:`w_\textrm{geom}` stand for metric coefficients.
Note that any geometric terms (e.g. Jacobians) in the L2 scalar product are automatically assembled
into :math:`w_\textrm{geom}`, depending on the space of :math:`\alpha`-forms.
For polar splines, the tensor-product vector is mapped to the polar sub-space with the basis extraction operator
(the polar basis functions are linear combinations of the tensor-product ones).
The integration is performed with Gauss-Legendre quadrature over the whole logical domain.
Parameters
----------
fun : Callable | xp.ndarray | list[Callable | xp.ndarray] | tuple[Callable | xp.ndarray]
Weight function(s) (callables or xp.ndarrays) in a 1d list of shape corresponding to number of components.
dofs : StencilVector | BlockVector | PolarVector, optional
The vector for the output. Either an element of the domain of the mass matrix (PolarVector for polar splines),
or a tensor-product Stencil-/BlockVector, in which case no basis extraction is performed.
apply_bc : bool, optional
Whether to apply essential boundary conditions to degrees of freedom.
clear : bool, optional
Whether to first set all data to zero before assembly. If False, the new contributions are added to existing ones in vec.
Returns
-------
dofs : StencilVector | BlockVector | PolarVector
The assembled degrees of freedom, before projection.
"""
# evaluate fun at quad_grid or check array size
if callable(fun):
fun_weights = fun(*self.quad_grid_mesh)
elif isinstance(fun, xp.ndarray):
assert fun.shape == self.quad_grid_mesh[0].shape, (
f"Expected shape {self.quad_grid_mesh[0].shape}, got {fun.shape =} instead."
)
fun_weights = fun
else:
assert (
len(
fun,
)
== 3
), f"List input only for vector-valued spaces of size 3, but {len(fun) =}."
fun_weights = []
# loop over rows (different meshes)
for mesh in self.quad_grid_mesh:
fun_weights += [[]]
# loop over columns (different functions)
for f in fun:
if callable(f):
fun_weights[-1] += [f(*mesh)]
elif isinstance(f, xp.ndarray):
assert f.shape == mesh[0].shape, f"Expected shape {mesh[0].shape}, got {f.shape =} instead."
fun_weights[-1] += [f]
else:
raise ValueError(
f"Expected callable or numpy array, got {type(f) =} instead.",
)
# check output vector
if dofs is None:
dofs = self.Mmat.codomain.zeros()
else:
assert isinstance(dofs, (StencilVector, BlockVector, PolarVector))
assert dofs.space in (self.Mmat.codomain, self.space.coeff_space)
# for polar splines, assemble into tensor-product vector first
is_polar = isinstance(dofs, PolarVector)
if is_polar:
vec = self._dofs_tp
else:
vec = dofs
# compute matrix data for kernel, i.e. fun * geom_weight
tot_weights = []
if isinstance(fun_weights, xp.ndarray):
tot_weights += [fun_weights * self.geom_weights]
else:
# loop over rows (differnt meshes)
for row_fun, row_geom, tmp in zip(fun_weights, self.geom_weights, self._tmp):
tmp *= 0.0
# loop over columns (different functions)
for fun_weight, geom_weight in zip(row_fun, row_geom):
# matrix-vector product
tmp += fun_weight * geom_weight
tot_weights += [tmp]
# clear data
if clear or is_polar:
if isinstance(vec, StencilVector):
vec._data[:] = 0.0
else:
for block in vec.blocks:
block._data[:] = 0.0
# loop over components (just one for scalar spaces)
for a, (fem_space, spans, wts, basis, mat_w) in enumerate(
zip(
self._tensor_fem_spaces,
self._spans_l,
self._wts_l,
self._bases_l,
tot_weights,
),
):
# indices
starts = [int(start) for start in fem_space.coeff_space.starts]
pads = fem_space.coeff_space.pads
if isinstance(vec, StencilVector):
kernel_3d_vec(
*spans,
*fem_space.degree,
*starts,
*pads,
*wts,
*basis,
mat_w,
vec._data,
)
else:
kernel_3d_vec(
*spans,
*fem_space.degree,
*starts,
*pads,
*wts,
*basis,
mat_w,
vec[a]._data,
)
# exchange assembly data (accumulate ghost regions) and update ghost regions
vec.exchange_assembly_data()
vec.update_ghost_regions()
# apply basis extraction operator (tensor-product --> polar)
if is_polar:
if clear:
self._extraction_op.dot(vec, out=dofs)
else:
dofs += self._extraction_op.dot(vec)
dofs.update_ghost_regions()
# apply boundary operator
if apply_bc:
dofs = self.mass_ops.derham.boundary_ops[self.space_key].dot(dofs)
return dofs
[docs]
def __call__(
self,
fun: Callable | list[Callable] | tuple[Callable],
out: StencilVector | BlockVector | PolarVector = None,
dofs: StencilVector | BlockVector | PolarVector = None,
apply_bc: bool = False,
) -> StencilVector | BlockVector | PolarVector:
"""
Applies projector to given callable(s).
Parameters
----------
fun : Callable | list[Callable] | tuple[Callable]
The function to be projected. List of three callables for vector-valued functions.
out : StencilVector | BlockVector | PolarVector, optional
If given, the result will be written into this vector in-place.
dofs : StencilVector | BlockVector | PolarVector, optional
If given, the dofs will be written into this vector in-place.
apply_bc : bool, optional
Whether to apply essential boundary conditions to degrees of freedom and coefficients.
Returns
-------
coeffs : feectools.linalg.basic.vector
The FEM spline coefficients after projection.
"""
return self.solve(self.get_dofs(fun, dofs=dofs, apply_bc=apply_bc), out=out)
[docs]
class AverageOperator(LinearOperator):
r"""
Class for quadrature operators, performs the average of a `FeecVariable` along a given direction.
For example along the :math:`\eta_3` direction, it applies the following linear operator :
.. math::
\mathbb M^{\alpha}_{(\mu,ijk),(\nu,mno)} = \delta_{i,m} \delta_{j,n} c_o
with :math:`c_o=\int_0^1 N_{o}(\eta_3) \textnormal{d} \eta` and :math:`N_{o}` the B-spline function at the place `o`.
In other words, it maps a spline function :math:`S_h` to the function obtained by averaging :math:`S_h` along the given direction, i.e. for the example of direction 3:
.. math::
S_h(\eta_1, \eta_2, \eta_3) = \sum_{mno} c_{mno} N_m(\eta_1) N_n(\eta_2) N_o(\eta_3) \quad \mapsto \quad \overline S_h(\eta_1, \eta_2, \eta_3) = \sum_{ijk} \overline c_{ij} N_i(\eta_1) N_j(\eta_2) N_k(\eta_3)\,,
with
.. math::
\overline c_{ij} = \sum_o \delta_{i,m} \delta_{j,n} \, c_{mno} \int_0^1 N_{o}(\eta_3) \textnormal{d} \eta_3 .
Parameters
----------
derham : Derham
The derham complexe that supports the space used
space : str
Identifier of the space on which the average is performed, either `"H1"`, `"Hcurl"`, `"Hdiv"`, `"L2"`, `"H1vec"`
direction : int, optional
The direction of the space along which the average is performed, either `0`, `1` or `2`.
transposed : bool, optional
Whether to take the transpose of the operator.
nquads : list[int], optional
Number of quadrature points in each direction. If not given, those of the derham complex are used.
"""
def __init__(
self,
derham: Derham,
space: str = "H1",
direction: int = 2,
transposed: bool = False,
nquads: list[int] | None = None,
):
if space not in derham.space_to_form:
raise AssertionError("Must match a space of the derham complex")
if space != "H1":
raise NotImplementedError("AverageOperator is only implemented for space H1")
space_id = "V" + derham.space_to_form[space]
self._V = getattr(derham, space_id) # StencilVectorSpace
self._domain = getattr(derham, space_id)
self._codomain = getattr(derham, space_id)
self._pads = self._V.pads # gets the number of ghost cells
self._derham = derham
self._dtype = self._domain.dtype
self._space = space
self._direction = direction
self._transposed = transposed
self._nquads = nquads
if direction == 0:
self._directions = (0, 1, 2)
elif direction == 1:
self._directions = (1, 0, 2)
elif direction == 2:
self._directions = (2, 0, 1)
else:
raise ValueError("invalid direction id, must be 0, 1 or 2")
comm = derham.comm
# Selection of ranks for each subcomms regarding their position in the two perpendicular directions to the averaged direction.
if not isinstance(comm, (SerialComm, type(None))):
rank = comm.Get_rank()
nprocs = derham.domain_decomposition.nprocs
coords = derham.domain_decomposition.coords
color1 = int(coords[self._directions[1]])
color2 = int(coords[self._directions[2]])
color = color1 * nprocs[self._directions[2]] + color2
self.subcomm = comm.Split(color=color, key=rank)
# We allocate memory for the 2D temporary array for each process
self._tmp = xp.zeros(
(
int(self._V.ends[self._directions[1]] - self._V.starts[self._directions[1]] + 1),
int(self._V.ends[self._directions[2]] - self._V.starts[self._directions[2]] + 1),
)
)
# We allocate memory for the weights (integrals of 1D B-splines)
self._weights = xp.zeros(int(self._V.ends[self._directions[0]] - self._V.starts[self._directions[0]] + 1))
self.allocate()
# definition of subscripts for function xp.einsum
if self._transposed:
if self._directions[0] == 0:
self._subscripts = ("ijk->jk", "ij,o->oij")
if self._directions[0] == 1:
self._subscripts = ("ijk->ik", "ij,o->ioj")
if self._directions[0] == 2:
self._subscripts = ("ijk->ij", "ij,o->ijo")
else:
if self._directions[0] == 0:
self._subscripts = ("ojk,o->jk",)
if self._directions[0] == 1:
self._subscripts = ("iok,o->ik",)
if self._directions[0] == 2:
self._subscripts = ("ijo,o->ij",)
# definition of slices
sl_ghost = tuple(slice(p, -p) if p > 0 else slice(None) for p in self._pads)
sl_broadcasting = [slice(None), slice(None), slice(None)]
sl_broadcasting[self._directions[0]] = None
self._slices = (sl_ghost, tuple(sl_broadcasting))
[docs]
def allocate(self):
"""Compute the weights, which are the integrals of 1D B-splines in the averaged direction"""
knots = getattr(self.derham.args_derham, "tn" + str(self._directions[0] + 1))
degree = self.derham.degree[self._directions[0]]
i_begin, i_end = self._V.starts[self._directions[0]], self._V.ends[self._directions[0]] + 1
if self.derham.bcs[self._directions[0]] is None:
i_begin += self.derham.degree[self._directions[0]]
i_end += self.derham.degree[self._directions[0]]
# General formula for any distribution of knots for the integral of a B-spline function, thus works with periodic and clamped boundary conditions :
self._weights[:] = (knots[i_begin + degree + 1 : i_end + degree + 1] - knots[i_begin:i_end]) / (degree + 1)
@property
def domain(self):
return self._domain
@property
def codomain(self):
return self._codomain
@property
def dtype(self):
return self._dtype
@property
def derham(self):
return self._derham
@property
def nquads(self):
if self._nquads is None:
return self.derham.nquads
else:
return self._nquads
[docs]
def dot(self, v, out=None):
# assert isinstance(v, StencilVector)
# assert v.space == self.domain
v.update_ghost_regions()
if out is None:
out = self.codomain.zeros()
x = v._data[self._slices[0]]
y = out._data[self._slices[0]]
if self._transposed:
xp.einsum(self._subscripts[0], x, out=self._tmp)
if not isinstance(self.derham.comm, (SerialComm, type(None))):
self.subcomm.Allreduce(MPI.IN_PLACE, self._tmp, MPI.SUM)
xp.einsum(self._subscripts[1], self._tmp, self._weights, out=y)
else:
xp.einsum(self._subscripts[0], x, self._weights, out=self._tmp)
if not isinstance(self.derham.comm, (SerialComm, type(None))):
self.subcomm.Allreduce(MPI.IN_PLACE, self._tmp, MPI.SUM)
y[:] = self._tmp[self._slices[1]]
return out
[docs]
def transpose(self, conjugate=False):
return AverageOperator(
self.derham, self._space, self._direction, transposed=not self._transposed, nquads=self._nquads
)
[docs]
class WeightedAverageProjection(LinearOperator):
r"""
Projection onto functions that are constant in the given directions, orthogonal with respect to
a symmetric positive (semi-)definite operator :math:`\mathbb S` on the 0-form coefficients:
.. math::
\Pi_{\mathbb S} = \mathbb E \, (\mathbb E^\top \mathbb S \, \mathbb E)^{-1} \, \mathbb E^\top \mathbb S \,,
where :math:`\mathbb E` embeds the coefficients of the remaining directions as constant along the
averaged ones (:math:`\mathbb E^\top` sums over the averaged directions). For a weighted mass matrix
:math:`\mathbb S`, :math:`\Pi_{\mathbb S} \phi` is the spline in the remaining directions closest to
:math:`\phi` in the weighted :math:`L^2` norm, i.e. the weighted average (e.g. the flux-surface
average with the volume element :math:`\sqrt g` for :math:`\mathbb M^0`). Then
.. math::
\mathbb S (\mathbb I - \Pi_{\mathbb S}) = \mathbb S - \mathbb S \mathbb E \, (\mathbb E^\top \mathbb S \, \mathbb E)^{-1} \, \mathbb E^\top \mathbb S
is symmetric. If the weight of :math:`\mathbb S` does not depend on the averaged directions,
:math:`\Pi_{\mathbb S}` is the plain average of :class:`AverageOperator`. If all three directions
are averaged, :math:`\mathbb E` is the vector of ones and the reduced matrix is the scalar
:math:`\mathbb 1^\top \mathbb S \, \mathbb 1` (the weighted mean over the whole domain).
The reduced matrix :math:`\mathbb E^\top \mathbb S \, \mathbb E` (on the remaining directions) is
assembled once by probing :math:`\mathbb S` with colored vectors and factorized on every process;
one application costs one application of :math:`\mathbb S` and a sparse solve.
Parameters
----------
derham : Derham
Discrete de Rham sequence (0-forms). With polar splines, the constructor returns a
:class:`PolarWeightedAverageProjection` instead.
S : LinearOperator
Symmetric operator on the 0-form coefficients (e.g. a weighted mass operator), positive
definite on the functions constant in the averaged directions (up to essential boundary
conditions, whose reduced rows are replaced by the identity).
directions : tuple[int]
The averaged directions (0, 1 and/or 2; all three for the global mean).
"""
[docs]
def __new__(cls, derham: Derham | None = None, *args, **kwargs):
# polar splines: the projection on the polar 0-forms
if cls is WeightedAverageProjection and derham is not None and derham.polar_splines:
return PolarWeightedAverageProjection(derham, *args, **kwargs)
return super().__new__(cls)
def __init__(self, derham: Derham, S: LinearOperator, directions: tuple[int, ...]):
import numpy as np
from scipy import sparse
from scipy.sparse.linalg import splu
V = derham.V0
assert S.domain == V and S.codomain == V, "S must act on the 0-form coefficients."
directions = tuple(sorted(set(directions)))
assert directions and all(d in (0, 1, 2) for d in directions)
kept = tuple(d for d in range(3) if d not in directions)
self._derham = derham
self._S = S
self._space = V
self._directions = directions
self._kept = kept
self._comm = None if isinstance(derham.comm, (SerialComm, type(None))) else derham.comm
self._local = tuple(slice(p * m, p * m + e - s + 1) for p, m, s, e in zip(V.pads, V.shifts, V.starts, V.ends))
self._red_shape = tuple(V.npts[d] for d in kept)
self._red_local = tuple(slice(V.starts[d], V.ends[d] + 1) for d in kept)
self._tmp = V.zeros()
self._tmp2 = V.zeros()
# reduced matrix E^T S E, column by column with colored probes: in each kept direction the
# columns of one color are at least 2 p + 1 apart (also across a periodic boundary)
strides = []
for d in kept:
n, p = V.npts[d], V.pads[d]
stride = n
if not V.periods[d]:
stride = min(n, 2 * p + 1)
else:
for s in range(2 * p + 1, n + 1):
if n % s == 0:
stride = s
break
strides.append(stride)
rows, cols, vals = [], [], []
n_red = int(np.prod(self._red_shape))
index = np.arange(n_red).reshape(self._red_shape)
for color in np.ndindex(*strides):
probe = np.zeros(self._red_shape)
sel = tuple(slice(c, None, s) for c, s in zip(color, strides))
probe[sel] = 1.0
col = self._ET(self._S.dot(self._E(probe, out=self._tmp), out=self._tmp2))
# entry (b, a) for the probed columns a: rows b within the coupling width of a
for a in index[sel].ravel():
a_idx = np.unravel_index(a, self._red_shape)
ranges = []
for k, d in enumerate(kept):
n, p = V.npts[d], V.pads[d]
r = np.arange(a_idx[k] - p, a_idx[k] + p + 1)
r = r % n if V.periods[d] else r[(r >= 0) & (r < n)]
ranges.append(np.unique(r))
for b_idx in np.ndindex(*[len(r) for r in ranges]):
b = tuple(r[i] for r, i in zip(ranges, b_idx))
rows.append(index[b])
cols.append(a)
vals.append(col[b])
K = sparse.coo_matrix((vals, (rows, cols)), shape=(n_red, n_red)).tocsc()
K.sum_duplicates()
# rows without entries (essential boundary conditions of S): identity
diag = K.diagonal()
empty = np.abs(diag) <= 1e-14 * max(np.abs(diag).max(), 1e-300)
if np.any(empty):
K = K + sparse.diags(empty.astype(float))
self._K_lu = splu(K.tocsc())
def _ET(self, v):
"""E^T v: sum over the averaged directions, as global array over the kept directions (all processes)."""
import numpy as np
loc = xp.to_numpy(v._data[self._local]).sum(axis=self._directions)
out = np.zeros(self._red_shape)
out[self._red_local] = loc
if self._comm is not None:
out = self._comm.allreduce(out, op=MPI.SUM)
return out
def _E(self, red, out):
"""E red: the global array over the kept directions, constant along the averaged ones."""
loc = red[self._red_local]
shape = [1, 1, 1]
for k, d in enumerate(self._kept):
shape[d] = loc.shape[k]
out._data[self._local] = xp.asarray(loc.reshape(shape))
out.update_ghost_regions()
return out
@property
def domain(self):
return self._space
@property
def codomain(self):
return self._space
@property
def dtype(self):
return self._space.dtype
@property
def directions(self) -> tuple[int, ...]:
"""The averaged directions."""
return self._directions
[docs]
def dot(self, v, out=None):
if out is None:
out = self._space.zeros()
red = self._K_lu.solve(self._ET(self._S.dot(v, out=self._tmp)).ravel()).reshape(self._red_shape)
return self._E(red, out)
[docs]
def transpose(self, conjugate=False):
raise NotImplementedError("Only S @ (I - P) is symmetric; P itself is not.")
[docs]
def tosparse(self):
raise NotImplementedError
[docs]
def toarray(self):
raise NotImplementedError
[docs]
class PolarWeightedAverageProjection(LinearOperator):
r"""
:class:`WeightedAverageProjection` on polar 0-forms,
.. math::
\Pi_{\mathbb S} = \mathbb B \, (\mathbb B^\top \mathbb S \, \mathbb B)^{-1} \, \mathbb B^\top \mathbb S \,,
where :math:`\mathbb B` embeds the polar 0-forms that are constant in the averaged directions:
* :math:`\eta_3`: the polar unknowns of the poloidal plane (polar coefficients and outer tensor-product rings),
constant in :math:`\eta_3`;
* :math:`\eta_2`: the functions of :math:`\eta_1` only, i.e. the function that is 1 on the inner rings (in polar
coefficients) and the constants in :math:`\eta_2` on the outer rings
(:func:`~struphy.polar.kron_2dx1d.poloidal_average_basis`).
Averages over :math:`\eta_1` (across the pole) are not implemented. The reduced matrix
:math:`\mathbb B^\top \mathbb S \, \mathbb B` is assembled once by probing :math:`\mathbb S` with colored vectors
and factorized on every process.
Parameters
----------
derham : Derham
Discrete de Rham sequence (with polar splines).
S : LinearOperator
Symmetric operator on the polar 0-forms (e.g. a weighted mass operator), positive definite on the
functions constant in the averaged directions (up to essential boundary conditions, whose reduced
rows are replaced by the identity).
directions : tuple[int]
The averaged directions (1 and/or 2).
"""
def __init__(self, derham: Derham, S: LinearOperator, directions: tuple[int, ...]):
import numpy as np
from scipy import sparse
from scipy.sparse.linalg import splu
from struphy.polar.basic import PolarDerhamSpace
from struphy.polar.kron_2dx1d import poloidal_average_basis, poloidal_extraction_matrix, poloidal_mass_matrix
V = S.domain
assert isinstance(V, PolarDerhamSpace) and V.space_id == "H1", "S must act on the polar 0-forms."
assert S.codomain == V
directions = tuple(sorted(set(directions)))
if 0 in directions:
raise NotImplementedError("Averages over eta1 (across the pole) are not implemented for polar splines.")
assert directions and all(d in (1, 2) for d in directions)
self._derham = derham
self._S = S
self._space = V
self._directions = directions
self._comm = None if isinstance(derham.comm, (SerialComm, type(None))) else derham.comm
if self._comm is not None and self._comm.Get_size() == 1:
self._comm = None
self._tmp = V.zeros()
self._tmp2 = V.zeros()
tp = V.parent_space
n1, n2, n3 = tp.npts
self._npol, self._nr = V.n_polar[0], V.n_rings[0]
self._n2, self._n3 = n2, n3
n_unknowns = self._npol + (n1 - self._nr) * n2
E_op = derham.extraction_ops["H1"]
self._B_pol = poloidal_average_basis(V, E_op) if 1 in directions else sparse.eye(n_unknowns, format="csr")
self._avg3 = 2 in directions
self._red_shape = (self._B_pol.shape[1], 1 if self._avg3 else n3)
# sparsity of the reduced matrix: poloidal coupling of the polar 0-forms, band in eta_3
Ep = abs(poloidal_extraction_matrix(V, (0,), E_op.blocks_ten_to_pol, None))
P_pol = Ep @ abs(poloidal_mass_matrix(derham, ("B", "B"), ("B", "B"), None)) @ Ep.T
P_pol = abs(self._B_pol).T @ P_pol @ abs(self._B_pol)
if self._avg3:
P_3 = sparse.csr_matrix(np.ones((1, 1)))
else:
p3 = derham.degree[2]
k = np.arange(n3)
dist = np.abs(k[:, None] - k[None, :])
if derham.spl_kind[2]:
dist = np.minimum(dist, n3 - dist)
P_3 = sparse.csr_matrix((dist <= p3).astype(float))
P = (sparse.kron(P_pol, P_3, format="csc") != 0).astype(float).tocsc()
n_red = P.shape[0]
# greedy coloring: columns of one color have disjoint rows
conflict = (P.T @ P).tocsr()
colors = -np.ones(n_red, dtype=int)
for a in range(n_red):
used = set(colors[conflict.indices[conflict.indptr[a] : conflict.indptr[a + 1]]])
c = 0
while c in used:
c += 1
colors[a] = c
rows, cols, vals = [], [], []
for color in range(colors.max() + 1):
probe_cols = np.flatnonzero(colors == color)
probe = np.zeros(n_red)
probe[probe_cols] = 1.0
col = self._BT(self._S.dot(self._B(probe.reshape(self._red_shape), out=self._tmp), out=self._tmp2)).ravel()
for a in probe_cols:
b = P.indices[P.indptr[a] : P.indptr[a + 1]]
rows.append(b)
cols.append(np.full(b.size, a))
vals.append(col[b])
K = sparse.coo_matrix(
(np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n_red, n_red)
).tocsc()
# rows without entries (essential boundary conditions of S): identity
diag = K.diagonal()
empty = np.abs(diag) <= 1e-14 * max(np.abs(diag).max(), 1e-300)
if np.any(empty):
K = K + sparse.diags(empty.astype(float))
self._K_lu = splu(K.tocsc())
logger.debug(f"PolarWeightedAverageProjection: reduced size {n_red}, {colors.max() + 1} probes")
def _BT(self, v: PolarVector):
"""B^T v as global array of the reduced shape (all processes)."""
import numpy as np
from struphy.polar.kron_2dx1d import _interior
nr, n2 = self._nr, self._n2
tp = self._space.parent_space
s, e = tp.starts, tp.ends
n1 = tp.npts[0]
# outer rings: local part (summed over eta_3 if averaged), then summed over the processes
r3 = self._red_shape[1]
outer = np.zeros((n1 - nr, n2, r3))
L = xp.to_numpy(_interior(v.tp))
i0 = max(s[0], nr)
if i0 <= e[0]:
loc = L[i0 - s[0] :]
loc = loc.sum(axis=2, keepdims=True) if self._avg3 else loc
k = slice(None) if self._avg3 else slice(s[2], e[2] + 1)
outer[i0 - nr : e[0] + 1 - nr, s[1] : e[1] + 1, k] = loc
if self._comm is not None:
outer = self._comm.allreduce(outer, op=MPI.SUM)
# polar coefficients are held by all processes
pol = np.asarray(v.pol[0])
pol = pol.sum(axis=1, keepdims=True) if self._avg3 else pol
Y = np.concatenate((pol, outer.reshape(-1, r3)), axis=0)
return self._B_pol.T @ Y
def _B(self, red, out: PolarVector) -> PolarVector:
"""B red: the polar 0-form constant in the averaged directions."""
import numpy as np
from struphy.polar.kron_2dx1d import _interior
nr, n2, npol = self._nr, self._n2, self._npol
tp = self._space.parent_space
s, e = tp.starts, tp.ends
n1 = tp.npts[0]
X = self._B_pol @ red
out.pol[0][:] = X[:npol]
outer = X[npol:].reshape(n1 - nr, n2, -1)
L = _interior(out.tp)
L[...] = 0.0
i0 = max(s[0], nr)
if i0 <= e[0]:
k = slice(None) if self._avg3 else slice(s[2], e[2] + 1)
L[i0 - s[0] :] = xp.asarray(outer[i0 - nr : e[0] + 1 - nr, s[1] : e[1] + 1, k])
out.tp.update_ghost_regions()
return out
@property
def domain(self):
return self._space
@property
def codomain(self):
return self._space
@property
def dtype(self):
return self._space.dtype
@property
def directions(self) -> tuple[int, ...]:
"""The averaged directions."""
return self._directions
@property
def basis_pol(self):
"""The poloidal factor of B (columns in the polar unknowns of the poloidal plane)."""
return self._B_pol
[docs]
def dot(self, v, out=None):
if out is None:
out = self._space.zeros()
red = self._K_lu.solve(self._BT(self._S.dot(v, out=self._tmp)).ravel()).reshape(self._red_shape)
return self._B(red, out)
[docs]
def transpose(self, conjugate=False):
raise NotImplementedError("Only S @ (I - P) is symmetric; P itself is not.")
[docs]
def tosparse(self):
raise NotImplementedError
[docs]
def toarray(self):
raise NotImplementedError