From 5a06ba55fc5e5b6897787042cde2b30dddb3c70e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 2 Apr 2025 15:18:08 +0200 Subject: [PATCH 01/23] Support Python 3.13 (#475) Closes #476: * Do not restrict the maximum Python version to 3.12 in `pyproject.toml` * Require `Cython >= 3` to avoid `h5py` installation crash w/ Python 3.13 * Require `sympde == 0.19.2` which supports Python 3.13 * Run unit tests with Python 3.13 too --- .github/workflows/continuous-integration.yml | 16 +++++++--------- pyproject.toml | 4 ++-- requirements.txt | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 7a6e6fe6a..ae4c2e09d 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -15,18 +15,16 @@ jobs: strategy: fail-fast: false matrix: - os: [ ubuntu-24.04 ] - python-version: [ 3.9, '3.10', '3.11', '3.12' ] + os: [ ubuntu-24.04, macos-14 ] + python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] isMerge: - ${{ github.event_name == 'push' && github.ref == 'refs/heads/devel' }} exclude: - - { isMerge: false, python-version: '3.10' } - - { isMerge: false, python-version: '3.11' } - include: - - os: macos-14 - python-version: '3.10' - - os: macos-14 - python-version: '3.11' + - { isMerge: false, python-version: '3.9' , os: macos-14 } + - { isMerge: false, python-version: '3.10', os: ubuntu-24.04 } + - { isMerge: false, python-version: '3.11', os: macos-14 } + - { isMerge: false, python-version: '3.12', os: ubuntu-24.04 } + - { isMerge: false, python-version: '3.13', os: macos-14 } name: ${{ matrix.os }} / Python ${{ matrix.python-version }} diff --git a/pyproject.toml b/pyproject.toml index 45e6ee4cd..43d4c527a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "psydac" version = "0.1" description = "Python package for isogeometric analysis (IGA)" readme = "README.md" -requires-python = ">= 3.9, < 3.13" +requires-python = ">= 3.9" license = {file = "LICENSE"} authors = [ {name = "Psydac development team", email = "psydac@googlegroups.com"} @@ -30,7 +30,7 @@ dependencies = [ 'pyevtk', # Our packages from PyPi - 'sympde == 0.19.1', + 'sympde == 0.19.2', 'pyccel >= 1.11.2', 'gelato == 0.12', diff --git a/requirements.txt b/requirements.txt index 8890db2f9..8cd73ee05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ wheel setuptools >= 61, != 67.2.0 numpy >= 1.16 scipy >= 1.12 -Cython >= 0.25, < 3.0 +Cython >= 3 mpi4py >= 4 # Required to build h5py from source From 42033436b81379aac2f966957ab2315f3ae7e5f6 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 7 Apr 2025 17:17:58 +0200 Subject: [PATCH 02/23] Fix PSYDAC_BACKEND_GPYCCEL flags on latest Apple silicon (#480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently, Psydac can not be used on Apple M4 computers since the `PSYDAC_BACKEND_GPYCCEL['flags']` is not set correctly. This change sets the flag for `Apple MX (etc.)` to `apple-mX`, which should work so long as the naming scheme stays unchanged. In addition, the regular expression used to find the GFortran version is now defined in a raw string instead of a standard Python UTF8 string. This avoids a `SyntaxWarning: invalid escape sequence`. --------- Co-authored-by: Yaman Güçlü --- psydac/api/settings.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/psydac/api/settings.py b/psydac/api/settings.py index 3a3830107..94762b5a9 100644 --- a/psydac/api/settings.py +++ b/psydac/api/settings.py @@ -45,7 +45,7 @@ # Get gfortran version gfortran_version_output = subprocess.check_output(['gfortran', '--version']).decode('utf-8') # nosec B603, B607 -gfortran_version_string = re.search("(\d+\.\d+\.\d+)", gfortran_version_output).group() +gfortran_version_string = re.search(r"(\d+\.\d+\.\d+)", gfortran_version_output).group() gfortran_version = Version(gfortran_version_string) # Platform-dependent flags @@ -53,10 +53,11 @@ # Apple silicon requires architecture-specific flags (see https://github.com/pyccel/psydac/pull/411) # which are only available on GCC version >= 14 - cpu_brand = subprocess.check_output(['sysctl','-n','machdep.cpu.brand_string']).decode('utf-8') # nosec B603, B607 - if "Apple M1" in cpu_brand: PSYDAC_BACKEND_GPYCCEL['flags'] += ' -mcpu=apple-m1' - elif "Apple M2" in cpu_brand: PSYDAC_BACKEND_GPYCCEL['flags'] += ' -mcpu=apple-m2' - elif "Apple M3" in cpu_brand: PSYDAC_BACKEND_GPYCCEL['flags'] += ' -mcpu=apple-m3' + cpu_brand = subprocess.check_output(['sysctl','-n','machdep.cpu.brand_string']).decode('utf-8').strip() # nosec B603, B607 + if cpu_brand.startswith("Apple M"): + # Example: "Apple M3 Pro (virtual)" --> " -mcpu=apple-m3" + cpu_flag = '-'.join(cpu_brand.lower().split()[:2]) + PSYDAC_BACKEND_GPYCCEL['flags'] += f' -mcpu={cpu_flag}' else: # TODO: Support later Apple CPU models. Perhaps the CPU naming scheme could be easily guessed # based on the output of 'sysctl -n machdep.cpu.brand_string', but I wouldn't rely on this From 1b34019ca6275e7807bf4dd2d12b32d2dea90ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 8 Apr 2025 17:07:58 +0200 Subject: [PATCH 03/23] Resolve warnings in sequential unit tests (#481) * Use raw strings for docstrings with LaTeX to avoid UTF8 syntax warnings on escape sequences; * Use CSC matrices to avoid SciPy sparse solver warning. We do not address the NumPy warnings which arise in our MPI unit tests (see #353). --- psydac/feec/global_projectors.py | 2 +- psydac/feec/multipatch/examples/timedomain_maxwell.py | 5 +++-- psydac/linalg/basic.py | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/psydac/feec/global_projectors.py b/psydac/feec/global_projectors.py index 3e40bf361..e5a61a0bb 100644 --- a/psydac/feec/global_projectors.py +++ b/psydac/feec/global_projectors.py @@ -356,7 +356,7 @@ def _function(self, dim): pass def __call__(self, fun): - """ + r""" Project vector function onto the given finite element space by the instance of this class. This happens in the logical domain $\hat{\Omega}$. diff --git a/psydac/feec/multipatch/examples/timedomain_maxwell.py b/psydac/feec/multipatch/examples/timedomain_maxwell.py index 6a6300772..9e772f3e5 100644 --- a/psydac/feec/multipatch/examples/timedomain_maxwell.py +++ b/psydac/feec/multipatch/examples/timedomain_maxwell.py @@ -420,9 +420,10 @@ def solve_td_maxwell_pbm(*, # Absorbing dC_m CH2 = C_m.transpose() @ H2_m H1A = H1_m + dt * A_eps - dC_m = sp.sparse.linalg.spsolve(H1A, CH2) - dCH1_m = sp.sparse.linalg.spsolve(H1A, H1_m) + H1A_csc = H1A.tocsc() + dC_m = sp.sparse.linalg.spsolve(H1A_csc, CH2.tocsc()) + dCH1_m = sp.sparse.linalg.spsolve(H1A_csc, H1_m.tocsc()) print(' .. matrix of the dual div (still in primal bases)...') div_m = dH0_m @ cP0_m.transpose() @ bD0_m.transpose() @ H1_m diff --git a/psydac/linalg/basic.py b/psydac/linalg/basic.py index db28bfd7a..80bcac3d0 100644 --- a/psydac/linalg/basic.py +++ b/psydac/linalg/basic.py @@ -614,7 +614,7 @@ def dot(self, v, out=None): #=============================================================================== class SumLinearOperator(LinearOperator): - """ + r""" Sum $\sum_{i=1}^n A_i$ of linear operators $A_1,\dots,A_n$ acting between the same vector spaces V (domain) and W (codomain). """ @@ -728,7 +728,7 @@ def dot(self, v, out=None): #=============================================================================== class ComposedLinearOperator(LinearOperator): - """ + r""" Composition $A_n\circ\dots\circ A_1$ of two or more linear operators $A_1,\dots,A_n$. """ @@ -788,7 +788,7 @@ def codomain(self): @property def multiplicants(self): - """ + r""" A tuple $(A_1,\dots,A_n)$ containing the multiplicants of the linear operator $self = A_n\circ\dots\circ A_1$. @@ -851,7 +851,7 @@ def set_backend(self, backend): #=============================================================================== class PowerLinearOperator(LinearOperator): - """ + r""" Power $A^n$ of a linear operator $A$ for some integer $n\geq 0$. """ From adb453a98d1bef53992fb34f798d38a6bd7e03b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Moral=20S=C3=A1nchez?= <88042165+e-moral-sanchez@users.noreply.github.com> Date: Sat, 12 Apr 2025 00:33:31 +0200 Subject: [PATCH 04/23] Pass `out` parameter to `petsc_to_psydac` (#483) --- psydac/linalg/utilities.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/psydac/linalg/utilities.py b/psydac/linalg/utilities.py index eb150f138..57a9a6b86 100644 --- a/psydac/linalg/utilities.py +++ b/psydac/linalg/utilities.py @@ -74,7 +74,7 @@ def _array_to_psydac_recursive(x, u): raise NotImplementedError(f'Can only handle StencilVector or BlockVector spaces, got {type(V)} instead') #============================================================================== -def petsc_to_psydac(x, Xh): +def petsc_to_psydac(x, Xh, out=None): """ Convert a PETSc.Vec object to a StencilVector or BlockVector. It assumes that PETSc was installed with the configuration for complex numbers. Uses the index conversion functions in psydac.linalg.topetsc.py. @@ -84,6 +84,12 @@ def petsc_to_psydac(x, Xh): x : PETSc.Vec PETSc vector + Xh : psydac.linalg.stencil.StencilVectorSpace | psydac.linalg.block.BlockVectorSpace + Space of the coefficients of the Psydac vector. + + out : psydac.linalg.stencil.StencilVector | psydac.linalg.block.BlockVector, optional + The Psydac vector where to store the result. + Returns ------- u : psydac.linalg.stencil.StencilVector | psydac.linalg.block.BlockVector @@ -94,7 +100,13 @@ def petsc_to_psydac(x, Xh): if any([isinstance(Xh.spaces[b], BlockVectorSpace) for b in range(len(Xh.spaces))]): raise NotImplementedError('Block of blocks not implemented.') - u = BlockVector(Xh) + if out is not None: + assert isinstance(out, BlockVector) + assert out.space is Xh + u = out + else: + u = BlockVector(Xh) + comm = x.comm dtype = Xh._dtype localsize, globalsize = x.getSizes() @@ -117,7 +129,13 @@ def petsc_to_psydac(x, Xh): elif isinstance(Xh, StencilVectorSpace): - u = StencilVector(Xh) + if out is not None: + assert isinstance(out, StencilVector) + assert out.space is Xh + u = out + else: + u = StencilVector(Xh) + comm = x.comm dtype = Xh.dtype localsize, globalsize = x.getSizes() From 24646ef35acc46d06b37af7d615d2ce06c7a8d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 5 May 2025 17:37:18 +0200 Subject: [PATCH 05/23] Rename `dot` as `inner` in `Vector`, `VectorSpace`, and subclasses (#484) Main changes -------------- * Rename the method `dot` of the base class `VectorSpace` as `inner`, and make it an abstract method (which must be implemented by the subclasses). * Rename the method `dot` of the base class `Vector` as `inner`. This is a concrete method which calls `self.space.inner` and does not need to be overridden by the subclasses. * Add `inner` methods to the classes `StencilVector`, `BlockVectorSpace`, and `DenseVectorSpace`. These methods override the abstract method of the base class as required, and are derived from the former functions `Stencil.dot`, `BlockVector.dot`, and `DenseVector.dot`, which have been removed (see next point). * Remove the property `dtype` and the method `dot` (now `inner`) from the classes `StencilVector`, `BlockVector`, and `DenseVector`, because the default methods of the base class already provide a sufficient implementation. This fixes #330. Necessary additional changes ------------------------------ * Update all linear solvers in `linalg.solvers` with the new method calls; * Update all unit tests with the new method calls, in files: - `api/tests/test_assembly.py` - `feec/tests/test_commuting_projections.py` - `feec/tests/test_global_projectors.py` - `linalg/tests/test_block.py` - `linalg/tests/test_linalg.py` - `linalg/tests/test_stencil_interface_matrix.py` - `linalg/tests/test_stencil_vector.py` Unrelated additional changes ----------------------------- * Rename the class `VectorDot` as `VectorInner` in module `api.ast.linalg`, although never used in Psydac. * Speed up 3D unit tests in: - `feec/tests/test_commuting_projectors.py` - `feec/tests/test_global_projectors.py` --- psydac/api/ast/linalg.py | 2 +- psydac/api/tests/test_assembly.py | 16 +- .../feec/tests/test_commuting_projections.py | 63 ++++---- psydac/feec/tests/test_global_projectors.py | 16 +- psydac/linalg/basic.py | 70 +++++--- psydac/linalg/block.py | 102 +++++++----- psydac/linalg/solvers.py | 90 +++++------ psydac/linalg/stencil.py | 150 +++++++++--------- psydac/linalg/tests/test_block.py | 8 +- psydac/linalg/tests/test_linalg.py | 20 +-- .../tests/test_stencil_interface_matrix.py | 2 +- psydac/linalg/tests/test_stencil_vector.py | 12 +- psydac/polar/dense.py | 97 +++++++---- 13 files changed, 377 insertions(+), 271 deletions(-) diff --git a/psydac/api/ast/linalg.py b/psydac/api/ast/linalg.py index f3a13b740..b44f3d753 100644 --- a/psydac/api/ast/linalg.py +++ b/psydac/api/ast/linalg.py @@ -428,7 +428,7 @@ def _compile_pyccel(self, mod, backend, verbose=False): return fmod #============================================================================== -class VectorDot(SplBasic): +class VectorInner(SplBasic): def __new__(cls, ndim, backend=None): tag = random_string(8) diff --git a/psydac/api/tests/test_assembly.py b/psydac/api/tests/test_assembly.py index a3bdb57e5..d00c58840 100644 --- a/psydac/api/tests/test_assembly.py +++ b/psydac/api/tests/test_assembly.py @@ -78,7 +78,7 @@ def test_field_and_constant(backend, dtype): # Test matrix A x = fh.coeffs #TODO change res into np.conj(res) when the conjugate is applied in the dot product in sympde - assert abs(x.dot(A.dot(x)) - res) < 1e-12 + assert abs(x.inner(A.dot(x)) - res) < 1e-12 # Test vector b assert abs(b.toarray().sum() - res) < 1e-12 @@ -138,10 +138,10 @@ def test_bilinearForm_complex(backend): x = fh.coeffs #TODO change res into np.conj(res) when the conjugate is applied in the dot product in sympde - assert abs(x.dot(A1.dot(x)) - res) < 1e-12 - assert abs(x.dot(A2.dot(x)) - res) < 1e-12 - assert abs(x.dot(A3.dot(x)) - res) < 1e-12 - assert abs(x.dot(A4.dot(x)) - res) < 1e-12 + assert abs(x.inner(A1.dot(x)) - res) < 1e-12 + assert abs(x.inner(A2.dot(x)) - res) < 1e-12 + assert abs(x.inner(A3.dot(x)) - res) < 1e-12 + assert abs(x.inner(A4.dot(x)) - res) < 1e-12 print("PASSED") @@ -375,7 +375,7 @@ def test_multiple_fields(backend, dtype): # Test matrix A #TODO change res into np.conj(res) when the conjugate is applied in the dot product in sympde - assert abs(x.dot(A.dot(x)) - res) < 1e-12 + assert abs(x.inner(A.dot(x)) - res) < 1e-12 # Test vector b assert abs(b.toarray().sum() - res) < 1e-12 @@ -518,10 +518,10 @@ def test_assembly_no_synchr_args(backend): rhoh3 = div.dot(uh) rhof3 = FemField(V1h, rhoh3) weight_mass_matrix = weight_int_prod_h.assemble(rho=rhof1) - inte_bilin = const_1.dot(weight_mass_matrix.dot(const_1)) + inte_bilin = const_1.inner(weight_mass_matrix.dot(const_1)) int_prod_rho = int_prod_h.assemble(rho=rhof2) - inte_lin = int_prod_rho.dot(const_1) + inte_lin = int_prod_rho.inner(const_1) inte_norm = func_h.assemble(rho=rhof3) diff --git a/psydac/feec/tests/test_commuting_projections.py b/psydac/feec/tests/test_commuting_projections.py index ba44ccd08..38881220f 100644 --- a/psydac/feec/tests/test_commuting_projections.py +++ b/psydac/feec/tests/test_commuting_projections.py @@ -1,4 +1,7 @@ # -*- coding: UTF-8 -*- +from mpi4py import MPI +import numpy as np +import pytest from psydac.feec.global_projectors import Projector_H1, Projector_L2, Projector_Hcurl, Projector_Hdiv from psydac.fem.tensor import TensorFemSpace, SplineSpace @@ -11,18 +14,14 @@ from psydac.linalg.solvers import inverse from psydac.linalg.basic import IdentityOperator -from mpi4py import MPI -import numpy as np -import pytest - #============================================================================== # 3D tests #============================================================================== -@pytest.mark.parametrize('Nel', [8, 12]) -@pytest.mark.parametrize('Nq', [5]) -@pytest.mark.parametrize('p', [2,3]) +@pytest.mark.parametrize('m', [1, 2]) @pytest.mark.parametrize('bc', [True, False]) -@pytest.mark.parametrize('m', [1,2]) +@pytest.mark.parametrize('p', [2, 3]) +@pytest.mark.parametrize('Nq', [5]) +@pytest.mark.parametrize('Nel', [5, 6]) def test_3d_commuting_pro_1(Nel, Nq, p, bc, m): fun1 = lambda xi1, xi2, xi3 : np.sin(xi1)*np.sin(xi2)*np.sin(xi3) @@ -83,20 +82,20 @@ def test_3d_commuting_pro_1(Nel, Nq, p, bc, m): Id_0 = IdentityOperator(H1.coeff_space) Err_0 = P0.solver @ P0.imat_kronecker - Id_0 e0 = Err_0 @ u0.coeffs # random vector could be used as well - norm2_e0 = np.sqrt(e0.dot(e0)) + norm2_e0 = np.sqrt(e0.inner(e0)) assert norm2_e0 < 1e-12 Id_1 = IdentityOperator(Hcurl.coeff_space) Err_1 = P1.solver @ P1.imat_kronecker - Id_1 e1 = Err_1 @ u1.coeffs # random vector could be used as well - norm2_e1 = np.sqrt(e1.dot(e1)) + norm2_e1 = np.sqrt(e1.inner(e1)) assert norm2_e1 < 1e-12 -@pytest.mark.parametrize('Nel', [8, 12]) -@pytest.mark.parametrize('Nq', [8]) -@pytest.mark.parametrize('p', [2,3]) +@pytest.mark.parametrize('m', [1, 2]) @pytest.mark.parametrize('bc', [True, False]) -@pytest.mark.parametrize('m', [1,2]) +@pytest.mark.parametrize('p', [2, 3]) +@pytest.mark.parametrize('Nq', [7]) +@pytest.mark.parametrize('Nel', [5, 6]) def test_3d_commuting_pro_2(Nel, Nq, p, bc, m): fun1 = lambda xi1, xi2, xi3 : np.sin(xi1)*np.sin(xi2)*np.sin(xi3) @@ -175,20 +174,20 @@ def test_3d_commuting_pro_2(Nel, Nq, p, bc, m): Id_1 = IdentityOperator(Hcurl.coeff_space) Err_1 = P1.solver @ P1.imat_kronecker - Id_1 e1 = Err_1 @ u1.coeffs # random vector could be used as well - norm2_e1 = np.sqrt(e1.dot(e1)) + norm2_e1 = np.sqrt(e1.inner(e1)) assert norm2_e1 < 1e-12 Id_2 = IdentityOperator(Hdiv.coeff_space) Err_2 = P2.solver @ P2.imat_kronecker - Id_2 e2 = Err_2 @ u2.coeffs # random vector could be used as well - norm2_e2 = np.sqrt(e2.dot(e2)) + norm2_e2 = np.sqrt(e2.inner(e2)) assert norm2_e2 < 1e-12 -@pytest.mark.parametrize('Nel', [8, 12]) -@pytest.mark.parametrize('Nq', [8]) -@pytest.mark.parametrize('p', [2,3]) +@pytest.mark.parametrize('m', [1, 2]) @pytest.mark.parametrize('bc', [True, False]) -@pytest.mark.parametrize('m', [1,2]) +@pytest.mark.parametrize('p', [2, 3]) +@pytest.mark.parametrize('Nq', [7]) +@pytest.mark.parametrize('Nel', [5, 6]) def test_3d_commuting_pro_3(Nel, Nq, p, bc, m): fun1 = lambda xi1, xi2, xi3 : np.sin(xi1)*np.sin(xi2)*np.sin(xi3) @@ -258,13 +257,13 @@ def test_3d_commuting_pro_3(Nel, Nq, p, bc, m): Id_2 = IdentityOperator(Hdiv.coeff_space) Err_2 = P2.solver @ P2.imat_kronecker - Id_2 e2 = Err_2 @ u2.coeffs # random vector could be used as well - norm2_e2 = np.sqrt(e2.dot(e2)) + norm2_e2 = np.sqrt(e2.inner(e2)) assert norm2_e2 < 1e-12 Id_3 = IdentityOperator(L2.coeff_space) Err_3 = P3.solver @ P3.imat_kronecker - Id_3 e3 = Err_3 @ u3.coeffs # random vector could be used as well - norm2_e3 = np.sqrt(e3.dot(e3)) + norm2_e3 = np.sqrt(e3.inner(e3)) assert norm2_e3 < 1e-12 #============================================================================== @@ -334,13 +333,13 @@ def test_2d_commuting_pro_1(Nel, Nq, p, bc, m): Id_0 = IdentityOperator(H1.coeff_space) Err_0 = P0.solver @ P0.imat_kronecker - Id_0 e0 = Err_0 @ u0.coeffs # random vector could be used as well - norm2_e0 = np.sqrt(e0.dot(e0)) + norm2_e0 = np.sqrt(e0.inner(e0)) assert norm2_e0 < 1e-12 Id_1 = IdentityOperator(Hcurl.coeff_space) Err_1 = P1.solver @ P1.imat_kronecker - Id_1 e1 = Err_1 @ u1.coeffs # random vector could be used as well - norm2_e1 = np.sqrt(e1.dot(e1)) + norm2_e1 = np.sqrt(e1.inner(e1)) assert norm2_e1 < 1e-12 @pytest.mark.parallel @@ -407,13 +406,13 @@ def test_2d_commuting_pro_2(Nel, Nq, p, bc, m): Id_0 = IdentityOperator(H1.coeff_space) Err_0 = P0.solver @ P0.imat_kronecker - Id_0 e0 = Err_0 @ u0.coeffs # random vector could be used as well - norm2_e0 = np.sqrt(e0.dot(e0)) + norm2_e0 = np.sqrt(e0.inner(e0)) assert norm2_e0 < 1e-12 Id_1 = IdentityOperator(Hdiv.coeff_space) Err_1 = P1.solver @ P1.imat_kronecker - Id_1 e1 = Err_1 @ u1.coeffs # random vector could be used as well - norm2_e1 = np.sqrt(e1.dot(e1)) + norm2_e1 = np.sqrt(e1.inner(e1)) assert norm2_e0 < 1e-12 @pytest.mark.parallel @@ -487,13 +486,13 @@ def test_2d_commuting_pro_3(Nel, Nq, p, bc, m): Id_2 = IdentityOperator(Hdiv.coeff_space) Err_2 = P2.solver @ P2.imat_kronecker - Id_2 e2 = Err_2 @ u2.coeffs - norm2_e2 = np.sqrt(e2.dot(e2)) + norm2_e2 = np.sqrt(e2.inner(e2)) assert norm2_e2 < 1e-12 Id_3 = IdentityOperator(L2.coeff_space) Err_3 = P3.solver @ P3.imat_kronecker - Id_3 e3 = Err_3 @ u3.coeffs - norm2_e3 = np.sqrt(e3.dot(e3)) + norm2_e3 = np.sqrt(e3.inner(e3)) assert norm2_e3 < 1e-12 @pytest.mark.parallel @@ -567,13 +566,13 @@ def test_2d_commuting_pro_4(Nel, Nq, p, bc, m): Id_1 = IdentityOperator(Hcurl.coeff_space) Err_1 = P1.solver @ P1.imat_kronecker - Id_1 e1 = Err_1 @ u1.coeffs - norm2_e1 = np.sqrt(e1.dot(e1)) + norm2_e1 = np.sqrt(e1.inner(e1)) assert norm2_e1 < 1e-12 Id_2 = IdentityOperator(L2.coeff_space) Err_2 = P2.solver @ P2.imat_kronecker - Id_2 e2 = Err_2 @ u2.coeffs - norm2_e2 = np.sqrt(e2.dot(e2)) + norm2_e2 = np.sqrt(e2.inner(e2)) assert norm2_e2 < 1e-12 #============================================================================== @@ -637,13 +636,13 @@ def test_1d_commuting_pro_1(Nel, Nq, p, bc, m): Id_0 = IdentityOperator(H1.coeff_space) Err_0 = P0.solver @ P0.imat_kronecker - Id_0 e0 = Err_0 @ u0.coeffs - norm2_e0 = np.sqrt(e0.dot(e0)) + norm2_e0 = np.sqrt(e0.inner(e0)) assert norm2_e0 < 1e-12 Id_1 = IdentityOperator(L2.coeff_space) Err_1 = P1.solver @ P1.imat_kronecker - Id_1 e1 = Err_1 @ u1.coeffs - norm2_e1 = np.sqrt(e1.dot(e1)) + norm2_e1 = np.sqrt(e1.inner(e1)) assert norm2_e1 < 1e-12 #============================================================================== diff --git a/psydac/feec/tests/test_global_projectors.py b/psydac/feec/tests/test_global_projectors.py index 303044f37..1d1f64282 100644 --- a/psydac/feec/tests/test_global_projectors.py +++ b/psydac/feec/tests/test_global_projectors.py @@ -248,7 +248,7 @@ def test_derham_projector_2d_hcurl(ncells, degree, periodic, multiplicity): assert maxnorm_error <= 1e-3 #============================================================================== -@pytest.mark.parametrize('ncells', [[30,30,30]]) +@pytest.mark.parametrize('ncells', [[20,20,20]]) @pytest.mark.parametrize('degree', [[2,2,2], [2,3,2], [3,3,3]]) @pytest.mark.parametrize('periodic', [[False, False, False], [True, True, True]]) @pytest.mark.parametrize('multiplicity', [[1,1,1], [1,2,2], [2,2,2]]) @@ -289,19 +289,23 @@ def test_derham_projector_3d(ncells, degree, periodic, multiplicity): # Test if max-norm of error is <= TOL maxnorm_error = abs(vals_u0 - vals_f).max() print(ncells, maxnorm_error) - assert maxnorm_error <= 3e-2 + assert maxnorm_error <= 0.01 + maxnorm_error = abs(vals_u1_1 - vals_f).max() print(ncells, maxnorm_error) - assert maxnorm_error <= 3e-2 + assert maxnorm_error <= 0.01 + maxnorm_error = abs(vals_u2_1 - vals_f).max() print(ncells, maxnorm_error) - assert maxnorm_error <= 3e-2 + assert maxnorm_error <= 0.05 + maxnorm_error = abs(vals_u3 - vals_f).max() print(ncells, maxnorm_error) - assert maxnorm_error <= 3e-2 + assert maxnorm_error <= 0.05 + maxnorm_error = abs(vals_ux_1 - vals_f).max() print(ncells, maxnorm_error) - assert maxnorm_error <= 3e-2 + assert maxnorm_error <= 0.02 #============================================================================== if __name__ == '__main__': diff --git a/psydac/linalg/basic.py b/psydac/linalg/basic.py index 80bcac3d0..bf88dbd07 100644 --- a/psydac/linalg/basic.py +++ b/psydac/linalg/basic.py @@ -34,7 +34,7 @@ #=============================================================================== class VectorSpace(ABC): """ - Finite-dimensional vector space V with a scalar (dot) product. + Finite-dimensional vector space V with a scalar (inner) product. """ @property @@ -66,10 +66,35 @@ def zeros(self): """ -# @abstractmethod - def dot(self, a, b): + @abstractmethod + def inner(self, x, y): """ - Evaluate the scalar product between two vectors of the same space. + Evaluate the inner vector product between two vectors of this space V. + + If the field of V is real, compute the classical scalar product. + If the field of V is complex, compute the classical sesquilinear + product with linearity on the second vector. + + TODO [YG 01.05.2025]: Currently, the first vector is conjugated. We + want to reverse this behavior in order to align with the convention + of FEniCS. + + Parameters + ---------- + x : Vector + The first vector in the scalar product. In the case of a complex + field, the inner product is antilinear w.r.t. this vector (hence + this vector is conjugated). + + y : Vector + The second vector in the scalar product. The inner product is + linear w.r.t. this vector. + + Returns + ------- + float | complex + The scalar product of the two vectors. Note that inner(x, x) is + a non-negative real number which is zero if and only if x = 0. """ @@ -108,7 +133,7 @@ def dtype(self): """ The data type of the vector field V this vector belongs to. """ return self.space.dtype - def dot(self, v): + def inner(self, v): """ Evaluate the scalar product with the vector v of the same space. @@ -120,7 +145,7 @@ def dot(self, v): """ assert isinstance(v, Vector) assert self.space is v.space - return self.space.dot(self, v) + return self.space.inner(self, v) def mul_iadd(self, a, v): """ @@ -150,8 +175,26 @@ def toarray(self, **kwargs): @abstractmethod def copy(self, out=None): - """Ensure x.copy(out=x) returns x and not a new object.""" - pass + """ + Return an identical copy of this vector. + + Subclasses must ensure that x.copy(out=x) returns x and not a new + object. + """ + + @abstractmethod + def conjugate(self, out=None): + """ + Compute the complex conjugate vector. + + Please note that x.conjugate(out=x) modifies x in place and returns x. + + If the field is real (i.e. `self.dtype in (np.float32, np.float64)`) this method is equivalent to `copy`. + If the field is complex (i.e. `self.dtype in (np.complex64, np.complex128)`) this method returns + the complex conjugate of `self`, element-wise. + + The behavior of this function is similar to `numpy.conjugate(self, out=None)`. + """ @abstractmethod def __neg__(self): @@ -181,17 +224,6 @@ def __iadd__(self, v): def __isub__(self, v): pass - @abstractmethod - def conjugate(self, out=None): - """Compute the complex conjugate vector. - - If the field is real (i.e. `self.dtype in (np.float32, np.float64)`) this method is equivalent to `copy`. - If the field is complex (i.e. `self.dtype in (np.complex64, np.complex128)`) this method returns - the complex conjugate of `self`, element-wise. - - The behavior of this function is similar to `numpy.conjugate(self, out=None)`. - """ - #------------------------------------- # Methods with default implementation #------------------------------------- diff --git a/psydac/linalg/block.py b/psydac/linalg/block.py index 49e29b60d..f2a755015 100644 --- a/psydac/linalg/block.py +++ b/psydac/linalg/block.py @@ -73,6 +73,7 @@ def dimension(self): def dtype(self): return self._dtype + # ... def zeros(self): """ Get a copy of the null element of the product space V = [V1, V2, ...] @@ -85,6 +86,44 @@ def zeros(self): """ return BlockVector(self, [Vi.zeros() for Vi in self._spaces]) + # ... + def inner(self, x, y): + """ + Evaluate the inner vector product between two vectors of this space V. + + If the field of V is real, compute the classical scalar product. + If the field of V is complex, compute the classical sesquilinear + product with linearity on the second vector. + + TODO [YG 01.05.2025]: Currently, the first vector is conjugated. We + want to reverse this behavior in order to align with the convention + of FEniCS. + + Parameters + ---------- + x : Vector + The first vector in the scalar product. In the case of a complex + field, the inner product is antilinear w.r.t. this vector (hence + this vector is conjugated). + + y : Vector + The second vector in the scalar product. The inner product is + linear w.r.t. this vector. + + Returns + ------- + float | complex + The scalar product of the two vectors. Note that inner(x, x) is + a non-negative real number which is zero if and only if x = 0. + + """ + + assert isinstance(x, BlockVector) + assert isinstance(y, BlockVector) + assert x.space is self + assert y.space is self + return sum(Vi.inner(xi, yi) for Vi, xi, yi in zip(self.spaces, x.blocks, y.blocks)) + #... def axpy(self, a, x, y): """ @@ -233,18 +272,13 @@ def __init__(self, V, blocks=None): #-------------------------------------- @property def space(self): + """ Vector space to which this vector belongs. """ return self._space - #... - @property - def dtype(self): - return self.space.dtype - - #... - def dot(self, v): - assert isinstance(v, BlockVector) - assert v._space is self._space - return sum(b1.dot(b2) for b1, b2 in zip(self._blocks, v._blocks)) + # ... + def toarray(self, order='C'): + """ Convert to Numpy 1D array. """ + return np.concatenate([bi.toarray(order=order) for bi in self._blocks]) #... def copy(self, out=None): @@ -256,6 +290,19 @@ def copy(self, out=None): w._sync = self._sync return w + #... + def conjugate(self, out=None): + if out is not None: + assert isinstance(out, BlockVector) + assert out.space is self.space + else: + out = BlockVector(self.space) + + for (Lij, Lij_out) in zip(self.blocks, out.blocks): + Lij.conjugate(out=Lij_out) + out._sync = self._sync + return out + #... def __neg__(self): w = BlockVector(self._space, [-b for b in self._blocks]) @@ -311,7 +358,16 @@ def __isub__(self, v): #-------------------------------------- # Other properties/methods #-------------------------------------- + @property + def blocks(self): + return tuple(self._blocks) + + #... + @property + def n_blocks(self): + return len(self._blocks) + # ... def __getitem__(self, key): return self._blocks[key] @@ -321,18 +377,6 @@ def __setitem__(self, key, value): assert isinstance(value, Vector) self._blocks[key] = value - def conjugate(self, out=None): - if out is not None: - assert isinstance(out, BlockVector) - assert out.space is self.space - else: - out = BlockVector(self.space) - - for (Lij, Lij_out) in zip(self.blocks, out.blocks): - Lij.conjugate(out=Lij_out) - out._sync = self._sync - return out - # ... @property def ghost_regions_in_sync(self): @@ -428,20 +472,6 @@ def exchange_assembly_data(self): for vi in self.blocks: vi.exchange_assembly_data() - # ... - @property - def n_blocks(self): - return len(self._blocks) - - # ... - @property - def blocks(self): - return tuple(self._blocks) - - # ... - def toarray(self, order='C'): - return np.concatenate([bi.toarray(order=order) for bi in self._blocks]) - # ... def toarray_local(self, order='C'): """ Convert to petsc Nest vector. diff --git a/psydac/linalg/solvers.py b/psydac/linalg/solvers.py index 3f81f64fa..8c6505ca7 100644 --- a/psydac/linalg/solvers.py +++ b/psydac/linalg/solvers.py @@ -143,7 +143,7 @@ def solve(self, b, out=None): b : psydac.linalg.basic.Vector Right-hand-side vector of linear system Ax = b. Individual entries b[i] need not be accessed, but b has 'shape' attribute and provides 'copy()' and - 'dot(p)' functions (dot(p) is the vector inner product b*p ); moreover, + 'inner(p)' functions (b.inner(p) is the vector inner product b*p); moreover, scalar multiplication and sum operations are available. out : psydac.linalg.basic.Vector | NoneType @@ -190,7 +190,7 @@ def solve(self, b, out=None): A.dot(x, out=v) b.copy(out=r) r -= v - am = r.dot(r).real + am = r.inner(r).real r.copy(out=p) tol_sqr = tol**2 @@ -209,12 +209,12 @@ def solve(self, b, out=None): m -= 1 break A.dot(p, out=v) - l = am / v.dot(p) + l = am / v.inner(p) x.mul_iadd(l, p) # this is x += l*p r.mul_iadd(-l, v) # this is r -= l*v - am1 = r.dot(r).real + am1 = r.inner(r).real p *= (am1/am) p += r am = am1 @@ -351,9 +351,9 @@ def solve(self, b, out=None): A.dot(x, out=v) b.copy(out=r) r -= v - nrmr_sqr = r.dot(r).real + nrmr_sqr = r.inner(r).real pc.dot(r, out=s) - am = s.dot(r) + am = s.inner(r) s.copy(out=p) tol_sqr = tol**2 @@ -374,15 +374,15 @@ def solve(self, b, out=None): break v = A.dot(p, out=v) - l = am / v.dot(p) + l = am / v.inner(p) x.mul_iadd(l, p) # this is x += l*p r.mul_iadd(-l, v) # this is r -= l*v - nrmr_sqr = r.dot(r).real + nrmr_sqr = r.inner(r).real pc.dot(r, out=s) - am1 = s.dot(r) + am1 = s.inner(r) # we are computing p = (am1 / am) * p + s by using axpy on s and exchanging the arrays s.mul_iadd((am1/am), p) @@ -466,7 +466,7 @@ def solve(self, b, out=None): b : psydac.linalg.basic.Vector Right-hand-side vector of linear system. Individual entries b[i] need not be accessed, but b has 'shape' attribute and provides 'copy()' and - 'dot(p)' functions (dot(p) is the vector inner product b*p ); moreover, + 'inner(p)' functions (b.inner(p) is the vector inner product b*p); moreover, scalar multiplication and sum operations are available. out : psydac.linalg.basic.Vector | NoneType @@ -523,7 +523,7 @@ def solve(self, b, out=None): p.copy(out=ps) v.copy(out=vs) - res_sqr = r.dot(r).real + res_sqr = r.inner(r).real tol_sqr = tol**2 if verbose: @@ -548,10 +548,10 @@ def solve(self, b, out=None): #----------------------- # c := (rs, r) - c = rs.dot(r) + c = rs.inner(r) # a := (rs, r) / (ps, v) - a = c / ps.dot(v) + a = c / ps.inner(v) #----------------------- # SOLUTION UPDATE @@ -567,10 +567,10 @@ def solve(self, b, out=None): rs.mul_iadd(-a.conjugate(), vs) # ||r||_2 := (r, r) - res_sqr = r.dot(r).real + res_sqr = r.inner(r).real # b := (rs, r)_{m+1} / (rs, r)_m - b = rs.dot(r) / c + b = rs.inner(r) / c # p := r + b*p p *= b @@ -654,7 +654,7 @@ def solve(self, b, out=None): b : psydac.linalg.basic.Vector Right-hand-side vector of linear system. Individual entries b[i] need not be accessed, but b has 'shape' attribute and provides 'copy()' and - 'dot(p)' functions (dot(p) is the vector inner product b*p ); moreover, + 'inner(p)' functions (b.inner(p) is the vector inner product b*p); moreover, scalar multiplication and sum operations are available. out : psydac.linalg.basic.Vector | NoneType The output vector, or None (optional). @@ -715,7 +715,7 @@ def solve(self, b, out=None): r.copy(out=r0) - res_sqr = r.dot(r).real + res_sqr = r.inner(r).real tol_sqr = tol ** 2 if verbose: @@ -739,10 +739,10 @@ def solve(self, b, out=None): # ----------------------- # c := (r0, r) - c = r0.dot(r) + c = r0.inner(r) # a := (r0, r) / (r0, v) - a = c / (r0.dot(v)) + a = c / (r0.inner(v)) # r := r - a*v r.mul_iadd(-a, v) @@ -751,7 +751,7 @@ def solve(self, b, out=None): vr = A.dot(r, out=vr) # w := (r, A*r) / (A*r, A*r) - w = r.dot(vr) / vr.dot(vr) + w = r.inner(vr) / vr.inner(vr) # ----------------------- # SOLUTION UPDATE @@ -765,13 +765,13 @@ def solve(self, b, out=None): r.mul_iadd(-w, vr) # ||r||_2 := (r, r) - res_sqr = r.dot(r).real + res_sqr = r.inner(r).real if res_sqr < tol_sqr: break # b := a / w * (r0, r)_{m+1} / (r0, r)_m - b = r0.dot(r) * a / (c * w) + b = r0.inner(r) * a / (c * w) # p := r + b*p- b*w*v p *= b @@ -853,7 +853,7 @@ def solve(self, b, out=None): b : psydac.linalg.basic.Vector Right-hand-side vector of linear system. Individual entries b[i] need not be accessed, but b has 'shape' attribute and provides 'copy()' and - 'dot(p)' functions (dot(p) is the vector inner product b*p ); moreover, + 'inner(p)' functions (b.inner(p) is the vector inner product b*p); moreover, scalar multiplication and sum operations are available. out : psydac.linalg.basic.Vector | NoneType The output vector, or None (optional). @@ -939,14 +939,14 @@ def solve(self, b, out=None): pc.dot(r, out=rp) rp.copy(out=pp) - rhop = rp.dot(rp) + rhop = rp.inner(rp) # save initial residual vector rp0 rp0 = self._tmps['rp0'] rp.copy(out=rp0) # squared residual norm and squared tolerance - res_sqr = r.dot(r).real + res_sqr = r.inner(r).real tol_sqr = tol**2 if verbose: @@ -964,7 +964,7 @@ def solve(self, b, out=None): # v = A @ pp, vp = PC @ v, alphap = rhop/(vp.rp0) A.dot(pp, out=v) pc.dot(v, out=vp) - alphap = rhop / vp.dot(rp0) + alphap = rhop / vp.inner(rp0) # s = r - alphap*v, sp = PC @ s r.copy(out=s) @@ -976,7 +976,7 @@ def solve(self, b, out=None): # t = A @ sp, tp = PC @ t, omegap = (tp.sp)/(tp.tp) A.dot(sp, out=t) pc.dot(t, out=tp) - omegap = tp.dot(sp) / tp.dot(tp) + omegap = tp.inner(sp) / tp.inner(tp) # x = x + alphap*pp + omegap*sp pp.copy(out=app) @@ -996,7 +996,7 @@ def solve(self, b, out=None): rp -= tp # rhop_new = rp.rp0, betap = (alphap*rhop_new)/(omegap*rhop) - rhop_new = rp.dot(rp0) + rhop_new = rp.inner(rp0) betap = (alphap*rhop_new) / (omegap*rhop) rhop = 1*rhop_new @@ -1007,7 +1007,7 @@ def solve(self, b, out=None): pp += rp # new residual norm - res_sqr = r.dot(r).real + res_sqr = r.inner(r).real niter += 1 @@ -1097,7 +1097,7 @@ def solve(self, b, out=None): b : psydac.linalg.basic.Vector Right-hand-side vector of linear system. Individual entries b[i] need not be accessed, but b has 'shape' attribute and provides 'copy()' and - 'dot(p)' functions (dot(p) is the vector inner product b*p ); moreover, + 'inner(p)' functions (b.inner(p) is the vector inner product b*p); moreover, scalar multiplication and sum operations are available. out : psydac.linalg.basic.Vector | NoneType @@ -1167,7 +1167,7 @@ def solve(self, b, out=None): y *= -1.0 y.copy(out=res_old) # res = b - A*x - beta = sqrt(res_old.dot(res_old)) + beta = sqrt(res_old.inner(res_old)) # Initialize other quantities oldb = 0 @@ -1211,7 +1211,7 @@ def solve(self, b, out=None): if itn >= 2: y.mul_iadd(-(beta/oldb), res_old) - alfa = v.dot(y) + alfa = v.inner(y) y.mul_iadd(-(alfa/beta), res_new) # We put res_new in res_old and y in res_new @@ -1219,7 +1219,7 @@ def solve(self, b, out=None): y.copy(out=res_new) oldb = beta - beta = sqrt(res_new.dot(res_new)) + beta = sqrt(res_new.inner(res_new)) tnorm2 += alfa**2 + oldb**2 + beta**2 # Apply previous rotation Qk-1 to get @@ -1266,7 +1266,7 @@ def solve(self, b, out=None): # Estimate various norms and test for convergence. Anorm = sqrt(tnorm2) - ynorm = sqrt(x.dot(x)) + ynorm = sqrt(x.inner(x)) rnorm = phibar if ynorm == 0 or Anorm == 0:test1 = inf @@ -1416,7 +1416,7 @@ def solve(self, b, out=None): b : psydac.linalg.basic.Vector Right-hand-side vector of linear system. Individual entries b[i] need not be accessed, but b has 'shape' attribute and provides 'copy()' and - 'dot(p)' functions (dot(p) is the vector inner product b*p ); moreover, + 'inner(p)' functions (b.inner(p) is the vector inner product b*p); moreover, scalar multiplication and sum operations are available. out : psydac.linalg.basic.Vector | NoneType @@ -1482,16 +1482,16 @@ def solve(self, b, out=None): btol = tol b.copy(out=u) - normb = sqrt(b.dot(b).real) + normb = sqrt(b.inner(b).real) A.dot(x, out=u_work) u -= u_work - beta = sqrt(u.dot(u).real) + beta = sqrt(u.inner(u).real) if beta > 0: u *= (1 / beta) At.dot(u, out=v) - alpha = sqrt(v.dot(v).real) + alpha = sqrt(v.inner(v).real) else: x.copy(out=v) alpha = 0 @@ -1554,14 +1554,14 @@ def solve(self, b, out=None): u *= -alpha A.dot(v, out=u_work) u += u_work - beta = sqrt(u.dot(u).real) + beta = sqrt(u.inner(u).real) if beta > 0: u *= (1 / beta) v *= -beta At.dot(u, out=v_work) v += v_work - alpha = sqrt(v.dot(v).real) + alpha = sqrt(v.inner(v).real) if alpha > 0:v *= (1 / alpha) # At this point, beta = beta_{k+1}, alpha = alpha_{k+1}. @@ -1638,7 +1638,7 @@ def solve(self, b, out=None): # Compute norms for convergence testing. normar = abs(zetabar) - normx = sqrt(x.dot(x).real) + normx = sqrt(x.inner(x).real) # Now use these norms to estimate certain other quantities, # some of which will be small near a solution. @@ -1751,7 +1751,7 @@ def solve(self, b, out=None): b : psydac.linalg.basic.Vector Right-hand-side vector of linear system Ax = b. Individual entries b[i] need not be accessed, but b has 'shape' attribute and provides 'copy()' and - 'dot(p)' functions (dot(p) is the vector inner product b*p ); moreover, + 'inner(p)' functions (b.inner(p) is the vector inner product b*p); moreover, scalar multiplication and sum operations are available. out : psydac.linalg.basic.Vector | NoneType @@ -1803,7 +1803,7 @@ def solve(self, b, out=None): A.dot( x , out=r) r -= b - am = sqrt(r.dot(r).real) + am = sqrt(r.inner(r).real) if am < tol: self._info = {'niter': 1, 'success': am < tol, 'res_norm': am } return x @@ -1877,10 +1877,10 @@ def arnoldi(self, k, p): self._A.dot( self._Q[k] , out=p) # Krylov vector for i in range(k + 1): # Modified Gram-Schmidt, keeping Hessenberg matrix - h[i] = p.dot(self._Q[i]) + h[i] = p.inner(self._Q[i]) p.mul_iadd(-h[i], self._Q[i]) - h[k+1] = sqrt(p.dot(p).real) + h[k+1] = sqrt(p.inner(p).real) p /= h[k+1] # Normalize vector if len(self._Q) > k + 1: diff --git a/psydac/linalg/stencil.py b/psydac/linalg/stencil.py index 882d2c854..0db30d639 100644 --- a/psydac/linalg/stencil.py +++ b/psydac/linalg/stencil.py @@ -214,6 +214,56 @@ def zeros(self): """ return StencilVector(self) + #... + def inner(self, x, y): + """ + Evaluate the inner vector product between two vectors of this space V. + + If the field of V is real, compute the classical scalar product. + If the field of V is complex, compute the classical sesquilinear + product with linearity on the second vector. + + TODO [YG 01.05.2025]: Currently, the first vector is conjugated. We + want to reverse this behavior in order to align with the convention + of FEniCS. + + Parameters + ---------- + x : Vector + The first vector in the scalar product. In the case of a complex + field, the inner product is antilinear w.r.t. this vector (hence + this vector is conjugated). + + y : Vector + The second vector in the scalar product. The inner product is + linear w.r.t. this vector. + + Returns + ------- + float | complex + The scalar product of the two vectors. Note that inner(x, x) is + a non-negative real number which is zero if and only if x = 0. + + """ + + assert isinstance(x, StencilVector) + assert isinstance(y, StencilVector) + assert x.space is self + assert y.space is self + + inner_func = self._inner_func + inner_args = (x._data, y._data, *self._inner_consts) + + if self.parallel: + # Sometimes in the parallel case, we can get an empty vector that breaks our kernel + x._dot_send_data[0] = 0 if x._data.shape[0] == 0 else inner_func(*inner_args) + self.cart.global_comm.Allreduce((x._dot_send_data, self.mpi_type), + (x._dot_recv_data, self.mpi_type), + op=MPI.SUM ) + return x._dot_recv_data[0] + else: + return inner_func(*inner_args) + # ... def axpy(self, a, x, y): """ @@ -424,46 +474,47 @@ def __del__(self): def space(self): return self._space - #... - @property - def dtype(self): - return self._space.dtype - - #... - def dot(self, v): + # ... + def toarray(self, *, order='C', with_pads=False): """ - Return the inner vector product between self and v. - - If the values are real, it returns the classical scalar product. - If the values are complex, it returns the classical sesquilinear product with linearity on the vector v. + Return a numpy 1D array corresponding to the given StencilVector, + with or without pads. Parameters ---------- - v : StencilVector - Vector of the same space than self needed for the scalar product. + with_pads : bool + If True, include pads in output array (ignored in serial case). + + order: {'C','F'} + Memory representation of the data ‘C’ for row-major ordering (C-style), ‘F’ column-major ordering (Fortran-style). Returns ------- - null: self._space.dtype - Scalar containing scalar product of v and self. + array : numpy.ndarray + A copy of the data array collapsed into one dimension. """ - assert isinstance(v, StencilVector) - assert v._space is self._space + # In parallel case, call different functions based on 'with_pads' flag + if self.space.parallel: + if with_pads: + return self._toarray_parallel_with_pads(order=order) + else: + return self._toarray_parallel_no_pads(order=order) - inner_func = self._space._inner_func - inner_args = (self._data, v._data, *self._space._inner_consts) + # In serial case, ignore 'with_pads' flag + return self.toarray_local(order=order) - if self._space.parallel: - # Sometimes in the parallel case, we can get an empty vector that breaks our kernel - self._dot_send_data[0] = 0 if self._data.shape[0] == 0 else inner_func(*inner_args) - self._space.cart.global_comm.Allreduce((self._dot_send_data, self._space.mpi_type), - (self._dot_recv_data, self._space.mpi_type), - op=MPI.SUM ) - return self._dot_recv_data[0] - else: - return inner_func(*inner_args) + #... + def copy(self, out=None): + if self is out: + return self + w = out or StencilVector( self._space ) + np.copyto(w._data, self._data, casting='no') + for axis, ext in self._space.interfaces: + np.copyto(w._interface_data[axis, ext], self._interface_data[axis, ext], casting='no') + w._sync = self._sync + return w #... def conjugate(self, out=None): @@ -478,17 +529,6 @@ def conjugate(self, out=None): out._sync = self._sync return out - #... - def copy(self, out=None): - if self is out: - return self - w = out or StencilVector( self._space ) - np.copyto(w._data, self._data, casting='no') - for axis, ext in self._space.interfaces: - np.copyto(w._interface_data[axis, ext], self._interface_data[axis, ext], casting='no') - w._sync = self._sync - return w - #... def __neg__(self): w = StencilVector( self._space ) @@ -583,38 +623,6 @@ def __str__(self): txt += '> sync :: {sync}\n' .format( sync = self._sync ) return txt - # ... - def toarray(self, *, order='C', with_pads=False): - """ - Return a numpy 1D array corresponding to the given StencilVector, - with or without pads. - - Parameters - ---------- - with_pads : bool - If True, include pads in output array. - - order: {'C','F'} - Memory representation of the data ‘C’ for row-major ordering (C-style), ‘F’ column-major ordering (Fortran-style). - - Returns - ------- - array : numpy.ndarray - A copy of the data array collapsed into one dimension. - - """ - - - # In parallel case, call different functions based on 'with_pads' flag - if self.space.parallel: - if with_pads: - return self._toarray_parallel_with_pads(order=order) - else: - return self._toarray_parallel_no_pads(order=order) - - # In serial case, ignore 'with_pads' flag - return self.toarray_local(order=order) - # ... def toarray_local(self , *, order='C'): """ return the local array without the padding""" diff --git a/psydac/linalg/tests/test_block.py b/psydac/linalg/tests/test_block.py index e2e4c9640..ff3bf1e6c 100644 --- a/psydac/linalg/tests/test_block.py +++ b/psydac/linalg/tests/test_block.py @@ -328,10 +328,10 @@ def test_block_serial_dimension( ndim, p, P1, P2, P3, dtype ): Y[1] = y2 # Test dot product - exact_dot = x1.dot(y1)+x2.dot(y2) + exact_inner = V.inner(x1, y1) + V.inner(x2, y2) assert X.dtype == dtype - assert np.allclose(X.dot(Y), exact_dot, rtol=1e-14, atol=1e-14 ) + assert np.allclose(W.inner(X, Y), exact_inner, rtol=1e-14, atol=1e-14 ) # Test axpy product axpy_exact = X + np.pi * cst * Y @@ -372,8 +372,8 @@ def test_block_serial_dimension( ndim, p, P1, P2, P3, dtype ): M = BlockLinearOperator(W, W, blocks=[[M1, M2], [M3, None]]) - Y[0]=M1.dot(x1)+M2.dot(x2) - Y[1]=M3.dot(x1) + Y[0] = M1.dot(x1) + M2.dot(x2) + Y[1] = M3.dot(x1) assert M.dtype == dtype assert np.allclose((M.dot(X)).toarray(), Y.toarray(), rtol=1e-14, atol=1e-14 ) diff --git a/psydac/linalg/tests/test_linalg.py b/psydac/linalg/tests/test_linalg.py index 3e03e8185..56c826df1 100644 --- a/psydac/linalg/tests/test_linalg.py +++ b/psydac/linalg/tests/test_linalg.py @@ -643,26 +643,26 @@ def test_inverse_transpose_interaction(n1, n2, p1, p2, P1=False, P2=False): assert isinstance(C_T, ConjugateGradient) assert isinstance(inverse(B_T, 'cg', tol=tol), ConjugateGradient) diff = C_T @ u - inverse(B_T, 'cg', tol=tol) @ u - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 # -1,T,T -> equal -1 diff = C_T.T @ u - C @ u - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 # -1,T,-1 -> equal T assert isinstance(inverse(C_T, 'bicg'), BlockLinearOperator) diff = inverse(C_T, 'bicg') @ u - B_T @ u - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 # T,-1,-1 -> equal T assert isinstance(inverse(inverse(B_T, 'cg', tol=tol), 'pcg', pc=P), BlockLinearOperator) diff = inverse(inverse(B_T, 'cg', tol=tol), 'pcg', pc=P) @ u - B_T @ u - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 # T,-1,T -> equal -1 assert isinstance(inverse(B_T, 'cg', tol=tol).T, ConjugateGradient) diff = inverse(B_T, 'cg', tol=tol) @ u - C @ u - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 ### ### StencilMatrix Transpose - Inverse Tests @@ -680,26 +680,26 @@ def test_inverse_transpose_interaction(n1, n2, p1, p2, P1=False, P2=False): assert isinstance(C_T, ConjugateGradient) assert isinstance(inverse(S_T, 'cg', tol=tol), ConjugateGradient) diff = C_T @ v - inverse(S_T, 'cg', tol=tol) @ v - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 # -1,T,T -> equal -1 diff = C_T.T @ v - C @ v - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 # -1,T,-1 -> equal T assert isinstance(inverse(C_T, 'bicg'), StencilMatrix) diff = inverse(C_T, 'bicg') @ v - S_T @ v - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 # T,-1,-1 -> equal T assert isinstance(inverse(inverse(S_T, 'cg', tol=tol), 'pcg', pc=P), StencilMatrix) diff = inverse(inverse(S_T, 'cg', tol=tol), 'pcg', pc=P) @ v - S_T @ v - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 # T,-1,T -> equal -1 assert isinstance(inverse(S_T, 'cg', tol=tol).T, ConjugateGradient) diff = inverse(S_T, 'cg', tol=tol) @ v - C @ v - assert diff.dot(diff) == 0 + assert diff.inner(diff) == 0 #=============================================================================== @pytest.mark.parametrize('n1', [3, 5]) diff --git a/psydac/linalg/tests/test_stencil_interface_matrix.py b/psydac/linalg/tests/test_stencil_interface_matrix.py index 63001a766..60693952c 100644 --- a/psydac/linalg/tests/test_stencil_interface_matrix.py +++ b/psydac/linalg/tests/test_stencil_interface_matrix.py @@ -369,7 +369,7 @@ def test_stencil_interface_matrix_2d_parallel_dot(n1, n2, p1, p2, expected): y = A.dot(x) # Check the results - assert y.dot(y) == expected + assert y.inner(y) == expected #=============================================================================== # SCRIPT FUNCTIONALITY diff --git a/psydac/linalg/tests/test_stencil_vector.py b/psydac/linalg/tests/test_stencil_vector.py index 95200ba37..0983edf80 100644 --- a/psydac/linalg/tests/test_stencil_vector.py +++ b/psydac/linalg/tests/test_stencil_vector.py @@ -288,8 +288,8 @@ def test_stencil_vector_2d_serial_dot(dtype, n1, n2, p1, p2, s1, s2, P1=True, P2 y[i1, i2] = f2(i1,i2) # Create inner vector product (x,y) and (y,x) - z1 = x.dot(y) - z2 = y.dot(x) + z1 = x.inner(y) + z2 = y.inner(x) # Exact value by Numpy dot and vdot if dtype==complex: @@ -939,8 +939,8 @@ def test_stencil_vector_2d_parallel_dot(dtype, n1, n2, p1, p2, s1, s2, P1=True, y[i1, i2] = f2(i1,i2) # Create scalar product (x,y) and (y,x) - res1 = x.dot(y) - res2 = y.dot(x) + res1 = x.inner(y) + res2 = y.inner(x) # Compute exact value with Numpy dot if dtype==complex: @@ -1008,8 +1008,8 @@ def test_stencil_vector_3d_parallel_dot(dtype, n1, n2, n3, p1, p2, p3, s1, s2, s x[i1, i2, i3] = f2(i1,i2,i3) # Create scalar product (x,y) and (y,x) - res1 = x.dot(y) - res2 = y.dot(x) + res1 = x.inner(y) + res2 = y.inner(x) # Compute exact value with Numpy dot if dtype == complex: diff --git a/psydac/polar/dense.py b/psydac/polar/dense.py index 2e209f95e..963333665 100644 --- a/psydac/polar/dense.py +++ b/psydac/polar/dense.py @@ -137,14 +137,66 @@ def zeros(self): data = np.zeros(self.ncoeff, dtype=self.dtype) return DenseVector(self, data) + # ... + def inner(self, x, y): + """ + Evaluate the inner vector product between two vectors of this space V. + + If the field of V is real, compute the classical scalar product. + If the field of V is complex, compute the classical sesquilinear + product with linearity on the second vector. + + TODO [YG 01.05.2025]: Currently, the first vector is conjugated. We + want to reverse this behavior in order to align with the convention + of FEniCS. + + Parameters + ---------- + x : Vector + The first vector in the scalar product. In the case of a complex + field, the inner product is antilinear w.r.t. this vector (hence + this vector is conjugated). + + y : Vector + The second vector in the scalar product. The inner product is + linear w.r.t. this vector. + + Returns + ------- + float | complex + The scalar product of the two vectors. Note that inner(x, x) is + a non-negative real number which is zero if and only if x = 0. + + """ + assert isinstance(x, DenseVector) + assert isinstance(y, DenseVector) + assert x.space is self + assert y.space is self + + res = np.dot(x._data, y._data) + + V = self + if V.parallel: + if V.radial_comm.rank == V.radial_root: + res = V.tensor_comm.allreduce(res) + res = V.radial_comm.bcast(res, root=V.radial_root) + + return res + # ... def axpy(self, a, x, y): + + assert isinstance(a, (int, float, complex)) + assert isinstance(x, DenseVector) + assert isinstance(y, DenseVector) + assert x.space is self + assert y.space is self + y += a * x #------------------------------------- # Other properties/methods #------------------------------------- - @property def parallel(self): return (self._cart is not None) @@ -202,33 +254,8 @@ def space(self): return self._space # ... - @property - def dtype(self): - return self.space.dtype - - # ... - def dot(self, v): - assert isinstance(v, DenseVector) - assert v._space is self._space - - res = np.dot(self._data, v._data) - - V = self._space - if V.parallel: - if V.radial_comm.rank == V.radial_root: - res = V.tensor_comm.allreduce(res) - res = V.radial_comm.bcast(res, root=V.radial_root) - - return res - - def conjugate(self, out=None): - if out is not None: - assert isinstance(out, DenseVector) - assert out.space is self.space - else: - out = DenseVector(self.space) - np.conjugate(self._data, out=out._data, casting='no') - return out + def toarray(self, **kwargs): + return self._data.copy() # ... def copy(self, out=None): @@ -242,6 +269,16 @@ def copy(self, out=None): else: return DenseVector(self._space, self._data.copy()) + # ... + def conjugate(self, out=None): + if out is not None: + assert isinstance(out, DenseVector) + assert out.space is self.space + else: + out = DenseVector(self.space) + np.conjugate(self._data, out=out._data, casting='no') + return out + # ... def __neg__(self): return DenseVector(self._space, -self._data) @@ -284,10 +321,6 @@ def __isub__(self, v): #------------------------------------- # Other properties/methods #------------------------------------- - def toarray(self, **kwargs): - return self._data.copy() - - # ... def update_ghost_regions(self, *, direction=None): pass From f96e8883ef580b49fcfddc18a64ff031fe308c2a Mon Sep 17 00:00:00 2001 From: Martin Campos Pinto Date: Fri, 9 May 2025 17:19:09 +0200 Subject: [PATCH 06/23] Update docstring for inner product in polar/dense.py (#488) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --------- Co-authored-by: Yaman Güçlü --- psydac/polar/dense.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/psydac/polar/dense.py b/psydac/polar/dense.py index 963333665..302e0a529 100644 --- a/psydac/polar/dense.py +++ b/psydac/polar/dense.py @@ -150,12 +150,16 @@ def inner(self, x, y): want to reverse this behavior in order to align with the convention of FEniCS. + TODO [MCP 07.05.2025]: Actually, there is currently no conjugation, + since `numpy.dot` is being called. + Parameters ---------- x : Vector The first vector in the scalar product. In the case of a complex field, the inner product is antilinear w.r.t. this vector (hence this vector is conjugated). + NOTE [MCP 07.05.2025]: currently without conjugate, see np.dot y : Vector The second vector in the scalar product. The inner product is From 54ea3cea9c34af651b0f8864ca8b47b56534da47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 26 May 2025 09:38:19 +0200 Subject: [PATCH 07/23] Add a `dot_inner` concrete method to the base class `LinearOperator` (#493) Implement `M.dot(u).inner(v)`, or `(M @ u).inner(v)`, without creating a temporary vector. The result of the dot product is written to a local work vector stored in the `LinearOperator` object. This work vector is then used to compute the inner product with the vector `v`. The subclasses do not need to override this method, unless a more efficient implementation which avoids writing to the work vector altogether (reducing memory pressure) is needed. A unit test is added: function `test_dot_inner` in file psydac/linalg/tests/test_linalg.py. Additionally, the helper function `get_StencilVectorSpace` defined in psydac/linalg/tests/test_linalg.py (only used in the same file and in test_matrix_free.py) has a new signature and now works in any number of dimensions. Fixes #491. --- psydac/linalg/basic.py | 98 ++++++++++++++++++++++--- psydac/linalg/tests/test_linalg.py | 84 +++++++++++++++++---- psydac/linalg/tests/test_matrix_free.py | 6 +- 3 files changed, 159 insertions(+), 29 deletions(-) diff --git a/psydac/linalg/basic.py b/psydac/linalg/basic.py index bf88dbd07..f9f63406d 100644 --- a/psydac/linalg/basic.py +++ b/psydac/linalg/basic.py @@ -267,32 +267,51 @@ def shape(self): @abstractmethod def domain(self): """ The domain of the linear operator - an element of Vectorspace """ - pass @property @abstractmethod def codomain(self): """ The codomain of the linear operator - an element of Vectorspace """ - pass @property @abstractmethod def dtype(self): - pass + """ The data type of the coefficients of the linear operator, + upon convertion to matrix. + """ @abstractmethod def tosparse(self): - pass + """ Convert to a sparse matrix in any of the formats supported by scipy.sparse.""" @abstractmethod def toarray(self): """ Convert to Numpy 2D array. """ - pass @abstractmethod def dot(self, v, out=None): - """ Apply linear operator to Vector v. Result is written to Vector out, if provided.""" - pass + """ Apply the LinearOperator self to the Vector v. + + The result is written to the Vector out, if provided. + + Parameters + ---------- + v : Vector + The vector to which the linear operator (self) is applied. It must + belong to the domain of self. + + out : Vector + The vector in which the result of the operation is stored. It must + belong to the codomain of self. If out is None, a new vector is + created and returned. + + Returns + ------- + Vector + The result of the operation. If out is None, a new vector is + returned. Otherwise, the result is stored in out and out is + returned. + """ @abstractmethod def transpose(self, conjugate=False): @@ -301,7 +320,6 @@ def transpose(self, conjugate=False): If conjugate is True, return the Hermitian transpose. """ - pass # TODO: check if we should add a copy method!!! @@ -335,7 +353,32 @@ def __rmul__(self, c): return self * c def __matmul__(self, B): - """ Creates an object of the class ComposedLinearOperator. """ + """ + Matrix multiplication using the @ operator. + + If B is a LinearOperator, create a ComposedLinearOperator object. + This is simplified to self if B is an IdentityOperator, and to a + ZeroOperator if B is a ZeroOperator. + + If B is a Vector, the @ operator is treated as a matrix-vector + multiplication and returns the result of self.dot(B). + + Parameters + ---------- + B : LinearOperator | Vector + The object to be multiplied with self. If B is a LinearOperator, + its codomain must be equal to the domain of self. If B is a Vector, + it must belong to the domain of self. + + Returns + ------- + LinearOperator | Vector + If B is a LinearOperator, return a ComposedLinearOperator object, + or a simplification to self or a ZeroOperator. In all cases the + resulting LinearOperator has the same domain as self and the same + codomain as B. If B is a Vector, return the result of self.dot(B), + which is a Vector belonging to the codomain of self. + """ assert isinstance(B, (LinearOperator, Vector)) if isinstance(B, LinearOperator): assert self.domain == B.codomain @@ -380,7 +423,6 @@ def __itruediv__(self, c): #------------------------------------- # Methods with default implementation #------------------------------------- - @property def T(self): """ Calls transpose method to return the transpose of self. """ @@ -403,6 +445,42 @@ def idot(self, v, out): assert out.space == self.codomain out += self.dot(v) + def dot_inner(self, v, w): + """ + Compute the inner product of (self @ v) with w, without a temporary. + + This is equivalent to self.dot(v).inner(w), but avoids the creation of + a temporary vector because the result of self.dot(v) is stored in a + local work array. If self is a positive-definite operator, this + operation is a (weighted) inner product. + + Parameters + ---------- + v : Vector + The vector to which the linear operator (self) is applied. It must + belong to the domain of self. + + w : Vector + The second vector in the inner product. It must belong to the + codomain of self. + + Returns + ------- + float | complex + The result of the inner product between (self @ v) and w. If the + field of self is real, this is a real number. If the field of self + is complex, this is a complex number. + """ + assert isinstance(v, Vector) + assert isinstance(w, Vector) + assert v.space is self.domain + assert w.space is self.codomain + + if not hasattr(self, '_work'): + self._work = self.codomain.zeros() + + return self.dot(v, out=self._work).inner(w) + #=============================================================================== class ZeroOperator(LinearOperator): """ diff --git a/psydac/linalg/tests/test_linalg.py b/psydac/linalg/tests/test_linalg.py index 56c826df1..8ce093094 100644 --- a/psydac/linalg/tests/test_linalg.py +++ b/psydac/linalg/tests/test_linalg.py @@ -40,13 +40,12 @@ def compute_global_starts_ends(domain_decomposition, npts): return global_starts, global_ends -def get_StencilVectorSpace(n1, n2, p1, p2, P1, P2): - npts = [n1, n2] - pads = [p1, p2] - periods = [P1, P2] +def get_StencilVectorSpace(npts, pads, periods): + assert len(npts) == len(pads) == len(periods) + shifts = [1] * len(npts) D = DomainDecomposition(npts, periods=periods) global_starts, global_ends = compute_global_starts_ends(D, npts) - C = CartDecomposition(D, npts, global_starts, global_ends, pads=pads, shifts=[1,1]) + C = CartDecomposition(D, npts, global_starts, global_ends, pads=pads, shifts=shifts) V = StencilVectorSpace(C) return V @@ -94,7 +93,7 @@ def test_square_stencil_basic(n1, n2, p1, p2, P1=False, P2=False): ### # Initiate StencilVectorSpace - V = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) # Initiate Linear Operators Z = ZeroOperator(V, V) @@ -280,7 +279,7 @@ def test_square_block_basic(n1, n2, p1, p2, P1=False, P2=False): # 3. Test special cases # Initiate StencilVectorSpace - V = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) # Initiate Linear Operators Z = ZeroOperator(V, V) @@ -449,8 +448,8 @@ def test_in_place_operations(n1, n2, p1, p2, P1=False, P2=False): # testing __imul__ although not explicitly implemented (in the LinearOperator class) # Initiate StencilVectorSpace - V = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) - Vc = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) + Vc = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) Vc._dtype = complex v = StencilVector(V) vc = StencilVector(Vc) @@ -544,9 +543,9 @@ def test_inverse_transpose_interaction(n1, n2, p1, p2, P1=False, P2=False): # 2. For both B and S, check whether all possible combinations of the transpose and the inverse behave as expected # Initiate StencilVectorSpace - V = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) - V2 = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) - W = get_StencilVectorSpace(n1+2, n2, p1, p2+1, P1, P2) + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) + V2 = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) + W = get_StencilVectorSpace([n1+2, n2], [p1, p2+1], [P1, P2]) # Initiate positive definite StencilMatrices for which the cg inverse works (necessary for certain tests) S = StencilMatrix(V, V) @@ -710,7 +709,7 @@ def test_inverse_transpose_interaction(n1, n2, p1, p2, P1=False, P2=False): def test_positive_definite_matrix(n1, n2, p1, p2): P1 = False P2 = False - V = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) S = get_positive_definite_StencilMatrix(V) assert_pos_def(S) @@ -753,7 +752,7 @@ def test_operator_evaluation(n1, n2, p1, p2): P2 = False # Initiate StencilVectorSpace V - V = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) # Initiate positive definite StencilMatrices for which the cg inverse works (necessary for certain tests) S = get_positive_definite_StencilMatrix(V) @@ -919,7 +918,7 @@ def test_internal_storage(): p2=1 P1=False P2=False - V = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) U1 = BlockVectorSpace(V, V) U2 = BlockVectorSpace(V, V, V) @@ -970,7 +969,7 @@ def test_x0update(solver): p2 = 2 P1 = False P2 = False - V = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) A = get_positive_definite_StencilMatrix(V) assert_pos_def(A) b = StencilVector(V) @@ -1005,6 +1004,59 @@ def test_x0update(solver): x = A_inv.dot(b, out=b) assert A_inv.get_options('x0') is x +#=============================================================================== +def test_dot_inner(): + + n1, n2 = 4, 7 + p1, p2 = 2, 3 + P1, P2 = False, False + + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) + M = get_positive_definite_StencilMatrix(V) + N = get_positive_definite_StencilMatrix(V) + + U1 = BlockVectorSpace(V, V) + U2 = BlockVectorSpace(V, V, V) + A = BlockLinearOperator(U1, U2, ((M, None), + (M, N), + (None, N))) + + b = A.domain.zeros() + c = A.codomain.zeros() + + # Set the values of b and c randomly from a uniform distribution over the + # interval [0, 1) + rng = np.random.default_rng(seed=42) + for bj in b: + Vj = bj.space + rng.random(size=Vj.shape, dtype=Vj.dtype, out=bj._data) + for ci in c: + Vi = ci.space + rng.random(size=Vi.shape, dtype=Vi.dtype, out=ci._data) + + # Create a work vector for the dot product, needed to compare results + work_vec = A.codomain.zeros() + + # Result of dot product is a temporary vector, which is allocated and then + # discarded. This is the default behavior of the dot method. + r0 = A.dot(b).inner(c) + + # Result of dot product is stored in work_vec and used in the next line + A.dot(b, out=work_vec) + r1 = work_vec.inner(c) + + # Result of dot product is stored in work_vec and used in the same line + r2 = A.dot(b, out=work_vec).inner(c) + + # Calling the dot_inner method, which uses an internal work vector to store + # the result of the dot product, and then uses it for the inner product. + r3 = A.dot_inner(b, c) + + # Check if the results are equal + assert r0 == r1 + assert r0 == r2 + assert r0 == r3 + #=============================================================================== # SCRIPT FUNCTIONALITY #=============================================================================== diff --git a/psydac/linalg/tests/test_matrix_free.py b/psydac/linalg/tests/test_matrix_free.py index e475fb15f..43531cbaf 100644 --- a/psydac/linalg/tests/test_matrix_free.py +++ b/psydac/linalg/tests/test_matrix_free.py @@ -63,8 +63,8 @@ def test_fake_matrix_free(n1, n2, p1, p2): m2 = n1+1 q1 = p1 # using same degrees because both spaces must have same padding for now q2 = p2 - V1 = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) - V2 = get_StencilVectorSpace(m1, m2, q1, q2, P1, P2) + V1 = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) + V2 = get_StencilVectorSpace([m1, m2], [q1, q2], [P1, P2]) S = get_random_StencilMatrix(codomain=V2, domain=V1) O = MatrixFreeLinearOperator(codomain=V2, domain=V1, dot=lambda v: S @ v) @@ -91,7 +91,7 @@ def test_solvers_matrix_free(solver): p2 = 2 P1 = False P2 = False - V = get_StencilVectorSpace(n1, n2, p1, p2, P1, P2) + V = get_StencilVectorSpace([n1, n2], [p1, p2], [P1, P2]) A_SM = get_positive_definite_StencilMatrix(V) assert_pos_def(A_SM) AT_SM = A_SM.transpose() From 5e3d7699734a0f074441fb2c157a6ba247c2d44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 30 May 2025 08:51:42 +0200 Subject: [PATCH 08/23] Install PETSc-3.23.2 (latest version) (#499) --- .github/workflows/{continuous-integration.yml => testing.yml} | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) rename .github/workflows/{continuous-integration.yml => testing.yml} (97%) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/testing.yml similarity index 97% rename from .github/workflows/continuous-integration.yml rename to .github/workflows/testing.yml index ae4c2e09d..115ae117f 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/testing.yml @@ -66,6 +66,7 @@ jobs: - name: Install non-Python dependencies on macOS if: matrix.os == 'macos-14' run: | + brew install make brew install open-mpi brew install hdf5-mpi brew install libomp @@ -79,6 +80,7 @@ jobs: ln -s ${gfort_path} ${folder}/gfortran fi echo "MPI_OPTS=--oversubscribe" >> $GITHUB_ENV + echo "/opt/homebrew/opt/make/libexec/gnubin" >> $GITHUB_PATH - name: Print information on MPI and HDF5 libraries run: | @@ -111,7 +113,7 @@ jobs: - if: steps.cache-petsc.outputs.cache-hit != 'true' name: Download a specific release of PETSc run: | - git clone --depth 1 --branch v3.22.2 https://gitlab.com/petsc/petsc.git + git clone --depth 1 --branch v3.23.2 https://gitlab.com/petsc/petsc.git - if: steps.cache-petsc.outputs.cache-hit != 'true' name: Install PETSc with complex support From 2f242b1bd66f674fabc4134a4a430eb667813792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 30 May 2025 16:25:59 +0200 Subject: [PATCH 09/23] Update authors (#500) --- AUTHORS | 20 +++++++++++++------- pyproject.toml | 4 ++-- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/AUTHORS b/AUTHORS index c09d4ae7f..609b8c3e5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,16 +1,22 @@ Maintainers ----------- -* Yaman Güçlü (administrator, owner) -* Ahmed Ratnani (owner) -* Said Hadjout +* Yaman Güçlü (original author, project lead) +* Martin Campos Pinto +* Ahmed Ratnani (original author) Contributors ------------ -* Jalal Lakhlili -* Martin Campos Pinto -* Antoine Lavandier -* David Schneller +* Said Hadjout (original author) * Julian Owezarek +* Antoine Lavandier * Tom Caruso * Elena Moral Sánchez +* Paul Rigor +* Frederik Schnack +* David Schneller * Valentin Carlier +* Stefan Possanner +* Jalal Lakhlili (original author) +* William Barham +* Max Lindqvist +* Emily Bourne diff --git a/pyproject.toml b/pyproject.toml index 43d4c527a..2d42320d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,9 @@ authors = [ {name = "Psydac development team", email = "psydac@googlegroups.com"} ] maintainers = [ - {name = "Yaman Güçlü" , email = "yaman.guclu@gmail.com"}, + {name = "Yaman Güçlü", email = "yaman.guclu@gmail.com"}, + {name = "Martin Campos Pinto", email = "martin.campos-pinto@ipp.mpg.de"}, {name = "Ahmed Ratnani", email = "ratnaniahmed@gmail.com"}, - {name = "Said Hadjout"}, ] keywords = ["FEM", "IGA", "B-spline", "NURBS"] classifiers = ["Programming Language :: Python :: 3"] From b5230b42ba92864748d12c5efe7b05214505205e Mon Sep 17 00:00:00 2001 From: Frederik Schnack Date: Fri, 13 Jun 2025 14:27:01 +0200 Subject: [PATCH 10/23] Fix the code generation of unnecessary derivatives (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR changes the code generation involved in the `discretize` function to avoid calculating unnecessary derivatives. This allows for using constant spline (degree 0) discretizations, for which we have to make a special case in the calculation of ghost regions in `psydac/linalg/stencil.py`. Additionally, we add basic unit tests checking discretizations using zero-degree splines. This fixes #489 and fixes #307. On this occasion, we also do small changes in the `.github/workflows/testing.yml` script as some tests had complications without them. --------- Co-authored-by: Yaman Güçlü --- .github/workflows/testing.yml | 4 +-- psydac/api/ast/expr.py | 12 ++++--- psydac/api/ast/fem.py | 18 ++++++---- psydac/api/ast/glt.py | 14 +++++--- psydac/api/ast/nodes.py | 1 - psydac/api/ast/utilities.py | 54 ++++++++++++++++++++++++++++ psydac/api/tests/test_api_feec_1d.py | 53 +++++++++++++++++++++++++++ psydac/api/tests/test_assembly.py | 54 ++++++++++++++++++++++++++++ psydac/linalg/stencil.py | 37 +++++++++++++++---- 9 files changed, 222 insertions(+), 25 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 115ae117f..802657cd0 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -87,9 +87,9 @@ jobs: ompi_info h5pcc -showconfig -echo || true - - name: Upgrade pip + - name: Upgrade pip, setuptools, and wheel run: | - python -m pip install --upgrade pip + python -m pip install --upgrade pip setuptools wheel - name: Determine directory of parallel HDF5 library run: | diff --git a/psydac/api/ast/expr.py b/psydac/api/ast/expr.py index 0e72504fd..4805b85d7 100644 --- a/psydac/api/ast/expr.py +++ b/psydac/api/ast/expr.py @@ -22,7 +22,6 @@ from sympde.topology.space import IndexedVectorFunction from sympde.topology.derivatives import _partial_derivatives from sympde.topology.derivatives import _logical_partial_derivatives -from sympde.topology.derivatives import get_max_partial_derivatives from sympde.topology.derivatives import get_atom_derivatives from sympde.topology.derivatives import get_index_derivatives from sympde.topology import LogicalExpr @@ -34,6 +33,7 @@ from .utilities import build_pythran_types_header, variables from .utilities import build_pyccel_type_annotations from .utilities import math_atoms_as_str +from .utilities import get_max_partial_derivatives from psydac.fem.vector import MultipatchFemSpace from .nodes import Zeros @@ -306,15 +306,19 @@ def _initialize(self): cls = IndexedVariable ) # ... TODO add it as a method to basic class - nderiv = 1 + nderiv = 0 + if isinstance(expr, Matrix): n_rows, n_cols = expr.shape for i_row in range(0, n_rows): for i_col in range(0, n_cols): - d = get_max_partial_derivatives(expr[i_row,i_col]) + d_atoms = _atomic(expr[i_row,i_col], cls=atoms_types) + Fs = [get_atom_derivatives(a) for a in d_atoms] + d = get_max_partial_derivatives(expr[i_row,i_col], logical=False, F=Fs) nderiv = max(nderiv, max(d.values())) else: - d = get_max_partial_derivatives(expr) + Fs = [get_atom_derivatives(a) for a in atoms] + d = get_max_partial_derivatives(expr, logical=False, F=Fs) nderiv = max(nderiv, max(d.values())) self._max_nderiv = nderiv diff --git a/psydac/api/ast/fem.py b/psydac/api/ast/fem.py index 1743518cd..b56ee3d28 100644 --- a/psydac/api/ast/fem.py +++ b/psydac/api/ast/fem.py @@ -11,7 +11,7 @@ from sympde.topology.basic import Boundary, Interface from sympde.topology import H1SpaceType, HcurlSpaceType, HdivSpaceType, L2SpaceType, UndefinedSpaceType, IdentityMapping from sympde.topology.space import ScalarFunction, VectorFunction, IndexedVectorFunction -from sympde.topology.derivatives import _logical_partial_derivatives, get_max_logical_partial_derivatives +from sympde.topology.derivatives import _logical_partial_derivatives, get_atom_logical_derivatives from sympde.topology.mapping import InterfaceMapping from sympde.calculus.core import is_zero, PlusInterfaceOperator @@ -44,7 +44,7 @@ from .nodes import GlobalThreadStarts, GlobalThreadEnds, GlobalThreadSizes from .nodes import Allocate, Array from .nodes import Block, ParallelBlock - +from .utilities import get_max_partial_derivatives from psydac.api.ast.utilities import variables from psydac.api.utilities import flatten @@ -339,7 +339,7 @@ def __init__(self, expr, terminal_expr, spaces, *, nquads, mapping_space=None, t fields = expand_hdiv_hcurl(fields) kwargs['nquads'] = nquads atoms_types = (ScalarFunction, VectorFunction, IndexedVectorFunction) - nderiv = 1 + nderiv = 0 terminal_expr = terminal_expr.expr if isinstance(terminal_expr, (ImmutableDenseMatrix, Matrix)): @@ -347,8 +347,6 @@ def __init__(self, expr, terminal_expr, spaces, *, nquads, mapping_space=None, t atomic_expr_field = {f:[] for f in fields} for i_row in range(0, n_rows): for i_col in range(0, n_cols): - d = get_max_logical_partial_derivatives(terminal_expr[i_row,i_col]) - nderiv = max(nderiv, max(d.values())) atoms = _atomic(terminal_expr[i_row, i_col], cls=atoms_types+_logical_partial_derivatives) #-------------------------------------------------------------------- # TODO [YG, 05.02.2021]: create 'get_test_function' and use it below: @@ -359,10 +357,12 @@ def __init__(self, expr, terminal_expr, spaces, *, nquads, mapping_space=None, t a = _atomic(f, cls=atoms_types) assert len(a) == 1 atomic_expr_field[a[0]].append(f) + + Fs = [get_atom_logical_derivatives(a) for a in atoms] + d = get_max_partial_derivatives(terminal_expr[i_row,i_col], logical=True, F=Fs) + nderiv = max(nderiv, max(d.values())) else: - d = get_max_logical_partial_derivatives(terminal_expr) - nderiv = max(nderiv, max(d.values())) atoms = _atomic(terminal_expr, cls=atoms_types+_logical_partial_derivatives) #-------------------------------------------------------------------- # TODO [YG, 05.02.2021]: create 'get_test_function' and use it below: @@ -375,6 +375,10 @@ def __init__(self, expr, terminal_expr, spaces, *, nquads, mapping_space=None, t assert len(a) == 1 atomic_expr_field[a[0]].append(f) + Fs = [get_atom_logical_derivatives(a) for a in atoms] + d = get_max_partial_derivatives(terminal_expr, logical=True, F=Fs) + nderiv = max(nderiv, max(d.values())) + terminal_expr = Matrix([[terminal_expr]]) d_tests = {v: {'global': GlobalTensorQuadratureTestBasis(v), diff --git a/psydac/api/ast/glt.py b/psydac/api/ast/glt.py index f0fc64ee8..00c80a1c4 100644 --- a/psydac/api/ast/glt.py +++ b/psydac/api/ast/glt.py @@ -26,8 +26,7 @@ from sympde.topology.space import VectorFunction from sympde.topology.space import IndexedVectorFunction from sympde.topology.derivatives import _partial_derivatives -from sympde.topology.derivatives import _logical_partial_derivatives -from sympde.topology.derivatives import get_max_partial_derivatives +from sympde.topology.derivatives import _logical_partial_derivatives, get_atom_derivatives from sympde.topology import LogicalExpr from sympde.topology import SymbolicExpr from sympde.calculus.matrices import SymbolicDeterminant @@ -43,6 +42,7 @@ from .utilities import build_pyccel_type_annotations from .utilities import is_mapping from .utilities import math_atoms_as_str +from .utilities import get_max_partial_derivatives from .evaluation import EvalArrayMapping, EvalArrayField from psydac.fem.vector import MultipatchFemSpace @@ -329,15 +329,19 @@ def _initialize(self, **kwargs): # ... # ... TODO add it as a method to basic class - nderiv = 1 + nderiv = 0 + if isinstance(expr, Matrix): n_rows, n_cols = expr.shape for i_row in range(0, n_rows): for i_col in range(0, n_cols): - d = get_max_partial_derivatives(expr[i_row,i_col]) + d_atoms = _atomic(expr[i_row,i_col], cls=atoms_types) + Fs = [get_atom_derivatives(a) for a in d_atoms] + d = get_max_partial_derivatives(expr[i_row,i_col], logical=False, F=Fs) nderiv = max(nderiv, max(d.values())) else: - d = get_max_partial_derivatives(expr) + Fs = [get_atom_derivatives(a) for a in atoms] + d = get_max_partial_derivatives(expr, logical=False, F=Fs) nderiv = max(nderiv, max(d.values())) self._max_nderiv = nderiv diff --git a/psydac/api/ast/nodes.py b/psydac/api/ast/nodes.py index 8a1cd5e63..caa76b554 100644 --- a/psydac/api/ast/nodes.py +++ b/psydac/api/ast/nodes.py @@ -2372,7 +2372,6 @@ def __new__(cls, M, nderiv, dtype='real'): if not M.is_analytical: dim = M.ldim - nderiv = 1 if nderiv == 0 else nderiv ops = [dx1, dx2, dx3][:dim] r = range(nderiv+1) ranges = [r]*dim diff --git a/psydac/api/ast/utilities.py b/psydac/api/ast/utilities.py index b4601b27d..61056c6b1 100644 --- a/psydac/api/ast/utilities.py +++ b/psydac/api/ast/utilities.py @@ -1,6 +1,7 @@ import re import string import random +from itertools import chain from sympy import Symbol, IndexedBase, Indexed, Idx from sympy import Mul, Pow, Function, Tuple @@ -19,6 +20,7 @@ from sympde.topology.derivatives import get_index_derivatives from sympde.topology.derivatives import get_atom_logical_derivatives from sympde.topology.derivatives import get_index_logical_derivatives +from sympde.topology.derivatives import get_index_derivatives_atom, get_index_logical_derivatives_atom from sympde.topology import LogicalExpr from sympde.topology import SymbolicExpr from sympde.core import Constant @@ -55,6 +57,58 @@ 'variables', ) +#============================================================================== +def get_max_partial_derivatives(expr, logical=False, F=None): + """ + Compute the maximum order of partial derivatives for each coordinate in an expression. + + TODO + ---- + Move to SymPDE and combine the `get_index(_logical)_derivatives_atom` functions there. + + Parameters + ---------- + expr : sympy.Expr + The SymPDE expression to analyze for partial derivatives. + + logical : bool, optional + If True, it considers logical coordinates (x1, x2, x3); otherwise, it considers physical coordinates (x, y, z). + + F : sympy.Atom | list[sympy.Atom], optional + If provided, it restricts the analysis to the specified atom(s). Otherwise, + it uses all atoms of default types that are contained in `expr`. The default + types represent elements of function spaces in SymPDE: `ScalarFunction`, + `VectorFunction`, and `IndexedVectorFunction`. + + Returns + ------- + d : dict[str, int] + A dictionary with keys ('x1', 'x2', 'x3') for logical or ('x', 'y', 'z') for physical coordinates and their corresponding maximum order of partial derivatives. + """ + + if logical: + d = {'x1': 0, 'x2': 0, 'x3': 0} + get_index = get_index_logical_derivatives_atom + else: + d = {'x': 0, 'y': 0, 'z': 0} + get_index = get_index_derivatives_atom + + if F is None: + F = (list(expr.atoms(ScalarFunction)) + + list(expr.atoms(VectorFunction)) + + list(expr.atoms(IndexedVectorFunction))) + elif not hasattr(F, '__iter__'): + F = [F] + + indices = chain.from_iterable(get_index(expr, Fi) for Fi in F) + + for dd in indices: + for k, v in dd.items(): + if v > d[k]: + d[k] = v + + return d + #============================================================================== def random_string( n ): chars = string.ascii_lowercase + string.digits diff --git a/psydac/api/tests/test_api_feec_1d.py b/psydac/api/tests/test_api_feec_1d.py index d9337f4a9..ad60a2c02 100644 --- a/psydac/api/tests/test_api_feec_1d.py +++ b/psydac/api/tests/test_api_feec_1d.py @@ -499,6 +499,32 @@ def test_maxwell_1d_periodic(): assert abs(namespace['error_E'] - ref['error_E']) / ref['error_E'] <= TOL assert abs(namespace['error_B'] - ref['error_B']) / ref['error_B'] <= TOL + +def test_maxwell_1d_periodic_deg_1(): + + namespace = run_maxwell_1d( + L = 1.0, + eps = 0.5, + ncells = 30, + degree = 1, + periodic = True, + Cp = 0.5, + nsteps = 1, + tend = None, + splitting_order = 2, + plot_interval = 0, + diagnostics_interval = 0, + tol = 1e-6, + bc_mode = None, + verbose = False + ) + + TOL = 1e-6 + ref = dict(error_E = 0.0072675885645832605, + error_B = 0.06600368653495972) + + assert abs(namespace['error_E'] - ref['error_E']) / ref['error_E'] <= TOL + assert abs(namespace['error_B'] - ref['error_B']) / ref['error_B'] <= TOL def test_maxwell_1d_periodic_mult(): @@ -608,6 +634,33 @@ def test_maxwell_1d_periodic_par(): assert abs(namespace['error_l2_E'] - ref['error_l2_E']) / ref['error_l2_E'] <= TOL assert abs(namespace['error_l2_B'] - ref['error_l2_B']) / ref['error_l2_B'] <= TOL +@pytest.mark.parallel +def test_maxwell_1d_periodic_par_deg_1(): + + namespace = run_maxwell_1d( + L = 1.0, + eps = 0.5, + ncells = 30, + degree = 1, + periodic = True, + Cp = 0.5, + nsteps = 1, + tend = None, + splitting_order = 2, + plot_interval = 0, + diagnostics_interval = 0, + tol = 1e-6, + bc_mode = None, + verbose = False + ) + + TOL = 1e-6 + ref = dict(error_l2_E = 0.0024771477815803107, + error_l2_B = 0.016857601931265228) + + assert abs(namespace['error_l2_E'] - ref['error_l2_E']) / ref['error_l2_E'] <= TOL + assert abs(namespace['error_l2_B'] - ref['error_l2_B']) / ref['error_l2_B'] <= TOL + @pytest.mark.parallel def test_maxwell_1d_dirichlet_strong_par(): diff --git a/psydac/api/tests/test_assembly.py b/psydac/api/tests/test_assembly.py index d00c58840..a84a41a2b 100644 --- a/psydac/api/tests/test_assembly.py +++ b/psydac/api/tests/test_assembly.py @@ -84,6 +84,60 @@ def test_field_and_constant(backend, dtype): assert abs(b.toarray().sum() - res) < 1e-12 print("PASSED") +#============================================================================== +def test_field_and_constant_deg_0(backend, dtype): + + # If 'backend' is specified, accelerate Python code by passing **kwargs + # to discretization of bilinear forms, linear forms and functionals. + kwargs = {'backend': PSYDAC_BACKENDS[backend]} if backend else {} + + domain = Square() + V = ScalarFunctionSpace('V', domain) + + # TODO: remove codomain_type when It is implemented in sympde + u = element_of(V, name='u') + v = element_of(V, name='v') + f = element_of(V, name='f') + + if dtype == 'complex': + c = Constant(name='c', complex=True) + V.codomain_type = dtype + g = I * c * f**2 + res = 1.j + cst=complex(1.0) + else: + c = Constant(name='c', real=True) + g = c * f**2 + res = 1 + cst=1.0 + + a = BilinearForm((u, v), integral(domain, g * u * v)) + l = LinearForm(v, integral(domain, g * v)) + + ncells = (5, 5) + degree = (0, 0) + domain_h = discretize(domain, ncells=ncells) + Vh = discretize(V, domain_h, degree=degree) + ah = discretize(a, domain_h, [Vh, Vh], **kwargs) + lh = discretize(l, domain_h, Vh , **kwargs) + + fh = FemField(Vh) + fh.coeffs[:] = 1 + + # Assembly call should not crash if correct arguments are used + A = ah.assemble(c=cst, f=fh) + b = lh.assemble(f=fh, c=cst) + + # Test matrix A + x = fh.coeffs + + #TODO change res into np.conj(res) when the conjugate is applied in the dot product in sympde + assert abs(x.inner(A.dot(x)) - res) < 1e-12 + + # Test vector b + assert abs(b.toarray().sum() - res) < 1e-12 + print("PASSED") + #============================================================================== def test_bilinearForm_complex(backend): diff --git a/psydac/linalg/stencil.py b/psydac/linalg/stencil.py index 0db30d639..73599655a 100644 --- a/psydac/linalg/stencil.py +++ b/psydac/linalg/stencil.py @@ -626,14 +626,13 @@ def __str__(self): # ... def toarray_local(self , *, order='C'): """ return the local array without the padding""" - - idx = tuple( slice(m*p,-m*p) for p,m in zip(self.pads, self.space.shifts) ) + idx = tuple( slice(m*p,-m*p) if p != 0 else slice(0, None) for p,m in zip(self.pads, self.space.shifts) ) return self._data[idx].flatten( order=order) # ... def _toarray_parallel_no_pads(self, order='C'): a = np.zeros( self.space.npts, self.dtype ) - idx_from = tuple( slice(m*p,-m*p) for p,m in zip(self.pads, self.space.shifts) ) + idx_from = tuple( slice(m*p,-m*p) if p != 0 else slice(0, None) for p,m in zip(self.pads, self.space.shifts) ) idx_to = tuple( slice(s,e+1) for s,e in zip(self.starts,self.ends) ) a[idx_to] = self._data[idx_from] return a.flatten( order=order) @@ -664,6 +663,9 @@ def _toarray_parallel_with_pads(self, order='C'): p = pads[direction] + if p == 0: + continue + # Left-most process: copy data from left to right if coord == 0: idx_from = tuple( @@ -689,7 +691,7 @@ def _toarray_parallel_with_pads(self, order='C'): a[idx_to] = a[idx_from] # Step 3: remove ghost regions from global array - idx = tuple( slice(p,-p) for p in pads ) + idx = tuple( slice(p,-p) if p != 0 else slice(0, None) for p in pads ) out = a[idx] # Step 4: return flattened array @@ -776,6 +778,9 @@ def _update_ghost_regions_serial(self): periodic = self._space.periods[direction] p = self._space.pads [direction] * self._space.shifts[direction] + if p == 0: + continue + idx_front = [slice(None)] * direction idx_back = [slice(None)] * (ndim-direction-1) @@ -820,6 +825,10 @@ def exchange_assembly_data(self): p = self._space.pads [direction] m = self._space.shifts[direction] + + if p == 0: + continue + idx_from = tuple(idx_front + [slice(-m*p,None) if (-m*p+p)!=0 else slice(-m*p,None)] + idx_back) self._data[idx_from] = 0. idx_from = tuple(idx_front + [slice(0,m*p)] + idx_back) @@ -835,6 +844,9 @@ def _exchange_assembly_data_serial(self): p = self._space.pads [direction] m = self._space.shifts [direction] + if p == 0: + continue + if periodic: idx_front = [slice(None)] * direction idx_back = [slice(None)] * (ndim-direction-1) @@ -1355,6 +1367,10 @@ def exchange_assembly_data(self): p = self._codomain.pads [direction] m = self._codomain.shifts[direction] + + if p == 0: + continue + idx_from = tuple( idx_front + [ slice(-m*p,None) if (-m*p+p)!=0 else slice(-m*p,None)] + idx_back ) self._data[idx_from] = 0. idx_from = tuple( idx_front + [ slice(0,m*p)] + idx_back ) @@ -1370,6 +1386,9 @@ def _exchange_assembly_data_serial(self): p = self._codomain.pads [direction] m = self._codomain.shifts[direction] + if p == 0: + continue + if periodic: idx_front = [slice(None)]*direction idx_back = [slice(None)]*(ndim-direction-1) @@ -1548,7 +1567,7 @@ def _tocoo_no_pads(self , order='C'): pp = [np.int64(compute_diag_len(p,mj,mi)-(p+1)) for p,mi,mj in zip(self._pads, cm, dm)] # Range of data owned by local process (no ghost regions) - local = tuple( [slice(mi*p,-mi*p) for p,mi in zip(cpads, cm)] + [slice(None)] * nd ) + local = tuple( [slice(mi*p,-mi*p) if p != 0 else slice(p, None) for p,mi in zip(cpads, cm)] + [slice(None)] * nd ) size = self._data[local].size # COO storage @@ -1681,6 +1700,9 @@ def _update_ghost_regions_serial(self): periodic = self._codomain.periods[direction] p = self._codomain.pads [direction] + if p == 0: + continue + idx_front = [slice(None)]*direction idx_back = [slice(None)]*(ndim-direction-1 + ndim) @@ -2598,7 +2620,7 @@ def _tocoo_no_pads(self): cols = [] data = [] # Range of data owned by local process (no ghost regions) - local = tuple( [slice(m*p,-m*p) for m,p in zip(cm, pp)] + [slice(None)] * nd ) + local = tuple( [slice(m*p,-m*p) if p != 0 else slice(0, None) for m,p in zip(cm, pp)] + [slice(None)] * nd ) pp = [compute_diag_len(p,mj,mi)-(p+1) for p,mi,mj in zip(self._pads, cm, dm)] for (index,value) in np.ndenumerate( self._data[local] ): @@ -2658,6 +2680,9 @@ def _update_ghost_regions_serial(self, direction: int): periodic = self._codomain.periods[direction] p = self._codomain.pads [direction] + if p == 0: + return + idx_front = [slice(None)] * direction idx_back = [slice(None)] * (ndim-direction-1) From f38741084f6f0ad5c108b5b74edb1c60a4aa1c6b Mon Sep 17 00:00:00 2001 From: Frederik Schnack Date: Wed, 18 Jun 2025 17:53:14 +0200 Subject: [PATCH 11/23] Fix bug in `allocate_matrices` in `DiscreteBilinearForm` (#507) Always use the maximum padding between test and trial spaces in `allocate_matrices` in `DiscreteBilinearForm`. (Earlier this was not done in the case of scalar spaces.) Fixes #504. --- psydac/api/fem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psydac/api/fem.py b/psydac/api/fem.py index 7b4ff7e11..21b3b4f6a 100644 --- a/psydac/api/fem.py +++ b/psydac/api/fem.py @@ -728,7 +728,7 @@ def allocate_matrices(self, backend=None): trd = trial_degree[j] pads[i,j][:] = np.array([td, trd]).max(axis=0) else: - pads = test_degree + pads = np.maximum(test_degree, trial_degree) if self._matrix is None and (is_broken or isinstance(expr, (ImmutableDenseMatrix, Matrix))): self._matrix = BlockLinearOperator(trial_space, test_space) From bccddfc04a1a08b78c689ecef6978fc3625e9d53 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 28 Jun 2025 09:21:03 +0200 Subject: [PATCH 12/23] Enable Pyccel 2.0 (#503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pyccel 2.0 was just released, which means some of the kernels in Psydac need to be updated: - Remove the `template` decorator (see https://github.com/pyccel/pyccel/pull/2331) * Add `T` as `TypeVar` * Specify `T`, `T[:]`, `T[:,:]`, ... in function arguments. - Replace `@types` decorators (see https://github.com/pyccel/pyccel/pull/2329) - Replace `const` with `Final` (see https://github.com/pyccel/pyccel/pull/2340) Further changes: - Update import path for `epyccel` (fixes #426) - Update arguments to `epyccel` (see https://github.com/pyccel/pyccel/pull/2348): * Rename `fflags` to `flags` * Replace `accelerators` list with `openmp` bool - Require Pyccel >= 2.0.1 (fixes #471) --------- Co-authored-by: Yaman Güçlü Co-authored-by: Elena Moral Sánchez Co-authored-by: Emily Bourne --- psydac/api/ast/linalg.py | 24 ++-- psydac/api/basic.py | 25 ++-- psydac/api/glt.py | 18 +-- psydac/api/settings.py | 8 +- psydac/api/tests/test_epyccel_flags.py | 16 ++- psydac/core/field_evaluation_kernels.py | 129 +++++++------------ psydac/linalg/kernels/axpy_kernels.py | 8 +- psydac/linalg/kernels/inner_kernels.py | 8 +- psydac/linalg/kernels/matvec_kernels.py | 6 +- psydac/linalg/kernels/stencil2IJV_kernels.py | 7 +- psydac/linalg/kernels/stencil2coo_kernels.py | 10 +- psydac/linalg/kernels/transpose_kernels.py | 10 +- pyproject.toml | 2 +- 13 files changed, 112 insertions(+), 159 deletions(-) diff --git a/psydac/api/ast/linalg.py b/psydac/api/ast/linalg.py index b44f3d753..0bdde2aba 100644 --- a/psydac/api/ast/linalg.py +++ b/psydac/api/ast/linalg.py @@ -410,21 +410,21 @@ def _compile(self, backend=None): def _compile_pyccel(self, mod, backend, verbose=False): # ... convert python to fortran using pyccel - compiler = backend['compiler'] - fflags = backend['flags'] - _PYCCEL_FOLDER = backend['folder'] - accelerators = ["openmp"] if backend["openmp"] else [] + compiler_family = backend['compiler_family'] + flags = backend['flags'] + _PYCCEL_FOLDER = backend['folder'] + openmp = backend["openmp"] - from pyccel.epyccel import epyccel + from pyccel import epyccel fmod = epyccel(mod, - accelerators = accelerators, - compiler = compiler, - fflags = fflags, - comm = self.comm, - bcast = True, - folder = _PYCCEL_FOLDER, - verbose = verbose) + openmp = openmp, + compiler_family = compiler_family, + flags = flags, + comm = self.comm, + bcast = True, + folder = _PYCCEL_FOLDER, + verbose = verbose) return fmod #============================================================================== diff --git a/psydac/api/basic.py b/psydac/api/basic.py index c63bb370f..2eb77abeb 100644 --- a/psydac/api/basic.py +++ b/psydac/api/basic.py @@ -254,20 +254,21 @@ def _compile_pythran(self, mod): def _compile_pyccel(self, mod, verbose=False): # ... convert python to fortran using pyccel - compiler = self.backend['compiler'] - fflags = self.backend['flags'] - accelerators = ["openmp"] if self.backend["openmp"] else [] - _PYCCEL_FOLDER = self.backend['folder'] + compiler_family = self.backend['compiler_family'] + flags = self.backend['flags'] + openmp = self.backend["openmp"] + _PYCCEL_FOLDER = self.backend['folder'] - from pyccel.epyccel import epyccel + # from pyccel.epyccel import epyccel + from pyccel import epyccel fmod = epyccel(mod, - accelerators = accelerators, - compiler = compiler, - fflags = fflags, - comm = self.comm, - bcast = True, - folder = _PYCCEL_FOLDER, - verbose = verbose) + openmp = openmp, + compiler_family = compiler_family, + flags = flags, + comm = self.comm, + bcast = True, + folder = _PYCCEL_FOLDER, + verbose = verbose) return fmod diff --git a/psydac/api/glt.py b/psydac/api/glt.py index fb58a3585..027b1ff23 100644 --- a/psydac/api/glt.py +++ b/psydac/api/glt.py @@ -334,12 +334,12 @@ def _compile_pyccel(self, namespace, verbose=False): module_name = self.dependencies_modname # ... - from pyccel.epyccel import epyccel + from pyccel import epyccel # ... convert python to fortran using pyccel - compiler = self.backend['compiler'] - fflags = self.backend['flags'] - _PYCCEL_FOLDER = self.backend['folder'] + compiler_family = self.backend['compiler_family'] + flags = self.backend['flags'] + _PYCCEL_FOLDER = self.backend['folder'] # ... # ... @@ -351,11 +351,11 @@ def _compile_pyccel(self, namespace, verbose=False): sys.path.append(self.folder) package = importlib.import_module( module_name ) f2py_module = epyccel( package, - compiler = compiler, - fflags = fflags, - comm = self.comm, - bcast = False, - folder = _PYCCEL_FOLDER ) + compiler_family = compiler_family, + flags = flags, + comm = self.comm, + bcast = False, + folder = _PYCCEL_FOLDER ) sys.path.remove(self.folder) # ... diff --git a/psydac/api/settings.py b/psydac/api/settings.py index 94762b5a9..d96c420c7 100644 --- a/psydac/api/settings.py +++ b/psydac/api/settings.py @@ -15,28 +15,28 @@ PSYDAC_BACKEND_PYTHON = {'name': 'python', 'tag':'python', 'openmp':False} PSYDAC_BACKEND_GPYCCEL = {'name': 'pyccel', - 'compiler': 'GNU', + 'compiler_family': 'GNU', 'flags' : '-O3 -ffast-math', 'folder' : '__gpyccel__', 'tag' : 'gpyccel', 'openmp' : False} PSYDAC_BACKEND_IPYCCEL = {'name': 'pyccel', - 'compiler': 'intel', + 'compiler_family': 'intel', 'flags' : '-O3', 'folder' : '__ipyccel__', 'tag' :'ipyccel', 'openmp' : False} PSYDAC_BACKEND_PGPYCCEL = {'name': 'pyccel', - 'compiler': 'PGI', + 'compiler_family': 'PGI', 'flags' : '-O3 -Munroll', 'folder' : '__pgpyccel__', 'tag' : 'pgpyccel', 'openmp' : False} PSYDAC_BACKEND_NVPYCCEL = {'name': 'pyccel', - 'compiler': 'nvidia', + 'compiler_family': 'nvidia', 'flags' : '-O3 -Munroll', 'folder' : '__nvpyccel__', 'tag' : 'nvpyccel', diff --git a/psydac/api/tests/test_epyccel_flags.py b/psydac/api/tests/test_epyccel_flags.py index cdbcb0906..3a85ddcf0 100644 --- a/psydac/api/tests/test_epyccel_flags.py +++ b/psydac/api/tests/test_epyccel_flags.py @@ -4,13 +4,17 @@ @pytest.mark.pyccel def test_epyccel_flags(): - from pyccel.epyccel import epyccel - from psydac.api.settings import PSYDAC_BACKEND_GPYCCEL as backend + from pyccel import epyccel + from psydac.api.settings import PSYDAC_BACKENDS + # Select the Pyccel backend which uses the GCC compilers + backend = PSYDAC_BACKENDS['pyccel-gcc'] + + # Arguments for `epyccel` kwargs = {'language' : 'fortran', - 'compiler' : backend['compiler'], - 'fflags' : backend['flags'], - 'accelerators' : ['openmp'] if backend['openmp'] else [], + 'compiler_family' : backend['compiler_family'], + 'flags' : backend['flags'], + 'openmp' : backend['openmp'], 'verbose' : True, } @@ -20,7 +24,7 @@ def f(x : float): # Pyccel magic # ------------ - # Fortran code is generated and then compiled with the selected compiler. + # Fortran code is generated and then compiled with the selected compiler family. # The compiled function is callable from Python through the C Python API. # The necessary C wrapper functions are also generated by Pyccel. fast_f = epyccel(f, **kwargs) diff --git a/psydac/core/field_evaluation_kernels.py b/psydac/core/field_evaluation_kernels.py index 689646bc5..e75854d98 100644 --- a/psydac/core/field_evaluation_kernels.py +++ b/psydac/core/field_evaluation_kernels.py @@ -1,18 +1,18 @@ import numpy as np -from pyccel.decorators import template +from typing import TypeVar +T = TypeVar('T', float, complex) # ============================================================================= # Field evaluation functions # ============================================================================= # ----------------------------------------------------------------------------- # 1: Regular tensor grid without weight # ----------------------------------------------------------------------------- -@template(name='T', types=['float[:,:,:,:]', 'complex[:,:,:,:]']) def eval_fields_3d_no_weights(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: int, k1: int, k2: int, k3: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_basis_3: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_spans_3: 'int[:]', - glob_arr_coeff: 'T', out_fields: 'T'): + glob_arr_coeff: 'T[:,:,:,:]', out_fields: 'T[:,:,:,:]'): """ Parameters ---------- @@ -95,11 +95,10 @@ def eval_fields_3d_no_weights(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int :] += spline * coeff_fields -@template(name='T', types=['float[:,:,:]', 'complex[:,:,:]']) def eval_fields_2d_no_weights(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', - global_spans_1: 'int[:]', global_spans_2: 'int[:]', glob_arr_coeff: 'T', - out_fields: 'T'): + global_spans_1: 'int[:]', global_spans_2: 'int[:]', glob_arr_coeff: 'T[:,:,:]', + out_fields: 'T[:,:,:]'): """ Parameters ---------- @@ -161,12 +160,11 @@ def eval_fields_2d_no_weights(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, :] += spline * coeff_fields -@template(name='T', types=['float[:,:]', 'complex[:,:]']) def eval_fields_1d_no_weights(nc1: int, f_p1: int, k1: int, global_basis_1: 'float[:,:,:,:]', global_spans_1: 'int[:]', - glob_arr_coeff: 'T', - out_fields: 'T'): + glob_arr_coeff: 'T[:,:]', + out_fields: 'T[:,:]'): """ Parameters ---------- @@ -207,12 +205,11 @@ def eval_fields_1d_no_weights(nc1: int, f_p1: int, k1: int, # ----------------------------------------------------------------------------- # 2: Irregular tensor grid without weights # ----------------------------------------------------------------------------- -@template(name='T', types=['float[:,:,:,:]', 'complex[:,:,:,:]']) def eval_fields_3d_irregular_no_weights(np1: int, np2: int, np3: int, f_p1: int, f_p2: int, f_p3: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', cell_index_3 : 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', global_basis_3: 'float[:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_spans_3: 'int[:]', - glob_arr_coeff: 'T', out_fields: 'T'): + glob_arr_coeff: 'T[:,:,:,:]', out_fields: 'T[:,:,:,:]'): """ Parameters ---------- @@ -292,12 +289,11 @@ def eval_fields_3d_irregular_no_weights(np1: int, np2: int, np3: int, f_p1: int, out_fields[i_p_1, i_p_2, i_p_3, :] += spline * coeff_fields -@template(name='T', types=['float[:,:,:]', 'complex[:,:,:]']) def eval_fields_2d_irregular_no_weights(np1: int, np2: int, f_p1: int, f_p2: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', - global_spans_1: 'int[:]', global_spans_2: 'int[:]', glob_arr_coeff: 'T', - out_fields: 'T'): + global_spans_1: 'int[:]', global_spans_2: 'int[:]', glob_arr_coeff: 'T[:,:,:]', + out_fields: 'T[:,:,:]'): """ Parameters ---------- @@ -359,13 +355,12 @@ def eval_fields_2d_irregular_no_weights(np1: int, np2: int, f_p1: int, f_p2: int out_fields[i_p_1, i_p_2, :] += spline * coeff_fields -@template(name='T', types=['float[:,:]', 'complex[:,:]']) def eval_fields_1d_irregular_no_weights(np1: int, f_p1: int, cell_index_1: 'int[:]', global_basis_1: 'float[:,:,:]', global_spans_1: 'int[:]', - glob_arr_coeff: 'T', - out_fields: 'T'): + glob_arr_coeff: 'T[:,:]', + out_fields: 'T[:,:]'): """ Parameters ---------- @@ -409,13 +404,12 @@ def eval_fields_1d_irregular_no_weights(np1: int, f_p1: int, # ----------------------------------------------------------------------------- # 3: Regular tensor grid with weights # ----------------------------------------------------------------------------- -@template(name='T', types=['float[:,:,:,:]', 'complex[:,:,:,:]']) def eval_fields_3d_weighted(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: int, k1: int, k2: int, k3: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_basis_3: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_spans_3: 'int[:]', - glob_arr_coeff: 'T', global_arr_weights: 'float[:,:,:]', - out_fields: 'T'): + glob_arr_coeff: 'T[:,:,:,:]', global_arr_weights: 'float[:,:,:]', + out_fields: 'T[:,:,:,:]'): """ Parameters ---------- @@ -521,11 +515,10 @@ def eval_fields_3d_weighted(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, :] += fields / weight -@template(name='T', types=['float[:,:,:]', 'complex[:,:,:]']) def eval_fields_2d_weighted(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', - global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff: 'T', - global_arr_weights: 'float[:,:]', out_fields: 'T'): + global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff: 'T[:,:,:]', + global_arr_weights: 'float[:,:]', out_fields: 'T[:,:,:]'): """ Parameters ---------- @@ -610,11 +603,10 @@ def eval_fields_2d_weighted(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k :] += fields / weight -@template(name='T', types=['float[:,:]', 'complex[:,:]']) def eval_fields_1d_weighted(nc1: int, f_p1: int, k1: int, global_basis_1: 'float[:,:,:,:]', - global_spans_1: 'int[:]', global_arr_coeff: 'T', - global_arr_weights: 'float[:]', out_fields: 'T'): + global_spans_1: 'int[:]', global_arr_coeff: 'T[:,:]', + global_arr_weights: 'float[:]', out_fields: 'T[:,:]'): """ Parameters ---------- @@ -680,13 +672,12 @@ def eval_fields_1d_weighted(nc1: int, f_p1: int, k1: int, # ----------------------------------------------------------------------------- # 4: Iregular tensor grid with weights # ----------------------------------------------------------------------------- -@template(name='T', types=['float[:,:,:,:]', 'complex[:,:,:,:]']) def eval_fields_3d_irregular_weighted(np1: int, np2: int, np3: int, f_p1: int, f_p2: int, f_p3: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', cell_index_3 : 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', global_basis_3: 'float[:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_spans_3: 'int[:]', - glob_arr_coeff: 'T', global_arr_weights: 'float[:,:,:]', - out_fields: 'T'): + glob_arr_coeff: 'T[:,:,:,:]', global_arr_weights: 'float[:,:,:]', + out_fields: 'T[:,:,:,:]'): """ Parameters ---------- @@ -785,12 +776,11 @@ def eval_fields_3d_irregular_weighted(np1: int, np2: int, np3: int, f_p1: int, f out_fields[i_p_1, i_p_2, i_p_3, :] += temp_fields / temp_weight -@template(name='T', types=['float[:,:,:]', 'complex[:,:,:]']) def eval_fields_2d_irregular_weighted(np1: int, np2: int, f_p1: int, f_p2: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', - global_arr_coeff: 'T', global_arr_weights: 'float[:,:]', - out_fields: 'T'): + global_arr_coeff: 'T[:,:,:]', global_arr_weights: 'float[:,:]', + out_fields: 'T[:,:,:]'): """ Parameters ---------- @@ -871,12 +861,11 @@ def eval_fields_2d_irregular_weighted(np1: int, np2: int, f_p1: int, f_p2: int, out_fields[i_p_1, i_p_2, :] += temp_fields / temp_weight -@template(name='T', types=['float[:,:]', 'complex[:,:]']) def eval_fields_1d_irregular_weighted(np1: int, f_p1: int, cell_index_1: 'int[:]', global_basis_1: 'float[:,:,:]', global_spans_1: 'int[:]', - global_arr_coeff: 'T', global_arr_weights: 'float[:]', - out_fields: 'T'): + global_arr_coeff: 'T[:,:]', global_arr_weights: 'float[:]', + out_fields: 'T[:,:]'): """ Parameters ---------- @@ -935,12 +924,11 @@ def eval_fields_1d_irregular_weighted(np1: int, f_p1: int, # ----------------------------------------------------------------------------- # 1: Regular tensor grid without weights # ----------------------------------------------------------------------------- -@template(name='T', types=['float[:,:,:]', 'complex[:,:,:]']) def eval_jac_det_3d(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: int, k1: int, k2: int, k3: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_basis_3: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', - global_spans_3: 'int[:]', global_arr_coeff_x: 'T', global_arr_coeff_y: 'T', - global_arr_coeff_z: 'T', jac_det: 'T'): + global_spans_3: 'int[:]', global_arr_coeff_x: 'T[:,:,:]', global_arr_coeff_y: 'T[:,:,:]', + global_arr_coeff_z: 'T[:,:,:]', jac_det: 'T[:,:,:]'): """ Parameters @@ -1096,11 +1084,10 @@ def eval_jac_det_3d(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: in - x_x3 * y_x2 * z_x1) -@template(name='T', types=['float[:,:]', 'complex[:,:]']) def eval_jac_det_2d(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', - global_arr_coeff_x: 'T', global_arr_coeff_y: 'T', - jac_det: 'T'): + global_arr_coeff_x: 'T[:,:]', global_arr_coeff_y: 'T[:,:]', + jac_det: 'T[:,:]'): """ Parameters ---------- @@ -1200,14 +1187,13 @@ def eval_jac_det_2d(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int, # ----------------------------------------------------------------------------- # 2: Irregular tensor grid without weights # ----------------------------------------------------------------------------- -@template(name='T', types=['float[:,:,:]', 'complex[:,:,:]']) def eval_jac_det_irregular_3d(np1: int, np2: int, np3: int, f_p1: int, f_p2: int, f_p3: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', cell_index_3 : 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', global_basis_3: 'float[:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', - global_spans_3: 'int[:]', global_arr_coeff_x: 'T', - global_arr_coeff_y: 'T', global_arr_coeff_z: 'T', - jac_det: 'T'): + global_spans_3: 'int[:]', global_arr_coeff_x: 'T[:,:,:]', + global_arr_coeff_y: 'T[:,:,:]', global_arr_coeff_z: 'T[:,:,:]', + jac_det: 'T[:,:,:]'): """ Parameters ---------- @@ -1350,11 +1336,10 @@ def eval_jac_det_irregular_3d(np1: int, np2: int, np3: int, f_p1: int, f_p2: int - temp_x_x3 * temp_y_x2 * temp_z_x1) -@template(name='T', types=['float[:,:]', 'complex[:,:]']) def eval_jac_det_irregular_2d(np1: int, np2: int, f_p1: int, f_p2: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', - global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T', - global_arr_coeff_y: 'T', jac_det: 'T'): + global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T[:,:]', + global_arr_coeff_y: 'T[:,:]', jac_det: 'T[:,:]'): """ Parameters ---------- @@ -1445,14 +1430,13 @@ def eval_jac_det_irregular_2d(np1: int, np2: int, f_p1: int, f_p2: int, cell_ind # 3: Regular tensor grid with weights # ----------------------------------------------------------------------------- -@template(name='T', types=['float[:,:,:]', 'complex[:,:,:]']) def eval_jac_det_3d_weights(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: int, k1: int, k2: int, k3: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_basis_3: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_spans_3: 'int[:]', - global_arr_coeff_x: 'T', global_arr_coeff_y: 'T', - global_arr_coeff_z: 'T', global_arr_coeff_weights: 'float[:,:,:]', - jac_det: 'T'): + global_arr_coeff_x: 'T[:,:,:]', global_arr_coeff_y: 'T[:,:,:]', + global_arr_coeff_z: 'T[:,:,:]', global_arr_coeff_weights: 'float[:,:,:]', + jac_det: 'T[:,:,:]'): """ Parameters @@ -1674,12 +1658,11 @@ def eval_jac_det_3d_weights(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, - x_x3 * y_x2 * z_x1) -@template(name='T', types=['float[:,:]', 'complex[:,:]']) def eval_jac_det_2d_weights(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', - global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T', - global_arr_coeff_y: 'T', global_arr_coeff_weights: 'float[:,:]', - jac_det: 'T'): + global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T[:,:]', + global_arr_coeff_y: 'T[:,:]', global_arr_coeff_weights: 'float[:,:]', + jac_det: 'T[:,:]'): """ Parameters ---------- @@ -1830,14 +1813,13 @@ def eval_jac_det_2d_weights(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k # ----------------------------------------------------------------------------- # 4: Irregular tensor grid with weights # ----------------------------------------------------------------------------- -@template(name='T', types=['float[:,:,:]', 'complex[:,:,:]']) def eval_jac_det_irregular_3d_weights(np1: int, np2: int, np3: int, f_p1: int, f_p2: int, f_p3: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', cell_index_3 : 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', global_basis_3: 'float[:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', - global_spans_3: 'int[:]', global_arr_coeff_x: 'T', - global_arr_coeff_y: 'T', global_arr_coeff_z: 'T', - global_arr_coeff_weights: 'float[:,:, :]', jac_det: 'T'): + global_spans_3: 'int[:]', global_arr_coeff_x: 'T[:,:,:]', + global_arr_coeff_y: 'T[:,:,:]', global_arr_coeff_z: 'T[:,:,:]', + global_arr_coeff_weights: 'float[:,:, :]', jac_det: 'T[:,:,:]'): """ Parameters ---------- @@ -2030,14 +2012,13 @@ def eval_jac_det_irregular_3d_weights(np1: int, np2: int, np3: int, f_p1: int, f - x_x3 * y_x2 * z_x1) -@template(name='T', types=['float[:,:]', 'complex[:,:]']) def eval_jac_det_irregular_2d_weights(np1: int, np2: int, f_p1: int, f_p2: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', - global_arr_coeff_x: 'T', global_arr_coeff_y: 'T', + global_arr_coeff_x: 'T[:,:]', global_arr_coeff_y: 'T[:,:]', global_arr_coeff_weights: 'float[:,:]', - jac_det: 'T'): + jac_det: 'T[:,:]'): """ Parameters ---------- @@ -2170,7 +2151,6 @@ def eval_jac_det_irregular_2d_weights(np1: int, np2: int, f_p1: int, f_p2: int, # ----------------------------------------------------------------------------- # 1: Regular tensor grid without weights # ----------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def eval_jacobians_3d(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: int, k1: int, k2: int, k3: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_basis_3: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', @@ -2328,7 +2308,6 @@ def eval_jacobians_3d(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: [z_x1, z_x2, z_x3]]) -@template(name='T', types=[float, complex]) def eval_jacobians_2d(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T[:,:]', global_arr_coeff_y: 'T[:,:]', @@ -2434,7 +2413,6 @@ def eval_jacobians_2d(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int # ----------------------------------------------------------------------------- # 2: Irregular tensor grid without weights # ----------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def eval_jacobians_irregular_3d(np1: int, np2: int, np3: int, f_p1: int, f_p2: int, f_p3: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', cell_index_3 : 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', @@ -2580,7 +2558,6 @@ def eval_jacobians_irregular_3d(np1: int, np2: int, np3: int, f_p1: int, f_p2: i [temp_z_x1, temp_z_x2, temp_z_x3]]) -@template(name='T', types=[float, complex]) def eval_jacobians_irregular_2d(np1: int, np2: int, f_p1: int, f_p2: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T[:,:]', @@ -2677,7 +2654,6 @@ def eval_jacobians_irregular_2d(np1: int, np2: int, f_p1: int, f_p2: int, cell_i # ----------------------------------------------------------------------------- # 3: Regular tensor grid with weights # ----------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def eval_jacobians_3d_weights(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: int, k1: int, k2: int, k3: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_basis_3: 'float[:,:,:,:]', @@ -2904,7 +2880,6 @@ def eval_jacobians_3d_weights(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: in [z_x1, z_x2, z_x3]]) -@template(name='T', types=[float, complex]) def eval_jacobians_2d_weights(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T[:,:]', @@ -3061,7 +3036,6 @@ def eval_jacobians_2d_weights(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int # ----------------------------------------------------------------------------- # 4: Irregular tensor grid with weights # ----------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def eval_jacobians_irregular_3d_weights(np1: int, np2: int, np3: int, f_p1: int, f_p2: int, f_p3: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', cell_index_3 : 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', @@ -3258,7 +3232,6 @@ def eval_jacobians_irregular_3d_weights(np1: int, np2: int, np3: int, f_p1: int, [z_x1, z_x2, z_x3]]) -@template(name='T', types=[float, complex]) def eval_jacobians_irregular_2d_weights(np1: int, np2: int, f_p1: int, f_p2: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', @@ -3400,7 +3373,6 @@ def eval_jacobians_irregular_2d_weights(np1: int, np2: int, f_p1: int, f_p2: int # ----------------------------------------------------------------------------- # 1: Regular tensor grid without weights # ----------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def eval_jacobians_inv_3d(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: int, k1: int, k2: int, k3: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_basis_3: 'float[:,:,:,:]', global_spans_1: 'int[:]', @@ -3575,7 +3547,6 @@ def eval_jacobians_inv_3d(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, [a_13, a_23, a_33]]) / det -@template(name='T', types=[float, complex]) def eval_jacobians_inv_2d(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T[:,:]', global_arr_coeff_y: 'T[:,:]', @@ -3683,7 +3654,6 @@ def eval_jacobians_inv_2d(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2 # ----------------------------------------------------------------------------- # 2: Irregular tensor grid without weights # ----------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def eval_jacobians_inv_irregular_3d(np1: int, np2: int, np3: int, f_p1: int, f_p2: int, f_p3: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', cell_index_3 : 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', @@ -3851,7 +3821,6 @@ def eval_jacobians_inv_irregular_3d(np1: int, np2: int, np3: int, f_p1: int, f_p [a_13, a_23, a_33]]) / det -@template(name='T', types=[float, complex]) def eval_jacobians_inv_irregular_2d(np1: int, np2: int, f_p1: int, f_p2: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T[:,:]', @@ -3951,7 +3920,6 @@ def eval_jacobians_inv_irregular_2d(np1: int, np2: int, f_p1: int, f_p2: int, ce # ----------------------------------------------------------------------------- # 3: Regular tensor grid with weights # ----------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def eval_jacobians_inv_3d_weights(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2: int, f_p3: int, k1: int, k2: int, k3: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_basis_3: 'float[:,:,:,:]', @@ -4193,7 +4161,6 @@ def eval_jacobians_inv_3d_weights(nc1: int, nc2: int, nc3: int, f_p1: int, f_p2 [a_13, a_23, a_33]]) / det -@template(name='T', types=[float, complex]) def eval_jacobians_inv_2d_weights(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: int, k2: int, global_basis_1: 'float[:,:,:,:]', global_basis_2: 'float[:,:,:,:]', global_spans_1: 'int[:]', global_spans_2: 'int[:]', global_arr_coeff_x: 'T[:,:]', @@ -4352,7 +4319,6 @@ def eval_jacobians_inv_2d_weights(nc1: int, nc2: int, f_p1: int, f_p2: int, k1: # ----------------------------------------------------------------------------- # 4: Irregular tensor grid with weights # ----------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def eval_jacobians_inv_irregular_3d_weights(np1: int, np2: int, np3: int, f_p1: int, f_p2: int, f_p3: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', cell_index_3 : 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', @@ -4567,7 +4533,6 @@ def eval_jacobians_inv_irregular_3d_weights(np1: int, np2: int, np3: int, f_p1: [a_13, a_23, a_33]]) / det -@template(name='T', types=[float, complex]) def eval_jacobians_inv_irregular_2d_weights(np1: int, np2: int, f_p1: int, f_p2: int, cell_index_1: 'int[:]', cell_index_2: 'int[:]', global_basis_1: 'float[:,:,:]', global_basis_2: 'float[:,:,:]', @@ -4710,7 +4675,6 @@ def eval_jacobians_inv_irregular_2d_weights(np1: int, np2: int, f_p1: int, f_p2: # -------------------------------------------------------------------------- # 1: L2 Push-forward # -------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def pushforward_2d_l2(fields_to_push: 'T[:,:,:]', sqrt_met_dets: 'float[:,:]', pushed_fields: 'T[:,:,:]'): """ @@ -4733,7 +4697,6 @@ def pushforward_2d_l2(fields_to_push: 'T[:,:,:]', sqrt_met_dets: 'float[:,:]', pushed_fields[:, :, i_f] = fields_to_push[:, :, i_f] / sqrt_met_dets[:, :] -@template(name='T', types=[float, complex]) def pushforward_3d_l2(fields_to_push: 'T[:,:,:,:]', sqrt_met_dets: 'float[:,:,:]', pushed_fields: 'T[:,:,:,:]'): """ Parameters @@ -4759,7 +4722,6 @@ def pushforward_3d_l2(fields_to_push: 'T[:,:,:,:]', sqrt_met_dets: 'float[:,:,:] # -------------------------------------------------------------------------- # 2: Hcurl Push-forward # -------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def pushforward_2d_hcurl(fields_to_push: 'T[:,:,:,:]', inv_jac_mats: 'float[:,:,:,:]', pushed_fields: 'T[:,:,:,:]'): """ @@ -4787,7 +4749,6 @@ def pushforward_2d_hcurl(fields_to_push: 'T[:,:,:,:]', inv_jac_mats: 'float[:,:, + inv_jac_mats[:, :, 1, 1] * fields_to_push[1, :, :, i_f]) -@template(name='T', types=[float, complex]) def pushforward_3d_hcurl(fields_to_push: 'T[:,:,:,:,:]', inv_jac_mats: 'float[:,:,:,:,:]', pushed_fields: 'T[:,:,:,:,:]'): """ @@ -4829,7 +4790,6 @@ def pushforward_3d_hcurl(fields_to_push: 'T[:,:,:,:,:]', inv_jac_mats: 'float[:, # -------------------------------------------------------------------------- # 1: Hdiv Push-forward # -------------------------------------------------------------------------- -@template(name='T', types=[float, complex]) def pushforward_2d_hdiv(fields_to_push: 'T[:,:,:,:]', jac_mats: 'float[:,:,:,:]', sqrt_met_dets: 'float[:,:]', pushed_fields: 'T[:,:,:,:]'): """ @@ -4861,7 +4821,6 @@ def pushforward_2d_hdiv(fields_to_push: 'T[:,:,:,:]', jac_mats: 'float[:,:,:,:]' + jac_mats[:, :, 1, 1] * fields_to_push[1, :, :, i_f]) / sqrt_met_dets[:, :] -@template(name='T', types=[float, complex]) def pushforward_3d_hdiv(fields_to_push: 'T[:,:,:,:,:]', jac_mats: 'float[:,:,:,:,:]', sqrt_met_dets: 'float[:,:,:]', pushed_fields: 'T[:,:,:,:,:]'): """ diff --git a/psydac/linalg/kernels/axpy_kernels.py b/psydac/linalg/kernels/axpy_kernels.py index 7cf7c81d4..5c3ca65ad 100644 --- a/psydac/linalg/kernels/axpy_kernels.py +++ b/psydac/linalg/kernels/axpy_kernels.py @@ -1,7 +1,9 @@ -from pyccel.decorators import template +from typing import TypeVar + +T = TypeVar('T', float, complex) + #======================================================================================================== -@template(name='T', types=[float, complex]) def axpy_1d(alpha: 'T', x: 'T[:]', y: 'T[:]'): """ Kernel for computing y = alpha * x + y. @@ -19,7 +21,6 @@ def axpy_1d(alpha: 'T', x: 'T[:]', y: 'T[:]'): y[i1] += alpha * x[i1] #======================================================================================================== -@template(name='T', types=[float, complex]) def axpy_2d(alpha: 'T', x: 'T[:,:]', y: 'T[:,:]'): """ Kernel for computing y = alpha * x + y. @@ -38,7 +39,6 @@ def axpy_2d(alpha: 'T', x: 'T[:,:]', y: 'T[:,:]'): y[i1, i2] += alpha * x[i1, i2] #======================================================================================================== -@template(name='T', types=[float, complex]) def axpy_3d(alpha: 'T', x: 'T[:,:,:]', y: 'T[:,:,:]'): """ Kernel for computing y = alpha * x + y. diff --git a/psydac/linalg/kernels/inner_kernels.py b/psydac/linalg/kernels/inner_kernels.py index 6da0dc238..a90316027 100644 --- a/psydac/linalg/kernels/inner_kernels.py +++ b/psydac/linalg/kernels/inner_kernels.py @@ -1,4 +1,3 @@ -from pyccel.decorators import template #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!# #!!!!!!!!!!!!!!!!!!! WARNING !!!!!!!!!!!!!!!!!!!# @@ -6,8 +5,11 @@ #!!!!!!!!!! This will need an update !!!!!!!!!!!# #!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!# +from typing import TypeVar + +T = TypeVar('T', float, complex) + #============================================================================== -@template(name='T', types=[float, complex]) def inner_1d(v1: 'T[:]', v2: 'T[:]', nghost0: 'int64'): """ Kernel for computing the inner product (case of two 1D vectors). @@ -34,7 +36,6 @@ def inner_1d(v1: 'T[:]', v2: 'T[:]', nghost0: 'int64'): return res #============================================================================== -@template(name='T', types=[float, complex]) def inner_2d(v1: 'T[:,:]', v2: 'T[:,:]', nghost0: 'int64', nghost1: 'int64'): """ Kernel for computing the inner product (case of two 2D vectors). @@ -65,7 +66,6 @@ def inner_2d(v1: 'T[:,:]', v2: 'T[:,:]', nghost0: 'int64', nghost1: 'int64'): return res #============================================================================== -@template(name='T', types=[float, complex]) def inner_3d(v1: 'T[:,:,:]', v2: 'T[:,:,:]', nghost0: 'int64', nghost1: 'int64', nghost2: 'int64'): """ Kernel for computing the inner product (case of two 3D vectors). diff --git a/psydac/linalg/kernels/matvec_kernels.py b/psydac/linalg/kernels/matvec_kernels.py index 40b474275..d85c0dcc2 100644 --- a/psydac/linalg/kernels/matvec_kernels.py +++ b/psydac/linalg/kernels/matvec_kernels.py @@ -1,7 +1,7 @@ -from pyccel.decorators import template +from typing import TypeVar +T = TypeVar('T', float, complex) -@template(name='T', types=[float, complex]) def matvec_1d(mat00:'T[:,:]', x0:'T[:]', out0:'T[:]', starts: 'int64[:]', nrows: 'int64[:]', nrows_extra: 'int64[:]', dm:'int64[:]', cm:'int64[:]', pad_imp:'int64[:]', ndiags:'int64[:]', gpads: 'int64[:]'): @@ -38,7 +38,6 @@ def matvec_1d(mat00:'T[:,:]', x0:'T[:]', out0:'T[:]', starts: 'int64[:]', nrows: -@template(name='T', types=[float, complex]) def matvec_2d(mat00:'T[:,:,:,:]', x0:'T[:,:]', out0:'T[:,:]', starts:'int64[:]', nrows:'int64[:]', nrows_extra:'int64[:]', dm:'int64[:]', cm:'int64[:]', pad_imp:'int64[:]', ndiags:'int64[:]', gpads: 'int64[:]'): @@ -104,7 +103,6 @@ def matvec_2d(mat00:'T[:,:,:,:]', x0:'T[:,:]', out0:'T[:,:]', starts:'int64[:]', out0[pxm1 + i1, pxm2 + i2] = v00 -@template(name='T', types=[float, complex]) def matvec_3d(mat00:'T[:,:,:,:,:,:]', x0:'T[:,:,:]', out0:'T[:,:,:]', starts:'int64[:]', nrows:'int64[:]', nrows_extra:'int64[:]', dm:'int64[:]', cm:'int64[:]', pad_imp:'int64[:]', ndiags:'int64[:]', gpads: 'int64[:]'): diff --git a/psydac/linalg/kernels/stencil2IJV_kernels.py b/psydac/linalg/kernels/stencil2IJV_kernels.py index 21eae3cb4..133ab45f7 100644 --- a/psydac/linalg/kernels/stencil2IJV_kernels.py +++ b/psydac/linalg/kernels/stencil2IJV_kernels.py @@ -1,9 +1,10 @@ # coding: utf-8 -from pyccel.decorators import template +from typing import TypeVar + +T = TypeVar('T', float, complex) #======================================================================================================== -@template(name='T', types=[float, complex]) def stencil2IJV_1d_C(A:'T[:,:]', Ib:'int64[:]', Jb:'int64[:]', Vb:'T[:]', rowmapb:'int64[:]', cnl1:'int64', dng1:'int64', cs1:'int64', cp1:'int64', cm1:'int64', dsh:'int64[:]', csh:'int64[:]', dgs1:'int64[:]', dge1:'int64[:]', @@ -57,7 +58,6 @@ def stencil2IJV_1d_C(A:'T[:,:]', Ib:'int64[:]', Jb:'int64[:]', Vb:'T[:]', rowmap return nnz_rows, nnz #======================================================================================================== -@template(name='T', types=[float, complex]) def stencil2IJV_2d_C(A:'T[:,:,:,:]', Ib:'int64[:]', Jb:'int64[:]', Vb:'T[:]', rowmapb:'int64[:]', cnl1:'int64', cnl2:'int64', dng1:'int64', dng2:'int64', cs1:'int64', cs2:'int64', cp1:'int64', cp2:'int64', cm1:'int64', cm2:'int64', @@ -132,7 +132,6 @@ def stencil2IJV_2d_C(A:'T[:,:,:,:]', Ib:'int64[:]', Jb:'int64[:]', Vb:'T[:]', ro return nnz_rows, nnz #======================================================================================================== -@template(name='T', types=[float, complex]) def stencil2IJV_3d_C(A:'T[:,:,:,:,:,:]', Ib:'int64[:]', Jb:'int64[:]', Vb:'T[:]', rowmapb:'int64[:]', cnl1:'int64', cnl2:'int64', cnl3:'int64', dng1:'int64', dng2:'int64', dng3:'int64', cs1:'int64', cs2:'int64', cs3:'int64', cp1:'int64', cp2:'int64', cp3:'int64', diff --git a/psydac/linalg/kernels/stencil2coo_kernels.py b/psydac/linalg/kernels/stencil2coo_kernels.py index 81c278df6..2985b5ad7 100644 --- a/psydac/linalg/kernels/stencil2coo_kernels.py +++ b/psydac/linalg/kernels/stencil2coo_kernels.py @@ -1,15 +1,16 @@ # coding: utf-8 -from pyccel.decorators import template #!!!!!!!!!!!!! #TODO avoid using The expensive modulo operator % in the non periodic case to make the methods faster #!!!!!!!!!!!!! +from typing import TypeVar + +T = TypeVar('T', float, complex) #__all__ = ['stencil2coo_1d_C','stencil2coo_1d_F','stencil2coo_2d_C','stencil2coo_2d_F', 'stencil2coo_3d_C', 'stencil2coo_3d_F'] #======================================================================================================== -@template(name='T', types=[float, complex]) def stencil2coo_1d_C(A:'T[:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[:]', nrl1:'int64', ncl1:'int64', s1:'int64', nr1:'int64', nc1:'int64', dm1:'int64', cm1:'int64', p1:'int64', dp1:'int64'): nnz = 0 @@ -28,7 +29,6 @@ def stencil2coo_1d_C(A:'T[:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[:]', return nnz #======================================================================================================== -@template(name='T', types=[float, complex]) def stencil2coo_1d_F(A:'T[:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[:]', nrl1:'int64', ncl1:'int64', s1:'int64', nr1:'int64', nc1:'int64', dm1:'int64', cm1:'int64', p1:'int64', dp1:'int64'): nnz = 0 @@ -47,7 +47,6 @@ def stencil2coo_1d_F(A:'T[:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[:]', return nnz #======================================================================================================== -@template(name='T', types=[float, complex]) def stencil2coo_2d_C(A:'T[:,:,:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[:]', nrl1:'int64', nrl2:'int64', ncl1:'int64', ncl2:'int64', s1:'int64', s2:'int64', nr1:'int64', nr2:'int64', @@ -78,7 +77,6 @@ def stencil2coo_2d_C(A:'T[:,:,:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[: return nnz #======================================================================================================== -@template(name='T', types=[float, complex]) def stencil2coo_2d_F(A:'T[:,:,:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[:]', nrl1:'int64', nrl2:'int64', ncl1:'int64', ncl2:'int64', s1:'int64', s2:'int64', nr1:'int64', nr2:'int64', @@ -109,7 +107,6 @@ def stencil2coo_2d_F(A:'T[:,:,:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[: return nnz #======================================================================================================== -@template(name='T', types=[float, complex]) def stencil2coo_3d_C(A:'T[:,:,:,:,:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[:]', nrl1:'int64', nrl2:'int64', nrl3:'int64', ncl1:'int64', ncl2:'int64', ncl3:'int64', s1:'int64', s2:'int64', s3:'int64', nr1:'int64', nr2:'int64', nr3:'int64', @@ -147,7 +144,6 @@ def stencil2coo_3d_C(A:'T[:,:,:,:,:,:]', data:'T[:]', rows:'int64[:]', cols:'int #======================================================================================================== -@template(name='T', types=[float, complex]) def stencil2coo_3d_F(A:'T[:,:,:,:,:,:]', data:'T[:]', rows:'int64[:]', cols:'int64[:]', nrl1:'int64', nrl2:'int64', nrl3:'int64', ncl1:'int64', ncl2:'int64', ncl3:'int64', s1:'int64', s2:'int64', s3:'int64', nr1:'int64', nr2:'int64', nr3:'int64', diff --git a/psydac/linalg/kernels/transpose_kernels.py b/psydac/linalg/kernels/transpose_kernels.py index ef61fed0f..19513f296 100644 --- a/psydac/linalg/kernels/transpose_kernels.py +++ b/psydac/linalg/kernels/transpose_kernels.py @@ -1,7 +1,8 @@ -from pyccel.decorators import template +from typing import TypeVar + +T = TypeVar('T', float, complex) #======================================================================================================== -@template(name='T', types=[float, complex]) def transpose_1d(M : "T[:,:]", Mt : "T[:,:]", n : "int64[:]", @@ -35,7 +36,6 @@ def transpose_1d(M : "T[:,:]", return #======================================================================================================== -@template(name='T', types=[float, complex]) def transpose_2d(M : "T[:,:,:,:]", Mt : "T[:,:,:,:]", n : "int64[:]", @@ -79,7 +79,6 @@ def transpose_2d(M : "T[:,:,:,:]", return #======================================================================================================== -@template(name='T', types=[float, complex]) def transpose_3d(M : "T[:,:,:,:,:,:]", Mt : "T[:,:,:,:,:,:]", n : "int64[:]", @@ -131,7 +130,6 @@ def transpose_3d(M : "T[:,:,:,:,:,:]", return #======================================================================================================== -@template(name='T', types=[float, complex]) def interface_transpose_1d(M : "T[:,:]", Mt : "T[:,:]", n : "int64[:]", @@ -168,7 +166,6 @@ def interface_transpose_1d(M : "T[:,:]", return #======================================================================================================== -@template(name='T', types=[float, complex]) def interface_transpose_2d(M : "T[:,:,:,:]", Mt : "T[:,:,:,:]", n : "int64[:]", @@ -214,7 +211,6 @@ def interface_transpose_2d(M : "T[:,:,:,:]", return #======================================================================================================== -@template(name='T', types=[float, complex]) def interface_transpose_3d(M : "T[:,:,:,:,:,:]", Mt : "T[:,:,:,:,:,:]", n : "int64[:]", diff --git a/pyproject.toml b/pyproject.toml index 2d42320d2..21bd7fb70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ # Our packages from PyPi 'sympde == 0.19.2', - 'pyccel >= 1.11.2', + 'pyccel >= 2.0.1', 'gelato == 0.12', # In addition, we depend on mpi4py and h5py (MPI version). From 42b214e2bd39f33d9fe5ba21ce5483806f402d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 14 Jul 2025 15:43:07 +0200 Subject: [PATCH 13/23] New installation procedure and README (#510) Recently the installation of `h5py` in parallel mode (i.e. with MPI support through `mpi4py`) seems to have been simplified. This may be due to the recent move of `mpi4py` to wheels, or other factors. Therefore we can now simplify our installation procedure as follows: ```sh pip install h5py --no-cache-dir --no-binary h5py pip install .[test] ``` We update here the CI workflows and completely rewrite our README file. **Commit summary** - Completely rewrite `README.md`: * Expand initial description * Add Citing and Contributing sections * Shorten Installation section * Reorganize documentation sections * Move detailed installation instructions to separate file `docs/installation.md` * Move mesh generation to separate file `docs/psydac-mesh.md` - Add BibTeX file `CITING.bib` with citation of 2022 ECCOMAS proceedings - Remove obsolete files `requirements.txt` and `requirements_extra.txt` - Rename (and move) `docs_requirements.txt` as `docs/requirements.txt` - Update installation procedure in `testing` CI workflow - Change name in `testing` workflow from "Run tests" to "Unit tests" - Modify `testing` and `documentation` workflows so that they only run when needed - Allow running workflows manually - Fix broken CI badge in `README.md` --- .github/workflows/documentation.yml | 15 +- .github/workflows/testing.yml | 27 ++- .gitignore | 3 + CITATION.bib | 10 + README.md | 223 +++++------------- docs/installation.md | 202 ++++++++++++++++ output.md => docs/output.md | 0 docs/psydac-mesh.md | 9 + .../requirements.txt | 0 pyproject.toml | 17 +- requirements.txt | 9 - requirements_extra.txt | 9 - 12 files changed, 334 insertions(+), 190 deletions(-) create mode 100644 CITATION.bib create mode 100644 docs/installation.md rename output.md => docs/output.md (100%) create mode 100644 docs/psydac-mesh.md rename docs_requirements.txt => docs/requirements.txt (100%) delete mode 100644 requirements.txt delete mode 100644 requirements_extra.txt diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index be24f89c9..41896bbb9 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -1,10 +1,19 @@ -name: documentation +name: Documentation on: push: branches: [ devel ] + paths: + - 'docs/**' + - 'psydac/**.py' + pull_request: branches: [ devel ] + paths: + - 'docs/**' + - 'psydac/**.py' + + workflow_dispatch: permissions: contents: read @@ -29,7 +38,7 @@ jobs: sudo apt install graphviz - name: Install Python dependencies run: | - python -m pip install -r docs_requirements.txt + python -m pip install -r docs/requirements.txt - name: Make the sphinx doc run: | rm -rf docs/source/modules/STUBDIR @@ -44,7 +53,7 @@ jobs: path: 'docs/build/html' deploy_docs: - if: github.event_name != 'pull_request' + if: github.event_name == 'push' && github.ref == 'refs/heads/devel' needs: build_docs runs-on: ubuntu-latest environment: diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 802657cd0..d8ff30b1c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,13 +1,33 @@ # This workflow will install Python dependencies and run tests with a variety of Python versions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions -name: Run tests +name: Unit tests on: push: branches: [ devel ] + paths: + - 'psydac/**' + - 'pyproject.toml' + - 'setup.py' + - 'pytest.ini' + - 'mpi_tester.py' + pull_request: branches: [ devel ] + types: + - opened + - synchronize + - reopened + - ready_for_review + paths: + - 'psydac/**' + - 'pyproject.toml' + - 'setup.py' + - 'pytest.ini' + - 'mpi_tester.py' + + workflow_dispatch: jobs: test: @@ -142,12 +162,11 @@ jobs: python -m pip install wheel Cython numpy python -m pip install src/binding/petsc4py - - name: Install Python dependencies + - name: Install h5py in parallel mode run: | export CC="mpicc" export HDF5_MPI="ON" - python -m pip install -r requirements.txt - python -m pip install -r requirements_extra.txt --no-build-isolation --no-cache-dir + python -m pip install h5py --no-cache-dir --no-binary h5py python -m pip list - name: Check parallel h5py installation diff --git a/.gitignore b/.gitignore index 71dfeaf04..b3cf64508 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ __test__/ # pycharm directory .idea + +# Visual Studio Code workspace files +*.code-workspace \ No newline at end of file diff --git a/CITATION.bib b/CITATION.bib new file mode 100644 index 000000000..24b0ca77b --- /dev/null +++ b/CITATION.bib @@ -0,0 +1,10 @@ +@inproceedings{Guclu2022, + title = {{{PSYDAC}}: A High-Performance {{IGA}} Library in {{Python}}}, + booktitle = {8th {{European Congress}} on {{Computational Methods}} in {{Applied Sciences}} and {{Engineering}}}, + author = {Güçlü, Y. and Hadjout, S. and Ratnani, A.}, + year = {2022}, + publisher = {CIMNE}, + doi = {10.23967/eccomas.2022.227}, + url = {https://www.scipedia.com/public/Guclu_et_al_2022a}, + eventtitle = {8th {{European Congress}} on {{Computational Methods}} in {{Applied Sciences}} and {{Engineering}}}, +} diff --git a/README.md b/README.md index 65c51f99f..d218c17cb 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,28 @@ # Welcome to PSYDAC -[![devel_tests](https://github.com/pyccel/psydac/actions/workflows/continuous-integration.yml/badge.svg)](https://github.com/pyccel/psydac/actions/workflows/continuous-integration.yml) [![docs](https://github.com/pyccel/psydac/actions/workflows/documentation.yml/badge.svg)](https://github.com/pyccel/psydac/actions/workflows/documentation.yml) +[![devel_tests](https://github.com/pyccel/psydac/actions/workflows/testing.yml/badge.svg)](https://github.com/pyccel/psydac/actions/workflows/testing.yml) [![docs](https://github.com/pyccel/psydac/actions/workflows/documentation.yml/badge.svg)](https://github.com/pyccel/psydac/actions/workflows/documentation.yml) -**PSYDAC** is a Python 3 Library for isogeometric analysis. +PSYDAC is a Python 3 library for isogeometric analysis. +It is an academic, open-source project created by numerical mathematicians at the [Max Planck Institute for Plasma Physics](https://www.ipp.mpg.de/en) ([NMPP](https://www.ipp.mpg.de/ippcms/eng/for/bereiche/numerik) division, [FEM](https://www.ipp.mpg.de/5150531/fem) group). -## Table of contents +PSYDAC can solve general systems of partial differential equations in weak form, which users define using the domain-specific language provided by [SymPDE](https://github.com/pyccel/sympde). +It supports finite element exterior calculus ([FEEC](https://en.wikipedia.org/wiki/Finite_element_exterior_calculus)) with tensor-product spline spaces and handles multi-patch geometries in various ways. -- [Requirements](#requirements) -- [Python setup and project download](#python-setup-and-project-download) -- [Installing the library](#installing-the-library) -- [Optional PETSc installation](#optional-petsc-installation) -- [Uninstall](#uninstall) -- [Running tests](#running-tests) -- [Speeding up Psydac's core](#speeding-up-psydacs-core) -- [User Documentation](#user-documentation) -- [Code Documentation](#code-documentation) +PSYDAC automatically generates Python code for the assembly of user-defined functionals and linear and bilinear forms from the weak formulation of the problem. +This Python code is then accelerated to C/Fortran speed using [Pyccel](https://github.com/pyccel/pyccel). +The library also enables large parallel computations on distributed-memory supercomputers using [MPI](https://en.wikipedia.org/wiki/Message_Passing_Interface) and [OpenMP](https://en.wikipedia.org/wiki/OpenMP). -## Requirements +## Citing -Psydac requires a certain number of components to be installed on the machine: +If PSYDAC has been significant in your research, and you would like to acknowledge the project in your academic publication, we would ask that you cite the following paper: + +Güçlü, Y., S. Hadjout, and A. Ratnani. “PSYDAC: A High-Performance IGA Library in Python.” In 8th European Congress on Computational Methods in Applied Sciences and Engineering. CIMNE, 2022. https://doi.org/10.23967/eccomas.2022.227. + +The associated BibTeX file can be found [here](./CITATION.bib). + +## Installation + +PSYDAC requires a certain number of components to be installed on the machine: - Fortran and C compilers with OpenMP support - OpenMP library @@ -26,189 +30,90 @@ Psydac requires a certain number of components to be installed on the machine: - MPI library - HDF5 library with MPI support -The installations instructions depend on the operating system and on the packaging manager used. - -### Linux Debian-Ubuntu-Mint - -To install all requirements on a Linux Ubuntu operating system, just use APT, the Advanced Packaging Tool: -```sh -sudo apt update -sudo apt install python3 python3-dev python3-pip -sudo apt install gcc gfortran -sudo apt install libblas-dev liblapack-dev -sudo apt install libopenmpi-dev openmpi-bin -sudo apt install libomp-dev libomp5 -sudo apt install libhdf5-openmpi-dev -``` - -### macOS - -To install all the requirements on a macOS operating system we recommend using [Homebrew](https://brew.sh/): - -```eh -brew update -brew install gcc -brew install openblas -brew install lapack -brew install open-mpi -brew install libomp -brew install hdf5-mpi -``` - -### Other operating systems - -Please see the [instructions for the pyccel library](https://github.com/pyccel/pyccel#Requirements) for further details. +The installation instructions depend on the operating system and on the packaging manager used. +It is particularly important to determine the **HDF5 root folder**, as this will be needed to install the [`h5py`](https://docs.h5py.org/en/latest/build.html#source-installation) package in parallel mode. +Detailed instructions can be found in the [documentation](./docs/installation.md). -## Python setup and project download - -We recommend creating a clean Python virtual environment using [venv](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment): -```sh +Once those components are installed, we recommend using [`venv`](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment) to set up a fresh Python virtual environment at a location ``: +```bash python3 -m venv -``` -where `` is the location to create the virtual environment. -(A new directory will be created at the required location.) - -In order to activate the environment from a new terminal session just run the command -```sh source /bin/activate ``` -One can clone the Psydac repository at any location `` in the filesystem which does not require administrator privileges, using either -```sh +PSYDAC and its Python dependencies can now be installed in the virtual environment using [`pip`](https://pip.pypa.io/en/stable/), the Python package manager: +```bash git clone https://github.com/pyccel/psydac.git -``` -or -```sh -git clone git@github.com:pyccel/psydac.git -``` -The latter command requires a GitHub account. -## Installing the library - -Psydac depends on several Python packages, which should be installed in the newly created virtual environment. -These dependencies can be installed from the cloned directory `/psydac` with the following steps. - -First, set an environment variable with the path to the parallel HDF5 library. -This path can be obtained with a command which depends on your system. - -- **Ubuntu/Debian**: - ```sh - export HDF5_DIR=$(dpkg -L libhdf5-openmpi-dev | grep "libhdf5.so" | xargs dirname) - ``` - -- **macOS**: - ```sh - export HDF5_DIR=$(brew list hdf5-mpi | grep "libhdf5.dylib" | xargs dirname | xargs dirname) - ``` - -Next, install the Python dependencies using `pip`: -```sh export CC="mpicc" export HDF5_MPI="ON" +export HDF5_DIR= -python3 -m pip install --upgrade pip -python3 -m pip install -r requirements.txt -python3 -m pip install -r requirements_extra.txt --no-build-isolation --no-cache-dir +pip install --upgrade pip +pip install h5py --no-cache-dir --no-binary h5py +pip install ./psydac ``` +Here `` is the path to the HDF5 root folder, such that `/lib/` contains the HDF5 dynamic libraries with MPI support. +For an editable install, the `-e/--editable` flag should be provided to the last command above. -At this point the Psydac library may be installed in **standard mode**, which copies the relevant files to the correct locations of the virtual environment, or in **development mode**, which only installs symbolic links to the Psydac directory. The latter mode allows one to effect the behavior of Psydac by modifying the source files. - -- **Standard mode**: - ```bash - python3 -m pip install . - ``` - -- **Development mode**: - ```bash - python3 -m pip install --editable . - ``` +Again, for more details we refer to our [documentation](./docs/installation.md). -## Optional PETSc installation +> [!TIP] +> PSYDAC provides the functionality to convert its MPI-parallel matrices and vectors to their [PETSc](https://petsc.org) equivalent, and back. +> This gives the user access to a wide variety of linear solvers and other algorithms. +> Instructions for installing [PETSc](https://petsc.org) and `petsc4py` can be found in our [documentation](.docs/installation.md#optional-petsc-installation). -Although Psydac provides several iterative linear solvers which work with our native matrices and vectors, it is often useful to access a dedicated library like [PETSc](https://petsc.org). To this end, our matrices and vectors have the method `topetsc()`, which converts them to the corresponding `petsc4py` objects. -(`petsc4py` is a Python package which provides Python bindings to PETSc.) After solving the linear system with a PETSc solver, the function `petsc_to_psydac` allows converting the solution vector back to the Psydac format. +## Running Tests -In order to use these additional feature, PETSc and petsc4py must be installed as follows. -First, we download the latest release of PETSc from its [official Git repository](https://gitlab.com/petsc/petsc): -```sh -git clone --depth 1 --branch v3.21.4 https://gitlab.com/petsc/petsc.git -``` -Next, we specify a configuration for complex numbers, and install PETSc in a local directory: -```sh -cd petsc - -export PETSC_DIR=$(pwd) -export PETSC_ARCH=petsc-cmplx - -./configure --with-scalar-type=complex --with-fortran-bindings=0 --have-numpy=1 - -make all check - -cd - -``` -Finally, we install the Python package `petsc4py` which is included in the `PETSc` source distribution: -```sh -python3 -m pip install wheel Cython numpy -python3 -m pip install petsc/src/binding/petsc4py +The test suite of PSYDAC is based on [`pytest`](https://docs.pytest.org/en/stable/), which should be installed in the same virtual environment: +```bash +source /bin/activate +pip install pytest ``` -## Uninstall - -- **Whichever the install mode**: - ```bash - python3 -m pip uninstall psydac - ``` -- **If PETSc was installed**: - ```bash - python3 -m pip uninstall petsc4py - ``` - -The non-Python dependencies can be uninstalled manually using the package manager. -In the case of PETSc, it is sufficient to remove the cloned source directory given that the installation has been performed locally. - -## Running tests - -Let `` be the installation directory of Psydac. +Let `` be the installation directory of PSYDAC. In order to run all serial and parallel tests which do not use PETSc, just type: ```bash export PSYDAC_MESH_DIR=/mesh/ -python3 -m pytest --pyargs psydac -m "not parallel and not petsc" -python3 /mpi_tester.py --pyargs psydac -m "parallel and not petsc" +pytest --pyargs psydac -m "not parallel and not petsc" +python /mpi_tester.py --pyargs psydac -m "parallel and not petsc" ``` If PETSc and petsc4py were installed, some additional tests can be run: ```bash -python3 -m pytest --pyargs psydac -m "not parallel and petsc" -python3 /mpi_tester.py --pyargs psydac -m "parallel and petsc" +pytest --pyargs psydac -m "not parallel and petsc" +python /mpi_tester.py --pyargs psydac -m "parallel and petsc" ``` -## Speeding up **Psydac**'s core +## Speeding up PSYDAC's core -Many of Psydac's low-level Python functions can be translated to a compiled language using the [Pyccel](https://github.com/pyccel/pyccel) transpiler. Currently, all of those functions are collected in modules which follow the name pattern `[module]_kernels.py`. +Many of PSYDAC's low-level Python functions can be translated to a compiled language using the [Pyccel](https://github.com/pyccel/pyccel) transpiler. Currently, all of those functions are collected in modules which follow the name pattern `[module]_kernels.py`. The classical installation translates all kernel files to Fortran without user intervention. This does not happen in the case of an editable install, but the command `psydac-accelerate` is made available to the user instead. This command applies Pyccel to all the kernel files in the source directory. The default language is currently Fortran, C should also be supported in a near future. - **Only in development mode**: ```bash - python3 /path/to/psydac/psydac_accelerate.py [--language LANGUAGE] [--openmp] + python /path/to/psydac/psydac_accelerate.py [--language LANGUAGE] [--openmp] ``` -## User documentation +## Examples and Tutorials -- [Output formats](./output.md) -- [Notebook examples](./examples/notebooks/) -- [Other examples](./examples/) +A [tutorial](https://pyccel.github.io/IGA-Python/intro.html) on isogeometric analysis, with many example notebooks where various PDEs are solved with PSYDAC, is under construction in the [IGA-Python](https://github.com/pyccel/IGA-Python) repository. +Some other examples can be found [here](./examples/). -## Code documentation +## Library Documentation -Find our latest code documentation [here](https://pyccel.github.io/psydac/). +- [Output formats](./docs/output.md) +- [Mesh generation](./docs/psydac-mesh.md) +- [Library reference](https://pyccel.github.io/psydac/) -## Mesh Generation +## Contributing -After installation, a command `psydac-mesh` will be available. +There are several ways to contribute to this project! -### Example of usage +If you find a problem, please check if this is already discussed in one of [our issues](https://github.com/pyccel/psydac/issues) and feel free to add your opinion; if not, please create a [new issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/creating-an-issue). +If you want to fix an issue, improve our notebooks, or add a new example, please [fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) our Git repository, make and commit your changes, and create a [pull request](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) (PRs). +All PRs are reviewed by the project maintainers. +During the PR review, GitHub workflows are triggered on various platforms. -```bash -psydac-mesh -n='16,16' -d='3,3' square mesh.h5 -``` +We keep an up-to-date list of maintainers and contributors in our [AUTHORS](./AUTHORS) file. +Thank you! diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 000000000..e71e4ddef --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,202 @@ +# Installation + +- [Requirements](#requirements) +- [Python setup and project download](#python-setup-and-project-download) +- [Installing the library](#installing-the-library) +- [Optional PETSc installation](#optional-petsc-installation) +- [Uninstall](#uninstall) + +## Requirements + +Psydac requires a certain number of components to be installed on the machine: + +- Fortran and C compilers with OpenMP support +- OpenMP library +- BLAS and LAPACK libraries +- MPI library +- HDF5 library with MPI support + +The installation instructions depend on the operating system and on the packaging manager used. + +### Linux Debian-Ubuntu-Mint + +To install all requirements on a Linux Ubuntu operating system, just use APT, the Advanced Packaging Tool: +```sh +sudo apt update +sudo apt install python3 python3-dev python3-pip +sudo apt install gcc gfortran +sudo apt install libblas-dev liblapack-dev +sudo apt install libopenmpi-dev openmpi-bin +sudo apt install libomp-dev libomp5 +sudo apt install libhdf5-openmpi-dev +``` + +### macOS + +To install all the requirements on a macOS operating system we recommend using [Homebrew](https://brew.sh/): + +```eh +brew update +brew install gcc +brew install openblas +brew install lapack +brew install open-mpi +brew install libomp +brew install hdf5-mpi +``` + +### Other operating systems + +Please see the [instructions for the pyccel library](https://github.com/pyccel/pyccel#Requirements) for further details. + +### High-performance computers using Environment Modules + +Many high-performance computers use [Environment Modules](https://modules.sourceforge.net/). +On those systems one typically needs to load the correct versions (i.e. compatible with each other) of the modules `gcc`, `openmpi`, and `hdf5-mpi`, e.g. + +```sh +module load gcc/15 +module load openmpi/5.0 +module load hdf5-mpi/1.14.1 +``` +OpenMP instructions should work out of the box. +For access to BLAS and LAPACK routines there are usually different options, we refer therefore to any documentation provided by the supercomputer's maintainers. + +## Python setup and project download + +We recommend creating a clean Python virtual environment using [venv](https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment): +```sh +python3 -m venv +``` +where `` is the location to create the virtual environment. +(A new directory will be created at the required location.) +In order to activate the environment just run the command +```sh +source /bin/activate +``` +At this point the commands `python` and [`pip`](https://pip.pypa.io/en/stable/) will refer to the Python 3 interpreter and package manager of the virtual environment, respectively. +Additionally, the command `deactivate` closes the environment. +It is good practice to keep `pip` up to date with +```sh +pip install --upgrade pip +``` + +One can clone the Psydac repository at any location `` in the filesystem which does not require administrator privileges, using either +```sh +git clone https://github.com/pyccel/psydac.git +``` +or +```sh +git clone git@github.com:pyccel/psydac.git +``` +The latter command requires a GitHub account. + +## Installing the library + +Psydac depends on several Python packages, which should be installed in the newly created virtual environment. +Almost all of these dependencies will be automatically installed by `pip` at the time of installing the `psydac` package later on. + +The single exception is the `h5py` package, which needs to be installed in parallel mode. +This means that a wheel will be built from sources and linked to the local parallel HDF5 library. + +To this end, we first set the environment variable `HDF5_DIR` s.t. the path `$HDF5_DIR/lib/` will correspond to the folder containing the dynamic library `libhdf5.so` (on Ubuntu/Debian) or `libhdf5.dylib` (on macOS). +This path can be obtained with a command which depends on your system. + +- **Ubuntu/Debian**: + ```sh + export HDF5_DIR=$(dpkg -L libhdf5-openmpi-dev | grep "libhdf5.so" | xargs dirname) + ``` + +- **macOS**: + ```sh + export HDF5_DIR=$(brew list hdf5-mpi | grep "libhdf5.dylib" | xargs dirname | xargs dirname) + ``` + +- **High-performance computers using [Environment Modules](https://modules.sourceforge.net/)**: + + The correct location of the HDF5 library can be found using the `module show` command, which reveals any environment variables after the `setenv` keyword. + For example, on this system both `HDF5_HOME` and `HDF5_ROOT` contain the information we need: + + ```sh + > module show hdf5-mpi/1.14.1 + + ------------------------------------------------------------------- + /mpcdf/soft/SLE_15/sub/gcc_15/sub/openmpi_5_0/modules/libs/hdf5-mpi/1.14.1: + + module-whatis {HDF5 library 1.14.1 with MPI support, built for openmpi_5_0_7_gcc_15_1} + conflict hdf5-serial + conflict hdf5-mpi + setenv HDF5_HOME /mpcdf/soft/SLE_15/packages/skylake/hdf5/gcc_15-15.1.0-openmpi_5.0-5.0.7/1.14.1 + setenv HDF5_ROOT /mpcdf/soft/SLE_15/packages/skylake/hdf5/gcc_15-15.1.0-openmpi_5.0-5.0.7/1.14.1 + prepend-path PATH /mpcdf/soft/SLE_15/packages/skylake/hdf5/gcc_15-15.1.0-openmpi_5.0-5.0.7/1.14.1/bin + ------------------------------------------------------------------- + ``` + + Therefore it is sufficient to set + + ```sh + export HDF5_DIR=$HDF5_HOME + ``` + +Next, install `h5py` in parallel mode using `pip`: +```sh +export CC="mpicc" +export HDF5_MPI="ON" + +pip install h5py --no-cache-dir --no-binary h5py +``` + +At this point the Psydac library may be installed from the cloned directory `/psydac` in **standard mode**, which copies the relevant files to the correct locations of the virtual environment, or in **development mode**, which only installs symbolic links to the Psydac directory. The latter mode allows one to affect the behavior of Psydac by modifying the source files. + +- **Standard mode**: + ```bash + pip install . + ``` + +- **Development mode**: + ```bash + pip install --editable . + ``` + +## Optional PETSc installation + +Although Psydac provides several iterative linear solvers which work with our native matrices and vectors, it is often useful to access a dedicated library like [PETSc](https://petsc.org). To this end, our matrices and vectors have the method `topetsc()`, which converts them to the corresponding `petsc4py` objects. +(`petsc4py` is a Python package which provides Python bindings to PETSc.) After solving the linear system with a PETSc solver, the function `petsc_to_psydac` allows converting the solution vector back to the Psydac format. + +In order to use these additional feature, PETSc and petsc4py must be installed as follows. +First, we download the latest release of PETSc from its [official Git repository](https://gitlab.com/petsc/petsc): +```sh +git clone --depth 1 --branch v3.21.4 https://gitlab.com/petsc/petsc.git +``` +Next, we specify a configuration for complex numbers, and install PETSc in a local directory: +```sh +cd petsc + +export PETSC_DIR=$(pwd) +export PETSC_ARCH=petsc-cmplx + +./configure --with-scalar-type=complex --with-fortran-bindings=0 --have-numpy=1 + +make all check + +cd - +``` +Finally, we install the Python package `petsc4py` which is included in the `PETSc` source distribution: +```sh +pip install wheel Cython numpy +pip install petsc/src/binding/petsc4py +``` + +## Uninstall + +- **Whichever the install mode**: + ```bash + pip uninstall psydac + ``` +- **If PETSc was installed**: + ```bash + pip uninstall petsc4py + ``` + +The non-Python dependencies can be uninstalled manually using the package manager. +In the case of PETSc, it is sufficient to remove the cloned source directory given that the installation has been performed locally. diff --git a/output.md b/docs/output.md similarity index 100% rename from output.md rename to docs/output.md diff --git a/docs/psydac-mesh.md b/docs/psydac-mesh.md new file mode 100644 index 000000000..20a543c34 --- /dev/null +++ b/docs/psydac-mesh.md @@ -0,0 +1,9 @@ +# Mesh Generation + +After installation, the command `psydac-mesh` will be available. + +## Example of usage + +```bash +psydac-mesh -n='16,16' -d='3,3' square mesh.h5 +``` diff --git a/docs_requirements.txt b/docs/requirements.txt similarity index 100% rename from docs_requirements.txt rename to docs/requirements.txt diff --git a/pyproject.toml b/pyproject.toml index 21bd7fb70..c20cf49d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools >= 64.0", "wheel", "numpy", "pyccel >= 1.9.2"] +requires = ["setuptools >= 64.0, != 67.2.0", "wheel", "numpy", "pyccel >= 2.0.1"] build-backend = "setuptools.build_meta" [project] @@ -34,12 +34,17 @@ dependencies = [ 'pyccel >= 2.0.1', 'gelato == 0.12', - # In addition, we depend on mpi4py and h5py (MPI version). - # Since h5py must be built from source, we run the commands + # MPI for Python provides Python bindings for the + # Message Passing Interface (MPI) standard + 'mpi4py >= 4', + + # In addition, we depend on h5py (MPI version). Since h5py must + # be built from source, we install it with the commands # - # python3 -m pip install requirements.txt - # python3 -m pip install . - 'mpi4py', + # export CC="mpicc" + # export HDF5_MPI="ON" + # export HDF5_DIR= + # pip install --no-cache-dir --no-binary h5py 'h5py', # When pyccel is run in parallel with MPI, it uses tblib to pickle diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8cd73ee05..000000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -wheel -setuptools >= 61, != 67.2.0 -numpy >= 1.16 -scipy >= 1.12 -Cython >= 3 -mpi4py >= 4 - -# Required to build h5py from source -pkgconfig diff --git a/requirements_extra.txt b/requirements_extra.txt deleted file mode 100644 index 5f4a812d3..000000000 --- a/requirements_extra.txt +++ /dev/null @@ -1,9 +0,0 @@ -# h5py must be built from source using the MPI compiler -# and linked to the parallel HDF5 library. To do so set -# -# CC="mpicc" -# HDF5_MPI="ON" -# HDF5_DIR=/usr/lib/x86_64-linux-gnu/hdf5/openmpi -# -h5py ---no-binary h5py From 776ac29310d1d1e1d375f7035ad5f9956ee39729 Mon Sep 17 00:00:00 2001 From: Julian Owezarek Date: Wed, 10 Sep 2025 16:40:56 +0200 Subject: [PATCH 14/23] Fast Matrix Assembly (#448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR implements the sum factorization algorithm as shown in **Sum factorization techniques in Isogeometric Analysis** by Andrea Bressan & Stefan Takacs for **bilinear forms on 3D volumes**. The old element-by-element assembly can still be employed by passing a corresponding flag ```python # a some BilinearForm a_h = discretize(a, domain_h, (Vh, Vh), backend=backend, sum_factorization=False) ``` Currently, the new generated code does not make use of OpenMP. Main changes -------------- - Write new module `psydac.api.fem_common` which: * Contains the old functions previously in module `psydac.api.fem`. * Has functions `construct_test_space_arguments` and `construct_trial_space_arguments` returning also the multiplicity. * Has new functions `compute_max_nderiv`, `compute_imports`, `compute_free_arguments`. - Move `DiscreteSumForm` to a new module `psydac.api.fem_sum_form`. - Create a new module `psydac.api.fem_bilinear_form` which only contains one new class `DiscreteBilinearForm`. The new module shows all imports clearly. The new class: * Generates the code to assemble the given discrete bilinear form into a matrix (a `BlockLinearOperator` object of `StencilMatrix` objects, or just a single `StencilMatrix` object), using sum factorization. * Does not inherit from `BasicDiscrete` (and hence `BasicCodeGen`). Accordingly, no PSYDAC abstract syntax tree (an object representing a function, of class `DefNode`) is created by the constructor of `AST`, which stores it in its `expr` attribute. (Both classes `DefNode` and `AST` are defined in module `psydac.api.ast.fem`.) * Does not create a `Parser` object from `psydac.api.ast.parser`, which used to convert a PSYDAC `DefNode` to an old-Pyccel `FunctionDef` from `psydac.pyccel.ast.core`. * Does not generate the old assembly Python code (in the form of a string) using the function `pycode` from `psydac.pyccel.codegen.printing.pycode`. - Add a new unit test file `api/tests/test_sum_factorization_assembly_3d`. - Modify function `discretize` in module `psydac.api.discretization` so that, given a bilinear form in 3D and not asking for OpenMP support, it creates an object of type `DiscreteBilinearForm` from module `psydac.api.fem_bilinear_form`. In all other cases, or if `sum_factorization=False`, it creates an object of namesake type from the old module `psydac.api.fem`. Other changes -------------- - Expand docstring of class `AST` in module `psydac.api.ast.fem`. - Expand docstrings of function `parse` and class `Parser` in module `psydac.api.ast.parser`. Clean up `Parser.__init__`. - Minor cleanup in class `BasicCodeGen` in module `psydac.api.basic`. - Generate random filenames using `random.choice()` instead of `random.SystemRandom().choice()`. Unrelated to matrix assembly: - Add method `set_scalar` to `ScaledLinearOperator`. - Reimplement method `idot` of `LinearOperator` using local storage. This avoids creating unnecessary temporary vectors (especially beneficial for the method `dot` of `SumLinearOperator`). --------- Co-authored-by: Yaman Güçlü Co-authored-by: elmosa --- .../compare_3d_matrix_assembly_speed.py | 643 +++++ performance/matrix_assembly_speed_log.md | 86 + psydac/api/ast/fem.py | 8 +- psydac/api/ast/parser.py | 113 +- psydac/api/basic.py | 8 +- psydac/api/discretization.py | 30 +- psydac/api/equation.py | 4 +- psydac/api/fem.py | 246 +- psydac/api/fem_bilinear_form.py | 2226 +++++++++++++++++ psydac/api/fem_common.py | 286 +++ psydac/api/fem_sum_form.py | 123 + psydac/api/tests/test_api_feec_3d.py | 49 +- .../test_sum_factorization_assembly_3d.py | 592 +++++ psydac/api/utilities.py | 43 +- psydac/linalg/basic.py | 98 +- 15 files changed, 4221 insertions(+), 334 deletions(-) create mode 100644 performance/compare_3d_matrix_assembly_speed.py create mode 100644 performance/matrix_assembly_speed_log.md create mode 100644 psydac/api/fem_bilinear_form.py create mode 100644 psydac/api/fem_common.py create mode 100644 psydac/api/fem_sum_form.py create mode 100644 psydac/api/tests/test_sum_factorization_assembly_3d.py diff --git a/performance/compare_3d_matrix_assembly_speed.py b/performance/compare_3d_matrix_assembly_speed.py new file mode 100644 index 000000000..0bc8678a5 --- /dev/null +++ b/performance/compare_3d_matrix_assembly_speed.py @@ -0,0 +1,643 @@ +import os +import shutil +import time +from pathlib import Path +from datetime import datetime + +from mpi4py import MPI +import numpy as np +#import matplotlib.pyplot as plt +from sympy import sin + +from sympde.calculus import dot, cross, grad, curl +from sympde.expr import BilinearForm, integral +from sympde.topology import element_of, elements_of, Cube, Mapping, ScalarFunctionSpace, Domain, Derham + +from psydac.api.discretization import discretize +from psydac.api.settings import PSYDAC_BACKEND_GPYCCEL +from psydac.cad.geometry import Geometry +from psydac.fem.basic import FemField +from psydac.mapping.discrete import SplineMapping + +datetime_md = datetime.today().strftime('%Y-%m-%d %H:%M:%S') +datetime_file = datetime.today().strftime('%Y-%m-%d_%H:%M:%S') + +""" +Executing this file will add new tables to the matrix_assembly_speed_log.md file in which we keep track of assembly and discretization speed. +Estimated runtime: ~4 min. + +""" + +comm = MPI.COMM_WORLD +backend = PSYDAC_BACKEND_GPYCCEL +mpi_rank = comm.rank + +if mpi_rank == 0: + print('Expected runtime: 4 min.') + print() + +class SquareTorus(Mapping): + + _expressions = {'x': 'x1 * cos(x2)', + 'y': 'x1 * sin(x2)', + 'z': 'x3'} + + _ldim = 3 + _pdim = 3 + +def make_square_torus_geometry_3d(ncells, degree, comm=None): + + if comm is not None: + mpi_rank = comm.Get_rank() + else: + mpi_rank = 0 + + if (ncells[0] == ncells[1]) and (ncells[0] == ncells[2]): + nc = f'{ncells[0]}' + else: + nc = f'{ncells[0]}_{ncells[1]}_{ncells[2]}' + + if (degree[0] == degree[1]) and (degree[0] == degree[2]): + de = f'{degree[0]}' + else: + de = f'{degree[0]}_{degree[1]}_{degree[2]}' + + name = f'st_3d_nc_{nc}_d_{de}' + + r = 0.5 + R = 1. + logical_domain = Cube('C', bounds1=(r, R), bounds2=(0, 2*np.pi), bounds3=(0, 1)) + domain_h = discretize(logical_domain, ncells=ncells, comm=comm) + + V = ScalarFunctionSpace('V', logical_domain) + V_h = discretize(V, domain_h, degree=degree) + + mapping = SquareTorus('S') + map_discrete = SplineMapping.from_mapping(V_h, mapping.get_callable_mapping()) + + geometry = Geometry.from_discrete_mapping(map_discrete, comm=comm) + + if mpi_rank == 0: + if not os.path.isdir('geometry'): + os.makedirs('geometry') + os.makedirs('geometry/files') + else: + if not os.path.isdir('geometry/files'): + os.makedirs('geometry/files') + + geometry.export(f'geometry/files/{name}.h5') + + return f'geometry/files/{name}.h5' + +# ---------- 1 ---------- +#t0_1_glob = time.time() +# +#ncells = [32, 32, 32] +#degree_list = [[2, 2, 2], [3, 3, 3], [4, 4, 4], [5, 5, 5]] +#degree_list2 = [[2, 2, 2], [3, 3, 3], [4, 4, 4]] +#periodic = [False, False, False] +# +##mapping = HalfHollowTorusMapping3D('M', R=2, r=1) +#mapping = SquareTorus('S') +##logical_domain = Cube('C', bounds1=(0,1), bounds2=(0,1), bounds3=(0,1)) +#r = 0.5 +#R = 1. +#logical_domain = Cube('C', bounds1=(r, R), bounds2=(0, 2*np.pi), bounds3=(0, 1)) +# +#domain = mapping(logical_domain) +#derham = Derham(domain) +#plot_domain(domain, draw=True, isolines=True) +# +#ass_time_H1_old = [[] for _ in degree_list] +#ass_time_H1_new = [[] for _ in degree_list] +# +#ass_time_Hcurl_old = [[] for _ in degree_list2] +#ass_time_Hcurl_new = [[] for _ in degree_list2] +# +#for i, degree in enumerate(degree_list): +# +# domain_h = discretize(domain, ncells=ncells, periodic=periodic, comm=comm) +# derham_h = discretize(derham, domain_h, degree=degree) +# +# V0 = derham.V0 +# V0h = derham_h.V0 +# +# u, v = elements_of(V0, names='u, v') +# +# a = BilinearForm((u, v), integral(domain, u*v)) +# +# a_h = discretize(a, domain_h, (V0h, V0h), backend=backend, sum_factorization=False) +# +# t0_ao = time.time() +# M_o = a_h.assemble() +# t1_ao = time.time() +# +# a_h = discretize(a, domain_h, (V0h, V0h), backend=backend) +# +# t0_an = time.time() +# M_n = a_h.assemble() +# t1_an = time.time() +# +# ass_time_H1_old[i].append(t1_ao - t0_ao) +# ass_time_H1_new[i].append(t1_an - t0_an) +# +# if degree != [5, 5, 5]: +# +# V1 = derham.V1 +# V1h = derham_h.V1 +# +# u, v = elements_of(V1, names='u, v') +# +# a = BilinearForm((u, v), integral(domain, dot(u, v))) +# +# a_h = discretize(a, domain_h, (V1h, V1h), backend=backend, sum_factorization=False) +# +# t0_ao = time.time() +# M_o = a_h.assemble() +# t1_ao = time.time() +# +# a_h = discretize(a, domain_h, (V1h, V1h), backend=backend) +# +# t0_an = time.time() +# M_n = a_h.assemble() +# t1_an = time.time() +# +# ass_time_Hcurl_old[i].append(t1_ao - t0_ao) +# ass_time_Hcurl_new[i].append(t1_an - t0_an) +# +#d = [degree[0] for degree in degree_list] +#d2 = [degree[0] for degree in degree_list2] +# +#ass_time_H1_old_d = [ass_time_H1_old[i][0] for i, _ in enumerate(degree_list)] +#ass_time_H1_new_d = [ass_time_H1_new[i][0] for i, _ in enumerate(degree_list)] +# +#ass_time_Hcurl_old_d = [ass_time_Hcurl_old[i][0] for i, _ in enumerate(degree_list2)] +#ass_time_Hcurl_new_d = [ass_time_Hcurl_new[i][0] for i, _ in enumerate(degree_list2)] +# +#if mpi_rank == 0: +# plt.plot(d, ass_time_H1_old_d, '--.', label=f'old Algorithm') +# plt.plot(d, ass_time_H1_new_d, '--.', label=f'new Algorithm') +# plt.title(r'Assembly times for the $H^1(\Omega)$ mass matrix') +# plt.legend() +# plt.yscale('log') +# plt.ylabel('Wallclock Time [s]') +# plt.xlabel('d - [d, d, d] Bspline degrees') +# plt.xticks(d) +# #plt.savefig(f'figures/H1_{datetime_file}.png') +# plt.show() +# plt.clf() +# +# plt.plot(d2, ass_time_Hcurl_old_d, '--.', label=f'old Algorithm') +# plt.plot(d2, ass_time_Hcurl_new_d, '--.', label=f'new Algorithm') +# plt.title(r'Assembly times for the $H(curl;\Omega)$ mass matrix') +# plt.legend() +# plt.yscale('log') +# plt.ylabel('Wallclock Time [s]') +# plt.xlabel('d - [d, d, d] Bspline degrees') +# plt.xticks(d2) +# #plt.savefig(f'figures/Hcurl_{datetime_file}.png') +# plt.show() +# +#t1_1_glob = time.time() +#if mpi_rank == 0: +# print(f'Part 1 out of 3 done after {(t1_1_glob-t0_1_glob)/60:.2g}min') +# ----------------------- + +# ---------- 2 ---------- +t0_2_glob = time.time() + +ncells = [32, 32, 32] +degree = [3, 3, 3] +periodic = [False, False, False] + +r = 0.5 +R = 1. +logical_domain = Cube('C', bounds1=(r, R), bounds2=(0, 2*np.pi), bounds3=(0, 1)) +logical_derham = Derham(logical_domain) + +logical_domain_h = discretize(logical_domain, ncells=ncells, periodic=periodic, comm=comm) +logical_derham_h = discretize(logical_derham, logical_domain_h, degree=degree) + +filename = make_square_torus_geometry_3d(ncells, degree, comm=comm) + +bspline_domain = Domain.from_file(filename) +bspline_derham = Derham(bspline_domain) + +bspline_domain_h = discretize(bspline_domain, filename=filename, comm=comm) +bspline_derham_h = discretize(bspline_derham, bspline_domain_h, degree=bspline_domain.mapping.get_callable_mapping().space.degree) + +mapping = SquareTorus('S') + +analytical_domain = mapping(logical_domain) +analytical_derham = Derham(analytical_domain) + +analytical_domain_h = discretize(analytical_domain, ncells=ncells, periodic=periodic, comm=comm) +analytical_derham_h = discretize(analytical_derham, analytical_domain_h, degree=degree) + +ax, ay, az = analytical_domain.coordinates +agamma = ax*ay*az + sin(ax*ay+az)**2 + +# 2.1 +dom = logical_domain +domh = logical_domain_h +V = logical_derham.V1 +Vh = logical_derham_h.V1 + +F = element_of(V, name='F') +f = Vh.coeff_space.zeros() +f[0]._data = np.ones(f[0]._data.shape) +f[1]._data = np.ones(f[1]._data.shape) +f[2]._data = np.ones(f[2]._data.shape) +f_field = FemField(Vh, f) + +u, v = elements_of(V, names='u, v') +a = BilinearForm((u, v), integral(dom, dot(cross(F, u), cross(F, v)))) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_21 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble(F=f_field) +t1 = time.time() +old_ass_21 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_21 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble(F=f_field) +t1 = time.time() +new_ass_21 = round(t1-t0, 3) + +# 2.2 +dom = analytical_domain +domh = analytical_domain_h +V = analytical_derham.V2 +Vh = analytical_derham_h.V2 + +u, v = elements_of(V, names='u, v') +a = BilinearForm((u, v), integral(dom, dot(u, v)*agamma)) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_22 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +old_ass_22 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_22 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +new_ass_22 = round(t1-t0, 3) + +# 2.3 +dom = bspline_domain +domh = bspline_domain_h +V = bspline_derham.V1 +Vh = bspline_derham_h.V1 + +u, v = elements_of(V, names='u, v') +a = BilinearForm((u, v), integral(dom, dot(curl(u), curl(v)))) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_23 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +old_ass_23 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_23 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +new_ass_23 = round(t1-t0, 3) + +t1_2_glob = time.time() +if mpi_rank == 0: + print(f'Part 2 out of 3 done after {(t1_2_glob-t0_2_glob)/60:.2g}min') +# ----------------------- + +# ---------- 3 ---------- +t0_3_glob = time.time() + +ncells = [16, 8, 32] +degree = [2, 4, 3] +periodic = [False, True, False] + +r = 0.5 +R = 1. +logical_domain = Cube('C', bounds1=(r, R), bounds2=(0, 2*np.pi), bounds3=(0, 1)) +logical_derham = Derham(logical_domain) + +logical_domain_h = discretize(logical_domain, ncells=ncells, periodic=periodic, comm=comm) +logical_derham_h = discretize(logical_derham, logical_domain_h, degree=degree) + +filename = make_square_torus_geometry_3d(ncells, degree, comm=comm) + +bspline_domain = Domain.from_file(filename) +bspline_derham = Derham(bspline_domain) + +bspline_domain_h = discretize(bspline_domain, filename=filename, comm=comm) +bspline_derham_h = discretize(bspline_derham, bspline_domain_h, degree=bspline_domain.mapping.get_callable_mapping().space.degree) + +mapping = SquareTorus('S') + +analytical_domain = mapping(logical_domain) +analytical_derham = Derham(analytical_domain) + +analytical_domain_h = discretize(analytical_domain, ncells=ncells, periodic=periodic, comm=comm) +analytical_derham_h = discretize(analytical_derham, analytical_domain_h, degree=degree) + +ax, ay, az = analytical_domain.coordinates +agamma = ax*ay*az + sin(ax*ay+az)**2 + +# 3.1.1 +dom = logical_domain +domh = logical_domain_h +V = logical_derham.V0 +Vh = logical_derham_h.V0 + +u, v = elements_of(V, names='u, v') +a = BilinearForm((u, v), integral(dom, dot(grad(u), grad(v)))) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_311 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +old_ass_311 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_311 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +new_ass_311 = round(t1-t0, 3) + +# 3.1.2 +dom = analytical_domain +domh = analytical_domain_h +V = analytical_derham.V0 +Vh = analytical_derham_h.V0 + +u, v = elements_of(V, names='u, v') +a = BilinearForm((u, v), integral(dom, dot(grad(u), grad(v)))) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_312 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +old_ass_312 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_312 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +new_ass_312 = round(t1-t0, 3) + +# 3.1.3 +dom = bspline_domain +domh = bspline_domain_h +V = bspline_derham.V0 +Vh = bspline_derham_h.V0 + +u, v = elements_of(V, names='u, v') +a = BilinearForm((u, v), integral(dom, dot(grad(u), grad(v)))) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_313 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +old_ass_313 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_313 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +new_ass_313 = round(t1-t0, 3) + +# 3.2 +dom = analytical_domain +domh = analytical_domain_h +V = ScalarFunctionSpace('V', analytical_domain) +Vh = discretize(V, analytical_domain_h, degree=degree) + +u, v = elements_of(V, names='u, v') +a = BilinearForm((u, v), integral(dom, dot(grad(u), grad(v)))) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_32 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +old_ass_32 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_32 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +new_ass_32 = round(t1-t0, 3) + +# 3.3 +dom = analytical_domain +domh = analytical_domain_h +V = ScalarFunctionSpace('V', analytical_domain) +Vh = discretize(V, analytical_domain_h, degree=degree) +W = analytical_derham.V0 +Wh = analytical_derham_h.V0 + +u = element_of(V, name='u') +v = element_of(W, name='v') +a = BilinearForm((u, v), integral(dom, dot(grad(u), grad(v)))) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_33 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +old_ass_33 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_33 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +new_ass_33 = round(t1-t0, 3) + +# ------------------ +dom = analytical_domain +domh = analytical_domain_h +V = analytical_derham.V0 +Vh = analytical_derham_h.V0 + +u, v = elements_of(V, names='u, v') +# ------------------ + +# 3.4 +a = BilinearForm((u, v), integral(dom, dot(grad(u), grad(v)) * agamma)) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_34 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +old_ass_34 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_34 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble() +t1 = time.time() +new_ass_34 = round(t1-t0, 3) + +# 3.5 +F = element_of(V, name='F') +f = Vh.coeff_space.zeros() +f._data = np.ones(f._data.shape) +f_field = FemField(Vh, f) + +a = BilinearForm((u, v), integral(dom, dot(grad(u), grad(v)) * F)) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_35 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble(F=f_field) +t1 = time.time() +old_ass_35 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_35 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble(F=f_field) +t1 = time.time() +new_ass_35 = round(t1-t0, 3) + +# 3.6 +mult = [1, 3, 2] +analytical_derham_h = discretize(analytical_derham, analytical_domain_h, degree=degree, multiplicity=mult) +Vh = analytical_derham_h.V0 + +a = BilinearForm((u, v), integral(dom, dot(grad(u), grad(v)) * F)) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend, sum_factorization=False) +t1 = time.time() +old_disc_36 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble(F=f_field) +t1 = time.time() +old_ass_36 = round(t1-t0, 3) + +t0 = time.time() +ah = discretize(a, domh, (Vh, Vh), backend=backend) +t1 = time.time() +new_disc_36 = round(t1-t0, 3) +t0 = time.time() +M = ah.assemble(F=f_field) +t1 = time.time() +new_ass_36 = round(t1-t0, 3) + +t1_3_glob = time.time() +if mpi_rank == 0: + print(f'Part 3 out of 3 done after {(t1_3_glob-t0_3_glob)/60:.2g}min') +# ----------------------- + +template = '''| Test case | old assembly | new assembly | old discretization | new discretization | +| --- | --- | --- | --- | --- | +| 2.1 | {old_ass_21} | {new_ass_21} | {old_disc_21} | {new_disc_21} | +| 2.2 | {old_ass_22} | {new_ass_22} | {old_disc_22} | {new_disc_22} | +| 2.3 | {old_ass_23} | {new_ass_23} | {old_disc_23} | {new_disc_23} | +| 3.1.1 | {old_ass_311} | {new_ass_311} | {old_disc_311} | {new_disc_311} | +| 3.1.2 | {old_ass_312} | {new_ass_312} | {old_disc_312} | {new_disc_312} | +| 3.1.3 | {old_ass_313} | {new_ass_313} | {old_disc_313} | {new_disc_313} | +| 3.2 | {old_ass_32} | {new_ass_32} | {old_disc_32} | {new_disc_32} | +| 3.3 | {old_ass_33} | {new_ass_33} | {old_disc_33} | {new_disc_33} | +| 3.4 | {old_ass_34} | {new_ass_34} | {old_disc_34} | {new_disc_34} | +| 3.5 | {old_ass_35} | {new_ass_35} | {old_disc_35} | {new_disc_35} | +| 3.6 | {old_ass_36} | {new_ass_36} | {old_disc_36} | {new_disc_36} |''' + +txt = '' +txt += f'{datetime_md}\n' +txt += f'----------\n\n' +#txt += f'![](tests/figures/H1_{datetime_file}.png)\n' +#txt += f'![](tests/figures/Hcurl_{datetime_file}.png)\n\n' +txt += template.format(old_ass_21=old_ass_21, new_ass_21=new_ass_21, old_disc_21=old_disc_21, new_disc_21=new_disc_21, + old_ass_22=old_ass_22, new_ass_22=new_ass_22, old_disc_22=old_disc_22, new_disc_22=new_disc_22, + old_ass_23=old_ass_23, new_ass_23=new_ass_23, old_disc_23=old_disc_23, new_disc_23=new_disc_23, + old_ass_311=old_ass_311, new_ass_311=new_ass_311, old_disc_311=old_disc_311, new_disc_311=new_disc_311, + old_ass_312=old_ass_312, new_ass_312=new_ass_312, old_disc_312=old_disc_312, new_disc_312=new_disc_312, + old_ass_313=old_ass_313, new_ass_313=new_ass_313, old_disc_313=old_disc_313, new_disc_313=new_disc_313, + old_ass_32=old_ass_32, new_ass_32=new_ass_32, old_disc_32=old_disc_32, new_disc_32=new_disc_32, + old_ass_33=old_ass_33, new_ass_33=new_ass_33, old_disc_33=old_disc_33, new_disc_33=new_disc_33, + old_ass_34=old_ass_34, new_ass_34=new_ass_34, old_disc_34=old_disc_34, new_disc_34=new_disc_34, + old_ass_35=old_ass_35, new_ass_35=new_ass_35, old_disc_35=old_disc_35, new_disc_35=new_disc_35, + old_ass_36=old_ass_36, new_ass_36=new_ass_36, old_disc_36=old_disc_36, new_disc_36=new_disc_36) +txt += '\n\n' + +if mpi_rank == 0: + # Write performance table to MarkDown file + with open('matrix_assembly_speed_log.md', 'a') as f: + f.write(txt) + + # Remove temporary folders + base_dir = Path(__file__).parent + dirs_to_remove = [ + "geometry", + "__psydac__", + "__pycache__", + "__epyccel__", + "__gpyccel__" + ] + for d in dirs_to_remove: + path = base_dir / d + if path.exists(): + try: + shutil.rmtree(path) + except Exception as e: + print(f"Failed to remove {path}: {e}") diff --git a/performance/matrix_assembly_speed_log.md b/performance/matrix_assembly_speed_log.md new file mode 100644 index 000000000..996975d26 --- /dev/null +++ b/performance/matrix_assembly_speed_log.md @@ -0,0 +1,86 @@ +New Matrix Assembly for Psydac +------------------------------ + +Here we keep track on the performance of the new assembly algorithm (sum factorization). +We measure both the discretization time of `BilinearForms` as well as the matrix assembly time of a `DiscreteBilinearForm` +and compare it to the old algorithm. Executing `compare_3d_matrix_assembly_speed.py` will add new data to this file. +This allows us to detect whether any future changes have a positive or negative impact. + +(Of course, runtime depends on the machine used to execute this file. Hence, an even decrease in runtime +is no reason to celebrate, and an even increase in runtime no reason to worry.) + +Test cases +---------- + +1. Scaling Analysis \ (**REMOVED**) + We assemble the H1 and Hcurl mass matrices ($\Omega$ is a half hollow torus (analytical mapping), ncells = [32, 32, 32], periodic=[False, False, False]) for varying degrees + and report the assembly times in Figures. + +2. Three specific test cases \ + We report assembly and discretization time for the following test cases \ + 2.1 "Q" + - $(u, v; F)\mapsto\int_{\Omega}(F\times u)\circ(F\times v)$ + - $u, v, F\in H(curl;\Omega)$, $F$ a "free field" + - no mapping ($\Omega=(0,1)^3$) + - ncells = [32, 32, 32], degree = [3, 3, 3], periodic = [False, False, False] + + 2.2 "weighted $Hdiv$ mass matrix" + - $(u, v; \gamma)\mapsto\int_{\Omega}u\circ v\ \gamma$ + - $u, v\in H(div;\Omega)$, $\gamma$ an analytic scalar weight function + - analytical mapping ($\Omega$ a half hollow torus) + - ncells = [32, 32, 32], degree = [3, 3, 3], periodic = [False, False, False] + + 2.3 "curl curl" + - $(u, v)\mapsto\int_{\Omega}(\nabla\times u)\circ(\nabla\times v)$ + - $u, v\in H(curl;\Omega)$ + - Bspline mapping ($\Omega$ a half hollow torus) + - ncells = [32, 32, 32], degree = [3, 3, 3], periodic = [False, False, False] + +3. More variations! + - $(u, v)\mapsto\int_{\Omega}\nabla u\circ\nabla v$ + - ncells = [16, 8, 32], degree = [2, 4, 3], periodic = [False, True, False] + - 3.1.1 + - `u, v = elements_of(derham.V0, names='u, v')` + - no mapping + - 3.1.2 + - `u, v = elements_of(derham.V0, names='u, v')` + - analytical mapping + - 3.1.3 + - `u, v = elements_of(derham.V0, names='u, v')` + - Bspline mapping + - 3.2 + - `u, v = elements_of(ScalarFunctionSpace('Vs', domain), names='u, v')` + - analytical mapping + - 3.3 + - `u = element_of(derham.V0, name='u')` + - `v = element_of(ScalarFunctionSpace('Vs', domain), name='v')` + - analytical mapping + - `u, v = elements_of(derham.V0, names='u, v')` + - analytical mapping + - 3.4 + - additional analytical weight function $\gamma$ + - 3.5 + - additional scalar free field $F\in H^1(\Omega)$ + - 3.6 + - multiplicity vector [1, 3, 2] + +Data +---- + +2025-09-09 17:28:19 (added by Julian O. - ThinkPad T14 on performance mode) +---------- + +| Test case | old assembly | new assembly | old discretization | new discretization | +| --- | --- | --- | --- | --- | +| 2.1 | 21.358 | 1.676 | 16.568 | 16.589 | +| 2.2 | 2.348 | 0.322 | 4.533 | 3.38 | +| 2.3 | 36.858 | 3.171 | 16.749 | 19.413 | +| 3.1.1 | 0.236 | 0.044 | 1.462 | 1.482 | +| 3.1.2 | 0.54 | 0.051 | 1.722 | 1.52 | +| 3.1.3 | 0.802 | 0.169 | 3.579 | 2.414 | +| 3.2 | 0.517 | 0.045 | 1.027 | 1.425 | +| 3.3 | 0.525 | 0.06 | 1.023 | 1.527 | +| 3.4 | 0.524 | 0.044 | 1.085 | 1.547 | +| 3.5 | 0.577 | 0.057 | 1.51 | 1.784 | +| 3.6 | 0.598 | 0.209 | 2.411 | 1.848 | + diff --git a/psydac/api/ast/fem.py b/psydac/api/ast/fem.py index b56ee3d28..adf7ff05f 100644 --- a/psydac/api/ast/fem.py +++ b/psydac/api/ast/fem.py @@ -238,8 +238,12 @@ def get_degrees(funcs, space): #============================================================================== class AST(object): """ - The Ast class transforms a terminal expression returned from sympde - into a DefNode + The AST class transforms a terminal expression returned from SymPDE + into a DefNode object, which it stores into the attribute `expr`. + + A DefNode represents a function definition, and it contains the full + PSYDAC abstract syntax tree for the assembly function of a BilinearForm, + LinearForm, or Functional. """ def __init__(self, expr, terminal_expr, spaces, *, nquads, mapping_space=None, tag=None, mapping=None, is_rational_mapping=None, diff --git a/psydac/api/ast/parser.py b/psydac/api/ast/parser.py index eb8ac0a79..f85d82600 100644 --- a/psydac/api/ast/parser.py +++ b/psydac/api/ast/parser.py @@ -4,28 +4,29 @@ from sympy import S from sympy import IndexedBase, Indexed -from sympy import Mul, Matrix, Expr +from sympy import Mul, Matrix from sympy import Add, And, StrictLessThan, Eq -from sympy import Abs, Not, floor -from sympy import Symbol, Idx -from sympy import Basic, Function +from sympy import Not +from sympy import Symbol +from sympy import Basic from sympy import MutableDenseNDimArray as MArray from sympy.simplify import cse_main from sympy.core.containers import Tuple from sympde.topology import (dx1, dx2, dx3) from sympde.topology import SymbolicExpr -from sympde.topology import LogicalExpr, Jacobian +from sympde.topology import LogicalExpr from sympde.expr.evaluation import _split_test_function -from sympde.calculus.matrices import SymbolicDeterminant -from sympde.topology import SymbolicWeightedVolume, InterfaceMapping +from sympde.topology import SymbolicWeightedVolume from sympde.topology import Boundary, NormalVector, Interface +from sympde.topology.basic import BasicDomain +from sympde.topology.mapping import Mapping from sympde.topology.derivatives import get_index_logical_derivatives -from psydac.pyccel.ast.core import Assign, Product, AugAssign, For +from psydac.pyccel.ast.core import Assign, AugAssign, For from psydac.pyccel.ast.core import Variable, IndexedVariable, IndexedElement -from psydac.pyccel.ast.core import Slice, String, ValuedArgument +from psydac.pyccel.ast.core import Slice from psydac.pyccel.ast.core import EmptyNode, Import, While, Return, If from psydac.pyccel.ast.core import CodeBlock, FunctionDef, Comment from psydac.pyccel.ast.builtins import Range @@ -37,7 +38,6 @@ from .nodes import AtomicNode from .nodes import BasisAtom -from .nodes import PhysicalBasisValue from .nodes import LogicalBasisValue from .nodes import TensorQuadrature from .nodes import LocalTensorQuadratureBasis @@ -46,7 +46,6 @@ from .nodes import GlobalTensorQuadratureTestBasis from .nodes import GlobalTensorQuadratureTrialBasis from .nodes import GlobalTensorQuadratureBasis -from .nodes import TensorQuadratureBasis from .nodes import SplitArray from .nodes import Reduction from .nodes import LogicalValueNode @@ -72,12 +71,11 @@ from .nodes import WeightedVolumeQuadrature from .nodes import LengthDofTest -from .nodes import index_outer_dof_test from .nodes import index_dof_test, index_dof_trial from .nodes import index_deriv, Max, Min from .nodes import Zeros, ZerosLike, Array -from .fem import expand, expand_hdiv_hcurl +from .fem import expand #============================================================================== # TODO move it @@ -106,23 +104,24 @@ def is_scalar_array(var): #============================================================================== def parse(expr, settings, backend=None): """ - This function takes a Psydac Ast and returns a Pyccel Ast + A function which takes a Psydac AST and transforms it to a Pyccel AST. + + This function takes a Psydac abstract syntax tree (AST) and returns a + Pyccel AST. In turn, this can be translated to Python code through a call + to the function `pycode` from `psydac.pyccel.codegen.printing.pycode`. Parameters ---------- + expr : Any + Psydac AST, of any type supported by the Parser class. - expr: - psydac ast node - - settings : - dictionary that continas number of dimension, mappings and target if provided + settings : dict + Dictionary that contains number of dimension, mappings and target if provided Returns ------- - - ast : Pyccel Ast - pyccel abstract syntax tree that can be translated into a Python code - + ast : psydac.pyccel.ast.basic.PyccelAstNode | psydac.pyccel.ast.core.FunctionDef + Pyccel abstract syntax tree that can be translated into a Python code. """ psy_parser = Parser(settings, backend) ast = psy_parser.doit(expr) @@ -131,37 +130,82 @@ def parse(expr, settings, backend=None): #============================================================================== class Parser(object): """ - This class takes a Psyadac Ast and transforms it to a Pyccel Ast - by calling the Parser.doit method + A Parser which takes a Psydac AST and transforms it to a Pyccel AST. + + This class takes a Psydac AST and transforms it to the AST of an old and + reduced version of Pyccel, which is shipped as `psydac.pyccel`. This + "mini-Pyccel" is then used for printing the Python code. + + The parsing is performed by passing any object of a "supported type" to the + method `Parser.doit`. This in turn calls `Parser._visit` which starts a + recursive tree traversal through specialized `Parser._visit_` + methods. If successful, `Parser.doit` generally returns a `PyccelAstNode` + object (from `psydac.pyccel.ast.basic`). If the input object is a `DefNode` + (representing a function definition) it returns a `FunctionDef` object + (from `psydac.pyccel.ast.core`). The resulting Pyccel AST can be printed to + Python code using the function `pycode` from + `psydac.pyccel.codegen.printing.pycode`. + + By "supported types" we mean any `` type for which a method + `Parser._visit_` is provided. The matching is done by name, and + it also checks any superclasses listed in `.__mro__` in the given + order. + Parameters + ---------- + settings : dict[str, Any] + A dictionary with required integer arguments `dim` (number of dimensions) + and `nderiv` (maximum number of derivatives), required argument `target` + (symbolic domain of expression, of type `BasicDomain` from + `sympde.topology.basic`), and optional argument `mapping` (domain + `Mapping` from `sympde.topology.mapping`). + + backend : dict[str, Any] + The backend dictionary as defined in `psydac.api.settings`. """ def __init__(self, settings, backend=None): + # Copy settings, hence input dictionary is not modified settings = settings.copy() + # ... Pop values from settings and perform sanity checks dim = settings.pop('dim', None) if dim is None: raise ValueError('dim not provided') + else: + assert isinstance(dim, int) - self._dim = dim - # ... - + assert dim > 0 nderiv = settings.pop('nderiv', None) if nderiv is None: raise ValueError('nderiv not provided') - - self._nderiv = nderiv + else: + assert isinstance(nderiv, int) + assert nderiv >= 0 target = settings.pop('target', None) if target is None: raise ValueError('target not provided') + else: + assert isinstance(target, BasicDomain) - self._target = target - - self._mapping = settings.pop('mapping', None) + mapping = settings.pop('mapping', None) + if mapping is not None: + assert isinstance(mapping, Mapping) + # ... + # Store extracted values and other settings + self._dim = dim + self._nderiv = nderiv + self._target = target + self._mapping = mapping self._settings = settings - self.backend = backend + + # Store backend dictionary + if backend is not None: + assert isinstance(backend, dict) + assert 'name' in backend.keys() + self.backend = backend # TODO improve self.indices = {} @@ -171,7 +215,6 @@ def __init__(self, settings, backend=None): self.arguments = {} self.allocated = {} self._math_functions = () - @property def settings(self): diff --git a/psydac/api/basic.py b/psydac/api/basic.py index 2eb77abeb..608a13246 100644 --- a/psydac/api/basic.py +++ b/psydac/api/basic.py @@ -143,9 +143,12 @@ def __init__(self, expr, *, folder=None, comm=None, root=None, discrete_space=No # raise ValueError('can not find {} implementation'.format(f)) if ast: - self._save_code(self._generate_code(), backend=self.backend['name']) + python_code = self._generate_code() + self._save_code(python_code, backend=self.backend['name']) + + if comm is not None and comm.size > 1: + comm.Barrier() - if comm is not None and comm.size>1: comm.Barrier() # compile code self._compile() @@ -259,7 +262,6 @@ def _compile_pyccel(self, mod, verbose=False): openmp = self.backend["openmp"] _PYCCEL_FOLDER = self.backend['folder'] - # from pyccel.epyccel import epyccel from pyccel import epyccel fmod = epyccel(mod, openmp = openmp, diff --git a/psydac/api/discretization.py b/psydac/api/discretization.py index 69dc89679..1cea9397e 100644 --- a/psydac/api/discretization.py +++ b/psydac/api/discretization.py @@ -25,10 +25,11 @@ from gelato.expr import GltExpr as sym_GltExpr +from psydac.api.fem_bilinear_form import DiscreteBilinearForm as DiscreteBilinearForm_SF +from psydac.api.fem_sum_form import DiscreteSumForm from psydac.api.fem import DiscreteBilinearForm from psydac.api.fem import DiscreteLinearForm from psydac.api.fem import DiscreteFunctional -from psydac.api.fem import DiscreteSumForm from psydac.api.feec import DiscreteDerham from psydac.api.glt import DiscreteGltExpr from psydac.api.expr import DiscreteExpr @@ -40,7 +41,6 @@ from psydac.fem.partitioning import create_cart, construct_connectivity, construct_interface_spaces, construct_reduced_interface_spaces from psydac.fem.vector import MultipatchFemSpace, VectorFemSpace from psydac.cad.geometry import Geometry -from psydac.mapping.discrete import NurbsMapping from psydac.linalg.stencil import StencilVectorSpace from psydac.linalg.block import BlockVectorSpace @@ -547,6 +547,27 @@ def discretize(a, *args, **kwargs): domain_h = args[0] assert isinstance(domain_h, Geometry) domain = domain_h.domain + dim = domain.dim + + # The current implementation of the sum factorization algorithm does not support openMP parallelization + # It is not properly tested, whether this way of excluding openMP parallelized code from using the sum factorization algorithm works. + backend = kwargs.get('backend')# or None + assembly_backend = kwargs.get('assembly_backend')# or None + assembly_backend = backend or assembly_backend + openmp = False if assembly_backend is None else assembly_backend.get('openmp') + + # Since all keyword arguments are hidden in kwargs, we cannot set the + # defaults in the function signature. Here we set the defaults for + # sum_factorization using dict.setdefault: we default to True for + # bilinear forms in 3D, and to False otherwise. If the user has chosen + # differently, we let the code run and see what happens! + # + # We also default to False if the code is to be parallelized w/ OpenMP. + # TODO [YG 21.07.2025]: Drop this restriction + if isinstance(a, sym_BilinearForm): + default = (dim == 3 and not openmp) + kwargs.setdefault('sum_factorization', default) + mapping = domain_h.domain.mapping kwargs['symbolic_mapping'] = mapping @@ -593,7 +614,10 @@ def discretize(a, *args, **kwargs): # return DiscreteSesquilinearForm(a, kernel_expr, *args, **kwargs) if isinstance(a, sym_BilinearForm): - return DiscreteBilinearForm(a, kernel_expr, *args, **kwargs) + if kwargs.pop('sum_factorization'): + return DiscreteBilinearForm_SF(a, kernel_expr, *args, **kwargs) + else: + return DiscreteBilinearForm(a, kernel_expr, *args, **kwargs) elif isinstance(a, sym_LinearForm): return DiscreteLinearForm(a, kernel_expr, *args, **kwargs) diff --git a/psydac/api/equation.py b/psydac/api/equation.py index 69ef5a077..deba2d081 100644 --- a/psydac/api/equation.py +++ b/psydac/api/equation.py @@ -105,7 +105,9 @@ def __init__(self, expr, *args, **kwargs): self._lhs = discretize(expr.lhs, domain, trial_test, **kwargs) # ... - + # sum_factorization (sum factorization algorithm) is currently not supported for LinearForms (the rhs) + if 'sum_factorization' in kwargs: + kwargs.pop('sum_factorization') self._rhs = discretize(expr.rhs, domain, test_space, **kwargs) # ... diff --git a/psydac/api/fem.py b/psydac/api/fem.py index 21b3b4f6a..e8cfcd4d1 100644 --- a/psydac/api/fem.py +++ b/psydac/api/fem.py @@ -12,15 +12,8 @@ from sympde.expr import Norm as sym_Norm from sympde.expr import SemiNorm as sym_SemiNorm from sympde.topology import Boundary, Interface -from sympde.topology import VectorFunctionSpace -from sympde.topology import ProductSpace -from sympde.topology import H1SpaceType, L2SpaceType, UndefinedSpaceType from sympde.calculus.core import PlusInterfaceOperator -from psydac.api.basic import BasicDiscrete -from psydac.api.basic import random_string -from psydac.api.grid import QuadratureGrid, BasisValues -from psydac.api.utilities import flatten from psydac.linalg.stencil import StencilVector, StencilMatrix, StencilInterfaceMatrix from psydac.linalg.basic import ComposedLinearOperator from psydac.linalg.block import BlockVectorSpace, BlockVector, BlockLinearOperator @@ -31,132 +24,25 @@ from psydac.fem.projectors import knot_insertion_projection_operator from psydac.core.bsplines import find_span, basis_funs_all_ders from psydac.ddm.cart import InterfaceCartDecomposition +from psydac.api.basic import BasicDiscrete +from psydac.api.grid import QuadratureGrid, BasisValues +from psydac.api.utilities import flatten, random_string +from psydac.api.fem_common import ( + collect_spaces, + construct_test_space_arguments, + construct_trial_space_arguments, + construct_quad_grids_arguments, + reset_arrays, + do_nothing, + extract_stencil_mats, +) __all__ = ( - 'collect_spaces', - 'compute_diag_len', - 'construct_test_space_arguments', - 'construct_trial_space_arguments', - 'construct_quad_grids_arguments', - 'reset_arrays', - 'do_nothing', - 'extract_stencil_mats', 'DiscreteBilinearForm', 'DiscreteFunctional', 'DiscreteLinearForm', - 'DiscreteSumForm', ) -#============================================================================== -def collect_spaces(space, *args): - """ - This function collect the arguments used in the assembly function - - Parameters - ---------- - space: - the symbolic space - - args : - list of discrete space components like basis values, spans, ... - - Returns - ------- - args : - list of discrete space components elements used in the asembly - - """ - - if isinstance(space, ProductSpace): - spaces = space.spaces - indices = [] - i = 0 - for space in spaces: - if isinstance(space, VectorFunctionSpace): - if isinstance(space.kind, (H1SpaceType, L2SpaceType, UndefinedSpaceType)): - indices.append(i) - else: - indices += [i+j for j in range(space.ldim)] - i = i + space.ldim - else: - indices.append(i) - i = i + 1 - args = [[e[i] for i in indices] for e in args] - - elif isinstance(space, VectorFunctionSpace): - if isinstance(space.kind, (H1SpaceType, L2SpaceType, UndefinedSpaceType)): - args = [[e[0]] for e in args] - - return args - -#============================================================================== -def compute_diag_len(p, md, mc): - n = ((np.ceil((p+1)/mc)-1)*md).astype('int') - n = n-np.minimum(0, n-p)+p+1 - return n.astype('int') - -#============================================================================== -def construct_test_space_arguments(basis_values): - space = basis_values.space - test_basis = basis_values.basis - spans = basis_values.spans - test_degrees = space.degree - pads = space.pads - multiplicity = space.multiplicity - - test_basis, test_degrees, spans = collect_spaces(space.symbolic_space, test_basis, test_degrees, spans) - - test_basis = flatten(test_basis) - test_degrees = flatten(test_degrees) - spans = flatten(spans) - pads = flatten(pads) - multiplicity = flatten(multiplicity) - pads = [p*m for p,m in zip(pads, multiplicity)] - return test_basis, test_degrees, spans, pads - -def construct_trial_space_arguments(basis_values): - space = basis_values.space - trial_basis = basis_values.basis - trial_degrees = space.degree - pads = space.pads - multiplicity = space.multiplicity - trial_basis, trial_degrees = collect_spaces(space.symbolic_space, trial_basis, trial_degrees) - - trial_basis = flatten(trial_basis) - trial_degrees = flatten(trial_degrees) - pads = flatten(pads) - multiplicity = flatten(multiplicity) - pads = [p*m for p,m in zip(pads, multiplicity)] - return trial_basis, trial_degrees, pads - -#============================================================================== -def construct_quad_grids_arguments(grid, use_weights=True): - points = grid.points - if use_weights: - weights = grid.weights - quads = flatten(list(zip(points, weights))) - else: - quads = flatten(list(zip(points))) - - nquads = flatten(grid.nquads) - n_elements = grid.n_elements - return n_elements, quads, nquads - -def reset_arrays(*args): - for a in args: - a[:]= 0.j if a.dtype==complex else 0. - -def do_nothing(*args): return 0 - -def extract_stencil_mats(mats): - new_mats = [] - for M in mats: - if isinstance(M, (StencilInterfaceMatrix, StencilMatrix)): - new_mats.append(M) - elif isinstance(M, ComposedLinearOperator): - new_mats += [i for i in M.multiplicants if isinstance(i, (StencilInterfaceMatrix, StencilMatrix))] - return new_mats - #============================================================================== class DiscreteBilinearForm(BasicDiscrete): """ @@ -430,7 +316,7 @@ def __init__(self, expr, kernel_expr, domain_h, spaces, *, nquads, with_openmp = (assembly_backend['name'] == 'pyccel' and assembly_backend['openmp']) if assembly_backend else False # Construct the arguments to be passed to the assemble() function, which is stored in self._func - self._args, self._threads_args = self.construct_arguments(with_openmp=with_openmp) + self._args, self._threads_args = self.construct_arguments(with_openmp) @property def domain(self): @@ -508,7 +394,7 @@ def assemble(self, *, reset=True, **kwargs): trial = True, grid = self.grid[0] ) - bs, d, s, p = construct_test_space_arguments(basis_v) + bs, d, s, p, mult = construct_test_space_arguments(basis_v) basis += bs spans += s degrees += [np.int64(a) for a in d] @@ -525,8 +411,6 @@ def assemble(self, *, reset=True, **kwargs): else: args = self._args -# args = args + self._element_loop_starts + self._element_loop_ends - if reset: reset_arrays(*self.global_matrices) @@ -582,9 +466,9 @@ def construct_arguments(self, with_openmp=False): Extra arguments used in the assembly method in case with_openmp=True. """ - test_basis, test_degrees, spans, pads = construct_test_space_arguments(self.test_basis) - trial_basis, trial_degrees, pads = construct_trial_space_arguments(self.trial_basis) - n_elements, quads, quad_degrees = construct_quad_grids_arguments(self.grid[0], use_weights=False) + test_basis, test_degrees, spans, pads, mult = construct_test_space_arguments(self.test_basis) + trial_basis, trial_degrees, pads, mult = construct_trial_space_arguments(self.trial_basis) + n_elements, quads, quad_degrees = construct_quad_grids_arguments(self.grid[0], use_weights=False) if len(self.grid)>1: quads = [*quads, *self.grid[1].points] @@ -1200,7 +1084,7 @@ def assemble(self, *, reset=True, **kwargs): trial = True, grid = self.grid ) - bs, d, s, p = construct_test_space_arguments(basis_v) + bs, d, s, p, m = construct_test_space_arguments(basis_v) basis += bs spans += s degrees += [np.int64(a) for a in d] @@ -1267,7 +1151,7 @@ def construct_arguments(self, with_openmp=False): Extra arguments used in the assembly method in case with_openmp=True. """ - tests_basis, tests_degrees, spans, pads = construct_test_space_arguments(self.test_basis) + tests_basis, tests_degrees, spans, pads, mult = construct_test_space_arguments(self.test_basis) n_elements, quads, nquads = construct_quad_grids_arguments(self.grid, use_weights=False) global_pads = self.space.coeff_space.pads @@ -1658,95 +1542,3 @@ def assemble(self, **kwargs): else: raise NotImplementedError('TODO') return v - -#============================================================================== -class DiscreteSumForm(BasicDiscrete): - - def __init__(self, a, kernel_expr, *args, **kwargs): - # TODO Uncomment when the SesquilinearForm exist in SymPDE - #if not isinstance(a, (sym_BilinearForm, sym_SesquilinearForm, sym_LinearForm, sym_Functional)): - # raise TypeError('> Expecting a symbolic BilinearForm, SesquilinearForm, LinearForm, Functional') - if not isinstance(a, (sym_BilinearForm, sym_LinearForm, sym_Functional)): - raise TypeError('> Expecting a symbolic BilinearForm, LinearForm, Functional') - - self._expr = a - backend = kwargs.pop('backend', None) - self._backend = backend - - folder = kwargs.get('folder', None) - self._folder = self._initialize_folder(folder) - - # create a module name if not given - tag = random_string(8) - - # ... - forms = [] - free_args = [] - self._kernel_expr = kernel_expr - operator = None - for e in kernel_expr: - if isinstance(a, sym_LinearForm): - kwargs['update_ghost_regions'] = False - ah = DiscreteLinearForm(a, e, *args, backend=backend, **kwargs) - kwargs['vector'] = ah._vector - operator = ah._vector - - # TODO Uncomment when the SesquilinearForm exist in SymPDE - # elif isinstance(a, sym_SesquilinearForm): - # kwargs['update_ghost_regions'] = False - # ah = DiscreteSesquilinearForm(a, e, *args, assembly_backend=backend, **kwargs) - # kwargs['matrix'] = ah._matrix - # operator = ah._matrix - - elif isinstance(a, sym_BilinearForm): - kwargs['update_ghost_regions'] = False - ah = DiscreteBilinearForm(a, e, *args, assembly_backend=backend, **kwargs) - kwargs['matrix'] = ah._matrix - operator = ah._matrix - - elif isinstance(a, sym_Functional): - ah = DiscreteFunctional(a, e, *args, backend=backend, **kwargs) - - forms.append(ah) - free_args.extend(ah.free_args) - - if isinstance(a, sym_BilinearForm): - is_broken = len(args[0].domain)>1 - if self._backend is not None and is_broken: - for mat in kwargs['matrix']._blocks.values(): - mat.set_backend(backend) - elif self._backend is not None: - kwargs['matrix'].set_backend(backend) - - self._forms = forms - self._operator = operator - self._free_args = tuple(set(free_args)) - self._is_functional = isinstance(a, sym_Functional) - # ... - - @property - def forms(self): - return self._forms - - @property - def free_args(self): - return self._free_args - - @property - def is_functional(self): - return self._is_functional - - def assemble(self, *, reset=True, **kwargs): - if not self.is_functional: - if reset : - reset_arrays(*[i for M in self.forms for i in M.global_matrices]) - - for form in self.forms: - form.assemble(reset=False, **kwargs) - self._operator.exchange_assembly_data() - return self._operator - else: - M = [form.assemble(**kwargs) for form in self.forms] - M = np.sum(M) - return M - diff --git a/psydac/api/fem_bilinear_form.py b/psydac/api/fem_bilinear_form.py new file mode 100644 index 000000000..8af05df83 --- /dev/null +++ b/psydac/api/fem_bilinear_form.py @@ -0,0 +1,2226 @@ +import sys +import os +import importlib + +import numpy as np + +from sympy import ImmutableDenseMatrix, Matrix, Symbol, sympify +from sympy.tensor.indexed import Indexed, IndexedBase +from sympy.simplify import cse_main + +from pyccel import epyccel + +from sympde.topology.basic import Boundary, Interface +from sympde.topology.mapping import Mapping, SymbolicExpr +from sympde.topology.space import ScalarFunction, VectorFunction, IndexedVectorFunction +from sympde.topology.derivatives import get_atom_logical_derivatives +from sympde.topology.derivatives import _logical_partial_derivatives +from sympde.topology.derivatives import get_index_logical_derivatives +from sympde.topology.derivatives import get_max_logical_partial_derivatives # NOTE [YG 31.07.2025]: Maybe use the one in ast.utilities +from sympde.expr.expr import BilinearForm +from sympde.expr.evaluation import KernelExpression, TerminalExpr +from sympde.calculus.core import PlusInterfaceOperator + +from psydac.cad.geometry import Geometry +from psydac.mapping.discrete import SplineMapping, NurbsMapping +from psydac.fem.basic import FemSpace, FemField +from psydac.fem.vector import VectorFemSpace +from psydac.linalg.stencil import StencilMatrix +from psydac.linalg.block import BlockVectorSpace, BlockLinearOperator +from psydac.api.grid import QuadratureGrid, BasisValues +from psydac.api.settings import PSYDAC_BACKENDS +from psydac.api.utilities import flatten, random_string +from psydac.api.fem_common import ( + compute_imports, + compute_max_nderiv, + compute_free_arguments, + construct_test_space_arguments, + construct_trial_space_arguments, + construct_quad_grids_arguments, + reset_arrays, + do_nothing, + extract_stencil_mats, +) + +# TODO [YG 01.08.2025]: Avoid importing anything from psydac.pyccel +from psydac.pyccel.ast.core import _atomic, Assign + +__all__ = ('DiscreteBilinearForm',) + +NoneType = type(None) + +#============================================================================== +class DiscreteBilinearForm: + """ + Discrete bilinear form ready to be assembled into a matrix. + + This class represents the concept of a discrete bilinear form in Psydac. + Instances of this class generate an appropriate matrix assembly kernel, + allocate the matrix if not provided, and prepare a list of arguments for + the kernel. + + An implementation of the sum factorization algorithm is used to assemble + the matrix. + + Parameters + ---------- + + expr : sympde.expr.expr.BilinearForm + The symbolic bilinear form. + + kernel_expr : list or tuple of sympde.expr.evaluation.KernelExpression + The atomic representation of the bilinear form. + + domain_h : psydac.cad.geometry.Geometry + The discretized domain. + + spaces : list of psydac.fem.basic.FemSpace + The discrete trial and test spaces. + + nquads : list or tuple of int + The number of quadrature points used in the assembly kernel along each + direction. + + matrix : psydac.linalg.stencil.StencilMatrix or psydac.linalg.block.BlockLinearOperator, optional + The matrix that we assemble into. If not provided, a new matrix is + created with the appropriate domain and codomain (default: None). + + update_ghost_regions : bool, default=True + Accumulate the contributions of the neighbouring processes. + + backend : dict, optional + The backend used to accelerate the computing kernels. + The backend dictionaries are defined in the file psydac/api/settings.py + + assembly_backend : dict, optional + The backend used to accelerate the assembly kernel. + The backend dictionaries are defined in the file psydac/api/settings.py + + linalg_backend : dict, optional + The backend used to accelerate the computing kernels of the linear operator. + The backend dictionaries are defined in the file psydac/api/settings.py + + symbolic_mapping : sympde.topology.mapping.Mapping, optional + The symbolic mapping which defines the physical domain of the bilinear form. + + See Also + -------- + DiscreteLinearForm + DiscreteFunctional + DiscreteSumForm + + """ + def __init__(self, expr, kernel_expr, domain_h, spaces, *, nquads, + matrix=None, update_ghost_regions=True, backend=None, + linalg_backend=None, assembly_backend=None, + symbolic_mapping=None): + + #... Sanity checks + assert isinstance(expr, BilinearForm) + assert isinstance(domain_h, Geometry) + for space in spaces: + assert isinstance(space, FemSpace) + for nquad in nquads: + assert isinstance(nquad, int) + assert nquad > 0 + assert isinstance(matrix, (NoneType, StencilMatrix, BlockLinearOperator)) + assert isinstance(update_ghost_regions, bool) + assert isinstance( backend, (NoneType, dict)) + assert isinstance( linalg_backend, (NoneType, dict)) + assert isinstance(assembly_backend, (NoneType, dict)) + assert isinstance(symbolic_mapping, (NoneType, Mapping)) + #... + + if isinstance(kernel_expr, (tuple, list)): + if len(kernel_expr) == 1: + kernel_expr = kernel_expr[0] + else: + raise ValueError('> Expecting only one kernel') + assert isinstance(kernel_expr, KernelExpression) + + self._kernel_expr = kernel_expr + self._expr = expr + self._target = kernel_expr.target + self._domain = domain_h.domain + self._spaces = spaces + self._matrix = matrix + + domain = self.domain + target = self.target + + # ... + if len(domain) > 1: + i, j = self.get_space_indices_from_target(domain, target) + test_space = self.spaces[1].spaces[i] + trial_space = self.spaces[0].spaces[j] + if isinstance(target, Interface): + m,_ = self.get_space_indices_from_target(domain, target.minus) + p,_ = self.get_space_indices_from_target(domain, target.plus) + mapping_m = list(domain_h.mappings.values())[m] + mapping_p = list(domain_h.mappings.values())[p] + mapping = (mapping_m, mapping_p) if mapping_m else None + else: + mapping = list(domain_h.mappings.values())[i] + else: + trial_space = self.spaces[0] + test_space = self.spaces[1] + mapping = list(domain_h.mappings.values())[0] + + self._mapping = mapping + + is_rational_mapping = False + mapping_space = None + if (mapping is not None) and not isinstance(target, Interface): + is_rational_mapping = isinstance(mapping, NurbsMapping) + mapping_space = mapping.space + elif (mapping is not None) and isinstance(target, Interface): + is_rational_mapping = (isinstance(mapping[0], NurbsMapping), isinstance(mapping[1], NurbsMapping)) + mapping_space = (mapping[0].space, mapping[1].space) + + self._is_rational_mapping = is_rational_mapping + # ... + + if isinstance(test_space.coeff_space, BlockVectorSpace): + coeff_space = test_space.coeff_space.spaces[0] + else: + coeff_space = test_space.coeff_space + + self._coeff_space = coeff_space + self._num_threads = 1 + if coeff_space.parallel and coeff_space.cart.num_threads > 1: + self._num_threads = coeff_space.cart.num_threads + + self._update_ghost_regions = update_ghost_regions + + # In case of multiple patches, if the communicator is MPI_COMM_NULL, we do not generate the assembly code + # because the patch is not owned by the MPI rank. + if coeff_space.parallel and coeff_space.cart.is_comm_null: + self._free_args = () + self._func = do_nothing + self._args = () + self._threads_args = () + self._global_matrices = () + self._update_ghost_regions = False + return + + # ... + test_ext = None + trial_ext = None + if isinstance(target, Boundary): + axis = target.axis + test_ext = target.ext + trial_ext = target.ext + elif isinstance(target, Interface): + # this part treats the cases of: + # integral(v_minus * u_plus) + # integral(v_plus * u_minus) + # the other cases, integral(v_minus * u_minus) and integral(v_plus * u_plus) + # are converted to boundary integrals by Sympde + axis = target.axis + test = self.kernel_expr.test + trial = self.kernel_expr.trial + test_target = target.plus if isinstance( test, PlusInterfaceOperator) else target.minus + trial_target = target.plus if isinstance(trial, PlusInterfaceOperator) else target.minus + test_ext = test_target.ext + trial_ext = trial_target.ext + ncells = tuple(max(i, j) for i, j in zip(test_space.ncells, trial_space.ncells)) + if isinstance(trial_space, VectorFemSpace): + spaces = [] + for sp in trial_space.spaces: + if (trial_target.axis, trial_target.ext) in sp.interfaces: + spaces.append(sp.get_refined_space(ncells).interfaces[trial_target.axis, trial_target.ext]) + + if len(spaces) == len(trial_space.spaces): + sym_space = trial_space.symbolic_space + trial_space = VectorFemSpace(*spaces) + trial_space.symbolic_space = sym_space + + elif (trial_target.axis, trial_target.ext) in trial_space.interfaces: + sym_space = trial_space.symbolic_space + trial_space = trial_space.get_refined_space(ncells).interfaces[trial_target.axis, trial_target.ext] + trial_space.symbolic_space = sym_space + + test_space = test_space.get_refined_space(ncells) + self._test_ext = test_target.ext + self._trial_ext = trial_target.ext + + #... + + # Assuming that all vector spaces (and their Cartesian decomposition, + # if any) are compatible with each other, extract the first available + # vector space from which (starts, ends, npts) will be read: + starts = coeff_space.starts + ends = coeff_space.ends + npts = coeff_space.npts + + # MPI communicator + comm = coeff_space.cart.comm if coeff_space.parallel else None + + # Store the MPI communicator (or None) + self._comm = comm + + #... + # Get default backend from environment, or use 'python'. + default_backend = PSYDAC_BACKENDS.get(os.environ.get('PSYDAC_BACKEND'))\ + or PSYDAC_BACKENDS['python'] + + # Backends for code generation + assembly_backend = backend or assembly_backend + linalg_backend = backend or linalg_backend + + # Store backend dictionary + self._backend = assembly_backend or default_backend + #... + + # TODO: remove + # BasicDiscrete generates the assembly code and sets the following attributes that are used afterwards: + # self._func, self._free_args, self._max_nderiv and self._backend +# BasicDiscrete.__init__(self, expr, kernel_expr, comm=comm, root=0, discrete_space=discrete_space, +# nquads=nquads, is_rational_mapping=is_rational_mapping, mapping=symbolic_mapping, +# mapping_space=mapping_space, num_threads=self._num_threads, backend=assembly_backend) + + + #... Compute the string with all the imports + texpr = kernel_expr + sym_expr = SymbolicExpr(texpr.expr) + imports = compute_imports(sym_expr, spaces=(trial_space, test_space), openmp=False) + indent = 4 + glue = '\n' + ' '* indent + imports_str = glue.join([f"from {m} import {', '.join(vars)}" + for m, vars in imports.items()]) + + # Broadcast the import information (sqrt, sin, pi, ...) to all processes + if (comm is not None) and (comm.size > 1): + imports_str = comm.bcast(imports_str, root=0) + + # Store the imports string as it will be used by make_file() + self._imports_string = imports_str + #... + + # Compute the highest order of derivation in the kernel expression + self._max_nderiv = compute_max_nderiv(kernel_expr) + + # TODO [YG 31.07.2025]: Implement this + self._free_args = compute_free_arguments(expr, kernel_expr) + + #... Handle the special case where the current MPI process does not need to do anything + if isinstance(target, (Boundary, Interface)): + + # If process does not own the boundary or interface, do not assemble anything + if test_ext == -1: + if starts[axis] != 0: + self._func = do_nothing + + elif test_ext == 1: + if ends[axis] != npts[axis]-1: + self._func = do_nothing + + # In case of target==Interface, we only use the MPI ranks that are on the interface to assemble the BilinearForm + if self._func == do_nothing and isinstance(target, Interface): + self._free_args = () + self._args = () + self._global_matrices = () + self._threads_args = () + return + #... + + #... Build the quadrature grids + if isinstance(target, Boundary): + test_grid = QuadratureGrid( test_space, axis=axis, ext= test_ext, nquads=nquads) + trial_grid = QuadratureGrid(trial_space, axis=axis, ext=trial_ext, nquads=nquads) + self._grid = (test_grid,) + elif isinstance(target, Interface): + # this part treats the cases of: + # integral(v_minus * u_plus) + # integral(v_plus * u_minus) + # the other cases, integral(v_minus * u_minus) and integral(v_plus * u_plus) + # are converted to boundary integrals by Sympde + test_grid = QuadratureGrid( test_space, axis=axis, ext= test_ext, nquads=nquads) + trial_grid = QuadratureGrid(trial_space, axis=axis, ext=trial_ext, nquads=nquads) + self._grid = (test_grid, trial_grid) if test_target == target.minus else (trial_grid, test_grid) + self._test_ext = test_target.ext + self._trial_ext = trial_target.ext + else: + test_grid = QuadratureGrid( test_space, nquads=nquads) + trial_grid = QuadratureGrid(trial_space, nquads=nquads) + self._grid = (test_grid,) + #... + + # Extract the basis function values on the quadrature grids + self._test_basis = BasisValues( + test_space, + nderiv = self.max_nderiv, + nquads = nquads, + trial = False, + grid = test_grid + ) + self._trial_basis = BasisValues( + trial_space, + nderiv = self.max_nderiv, + nquads = nquads, + trial = True , + grid = trial_grid + ) + + # Allocate the output matrix, if needed + self.allocate_matrices(linalg_backend) + + # Determine whether OpenMP instructions were generated + self._with_openmp = (assembly_backend['name'] == 'pyccel' and assembly_backend['openmp']) if assembly_backend else False + + # Construct the arguments to be passed to the assemble() function, which is stored in self._func + # First we generate the assembly file + + # pyccelize process of computing the test_trial arrays + # currently set to False, as a Python 3.9 test fails, and due to the "speed up" not being significant + self._pyccelize_test_trial_computation = False + + # no openmp support yet: with_openmp is not passed + self._args, self._threads_args = self.construct_arguments_generate_assembly_file() + + #-------------------------------------------------------------------------- + @property + def comm(self): + return self._comm + + @property + def expr(self): + return self._expr + + @property + def kernel_expr(self): + return self._kernel_expr + + @property + def domain(self): + return self._domain + + @property + def mapping(self): + return self._mapping + + @property + def is_rational_mapping(self): + return self._is_rational_mapping + + @property + def target(self): + return self._target + + @property + def spaces(self): + return self._spaces + + @property + def test_basis(self): + return self._test_basis + + @property + def trial_basis(self): + return self._trial_basis + + @property + def grid(self): + return self._grid + + @property + def nquads(self): + return self._grid[0].nquads + + @property + def free_args(self): + return self._free_args + + @property + def max_nderiv(self): + # TODO: compute with read_BilinearForm and store + return self._max_nderiv + + @property + def backend(self): + return self._backend + + @property + def args(self): + return self._args + + @property + def global_matrices(self): + return self._global_matrices + + #-------------------------------------------------------------------------- + def allocate_matrices(self, backend=None): + """ + Allocate the global matrices used in the assembly method. + In this method we allocate only the matrices that are computed in the self._target domain, + we also avoid double allocation if we have many DiscreteLinearForm that are defined on the same self._target domain. + + Parameters + ---------- + backend : dict + The backend used to accelerate the computing kernels. + + """ + global_mats = {} + + expr = self.kernel_expr.expr + target = self.kernel_expr.target + test_degree = np.array(self.test_basis.space.degree) + trial_degree = np.array(self.trial_basis.space.degree) + test_space = self.spaces[1].coeff_space + trial_space = self.spaces[0].coeff_space + test_fem_space = self.spaces[1] + trial_fem_space = self.spaces[0] + domain = self.domain + is_broken = len(domain) > 1 + is_conformal = True + + if isinstance(expr, (ImmutableDenseMatrix, Matrix)): + if not isinstance(test_degree[0],(list, tuple, np.ndarray)): + test_degree = [test_degree] + + if not isinstance(trial_degree[0],(list, tuple, np.ndarray)): + trial_degree = [trial_degree] + + pads = np.empty((len(test_degree),len(trial_degree),len(test_degree[0])), dtype=int) + for i in range(len(test_degree)): + for j in range(len(trial_degree)): + td = test_degree[i] + trd = trial_degree[j] + pads[i,j][:] = np.array([td, trd]).max(axis=0) + else: + pads = np.maximum(test_degree, trial_degree) + + if self._matrix is None and (is_broken or isinstance(expr, (ImmutableDenseMatrix, Matrix))): + self._matrix = BlockLinearOperator(trial_space, test_space) + + if is_broken: + i, j = self.get_space_indices_from_target(domain, target) + test_fem_space = self.spaces[1].spaces[i] + trial_fem_space = self.spaces[0].spaces[j] + test_space = test_space.spaces[i] + trial_space = trial_space.spaces[j] + ncells = tuple(max(i,j) for i,j in zip(test_fem_space.ncells, trial_fem_space.ncells)) + is_conformal = tuple(test_fem_space.ncells) == ncells and tuple(trial_fem_space.ncells) == ncells + if is_broken and not is_conformal and not i==j: + use_restriction = all(trn>=tn for trn,tn in zip(trial_fem_space.ncells, test_fem_space.ncells)) + use_prolongation = not use_restriction + + else: + ncells = tuple(max(i,j) for i,j in zip(test_fem_space.ncells, trial_fem_space.ncells)) + i=0 + j=0 + #else so initialisation causing bug on line 682 + + if isinstance(expr, (ImmutableDenseMatrix, Matrix)): # case of system of equations + + if is_broken: #multi patch + if not self._matrix[i,j]: + mat = BlockLinearOperator(trial_fem_space.get_refined_space(ncells).coeff_space, test_fem_space.get_refined_space(ncells).coeff_space) + if not is_conformal and not i==j: + if use_restriction: + Ps = [knot_insertion_projection_operator(ts.get_refined_space(ncells), ts) for ts in test_fem_space.spaces] + P = BlockLinearOperator(test_fem_space.get_refined_space(ncells).coeff_space, test_fem_space.coeff_space) + for ni,Pi in enumerate(Ps): + P[ni,ni] = Pi + + mat = ComposedLinearOperator(trial_space, test_space, P, mat) + + elif use_prolongation: + Ps = [knot_insertion_projection_operator(trs, trs.get_refined_space(ncells)) for trs in trial_fem_space.spaces] + P = BlockLinearOperator(trial_fem_space.coeff_space, trial_fem_space.get_refined_space(ncells).coeff_space) + for ni,Pi in enumerate(Ps): + P[ni,ni] = Pi + + mat = ComposedLinearOperator(trial_space, test_space, mat, P) + + self._matrix[i,j] = mat + + matrix = self._matrix[i,j] + else: # single patch + matrix = self._matrix + + shape = expr.shape + for k1 in range(shape[0]): + for k2 in range(shape[1]): + if expr[k1,k2].is_zero: + continue + + if isinstance(test_fem_space, VectorFemSpace): + ts_space = test_fem_space.get_refined_space(ncells).coeff_space.spaces[k1] + else: + ts_space = test_fem_space.get_refined_space(ncells).coeff_space + + if isinstance(trial_fem_space, VectorFemSpace): + tr_space = trial_fem_space.get_refined_space(ncells).coeff_space.spaces[k2] + else: + tr_space = trial_fem_space.get_refined_space(ncells).coeff_space + + if is_conformal and matrix[k1, k2]: + global_mats[k1, k2] = matrix[k1, k2] + elif not i == j: # assembling in an interface (type(target) == Interface) + axis = target.axis + ext_d = self._trial_ext + ext_c = self._test_ext + test_n = self. test_basis.space.spaces[k1].spaces[axis].nbasis + test_s = self. test_basis.space.spaces[k1].coeff_space.starts[axis] + trial_n = self.trial_basis.space.spaces[k2].spaces[axis].nbasis + cart = self.trial_basis.space.spaces[k2].coeff_space.cart + trial_s = cart.global_starts[axis][cart._coords[axis]] + + s_d = trial_n - trial_s - trial_degree[k2][axis] - 1 if ext_d == 1 else 0 + s_c = test_n - trial_s - test_degree[k1][axis] - 1 if ext_c == 1 else 0 + + # We only handle the case where direction = 1 + direction = target.ornt + if domain.dim == 2: + assert direction == 1 + elif domain.dim == 3: + assert all(d==1 for d in direction) + + direction = 1 + flip = [direction]*domain.dim + flip[axis] = 1 + if self._func != do_nothing: + global_mats[k1, k2] = StencilInterfaceMatrix(tr_space, ts_space, + s_d, s_c, + axis, axis, + ext_d, ext_c, + pads=tuple(pads[k1, k2]), + flip=flip) + else: + global_mats[k1, k2] = StencilMatrix(tr_space, ts_space, pads = tuple(pads[k1, k2])) + + if is_conformal: + matrix[k1, k2] = global_mats[k1, k2] + elif use_restriction: + matrix.multiplicants[-1][k1, k2] = global_mats[k1, k2] + elif use_prolongation: + matrix.multiplicants[0][k1, k2] = global_mats[k1, k2] + + else: # case of scalar equation + if is_broken: # multi-patch + if self._matrix[i, j]: + global_mats[i, j] = self._matrix[i, j] + + elif not i == j: # assembling in an interface (type(target) == Interface) + axis = target.axis + ext_d = self._trial_ext + ext_c = self._test_ext + test_n = self.test_basis.space.spaces[axis].nbasis + test_s = self.test_basis.space.coeff_space.starts[axis] + trial_n = self.trial_basis.space.spaces[axis].nbasis + cart = self.trial_basis.space.coeff_space.cart + trial_s = cart.global_starts[axis][cart._coords[axis]] + + s_d = trial_n - trial_s - trial_degree[axis] - 1 if ext_d == 1 else 0 + s_c = test_n - trial_s - test_degree[axis] - 1 if ext_c == 1 else 0 + + # We only handle the case where direction = 1 + direction = target.ornt + if domain.dim == 2: + assert direction == 1 + elif domain.dim == 3: + assert all(d==1 for d in direction) + + direction = 1 + flip = [direction]*domain.dim + flip[axis] = 1 + + if self._func != do_nothing: + mat = StencilInterfaceMatrix(trial_fem_space.get_refined_space(ncells).coeff_space, + test_fem_space.get_refined_space(ncells).coeff_space, + s_d, s_c, + axis, axis, + ext_d, ext_c, + flip=flip) + if not is_conformal: + if use_restriction: + P = knot_insertion_projection_operator(test_fem_space.get_refined_space(ncells), test_fem_space) + mat = ComposedLinearOperator(trial_space, test_space, P, mat) + elif use_prolongation: + P = knot_insertion_projection_operator(trial_fem_space, trial_fem_space.get_refined_space(ncells)) + mat = ComposedLinearOperator(trial_space, test_space, mat, P) + + global_mats[i, j] = mat + + # define part of the global matrix as a StencilMatrix + else: + global_mats[i, j] = StencilMatrix(trial_space, test_space, pads=tuple(pads)) + + if (i, j) in global_mats: + self._matrix[i, j] = global_mats[i, j] + + + # in single patch case, we define the matrices needed for the patch + else: + if self._matrix: + global_mats[0, 0] = self._matrix + else: + global_mats[0, 0] = StencilMatrix(trial_space, test_space, pads=tuple(pads)) + + self._matrix = global_mats[0, 0] + + # Set the backend of our matrices if given + if backend is not None and is_broken: + for mat in global_mats.values(): + mat.set_backend(backend) + elif backend is not None: + self._matrix.set_backend(backend) + + self._global_matrices = [M._data for M in extract_stencil_mats(global_mats.values())] + + #-------------------------------------------------------------------------- + def assemble(self, *, reset=True, **kwargs): + """ + This method assembles the left hand side Matrix by calling the private method `self._func` with proper arguments. + + In the complex case, this function returns the matrix conjugate. This comes from the fact that the + problem `a(u,v)=b(v)` is discretized as `A @ conj(U) = B` due to the antilinearity of `a` in the first variable. + Thus, to obtain `U`, the assemble function returns `conj(A)`. + + TODO: remove these lines when the dot product is changed for complex. + For now, since the dot product does not compute the conjugate in the complex case. We do not use the conjugate in the assemble function. + It should work if the complex only comes from the `rhs` in the linear form. + """ + + if self._free_args: + basis = [] + spans = [] + degrees = [] + pads = [] + coeffs = [] + consts = [] + + for key in self._free_args: + v = kwargs[key] + + if len(self.domain) > 1 and isinstance(v, FemField) and (v.space.is_multipatch or v.space.is_vector_valued): + assert v.space.is_multipatch ## [MCP 27.03.2025] should hold since len(domain) > 1. If Ok we can simplify above if + i, j = self.get_space_indices_from_target(self.domain, self.target) + assert i == j + v = v[i] + if isinstance(v, FemField): + assert len(self.grid) == 1 + if not v.coeffs.ghost_regions_in_sync: + v.coeffs.update_ghost_regions() + basis_v = BasisValues( + v.space, + nderiv = self.max_nderiv, + nquads = self.nquads, + trial = True, + grid = self.grid[0] + ) + bs, d, s, p, mult = construct_test_space_arguments(basis_v) + basis += bs + spans += s + degrees += [np.int64(a) for a in d] + pads += [np.int64(a) for a in p] + if v.space.is_multipatch or v.space.is_vector_valued: + coeffs += (e._data for e in v.coeffs) + else: + coeffs += (v.coeffs._data, ) + else: + consts += (v, ) + + args = (*self.args, *basis, *spans, *degrees, *pads, *coeffs, *consts) + + else: + args = self._args + + if reset: + reset_arrays(*self.global_matrices) + + self._func(*args, *self._threads_args) + if self._matrix and self._update_ghost_regions: + self._matrix.exchange_assembly_data() + + # TODO : uncomment this line when the conjugate is applied on the dot product in the complex case + #self._matrix.conjugate(out=self._matrix) + + if self._matrix: + self._matrix.ghost_regions_in_sync = False + + return self._matrix + + #-------------------------------------------------------------------------- + @property + def _assembly_template_head(self): + """A template for the 'head' of the assembly function. Only used with the sum factorization algorithm.""" + code = '''def assemble_matrix_{FILE_ID}({MAPPING_PART_1} +{SPAN} {MAPPING_PART_2} + global_x1 : "float64[:,:]", global_x2 : "float64[:,:]", global_x3 : "float64[:,:]", + {MAPPING_PART_3} + n_element_1 : "int64", n_element_2 : "int64", n_element_3 : "int64", + nq1 : "int64", nq2 : "int64", nq3 : "int64", + pad1 : "int64", pad2 : "int64", pad3 : "int64", + {MAPPING_PART_4} +{G_MAT}{NEW_ARGS}{FIELD_ARGS}): + + from numpy import abs as Abs + {imports} +''' + return code + + #-------------------------------------------------------------------------- + @property + def _assembly_template_body_bspline(self): + """A template for the 'body' of the assembly function (when using a spline mapping). Only used with the sum factorization algorithm.""" + code = ''' + arr_coeffs_x = zeros((1 + test_mapping_p1, 1 + test_mapping_p2, 1 + test_mapping_p3), dtype='float64') + arr_coeffs_y = zeros((1 + test_mapping_p1, 1 + test_mapping_p2, 1 + test_mapping_p3), dtype='float64') + arr_coeffs_z = zeros((1 + test_mapping_p1, 1 + test_mapping_p2, 1 + test_mapping_p3), dtype='float64') + +{F_COEFFS_ZEROS} + +{KEYS} + for k_1 in range(n_element_1): + span_mapping_1 = global_span_mapping_1[k_1] +{LOCAL_SPAN}{F_SPAN_1}{A1} + for q_1 in range(nq1): + for k_2 in range(n_element_2): + span_mapping_2 = global_span_mapping_2[k_2] +{F_SPAN_2} + for q_2 in range(nq2): + for k_3 in range(n_element_3): + span_mapping_3 = global_span_mapping_3[k_3] +{F_SPAN_3}{F_COEFFS} + arr_coeffs_x[:,:,:] = global_arr_coeffs_x[test_mapping_p1 + span_mapping_1 - test_mapping_p1:test_mapping_p1 + 1 + span_mapping_1,test_mapping_p2 + span_mapping_2 - test_mapping_p2:test_mapping_p2 + 1 + span_mapping_2,test_mapping_p3 + span_mapping_3 - test_mapping_p3:test_mapping_p3 + 1 + span_mapping_3] + arr_coeffs_y[:,:,:] = global_arr_coeffs_y[test_mapping_p1 + span_mapping_1 - test_mapping_p1:test_mapping_p1 + 1 + span_mapping_1,test_mapping_p2 + span_mapping_2 - test_mapping_p2:test_mapping_p2 + 1 + span_mapping_2,test_mapping_p3 + span_mapping_3 - test_mapping_p3:test_mapping_p3 + 1 + span_mapping_3] + arr_coeffs_z[:,:,:] = global_arr_coeffs_z[test_mapping_p1 + span_mapping_1 - test_mapping_p1:test_mapping_p1 + 1 + span_mapping_1,test_mapping_p2 + span_mapping_2 - test_mapping_p2:test_mapping_p2 + 1 + span_mapping_2,test_mapping_p3 + span_mapping_3 - test_mapping_p3:test_mapping_p3 + 1 + span_mapping_3] + for q_3 in range(nq3): + x = 0.0 + y = 0.0 + z = 0.0 + + x_x1 = 0.0 + x_x2 = 0.0 + x_x3 = 0.0 + y_x1 = 0.0 + y_x2 = 0.0 + y_x3 = 0.0 + z_x1 = 0.0 + z_x2 = 0.0 + z_x3 = 0.0 +{D2_1} + +{F_INIT} + +{F_ASSIGN_LOOP} + + for i_1 in range(test_mapping_p1+1): + mapping_1 = global_basis_mapping_1[k_1, i_1, 0, q_1] + mapping_1_x1 = global_basis_mapping_1[k_1, i_1, 1, q_1] + {D2_2} + for i_2 in range(test_mapping_p2+1): + mapping_2 = global_basis_mapping_2[k_2, i_2, 0, q_2] + mapping_2_x2 = global_basis_mapping_2[k_2, i_2, 1, q_2] + {D2_3} + for i_3 in range(test_mapping_p3+1): + mapping_3 = global_basis_mapping_3[k_3, i_3, 0, q_3] + mapping_3_x3 = global_basis_mapping_3[k_3, i_3, 1, q_3] + {D2_4} + + coeff_x = arr_coeffs_x[i_1,i_2,i_3] + coeff_y = arr_coeffs_y[i_1,i_2,i_3] + coeff_z = arr_coeffs_z[i_1,i_2,i_3] + + mapping = mapping_1*mapping_2*mapping_3 + mapping_x1 = mapping_1_x1*mapping_2*mapping_3 + mapping_x2 = mapping_1*mapping_2_x2*mapping_3 + mapping_x3 = mapping_1*mapping_2*mapping_3_x3 + +{D2_5} + + x += mapping*coeff_x + y += mapping*coeff_y + z += mapping*coeff_z + + x_x1 += mapping_x1*coeff_x + x_x2 += mapping_x2*coeff_x + x_x3 += mapping_x3*coeff_x + y_x1 += mapping_x1*coeff_y + y_x2 += mapping_x2*coeff_y + y_x3 += mapping_x3*coeff_y + z_x1 += mapping_x1*coeff_z + z_x2 += mapping_x2*coeff_z + z_x3 += mapping_x3*coeff_z + +{D2_6} + +{TEMPS} +{COUPLING_TERMS} +''' + return code + + #-------------------------------------------------------------------------- + @property + def _assembly_template_body_analytic(self): + """A template for the 'body' of the assembly function (when using an analytic or no mapping). Only used with the sum factorization algorithm.""" + code = ''' + local_x1 = zeros_like(global_x1[0,:]) + local_x2 = zeros_like(global_x2[0,:]) + local_x3 = zeros_like(global_x3[0,:]) + +{F_COEFFS_ZEROS} + +{KEYS} + for k_1 in range(n_element_1): + local_x1[:] = global_x1[k_1,:] +{LOCAL_SPAN}{F_SPAN_1}{A1} + for q_1 in range(nq1): + x1 = local_x1[q_1] + for k_2 in range(n_element_2): + local_x2[:] = global_x2[k_2,:] +{F_SPAN_2} + for q_2 in range(nq2): + x2 = local_x2[q_2] + for k_3 in range(n_element_3): + local_x3[:] = global_x3[k_3,:] +{F_SPAN_3}{F_COEFFS} + for q_3 in range(nq3): + x3 = local_x3[q_3] + +{F_INIT} + +{F_ASSIGN_LOOP} + +{TEMPS} +{COUPLING_TERMS} +''' + return code + + #-------------------------------------------------------------------------- + @property + def _assembly_template_loop(self): + """A template for the 'loop' of the assembly function. Only used with the sum factorization algorithm.""" + code = ''' + {A2}[:] = 0.0 + for k_2 in range(n_element_2): + {SPAN_2} = {GLOBAL_SPAN_2}[k_2] + for q_2 in range(nq2): + {A3}[:] = 0.0 + for k_3 in range(n_element_3): + {SPAN_3} = {GLOBAL_SPAN_3}[k_3] + for q_3 in range(nq3): + a4 = {COUPLING_TERMS}[k_2, q_2, k_3, q_3, :] + for i_3 in range({TEST_V_P3} + 1): + for j_3 in range({TRIAL_U_P3} + 1): + for e in range({NEXPR}): + {A3}[e, {SPAN_3} - {TEST_V_P3} + i_3, {MAX_P3} - {I_3} + j_3] += {TEST_TRIAL_3}[k_3, q_3, i_3, j_3, {KEYS_3}[2*e], {KEYS_3}[2*e+1]] * a4[e] + for i_2 in range({TEST_V_P2} + 1): + for j_2 in range({TRIAL_U_P2} + 1): + for e in range({NEXPR}): + {A2}[e, {SPAN_2} - {TEST_V_P2} + i_2, :, {MAX_P2} - {I_2} + j_2, :] += {TEST_TRIAL_2}[k_2, q_2, i_2, j_2, {KEYS_2}[2*e], {KEYS_2}[2*e+1]] * {A3}[e,:,:] + for i_1 in range({TEST_V_P1} + 1): + for j_1 in range({TRIAL_U_P1} + 1): + {A1}[i_1, :, :, {MAX_P1} - {I_1} + j_1, :, :] += {A2_TEMP} +''' + return code + + #-------------------------------------------------------------------------- + def make_file(self, temps, ordered_stmts, field_derivatives, max_logical_derivative, test_mult, trial_mult, test_v_p, trial_u_p, keys_1, keys_2, keys_3, mapping_option): + """ + Part of the sum factorization algorithm implementation. + Generates the correct assembly file. + Used at the end of construct_arguments_generate_assembly_file, before eventually pyccelizing that file. + + Parameters + ---------- + temps : tuple + Tuple of Assign statements defining temporary values. + Arithmetic combinations of these make up the coupling terms. + + ordered_stmts : dict + Dictionary defining the coupling terms. Keys are combinations of + test and trial function components, values are Assign statements + in terms of temporaries appearing in temps. + + field_derivatives : dict + Dictionary containing information on the derivatives of free FemFields. + Keys are components of free FemFields. Values are dictionaries again. + Their keys are names, as appearing in the assembly file, of partial derivatives of the + corresponding FemField component, and their values are dictionaries again. + Example: {F1_0_x3 : {'x1': 0, 'x2': 0, 'x3': 1}, F1_0_x2 : ...} + Meaning: There exists a free FemField named F1. Among other, the partial derivative w.r.t. x3 + of its first component F1_0 appears. + + max_logical_derivative : int + The largest appearing derivative order. + + test_mult : list + List of length 3(scalar test function) or 9(vector test function) including multiplicity information. + + trial_mult : list + List of length 3(scalar trial function) or 9(vector trial function) including multiplicity information. + + test_v_p : dict + Dictionary of length 1(scalar test function) or length 3(vector test function). + Each key corresponds to a component of the funciton (space), and each corresponding value + is a list of Bspline degrees of length 3. Example: Discretizing a de de Rham sequence using + a degree vector [2, 3, 4] means that test_v_p for a test function belonging to H(curl) will be + {0: [1, 3, 4], 1: [2, 2, 4], 2: [2, 3, 3]} + + trial_u_p : dict + Dictionary of length 1(scalar trial function) or length 3(vector trial function). + Each key corresponds to a component of the funciton (space), and each corresponding value + is a list of Bspline degrees of length 3. Example: Discretizing a de de Rham sequence using + a degree vector [2, 3, 4] means that trial_u_p for a trial function belonging to H^1 will be + {0: [2, 3, 4]} + + keys_1 : dict + Dictionary relating subexpressions to x1-derivative combinations. + Keys are combinations of test and trial function components. + Values are lists, each entry corresponding to one appearing partial derivative + combination of these components. + Example: keys_1[(u[0], v[1])][3] = [1,0] means that the fourth ([3]) + sub-expression (partial derivative combination) corresponding to the trial-test-function-component-product + u[0] * v[1] involves a first derivative in x1 direction of the trial function + and no derivative in x1 direction of the test function. + Information on appearing partial derivatives in x2 and x3 direction is stored in keys_2 and keys_3. + + keys_2 : dict + See keys_1. + + keys_3 : dict + See keys_1. + + mapping_option : None | 'Bspline' + None in case of no mapping or an analytical mapping, 'Bspline' in case of a Bspline mapping. + + Returns + ------- + + file_id : str + random string of length 8, corresponding to the assembly file name located in __psydac__/ + + """ + + #------------------------- FILE_ID ------------------------- + comm = self.comm + + # Root process generates a random string to be used as file_id + if comm is None or comm.rank == 0: + file_id = random_string(size=8) + else: + file_id = None + + # Parallel case: root process broadcasts file_id to all processes + if comm is not None and comm.size > 1: + file_id = comm.bcast(file_id, root=0) + + # ----- free FemField related strings ----- + + # used as {FIELD_ARGS} in _assembly_template_head + # adding the right arguments for free FemFields to the assembly function header + basis_args_block = [f'global_test_basis_'+'{field}'+f'_{i+1} : "float64[:,:,:,:]"' for i in range(3)] + basis_args_block = ", ".join(basis_args_block) + "," + basis_args_block = [basis_args_block.format(field=field) for field in field_derivatives] + basis_args = " " + "\n ".join(basis_args_block) + "\n" + span_args_block = [f'global_span_'+'{field}'+f'_{i+1} : "int64[:]"' for i in range(3)] + span_args_block = ", ".join(span_args_block) + "," + span_args_block = [span_args_block.format(field=field) for field in field_derivatives] + span_args = " " + "\n ".join(span_args_block) + "\n" + degree_args_block = [f'test_'+'{field}'+f'_p{i+1} : "int64"' for i in range(3)] + degree_args_block = ", ".join(degree_args_block) + "," + degree_args_block = [degree_args_block.format(field=field) for field in field_derivatives] + degree_args = " " + "\n ".join(degree_args_block) + "\n" + pad_args_block = [f'pad_'+'{field}'+f'_{i+1} : "int64"' for i in range(3)] + pad_args_block = ", ".join(pad_args_block) + "," + pad_args_block = [pad_args_block.format(field=field) for field in field_derivatives] + pad_args = " " + "\n ".join(pad_args_block) + "\n" + coeff_args_block = [f'global_arr_coeffs_{field} : "float64[:,:,:]"' for field in field_derivatives] + coeff_args = " " + ", ".join(coeff_args_block) + FIELD_ARGS = basis_args+span_args+degree_args+pad_args+coeff_args + + # {F_COEFFS_ZEROS} in both _assembly_template_body_bspline & _analytic + F_COEFFS_ZEROS = "\n".join([f" arr_coeffs_{field} = zeros((1 + test_{field}_p1, 1 + test_{field}_p2, 1 + test_{field}_p3), dtype='float64')" for field in field_derivatives]) + + # {F_SPAN_1}, {F_SPAN_2}, {F_SPAN_3} in both _assembly_template_body_bspline & _analytic + F_SPAN_1 = "\n".join([f" span_{field}_1 = global_span_{field}_1[k_1]" for field in field_derivatives]) + "\n" + F_SPAN_2 = "\n".join([f" span_{field}_2 = global_span_{field}_2[k_2]" for field in field_derivatives]) + "\n" + F_SPAN_3 = "\n".join([f" span_{field}_3 = global_span_{field}_3[k_3]" for field in field_derivatives]) + "\n" + + # {F_COEFFS} in both _assembly_template_body_bspline & _analytic + coeff_ranges = ", ".join([f"pad_"+"{field}"+f"_{i+1} + span_"+"{field}"+f"_{i+1} - test_"+"{field}"+f"_p{i+1}:1 + pad_"+"{field}"+f"_{i+1} + span_"+"{field}"+f"_{i+1}" for i in range(3)]) + F_COEFFS = "\n".join([f" arr_coeffs_{field}[:,:,:] = global_arr_coeffs_{field}[{coeff_ranges.format(field=field)}]" for i, field in enumerate(field_derivatives)]) + + # {F_INIT} + F_INIT = "\n".join([f" {derivative} = 0.0" for field in field_derivatives for derivative in field_derivatives[field]]) + + # + # field_init assigns 0 to appearing free FemField derivatives (F_x1 = 0.0 \n F_x2 = 0.0 \n ...) + # In the following, we assemble loops that correctly compute those free FemField derivatives at + # a specific quadrature point (q_1, q_2, q_3). Those values will then be used in the computation + # of the temps or directly in the computation of the coupling terms + # + assign_loop_contents = {'1':{}, '2':{}, '3':{}} + multiplication_info = {} + + for field, derivatives in field_derivatives.items(): + multiplication_info[field] = {} + assign_statements = {'1':[], '2':[], '3':[]} + for derivative, dxs in derivatives.items(): + multiplication_info[field][derivative] = [] + dx1 = dxs['x1'] + dx2 = dxs['x2'] + dx3 = dxs['x3'] + for i, dx in enumerate([dx1, dx2, dx3]): + name = f"{field}_{i+1}" if dx == 0 else f"{field}_{i+1}_{dx*f'x{i+1}'}" + multiplication_info[field][derivative].append(name) + if dx == 0: + assign_statement = f"{name} = global_test_basis_{field}_{i+1}[k_{i+1}, i_{i+1}, 0, q_{i+1}]" + else: + assign_statement = f"{name} = global_test_basis_{field}_{i+1}[k_{i+1}, i_{i+1}, {dx}, q_{i+1}]" + if assign_statement not in assign_statements[f"{i+1}"]: + assign_statements[f"{i+1}"].append(assign_statement) + for i in range(3): + content = ("\n"+(8+i)*" ").join(assign_statements[f"{i+1}"]) + assign_loop_contents[f"{i+1}"][field] = content + tab = 7*" " + assign = [] + for field in field_derivatives: + txt = f"{tab}for i_1 in range(1 + test_{field}_p1):\n" + \ + f"{tab} {assign_loop_contents['1'][field]}\n" + \ + f"{tab} for i_2 in range(1 + test_{field}_p2):\n" + \ + f"{tab} {assign_loop_contents['2'][field]}\n" + \ + f"{tab} for i_3 in range(1 + test_{field}_p3):\n" + \ + f"{tab} {assign_loop_contents['3'][field]}\n" + \ + f"{tab} coeff_{field} = arr_coeffs_{field}[i_1, i_2, i_3]\n" + for derivative in multiplication_info[field]: + factors = " * ".join(multiplication_info[field][derivative]) + txt += f"{tab} {derivative} += {factors} * coeff_{field}\n" + txt += "\n" + assign.append(txt) + + # {F_ASSIGN_LOOP} in both _assembly_template_body_bspline & _analytic + F_ASSIGN_LOOP = "\n".join(assign) + + # ----------------------------------------- + + # ----- load the templates ----- + # + # head for the function header and imports + # body for the computation of coupling terms + # loop (part of function body): one loop per block ( e.g. (u[0], v[1]) ), each loop effectively + # assembles one StencilMatrix per sub expression ( e.g. (dx1(u[0]), dx3(v[1])) ) + code_head = self._assembly_template_head + code_loop = self._assembly_template_loop + if mapping_option == 'Bspline': + code_body = self._assembly_template_body_bspline + else: + code_body = self._assembly_template_body_analytic + # ------------------------------ + + # ---- obtain basic information not explicitely passed in the args ----- + blocks = ordered_stmts.keys() + block_list = list(blocks) + trial_components = [block[0] for block in block_list] + test_components = [block[1] for block in block_list] + nu = len(set(trial_components)) + nv = len(set(test_components)) + d = 3 + assert d == 3 + # ---------------------------------------------------------------------- + + # Prepare strings and string templates depending on whether the trial and test function are vector-valued or not (nu, nv > 1 or == 1) + + # ------------------------- STRINGS HEAD ------------------------- + + global_span_v_str = 'global_span_v_{v_j}_' if nv > 1 else 'global_span_v_' + + if mapping_option == 'Bspline': + MAPPING_PART_1 = 'global_basis_mapping_1 : "float64[:,:,:,:]", global_basis_mapping_2 : "float64[:,:,:,:]", global_basis_mapping_3 : "float64[:,:,:,:]", ' + MAPPING_PART_2 = 'global_span_mapping_1 : "int64[:]", global_span_mapping_2 : "int64[:]", global_span_mapping_3 : "int64[:]", ' + MAPPING_PART_3 = 'test_mapping_p1 : "int64", test_mapping_p2 : "int64", test_mapping_p3 : "int64", ' + MAPPING_PART_4 = 'global_arr_coeffs_x : "float64[:,:,:]", global_arr_coeffs_y : "float64[:,:,:]", global_arr_coeffs_z : "float64[:,:,:]", ' + else: + MAPPING_PART_1 = '' + MAPPING_PART_2 = '' + MAPPING_PART_3 = '' + MAPPING_PART_4 = '' + + if nv > 1: + tt1_str = 'test_trial_1_u_{u_i}_v_{v_j}' if nu > 1 else 'test_trial_1_u_v_{v_j}' + tt2_str = 'test_trial_2_u_{u_i}_v_{v_j}' if nu > 1 else 'test_trial_2_u_v_{v_j}' + tt3_str = 'test_trial_3_u_{u_i}_v_{v_j}' if nu > 1 else 'test_trial_3_u_v_{v_j}' + a3_str = 'a3_u_{u_i}_v_{v_j}' if nu > 1 else 'a3_u_v_{v_j}' + a2_str = 'a2_u_{u_i}_v_{v_j}' if nu > 1 else 'a2_u_v_{v_j}' + ct_str = 'coupling_terms_u_{u_i}_v_{v_j}' if nu > 1 else 'coupling_terms_u_v_{v_j}' + g_mat_str = 'g_mat_u_{u_i}_v_{v_j}' if nu > 1 else 'g_mat_u_v_{v_j}' + else: + tt1_str = 'test_trial_1_u_{u_i}_v' if nu > 1 else 'test_trial_1_u_v' + tt2_str = 'test_trial_2_u_{u_i}_v' if nu > 1 else 'test_trial_2_u_v' + tt3_str = 'test_trial_3_u_{u_i}_v' if nu > 1 else 'test_trial_3_u_v' + a3_str = 'a3_u_{u_i}_v' if nu > 1 else 'a3_u_v' + a2_str = 'a2_u_{u_i}_v' if nu > 1 else 'a2_u_v' + ct_str = 'coupling_terms_u_{u_i}_v' if nu > 1 else 'coupling_terms_u_v' + g_mat_str = 'g_mat_u_{u_i}_v' if nu > 1 else 'g_mat_u_v' + + # ---------------------------------------------------------------- + + # ------------------------- STRINGS BODY ------------------------- + + span_v_1_str = 'span_v_{v_j}_1' if nv > 1 else 'span_v_1' + test_v_p1_str = 'test_v_{v_j}_p1' if nv > 1 else 'test_v_p1' + + if nv > 1: + keys_2_str = 'keys_2_u_{u_i}_v_{v_j}' if nu > 1 else 'keys_2_u_v_{v_j}' + keys_3_str = 'keys_3_u_{u_i}_v_{v_j}' if nu > 1 else 'keys_3_u_v_{v_j}' + a1_str = 'a1_u_{u_i}_v_{v_j}' if nu > 1 else 'a1_u_v_{v_j}' + else: + keys_2_str = 'keys_2_u_{u_i}_v' if nu > 1 else 'keys_2_u_v' + keys_3_str = 'keys_3_u_{u_i}_v' if nu > 1 else 'keys_3_u_v' + a1_str = 'a1_u_{u_i}_v' if nu > 1 else 'a1_u_v' + + # ---------------------------------------------------------------- + + # ------------------------- STRINGS LOOP ------------------------- + + span_2_str = 'span_v_{v_j}_2' if nv > 1 else 'span_v_2' + span_3_str = 'span_v_{v_j}_3' if nv > 1 else 'span_v_3' + global_span_2_str = 'global_span_v_{v_j}_2' if nv > 1 else 'global_span_v_2' + global_span_3_str = 'global_span_v_{v_j}_3' if nv > 1 else 'global_span_v_3' + + # ---------------------------------------------------------------- + + #------------------------- MAKE HEAD ------------------------- + SPAN = '' + G_MAT = '' + + TT1 = ' ' + TT2 = ' ' + TT3 = ' ' + A3 = ' ' + A2 = ' ' + CT = ' ' + + for v_j in range(nv): + global_span_v = global_span_v_str.format(v_j=v_j) + SPAN += ' ' + for di in range(d): + SPAN += f'{global_span_v}{di+1} : "int64[:]", ' + SPAN = SPAN[:-1] + '\n' + + for block in blocks: + u_i = block[0].indices[0] if nu > 1 else 0 + v_j = block[1].indices[0] if nv > 1 else 0 + + # reverse order intended + if ((nu > 1) and (nv > 1)): + g_mat = g_mat_str.format(u_i=v_j, v_j=u_i) + else: + g_mat = g_mat_str.format(u_i=u_i, v_j=v_j) + G_MAT += f' {g_mat} : "float64[:,:,:,:,:,:]",\n' + + TT1 += tt1_str.format(u_i=u_i, v_j=v_j) + ' : "float64[:,:,:,:,:,:]", ' + TT2 += tt2_str.format(u_i=u_i, v_j=v_j) + ' : "float64[:,:,:,:,:,:]", ' + TT3 += tt3_str.format(u_i=u_i, v_j=v_j) + ' : "float64[:,:,:,:,:,:]", ' + A3 += a3_str.format(u_i=u_i, v_j=v_j) + ' : "float64[:,:,:]", ' + A2 += a2_str.format(u_i=u_i, v_j=v_j) + ' : "float64[:,:,:,:,:]", ' + CT += ct_str.format(u_i=u_i, v_j=v_j) + ' : "float64[:,:,:,:,:]", ' + + TT1 += '\n' + TT2 += '\n' + TT3 += '\n' + A3 += '\n' + A2 += '\n' + CT += '\n' + NEW_ARGS = TT1 + TT2 + TT3 + A3 + A2 + CT + IMPORTS = self._imports_string + + head = code_head.format(FILE_ID = file_id, + SPAN = SPAN, + G_MAT = G_MAT, + NEW_ARGS = NEW_ARGS, + MAPPING_PART_1 = MAPPING_PART_1, + MAPPING_PART_2 = MAPPING_PART_2, + MAPPING_PART_3 = MAPPING_PART_3, + MAPPING_PART_4 = MAPPING_PART_4, + FIELD_ARGS = FIELD_ARGS, + imports = IMPORTS) + + #------------------------- MAKE BODY ------------------------- + A1 = '' + KEYS_2 = '' + KEYS_3 = '' + LOCAL_SPAN = '' + TEMPS = '' + COUPLING_TERMS = '' + + for block in blocks: + u_i = block[0].indices[0] if nu > 1 else 0 + v_j = block[1].indices[0] if nv > 1 else 0 + + keys2 = keys_2[block].copy() + keys3 = keys_3[block].copy() + keys2 = ','.join(str(i) for i in keys2.flatten()) + keys3 = ','.join(str(i) for i in keys3.flatten()) + KEYS2 = keys_2_str.format(u_i=u_i, v_j=v_j) + KEYS3 = keys_3_str.format(u_i=u_i, v_j=v_j) + KEYS_2 += f' {KEYS2} = array([{keys2}])\n' + KEYS_3 += f' {KEYS3} = array([{keys3}])\n' + + test_v_p1, test_v_p2, test_v_p3 = test_v_p[v_j] + a1 = a1_str.format(u_i=u_i, v_j=v_j) + g_mat = g_mat_str.format(u_i=u_i, v_j=v_j) + TEST_V_P1 = test_v_p1_str.format(v_j=v_j) + SPAN_V_1 = span_v_1_str.format(v_j=v_j) + + A1_1 = f'{test_mult[0]}*pad1 + {SPAN_V_1} - {test_v_p1} : {test_mult[0]}*pad1 + {SPAN_V_1} + 1' if test_mult[0] > 1 else f'pad1 + {SPAN_V_1} - {test_v_p1} : pad1 + {SPAN_V_1} + 1' + A1_2 = f'{test_mult[1]}*pad2 : {test_mult[1]}*pad2 + n_element_2 + {test_v_p2} + ({test_mult[1]}-1)*(n_element_2-1)' if test_mult[1] > 1 else f'pad2 : pad2 + n_element_2 + {test_v_p2}' + A1_3 = f'{test_mult[2]}*pad3 : {test_mult[2]}*pad3 + n_element_3 + {test_v_p3} + ({test_mult[2]}-1)*(n_element_3-1)' if test_mult[2] > 1 else f'pad3 : pad3 + n_element_3 + {test_v_p3}' + A1 += f' {a1} = {g_mat}[{A1_1}, {A1_2}, {A1_3}, :, :, :]\n' + + for v_j in range(nv): + local_span_v_1 = span_v_1_str.format(v_j=v_j) + global_span_v = global_span_v_str.format(v_j=v_j) + LOCAL_SPAN += f' {local_span_v_1} = {global_span_v}1[k_1]\n' + + for temp in temps: + TEMPS += f' {temp.lhs} = {temp.rhs}\n' + for block in blocks: + for stmt in ordered_stmts[block]: + COUPLING_TERMS += f' {stmt.lhs} = {stmt.rhs}\n' + + KEYS = KEYS_2 + KEYS_3 + + # This part is interesting. Right now, below you find hardcoded rules regarding lines of code + # that need to be included when max_logical_derivative == 2 ( and mapping_option == 'Bspline'). + # E.g., the bilinear form corresponding to a bilaplacian problem ( laplace(laplace(u)) = f ) satisfies this assumption. + # This hardcoded set of rules could be generalized to n-th max derivatives - if needed! + # But for now, higher than second order derivatives on either trial or test function are not supported. + # + # Additional note: Given a Bspline mapping, the code computing the first order derivatives of mapping related terms is always required! + # Even in the case of a trivial bilinear form without derivatives. But only when there are second order partial derivatives involved + # do we need to compute second derivatives of the mapping (chain rule). + if (mapping_option == 'Bspline') and (max_logical_derivative == 2): + D2_1 = '\n' + spaces1 = ' ' + spaces2 = spaces1 + ' ' + for symbol in ('x', 'y', 'z'): + for d1 in range(1, 4): + for d2 in range(1, 4): + if d2 >= d1: + D2_1 += spaces1 + f'{symbol}_x{d1}x{d2} = 0.0\n' + D2_1 += '\n' + D2_2 = 'mapping_1_x1x1 = global_basis_mapping_1[k_1, i_1, 2, q_1]' + D2_3 = 'mapping_2_x2x2 = global_basis_mapping_2[k_2, i_2, 2, q_2]' + D2_4 = 'mapping_3_x3x3 = global_basis_mapping_3[k_3, i_3, 2, q_3]' + D2_5 = spaces2+'mapping_x1x1 = mapping_1_x1x1 * mapping_2 * mapping_3\n'+spaces2 + D2_5 += 'mapping_x1x2 = mapping_1_x1 * mapping_2_x2 * mapping_3\n'+spaces2 + D2_5 += 'mapping_x1x3 = mapping_1_x1 * mapping_2 * mapping_3_x3\n'+spaces2 + D2_5 += 'mapping_x2x2 = mapping_1 * mapping_2_x2x2 * mapping_3\n'+spaces2 + D2_5 += 'mapping_x2x3 = mapping_1 * mapping_2_x2 * mapping_3_x3\n'+spaces2 + D2_5 += 'mapping_x3x3 = mapping_1 * mapping_2 * mapping_3_x3x3\n' + D2_6 = '' + for symbol in ('x', 'y', 'z'): + for d1 in range(1, 4): + for d2 in range(1, 4): + if d2 >= d1: + D2_6 += f'{spaces2}{symbol}_x{d1}x{d2} += mapping_x{d1}x{d2} * coeff_{symbol}\n' + D2_6 += '\n' + else: + D2_1 = '' + D2_2 = '' + D2_3 = '' + D2_4 = '' + D2_5 = '' + D2_6 = '' + + body = code_body.format(LOCAL_SPAN = LOCAL_SPAN, + KEYS = KEYS, + A1 = A1, + TEMPS = TEMPS, + COUPLING_TERMS = COUPLING_TERMS, + F_COEFFS_ZEROS = F_COEFFS_ZEROS, + F_SPAN_1 = F_SPAN_1, + F_SPAN_2 = F_SPAN_2, + F_SPAN_3 = F_SPAN_3, + F_COEFFS = F_COEFFS, + F_INIT = F_INIT, + F_ASSIGN_LOOP = F_ASSIGN_LOOP, + D2_1 = D2_1, + D2_2 = D2_2, + D2_3 = D2_3, + D2_4 = D2_4, + D2_5 = D2_5, + D2_6 = D2_6) + + #------------------------- MAKE LOOP ------------------------- + assembly_code = head + body + loop_str = '' + + for block in blocks: + u_i = block[0].indices[0] if nu > 1 else 0 + v_j = block[1].indices[0] if nv > 1 else 0 + + A1 = a1_str.format(u_i=u_i, v_j=v_j) + A2 = a2_str.format(u_i=u_i, v_j=v_j) + A3 = a3_str.format(u_i=u_i, v_j=v_j) + TEST_TRIAL_2 = tt2_str.format(u_i=u_i, v_j=v_j) + TEST_TRIAL_3 = tt3_str.format(u_i=u_i, v_j=v_j) + SPAN_2 = span_2_str.format(u_i=u_i, v_j=v_j) + SPAN_3 = span_3_str.format(u_i=u_i, v_j=v_j) + GLOBAL_SPAN_2 = global_span_2_str.format(u_i=u_i, v_j=v_j) + GLOBAL_SPAN_3 = global_span_3_str.format(u_i=u_i, v_j=v_j) + KEYS_3 = keys_3_str.format(u_i=u_i, v_j=v_j) + KEYS_2 = keys_2_str.format(u_i=u_i, v_j=v_j) + COUPLING_TERMS = ct_str.format(u_i=u_i, v_j=v_j) + + TEST_V_P1, TEST_V_P2, TEST_V_P3 = test_v_p[v_j] + TRIAL_U_P1, TRIAL_U_P2, TRIAL_U_P3 = trial_u_p[u_i] + MAX_P1 = max(TEST_V_P1, TRIAL_U_P1) + MAX_P2 = max(TEST_V_P2, TRIAL_U_P2) + MAX_P3 = max(TEST_V_P3, TRIAL_U_P3) + NEXPR = len(ordered_stmts[block]) + + keys1 = keys_1[block] + TEST_TRIAL_1 = tt1_str.format(u_i=u_i, v_j=v_j) + A2_TEMP = " + ".join([f"{TEST_TRIAL_1}[k_1, q_1, i_1, j_1, {keys1[e][0]}, {keys1[e][1]}] * {A2}[{e},:,:,:,:]" for e in range(NEXPR)]) + + I_1 = f'int(floor(i_1/{test_mult[0]})*{trial_mult[0]})' if max(test_mult[0], trial_mult[0]) > 1 else 'i_1' + I_2 = f'int(floor(i_2/{test_mult[1]})*{trial_mult[1]})' if max(test_mult[1], trial_mult[1]) > 1 else 'i_2' + I_3 = f'int(floor(i_3/{test_mult[2]})*{trial_mult[2]})' if max(test_mult[2], trial_mult[2]) > 1 else 'i_3' + #MAX_P1 = max(int( ( MAX_P1 + np.floor(MAX_P1 / test_mult[0]) * trial_mult[0] ) / 2 ), MAX_P1) if max(test_mult[0], trial_mult[0]) > 1 else MAX_P1 + #MAX_P2 = max(int( ( MAX_P2 + np.floor(MAX_P2 / test_mult[1]) * trial_mult[1] ) / 2 ), MAX_P2) if max(test_mult[1], trial_mult[1]) > 1 else MAX_P2 + #MAX_P3 = max(int( ( MAX_P3 + np.floor(MAX_P3 / test_mult[2]) * trial_mult[2] ) / 2 ), MAX_P3) if max(test_mult[2], trial_mult[2]) > 1 else MAX_P3 + n_cols_x1 = max( int(MAX_P1 + 1 + np.floor(MAX_P1 / test_mult[0]) * trial_mult[0]), 2*MAX_P1+1 ) + n_cols_x2 = max( int(MAX_P2 + 1 + np.floor(MAX_P2 / test_mult[1]) * trial_mult[1]), 2*MAX_P2+1 ) + n_cols_x3 = max( int(MAX_P3 + 1 + np.floor(MAX_P3 / test_mult[2]) * trial_mult[2]), 2*MAX_P3+1 ) + MAX_P1 = n_cols_x1 - MAX_P1 - 1 + MAX_P2 = n_cols_x2 - MAX_P2 - 1 + MAX_P3 = n_cols_x3 - MAX_P3 - 1 + + loop = code_loop.format(A1 = A1, + A2 = A2, + A3 = A3, + TEST_TRIAL_2 = TEST_TRIAL_2, + TEST_TRIAL_3 = TEST_TRIAL_3, + SPAN_2 = SPAN_2, + SPAN_3 = SPAN_3, + GLOBAL_SPAN_2 = GLOBAL_SPAN_2, + GLOBAL_SPAN_3 = GLOBAL_SPAN_3, + KEYS_2 = KEYS_2, + KEYS_3 = KEYS_3, + COUPLING_TERMS = COUPLING_TERMS, + TEST_V_P1 = TEST_V_P1, + TEST_V_P2 = TEST_V_P2, + TEST_V_P3 = TEST_V_P3, + TRIAL_U_P1 = TRIAL_U_P1, + TRIAL_U_P2 = TRIAL_U_P2, + TRIAL_U_P3 = TRIAL_U_P3, + MAX_P1 = MAX_P1, + MAX_P2 = MAX_P2, + MAX_P3 = MAX_P3, + NEXPR = NEXPR, + A2_TEMP = A2_TEMP, + I_1 = I_1, + I_2 = I_2, + I_3 = I_3) + + loop_str += loop + + assembly_code += loop_str + assembly_code += '\n return\n' + + #------------------------- MAKE FILE ------------------------- + import os + if not os.path.isdir('__psydac__'): + os.makedirs('__psydac__') + + # Root process writes the assembly code to a file + if comm is None or comm.rank == 0: + filename = f'__psydac__/assemble_{file_id}.py' + f = open(filename, 'w') + f.writelines(assembly_code) + f.close() + + # Parallel case: wait for the file to be closed before proceeding + if comm is not None and comm.size > 1: + _ = comm.bcast(None, root=0) + + return file_id + + #-------------------------------------------------------------------------- + def read_BilinearForm(self): + """ + Part of the sum factorization algorithm implementation. + Used at the beginning of construct_arguments_generate_assembly_file(). + It's output determines both the design of the assembly function, and the arguments passed to it. + + Returns + ------- + + temps : tuple + tuple of Assign objects. Often times usable building blocks of complicated coupling terms. + + ordered_stmts : dict + assigns each block (trial&test component combination) a list of coupling term assignment + + ordered_sub_exprs_keys : dict + relates each coupling term assignment of ordered_stmts a partial derivative combination + + mapping_option : str | None + 'Bspline' if a spline mapping is involved, None if an analytical or no mapping is involved + + field_derivatives : dict + contains information regarding appearing free FemFields and appearing partial derivatives of those + + g_mat_information_false : list + possibly wrong list of non-zero blocks + + g_mat_information_true : list + correct list of non-zero blocks + + max_logical_derivative : int + maximum appearing partial derivative (in any fixed direction) + + """ + + a = self.expr + domain = a.domain + + # Because an analytical mapping only changes the expression, only the case of a Bspline mapping has to be treated + # entirely different + mapping_option = 'Bspline' if isinstance(self._mapping, SplineMapping) else None + + # The following are tuples consisting of test, trial and free FemField functions appearing, e.g. + # u, v, F1, F2 = elements_of(V, names='u, v, F1, F2) + # a = BilinearForm((u, v), integral(domain, dot(u, F1) * dot(v, F2))) + # tests = (v, ), trials = (u, ) fields = (F1, F2) - Note: The order of F1 & F2 is apparently random and changes from time to time! + # tuple entries are either sympde.topology.space.ScalarFunction or sympde.topology.space.VectorFunction objects + tests = a.test_functions + trials = a.trial_functions + fields = a.fields + + # A sympde.expr.evaluation.DomainExpression object + # TODO [YG 31.07.2025]: Why not using self.terminal_expr[0] instead? + texpr = TerminalExpr(a, domain)[0] + + # We extract all appearing components of test, trial and free FemFields, as well as appearing partial derivatives of these. + # e.g. atoms = [F1[1], F2[1], v[0], u[0], F1[2], F2[2], F1[0], v[1], v[2], F2[0], u[1], u[2]] + # for a bilinear form, without derivatives, involving two vector valued Fem fields F1 & F2 and vector valued test & trial functions v and u + atoms_types = (ScalarFunction, VectorFunction, IndexedVectorFunction) + atoms = _atomic(texpr, cls=atoms_types+_logical_partial_derivatives) + + # Preparing to sort all atoms into test_, trial_ and field_atoms + test_atoms = {} + for v in tests: + if isinstance(v, VectorFunction): + for i in range(domain.dim): + test_atoms[v[i]] = [] + else: + test_atoms[v] = [] + + trial_atoms = {} + for u in trials: + if isinstance(u, VectorFunction): + for i in range(domain.dim): + trial_atoms[u[i]] = [] + else: + trial_atoms[u] = [] + + field_atoms = {} + for f in fields: + if isinstance(f, VectorFunction): + for i in range(domain.dim): + field_atoms[f[i]] = [] + else: + field_atoms[f] = [] + + # atoms can consist of scalar functions (u, v), partial derivatives of scalar functions (dx1(u), dx3(v), ...), + # components of vector valued functions (u[0], v[1], ...), partial derivatives of components of vector valued functions + # (dx1(u[0]), dx3(v[1]), ...), and the same thing but for free FemFields. + # With + # get_atom_logical_derivatives(atom) + # we obtain the component without partial derivatives (u -> u ; dx1(u) -> u ; dx2(v[2]) -> v[2] ; ...) + # This way we can gather subexpressions belonging to the same block + for atom in atoms: + a = get_atom_logical_derivatives(atom) + # IF: NOT Indexed Mapping AND NOT VectorFunction + # I guess: <=> IF ScalarFunction + if not ((isinstance(a, Indexed) and isinstance(a.base, Mapping)) or (isinstance(a, IndexedVectorFunction))): + if a in tests: + # tests is a tuple, e.g. (v, ), hence tests[0] = v + test_atoms[tests[0]].append(atom) + elif a in trials: + trial_atoms[trials[0]].append(atom) + elif a in fields: + # while there can only be one trial and one test function, there can be multiple free FemFields. + for f in field_atoms: + if f == a: + field_atoms[f].append(atom) + else: + raise NotImplementedError(f"atoms of type {str(atom)} are not supported") + # IF VectorFunction + elif isinstance(a, IndexedVectorFunction): + # .base returns ... the base of a VectorFunction! E.g., u[2] -> u, v[0] -> v + if a.base in tests: + for vi in test_atoms: + if vi == a: + test_atoms[vi].append(atom) + break + elif a.base in trials: + for ui in trial_atoms: + if ui == a: + trial_atoms[ui].append(atom) + break + elif a.base in fields: + for fi in field_atoms: + if fi == a: + field_atoms[fi].append(atom) + break + else: + raise NotImplementedError(f"atoms of type {str(atom)} are not supported") + + # ----- Julian O. 11.06.25 ----- + # Regarding the code that follows: + # When dealing with a DiscreteBilinearForm depending on two or more free FemFields, + # the order of the dictionary `field_derivatives` must be the same as the order + # of the free FemFields in `self._free_args`. + # For some reason, the order of all appearing "atoms" in a BilinearForm (trial function, test function, free fields, .?.) + # as obtained in the __init__ of AST + # atoms = terminal_expr.expr.atoms(ScalarFunction, VectorFunction) + # is random and changes from code execution to code execution. + # This order of atoms however determines the order of the free FemFields appearing in `self._free_args`. + # In particular, this order only sometimes matches the order of `field_derivatives`, which results in wrong matrices. + # + # Below is the old version of the code that follows: + #field_derivatives = {} + #for key in field_atoms: + # sym_key = SymbolicExpr(key) + # field_derivatives[sym_key] = {} + # for f in field_atoms[key]: + # field_derivatives[sym_key][SymbolicExpr(f)] = get_index_logical_derivatives(f) + # ------------------------------ + + # For the computation of the coupling terms, among other we need to organize information + # related to free FemFields. For now, we have the dictionary field_atoms, whose keys are + # components of appearing fields, and whose values are appearing partial derivatives of these, e.g., + # field_atoms = {'F1[0]':[dx1(F1[0]), ], 'F1[1]':[dx2(F1[1]), ], 'F1[2]':[dx3(F1[2]), ], 'F2':[F2, ]} + # + # We now create the dictionary field_derivatives. + # It's keys are SymbolicExpr of the previous keys (F1[0] -> F1_0, F1[1] -> F1_1, F1[2] -> F1_2, F2 -> F2) + # and its values are again dictionaries, whose keys are symbolic expressions of the appearing partial derivatives, e.g. + # dx1(F1[0]) -> F1_0_x1, dx2(F1[1]) -> F1_1_x2, dx3(F1[2]) -> F1_2_x3, F2 -> F2, + # and whose values are dictionaries that store the respective derivative information. + # Consider for example the BilinearForm (u, v) \mapsto integral(domain, dot(u, grad(Fs)) * dot(v, grad(Fs2)): + # The corresponding field_derivatives dict will be + # {Fs: {Fs_x3: {'x1': 0, 'x2': 0, 'x3': 1}, Fs_x2: {'x1': 0, 'x2': 1, 'x3': 0}, Fs_x1: {'x1': 1, 'x2': 0, 'x3': 0}}, Fs2: {Fs2_x3: {'x1': 0, 'x2': 0, 'x3': 1}, Fs2_x2: {'x1': 0, 'x2': 1, 'x3': 0}, Fs2_x1: {'x1': 1, 'x2': 0, 'x3': 0}}} + + # Amount of free FemFields (NOT counting each component individually) + n_free_fields = len(self._free_args) + field_derivatives = {} + # The keys in field_derivatives will be in the same order as the fields appearing in self._free_args + for n in range(n_free_fields): + # The key might be F1[0], but we want to check whether F1 == self._free_args[0], and ... + for key in field_atoms: + # ... field_name does exactly that + field_name = str(key.base) if hasattr(key, 'base') else str(key) + if field_name == self._free_args[n]: + # SymbolicExpr transforms something like F1[0] into F1_0 (part of the name of a variable in the assembly code later) + sym_key = SymbolicExpr(key) + field_derivatives[sym_key] = {} + for f in field_atoms[key]: + # And similarly f, which might look like dx1(F1[0]), will be transformed to F1_0_x2 + # while get_index_logical_derivatives(dx1(F1[0])) = {'x1': 1, 'x2': 0, 'x3': 0} + field_derivatives[sym_key][SymbolicExpr(f)] = get_index_logical_derivatives(f) + + # This part was proposed by Said at some point + #syme = False + #if syme: + # from symengine import sympify as syme_sympify + # sym_test_atoms = {k:[syme_sympify(SymbolicExpr(ai)) for ai in a] for k,a in test_atoms.items()} + # sym_trial_atoms = {k:[syme_sympify(SymbolicExpr(ai)) for ai in a] for k,a in trial_atoms.items()} + # sym_expr = syme_sympify(SymbolicExpr(texpr.expr)) + #else: + # sym_test_atoms = {k:[SymbolicExpr(ai) for ai in a] for k,a in test_atoms.items()} + # sym_trial_atoms = {k:[SymbolicExpr(ai) for ai in a] for k,a in trial_atoms.items()} + # sym_expr = SymbolicExpr(texpr.expr) + + # test_atoms is a dict whose values are components of the test function and whose values + # are arrays with appearing partial derivatives of those components. + # sym_test_atoms has the same structure, but replaces the appearing partial derivatives with + # symbolic expressions of those partial derivatives. E.g., + # test_atoms: {v2[0]: [dx3(v2[0]), dx2(v2[0])], v2[1]: [dx1(v2[1]), dx3(v2[1])], v2[2]: [dx2(v2[2]), dx1(v2[2])]} + # sym_test_atoms: {v2[0]: [v2_0_x3, v2_0_x2], v2[1]: [v2_1_x1, v2_1_x3], v2[2]: [v2_2_x2, v2_2_x1]} + # In the following, we will gather all (coupling) terms of a specific combination of a sym_test_atom with a sym_trial_atom in sym_expr + sym_test_atoms = {k:[SymbolicExpr(ai) for ai in a] for k,a in test_atoms.items()} + sym_trial_atoms = {k:[SymbolicExpr(ai) for ai in a] for k,a in trial_atoms.items()} + sym_expr = SymbolicExpr(texpr.expr) + + # ----- temps, rhs ----- + + trials_subs = {ui:0 for u in sym_trial_atoms for ui in sym_trial_atoms[u]} + tests_subs = {vi:0 for v in sym_test_atoms for vi in sym_test_atoms[v]} + sub_exprs = {} + + # This is where the real magic happens: The at times extremely long and complicated SymbolicExpr sym_expr + # 0. is brought into a more readable form (sub_exprs) & + # 1. gets split into many small parts (temps), that often times appear in multiple sub_exprs, + # but now only have to be computed once, e.g. (temp_0, -F2_1*F1_1) & + # 2. those temporaries get assigned to coupling terms (rhs), i.e.: + # The coupling term corresponding to the sub-expr dx1(u[0])*dx3(v[1]) might be -temp_7*(temp_22*temp_27 + temp_33*temp_35 + temp_36*temp_37) + for u in sym_trial_atoms: + for v in sym_test_atoms: + if isinstance(u, IndexedVectorFunction) and isinstance(v, IndexedVectorFunction): + sub_expr = sym_expr[v.indices[0], u.indices[0]] + elif isinstance(u, ScalarFunction) and isinstance(v, ScalarFunction): + sub_expr = sym_expr + elif isinstance(u, ScalarFunction) and isinstance(v, IndexedVectorFunction): + sub_expr = sym_expr[v.indices[0]] + elif isinstance(u, IndexedVectorFunction) and isinstance(v, ScalarFunction): + sub_expr = sym_expr[u.indices[0]] + for ui,sui in zip(trial_atoms[u], sym_trial_atoms[u]): + trcp = trials_subs.copy() + trcp[sui] = 1 + newsub_expr = sub_expr.subs(trcp) + for vi,svi in zip(test_atoms[v],sym_test_atoms[v]): + tcp = tests_subs.copy() + tcp[svi] = 1 + expr = newsub_expr.subs(tcp) + if not expr.is_zero: + sub_exprs[ui,vi] = sympify(expr) + + temps, rhs = cse_main.cse(sub_exprs.values(), symbols=cse_main.numbered_symbols(prefix=f'temp_')) + + # ---------------------- + + # Finally, temps and rhs must be brought into a form that can be included in the assembly code, e.g. + # temp_0 = x_x1*y_x2 + # temp_1 = x_x2*z_x1 + # temp_2 = y_x1*z_x2 + # ... + # coupling_terms_u_v[k_2, q_2, k_3, q_3, 0] = temp_7*(temp_10**2*temp_9 + temp_11**2*temp_9 + temp_8**2*temp_9) + # coupling_terms_u_v[k_2, q_2, k_3, q_3, 1] = temp_18 + # coupling_terms_u_v[k_2, q_2, k_3, q_3, 2] = temp_22 + # ... + + # See above example: In our implementation of the sum factorization algorithm, we precompute arrays + # for each quadrature point in x1 direction, meaning that those arrays contain values depending on + # elements and quadrature points in x2 and x3 direction (k_2, k_3 & q_2 & q_3) + element_indices = [Symbol('k_{}'.format(i)) for i in range(2,4)] + quadrature_indices = [Symbol('q_{}'.format(i)) for i in range(2,4)] + # indices = (k_2, q_2, k_3, q_3) + indices = tuple(j for i in zip(element_indices, quadrature_indices) for j in i) + + # From the sub_exprs dictionary, we read all the appearing trial and test component combinations (blocks) that + # add a non-zero contribution to the matrix + ordered_stmts = {} + ordered_sub_exprs_keys = {} + for key in sub_exprs.keys(): + u_i, v_j = [get_atom_logical_derivatives(atom) for atom in key] + ordered_stmts[u_i, v_j] = [] + ordered_sub_exprs_keys[u_i, v_j] = [] + blocks = ordered_stmts.keys() + + block_list = list(blocks) + trial_components = [block[0] for block in block_list] + test_components = [block[1] for block in block_list] + nu = len(set(trial_components)) + nv = len(set(test_components)) + + expr = self.kernel_expr.expr + + # We store the maximum partial derivative (for a fixed direction), not including pertial derivatives + # appearing in mapping related terms (i.e., a BilinearForm on a mapped domain will have max_logical_derivative = 0 + # even though derivatives of the (spline) mapping appear in the coupling terms). + if isinstance(expr, (ImmutableDenseMatrix, Matrix)): + shape = expr.shape + logical_max_derivatives = [] + for k1 in range(shape[0]): + for k2 in range(shape[1]): + logical_max_derivatives.append(get_max_logical_partial_derivatives(expr[k1,k2])) + max_logical_derivative = max([max([value for value in dic.values()]) for dic in logical_max_derivatives]) + else: + max_logical_derivative = max([value for value in get_max_logical_partial_derivatives(expr).values()]) + + # See comment underneath this code block for more details. + # There was a test case, in which the amount of generated StencilMatrices (one for each appearing block, + # i.e., one for each trial&test component combination for which a non-zero coupling term exists) + # was larger than the amount true amount of needed StencilMatrices. + # That discrepancy appears when expr[block].is_zero wrongly does not detect that a block is zero, + # whereas the corresponding block does rightfully not appear in block_list! + if isinstance(expr, (ImmutableDenseMatrix, Matrix)): # only relevenat if either trial or test function is vector valued + g_mat_information_false = [] + shape = expr.shape + for k1 in range(shape[0]): + for k2 in range(shape[1]): + if not expr[k1,k2].is_zero: # although it might actually be zero! + if (nu == 1) and (nv > 1): + g_mat_information_false.append((k2,k1)) + else: + g_mat_information_false.append((k1,k2)) + if nu == 1: + g_mat_information_true = [(0, get_atom_logical_derivatives(block[1]).indices[0]) for block in block_list] + elif nv == 1: + g_mat_information_true = [(get_atom_logical_derivatives(block[0]).indices[0], 0) for block in block_list] + else: + g_mat_information_true = [(get_atom_logical_derivatives(block[0]).indices[0], get_atom_logical_derivatives(block[1]).indices[0]) for block in block_list] + else: + g_mat_information_false = [] + g_mat_information_true = [] + + # Julian O. 17.06.25: Back when I added this unreadable comment below I forgot to write a test for this problem. + # Eventually it might be interesting to remove everything related to `g_mat_information_false/true` + # and see where errors occur. + # + #1, 1: expr[1,1] = F0*sqrt(x1**2*(x1*cos(2*pi*x3) + 2)**2*(sin(pi*x2)**2 + cos(pi*x2)**2)**2*(sin(2*pi*x3)**2 + cos(2*pi*x3)**2)**2)*(pi*(x1*cos(2*pi*x3) + 2)* + # (-2*pi*x1*sin(pi*x2)*sin(2*pi*x3)*dx1(v1[1]) - sin(pi*x2)*cos(2*pi*x3)*dx3(v1[1]))*cos(pi*x2)*w2[1] - pi*(x1*cos(2*pi*x3) + 2)*(-2*pi*x1*sin(2*pi*x3)*cos(pi*x2)*dx1(v1[1]) - + # cos(pi*x2)*cos(2*pi*x3)*dx3(v1[1]))*sin(pi*x2)*w2[1])/(2*pi**2*x1**2*(x1*cos(2*pi*x3) + 2)**2*(sin(pi*x2)**2 + cos(pi*x2)**2)**2*(sin(2*pi*x3)**2 + cos(2*pi*x3)**2)**2) + # = 0 - but is not yet detected as 0! Hence a matrix is generated, that later is not required! + # + + # Here we create a template for the names of the coupling terms arrays, + # depending on whether or not trial and test function are scalar or vector valued + if nv > 1: + ct_str = 'coupling_terms_u_{u_i}_v_{v_j}' if nu > 1 else 'coupling_terms_u_v_{v_j}' + else: + ct_str = 'coupling_terms_u_{u_i}_v' if nu > 1 else 'coupling_terms_u_v' + + # Now we format this template based on the appearing blocks (combinations of trial and test function components) + # and transform those formatted strings into IndexedBase objects + lhs = {} + for block in blocks: + u_i = get_atom_logical_derivatives(block[0]).indices[0] if nu > 1 else 0 + v_j = get_atom_logical_derivatives(block[1]).indices[0] if nv > 1 else 0 + ct = ct_str.format(u_i=u_i, v_j=v_j) + lhs[block] = IndexedBase(f'{ct}') + + # lhs[block] will look w.g. like this coupling_terms_u_v (u, v scalar). + # Now, we add to that [k_2, q_2, k_3, q_3, count], where count enumerates the sub expressions belonging to the same block + # sub expressions corresponding to the block (u[0], v[1]) might be: (u[0], v[1]), (dx1(u[0]), v[1]), (dx2(u[0]), v[1]), ... + # and then assign the corresponding rhs, e.g. temp_7*(temp_10**2*temp_9 + temp_11**2*temp_9 + temp_8**2*temp_9), to obtain: + # coupling_terms_u_v[k_2, q_2, k_3, q_3, 4] = temp_7*(temp_10**2*temp_9 + temp_11**2*temp_9 + temp_8**2*temp_9) + counts = {block:0 for block in blocks} + for r,key in zip(rhs, sub_exprs.keys()): + u_i, v_j = [get_atom_logical_derivatives(atom) for atom in key] + count = counts[u_i, v_j] + counts[u_i, v_j] += 1 + ordered_stmts[u_i, v_j].append(Assign(lhs[u_i, v_j][(*indices, count)], r)) + ordered_sub_exprs_keys[u_i, v_j].append(key) + # ordered_stmts is a dict whose keys are combinations of trial and test functions components (e.g. u[0], v[1]), + # and whose values are a list of coupling term assignments corresponding to this block, e.g. + # (v1[0], v2[0]): [coupling_terms_u_0_v_0[k_2, q_2, k_3, q_3, 0] := -1, coupling_terms_u_0_v_0[k_2, q_2, k_3, q_3, 1] := 1] + # + # The information regarding which partial derivative combination belongs to which coupling term is stored in ordered_sub_exprs_keys. + # This dict has the same keys, but instead of coupling term assignments as values, list of tuples of partial derivative combinations are stored. + + # temps, which previously consisted of tuples like this one: (temp_0, -F2_1*F1_1), + # will now be a tuple consisting of assignments, e.g. (temp_0 := -F2_1*F1_1, ...) + temps = tuple(Assign(a,b) for a,b in temps) + + return temps, ordered_stmts, ordered_sub_exprs_keys, mapping_option, field_derivatives, g_mat_information_false, g_mat_information_true, max_logical_derivative + + #-------------------------------------------------------------------------- + def construct_arguments_generate_assembly_file(self): + """ + Collect the arguments used in the assembly method, and generate and possibly pyccelize the assembly function. + + Used only when sum factorization is enabled, else the method construct_arguments is called. + + Returns + ------- + args: tuple + The arguments passed to the assembly method. + + threads_args: None + None as openMP parallelization is not supported by this implementation. + + """ + temps, ordered_stmts, ordered_sub_exprs_keys, mapping_option, field_derivatives, g_mat_information_false, g_mat_information_true, max_logical_derivative = self.read_BilinearForm() + + # Each block corresponds to a combination of trial and test function components, and thus indeed to a "block" in the matrix. + # Not all possible combination have to exist, e.g., + # given a function space of vector valued functions V (3d) and a bilinear form a: VxV -> R, a(u, v) = (u, v)_L^2(Omega) + # there will be only 3 blocks on a logical domain (u[0]&v[0], u[1]&v[1], u[2]&v[2]), + # but up to 9 blocks on a mapped domain (e.g. u[0]&v[1], ...) + blocks = ordered_stmts.keys() + block_list = list(blocks) + trial_components = [block[0] for block in block_list] + test_components = [block[1] for block in block_list] + # dim = 1 corresponds to a scalar valued function, dim = 3 to a vector valued function + trial_dim = len(set(trial_components)) + test_dim = len(set(test_components)) + + # A reminder that this implementation only supports bilinear forms on 3d domains. + d = 3 + assert d == 3 + + # Rename - also: establish that throughout "u" corresponds to the trial function, whereas "v" corresponds to the test function + nu = trial_dim # dim of trial function; 1 (scalar) or 3 (vector) + nv = test_dim # dim of test function ; 1 (scalar) or 3 (vector) + + # Obtain the most basic information: function values, degrees, spans, ... + test_basis, test_degrees, spans, pads, test_mult = construct_test_space_arguments(self.test_basis) + trial_basis, trial_degrees, pads, trial_mult = construct_trial_space_arguments(self.trial_basis) + n_elements, quads, quad_degrees = construct_quad_grids_arguments(self.grid[0], use_weights=False) + + #! pads is being overwritten. That is because already somewhere else (__init__ of StencilMatrix via self.allocate_matrices) + # do we assert that domain and codomain (trial and test) pads coincide! + # That is not strictly necessary as Valentin at some point proved in one of his branches, but currently not implemented as + # not required. + + #! the above pads variable is multiplied by the multiplicity vector! For the remaining implementation, we need + # the pads vector un-multiplied, as obtained by : + pads = self.test_basis.space.coeff_space.pads + + # quad_degrees is the amount of quadrature points per element in each direction + # Clearly, this amount must coincide with the amount of basis function values stored per element in test_basis and trial_basis + n_element_1, n_element_2, n_element_3 = n_elements + k1, k2, k3 = quad_degrees + + # We store component wise degree and function values for trial and test function in the dictionaries + # trial_u_p, global_basis_u, test_v_p, global_basis_v + if (nu == 3) and (len(trial_basis) == 3): + # Edge Case: If the trial function space V is a VectorFunctionSpace + # but neither an Hdiv nor an Hcurl space, i.e., + # V = VectorFunctionSpace('V', domain) and not +, kind='hcurl') or +, kind='hdiv') + # then the function values in each of the three directions are identical for each of the three components. + # Hence len(trial_basis) == 3 instead of 9. + # + # global_basis_u is a dict whose values are arrays of function values of one particular trial function component, + # hence for this edge case we simply assign the same array trial_basis to each component + # Same function degree in each direction for each component -> do the same thing with trial_u_p + trial_u_p = {u:trial_degrees for u in range(nu)} + global_basis_u = {u:trial_basis for u in range(nu)} + else: + trial_u_p = {u:trial_degrees[d*u:d*(u+1)] for u in range(nu)} + global_basis_u = {u:trial_basis[d*u:d*(u+1)] for u in range(nu)} + if (nv == 3) and (len(test_basis) == 3): + # See above explanation, which also applies for the spans variable + test_v_p = {v:test_degrees for v in range(nv)} + global_basis_v = {v:test_basis for v in range(nv)} + spans = [*spans, *spans, *spans] + else: + test_v_p = {v:test_degrees[d*v:d*(v+1)] for v in range(nv)} + global_basis_v = {v:test_basis[d*v:d*(v+1)] for v in range(nv)} + + # See other method construct_arguments: + # When self._target is an Interface domain len(self._grid) == 2 + # where grid contains the QuadratureGrid of both sides of the interface + assert len(self.grid) == 1 + if self.mapping: + # We gather mapping related information in the case of a Bspline mapping + # self.mapping == False if either no or an analytical mapping + + map_coeffs = [[e._coeffs._data for e in self.mapping._fields]] + spaces = [self.mapping._fields[0].space] + map_degree = [sp.degree for sp in spaces] + map_span = [[q.spans - s for q,s in zip(sp.get_assembly_grids(*self.nquads), sp.coeff_space.starts)] for sp in spaces] + map_basis = [[q.basis for q in sp.get_assembly_grids(*self.nquads)] for sp in spaces] + points = [g.points for g in self.grid] + weights = [self.mapping.weights_field.coeffs._data] if self.is_rational_mapping else [] + + for i in range(len(self.grid)): + axis = self.grid[i].axis + # See construct_arguments - have not come across an example of when axis was not None! + assert axis is None + + map_degree = flatten(map_degree) + map_span = flatten(map_span) + map_basis = flatten(map_basis) + points = flatten(points) + mapping = [*map_coeffs[0], *weights] + else: + + mapping = [] + map_degree = [] + map_span = [] + map_basis = [] + + #---------- The following part is entirely different from the old construct_arguments method ---------- + + # Each block, say u[0]&v[1], + # consists of possibly many derivative combinations (sub-expressions) of these two components, e.g. + # dx1(u[0])&dx1(v[1]) or dx1(u[0])&dx2(v[1]) (dx1, dx2, dx3 representing respective partial derivatives). + # + # For each block, here still e.g. u[0]&v[1], + # and for each sub-expression, we store corresponding derivative information: + # get_index_logical_derivatives(dx1(u[0])) = {'x1': 1, 'x2': 0, 'x3': 0} + # get_index_logical_derivatives(dx2(v[1])) = {'x1': 0, 'x2': 1, 'x3': 0} + # Each of these 6 dicts has for each block an array of length #sub-expressions (appearing derivative combination) stored + # x2_test_keys[(u[0], v[1])][3] = 2 means, that the fourth sub-expression of block (u[0], v[1]) + # involves a second partial derivative of the test function in x2 direction + x1_trial_keys = {block:[] for block in blocks} + x1_test_keys = {block:[] for block in blocks} + x2_trial_keys = {block:[] for block in blocks} + x2_test_keys = {block:[] for block in blocks} + x3_trial_keys = {block:[] for block in blocks} + x3_test_keys = {block:[] for block in blocks} + + for block in blocks: + # alpha, beta for example being dx1(u[0]), dx2(v[1]) + for alpha, beta in ordered_sub_exprs_keys[block]: + x1_trial_keys[block].append(get_index_logical_derivatives(alpha)['x1']) + x1_test_keys [block].append(get_index_logical_derivatives(beta) ['x1']) + x2_trial_keys[block].append(get_index_logical_derivatives(alpha)['x2']) + x2_test_keys [block].append(get_index_logical_derivatives(beta) ['x2']) + x3_trial_keys[block].append(get_index_logical_derivatives(alpha)['x3']) + x3_test_keys [block].append(get_index_logical_derivatives(beta) ['x3']) + + # See sum factorization paper by Bressan & Takacs: + # coupling_terms, a3 and a2 correspond to A^{>=4}_{x1,x2,x3}, A^{>=3}_{x1,x2} and A^{>=2}_{x1} + # Here, for each block we assign a zero-array of the correct size. + coupling_terms = {} + a3 = {} + a2 = {} + + # For each block, we precompute ~enough~ products of partial derivatives of trial and basis functions in each direction + # These precomputed values will then be read rather than computed in the assembly + test_trial_1s = {} + test_trial_2s = {} + test_trial_3s = {} + + # keys_1/2/3 is a restructuring of the 6 dictionaries created above + keys_1 = {} + keys_2 = {} + keys_3 = {} + + assembly_backend = self.backend + if self._pyccelize_test_trial_computation and assembly_backend['name'] == 'pyccel': + + import os + if not os.path.isdir('__psydac__'): + os.makedirs('__psydac__') + + comm = self.comm + + if comm is not None and comm.size > 1: + if comm.rank == 0: + filename = '__psydac__/test_trial_computation.py' + code = self.test_trial_template + f = open(filename, 'w') + f.writelines(code) + f.close() + else: + filename = '__psydac__/test_trial_computation.py' + code = self.test_trial_template + f = open(filename, 'w') + f.writelines(code) + f.close() + + base_dirpath = os.getcwd() + sys.path.insert(0, base_dirpath) + + package = importlib.import_module(f'__psydac__.test_trial_computation') + kwargs = { + 'language' : 'fortran', + 'compiler_family' : assembly_backend['compiler_family'], + 'flags' : assembly_backend['flags'], + 'openmp' : True if assembly_backend['openmp'] else False, + 'verbose' : False, + 'comm' : self.comm, + } + + test_trial_func = epyccel(package.test_trial_array, **kwargs) + + for block in blocks: + # We translate a block, e.g. (u[0], v[1]) into two integers u_i=0, v_j=1. + # In the case of a scalar function (u, v instead of u[0], u[1], u[2], v[0], v[1], v[2]), store 0. + u_i = block[0].indices[0] if nu > 1 else 0 + v_j = block[1].indices[0] if nv > 1 else 0 + + # keys_2[(u[0], v[1])][3] = (1,2) means that the fourth sub-expression corresponding to the trial-test-function-component-product + # u[0] * v[1] involves a first derivative in x2 direction of the trial function and a second derivative in x2 direction of the test function + keys_1[block] = np.array([(alpha_1, beta_1) for alpha_1, beta_1 in zip(x1_trial_keys[block], x1_test_keys[block])]) + keys_2[block] = np.array([(alpha_2, beta_2) for alpha_2, beta_2 in zip(x2_trial_keys[block], x2_test_keys[block])]) + keys_3[block] = np.array([(alpha_3, beta_3) for alpha_3, beta_3 in zip(x3_trial_keys[block], x3_test_keys[block])]) + + # Those are the function values in each direction of a particular component of the trial/test function + global_basis_u_1, global_basis_u_2, global_basis_u_3 = global_basis_u[u_i] + global_basis_v_1, global_basis_v_2, global_basis_v_3 = global_basis_v[v_j] + + # Those are the Bspline degrees in each direction of a particular component of the trial/test function + trial_u_p1, trial_u_p2, trial_u_p3 = trial_u_p[u_i] + test_v_p1, test_v_p2, test_v_p3 = test_v_p [v_j] + + max_p_2 = max(test_v_p2, trial_u_p2) + max_p_3 = max(test_v_p3, trial_u_p3) + + # That's the amount of subexpressions, i.e., combinations of partial derivatives appearing for a specific combination of + # trial and test function components + n_expr = len(ordered_stmts[block]) + + # To compute enough (possibly too many, but never too few) products of trial and test functions, we read the maximum + # appearing partial derivative (for this specific block, in each direction, for both trial and test function) + max_block_trial_x1_derivative = max(x1_trial_keys[block]) + max_block_trial_x2_derivative = max(x2_trial_keys[block]) + max_block_trial_x3_derivative = max(x3_trial_keys[block]) + max_block_test_x1_derivative = max(x1_test_keys[block]) + max_block_test_x2_derivative = max(x2_test_keys[block]) + max_block_test_x3_derivative = max(x3_test_keys[block]) + + # On each Bspline cell (element / subdomain), there are (test_degree+1)*(trial_degree+1) test & trial function pairs + # of non-zero product. + # Hence, we assign zeros for each element, each quadrature point on the element, each test and trial function combination, + # and each (or even more than required) appearing partial derivative combination of these functions - in each direction + test_trial_1 = np.zeros((n_element_1, k1, test_v_p1 + 1, trial_u_p1 + 1, max_block_trial_x1_derivative+1, max_block_test_x1_derivative+1), dtype='float64') + test_trial_2 = np.zeros((n_element_2, k2, test_v_p2 + 1, trial_u_p2 + 1, max_block_trial_x2_derivative+1, max_block_test_x2_derivative+1), dtype='float64') + test_trial_3 = np.zeros((n_element_3, k3, test_v_p3 + 1, trial_u_p3 + 1, max_block_trial_x3_derivative+1, max_block_test_x3_derivative+1), dtype='float64') + + # And that's how we fill the test_trial arrays + if self._pyccelize_test_trial_computation and assembly_backend['name'] == 'pyccel': + for args in zip(n_elements, + quad_degrees, [test_v_p1, test_v_p2, test_v_p3], [trial_u_p1, trial_u_p2, trial_u_p3], + [global_basis_u_1, global_basis_u_2, global_basis_u_3], [global_basis_v_1, global_basis_v_2, global_basis_v_3], + [max_block_trial_x1_derivative, max_block_trial_x2_derivative, max_block_trial_x3_derivative], [max_block_test_x1_derivative, max_block_test_x2_derivative, max_block_test_x3_derivative], + [test_trial_1, test_trial_2, test_trial_3]): + + args = tuple(np.int64(a) if isinstance(a, int) else a for a in args) + + test_trial_func(*args) + else: + for k_1 in range(n_element_1): + for q_1 in range(k1): + for i_1 in range(test_v_p1 + 1): + for j_1 in range(trial_u_p1 + 1): + trial = global_basis_u_1[k_1, j_1, :, q_1] + test = global_basis_v_1[k_1, i_1, :, q_1] + for alpha_1 in range(max_block_trial_x1_derivative+1): + for beta_1 in range(max_block_test_x1_derivative+1): + test_trial_1[k_1, q_1, i_1, j_1, alpha_1, beta_1] = trial[alpha_1] * test[beta_1] + + for k_2 in range(n_element_2): + for q_2 in range(k2): + for i_2 in range(test_v_p2 + 1): + for j_2 in range(trial_u_p2 + 1): + trial = global_basis_u_2[k_2, j_2, :, q_2] + test = global_basis_v_2[k_2, i_2, :, q_2] + for alpha_2 in range(max_block_trial_x2_derivative+1): + for beta_2 in range(max_block_test_x2_derivative+1): + test_trial_2[k_2, q_2, i_2, j_2, alpha_2, beta_2] = trial[alpha_2] * test[beta_2] + + for k_3 in range(n_element_3): + for q_3 in range(k3): + for i_3 in range(test_v_p3 + 1): + for j_3 in range(trial_u_p3 + 1): + trial = global_basis_u_3[k_3, j_3, :, q_3] + test = global_basis_v_3[k_3, i_3, :, q_3] + for alpha_3 in range(max_block_trial_x3_derivative+1): + for beta_3 in range(max_block_test_x3_derivative+1): + test_trial_3[k_3, q_3, i_3, j_3, alpha_3, beta_3] = trial[alpha_3] * test[beta_3] + + test_trial_1s[block] = test_trial_1 + test_trial_2s[block] = test_trial_2 + test_trial_3s[block] = test_trial_3 + + # Instead of having a different a3, a2 & coupling term array for each sub-expression, we choose to have only one + # such array per block. + # a3 will store line integral values for all combinations of test and trial functions in x3 direction, hence the dimension + # (n_element_3 + test_v_p3 + (mult[2]-1)*(n_element_3-1), 2 * max_p_3 + 1) + # a2 will store surface integral values for all combinations of test and trial functions in x2 and x3 direction, hence the dimension ... + # coupling_terms stores point values of the coupling terms at all quadrature points + # but only in x2 and x3 direction, because we only "precompute" this array for a fixed quadrature point in x1 direction + + # a3[block] size explained: #sub expressions ; #test functions depending on x3 ; #complicated expression for the minimum columns needed + # to store local information correctly. 2*degree+1 in the simplest case. + n_funs_x2 = n_element_2 + test_v_p2 + (test_mult[1]-1)*(n_element_2-1) + n_funs_x3 = n_element_3 + test_v_p3 + (test_mult[2]-1)*(n_element_3-1) + n_cols_x2 = max( int(max_p_2 + 1 + np.floor(max_p_2 / test_mult[1]) * trial_mult[1]), 2*max_p_2+1 ) + n_cols_x3 = max( int(max_p_3 + 1 + np.floor(max_p_3 / test_mult[2]) * trial_mult[2]), 2*max_p_3+1 ) + + a3[block] = np.zeros((n_expr, n_funs_x3, n_cols_x3), dtype='float64') + a2[block] = np.zeros((n_expr, n_funs_x2, n_funs_x3, n_cols_x2, n_cols_x3), dtype='float64') + + coupling_terms[block] = np.zeros((n_element_2, k2, n_element_3, k3, n_expr), dtype='float64') + + # We gather the socalled new args - all other args are being obtained in a similar way using the old assembly implementation + new_args = (*list(test_trial_1s.values()), + *list(test_trial_2s.values()), + *list(test_trial_3s.values()), + *list(a3.values()), + *list(a2.values()), + *list(coupling_terms.values())) + + # This part is a bit shady. + # There has been a case, where my code wasn't running, because one instance of deep-(Psydac/Sympde/Sympy)-code + # correctly understood that a possibly complicated expression (corresponding to a block) in fact evaluates to 0, + # and hence no StencilMatrix for that particular block ever needs to be created - but a different part of + # deep-(Psydac/Sympde/Sympy)-code did not get that simplification right (yet?), and decided that the assembly code + # needs a StencilMatrix as input for this particular block. + # See readBilinearForm for additional information. + # This part of the code filters out unnecessary StencilMatrices, such that only the relevant StencilMatrices + # are being passed to the assembly function + expr = self.kernel_expr.expr + if isinstance(expr, (ImmutableDenseMatrix, Matrix)): + matrices = [] + for i, block in enumerate(g_mat_information_false): + if block in g_mat_information_true: + matrices.append(self._global_matrices[i]) + else: + matrices = self._global_matrices + + # We have gathered all args! + args = (*map_basis, *spans, *map_span, *quads, *map_degree, *n_elements, *quad_degrees, *pads, *mapping, *matrices, + *new_args) + + threads_args = () + + args = tuple(np.int64(a) if isinstance(a, int) else a for a in args) + threads_args = tuple(np.int64(a) if isinstance(a, int) else a for a in threads_args) + + #---------- We now generate the assembly file ---------- + + # file_id is a random string that has been used to name the assembly file + file_id = self.make_file(temps, ordered_stmts, field_derivatives, max_logical_derivative, test_mult, trial_mult, test_v_p, trial_u_p, keys_1, keys_2, keys_3, mapping_option) + + # Store the current directory and add it to the variable `sys.path` + # to imitate Python's import behavior + import os + base_dirpath = os.getcwd() + sys.path.insert(0, base_dirpath) + + # Import the generated assembly function + package = importlib.import_module(f'__psydac__.assemble_{file_id}') + + # The assembly function is the one that has been generated in the make_file method + assembly_function_name = f'assemble_matrix_{file_id}' + assembly_function = getattr(package, assembly_function_name) + + # If the backend is pyccel, we compile the new assembly function + assembly_backend = self.backend + if assembly_backend['name'] == 'pyccel': + kwargs = { + 'language' : 'fortran', # hardcoded for now + 'compiler_family' : assembly_backend['compiler_family'], + 'flags' : assembly_backend['flags'], + 'openmp' : True if assembly_backend['openmp'] else False, + 'verbose' : False, + # 'folder': assembly_backend['folder'], + 'comm' : self.comm, + # 'time_execution': verbose, + # 'verbose': verbose + } + new_func = epyccel(assembly_function, **kwargs) + else: + new_func = assembly_function + + # Use the new assembly function (either compiled or not) + self._func = new_func + + return args, threads_args + + #-------------------------------------------------------------------------- + @property + def test_trial_template(self): + code = '''def test_trial_array(n_element : "int64", + quad_degree : "int64", test_degree : "int64", trial_degree : "int64", + trial_basis : "float64[:,:,:,:]", test_basis : "float64[:,:,:,:]", + max_trial_derivative : "int64", max_test_derivative : "int64", + test_trial : "float64[:,:,:,:,:,:]"): + + for k in range(n_element): + for q in range(quad_degree): + for i in range(test_degree + 1): + for j in range(trial_degree + 1): + trial = trial_basis[k, j, :, q] + test = test_basis [k, i, :, q] + for alpha in range(max_trial_derivative + 1): + for beta in range(max_test_derivative + 1): + test_trial[k, q, i, j, alpha, beta] = trial[alpha] * test[beta] + + return +''' + return code diff --git a/psydac/api/fem_common.py b/psydac/api/fem_common.py new file mode 100644 index 000000000..9c6c0bd6a --- /dev/null +++ b/psydac/api/fem_common.py @@ -0,0 +1,286 @@ +from typing import Iterable + +from sympy import Expr, ImmutableDenseMatrix, Matrix + +import numpy as np + +from sympde.expr.basic import BasicForm +from sympde.expr.evaluation import KernelExpression +from sympde.topology.space import ScalarFunction, VectorFunction, IndexedVectorFunction +from sympde.topology.space import ProductSpace, VectorFunctionSpace +from sympde.topology.datatype import H1SpaceType, L2SpaceType, UndefinedSpaceType +from sympde.topology.derivatives import get_atom_logical_derivatives +from sympde.topology.derivatives import _logical_partial_derivatives + +from psydac.fem.basic import FemSpace +from psydac.linalg.stencil import StencilMatrix, StencilInterfaceMatrix +from psydac.linalg.basic import ComposedLinearOperator +from psydac.api.utilities import flatten +from psydac.api.ast.utilities import math_atoms_as_str, get_max_partial_derivatives + +# TODO [YG 01.08.2025]: Avoid importing anything from psydac.pyccel +from psydac.pyccel.ast.core import _atomic + +__all__ = ( + 'compute_max_nderiv', + 'compute_imports', + 'compute_free_arguments', + 'collect_spaces', + 'compute_diag_len', + 'construct_test_space_arguments', + 'construct_trial_space_arguments', + 'construct_quad_grids_arguments', + 'do_nothing', + 'extract_stencil_mats', + 'reset_arrays', +) + +#============================================================================== +def compute_max_nderiv(kernel_expr: KernelExpression) -> int: + """ + Compute the highest derivative order in the given kernel expression. + + Parameters + ---------- + kernel_expr : KernelExpression (from sympde.expr.evaluation) + + Returns + ------- + nderiv : int + The highest order of derivation in `terminal_expr`. + + """ + assert isinstance(kernel_expr, KernelExpression) + + terminal_expr = kernel_expr.expr + if not isinstance(terminal_expr, (ImmutableDenseMatrix, Matrix)): + terminal_expr = ImmutableDenseMatrix([[terminal_expr]]) + n_rows, n_cols = terminal_expr.shape + + atoms_types = (ScalarFunction, VectorFunction, IndexedVectorFunction) + extended_atoms_types = atoms_types + _logical_partial_derivatives + + nderiv = 0 + for i_row in range(n_rows): + for i_col in range(n_cols): + texpr = terminal_expr[i_row, i_col] + atoms = _atomic(texpr, cls=extended_atoms_types) + Fs = [get_atom_logical_derivatives(a) for a in atoms] + d = get_max_partial_derivatives(texpr, logical=True, F=Fs) + nderiv = max(nderiv, max(d.values())) + + return nderiv + +#============================================================================== +def compute_imports(expr: Expr, + spaces: Iterable[FemSpace], + *, + openmp: bool + ) -> dict[str, list[str]]: + """ + Compute all the imports to be added to the generated Python code. + + Parameters + ---------- + expr : sympy.Expr + The integrand expression of a BilinearForm, LinearForm, or + Functional. This is a pure SymPy expression where SymPDE partial + derivatives have been converted to SymPy symbols. See Notes. + + spaces : iterable of psydac.fem.FemSpace + The discrete spaces which define the finite element representation + of a BilinearForm, LinearForm, or Functional. + + openmp : bool + Whether or not OpenMP pragmas and functions are used in the code. + + Returns + ------- + imports : dict[str, list[str]] + A dictionary whose keys are the names of the Python modules to be + imported, and whose values are the names of the corresponding + objects (variables, functions, classes) to be imported from the + modules. + + Notes + ----- + Assume that we start from an object of type BilinearForm, LinearForm, + or Functional. We take the integrand and expand its vector operations + with TerminalExpr(), then pull back from physical to logical + coordinates with LogicalExpr(), and finally convert the symbolic + partial derivatives with SymbolicExpr(). Where: + - TerminalExpr is defined in sympde.expr.evaluation + - LogicalExpr is defined in sympde.topology.mapping + - SymbolicExpr is defined in sympde.topology.mapping + + The resulting expression `expr` can be passed to this function. + """ + assert isinstance(expr, Expr) + assert all(isinstance(V, FemSpace) for V in spaces) + assert isinstance(openmp, bool) + + # Determine the type of scalar quantities to be managed in the code + dtypes = [getattr(V.symbolic_space, 'codomain_type', 'real') for V in spaces] + assert all(t in ['complex', 'real'] for t in dtypes) + dtype = 'complex' if 'complex' in dtypes else 'real' + + # TODO uncomment this line when we have a SesquilinearForm defined in SymPDE + #assert isinstance(expr, SesquilinearForm) + + #... Compute the imports + math_library = 'cmath' if dtype=='complex' else 'math' # Function names are the same + math_imports = math_atoms_as_str(expr, 'math') + numpy_imports = ['array', 'zeros', 'zeros_like', 'floor'] + + imports = {'numpy': numpy_imports} + if math_imports: + imports[math_library] = math_imports + if openmp: + imports['pyccel.stdlib.internal.openmp'] = ['omp_get_thread_num'] + #... + + return imports + +#============================================================================== +def compute_free_arguments(expr: BasicForm, kernel_expr: KernelExpression) -> tuple[str]: + """ + The string representation (i.e. the names) of the free arguments in + the given BilinearForm, LinearForm, or Functional. + + Parameters + ---------- + expr : BilinearForm | LinearForm | Functional + The expression of which we want to compute the free arguments. + + kernel_expr : sympde.expr.evaluation.KernelExpression + The atomic representation of the form, which is obtained after using + LogicalExpr (if there is a mapping) and TerminalExpr on `expr`. + + Returns + ------- + tuple[str] + The string representation (i.e. the names) of the free arguments in + the given BilinearForm, LinearForm, or Functional. + + """ + assert isinstance(expr, BasicForm) + assert isinstance(kernel_expr, KernelExpression) + + free_args_dict = expr.get_free_variables() + free_args_str = tuple(str(a) for a in free_args_dict) + + return free_args_str + +#============================================================================== +def collect_spaces(space, *args): + """ + This function collect the arguments used in the assembly function + + Parameters + ---------- + space: + the symbolic space + + args : + list of discrete space components like basis values, spans, ... + + Returns + ------- + args : + list of discrete space components elements used in the asembly + + """ + + if isinstance(space, ProductSpace): + spaces = space.spaces + indices = [] + i = 0 + for space in spaces: + if isinstance(space, VectorFunctionSpace): + if isinstance(space.kind, (H1SpaceType, L2SpaceType, UndefinedSpaceType)): + indices.append(i) + else: + indices += [i+j for j in range(space.ldim)] + i = i + space.ldim + else: + indices.append(i) + i = i + 1 + args = [[e[i] for i in indices] for e in args] + + elif isinstance(space, VectorFunctionSpace): + if isinstance(space.kind, (H1SpaceType, L2SpaceType, UndefinedSpaceType)): + args = [[e[0]] for e in args] + + return args + +#============================================================================== +def compute_diag_len(p, md, mc): + n = ((np.ceil((p+1)/mc)-1)*md).astype('int') + n = n-np.minimum(0, n-p)+p+1 + return n.astype('int') + +#============================================================================== +def construct_test_space_arguments(basis_values): + space = basis_values.space + test_basis = basis_values.basis + spans = basis_values.spans + test_degrees = space.degree + pads = space.pads + multiplicity = space.multiplicity + + test_basis, test_degrees, spans = collect_spaces(space.symbolic_space, test_basis, test_degrees, spans) + + test_basis = flatten(test_basis) + test_degrees = flatten(test_degrees) + spans = flatten(spans) + pads = flatten(pads) + multiplicity = flatten(multiplicity) + pads = [p*m for p,m in zip(pads, multiplicity)] + return test_basis, test_degrees, spans, pads, multiplicity + +def construct_trial_space_arguments(basis_values): + space = basis_values.space + trial_basis = basis_values.basis + trial_degrees = space.degree + pads = space.pads + multiplicity = space.multiplicity + trial_basis, trial_degrees = collect_spaces(space.symbolic_space, trial_basis, trial_degrees) + + trial_basis = flatten(trial_basis) + trial_degrees = flatten(trial_degrees) + pads = flatten(pads) + multiplicity = flatten(multiplicity) + pads = [p*m for p,m in zip(pads, multiplicity)] + return trial_basis, trial_degrees, pads, multiplicity + +#============================================================================== +def construct_quad_grids_arguments(grid, use_weights=True): + points = grid.points + if use_weights: + weights = grid.weights + quads = flatten(list(zip(points, weights))) + else: + quads = flatten(list(zip(points))) + + nquads = flatten(grid.nquads) + n_elements = grid.n_elements + return n_elements, quads, nquads + +#============================================================================== +def do_nothing(*args): + return 0 + +#============================================================================== +def extract_stencil_mats(mats): + new_mats = [] + for M in mats: + if isinstance(M, (StencilInterfaceMatrix, StencilMatrix)): + new_mats.append(M) + elif isinstance(M, ComposedLinearOperator): + new_mats += [i for i in M.multiplicants if isinstance(i, (StencilInterfaceMatrix, StencilMatrix))] + return new_mats + +#============================================================================== +def reset_arrays(*args): + for a in args: + a[:]= 0.j if a.dtype==complex else 0. diff --git a/psydac/api/fem_sum_form.py b/psydac/api/fem_sum_form.py new file mode 100644 index 000000000..7d387b733 --- /dev/null +++ b/psydac/api/fem_sum_form.py @@ -0,0 +1,123 @@ +import numpy as np + +from sympde.expr.expr import ( + BilinearForm as sym_BilinearForm, + LinearForm as sym_LinearForm, + Functional as sym_Functional +) + +from .basic import BasicDiscrete +from .fem import DiscreteFunctional +from .fem import DiscreteLinearForm +from .fem import DiscreteBilinearForm +from .fem_bilinear_form import DiscreteBilinearForm as DiscreteBilinearForm_SF +from .fem_common import reset_arrays +from .utilities import random_string + +__all__ = ('DiscreteSumForm',) + +#============================================================================== +class DiscreteSumForm(BasicDiscrete): + + def __init__(self, a, kernel_expr, *args, **kwargs): + + # Sum factorization is only implemented for bilinear forms in 3D, in + # which case we use it by default. A 2D implementation should be the + # next step, hence we allow the user to pass `sum_factorization=True` + # even if not supported yet. In the case of linear forms or functionals + # this option is irrelevant for now, so we ignore it. + # + # In every case we remove the `sum_factorization` key from the dict + # in order to avoid errors, because none of the class constructors + # accept this argument. + sum_factorization = kwargs.pop('sum_factorization', a.ldim == 3) + + # TODO Uncomment when the SesquilinearForm exist in SymPDE + #if not isinstance(a, (sym_BilinearForm, sym_SesquilinearForm, sym_LinearForm, sym_Functional)): + # raise TypeError('> Expecting a symbolic BilinearForm, SesquilinearForm, LinearForm, Functional') + if not isinstance(a, (sym_BilinearForm, sym_LinearForm, sym_Functional)): + raise TypeError('> Expecting a symbolic BilinearForm, LinearForm, Functional') + + self._expr = a + backend = kwargs.pop('backend', None) + self._backend = backend + + folder = kwargs.get('folder', None) + self._folder = self._initialize_folder(folder) + + # create a module name if not given + tag = random_string(8) + + # ... + forms = [] + free_args = [] + self._kernel_expr = kernel_expr + operator = None + for e in kernel_expr: + if isinstance(a, sym_LinearForm): + kwargs['update_ghost_regions'] = False + ah = DiscreteLinearForm(a, e, *args, backend=backend, **kwargs) + kwargs['vector'] = ah._vector + operator = ah._vector + + # TODO Uncomment when the SesquilinearForm exist in SymPDE + # elif isinstance(a, sym_SesquilinearForm): + # kwargs['update_ghost_regions'] = False + # ah = DiscreteSesquilinearForm(a, e, *args, assembly_backend=backend, **kwargs) + # kwargs['matrix'] = ah._matrix + # operator = ah._matrix + + elif isinstance(a, sym_BilinearForm): + kwargs['update_ghost_regions'] = False + if sum_factorization: + ah = DiscreteBilinearForm_SF(a, e, *args, assembly_backend=backend, **kwargs) + else: + ah = DiscreteBilinearForm(a, e, *args, assembly_backend=backend, **kwargs) + kwargs['matrix'] = ah._matrix + operator = ah._matrix + + elif isinstance(a, sym_Functional): + ah = DiscreteFunctional(a, e, *args, backend=backend, **kwargs) + + forms.append(ah) + free_args.extend(ah.free_args) + + if isinstance(a, sym_BilinearForm): + is_broken = len(args[0].domain)>1 + if self._backend is not None and is_broken: + for mat in kwargs['matrix']._blocks.values(): + mat.set_backend(backend) + elif self._backend is not None: + kwargs['matrix'].set_backend(backend) + + self._forms = forms + self._operator = operator + self._free_args = tuple(set(free_args)) + self._is_functional = isinstance(a, sym_Functional) + # ... + + @property + def forms(self): + return self._forms + + @property + def free_args(self): + return self._free_args + + @property + def is_functional(self): + return self._is_functional + + def assemble(self, *, reset=True, **kwargs): + if not self.is_functional: + if reset : + reset_arrays(*[i for M in self.forms for i in M.global_matrices]) + + for form in self.forms: + form.assemble(reset=False, **kwargs) + self._operator.exchange_assembly_data() + return self._operator + else: + M = [form.assemble(**kwargs) for form in self.forms] + M = np.sum(M) + return M diff --git a/psydac/api/tests/test_api_feec_3d.py b/psydac/api/tests/test_api_feec_3d.py index d5220b5bc..9da51f784 100644 --- a/psydac/api/tests/test_api_feec_3d.py +++ b/psydac/api/tests/test_api_feec_3d.py @@ -19,7 +19,7 @@ from psydac.fem.basic import FemField from psydac.api.discretization import discretize from psydac.feec.pull_push import push_3d_hcurl, push_3d_hdiv -from psydac.api.settings import PSYDAC_BACKEND_GPYCCEL +from psydac.api.settings import PSYDAC_BACKENDS from psydac.linalg.utilities import array_to_psydac from psydac.linalg.solvers import inverse @@ -112,22 +112,31 @@ def run_maxwell_3d_scipy(logical_domain, mapping, e_ex, b_ex, ncells, degree, pe # Discrete objects: Psydac #------------------------------------------------------------------------------ + # Select backend for acceleration of the generated assembly code + backend = PSYDAC_BACKENDS['pyccel-gcc'] + + # Select multiplicity of internal knots along each direction + multiplicity = [mult, mult, mult] + + # Create Geometry & DiscreteDerham objects domain_h = discretize(domain, ncells=ncells, periodic=periodic, comm=MPI.COMM_WORLD) - derham_h = discretize(derham, domain_h, degree=degree, multiplicity = [mult,mult,mult]) + derham_h = discretize(derham, domain_h, degree=degree, multiplicity=multiplicity) - a1_h = discretize(a1, domain_h, (derham_h.V1, derham_h.V1), backend=PSYDAC_BACKEND_GPYCCEL) - a2_h = discretize(a2, domain_h, (derham_h.V2, derham_h.V2), backend=PSYDAC_BACKEND_GPYCCEL) + # Create DiscreteBilinearForm objects. Assembly code is generated here + a1_h = discretize(a1, domain_h, (derham_h.V1, derham_h.V1), backend=backend) + a2_h = discretize(a2, domain_h, (derham_h.V2, derham_h.V2), backend=backend) - # StencilMatrix objects + # Assemble matrices as StencilMatrix objects, then convert them to SciPy's CSC/CSR formats M1 = a1_h.assemble().tosparse().tocsc() M2 = a2_h.assemble().tosparse().tocsr() - # Diff operators + # Get differential operators as BlockLinearOperator objects GRAD, CURL, DIV = derham_h.derivatives_as_matrices - # Porjectors - P0, P1, P2, P3 = derham_h.projectors(nquads=[5,5,5]) + # Get projectors as objects of type Projector_H1, Projector_Hcurl, Projector_Hdiv, Projector_L2 + P0, P1, P2, P3 = derham_h.projectors(nquads=[5, 5, 5]) + # Convert the CURL BlockLinearOperator to SciPy's CSR format CURL = CURL.transform(lambda block: block.tokronstencil().tostencil()).tosparse().tocsr() # initial conditions @@ -145,7 +154,6 @@ def run_maxwell_3d_scipy(logical_domain, mapping, e_ex, b_ex, ncells, degree, pe # project initial conditions e0_coeff = P1(e0).coeffs - b0_coeff = P2(b0).coeffs # time integrator @@ -207,21 +215,29 @@ def run_maxwell_3d_stencil(logical_domain, mapping, e_ex, b_ex, ncells, degree, # Discrete objects: Psydac #------------------------------------------------------------------------------ + # Select backend for acceleration of the generated assembly code + backend = PSYDAC_BACKENDS['pyccel-gcc'] + + # Select multiplicity of internal knots along each direction + multiplicity = [mult, mult, mult] + + # Create Geometry & DiscreteDerham objects domain_h = discretize(domain, ncells=ncells, periodic=periodic, comm=MPI.COMM_WORLD) - derham_h = discretize(derham, domain_h, degree=degree, multiplicity = [mult,mult,mult]) + derham_h = discretize(derham, domain_h, degree=degree, multiplicity=multiplicity) - a1_h = discretize(a1, domain_h, (derham_h.V1, derham_h.V1), backend=PSYDAC_BACKEND_GPYCCEL) - a2_h = discretize(a2, domain_h, (derham_h.V2, derham_h.V2), backend=PSYDAC_BACKEND_GPYCCEL) + # Create DiscreteBilinearForm objects. Assembly code is generated here + a1_h = discretize(a1, domain_h, (derham_h.V1, derham_h.V1), backend=backend) + a2_h = discretize(a2, domain_h, (derham_h.V2, derham_h.V2), backend=backend) - # StencilMatrix objects + # Assemble matrices as StencilMatrix objects M1 = a1_h.assemble() M2 = a2_h.assemble() - # Diff operators + # Get differential operators as BlockLinearOperator objects GRAD, CURL, DIV = derham_h.derivatives_as_matrices - # Porjectors - P0, P1, P2, P3 = derham_h.projectors(nquads=[5,5,5]) + # Get projectors as objects of type Projector_H1, Projector_Hcurl, Projector_Hdiv, Projector_L2 + P0, P1, P2, P3 = derham_h.projectors(nquads=[5, 5, 5]) # initial conditions e0_1 = lambda x, y, z: e_ex[0](0, x, y, z) @@ -238,7 +254,6 @@ def run_maxwell_3d_stencil(logical_domain, mapping, e_ex, b_ex, ncells, degree, # project initial conditions e0_coeff = P1(e0).coeffs - b0_coeff = P2(b0).coeffs # time integrator diff --git a/psydac/api/tests/test_sum_factorization_assembly_3d.py b/psydac/api/tests/test_sum_factorization_assembly_3d.py new file mode 100644 index 000000000..7aab017a3 --- /dev/null +++ b/psydac/api/tests/test_sum_factorization_assembly_3d.py @@ -0,0 +1,592 @@ +import os +import pytest +import time +import numpy as np + +from sympy import sin, sqrt, pi, Abs, cos, tan + +from sympde.calculus import dot, cross, grad, curl, div, laplace +from sympde.expr import BilinearForm, integral +from sympde.topology import element_of, elements_of, Cube, Mapping, ScalarFunctionSpace, VectorFunctionSpace, Domain, Derham + +from psydac.api.discretization import discretize +from psydac.api.settings import PSYDAC_BACKEND_GPYCCEL +from psydac.linalg.block import BlockVectorSpace +from psydac.fem.basic import FemField + +try: + mesh_dir = os.environ['PSYDAC_MESH_DIR'] + +except: + base_dir = os.path.dirname(os.path.realpath(__file__)) + base_dir = os.path.join(base_dir, '..', '..', '..') + mesh_dir = os.path.join(base_dir, 'mesh') + + +# With PR #448, matrices corresponding to bilinear forms on 3D domains are being assembled using a so called sum factorization algorithm. +# Unless explicitely using the old algorithm, this happens automatically, and hence all old tests passing should indicate that the implementation of the sum factorization algorithm has been successful. +# Nonetheless, there are various difficulties in the implementation, and possibly not all of them are accounted for in the existing tests. + +# This file is designed to test such "difficult" edge cases - for mapped (Bspline & analytical) and parametric domains: + +# Such "difficult" edge cases are: + +# 1. bilinear forms on different spaces +# 2. (FemField / analytical / ...) weight functions +# 3. high derivatives (>=2) +# 4. (multiple) free FemFields +# 5. complicated expressions +# 6. uncommen (numpy) functions that need be imported correctly in the assembly file (here in the weight function) + +# These tests also return old and new discretization and assembly times. + +# Most of the time, being close to the "old matrix" (generated using the old assembly algorithm) will be the requirement to pass a test, +# as the old implementation has not caused problems in a long time and is considered to function properly. + +# Update: Instead of testing all mapping options all of the time, we now rather randomly test one of the three options! +#@pytest.mark.parametrize('mapping', ('None', 'Analytical', 'Bspline')) +def test_assembly(): # mapping): + + rng = np.random.default_rng() # (seed=42) + mapping_options = ['None', 'Analytical', 'Bspline'] + mapping = mapping_options[int(np.floor(rng.random()*3))] + + ncells = [7, 5, 6] + degree = [2, 4, 3] + periodic = [int(np.floor(rng.random()*2))==True for _ in range(3)] + print(f'Random periodicity: {periodic}') + + trial_multiplicity = [1, 3, 2] + test_multiplicity = [2, 2, 3] + + backend = PSYDAC_BACKEND_GPYCCEL + + if mapping == 'None': + + domain = Cube('C', bounds1=(0,1), bounds2=(0,1), bounds3=(0,1)) + derham = Derham(domain) + + domain_h = discretize(domain, ncells=ncells, periodic=periodic) + derham_h = discretize(derham, domain_h, degree=degree, multiplicity=trial_multiplicity) + derham_test_h = discretize(derham, domain_h, degree=degree, multiplicity=test_multiplicity) + + elif mapping == 'Bspline': + + filename = os.path.join(mesh_dir, 'identity_3d.h5') + + domain = Domain.from_file(filename=filename) + derham = Derham(domain) + + domain_h = discretize(domain, filename=filename) + derham_h = discretize(derham, domain_h, degree=domain.mapping.get_callable_mapping().space.degree, multiplicity=trial_multiplicity) + derham_test_h = discretize(derham, domain_h, degree=domain.mapping.get_callable_mapping().space.degree, multiplicity=test_multiplicity) + + elif mapping == 'Analytical': + + class HalfSquareTorusMapping3D(Mapping): + _expressions = {'x': 'x1 * cos(x2)', + 'y': 'x1 * sin(x2)', + 'z': 'x3'} + + _ldim = 3 + _pdim = 3 + + M = HalfSquareTorusMapping3D('M') + logical_domain = Cube('C', bounds1=(0.3,1), bounds2=(0,np.pi), bounds3=(0,1)) + + domain = M(logical_domain) + derham = Derham(domain) + + domain_h = discretize(domain, ncells=ncells, periodic=periodic) + derham_h = discretize(derham, domain_h, degree=degree, multiplicity=trial_multiplicity) + derham_test_h = discretize(derham, domain_h, degree=degree, multiplicity=test_multiplicity) + + x, y, z = domain.coordinates + weight = 1 + sqrt(Abs(x*y**2 + z)) + abs(sin(x-2*y + pi + np.pi)) + + V0 = derham.V0 + V0h = derham_h.V0 + V1 = derham.V1 + V1h = derham_h.V1 + V2 = derham.V2 + V2h = derham_h.V2 + V3 = derham.V3 + V3h = derham_h.V3 + + V0h_test = derham_test_h.V0 + V1h_test = derham_test_h.V1 + V2h_test = derham_test_h.V2 + V3h_test = derham_test_h.V3 + + Vs = ScalarFunctionSpace('Vsh', domain) + Vsh = discretize(Vs, domain_h, degree=degree) + Vvc = VectorFunctionSpace('Vvc', domain, kind='hcurl') + Vvch = discretize(Vvc, domain_h, degree=degree) + Vvd = VectorFunctionSpace('Vvd', domain, kind='hdiv') + Vvdh = discretize(Vvd, domain_h, degree=degree) + + Vsh_test = discretize(Vs, domain_h, degree=degree, multiplicity=test_multiplicity) + Vvch_test = discretize(Vs, domain_h, degree=degree, multiplicity=test_multiplicity) + Vvdh_test = discretize(Vs, domain_h, degree=degree, multiplicity=test_multiplicity) + + u1, u2, F01, F02, F03 = elements_of(V0, names='u1, u2, F01, F02, F03') + v1, v2, F11, F12, F13 = elements_of(V1, names='v1, v2, F11, F12, F13') + w1, w2, F21, F22, F23 = elements_of(V2, names='w1, w2, F21, F22, F23') + f1, f2, F31, F32, F33 = elements_of(V3, names='f1, f2, F31, F32, F33') + + fs1, fs2, Fs1, Fs2, Fs3 = elements_of(Vs, names='fs1, fs2, Fs1, Fs2, Fs3') + fvc1, fvc2, Fvc1, Fvc2, Fvc3 = elements_of(Vvc, names='fvc1, fvc2, Fvc1, Fvc2, Fvc3') + fvd1, fvd2, Fvd1, Fvd2, Fvd3 = elements_of(Vvd, names='fvd1, fvd2, Fvd1, Fvd2, Fvd3') + + trial_spaces = {'V0': {'Vh':V0h, 'funcs':[u1, u2]}, + 'V1': {'Vh':V1h, 'funcs':[v1, v2]}, + 'V2': {'Vh':V2h, 'funcs':[w1, w2]}, + 'V3': {'Vh':V3h, 'funcs':[f1, f2]}, + 'Vs': {'Vh':Vsh, 'funcs':[fs1, fs2]}, + 'Vvc':{'Vh':Vvch, 'funcs':[fvc1, fvc2]}, + 'Vvd':{'Vh':Vvdh, 'funcs':[fvd1, fvd2]}} + + test_spaces = {'V0': {'Vh':V0h_test, 'funcs':[u1, u2]}, + 'V1': {'Vh':V1h_test, 'funcs':[v1, v2]}, + 'V2': {'Vh':V2h_test, 'funcs':[w1, w2]}, + 'V3': {'Vh':V3h_test, 'funcs':[f1, f2]}, + 'Vs': {'Vh':Vsh_test, 'funcs':[fs1, fs2]}, + 'Vvc':{'Vh':Vvch_test, 'funcs':[fvc1, fvc2]}, + 'Vvd':{'Vh':Vvdh_test, 'funcs':[fvd1, fvd2]}} + + F01_coeffs = V0h.coeff_space.zeros() + rng.random(size=F01_coeffs._data.shape, dtype='float64', out=F01_coeffs._data) + F11_coeffs = V1h.coeff_space.zeros() + for block in F11_coeffs.blocks: + rng.random(size=block._data.shape, dtype='float64', out=block._data) + F12_coeffs = V1h.coeff_space.zeros() + for block in F12_coeffs.blocks: + rng.random(size=block._data.shape, dtype='float64', out=block._data) + F21_coeffs = V2h.coeff_space.zeros() + for block in F21_coeffs.blocks: + rng.random(size=block._data.shape, dtype='float64', out=block._data) + Fvc1_coeffs = Vvch.coeff_space.zeros() + for block in Fvc1_coeffs.blocks: + rng.random(size=block._data.shape, dtype='float64', out=block._data) + Fs1_coeffs = Vsh.coeff_space.zeros() + rng.random(size=Fs1_coeffs._data.shape, dtype='float64', out=Fs1_coeffs._data) + Fs2_coeffs = Vsh.coeff_space.zeros() + rng.random(size=Fs2_coeffs._data.shape, dtype='float64', out=Fs2_coeffs._data) + Fvd1_coeffs = Vvdh.coeff_space.zeros() + for block in Fvd1_coeffs.blocks: + rng.random(size=block._data.shape, dtype='float64', out=block._data) + + F01_field = FemField(V0h, F01_coeffs) + F11_field = FemField(V1h, F11_coeffs) + F12_field = FemField(V1h, F12_coeffs) + F21_field = FemField(V2h, F21_coeffs) + Fvc1_field = FemField(Vvch, Fvc1_coeffs) + Fs1_field = FemField(Vsh, Fs1_coeffs) + Fs2_field = FemField(Vsh, Fs2_coeffs) + Fvd1_field = FemField(Vvdh, Fvd1_coeffs) + + bilinear_forms = { # one and two free FemFields without derivatives (with derivatives in seperate test) + # complicated expressions + 'Q' :{'trial' :'V1', 'test':'V1', + 'expr' :dot(cross(F11, v1), cross(F11, v2)), + 'fields':[F11_field, ]}, + 'equilibrium' :{'trial' :'V1', 'test':'V1', + 'expr' :dot(cross(F11, v1), cross(v2, F12)), + 'fields':[F11_field, F12_field]}, + 'Elena' :{'trial' :'V1', 'test':'V1', + 'expr' :dot(F01*v1, v2), + 'fields':[F01_field, ]}, + # weight function, free FemField, different spaces + 'dot(grad(u),v)':{'trial' :'V0', 'test':'V1', + 'expr' :dot(grad(u1), v2)*F01*weight, + 'fields':[F01_field, ]}, + 'dot(curl(v),w)':{'trial' :'V1', 'test':'V2', + 'expr' :dot(curl(v1), F21)*div(w2)*weight, + 'fields':[F21_field, ]}, + # among other difficulties: multiple scalar FemFields + 'ScalarFields' :{'trial' :'V0', 'test':'V1', + 'expr' :dot(grad(u1), curl(Fvc1))*dot(grad(Fs1), curl(v2))*Fs2*div(Fvd1), + 'fields':[Fvc1_field, Fs1_field, Fs2_field, Fvd1_field]}, + # high derivatives, not FEEC + 'bilaplace' :{'trial' :'Vs', 'test':'Vs', + 'expr' :laplace(fs1)*laplace(fs2)} + } + + # test all BFs + # bilinear_form_strings_to_test = list(bilinear_forms.keys()) + + # or only a subset + bilinear_form_strings_to_test = list(bilinear_forms.keys())[:-1] # exclude expensive bilaplace test + + bilinear_forms_to_test = {} + for name in bilinear_form_strings_to_test: + bilinear_forms_to_test[name] = bilinear_forms[name] + + int_0 = lambda expr: integral(domain, expr) + print() + + for bf_name, bf_data in bilinear_forms_to_test.items(): + + trial_space = trial_spaces[bf_data['trial']] + Vh = trial_space['Vh'] + u = trial_space['funcs'][0] + test_space = test_spaces[bf_data['test']] + Wh = test_space ['Vh'] + v = test_space['funcs'][1] + expr = bf_data['expr'] + if 'fields' in bf_data.keys(): + fields = bf_data['fields'] + + a = BilinearForm((u, v), int_0(expr)) + + t0 = time.time() + ah_old = discretize(a, domain_h, (Vh, Wh), backend=backend, sum_factorization=False) + t1 = time.time() + discretization_time_old = t1 - t0 + + t0 = time.time() + ah = discretize(a, domain_h, (Vh, Wh), backend=backend) + t1 = time.time() + discretization_time = t1 - t0 + + if bf_name == 'Q': + t0_old = time.time() + A_old = ah_old.assemble(F11=fields[0]) + t1_old = time.time() + + t0 = time.time() + A = ah.assemble(F11=fields[0]) + t1 = time.time() + elif bf_name == 'equilibrium': + t0_old = time.time() + A_old = ah_old.assemble(F11=fields[0], F12=fields[1]) + t1_old = time.time() + + t0 = time.time() + A = ah.assemble(F11=fields[0], F12=fields[1]) + t1 = time.time() + elif bf_name in ('Elena', 'dot(grad(u),v)'): + t0_old = time.time() + A_old = ah_old.assemble(F01=fields[0]) + t1_old = time.time() + + t0 = time.time() + A = ah.assemble(F01=fields[0]) + t1 = time.time() + elif bf_name == 'dot(curl(v),w)': + t0_old = time.time() + A_old = ah_old.assemble(F21=fields[0]) + t1_old = time.time() + + t0 = time.time() + A = ah.assemble(F21=fields[0]) + t1 = time.time() + elif bf_name == 'ScalarFields': + t0_old = time.time() + A_old = ah_old.assemble(Fvc1=fields[0], Fs1=fields[1], Fs2=fields[2], Fvd1=fields[3]) + t1_old = time.time() + + t0 = time.time() + A = ah.assemble(Fvc1=fields[0], Fs1=fields[1], Fs2=fields[2], Fvd1=fields[3]) + t1 = time.time() + else: + t0_old = time.time() + A_old = ah_old.assemble() + t1_old = time.time() + + t0 = time.time() + A = ah.assemble() + t1 = time.time() + + assembly_time_old = t1_old - t0_old + assembly_time = t1 - t0 + + # Testing whether two linear operators are identical by comparing their arrays is quite expensive. + # Thus we instead test whether three random domain vectors applied to both + # the old and the new matrix produce the same codomain vector. + + domain_vector1 = Vh.coeff_space.zeros() + domain_vector2 = Vh.coeff_space.zeros() + domain_vector3 = Vh.coeff_space.zeros() + domain_vectors = [domain_vector1, domain_vector2, domain_vector3] + + if isinstance(Vh.coeff_space, BlockVectorSpace): + for domain_vector in domain_vectors: + for block in domain_vector.blocks: + rng.random(size=block._data.shape, dtype='float64', out=block._data) + else: + for domain_vector in domain_vectors: + rng.random(size=domain_vector._data.shape, dtype='float64', out=domain_vector._data) + + err = [] + rel_err = [] + + for domain_vector in domain_vectors: + codomain_vector = A @ domain_vector + codomain_vector_old = A_old @ domain_vector + + norm_old = np.sqrt(codomain_vector_old.inner(codomain_vector_old)) + + diff = codomain_vector - codomain_vector_old + + err.append(np.sqrt(diff.inner(diff))) + rel_err.append(err[-1] / norm_old) + + print(f' >>> Mapping: {mapping}') + print(f' >>> BilinearForm: {bf_name}') + print(f' >>> Discretization in: Old {discretization_time_old:.3g} \t\t || New {discretization_time:.3g} \t\t || Old/New {discretization_time_old/discretization_time:.3g}') + print(f' >>> Assembly in: Old {assembly_time_old:.3g} \t \t || New {assembly_time:.3g} \t\t || Old/New {assembly_time_old/assembly_time:.3g}') + print(f' >>> Error: {max(err):.3g}') + print(f' >>> Rel. Error: {max(rel_err):.3g}') + print() + + assert max(rel_err) < 1e-12 # arbitrary rel. error bound (How to test better?) + + +# fixed by PR #507 +#@pytest.mark.xfail +def test_allocate_matrix_bug(): + """ + This test is related to Issue #504. + + The bilinear form + (V0 x V3) ni (u, f) mapsto int_{Omega} u*f + should be the transpose of the bilinear form + (V3 x V0) ni (f, u) mapsto int_{Omega} u*f + but is not. + """ + + ncells = [15, 16, 17] + degree = [4, 3, 2] + periodic = [False, True, False] + + backend = PSYDAC_BACKEND_GPYCCEL + + domain = Cube('C', bounds1=(0,1), bounds2=(0,1), bounds3=(0,1)) + derham = Derham(domain) + + domain_h = discretize(domain, ncells=ncells, periodic=periodic) + derham_h = discretize(derham, domain_h, degree=degree) + + P0, _, _, P3 = derham_h.projectors() + + V0 = derham.V0 + V0h = derham_h.V0 + V3 = derham.V3 + V3h = derham_h.V3 + + u = element_of(V0, name='u') + f = element_of(V3, name='f') + + fun = lambda x, y, z : 1 + u_coeffs = P0(fun).coeffs + f_coeffs = P3(fun).coeffs + + a0 = BilinearForm((u, f), integral(domain, u*f)) + a1 = BilinearForm((f, u), integral(domain, u*f)) + + a0h = discretize(a0, domain_h, (V0h, V3h), backend=backend, sum_factorization=False) + a1h = discretize(a1, domain_h, (V3h, V0h), backend=backend, sum_factorization=False) + + A0 = a0h.assemble() + A1 = a1h.assemble() + A1T = A1.T + + # Clearly, it should hold A1T = A0, and further ||A0|| = ||A1||. + A0arr = A0.toarray() + A1arr = A1.toarray() + A1Tarr = A1T.toarray() + + diff1 = np.linalg.norm(A0arr - A1Tarr) + diff2 = np.linalg.norm(A0arr) - np.linalg.norm(A1arr) + + print(f' || A0 - A1.T || = {diff1:.3g}') + print(f' ||A0|| - ||A1|| = {diff2:.3g}') + + # Further, the following integral should evaluate to 1. + # This however is only the case for the second integral, + # independent on whether one uses the new or old assembly algorithm. + + print(f' 1 =? {A0.dot_inner(u_coeffs, f_coeffs)}') + print(f' 1 =? {A1.dot_inner(f_coeffs, u_coeffs)}') + + assert diff1 <= 1e-12 # arbitrary error bound + assert diff2 <= 1e-12 # arbitrary error bound + +#@pytest.mark.xfail +def test_free_FemField_derivatives(): + """ + These particular bilinear forms, when using a constant 1-vector coefficient vector for the free FemFields, + causes problems in a different test file of mine. + In particular, the assembled matrices used to have really small norms (~e-13). + That is probably due to the constant 1-vector corresponding to a constant function, + which means that all appearing derivatives of free FemFields are 0. + + When using meaningful free FemFields, these dubious observations disappeared. + + """ + + ncells = [5, 2, 4] + degree = [2, 1, 3] + periodic = [False, False, False] + + backend = PSYDAC_BACKEND_GPYCCEL + + domain = Cube('C', bounds1=(0,1), bounds2=(0,1), bounds3=(0,1)) + derham = Derham(domain) + + domain_h = discretize(domain, ncells=ncells, periodic=periodic) + derham_h = discretize(derham, domain_h, degree=degree) + + P0, P1, P2, P3 = derham_h.projectors() + + V0 = derham.V0 + V0h = derham_h.V0 + V1 = derham.V1 + V1h = derham_h.V1 + V2 = derham.V2 + V2h = derham_h.V2 + + u = element_of (V0, name= 'u') + v, F1 = elements_of(V1, names='v, F1') + w1, w2, F2 = elements_of(V2, names='w1, w2, F2') + + u_func = lambda x, y, z: x + y + z + u_coeffs = P0(u_func).coeffs + + v_1 = lambda x, y, z: 1 + v_2 = lambda x, y, z: 1 + v_3 = lambda x, y, z: 1 + v_func = (v_1, v_2, v_3) + v_coeffs = P1(v_func).coeffs + + w_1 = lambda x, y, z: x + w_2 = lambda x, y, z: y + w_3 = lambda x, y, z: z + w_func = (w_1, w_2, w_3) + w_coeffs = P2(w_func).coeffs + + dubious_observations = False + + if dubious_observations: + F1_coeffs = V1h.coeff_space.zeros() + F2_coeffs = V2h.coeff_space.zeros() + for block in F1_coeffs.blocks: + block._data = np.ones(block._data.shape, dtype='float64') + for block in F2_coeffs.blocks: + block._data = np.ones(block._data.shape, dtype='float64') + F1_FF = FemField(V1h, F1_coeffs) + F2_FF = FemField(V2h, F2_coeffs) + else: + F1_1 = lambda x, y, z: z + F1_2 = lambda x, y, z: x + F1_3 = lambda x, y, z: y + F1_func = (F1_1, F1_2, F1_3) + F1_FF = P1(F1_func) + + F2_FF = FemField(V2h, w_coeffs.copy()) + + # with the above choices (dubious_observation = False): + # a0 reduces to 3*int_{Omega}x+y+z with Omega being the unit square. Expected value: 4.5 + a0 = BilinearForm((u, v), integral(domain, dot(grad(u), curl(F1)) * dot(v, F1))) + # a1 reduces to 9* ----------------------------------------- " -------------------- 13.5 + a1 = BilinearForm((u, w2), integral(domain, dot(grad(u), F2)*div(w2)*div(F2))) + # a2 reduces to 3* ----------------------------------------- " --------------------- 4.5 + a2 = BilinearForm((w1, w2), integral(domain, dot(curl(F1), w1)*div(w2))) + + a0h_old = discretize(a0, domain_h, (V0h, V1h), backend=backend, sum_factorization=False) + a1h_old = discretize(a1, domain_h, (V0h, V2h), backend=backend, sum_factorization=False) + a2h_old = discretize(a2, domain_h, (V2h, V2h), backend=backend, sum_factorization=False) + + a0h = discretize(a0, domain_h, (V0h, V1h), backend=backend) + a1h = discretize(a1, domain_h, (V0h, V2h), backend=backend) + a2h = discretize(a2, domain_h, (V2h, V2h), backend=backend) + + bfs = [(a0h_old, a0h), (a1h_old, a1h), (a2h_old, a2h)] + print() + + for i, (ah_old, ah) in enumerate(bfs): + + if i in (0, 2): + A_old = ah_old.assemble(F1=F1_FF) + A = ah.assemble(F1=F1_FF) + else: + A_old = ah_old.assemble(F2=F2_FF) + A = ah.assemble(F2=F2_FF) + + if i == 0: + value_old = A_old.dot_inner(u_coeffs, v_coeffs) + value = A.dot_inner(u_coeffs, v_coeffs) + elif i == 1: + value_old = A_old.dot_inner(u_coeffs, w_coeffs) + value = A.dot_inner(u_coeffs, w_coeffs) + else: + value_old = A_old.dot_inner(w_coeffs, w_coeffs) + value = A.dot_inner(w_coeffs, w_coeffs) + + A_old_arr = A_old.toarray() + A_arr = A.toarray() + A_old_norm = np.linalg.norm(A_old_arr) + A_norm = np.linalg.norm(A_arr) + + err = np.linalg.norm(A_old_arr - A_arr) + rel_err = err / A_old_norm + + print(f' i = {i}') + print(f' >>> Error: {err:.3g}') + print(f' >>> Rel. Error: {rel_err:.3g}') + print(f' >>> Norms: ||A_old|| = {A_old_norm:.3g} \t\t ||A|| = {A_norm:.3g}') + print() + + # arbitrary tolerance + tol = 1e-12 + if not dubious_observations: + assert abs(value-value_old) < tol + assert rel_err < tol + +def test_assembly_free_FemFields(): + + backend = PSYDAC_BACKEND_GPYCCEL + + domain = Cube(bounds1=(2626,3179), bounds2=(-138, 138), bounds3=(-760.3, 69)) + derham = Derham(domain) + V0 = derham.V0 + V1 = derham.V1 + + u = element_of(V1, name='u') + v = element_of(V1, name='v') + p = element_of(V0, name='p') + + p_call = lambda xi,yi,zi: np.cos(2*np.pi*(xi-2626)/553) + np.tan(2*np.pi*(zi+760.3)/(5*829.3)) + x,y,z = domain.coordinates + p_sym = cos(2*np.pi*(x-2626)/553) + tan(2*np.pi*(z+760.3)/(5*829.3)) + + a_sym = BilinearForm((u, v), integral(domain, p_sym*dot(u, v))) + a_fem = BilinearForm((u, v), integral(domain, p*dot(u, v))) + + ncells = (11, 1, 17) + degree = (3, 1, 4) + domain_h = discretize(domain, ncells=ncells, periodic=(False, True, False)) + derham_h = discretize(derham, domain_h, degree=degree) + V1_h = derham_h.V1 + P0 = derham_h.projectors()[0] + + p_fem = P0(p_call) + + a_sym_h = discretize(a_sym, domain_h, (V1_h, V1_h), backend=backend) + a_fem_h = discretize(a_fem, domain_h, (V1_h, V1_h), backend=backend) + + A_sym = a_sym_h.assemble() + A_fem = a_fem_h.assemble(p=p_fem) + + A_sym_sp = A_sym.tosparse() + A_fem_sp = A_fem.tosparse() + diff = A_sym_sp - A_fem_sp + norm_sym = np.linalg.norm(A_sym_sp.data) + norm_fem = np.linalg.norm(A_fem_sp.data) + error = np.linalg.norm(diff.data) + rel_err = error / norm_sym + + #print(norm_sym, norm_fem, error, rel_err) + + assert rel_err < 1e-4 diff --git a/psydac/api/utilities.py b/psydac/api/utilities.py index c8080876b..108398e0f 100644 --- a/psydac/api/utilities.py +++ b/psydac/api/utilities.py @@ -1,16 +1,19 @@ # coding: utf-8 -from sympy.core.containers import Tuple -from sympy import Matrix, ImmutableDenseMatrix, MutableDenseNDimArray - -import inspect -import sys import os -import importlib import string import random -import numpy as np +from sympy.core.containers import Tuple +from sympy import Matrix, ImmutableDenseMatrix, MutableDenseNDimArray + +__all__ = ( + 'flatten', + 'mkdir_p', + 'touch_init_file', + 'random_string', + 'write_code' +) #============================================================================== def flatten(args): @@ -53,11 +56,26 @@ def touch_init_file(path): os.utime(path, None) #============================================================================== -def random_string( n ): - # we remove uppercase letters because of f2py - chars = string.ascii_lowercase + string.digits - selector = random.SystemRandom() - return ''.join( selector.choice( chars ) for _ in range( n ) ) +def random_string(size: int = 8, + chars: int = string.ascii_lowercase + string.digits) -> str: + """ + Create a random string of given length to be used in generated file names. + + Parameters + ---------- + size : int, optional + Length of the string (default: 8). + + chars : str, optional + A string with the avalailable characters for random drawing (default: + ASCII lower case characters + decimal digits) + + Returns + ------- + str + A random string of given length, made of the given characters. + """ + return ''.join(random.choice(chars) for _ in range(size)) #============================================================================== def write_code(filename, code, folder=None): @@ -81,4 +99,3 @@ def write_code(filename, code, folder=None): f.close() return filename - diff --git a/psydac/linalg/basic.py b/psydac/linalg/basic.py index f9f63406d..a6a02f68a 100644 --- a/psydac/linalg/basic.py +++ b/psydac/linalg/basic.py @@ -435,15 +435,35 @@ def H(self): def idot(self, v, out): """ - Implements out += self @ v with a temporary. - Subclasses should provide an implementation without a temporary. + Implements `out += self @ v` without a temporary, using a work array. + + This default implementation uses a local work array to store the result + of `self @ v`, and then sums it to the vector `out`. This doubles the + amount of read/write operations from/to local memory. If possible, + subclasses should provide a more efficient implementation which does + not use work arrays. + + Parameters + ---------- + v : Vector + The vector to which the linear operator `self` is applied. It must + belong to the domain of `self`. + + out : Vector + The vector to be incremented by `self @ v`. It must belong to the + codomain of `self`. """ - assert isinstance(v, Vector) - assert v.space == self.domain + assert isinstance( v, Vector) assert isinstance(out, Vector) - assert out.space == self.codomain - out += self.dot(v) + assert v.space is self.domain + assert out.space is self.codomain + + if not hasattr(self, '_work'): + self._work = self.codomain.zeros() + + self.dot(v, out=self._work) + out += self._work def dot_inner(self, v, w): """ @@ -695,6 +715,10 @@ def operator(self): def dtype(self): return None + def set_scalar(self, c): + """ Modifies the scalar with which this LinearOperator is multiplied. E.g. for updating the stepsize.""" + self._scalar = c + def toarray(self): return self._scalar*self._operator.toarray() @@ -758,7 +782,11 @@ def __init__(self, domain, codomain, *args): self._domain = domain self._codomain = codomain self._addends = addends + self._out = codomain.zeros() + #------------------------------------- + # Abstract interface + #------------------------------------- @property def domain(self): """ The domain of the linear operator, element of class ``VectorSpace``. """ @@ -769,26 +797,39 @@ def codomain(self): """ The codomain of the linear operator, element of class ``VectorSpace``. """ return self._codomain - @property - def addends(self): - """ A tuple containing the addends of the linear operator, elements of class ``LinearOperator``. """ - return self._addends - @property def dtype(self): return None + def tosparse(self): + from scipy.sparse import csr_matrix + out = csr_matrix(self.shape, dtype=self.dtype) + for a in self._addends: + out += a.tosparse() + return out + def toarray(self): out = np.zeros(self.shape, dtype=self.dtype) for a in self._addends: out += a.toarray() return out - def tosparse(self): - from scipy.sparse import csr_matrix - out = csr_matrix(self.shape, dtype=self.dtype) - for a in self._addends: - out += a.tosparse() + def dot(self, v, out=None): + """ Evaluates SumLinearOperator object at a vector v element of domain. """ + + assert isinstance(v, Vector) + assert v.space is self.domain + + if out is not None: + assert isinstance(out, Vector) + assert out.space is self.codomain + out *= 0 + else: + out = self.codomain.zeros() + + for A in self._addends: + A.idot(v, out) + return out def transpose(self, conjugate=False): @@ -797,6 +838,14 @@ def transpose(self, conjugate=False): t_addends = (*t_addends, a.transpose(conjugate=conjugate)) return SumLinearOperator(self.codomain, self.domain, *t_addends) + #-------------------------------------- + # Other properties/methods + #-------------------------------------- + @property + def addends(self): + """ A tuple containing the addends of the linear operator, elements of class ``LinearOperator``. """ + return self._addends + @staticmethod def simplify(addends): """ Simplifies a sum of linear operators by combining addends of the same class. """ @@ -819,23 +868,6 @@ def simplify(addends): out = (*out, A) return out - def dot(self, v, out=None): - """ Evaluates SumLinearOperator object at a vector v element of domain. """ - assert isinstance(v, Vector) - assert v.space == self.domain - if out is not None: - assert isinstance(out, Vector) - assert out.space == self.codomain - out *= 0 - for a in self._addends: - a.idot(v, out) - return out - else: - out = self.codomain.zeros() - for a in self._addends: - a.idot(v, out=out) - return out - #=============================================================================== class ComposedLinearOperator(LinearOperator): r""" From fe56bcd96db042d2048c0d50ca4c5387b94958bd Mon Sep 17 00:00:00 2001 From: Frederik Schnack Date: Mon, 22 Sep 2025 21:04:18 +0200 Subject: [PATCH 15/23] Improve multipatch FEM API (#509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Commit summary - Fix #461 : Move contents of `psydac/feec/multipatch/api.py` to `psydac/api/feec.py` and `psydac/api/discretization.py`. - Remove old code from `psydac/feec/multipatch/operators.py` and replace it by the contents in `psydac/feec/multipatch/non_matching_operators.py` to make the structure more clear. - Fix #463 - Fix #272 : keep the `FEMLinearOperator` as a more light-weight class that encapsulates a `LinearOperator` and has a `apply` and `__call__` function to make it act on a `FEMField`. Remove duplicated code with `LinearOperator`. Make operators in `psydac/feec` subclasses of `LinearOperator`. - Fix #462: Add conforming projections and Hodge operators to single-patch `DiscreteDeRham` - Add single-patch test for the conforming projectors - Adapt all the tests/files to the new notations. For the single patch cases mostly renaming `derham_h.derivatives` to `derham_h.derivatives()` and `derham_h.derivatives_as_matrices` to `derham_h.derivatives(kind='linop')` - Merge single-patch and multi-patch operators to the same file if it makes things clearer. Take Hodge and conforming projection operators out of the multipatch subdirectory. - Fix #409 : the global projector interface in DiscreteDeRhamMultipatch - Fix #331. - Add a `SparseMatrixLinearOperator` in `psydac/linalg/sparse.py` to use a sparse matrix as a `LinearOperator`. This is needed for the conforming projections. - Rename `GlobalProjectors` to `GlobalGeometricProjectors` ### Notes With the current changes, we get all FEEC operators directly from the discrete de Rham object. Further, the same code also runs if the domain is a single patch. --------- Co-authored-by: Yaman Güçlü --- docs/source/modules/feec.multipatch.rst | 5 - docs/source/modules/feec.rst | 4 +- docs/source/modules/linalg.rst | 1 + psydac/api/discretization.py | 61 +- psydac/api/feec.py | 484 ++++++- psydac/api/tests/test_api_2d_fields.py | 10 +- psydac/api/tests/test_api_feec_1d.py | 2 +- psydac/api/tests/test_api_feec_2d.py | 2 +- psydac/api/tests/test_api_feec_3d.py | 8 +- psydac/api/tests/test_assembly.py | 2 +- ..._operators.py => conforming_projectors.py} | 573 ++++++-- psydac/feec/derivatives.py | 220 ++- ...tors.py => global_geometric_projectors.py} | 60 +- psydac/feec/hodge.py | 148 ++ psydac/feec/multipatch/api.py | 301 ---- .../examples/h1_source_pbms_conga_2d.py | 162 +-- .../examples/hcurl_eigen_pbms_conga_2d.py | 170 +-- .../examples/hcurl_eigen_pbms_dg_2d.py | 78 +- .../examples/hcurl_eigen_testcases.py | 4 - .../examples/hcurl_source_pbms_conga_2d.py | 207 +-- .../examples/hcurl_source_testcase.py | 4 - .../multipatch/examples/ppc_test_cases.py | 17 +- .../multipatch/examples/timedomain_maxwell.py | 937 +++---------- .../examples/timedomain_maxwell_testcase.py | 159 +-- .../feec/multipatch/fem_linear_operators.py | 218 --- psydac/feec/multipatch/operators.py | 1248 ----------------- .../tests/test_feec_maxwell_multipatch_2d.py | 20 +- .../tests/test_feec_poisson_multipatch_2d.py | 5 +- psydac/feec/multipatch/utils_conga_2d.py | 193 ++- psydac/feec/tests/test_axis_projection.py | 6 +- .../feec/tests/test_commuting_projections.py | 60 +- .../tests/test_commuting_projections_dual.py | 18 +- .../tests/test_differentiation_matrices.py | 58 +- .../test_feec_conf_projectors_cart_2d.py | 193 +-- psydac/feec/tests/test_global_projectors.py | 16 +- .../feec/tests/test_projections_parallel.py | 26 +- psydac/fem/basic.py | 87 +- psydac/fem/projectors.py | 64 +- psydac/linalg/basic.py | 7 +- psydac/linalg/sparse.py | 114 ++ psydac/linalg/tests/test_block.py | 82 ++ psydac/linalg/utilities.py | 4 +- 42 files changed, 2373 insertions(+), 3665 deletions(-) rename psydac/feec/{multipatch/non_matching_operators.py => conforming_projectors.py} (68%) rename psydac/feec/{global_projectors.py => global_geometric_projectors.py} (94%) create mode 100644 psydac/feec/hodge.py delete mode 100644 psydac/feec/multipatch/api.py delete mode 100644 psydac/feec/multipatch/fem_linear_operators.py delete mode 100644 psydac/feec/multipatch/operators.py rename psydac/feec/{multipatch => }/tests/test_feec_conf_projectors_cart_2d.py (54%) create mode 100644 psydac/linalg/sparse.py diff --git a/docs/source/modules/feec.multipatch.rst b/docs/source/modules/feec.multipatch.rst index 64bf49a12..85bf08af1 100644 --- a/docs/source/modules/feec.multipatch.rst +++ b/docs/source/modules/feec.multipatch.rst @@ -7,11 +7,6 @@ feec.multipatch :toctree: STUBDIR :template: autosummary/module.rst - multipatch.api - multipatch.fem_linear_operators multipatch.multipatch_domain_utilities - multipatch.non_matching_operators - multipatch.operators - multipatch.plotting_utilities multipatch.utilities multipatch.utils_conga_2d diff --git a/docs/source/modules/feec.rst b/docs/source/modules/feec.rst index 5d424f396..f75dfb8cc 100644 --- a/docs/source/modules/feec.rst +++ b/docs/source/modules/feec.rst @@ -7,8 +7,10 @@ feec :toctree: STUBDIR :template: autosummary/module.rst + feec.conforming_projectors feec.derivatives - feec.global_projectors + feec.global_geometric_projectors + feec.hodge feec.pull_push feec.pushforward diff --git a/docs/source/modules/linalg.rst b/docs/source/modules/linalg.rst index 7ccbbf491..a625296ef 100644 --- a/docs/source/modules/linalg.rst +++ b/docs/source/modules/linalg.rst @@ -14,6 +14,7 @@ linalg linalg.kernels linalg.kron linalg.solvers + linalg.sparse linalg.stencil linalg.topetsc linalg.utilities diff --git a/psydac/api/discretization.py b/psydac/api/discretization.py index 1cea9397e..bedec470e 100644 --- a/psydac/api/discretization.py +++ b/psydac/api/discretization.py @@ -30,7 +30,7 @@ from psydac.api.fem import DiscreteBilinearForm from psydac.api.fem import DiscreteLinearForm from psydac.api.fem import DiscreteFunctional -from psydac.api.feec import DiscreteDerham +from psydac.api.feec import DiscreteDeRham, DiscreteDeRhamMultipatch from psydac.api.glt import DiscreteGltExpr from psydac.api.expr import DiscreteExpr from psydac.api.equation import DiscreteEquation @@ -47,6 +47,7 @@ __all__ = ( 'discretize', 'discretize_derham', + 'discretize_derham_multipatch', 'reduce_space_degrees', 'discretize_space', 'discretize_domain' @@ -147,10 +148,10 @@ def get_max_degree(*spaces): #============================================================================== def discretize_derham(derham, domain_h, *, get_H1vec_space=False, **kwargs): """ - Create a discrete De Rham sequence from a symbolic one. + Create a discrete de Rham sequence from a symbolic one. This function creates the discrete spaces from the symbolic ones, and then - creates a DiscreteDerham object from them. + creates a DiscreteDeRham object from them. Parameters ---------- @@ -168,18 +169,16 @@ def discretize_derham(derham, domain_h, *, get_H1vec_space=False, **kwargs): Returns ------- - DiscreteDerham - The discrete De Rham sequence containing the discrete spaces, + DiscreteDeRham + The discrete de Rham sequence containing the discrete spaces, differential operators and projectors. See Also -------- discretize_space - """ ldim = derham.shape - mapping = domain_h.domain.mapping # NOTE: assuming single-patch domain! bases = ['B'] + ldim * ['M'] spaces = [discretize_space(V, domain_h, basis=basis, **kwargs) for V, basis in zip(derham.spaces, bases)] @@ -192,7 +191,48 @@ def discretize_derham(derham, domain_h, *, get_H1vec_space=False, **kwargs): #We still need to specify the symbolic space because of "_recursive_element_of" not implemented in sympde spaces.append(Xh) - return DiscreteDerham(mapping, *spaces) + return DiscreteDeRham(domain_h, *spaces) + +#============================================================================== +def discretize_derham_multipatch(derham, domain_h, **kwargs): + """ + Create a discrete multipatch de Rham sequence from a symbolic one. + + This function creates the broken discrete spaces from the symbolic ones, and then + creates a DiscreteDeRhamMultipatch object from them. + + Parameters + ---------- + derham : sympde.topology.space.Derham + The symbolic Derham sequence. + + domain_h : Geometry + Discrete domain where the spaces will be discretized. + + **kwargs : dict + Optional parameters for the space discretization. + + Returns + ------- + DiscreteDeRhamMultipatch + The discrete multipatch de Rham sequence containing the discrete spaces, + differential operators and projectors. + + See Also + -------- + discretize_derham + discretize_space + """ + + ldim = derham.shape + bases = ['B'] + ldim * ['M'] + spaces = [discretize_space(V, domain_h, basis=basis, **kwargs) \ + for V, basis in zip(derham.spaces, bases)] + + return DiscreteDeRhamMultipatch( + domain_h = domain_h, + spaces = spaces + ) #============================================================================== def reduce_space_degrees(V, Vh, *, basis='B', sequence='DR'): @@ -631,9 +671,12 @@ def discretize(a, *args, **kwargs): elif isinstance(a, BasicFunctionSpace): return discretize_space(a, *args, **kwargs) - elif isinstance(a, Derham): + elif isinstance(a, Derham) and not a.V0.is_broken: return discretize_derham(a, *args, **kwargs) + elif isinstance(a, Derham) and a.V0.is_broken: + return discretize_derham_multipatch(a, *args, **kwargs) + elif isinstance(a, Domain): return discretize_domain(a, *args, **kwargs) diff --git a/psydac/api/feec.py b/psydac/api/feec.py index cfec097eb..2bd95ffbd 100644 --- a/psydac/api/feec.py +++ b/psydac/api/feec.py @@ -1,45 +1,54 @@ -from sympde.topology.mapping import Mapping - -from psydac.api.basic import BasicDiscrete -from psydac.feec.derivatives import Derivative_1D, Gradient_2D, Gradient_3D -from psydac.feec.derivatives import ScalarCurl_2D, VectorCurl_2D, Curl_3D -from psydac.feec.derivatives import Divergence_2D, Divergence_3D -from psydac.feec.global_projectors import Projector_H1, Projector_Hcurl, Projector_H1vec -from psydac.feec.global_projectors import Projector_Hdiv, Projector_L2 -from psydac.feec.pull_push import pull_1d_h1, pull_1d_l2 -from psydac.feec.pull_push import pull_2d_h1, pull_2d_hcurl, pull_2d_hdiv, pull_2d_l2, pull_2d_h1vec -from psydac.feec.pull_push import pull_3d_h1, pull_3d_hcurl, pull_3d_hdiv, pull_3d_l2, pull_3d_h1vec -from psydac.fem.basic import FemSpace -from psydac.fem.vector import VectorFemSpace - -__all__ = ('DiscreteDerham',) +from psydac.api.basic import BasicDiscrete + +from psydac.feec.derivatives import Derivative1D, Gradient2D, Gradient3D +from psydac.feec.derivatives import ScalarCurl2D, VectorCurl2D, Curl3D +from psydac.feec.derivatives import Divergence2D, Divergence3D +from psydac.feec.derivatives import BrokenGradient2D +from psydac.feec.derivatives import BrokenScalarCurl2D + +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorH1 +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorHcurl +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorH1vec +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorHdiv +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorL2 +from psydac.feec.global_geometric_projectors import MultipatchGeometricProjector + +from psydac.feec.conforming_projectors import ConformingProjectionV0 +from psydac.feec.conforming_projectors import ConformingProjectionV1 + +from psydac.feec.hodge import HodgeOperator + +from psydac.feec.pull_push import pull_1d_h1, pull_1d_l2 +from psydac.feec.pull_push import pull_2d_h1, pull_2d_hcurl +from psydac.feec.pull_push import pull_2d_hdiv, pull_2d_l2, pull_2d_h1vec +from psydac.feec.pull_push import pull_3d_h1, pull_3d_hcurl +from psydac.feec.pull_push import pull_3d_hdiv, pull_3d_l2, pull_3d_h1vec + +from psydac.fem.basic import FemSpace, FemLinearOperator +from psydac.fem.vector import VectorFemSpace +from psydac.linalg.basic import IdentityOperator + +__all__ = ('DiscreteDeRham', 'DiscreteDeRhamMultipatch',) #============================================================================== -class DiscreteDerham(BasicDiscrete): +class DiscreteDeRham(BasicDiscrete): """ A discrete de Rham sequence built over a single-patch geometry. Parameters ---------- - mapping : Mapping or None - Symbolic mapping from the logical space to the physical space, if any. + domain_h : Geometry + The discretized domain, which is a single-patch geometry. *spaces : list of FemSpace The discrete spaces of the de Rham sequence. Notes ----- - - The basic type Mapping is defined in module sympde.topology.mapping. - A discrete mapping (spline or NURBS) may be attached to it. - - This constructor should not be called directly, but rather from the `discretize_derham` function in `psydac.api.discretization`. - - - For the multipatch counterpart of this class please see - `MultipatchDiscreteDerham` in `psydac.feec.multipatch.api`. """ - def __init__(self, mapping, *spaces): + def __init__(self, domain_h, *spaces): - assert (mapping is None) or isinstance(mapping, Mapping) assert all(isinstance(space, FemSpace) for space in spaces) self.has_vec = isinstance(spaces[-1], VectorFemSpace) @@ -53,51 +62,75 @@ def __init__(self, mapping, *spaces): dim = len(spaces) - 1 self._spaces = spaces + self._domain_h = domain_h + self._sequence = tuple(space.symbolic_space.kind.name for space in spaces) self._dim = dim - self._mapping = mapping - self._callable_mapping = mapping.get_callable_mapping() if mapping else None + self._mapping = domain_h.domain.mapping + self._callable_mapping = self._mapping.get_callable_mapping() if self._mapping else None if dim == 1: - D0 = Derivative_1D(spaces[0], spaces[1]) + D0 = Derivative1D(spaces[0], spaces[1]) + spaces[0].diff = spaces[0].grad = D0 + self._derivatives = (D0,) + elif dim == 2: kind = spaces[1].symbolic_space.kind.name if kind == 'hcurl': - D0 = Gradient_2D(spaces[0], spaces[1]) - D1 = ScalarCurl_2D(spaces[1], spaces[2]) + D0 = Gradient2D(spaces[0], spaces[1]) + D1 = ScalarCurl2D(spaces[1], spaces[2]) spaces[0].diff = spaces[0].grad = D0 spaces[1].diff = spaces[1].curl = D1 + self._derivatives = (D0, D1) + elif kind == 'hdiv': - D0 = VectorCurl_2D(spaces[0], spaces[1]) - D1 = Divergence_2D(spaces[1], spaces[2]) + D0 = VectorCurl2D(spaces[0], spaces[1]) + D1 = Divergence2D(spaces[1], spaces[2]) spaces[0].diff = spaces[0].rot = D0 spaces[1].diff = spaces[1].div = D1 + self._derivatives = (D0, D1) + + elif dim == 3: - D0 = Gradient_3D(spaces[0], spaces[1]) - D1 = Curl_3D(spaces[1], spaces[2]) - D2 = Divergence_3D(spaces[2], spaces[3]) + D0 = Gradient3D(spaces[0], spaces[1]) + D1 = Curl3D(spaces[1], spaces[2]) + D2 = Divergence3D(spaces[2], spaces[3]) spaces[0].diff = spaces[0].grad = D0 spaces[1].diff = spaces[1].curl = D1 spaces[2].diff = spaces[2].div = D2 + self._derivatives = (D0, D1, D2) + else: raise ValueError('Dimension {} is not available'.format(dim)) + self._hodge_operators = () + self._conf_proj = () #-------------------------------------------------------------------------- @property def dim(self): """Dimension of the physical and logical domains, which are assumed to be the same.""" return self._dim + + @property + def domain_h(self): + """Discretized domain.""" + return self._domain_h + + @property + def spaces(self): + """Spaces of the proper de Rham sequence (excluding Hvec).""" + return self._spaces @property def V0(self): @@ -124,6 +157,10 @@ def V3(self): """Fourth space of the de Rham sequence : L2 space in 3d""" return self._spaces[3] + @property + def sequence(self): + return self._sequence + @property def H1vec(self): """Vector-valued H1 space built as the Cartesian product of N copies of V0, @@ -131,11 +168,6 @@ def H1vec(self): assert self.has_vec return self._H1vec - @property - def spaces(self): - """Spaces of the proper de Rham sequence (excluding Hvec).""" - return self._spaces - @property def mapping(self): """The mapping from the logical space to the physical space.""" @@ -146,32 +178,17 @@ def callable_mapping(self): """The mapping as a callable.""" return self._callable_mapping - @property - def derivatives_as_matrices(self): - """Differential operators of the De Rham sequence as LinearOperator objects.""" - return tuple(V.diff.matrix for V in self.spaces[:-1]) - - @property - def derivatives(self): - """Differential operators of the De Rham sequence as `DiffOperator` objects. - - Those are objects with `domain` and `codomain` properties that are `FemSpace`, - they act on `FemField` (they take a `FemField` of their `domain` as input and return - a `FemField` of their `codomain`. - """ - return tuple(V.diff for V in self.spaces[:-1]) - #-------------------------------------------------------------------------- def projectors(self, *, kind='global', nquads=None): """Projectors mapping callable functions of the physical coordinates to a - corresponding `FemField` object in the De Rham sequence. + corresponding `FemField` object in the de Rham sequence. Parameters ---------- kind : str Type of the projection : at the moment, only global is accepted and returns geometric commuting projectors based on interpolation/histopolation - for the De Rham sequence (GlobalProjector objects). + for the de Rham sequence (GlobalProjector objects). nquads : list(int) | tuple(int) Number of quadrature points along each direction, to be used in Gauss @@ -182,7 +199,7 @@ def projectors(self, *, kind='global', nquads=None): P0, ..., Pn : callables Projectors that can be called on any callable function that maps from the physical space to R (scalar case) or R^d (vector case) and - returns a FemField belonging to the i-th space of the De Rham sequence + returns a FemField belonging to the i-th space of the de Rham sequence """ if not (kind == 'global'): @@ -200,8 +217,8 @@ def projectors(self, *, kind='global', nquads=None): assert all(nq >= 1 for nq in nquads) if self.dim == 1: - P0 = Projector_H1(self.V0) - P1 = Projector_L2(self.V1, nquads) + P0 = GlobalGeometricProjectorH1(self.V0) + P1 = GlobalGeometricProjectorL2(self.V1, nquads) if self.mapping: P0_m = lambda f: P0(pull_1d_h1(f, self.callable_mapping)) P1_m = lambda f: P1(pull_1d_l2(f, self.callable_mapping)) @@ -209,19 +226,19 @@ def projectors(self, *, kind='global', nquads=None): return P0, P1 elif self.dim == 2: - P0 = Projector_H1(self.V0) - P2 = Projector_L2(self.V2, nquads) + P0 = GlobalGeometricProjectorH1(self.V0) + P2 = GlobalGeometricProjectorL2(self.V2, nquads) kind = self.V1.symbolic_space.kind.name if kind == 'hcurl': - P1 = Projector_Hcurl(self.V1, nquads) + P1 = GlobalGeometricProjectorHcurl(self.V1, nquads) elif kind == 'hdiv': - P1 = Projector_Hdiv(self.V1, nquads) + P1 = GlobalGeometricProjectorHdiv(self.V1, nquads) else: raise TypeError('projector of space type {} is not available'.format(kind)) if self.has_vec : - Pvec = Projector_H1vec(self.H1vec, nquads) + Pvec = GlobalGeometricProjectorH1vec(self.H1vec, nquads) if self.mapping: P0_m = lambda f: P0(pull_2d_h1(f, self.callable_mapping)) @@ -242,12 +259,12 @@ def projectors(self, *, kind='global', nquads=None): return P0, P1, P2 elif self.dim == 3: - P0 = Projector_H1 (self.V0) - P1 = Projector_Hcurl(self.V1, nquads) - P2 = Projector_Hdiv (self.V2, nquads) - P3 = Projector_L2 (self.V3, nquads) + P0 = GlobalGeometricProjectorH1 (self.V0) + P1 = GlobalGeometricProjectorHcurl(self.V1, nquads) + P2 = GlobalGeometricProjectorHdiv (self.V2, nquads) + P3 = GlobalGeometricProjectorL2 (self.V3, nquads) if self.has_vec : - Pvec = Projector_H1vec(self.H1vec) + Pvec = GlobalGeometricProjectorH1vec(self.H1vec) if self.mapping: P0_m = lambda f: P0(pull_3d_h1 (f, self.callable_mapping)) P1_m = lambda f: P1(pull_3d_hcurl(f, self.callable_mapping)) @@ -264,3 +281,328 @@ def projectors(self, *, kind='global', nquads=None): else : return P0, P1, P2, P3 + #-------------------------------------------------------------------------- + def derivatives(self, kind='femlinop'): + if kind == 'femlinop': + return self._derivatives + elif kind == 'linop': + return tuple(b_diff.linop for b_diff in self._derivatives) + + #-------------------------------------------------------------------------- + def conforming_projectors(self, kind='femlinop', mom_pres=False, p_moments=-1, hom_bc=False): + """ + return the conforming projectors of the broken multi-patch space + + Parameters + ---------- + + p_moments : + The number of moments preserved by the projector. + + hom_bc: + Apply homogenous boundary conditions if True + + kind : + The kind of the projector, can be 'femlinop' or 'linop'. + - 'femlinop' returns a psydac FemLinearOperator (default) + - 'linop' returns a psydac LinearOperator + + Returns + ------- + cP0, cP1, cP2 : Tuple of or + The conforming projectors of each space and in desired form. + + """ + + if hom_bc is None: + raise ValueError('please provide a value for "hom_bc" argument') + + if self.dim == 1: + raise NotImplementedError("1D projectors are not available") + + elif self.dim == 2: + if self.sequence[1] != 'hcurl': + raise NotImplementedError('2D sequence with H-div not available yet') + + else: + + if not self._conf_proj: + + cP0 = ConformingProjectionV0(self.V0, mom_pres=mom_pres, p_moments=p_moments, hom_bc=hom_bc) + cP1 = ConformingProjectionV1(self.V1, mom_pres=mom_pres, p_moments=p_moments, hom_bc=hom_bc) + + I2 = IdentityOperator(self.V2.coeff_space) + cP2 = FemLinearOperator(fem_domain=self.V2, fem_codomain=self.V2, linop=I2) + + self._conf_proj = (cP0, cP1, cP2) + + if kind == 'femlinop': + return self._conf_proj[0], self._conf_proj[1], self._conf_proj[2] + elif kind == 'linop': + return self._conf_proj[0].linop, self._conf_proj[1].linop, self._conf_proj[2].linop + + elif self.dim == 3: + raise NotImplementedError("3D projectors are not available") + + #-------------------------------------------------------------------------- + def _init_hodge_operators(self, backend_language='python'): + """ + Initialize the Hodge operator for the multipatch de Rham sequence. + + Parameters + ---------- + + backend_language: + The backend used to accelerate the code + + """ + if not self._hodge_operators: + + if self.dim == 1: + H0 = HodgeOperator(self.V0, self.domain_h, backend_language=backend_language) + H1 = HodgeOperator(self.V1, self.domain_h, backend_language=backend_language) + + self._hodge_operators = (H0, H1) + + elif self.dim == 2: + + H0 = HodgeOperator(self.V0, self.domain_h, backend_language=backend_language) + H1 = HodgeOperator(self.V1, self.domain_h, backend_language=backend_language) + H2 = HodgeOperator(self.V2, self.domain_h, backend_language=backend_language) + + self._hodge_operators = (H0, H1, H2) + + elif self.dim == 3: + + H0 = HodgeOperator(self.V0, self.domain_h, backend_language=backend_language) + H1 = HodgeOperator(self.V1, self.domain_h, backend_language=backend_language) + H2 = HodgeOperator(self.V2, self.domain_h, backend_language=backend_language) + H3 = HodgeOperator(self.V3, self.domain_h, backend_language=backend_language) + + self._hodge_operators = (H0, H1, H2, H3) + + #-------------------------------------------------------------------------- + def _get_hodge_operator(self, H, dual=False, kind='femlinop'): + """ + Helper function to return the Hodge operator in the specified form. + + Parameters + ---------- + H : + + dual : + If True, returns the dual Hodge operator + + kind : + The kind of the projector, can be 'femlinop' or 'linop'. + - 'femlinop' returns a psydac FemLinearOperator (default) + - 'linop' returns a psydac LinearOperator + + Returns + ------- + Hodge operator in the specified form. + """ + + if not dual: + if kind == 'femlinop': + return H.hodge + elif kind == 'linop': + return H.linop + else: + if kind == 'femlinop': + return H.dual_hodge + elif kind == 'linop': + return H.dual_linop + + #-------------------------------------------------------------------------- + def hodge_operator(self, space=None, dual=False, kind='femlinop', backend_language='python'): + """ + Returns the Hodge operator for the given space and specified kind. + + Parameters + ---------- + space : str or None + The space for which to return the Hodge operator, can be 'V0', 'V1', 'V2' or None. + If None, returns a tuple with all three Hodge operators. + + dual : bool + If True, returns the dual Hodge operator. + + kind : + The kind of the projector, can be 'femlinop' or 'linop'. + - 'femlinop' returns a psydac FemLinearOperator (default) + - 'linop' returns a psydac LinearOperator + + backend_language : str + The backend used to accelerate the code, default is 'python'. + + Returns + ------- + The Hodge operator of the space of the specified kind. + + H : or + """ + + if not self._hodge_operators: + self._init_hodge_operators(backend_language=backend_language) + + if space == 'V0': + return self._get_hodge_operator(self._hodge_operators[0], dual=dual, kind=kind) + + elif space == 'V1': + return self._get_hodge_operator(self._hodge_operators[1], dual=dual, kind=kind) + + elif space == 'V2': + return self._get_hodge_operator(self._hodge_operators[2], dual=dual, kind=kind) + + elif space == 'V3': + return self._get_hodge_operator(self._hodge_operators[3], dual=dual, kind=kind) + + #-------------------------------------------------------------------------- + def hodge_operators(self, dual=False, kind='femlinop', backend_language='python'): + """ + Returns the Hodge operators for the specified kind. + + Parameters + ---------- + dual : bool + If True, returns the dual Hodge operator. + + kind : + The kind of the projector, can be 'femlinop' or 'linop'. + - 'femlinop' returns a psydac FemLinearOperator (default) + - 'linop' returns a psydac LinearOperator + + backend_language : str + The backend used to accelerate the code, default is 'python'. + + Returns + ------- + The Hodge operators of all spaces and of the specified kind. + """ + + if not self._hodge_operators: + self._init_hodge_operators(backend_language=backend_language) + + return tuple(self._get_hodge_operator(H, dual=dual, kind=kind) for H in self._hodge_operators) + + +#============================================================================== +class DiscreteDeRhamMultipatch(DiscreteDeRham): + """ Represents the discrete de Rham sequence for multipatch domains. + It only works when the number of patches>1. + + Parameters + ---------- + domain_h: + The discrete domain + + spaces: + The discrete spaces that are contained in the de Rham sequence + """ + + def __init__(self, *, domain_h, spaces): + + dim = len(spaces) - 1 + self._spaces = tuple(spaces) + self._dim = dim + self._mapping = domain_h.domain.mapping + self._callable_mapping = [m.get_callable_mapping() for m in self._mapping.mappings.values()] if self._mapping else None + self._domain_h = domain_h + self._sequence = tuple(space.symbolic_space.kind.name for space in spaces) + + + if dim == 1: + raise NotImplementedError('1D FEEC multipatch non available yet') + + elif dim == 2: + + if self._sequence[1] == 'hcurl': + + self._derivatives = ( + BrokenGradient2D(self.V0, self.V1), + BrokenScalarCurl2D(self.V1, self.V2), # None, + ) + + elif self._sequence[1] == 'hdiv': + raise NotImplementedError('2D sequence with H-div not available yet') + + else: + raise ValueError('2D sequence not understood') + + elif dim == 3: + raise NotImplementedError('3D FEEC multipatch non available yet') + + else: + raise ValueError('Dimension {} is not available'.format(dim)) + + self._hodge_operators = () + self._conf_proj = () + + #-------------------------------------------------------------------------- + @property + def H1vec(self): + raise NotImplementedError('Not implemented for Multipatch de Rham sequences.') + + #-------------------------------------------------------------------------- + def projectors(self, *, kind='global', nquads=None): + """ + This method returns the patch-wise commuting projectors on the broken multi-patch space + + Parameters + ---------- + kind: + The projectors kind, can be global or local + + nquads: + The number of quadrature points. + + Returns + ------- + P0: + Patch wise H1 projector + + P1: + Patch wise Hcurl projector + + P2: + Patch wise L2 projector + + Notes + ----- + - when applied to smooth functions they return conforming fields + - default 'global projectors' correspond to geometric interpolation/histopolation operators on Greville grids + - here 'global' is a patch-level notion, as the interpolation-type problems are solved on each patch independently + """ + if not (kind == 'global'): + raise NotImplementedError('only global projectors are available') + + if self.dim == 1: + raise NotImplementedError("1D projectors are not available") + + elif self.dim == 2: + P0 = MultipatchGeometricProjector(self.V0, GlobalGeometricProjectorH1) + + if self.sequence[1] == 'hcurl': + P1 = MultipatchGeometricProjector(self.V1, GlobalGeometricProjectorHcurl, nquads=nquads) + else: + P1 = MultipatchGeometricProjector(self.V1, GlobalGeometricProjectorHdiv, nquads=nquads) + + P2 = MultipatchGeometricProjector(self.V2, GlobalGeometricProjectorL2, nquads=nquads) + + if self.mapping: + P0_m = lambda f : P0([pull_2d_h1(f, m) for m in self.callable_mapping]) + + if self.sequence[1] == 'hcurl': + P1_m = lambda f : P1([pull_2d_hcurl(f, m) for m in self.callable_mapping]) + else: + raise NotImplementedError('2D sequence with H-div not available yet') + + P2_m = lambda f : P2([pull_2d_l2(f, m) for m in self.callable_mapping]) + + return P0_m, P1_m, P2_m + + return P0, P1, P2 + + elif self.dim == 3: + raise NotImplementedError("3D projectors are not available") diff --git a/psydac/api/tests/test_api_2d_fields.py b/psydac/api/tests/test_api_2d_fields.py index abd7a1d75..d829bcfd5 100644 --- a/psydac/api/tests/test_api_2d_fields.py +++ b/psydac/api/tests/test_api_2d_fields.py @@ -32,10 +32,10 @@ from sympde.expr import Norm from sympde.expr import find, EssentialBC -from psydac.fem.basic import FemField -from psydac.api.discretization import discretize -from psydac.api.settings import PSYDAC_BACKEND_GPYCCEL -from psydac.feec.global_projectors import Projector_H1 +from psydac.fem.basic import FemField +from psydac.api.discretization import discretize +from psydac.api.settings import PSYDAC_BACKEND_GPYCCEL +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorH1 # ... get the mesh directory try: @@ -142,7 +142,7 @@ def run_boundary_field_test(domain, boundary, f, ncells): x,y = domain.coordinates f_lambda = lambdify([x,y], f, 'math') - Pi0 = Projector_H1(Vh) + Pi0 = GlobalGeometricProjectorH1(Vh) fh = Pi0(f_lambda) fh.coeffs.update_ghost_regions() diff --git a/psydac/api/tests/test_api_feec_1d.py b/psydac/api/tests/test_api_feec_1d.py index ad60a2c02..fd7e7f7dd 100644 --- a/psydac/api/tests/test_api_feec_1d.py +++ b/psydac/api/tests/test_api_feec_1d.py @@ -145,7 +145,7 @@ class CollelaMapping1D(Mapping): M1 = a1_h.assemble() # Differential operators - D0, = derham_h.derivatives_as_matrices + D0, = derham_h.derivatives(kind='linop') # Transpose of derivative matrix D0_T = D0.T diff --git a/psydac/api/tests/test_api_feec_2d.py b/psydac/api/tests/test_api_feec_2d.py index 40b607615..c221af521 100644 --- a/psydac/api/tests/test_api_feec_2d.py +++ b/psydac/api/tests/test_api_feec_2d.py @@ -325,7 +325,7 @@ class CollelaMapping2D(Mapping): M2 = a2_h.assemble() # Differential operators (StencilMatrix or BlockLinearOperator objects) - D0, D1 = derham_h.derivatives_as_matrices + D0, D1 = derham_h.derivatives(kind='linop') # Discretize and assemble penalization matrix if not periodic: diff --git a/psydac/api/tests/test_api_feec_3d.py b/psydac/api/tests/test_api_feec_3d.py index 9da51f784..3a05bdf0b 100644 --- a/psydac/api/tests/test_api_feec_3d.py +++ b/psydac/api/tests/test_api_feec_3d.py @@ -130,8 +130,8 @@ def run_maxwell_3d_scipy(logical_domain, mapping, e_ex, b_ex, ncells, degree, pe M1 = a1_h.assemble().tosparse().tocsc() M2 = a2_h.assemble().tosparse().tocsr() - # Get differential operators as BlockLinearOperator objects - GRAD, CURL, DIV = derham_h.derivatives_as_matrices + # Diff operators + GRAD, CURL, DIV = derham_h.derivatives(kind='linop') # Get projectors as objects of type Projector_H1, Projector_Hcurl, Projector_Hdiv, Projector_L2 P0, P1, P2, P3 = derham_h.projectors(nquads=[5, 5, 5]) @@ -233,8 +233,8 @@ def run_maxwell_3d_stencil(logical_domain, mapping, e_ex, b_ex, ncells, degree, M1 = a1_h.assemble() M2 = a2_h.assemble() - # Get differential operators as BlockLinearOperator objects - GRAD, CURL, DIV = derham_h.derivatives_as_matrices + # Diff operators + GRAD, CURL, DIV = derham_h.derivatives(kind='linop') # Get projectors as objects of type Projector_H1, Projector_Hcurl, Projector_Hdiv, Projector_L2 P0, P1, P2, P3 = derham_h.projectors(nquads=[5, 5, 5]) diff --git a/psydac/api/tests/test_assembly.py b/psydac/api/tests/test_assembly.py index a84a41a2b..189b6c102 100644 --- a/psydac/api/tests/test_assembly.py +++ b/psydac/api/tests/test_assembly.py @@ -544,7 +544,7 @@ def test_assembly_no_synchr_args(backend): V1h = derham_h.V1 #differential operator - div, = derham_h.derivatives_as_matrices + div, = derham_h.derivatives(kind='linop') rho = element_of(V1h.symbolic_space, name='rho') g = element_of(V1h.symbolic_space, name='g') diff --git a/psydac/feec/multipatch/non_matching_operators.py b/psydac/feec/conforming_projectors.py similarity index 68% rename from psydac/feec/multipatch/non_matching_operators.py rename to psydac/feec/conforming_projectors.py index 4b172e7de..d045a702e 100644 --- a/psydac/feec/multipatch/non_matching_operators.py +++ b/psydac/feec/conforming_projectors.py @@ -1,21 +1,24 @@ -""" -This module provides utilities for constructing the conforming projections -for a H1-Hcurl-L2 broken FEEC de Rham sequence. -""" - +# coding: utf-8 +# Conga operators on piecewise (broken) de Rham sequences import os - import numpy as np -from scipy.sparse import eye as sparse_eye -from scipy.sparse import csr_matrix +from scipy.sparse import eye as sparse_eye +from scipy.sparse import csr_matrix +from scipy.special import comb from sympde.topology import Boundary, Interface -from psydac.fem.splines import SplineSpace -from psydac.utilities.quadratures import gauss_legendre -from psydac.core.bsplines import quadrature_grid, basis_ders_on_quad_grid, find_spans, elements_spans, cell_index, basis_ders_on_irregular_grid +from psydac.core.bsplines import quadrature_grid, basis_ders_on_quad_grid, find_spans, elements_spans, cell_index, basis_ders_on_irregular_grid +from psydac.fem.basic import FemLinearOperator +from psydac.fem.splines import SplineSpace +from psydac.utilities.quadratures import gauss_legendre +from psydac.linalg.sparse import SparseMatrixLinearOperator +__all__ = ( + 'ConformingProjectionV0', + 'ConformingProjectionV1', +) def get_patch_index_from_face(domain, face): """ @@ -118,7 +121,6 @@ def get_corners(domain, boundary_only): patches = domain.interior.args bd = domain.boundary - # corner_data[corner] = (patch_ind => local coordinates) corner_data = dict() if boundary_only: @@ -143,7 +145,6 @@ def get_corners(domain, boundary_only): else: for co in cos: corner_data[co] = dict() - for cb in co.corners: p_ind = patches.index(cb.domain) c_coord = cb.coordinates @@ -192,63 +193,48 @@ def construct_restriction_operator_1D( """ n_c = coarse_space_1d.nbasis n_f = fine_space_1d.nbasis - R = np.zeros((n_c, n_f)) if coarse_space_1d.basis == 'B': + #map V^+ to V^+_0 T = np.zeros((n_f, n_f)) - for i in range(1, n_f - 1): + for i in range(n_f): for j in range(n_f): - T[i, j] = int(i == j) - E[i, 0] * int(0 == j) - \ - E[i, -1] * int(n_f - 1 == j) + T[i, j] = int(i == j) - E[i, 0] * int(0 == j) - E[i, -1] * int(n_f - 1 == j) - cf_mass_mat = calculate_mixed_mass_matrix(coarse_space_1d, fine_space_1d)[ - 1:-1, 1:-1].transpose() - c_mass_mat = calculate_mass_matrix(coarse_space_1d)[1:-1, 1:-1] + cf_mass_mat = calculate_mixed_mass_matrix(coarse_space_1d, fine_space_1d).transpose() + c_mass_mat = calculate_mass_matrix(coarse_space_1d) if p_moments > 0: - - if not p_moments % 2 == 0: - p_moments += 1 - c_poly_mat = calculate_poly_basis_integral( - coarse_space_1d, p_moments=p_moments - 1)[:, 1:-1] - f_poly_mat = calculate_poly_basis_integral( - fine_space_1d, p_moments=p_moments - 1)[:, 1:-1] - - c_mass_mat[0:p_moments // 2, :] = c_poly_mat[0:p_moments // 2, :] - c_mass_mat[-p_moments // 2:, :] = c_poly_mat[-p_moments // 2:, :] - - cf_mass_mat[0:p_moments // 2, :] = f_poly_mat[0:p_moments // 2, :] - cf_mass_mat[-p_moments // 2:, :] = f_poly_mat[-p_moments // 2:, :] - - R0 = np.linalg.solve(c_mass_mat, cf_mass_mat) - R[1:-1, 1:-1] = R0 - R = R @ T - + # L^2 projection from V^+_0 to V^- + R[:, 1:-1] = np.linalg.solve(c_mass_mat, cf_mass_mat[:, 1:-1]) + gamma = get_1d_moment_correction(coarse_space_1d, p_moments=p_moments) + n = len(gamma) + + # maps V^- to V^+_0 in a moment preserving way + T2 = np.eye(n_c) + T2[0, 0] = T2[-1, -1] = 0 + T2[1:n+1, 0] += gamma + T2[-(n+1):-1, -1] += gamma[::-1] + + # maps V^+ to V^- in a moment preserving way + R = T2 @ R @ T + + else: + R[1:-1, 1:-1] = np.linalg.solve(c_mass_mat[1:-1, 1:-1], cf_mass_mat[1:-1, 1:-1]) + R = R @ T + + # add the degrees of freedom of T back R[0, 0] += 1 R[-1, -1] += 1 + else: - cf_mass_mat = calculate_mixed_mass_matrix( - coarse_space_1d, fine_space_1d).transpose() + cf_mass_mat = calculate_mixed_mass_matrix(coarse_space_1d, fine_space_1d).transpose() c_mass_mat = calculate_mass_matrix(coarse_space_1d) - if p_moments > 0: - - if not p_moments % 2 == 0: - p_moments += 1 - c_poly_mat = calculate_poly_basis_integral( - coarse_space_1d, p_moments=p_moments - 1) - f_poly_mat = calculate_poly_basis_integral( - fine_space_1d, p_moments=p_moments - 1) - - c_mass_mat[0:p_moments // 2, :] = c_poly_mat[0:p_moments // 2, :] - c_mass_mat[-p_moments // 2:, :] = c_poly_mat[-p_moments // 2:, :] - - cf_mass_mat[0:p_moments // 2, :] = f_poly_mat[0:p_moments // 2, :] - cf_mass_mat[-p_moments // 2:, :] = f_poly_mat[-p_moments // 2:, :] - + # The pure L^2 projection is already moment preserving R = np.linalg.solve(c_mass_mat, cf_mass_mat) return R @@ -287,8 +273,7 @@ def get_extension_restriction(coarse_space_1d, fine_space_1d, p_moments=-1): spl_type = coarse_space_1d.basis if not matching_interfaces: - grid = np.linspace( - fine_space_1d.breaks[0], fine_space_1d.breaks[-1], coarse_space_1d.ncells + 1) + grid = np.linspace(fine_space_1d.breaks[0], fine_space_1d.breaks[-1], coarse_space_1d.ncells + 1) coarse_space_1d_k_plus = SplineSpace( degree=fine_space_1d.degree, grid=grid, @@ -297,25 +282,17 @@ def get_extension_restriction(coarse_space_1d, fine_space_1d, p_moments=-1): E_1D = construct_extension_operator_1D( domain=coarse_space_1d_k_plus, codomain=fine_space_1d) + R_1D = construct_restriction_operator_1D( coarse_space_1d_k_plus, fine_space_1d, E_1D, p_moments) - ER_1D = E_1D @ R_1D + assert np.allclose(R_1D @ E_1D, np.eye(coarse_space_1d.nbasis), 1e-12, 1e-12) + else: ER_1D = R_1D = E_1D = sparse_eye( fine_space_1d.nbasis, format="lil") - # TODO remove later - assert ( - np.allclose( - np.linalg.norm( - R_1D @ E_1D - - np.eye( - coarse_space_1d.nbasis)), - 0, - 1e-12, - 1e-12)) return E_1D, R_1D, ER_1D @@ -418,8 +395,7 @@ def calculate_mixed_mass_matrix(domain_space, codomain_space): fine_basis = basis_ders_on_quad_grid(fknots, fdeg, quad_x, 0, spl_type) coarse_basis = [ basis_ders_on_irregular_grid( - knots, deg, q, cell_index( - breaks, q), 0, spl_type) for q in quad_x] + knots, deg, q, cell_index(breaks, q), 0, spl_type) for q in quad_x] fine_spans = elements_spans(fknots, deg) coarse_spans = [find_spans(knots, deg, q[0])[0] for q in quad_x] @@ -471,7 +447,6 @@ def calculate_poly_basis_integral(space_1d, p_moments=-1): enddom = breaks[-1] begdom = breaks[0] denom = enddom - begdom - order = max(p_moments + 1, deg + 1) u, w = gauss_legendre(order) @@ -484,8 +459,7 @@ def calculate_poly_basis_integral(space_1d, p_moments=-1): Mass_mat = np.zeros((p_moments + 1, space_1d.nbasis)) for ie1 in range(Nel): # loop on cells - for pol in range( - p_moments + 1): # loops on basis function in each cell + for pol in range(p_moments + 1): # loops on basis function in each cell for il2 in range(deg + 1): # loops on basis function in each cell val = 0. @@ -494,7 +468,7 @@ def calculate_poly_basis_integral(space_1d, p_moments=-1): x = quad_x[ie1, q1] # val += quad_w[ie1, q1] * v0 * ((enddom-x)/denom)**pol val += quad_w[ie1, q1] * v0 * \ - ((enddom - x) / denom)**(p_moments - pol) * (x / denom)**pol + comb(p_moments, pol) * ((enddom - x) / denom)**(p_moments - pol) * ((x - begdom) / denom)**pol locind2 = il2 + spans[ie1] - deg Mass_mat[pol, locind2] += val @@ -520,27 +494,24 @@ def get_1d_moment_correction(space_1d, p_moments=-1): """ if p_moments < 0: - return None + return [] if space_1d.ncells <= p_moments + 1: - print("Careful, the correction term is currently not independent of the mesh.") - + p_moments = space_1d.ncells - 2 + print(f"The prescribed degree of preserved moments was too high, given the number of cells in the patch. It has been reduced to degree {p_moments}.") + if p_moments >= 0: # to preserve moments of degree p we need 1+p conforming basis functions in the patch (the "interior" ones) # and for the given regularity constraint, there are # local_shape[conf_axis]-2*(1+reg) such conforming functions p_max = space_1d.nbasis - 3 if p_max < p_moments: - print( - " ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **") + print(" ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **") print(" ** WARNING -- WARNING -- WARNING ") - print( - f" ** conf. projection imposing C0 smoothness on scalar space along this axis :") - print( - f" ** there are not enough dofs in a patch to preserve moments of degree {p_moments} !") + print(f" ** conf. projection imposing C0 smoothness on scalar space along this axis :") + print(f" ** there are not enough dofs in a patch to preserve moments of degree {p_moments} !") print(f" ** Only able to preserve up to degree --> {p_max} <-- ") - print( - " ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **") + print(" ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** ** **") p_moments = p_max Mass_mat = calculate_poly_basis_integral(space_1d, p_moments) @@ -549,14 +520,16 @@ def get_1d_moment_correction(space_1d, p_moments=-1): return gamma -def construct_h1_conforming_projection( - Vh, reg_orders=0, p_moments=-1, hom_bc=False): +#============================================================================== +# Multipatch conforming projectors +#============================================================================== +def construct_h1_conforming_projection(Vh, reg_orders=0, p_moments=-1, hom_bc=False): """ Construct the conforming projection for a scalar space for a given regularity (0 continuous, -1 discontinuous). Parameters ---------- - Vh : TensorFemSpace + Vh : MultipatchFemSpace Finite Element Space coming from the discrete de Rham sequence. reg_orders : (int) @@ -582,8 +555,8 @@ def construct_h1_conforming_projection( # moment corrections perpendicular to interfaces # assume same moments everywhere - gamma = get_1d_moment_correction( - Vh.patch_spaces[0].spaces[0], p_moments=p_moments) + gamma = get_1d_moment_correction(Vh.spaces[0].spaces[0], p_moments=p_moments) + p_moments = len(gamma)-1 domain = Vh.symbolic_space.domain ndim = 2 @@ -592,22 +565,21 @@ def construct_h1_conforming_projection( l2g = Local2GlobalIndexMap(ndim, len(domain), n_components) for k in range(n_patches): - Vk = Vh.patch_spaces[k] + Vk = Vh.spaces[k] # T is a TensorFemSpace and S is a 1D SplineSpace shapes = [S.nbasis for S in Vk.spaces] l2g.set_patch_shapes(k, shapes) # P vertex # vertex correction matrix - Proj_vertex = sparse_eye(dim_tot, format="lil") + Proj_vertex = sparse_eye(dim_tot, format="lil") corner_indices = set() corners = get_corners(domain, False) def get_vertex_index_from_patch(patch, coords): - # coords = co[patch] - nbasis0 = Vh.patch_spaces[patch].spaces[coords[0]].nbasis - 1 - nbasis1 = Vh.patch_spaces[patch].spaces[coords[1]].nbasis - 1 + nbasis0 = Vh.spaces[patch].spaces[coords[0]].nbasis - 1 + nbasis1 = Vh.spaces[patch].spaces[coords[1]].nbasis - 1 # patch local index multi_index = [None] * ndim @@ -621,12 +593,11 @@ def vertex_moment_indices(axis, coords, patch, p_moments): if coords[axis] == 0: return range(1, p_moments + 2) else: - return range(Vh.patch_spaces[patch].spaces[coords[axis]].nbasis - 1 - 1, - Vh.patch_spaces[patch].spaces[coords[axis]].nbasis - 1 - p_moments - 2, -1) + return range(Vh.spaces[patch].spaces[coords[axis]].nbasis - 1 - 1, + Vh.spaces[patch].spaces[coords[axis]].nbasis - 1 - p_moments - 2, -1) # loop over all vertices for (bd, co) in corners.items(): - # len(co)=#v is the number of adjacent patches at a vertex corr = len(co) @@ -639,9 +610,9 @@ def vertex_moment_indices(axis, coords, patch, p_moments): corner_indices.add(ig) for patch2 in co: - # local vertex coordinates in patch2 coords2 = co[patch2] + # global index jg = get_vertex_index_from_patch(patch2, coords2) @@ -701,7 +672,6 @@ def vertex_moment_indices(axis, coords, patch, p_moments): corners = get_corners(domain, True) if hom_bc: for (bd, co) in corners.items(): - for patch1 in co: # local vertex coordinates in patch2 @@ -714,6 +684,7 @@ def vertex_moment_indices(axis, coords, patch, p_moments): # local vertex coordinates in patch2 coords2 = co[patch2] + # global index jg = get_vertex_index_from_patch(patch2, coords2) @@ -830,8 +801,8 @@ def get_mu_minus(j, coarse_space, fine_space, R): k_minus = get_patch_index_from_face(domain, I.minus) k_plus = get_patch_index_from_face(domain, I.plus) - I_minus_ncells = Vh.patch_spaces[k_minus].ncells - I_plus_ncells = Vh.patch_spaces[k_plus].ncells + I_minus_ncells = Vh.spaces[k_minus].ncells + I_plus_ncells = Vh.spaces[k_plus].ncells # logical directions normal to interface if I_minus_ncells <= I_plus_ncells: @@ -848,8 +819,8 @@ def get_mu_minus(j, coarse_space, fine_space, R): d_fine = 1 - fine_axis d_coarse = 1 - coarse_axis - space_fine = Vh.patch_spaces[k_fine] - space_coarse = Vh.patch_spaces[k_coarse] + space_fine = Vh.spaces[k_fine] + space_coarse = Vh.spaces[k_coarse] coarse_space_1d = space_coarse.spaces[d_coarse] fine_space_1d = space_fine.spaces[d_fine] @@ -962,7 +933,7 @@ def get_mu_minus(j, coarse_space, fine_space, R): if hom_bc: for bn in domain.boundary: k = get_patch_index_from_face(domain, bn) - space_k = Vh.patch_spaces[k] + space_k = Vh.spaces[k] axis = bn.axis d = 1 - axis @@ -979,17 +950,19 @@ def get_mu_minus(j, coarse_space, fine_space, R): pg = edge_moment_index(p, i, axis, ext, space_k, k) Proj_edge[pg, ig] = gamma[p] else: - if corner_indices.issuperset({ig}): - mu_minus = get_mu_minus( - j, space_k_1d, space_k_1d, np.eye( - space_k_1d.nbasis)) + #if corner_indices.issuperset({ig}): + mu_minus = get_mu_minus( + i, space_k_1d, space_k_1d, np.eye( + space_k_1d.nbasis)) - for p in range(p_moments + 1): - for m in range(space_k_1d.nbasis): - pg = edge_moment_index( - p, m, axis, ext, space_k, k) - Proj_edge[pg, ig] = gamma[p] * mu_minus[m] - else: + for p in range(p_moments + 1): + for m in range(space_k_1d.nbasis): + pg = edge_moment_index( + p, m, axis, ext, space_k, k) + Proj_edge[pg, ig] = gamma[p] * mu_minus[m] + + if not corner_indices.issuperset({ig}): + corner_indices.add(ig) multi_index = [None] * ndim for p in range(p_moments + 1): @@ -997,22 +970,20 @@ def get_mu_minus(j, coarse_space, fine_space, R): 1 else space_k.spaces[axis].nbasis - 1 - p - 1 for pd in range(p_moments + 1): multi_index[1 - axis] = pd + \ - 1 if i == 0 else space_k.spaces[1 - - axis].nbasis - 1 - pd - 1 + 1 if i == 0 else space_k.spaces[1 - axis].nbasis - 1 - pd - 1 pg = l2g.get_index(k, 0, multi_index) Proj_edge[pg, ig] = gamma[p] * gamma[pd] return Proj_edge @ Proj_vertex -def construct_hcurl_conforming_projection( - Vh, reg_orders=0, p_moments=-1, hom_bc=False): +def construct_hcurl_conforming_projection(Vh, reg_orders=0, p_moments=-1, hom_bc=False): """ Construct the conforming projection for a vector Hcurl space for a given regularity (0 continuous, -1 discontinuous). Parameters ---------- - Vh : TensorFemSpace + Vh : MultipatchFemSpace Finite Element Space coming from the discrete de Rham sequence. reg_orders : (int) @@ -1037,8 +1008,9 @@ def construct_hcurl_conforming_projection( return sparse_eye(dim_tot, format="lil") # moment corrections perpendicular to interfaces - gamma = [get_1d_moment_correction( - Vh.patch_spaces[0].spaces[1 - d].spaces[d], p_moments=p_moments) for d in range(2)] + # should be in the V^0 spaces + gamma = [get_1d_moment_correction(Vh.spaces[0].spaces[1 - d].spaces[d], p_moments=p_moments) for d in range(2)] + p_moments = min([len(g) for g in gamma])-1 domain = Vh.symbolic_space.domain ndim = 2 @@ -1047,7 +1019,7 @@ def construct_hcurl_conforming_projection( l2g = Local2GlobalIndexMap(ndim, len(domain), n_components) for k in range(n_patches): - Vk = Vh.patch_spaces[k] + Vk = Vh.spaces[k] # T is a TensorFemSpace and S is a 1D SplineSpace shapes = [[S.nbasis for S in T.spaces] for T in Vk.spaces] l2g.set_patch_shapes(k, *shapes) @@ -1073,7 +1045,7 @@ def edge_moment_index(p, i, axis, ext, space, k): multi_index[axis] = p + 1 if ext == - \ 1 else space.spaces[1 - axis].spaces[axis].nbasis - 1 - p - 1 return l2g.get_index(k, 1 - axis, multi_index) - + # loop over all interfaces for I in Interfaces: direction = I.ornt @@ -1086,8 +1058,8 @@ def edge_moment_index(p, i, axis, ext, space, k): minus_axis, plus_axis = I.minus.axis, I.plus.axis # logical directions along the interface d_minus, d_plus = 1 - minus_axis, 1 - plus_axis - I_minus_ncells = Vh.patch_spaces[k_minus].spaces[d_minus].ncells[d_minus] - I_plus_ncells = Vh.patch_spaces[k_plus].spaces[d_plus].ncells[d_plus] + I_minus_ncells = Vh.spaces[k_minus].spaces[d_minus].ncells[d_minus] + I_plus_ncells = Vh.spaces[k_plus].spaces[d_plus].ncells[d_plus] # logical directions normal to interface if I_minus_ncells <= I_plus_ncells: @@ -1104,8 +1076,8 @@ def edge_moment_index(p, i, axis, ext, space, k): d_fine = 1 - fine_axis d_coarse = 1 - coarse_axis - space_fine = Vh.patch_spaces[k_fine] - space_coarse = Vh.patch_spaces[k_coarse] + space_fine = Vh.spaces[k_fine] + space_coarse = Vh.spaces[k_coarse] coarse_space_1d = space_coarse.spaces[d_coarse].spaces[d_coarse] fine_space_1d = space_fine.spaces[d_fine].spaces[d_fine] @@ -1164,7 +1136,7 @@ def edge_moment_index(p, i, axis, ext, space, k): # boundary condition for bn in domain.boundary: k = get_patch_index_from_face(domain, bn) - space_k = Vh.patch_spaces[k] + space_k = Vh.spaces[k] axis = bn.axis if not hom_bc: @@ -1184,3 +1156,346 @@ def edge_moment_index(p, i, axis, ext, space, k): Proj_edge[pg, ig] = gamma[d][p] return Proj_edge + +#============================================================================== +# Singlepatch conforming projectors +#============================================================================== +def construct_h1_singlepatch_conforming_projection(Vh, reg_orders=0, p_moments=-1, hom_bc=False): + """ + Construct the conforming projection for a scalar space for a given regularity (0 continuous, -1 discontinuous). + + Parameters + ---------- + Vh : MultipatchFemSpace + Finite Element Space coming from the discrete de Rham sequence. + + reg_orders : (int) + Regularity in each space direction -1 or 0. + + p_moments : (int) + Number of moments to be preserved. + + hom_bc : (bool) + Homogeneous boundary conditions. + + Returns + ------- + cP : scipy.sparse.csr_array + Conforming projection as a sparse matrix. + """ + + dim_tot = Vh.nbasis + + # fully discontinuous space + if reg_orders < 0 or not hom_bc: + return sparse_eye(dim_tot, format="lil") + + # moment corrections perpendicular to interfaces + # assume same moments everywhere + gamma = get_1d_moment_correction(Vh.spaces[0], p_moments=p_moments) + p_moments = len(gamma)-1 + + domain = Vh.symbolic_space.domain + ndim = 2 + n_components = 1 + n_patches = len(domain) + + l2g = Local2GlobalIndexMap(ndim, len(domain), n_components) + # T is a TensorFemSpace and S is a 1D SplineSpace + shapes = [S.nbasis for S in Vh.spaces] + l2g.set_patch_shapes(0, shapes) + + # P vertex + # vertex correction matrix + Proj_vertex = sparse_eye(dim_tot, format="lil") + + + def get_vertex_index(coords): + nbasis0 = Vh.spaces[coords[0]].nbasis - 1 + nbasis1 = Vh.spaces[coords[1]].nbasis - 1 + + # patch local index + multi_index = [None] * ndim + multi_index[0] = 0 if coords[0] == 0 else nbasis0 + multi_index[1] = 0 if coords[1] == 0 else nbasis1 + + # global index + return l2g.get_index(0, 0, multi_index) + + def vertex_moment_indices(axis, coords, p_moments): + if coords[axis] == 0: + return range(1, p_moments + 2) + else: + return range(Vh.spaces[coords[axis]].nbasis - 1 - 1, + Vh.spaces[coords[axis]].nbasis - 1 - p_moments - 2, -1) + + # boundary conditions + + for co in [(0,0), (1,0), (0,1), (1,1)]: + + # global index + ig = get_vertex_index(co) + + # conformity constraint + Proj_vertex[ig, ig] = 0 + + + if p_moments == -1: + continue + + # moment corrections from patch1 to patch1 + axis = 0 + d = 1 + multi_index_p = [None] * ndim + + d_moment_index = vertex_moment_indices(d, co, p_moments) + axis_moment_index = vertex_moment_indices(axis, co, p_moments) + + for pd in range(0, p_moments + 1): + multi_index_p[d] = d_moment_index[pd] + + for p in range(0, p_moments + 1): + multi_index_p[axis] = axis_moment_index[p] + + pg = l2g.get_index(0, 0, multi_index_p) + Proj_vertex[pg, ig] = gamma[p] * gamma[pd] + + # P edge + # edge correction matrix + Proj_edge = sparse_eye(dim_tot, format="lil") + + def get_edge_index(j, axis, ext): + multi_index = [None] * ndim + multi_index[axis] = 0 if ext == - 1 else Vh.spaces[axis].nbasis - 1 + multi_index[1 - axis] = j + return l2g.get_index(0, 0, multi_index) + + def edge_moment_index(p, i, axis, ext): + multi_index = [None] * ndim + multi_index[1 - axis] = i + multi_index[axis] = p + 1 if ext == -1 else Vh.spaces[axis].nbasis - 1 - p - 1 + return l2g.get_index(0, 0, multi_index) + + + def get_mu_minus(j, coarse_space, fine_space, R): + mu_plus = np.zeros(fine_space.nbasis) + mu_minus = np.zeros(coarse_space.nbasis) + + if j == 0: + mu_minus[0] = 1 + for p in range(p_moments + 1): + mu_plus[p + 1] = gamma[p] + else: + mu_minus[-1] = 1 + for p in range(p_moments + 1): + mu_plus[-1 - (p + 1)] = gamma[p] + + for m in range(coarse_space.nbasis): + for l in range(fine_space.nbasis): + mu_minus[m] += R[m, l] * mu_plus[l] + + if j == 0: + mu_minus[m] -= R[m, 0] + else: + mu_minus[m] -= R[m, -1] + + return mu_minus + + + # boundary condition + for bn in domain.boundary: + space_k = Vh + axis = bn.axis + + d = 1 - axis + ext = bn.ext + space_k_1d = space_k.spaces[d] + + for i in range(0, space_k_1d.nbasis): + ig = get_edge_index(i, axis, ext) + Proj_edge[ig, ig] = 0 + + if (i != 0 and i != space_k_1d.nbasis - 1): + for p in range(p_moments + 1): + + pg = edge_moment_index(p, i, axis, ext) + Proj_edge[pg, ig] = gamma[p] + else: + #if corner_indices.issuperset({ig}): + mu_minus = get_mu_minus( + i, space_k_1d, space_k_1d, np.eye( + space_k_1d.nbasis)) + + for p in range(p_moments + 1): + for m in range(space_k_1d.nbasis): + pg = edge_moment_index( + p, m, axis, ext) + Proj_edge[pg, ig] = gamma[p] * mu_minus[m] + + + return Proj_edge @ Proj_vertex + + +def construct_hcurl_singlepatch_conforming_projection(Vh, reg_orders=0, p_moments=-1, hom_bc=False): + """ + Construct the conforming projection for a single patch vector Hcurl space for a given regularity (0 continuous, -1 discontinuous). + + Parameters + ---------- + Vh : MultipatchFemSpace + Finite Element Space coming from the discrete de Rham sequence. + + reg_orders : (int) + Regularity in each space direction -1 or 0. + + p_moments : (int) + Number of polynomial moments to be preserved. + + hom_bc : (bool) + Tangential homogeneous boundary conditions. + + Returns + ------- + cP : scipy.sparse.csr_array + Conforming projection as a sparse matrix. + """ + + dim_tot = Vh.nbasis + + # fully discontinuous space + if reg_orders < 0 or not hom_bc: + return sparse_eye(dim_tot, format="lil") + + # moment corrections perpendicular to interfaces + # should be in the V^0 spaces + + gamma = [get_1d_moment_correction(Vh.spaces[1 - d].spaces[d], p_moments=p_moments) for d in range(2)] + p_moments = min([len(g) for g in gamma])-1 + + domain = Vh.symbolic_space.domain + ndim = 2 + n_components = 2 + n_patches = len(domain) + + l2g = Local2GlobalIndexMap(ndim, len(domain), n_components) + # T is a TensorFemSpace and S is a 1D SplineSpace + shapes = [[S.nbasis for S in T.spaces] for T in Vh.spaces] + l2g.set_patch_shapes(0, *shapes) + + # P edge + # edge correction matrix + Proj_edge = sparse_eye(dim_tot, format="lil") + + def get_edge_index(j, axis, ext): + multi_index = [None] * ndim + multi_index[axis] = 0 if ext == -1 else Vh.spaces[1 - axis].spaces[axis].nbasis - 1 + multi_index[1 - axis] = j + return l2g.get_index(0, 1 - axis, multi_index) + + def edge_moment_index(p, i, axis, ext): + multi_index = [None] * ndim + multi_index[1 - axis] = i + multi_index[axis] = p + 1 if ext == -1 else Vh.spaces[1 - axis].spaces[axis].nbasis - 1 - p - 1 + return l2g.get_index(0, 1 - axis, multi_index) + + + # boundary condition + for bn in domain.boundary: + + axis = bn.axis + d = 1 - axis + ext = bn.ext + space_1d = Vh.spaces[d].spaces[d] + + for i in range(0, space_1d.nbasis): + ig = get_edge_index(i, axis, ext) + Proj_edge[ig, ig] = 0 + + for p in range(p_moments + 1): + + pg = edge_moment_index(p, i, axis, ext) + Proj_edge[pg, ig] = gamma[d][p] + + return Proj_edge + + +# =============================================================================== + +class ConformingProjectionV0(FemLinearOperator): + """ + Conforming projection from global broken V0 space to conforming global V0 space + Defined by averaging of interface (including vertex) dofs + and adding moment correction terms + + Parameters + ---------- + V0h: + The discrete space + + p_moments: + Number of polynomial moments to be preserved in the projection. + + hom_bc : + Apply homogenous boundary conditions if True + """ + def __init__( + self, + V0h, + mom_pres=False, + p_moments=-1, + hom_bc=False): + + if mom_pres: + if V0h.is_multipatch: + p_moments = max(p_moments, max(V0h.degree[0])) + else: + p_moments = max(p_moments, max(V0h.degree)) + + FemLinearOperator.__init__(self, fem_domain=V0h, fem_codomain=V0h) + + if V0h.is_multipatch: + sparse_matrix = construct_h1_conforming_projection(V0h, reg_orders=0, p_moments=p_moments, hom_bc=hom_bc) + else: + sparse_matrix = construct_h1_singlepatch_conforming_projection(V0h, reg_orders=0, p_moments=p_moments, hom_bc=hom_bc) + + self._linop = SparseMatrixLinearOperator(self.linop_domain, self.linop_codomain, sparse_matrix.tocsr()) + + +class ConformingProjectionV1(FemLinearOperator): + """ + Conforming projection from global broken V1 space to conforming V1 global space + Defined by averaging of (only) interface dofs + and adding moment correction terms + + Parameters + ---------- + V1h: + The discrete space + + p_moments: + Number of polynomial moments to be preserved in the projection. + + hom_bc : + Apply homogenous boundary conditions if True + """ + def __init__( + self, + V1h, + mom_pres=False, + p_moments=-1, + hom_bc=False): + + if mom_pres: + if V1h.is_multipatch: + p_moments = max(p_moments, max(V1h.spaces[0].degree[0])) + else: + p_moments = max(p_moments, max(V1h.degree[0])) + + FemLinearOperator.__init__(self, fem_domain=V1h, fem_codomain=V1h) + + if V1h.is_multipatch: + sparse_matrix = construct_hcurl_conforming_projection(V1h, reg_orders=0, p_moments=p_moments, hom_bc=hom_bc) + else: + sparse_matrix = construct_hcurl_singlepatch_conforming_projection(V1h, reg_orders=0, p_moments=p_moments, hom_bc=hom_bc) + + self._linop = SparseMatrixLinearOperator(self.linop_domain, self.linop_codomain, sparse_matrix.tocsr()) diff --git a/psydac/feec/derivatives.py b/psydac/feec/derivatives.py index f7f28ccbd..1fd3de5d2 100644 --- a/psydac/feec/derivatives.py +++ b/psydac/feec/derivatives.py @@ -9,38 +9,28 @@ from psydac.fem.vector import VectorFemSpace from psydac.fem.tensor import TensorFemSpace from psydac.linalg.basic import IdentityOperator -from psydac.fem.basic import FemField, FemSpace +from psydac.fem.basic import FemField, FemSpace, FemLinearOperator from psydac.linalg.basic import LinearOperator from psydac.ddm.cart import DomainDecomposition, CartDecomposition __all__ = ( 'DirectionalDerivativeOperator', - 'DiffOperator', - 'Derivative_1D', - 'Gradient_2D', - 'Gradient_3D', - 'ScalarCurl_2D', - 'VectorCurl_2D', - 'Curl_3D', - 'Divergence_2D', - 'Divergence_3D', - 'block_tostencil' + 'Derivative1D', + 'Gradient2D', + 'Gradient3D', + 'ScalarCurl2D', + 'VectorCurl2D', + 'Curl3D', + 'Divergence2D', + 'Divergence3D', + 'BrokenGradient2D', + 'BrokenTransposedGradient2D', + 'BrokenScalarCurl2D', + 'BrokenTransposedScalarCurl2D', ) #==================================================================================================== -def block_tostencil(M): - """ - Convert a BlockLinearOperator that contains KroneckerStencilMatrix objects - to a BlockLinearOperator that contains StencilMatrix objects - """ - blocks = [list(b) for b in M.blocks] - for i1,b in enumerate(blocks): - for i2, mat in enumerate(b): - if mat is None: - continue - blocks[i1][i2] = mat.tostencil() - return BlockLinearOperator(M.domain, M.codomain, blocks=blocks) - +# Singlepatch derivative operators #==================================================================================================== class DirectionalDerivativeOperator(LinearOperator): """ @@ -361,40 +351,7 @@ def copy(self): self._diffdir, negative=self._negative, transposed=self._transposed) #==================================================================================================== -class DiffOperator: - def __init__(self, domain, codomain, matrix): - assert isinstance(domain, FemSpace) - assert isinstance(codomain, FemSpace) - assert isinstance(matrix, LinearOperator) - assert domain.coeff_space is matrix.domain - assert codomain.coeff_space is matrix.codomain - - self._domain = domain - self._codomain = codomain - self._matrix = matrix - - @property - def matrix(self): - return self._matrix - - @property - def domain(self): - return self._domain - - @property - def codomain(self): - return self._codomain - - def __call__(self, u): - assert isinstance(u, FemField) - assert u.space == self.domain - - coeffs = self.matrix.dot(u.coeffs) - - return FemField(self.codomain, coeffs=coeffs) - -#==================================================================================================== -class Derivative_1D(DiffOperator): +class Derivative1D(FemLinearOperator): """ 1D derivative. @@ -415,10 +372,10 @@ def __init__(self, H1, L2): assert H1.degree[0] == L2.degree[0] + 1 # Store data in object - super().__init__(H1, L2, DirectionalDerivativeOperator(H1.coeff_space, L2.coeff_space, 0)) + super().__init__(fem_domain = H1, fem_codomain = L2, linop = DirectionalDerivativeOperator(H1.coeff_space, L2.coeff_space, 0)) #==================================================================================================== -class Gradient_2D(DiffOperator): +class Gradient2D(FemLinearOperator): """ Gradient operator in 2D. @@ -434,7 +391,7 @@ class Gradient_2D(DiffOperator): def __init__(self, H1, Hcurl): assert isinstance( H1, TensorFemSpace); assert H1.ldim == 2 - assert isinstance(Hcurl, VectorFemSpace); assert Hcurl.ldim == 2 + assert isinstance(Hcurl, VectorFemSpace); assert Hcurl.ldim == 2 assert Hcurl.spaces[0].periodic == H1.periodic assert Hcurl.spaces[1].periodic == H1.periodic @@ -454,11 +411,10 @@ def __init__(self, H1, Hcurl): matrix = BlockLinearOperator(H1.coeff_space, Hcurl.coeff_space, blocks=blocks) # Store data in object - super().__init__(H1, Hcurl, matrix) - + super().__init__(fem_domain = H1, fem_codomain = Hcurl, linop = matrix) #==================================================================================================== -class Gradient_3D(DiffOperator): +class Gradient3D(FemLinearOperator): """ Gradient operator in 3D. @@ -497,10 +453,10 @@ def __init__(self, H1, Hcurl): matrix = BlockLinearOperator(H1.coeff_space, Hcurl.coeff_space, blocks=blocks) # Store data in object - super().__init__(H1, Hcurl, matrix) + super().__init__(fem_domain = H1, fem_codomain = Hcurl, linop = matrix) #==================================================================================================== -class ScalarCurl_2D(DiffOperator): +class ScalarCurl2D(FemLinearOperator): """ Scalar curl operator in 2D: computes a scalar field from a vector field. @@ -536,10 +492,10 @@ def __init__(self, Hcurl, L2): matrix = BlockLinearOperator(Hcurl.coeff_space, L2.coeff_space, blocks=blocks) # Store data in object - super().__init__(Hcurl, L2, matrix) + super().__init__(fem_domain = Hcurl, fem_codomain = L2, linop = matrix) #==================================================================================================== -class VectorCurl_2D(DiffOperator): +class VectorCurl2D(FemLinearOperator): """ Vector curl operator in 2D: computes a vector field from a scalar field. This is sometimes called the 'rot' operator. @@ -576,10 +532,10 @@ def __init__(self, H1, Hdiv): matrix = BlockLinearOperator(H1.coeff_space, Hdiv.coeff_space, blocks=blocks) # Store data in object - super().__init__(H1, Hdiv, matrix) + super().__init__(fem_domain = H1, fem_codomain = Hdiv, linop = matrix) #==================================================================================================== -class Curl_3D(DiffOperator): +class Curl3D(FemLinearOperator): """ Curl operator in 3D. @@ -626,10 +582,10 @@ def __init__(self, Hcurl, Hdiv): # ... # Store data in object - super().__init__(Hcurl, Hdiv, matrix) + super().__init__(fem_domain = Hcurl, fem_codomain = Hdiv, linop = matrix) #==================================================================================================== -class Divergence_2D(DiffOperator): +class Divergence2D(FemLinearOperator): """ Divergence operator in 2D. @@ -665,10 +621,10 @@ def __init__(self, Hdiv, L2): matrix = BlockLinearOperator(Hdiv.coeff_space, L2.coeff_space, blocks=blocks) # Store data in object - super().__init__(Hdiv, L2, matrix) + super().__init__(fem_domain = Hdiv, fem_codomain = L2, linop = matrix) #==================================================================================================== -class Divergence_3D(DiffOperator): +class Divergence3D(FemLinearOperator): """ Divergence operator in 3D. @@ -707,4 +663,118 @@ def __init__(self, Hdiv, L2): matrix = BlockLinearOperator(Hdiv.coeff_space, L2.coeff_space, blocks=blocks) # Store data in object - super().__init__(Hdiv, L2, matrix) + super().__init__(fem_domain = Hdiv, fem_codomain = L2, linop = matrix) + +#==================================================================================================== +# 2D Multipatch derivative operators +#==================================================================================================== +class BrokenGradient2D(FemLinearOperator): + """ + Gradient operator in a 2D multipatch domain, + acting independently on each patch. + In general, the resulting field is therefore discontinuous, or "broken". + + Parameters + ---------- + V0h : MultipatchFemSpace + Domain of the gradient operator. + + V1h : MultipatchFemSpace + Codomain of the gradient operator. + """ + def __init__(self, V0h, V1h): + + FemLinearOperator.__init__(self, fem_domain=V0h, fem_codomain=V1h) + + D0s = [Gradient2D(V0, V1) for V0, V1 in zip(V0h.spaces, V1h.spaces)] + + self._linop = BlockLinearOperator(self.linop_domain, self.linop_codomain, blocks={ + (i, i): D0i.linop for i, D0i in enumerate(D0s)}) + + def transpose(self, conjugate=False): + # todo (MCP): define as the dual differential operator + return BrokenTransposedGradient2D(self.fem_domain, self.fem_codomain) + +# ============================================================================== +class BrokenTransposedGradient2D(FemLinearOperator): + """ + Transposed gradient operator in a 2D multipatch domain, + acting independently on each patch. + In general, the resulting field is therefore discontinuous, or "broken". + + Parameters + ---------- + V0h : MultipatchFemSpace + Codomain of the transposed gradient operator. + + V1h : MultipatchFemSpace + Domain of the transposed gradient operator. + """ + def __init__(self, V0h, V1h): + + FemLinearOperator.__init__(self, fem_domain=V1h, fem_codomain=V0h) + + D0s = [Gradient2D(V0, V1) for V0, V1 in zip(V0h.spaces, V1h.spaces)] + + self._linop = BlockLinearOperator(self.linop_domain, self.linop_codomain, blocks={ + (i, i): D0i.linop.T for i, D0i in enumerate(D0s)}) + + def transpose(self, conjugate=False): + # todo (MCP): discard + return BrokenGradient2D(self.fem_codomain, self.fem_domain) + +# ============================================================================== +class BrokenScalarCurl2D(FemLinearOperator): + """ + Scalar curl operator in a 2D multipatch domain, + acting independently on each patch. + In general, the resulting field is therefore discontinuous, or "broken". + + Parameters + ---------- + V1h : MultipatchFemSpace + Domain of the scalar curl operator. + + V2h : MultipatchFemSpace + Codomain of the scalar curl operator. + """ + def __init__(self, V1h, V2h): + + FemLinearOperator.__init__(self, fem_domain=V1h, fem_codomain=V2h) + + D1s = [ScalarCurl2D(V1, V2) for V1, V2 in zip(V1h.spaces, V2h.spaces)] + + self._linop = BlockLinearOperator(self.linop_domain, self.linop_codomain, blocks={ + (i, i): D1i.linop for i, D1i in enumerate(D1s)}) + + def transpose(self, conjugate=False): + return BrokenTransposedScalarCurl2D( + V1h=self.fem_domain, V2h=self.fem_codomain) + + +# ============================================================================== +class BrokenTransposedScalarCurl2D(FemLinearOperator): + """ + Transposed scalar curl operator in a 2D multipatch domain, + acting independently on each patch. + In general, the resulting field is therefore discontinuous, or "broken". + + Parameters + ---------- + V1h : MultipatchFemSpace + Codomain of the transposed scalar curl operator. + + V2h : MultipatchFemSpace + Domain of the transposed scalar curl operator. + """ + def __init__(self, V1h, V2h): + + FemLinearOperator.__init__(self, fem_domain=V2h, fem_codomain=V1h) + + D1s = [ScalarCurl2D(V1, V2) for V1, V2 in zip(V1h.spaces, V2h.spaces)] + + self._linop = BlockLinearOperator(self.linop_domain, self.linop_codomain, blocks={ + (i, i): D1i.linop.T for i, D1i in enumerate(D1s)}) + + def transpose(self, conjugate=False): + return BrokenScalarCurl2D(V1h=self.fem_codomain, V2h=self.fem_domain) diff --git a/psydac/feec/global_projectors.py b/psydac/feec/global_geometric_projectors.py similarity index 94% rename from psydac/feec/global_projectors.py rename to psydac/feec/global_geometric_projectors.py index e5a61a0bb..019c2f441 100644 --- a/psydac/feec/global_projectors.py +++ b/psydac/feec/global_geometric_projectors.py @@ -4,26 +4,27 @@ from psydac.linalg.kron import KroneckerLinearSolver, KroneckerStencilMatrix from psydac.linalg.stencil import StencilMatrix, StencilVectorSpace -from psydac.linalg.block import BlockLinearOperator +from psydac.linalg.block import BlockLinearOperator, BlockVector from psydac.core.bsplines import quadrature_grid from psydac.utilities.quadratures import gauss_legendre from psydac.fem.basic import FemField from psydac.fem.tensor import TensorFemSpace -from psydac.fem.vector import VectorFemSpace +from psydac.fem.vector import VectorFemSpace, MultipatchFemSpace from psydac.ddm.cart import DomainDecomposition, CartDecomposition from psydac.utilities.utils import roll_edges from abc import ABCMeta, abstractmethod -__all__ = ('GlobalProjector', 'Projector_H1', 'Projector_Hcurl', 'Projector_Hdiv', 'Projector_L2', +__all__ = ('GlobalGeometricProjector', 'GlobalGeometricProjectorH1', 'GlobalGeometricProjectorHcurl', 'GlobalGeometricProjectorHdiv', 'GlobalGeometricProjectorL2', + 'MultipatchGeometricProjector', 'evaluate_dofs_1d_0form', 'evaluate_dofs_1d_1form', 'evaluate_dofs_2d_0form', 'evaluate_dofs_2d_1form_hcurl', 'evaluate_dofs_2d_1form_hdiv', 'evaluate_dofs_2d_2form', 'evaluate_dofs_3d_0form', 'evaluate_dofs_3d_1form', 'evaluate_dofs_3d_2form', 'evaluate_dofs_3d_3form') #============================================================================== -class GlobalProjector(metaclass=ABCMeta): +class GlobalGeometricProjector(metaclass=ABCMeta): """ Projects callable functions to some scalar or vector FEM space. @@ -43,7 +44,7 @@ class GlobalProjector(metaclass=ABCMeta): space : VectorFemSpace | TensorFemSpace Some finite element space, codomain of the projection operator. The exact structure where to use histopolation and where interpolation - has to be given by a subclass of the GlobalProjector class. + has to be given by a subclass of the GlobalGeometricProjector class. As of now, it is implicitly assumed for a VectorFemSpace, that for each direction that all spaces with interpolation are the same, and all spaces with histopolation are the same (i.e. yield the same quadrature/interpolation points etc.); so use with care on an arbitrary VectorFemSpace. @@ -389,7 +390,9 @@ def __call__(self, fun): return FemField(self._space, coeffs=coeffs) #============================================================================== -class Projector_H1(GlobalProjector): +# SINGLEPATCH PROJECTORS +#============================================================================== +class GlobalGeometricProjectorH1(GlobalGeometricProjector): """ Projector from H1 to an H1-conforming finite element space (i.e. a finite dimensional subspace of H1) constructed with tensor-product B-splines in 1, @@ -439,7 +442,7 @@ def __call__(self, fun): return super().__call__(fun) #============================================================================== -class Projector_Hcurl(GlobalProjector): +class GlobalGeometricProjectorHcurl(GlobalGeometricProjector): """ Projector from H(curl) to an H(curl)-conforming finite element space, i.e. a finite dimensional subspace of H(curl), constructed with tensor-product @@ -512,7 +515,7 @@ def __call__(self, fun): return super().__call__(fun) #============================================================================== -class Projector_Hdiv(GlobalProjector): +class GlobalGeometricProjectorHdiv(GlobalGeometricProjector): """ Projector from H(div) to an H(div)-conforming finite element space, i.e. a finite dimensional subspace of H(div), constructed with tensor-product @@ -589,7 +592,7 @@ def __call__(self, fun): return super().__call__(fun) #============================================================================== -class Projector_L2(GlobalProjector): +class GlobalGeometricProjectorL2(GlobalGeometricProjector): """ Projector from L2 to an L2-conforming finite element space (i.e. a finite dimensional subspace of L2) constructed with tensor-product M-splines in 1, @@ -648,7 +651,8 @@ def __call__(self, fun): """ return super().__call__(fun) -class Projector_H1vec(GlobalProjector): +#============================================================================== +class GlobalGeometricProjectorH1vec(GlobalGeometricProjector): """ Projector from H1^3 = H1 x H1 x H1 to a conforming finite element space, i.e. a finite dimensional subspace of H1^3, constructed with tensor-product @@ -712,6 +716,42 @@ def __call__(self, fun): """ return super().__call__(fun) +#============================================================================== +# MULTIPATCH PROJECTORS (2D) +#============================================================================== +class MultipatchGeometricProjector: + """ + Global Geometric Projector base class for multipatch domains. + + Parameters + ---------- + space : MultipatchFemSpace + Multipatch finite element space, codomain of the projection operator. + Projector : type[GlobalGeometricProjector] + Class of the projector to instantiate for each patch. + nquads : Iterable[int] + Number of quadrature points per cell along each direction. + This is a parameter passed to the constructor of Projector. + """ + + def __init__(self, space, Projector, nquads=None): + assert isinstance(space, MultipatchFemSpace) + assert isinstance(Projector, type) + assert issubclass(Projector, GlobalGeometricProjector) + + self._Vh = Vh = space + self._Ps = [Projector(V, nquads=nquads) for V in Vh.spaces] + + def __call__(self, funs): + """ + project a list of functions given in the logical domain + """ + us = [P(fun) for P, fun, in zip(self._Ps, funs)] + + u_c = BlockVector(self._Vh.coeff_space, blocks=[uj.coeffs for uj in us]) + + return FemField(self._Vh, coeffs=u_c) + #============================================================================== # 1D DEGREES OF FREEDOM #============================================================================== diff --git a/psydac/feec/hodge.py b/psydac/feec/hodge.py new file mode 100644 index 000000000..26cb702b4 --- /dev/null +++ b/psydac/feec/hodge.py @@ -0,0 +1,148 @@ +import os +import numpy as np + +from sympde.topology import elements_of +from sympde.topology.space import ScalarFunction +from sympde.calculus import dot +from sympde.expr.expr import BilinearForm +from sympde.expr.expr import integral + +from psydac.api.settings import PSYDAC_BACKENDS + +# =============================================================================== +class HodgeOperator: + """ + Change of basis operator: dual basis -> primal basis + + self._linop: matrix (LinearOperator) of the primal Hodge = this is the mass matrix ! + self.dual_linop: this is the INVERSE mass matrix (LinearOperator) + + Parameters + ---------- + Vh: + The discrete space + + domain_h: + The discrete domain of the projector + + metric : + the metric of the de Rham complex + + backend_language: + The backend used to accelerate the code + + Notes + ----- + We only support the identity metric, this implies that the dual Hodge is the inverse of the primal one. + # todo: allow for non-identity metrics + """ + + def __init__(self, Vh, domain_h, metric='identity', backend_language='python'): + + self._fem_domain = Vh + self._fem_codomain = Vh + + # FemLinearOperators + self._primal_hodge = None + self._dual_hodge = None + + # LinearOperators + self._linop = None + self._dual_linop = None + + self._domain_h = domain_h + self._backend_language = backend_language + + if not (metric == 'identity'): + raise NotImplementedError('only the identity metric is available') + + self._metric = metric + + def assemble_matrix(self): + """ + the Hodge matrix is the patch-wise multi-patch mass matrix + it is not stored by default but assembled on demand + """ + from psydac.api.discretization import discretize + from psydac.fem.basic import FemLinearOperator + + if self._linop is None: + Vh = self._fem_domain + assert Vh == self._fem_codomain + + V = Vh.symbolic_space + domain = V.domain + u, v = elements_of(V, names='u, v') + + if isinstance(u, ScalarFunction): + expr = u * v + else: + expr = dot(u, v) + + a = BilinearForm((u, v), integral(domain, expr)) + ah = discretize(a, self._domain_h, [Vh, Vh], backend=PSYDAC_BACKENDS[self._backend_language]) + + self._linop = ah.assemble() # Mass matrix in stencil format + + self._primal_hodge = FemLinearOperator(self._fem_domain, self._fem_codomain, linop=self._linop) + + def assemble_dual_matrix(self, solver ='cg', **kwargs): + """ + the dual Hodge matrix is the patch-wise inverse of the multi-patch mass matrix + it is not stored by default but computed on demand, by approximate local (patch-wise) inversion of the mass matrix + """ + from psydac.linalg.solvers import inverse + from psydac.linalg.block import BlockLinearOperator + from psydac.fem.basic import FemLinearOperator + + if self._dual_linop is None: + if not self._linop: + self.assemble_matrix() + + M = self._linop # mass matrix of the (primal) basis + + if self._fem_domain.is_multipatch: + + nrows = M.n_block_rows + ncols = M.n_block_cols + + inv_M_blocks = [list(b) for b in M.blocks] + for i in range(nrows): + Mii = M[i, i] + inv_Mii = inverse(Mii, solver=solver, **kwargs) + inv_M_blocks[i][i] = inv_Mii + + self._dual_linop = BlockLinearOperator(M.codomain, M.domain, blocks=inv_M_blocks) + self._dual_hodge = FemLinearOperator(self._fem_codomain, self._fem_domain, linop=self._dual_linop) + + else: + inv_M = inverse(M, solver=solver, **kwargs) + self._dual_hodge = FemLinearOperator(self._fem_codomain, self._fem_domain, linop=self._dual_linop) + + @property + def linop(self): + if self._linop is None: + self.assemble_matrix() + + return self._linop + + @property + def dual_linop(self): + if self._dual_linop is None: + self.assemble_dual_matrix() + + return self._dual_linop + + @property + def hodge(self): + if self._linop is None: + self.assemble_matrix() + + return self._primal_hodge + + @property + def dual_hodge(self): + if self._dual_linop is None: + self.assemble_dual_matrix() + + return self._dual_hodge diff --git a/psydac/feec/multipatch/api.py b/psydac/feec/multipatch/api.py deleted file mode 100644 index 566815421..000000000 --- a/psydac/feec/multipatch/api.py +++ /dev/null @@ -1,301 +0,0 @@ -# coding: utf-8 -import os - -from sympde.topology import Derham -from sympde.topology import element_of, elements_of -from sympde.topology.space import ScalarFunction -from sympde.calculus import grad, dot, inner, rot, div -from sympde.calculus import laplace, bracket, convect -from sympde.calculus import jump, avg, Dn, minus, plus -from sympde.expr.expr import LinearForm, BilinearForm, integral - -from psydac.api.settings import PSYDAC_BACKENDS - -from psydac.api.discretization import discretize as discretize_single_patch -from psydac.api.discretization import discretize_space -from psydac.api.discretization import DiscreteDerham -from psydac.feec.multipatch.operators import BrokenGradient_2D -from psydac.feec.multipatch.operators import BrokenScalarCurl_2D -from psydac.feec.multipatch.operators import Multipatch_Projector_H1 -from psydac.feec.multipatch.operators import Multipatch_Projector_Hcurl -from psydac.feec.multipatch.operators import Multipatch_Projector_L2 -from psydac.feec.multipatch.operators import ConformingProjection_V0 -from psydac.feec.multipatch.operators import ConformingProjection_V1 -from psydac.feec.multipatch.fem_linear_operators import IdLinearOperator - - -__all__ = ('DiscreteDerhamMultipatch', 'discretize', 'discretize_derham_multipatch') - -#============================================================================== -class DiscreteDerhamMultipatch(DiscreteDerham): - """ Represents the discrete De Rham sequence for multipatch domains. - It only works when the number of patches>1 - - Parameters - ---------- - mapping: - The mapping of the multipatch domain, the multipatch mapping contains the mapping of each patch - - domain_h: - The discrete domain - - spaces: - The discrete spaces that are contained in the De Rham sequence - - sequence: - The space kind of each space in the De Rham sequence - """ - - def __init__(self, *, mapping, domain_h, spaces, sequence=None): - - - dim = len(spaces) - 1 - self._dim = dim - self._mapping = mapping - self._spaces = tuple(spaces) - self._domain_h = domain_h - - if sequence: - if len(sequence) != dim + 1: - raise ValueError('Expected len(sequence) = {}, got {} instead'. - format(dim + 1, len(sequence))) - - if dim == 1: - self._sequence = ('h1', 'l2') - raise NotImplementedError('1D FEEC multipatch non available yet') - - elif dim == 2: - if sequence is None: - raise ValueError('Sequence must be specified in 2D case') - - elif tuple(sequence) == ('h1', 'hcurl', 'l2'): - self._sequence = tuple(sequence) - self._broken_diff_ops = ( - BrokenGradient_2D(self.V0, self.V1), - BrokenScalarCurl_2D(self.V1, self.V2), # None, - ) - - elif tuple(sequence) == ('h1', 'hdiv', 'l2'): - self._sequence = tuple(sequence) - raise NotImplementedError('2D sequence with H-div not available yet') - - else: - raise ValueError('2D sequence not understood') - - elif dim == 3: - self._sequence = ('h1', 'hcurl', 'hdiv', 'l2') - raise NotImplementedError('3D FEEC multipatch non available yet') - - else: - raise ValueError('Dimension {} is not available'.format(dim)) - - #-------------------------------------------------------------------------- - @property - def sequence(self): - return self._sequence - - # ... - @property - def broken_derivatives_as_operators(self): - return self._broken_diff_ops - - # ... - @property - def broken_derivatives_as_matrices(self): - return tuple(b_diff.matrix for b_diff in self._broken_diff_ops) - - #-------------------------------------------------------------------------- - def projectors(self, *, kind='global', nquads=None): - """ - This method returns the patch-wise commuting projectors on the broken multi-patch space - - Parameters - ---------- - kind: - The projectors kind, can be global or local - - nquads: - The number of quadrature points. - - Returns - ------- - P0: - Patch wise H1 projector - - P1: - Patch wise Hcurl projector - - P2: - Patch wise L2 projector - - Notes - ----- - - when applied to smooth functions they return conforming fields - - default 'global projectors' correspond to geometric interpolation/histopolation operators on Greville grids - - here 'global' is a patch-level notion, as the interpolation-type problems are solved on each patch independently - """ - if not (kind == 'global'): - raise NotImplementedError('only global projectors are available') - - if self.dim == 1: - raise NotImplementedError("1D projectors are not available") - - elif self.dim == 2: - P0 = Multipatch_Projector_H1(self.V0) - - if self.sequence[1] == 'hcurl': - P1 = Multipatch_Projector_Hcurl(self.V1, nquads=nquads) - else: - P1 = None # TODO: Multipatch_Projector_Hdiv(self.V1, nquads=nquads) - raise NotImplementedError('2D sequence with H-div not available yet') - - P2 = Multipatch_Projector_L2(self.V2, nquads=nquads) - return P0, P1, P2 - - elif self.dim == 3: - raise NotImplementedError("3D projectors are not available") - - #-------------------------------------------------------------------------- - def conforming_projection(self, space, hom_bc=False, backend_language="python", load_dir=None): - """ - return the conforming projectors of the broken multi-patch space - - Parameters - ---------- - space : - The space of the projector - - hom_bc: - Apply homogenous boundary conditions if True - - backend_language: - The backend used to accelerate the code - - load_dir: - Filename for storage in sparse matrix format - - Returns - ------- - Cp: - The conforming projector - - """ - if hom_bc is None: - raise ValueError('please provide a value for "hom_bc" argument') - - if isinstance(load_dir, str): - if not os.path.exists(load_dir): - os.makedirs(load_dir) - if space == 'V0': - P_name = 'cP0' - elif space == 'V1': - P_name = 'cP1' - elif space == 'V2': - P_name = 'cP2' - else: - raise ValueError(space) - - if hom_bc: - storage_fn = load_dir + '/{}_hom_m.npz'.format(P_name) - else: - storage_fn = load_dir + '/{}_m.npz'.format(P_name) - else: - storage_fn = None - - cP = None - if self.dim == 1: - raise NotImplementedError("1D projectors are not available") - - elif self.dim == 2: - if space == 'V0': - cP = ConformingProjection_V0(self.V0, self._domain_h, hom_bc=hom_bc, backend_language=backend_language, storage_fn=storage_fn) - elif space == 'V1': - if self.sequence[1] == 'hcurl': - cP = ConformingProjection_V1(self.V1, self._domain_h, hom_bc=hom_bc, backend_language=backend_language, storage_fn=storage_fn) - else: - raise NotImplementedError('2D sequence with H-div not available yet') - - elif space == 'V2': - cP = IdLinearOperator(self.V2) # no storage needed! - else: - raise ValueError('Invalid value for "space" argument: {}'.format(space)) - - elif self.dim == 3: - raise NotImplementedError("3D projectors are not available") - - return cP - - def get_dual_dofs(self, space, f, backend_language="python", return_format='stencil_array'): - """ - return the dual dofs tilde_sigma_i(f) = < Lambda_i, f >_{L2} i = 1, .. dim(V^k)) of a given function f, as a stencil array or numpy array - - Parameters - ---------- - space : - The space of the dual dofs - - f : - The function used for evaluation - - backend_language: - The backend used to accelerate the code - - return_format: - The format of the dofs, can be 'stencil_array' or 'numpy_array' - - Returns - ------- - tilde_f: - The dual dofs - """ - if space == 'V0': - Vh = self.V0 - elif space == 'V1': - Vh = self.V1 - elif space == 'V2': - Vh = self.V2 - else: - raise NotImplementedError("The space of kind {} is not available".format(space)) - - V = Vh.symbolic_space - v = element_of(V, name='v') - - if isinstance(v, ScalarFunction): - expr = f*v - else: - expr = dot(f,v) - - l = LinearForm(v, integral( V.domain, expr)) - lh = discretize(l, self._domain_h, Vh, backend=PSYDAC_BACKENDS[backend_language]) - tilde_f = lh.assemble() - - if return_format == 'numpy_array': - return tilde_f.toarray() - else: - return tilde_f - -#============================================================================== -def discretize_derham_multipatch(derham, domain_h, *args, **kwargs): - - ldim = derham.shape - mapping = derham.spaces[0].domain.mapping - - bases = ['B'] + ldim * ['M'] - spaces = [discretize_space(V, domain_h, *args, basis=basis, **kwargs) \ - for V, basis in zip(derham.spaces, bases)] - - return DiscreteDerhamMultipatch( - mapping = mapping, - domain_h = domain_h, - spaces = spaces, - sequence = [V.kind.name for V in derham.spaces] - ) - -#============================================================================== -def discretize(expr, *args, **kwargs): - - if isinstance(expr, Derham) and expr.V0.is_broken: - return discretize_derham_multipatch(expr, *args, **kwargs) - - else: - return discretize_single_patch(expr, *args, **kwargs) diff --git a/psydac/feec/multipatch/examples/h1_source_pbms_conga_2d.py b/psydac/feec/multipatch/examples/h1_source_pbms_conga_2d.py index 7b1e33792..aaebd3140 100644 --- a/psydac/feec/multipatch/examples/h1_source_pbms_conga_2d.py +++ b/psydac/feec/multipatch/examples/h1_source_pbms_conga_2d.py @@ -12,38 +12,21 @@ V0h --grad-> V1h -—curl-> V2h """ - -from mpi4py import MPI - import os import numpy as np -from collections import OrderedDict - -from sympy import lambdify -from scipy.sparse.linalg import spsolve -from sympde.calculus import dot -from sympde.expr.expr import LinearForm -from sympde.expr.expr import integral, Norm from sympde.topology import Derham -from sympde.topology import element_of -from psydac.api.settings import PSYDAC_BACKENDS -from psydac.feec.multipatch.api import discretize -from psydac.feec.pull_push import pull_2d_h1 -from psydac.feec.multipatch.utils_conga_2d import P0_phys +from psydac.api.discretization import discretize +from psydac.linalg.basic import IdentityOperator +from psydac.linalg.solvers import inverse -from psydac.feec.multipatch.fem_linear_operators import IdLinearOperator -from psydac.feec.multipatch.operators import HodgeOperator from psydac.feec.multipatch.multipatch_domain_utilities import build_multipatch_domain -from psydac.feec.multipatch.examples.ppc_test_cases import get_source_and_solution_h1 -from psydac.feec.multipatch.utilities import time_count -from psydac.feec.multipatch.non_matching_operators import construct_h1_conforming_projection, construct_hcurl_conforming_projection -from psydac.api.postprocessing import OutputManager, PostProcessManager +from psydac.feec.multipatch.examples.ppc_test_cases import get_source_and_solution_h1 -from psydac.linalg.utilities import array_to_psydac -from psydac.fem.basic import FemField +from psydac.fem.projectors import get_dual_dofs +from psydac.fem.basic import FemField from psydac.api.postprocessing import OutputManager, PostProcessManager @@ -98,9 +81,6 @@ def solve_h1_source_pbm( print('building the multipatch domain...') domain = build_multipatch_domain(domain_name=domain_name) - mappings = OrderedDict([(P.logical_domain, P.mapping) - for P in domain.interior]) - mappings_list = list(mappings.values()) if isinstance(nc, int): ncells = [nc, nc] @@ -114,102 +94,89 @@ def solve_h1_source_pbm( derham = Derham(domain, ["H1", "Hcurl", "L2"]) derham_h = discretize(derham, domain_h, degree=degree) - # multi-patch (broken) spaces - V0h = derham_h.V0 - V1h = derham_h.V1 - V2h = derham_h.V2 + V0h, V1h, V2h = derham_h.spaces print('dim(V0h) = {}'.format(V0h.nbasis)) print('dim(V1h) = {}'.format(V1h.nbasis)) print('dim(V2h) = {}'.format(V2h.nbasis)) print('broken differential operators...') # broken (patch-wise) differential operators - bD0, bD1 = derham_h.broken_derivatives_as_operators - bD0_m = bD0.to_sparse_matrix() - - print('building the discrete operators:') - print('commuting projection operators...') - nquads = [4 * (d + 1) for d in degree] - P0, P1, P2 = derham_h.projectors(nquads=nquads) - - I0 = IdLinearOperator(V0h) - I0_m = I0.to_sparse_matrix() + bD0, bD1 = derham_h.derivatives(kind='linop') print('Hodge operators...') - # multi-patch (broken) linear operators / matrices - H0 = HodgeOperator(V0h, domain_h, backend_language=backend_language) - H1 = HodgeOperator(V1h, domain_h, backend_language=backend_language) - - H0_m = H0.to_sparse_matrix() # = mass matrix of V0 - dH0_m = H0.get_dual_Hodge_sparse_matrix() # = inverse mass matrix of V0 - H1_m = H1.to_sparse_matrix() # = mass matrix of V1 + # multi-patch (broken) linear operators + H0 = derham_h.hodge_operator(space='V0', kind='linop', backend_language=backend_language) + H1 = derham_h.hodge_operator(space='V1', kind='linop', backend_language=backend_language) + dH0 = derham_h.hodge_operator(space='V0', kind='linop', dual=True, backend_language=backend_language) print('conforming projection operators...') # conforming Projections (should take into account the boundary conditions # of the continuous deRham sequence) - cP0_m = construct_h1_conforming_projection(V0h, hom_bc=True) - - def lift_u_bc(u_bc): - if u_bc is not None: - print('lifting the boundary condition in V0h... [warning: Not Tested Yet!]') - d_ubc_c = derham_h.get_dual_dofs(space='V0', f=u_bc, backend_language=backend_language, return_format='numpy_array') - ubc_c = dH0_m.dot(d_ubc_c) + cP0, cP1, cP2 = derham_h.conforming_projectors(kind='linop', hom_bc = True) - ubc_c = ubc_c - cP0_m.dot(ubc_c) - else: - ubc_c = None - return ubc_c + print('building the discrete operators:') - # Conga (projection-based) stiffness matrices: - # div grad: - pre_DG_m = - bD0_m.transpose() @ H1_m @ bD0_m + I0 = IdentityOperator(V0h.coeff_space) + + # div grad + DG = - bD0.T @ H1 @ bD0 # jump penalization: - jump_penal_m = I0_m - cP0_m - JP0_m = jump_penal_m.transpose() @ H0_m @ jump_penal_m + JP0 = (I0 - cP0).T @ H0 @ (I0 - cP0) # useful for the boundary condition (if present) - pre_A_m = cP0_m.transpose() @ (eta * H0_m - mu * pre_DG_m) - A_m = pre_A_m @ cP0_m + gamma_h * JP0_m + pre_A = cP0.T @ (eta * H0 - mu * DG) + + A = pre_A @ cP0 + gamma_h * JP0 + + + f_scal, u_bc, u_ex = get_source_and_solution_h1(source_type=source_type, eta=eta, mu=mu, domain=domain, domain_name=domain_name,) - print('getting the source and ref solution...') - f_scal, u_bc, u_ex = get_source_and_solution_h1( - source_type=source_type, eta=eta, mu=mu, domain=domain, domain_name=domain_name, - ) + df = get_dual_dofs(Vh=V0h, f=f_scal, domain_h=domain_h, backend_language=backend_language) + f = dH0 @ df + df = cP0.T @ df - # compute approximate source f_h - b_c = derham_h.get_dual_dofs(space='V0', f=f_scal, backend_language=backend_language, return_format='numpy_array') - # source in primal sequence for plotting - f_c = dH0_m.dot(b_c) - b_c = cP0_m.transpose() @ b_c + def lift_u_bc(u_bc): + if u_bc is not None: + du_bc = get_dual_dofs(Vh=V0h, f=u_bc, domain_h = domain_h, backend_language=backend_language) + ubc = dH0.dot(du_bc) + ubc -= cP0.dot(ubc) - ubc_c = lift_u_bc(u_bc) + else: + ubc = None + + return ubc - if ubc_c is not None: + ubc = lift_u_bc(u_bc) + + if ubc is not None: # modified source for the homogeneous pbm print('modifying the source with lifted bc solution...') - b_c = b_c - pre_A_m.dot(ubc_c) + df -= pre_A @ ubc # direct solve with scipy spsolve - print('solving source problem with scipy.spsolve...') - uh_c = spsolve(A_m, b_c) + print('solving source problem with conjugate gradient...') + solver = inverse(A, solver='cg', tol=1e-8) + u = solver.solve(df) # project the homogeneous solution on the conforming problem space print('projecting the homogeneous solution on the conforming problem space...') - uh_c = cP0_m.dot(uh_c) + u = cP0.dot(u) - if ubc_c is not None: + if ubc is not None: # adding the lifted boundary condition print('adding the lifted boundary condition...') - uh_c += ubc_c + u += ubc - print('getting and plotting the FEM solution from numpy coefs array...') if u_ex: - u_ex_c = derham_h.get_dual_dofs(space='V0', f=u_ex, backend_language=backend_language, return_format='numpy_array') - u_ex_c = dH0_m.dot(u_ex_c) + u_ex = get_dual_dofs(Vh=V0h, f=u_ex, domain_h=domain_h, backend_language=backend_language) + u_ex = dH0.dot(u_ex) + if plot_dir is not None: + print('plotting the FEM solution...') + if not os.path.exists(plot_dir): os.makedirs(plot_dir) @@ -217,17 +184,14 @@ def lift_u_bc(u_bc): OM.add_spaces(V0h=V0h) OM.set_static() - stencil_coeffs = array_to_psydac(uh_c, V0h.coeff_space) - vh = FemField(V0h, coeffs=stencil_coeffs) - OM.export_fields(vh=vh) + uh = FemField(V0h, coeffs=u) + OM.export_fields(uh=uh) - stencil_coeffs = array_to_psydac(f_c, V0h.coeff_space) - fh = FemField(V0h, coeffs=stencil_coeffs) + fh = FemField(V0h, coeffs=f) OM.export_fields(fh=fh) if u_ex: - stencil_coeffs = array_to_psydac(u_ex_c, V0h.coeff_space) - uh_ex = FemField(V0h, coeffs=stencil_coeffs) + uh_ex = FemField(V0h, coeffs=u_ex) OM.export_fields(uh_ex=uh_ex) OM.export_space_info() @@ -243,7 +207,7 @@ def lift_u_bc(u_bc): grid=None, npts_per_cell=[6] * 2, snapshots='all', - fields='vh') + fields='uh') PM.export_to_vtk( plot_dir + "/f_h", @@ -263,9 +227,9 @@ def lift_u_bc(u_bc): PM.close() if u_ex: - err = uh_c - u_ex_c - rel_err = np.sqrt(np.dot(err, H0_m.dot(err)))/np.sqrt(np.dot(u_ex_c,H0_m.dot(u_ex_c))) - + err = u - u_ex + rel_err = np.sqrt(H0.dot_inner(err, err) / H0.dot_inner(u_ex, u_ex)) + return rel_err @@ -273,19 +237,21 @@ def lift_u_bc(u_bc): omega = np.sqrt(170) # source eta = -omega**2 + mu=0 + gamma_h = 10 source_type = 'manu_poisson_elliptic' domain_name = 'pretzel_f' - nc = 10 + nc = 4 deg = 2 run_dir = '{}_{}_nc={}_deg={}/'.format(domain_name, source_type, nc, deg) solve_h1_source_pbm( nc=nc, deg=deg, eta=eta, - mu=1, # 1, + mu=mu, # 1, domain_name=domain_name, source_type=source_type, backend_language='pyccel-gcc', diff --git a/psydac/feec/multipatch/examples/hcurl_eigen_pbms_conga_2d.py b/psydac/feec/multipatch/examples/hcurl_eigen_pbms_conga_2d.py index db54565c7..418118469 100644 --- a/psydac/feec/multipatch/examples/hcurl_eigen_pbms_conga_2d.py +++ b/psydac/feec/multipatch/examples/hcurl_eigen_pbms_conga_2d.py @@ -2,41 +2,32 @@ Solve the eigenvalue problem for the curl-curl operator in 2D with a FEEC discretization """ import os -from mpi4py import MPI - import numpy as np -import matplotlib.pyplot as plt -from collections import OrderedDict + from sympde.topology import Derham -from psydac.feec.multipatch.api import discretize +from psydac.api.discretization import discretize from psydac.api.settings import PSYDAC_BACKENDS -from psydac.feec.multipatch.fem_linear_operators import IdLinearOperator -from psydac.feec.multipatch.operators import HodgeOperator from psydac.feec.multipatch.multipatch_domain_utilities import build_multipatch_domain -from psydac.feec.multipatch.utilities import time_count, get_run_dir, get_plot_dir, get_mat_dir, get_sol_dir, diag_fn -from psydac.feec.multipatch.utils_conga_2d import write_diags_to_file +from psydac.feec.multipatch.utilities import time_count -from sympde.topology import Square -from sympde.topology import IdentityMapping, PolarMapping from scipy.sparse.linalg import spilu, lgmres from scipy.sparse.linalg import LinearOperator, eigsh, minres -from scipy.sparse import csr_matrix from scipy.linalg import norm +from psydac.linalg.basic import IdentityOperator from psydac.linalg.utilities import array_to_psydac from psydac.fem.basic import FemField from psydac.feec.multipatch.multipatch_domain_utilities import build_cartesian_multipatch_domain -from psydac.feec.multipatch.non_matching_operators import construct_h1_conforming_projection, construct_hcurl_conforming_projection from psydac.api.postprocessing import OutputManager, PostProcessManager def hcurl_solve_eigen_pbm(ncells=np.array([[8, 4], [4, 4]]), degree=(3, 3), domain=([0, np.pi], [0, np.pi]), domain_name='refined_square', backend_language='pyccel-gcc', mu=1, nu=0, gamma_h=0, generalized_pbm=False, sigma=5, nb_eigs_solve=8, nb_eigs_plot=5, skip_eigs_threshold=1e-7, - plot_dir=None, m_load_dir=None,): + plot_dir=None): """ Solve the eigenvalue problem for the curl-curl operator in 2D with DG discretization @@ -70,8 +61,6 @@ def hcurl_solve_eigen_pbm(ncells=np.array([[8, 4], [4, 4]]), degree=(3, 3), doma Threshold for the eigenvalues to skip plot_dir : str Directory for the plots - m_load_dir : str - Directory to save and load the matrices """ diags = {} @@ -110,10 +99,6 @@ def hcurl_solve_eigen_pbm(ncells=np.array([[8, 4], [4, 4]]), degree=(3, 3), doma elif ncells.ndim == 2: ncells = {patch.name: [ncells[int(patch.name[2])][int(patch.name[4])], ncells[int(patch.name[2])][int(patch.name[4])]] for patch in domain.interior} - - mappings = OrderedDict([(P.logical_domain, P.mapping) - for P in domain.interior]) - mappings_list = list(mappings.values()) t_stamp = time_count(t_stamp) print(' .. discrete domain...') @@ -128,9 +113,7 @@ def hcurl_solve_eigen_pbm(ncells=np.array([[8, 4], [4, 4]]), degree=(3, 3), doma print(' .. discrete derham sequence...') derham_h = discretize(derham, domain_h, degree=degree) - V0h = derham_h.V0 - V1h = derham_h.V1 - V2h = derham_h.V2 + V0h, V1h, V2h = derham_h.spaces print('dim(V0h) = {}'.format(V0h.nbasis)) print('dim(V1h) = {}'.format(V1h.nbasis)) print('dim(V2h) = {}'.format(V2h.nbasis)) @@ -142,64 +125,27 @@ def hcurl_solve_eigen_pbm(ncells=np.array([[8, 4], [4, 4]]), degree=(3, 3), doma print('building the discrete operators:') print('commuting projection operators...') - I1 = IdLinearOperator(V1h) - I1_m = I1.to_sparse_matrix() + I1 = IdentityOperator(V1h.coeff_space) t_stamp = time_count(t_stamp) print('Hodge operators...') # multi-patch (broken) linear operators / matrices - H0 = HodgeOperator( - V0h, - domain_h, - backend_language=backend_language, - load_dir=m_load_dir, - load_space_index=0) - H1 = HodgeOperator( - V1h, - domain_h, - backend_language=backend_language, - load_dir=m_load_dir, - load_space_index=1) - H2 = HodgeOperator( - V2h, - domain_h, - backend_language=backend_language, - load_dir=m_load_dir, - load_space_index=2) - - H0_m = H0.to_sparse_matrix() # = mass matrix of V0 - dH0_m = H0.get_dual_Hodge_sparse_matrix() # = inverse mass matrix of V0 - H1_m = H1.to_sparse_matrix() # = mass matrix of V1 - dH1_m = H1.get_dual_Hodge_sparse_matrix() # = inverse mass matrix of V1 - H2_m = H2.to_sparse_matrix() # = mass matrix of V2 - dH2_m = H2.get_dual_Hodge_sparse_matrix() # = inverse mass matrix of V2 + H0, H1, H2 = derham_h.hodge_operators(kind='linop', backend_language=backend_language) + dH0, dH1, dH2 = derham_h.hodge_operators(kind='linop', dual=True, backend_language=backend_language) t_stamp = time_count(t_stamp) print('conforming projection operators...') # conforming Projections (should take into account the boundary conditions # of the continuous deRham sequence) - cP0_m = construct_h1_conforming_projection(V0h, hom_bc=True) - cP1_m = construct_hcurl_conforming_projection(V1h, hom_bc=True) + cP0, cP1, cP2 = derham_h.conforming_projectors(kind='linop', hom_bc = True) - t_stamp = time_count(t_stamp) - print('broken differential operators...') - bD0, bD1 = derham_h.broken_derivatives_as_operators - bD0_m = bD0.to_sparse_matrix() - bD1_m = bD1.to_sparse_matrix() t_stamp = time_count(t_stamp) - print('converting some matrices to csr format...') - - H1_m = H1_m.tocsr() - dH1_m = dH1_m.tocsr() - H2_m = H2_m.tocsr() - bD1_m = bD1_m.tocsr() + print('broken differential operators...') + bD0, bD1 = derham_h.derivatives(kind='linop') - if not os.path.exists(plot_dir): - os.makedirs(plot_dir) print('computing the full operator matrix...') - A_m = np.zeros_like(H1_m) # Conga (projection-based) stiffness matrices if mu != 0: @@ -208,33 +154,29 @@ def hcurl_solve_eigen_pbm(ncells=np.array([[8, 4], [4, 4]]), degree=(3, 3), doma print('mu = {}'.format(mu)) print('curl-curl stiffness matrix...') - pre_CC_m = bD1_m.transpose() @ H2_m @ bD1_m - CC_m = cP1_m.transpose() @ pre_CC_m @ cP1_m # Conga stiffness matrix - A_m += mu * CC_m + CC = cP1.T @ bD1.T @ H2 @ bD1 @ cP1 # Conga stiffness matrix + A = mu * CC if nu != 0: - pre_GD_m = - H1_m @ bD0_m @ cP0_m @ dH0_m @ cP0_m.transpose() @ bD0_m.transpose() @ H1_m - GD_m = cP1_m.transpose() @ pre_GD_m @ cP1_m # Conga stiffness matrix - A_m -= nu * GD_m + GD = - cP1.T @ H1 @ bD0 @ cP0 @ dH0 @ cP0.T @ bD0.T @ H1 @ cP1 + A -= nu * GD # jump stabilization in V1h: if gamma_h != 0 or generalized_pbm: t_stamp = time_count(t_stamp) print('jump stabilization matrix...') - jump_stab_m = I1_m - cP1_m - JS_m = jump_stab_m.transpose() @ H1_m @ jump_stab_m - A_m += gamma_h * JS_m + JS = (I1 - cP1).T @ H1 @ (I1 - cP1) + A += gamma_h * JS if generalized_pbm: print('adding jump stabilization to RHS of generalized eigenproblem...') - B_m = cP1_m.transpose() @ H1_m @ cP1_m + JS_m + B = cP1.T @ H1 @ cP1 + JS else: - B_m = H1_m + B = H1 t_stamp = time_count(t_stamp) print('solving matrix eigenproblem...') - all_eigenvalues, all_eigenvectors_transp = get_eigenvalues( - nb_eigs_solve, sigma, A_m, B_m) + all_eigenvalues, all_eigenvectors_transp = get_eigenvalues(nb_eigs_solve, sigma, A.tosparse(), B.tosparse()) # Eigenvalue processing t_stamp = time_count(t_stamp) print('sorting out eigenvalues...') @@ -262,37 +204,45 @@ def hcurl_solve_eigen_pbm(ncells=np.array([[8, 4], [4, 4]]), degree=(3, 3), doma t_stamp = time_count(t_stamp) print('plotting the eigenmodes...') - OM = OutputManager(plot_dir + '/spaces.yml', plot_dir + '/fields.h5') - OM.add_spaces(V1h=V1h) - OM.export_space_info() - - nb_eigs = len(eigenvalues) - for i in range(min(nb_eigs_plot, nb_eigs)): - - print('looking at emode i = {}... '.format(i)) - lambda_i = eigenvalues[i] - emode_i = np.real(eigenvectors[i]) - norm_emode_i = np.dot(emode_i, H1_m.dot(emode_i)) - eh_c = emode_i / norm_emode_i - - stencil_coeffs = array_to_psydac(cP1_m @ eh_c, V1h.coeff_space) - vh = FemField(V1h, coeffs=stencil_coeffs) - OM.add_snapshot(i, i) - OM.export_fields(vh=vh) - - OM.close() - - PM = PostProcessManager( - domain=domain, - space_file=plot_dir + '/spaces.yml', - fields_file=plot_dir + '/fields.h5') - PM.export_to_vtk( - plot_dir + "/eigenvalues", - grid=None, - npts_per_cell=[6] * 2, - snapshots='all', - fields='vh') - PM.close() + if plot_dir: + + if not os.path.exists(plot_dir): + os.makedirs(plot_dir) + + OM = OutputManager(plot_dir + '/spaces.yml', plot_dir + '/fields.h5') + OM.add_spaces(V1h=V1h) + OM.export_space_info() + + nb_eigs = len(eigenvalues) + H1_m = H1.tosparse() + cP1_m = cP1.tosparse() + + for i in range(min(nb_eigs_plot, nb_eigs)): + + print('looking at emode i = {}... '.format(i)) + lambda_i = eigenvalues[i] + emode_i = np.real(eigenvectors[i]) + norm_emode_i = np.dot(emode_i, H1_m.dot(emode_i)) + eh_c = emode_i / norm_emode_i + + stencil_coeffs = array_to_psydac(cP1_m @ eh_c, V1h.coeff_space) + vh = FemField(V1h, coeffs=stencil_coeffs) + OM.add_snapshot(i, i) + OM.export_fields(vh=vh) + + OM.close() + + PM = PostProcessManager( + domain=domain, + space_file=plot_dir + '/spaces.yml', + fields_file=plot_dir + '/fields.h5') + PM.export_to_vtk( + plot_dir + "/eigenvalues", + grid=None, + npts_per_cell=[6] * 2, + snapshots='all', + fields='vh') + PM.close() t_stamp = time_count(t_stamp) diff --git a/psydac/feec/multipatch/examples/hcurl_eigen_pbms_dg_2d.py b/psydac/feec/multipatch/examples/hcurl_eigen_pbms_dg_2d.py index 58c6a2bb6..0235dd72d 100644 --- a/psydac/feec/multipatch/examples/hcurl_eigen_pbms_dg_2d.py +++ b/psydac/feec/multipatch/examples/hcurl_eigen_pbms_dg_2d.py @@ -3,14 +3,13 @@ A. Buffa and I. Perugia, “Discontinuous Galerkin Approximation of the Maxwell Eigenproblem” SIAM Journal on Numerical Analysis 44 (2006) """ - import os from mpi4py import MPI from collections import OrderedDict import numpy as np import matplotlib.pyplot -from scipy.sparse.linalg import spsolve, inv + from scipy.sparse.linalg import LinearOperator, eigsh, minres from sympde.calculus import grad, dot, curl, cross @@ -26,14 +25,12 @@ from sympde.expr.equation import find, EssentialBC from psydac.linalg.utilities import array_to_psydac -from psydac.api.tests.build_domain import build_pretzel from psydac.fem.basic import FemField -from psydac.api.settings import PSYDAC_BACKEND_GPYCCEL from psydac.feec.pull_push import pull_2d_hcurl from psydac.feec.multipatch.multipatch_domain_utilities import build_multipatch_domain -from psydac.feec.multipatch.utilities import time_count, get_run_dir, get_plot_dir, get_mat_dir, get_sol_dir, diag_fn -from psydac.feec.multipatch.api import discretize +from psydac.feec.multipatch.utilities import time_count +from psydac.api.discretization import discretize from psydac.feec.multipatch.multipatch_domain_utilities import build_cartesian_multipatch_domain from psydac.api.postprocessing import OutputManager, PostProcessManager @@ -194,40 +191,41 @@ def avr(w): return 0.5 * plus(w) + 0.5 * minus(w) t_stamp = time_count(t_stamp) print('plotting the eigenmodes...') - if not os.path.exists(plot_dir): - os.makedirs(plot_dir) - - OM = OutputManager(plot_dir + '/spaces.yml', plot_dir + '/fields.h5') - OM.add_spaces(Vh=Vh) - OM.export_space_info() - - nb_eigs = len(eigenvalues) - for i in range(min(nb_eigs_plot, nb_eigs)): - - print('looking at emode i = {}... '.format(i)) - lambda_i = eigenvalues[i] - emode_i = np.real(eigenvectors[i]) - norm_emode_i = np.dot(emode_i, Bh_m.dot(emode_i)) - eh_c = emode_i / norm_emode_i - - stencil_coeffs = array_to_psydac(eh_c, Vh.coeff_space) - vh = FemField(Vh, coeffs=stencil_coeffs) - OM.add_snapshot(i, i) - OM.export_fields(vh=vh) - - OM.close() - - PM = PostProcessManager( - domain=domain, - space_file=plot_dir + '/spaces.yml', - fields_file=plot_dir + '/fields.h5') - PM.export_to_vtk( - plot_dir + "/eigenvalues", - grid=None, - npts_per_cell=[6] * 2, - snapshots='all', - fields='vh') - PM.close() + if plot_dir: + if not os.path.exists(plot_dir): + os.makedirs(plot_dir) + + OM = OutputManager(plot_dir + '/spaces.yml', plot_dir + '/fields.h5') + OM.add_spaces(Vh=Vh) + OM.export_space_info() + + nb_eigs = len(eigenvalues) + for i in range(min(nb_eigs_plot, nb_eigs)): + + print('looking at emode i = {}... '.format(i)) + lambda_i = eigenvalues[i] + emode_i = np.real(eigenvectors[i]) + norm_emode_i = np.dot(emode_i, Bh_m.dot(emode_i)) + eh_c = emode_i / norm_emode_i + + stencil_coeffs = array_to_psydac(eh_c, Vh.coeff_space) + vh = FemField(Vh, coeffs=stencil_coeffs) + OM.add_snapshot(i, i) + OM.export_fields(vh=vh) + + OM.close() + + PM = PostProcessManager( + domain=domain, + space_file=plot_dir + '/spaces.yml', + fields_file=plot_dir + '/fields.h5') + PM.export_to_vtk( + plot_dir + "/eigenvalues", + grid=None, + npts_per_cell=[6] * 2, + snapshots='all', + fields='vh') + PM.close() t_stamp = time_count(t_stamp) diff --git a/psydac/feec/multipatch/examples/hcurl_eigen_testcases.py b/psydac/feec/multipatch/examples/hcurl_eigen_testcases.py index 4f311a7eb..5e887beda 100644 --- a/psydac/feec/multipatch/examples/hcurl_eigen_testcases.py +++ b/psydac/feec/multipatch/examples/hcurl_eigen_testcases.py @@ -220,9 +220,6 @@ diag_filename = plot_dir + '/' + diag_fn() common_diag_filename = './' + case_dir + '_diags.txt' -# to save and load matrices -# m_load_dir = get_mat_dir(domain_name, nc, deg) -m_load_dir = None print('\n --- --- --- --- --- --- --- --- --- --- --- --- --- --- \n') print(' Calling hcurl_solve_eigen_pbm() with params = {}'.format(params)) @@ -254,7 +251,6 @@ domain_name=domain_name, domain=domain, backend_language=backend_language, plot_dir=plot_dir, - m_load_dir=m_load_dir, ) elif method == 'dg': diff --git a/psydac/feec/multipatch/examples/hcurl_source_pbms_conga_2d.py b/psydac/feec/multipatch/examples/hcurl_source_pbms_conga_2d.py index b7442e054..e2252a0e2 100644 --- a/psydac/feec/multipatch/examples/hcurl_source_pbms_conga_2d.py +++ b/psydac/feec/multipatch/examples/hcurl_source_pbms_conga_2d.py @@ -14,42 +14,29 @@ """ import os -from mpi4py import MPI import numpy as np -from collections import OrderedDict -from sympy import lambdify, Matrix - -from scipy.sparse.linalg import spsolve - -from sympde.calculus import dot -from sympde.topology import element_of -from sympde.expr.expr import LinearForm -from sympde.expr.expr import integral, Norm from sympde.topology import Derham -from psydac.api.settings import PSYDAC_BACKENDS -from psydac.feec.pull_push import pull_2d_hcurl -from psydac.feec.multipatch.api import discretize -from psydac.feec.multipatch.fem_linear_operators import IdLinearOperator -from psydac.feec.multipatch.operators import HodgeOperator +from psydac.api.discretization import discretize from psydac.feec.multipatch.multipatch_domain_utilities import build_multipatch_domain from psydac.feec.multipatch.examples.ppc_test_cases import get_source_and_solution_hcurl -from psydac.feec.multipatch.utils_conga_2d import DiagGrid, P0_phys, P1_phys, P2_phys, get_Vh_diags_for +from psydac.feec.multipatch.utils_conga_2d import P1_phys from psydac.feec.multipatch.utilities import time_count -from psydac.linalg.utilities import array_to_psydac +# from psydac.linalg.utilities import array_to_psydac from psydac.fem.basic import FemField -from psydac.feec.multipatch.non_matching_operators import construct_h1_conforming_projection, construct_hcurl_conforming_projection from psydac.api.postprocessing import OutputManager, PostProcessManager +from psydac.linalg.basic import IdentityOperator +from psydac.fem.projectors import get_dual_dofs +from psydac.linalg.solvers import inverse + def solve_hcurl_source_pbm( - nc=4, deg=4, domain_name='pretzel_f', backend_language=None, source_proj='P_geom', source_type='manu_J', + nc=4, deg=4, domain_name='pretzel_f', backend_language=None, source_proj='tilde_Pi', source_type='manu_J', eta=-10., mu=1., nu=1., gamma_h=10., - project_sol=False, plot_dir=None, - m_load_dir=None, -): + project_sol=True, plot_dir=None): """ solver for the problem: find u in H(curl), such that @@ -82,15 +69,11 @@ def solve_hcurl_source_pbm( :param source_proj: approximation operator (in V1h) for the source, possible values are - 'tilde_Pi': dual commuting projection, an L2 projection filtered by the adjoint conforming projection) :param source_type: must be implemented in get_source_and_solution() - :param m_load_dir: directory for matrix storage """ diags = {} degree = [deg, deg] - if m_load_dir is not None: - if not os.path.exists(m_load_dir): - os.makedirs(m_load_dir) print('---------------------------------------------------------------------------------------------------------') print('Starting solve_hcurl_source_pbm function with: ') @@ -107,9 +90,9 @@ def solve_hcurl_source_pbm( t_stamp = time_count() print(' .. multi-patch domain...') domain = build_multipatch_domain(domain_name=domain_name) - mappings = OrderedDict([(P.logical_domain, P.mapping) - for P in domain.interior]) - mappings_list = list(mappings.values()) + # mappings = OrderedDict([(P.logical_domain, P.mapping) + # for P in domain.interior]) + # mappings_list = list(mappings.values()) if isinstance(nc, int): ncells = [nc, nc] @@ -117,8 +100,6 @@ def solve_hcurl_source_pbm( ncells = {patch.name: [nc[i], nc[i]] for (i, patch) in enumerate(domain.interior)} - # for diagnosttics - diag_grid = DiagGrid(mappings=mappings, N_diag=100) t_stamp = time_count(t_stamp) print(' .. derham sequence...') @@ -134,14 +115,14 @@ def solve_hcurl_source_pbm( t_stamp = time_count(t_stamp) print(' .. commuting projection operators...') - nquads = [4 * (d + 1) for d in degree] + nquads = [10 * (d + 1) for d in degree] P0, P1, P2 = derham_h.projectors(nquads=nquads) t_stamp = time_count(t_stamp) print(' .. multi-patch spaces...') - V0h = derham_h.V0 - V1h = derham_h.V1 - V2h = derham_h.V2 + V0h, V1h, V2h = derham_h.spaces + mappings = derham_h.callable_mapping + print('dim(V0h) = {}'.format(V0h.nbasis)) print('dim(V1h) = {}'.format(V1h.nbasis)) print('dim(V2h) = {}'.format(V2h.nbasis)) @@ -151,101 +132,44 @@ def solve_hcurl_source_pbm( t_stamp = time_count(t_stamp) print(' .. Id operator and matrix...') - I1 = IdLinearOperator(V1h) - I1_m = I1.to_sparse_matrix() + I1 = IdentityOperator(V1h.coeff_space) t_stamp = time_count(t_stamp) print(' .. Hodge operators...') # multi-patch (broken) linear operators / matrices # other option: define as Hodge Operators: - H0 = HodgeOperator( - V0h, - domain_h, - backend_language=backend_language, - load_dir=m_load_dir, - load_space_index=0) - H1 = HodgeOperator( - V1h, - domain_h, - backend_language=backend_language, - load_dir=m_load_dir, - load_space_index=1) - H2 = HodgeOperator( - V2h, - domain_h, - backend_language=backend_language, - load_dir=m_load_dir, - load_space_index=2) - - t_stamp = time_count(t_stamp) - print(' .. Hodge matrix H0_m = M0_m ...') - H0_m = H0.to_sparse_matrix() - t_stamp = time_count(t_stamp) - print(' .. dual Hodge matrix dH0_m = inv_M0_m ...') - dH0_m = H0.get_dual_Hodge_sparse_matrix() - - t_stamp = time_count(t_stamp) - print(' .. Hodge matrix H1_m = M1_m ...') - H1_m = H1.to_sparse_matrix() - t_stamp = time_count(t_stamp) - print(' .. dual Hodge matrix dH1_m = inv_M1_m ...') - dH1_m = H1.get_dual_Hodge_sparse_matrix() + H0, H1, H2 = derham_h.hodge_operators(kind='linop', backend_language=backend_language) + dH0, dH1, dH2 = derham_h.hodge_operators(kind='linop', dual=True, backend_language=backend_language) - t_stamp = time_count(t_stamp) - print(' .. Hodge matrix H2_m = M2_m ...') - H2_m = H2.to_sparse_matrix() - dH2_m = H2.get_dual_Hodge_sparse_matrix() t_stamp = time_count(t_stamp) print(' .. conforming Projection operators...') # conforming Projections (should take into account the boundary conditions # of the continuous deRham sequence) - cP0_m = construct_h1_conforming_projection(V0h, hom_bc=True) - cP1_m = construct_hcurl_conforming_projection(V1h, hom_bc=True) + cP0, cP1, cP2 = derham_h.conforming_projectors(kind='linop', hom_bc = True) + t_stamp = time_count(t_stamp) print(' .. broken differential operators...') # broken (patch-wise) differential operators - bD0, bD1 = derham_h.broken_derivatives_as_operators - bD0_m = bD0.to_sparse_matrix() - bD1_m = bD1.to_sparse_matrix() - - if plot_dir is not None and not os.path.exists(plot_dir): - os.makedirs(plot_dir) - - def lift_u_bc(u_bc): - if u_bc is not None: - print('lifting the boundary condition in V1h...') - # note: for simplicity we apply the full P1 on u_bc, but we only - # need to set the boundary dofs - uh_bc = P1_phys(u_bc, P1, domain, mappings_list) - ubc_c = uh_bc.coeffs.toarray() - # removing internal dofs (otherwise ubc_c may already be a very - # good approximation of uh_c ...) - ubc_c = ubc_c - cP1_m.dot(ubc_c) - else: - ubc_c = None - return ubc_c + bD0, bD1 = derham_h.derivatives(kind='linop') # Conga (projection-based) stiffness matrices # curl curl: t_stamp = time_count(t_stamp) print(' .. curl-curl stiffness matrix...') - print(bD1_m.shape, H2_m.shape) - pre_CC_m = bD1_m.transpose() @ H2_m @ bD1_m - # CC_m = cP1_m.transpose() @ pre_CC_m @ cP1_m # Conga stiffness matrix + pre_CC = bD1.T @ H2 @ bD1 # grad div: t_stamp = time_count(t_stamp) print(' .. grad-div stiffness matrix...') - pre_GD_m = - H1_m @ bD0_m @ cP0_m @ dH0_m @ cP0_m.transpose() @ bD0_m.transpose() @ H1_m - # GD_m = cP1_m.transpose() @ pre_GD_m @ cP1_m # Conga stiffness matrix + pre_GD = - H1 @ bD0 @ cP0 @ dH0 @ cP0.T @ bD0.T @ H1 # jump stabilization: t_stamp = time_count(t_stamp) print(' .. jump stabilization matrix...') - jump_penal_m = I1_m - cP1_m - JP_m = jump_penal_m.transpose() @ H1_m @ jump_penal_m + JS = (I1 - cP1).T @ H1 @ (I1 - cP1) + t_stamp = time_count(t_stamp) print(' .. full operator matrix...') @@ -254,69 +178,81 @@ def lift_u_bc(u_bc): print('nu = {}'.format(nu)) print('STABILIZATION: gamma_h = {}'.format(gamma_h)) # useful for the boundary condition (if present) - pre_A_m = cP1_m.transpose() @ (eta * H1_m + mu * pre_CC_m - nu * pre_GD_m) - A_m = pre_A_m @ cP1_m + gamma_h * JP_m + pre_A = eta * cP1.T @ H1 + if mu != 0: + pre_A += mu * cP1.T @ pre_CC + if nu != 0: + pre_A -= nu * cP1.T @ pre_GD + + A = pre_A @ cP1 + gamma_h * JS t_stamp = time_count(t_stamp) print() print(' -- getting source --') - f_vect, u_bc, u_ex, curl_u_ex, div_u_ex = get_source_and_solution_hcurl( - source_type=source_type, eta=eta, mu=mu, domain=domain, domain_name=domain_name,) + f_vect, u_bc, u_ex, curl_u_ex, div_u_ex = get_source_and_solution_hcurl(source_type=source_type, eta=eta, mu=mu, domain=domain, domain_name=domain_name,) # compute approximate source f_h t_stamp = time_count(t_stamp) # f_h = L2 projection of f_vect, with filtering if tilde_Pi - print(' .. projecting the source with ' + - source_proj +' projection...') - - tilde_f_c = derham_h.get_dual_dofs( - space='V1', - f=f_vect, - backend_language=backend_language, - return_format='numpy_array') + print(' .. projecting the source with ' + source_proj +' projection...') + + tilde_f = get_dual_dofs(Vh=V1h, f=f_vect, domain_h=domain_h, backend_language=backend_language) + if source_proj == 'tilde_Pi': - print(' .. filtering the discrete source with P0.T ...') - tilde_f_c = cP1_m.transpose() @ tilde_f_c + print(' .. filtering the discrete source with P1.T ...') + tilde_f = cP1.T @ tilde_f + + def lift_u_bc(u_bc): + if u_bc is not None: + ubc = P1_phys(u_bc, P1, domain).coeffs + ubc -= cP1.dot(ubc) + + else: + ubc = None + + return ubc - ubc_c = lift_u_bc(u_bc) - if ubc_c is not None: + ubc = lift_u_bc(u_bc) + + if ubc is not None: # modified source for the homogeneous pbm t_stamp = time_count(t_stamp) print(' .. modifying the source with lifted bc solution...') - tilde_f_c = tilde_f_c - pre_A_m.dot(ubc_c) + tilde_f -= pre_A.dot(ubc) # direct solve with scipy spsolve t_stamp = time_count(t_stamp) - print() - print(' -- solving source problem with scipy.spsolve...') - uh_c = spsolve(A_m, tilde_f_c) + print('solving source problem with conjugate gradient...') + solver = inverse(A, solver='cg', tol=1e-8) + u = solver.solve(tilde_f) # project the homogeneous solution on the conforming problem space + t_stamp = time_count(t_stamp) if project_sol: - t_stamp = time_count(t_stamp) print(' .. projecting the homogeneous solution on the conforming problem space...') - uh_c = cP1_m.dot(uh_c) - else: - print(' .. NOT projecting the homogeneous solution on the conforming problem space') + u = cP1.dot(u) - if ubc_c is not None: + if ubc is not None: # adding the lifted boundary condition t_stamp = time_count(t_stamp) print(' .. adding the lifted boundary condition...') - uh_c += ubc_c + u += ubc - uh = FemField(V1h, coeffs=array_to_psydac(uh_c, V1h.coeff_space)) + uh = FemField(V1h, coeffs=u) #need cp1 here? - f_c = dH1_m.dot(tilde_f_c) - jh = FemField(V1h, coeffs=array_to_psydac(f_c, V1h.coeff_space)) + f = dH1.dot(tilde_f) + jh = FemField(V1h, coeffs=f) t_stamp = time_count(t_stamp) print(' -- plots and diagnostics --') if plot_dir: + if not os.path.exists(plot_dir): + os.makedirs(plot_dir) + OM = OutputManager(plot_dir + '/spaces.yml', plot_dir + '/fields.h5') OM.add_spaces(V1h=V1h) OM.set_static() @@ -349,11 +285,12 @@ def lift_u_bc(u_bc): time_count(t_stamp) if u_ex: - u_ex_c = P1_phys(u_ex, P1, domain, mappings_list).coeffs.toarray() - err = u_ex_c - uh_c - l2_error = np.sqrt(np.dot(err, H1_m.dot(err)))/np.sqrt(np.dot(u_ex_c,H1_m.dot(u_ex_c))) + u_ex_p = P1_phys(u_ex, P1, domain).coeffs + + err = u_ex_p - u + print(err.inner(H1.dot(err))) + l2_error = np.sqrt( H1.dot_inner(err, err) / H1.dot_inner(u_ex_p, u_ex_p)) print(l2_error) - #return l2_error diags['err'] = l2_error return diags diff --git a/psydac/feec/multipatch/examples/hcurl_source_testcase.py b/psydac/feec/multipatch/examples/hcurl_source_testcase.py index 35aa79dd6..720fb75b5 100644 --- a/psydac/feec/multipatch/examples/hcurl_source_testcase.py +++ b/psydac/feec/multipatch/examples/hcurl_source_testcase.py @@ -94,9 +94,6 @@ diag_filename = plot_dir + '/' + \ diag_fn(source_type=source_type, source_proj=source_proj) - # to save and load matrices - m_load_dir = get_mat_dir(domain_name, nc, deg) - # to save the FEM sol print('\n --- --- --- --- --- --- --- --- --- --- --- --- --- --- \n') @@ -124,7 +121,6 @@ project_sol=project_sol, gamma_h=gamma_h, plot_dir=plot_dir, - m_load_dir=m_load_dir, ) # diff --git a/psydac/feec/multipatch/examples/ppc_test_cases.py b/psydac/feec/multipatch/examples/ppc_test_cases.py index 94b772f4d..f0f7d0f8c 100644 --- a/psydac/feec/multipatch/examples/ppc_test_cases.py +++ b/psydac/feec/multipatch/examples/ppc_test_cases.py @@ -1,24 +1,9 @@ # coding: utf-8 - -from sympy.functions.special.error_functions import erf -from mpi4py import MPI - import os import numpy as np from sympy import pi, cos, sin, Tuple, exp, atan, atan2 - -from sympde.topology import Derham - -from psydac.fem.basic import FemField -from psydac.feec.multipatch.api import discretize -from psydac.feec.multipatch.operators import HodgeOperator -from psydac.fem.plotting_utilities import get_plotting_grid, my_small_plot, my_small_streamplot -from psydac.feec.multipatch.multipatch_domain_utilities import build_multipatch_domain - -comm = MPI.COMM_WORLD - - +from sympy.functions.special.error_functions import erf # todo [MCP, 12/02/2022]: add an 'equation' argument to be able to return # 'exact solution' diff --git a/psydac/feec/multipatch/examples/timedomain_maxwell.py b/psydac/feec/multipatch/examples/timedomain_maxwell.py index 9e772f3e5..f382f0111 100644 --- a/psydac/feec/multipatch/examples/timedomain_maxwell.py +++ b/psydac/feec/multipatch/examples/timedomain_maxwell.py @@ -30,25 +30,22 @@ from sympde.expr.expr import LinearForm from sympde.expr.expr import integral, Norm from sympde.topology import Derham +from psydac.linalg.basic import IdentityOperator from psydac.api.settings import PSYDAC_BACKENDS -from psydac.feec.pull_push import pull_2d_hcurl -from psydac.feec.multipatch.api import discretize -from psydac.feec.multipatch.fem_linear_operators import IdLinearOperator -from psydac.feec.multipatch.operators import HodgeOperator, get_K0_and_K0_inv, get_K1_and_K1_inv -# , write_field_to_diag_grid, +from psydac.api.discretization import discretize + from psydac.fem.plotting_utilities import plot_field_2d as plot_field from psydac.feec.multipatch.multipatch_domain_utilities import build_multipatch_domain -# , get_praxial_Gaussian_beam_E, get_easy_Gaussian_beam_E, get_easy_Gaussian_beam_B,get_easy_Gaussian_beam_E_2, get_easy_Gaussian_beam_B_2 + from psydac.feec.multipatch.examples.ppc_test_cases import get_source_and_solution_hcurl, get_div_free_pulse, get_curl_free_pulse, get_Delta_phi_pulse, get_Gaussian_beam from psydac.feec.multipatch.utils_conga_2d import DiagGrid, P0_phys, P1_phys, P2_phys, get_Vh_diags_for -from psydac.feec.multipatch.utilities import time_count # , export_sol, import_sol -from psydac.linalg.utilities import array_to_psydac +from psydac.feec.multipatch.utilities import time_count from psydac.fem.basic import FemField -from psydac.feec.multipatch.non_matching_operators import construct_hcurl_conforming_projection, construct_h1_conforming_projection from psydac.feec.multipatch.multipatch_domain_utilities import build_cartesian_multipatch_domain from psydac.api.postprocessing import OutputManager, PostProcessManager +from psydac.fem.projectors import get_dual_dofs def solve_td_maxwell_pbm(*, @@ -61,26 +58,13 @@ def solve_td_maxwell_pbm(*, backend='pyccel-gcc', source_type='zero', source_omega=None, - source_proj='P_geom', - conf_proj='BSP', - gamma_h=10., + source_proj='P_L2', project_sol=False, filter_source=True, - quad_param=1, - E0_type='zero', + E0_type='pulse_2', E0_proj='P_L2', - hide_plots=True, plot_dir=None, plot_time_ranges=None, - plot_source=False, - plot_divE=False, - diag_dt=None, - # diag_dtau = None, - cb_min_sol=None, - cb_max_sol=None, - m_load_dir=None, - th_sol_filename="", - source_is_harmonic=False, domain_lims=None ): """ @@ -145,16 +129,6 @@ def solve_td_maxwell_pbm(*, dual degrees of freedom. Change of basis from primal to dual (and vice versa) is obtained through multiplication with the proper Hodge matrix. - conf_proj : str {'BSP' | 'GSP'} - Kind of conforming projection operator. Choose 'BSP' for an operator - based on the spline coefficients, which has maximum data locality. - Choose 'GSP' for an operator based on the geometric degrees of freedom, - which requires a change of basis (from B-spline to geometric, and then - vice versa) on the patch interfaces. - - gamma_h : float - Jump penalization parameter. - project_sol : bool Whether the solution fields should be projected onto the corresponding conforming spaces before plotting them. @@ -163,23 +137,14 @@ def solve_td_maxwell_pbm(*, If True, the current source will be filtered with the conforming projector operator (or its dual, depending on which basis is used). - quad_param : int - Multiplicative factor for the number of quadrature points; set - `quad_param` > 1 if you suspect that the quadrature is not accurate. - - E0_type : str {'zero', 'th_sol', 'pulse'} - Initial conditions for the electric field. Choose 'zero' for E0=0, - 'th_sol' for a field obtained from the time-harmonic Maxwell solver - (must provide a time-harmonic current source and set `source_omega`), + E0_type : str {'zero', 'pulse'} + Initial conditions for the electric field. Choose 'zero' for E0=0 and 'pulse' for a non-zero field localized in a small region. E0_proj : str {'P_geom' | 'P_L2'} Name of the approximation operator for the initial electric field E0 (see source_proj for details). Only relevant if E0 is not zero. - hide_plots : bool - If True, no windows are opened to show the figures interactively. - plot_dir : str Path to the directory where the figures will be saved. @@ -187,33 +152,12 @@ def solve_td_maxwell_pbm(*, List of lists, of the form `[[start, end], dtp]`, where `[start, end]` is a time interval and `dtp` is the time between two successive plots. - plot_source : bool - If True, plot the discrete field that approximates the current source. - - plot_divE : bool - If True, compute and plot the (weak) divergence of the electric field. - - diag_dt : float - Time elapsed between two successive calculations of scalar diagnostic - quantities. - - cb_min_sol : float - Minimum value to be used in colorbars when visualizing the solution. - - cb_max_sol : float - Maximum value to be used in colorbars when visualizing the solution. - - m_load_dir : str - Path to directory for matrix storage. - - th_sol_filename : str - Path to file with time-harmonic solution (to be used in conjuction with - `source_is_harmonic = True` and `E0_type = 'th_sol'`). + domain_lims : list + If the domain_name is 'refined_square' or 'square_L_shape', this + parameter must be set to the list of the two intervals defining the + rectangular domain, i.e. `[[x_min, x_max], [y_min, y_max]]`. """ - diags = {} - - # ncells = [nc, nc] degree = [deg, deg] if source_omega is not None: @@ -225,18 +169,6 @@ def solve_td_maxwell_pbm(*, [[0, final_time], final_time] ] - if diag_dt is None: - diag_dt = 0.1 - - # if backend is None: - # if domain_name in ['pretzel', 'pretzel_f'] and nc > 8: - # backend = 'numba' - # else: - # backend = 'python' - # print('[note: using '+backend_language+ ' backends in discretize functions]') - if m_load_dir is not None: - if not os.path.exists(m_load_dir): - os.makedirs(m_load_dir) print('---------------------------------------------------------------------------------------------------------') print('Starting solve_td_maxwell_pbm function with: ') @@ -248,10 +180,8 @@ def solve_td_maxwell_pbm(*, print(' source_type = {}'.format(source_type)) print(' source_proj = {}'.format(source_proj)) print(' backend = {}'.format(backend)) - # TODO: print other parameters print('---------------------------------------------------------------------------------------------------------') - debug = False print() print(' -- building discrete spaces and operators --') @@ -267,10 +197,10 @@ def solve_td_maxwell_pbm(*, if isinstance(nc, int): ncells = [nc, nc] - elif ncells.ndim == 1: + elif nc.ndim == 1: ncells = {patch.name: [nc[i], nc[i]] for (i, patch) in enumerate(domain.interior)} - elif ncells.ndim == 2: + elif nc.ndim == 2: ncells = {patch.name: [nc[int(patch.name[2])][int(patch.name[4])], nc[int(patch.name[2])][int(patch.name[4])]] for patch in domain.interior} @@ -278,8 +208,6 @@ def solve_td_maxwell_pbm(*, for P in domain.interior]) mappings_list = list(mappings.values()) - # for diagnosttics - diag_grid = DiagGrid(mappings=mappings, N_diag=100) t_stamp = time_count(t_stamp) print(' .. derham sequence...') @@ -301,101 +229,37 @@ def solve_td_maxwell_pbm(*, t_stamp = time_count(t_stamp) print(' .. multi-patch spaces...') - V0h = derham_h.V0 - V1h = derham_h.V1 - V2h = derham_h.V2 - print('dim(V0h) = {}'.format(V0h.nbasis)) - print('dim(V1h) = {}'.format(V1h.nbasis)) - print('dim(V2h) = {}'.format(V2h.nbasis)) - diags['ndofs_V0'] = V0h.nbasis - diags['ndofs_V1'] = V1h.nbasis - diags['ndofs_V2'] = V2h.nbasis + V0h, V1h, V2h = derham_h.spaces t_stamp = time_count(t_stamp) print(' .. Id operator and matrix...') - I1 = IdLinearOperator(V1h) - I1_m = I1.to_sparse_matrix() + I1 = IdentityOperator(V1h.coeff_space) t_stamp = time_count(t_stamp) print(' .. Hodge operators...') - # multi-patch (broken) linear operators / matrices - # other option: define as Hodge Operators: - H0 = HodgeOperator( - V0h, - domain_h, - backend_language=backend, - load_dir=m_load_dir, - load_space_index=0) - H1 = HodgeOperator( - V1h, - domain_h, - backend_language=backend, - load_dir=m_load_dir, - load_space_index=1) - H2 = HodgeOperator( - V2h, - domain_h, - backend_language=backend, - load_dir=m_load_dir, - load_space_index=2) - - t_stamp = time_count(t_stamp) - print(' .. Hodge matrix H0_m = M0_m ...') - H0_m = H0.to_sparse_matrix() - t_stamp = time_count(t_stamp) - print(' .. dual Hodge matrix dH0_m = inv_M0_m ...') - dH0_m = H0.get_dual_Hodge_sparse_matrix() + H0, H1, H2 = derham_h.hodge_operators(kind='linop') + dH0, dH1, dH2 = derham_h.hodge_operators(kind='linop', dual=True) - t_stamp = time_count(t_stamp) - print(' .. Hodge matrix H1_m = M1_m ...') - H1_m = H1.to_sparse_matrix() - t_stamp = time_count(t_stamp) - print(' .. dual Hodge matrix dH1_m = inv_M1_m ...') - dH1_m = H1.get_dual_Hodge_sparse_matrix() - - t_stamp = time_count(t_stamp) - print(' .. Hodge matrix dH2_m = M2_m ...') - H2_m = H2.to_sparse_matrix() - print(' .. dual Hodge matrix dH2_m = inv_M2_m ...') - dH2_m = H2.get_dual_Hodge_sparse_matrix() t_stamp = time_count(t_stamp) print(' .. conforming Projection operators...') - cP0_m = construct_h1_conforming_projection(V0h, hom_bc=False) - cP1_m = construct_hcurl_conforming_projection(V1h, hom_bc=False) - - if conf_proj == 'GSP': - print(' [* GSP-conga: using Geometric Spline conf Projections ]') - K0, K0_inv = get_K0_and_K0_inv(V0h, uniform_patches=False) - cP0_m = K0_inv @ cP0_m @ K0 - K1, K1_inv = get_K1_and_K1_inv(V1h, uniform_patches=False) - cP1_m = K1_inv @ cP1_m @ K1 - elif conf_proj == 'BSP': - print(' [* BSP-conga: using B-Spline conf Projections ]') - else: - raise ValueError(conf_proj) + cP0, cP1, cP2 = derham_h.conforming_projectors(kind='linop', p_moments = degree[0]+2, hom_bc = False) t_stamp = time_count(t_stamp) print(' .. broken differential operators...') - # broken (patch-wise) differential operators - bD0, bD1 = derham_h.broken_derivatives_as_operators - bD0_m = bD0.to_sparse_matrix() - bD1_m = bD1.to_sparse_matrix() + bD0, bD1 = derham_h.derivatives(kind='linop') + if plot_dir is not None and not os.path.exists(plot_dir): os.makedirs(plot_dir) - # Conga (projection-based) matrices - t_stamp = time_count(t_stamp) - dH1_m = dH1_m.tocsr() - H2_m = H2_m.tocsr() - cP1_m = cP1_m.tocsr() - bD1_m = bD1_m.tocsr() - print(' .. matrix of the primal curl (in primal bases)...') - C_m = bD1_m @ cP1_m + C = bD1 @ cP1 print(' .. matrix of the dual curl (also in primal bases)...') + dC = dH1 @ C.T @ H2 + + ### Silvermueller ABC from sympde.calculus import grad, dot, curl, cross from sympde.topology import NormalVector from sympde.expr.expr import BilinearForm @@ -408,37 +272,38 @@ def solve_td_maxwell_pbm(*, a = BilinearForm((u, v), integral(boundary, expr_b)) ah = discretize(a, domain_h, [V1h, V1h], backend=PSYDAC_BACKENDS[backend],) - A_eps = ah.assemble().tosparse() + A_eps = ah.assemble() + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - dC_m = dH1_m @ C_m.transpose() @ H2_m # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # Compute stable time step size based on max CFL and max dt - dt = compute_stable_dt(C_m=C_m, dC_m=dC_m, cfl_max=cfl_max, dt_max=dt_max) + dt = compute_stable_dt(C=C, dC=dC, cfl_max=cfl_max, dt_max=dt_max) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - # Absorbing dC_m - CH2 = C_m.transpose() @ H2_m - H1A = H1_m + dt * A_eps - - H1A_csc = H1A.tocsc() - dC_m = sp.sparse.linalg.spsolve(H1A_csc, CH2.tocsc()) - dCH1_m = sp.sparse.linalg.spsolve(H1A_csc, H1_m.tocsc()) + # Absorbing dC + CH2 = C.T @ H2 + H1A = H1 + dt * A_eps + + # alternative inverse + # from psydac.linalg.solvers import inverse + # H1A_inv = inverse(H1A, solver='cg', tol=1e-8) + ### + M = H1A + from scipy.linalg import inv + from scipy.sparse import csr_matrix + from psydac.linalg.sparse import SparseMatrixLinearOperator + M_inv = inv(M.toarray()) + M_inv = csr_matrix(M_inv) + H1A_inv = SparseMatrixLinearOperator(M.codomain, M.domain, M_inv) + #### + + dC = H1A_inv @ CH2 + dCH1 = H1A_inv @ H1 print(' .. matrix of the dual div (still in primal bases)...') - div_m = dH0_m @ cP0_m.transpose() @ bD0_m.transpose() @ H1_m - - # jump stabilization (may not be needed) - t_stamp = time_count(t_stamp) - print(' .. jump stabilization matrix...') - jump_penal_m = I1_m - cP1_m - JP_m = jump_penal_m.transpose() * H1_m * jump_penal_m + D = dH0 @ cP0.T @ bD0.T @ H1 - # t_stamp = time_count(t_stamp) - # print(' .. full operator matrix...') - # print('STABILIZATION: gamma_h = {}'.format(gamma_h)) - # pre_A_m = cP1_m.transpose() @ ( eta * H1_m + mu * pre_CC_m - nu * pre_GD_m ) # useful for the boundary condition (if present) - # A_m = pre_A_m @ cP1_m + gamma_h * JP_m print(" Reduce time step to match the simulation final time:") Nt = int(np.ceil(final_time / dt)) @@ -447,8 +312,7 @@ def solve_td_maxwell_pbm(*, print(f" . Nb of time steps: Nt = {Nt}") # ... - def is_plotting_time(nt, *, dt=dt, Nt=Nt, - plot_time_ranges=plot_time_ranges): + def is_plotting_time(nt, *, dt=dt, Nt=Nt, plot_time_ranges=plot_time_ranges): if nt in [0, Nt]: return True for [start, end], dt_plots in plot_time_ranges: @@ -459,21 +323,11 @@ def is_plotting_time(nt, *, dt=dt, Nt=Nt, return False # ... - # Number of time step between two successive calculations of the scalar - # diagnostics - diag_nt = max(int(diag_dt // dt), 1) print(' ------ ------ ------ ------ ------ ------ ------ ------ ') print(' ------ ------ ------ ------ ------ ------ ------ ------ ') - print( - ' total nb of time steps: Nt = {}, final time: T = {:5.4f}'.format( - Nt, - final_time)) + print(' total nb of time steps: Nt = {}, final time: T = {:5.4f}'.format(Nt, final_time)) print(' ------ ------ ------ ------ ------ ------ ------ ------ ') - print(' plotting times: the solution will be plotted for...') - for nt in range(Nt + 1): - if is_plotting_time(nt): - print(' * nt = {}, t = {:5.4f}'.format(nt, dt * nt)) print(' ------ ------ ------ ------ ------ ------ ------ ------ ') print(' ------ ------ ------ ------ ------ ------ ------ ------ ') @@ -483,8 +337,10 @@ def is_plotting_time(nt, *, dt=dt, Nt=Nt, t_stamp = time_count(t_stamp) print() print(' -- getting source --') - f0_c = None - f0_harmonic_c = None + f0_h = None + f0_harmonic_h = None + rho0_h = None + if source_type == 'zero': f0 = None @@ -492,11 +348,11 @@ def is_plotting_time(nt, *, dt=dt, Nt=Nt, elif source_type == 'pulse': - f0 = get_div_free_pulse(x_0=1.0, y_0=1.0, domain=domain) + f0 = get_div_free_pulse(x_0=np.pi/2, y_0=np.pi/2, domain=domain) elif source_type == 'cf_pulse': - f0 = get_curl_free_pulse(x_0=1.0, y_0=1.0, domain=domain) + f0 = get_curl_free_pulse(x_0=np.pi/2, y_0=np.pi/2, domain=domain) elif source_type == 'Il_pulse': # Issautier-like pulse # source will be @@ -507,91 +363,60 @@ def is_plotting_time(nt, *, dt=dt, Nt=Nt, # rho = - sin(om*t)/om * Delta phi # and Gauss' law reads # div E = rho = - sin(om*t)/om * Delta phi - f0 = get_div_free_pulse( - x_0=1.0, y_0=1.0, domain=domain) # this is curl A - f0_harmonic = get_curl_free_pulse( - x_0=1.0, y_0=1.0, domain=domain) # this is grad phi - assert not source_is_harmonic - - rho0 = get_Delta_phi_pulse( - x_0=1.0, y_0=1.0, domain=domain) # this is Delta phi - tilde_rho0_c = derham_h.get_dual_dofs( - space='V0', - f=rho0, - backend_language=backend, - return_format='numpy_array') - tilde_rho0_c = cP0_m.transpose() @ tilde_rho0_c - rho0_c = dH0_m.dot(tilde_rho0_c) + f0 = get_div_free_pulse(x_0=np.pi/2, y_0=np.pi/2, domain=domain) # this is curl A + f0_harmonic = get_curl_free_pulse( x_0=np.pi/2, y_0=np.pi/2, domain=domain) # this is grad phi + + rho0 = get_Delta_phi_pulse(x_0=np.pi/2, y_0=np.pi/2, domain=domain) # this is Delta phi + tilde_rho0_h = get_dual_dofs(Vh=V0h, f=rho0, domain_h=domain_h, backend_language=backend) + tilde_rho0_h = cP0.T @ tilde_rho0_h + rho0_h = dH0.dot(tilde_rho0_h) else: - f0, u_bc, u_ex, curl_u_ex, div_u_ex = get_source_and_solution_hcurl( - source_type=source_type, domain=domain, domain_name=domain_name, - ) + f0, u_bc, u_ex, curl_u_ex, div_u_ex = get_source_and_solution_hcurl(source_type=source_type, domain=domain, domain_name=domain_name) assert u_bc is None # only homogeneous BC's for now - # f0_c = np.zeros(V1h.nbasis) if source_omega is not None: f0_harmonic = f0 f0 = None - if E0_type == 'th_sol': - # use source enveloppe for smooth transition from 0 to 1 - def source_enveloppe(tau): - return (special.erf((tau / 25) - 2) - special.erf(-2)) / 2 - else: - def source_enveloppe(tau): - return 1 + + def source_enveloppe(tau): + return 1 t_stamp = time_count(t_stamp) - tilde_f0_c = f0_c = None - tilde_f0_harmonic_c = f0_harmonic_c = None + tilde_f0_h = f0_h = None + tilde_f0_harmonic_h = f0_harmonic_h = None + if source_proj == 'P_geom': print(' .. projecting the source with commuting projection...') + if f0 is not None: - f0_h = P1_phys(f0, P1, domain, mappings_list) - f0_c = f0_h.coeffs.toarray() - tilde_f0_c = H1_m.dot(f0_c) + f0_h = P1_phys(f0, P1, domain).coeffs + tilde_f0_h = H1.dot(f0_h) + if f0_harmonic is not None: - f0_harmonic_h = P1_phys(f0_harmonic, P1, domain, mappings_list) - f0_harmonic_c = f0_harmonic_h.coeffs.toarray() - tilde_f0_harmonic_c = H1_m.dot(f0_harmonic_c) + f0_harmonic_h = P1_phys(f0_harmonic, P1, domain).coeffs + tilde_f0_harmonic_h = H1.dot(f0_harmonic_h) elif source_proj == 'P_L2': - # helper: save/load coefs + if f0 is not None: if source_type == 'Il_pulse': source_name = 'Il_pulse_f0' else: source_name = source_type - sdd_filename = m_load_dir + '/' + source_name + \ - '_dual_dofs_qp{}.npy'.format(quad_param) - if os.path.exists(sdd_filename): - print( - ' .. loading source dual dofs from file {}'.format(sdd_filename)) - tilde_f0_c = np.load(sdd_filename) - else: - print(' .. projecting the source f0 with L2 projection...') - tilde_f0_c = derham_h.get_dual_dofs( - space='V1', f=f0, backend_language=backend, return_format='numpy_array') - print(' .. saving source dual dofs to file {}'.format(sdd_filename)) - np.save(sdd_filename, tilde_f0_c) + + print(' .. projecting the source f0 with L2 projection...') + tilde_f0_h = get_dual_dofs(Vh=V1h, f=f0, domain_h=domain_h, backend_language=backend) + if f0_harmonic is not None: if source_type == 'Il_pulse': source_name = 'Il_pulse_f0_harmonic' else: source_name = source_type - sdd_filename = m_load_dir + '/' + source_name + \ - '_dual_dofs_qp{}.npy'.format(quad_param) - if os.path.exists(sdd_filename): - print( - ' .. loading source dual dofs from file {}'.format(sdd_filename)) - tilde_f0_harmonic_c = np.load(sdd_filename) - else: - print(' .. projecting the source f0_harmonic with L2 projection...') - tilde_f0_harmonic_c = derham_h.get_dual_dofs( - space='V1', f=f0_harmonic, backend_language=backend, return_format='numpy_array') - print(' .. saving source dual dofs to file {}'.format(sdd_filename)) - np.save(sdd_filename, tilde_f0_harmonic_c) + + print(' .. projecting the source f0_harmonic with L2 projection...') + tilde_f0_harmonic_h = get_dual_dofs(Vh=V1h, f=f0_harmonic, domain_h=domain_h, backend_language=backend) else: raise ValueError(source_proj) @@ -599,259 +424,23 @@ def source_enveloppe(tau): t_stamp = time_count(t_stamp) if filter_source: print(' .. filtering the source...') - if tilde_f0_c is not None: - tilde_f0_c = cP1_m.transpose() @ tilde_f0_c - if tilde_f0_harmonic_c is not None: - tilde_f0_harmonic_c = cP1_m.transpose() @ tilde_f0_harmonic_c - - if tilde_f0_c is not None: - f0_c = dH1_m.dot(tilde_f0_c) - - if debug: - title = 'f0 part of source' - params_str = 'omega={}_gamma_h={}_Pf={}'.format( - source_omega, gamma_h, source_proj) - plot_field(numpy_coeffs=f0_c, Vh=V1h, space_kind='hcurl', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + '_f0.pdf', - plot_type='components', cb_min=cb_min_sol, cb_max=cb_max_sol, hide_plot=hide_plots) - plot_field(numpy_coeffs=f0_c, Vh=V1h, space_kind='hcurl', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + '_f0_vf.pdf', - plot_type='vector_field', cb_min=None, cb_max=None, hide_plot=hide_plots) - divf0_c = div_m @ f0_c - title = 'div f0' - plot_field(numpy_coeffs=divf0_c, Vh=V0h, space_kind='h1', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + '_divf0.pdf', - plot_type='components', cb_min=cb_min_sol, cb_max=cb_max_sol, hide_plot=hide_plots) - - if tilde_f0_harmonic_c is not None: - f0_harmonic_c = dH1_m.dot(tilde_f0_harmonic_c) - - if debug: - title = 'f0_harmonic part of source' - params_str = 'omega={}_gamma_h={}_Pf={}'.format( - source_omega, gamma_h, source_proj) - plot_field(numpy_coeffs=f0_harmonic_c, Vh=V1h, space_kind='hcurl', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + '_f0_harmonic.pdf', - plot_type='components', cb_min=None, cb_max=None, hide_plot=hide_plots) - plot_field(numpy_coeffs=f0_harmonic_c, Vh=V1h, space_kind='hcurl', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + '_f0_harmonic_vf.pdf', - plot_type='vector_field', cb_min=None, cb_max=None, hide_plot=hide_plots) - divf0_c = div_m @ f0_harmonic_c - title = 'div f0_harmonic' - plot_field(numpy_coeffs=divf0_c, Vh=V0h, space_kind='h1', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + '_divf0_harmonic.pdf', - plot_type='components', cb_min=cb_min_sol, cb_max=cb_max_sol, hide_plot=hide_plots) - - # else: - # raise NotImplementedError - - if f0_c is None: - f0_c = np.zeros(V1h.nbasis) - - # if plot_source and plot_dir: - # plot_field(numpy_coeffs=f0_c, Vh=V1h, space_kind='hcurl', domain=domain, title='f0_h with P = '+source_proj, filename=plot_dir+'/f0h_'+source_proj+'.png', hide_plot=hide_plots) - # plot_field(numpy_coeffs=f0_c, Vh=V1h, plot_type='vector_field', space_kind='hcurl', domain=domain, title='f0_h with P = '+source_proj, filename=plot_dir+'/f0h_'+source_proj+'_vf.png', hide_plot=hide_plots) + if tilde_f0_h is not None: + tilde_f0_h = cP1.T @ tilde_f0_h - t_stamp = time_count(t_stamp) + if tilde_f0_harmonic_h is not None: + tilde_f0_harmonic_h = cP1.T @ tilde_f0_harmonic_h + + if tilde_f0_h is not None: + f0_h = dH1.dot(tilde_f0_h) + + if tilde_f0_harmonic_h is not None: + f0_harmonic_h = dH1.dot(tilde_f0_harmonic_h) - def plot_J_source_nPlusHalf(f_c, nt): - print(' .. plotting the source...') - title = r'source $J^{n+1/2}_h$ (amplitude)' + \ - ' for $\\omega = {}$, $n = {}$'.format(source_omega, nt) - params_str = 'omega={}_gamma_h={}_Pf={}'.format( - source_omega, gamma_h, source_proj) - plot_field(numpy_coeffs=f_c, Vh=V1h, space_kind='hcurl', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + - '_Jh_nt={}.pdf'.format(nt), - plot_type='amplitude', cb_min=cb_min_sol, cb_max=cb_max_sol, hide_plot=hide_plots) - title = r'source $J^{n+1/2}_h$' + \ - ' for $\\omega = {}$, $n = {}$'.format(source_omega, nt) - plot_field(numpy_coeffs=f_c, Vh=V1h, space_kind='hcurl', domain=domain, title=title, - filename=plot_dir + '/' + params_str + - '_Jh_vf_nt={}.pdf'.format(nt), - plot_type='vector_field', vf_skip=1, hide_plot=hide_plots) - - def plot_E_field(E_c, nt, project_sol=False, plot_divE=False): - - # only E for now - if plot_dir: - - plot_omega_normalized_sol = (source_omega is not None) - # project the homogeneous solution on the conforming problem space - if project_sol: - # t_stamp = time_count(t_stamp) - print( - ' .. projecting the homogeneous solution on the conforming problem space...') - Ep_c = cP1_m.dot(E_c) - else: - Ep_c = E_c - print( - ' .. NOT projecting the homogeneous solution on the conforming problem space') - if plot_omega_normalized_sol: - print(' .. plotting the E/omega field...') - u_c = (1 / source_omega) * Ep_c - title = r'$u_h = E_h/\omega$ (amplitude) for $\omega = {:5.4f}$, $t = {:5.4f}$'.format( - source_omega, dt * nt) - params_str = 'omega={:5.4f}_gamma_h={}_Pf={}_Nt_pp={}'.format( - source_omega, gamma_h, source_proj, Nt_pp) - else: - print(' .. plotting the E field...') - if E0_type == 'pulse': - title = r'$t = {:5.4f}$'.format(dt * nt) - else: - title = r'$E_h$ (amplitude) at $t = {:5.4f}$'.format( - dt * nt) - u_c = Ep_c - params_str = f'gamma_h={gamma_h}_dt={dt}' - - plot_field(numpy_coeffs=u_c, Vh=V1h, space_kind='hcurl', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + - '_Eh_nt={}.pdf'.format(nt), - plot_type='amplitude', cb_min=cb_min_sol, cb_max=cb_max_sol, hide_plot=hide_plots) - - if plot_divE: - params_str = f'gamma_h={gamma_h}_dt={dt}' - if source_type == 'Il_pulse': - plot_type = 'components' - rho_c = rho0_c * \ - np.sin(source_omega * dt * nt) / source_omega - rho_norm2 = np.dot(rho_c, H0_m.dot(rho_c)) - title = r'$\rho_h$ at $t = {:5.4f}, norm = {}$'.format( - dt * nt, np.sqrt(rho_norm2)) - plot_field(numpy_coeffs=rho_c, Vh=V0h, space_kind='h1', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + - '_rho_nt={}.pdf'.format(nt), - plot_type=plot_type, cb_min=None, cb_max=None, hide_plot=hide_plots) - else: - plot_type = 'amplitude' - - divE_c = div_m @ Ep_c - divE_norm2 = np.dot(divE_c, H0_m.dot(divE_c)) - if project_sol: - title = r'div $P^1_h E_h$ at $t = {:5.4f}, norm = {}$'.format( - dt * nt, np.sqrt(divE_norm2)) - else: - title = r'div $E_h$ at $t = {:5.4f}, norm = {}$'.format( - dt * nt, np.sqrt(divE_norm2)) - plot_field(numpy_coeffs=divE_c, Vh=V0h, space_kind='h1', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + - '_divEh_nt={}.pdf'.format(nt), - plot_type=plot_type, cb_min=None, cb_max=None, hide_plot=hide_plots) - - else: - print(' -- WARNING: unknown plot_dir !!') - - def plot_B_field(B_c, nt): - - if plot_dir: - - print(' .. plotting B field...') - params_str = f'gamma_h={gamma_h}_dt={dt}' - - title = r'$B_h$ (amplitude) for $t = {:5.4f}$'.format(dt * nt) - plot_field(numpy_coeffs=B_c, Vh=V2h, space_kind='l2', domain=domain, surface_plot=False, title=title, - filename=plot_dir + '/' + params_str + - '_Bh_nt={}.pdf'.format(nt), - plot_type='amplitude', cb_min=cb_min_sol, cb_max=cb_max_sol, hide_plot=hide_plots) - - else: - print(' -- WARNING: unknown plot_dir !!') - - def plot_time_diags(time_diag, E_norm2_diag, B_norm2_diag, divE_norm2_diag, nt_start, nt_end, - GaussErr_norm2_diag=None, GaussErrP_norm2_diag=None, - PE_norm2_diag=None, I_PE_norm2_diag=None, J_norm2_diag=None, skip_titles=True): - - nt_start = max(nt_start, 0) - nt_end = min(nt_end, Nt) - - td = time_diag[nt_start:nt_end + 1] - t_label = r'$t$' - - # norm || E || - fig, ax = plt.subplots() - ax.plot(td, - np.sqrt(E_norm2_diag[nt_start:nt_end + 1]), - '-', - ms=7, - mfc='None', - mec='k') # , label='||E||', zorder=10) - if skip_titles: - title = '' - else: - title = r'$||E_h(t)||$ vs ' + t_label - ax.set_xlabel(t_label, fontsize=16) - ax.set_title(title, fontsize=18) - fig.tight_layout() - diag_fn = plot_dir + \ - f'/diag_E_norm_gamma={gamma_h}_dt={dt}_trange=[{dt*nt_start}, {dt*nt_end}].pdf' - print(f"saving plot for '{title}' in figure '{diag_fn}") - fig.savefig(diag_fn) - - # energy - fig, ax = plt.subplots() - E_energ = .5 * E_norm2_diag[nt_start:nt_end + 1] - B_energ = .5 * B_norm2_diag[nt_start:nt_end + 1] - ax.plot(td, E_energ, '-', ms=7, mfc='None', c='k', - label=r'$\frac{1}{2}||E||^2$') # , zorder=10) - ax.plot(td, B_energ, '-', ms=7, mfc='None', c='g', - label=r'$\frac{1}{2}||B||^2$') # , zorder=10) - ax.plot(td, E_energ + B_energ, '-', ms=7, mfc='None', c='b', - label=r'$\frac{1}{2}(||E||^2+||B||^2)$') # , zorder=10) - ax.legend(loc='best') - if skip_titles: - title = '' - else: - title = r'energy vs ' + t_label - if E0_type == 'pulse': - ax.set_ylim([0, 5]) - ax.set_xlabel(t_label, fontsize=16) - ax.set_title(title, fontsize=18) - fig.tight_layout() - diag_fn = plot_dir + \ - f'/diag_energy_gamma={gamma_h}_dt={dt}_trange=[{dt*nt_start},{dt*nt_end}].pdf' - print(f"saving plot for '{title}' in figure '{diag_fn}") - fig.savefig(diag_fn) - - # One curve per plot from now on. - # Collect information in a list where each item is of the form [tag, - # data, title] - time_diagnostics = [] - - if project_sol: - time_diagnostics += [['divPE', divE_norm2_diag, - r'$||div_h P^1_h E_h(t)||$ vs ' + t_label]] - else: - time_diagnostics += [['divE', divE_norm2_diag, - r'$||div_h E_h(t)||$ vs ' + t_label]] - - time_diagnostics += [ - ['I_PE', I_PE_norm2_diag, r'$||(I-P^1)E_h(t)||$ vs ' + t_label], - ['PE', PE_norm2_diag, r'$||(I-P^1)E_h(t)||$ vs ' + t_label], - ['GaussErr', GaussErr_norm2_diag, - r'$||(\rho_h - div_h E_h)(t)||$ vs ' + t_label], - ['GaussErrP', GaussErrP_norm2_diag, - r'$||(\rho_h - div_h E_h)(t)||$ vs ' + t_label], - ['J_norm', J_norm2_diag, r'$||J_h(t)||$ vs ' + t_label], - ] - for tag, data, title in time_diagnostics: - if data is None: - continue - fig, ax = plt.subplots() - ax.plot(td, - np.sqrt(I_PE_norm2_diag[nt_start:nt_end + 1]), - '-', - ms=7, - mfc='None', - mec='k') # , label='||E||', zorder=10) - diag_fn = plot_dir + \ - f'/diag_{tag}_gamma={gamma_h}_dt={dt}_trange=[{dt*nt_start},{dt*nt_end}].pdf' - ax.set_xlabel(t_label, fontsize=16) - if not skip_titles: - ax.set_title(title, fontsize=18) - fig.tight_layout() - print(f"saving plot for '{title}' in figure '{diag_fn}") - fig.savefig(diag_fn) + if f0_h is None: + f0_h = V1h.coeff_space.zeros() + + t_stamp = time_count(t_stamp) # diags arrays E_norm2_diag = np.zeros(Nt + 1) @@ -874,114 +463,73 @@ def plot_time_diags(time_diag, E_norm2_diag, B_norm2_diag, divE_norm2_diag, nt_s print(' .. initial solution ..') # initial B sol - B_c = np.zeros(V2h.nbasis) + B_h = V2h.coeff_space.zeros() + E_h = V1h.coeff_space.zeros() # initial E sol - if E0_type == 'th_sol': - - if os.path.exists(th_sol_filename): - print( - ' .. loading time-harmonic solution from file {}'.format(th_sol_filename)) - E_c = source_omega * np.load(th_sol_filename) - assert len(E_c) == V1h.nbasis - else: - print( - ' .. Error: time-harmonic solution file given {}, but not found'.format(th_sol_filename)) - raise ValueError(th_sol_filename) - - elif E0_type == 'zero': - E_c = np.zeros(V1h.nbasis) + if E0_type == 'zero': + E_h = V1h.coeff_space.zeros() elif E0_type == 'pulse': - E0 = get_div_free_pulse(x_0=1.0, y_0=1.0, domain=domain) + E0 = get_div_free_pulse(x_0=np.pi/2, y_0=np.pi/2, domain=domain) if E0_proj == 'P_geom': print(' .. projecting E0 with commuting projection...') - E0_h = P1_phys(E0, P1, domain, mappings_list) - E_c = E0_h.coeffs.toarray() + E0_h = P1_phys(E0, P1, domain) + E_h = E0_h.coeffs elif E0_proj == 'P_L2': - # helper: save/load coefs - E0dd_filename = m_load_dir + \ - '/E0_pulse_dual_dofs_qp{}.npy'.format(quad_param) - if os.path.exists(E0dd_filename): - print(' .. loading E0 dual dofs from file {}'.format(E0dd_filename)) - tilde_E0_c = np.load(E0dd_filename) - else: - print(' .. projecting E0 with L2 projection...') - tilde_E0_c = derham_h.get_dual_dofs( - space='V1', f=E0, backend_language=backend, return_format='numpy_array') - print(' .. saving E0 dual dofs to file {}'.format(E0dd_filename)) - np.save(E0dd_filename, tilde_E0_c) - E_c = dH1_m.dot(tilde_E0_c) - elif E0_type == 'pulse_2': - # E0 = get_praxial_Gaussian_beam_E(x_0=3.14, y_0=3.14, domain=domain) + print(' .. projecting E0 with L2 projection...') + tilde_E0_h = get_dual_dofs(Vh=V1h, f=E0, domain_h=domain_h, backend_language=backend) + E_h = dH1.dot(tilde_E0_h) - # E0 = get_easy_Gaussian_beam_E_2(x_0=0.05, y_0=0.05, domain=domain) - # B0 = get_easy_Gaussian_beam_B_2(x_0=0.05, y_0=0.05, domain=domain) + elif E0_type == 'pulse_2': - E0, B0 = get_Gaussian_beam(y_0=3.14, x_0=3.14, domain=domain) - # B0 = get_easy_Gaussian_beam_B(x_0=3.14, y_0=0.05, domain=domain) + E0, B0 = get_Gaussian_beam(y_0=np.pi/2, x_0=np.pi/2, domain=domain) if E0_proj == 'P_geom': print(' .. projecting E0 with commuting projection...') - E0_h = P1_phys(E0, P1, domain, mappings_list) - E_c = E0_h.coeffs.toarray() + E0_h = P1_phys(E0, P1, domain) + E_h = E0_h.coeffs - # B_c = np.real( - 1j * C_m @ E_c) - # E_c = np.real(E_c) - B0_h = P2_phys(B0, P2, domain, mappings_list) - B_c = B0_h.coeffs.toarray() + B0_h = P2_phys(B0, P2, domain) + B_h = B0_h.coeffs elif E0_proj == 'P_L2': - # helper: save/load coefs - E0dd_filename = m_load_dir + \ - '/E0_pulse_dual_dofs_qp{}.npy'.format(quad_param) - if False: # os.path.exists(E0dd_filename): - print(' .. loading E0 dual dofs from file {}'.format(E0dd_filename)) - tilde_E0_c = np.load(E0dd_filename) - else: - print(' .. projecting E0 with L2 projection...') - - tilde_E0_c = derham_h.get_dual_dofs( - space='V1', f=E0, backend_language=backend, return_format='numpy_array') - print(' .. saving E0 dual dofs to file {}'.format(E0dd_filename)) - # np.save(E0dd_filename, tilde_E0_c) + + print(' .. projecting E0 with L2 projection...') + tilde_E0_h = get_dual_dofs(Vh=V1h, f=E0, domain_h=domain_h, backend_language=backend) + E_h = dH1.dot(tilde_E0_h) - E_c = dH1_m.dot(tilde_E0_c) - dH2_m = H2.get_dual_sparse_matrix() - tilde_B0_c = derham_h.get_dual_dofs( - space='V2', f=B0, backend_language=backend, return_format='numpy_array') - B_c = dH2_m.dot(tilde_B0_c) + tilde_B0_h = get_dual_dofs(Vh=V2h, f=B0, domain_h=domain_h, backend_language=backend) + B_h = dH2.dot(tilde_B0_h) - # B_c = np.real( - C_m @ E_c) - # E_c = np.real(E_c) else: raise ValueError(E0_type) # ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- ----- # time loop - def compute_diags(E_c, B_c, J_c, nt): + def compute_diags(E_h, B_h, J_h, nt): time_diag[nt] = (nt) * dt - PE_c = cP1_m.dot(E_c) - I_PE_c = E_c - PE_c - E_norm2_diag[nt] = np.dot(E_c, H1_m.dot(E_c)) - PE_norm2_diag[nt] = np.dot(PE_c, H1_m.dot(PE_c)) - I_PE_norm2_diag[nt] = np.dot(I_PE_c, H1_m.dot(I_PE_c)) - J_norm2_diag[nt] = np.dot(J_c, H1_m.dot(J_c)) - B_norm2_diag[nt] = np.dot(B_c, H2_m.dot(B_c)) - divE_c = div_m @ E_c - divE_norm2_diag[nt] = np.dot(divE_c, H0_m.dot(divE_c)) - if source_type == 'Il_pulse': - rho_c = rho0_c * np.sin(source_omega * nt * dt) / omega - GaussErr = rho_c - divE_c - GaussErrP = rho_c - div_m @ PE_c - GaussErr_norm2_diag[nt] = np.dot(GaussErr, H0_m.dot(GaussErr)) - GaussErrP_norm2_diag[nt] = np.dot(GaussErrP, H0_m.dot(GaussErrP)) + PE_h = cP1.dot(E_h) + I_PE_h = E_h - PE_h + E_norm2_diag[nt] = E_h.inner(H1.dot(E_h)) + PE_norm2_diag[nt] = PE_h.inner(H1.dot(PE_h)) + I_PE_norm2_diag[nt] = I_PE_h.inner(H1.dot(I_PE_h)) + J_norm2_diag[nt] = J_h.inner(H1.dot(J_h)) + B_norm2_diag[nt] = B_h.inner(H2.dot(B_h)) + divE_h = D @ E_h + divE_norm2_diag[nt] = divE_h.inner(H0.dot(divE_h)) + if source_type == 'Il_pulse' and source_omega is not None: + rho_h = rho0_h * np.sin(source_omega * nt * dt) / omega + GaussErr = rho_h - divE_h + GaussErrP = rho_h - D @ PE_h + GaussErr_norm2_diag[nt] = GaussErr.inner(H0.dot(GaussErr)) + GaussErrP_norm2_diag[nt] = GaussErrP.inner(H0.dot(GaussErrP)) if plot_dir: OM1 = OutputManager(plot_dir + '/spaces1.yml', plot_dir + '/fields1.h5') @@ -992,206 +540,85 @@ def compute_diags(E_c, B_c, J_c, nt): OM2.add_spaces(V2h=V2h) OM2.export_space_info() - stencil_coeffs_E = array_to_psydac(cP1_m @ E_c, V1h.coeff_space) - Eh = FemField(V1h, coeffs=stencil_coeffs_E) + Eh = FemField(V1h, coeffs=cP1 @ E_h) OM1.add_snapshot(t=0, ts=0) OM1.export_fields(Eh=Eh) - stencil_coeffs_B = array_to_psydac(B_c, V2h.coeff_space) - Bh = FemField(V2h, coeffs=stencil_coeffs_B) + Bh = FemField(V2h, coeffs=B_h) OM2.add_snapshot(t=0, ts=0) OM2.export_fields(Bh=Bh) - # PM = PostProcessManager(domain=domain, space_file=plot_dir+'/spaces1.yml', fields_file=plot_dir+'/fields1.h5' ) - # PM.export_to_vtk(plot_dir+"/Eh",grid=None, npts_per_cell=[6]*2, snapshots='all', fields='vh' ) - - # OM1.close() - # PM.close() - - # plot_E_field(E_c, nt=0, project_sol=project_sol, plot_divE=plot_divE) - # plot_B_field(B_c, nt=0) - f_c = np.copy(f0_c) + f_h = f0_h.copy() for nt in range(Nt): print(' .. nt+1 = {}/{}'.format(nt + 1, Nt)) # 1/2 faraday: Bn -> Bn+1/2 - B_c[:] -= (dt / 2) * C_m @ E_c + B_h -= (dt / 2) * C @ E_h # ampere: En -> En+1 - if f0_harmonic_c is not None: - f_harmonic_c = f0_harmonic_c * (np.sin(source_omega * (nt + 1) * dt) - np.sin( - source_omega * (nt) * dt)) / (dt * source_omega) # * source_enveloppe(omega*(nt+1/2)*dt) - f_c[:] = f0_c + f_harmonic_c + if f0_harmonic_h is not None and source_omega is not None: + f_harmonic_h = f0_harmonic_h * (np.sin(source_omega * (nt + 1) * dt) - np.sin(source_omega * (nt) * dt)) / (dt * source_omega) # * source_enveloppe(omega*(nt+1/2)*dt) + f_h = f0_h + f_harmonic_h - if nt == 0: - if plot_dir: - plot_J_source_nPlusHalf(f_c, nt=0) - compute_diags(E_c, B_c, f_c, nt=0) - - E_c[:] = dCH1_m @ E_c + dt * (dC_m @ B_c - f_c) - - # if abs(gamma_h) > 1e-10: - # E_c[:] -= dt * gamma_h * JP_m @ E_c + E_h = dCH1 @ E_h + dt * (dC @ B_h - f_h) # 1/2 faraday: Bn+1/2 -> Bn+1 - B_c[:] -= (dt / 2) * C_m @ E_c + B_h -= (dt / 2) * C @ E_h # diags: - compute_diags(E_c, B_c, f_c, nt=nt + 1) - - # PE_c = cP1_m.dot(E_c) - # I_PE_c = E_c-PE_c - # E_norm2_diag[nt+1] = np.dot(E_c,H1_m.dot(E_c)) - # PE_norm2_diag[nt+1] = np.dot(PE_c,H1_m.dot(PE_c)) - # I_PE_norm2_diag[nt+1] = np.dot(I_PE_c,H1_m.dot(I_PE_c)) - # B_norm2_diag[nt+1] = np.dot(B_c,H2_m.dot(B_c)) - # time_diag[nt+1] = (nt+1)*dt - - # diags: div - # if project_sol: - # Ep_c = PE_c # = cP1_m.dot(E_c) - # else: - # Ep_c = E_c - # divE_c = div_m @ Ep_c - # divE_norm2 = np.dot(divE_c, H0_m.dot(divE_c)) - # # print('in diag[{}]: divE_norm = {}'.format(nt+1, np.sqrt(divE_norm2))) - # divE_norm2_diag[nt+1] = divE_norm2 - - # if source_type == 'Il_pulse': - # rho_c = rho0_c * np.sin(omega*dt*(nt+1))/omega - # GaussErr = rho_c - div_m @ E_c - # GaussErrP = rho_c - div_m @ (cP1_m.dot(E_c)) - # GaussErr_norm2_diag[nt+1] = np.dot(GaussErr, H0_m.dot(GaussErr)) - # GaussErrP_norm2_diag[nt+1] = np.dot(GaussErrP, H0_m.dot(GaussErrP)) - - if debug: - divCB_c = div_m @ dC_m @ B_c - divCB_norm2 = np.dot(divCB_c, H0_m.dot(divCB_c)) - print('-- [{}]: dt*|| div CB || = {}'.format(nt + - 1, dt * np.sqrt(divCB_norm2))) - - divf_c = div_m @ f_c - divf_norm2 = np.dot(divf_c, H0_m.dot(divf_c)) - print('-- [{}]: dt*|| div f || = {}'.format(nt + - 1, dt * np.sqrt(divf_norm2))) - - divE_c = div_m @ E_c - divE_norm2 = np.dot(divE_c, H0_m.dot(divE_c)) - print('-- [{}]: || div E || = {}'.format(nt + 1, np.sqrt(divE_norm2))) + compute_diags(E_h, B_h, f_h, nt=nt + 1) + + if is_plotting_time(nt + 1) and plot_dir: - print("Plot Stuff") - # plot_E_field(E_c, nt=nt+1, project_sol=True, plot_divE=False) - # plot_B_field(B_c, nt=nt+1) - # plot_J_source_nPlusHalf(f_c, nt=nt) + print("Plot fields") - stencil_coeffs_E = array_to_psydac(cP1_m @ E_c, V1h.coeff_space) - Eh = FemField(V1h, coeffs=stencil_coeffs_E) + Eh = FemField(V1h, coeffs=cP1 @ E_h) OM1.add_snapshot(t=nt * dt, ts=nt) OM1.export_fields(Eh=Eh) - stencil_coeffs_B = array_to_psydac(B_c, V2h.coeff_space) - Bh = FemField(V2h, coeffs=stencil_coeffs_B) + Bh = FemField(V2h, coeffs=B_h) OM2.add_snapshot(t=nt * dt, ts=nt) OM2.export_fields(Bh=Bh) - # if (nt+1) % diag_nt == 0: - # plot_time_diags(time_diag, E_norm2_diag, B_norm2_diag, divE_norm2_diag, nt_start=(nt+1)-diag_nt, nt_end=(nt+1), - # PE_norm2_diag=PE_norm2_diag, I_PE_norm2_diag=I_PE_norm2_diag, J_norm2_diag=J_norm2_diag, - # GaussErr_norm2_diag=GaussErr_norm2_diag, - # GaussErrP_norm2_diag=GaussErrP_norm2_diag) + if plot_dir: OM1.close() - print("Do some PP") + print("Post process fields") PM = PostProcessManager( domain=domain, - space_file=plot_dir + - '/spaces1.yml', - fields_file=plot_dir + - '/fields1.h5') + space_file=plot_dir + '/spaces1.yml', + fields_file=plot_dir + '/fields1.h5') PM.export_to_vtk( plot_dir + "/Eh", grid=None, - npts_per_cell=2, + npts_per_cell=4, snapshots='all', fields='Eh') PM.close() PM = PostProcessManager( domain=domain, - space_file=plot_dir + - '/spaces2.yml', - fields_file=plot_dir + - '/fields2.h5') + space_file=plot_dir + '/spaces2.yml', + fields_file=plot_dir + '/fields2.h5') PM.export_to_vtk( plot_dir + "/Bh", grid=None, - npts_per_cell=2, + npts_per_cell=4, snapshots='all', fields='Bh') PM.close() - # plot_time_diags(time_diag, E_norm2_diag, B_norm2_diag, divE_norm2_diag, nt_start=0, nt_end=Nt, - # PE_norm2_diag=PE_norm2_diag, I_PE_norm2_diag=I_PE_norm2_diag, J_norm2_diag=J_norm2_diag, - # GaussErr_norm2_diag=GaussErr_norm2_diag, - # GaussErrP_norm2_diag=GaussErrP_norm2_diag) - - # Eh = FemField(V1h, coeffs=array_to_stencil(E_c, V1h.coeff_space)) - # t_stamp = time_count(t_stamp) - - # if sol_filename: - # raise NotImplementedError - # print(' .. saving final solution coeffs to file {}'.format(sol_filename)) - # np.save(sol_filename, E_c) - - # time_count(t_stamp) - - # print() - # print(' -- plots and diagnostics --') - - # # diagnostics: errors - # err_diags = diag_grid.get_diags_for(v=uh, space='V1') - # for key, value in err_diags.items(): - # diags[key] = value - # if u_ex is not None: - # check_diags = get_Vh_diags_for(v=uh, v_ref=uh_ref, M_m=H1_m, msg='error between Ph(u_ex) and u_h') - # diags['norm_Pu_ex'] = check_diags['sol_ref_norm'] - # diags['rel_l2_error_in_Vh'] = check_diags['rel_l2_error'] - # if curl_u_ex is not None: - # print(' .. diag on curl_u:') - # curl_uh_c = bD1_m @ cP1_m @ uh_c - # title = r'curl $u_h$ (amplitude) for $\eta = $'+repr(eta) - # params_str = 'eta={}_mu={}_nu={}_gamma_h={}_Pf={}'.format(eta, mu, nu, gamma_h, source_proj) - # plot_field(numpy_coeffs=curl_uh_c, Vh=V2h, space_kind='l2', domain=domain, surface_plot=False, title=title, filename=plot_dir+'/'+params_str+'_curl_uh.png', - # plot_type='amplitude', cb_min=None, cb_max=None, hide_plot=hide_plots) - - # curl_uh = FemField(V2h, coeffs=array_to_stencil(curl_uh_c, V2h.coeff_space)) - # curl_diags = diag_grid.get_diags_for(v=curl_uh, space='V2') - # diags['curl_error (to be checked)'] = curl_diags['rel_l2_error'] - - # title = r'div_h $u_h$ (amplitude) for $\eta = $'+repr(eta) - # params_str = 'eta={}_mu={}_nu={}_gamma_h={}_Pf={}'.format(eta, mu, nu, gamma_h, source_proj) - # plot_field(numpy_coeffs=div_uh_c, Vh=V0h, space_kind='h1', domain=domain, surface_plot=False, title=title, filename=plot_dir+'/'+params_str+'_div_uh.png', - # plot_type='amplitude', cb_min=None, cb_max=None, hide_plot=hide_plots) - - # div_uh = FemField(V0h, coeffs=array_to_stencil(div_uh_c, V0h.coeff_space)) - # div_diags = diag_grid.get_diags_for(v=div_uh, space='V0') - # diags['div_error (to be checked)'] = div_diags['rel_l2_error'] - - return diags - - -# def compute_stable_dt(cfl_max, dt_max, C_m, dC_m, V1_dim): -def compute_stable_dt(*, C_m, dC_m, cfl_max, dt_max=None): +def compute_stable_dt(*, C, dC, cfl_max, dt_max=None): """ Compute a stable time step size based on the maximum CFL parameter in the domain. To this end we estimate the operator norm of - `dC_m @ C_m: V1h -> V1h`, + `dC @ C: V1h -> V1h`, find the largest stable time step compatible with Strang splitting, and rescale it by the provided `cfl_max`. Setting `cfl_max = 1` would run the @@ -1204,10 +631,10 @@ def compute_stable_dt(*, C_m, dC_m, cfl_max, dt_max=None): Parameters ---------- - C_m : scipy.sparse.spmatrix + C : LinearOperator Matrix of the Curl operator. - dC_m : scipy.sparse.spmatrix + dC : LinearOperator Matrix of the dual Curl operator. cfl_max : float @@ -1227,36 +654,37 @@ def compute_stable_dt(*, C_m, dC_m, cfl_max, dt_max=None): print(" .. compute_stable_dt by estimating the operator norm of ") print(" .. dC_m @ C_m: V1h -> V1h ") - print(" .. with dim(V1h) = {} ...".format(C_m.shape[1])) + print(" .. with dim(V1h) = {} ...".format(C.domain.dimension)) if not (0 < cfl_max < 1): print(' ****** ****** ****** ****** ****** ****** ') print(' WARNING !!! cfl = {} '.format(cfl)) print(' ****** ****** ****** ****** ****** ****** ') - def vect_norm_2(vv): - return np.sqrt(np.dot(vv, vv)) - t_stamp = time_count() - vv = np.random.random(C_m.shape[1]) - norm_vv = vect_norm_2(vv) + V = C.domain + from psydac.linalg.utilities import array_to_psydac + vv = array_to_psydac(np.random.rand(V.dimension), V) + + norm_vv = np.sqrt(vv.inner(vv)) + max_ncfl = 500 ncfl = 0 spectral_rho = 1 conv = False - CC_m = dC_m @ C_m + CC = dC @ C while not (conv or ncfl > max_ncfl): - vv[:] = (1. / norm_vv) * vv + vv *= (1. / norm_vv) ncfl += 1 - vv[:] = CC_m.dot(vv) + CC.dot(vv, out=vv) - norm_vv = vect_norm_2(vv) + norm_vv = np.sqrt(vv.inner(vv)) old_spectral_rho = spectral_rho - spectral_rho = vect_norm_2(vv) # approximation + spectral_rho = norm_vv # approximation conv = abs((spectral_rho - old_spectral_rho) / spectral_rho) < 0.001 - print(" ... spectral radius iteration: spectral_rho( dC_m @ C_m ) ~= {}".format(spectral_rho)) + print(" ... spectral radius iteration: spectral_rho( dC @ C ) ~= {}".format(spectral_rho)) t_stamp = time_count(t_stamp) norm_op = np.sqrt(spectral_rho) @@ -1269,11 +697,8 @@ def vect_norm_2(vv): dt = min(dt, dt_max) print(" Time step dt computed for Maxwell solver:") - print( - f" Based on cfl_max = {cfl_max} and dt_max = {dt_max}, we set dt = {dt}") - print( - f" -- note that c*Dt = {light_c*dt} and c_dt_max = {c_dt_max}, thus c * dt / c_dt_max = {light_c*dt/c_dt_max}") - print( - f" -- and spectral_radius((c*dt)**2* dC_m @ C_m ) = {(light_c * dt * norm_op)**2} (should be < 4).") + print(f" Based on cfl_max = {cfl_max} and dt_max = {dt_max}, we set dt = {dt}") + print(f" -- note that c*Dt = {light_c*dt} and c_dt_max = {c_dt_max}, thus c * dt / c_dt_max = {light_c*dt/c_dt_max}") + print(f" -- and spectral_radius((c*dt)**2* dC @ C ) = {(light_c * dt * norm_op)**2} (should be < 4).") return dt diff --git a/psydac/feec/multipatch/examples/timedomain_maxwell_testcase.py b/psydac/feec/multipatch/examples/timedomain_maxwell_testcase.py index 19c1e13d6..e17c70b4a 100644 --- a/psydac/feec/multipatch/examples/timedomain_maxwell_testcase.py +++ b/psydac/feec/multipatch/examples/timedomain_maxwell_testcase.py @@ -5,22 +5,15 @@ import numpy as np from psydac.feec.multipatch.examples.timedomain_maxwell import solve_td_maxwell_pbm -from psydac.feec.multipatch.utilities import time_count, FEM_sol_fn, get_run_dir, get_plot_dir, get_mat_dir, get_sol_dir, diag_fn -from psydac.feec.multipatch.utils_conga_2d import write_diags_to_file - -t_stamp_full = time_count() +from psydac.feec.multipatch.utilities import get_run_dir, get_plot_dir # ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- # -# main test-cases and parameters used for the ppc paper: - -test_case = 'E0_pulse_no_source' # used in paper -# test_case = 'Issautier_like_source' # used in paper -# test_case = 'transient_to_harmonic' # actually, not used in paper +test_case = 'E0_pulse_no_source' +# test_case = 'Issautier_like_source' # J_proj_case = 'P_geom' J_proj_case = 'P_L2' -# J_proj_case = 'tilde Pi_1' # # ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- @@ -34,62 +27,21 @@ # domain_name = 'pretzel_f' # non-conf domains -domain = [[0, 2 * np.pi], [0, 2 * np.pi]] # interval in x- and y-direction +domain = [[0, np.pi], [0, np.pi]] # interval in x- and y-direction domain_name = 'refined_square' # use isotropic meshes (probably with a square domain) # 4x8= 64 patches # care for the transpose -ncells = np.array([[16, 16], - [16, 16]]) - -# ncells = np.array([[8,8,16,8], -# [8,8,16,8], -# [8,8,16,8], -# [8,8,16,8]]) -# ncells = np.array([[8,8,8,8], -# [8,8,8,8], -# [8,8,8,8], -# [8,8,8,8]]) -# ncells = np.array([[8,8,16,8,8,8], -# [8,8,16,8,8,8], -# [8,8,16,8,8,8], -# [8,8,16,8,8,8]]) - -# ncells = np.array([[4, 4, 4], -# [4, 8, 4], -# [8, 16, 8], -# [4, 8, 4], -# [4, 4, 4]]) -# ncells = np.array([[4, 4, 4, 4], -# [4, 8, 8, 4], -# [8, 16, 16, 8], -# [4, 8, 8, 4], -# [4, 4, 4, 4]]).transpose() -# ncells = np.array([[4, 4, 4, 4], -# [4, 4, 4, 4], -# [4, 8, 8, 4], -# [8, 16, 16, 8], -# [8, 16, 16, 8], -# [4, 8, 8, 4], -# [4, 4, 4, 4], -# [4, 4, 4, 4]]) - +ncells = np.array([[10, 10, 10], + [10, 20, 10], + [10, 10, 10]]) cfl_max = 0.8 + # 'P_geom' # projection used for initial E0 (B0 = 0 in all cases) -E0_proj = 'P_geom' +E0_proj = 'P_L2' backend = 'pyccel-gcc' project_sol = True # whether cP1 E_h is plotted instead of E_h -# multiplicative parameter for quadrature order in (bi)linear forms -# discretizaion -quad_param = 4 -gamma_h = 0 # jump dissipation parameter (not used in paper) -# 'BSP' # type of conforming projection operators (averaging B-spline or Geometric-splines coefficients) -conf_proj = 'GSP' -hide_plots = True -plot_divE = True -# time interval between scalar diagnostics (if None, compute every time step) -diag_dt = None # Parameters that depend on test case if test_case == 'E0_pulse_no_source': @@ -97,9 +49,8 @@ E0_type = 'pulse_2' # non-zero initial conditions source_type = 'zero' # no current source source_omega = None - final_time = 9.02 # wave transit time in domain is > 4 + final_time = 2 # wave transit time in domain is > 4 dt_max = None - plot_source = False plot_a_lot = True if plot_a_lot: @@ -110,9 +61,6 @@ [[final_time - 1, final_time], 0.1], ] - cb_min_sol = 0 - cb_max_sol = 5 - # TODO: check elif test_case == 'Issautier_like_source': @@ -120,9 +68,9 @@ source_type = 'Il_pulse' source_omega = None final_time = 20 - plot_source = True dt_max = None - if deg_s == [3] and final_time == 20: + + if deg == 3 and final_time == 20: plot_time_ranges = [ [[1.9, 2], 0.1], @@ -131,36 +79,6 @@ [[19.9, 20], 0.1], ] - # plot_time_ranges = [ - # ] - # if nc_s == [8]: - # Nt_pp = 10 - - cb_min_sol = 0 # None - cb_max_sol = 0.3 # None - -# TODO: check -elif test_case == 'transient_to_harmonic': - - E0_type = 'th_sol' - source_type = 'elliptic_J' - source_omega = np.sqrt(50) # source time pulsation - plot_source = True - - source_period = 2 * np.pi / source_omega - nb_t_periods = 100 - Nt_pp = 20 - - dt_max = source_period / Nt_pp - final_time = nb_t_periods * source_period - - plot_time_ranges = [ - [[(nb_t_periods - 2) * source_period, final_time], dt_max] - ] - - cb_min_sol = 0 - cb_max_sol = 1 - else: raise ValueError(test_case) @@ -181,8 +99,8 @@ else: raise ValueError(J_proj_case) -case_dir = 'nov14_' + test_case + '_J_proj=' + \ - J_proj_case + '_qp{}'.format(quad_param) +case_dir = 'tdmaxwell_' + test_case + '_J_proj=' + J_proj_case + if filter_source: case_dir += '_Jfilter' else: @@ -198,32 +116,17 @@ # # ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- -common_diag_filename = './' + case_dir + '_diags.txt' - - run_dir = get_run_dir( domain_name, sum(ncells), deg, source_type=source_type, - conf_proj=conf_proj) + conf_proj="") + plot_dir = get_plot_dir(case_dir, run_dir) -diag_filename = plot_dir + '/' + \ - diag_fn(source_type=source_type, source_proj=source_proj) - -# to save and load matrices -m_load_dir = get_mat_dir(domain_name, sum(ncells), deg, quad_param=quad_param) - -if E0_type == 'th_sol': - # initial E0 will be loaded from time-harmonic FEM solution - th_case_dir = 'maxwell_hom_eta=50' - th_sol_dir = get_sol_dir(th_case_dir, domain_name, sum(ncells), deg) - th_sol_filename = th_sol_dir + '/' + \ - FEM_sol_fn(source_type=source_type, source_proj=source_proj) -else: - # no initial solution to load - th_sol_filename = '' + +# params = { 'nc': ncells, 'deg': deg, @@ -235,23 +138,12 @@ 'source_type': source_type, 'source_omega': source_omega, 'source_proj': source_proj, - 'conf_proj': conf_proj, - 'gamma_h': gamma_h, 'project_sol': project_sol, 'filter_source': filter_source, - 'quad_param': quad_param, 'E0_type': E0_type, 'E0_proj': E0_proj, - 'hide_plots': hide_plots, 'plot_dir': plot_dir, 'plot_time_ranges': plot_time_ranges, - 'plot_source': plot_source, - 'plot_divE': plot_divE, - 'diag_dt': diag_dt, - 'cb_min_sol': cb_min_sol, - 'cb_max_sol': cb_max_sol, - 'm_load_dir': m_load_dir, - 'th_sol_filename': th_sol_filename, 'domain_lims': domain } @@ -259,17 +151,4 @@ print(' Calling solve_td_maxwell_pbm() with params = {}'.format(params)) print('\n --- --- --- --- --- --- --- --- --- --- --- --- --- --- \n') -diags = solve_td_maxwell_pbm(**params) - -write_diags_to_file( - diags, - script_filename=__file__, - diag_filename=diag_filename, - params=params) -write_diags_to_file( - diags, - script_filename=__file__, - diag_filename=common_diag_filename, - params=params) - -time_count(t_stamp_full, msg='full program') +solve_td_maxwell_pbm(**params) diff --git a/psydac/feec/multipatch/fem_linear_operators.py b/psydac/feec/multipatch/fem_linear_operators.py deleted file mode 100644 index aafb77931..000000000 --- a/psydac/feec/multipatch/fem_linear_operators.py +++ /dev/null @@ -1,218 +0,0 @@ -# coding: utf-8 - -from mpi4py import MPI - -from scipy.sparse import eye as sparse_id - -from psydac.linalg.basic import LinearOperator -from psydac.fem.basic import FemField - -#=============================================================================== -class FemLinearOperator( LinearOperator ): - """ - Linear operators with an additional Fem layer - """ - - def __init__( self, fem_domain=None, fem_codomain=None, matrix=None, sparse_matrix=None): - """ - we may store the matrix of the linear operator with different formats - :param matrix: stencil format - :param sparse_matrix: scipy sparse format - """ - assert fem_domain - self._fem_domain = fem_domain - if fem_codomain: - self._fem_codomain = fem_codomain - else: - self._fem_codomain = fem_domain - self._domain = self._fem_domain.coeff_space - self._codomain = self._fem_codomain.coeff_space - - self._matrix = matrix - self._sparse_matrix = sparse_matrix - - @property - def domain( self ): - return self._domain - - @property - def codomain( self ): - return self._codomain - - @property - def fem_domain( self ): - return self._fem_domain - - @property - def fem_codomain( self ): - return self._fem_codomain - - @property - def matrix( self ): - return self._matrix - - @property - def T(self): - return self.transpose() - - @property - def dtype( self ): - return self.domain.dtype - - def toarray(self): - return self._matrix.toarray() - #raise NotImplementedError('toarray() is not defined for FEMLinearOperators.') - - def tosparse(self): - return self._matrix.tosparse() - #raise NotImplementedError('tosparse() is not defined for FEMLinearOperators.') - - # ... - def transpose(self, conjugate=False): - raise NotImplementedError('Class does not provide a transpose() method') - - # ... - def to_sparse_matrix( self , **kwargs): - if self._sparse_matrix is not None: - return self._sparse_matrix - elif self._matrix is not None: - return self._matrix.tosparse() - else: - raise NotImplementedError('Class does not provide a get_sparse_matrix() method without a matrix') - - # ... - def __call__( self, f ): - if self._matrix is not None: - coeffs = self._matrix.dot(f.coeffs) - return FemField(self.fem_codomain, coeffs=coeffs) - else: - raise NotImplementedError('Class does not provide a __call__ method without a matrix') - - # ... - def dot( self, f_coeffs, out=None ): - # coeffs layer - if self._matrix is not None: - f = FemField(self.fem_domain, coeffs=f_coeffs) - return self(f).coeffs - else: - raise NotImplementedError('Class does not provide a dot method without a matrix') - - # ... - def __mul__(self, c): - return MultLinearOperator(c, self) - - # ... - def __add__(self, C): - assert isinstance(C, FemLinearOperator) - return SumLinearOperator(C, self) - - # ... - def __sub__(self, C): - assert isinstance(C, FemLinearOperator) - return SumLinearOperator(C, -self) - - # ... - def __neg__(self): - return MultLinearOperator(-1, self) - - -#============================================================================== -class ComposedLinearOperator( FemLinearOperator ): - """ - operator L = L_1 .. L_n - with L_i = self._operators[i-1] - (so, the last one is applied first, like in a product) - """ - def __init__( self, operators ): - n = len(operators) - assert all([isinstance(operators[i], FemLinearOperator) for i in range(n)]) - assert all([operators[i].fem_domain == operators[i+1].fem_codomain for i in range(n-1)]) - FemLinearOperator.__init__( - self, fem_domain=operators[-1].fem_domain, fem_codomain=operators[0].fem_codomain - ) - self._operators = operators - self._n = n - - # matrix not defined by matrix product because it could break the Stencil Matrix structure - - def to_sparse_matrix( self, **kwargs): - mat = self._operators[-1].to_sparse_matrix() - for i in range(2, self._n+1): - mat = self._operators[-i].to_sparse_matrix() * mat - return mat - - def __call__( self, f ): - v = self._operators[-1](f) - for i in range(2, self._n+1): - v = self._operators[-i](v) - return v - - def dot( self, f_coeffs, out=None ): - v_coeffs = self._operators[-1].dot(f_coeffs) - for i in range(2, self._n+1): - v_coeffs = self._operators[-i].dot(v_coeffs) - return v_coeffs - - -#============================================================================== -class IdLinearOperator( FemLinearOperator ): - - def __init__( self, V ): - FemLinearOperator.__init__(self, fem_domain=V) - - def to_sparse_matrix( self , **kwargs): - return sparse_id( self.fem_domain.nbasis ) - - def __call__( self, f ): - return f - - def dot( self, f_coeffs, out=None ): - return f_coeffs - -#============================================================================== -class SumLinearOperator( FemLinearOperator ): - - def __init__( self, B, A ): - assert isinstance(A, FemLinearOperator) - assert isinstance(B, FemLinearOperator) - assert B.fem_domain == A.fem_domain - assert B.fem_codomain == A.fem_codomain - FemLinearOperator.__init__( - self, fem_domain=A.fem_domain, fem_codomain=A.fem_codomain - ) - self._A = A - self._B = B - - def to_sparse_matrix( self, **kwargs): - return self._A.to_sparse_matrix() + self._B.to_sparse_matrix() - - def __call__( self, f ): - # fem layer - return self._B(f) + self._A(f) - - def dot( self, f_coeffs, out=None ): - # coeffs layer - return self._B.dot(f_coeffs) + self._A.dot(f_coeffs) - -#============================================================================== -class MultLinearOperator( FemLinearOperator ): - - def __init__( self, c, A ): - assert isinstance(A, FemLinearOperator) - FemLinearOperator.__init__( - self, fem_domain=A.fem_domain, fem_codomain=A.fem_codomain - ) - self._A = A - self._c = c - - def to_sparse_matrix( self, **kwargs): - return self._c * self._A.to_sparse_matrix() - - def __call__( self, f ): - # fem layer - return self._c * self._A(f) - - def dot( self, f_coeffs, out=None ): - # coeffs layer - return self._c * self._A.dot(f_coeffs) - diff --git a/psydac/feec/multipatch/operators.py b/psydac/feec/multipatch/operators.py deleted file mode 100644 index 0ee00210c..000000000 --- a/psydac/feec/multipatch/operators.py +++ /dev/null @@ -1,1248 +0,0 @@ -# coding: utf-8 - -# Conga operators on piecewise (broken) de Rham sequences - -from sympy import Tuple -from mpi4py import MPI -import os -import numpy as np - -from scipy.sparse import save_npz, load_npz -from scipy.sparse import kron, block_diag -from scipy.sparse.linalg import inv - -from sympde.topology import Boundary, Interface, Union -from sympde.topology import element_of, elements_of -from sympde.topology.space import ScalarFunction -from sympde.calculus import grad, dot, inner, rot, div -from sympde.calculus import laplace, bracket, convect -from sympde.calculus import jump, avg, Dn, minus, plus -from sympde.expr.expr import LinearForm, BilinearForm -from sympde.expr.expr import integral - -from psydac.core.bsplines import collocation_matrix, histopolation_matrix - -from psydac.api.discretization import discretize -from psydac.api.essential_bc import apply_essential_bc_stencil -from psydac.api.settings import PSYDAC_BACKENDS -from psydac.linalg.block import BlockVectorSpace, BlockVector, BlockLinearOperator -from psydac.linalg.stencil import StencilVector, StencilMatrix, StencilInterfaceMatrix -from psydac.linalg.solvers import inverse -from psydac.fem.basic import FemField - - -from psydac.feec.global_projectors import Projector_H1, Projector_Hcurl, Projector_L2 -from psydac.feec.derivatives import Gradient_2D, ScalarCurl_2D -from psydac.feec.multipatch.fem_linear_operators import FemLinearOperator - - -def get_patch_index_from_face(domain, face): - """ Return the patch index of subdomain/boundary - - Parameters - ---------- - domain : - The Symbolic domain - - face : - A patch or a boundary of a patch - - Returns - ------- - i : - The index of a subdomain/boundary in the multipatch domain - """ - - if domain.mapping: - domain = domain.logical_domain - if face.mapping: - face = face.logical_domain - - domains = domain.interior.args - if isinstance(face, Interface): - raise NotImplementedError( - "This face is an interface, it has several indices -- I am a machine, I cannot choose. Help.") - elif isinstance(face, Boundary): - i = domains.index(face.domain) - else: - i = domains.index(face) - return i - - -def get_interface_from_corners(corner1, corner2, domain): - """ Return the interface between two corners from two different patches that correspond to a single (physical) vertex. - - Parameters - ---------- - corner1 : - The first corner of the 2D interface - - corner2 : - The second corner of the 2D interface - - domain : - The Symbolic domain - - Returns - ------- - interface: - The interface between two vertices - - """ - - interface = [] - interfaces = domain.interfaces - - if not isinstance(interfaces, Union): - interfaces = (interfaces,) - - for i in interfaces: - if i.plus.domain in [corner1.domain, corner2.domain]: - if i.minus.domain in [corner1.domain, corner2.domain]: - interface.append(i) - - bd1 = corner1.boundaries - bd2 = corner2.boundaries - - new_interface = [] - - for i in interface: - if i.minus in bd1 + bd2: - if i.plus in bd2 + bd1: - new_interface.append(i) - - if len(new_interface) == 1: - return new_interface[0] - if len(new_interface) > 1: - raise ValueError( - 'found more than one interface for the corners {} and {}'.format( - corner1, corner2)) - return None - - -def get_row_col_index(corner1, corner2, interface, axis, V1, V2): - """ Return the row and column index of a corner in the StencilInterfaceMatrix - for dofs of H1 type spaces - - Parameters - ---------- - corner1 : - The first corner of the 2D interface - - corner2 : - The second corner of the 2D interface - - interface : - The interface between the two corners - - axis : - Axis of the interface - - V1 : - Test Space - - V2 : - Trial Space - - Returns - ------- - index: - The StencilInterfaceMatrix index of the corner, it has the form (i1, i2, k1, k2) in 2D, - where (i1, i2) identifies the row and (k1, k2) the diagonal. - """ - start = V1.coeff_space.starts - end = V1.coeff_space.ends - degree = V2.degree - start_end = (start, end) - - row = [None] * len(start) - col = [0] * len(start) - - assert corner1.boundaries[0].axis == corner2.boundaries[0].axis - - for bd in corner1.boundaries: - row[bd.axis] = start_end[(bd.ext + 1) // 2][bd.axis] - - if interface is None and corner1.domain != corner2.domain: - bd = [i for i in corner1.boundaries if i.axis == axis][0] - if bd.ext == 1: - row[bd.axis] = degree[bd.axis] - - if interface is None: - return row + col - - axis = interface.axis - - if interface.minus.domain == corner1.domain: - if interface.minus.ext == -1: - row[axis] = 0 - else: - row[axis] = degree[axis] - else: - if interface.plus.ext == -1: - row[axis] = 0 - else: - row[axis] = degree[axis] - - if interface.minus.ext == interface.plus.ext: - pass - elif interface.minus.domain == corner1.domain: - if interface.minus.ext == -1: - col[axis] = degree[axis] - else: - col[axis] = -degree[axis] - else: - if interface.plus.ext == -1: - col[axis] = degree[axis] - else: - col[axis] = -degree[axis] - - return row + col - - -# =============================================================================== -def allocate_interface_matrix(corners, test_space, trial_space): - """ Allocate the interface matrix for a vertex shared by two patches - - Parameters - ---------- - corners: - The patch corners corresponding to the common shared vertex - - test_space: - The test space - - trial_space: - The trial space - - Returns - ------- - mat: - The interface matrix shared by two patches - """ - bi, bj = list(zip(*corners)) - permutation = np.arange(bi[0].domain.dim) - - flips = [] - k = 0 - while k < len(bi): - c1 = np.array(bi[k].coordinates) - c2 = np.array(bj[k].coordinates)[permutation] - flips.append( - np.array([-1 if d1 != d2 else 1 for d1, d2 in zip(c1, c2)])) - - if np.sum(abs(flips[0] - flips[-1])) != 0: - prod = [f1 * f2 for f1, f2 in zip(flips[0], flips[-1])] - while -1 in prod: - i1 = prod.index(-1) - if -1 in prod[i1 + 1:]: - i2 = i1 + 1 + prod[i1 + 1:].index(-1) - prod = prod[i2 + 1:] - permutation[i1], permutation[i2] = permutation[i2], permutation[i1] - k = -1 - flips = [] - else: - break - - k += 1 - - assert all(abs(flips[0] - i).sum() == 0 for i in flips) - cs = list(zip(*[i.coordinates for i in bi])) - axis = [all(i[0] == j for j in i) for i in cs].index(True) - ext = 1 if cs[axis][0] == 1 else -1 - s = test_space.get_assembly_grids( - )[axis].spans[-1 if ext == 1 else 0] - test_space.degree[axis] - - mat = StencilInterfaceMatrix( - trial_space.coeff_space, - test_space.coeff_space, - s, - s, - axis, - flip=flips[0], - permutation=list(permutation)) - return mat - -# =============================================================================== -# The following operators are not compatible with the changes in the Stencil format -# and their datatype does not allow for non-matching interfaces, but they might be -# useful for future implementations -# =============================================================================== - - -class ConformingProjection_V0(FemLinearOperator): - """ - Conforming projection from global broken V0 space to conforming global V0 space - Defined by averaging of interface dofs - - Parameters - ---------- - V0h: - The discrete space - - domain_h: - The discrete domain of the projector - - hom_bc : - Apply homogenous boundary conditions if True - - backend_language: - The backend used to accelerate the code - - storage_fn: - filename to store/load the operator sparse matrix - """ - # todo (MCP, 16.03.2021): - # - avoid discretizing a bilinear form - # - allow case without interfaces (single or multipatch) - - def __init__( - self, - V0h, - domain_h, - hom_bc=False, - backend_language='python', - storage_fn=None): - - FemLinearOperator.__init__(self, fem_domain=V0h) - - V0 = V0h.symbolic_space - domain = V0.domain - self.symbolic_domain = domain - - if storage_fn and os.path.exists(storage_fn): - print( - "[ConformingProjection_V0] loading operator sparse matrix from " + - storage_fn) - self._sparse_matrix = load_npz(storage_fn) - - else: - # assemble the operator matrix - u, v = elements_of(V0, names='u, v') - expr = u * v # dot(u,v) - - Interfaces = domain.interfaces # note: interfaces does not include the boundary - # this penalization is for an H1-conforming space - expr_I = (plus(u) - minus(u)) * (plus(v) - minus(v)) - - a = BilinearForm((u, v), integral(domain, expr) + - integral(Interfaces, expr_I)) - # print('[[ forcing python backend for ConformingProjection_V0]] ') - # backend_language = 'python' - ah = discretize( - a, domain_h, [ - V0h, V0h], backend=PSYDAC_BACKENDS[backend_language]) - - # self._A = ah.assemble() - self._A = ah.forms[0]._matrix - - spaces = self._A.domain.spaces - - if isinstance(Interfaces, Interface): - Interfaces = (Interfaces, ) - - for b1 in self._A.blocks: - for A in b1: - if A is None: - continue - A[:, :, :, :] = 0 - - indices = [slice(None, None)] * domain.dim + [0] * domain.dim - - for i in range(len(self._A.blocks)): - self._A[i, i][tuple(indices)] = 1 - - for I in Interfaces: - - axis = I.axis - i_minus = get_patch_index_from_face(domain, I.minus) - i_plus = get_patch_index_from_face(domain, I.plus) - - sp_minus = spaces[i_minus] - sp_plus = spaces[i_plus] - - s_minus = sp_minus.starts[axis] - e_minus = sp_minus.ends[axis] - - s_plus = sp_plus.starts[axis] - e_plus = sp_plus.ends[axis] - - d_minus = V0h.spaces[i_minus].degree[axis] - d_plus = V0h.spaces[i_plus].degree[axis] - - indices = [slice(None, None)] * domain.dim + [0] * domain.dim - - minus_ext = I.minus.ext - plus_ext = I.plus.ext - - if minus_ext == 1: - indices[axis] = e_minus - else: - indices[axis] = s_minus - self._A[i_minus, i_minus][tuple(indices)] = 1 / 2 - - if plus_ext == 1: - indices[axis] = e_plus - else: - indices[axis] = s_plus - - self._A[i_plus, i_plus][tuple(indices)] = 1 / 2 - - if plus_ext == minus_ext: - if minus_ext == 1: - indices[axis] = d_minus - else: - indices[axis] = s_minus - - self._A[i_minus, i_plus][tuple(indices)] = 1 / 2 - - if plus_ext == 1: - indices[axis] = d_plus - else: - indices[axis] = s_plus - - self._A[i_plus, i_minus][tuple(indices)] = 1 / 2 - - else: - if minus_ext == 1: - indices[axis] = d_minus - else: - indices[axis] = s_minus - - if plus_ext == 1: - indices[domain.dim + axis] = d_plus - else: - indices[domain.dim + axis] = -d_plus - - self._A[i_minus, i_plus][tuple(indices)] = 1 / 2 - - if plus_ext == 1: - indices[axis] = d_plus - else: - indices[axis] = s_plus - - if minus_ext == 1: - indices[domain.dim + axis] = d_minus - else: - indices[domain.dim + axis] = -d_minus - - self._A[i_plus, i_minus][tuple(indices)] = 1 / 2 - - domain = domain.logical_domain - corner_blocks = {} - for c in domain.corners: - for b1 in c.corners: - i = get_patch_index_from_face(domain, b1.domain) - for b2 in c.corners: - j = get_patch_index_from_face(domain, b2.domain) - if (i, j) in corner_blocks: - corner_blocks[i, j] += [(b1, b2)] - else: - corner_blocks[i, j] = [(b1, b2)] - - for c in domain.corners: - if len(c) == 2: - continue - for b1 in c.corners: - i = get_patch_index_from_face(domain, b1.domain) - for b2 in c.corners: - j = get_patch_index_from_face(domain, b2.domain) - interface = get_interface_from_corners(b1, b2, domain) - axis = None - if self._A[i, j] is None: - self._A[i, j] = allocate_interface_matrix( - corner_blocks[i, j], V0h.spaces[i], V0h.spaces[j]) - - if i != j and self._A[i, j]: - axis = self._A[i, j]._dim - index = get_row_col_index( - b1, b2, interface, axis, V0h.spaces[i], V0h.spaces[j]) - self._A[i, j][tuple(index)] = 1 / len(c) - - if hom_bc: - for bn in domain.boundary: - self.set_homogenous_bc(bn) - - self._matrix = self._A - self._sparse_matrix = self._matrix.tosparse() # self._sparse_matrix - - if storage_fn: - print( - "[ConformingProjection_V0] storing operator sparse matrix in " + - storage_fn) - save_npz(storage_fn, self._sparse_matrix) - - def set_homogenous_bc(self, boundary, rhs=None): - domain = self.symbolic_domain - Vh = self.fem_domain - if domain.mapping: - domain = domain.logical_domain - if boundary.mapping: - boundary = boundary.logical_domain - - corners = domain.corners - i = get_patch_index_from_face(domain, boundary) - if rhs: - apply_essential_bc_stencil( - rhs[i], axis=boundary.axis, ext=boundary.ext, order=0) - for j in range(len(domain)): - if self._A[i, j] is None: - continue - apply_essential_bc_stencil( - self._A[i, j], axis=boundary.axis, ext=boundary.ext, order=0) - - for c in corners: - faces = [f for b in c.corners for f in b.boundaries] - if len(c) == 2: - continue - if boundary in faces: - for b1 in c.corners: - i = get_patch_index_from_face(domain, b1.domain) - for b2 in c.corners: - j = get_patch_index_from_face(domain, b2.domain) - interface = get_interface_from_corners(b1, b2, domain) - axis = None - if i != j: - axis = self._A[i, j].dim - index = get_row_col_index( - b1, b2, interface, axis, Vh.spaces[i], Vh.spaces[j]) - self._A[i, j][tuple(index)] = 0. - - if i == j and rhs: - rhs[i][tuple(index[:2])] = 0. - -# =============================================================================== - - -class ConformingProjection_V1(FemLinearOperator): - """ - Conforming projection from global broken V1 space to conforming V1 global space - - proj.dot(v) returns the conforming projection of v, computed by solving linear system - - Parameters - ---------- - V1h: - The discrete space - - domain_h: - The discrete domain of the projector - - hom_bc : - Apply homogenous boundary conditions if True - - backend_language: - The backend used to accelerate the code - - storage_fn: - filename to store/load the operator sparse matrix - """ - # todo (MCP, 16.03.2021): - # - avoid discretizing a bilinear form - # - allow case without interfaces (single or multipatch) - - def __init__( - self, - V1h, - domain_h, - hom_bc=False, - backend_language='python', - storage_fn=None): - - FemLinearOperator.__init__(self, fem_domain=V1h) - - V1 = V1h.symbolic_space - domain = V1.domain - self.symbolic_domain = domain - - if storage_fn and os.path.exists(storage_fn): - print( - "[ConformingProjection_V1] loading operator sparse matrix from " + - storage_fn) - self._sparse_matrix = load_npz(storage_fn) - - else: - # assemble the operator matrix - u, v = elements_of(V1, names='u, v') - expr = dot(u, v) - # - Interfaces = domain.interfaces # note: interfaces does not include the boundary - # this penalization is for an H1-conforming space - expr_I = dot(plus(u) - minus(u), plus(v) - minus(v)) - - a = BilinearForm((u, v), integral(domain, expr) + - integral(Interfaces, expr_I)) - # print('[[ forcing python backend for ConformingProjection_V1]] ') - # backend_language = 'python' - ah = discretize( - a, domain_h, [ - V1h, V1h], backend=PSYDAC_BACKENDS[backend_language]) - # - # # self._A = ah.assemble() - self._A = ah.forms[0]._matrix - # C1 = V1h.coeff_space - # self._A = BlockLinearOperator(C1, C1) - - for b1 in self._A.blocks: - for b2 in b1: - if b2 is None: - continue - for b3 in b2.blocks: - for A in b3: - if A is None: - continue - A[:, :, :, :] = 0 - - spaces = self._A.domain.spaces - - if isinstance(Interfaces, Interface): - Interfaces = (Interfaces, ) - - indices = [slice(None, None)] * domain.dim + [0] * domain.dim - - for i in range(len(self._A.blocks)): - self._A[i, i][0, 0][tuple(indices)] = 1 - self._A[i, i][1, 1][tuple(indices)] = 1 - - # empty list if no interfaces ? - if Interfaces is not None: - - for I in Interfaces: - - i_minus = get_patch_index_from_face(domain, I.minus) - i_plus = get_patch_index_from_face(domain, I.plus) - - indices = [slice(None, None)] * \ - domain.dim + [0] * domain.dim - - sp1 = spaces[i_minus] - sp2 = spaces[i_plus] - - s11 = sp1.spaces[0].starts[I.axis] - e11 = sp1.spaces[0].ends[I.axis] - s12 = sp1.spaces[1].starts[I.axis] - e12 = sp1.spaces[1].ends[I.axis] - - s21 = sp2.spaces[0].starts[I.axis] - e21 = sp2.spaces[0].ends[I.axis] - s22 = sp2.spaces[1].starts[I.axis] - e22 = sp2.spaces[1].ends[I.axis] - - d11 = V1h.spaces[i_minus].spaces[0].degree[I.axis] - d12 = V1h.spaces[i_minus].spaces[1].degree[I.axis] - - d21 = V1h.spaces[i_plus].spaces[0].degree[I.axis] - d22 = V1h.spaces[i_plus].spaces[1].degree[I.axis] - - s_minus = [s11, s12] - e_minus = [e11, e12] - - s_plus = [s21, s22] - e_plus = [e21, e22] - - d_minus = [d11, d12] - d_plus = [d21, d22] - - minus_ext = I.minus.ext - plus_ext = I.plus.ext - - axis = I.axis - for k in range(domain.dim): - if k == I.axis: - continue - - if minus_ext == 1: - indices[axis] = e_minus[k] - else: - indices[axis] = s_minus[k] - self._A[i_minus, i_minus][k, k][tuple(indices)] = 1 / 2 - - if plus_ext == 1: - indices[axis] = e_plus[k] - else: - indices[axis] = s_plus[k] - - self._A[i_plus, i_plus][k, k][tuple(indices)] = 1 / 2 - - if plus_ext == minus_ext: - if minus_ext == 1: - indices[axis] = d_minus[k] - else: - indices[axis] = s_minus[k] - - self._A[i_minus, i_plus][k, k][tuple( - indices)] = 1 / 2 * I.direction - - if plus_ext == 1: - indices[axis] = d_plus[k] - else: - indices[axis] = s_plus[k] - - self._A[i_plus, i_minus][k, k][tuple( - indices)] = 1 / 2 * I.direction - - else: - if minus_ext == 1: - indices[axis] = d_minus[k] - else: - indices[axis] = s_minus[k] - - if plus_ext == 1: - indices[domain.dim + axis] = d_plus[k] - else: - indices[domain.dim + axis] = -d_plus[k] - - self._A[i_minus, i_plus][k, k][tuple( - indices)] = 1 / 2 * I.direction - - if plus_ext == 1: - indices[axis] = d_plus[k] - else: - indices[axis] = s_plus[k] - - if minus_ext == 1: - indices[domain.dim + axis] = d_minus[k] - else: - indices[domain.dim + axis] = -d_minus[k] - - self._A[i_plus, i_minus][k, k][tuple( - indices)] = 1 / 2 * I.direction - - if hom_bc: - for bn in domain.boundary: - self.set_homogenous_bc(bn) - - self._matrix = self._A - self._sparse_matrix = self._matrix.tosparse() - - if storage_fn: - print( - "[ConformingProjection_V1] storing operator sparse matrix in " + - storage_fn) - save_npz(storage_fn, self._sparse_matrix) - - def set_homogenous_bc(self, boundary): - domain = self.symbolic_domain - Vh = self.fem_domain - - i = get_patch_index_from_face(domain, boundary) - axis = boundary.axis - ext = boundary.ext - for j in range(len(domain)): - if self._A[i, j] is None: - continue - apply_essential_bc_stencil( - self._A[i, j][1 - axis, 1 - axis], axis=axis, ext=ext, order=0) - - -# =============================================================================== -def get_K0_and_K0_inv(V0h, uniform_patches=False): - """ - Compute the change of basis matrices K0 and K0^{-1} in V0h. - - With - K0_ij = sigma^0_i(B_j) = B_jx(n_ix) * B_jy(n_iy) - where sigma_i is the geometric (interpolation) dof - and B_j is the tensor-product B-spline - """ - if uniform_patches: - print(' [[WARNING -- hack in get_K0_and_K0_inv: using copies of 1st-patch matrices in every patch ]] ') - - V0 = V0h.symbolic_space # VOh is FemSpace - domain = V0.domain - K0_blocks = [] - K0_inv_blocks = [] - for k, D in enumerate(domain.interior): - if uniform_patches and k > 0: - K0_k = K0_blocks[0].copy() - K0_inv_k = K0_inv_blocks[0].copy() - - else: - V0_k = V0h.spaces[k] # fem space on patch k: (TensorFemSpace) - K0_k_factors = [None, None] - for d in [0, 1]: - # 1d fem space alond dim d (SplineSpace) - V0_kd = V0_k.spaces[d] - K0_k_factors[d] = collocation_matrix( - knots=V0_kd.knots, - degree=V0_kd.degree, - periodic=V0_kd.periodic, - normalization=V0_kd.basis, - xgrid=V0_kd.greville - ) - K0_k = kron(*K0_k_factors) - K0_k.eliminate_zeros() - K0_inv_k = inv(K0_k.tocsc()) - K0_inv_k.eliminate_zeros() - - K0_blocks.append(K0_k) - K0_inv_blocks.append(K0_inv_k) - K0 = block_diag(K0_blocks) - K0_inv = block_diag(K0_inv_blocks) - return K0, K0_inv - - -# =============================================================================== -def get_K1_and_K1_inv(V1h, uniform_patches=False): - """ - Compute the change of basis matrices K1 and K1^{-1} in Hcurl space V1h. - - With - K1_ij = sigma^1_i(B_j) = int_{e_ix}(M_jx) * B_jy(n_iy) - if i = horizontal edge [e_ix, n_iy] and j = (M_jx o B_jy) x-oriented MoB spline - or - = B_jx(n_ix) * int_{e_iy}(M_jy) - if i = vertical edge [n_ix, e_iy] and j = (B_jx o M_jy) y-oriented BoM spline - (above, 'o' denotes tensor-product for functions) - """ - if uniform_patches: - print(' [[WARNING -- hack in get_K1_and_K1_inv: using copies of 1st-patch matrices in every patch ]] ') - - V1 = V1h.symbolic_space # V1h is FemSpace - domain = V1.domain - K1_blocks = [] - K1_inv_blocks = [] - for k, D in enumerate(domain.interior): - if uniform_patches and k > 0: - K1_k = K1_blocks[0].copy() - K1_inv_k = K1_inv_blocks[0].copy() - - else: - # fem space on patch k: - V1_k = V1h.spaces[k] - K1_k_blocks = [] - for c in [0, 1]: # dim of component - # fem space for comp. dc (TensorFemSpace) - V1_kc = V1_k.spaces[c] - K1_kc_factors = [None, None] - for d in [0, 1]: # dim of variable - # 1d fem space for comp c alond dim d (SplineSpace) - V1_kcd = V1_kc.spaces[d] - if c == d: - K1_kc_factors[d] = histopolation_matrix( - knots=V1_kcd.knots, - degree=V1_kcd.degree, - periodic=V1_kcd.periodic, - normalization=V1_kcd.basis, - xgrid=V1_kcd.ext_greville - ) - else: - K1_kc_factors[d] = collocation_matrix( - knots=V1_kcd.knots, - degree=V1_kcd.degree, - periodic=V1_kcd.periodic, - normalization=V1_kcd.basis, - xgrid=V1_kcd.greville - ) - K1_kc = kron(*K1_kc_factors) - K1_kc.eliminate_zeros() - K1_k_blocks.append(K1_kc) - K1_k = block_diag(K1_k_blocks) - K1_k.eliminate_zeros() - K1_inv_k = inv(K1_k.tocsc()) - K1_inv_k.eliminate_zeros() - - K1_blocks.append(K1_k) - K1_inv_blocks.append(K1_inv_k) - - K1 = block_diag(K1_blocks) - K1_inv = block_diag(K1_inv_blocks) - return K1, K1_inv - - -# #=============================================================================== -# def get_M_and_M_inv(Vh, subdomains_h, is_scalar, backend_language='python'): -# """ -# compute the mass matrix M and M^{-1} in multipatch space Vh -# DOES NOT WORK -- SHOULD WE HAVE THE POSSIBILITY OF DOING THAT ? -# """ -# from pprint import pprint -# -# V = Vh.symbolic_space # VOh is FemSpace -# domain = V.domain -# M_blocks = [] -# M_inv_blocks = [] -# -# # print('type(domain_h) = ', type(domain_h)) -# # -# # print('type(domain_h._patches) = ', type(domain_h._patches)) -# # print('len(domain_h._patches) = ', len(domain_h._patches)) -# # -# # mappings = domain_h.mappings -# # print('type(mappings) = ', type(mappings)) -# # print('len(mappings) = ', len(mappings)) -# # -# # mappings_list = list(mappings.values()) -# # print('len(mappings_list) = ', len(mappings_list)) -# # -# # print('type(mappings_list[0]) = ', type(mappings_list[0])) -# -# for k, Dh_k in enumerate(subdomains_h): -# -# print('k = ', k) -# print('type(Dh_k) = ', type(Dh_k)) -# # print('Dh = ', Dh) -# D_k = domain.interior[k] -# -# # exit() -# -# # for k, D in enumerate(domain.interior): -# -# V_k = V.spaces[k] -# Vh_k = Vh.spaces[k] -# -# # print(type(domain_h)) -# # -# # pprint(dir(domain_h)) -# # -# # -# # print(len(domain_h._patches)) -# # exit() -# # Dh_k = domain_h.spaces[k] # fem space on patch k: (TensorFemSpace) -# u, v = elements_of(V_k, names='u, v') -# if is_scalar: -# expr = u*v -# else: -# expr = dot(u,v) -# a_k = BilinearForm((u,v), integral(D_k, expr)) -# a_kh = discretize(a_k, Dh_k, [Vh_k, Vh_k], backend=PSYDAC_BACKENDS[backend_language]) # 'pyccel-gcc']) -# -# M_k = a_kh.assemble().toarray() -# M_k.eliminate_zeros() -# M_inv_k = inv(M_k.tocsc()) -# M_inv_k.eliminate_zeros() -# -# M_blocks.append(M_k) -# M_inv_blocks.append(M_inv_k) -# M = block_diag(M_blocks) -# M_inv = block_diag(M_inv_blocks) -# return M, M_inv - -# =============================================================================== -class HodgeOperator(FemLinearOperator): - """ - Change of basis operator: dual basis -> primal basis - - self._matrix: matrix of the primal Hodge = this is the mass matrix ! - self.dual_Hodge_matrix: this is the INVERSE mass matrix - - Parameters - ---------- - Vh: - The discrete space - - domain_h: - The discrete domain of the projector - - metric : - the metric of the de Rham complex - - backend_language: - The backend used to accelerate the code - - load_dir: - storage files for the primal and dual Hodge sparse matrice - - load_space_index: - the space index in the derham sequence - - Notes - ----- - Either we use a storage, or these matrices are only computed on demand - # todo: we compute the sparse matrix when to_sparse_matrix is called -- but never the stencil matrix (should be fixed...) - We only support the identity metric, this implies that the dual Hodge is the inverse of the primal one. - # todo: allow for non-identity metrics - """ - - def __init__( - self, - Vh, - domain_h, - metric='identity', - backend_language='python', - load_dir=None, - load_space_index=''): - - FemLinearOperator.__init__(self, fem_domain=Vh) - self._domain_h = domain_h - self._backend_language = backend_language - self._dual_Hodge_sparse_matrix = None - - assert metric == 'identity' - self._metric = metric - - if load_dir and isinstance(load_dir, str): - if not os.path.exists(load_dir): - os.makedirs(load_dir) - assert str(load_space_index) in ['0', '1', '2', '3'] - primal_Hodge_storage_fn = load_dir + \ - '/H{}_m.npz'.format(load_space_index) - dual_Hodge_storage_fn = load_dir + \ - '/dH{}_m.npz'.format(load_space_index) - - primal_Hodge_is_stored = os.path.exists(primal_Hodge_storage_fn) - dual_Hodge_is_stored = os.path.exists(dual_Hodge_storage_fn) - if dual_Hodge_is_stored: - assert primal_Hodge_is_stored - print( - " ... loading dual Hodge sparse matrix from " + - dual_Hodge_storage_fn) - self._dual_Hodge_sparse_matrix = load_npz( - dual_Hodge_storage_fn) - print( - "[HodgeOperator] loading primal Hodge sparse matrix from " + - primal_Hodge_storage_fn) - self._sparse_matrix = load_npz(primal_Hodge_storage_fn) - else: - assert not primal_Hodge_is_stored - print( - "[HodgeOperator] assembling both sparse matrices for storage...") - self.assemble_primal_Hodge_matrix() - print( - "[HodgeOperator] storing primal Hodge sparse matrix in " + - primal_Hodge_storage_fn) - save_npz(primal_Hodge_storage_fn, self._sparse_matrix) - self.assemble_dual_Hodge_matrix() - print( - "[HodgeOperator] storing dual Hodge sparse matrix in " + - dual_Hodge_storage_fn) - save_npz(dual_Hodge_storage_fn, self._dual_Hodge_sparse_matrix) - else: - # matrices are not stored, we will probably compute them later - pass - - def to_sparse_matrix(self): - """ - the Hodge matrix is the patch-wise multi-patch mass matrix - it is not stored by default but assembled on demand - """ - - if (self._sparse_matrix is not None) or (self._matrix is not None): - return FemLinearOperator.to_sparse_matrix(self) - - self.assemble_primal_Hodge_matrix() - - return self._sparse_matrix - - def assemble_primal_Hodge_matrix(self): - """ - the Hodge matrix is the patch-wise multi-patch mass matrix - it is not stored by default but assembled on demand - """ - - if self._matrix is None: - Vh = self.fem_domain - assert Vh == self.fem_codomain - - V = Vh.symbolic_space - domain = V.domain - # domain_h = V0h.domain: would be nice... - u, v = elements_of(V, names='u, v') - - if isinstance(u, ScalarFunction): - expr = u * v - else: - expr = dot(u, v) - - a = BilinearForm((u, v), integral(domain, expr)) - ah = discretize(a, self._domain_h, [ - Vh, Vh], backend=PSYDAC_BACKENDS[self._backend_language]) - - self._matrix = ah.assemble() # Mass matrix in stencil format - self._sparse_matrix = self._matrix.tosparse() - - def get_dual_Hodge_sparse_matrix(self): - if self._dual_Hodge_sparse_matrix is None: - self.assemble_dual_Hodge_matrix() - - return self._dual_Hodge_sparse_matrix - - def assemble_dual_Hodge_matrix(self): - """ - the dual Hodge matrix is the patch-wise inverse of the multi-patch mass matrix - it is not stored by default but computed on demand, by local (patch-wise) inversion of the mass matrix - """ - - if self._dual_Hodge_sparse_matrix is None: - if not self._matrix: - self.assemble_primal_Hodge_matrix() - - M = self._matrix # mass matrix of the (primal) basis - nrows = M.n_block_rows - ncols = M.n_block_cols - - inv_M_blocks = [] - for i in range(nrows): - Mii = M[i, i].tosparse() - inv_Mii = inv(Mii.tocsc()) - inv_Mii.eliminate_zeros() - inv_M_blocks.append(inv_Mii) - - inv_M = block_diag(inv_M_blocks) - self._dual_Hodge_sparse_matrix = inv_M - -# ============================================================================== - - -class BrokenGradient_2D(FemLinearOperator): - - def __init__(self, V0h, V1h): - - FemLinearOperator.__init__(self, fem_domain=V0h, fem_codomain=V1h) - - D0s = [Gradient_2D(V0, V1) for V0, V1 in zip(V0h.spaces, V1h.spaces)] - - self._matrix = BlockLinearOperator(self.domain, self.codomain, blocks={ - (i, i): D0i._matrix for i, D0i in enumerate(D0s)}) - - def transpose(self, conjugate=False): - # todo (MCP): define as the dual differential operator - return BrokenTransposedGradient_2D(self.fem_domain, self.fem_codomain) - -# ============================================================================== - - -class BrokenTransposedGradient_2D(FemLinearOperator): - - def __init__(self, V0h, V1h): - - FemLinearOperator.__init__(self, fem_domain=V1h, fem_codomain=V0h) - - D0s = [Gradient_2D(V0, V1) for V0, V1 in zip(V0h.spaces, V1h.spaces)] - - self._matrix = BlockLinearOperator(self.domain, self.codomain, blocks={ - (i, i): D0i._matrix.T for i, D0i in enumerate(D0s)}) - - def transpose(self, conjugate=False): - # todo (MCP): discard - return BrokenGradient_2D(self.fem_codomain, self.fem_domain) - - -# ============================================================================== -class BrokenScalarCurl_2D(FemLinearOperator): - def __init__(self, V1h, V2h): - - FemLinearOperator.__init__(self, fem_domain=V1h, fem_codomain=V2h) - - D1s = [ScalarCurl_2D(V1, V2) for V1, V2 in zip(V1h.spaces, V2h.spaces)] - - self._matrix = BlockLinearOperator(self.domain, self.codomain, blocks={ - (i, i): D1i._matrix for i, D1i in enumerate(D1s)}) - - def transpose(self, conjugate=False): - return BrokenTransposedScalarCurl_2D( - V1h=self.fem_domain, V2h=self.fem_codomain) - - -# ============================================================================== -class BrokenTransposedScalarCurl_2D(FemLinearOperator): - - def __init__(self, V1h, V2h): - - FemLinearOperator.__init__(self, fem_domain=V2h, fem_codomain=V1h) - - D1s = [ScalarCurl_2D(V1, V2) for V1, V2 in zip(V1h.spaces, V2h.spaces)] - - self._matrix = BlockLinearOperator(self.domain, self.codomain, blocks={ - (i, i): D1i._matrix.T for i, D1i in enumerate(D1s)}) - - def transpose(self, conjugate=False): - return BrokenScalarCurl_2D(V1h=self.fem_codomain, V2h=self.fem_domain) - - -# ============================================================================== - -# def multipatch_Moments_Hcurl(f, V1h, domain_h): - -def ortho_proj_Hcurl(EE, V1h, domain_h, M1, backend_language='python'): - """ - return orthogonal projection of E on V1h, given M1 the mass matrix - """ - assert isinstance(EE, Tuple) - V1 = V1h.symbolic_space - v = element_of(V1, name='v') - l = LinearForm(v, integral(V1.domain, dot(v, EE))) - lh = discretize( - l, - domain_h, - V1h, - backend=PSYDAC_BACKENDS[backend_language]) - b = lh.assemble() - M1_inv = inverse(M1.mat(), 'pcg', pc='jacobi', tol=1e-10) - sol_coeffs = M1_inv @ b - - return FemField(V1h, coeffs=sol_coeffs) - -# ============================================================================== - - -class Multipatch_Projector_H1: - """ - to apply the H1 projection (2D) on every patch - """ - - def __init__(self, V0h): - - self._P0s = [Projector_H1(V) for V in V0h.spaces] - self._V0h = V0h # multipatch Fem Space - - def __call__(self, funs_log): - """ - project a list of functions given in the logical domain - """ - u0s = [P(fun) for P, fun, in zip(self._P0s, funs_log)] - - u0_coeffs = BlockVector(self._V0h.coeff_space, - blocks=[u0j.coeffs for u0j in u0s]) - - return FemField(self._V0h, coeffs=u0_coeffs) - -# ============================================================================== - - -class Multipatch_Projector_Hcurl: - - """ - to apply the Hcurl projection (2D) on every patch - """ - - def __init__(self, V1h, nquads=None): - - self._P1s = [Projector_Hcurl(V, nquads=nquads) for V in V1h.spaces] - self._V1h = V1h # multipatch Fem Space - - def __call__(self, funs_log): - """ - project a list of functions given in the logical domain - """ - E1s = [P(fun) for P, fun, in zip(self._P1s, funs_log)] - - E1_coeffs = BlockVector(self._V1h.coeff_space, - blocks=[E1j.coeffs for E1j in E1s]) - - return FemField(self._V1h, coeffs=E1_coeffs) - -# ============================================================================== - - -class Multipatch_Projector_L2: - - """ - to apply the L2 projection (2D) on every patch - """ - - def __init__(self, V2h, nquads=None): - - self._P2s = [Projector_L2(V, nquads=nquads) for V in V2h.spaces] - self._V2h = V2h # multipatch Fem Space - - def __call__(self, funs_log): - """ - project a list of functions given in the logical domain - """ - B2s = [P(fun) for P, fun, in zip(self._P2s, funs_log)] - - B2_coeffs = BlockVector(self._V2h.coeff_space, - blocks=[B2j.coeffs for B2j in B2s]) - - return FemField(self._V2h, coeffs=B2_coeffs) diff --git a/psydac/feec/multipatch/tests/test_feec_maxwell_multipatch_2d.py b/psydac/feec/multipatch/tests/test_feec_maxwell_multipatch_2d.py index bb1b09004..3986aabb2 100644 --- a/psydac/feec/multipatch/tests/test_feec_maxwell_multipatch_2d.py +++ b/psydac/feec/multipatch/tests/test_feec_maxwell_multipatch_2d.py @@ -1,13 +1,13 @@ # coding: utf-8 import numpy as np +import pytest from psydac.feec.multipatch.examples.hcurl_source_pbms_conga_2d import solve_hcurl_source_pbm from psydac.feec.multipatch.examples.hcurl_eigen_pbms_conga_2d import hcurl_solve_eigen_pbm from psydac.feec.multipatch.examples.hcurl_eigen_pbms_dg_2d import hcurl_solve_eigen_pbm_dg from psydac.feec.multipatch.examples.timedomain_maxwell import solve_td_maxwell_pbm - def test_time_harmonic_maxwell_pretzel_f(): nc = 4 deg = 2 @@ -29,8 +29,7 @@ def test_time_harmonic_maxwell_pretzel_f(): source_proj=source_proj, backend_language='pyccel-gcc') - assert abs(diags["err"] - 0.007201508128407582) < 1e-10 - + assert abs(diags["err"] - 0.0072015081402929445) < 1e-10 def test_time_harmonic_maxwell_pretzel_f_nc(): deg = 2 @@ -54,8 +53,7 @@ def test_time_harmonic_maxwell_pretzel_f_nc(): source_proj=source_proj, backend_language='pyccel-gcc') - assert abs(diags["err"] - 0.004849165663310541) < 1e-10 - + assert abs(diags["err"] - 0.004849225522124346) < 5e-7 def test_maxwell_eigen_curved_L_shape(): domain_name = 'curved_L_shape' @@ -88,7 +86,6 @@ def test_maxwell_eigen_curved_L_shape(): nb_eigs_plot=nb_eigs_plot, domain_name=domain_name, domain=domain, backend_language='pyccel-gcc', - plot_dir='./plots/eigen_maxell', ) error = 0 @@ -97,8 +94,7 @@ def test_maxwell_eigen_curved_L_shape(): error += (eigenvalues[k] - ref_sigmas[k])**2 error = np.sqrt(error) - assert abs(error - 0.01291539899483907) < 1e-10 - + assert abs(error - 0.012915398994855902) < 1e-10 def test_maxwell_eigen_curved_L_shape_nc(): domain_name = 'curved_L_shape' @@ -133,7 +129,6 @@ def test_maxwell_eigen_curved_L_shape_nc(): nb_eigs_plot=nb_eigs_plot, domain_name=domain_name, domain=domain, backend_language='pyccel-gcc', - plot_dir='./plots/eigen_maxell_nc', ) error = 0 @@ -142,8 +137,7 @@ def test_maxwell_eigen_curved_L_shape_nc(): error += (eigenvalues[k] - ref_sigmas[k])**2 error = np.sqrt(error) - assert abs(error - 0.010504876643873904) < 1e-10 - + assert abs(error - 0.010504876643886937) < 1e-10 def test_maxwell_eigen_curved_L_shape_dg(): domain_name = 'curved_L_shape' @@ -176,7 +170,6 @@ def test_maxwell_eigen_curved_L_shape_dg(): nb_eigs_plot=nb_eigs_plot, domain_name=domain_name, domain=domain, backend_language='pyccel-gcc', - plot_dir='./plots/eigen_maxell_dg', ) error = 0 @@ -184,9 +177,8 @@ def test_maxwell_eigen_curved_L_shape_dg(): for k in range(n_errs): error += (eigenvalues[k] - ref_sigmas[k])**2 error = np.sqrt(error) - - assert abs(error - 0.035139029534570064) < 1e-10 + assert abs(error - 0.035139029534592255) < 1e-10 def test_maxwell_timedomain(): solve_td_maxwell_pbm(nc = 4, deg = 2, final_time = 2, domain_name = 'square_2') diff --git a/psydac/feec/multipatch/tests/test_feec_poisson_multipatch_2d.py b/psydac/feec/multipatch/tests/test_feec_poisson_multipatch_2d.py index 7a0d94cbb..2804fba2d 100644 --- a/psydac/feec/multipatch/tests/test_feec_poisson_multipatch_2d.py +++ b/psydac/feec/multipatch/tests/test_feec_poisson_multipatch_2d.py @@ -2,7 +2,6 @@ from psydac.feec.multipatch.examples.h1_source_pbms_conga_2d import solve_h1_source_pbm - def test_poisson_pretzel_f(): source_type = 'manu_poisson_2' @@ -19,7 +18,7 @@ def test_poisson_pretzel_f(): backend_language='pyccel-gcc', plot_dir=None) - assert abs(l2_error - 1.0585687717792318e-05) < 1e-10 + assert abs(l2_error - 1.1016888403643595e-05) < 5e-8 def test_poisson_pretzel_f_nc(): @@ -39,7 +38,7 @@ def test_poisson_pretzel_f_nc(): backend_language='pyccel-gcc', plot_dir=None) - assert abs(l2_error - 6.051557012306659e-06) < 1e-10 + assert abs(l2_error - 7.079666478120528e-06) < 5e-8 # ============================================================================== diff --git a/psydac/feec/multipatch/utils_conga_2d.py b/psydac/feec/multipatch/utils_conga_2d.py index 351511d5e..b457f0232 100644 --- a/psydac/feec/multipatch/utils_conga_2d.py +++ b/psydac/feec/multipatch/utils_conga_2d.py @@ -5,70 +5,41 @@ from sympy import lambdify from sympde.topology import Derham +from sympde.topology.callable_mapping import BasicCallableMapping from psydac.api.settings import PSYDAC_BACKENDS from psydac.feec.pull_push import pull_2d_h1, pull_2d_hcurl, pull_2d_l2 -from psydac.feec.multipatch.api import discretize -from psydac.feec.multipatch.utilities import time_count # , export_sol, import_sol +from psydac.api.discretization import discretize +from psydac.feec.multipatch.utilities import time_count from psydac.linalg.utilities import array_to_psydac from psydac.fem.basic import FemField from psydac.fem.plotting_utilities import get_plotting_grid, get_grid_quad_weights, get_grid_vals +from scipy.sparse import kron, block_diag +from psydac.core.bsplines import collocation_matrix, histopolation_matrix +from psydac.linalg.solvers import inverse -# commuting projections on the physical domain (should probably be in the -# interface) -def P0_phys(f_phys, P0, domain, mappings_list): - f = lambdify(domain.coordinates, f_phys) - f_log = [pull_2d_h1(f, m.get_callable_mapping()) for m in mappings_list] - return P0(f_log) - - -def P1_phys(f_phys, P1, domain, mappings_list): - f_x = lambdify(domain.coordinates, f_phys[0]) - f_y = lambdify(domain.coordinates, f_phys[1]) - f_log = [pull_2d_hcurl([f_x, f_y], m.get_callable_mapping()) - for m in mappings_list] - return P1(f_log) - - -def P2_phys(f_phys, P2, domain, mappings_list): - f = lambdify(domain.coordinates, f_phys) - f_log = [pull_2d_l2(f, m.get_callable_mapping()) for m in mappings_list] - return P2(f_log) # commuting projections on the physical domain (should probably be in the # interface) - - -def P_phys_h1(f_phys, P0, domain, mappings_list): +def P0_phys(f_phys, P0, domain): f = lambdify(domain.coordinates, f_phys) - if len(mappings_list) == 1: - m = mappings_list[0] - f_log = pull_2d_h1(f, m) - else: - f_log = [pull_2d_h1(f, m) for m in mappings_list] - return P0(f_log) + return P0(f) -def P_phys_hcurl(f_phys, P1, domain, mappings_list): - f_x = lambdify(domain.coordinates, f_phys[0]) - f_y = lambdify(domain.coordinates, f_phys[1]) - f_log = [pull_2d_hcurl([f_x, f_y], m) for m in mappings_list] - return P1(f_log) - -def P_phys_hdiv(f_phys, P1, domain, mappings_list): +def P1_phys(f_phys, P1, domain): f_x = lambdify(domain.coordinates, f_phys[0]) f_y = lambdify(domain.coordinates, f_phys[1]) - f_log = [pull_2d_hdiv([f_x, f_y], m) for m in mappings_list] - return P1(f_log) + + return P1([f_x, f_y]) -def P_phys_l2(f_phys, P2, domain, mappings_list): +def P2_phys(f_phys, P2, domain): f = lambdify(domain.coordinates, f_phys) - f_log = [pull_2d_l2(f, m) for m in mappings_list] - return P2(f_log) + + return P2(f) def get_kind(space='V*'): @@ -83,6 +54,142 @@ def get_kind(space='V*'): raise ValueError(space) return kind +# =============================================================================== +def get_K0_and_K0_inv(V0h, uniform_patches=False): + """ + Compute the change of basis matrices K0 and K0^{-1} in V0h. + + With + K0_ij = sigma^0_i(B_j) = B_jx(n_ix) * B_jy(n_iy) + where sigma_i is the geometric (interpolation) dof + and B_j is the tensor-product B-spline + """ + if uniform_patches: + print(' [[WARNING -- hack in get_K0_and_K0_inv: using copies of 1st-patch matrices in every patch ]] ') + + V0 = V0h.symbolic_space # VOh is FemSpace + domain = V0.domain + K0_blocks = [] + K0_inv_blocks = [] + for k, D in enumerate(domain.interior): + if uniform_patches and k > 0: + K0_k = K0_blocks[0].copy() + K0_inv_k = K0_inv_blocks[0].copy() + + else: + V0_k = V0h.spaces[k] # fem space on patch k: (TensorFemSpace) + K0_k_factors = [None, None] + for d in [0, 1]: + # 1d fem space alond dim d (SplineSpace) + V0_kd = V0_k.spaces[d] + K0_k_factors[d] = collocation_matrix( + knots=V0_kd.knots, + degree=V0_kd.degree, + periodic=V0_kd.periodic, + normalization=V0_kd.basis, + xgrid=V0_kd.greville + ) + K0_k = kron(*K0_k_factors) + K0_k.eliminate_zeros() + K0_inv_k = inv(K0_k.tocsc()) + K0_inv_k.eliminate_zeros() + + K0_blocks.append(K0_k) + K0_inv_blocks.append(K0_inv_k) + K0 = block_diag(K0_blocks) + K0_inv = block_diag(K0_inv_blocks) + return K0, K0_inv + + +# =============================================================================== +def get_K1_and_K1_inv(V1h, uniform_patches=False): + """ + Compute the change of basis matrices K1 and K1^{-1} in Hcurl space V1h. + + With + K1_ij = sigma^1_i(B_j) = int_{e_ix}(M_jx) * B_jy(n_iy) + if i = horizontal edge [e_ix, n_iy] and j = (M_jx o B_jy) x-oriented MoB spline + or + = B_jx(n_ix) * int_{e_iy}(M_jy) + if i = vertical edge [n_ix, e_iy] and j = (B_jx o M_jy) y-oriented BoM spline + (above, 'o' denotes tensor-product for functions) + """ + if uniform_patches: + print(' [[WARNING -- hack in get_K1_and_K1_inv: using copies of 1st-patch matrices in every patch ]] ') + + V1 = V1h.symbolic_space # V1h is FemSpace + domain = V1.domain + K1_blocks = [] + K1_inv_blocks = [] + for k, D in enumerate(domain.interior): + if uniform_patches and k > 0: + K1_k = K1_blocks[0].copy() + K1_inv_k = K1_inv_blocks[0].copy() + + else: + # fem space on patch k: + V1_k = V1h.spaces[k] + K1_k_blocks = [] + for c in [0, 1]: # dim of component + # fem space for comp. dc (TensorFemSpace) + V1_kc = V1_k.spaces[c] + K1_kc_factors = [None, None] + for d in [0, 1]: # dim of variable + # 1d fem space for comp c alond dim d (SplineSpace) + V1_kcd = V1_kc.spaces[d] + if c == d: + K1_kc_factors[d] = histopolation_matrix( + knots=V1_kcd.knots, + degree=V1_kcd.degree, + periodic=V1_kcd.periodic, + normalization=V1_kcd.basis, + xgrid=V1_kcd.ext_greville + ) + else: + K1_kc_factors[d] = collocation_matrix( + knots=V1_kcd.knots, + degree=V1_kcd.degree, + periodic=V1_kcd.periodic, + normalization=V1_kcd.basis, + xgrid=V1_kcd.greville + ) + K1_kc = kron(*K1_kc_factors) + K1_kc.eliminate_zeros() + K1_k_blocks.append(K1_kc) + K1_k = block_diag(K1_k_blocks) + K1_k.eliminate_zeros() + K1_inv_k = inv(K1_k.tocsc()) + K1_inv_k.eliminate_zeros() + + K1_blocks.append(K1_k) + K1_inv_blocks.append(K1_inv_k) + + K1 = block_diag(K1_blocks) + K1_inv = block_diag(K1_inv_blocks) + return K1, K1_inv + +# =============================================================================== + + +def ortho_proj_Hcurl(EE, V1h, domain_h, M1, backend_language='python'): + """ + return orthogonal projection of E on V1h, given M1 the mass matrix + """ + assert isinstance(EE, Tuple) + V1 = V1h.symbolic_space + v = element_of(V1, name='v') + l = LinearForm(v, integral(V1.domain, dot(v, EE))) + lh = discretize( + l, + domain_h, + V1h, + backend=PSYDAC_BACKENDS[backend_language]) + b = lh.assemble() + M1_inv = inverse(M1.mat(), 'pcg', pc='jacobi', tol=1e-10) + sol_coeffs = M1_inv @ b + + return FemField(V1h, coeffs=sol_coeffs) + # =============================================================================== class DiagGrid(): diff --git a/psydac/feec/tests/test_axis_projection.py b/psydac/feec/tests/test_axis_projection.py index 61e1dfb4f..7a81f0953 100644 --- a/psydac/feec/tests/test_axis_projection.py +++ b/psydac/feec/tests/test_axis_projection.py @@ -1,6 +1,6 @@ -from sympde.topology import Square, Derham, element_of -from sympde.expr.expr import BilinearForm, integral -from psydac.feec.multipatch.api import discretize +from sympde.topology import Square, Derham, element_of +from sympde.expr.expr import BilinearForm, integral +from psydac.api.discretization import discretize from psydac.api.settings import PSYDAC_BACKENDS def test_axis_projection(): diff --git a/psydac/feec/tests/test_commuting_projections.py b/psydac/feec/tests/test_commuting_projections.py index 38881220f..b038fbc25 100644 --- a/psydac/feec/tests/test_commuting_projections.py +++ b/psydac/feec/tests/test_commuting_projections.py @@ -3,13 +3,17 @@ import numpy as np import pytest -from psydac.feec.global_projectors import Projector_H1, Projector_L2, Projector_Hcurl, Projector_Hdiv +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorH1 +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorL2 +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorHcurl +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorHdiv + from psydac.fem.tensor import TensorFemSpace, SplineSpace from psydac.fem.vector import VectorFemSpace from psydac.core.bsplines import make_knots -from psydac.feec.derivatives import Derivative_1D, Gradient_2D, Gradient_3D -from psydac.feec.derivatives import ScalarCurl_2D, VectorCurl_2D, Curl_3D -from psydac.feec.derivatives import Divergence_2D, Divergence_3D +from psydac.feec.derivatives import Derivative1D, Gradient2D, Gradient3D +from psydac.feec.derivatives import ScalarCurl2D, VectorCurl2D, Curl3D +from psydac.feec.derivatives import Divergence2D, Divergence3D from psydac.ddm.cart import DomainDecomposition from psydac.linalg.solvers import inverse from psydac.linalg.basic import IdentityOperator @@ -57,13 +61,13 @@ def test_3d_commuting_pro_1(Nel, Nq, p, bc, m): Hcurl = VectorFemSpace(*spaces) # create an instance of the H1 projector class - P0 = Projector_H1(H1) + P0 = GlobalGeometricProjectorH1(H1) # Build linear operators on stencil arrays - grad = Gradient_3D(H1, Hcurl) + grad = Gradient3D(H1, Hcurl) # create an instance of the projector class - P1 = Projector_Hcurl(Hcurl, Nq) + P1 = GlobalGeometricProjectorHcurl(Hcurl, Nq) #------------------------------------- # Projections and discrete derivatives #------------------------------------- @@ -150,11 +154,11 @@ def test_3d_commuting_pro_2(Nel, Nq, p, bc, m): Hdiv = VectorFemSpace(*spaces) # Build linear operators on stencil arrays - curl = Curl_3D(Hcurl, Hdiv) + curl = Curl3D(Hcurl, Hdiv) # create an instance of the projector class - P1 = Projector_Hcurl(Hcurl, Nq) - P2 = Projector_Hdiv(Hdiv, Nq) + P1 = GlobalGeometricProjectorHcurl(Hcurl, Nq) + P2 = GlobalGeometricProjectorHdiv(Hdiv, Nq) #------------------------------------- # Projections and discrete derivatives @@ -232,11 +236,11 @@ def test_3d_commuting_pro_3(Nel, Nq, p, bc, m): # create an instance of the H1 projector class # Build linear operators on stencil arrays - div = Divergence_3D(Hdiv, L2) + div = Divergence3D(Hdiv, L2) # create an instance of the projector class - P2 = Projector_Hdiv(Hdiv, Nq) - P3 = Projector_L2(L2, Nq) + P2 = GlobalGeometricProjectorHdiv(Hdiv, Nq) + P3 = GlobalGeometricProjectorL2(L2, Nq) #------------------------------------- # Projections and discrete derivatives @@ -307,13 +311,13 @@ def test_2d_commuting_pro_1(Nel, Nq, p, bc, m): Hcurl = VectorFemSpace(*spaces) # create an instance of the H1 projector class - P0 = Projector_H1(H1) + P0 = GlobalGeometricProjectorH1(H1) # Build linear operators on stencil arrays - grad = Gradient_2D(H1, Hcurl) + grad = Gradient2D(H1, Hcurl) # create an instance of the projector class - P1 = Projector_Hcurl(Hcurl, Nq) + P1 = GlobalGeometricProjectorHcurl(Hcurl, Nq) #------------------------------------- # Projections and discrete derivatives #------------------------------------- @@ -380,13 +384,13 @@ def test_2d_commuting_pro_2(Nel, Nq, p, bc, m): Hdiv = VectorFemSpace(*spaces) # create an instance of the H1 projector class - P0 = Projector_H1(H1) + P0 = GlobalGeometricProjectorH1(H1) # Linear operator: 2D vector curl - curl = VectorCurl_2D(H1, Hdiv) + curl = VectorCurl2D(H1, Hdiv) # create an instance of the projector class - P1 = Projector_Hdiv(Hdiv, Nq) + P1 = GlobalGeometricProjectorHdiv(Hdiv, Nq) #------------------------------------- # Projections and discrete derivatives #------------------------------------- @@ -461,11 +465,11 @@ def test_2d_commuting_pro_3(Nel, Nq, p, bc, m): # create an instance of the H1 projector class # Build linear operators on stencil arrays - div = Divergence_2D(Hdiv, L2) + div = Divergence2D(Hdiv, L2) # create an instance of the projector class - P2 = Projector_Hdiv(Hdiv, Nq) - P3 = Projector_L2(L2, Nq) + P2 = GlobalGeometricProjectorHdiv(Hdiv, Nq) + P3 = GlobalGeometricProjectorL2(L2, Nq) #------------------------------------- # Projections and discrete derivatives @@ -541,11 +545,11 @@ def test_2d_commuting_pro_4(Nel, Nq, p, bc, m): # create an instance of the H1 projector class # Build linear operators on stencil arrays - curl = ScalarCurl_2D(Hcurl, L2) + curl = ScalarCurl2D(Hcurl, L2) # create an instance of the projector class - P1 = Projector_Hcurl(Hcurl, Nq) - P2 = Projector_L2(L2, Nq) + P1 = GlobalGeometricProjectorHcurl(Hcurl, Nq) + P2 = GlobalGeometricProjectorL2(L2, Nq) #------------------------------------- # Projections and discrete derivatives @@ -610,13 +614,13 @@ def test_1d_commuting_pro_1(Nel, Nq, p, bc, m): L2 = H1.reduce_degree(axes=[0], basis='M') # create an instance of the H1 projector class - P0 = Projector_H1(H1) + P0 = GlobalGeometricProjectorH1(H1) # Build linear operators on stencil arrays - grad = Derivative_1D(H1, L2) + grad = Derivative1D(H1, L2) # create an instance of the projector class - P1 = Projector_L2(L2, Nq) + P1 = GlobalGeometricProjectorL2(L2, Nq) #------------------------------------- # Projections and discrete derivatives #------------------------------------- diff --git a/psydac/feec/tests/test_commuting_projections_dual.py b/psydac/feec/tests/test_commuting_projections_dual.py index 944915bc4..9057ba1ff 100644 --- a/psydac/feec/tests/test_commuting_projections_dual.py +++ b/psydac/feec/tests/test_commuting_projections_dual.py @@ -1,6 +1,6 @@ -from psydac.feec.derivatives import Gradient_3D -from psydac.feec.derivatives import Curl_3D -from psydac.feec.derivatives import Divergence_3D +from psydac.feec.derivatives import Gradient3D +from psydac.feec.derivatives import Curl3D +from psydac.feec.derivatives import Divergence3D from sympde.expr import LinearForm, integral from sympde.topology import Derham, element_of, Cube from psydac.api.discretization import discretize @@ -39,7 +39,7 @@ def test_transpose_div_3d(Nel, Nq, p, bc, m): v2 = element_of(derham.V2, name='v2') v3 = element_of(derham.V3, name='v3') - div = Divergence_3D(derham_h.V2, derham_h.V3) + div = Divergence3D(derham_h.V2, derham_h.V3) f2 = LinearForm(v2, integral(domain, D1fun1(*domain.coordinates)*v2[0] + D2fun1(*domain.coordinates)*v2[1] + D3fun1(*domain.coordinates)*v2[2])) f3 = LinearForm(v3, integral(domain, fun1(*domain.coordinates) * v3)) @@ -47,7 +47,7 @@ def test_transpose_div_3d(Nel, Nq, p, bc, m): u2 = discretize(f2, domain_h, derham_h.V2, nquads=Nq, backend=PSYDAC_BACKENDS['pyccel-gcc']).assemble() u3 = discretize(f3, domain_h, derham_h.V3, nquads=Nq, backend=PSYDAC_BACKENDS['pyccel-gcc']).assemble() - divT_u3 = - div.matrix.T.dot(u3) + divT_u3 = - div.linop.T.dot(u3) error = abs((u2-divT_u3).toarray()).max() assert error < 2e-10 @@ -97,7 +97,7 @@ def test_transpose_curl_3d(Nel, Nq, p, bc, m): v1 = element_of(derham.V1, name='v1') v2 = element_of(derham.V2, name='v2') - curl = Curl_3D(derham_h.V1, derham_h.V2) + curl = Curl3D(derham_h.V1, derham_h.V2) f1 = LinearForm(v1, integral(domain, cf1(*domain.coordinates)*v1[0] + cf2(*domain.coordinates)*v1[1] + cf3(*domain.coordinates)*v1[2])) f2 = LinearForm(v2, integral(domain, fun1(*domain.coordinates)*v2[0] + fun2(*domain.coordinates)*v2[1] + fun3(*domain.coordinates)*v2[2])) @@ -105,7 +105,7 @@ def test_transpose_curl_3d(Nel, Nq, p, bc, m): u1 = discretize(f1, domain_h, derham_h.V1, nquads=Nq, backend=PSYDAC_BACKENDS['pyccel-gcc']).assemble() u2 = discretize(f2, domain_h, derham_h.V2, nquads=Nq, backend=PSYDAC_BACKENDS['pyccel-gcc']).assemble() - curlT_u2 = curl.matrix.T.dot(u2) + curlT_u2 = curl.linop.T.dot(u2) error = abs((u1-curlT_u2).toarray()).max() assert error < 2e-9 @@ -144,7 +144,7 @@ def test_transpose_grad_3d(Nel, Nq, p, bc, m): v0 = element_of(derham.V0, name='v0') v1 = element_of(derham.V1, name='v1') - grad = Gradient_3D(derham_h.V0, derham_h.V1) + grad = Gradient3D(derham_h.V0, derham_h.V1) f0 = LinearForm(v0, integral(domain, (D1fun1(*domain.coordinates) + D2fun2(*domain.coordinates) + D3fun3(*domain.coordinates))*v0)) f1 = LinearForm(v1, integral(domain, fun1(*domain.coordinates)*v1[0] + fun2(*domain.coordinates)*v1[1] + fun3(*domain.coordinates)*v1[2])) @@ -152,7 +152,7 @@ def test_transpose_grad_3d(Nel, Nq, p, bc, m): u0 = discretize(f0, domain_h, derham_h.V0, nquads=Nq, backend=PSYDAC_BACKENDS['pyccel-gcc']).assemble() u1 = discretize(f1, domain_h, derham_h.V1, nquads=Nq, backend=PSYDAC_BACKENDS['pyccel-gcc']).assemble() - gradT_u1 = -grad.matrix.T.dot(u1) + gradT_u1 = -grad.linop.T.dot(u1) error = abs((u0-gradT_u1).toarray()).max() assert error < 5e-10 diff --git a/psydac/feec/tests/test_differentiation_matrices.py b/psydac/feec/tests/test_differentiation_matrices.py index a60cbae36..609799dd3 100644 --- a/psydac/feec/tests/test_differentiation_matrices.py +++ b/psydac/feec/tests/test_differentiation_matrices.py @@ -8,11 +8,9 @@ from psydac.fem.vector import VectorFemSpace from psydac.feec.derivatives import DirectionalDerivativeOperator -from psydac.feec.derivatives import Derivative_1D, Gradient_2D, Gradient_3D -from psydac.feec.derivatives import ScalarCurl_2D, VectorCurl_2D, Curl_3D -from psydac.feec.derivatives import Divergence_2D, Divergence_3D - -from psydac.feec.global_projectors import Projector_H1 +from psydac.feec.derivatives import Derivative1D, Gradient2D, Gradient3D +from psydac.feec.derivatives import ScalarCurl2D, VectorCurl2D, Curl3D +from psydac.feec.derivatives import Divergence2D, Divergence3D from psydac.ddm.cart import DomainDecomposition from mpi4py import MPI @@ -352,7 +350,7 @@ def test_directional_derivative_operator_3d_par(domain, ncells, degree, periodic @pytest.mark.parametrize('seed', [1,3]) @pytest.mark.parametrize('multiplicity', [1,2]) -def test_Derivative_1D(domain, ncells, degree, periodic, seed, multiplicity): +def test_Derivative1D(domain, ncells, degree, periodic, seed, multiplicity): # determinize tests np.random.seed(seed) @@ -372,7 +370,7 @@ def test_Derivative_1D(domain, ncells, degree, periodic, seed, multiplicity): u0 = FemField(V0) # Linear operator: 1D derivative - grad = Derivative_1D(V0, V1) + grad = Derivative1D(V0, V1) # Create random field in V0 s, = V0.coeff_space.starts @@ -401,7 +399,7 @@ def test_Derivative_1D(domain, ncells, degree, periodic, seed, multiplicity): @pytest.mark.parametrize('seed', [1,3]) @pytest.mark.parametrize('multiplicity', [(1, 1), (1, 2), (2, 2)]) -def test_Gradient_2D(domain, ncells, degree, periodic, seed, multiplicity): +def test_Gradient2D(domain, ncells, degree, periodic, seed, multiplicity): # determinize tests np.random.seed(seed) @@ -421,7 +419,7 @@ def test_Gradient_2D(domain, ncells, degree, periodic, seed, multiplicity): V1 = VectorFemSpace(DxNy, NxDy) # Linear operator: 2D gradient - grad = Gradient_2D(V0, V1) + grad = Gradient2D(V0, V1) # Create random field in V0 u0 = FemField(V0) @@ -463,7 +461,7 @@ def test_Gradient_2D(domain, ncells, degree, periodic, seed, multiplicity): @pytest.mark.parametrize('seed', [1,3]) @pytest.mark.parametrize('multiplicity', [(1, 1, 1), (1, 2, 2), (2, 2, 2)]) -def test_Gradient_3D(domain, ncells, degree, periodic, seed, multiplicity): +def test_Gradient3D(domain, ncells, degree, periodic, seed, multiplicity): if any([ncells[d] <= degree[d] and periodic[d] for d in range(3)]): return @@ -489,7 +487,7 @@ def test_Gradient_3D(domain, ncells, degree, periodic, seed, multiplicity): V1 = VectorFemSpace(DxNyNz, NxDyNz, NxNyDz) # Linear operator: 3D gradient - grad = Gradient_3D(V0, V1) + grad = Gradient3D(V0, V1) # Create random field in V0 u0 = FemField(V0) @@ -530,7 +528,7 @@ def test_Gradient_3D(domain, ncells, degree, periodic, seed, multiplicity): @pytest.mark.parametrize('multiplicity', [(1, 1), (1, 2), (2, 2)]) -def test_ScalarCurl_2D(domain, ncells, degree, periodic, seed, multiplicity): +def test_ScalarCurl2D(domain, ncells, degree, periodic, seed, multiplicity): # determinize tests np.random.seed(seed) @@ -555,7 +553,7 @@ def test_ScalarCurl_2D(domain, ncells, degree, periodic, seed, multiplicity): V2 = DxDy # Linear operator: curl - curl = ScalarCurl_2D(V1, V2) + curl = ScalarCurl2D(V1, V2) # ... # Create random field in V1 @@ -605,7 +603,7 @@ def eval_curl(fx, fy, *eta): @pytest.mark.parametrize('seed', [1,3]) @pytest.mark.parametrize('multiplicity', [(1, 1), (1, 2), (2, 2)]) -def test_VectorCurl_2D(domain, ncells, degree, periodic, seed, multiplicity): +def test_VectorCurl2D(domain, ncells, degree, periodic, seed, multiplicity): # determinize tests np.random.seed(seed) @@ -625,7 +623,7 @@ def test_VectorCurl_2D(domain, ncells, degree, periodic, seed, multiplicity): V1 = VectorFemSpace(NxDy, DxNy) # Linear operator: 2D vector curl - curl = VectorCurl_2D(V0, V1) + curl = VectorCurl2D(V0, V1) # Create random field in V0 u0 = FemField(V0) @@ -671,7 +669,7 @@ def eval_curl(f, *eta): @pytest.mark.parametrize('seed', [1,3]) @pytest.mark.parametrize('multiplicity', [(1, 1, 1), (1, 2, 2), (2, 2, 2)]) -def test_Curl_3D(domain, ncells, degree, periodic, seed, multiplicity): +def test_Curl3D(domain, ncells, degree, periodic, seed, multiplicity): if any([ncells[d] <= degree[d] and periodic[d] for d in range(3)]): return @@ -703,7 +701,7 @@ def test_Curl_3D(domain, ncells, degree, periodic, seed, multiplicity): V2 = VectorFemSpace(NxDyDz, DxNyDz, DxDyNz) # Linear operator: curl - curl = Curl_3D(V1, V2) + curl = Curl3D(V1, V2) # ... # Create random field in V1 @@ -763,7 +761,7 @@ def eval_curl(fx, fy, fz, *eta): @pytest.mark.parametrize('seed', [1,3]) @pytest.mark.parametrize('multiplicity', [(1, 1), (1, 2), (2, 2)]) -def test_Divergence_2D(domain, ncells, degree, periodic, seed, multiplicity): +def test_Divergence2D(domain, ncells, degree, periodic, seed, multiplicity): # determinize tests np.random.seed(seed) @@ -787,7 +785,7 @@ def test_Divergence_2D(domain, ncells, degree, periodic, seed, multiplicity): V2 = V0.reduce_degree(axes=[0, 1], basis='M') # Linear operator: divergence - div = Divergence_2D(V1, V2) + div = Divergence2D(V1, V2) # ... # Create random field in V1 @@ -839,7 +837,7 @@ def eval_div(fx, fy, *eta): @pytest.mark.parametrize('seed', [1,3]) @pytest.mark.parametrize('multiplicity', [(1, 1, 1), (1, 2, 2), (2, 2, 2)]) -def test_Divergence_3D(domain, ncells, degree, periodic, seed, multiplicity): +def test_Divergence3D(domain, ncells, degree, periodic, seed, multiplicity): # determinize tests np.random.seed(seed) @@ -865,7 +863,7 @@ def test_Divergence_3D(domain, ncells, degree, periodic, seed, multiplicity): V3 = V0.reduce_degree(axes=[0, 1, 2], basis='M') # Linear operator: divergence - div = Divergence_3D(V2, V3) + div = Divergence3D(V2, V3) # ... # Create random field in V2 @@ -915,11 +913,11 @@ def eval_div(fx, fy, fz, *eta): #============================================================================== if __name__ == '__main__': - test_Derivative_1D(domain=[0, 1], ncells=3, degree=3, periodic=False, seed=1, multiplicity=1) - test_Derivative_1D(domain=[0, 1], ncells=12, degree=3, periodic=True, seed=1, multiplicity=1) + test_Derivative1D(domain=[0, 1], ncells=3, degree=3, periodic=False, seed=1, multiplicity=1) + test_Derivative1D(domain=[0, 1], ncells=12, degree=3, periodic=True, seed=1, multiplicity=1) - test_Gradient_2D( + test_Gradient2D( domain = ([0, 1], [0, 1]), ncells = (10, 15), degree = (3, 2), @@ -927,7 +925,7 @@ def eval_div(fx, fy, fz, *eta): seed = 1 ) - test_Gradient_3D( + test_Gradient3D( domain = ([0, 1], [0, 1], [0, 1]), ncells = (5, 8, 4), degree = (3, 2, 3), @@ -935,7 +933,7 @@ def eval_div(fx, fy, fz, *eta): seed = 1 ) - test_ScalarCurl_2D( + test_ScalarCurl2D( domain = ([0, 1], [0, 1]), ncells = (10, 15), degree = (3, 2), @@ -943,7 +941,7 @@ def eval_div(fx, fy, fz, *eta): seed = 1 ) - test_VectorCurl_2D( + test_VectorCurl2D( domain = ([0, 1], [0, 1]), ncells = (10, 15), degree = (3, 2), @@ -951,7 +949,7 @@ def eval_div(fx, fy, fz, *eta): seed = 1 ) - test_Curl_3D( + test_Curl3D( domain = ([0, 1], [0, 1], [0, 1]), ncells = (5, 8, 4), degree = (3, 2, 3), @@ -959,7 +957,7 @@ def eval_div(fx, fy, fz, *eta): seed = 1 ) - test_Divergence_2D( + test_Divergence2D( domain = ([0, 1], [0, 1]), ncells = (10, 15), degree = (3, 2), @@ -967,7 +965,7 @@ def eval_div(fx, fy, fz, *eta): seed = 1 ) - test_Divergence_3D( + test_Divergence3D( domain = ([0, 1], [0, 1], [0, 1]), ncells = (5, 8, 4), degree = (3, 2, 3), diff --git a/psydac/feec/multipatch/tests/test_feec_conf_projectors_cart_2d.py b/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py similarity index 54% rename from psydac/feec/multipatch/tests/test_feec_conf_projectors_cart_2d.py rename to psydac/feec/tests/test_feec_conf_projectors_cart_2d.py index 3d0dd85ff..01a0dd38e 100644 --- a/psydac/feec/multipatch/tests/test_feec_conf_projectors_cart_2d.py +++ b/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py @@ -2,16 +2,16 @@ from collections import OrderedDict import numpy as np -from sympy import Tuple +from sympy import Tuple, lambdify from scipy.sparse.linalg import norm as sp_norm +from scipy.sparse.linalg import inv from sympde.topology.domain import Domain from sympde.topology import Derham, Square, IdentityMapping -from psydac.feec.multipatch.api import discretize -from psydac.feec.multipatch.operators import HodgeOperator -from psydac.feec.multipatch.non_matching_operators import construct_h1_conforming_projection, construct_hcurl_conforming_projection -from psydac.feec.multipatch.utils_conga_2d import P_phys_l2, P_phys_hdiv, P_phys_hcurl, P_phys_h1 +from psydac.api.discretization import discretize + +from psydac.fem.projectors import get_dual_dofs def get_polynomial_function(degree, hom_bc_axes, domain): @@ -37,7 +37,10 @@ def get_polynomial_function(degree, hom_bc_axes, domain): # else: g0_y = (y - 0.75)**degree[1] - return g0_x * g0_y + expr = g0_x * g0_y + callable_function = lambdify(domain.coordinates, expr) + + return expr, callable_function # ============================================================================== @@ -46,10 +49,10 @@ def get_polynomial_function(degree, hom_bc_axes, domain): @pytest.mark.parametrize('nc', [5]) @pytest.mark.parametrize('reg', [0]) @pytest.mark.parametrize('hom_bc', [False, True]) -@pytest.mark.parametrize('domain_name', ["4patch_nc", "2patch_nc"]) +@pytest.mark.parametrize('domain_name', ["1patch", "4patch_nc", "2patch_nc"]) @pytest.mark.parametrize("nonconforming, full_mom_pres", [(True, True), (False, True)]) -# NOTE (MCP march 2025): momentum conservation fails for nc = 4 and degree = 3, why? + def test_conf_projectors_2d( V1_type, degree, @@ -60,8 +63,12 @@ def test_conf_projectors_2d( domain_name, nonconforming ): + if domain_name == '1patch': + log_domain = Square('Omega', bounds1=(0, 1), bounds2=(0, 1)) + mapping = IdentityMapping('M1', dim=2) + domain = mapping(log_domain) - if domain_name == '2patch_nc': + elif domain_name == '2patch_nc': A = Square('A', bounds1=(0, 0.5), bounds2=(0, 1)) B = Square('B', bounds1=(0.5, 1.), bounds2=(0, 1)) @@ -95,8 +102,10 @@ def test_conf_projectors_2d( ((0, 1, 1), (2, 1, -1), 1), ((1, 1, 1), (3, 1, -1), 1)], name='domain') + if domain_name == '1patch': + ncells_h = {domain.name: [nc, nc]} - if nonconforming: + elif nonconforming: if len(domain) == 2: ncells_h = { 'M1(A)': [nc, nc], @@ -115,24 +124,14 @@ def test_conf_projectors_2d( for k, D in enumerate(domain.interior): ncells_h[D.name] = [nc, nc] - derham = Derham(domain, ["H1", "Hcurl", "L2"]) domain_h = discretize(domain, ncells=ncells_h) # Vh space - derham_h = discretize(derham, domain_h, degree=degree) - V0h = derham_h.V0 - V1h = derham_h.V1 - V2h = derham_h.V2 - - mappings = OrderedDict([(P.logical_domain, P.mapping) - for P in domain.interior]) - mappings_list = [m.get_callable_mapping() for m in mappings.values()] - p_derham = Derham(domain, ["H1", V1_type, "L2"]) + derham = Derham(domain, ["H1", V1_type, "L2"]) nquads = [(d + 1) for d in degree] - p_derham_h = discretize(p_derham, domain_h, degree=degree) - p_V0h = p_derham_h.V0 - p_V1h = p_derham_h.V1 - p_V2h = p_derham_h.V2 + derham_h = discretize(derham, domain_h, degree=degree) + V0h, V1h, V2h = derham_h.spaces + # full moment preservation only possible if enough interior functions in a # patch (<=> enough cells) @@ -145,38 +144,26 @@ def test_conf_projectors_2d( # moment preservation... # geometric projections (operators) - p_geomP0, p_geomP1, p_geomP2 = p_derham_h.projectors(nquads=nquads) + geomP0, geomP1, geomP2 = derham_h.projectors(nquads=nquads) # conforming projections (scipy matrices) - cP0 = construct_h1_conforming_projection(V0h, reg, mom_pres, hom_bc) - cP1 = construct_hcurl_conforming_projection(V1h, reg, mom_pres, hom_bc) - cP2 = construct_h1_conforming_projection(V2h, reg - 1, mom_pres, hom_bc) + cP0, cP1, cP2 = derham_h.conforming_projectors(p_moments=mom_pres, hom_bc=hom_bc) + cP0, cP1, cP2 = (m.tosparse() for m in (cP0, cP1, cP2)) + + M0, M1, M2 = derham_h.hodge_operators() + M0, M1, M2 = (m.tosparse().tocsc() for m in (M0, M1, M2)) - HOp0 = HodgeOperator(p_V0h, domain_h) - M0 = HOp0.to_sparse_matrix() # mass matrix - M0_inv = HOp0.get_dual_Hodge_sparse_matrix() # inverse mass matrix + M0_inv, M1_inv, M2_inv = (inv(m) for m in (M0, M1, M2)) - HOp1 = HodgeOperator(p_V1h, domain_h) - M1 = HOp1.to_sparse_matrix() # mass matrix - M1_inv = HOp1.get_dual_Hodge_sparse_matrix() # inverse mass matrix + bD0, bD1 = derham_h.derivatives() + bD0, bD1 = (m.tosparse() for m in (bD0, bD1)) - HOp2 = HodgeOperator(p_V2h, domain_h) - M2 = HOp2.to_sparse_matrix() # mass matrix - M2_inv = HOp2.get_dual_Hodge_sparse_matrix() # inverse mass matrix - - bD0, bD1 = p_derham_h.broken_derivatives_as_operators - - bD0 = bD0.to_sparse_matrix() # broken grad - bD1 = bD1.to_sparse_matrix() # broken curl or div D0 = bD0 @ cP0 # Conga grad D1 = bD1 @ cP1 # Conga curl or div - assert np.allclose(sp_norm(cP0 - cP0 @ cP0), 0, 1e-12, - 1e-12) # cP0 is a projection - assert np.allclose(sp_norm(cP1 - cP1 @ cP1), 0, 1e-12, - 1e-12) # cP1 is a projection - assert np.allclose(sp_norm(cP2 - cP2 @ cP2), 0, 1e-12, - 1e-12) # cP2 is a projection + assert np.allclose(sp_norm(cP0 - cP0 @ cP0), 0, 1e-12, 1e-12) # cP0 is a projection + assert np.allclose(sp_norm(cP1 - cP1 @ cP1), 0, 1e-12, 1e-12) # cP1 is a projection + assert np.allclose(sp_norm(cP2 - cP2 @ cP2), 0, 1e-12, 1e-12) # cP2 is a projection # D0 maps in the conforming V1 space (where cP1 coincides with Id) assert np.allclose(sp_norm(D0 - cP1 @ D0), 0, 1e-12, 1e-12) @@ -186,14 +173,11 @@ def test_conf_projectors_2d( # comparing projections of polynomials which should be exact # tests on cP0: - g0 = get_polynomial_function( - degree=degree, hom_bc_axes=[ - hom_bc, hom_bc], domain=domain) - g0h = P_phys_h1(g0, p_geomP0, domain, mappings_list) + g0, g0_fun = get_polynomial_function(degree=degree, hom_bc_axes=[hom_bc, hom_bc], domain=domain) + g0h = geomP0(g0_fun) g0_c = g0h.coeffs.toarray() - tilde_g0_c = p_derham_h.get_dual_dofs( - space='V0', f=g0, return_format='numpy_array') + tilde_g0_c = get_dual_dofs(Vh=V0h, f=g0, domain_h = domain_h, return_format='numpy_array') g0_L2_c = M0_inv @ tilde_g0_c # (P0_geom - P0_L2) polynomial = 0 @@ -206,46 +190,27 @@ def test_conf_projectors_2d( # the following projection should be exact for polynomials of proper degree (no bc) # conf_P0* : L2 -> V0 defined by := # for all phi in V0 - g0 = get_polynomial_function(degree=degree, hom_bc_axes=[ - False, False], domain=domain) - g0h = P_phys_h1(g0, p_geomP0, domain, mappings_list) + g0, g0_fun = get_polynomial_function(degree=degree, hom_bc_axes=[False, False], domain=domain) + g0h = geomP0(g0_fun) g0_c = g0h.coeffs.toarray() - tilde_g0_c = p_derham_h.get_dual_dofs( - space='V0', f=g0, return_format='numpy_array') + tilde_g0_c = get_dual_dofs(Vh=V0h, f=g0, domain_h = domain_h, return_format='numpy_array') + g0_star_c = M0_inv @ cP0.transpose() @ tilde_g0_c # (P10_geom - P0_star) polynomial = 0 assert np.allclose(g0_c, g0_star_c, 1e-12, 1e-12) # tests on cP1: + G1_x, G1_x_fun = get_polynomial_function(degree=[degree[0] - 1,degree[1]], hom_bc_axes=[False, hom_bc], domain=domain) + G1_y, G1_y_fun = get_polynomial_function(degree=[degree[0], degree[1] - 1], hom_bc_axes=[hom_bc, False], domain=domain) + + G1 = Tuple(G1_x, G1_y) + G1_fun = [G1_x_fun, G1_y_fun] - G1 = Tuple( - get_polynomial_function( - degree=[ - degree[0] - 1, - degree[1]], - hom_bc_axes=[ - False, - hom_bc], - domain=domain), - get_polynomial_function( - degree=[ - degree[0], - degree[1] - 1], - hom_bc_axes=[ - hom_bc, - False], - domain=domain) - ) - - if V1_type == "Hcurl": - G1h = P_phys_hcurl(G1, p_geomP1, domain, mappings_list) - elif V1_type == "Hdiv": - G1h = P_phys_hdiv(G1, p_geomP1, domain, mappings_list) - + G1h = geomP1(G1_fun) G1_c = G1h.coeffs.toarray() - tilde_G1_c = p_derham_h.get_dual_dofs( - space='V1', f=G1, return_format='numpy_array') + tilde_G1_c = get_dual_dofs(Vh=V1h, f=G1, domain_h=domain_h, return_format='numpy_array') + G1_L2_c = M1_inv @ tilde_G1_c assert np.allclose(G1_c, G1_L2_c, 1e-12, 1e-12) @@ -254,48 +219,29 @@ def test_conf_projectors_2d( if full_mom_pres: # as above - G1 = Tuple( - get_polynomial_function( - degree=[ - degree[0] - 1, - degree[1]], - hom_bc_axes=[ - False, - False], - domain=domain), - get_polynomial_function( - degree=[ - degree[0], - degree[1] - 1], - hom_bc_axes=[ - False, - False], - domain=domain) - ) - - G1h = P_phys_hcurl(G1, p_geomP1, domain, mappings_list) - G1_c = G1h.coeffs.toarray() - - tilde_G1_c = p_derham_h.get_dual_dofs( - space='V1', f=G1, return_format='numpy_array') - G1_star_c = M1_inv @ cP1.transpose() @ tilde_G1_c - # (P1_geom - P1_star) polynomial = 0 - assert np.allclose(G1_c, G1_star_c, 1e-12, 1e-12) + G1_x, G1_x_fun = get_polynomial_function(degree=[degree[0] - 1,degree[1]], hom_bc_axes=[False, False], domain=domain) + G1_y, G1_y_fun = get_polynomial_function(degree=[degree[0], degree[1] - 1], hom_bc_axes=[False, False], domain=domain) + + G1 = Tuple(G1_x, G1_y) + G1_fun = [G1_x_fun, G1_y_fun] + + G1h = geomP1(G1_fun) + G1_c = G1h.coeffs.toarray() + + G1h = geomP1(G1_fun) + G1_c = G1h.coeffs.toarray() + + tilde_G1_c = get_dual_dofs(Vh=V1h, f=G1, domain_h=domain_h, return_format='numpy_array') + G1_star_c = M1_inv @ cP1.transpose() @ tilde_G1_c + # (P1_geom - P1_star) polynomial = 0 + assert np.allclose(G1_c, G1_star_c, 1e-12, 1e-12) # tests on cP2 (non trivial for reg = 1): - g2 = get_polynomial_function( - degree=[ - degree[0] - 1, - degree[1] - 1], - hom_bc_axes=[ - False, - False], - domain=domain) - g2h = P_phys_l2(g2, p_geomP2, domain, mappings_list) + g2, g2_fun = get_polynomial_function(degree=[degree[0] - 1, degree[1] - 1], hom_bc_axes=[False, False], domain=domain) + g2h = geomP2(g2_fun) g2_c = g2h.coeffs.toarray() - tilde_g2_c = p_derham_h.get_dual_dofs( - space='V2', f=g2, return_format='numpy_array') + tilde_g2_c = get_dual_dofs(Vh=V2h, f=g2, domain_h=domain_h, return_format='numpy_array') g2_L2_c = M2_inv @ tilde_g2_c # (P2_geom - P2_L2) polynomial = 0 @@ -305,7 +251,6 @@ def test_conf_projectors_2d( if full_mom_pres: # as above, here with same degree and bc as - # tilde_g2_c = p_derham_h.get_dual_dofs(space='V2', f=g2, return_format='numpy_array', nquads=nquads) g2_star_c = M2_inv @ cP2.transpose() @ tilde_g2_c # (P2_geom - P2_star) polynomial = 0 assert np.allclose(g2_c, g2_star_c, 1e-12, 1e-12) diff --git a/psydac/feec/tests/test_global_projectors.py b/psydac/feec/tests/test_global_projectors.py index 1d1f64282..c1389f17f 100644 --- a/psydac/feec/tests/test_global_projectors.py +++ b/psydac/feec/tests/test_global_projectors.py @@ -1,11 +1,13 @@ import numpy as np import pytest -from psydac.core.bsplines import make_knots -from psydac.fem.basic import FemField -from psydac.fem.splines import SplineSpace -from psydac.fem.tensor import TensorFemSpace -from psydac.feec.global_projectors import Projector_H1, Projector_L2 +from psydac.core.bsplines import make_knots +from psydac.fem.basic import FemField +from psydac.fem.splines import SplineSpace +from psydac.fem.tensor import TensorFemSpace +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorH1 +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorL2 + from psydac.ddm.cart import DomainDecomposition from sympde.topology import Square, Cube from psydac.api.discretization import discretize @@ -33,7 +35,7 @@ def test_H1_projector_1d(domain, ncells, degree, periodic, multiplicity): V0 = TensorFemSpace(domain_decomposition, N) # Projector onto H1 space (1D interpolation) - P0 = Projector_H1(V0) + P0 = GlobalGeometricProjectorH1(V0) # Function to project f = lambda xi1 : np.sin( xi1 + 0.5 ) @@ -79,7 +81,7 @@ def test_L2_projector_1d(domain, ncells, degree, periodic, nquads, multiplicity) V1 = V0.reduce_degree(axes=[0], basis='M') # Projector onto L2 space (1D histopolation) - P1 = Projector_L2(V1, nquads=[nquads]) + P1 = GlobalGeometricProjectorL2(V1, nquads=[nquads]) # Function to project f = lambda xi1 : np.sin( xi1 + 0.5 ) diff --git a/psydac/feec/tests/test_projections_parallel.py b/psydac/feec/tests/test_projections_parallel.py index 034287bd1..a4cad1cdc 100644 --- a/psydac/feec/tests/test_projections_parallel.py +++ b/psydac/feec/tests/test_projections_parallel.py @@ -9,9 +9,13 @@ from psydac.fem.splines import SplineSpace from psydac.fem.tensor import TensorFemSpace from psydac.fem.vector import VectorFemSpace -from psydac.feec.global_projectors import Projector_H1, Projector_L2, Projector_Hcurl, Projector_Hdiv from psydac.ddm.cart import DomainDecomposition +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorH1 +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorL2 +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorHcurl +from psydac.feec.global_geometric_projectors import GlobalGeometricProjectorHdiv + def run_projection_comparison(domain, ncells, degree, periodic, funcs, reduce): @@ -19,45 +23,45 @@ def run_projection_comparison(domain, ncells, degree, periodic, funcs, reduce): if len(domain) == 1: if reduce == 0: opV = lambda V0: V0 - opP = Projector_H1 + opP = GlobalGeometricProjectorH1 else: opV = lambda V0: V0.reduce_degree(axes=[0], basis='M') - opP = Projector_L2 + opP = GlobalGeometricProjectorL2 elif len(domain) == 2: if reduce == 0: opV = lambda V0: V0 - opP = Projector_H1 + opP = GlobalGeometricProjectorH1 elif reduce == 1: opV = lambda V0: VectorFemSpace(V0.reduce_degree(axes=[0], basis='M'), V0.reduce_degree(axes=[1], basis='M')) - opP = Projector_Hcurl + opP = GlobalGeometricProjectorHcurl elif reduce == 2: # (note: this would be more instructive, if the index was 1 as well...) opV = lambda V0: VectorFemSpace(V0.reduce_degree(axes=[1], basis='M'), V0.reduce_degree(axes=[0], basis='M')) - opP = Projector_Hdiv + opP = GlobalGeometricProjectorHdiv else: opV = lambda V0: V0.reduce_degree(axes=[0,1], basis='M') - opP = Projector_L2 + opP = GlobalGeometricProjectorL2 elif len(domain) == 3: if reduce == 0: opV = lambda V0: V0 - opP = Projector_H1 + opP = GlobalGeometricProjectorH1 elif reduce == 1: opV = lambda V0: VectorFemSpace(V0.reduce_degree(axes=[0], basis='M'), V0.reduce_degree(axes=[1], basis='M'), V0.reduce_degree(axes=[2], basis='M')) - opP = Projector_Hcurl + opP = GlobalGeometricProjectorHcurl elif reduce == 2: opV = lambda V0: VectorFemSpace(V0.reduce_degree(axes=[1,2], basis='M'), V0.reduce_degree(axes=[0,2], basis='M'), V0.reduce_degree(axes=[0,1], basis='M')) - opP = Projector_Hdiv + opP = GlobalGeometricProjectorHdiv else: opV = lambda V0: V0.reduce_degree(axes=[0,1,2], basis='M') - opP = Projector_L2 + opP = GlobalGeometricProjectorL2 # Choose number of quadrature points nquads = None if reduce == 0 else [d + 1 for d in degree] diff --git a/psydac/fem/basic.py b/psydac/fem/basic.py index 09a1466fb..090243205 100644 --- a/psydac/fem/basic.py +++ b/psydac/fem/basic.py @@ -7,9 +7,9 @@ """ from abc import ABCMeta, abstractmethod -from psydac.linalg.basic import Vector +from psydac.linalg.basic import Vector, LinearOperator -__all__ = ('FemSpace', 'FemField') +__all__ = ('FemSpace', 'FemField', 'FemLinearOperator') #=============================================================================== # ABSTRACT BASE CLASS: FINITE ELEMENT SPACE @@ -380,3 +380,86 @@ def __isub__(self, other): assert self._space is other._space self._coeffs -= other._coeffs return self + +#=============================================================================== +# CONCRETE CLASS: Linear Operator acting on a FEM field +#=============================================================================== +class FemLinearOperator: + """ + Linear operators with an additional FEM layer. + There is also a shorthand access to sparse matrices as they are sometimes + used in the FEEC interfaces. + Parameters + ---------- + fem_domain : psydac.fem.basic.FemSpace + The discrete space of the domain + + fem_codomain : psydac.fem.basic.FemSpace + The discrete space of the codomain + + linop : + Linear Operator. + + """ + + def __init__(self, fem_domain, fem_codomain, *, linop=None): + assert isinstance(fem_domain, FemSpace) + assert isinstance(fem_codomain, FemSpace) + if linop is not None: + assert isinstance(linop, LinearOperator) + + self._fem_domain = fem_domain + self._fem_codomain = fem_codomain + + self._linop_domain = fem_domain.coeff_space + self._linop_codomain = fem_codomain.coeff_space + + self._linop = linop + + @property + def fem_domain(self): + return self._fem_domain + + @property + def fem_codomain(self): + return self._fem_codomain + + @property + def linop_domain(self): + return self._linop_domain + + @property + def linop_codomain(self): + return self._linop_codomain + + @property + def linop(self): + return self._linop + + def toarray(self): + return self._linop.toarray() + + def tosparse(self): + return self._linop.tosparse() + + #-------------------------------------------------------------------------- + def __call__(self, u, *, out=None): + assert isinstance(u, FemField) + assert u.space == self.fem_domain + + if self._linop is not None: + coeffs = self._linop.dot(u.coeffs) + else: + raise NotImplementedError('Class does not provide a __call__ method without a linear operator') + + return FemField(self.fem_codomain, coeffs=coeffs) + + def dot(self, f_coeffs, *, out=None): + assert isinstance(f_coeffs, Vector) + assert f_coeffs.space is self._linop_domain + + if self._linop is not None: + f = FemField(self.fem_domain, coeffs=f_coeffs) + return self(f).coeffs + else: + raise NotImplementedError('Class does not provide a dot method without a linear operator') diff --git a/psydac/fem/projectors.py b/psydac/fem/projectors.py index d40db6a8c..e13185051 100644 --- a/psydac/fem/projectors.py +++ b/psydac/fem/projectors.py @@ -1,8 +1,17 @@ import numpy as np -from psydac.linalg.kron import KroneckerDenseMatrix -from psydac.core.bsplines import hrefinement_matrix -from psydac.linalg.stencil import StencilVectorSpace +from sympde.topology import element_of +from sympde.topology.space import ScalarFunction +from sympde.topology.mapping import Mapping +from sympde.calculus import dot +from sympde.expr.expr import LinearForm, integral + +from psydac.api.settings import PSYDAC_BACKENDS + +from psydac.linalg.kron import KroneckerDenseMatrix +from psydac.core.bsplines import hrefinement_matrix +from psydac.linalg.stencil import StencilVectorSpace +from psydac.fem.basic import FemSpace __all__ = ('knots_to_insert', 'knot_insertion_projection_operator') @@ -100,3 +109,52 @@ def knot_insertion_projection_operator(domain, codomain): ops.append(np.eye(d.nbasis)) return KroneckerDenseMatrix(domain.coeff_space, codomain.coeff_space, *ops) + + +def get_dual_dofs(Vh, f, domain_h, backend_language="python", return_format='stencil_array'): + """ + return the dual dofs tilde_sigma_i(f) = < Lambda_i, f >_{L2} i = 1, .. dim(Vh)) of a given function f, as a stencil array or numpy array + + Parameters + ---------- + Vh : FemSpace + The discrete space for the dual dofs + + f : + The function used for evaluation + + domain_h : + The discrete domain corresponding to Vh + + backend_language: + The backend used to accelerate the code + + return_format: + The format of the dofs, can be 'stencil_array' or 'numpy_array' + + Returns + ------- + tilde_f: + The dual dofs + """ + + from psydac.api.discretization import discretize + + assert isinstance(Vh, FemSpace) + + V = Vh.symbolic_space + v = element_of(V, name='v') + + if Vh.is_vector_valued: + expr = dot(f,v) + else: + expr = f*v + + l = LinearForm(v, integral( V.domain, expr)) + lh = discretize(l, domain_h, Vh, backend=PSYDAC_BACKENDS[backend_language]) + tilde_f = lh.assemble() + + if return_format == 'numpy_array': + return tilde_f.toarray() + else: + return tilde_f diff --git a/psydac/linalg/basic.py b/psydac/linalg/basic.py index a6a02f68a..ba8ca3663 100644 --- a/psydac/linalg/basic.py +++ b/psydac/linalg/basic.py @@ -720,11 +720,10 @@ def set_scalar(self, c): self._scalar = c def toarray(self): - return self._scalar*self._operator.toarray() + return self._scalar * self._operator.toarray() def tosparse(self): - from scipy.sparse import csr_matrix - return self._scalar*csr_matrix(self._operator.toarray()) + return self._scalar * self._operator.tosparse().tocsr() def transpose(self, conjugate=False): return ScaledLinearOperator(domain=self.codomain, codomain=self.domain, c=self._scalar if not conjugate else np.conjugate(self._scalar), A=self._operator.transpose(conjugate=conjugate)) @@ -1326,7 +1325,7 @@ def dot(self, v, out=None, **kwargs): self._dot(v, out=out, **kwargs) else: # provided dot product does not take an out argument: we simply copy the result into out - self._dot(v).copy(out=out, **kwargs) + self._dot(v, **kwargs).copy(out=out) return out diff --git a/psydac/linalg/sparse.py b/psydac/linalg/sparse.py new file mode 100644 index 000000000..71fa29fed --- /dev/null +++ b/psydac/linalg/sparse.py @@ -0,0 +1,114 @@ +# coding: utf-8 + +from scipy.sparse import sparray, csr_array, bsr_array +from scipy.sparse import spmatrix, csr_matrix, bsr_matrix + +from psydac.linalg.basic import LinearOperator +from psydac.linalg.basic import VectorSpace, Vector, LinearOperator +from psydac.linalg.stencil import StencilVector +from psydac.linalg.block import BlockVector + +__all__ = ( + 'SparseMatrixLinearOperator', +) + +class SparseMatrixLinearOperator(LinearOperator): + """ + LinearOperator representation of a sparse matrix. + + Parameters + ---------- + domain : VectorSpace + The domain of the operator. + + codomain : VectorSpace + The codomain of the operator. + + sparse_matrix : scipy.sparse.sparray | scipy.sparse.spmatrix + The sparse SciPy matrix representing the operator. Recommended formats are + CSR and BSR. Any other format will be converted to CSR (csr_array). + """ + + def __init__(self, domain, codomain, sparse_matrix): + + assert isinstance(domain, VectorSpace) + assert isinstance(codomain, VectorSpace) + assert isinstance(sparse_matrix, (sparray, spmatrix)) + + if not isinstance(sparse_matrix, + (csr_array, csr_matrix, + bsr_array, bsr_matrix)): + sparse_matrix = sparse_matrix.tocsr() + + if domain.parallel: + raise NotImplementedError('Parallel SparseMatrixLinearOperator not supported yet.') + + self._domain = domain + self._codomain = codomain + self._matrix = sparse_matrix + + @property + def domain(self): + return self._domain + + @property + def codomain(self): + return self._codomain + + @property + def dtype(self): + return self._matrix.dtype + + def toarray(self): + return self._matrix.toarray() + + def tosparse(self): + return self._matrix + + def transpose(self, conjugate=False): + if conjugate: + return SparseMatrixLinearOperator(self.codomain, self.domain, self._matrix.getH().tocsr()) + else: + return SparseMatrixLinearOperator(self.codomain, self.domain, self._matrix.T.tocsr()) + + def dot(self, v, out=None): + assert isinstance(v, Vector) + assert v.space is self.domain + + if out is not None: + assert isinstance(out, Vector) + assert out.space is self.codomain + out *= 0 + else: + out = self.codomain.zeros() + + self._dot_recursive(v, out=out) + + return out + + def _dot_recursive(self, v, out, ind_V=0, ind_W=0): + V = v.space + W = out.space + + if isinstance(v, StencilVector): + index_global_W = tuple(slice(s, e+1) for s, e in zip(W.starts, W.ends)) + index_global_V = tuple(slice(s, e+1) for s, e in zip(V.starts, V.ends)) + + dim_W = W.dimension + dim_V = V.dimension + + out[index_global_W].flat += self._matrix[ind_W:ind_W+dim_W, ind_V:ind_V+dim_V] @ v[index_global_V].flat + + elif isinstance(v, BlockVector): + + offset_i = ind_W + for (i, Wi) in enumerate(W.spaces): + + offset_j = ind_V + for (j, Vj) in enumerate(V.spaces): + + self._dot_recursive(v[j], out[i], ind_V=offset_j, ind_W=offset_i) + + offset_j += Vj.dimension + + offset_i += Wi.dimension diff --git a/psydac/linalg/tests/test_block.py b/psydac/linalg/tests/test_block.py index ff3bf1e6c..23a53046a 100644 --- a/psydac/linalg/tests/test_block.py +++ b/psydac/linalg/tests/test_block.py @@ -10,6 +10,7 @@ from psydac.linalg.block import BlockVectorSpace, BlockVector from psydac.linalg.block import BlockLinearOperator from psydac.linalg.utilities import array_to_psydac, petsc_to_psydac +from psydac.linalg.sparse import SparseMatrixLinearOperator from psydac.api.settings import PSYDAC_BACKEND_GPYCCEL from psydac.ddm.cart import DomainDecomposition, CartDecomposition @@ -727,6 +728,87 @@ def test_block_linear_operator_serial_dot( dtype, n1, n2, p1, p2, P1, P2 ): @pytest.mark.parametrize( 'dtype', [float, complex] ) @pytest.mark.parametrize( 'n1', [8, 16] ) @pytest.mark.parametrize( 'n2', [8, 12] ) +@pytest.mark.parametrize( 'p1', [1, 3] ) +@pytest.mark.parametrize( 'p2', [1, 2] ) +@pytest.mark.parametrize( 'P1', [True, False] ) +@pytest.mark.parametrize( 'P2', [True] ) + +def test_sparse_matrix_linear_operator_serial_dot( dtype, n1, n2, p1, p2, P1, P2 ): + # set seed for reproducibility + seed(n1*n2*p1*p2) + + D = DomainDecomposition([n1,n2], periods=[P1,P2]) + + # Partition the points + npts = [n1,n2] + global_starts, global_ends = compute_global_starts_ends(D, npts) + + cart = CartDecomposition(D, npts, global_starts, global_ends, pads=[p1,p2], shifts=[1,1]) + + # Create vector spaces, stencil matrices, and stencil vectors + V = StencilVectorSpace( cart, dtype=dtype ) + M1 = StencilMatrix( V, V) + M2 = StencilMatrix( V, V ) + M3 = StencilMatrix( V, V ) + x1 = StencilVector( V ) + x2 = StencilVector( V ) + + # Fill in stencil matrices based on diagonal index + if dtype==complex: + f=lambda k1,k2: 10j*k1+k2 + else: + f=lambda k1,k2: 10*k1+k2 + + for k1 in range(-p1,p1+1): + for k2 in range(-p2,p2+1): + M1[:,:,k1,k2] = f(k1,k2) + M2[:,:,k1,k2] = f(k1,k2)+2. + M3[:,:,k1,k2] = f(k1,k2)+5. + + M1.remove_spurious_entries() + M2.remove_spurious_entries() + M3.remove_spurious_entries() + + # Fill in vector with random values, then update ghost regions + for i1 in range(n1): + for i2 in range(n2): + x1[i1,i2] = 2.0*random() - 1.0 + x2[i1,i2] = 5.0*random() - 1.0 + x1.update_ghost_regions() + x2.update_ghost_regions() + + W = BlockVectorSpace(V, V) + + # Construct a BlockLinearOperator object containing M1, M2, M, using 3 ways + # |M1 M2| + # L = | | + # |M3 0 | + + dict_blocks = {(0,0):M1, (0,1):M2, (1,0):M3} + + L = BlockLinearOperator( W, W, blocks=dict_blocks ) + Lm = SparseMatrixLinearOperator(W, W, L.tosparse().tocsr()) + + # Construct a BlockVector object containing x1 and x2 + # |x1| + # X = | | + # |x2| + + X = BlockVector(W) + X[0] = x1 + X[1] = x2 + + # Compute BlockLinearOperator product + Y = L.dot(X) + + Ym = Lm.dot(X) + + # Check data in 1D array + assert np.allclose( Ym.toarray(), Y.toarray(), rtol=1e-12, atol=1e-12 ) +#=============================================================================== +@pytest.mark.parametrize( 'dtype', [float, complex] ) +@pytest.mark.parametrize( 'n1', [8, 16] ) +@pytest.mark.parametrize( 'n2', [8, 12] ) @pytest.mark.parametrize( 'p1', [1, 2] ) @pytest.mark.parametrize( 'p2', [1, 3] ) @pytest.mark.parametrize( 'P1', [True, False] ) diff --git a/psydac/linalg/utilities.py b/psydac/linalg/utilities.py index 57a9a6b86..fb13a2e6d 100644 --- a/psydac/linalg/utilities.py +++ b/psydac/linalg/utilities.py @@ -4,14 +4,14 @@ from math import sqrt from psydac.linalg.basic import Vector -from psydac.linalg.stencil import StencilVectorSpace, StencilVector +from psydac.linalg.stencil import StencilVector, StencilVectorSpace from psydac.linalg.block import BlockVector, BlockVectorSpace from psydac.linalg.topetsc import petsc_local_to_psydac, get_npts_per_block __all__ = ( 'array_to_psydac', 'petsc_to_psydac', - '_sym_ortho' + '_sym_ortho', ) #============================================================================== From ccbd6e3b029bf8e978780c73a643b7b1186b0942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 1 Oct 2025 06:25:42 +0200 Subject: [PATCH 16/23] Allow `mpi_dims_mask` with geometry file (#526) Add the optional parameter `mpi_dims_mask` to the constructor of class `Geometry`, as well as its class methods `from_discrete_mapping` and `from_topological_domain`. Add unit tests to verify that the domain is correctly decomposed. --------- Co-authored-by: Alisa Kirkinskaia Co-authored-by: Alisa Kirkinskaia --- psydac/api/discretization.py | 2 +- psydac/cad/geometry.py | 18 +++--- psydac/cad/tests/test_geometry.py | 97 ++++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/psydac/api/discretization.py b/psydac/api/discretization.py index bedec470e..d98ba1a51 100644 --- a/psydac/api/discretization.py +++ b/psydac/api/discretization.py @@ -575,7 +575,7 @@ def discretize_domain(domain, *, filename=None, ncells=None, periodic=None, comm raise ValueError("Cannot provide both 'filename' and 'ncells'") elif filename: - return Geometry(filename=filename, comm=comm) + return Geometry(filename=filename, comm=comm, mpi_dims_mask=mpi_dims_mask) elif ncells: return Geometry.from_topological_domain(domain, ncells, periodic=periodic, comm=comm, mpi_dims_mask=mpi_dims_mask) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 066dc8231..58d3dc3ba 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -30,7 +30,7 @@ from sympde.topology.basic import Union #============================================================================== -class Geometry( object ): +class Geometry: """ Distributed discrete geometry that works for single and multiple patches. The Geometry object can be created in two ways: @@ -75,7 +75,7 @@ def __init__(self, domain=None, ncells=None, periodic=None, mappings=None, # ... read the geometry if the filename is given if filename is not None: - self.read(filename, comm=comm) + self.read(filename, comm=comm, mpi_dims_mask=mpi_dims_mask) elif domain is not None: assert isinstance(domain, Domain) @@ -122,7 +122,7 @@ def __init__(self, domain=None, ncells=None, periodic=None, mappings=None, # Option [2]: from a discrete mapping #-------------------------------------------------------------------------- @classmethod - def from_discrete_mapping(cls, mapping, comm=None, name=None): + def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=None): """Create a geometry from one discrete mapping. Parameters @@ -132,7 +132,11 @@ def from_discrete_mapping(cls, mapping, comm=None, name=None): comm : MPI.Comm MPI intra-communicator. - + + mpi_dims_mask: list of bool + True if the dimension is to be used in the domain decomposition (=default for each dimension). + If mpi_dims_mask[i]=False, the i-th dimension will not be decomposed. + name : string Optional name for the Mapping that will be created. Needed to avoid conflicts in case several mappings are created @@ -150,7 +154,7 @@ def from_discrete_mapping(cls, mapping, comm=None, name=None): ncells = {domain.name: mapping.space.domain_decomposition.ncells} periodic = {domain.name: mapping.space.domain_decomposition.periods} - return Geometry(domain=domain, ncells=ncells, periodic=periodic, mappings=mappings, comm=comm) + return Geometry(domain=domain, ncells=ncells, periodic=periodic, mappings=mappings, comm=comm, mpi_dims_mask=mpi_dims_mask) #-------------------------------------------------------------------------- @@ -223,7 +227,7 @@ def mappings(self): def __len__(self): return len(self.domain) - def read( self, filename, comm=None ): + def read(self, filename, comm=None, mpi_dims_mask=None): # ... check extension of the file basename, ext = os.path.splitext(filename) if not(ext == '.h5'): @@ -287,7 +291,7 @@ def read( self, filename, comm=None ): self._cart = None if n_patches == 1: - self._ddm = DomainDecomposition(ncells[domain.name], periodic[domain.name], comm=comm) + self._ddm = DomainDecomposition(ncells[domain.name], periodic[domain.name], comm=comm, mpi_dims_mask=mpi_dims_mask) ddms = [self._ddm] else: ncells_ = [ncells[itr.name] for itr in interiors] diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 0f0f1a93d..458fc8f50 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -17,6 +17,8 @@ from psydac.utilities.utils import refine_array_1d from psydac.ddm.cart import DomainDecomposition +from mpi4py import MPI + base_dir = os.path.dirname(os.path.realpath(__file__)) #============================================================================== def test_geometry_2d_1(): @@ -169,6 +171,99 @@ def test_geometry_2d_4(): # export it geo.export('circle.h5') +#============================================================================== +@pytest.mark.parallel +def test_geometry_with_mpi_dims_mask(): + + comm = MPI.COMM_WORLD + rank = comm.rank + size = comm.size + mpi_dims_mask = [False, True, False] # We will verify that this has an effect + ncells = [4, 2*size, 8] # Each process should have two cells along x2 + degree = [2, 2, 2] + + expected_starts = (0, 2 * rank, 0) + expected_ends = (3, 2 * rank + 1, 7) + + # create an identity mapping + mapping = discrete_mapping('identity', ncells=ncells, degree=degree) + + # create a topological domain + F = Mapping('F', dim=3) + domain = F(Cube(name='Omega')) + + # associate the mapping to the topological domain + mappings = {domain.name: mapping} + + # Define d_ncells as a dict + d_ncells = {domain.name: ncells} + + # Create a geometry from a topological domain and the dict of mappings + # Here we allow for any distribution of the domain: mpi_dims_mask is not passed + geo = Geometry(domain=domain, ncells=d_ncells, mappings=mappings, comm=comm) + geo.export('geo_mpi_dims.h5') + + # Read geometry file in parallel, but using mpi_dims_mask + geo_from_file = Geometry(filename='geo_mpi_dims.h5', comm=comm, mpi_dims_mask=mpi_dims_mask) + + # Verify that the domain is distributed as expected + assert geo_from_file.ddm.starts == expected_starts + assert geo_from_file.ddm.ends == expected_ends + + # Safely remove the file + comm.Barrier() + if rank == 0: + os.remove('geo_mpi_dims.h5') + + +# ============================================================================== +@pytest.mark.parallel +def test_from_discrete_mapping(): + + comm = MPI.COMM_WORLD + rank = comm.rank + size = comm.size + mpi_dims_mask = [False, False, True] # We swill verify that this has an effect + ncells = [4, 8, 2 * size] # Each process should have two cells along x3 + degree = [3, 3, 3] + + expected_starts = (0, 0, 2 * rank) + expected_ends = (3, 7, 2 * rank + 1) + + # Create a mapping + mapping = discrete_mapping('identity', ncells=ncells, degree=degree) + + # Create geometry from the mapping using mpi_dims_mask + geo_from_mapping = Geometry.from_discrete_mapping(mapping, comm=comm, mpi_dims_mask=mpi_dims_mask) + + # Verify that the domain is distributed as expected + assert geo_from_mapping.ddm.starts == expected_starts + assert geo_from_mapping.ddm.ends == expected_ends + +# ============================================================================== +@pytest.mark.parallel +def test_from_topological_domain(): + + comm = MPI.COMM_WORLD + rank = comm.rank + size = comm.size + mpi_dims_mask = [False, True, False] # We will verify that this has an effect + ncells = [4, 2 * size, 8] # Each process should have two cells along x2 + + expected_starts = (0, 2 * rank, 0) + expected_ends = (3, 2 * rank + 1, 7) + + # Create a topological domain + F = Mapping('F', dim=3) + domain = F(Cube(name='Omega')) + + # Create geometry from topological domain using mpi_dims_mask + geo_from_domain = Geometry.from_topological_domain(domain, ncells, comm=comm, mpi_dims_mask=mpi_dims_mask) + + # Verify that the domain is distributed as expected + assert geo_from_domain.ddm.starts == expected_starts + assert geo_from_domain.ddm.ends == expected_ends + #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) @pytest.mark.parametrize( 'degree', [[2,2], [3,2], [2,3], [3,3], [4,4]] ) @@ -289,7 +384,7 @@ def teardown_module(): 'quart_circle_1.h5', 'circle.h5', 'pipe.h5', - 'L_shaped.h5' + 'L_shaped.h5', ] for fname in filenames: if os.path.exists(fname): From c531d986c6d15dd4d83e3bb7dc4146913400a957 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 13 Oct 2025 22:43:22 +0200 Subject: [PATCH 17/23] Fix the parallel low-level 2D Poisson example (#528) Fix `examples/poisson_2d_mapping.py`: - Use renamed method `get_assembly_grids` (formerly `get_quadrature_grids`) of class `TensorFemSpace` - Add missing definition of `Vnew` variable in the case of distributed visualization - Avoid string warnings --- examples/poisson_2d_mapping.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/poisson_2d_mapping.py b/examples/poisson_2d_mapping.py index 602b9f34c..74fb620d3 100644 --- a/examples/poisson_2d_mapping.py +++ b/examples/poisson_2d_mapping.py @@ -57,7 +57,7 @@ def __call__(self, phi): #============================================================================== class Poisson2D: - """ + r""" Exact solution to the 2D Poisson equation with Dirichlet boundary conditions, to be employed for the method of manufactured solutions. @@ -77,7 +77,7 @@ def __init__(self, domain, periodic, mapping, phi, rho, O_point=False): # ... @staticmethod def new_square(mx=1, my=1): - """ + r""" Solve Poisson's equation on the unit square. : code @@ -103,7 +103,7 @@ def new_square(mx=1, my=1): # ... @staticmethod def new_annulus(rmin=0.5, rmax=1.0): - """ + r""" Solve Poisson's equation on an annulus centered at (x,y)=(0,0), with logical coordinates (r,theta): @@ -143,7 +143,7 @@ def new_annulus(rmin=0.5, rmax=1.0): # ... @staticmethod def new_circle(): - """ + r""" Solve Poisson's equation on a unit circle centered at (x,y)=(0,0), with logical coordinates (r,theta): @@ -401,7 +401,7 @@ def assemble_matrices(V, mapping, kernel, *, nquads): [p1, p2] = V.coeff_space.pads # Quadrature data - quad_grids = V.get_quadrature_grids(*nquads) + quad_grids = V.get_assembly_grids(*nquads) [ nk1, nk2] = [g.num_elements for g in quad_grids] [ nq1, nq2] = [g.num_quad_pts for g in quad_grids] [ spans_1, spans_2] = [g.spans for g in quad_grids] @@ -483,7 +483,7 @@ def assemble_rhs(V, mapping, f, *, nquads): [p1, p2] = V.coeff_space.pads # Quadrature data - quad_grids = V.get_quadrature_grids(*nquads) + quad_grids = V.get_assembly_grids(*nquads) [ nk1, nk2] = [g.num_elements for g in quad_grids] [ nq1, nq2] = [g.num_quad_pts for g in quad_grids] [ spans_1, spans_2] = [g.spans for g in quad_grids] @@ -803,6 +803,9 @@ def main(*, test_case, ncells, degree, nquads, # Import solution vector into new serial field phi, = Vnew.import_fields( 'fields.h5', 'phi' ) + else: + Vnew = V + # Compute numerical solution (and error) on refined logical grid [sk1, sk2], [ek1, ek2] = Vnew.local_domain From c97f2652eb2fb77bb57ef08849a49e5674bdbb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 21 Oct 2025 13:41:19 +0200 Subject: [PATCH 18/23] Fix method `plot_2d_decomposition` of `TensorFemSpace` (#529) Fix bug in method `plot_2d_decomposition` of `TensorFemSpace`, which was failing when run in parallel with a distributed spline mapping. - Create a new test file `psydac/fem/tests/test_tensor.py` with a unit test which fails on the `devel` branch. This compares the generated PNG images with "reference" ones which are known to be correct, within a 2 % relative tolerance on each of the RGB channels. - Only evaluate mapping in local subdomain owned by process - Gather global mapping information on root process - Add optional parameters `fig`, `ax`, and `mpi_root` - Add docstring - Update `examples/poisson_2d_mapping.py` to pass the correct mapping (i.e. also a distributed spline mapping if that is used in the computations) to `plot_2d_decomposition`. --- .github/workflows/testing.yml | 2 +- examples/poisson_2d_mapping.py | 3 +- psydac/fem/tensor.py | 125 ++++++++-- .../tests/data/decomp_analytical_1_procs.png | Bin 0 -> 56846 bytes .../tests/data/decomp_analytical_4_procs.png | Bin 0 -> 59285 bytes .../fem/tests/data/decomp_spline_1_procs.png | Bin 0 -> 56851 bytes .../fem/tests/data/decomp_spline_4_procs.png | Bin 0 -> 59379 bytes psydac/fem/tests/test_tensor.py | 220 ++++++++++++++++++ pyproject.toml | 3 +- 9 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 psydac/fem/tests/data/decomp_analytical_1_procs.png create mode 100644 psydac/fem/tests/data/decomp_analytical_4_procs.png create mode 100644 psydac/fem/tests/data/decomp_spline_1_procs.png create mode 100644 psydac/fem/tests/data/decomp_spline_4_procs.png create mode 100644 psydac/fem/tests/test_tensor.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index d8ff30b1c..c3c2da8a4 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -195,7 +195,7 @@ jobs: - name: Run coverage tests on macOS if: matrix.os == 'macos-14' working-directory: ./pytest - run: >- + run: >- python -m pytest -n auto --cov psydac --cov-config $GITHUB_WORKSPACE/pyproject.toml diff --git a/examples/poisson_2d_mapping.py b/examples/poisson_2d_mapping.py index 74fb620d3..79e84b843 100644 --- a/examples/poisson_2d_mapping.py +++ b/examples/poisson_2d_mapping.py @@ -781,7 +781,8 @@ def main(*, test_case, ncells, degree, nquads, ########## # Plot domain decomposition (master only) - V.plot_2d_decomposition(model.mapping, refine=N) + fig = V.plot_2d_decomposition(mapping, refine=N) + fig.show() # Perform other visualization using master or all processes if not distribute_viz: diff --git a/psydac/fem/tensor.py b/psydac/fem/tensor.py index 644f08cc4..9bc6678e4 100644 --- a/psydac/fem/tensor.py +++ b/psydac/fem/tensor.py @@ -1184,24 +1184,77 @@ def set_refined_space(self, ncells, new_space): self._refined_space[tuple(ncells)] = new_space # ... - def plot_2d_decomposition(self, mapping=None, refine=10): + def plot_2d_decomposition(self, mapping=None, *, refine=10, fig=None, ax=None, mpi_root=0): + """ + Plot decomposition of 2D TensorFemSpace w/ mapping to 2D physical space + + Plot the domain decomposition across MPI processes of a 2D + TensorFemSpace with a mapping between 2D logical and 2D physical spaces. + This function must be called collectively, and only the root process will make + the plot. On non-root processes the arguments `fig` and `ax` must be None. + + Parameters + ---------- + mapping : BasicCallableMapping + Mapping from (eta1, eta2) to (x1, x2). + + refine : int, default=10 + Cell refinement along the logical dimensions eta1 and eta2. + + fig : plt.Figure, optional + Figure where the plot should be made. Must be None on non-root processes. + ax : plt.Axes, optional + Axes where the plot should be made. Must be None on non-root processes. + + mpi_root: int, default=0 + The rank of the MPI root process which should create the plot. + + Returns + ------- + plt.Figure + Figure where the plot was made. Coincides with `fig` if provided. + """ import matplotlib.pyplot as plt from matplotlib.patches import Polygon, Patch + from sympde.topology.mapping import BasicCallableMapping from psydac.utilities.utils import refine_array_1d + # Sanity check + assert self.ldim == 2, "Function only works in 2D" + + # Check mapping if mapping is None: mapping = lambda eta: eta else: - assert mapping.ldim == self.ldim == 2 - assert mapping.pdim == self.ldim == 2 + assert isinstance(mapping, BasicCallableMapping) + assert mapping.ldim == 2, "Domain of argument `mapping` must be 2D" + assert mapping.pdim == 2, "Codomain of argument `mapping` must be 2D" - assert refine >= 1 - N = int(refine) - V1, V2 = self.spaces + # Check refine argument + assert isinstance(refine, int), f"Argument `refine` must be int, got {type(refine)} instead" + assert refine >= 1, f"Argument `refine` must be >= 1, got {refine} instead" + # Extract information about MPI communicator mpi_comm = self.coeff_space.cart.comm mpi_rank = mpi_comm.rank + mpi_size = mpi_comm.size + + # Check mpi_root argument + assert isinstance(mpi_root, int), f"Argument `mpi_root` must be int, got {type(mpi_root)} instead" + assert mpi_root >= 0, f"Argument `mpi_root` must be >= 0, got {mpi_root} instead" + assert mpi_root < mpi_size, f"Argument `mpi_root` must be smaller than communicator size ({mpi_size}), got {mpi_root} instead" + + # Check fig and ax arguments + if mpi_rank == mpi_root: + assert isinstance(fig, plt.Figure) or fig is None, f"Argument `fig` must be matplotlib Figure, got {type(fig)} instead" + assert isinstance(ax, plt.Axes) or ax is None, f"Argument `ax` must be matplotlib Axes, got {type(ax)} instead" + else: + assert fig is None, f"Argument `fig` must be None on non-root process with rank {mpi_rank}" + assert ax is None, f"Argument `ax` must be None on non-root process with rank {mpi_rank}" + + N = refine + V1, V2 = self.spaces # Local grid, refined [sk1, sk2], [ek1, ek2] = self.local_domain @@ -1218,23 +1271,62 @@ def plot_2d_decomposition(self, mapping=None, refine=10): poly = Polygon(xy, edgecolor='None') # Gather polygons on master process - polys = mpi_comm.gather(poly) + polys = mpi_comm.gather(poly, root=mpi_root) + + # Gather (s1, s2, e1, e2) on root + if mpi_rank == mpi_root: + s1_all = np.empty(mpi_size, dtype=int) + s2_all = np.empty(mpi_size, dtype=int) + e1_all = np.empty(mpi_size, dtype=int) + e2_all = np.empty(mpi_size, dtype=int) + else: + s1_all = None + s2_all = None + e1_all = None + e2_all = None + + mpi_comm.Gather(sk1 * N, s1_all, root=mpi_root) + mpi_comm.Gather(sk2 * N, s2_all, root=mpi_root) + mpi_comm.Gather((ek1 + 1) * N, e1_all, root=mpi_root) + mpi_comm.Gather((ek2 + 1) * N, e2_all, root=mpi_root) + + # Gather pcoords on root + # TODO: use Gatherv, and NumPy arrays as buffers + gathered_pcoords = mpi_comm.gather(pcoords, root=mpi_root) #------------------------------- # Non-master processes stop here - if mpi_rank != 0: + if mpi_rank != mpi_root: return #------------------------------- - # Global grid, refined - eta1 = refine_array_1d(V1.breaks, N) - eta2 = refine_array_1d(V2.breaks, N) - pcoords = np.array([[mapping(e1, e2) for e2 in eta2] for e1 in eta1]) - xx = pcoords[:, :, 0] - yy = pcoords[:, :, 1] + # Reconstruct global grid (refined) on root process + global_shape = ((V1.breaks.size - 1) * N + 1, + (V2.breaks.size - 1) * N + 1, + 2) + pcoords_global = np.empty(global_shape) + + for rank in range(mpi_comm.size): + s1 = s1_all[rank] + e1 = e1_all[rank] + s2 = s2_all[rank] + e2 = e2_all[rank] + pcoords_global[s1:e1+1, s2:e2+1, :] = gathered_pcoords[rank] + + xx = pcoords_global[:, :, 0] + yy = pcoords_global[:, :, 1] + + # If fig or ax are given, get one from the other. Otherwise create new ones + if fig and ax: + assert ax in fig.axes, "Argument `ax` must be in `fig.axes`" + elif fig: + ax = fig.gca() + elif ax: + fig = ax.figure + else: + fig, ax = plt.subplots(1, 1) # Plot decomposed domain - fig, ax = plt.subplots(1, 1) colors = itertools.cycle(plt.rcParams['axes.prop_cycle'].by_key()['color']) handles = [] for i, (poly, color) in enumerate(zip(polys, colors)): @@ -1253,7 +1345,8 @@ def plot_2d_decomposition(self, mapping=None, refine=10): ax.set_aspect('equal') ax.legend(handles=handles, bbox_to_anchor=(1.05, 1), loc=2) fig.tight_layout() - fig.show() + + return fig # ... def __str__(self): diff --git a/psydac/fem/tests/data/decomp_analytical_1_procs.png b/psydac/fem/tests/data/decomp_analytical_1_procs.png new file mode 100644 index 0000000000000000000000000000000000000000..f4066700f4b9d6fe9810766c17e86d2b867ddb97 GIT binary patch literal 56846 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
az~HoH?nR$_S1gy2GTB^g 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 literal 0 HcmV?d00001 diff --git a/psydac/fem/tests/data/decomp_analytical_4_procs.png b/psydac/fem/tests/data/decomp_analytical_4_procs.png new file mode 100644 index 0000000000000000000000000000000000000000..9eedf7529245c9e54b39b9afc043f16231e6cec6 GIT binary patch literal 59285 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
  • 99rAj9%#7P0`JrK*V!5B(ca%f6X!t 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 Zf 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 Y8NKK&eB(4-!-FqF17rRBlv6?f0DGx9Hvj+t literal 0 HcmV?d00001 diff --git a/psydac/fem/tests/test_tensor.py b/psydac/fem/tests/test_tensor.py new file mode 100644 index 000000000..cc0dc8200 --- /dev/null +++ b/psydac/fem/tests/test_tensor.py @@ -0,0 +1,220 @@ +import os +import contextlib +from pathlib import Path + +import pytest +import numpy as np +import matplotlib as mpl +import matplotlib.pyplot as plt +from PIL import Image +from mpi4py import MPI + +from sympde.topology.domain import Square +from sympde.topology.space import ScalarFunctionSpace +from sympde.topology.analytical_mapping import TargetMapping + +from psydac.mapping.discrete_gallery import discrete_mapping +from psydac.api.discretization import discretize + + +#============================================================================== +# Machinery for comparing a PNG image with a reference on the root MPI process +#============================================================================== + +def similar_images(file1, file2, tolerance=0.01): + """ + Compare two PNG images and check if they are similar within tolerance. + + Parameters + ---------- + file1 : str + Path to first PNG file. + file2 : str + Path to second PNG file. + tolerance: float + Maximum allowed average difference between pixel values (0-1). + + Returns + ------- + bool : + True if images are similar enough, False otherwise. + """ + # Load images and convert to numpy arrays + img1 = np.array(Image.open(file1)).astype(float) + img2 = np.array(Image.open(file2)).astype(float) + + # Check dimensions match + if img1.shape != img2.shape: + return False + + # Normalize pixel values to 0-1 + img1 = img1 / 255.0 + img2 = img2 / 255.0 + + # Calculate mean absolute difference + diff = np.mean(np.abs(img1 - img2)) + + return diff <= tolerance + + +@contextlib.contextmanager +def consistent_png_rendering(): + """ + Context manager for consistent Matplotlib rendering across platforms. + """ + # Store original settings + orig_backend = mpl.get_backend() + orig_settings = { + 'text.usetex': mpl.rcParams['text.usetex'], + 'font.family': mpl.rcParams['font.family'], + } + + try: + # Use Agg backend (pure python, no GUI) + mpl.use('Agg') + # Configure settings for consistent rendering + mpl.rcParams.update({ + 'text.usetex': False, + 'font.family': 'DejaVu Sans', + }) + yield # Control returns to the with block + finally: + # Restore original settings + mpl.use(orig_backend) + mpl.rcParams.update(orig_settings) + + +def compare_figure_to_reference(fig, filename, *, dpi, tol, folder, comm, root): + """ + Compare a matplotlib figure to a reference PNG file on the root MPI process. + + Parameters + ---------- + fig : matplotlib.figure.Figure + The figure to compare with the reference image. + filename : str + Name of the PNG file to save and compare. + dpi : int + Dots per inch resolution for saving the figure. + tol : float + Tolerance for image comparison (between 0 and 1). + folder : str + Name of the folder containing reference images. + comm : mpi4py.MPI.Comm + MPI communicator object. + root : int + Rank of the MPI process that should perform the comparison. + + Returns + ------- + bool + True if the images are similar enough within the tolerance, + False otherwise. The result is broadcast to all MPI processes. + + Notes + ----- + The function saves the figure to a temporary file, compares it with + the reference image, and then removes the temporary file. Only the + root process performs the actual comparison, but the result is + broadcast to all processes. + """ + if comm.rank == root: + test_dir = Path(__file__).parent.absolute() + file1 = test_dir / filename + file2 = test_dir / folder / filename + with consistent_png_rendering(): + fig.savefig(file1, dpi=dpi) + close_enough = similar_images(file1, file2, tol) + # Clean up the temporary file + os.remove(file1) + else: + close_enough = None + + # Broadcast the boolean result from the root process to all others + close_enough = comm.bcast(close_enough, root=root) + + # All MPI processes return the same result + return close_enough + +#============================================================================== +# Unit tests +#============================================================================== +@pytest.mark.parallel +@pytest.mark.parametrize('root', ['first', 'last']) +@pytest.mark.parametrize('kind', ['spline', 'analytical']) +def test_plot_2d_decomposition(kind, root): + + # MPI communicator + mpi_comm = MPI.COMM_WORLD + mpi_size = mpi_comm.size + mpi_rank = mpi_comm.rank + + # MPI rank which should make the plot + if root == 'first': + mpi_root = 0 + elif root == 'last': + mpi_root = mpi_size - 1 + else: + raise ValueError(f'root argument has wrong value {root}') + + # Parameters of tensor-product 2D spline space + ncells = (6, 9) + degree = (2, 2) + + if kind == 'spline': + # 2D spline mapping and tensor FEM space (distributed) + F, Vh = discrete_mapping('target', ncells=ncells, degree=degree, + comm=mpi_comm, return_space=True) + elif kind == 'analytical': + Omega = Square('Omega', bounds1=(0, 1), bounds2=(0, 2 * np.pi)) + params = dict(c1=0, c2=0, k=0.3, D=0.2) + M = TargetMapping('M', dim=2, **params) + domain = M(Omega) + V = ScalarFunctionSpace('V', domain) + + # 2D Geometry object + domain_h = discretize(domain, ncells=ncells, periodic=(False, True), + comm=mpi_comm) + + # 2D spline tensor FEM space (distributed) + Vh = discretize(V, domain_h, degree=degree) + + # 2D callable mapping (analytical) + F = M.get_callable_mapping() + else: + raise ValueError(f'kind argument has wrong value {kind}') + + # Name of temporary image file to be compared with reference one + filename = f'decomp_{kind}_{mpi_size}_procs.png' + + # Relative tolerance for image comparison + RTOL = 0.02 + + # Plot 2D decomposition + # [1] Run without passing (fig, ax) + fig = Vh.plot_2d_decomposition(F, refine=5, mpi_root=mpi_root) + assert compare_figure_to_reference(fig, filename, folder='data', dpi=100, + tol=RTOL, comm=mpi_comm, root=mpi_root) + + # [2] Run with given (fig, ax), compatible + fig2, ax2 = plt.subplots(1, 1) if mpi_rank == mpi_root else (None, None) + Vh.plot_2d_decomposition(F, refine=5, fig=fig2, ax=ax2, mpi_root=mpi_root) + assert compare_figure_to_reference(fig2, filename, folder='data', dpi=100, + tol=RTOL, comm=mpi_comm, root=mpi_root) + + # [3] Run with given (fig, ax), incompatible + if mpi_rank == mpi_root: + fig3, ax3 = plt.subplots(1, 1) + with pytest.raises(AssertionError) as excinfo: + Vh.plot_2d_decomposition(F, refine=5, fig=fig2, ax=ax3, mpi_root=mpi_root) + assert "Argument `ax` must be in `fig.axes`" in str(excinfo.value) + plt.close(fig3) + else: + Vh.plot_2d_decomposition(F, refine=5, fig=None, ax=None, mpi_root=mpi_root) + +#============================================================================== +if __name__ == '__main__': + + test_plot_2d_decomposition('spline', 'first') + test_plot_2d_decomposition('analytical', 'last') + plt.show() diff --git a/pyproject.toml b/pyproject.toml index c20cf49d3..a8df91d59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ test = [ "pytest-cov >= 5.0.0", 'pytest >= 4.5', 'pytest-xdist >= 1.16', + 'Pillow', # Python Imaging Library (PIL) fork ] [project.urls] @@ -76,7 +77,7 @@ exclude = ["*__psydac__*"] namespaces = false [tool.setuptools.package-data] -"*" = ["*.txt"] +"*" = ["*.txt", "**/tests/data/*.png"] [tool.coverage.run] branch = true From b6d4d5d3cb816b86252c327dcd4970451a717d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 30 Oct 2025 16:53:41 +0100 Subject: [PATCH 19/23] Describe library name in README.md (#535) Add a NOTE block with the meaning of the PSYDAC acronym, as well as its pronounciation. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index d218c17cb..819214681 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ PSYDAC automatically generates Python code for the assembly of user-defined func This Python code is then accelerated to C/Fortran speed using [Pyccel](https://github.com/pyccel/pyccel). The library also enables large parallel computations on distributed-memory supercomputers using [MPI](https://en.wikipedia.org/wiki/Message_Passing_Interface) and [OpenMP](https://en.wikipedia.org/wiki/OpenMP). +> [!NOTE] +> The name PSYDAC stands for "Python Spline librarY for Differential equations with Automatic Code generation". +> It is pronounced like the famous Pokémon character, from which the developers draw inspiration for its psychic powers. + ## Citing If PSYDAC has been significant in your research, and you would like to acknowledge the project in your academic publication, we would ask that you cite the following paper: From c7ca428704c8f99a86882b6738ea146d9c7a394b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 7 Nov 2025 00:22:18 +0100 Subject: [PATCH 20/23] Fix parallelization bug in polar splines (#539) --- examples/poisson_2d_mapping.py | 5 +++-- psydac/polar/dense.py | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/poisson_2d_mapping.py b/examples/poisson_2d_mapping.py index 79e84b843..cd69c2b47 100644 --- a/examples/poisson_2d_mapping.py +++ b/examples/poisson_2d_mapping.py @@ -780,9 +780,10 @@ def main(*, test_case, ncells, degree, nquads, N = 10 ########## - # Plot domain decomposition (master only) + # Plot domain decomposition (collective call, master creates a figure) fig = V.plot_2d_decomposition(mapping, refine=N) - fig.show() + if fig: + fig.show() # Perform other visualization using master or all processes if not distribute_viz: diff --git a/psydac/polar/dense.py b/psydac/polar/dense.py index 302e0a529..da26bedc0 100644 --- a/psydac/polar/dense.py +++ b/psydac/polar/dense.py @@ -3,6 +3,7 @@ # Copyright 2018 Yaman Güçlü import numpy as np +from mpi4py import MPI from scipy.sparse import coo_matrix from psydac.linalg.basic import VectorSpace, Vector, LinearOperator @@ -177,12 +178,15 @@ def inner(self, x, y): assert x.space is self assert y.space is self + # 1. Local dot product res = np.dot(x._data, y._data) V = self if V.parallel: - if V.radial_comm.rank == V.radial_root: + # 2. MPI_ALLREDUCE operation on the M-2 dimensional tensor subcomm. + if (V.tensor_comm != MPI.COMM_NULL) and (V.radial_comm.rank == V.radial_root): res = V.tensor_comm.allreduce(res) + # 3. MPI_BCAST operation on the 1D radial subcommunicator res = V.radial_comm.bcast(res, root=V.radial_root) return res From 153055f6c21bf5382ea6230d698a99037cc750e0 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 7 Nov 2025 07:59:54 +0100 Subject: [PATCH 21/23] change version to 2.6.0.dev0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5b460fb16..080d99941 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "psydac" -version = "2.5.0.dev0" +version = "2.6.0.dev0" description = "Python package for isogeometric analysis (IGA)" readme = "README.md" requires-python = ">= 3.10" From a4ca280d0288c6cfef844e24171dbdc486d914bc Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 7 Nov 2025 08:44:33 +0100 Subject: [PATCH 22/23] remove test_tensor.py --- psydac/fem/tests/test_tensor.py | 220 -------------------------------- 1 file changed, 220 deletions(-) delete mode 100644 psydac/fem/tests/test_tensor.py diff --git a/psydac/fem/tests/test_tensor.py b/psydac/fem/tests/test_tensor.py deleted file mode 100644 index cc0dc8200..000000000 --- a/psydac/fem/tests/test_tensor.py +++ /dev/null @@ -1,220 +0,0 @@ -import os -import contextlib -from pathlib import Path - -import pytest -import numpy as np -import matplotlib as mpl -import matplotlib.pyplot as plt -from PIL import Image -from mpi4py import MPI - -from sympde.topology.domain import Square -from sympde.topology.space import ScalarFunctionSpace -from sympde.topology.analytical_mapping import TargetMapping - -from psydac.mapping.discrete_gallery import discrete_mapping -from psydac.api.discretization import discretize - - -#============================================================================== -# Machinery for comparing a PNG image with a reference on the root MPI process -#============================================================================== - -def similar_images(file1, file2, tolerance=0.01): - """ - Compare two PNG images and check if they are similar within tolerance. - - Parameters - ---------- - file1 : str - Path to first PNG file. - file2 : str - Path to second PNG file. - tolerance: float - Maximum allowed average difference between pixel values (0-1). - - Returns - ------- - bool : - True if images are similar enough, False otherwise. - """ - # Load images and convert to numpy arrays - img1 = np.array(Image.open(file1)).astype(float) - img2 = np.array(Image.open(file2)).astype(float) - - # Check dimensions match - if img1.shape != img2.shape: - return False - - # Normalize pixel values to 0-1 - img1 = img1 / 255.0 - img2 = img2 / 255.0 - - # Calculate mean absolute difference - diff = np.mean(np.abs(img1 - img2)) - - return diff <= tolerance - - -@contextlib.contextmanager -def consistent_png_rendering(): - """ - Context manager for consistent Matplotlib rendering across platforms. - """ - # Store original settings - orig_backend = mpl.get_backend() - orig_settings = { - 'text.usetex': mpl.rcParams['text.usetex'], - 'font.family': mpl.rcParams['font.family'], - } - - try: - # Use Agg backend (pure python, no GUI) - mpl.use('Agg') - # Configure settings for consistent rendering - mpl.rcParams.update({ - 'text.usetex': False, - 'font.family': 'DejaVu Sans', - }) - yield # Control returns to the with block - finally: - # Restore original settings - mpl.use(orig_backend) - mpl.rcParams.update(orig_settings) - - -def compare_figure_to_reference(fig, filename, *, dpi, tol, folder, comm, root): - """ - Compare a matplotlib figure to a reference PNG file on the root MPI process. - - Parameters - ---------- - fig : matplotlib.figure.Figure - The figure to compare with the reference image. - filename : str - Name of the PNG file to save and compare. - dpi : int - Dots per inch resolution for saving the figure. - tol : float - Tolerance for image comparison (between 0 and 1). - folder : str - Name of the folder containing reference images. - comm : mpi4py.MPI.Comm - MPI communicator object. - root : int - Rank of the MPI process that should perform the comparison. - - Returns - ------- - bool - True if the images are similar enough within the tolerance, - False otherwise. The result is broadcast to all MPI processes. - - Notes - ----- - The function saves the figure to a temporary file, compares it with - the reference image, and then removes the temporary file. Only the - root process performs the actual comparison, but the result is - broadcast to all processes. - """ - if comm.rank == root: - test_dir = Path(__file__).parent.absolute() - file1 = test_dir / filename - file2 = test_dir / folder / filename - with consistent_png_rendering(): - fig.savefig(file1, dpi=dpi) - close_enough = similar_images(file1, file2, tol) - # Clean up the temporary file - os.remove(file1) - else: - close_enough = None - - # Broadcast the boolean result from the root process to all others - close_enough = comm.bcast(close_enough, root=root) - - # All MPI processes return the same result - return close_enough - -#============================================================================== -# Unit tests -#============================================================================== -@pytest.mark.parallel -@pytest.mark.parametrize('root', ['first', 'last']) -@pytest.mark.parametrize('kind', ['spline', 'analytical']) -def test_plot_2d_decomposition(kind, root): - - # MPI communicator - mpi_comm = MPI.COMM_WORLD - mpi_size = mpi_comm.size - mpi_rank = mpi_comm.rank - - # MPI rank which should make the plot - if root == 'first': - mpi_root = 0 - elif root == 'last': - mpi_root = mpi_size - 1 - else: - raise ValueError(f'root argument has wrong value {root}') - - # Parameters of tensor-product 2D spline space - ncells = (6, 9) - degree = (2, 2) - - if kind == 'spline': - # 2D spline mapping and tensor FEM space (distributed) - F, Vh = discrete_mapping('target', ncells=ncells, degree=degree, - comm=mpi_comm, return_space=True) - elif kind == 'analytical': - Omega = Square('Omega', bounds1=(0, 1), bounds2=(0, 2 * np.pi)) - params = dict(c1=0, c2=0, k=0.3, D=0.2) - M = TargetMapping('M', dim=2, **params) - domain = M(Omega) - V = ScalarFunctionSpace('V', domain) - - # 2D Geometry object - domain_h = discretize(domain, ncells=ncells, periodic=(False, True), - comm=mpi_comm) - - # 2D spline tensor FEM space (distributed) - Vh = discretize(V, domain_h, degree=degree) - - # 2D callable mapping (analytical) - F = M.get_callable_mapping() - else: - raise ValueError(f'kind argument has wrong value {kind}') - - # Name of temporary image file to be compared with reference one - filename = f'decomp_{kind}_{mpi_size}_procs.png' - - # Relative tolerance for image comparison - RTOL = 0.02 - - # Plot 2D decomposition - # [1] Run without passing (fig, ax) - fig = Vh.plot_2d_decomposition(F, refine=5, mpi_root=mpi_root) - assert compare_figure_to_reference(fig, filename, folder='data', dpi=100, - tol=RTOL, comm=mpi_comm, root=mpi_root) - - # [2] Run with given (fig, ax), compatible - fig2, ax2 = plt.subplots(1, 1) if mpi_rank == mpi_root else (None, None) - Vh.plot_2d_decomposition(F, refine=5, fig=fig2, ax=ax2, mpi_root=mpi_root) - assert compare_figure_to_reference(fig2, filename, folder='data', dpi=100, - tol=RTOL, comm=mpi_comm, root=mpi_root) - - # [3] Run with given (fig, ax), incompatible - if mpi_rank == mpi_root: - fig3, ax3 = plt.subplots(1, 1) - with pytest.raises(AssertionError) as excinfo: - Vh.plot_2d_decomposition(F, refine=5, fig=fig2, ax=ax3, mpi_root=mpi_root) - assert "Argument `ax` must be in `fig.axes`" in str(excinfo.value) - plt.close(fig3) - else: - Vh.plot_2d_decomposition(F, refine=5, fig=None, ax=None, mpi_root=mpi_root) - -#============================================================================== -if __name__ == '__main__': - - test_plot_2d_decomposition('spline', 'first') - test_plot_2d_decomposition('analytical', 'last') - plt.show() From 750721a4343489638a530f13258672bd05931948 Mon Sep 17 00:00:00 2001 From: Stefan Possanner Date: Fri, 7 Nov 2025 16:50:03 +0100 Subject: [PATCH 23/23] install struohy from 108-psydac-change-renaming-of-globalpojector --- .github/workflows/test-struphy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-struphy.yml b/.github/workflows/test-struphy.yml index 807fd0220..3fef4e200 100644 --- a/.github/workflows/test-struphy.yml +++ b/.github/workflows/test-struphy.yml @@ -58,6 +58,7 @@ jobs: echo "Psydac location for this branch" pip show psydac pip uninstall psydac -y + git checkout 108-psydac-change-renaming-of-globalpojector python -m pip install ".[phys]" --no-cache-dir echo "Psydac location after installing struphy" pip show psydac
  • !U8f%GEQGZOAGM44)TZi?@tihv4pB-wC?bM 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 VESLAC_NN^1XMD=cun6NA{tsFe&HMlW literal 0 HcmV?d00001 diff --git a/psydac/fem/tests/data/decomp_spline_1_procs.png b/psydac/fem/tests/data/decomp_spline_1_procs.png new file mode 100644 index 0000000000000000000000000000000000000000..5e4ffe2578be9cb4282e957cd331f8af8a5cb588 GIT binary patch literal 56851 zcmb6BbySq?8$F5-qI9X0iXaA|ASIomfD!`IAs`?~cgLWDgfuE00@5MU4N?LE0@B^x zG1Ls_n)h>l-+zAVoVCtcFUyy4eCB@c`-;8ywVw%8dLctfbe#x+K#)Fv_VgtJalr?H zz?mk%gI9Qa$LHY>Ax9}qM->|rN0-<3Zx9Nv9c?Xb94*ZZZ#loQcQCWD=6T4)`H-LO z)>}tMTL&Qy4y*t3j)yk(rX2D`cU0jjgtpJL91sZd*VrGNEb&Y;1Y!dD{OMy=*M#*6 z7gyCchYdShwf%BTwSCnoju&yLR4H0MQdJvjt&OTr3?%1PY-$gd=D*pfMSTn+FvR)H zbi2<(VJoWr-|49lceHQGbMB~%g2_uWTHFp>9#hW^1d~vrOKOi4`Y9v{7=0=5NlMe0 zZ~0<>8I8W^^S{?<2@!tSSI9W8Ac){il5_@l5tP`!6KHsbV8OoI&L;w)guTEl;toy< z_RY;W(ibGKZw|l>{{MWT%U6R^IUN@NREV3-HV4Wl2ykuo9H*JL_hg&)BxwG-u}AdGwT_u{``AtYHC_03#Nhkq3-WLu{^Bo>>JD3 za90sU*c&7`Z4<{lF4U{tK1I(jE^;F=p4JsJc=X;!uf@>&Dj!L%_4TS)Mz+m0zWsbI zx3YeS7G&402(R9%i~qz?7n7czE;Ll2Q%y+rgSSZD0Ajqht1)KYflveb3TdxA2zn{O^dnOiZ)30s^xE zca9FnY}UG*rfT0rnA(h2DEBI3V#|l*XU0S z*RUlY9tmv&|CFc766QC`bYrwM!D&#dLigcI6_rZ3a&|#MfrHg8is>Ye&2NgyVsL%; z!}a!!@+rNoJ@!{spIKEh!oPm~*kzKYQK+YD-+s@jARFxvC+NUbK5kEpsC7T+D`&Kv zsG@r7NLj|vjwDaj4 z{%~22R}@Y5z`0={2U4S|r{5DJRxh$ndG3*_WXLm$izmnOnA-O%@E6(6NVbMC6rRt$ z8rxs#C-jpXN1mgDukn0DV)l|nPL|W;lSE=4zN*@8z$O1%ZliBqcWhF3j`2X(?e5wq zAu(=DFA{#^l8ZE8!otaWZU&s;a8(lo!fwP|I{@x0AyG7Rv82Ma)st zK&7GkSY4g^9AfF3xVH#4{)C;^vm9QrP|nUrDK3hiACogDI$jAA#|ZRiD9lC3(7!&} zUvVm^J>9IXgxleal|P$fIG^6E-pbG^xBe5)uUK!g_$OA^p^JBZet!G!6LPzgl}y;Q z9^3VzwprBaTCm2;m%jp;lYYl=8683Pg|ryXtQYqzCb~>w|1??smyNyoiV5p(fw^Ej zltU8s7Bn;*;1;-?R#h>lQ|G&x;^^#d-so4(*IXvunD0mjPh*$%%a?H6Wy&kT>u}_R z_xSjj&SSj@&hAS+dc#Hpb2OI#Z$8`aR}AqEQxx8K)GfF~hoE+io& zBgVlIW;fT$<#A|PP*RE+J~>S6Bp}|VE_TeiIwaZq zByg`=xUlyaTQZn@mz6kb_apJ%%6(-s+mCB*erk2JJ##YD^pUimo142NHT5>SR3C{} zQ&Cf^-u^{=Ftk~<_K@^1WTonP4x3O0JvWD>>6`NwTP;Qc$+(@<4?i%m?( z#tjasIUzI zD)RCW$u(}@_9*7P78?D3)#pDuANC_{IB98l%_-wD>{h-AAdop2LR6R&86uJ6yr#ln*RyZ2yoqz4m(%C-w)>2jn5ypz7vn45zRVQFP~> zc#1)P`6x7`>wUaXpl157%xz|AeDT;u>Z*x*oCY5JYL~36=kV`%BR-+$krNXfp9LTQqNGe|s`!pSjF> zqGNR+cTHE^s5MwfydK*_-X5La@_nlh{kw=ED=+UZBO{KF)mRzMjDe9pEAqIVlJM&%HMAiNdT89^}#>N_-m)0#^0#Ysg`}Y}J0mI#O*s|owT93UMe}+Lq38*6lF~L(YzfH$~y#7%4nRps?I`Z=k z@e_Zt0sXc9OmbNMgEl$``KZS)lf}FU{3P@AYU5rvedIkJcUl`9XnzEuS7tf#EB$%Y z=x)*VlGjam?gy*ik+pVn(vsgOL_H4wEm0Er4a3!w%s$vYmNqlX7F!BvA`=eA{vgq7 z`ZdRk@z^q3gFYN{D#Q6VsB5656Gkt39qmBlrcY3W48LcgvT<13b#{zZQ4Ni0C z*z;tKqyEgk)|t&T(TTd+d1JP6DJhpfv~8|8x+zm3{$oxKwmsdVBpRrExO>c`5a&B$ zxmbUWwn^+c`$f#5q^{md$ecit89>C&{jN-wIu zeEE{2n3~WrUK=m;tWEL}^E>%Op)W0;XoilDt0%0JpuR#e8kyg%qDXa!AW5M}Y>w9V zZXYeRAiQ{Ceqn*jW>N&Y;uF}2BRKoDN84W8jrfAllCB9mvqkH58K~&w65OZ+pIga4i(-Mcp-LdJeaT3rlKg?0(i0Gcqy4- zucrzYhy5GuJE+;Uo7W2KyRoNWD(FvkvqOsJRdaEVn4Fmz>s2OsgPqxy8dwxIf6_vQ zhzPa@JmDS%pyjMUd{R+SiJ-61A@xhJAtLA3(|$U z8@+%=Cww&h;h^xzw{YjJT9mn{`~F`x>Q8*0N46V#J>o+TtCnx)%Y@N8ZGMm7<2)4; z-gUcSF8s4FGs#_cZ6G(=q)4+wIZfJZ3VB}32QWp*%HR?EIN#zseEPp+Vn+sDhDC3I zkc|G0xKj$yR_6g!9)}HJ4_TxPV!HL#0BlAR1RYlY*MISEI)<;SVkeYpkT02tIo618NkuoiE5W2xJ@jONOYYI9PRrqx7L+* z*z>&q(FOqO-YUnH)a)GXCDUS?8?*}jB~Y-MTrt+^$mZvcP$5b^&bbrk*o z5zY4he|~xAE#oW;B~g(F%KFNcE833pUn%gp75cNXvTiXm%f!hMUuK4`c3Gy{9<4S-}-Ee8$XQB{a|{h8S|~CCc%25Y6Xe(r6^(s#%f2Y zb?8*73zzDZ{Anc_a1_x+_5a9zjmd%H28fp^vH12Ztu33;h08Nt#ryCrEtB#3EiC;F z5NAv7X@lg2dVEv#3$8j4I4v!$m;d_P|0B$^{ugi<4GUqXK}7 zFg7)H*$#ec^&iD~gcM1H-L`AH>0-OnLgTVu z@|3v!$`7nIARRb1RM5!a!%tyu*Sb?N7i!d#Bx*TX6DvdS=4+bd_OW`i>cI=1;7Qm_ z0Z0Ua)vPqlBEw678*#Bz0buFoa&{nfB*+hT4M2fAI%J)6gnkH7SEuo><{JS=3W!Dzl-g`c9pt+;&?w1KdkT(^E$y|K6I6ao|_+bdy|ov zSgB9ccCfWP-e;~%ib=Xl*8uTOI1TMUJ>LV^0{n6cZ|UdT-rLYGRhLSEBHK2R@;7hd zesX4n6E>_bb69#I1_VOvXc`w7KmH?yPZ$#^snTKPKhBbBZ-8%ZLqhMs&q_2FvD(l7 z7JB>|mU)+j((V90X3kLv8l!8io~qgcP{F@bTYcY&p&}P!4wBE$Oig-{y0B%C89~Qw z98l*tUZ^jg?95K3KQ}H$ULklQ6*`H}p7*TBeIbIA>@H zEXU|O4wpN1Z;x_Y%P)@+_264dbkU3216+TbgQKw9!p+SMM3;8VhACFh2x;c0KsDZ#XpmrjzkFUhOk0|2lu9G zgI&f70YOi?&XHn&AOf4&%g2AgC%*~QY!1%J8TUTj7AG|wXkKjko;y;BLx9GbhKr1J&UAZ= z(up()dRKth(F6U&>Pa_U#xIj$D5)%ZX42&=KNbwtNDd03A~0fM3NhP!9#i;`FecP0fR!IdT`N_h(Jn9xQb6f|JJOqWdy}& zCg?#yR&=aIAONW^@b-{vJSN+i(=C4Y38;_lpfXj0UZz#;%&sVUK)F?S`jCIp)hIWa zUFajIqX91#GLyY$L0MU7yPeJ0tRtW z_As39z`aU$hmJ7q#bYiltxEfQaO{jgr51r=r|0G6eK37+=pvmK0qEc>RvZNBWwT;r(uON?};ByL!q$Tj?=4k2Mvr3yPJQ%J#kz9=I;u0 zGnV^}Y?XmV<4mhlUM-YZbefUUh$SO3_#u)4hBXbwbr+US50F_Zy700eqhNTqfiy`QlyW!$-AHB&^=deVI?m1#uM4{hxqjabO7zzM9 zM1$RLuDgl}?;0ci6-8M{V@_S>MoN6=jUKk~u6`EUGQ$zy9N1&kKf8=c-H;IYv zcMEx5t@nVmNJ9iyBD~mVEdHWTVie^&0!E=f0JSPlR&x*PJTm@mLeyNhD1Rd4YSPPP zifSu1Bb*dnTdU2aRS@u(Z{<3o&Ee-rv)qw!EiarO(9nMa?Y#!5<-zsUpu^sK?amVT z#*#kRw(IDVlXD-7!9N0)hBD%BSP~9f_0v?OZ0aHs@iosIab(ZW>mHJtHGD!R3sD;B zG}9E*=N=v%-B#2_-wC1o_!h<6!W9tXUqDBHT77c1*K_UBTM8(K;_oC7I#XQpx`*Od z$c1rR49vZKUNk3AZT3^MOg{guGq8T}bsb~;>WH)Zb=x*ps!_1LBgi6c{!dEb}9 z35!tP(pZ?@`{PH;of8F2RBM7L((>B%yan3LFuhZa31ZWO%in zCEBx_F;4{Hb3$?!yS`8(U|bD{(s$Vf!b3jpQ6NA@a#h>{uFNQhmGd7c z;k>lWK@*SYcWK0kJ9M~VcTZ=}4ZBz6?CiL($|WR8+CEt@Zno39=4q11Z6SQt3zsFE z2Y|^#KoaF@6iSy0wF}HWWP63@ykBD2nlKQZb3nYaIr4YbL!o`dI1zOVDv*3jpQ0;p zzvG=Y2Dj6Vav&y(bS54KT(R;jL6VOj8wG$Pdm7xjW)P^Q?aUUD0B$INJpiTa4&d=rHSnRe3d;n@2>w%ie&A1vxqbK$*T#BJGsyhb z@o(ba0&DcDGFLz|5sqR~xXsUB-epA0_|z9$24mc?%n<@-=M+6mF7!T_XVd&3av77J ze~P$axF#69{)xUNCZ-)goB(K-tgNhoBS(NDA!j86`1&7(V72!y-k}t;>)kUd&xKb^ zDCaN(!gDRvb0M^9wqUtg7LvCs1)r)HtmuCDD_l@LpSp?9NsY0hAerWvytcQ~wIbRsV8j&E-U0nXV5?@2 z0Ph1AGL&Mr0Sm=a)j+_LMQu(TPk$hn;EiuQYOlVudk?S5gpbLvXD z>99N5osD#z$I5Oqel!&eUv=&hP)e2rVK0R2C+fSIG~Em|vf_AtU6atq z3|5G>i5bvkx1q23VAU-^LHq?hS0&H5u#xpP0)oRHYVn0|*CV$Wo>PjPg7mlDD)^3k z9SM_H5y;19+&-7P8Ka(yop2+d7lm%q%QO1E#la?+@>r<_4Rk>Fik>N-ox7pJ@&B@i zKA1NYdtX+wKrSz{I@79X9|g}Dyy1GRC^eqEB;owo+2qV*c%U_%D~} zd{Setw4A7G3FlYmXi#skU>g78x;JbW6unQZr51_a{)_wct{r!wG_pU?}BQ^&2rz2`J77SrFtTY>OWmwFs%L<*}E*@pYB}ieppWUXf_`xOwg24_WwY07Q$xh4czrVNCW7?Pg91<%stm@*>a{u+(viUI@XuphjrbL3#X!3#C zw{5Y5OB|z*2dexkhS(dD_19wfq+0r%D4<7WQR4@-gqoM7X|FqZ7izDwm6^r1&>BhWa(jsI(2-NycN#`(P;OW(z>vUI%xO}z$S@x~!^k2`0z zms5+Q5J~|8qf_UMV|8A_cVt4b%6D5zV0zL~G?U^n!EtWW<7e&k09)=HR9KAJmCbxN zN6zr&46>(~Ph2?j8$;D)XK7ZGH+uhdT}P8ODMjU59284FK5MD|)o5TXz_Pz+?VF-s z-`eNJ8JMQnH0p6T=*l_!(15Ud1ZvUBrxk*{<2iF8CCaq53C6wB0qA#-cFnz*Gjc89 z|Nac#0`+IR#EXB#jFV6{s!`uZPIJo7r6+aDal+jWYc)Mc0#T|s+cX>LuHENZlZR`Q zG-`JSlb+a-(1zu2(UKeE2aLs-z-dD7z7Av7aq~r|^_3ZdS-Y(v3r@ zZvGB8506%K@1q{v?9pgPasS3SD2BZ^y>^9q8;=psXF5Gv*_ySuv;fVIe%Cxaye zTCo)+YYF61GSn9p)jq}J8?A9EOxo>NIa;UX8I`L1fBM<9n0=idDfukQq4#7Y)#Vm# z!=MMHV6>=0>k6{X?Kc-~UU!9PHCM(zYlLsk1E@MLtMt&^&##&q*k)YGW6RjD))+qc z*FxFj6)K_S{U^DQwkti!^(xDK5CiFtJAgvQ{`_ju=YR6+{s3`>_p^ z<-gys97Xw6JyyvTn7N2$voNA-B;_A(fFWJ$`bhDdNPj5E#}7ege!)Wdi_4Xp@Xia% z1IQ4xv(UpoNK+NLbR{!xii`0hQ1;#0reEbV#<$}hf8G9w&TREF_`Eq+RTuYJnx z<3yxHP~KrjZ~th7hn{&Z4{5N$MN&dRB`3~^3enk)UJqOmxzhHc4bT}(Iv;|(uSG;H z`HxZR)~rw3|IwQ)_T%bdG>g?a)N<8V3KL=G`}ynFT|m=P=sdI) JaVhgk#e<)B| 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 z�U9#c{RI!9Y$S(Vl2pmW1A(&gZ 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