Source code for psydac.fem.vector

# coding: utf-8

# TODO: - have a block version for VectorSpace when all component spaces are the same
import numpy as np

from functools import reduce
from typing import Optional

from psydac.linalg.basic   import Vector
from psydac.linalg.stencil import StencilVectorSpace
from psydac.linalg.block   import BlockVectorSpace
from psydac.fem.basic      import FemSpace, FemField

__all__ = ('VectorFemSpace', 'MultipatchFemSpace')

#===============================================================================
[docs] class VectorFemSpace(FemSpace): """ FEM space with a vector basis defined on a single patch. This class is used to represent either spaces of vector-valued FEM fields, or product spaces involved in systems of equations. Parameters ---------- *spaces : FemSpace Single-patch FEM spaces, either scalar or vector-valued. """ def __init__(self, *spaces): # Check that all input spaces are of the correct type assert all(isinstance(V, FemSpace) for V in spaces) # We do not accept multipatch spaces yet assert not any(V.is_multipatch for V in spaces) # All input spaces are flattened into a tuple `new_spaces` of scalar spaces new_spaces = [sp.spaces if isinstance(sp, VectorFemSpace) else [sp] for sp in spaces] new_spaces = tuple(sp2 for sp1 in new_spaces for sp2 in sp1) # Check that we indeed have scalar spaces only assert not any(V.is_vector_valued for V in new_spaces) # Check that all spaces have the same parametric dimension ldims = [V.ldim for V in new_spaces] assert len(set(ldims)) == 1 # Make sure that all spaces have the same periodicity along each axis periodic = [V.periodic for V in new_spaces] for pp in zip(*periodic): assert len(set(pp)) == 1 # Make sure that all spaces have the same mapping or no mapping at all # Mapping must be of type BasicCallableMapping defined in SymPDE # [YG, 27.03.2025]: this class was setting its mapping to None mappings = [V.mapping for V in new_spaces] assert len(set(mappings)) == 1 assert mappings[0] is None or isinstance(mappings[0], BasicCallableMapping) # Make sure that all spaces have the same number of cells along each axis # [YG, 27.03.2025]: This is not part of the abstract interface of # FemSpace and it assumes that all spaces are TensorFemSpaces ncells = [V.ncells for V in new_spaces] for nc in zip(*ncells): assert len(set(nc)) == 1 # Compute the SymPDE symbolic space from the symbolic spaces of the input spaces # symbolic_spaces = [V.symbolic_space for V in spaces] # symbolic_space = reduce(lambda x, y: x * y, symbolic_spaces) if all(symbolic_spaces) else None self._symbolic_space = None # if all(s.symbolic_space for s in spaces): # symbolic_spaces = [s.symbolic_space for s in spaces] # self._symbolic_space = reduce(lambda x,y:x*y, symbolic_spaces) # Compute the VectorSpace of the coefficients coeff_space = BlockVectorSpace(*[V.coeff_space for V in new_spaces]) # Store information in private attributes self._ldim : int = ldims[0] self._periodic : tuple[bool, ...] = periodic[0] self._spaces : tuple[FemSpace] = new_spaces self._coeff_space : BlockVectorSpace = coeff_space self._ncells : tuple[int, ...] = ncells[0] # not used in the abstract interface self._mapping : Optional[BasicCallableMapping] = mappings[0] # self._symbolic_space : Optional[BasicFunctionSpace] = symbolic_space # ++++++++++++++ Extra operations for multigrid methods ++++++++++++++ # Initialize the dictionary that will store the refined VectorFemSpaces self._refined_space = {} # Compute the refined VectorFemSpaces from the refined spaces of the # scalar spaces `new_spaces`. The method `set_refined_space` is used to # update the dictionary, and it perfoms additional checks. We also use # the property `spaces` which is not part of the abstract interface. self.set_refined_space(self.ncells, self) for key in self.spaces[0]._refined_space: if key != tuple(self.ncells): V_fine = VectorFemSpace(*[V._refined_space[key] for V in self.spaces]) self.set_refined_space(key, V_fine) #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ #-------------------------------------------------------------------------- # Abstract interface: read-only attributes #-------------------------------------------------------------------------- @property def ldim(self): """ Parametric dimension. """ return self._ldim @property def periodic(self): """ Tuple of booleans: along each logical dimension, say if domain is periodic. :rtype: tuple[bool] """ return self._periodic @property def mapping(self): # [YG, 27.03.2025]: not clear why there should be no mapping here #return None return self._mapping @property def coeff_space(self): """ Vector space of the coefficients (mapping invariant). :rtype: psydac.linalg.block.BlockVectorSpace """ return self._coeff_space @property def symbolic_space( self ): return self._symbolic_space @symbolic_space.setter def symbolic_space( self, symbolic_space ): #assert isinstance(symbolic_space, BasicFunctionSpace) self._symbolic_space = symbolic_space @property def patch_spaces(self): return (self,) @property def component_spaces(self): return self._spaces @property def axis_spaces(self): raise NotImplementedError('Vector Fem space has no list of axis spaces') @property def is_multipatch(self): return False @property def is_vector_valued(self): return True #-------------------------------------------------------------------------- # Abstract interface: evaluation methods #--------------------------------------------------------------------------
[docs] def eval_field( self, field, *eta, weights=None): assert isinstance(field, FemField) assert field.space is self assert len(eta) == self._ldim return self.eval_fields(eta, field, weights=weights)[0]
# ...
[docs] def eval_fields(self, grid, *fields, weights=None, npts_per_cell=None, overlap=0): """Evaluates one or several fields on the given location(s) grid. Parameters ---------- grid : List of ndarray Grid on which to evaluate the fields. Each array in this list corresponds to one logical coordinate. *fields : tuple of psydac.fem.basic.FemField Fields to evaluate. weights : psydac.fem.basic.FemField or None, optional Weights field used to weight the basis functions thus turning them into NURBS. The same weights field is used for all of fields and they thus have to use the same basis functions. npts_per_cell: int, tuple of int or None, optional Number of evaluation points in each cell. If an integer is given, then assume that it is the same in every direction. overlap : int How much to overlap. Only used in the distributed context. Returns ------- List of list of ndarray List of the same length as `fields`, containing for each field a list of `self.ldim` arrays, i.e. one array for each logical coordinate. See Also -------- psydac.fem.tensor.TensorFemSpace.eval_fields : More information about the grid parameter. """ result = [] if weights is not None: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields(grid, *fields_i, npts_per_cell=npts_per_cell, weights=weights.fields[i], overlap=overlap)) else: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields(grid, *fields_i, npts_per_cell=npts_per_cell, overlap=overlap)) return [tuple(result[j][i] for j in range(self.ldim)) for i in range(len(fields))]
# ...
[docs] def eval_fields_regular_tensor_grid(self, grid, *fields, weights=None, overlap=0): """Evaluates one or several fields on a regular tensor grid. Parameters ---------- grid : List of ndarray List of 2D arrays representing each direction of the grid. Each of these arrays should have shape (ne_xi, nv_xi) where ne_xi is the number of cells in the domain in the direction xi and nv_xi is the number of evaluation points in the same direction. *fields : tuple of psydac.fem.basic.FemField Fields to evaluate. weights : psydac.fem.basic.FemField or None, optional Weights field used to weight the basis functions thus turning them into NURBS. The same weights field is used for all of fields and they thus have to use the same basis functions. overlap : int How much to overlap. Only used in the distributed context. Returns ------- List of list of ndarray List of the same length as `fields`, containing for each field a list of `self.ldim` arrays, i.e. one array for each logical coordinate. See Also -------- psydac.fem.tensor.TensorFemSpace.eval_fields : More information about the grid parameter. """ for f in fields: # Necessary if vector coeffs is distributed across processes if not f.coeffs.ghost_regions_in_sync: f.coeffs.update_ghost_regions() result = [] if isinstance(overlap, int): overlap = [overlap] * self.ldim if weights is not None: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields_regular_tensor_grid(grid, *fields_i, weights=weights.fields[i], overlap=overlap[i])) else: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields_regular_tensor_grid(grid, *fields_i, overlap=overlap[i])) return result
# ...
[docs] def eval_fields_irregular_tensor_grid(self, grid, *fields, weights=None, overlap=0): """Evaluates one or several fields on an irregular tensor grid i.e. a tensor grid where the number of points per cell depends on the cell. Parameters ---------- grid : List of ndarray List of 1D arrays representing each direction of the grid. *fields : tuple of psydac.fem.basic.FemField Fields to evaluate. weights : psydac.fem.basic.FemField or None, optional Weights field used to weight the basis functions thus turning them into NURBS. The same weights field is used for all of fields and they thus have to use the same basis functions. overlap : int How much to overlap. Only used in the distributed context. Returns ------- List of list of ndarray List of the same length as `fields`, containing for each field a list of `self.ldim` arrays, i.e. one array for each logical coordinate. See Also -------- psydac.fem.tensor.TensorFemSpace.eval_fields : More information about the grid parameter. """ for f in fields: # Necessary if vector coeffs is distributed across processes if not f.coeffs.ghost_regions_in_sync: f.coeffs.update_ghost_regions() result = [] if isinstance(overlap, int): overlap = [overlap] * self.ldim if weights is not None: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields_irregular_tensor_grid(grid, *fields_i, weights=weights.fields[i], overlap=overlap[i])) else: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields_irregular_tensor_grid(grid, *fields_i, overlap=overlap[i])) return result
# ...
[docs] def eval_field_gradient( self, field, *eta ): assert isinstance( field, FemField ) assert field.space is self assert len( eta ) == self._ldim raise NotImplementedError( "VectorFemSpace not yet operational" )
# ...
[docs] def integral( self, f ): assert hasattr( f, '__call__' ) raise NotImplementedError( "VectorFemSpace not yet operational" )
#-------------------------------------------------------------------------- # Other properties and methods #-------------------------------------------------------------------------- @property def nbasis(self): dims = [V.nbasis for V in self.spaces] return sum(dims) @property def degree(self): return [V.degree for V in self.spaces] @property def multiplicity(self): return [V.multiplicity for V in self.spaces] @property def pads(self): return [V.pads for V in self.spaces] @property def ncells(self): return self._ncells @property def spaces( self ): return self._spaces # ...
[docs] def get_refined_space(self, ncells): return self._refined_space[tuple(ncells)]
[docs] def set_refined_space(self, ncells, new_space): # [YG, 27.03.2025]: It appears that this method is assuming that the # refined space has ldim=2, and that the number of cells is the same # along each axis. These two conditions are very strong. assert all(nc1==nc2 for nc1,nc2 in zip(ncells, new_space.ncells)) self._refined_space[tuple(ncells)] = new_space
def __str__(self): """Pretty printing""" txt = '\n' txt += '> ldim :: {ldim}\n'.format(ldim=self.ldim) txt += '> total nbasis :: {dim}\n'.format(dim=self.nbasis) dims = ', '.join(str(V.nbasis) for V in self.spaces) txt += '> nbasis :: ({dims})\n'.format(dims=dims) return txt
#=============================================================================== class MultipatchFemSpace(FemSpace): """ Product of single-patch FEM spaces. Parameters ---------- *spaces : FemSpace Single-patch FEM spaces, either scalar or vector-valued. connectivity : dict, optional Dictionary representing the connectivity between the patches. """ def __init__(self, *spaces, connectivity=None): if connectivity is None: connectivity = {} # [YG, 28.03.2025]: What happens if we have only one space? if len(spaces) == 1: return self._spaces = spaces # ... make sure that all spaces have the same parametric dimension ldims = [V.ldim for V in self.spaces] assert len(np.unique(ldims)) == 1 self._ldim = ldims[0] # ... self._coeff_space = BlockVectorSpace(*[V.coeff_space for V in self.spaces], connectivity=connectivity) self._symbolic_space = None self._connectivity = connectivity.copy() #-------------------------------------------------------------------------- # Abstract interface: read-only attributes #-------------------------------------------------------------------------- @property def ldim(self): """ Parametric dimension. """ return self._ldim @property def periodic(self): # [YG, 28.03.2025]: this is not consistent with the abstract interface, # which requires a tuple of booleans, but the periodicity of a multipatch # space is not well defined in general. return [V.periodic for V in self.spaces] @property def mapping(self): return None @property def coeff_space(self): """ Vector space of the coefficients (mapping invariant). :rtype: psydac.linalg.basic.BlockVectorSpace """ return self._coeff_space @property def symbolic_space( self ): return self._symbolic_space @symbolic_space.setter def symbolic_space( self, symbolic_space ): #assert isinstance(symbolic_space, BasicFunctionSpace) self._symbolic_space = symbolic_space @property def patch_spaces(self): return self._spaces @property def component_spaces(self): """ Return the component spaces (self if scalar-valued) as a tuple. """ if self.is_vector_valued: # should we return here the multipatch scalar-valued space? raise NotImplementedError('Component spaces not implemented for multipatch spaces') else: return self._spaces @property def axis_spaces(self): raise NotImplementedError('Multipatch space has no list of axis spaces') @property def is_multipatch(self): return True @property def is_vector_valued(self): return self.patch_spaces[0].is_vector_valued #-------------------------------------------------------------------------- # Abstract interface: evaluation methods #-------------------------------------------------------------------------- def eval_field( self, field, *eta, weights=None): assert isinstance(field, FemField) assert field.space is self assert len(eta) == self._ldim raise NotImplementedError() # return self.eval_fields(eta, field, weights=weights)[0] # ... def eval_fields(self, grid, *fields, weights=None, npts_per_cell=None, overlap=0): """Evaluates one or several fields on the given location(s) grid. Parameters ---------- grid : List of ndarray Grid on which to evaluate the fields. Each array in this list corresponds to one logical coordinate. *fields : tuple of psydac.fem.basic.FemField Fields to evaluate. weights : psydac.fem.basic.FemField or None, optional Weights field used to weight the basis functions thus turning them into NURBS. The same weights field is used for all of fields and they thus have to use the same basis functions. npts_per_cell: int, tuple of int or None, optional Number of evaluation points in each cell. If an integer is given, then assume that it is the same in every direction. overlap : int How much to overlap. Only used in the distributed context. Returns ------- List of list of ndarray List of the same length as `fields`, containing for each field a list of `self.ldim` arrays, i.e. one array for each logical coordinate. See Also -------- psydac.fem.tensor.TensorFemSpace.eval_fields : More information about the grid parameter. """ result = [] if weights is not None: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields(grid, *fields_i, npts_per_cell=npts_per_cell, weights=weights.fields[i], overlap=overlap)) else: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields(grid, *fields_i, npts_per_cell=npts_per_cell, overlap=overlap)) return [tuple(result[j][i] for j in range(self.ldim)) for i in range(len(fields))] # ... def eval_fields_regular_tensor_grid(self, grid, *fields, weights=None, overlap=0): """Evaluates one or several fields on a regular tensor grid. Parameters ---------- grid : List of ndarray List of 2D arrays representing each direction of the grid. Each of these arrays should have shape (ne_xi, nv_xi) where ne_xi is the number of cells in the domain in the direction xi and nv_xi is the number of evaluation points in the same direction. *fields : tuple of psydac.fem.basic.FemField Fields to evaluate. weights : psydac.fem.basic.FemField or None, optional Weights field used to weight the basis functions thus turning them into NURBS. The same weights field is used for all of fields and they thus have to use the same basis functions. overlap : int How much to overlap. Only used in the distributed context. Returns ------- List of list of ndarray List of the same length as `fields`, containing for each field a list of `self.ldim` arrays, i.e. one array for each logical coordinate. See Also -------- psydac.fem.tensor.TensorFemSpace.eval_fields : More information about the grid parameter. """ for f in fields: # Necessary if vector coeffs is distributed across processes if not f.coeffs.ghost_regions_in_sync: f.coeffs.update_ghost_regions() result = [] if isinstance(overlap, int): overlap = [overlap] * self.ldim if weights is not None: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields_regular_tensor_grid(grid, *fields_i, weights=weights.fields[i], overlap=overlap[i])) else: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields_regular_tensor_grid(grid, *fields_i, overlap=overlap[i])) return result # ... def eval_fields_irregular_tensor_grid(self, grid, *fields, weights=None, overlap=0): """Evaluates one or several fields on an irregular tensor grid i.e. a tensor grid where the number of points per cell depends on the cell. Parameters ---------- grid : List of ndarray List of 1D arrays representing each direction of the grid. *fields : tuple of psydac.fem.basic.FemField Fields to evaluate. weights : psydac.fem.basic.FemField or None, optional Weights field used to weight the basis functions thus turning them into NURBS. The same weights field is used for all of fields and they thus have to use the same basis functions. overlap : int How much to overlap. Only used in the distributed context. Returns ------- List of list of ndarray List of the same length as `fields`, containing for each field a list of `self.ldim` arrays, i.e. one array for each logical coordinate. See Also -------- psydac.fem.tensor.TensorFemSpace.eval_fields : More information about the grid parameter. """ for f in fields: # Necessary if vector coeffs is distributed across processes if not f.coeffs.ghost_regions_in_sync: f.coeffs.update_ghost_regions() result = [] if isinstance(overlap, int): overlap = [overlap] * self.ldim if weights is not None: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields_irregular_tensor_grid(grid, *fields_i, weights=weights.fields[i], overlap=overlap[i])) else: for i in range(self.ldim): fields_i = list(field.fields[i] for field in fields) result.append(self._spaces[i].eval_fields_irregular_tensor_grid(grid, *fields_i, overlap=overlap[i])) return result # ... def eval_field_gradient( self, field, *eta ): raise NotImplementedError( "MultipatchFemSpace not yet operational" ) # ... def integral( self, f ): raise NotImplementedError( "MultipatchFemSpace not yet operational" ) #-------------------------------------------------------------------------- # Other properties and methods #-------------------------------------------------------------------------- @property def nbasis(self): dims = [V.nbasis for V in self.spaces] return sum(dims) @property def degree(self): return [V.degree for V in self.spaces] @property def multiplicity(self): return [V.multiplicity for V in self.spaces] @property def pads(self): return [V.pads for V in self.spaces] @property def ncells(self): return self._ncells @property def spaces( self ): return self._spaces @property def n_components( self ): return len(self.spaces) # TODO improve @property def comm( self ): return self.spaces[0].comm