From 461ae4ecedea7987b01fd0ff03edacb7706f07d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 18 Sep 2025 15:27:28 +0200 Subject: [PATCH 001/129] Allow `mpi_dims_mask` with geometry file --- psydac/api/discretization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psydac/api/discretization.py b/psydac/api/discretization.py index 1cea9397e..793ebd9f7 100644 --- a/psydac/api/discretization.py +++ b/psydac/api/discretization.py @@ -535,7 +535,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) From 1e46072d3152352ca396928f0f0a68b0f8866687 Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Fri, 19 Sep 2025 08:21:23 +0200 Subject: [PATCH 002/129] unit test for mpi_dims_mask --- psydac/cad/tests/test_geometry.py | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 0f0f1a93d..10e24f0a8 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -169,6 +169,41 @@ def test_geometry_2d_4(): # export it geo.export('circle.h5') +#============================================================================== +@pytest.mark.parallel +def test_geometry_with_mpi_dims_mask(): + from mpi4py import MPI + comm = MPI.COMM_WORLD + rank = comm.Get_rank() + mpi_dims_mask = [False, True] + ncells = [4, 4] + degree = [2, 2] + # create an identity mapping + mapping = discrete_mapping('identity', ncells=ncells, degree=degree) + + # create a topological domain + F = Mapping('F', dim=2) + domain = F(Square(name='Omega')) + + # associate the mapping to the topological domain + mappings = {domain.name: mapping} + + # Define ncells as a dict + ncells = {domain.name: ncells} + + # create a geometry from a topological domain and the dict of mappings + geo = Geometry(domain=domain, ncells=ncells, mappings=mappings) + + # # export the geometry + if rank == 0: + geo.export('geo_mpi_dims.h5') + comm.Barrier() + geo_from_file = Geometry(filename='geo_mpi_dims.h5', comm=comm, mpi_dims_mask=mpi_dims_mask) + if rank == 0: + assert geo_from_file.ddm.starts == (0, 0) + elif rank == 1: + assert geo_from_file.ddm.starts == (0, 2) + #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) @pytest.mark.parametrize( 'degree', [[2,2], [3,2], [2,3], [3,3], [4,4]] ) From 3376e3115b1d5a3d76f61e0f13d78243795396b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 19 Sep 2025 09:39:26 +0200 Subject: [PATCH 003/129] Update test_geometry.py - Write 3D geometry in parallel without `mpi_dims_mask` - Read 3D geometry in parallel with `mpi_dims_mask` - Verify correct distribution of domain for any number of MPI processes --- psydac/cad/tests/test_geometry.py | 42 ++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 10e24f0a8..91116fcfc 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -173,17 +173,20 @@ def test_geometry_2d_4(): @pytest.mark.parallel def test_geometry_with_mpi_dims_mask(): from mpi4py import MPI + comm = MPI.COMM_WORLD - rank = comm.Get_rank() - mpi_dims_mask = [False, True] - ncells = [4, 4] - degree = [2, 2] + 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] + # create an identity mapping mapping = discrete_mapping('identity', ncells=ncells, degree=degree) # create a topological domain - F = Mapping('F', dim=2) - domain = F(Square(name='Omega')) + F = Mapping('F', dim=3) + domain = F(Cube(name='Omega')) # associate the mapping to the topological domain mappings = {domain.name: mapping} @@ -191,18 +194,22 @@ def test_geometry_with_mpi_dims_mask(): # Define ncells as a dict ncells = {domain.name: ncells} - # create a geometry from a topological domain and the dict of mappings - geo = Geometry(domain=domain, ncells=ncells, mappings=mappings) + # 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=ncells, mappings=mappings, comm=comm) + geo.export('geo_mpi_dims.h5') - # # export the geometry - if rank == 0: - geo.export('geo_mpi_dims.h5') - comm.Barrier() + # 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) - if rank == 0: - assert geo_from_file.ddm.starts == (0, 0) - elif rank == 1: - assert geo_from_file.ddm.starts == (0, 2) + + # Verify that the domain is distributed as expected + assert geo_from_file.ddm.starts[0] == 0 + assert geo_from_file.ddm.starts[1] == 2 * rank + assert geo_from_file.ddm.starts[2] == 0 + + assert geo_from_file.ddm.ends[0] == ncells[0] - 1 + assert geo_from_file.ddm.ends[1] == 2 * rank + 1 + assert geo_from_file.ddm.ends[2] == ncells[2] - 1 #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) @@ -319,12 +326,13 @@ def teardown_module(): 'geo.h5', 'geo_0.h5', 'geo_1.h5', + 'geo_mpi_dims.h5', 'quart_circle.h5', 'quart_circle_0.h5', 'quart_circle_1.h5', 'circle.h5', 'pipe.h5', - 'L_shaped.h5' + 'L_shaped.h5', ] for fname in filenames: if os.path.exists(fname): From 270471687cf358b47b215ad48aea7c3547c2fbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 19 Sep 2025 09:56:26 +0200 Subject: [PATCH 004/129] Update geometry.py Add parameter `mpi_dims_mask` to class method `from_discrete_mapping` and bound method `read`. --- psydac/cad/geometry.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) 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] From e1a13f8da451c8b6385928daee2e8530f3987bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 19 Sep 2025 11:36:51 +0200 Subject: [PATCH 005/129] Update test_geometry.py Use new variable for dictionary with number of cells for each patch. --- psydac/cad/tests/test_geometry.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 91116fcfc..914f4d24e 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -191,12 +191,12 @@ def test_geometry_with_mpi_dims_mask(): # associate the mapping to the topological domain mappings = {domain.name: mapping} - # Define ncells as a dict - ncells = {domain.name: ncells} + # 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=ncells, mappings=mappings, comm=comm) + 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 From 51a32da547a0ebc096ab050a8627f820df7b940a Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Wed, 24 Sep 2025 08:47:01 +0200 Subject: [PATCH 006/129] new tests for class Geometry test methods from_discrete_mapping and from_topological_domain with mpi_dims_mask --- psydac/cad/tests/test_geometry.py | 63 +++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 7 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 914f4d24e..95b3619d1 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(): @@ -172,7 +174,6 @@ def test_geometry_2d_4(): #============================================================================== @pytest.mark.parallel def test_geometry_with_mpi_dims_mask(): - from mpi4py import MPI comm = MPI.COMM_WORLD rank = comm.rank @@ -203,13 +204,61 @@ def test_geometry_with_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[0] == 0 - assert geo_from_file.ddm.starts[1] == 2 * rank - assert geo_from_file.ddm.starts[2] == 0 + check_decomposition(geo_from_file, ncells, rank, mpi_dims_mask) + +# ============================================================================== +@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] + + # 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 + check_decomposition(geo_from_mapping, ncells, rank, mpi_dims_mask) + +# ============================================================================== +@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 + + # 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 + check_decomposition(geo_from_domain, ncells, rank, mpi_dims_mask) + +# ============================================================================== +# Check that each process has 2 cells along the axis where mpi_dims_mask is True +# Only 1 dimension should be decomposed +def check_decomposition(geometry, ncells, rank, mpi_dims_mask): + + for i in range(len(ncells)): + if mpi_dims_mask[i]: + assert geometry.ddm.starts[i] == 2 * rank + assert geometry.ddm.ends[i] == 2 * rank + 1 + else: + assert geometry.ddm.starts[i] == 0 + assert geometry.ddm.ends[i] == ncells[i] - 1 - assert geo_from_file.ddm.ends[0] == ncells[0] - 1 - assert geo_from_file.ddm.ends[1] == 2 * rank + 1 - assert geo_from_file.ddm.ends[2] == ncells[2] - 1 #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) From 505db3196d9cc66f5578b2313069bf1b28547ecb Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Wed, 24 Sep 2025 14:51:54 +0200 Subject: [PATCH 007/129] run only my tests --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index d8ff30b1c..ce810cfd6 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -224,7 +224,7 @@ jobs: - name: Run MPI tests with Pytest working-directory: ./pytest run: | - python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and not petsc" + python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac/cad/tests -m "parallel and not petsc" - name: Run single-process PETSc tests with Pytest working-directory: ./pytest From 39c51a432cd370decd46f8c4011dd247f243a6ed Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Wed, 24 Sep 2025 15:02:06 +0200 Subject: [PATCH 008/129] run only my tests --- .github/workflows/testing.yml | 74 +++++++++++++++++------------------ 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index ce810cfd6..4571fa5af 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -192,49 +192,49 @@ jobs: mkdir pytest cp mpi_tester.py pytest - - name: Run coverage tests on macOS - if: matrix.os == 'macos-14' - working-directory: ./pytest - run: >- - python -m pytest -n auto - --cov psydac - --cov-config $GITHUB_WORKSPACE/pyproject.toml - --cov-report xml - --pyargs psydac -m "not parallel and not petsc" - - - name: Run single-process tests with Pytest on Ubuntu - if: matrix.os == 'ubuntu-24.04' - working-directory: ./pytest - run: | - python -m pytest -n auto --pyargs psydac -m "not parallel and not petsc" - - - name: Upload coverage report to Codacy - if: matrix.os == 'macos-14' - uses: codacy/codacy-coverage-reporter-action@v1.3.0 - with: - project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} - coverage-reports: ./pytest/coverage.xml - - - name: Print detailed coverage results on macOS - if: matrix.os == 'macos-14' - working-directory: ./pytest - run: | - coverage report --ignore-errors --show-missing --sort=cover +# - name: Run coverage tests on macOS +# if: matrix.os == 'macos-14' +# working-directory: ./pytest +# run: >- +# python -m pytest -n auto +# --cov psydac +# --cov-config $GITHUB_WORKSPACE/pyproject.toml +# --cov-report xml +# --pyargs psydac -m "not parallel and not petsc" + +# - name: Run single-process tests with Pytest on Ubuntu +# if: matrix.os == 'ubuntu-24.04' +# working-directory: ./pytest +# run: | +# python -m pytest -n auto --pyargs psydac -m "not parallel and not petsc" + +# - name: Upload coverage report to Codacy +# if: matrix.os == 'macos-14' +# uses: codacy/codacy-coverage-reporter-action@v1.3.0 +# with: +# project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} +# coverage-reports: ./pytest/coverage.xml +# +# - name: Print detailed coverage results on macOS +# if: matrix.os == 'macos-14' +# working-directory: ./pytest +# run: | +# coverage report --ignore-errors --show-missing --sort=cover - name: Run MPI tests with Pytest working-directory: ./pytest run: | python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac/cad/tests -m "parallel and not petsc" - - name: Run single-process PETSc tests with Pytest - working-directory: ./pytest - run: | - python -m pytest -n auto --pyargs psydac -m "not parallel and petsc" - - - name: Run MPI PETSc tests with Pytest - working-directory: ./pytest - run: | - python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and petsc" +# - name: Run single-process PETSc tests with Pytest +# working-directory: ./pytest +# run: | +# python -m pytest -n auto --pyargs psydac -m "not parallel and petsc" +# +# - name: Run MPI PETSc tests with Pytest +# working-directory: ./pytest +# run: | +# python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and petsc" - name: Remove test directory if: always() From 34e42e6e2951874ebdbaac68da6ddf9fa6264465 Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Wed, 24 Sep 2025 15:12:46 +0200 Subject: [PATCH 009/129] run only my tests --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 4571fa5af..7f028cff0 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -224,7 +224,7 @@ jobs: - name: Run MPI tests with Pytest working-directory: ./pytest run: | - python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac/cad/tests -m "parallel and not petsc" + python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs ./cad/tests -m "parallel and not petsc" # - name: Run single-process PETSc tests with Pytest # working-directory: ./pytest From 90553f9ddd4573b30dcb231a48f81c02b5d14d2d Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Wed, 24 Sep 2025 15:19:56 +0200 Subject: [PATCH 010/129] run only my tests --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 7f028cff0..fedbbf955 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -224,7 +224,7 @@ jobs: - name: Run MPI tests with Pytest working-directory: ./pytest run: | - python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs ./cad/tests -m "parallel and not petsc" + python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs cad/tests -m "parallel and not petsc" # - name: Run single-process PETSc tests with Pytest # working-directory: ./pytest From 243d2b1f8f5a39cbadd80f05cb4739721e2bcae0 Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Wed, 24 Sep 2025 15:39:19 +0200 Subject: [PATCH 011/129] run only my tests --- .github/workflows/testing.yml | 2 +- psydac/cad/tests/test_geometry.py | 1 + pytest.ini | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index fedbbf955..a402b744b 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -224,7 +224,7 @@ jobs: - name: Run MPI tests with Pytest working-directory: ./pytest run: | - python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs cad/tests -m "parallel and not petsc" + python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and not petsc and geo" # - name: Run single-process PETSc tests with Pytest # working-directory: ./pytest diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 95b3619d1..f8da1048c 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -173,6 +173,7 @@ def test_geometry_2d_4(): #============================================================================== @pytest.mark.parallel +@pytest.mark.geo def test_geometry_with_mpi_dims_mask(): comm = MPI.COMM_WORLD diff --git a/pytest.ini b/pytest.ini index 91ef13cfa..417753c5f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -10,6 +10,7 @@ markers = parallel: test to be run using 'mpiexec', petsc: test requiring a working PETSc installation with petsc4py Python bindings pyccel: test for checking Pyccel setup on machine + geo: geometry python_files = test_*.py python_classes = From 9bfda593920e75b7542f609031d405a86c228ab2 Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Wed, 24 Sep 2025 16:03:53 +0200 Subject: [PATCH 012/129] run only my tests --- psydac/cad/tests/test_geometry.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index f8da1048c..74856ca6e 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -207,6 +207,11 @@ def test_geometry_with_mpi_dims_mask(): # Verify that the domain is distributed as expected check_decomposition(geo_from_file, ncells, rank, mpi_dims_mask) + comm.Barrier() + if rank == 0: + os.remove('geo_mpi_dims.h5') + + # ============================================================================== @pytest.mark.parallel def test_from_discrete_mapping(): @@ -376,7 +381,6 @@ def teardown_module(): 'geo.h5', 'geo_0.h5', 'geo_1.h5', - 'geo_mpi_dims.h5', 'quart_circle.h5', 'quart_circle_0.h5', 'quart_circle_1.h5', From f75df55c67ebb5e9da00172967d1796181517dc9 Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Wed, 24 Sep 2025 16:17:03 +0200 Subject: [PATCH 013/129] fixed failing tests due to deleting file in parallel --- .github/workflows/testing.yml | 78 +++++++++++++++---------------- psydac/cad/tests/test_geometry.py | 2 +- pytest.ini | 3 +- 3 files changed, 41 insertions(+), 42 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index a402b744b..cf626f63e 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -192,51 +192,51 @@ jobs: mkdir pytest cp mpi_tester.py pytest -# - name: Run coverage tests on macOS -# if: matrix.os == 'macos-14' -# working-directory: ./pytest -# run: >- -# python -m pytest -n auto -# --cov psydac -# --cov-config $GITHUB_WORKSPACE/pyproject.toml -# --cov-report xml -# --pyargs psydac -m "not parallel and not petsc" - -# - name: Run single-process tests with Pytest on Ubuntu -# if: matrix.os == 'ubuntu-24.04' -# working-directory: ./pytest -# run: | -# python -m pytest -n auto --pyargs psydac -m "not parallel and not petsc" - -# - name: Upload coverage report to Codacy -# if: matrix.os == 'macos-14' -# uses: codacy/codacy-coverage-reporter-action@v1.3.0 -# with: -# project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} -# coverage-reports: ./pytest/coverage.xml -# -# - name: Print detailed coverage results on macOS -# if: matrix.os == 'macos-14' -# working-directory: ./pytest -# run: | -# coverage report --ignore-errors --show-missing --sort=cover + - name: Run coverage tests on macOS + if: matrix.os == 'macos-14' + working-directory: ./pytest + run: >- + python -m pytest -n auto + --cov psydac + --cov-config $GITHUB_WORKSPACE/pyproject.toml + --cov-report xml + --pyargs psydac -m "not parallel and not petsc" + + - name: Run single-process tests with Pytest on Ubuntu + if: matrix.os == 'ubuntu-24.04' + working-directory: ./pytest + run: | + python -m pytest -n auto --pyargs psydac -m "not parallel and not petsc" + + - name: Upload coverage report to Codacy + if: matrix.os == 'macos-14' + uses: codacy/codacy-coverage-reporter-action@v1.3.0 + with: + project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} + coverage-reports: ./pytest/coverage.xml + + - name: Print detailed coverage results on macOS + if: matrix.os == 'macos-14' + working-directory: ./pytest + run: | + coverage report --ignore-errors --show-missing --sort=cover - name: Run MPI tests with Pytest working-directory: ./pytest run: | - python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and not petsc and geo" + python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and not petsc" + + - name: Run single-process PETSc tests with Pytest + working-directory: ./pytest + run: | + python -m pytest -n auto --pyargs psydac -m "not parallel and petsc" -# - name: Run single-process PETSc tests with Pytest -# working-directory: ./pytest -# run: | -# python -m pytest -n auto --pyargs psydac -m "not parallel and petsc" -# -# - name: Run MPI PETSc tests with Pytest -# working-directory: ./pytest -# run: | -# python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and petsc" + - name: Run MPI PETSc tests with Pytest + working-directory: ./pytest + run: | + python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and petsc" - name: Remove test directory if: always() run: | - rm -rf pytest + rm -rf pytest \ No newline at end of file diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 74856ca6e..7fd67662a 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -173,7 +173,6 @@ def test_geometry_2d_4(): #============================================================================== @pytest.mark.parallel -@pytest.mark.geo def test_geometry_with_mpi_dims_mask(): comm = MPI.COMM_WORLD @@ -207,6 +206,7 @@ def test_geometry_with_mpi_dims_mask(): # Verify that the domain is distributed as expected check_decomposition(geo_from_file, ncells, rank, mpi_dims_mask) + # Safely remove the file comm.Barrier() if rank == 0: os.remove('geo_mpi_dims.h5') diff --git a/pytest.ini b/pytest.ini index 417753c5f..f9bac0384 100644 --- a/pytest.ini +++ b/pytest.ini @@ -10,8 +10,7 @@ markers = parallel: test to be run using 'mpiexec', petsc: test requiring a working PETSc installation with petsc4py Python bindings pyccel: test for checking Pyccel setup on machine - geo: geometry python_files = test_*.py python_classes = -python_functions = test_* +python_functions = test_* \ No newline at end of file From 2a7bb4d6f6dcbaf1ce3adbe31205522fe9e45dc2 Mon Sep 17 00:00:00 2001 From: Alisa Kirkinskaia Date: Wed, 24 Sep 2025 16:28:53 +0200 Subject: [PATCH 014/129] revert files --- .github/workflows/testing.yml | 2 +- pytest.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index cf626f63e..d8ff30b1c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -239,4 +239,4 @@ jobs: - name: Remove test directory if: always() run: | - rm -rf pytest \ No newline at end of file + rm -rf pytest diff --git a/pytest.ini b/pytest.ini index f9bac0384..91ef13cfa 100644 --- a/pytest.ini +++ b/pytest.ini @@ -13,4 +13,4 @@ markers = python_files = test_*.py python_classes = -python_functions = test_* \ No newline at end of file +python_functions = test_* From e7b6bfbdf2fb30d17a3e3493131953bc75909140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 30 Sep 2025 15:01:03 +0200 Subject: [PATCH 015/129] Avoid function check_decomposition in tests --- psydac/cad/tests/test_geometry.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 7fd67662a..09c096134 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -181,6 +181,9 @@ def test_geometry_with_mpi_dims_mask(): 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) @@ -204,7 +207,8 @@ def test_geometry_with_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 - check_decomposition(geo_from_file, ncells, rank, mpi_dims_mask) + assert geo_from_file.ddm.starts == expected_starts + assert geo_from_file.ddm.ends == expected_ends # Safely remove the file comm.Barrier() @@ -223,6 +227,9 @@ def test_from_discrete_mapping(): 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) @@ -230,7 +237,8 @@ def test_from_discrete_mapping(): geo_from_mapping = Geometry.from_discrete_mapping(mapping, comm=comm, mpi_dims_mask=mpi_dims_mask) # Verify that the domain is distributed as expected - check_decomposition(geo_from_mapping, ncells, rank, mpi_dims_mask) + assert geo_from_mapping.ddm.starts == expected_starts + assert geo_from_mapping.ddm.ends == expected_ends # ============================================================================== @pytest.mark.parallel @@ -242,6 +250,8 @@ def test_from_topological_domain(): 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')) @@ -250,21 +260,8 @@ def test_from_topological_domain(): 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 - check_decomposition(geo_from_domain, ncells, rank, mpi_dims_mask) - -# ============================================================================== -# Check that each process has 2 cells along the axis where mpi_dims_mask is True -# Only 1 dimension should be decomposed -def check_decomposition(geometry, ncells, rank, mpi_dims_mask): - - for i in range(len(ncells)): - if mpi_dims_mask[i]: - assert geometry.ddm.starts[i] == 2 * rank - assert geometry.ddm.ends[i] == 2 * rank + 1 - else: - assert geometry.ddm.starts[i] == 0 - assert geometry.ddm.ends[i] == ncells[i] - 1 - + assert geometry.ddm.starts == expected_starts + assert geometry.ddm.ends == expected_ends #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) From f6afb5451015c11da54a8b7c86d99dee7c361fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 30 Sep 2025 17:52:37 +0200 Subject: [PATCH 016/129] Fix typo in test_geometry.py --- psydac/cad/tests/test_geometry.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 09c096134..458fc8f50 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -252,6 +252,7 @@ def test_from_topological_domain(): 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')) @@ -260,8 +261,8 @@ def test_from_topological_domain(): 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 geometry.ddm.starts == expected_starts - assert geometry.ddm.ends == expected_ends + 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]] ) From 81b1c6dbaf15af2e34772481a86dbfd3ec655ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 30 Sep 2025 18:39:13 +0200 Subject: [PATCH 017/129] Improve Geometry class: - Add new constructor Geometry.from_file - Remove `filename` from __init__ parameters --- psydac/cad/geometry.py | 249 ++++++++++++++++++++++++++--------------- 1 file changed, 156 insertions(+), 93 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 58d3dc3ba..8a4c7ddac 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -4,20 +4,18 @@ # the topology i.e. connectivity, boundaries # For the moment, it is used as a container, that can be loaded from a file # (hdf5) -from itertools import product -from collections import abc +import os +from typing import Iterable +from itertools import chain + import numpy as np -import string -import random import h5py import yaml -import os -import string -import random - - from mpi4py import MPI +from sympde.topology import Domain, Interface, Line, Square, Cube, NCubeInterior, Mapping, NCube +from sympde.topology.basic import Union + from psydac.fem.splines import SplineSpace from psydac.fem.tensor import TensorFemSpace from psydac.fem.partitioning import create_cart, construct_connectivity, construct_interface_spaces @@ -25,9 +23,15 @@ from psydac.linalg.block import BlockVectorSpace, BlockVector from psydac.ddm.cart import DomainDecomposition, MultiPatchDomainDecomposition +__all__ = ( + 'Geometry', + 'export_nurbs_to_hdf5', + 'import_geopdes_to_nurbs', + 'refine_knots', + 'refine_nurbs', +) -from sympde.topology import Domain, Interface, Line, Square, Cube, NCubeInterior, Mapping, NCube -from sympde.topology.basic import Union +NoneType = type(None) #============================================================================== class Geometry: @@ -51,12 +55,9 @@ class Geometry: mappings : dict The Mapping of each patch. - filename: str - The path to the geometry file. - - comm: MPI.Comm + comm: MPI.Intracomm 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. @@ -67,63 +68,125 @@ class Geometry: _patches = [] _topology = None + def __init__(self, + domain : Domain, + *, + ncells : dict[str, Iterable[int]], + mappings : dict[str, SplineMapping | None] = None, + periodic : dict[str, Iterable[bool]] = None, + comm : MPI.Intracomm = None, + mpi_dims_mask : Iterable[bool] = None): + + # Type checks + assert isinstance(domain, Domain) + assert isinstance(ncells, dict) + assert isinstance(mappings, dict) + assert isinstance(periodic, (NoneType, dict)) + assert isinstance(comm, (NoneType, MPI.Intracomm)) + assert isinstance(mpi_dims_mask, (NoneType, Iterable)) + + # Extract info from domain + ldim : int = domain.dim + interior_names : list = domain.interior_names + set_interior_names = set(interior_names) + + # Check sanity of ncells + assert set(ncells.keys()) == set_interior_names + assert all(len(n) == ldim for n in ncells.values()) + assert all(isinstance(ni, int) for ni in chain(*ncells.values())) + assert all(ni > 0 for ni in chain(*ncells.values())) + + # Check sanity of periodic + if periodic is None: + periodic = {patch: [False]*len(ncells_i) for patch, ncells_i in ncells.items()} + else: + assert set(periodic.keys()) == set_interior_names + assert all(len(p) == ldim for p in periodic.values()) + assert all(isinstance(pi, bool) for pi in chain(*periodic.values())) + + # Check sanity of mappings + if mappings is None: + mappings = {itr.name : None for itr in domain.interior} + else: + assert set(mappings.keys()) == set_interior_names + assert all(isinstance(m, (SplineMapping, NoneType)) for m in mappings.values()) + + # Check sanity of mpi_dims_mask + if mpi_dims_mask is not None: + assert len(mpi_dims_mask) == ldim + assert all(isinstance(mask, bool) for mask in mpi_dims_mask) + + # Create a (multi-patch) domain decomposition + if len(domain) == 1: + #name = domain.name + name = interior_names[0] + ddm = DomainDecomposition( + ncells[name], + periodic[name], + comm = comm, + mpi_dims_mask = mpi_dims_mask, + ) + else: + ddm = MultiPatchDomainDecomposition( + ncells = [ ncells[itr] for itr in interior_names], + periodic = [periodic[itr] for itr in interior_names], + comm = comm, + ) + + # Add attributes to the new object + self._domain = domain + self._ldim = domain.dim + self._pdim = domain.dim # TODO must be given => only dim is defined for a Domain + self._ncells = ncells + self._periodic = periodic + self._mappings = mappings + self._comm = comm + self._ddm = ddm + self._cart = None + self._is_parallel = comm is not None + #-------------------------------------------------------------------------- - # Option [1]: from a (domain, mappings) or a file + # Option [1]: from a file #-------------------------------------------------------------------------- - def __init__(self, domain=None, ncells=None, periodic=None, mappings=None, - filename=None, comm=None, mpi_dims_mask=None): - - # ... read the geometry if the filename is given - if filename is not None: - self.read(filename, comm=comm, mpi_dims_mask=mpi_dims_mask) - - elif domain is not None: - assert isinstance(domain, Domain) - assert isinstance(ncells, dict) - assert isinstance(mappings, dict) - if periodic is not None: - assert isinstance(periodic, dict) - - # ... check sanity - interior_names = domain.interior_names - mappings_keys = sorted(list(mappings.keys())) - - assert sorted(interior_names) == mappings_keys - # ... - - if periodic is None: - periodic = {patch: [False]*len(ncells_i) for patch, ncells_i in ncells.items()} - - self._domain = domain - self._ldim = domain.dim - self._pdim = domain.dim # TODO must be given => only dim is defined for a Domain - self._ncells = ncells - self._periodic = periodic - self._mappings = mappings - self._cart = None - self._is_parallel = comm is not None - - if len(domain) == 1: - #name = domain.name - name = interior_names[0] - self._ddm = DomainDecomposition(ncells[name], periodic[name], comm=comm, mpi_dims_mask=mpi_dims_mask) - else: - ncells = [ncells[itr] for itr in interior_names] - periodic = [periodic[itr] for itr in interior_names] - self._ddm = MultiPatchDomainDecomposition(ncells, periodic, comm=comm) + @classmethod + def from_file(cls, + filename : str, + *, + comm : MPI.Intracomm = None, + mpi_dims_mask : Iterable[bool] = None): - else: - raise ValueError('Wrong input') - # ... + """ + Create a Geometry instance from an HDF5 input file in Psydac format. + + Parameters + ---------- + filename: str + The path to the geometry file. - self._comm = comm + comm: MPI.Intracomm, optional + The MPI intra-communicator. + + mpi_dims_mask: list[bool], optional + 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. + + Returns + ------- + Geometry + The new instance. + """ + geo = super().__new__(cls) + geo.read(filename, comm=comm, mpi_dims_mask=mpi_dims_mask) + return geo #-------------------------------------------------------------------------- # Option [2]: from a discrete mapping #-------------------------------------------------------------------------- @classmethod def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=None): - """Create a geometry from one discrete mapping. + """ + Create a single-patch Geometry instance from one discrete mapping. Parameters ---------- @@ -137,9 +200,14 @@ def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=N 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 + name : str Optional name for the Mapping that will be created. Needed to avoid conflicts in case several mappings are created + + Returns + ------- + Geometry + The new instance. """ mapping_name = name if name else 'mapping' @@ -156,7 +224,6 @@ def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=N return Geometry(domain=domain, ncells=ncells, periodic=periodic, mappings=mappings, comm=comm, mpi_dims_mask=mpi_dims_mask) - #-------------------------------------------------------------------------- # Option [3]: discrete topological line/square/cube #-------------------------------------------------------------------------- @@ -238,38 +305,36 @@ def read(self, filename, comm=None, mpi_dims_mask=None): domain = Domain.from_file(filename) connectivity = construct_connectivity(domain) - if len(domain)==1: - interiors = [domain.interior] + if len(domain) == 1: + interiors = [domain.interior] else: - interiors = list(domain.interior.args) - - if not(comm is None): - kwargs = dict( driver='mpio', comm=comm ) if comm.size > 1 else {} + interiors = list(domain.interior.args) + if comm is not None: + kwargs = dict(driver='mpio', comm=comm) if comm.size > 1 else {} else: kwargs = {} - h5 = h5py.File( filename, mode='r', **kwargs ) - yml = yaml.load( h5['geometry.yml'][()], Loader=yaml.SafeLoader ) + h5 = h5py.File(filename, mode='r', **kwargs) + yml = yaml.load(h5['geometry.yml'][()], Loader=yaml.SafeLoader) ldim = yml['ldim'] pdim = yml['pdim'] - n_patches = len( yml['patches'] ) + n_patches = len(yml['patches']) # ... if n_patches == 0: - h5.close() raise ValueError( "Input file contains no patches." ) # ... - # ... read patchs + # ... read patches mappings = {} ncells = {} periodic = {} - spaces = [None]*n_patches - for i_patch in range( n_patches ): + spaces = [None] * n_patches + for i_patch in range(n_patches): item = yml['patches'][i_patch] patch_name = item['name'] @@ -289,29 +354,27 @@ def read(self, filename, comm=None, mpi_dims_mask=None): ncells [interiors[i_patch].name] = [sp.ncells for sp in space_i] periodic[interiors[i_patch].name] = periodic_i - self._cart = None if n_patches == 1: - self._ddm = DomainDecomposition(ncells[domain.name], periodic[domain.name], comm=comm, mpi_dims_mask=mpi_dims_mask) - ddms = [self._ddm] + ddm = DomainDecomposition(ncells[domain.name], periodic[domain.name], comm=comm, mpi_dims_mask=mpi_dims_mask) + ddms = [ddm] else: - ncells_ = [ncells[itr.name] for itr in interiors] - periodic = [periodic[itr.name] for itr in interiors] - self._ddm = MultiPatchDomainDecomposition(ncells_, periodic, comm=comm) - ddms = self._ddm.domains + ncells_ = [ncells[itr.name] for itr in interiors] + periodic = [periodic[itr.name] for itr in interiors] + ddm = MultiPatchDomainDecomposition(ncells_, periodic, comm=comm) + ddms = ddm.domains carts = create_cart(ddms, spaces) - g_spaces = {inter:TensorFemSpace( ddms[i], *spaces[i], cart=carts[i]) for i,inter in enumerate(interiors)} + g_spaces = {inter:TensorFemSpace(ddms[i], *spaces[i], cart=carts[i]) for i,inter in enumerate(interiors)} - for i,j in connectivity: - ((axis_i, ext_i), (axis_j , ext_j)) = connectivity[i, j] + for i, j in connectivity: minus = interiors[i] plus = interiors[j] - max_ncells = [max(ni,nj) for ni,nj in zip(ncells[minus.name],ncells[plus.name])] + max_ncells = [max(ni, nj) for ni, nj in zip(ncells[minus.name], ncells[plus.name])] g_spaces[minus].add_refined_space(ncells=max_ncells) - g_spaces[plus].add_refined_space(ncells=max_ncells) + g_spaces[plus ].add_refined_space(ncells=max_ncells) # ... construct interface spaces - construct_interface_spaces(self._ddm, g_spaces, carts, interiors, connectivity) + construct_interface_spaces(ddm, g_spaces, carts, interiors, connectivity) for i_patch in range( n_patches ): @@ -367,7 +430,6 @@ def read(self, filename, comm=None, mpi_dims_mask=None): if isinstance(mapping, NurbsMapping): mapping.weights_field.coeffs.update_ghost_regions() - # ... close the h5 file h5.close() # ... @@ -383,6 +445,8 @@ def read(self, filename, comm=None, mpi_dims_mask=None): self._mappings = mappings self._domain = domain self._comm = comm + self._ddm = ddm + self._cart = None self._ncells = ncells self._periodic = periodic self._is_parallel = comm is not None @@ -426,7 +490,6 @@ def export( self, filename ): yml['patches'] = patches_info # ... - # ... topology topo_yml = self.domain.todict() # ... From e3c30dbb10ea7aaab950c1ef26d475868156a8d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 30 Sep 2025 18:57:14 +0200 Subject: [PATCH 018/129] Update Geometry unit tests --- psydac/cad/tests/test_geometry.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 458fc8f50..a5963a525 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -45,7 +45,7 @@ def test_geometry_2d_1(): geo.export('geo.h5') # read it again - geo_0 = Geometry(filename='geo.h5') + geo_0 = Geometry.from_file('geo.h5') # export it again geo_0.export('geo_0.h5') @@ -94,7 +94,7 @@ def test_geometry_2d_2(): geo.export('quart_circle.h5') # read it again - geo_0 = Geometry(filename='quart_circle.h5') + geo_0 = Geometry.from_file('quart_circle.h5') # export it again geo_0.export('quart_circle_0.h5') @@ -173,7 +173,7 @@ def test_geometry_2d_4(): #============================================================================== @pytest.mark.parallel -def test_geometry_with_mpi_dims_mask(): +def test_from_file_with_mpi_dims_mask(): comm = MPI.COMM_WORLD rank = comm.rank @@ -204,7 +204,7 @@ def test_geometry_with_mpi_dims_mask(): 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) + geo_from_file = Geometry.from_file(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 @@ -223,7 +223,7 @@ 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 + mpi_dims_mask = [False, False, True] # We will verify that this has an effect ncells = [4, 8, 2 * size] # Each process should have two cells along x3 degree = [3, 3, 3] @@ -284,7 +284,7 @@ def test_export_nurbs_to_hdf5(ncells, degree): export_nurbs_to_hdf5(filename, new_pipe) # read the geometry - geo = Geometry(filename=filename) + geo = Geometry.from_file(filename) domain = geo.domain min_coords = domain.logical_domain.min_coords @@ -333,7 +333,7 @@ def test_import_geopdes_to_nurbs(ncells, degree): export_nurbs_to_hdf5(filename, L_shaped) # read the geometry - geo = Geometry(filename=filename) + geo = Geometry.from_file(filename) domain = geo.domain min_coords = domain.logical_domain.min_coords From 8716dcbfa3ccbbc0a8c743ccb36ded6a0534371a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 1 Oct 2025 12:33:48 +0200 Subject: [PATCH 019/129] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit ccbd6e3b029bf8e978780c73a643b7b1186b0942 Author: Yaman Güçlü Date: Wed Oct 1 06:25:42 2025 +0200 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/cad/tests/test_geometry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index a5963a525..07a30e77d 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -19,6 +19,8 @@ from mpi4py import MPI +from mpi4py import MPI + base_dir = os.path.dirname(os.path.realpath(__file__)) #============================================================================== def test_geometry_2d_1(): From 7111b8e4da96879f6afc91de7926fef1a0440c73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 1 Oct 2025 12:57:10 +0200 Subject: [PATCH 020/129] Do not create __psydac__ directory in parallel --- psydac/api/fem_bilinear_form.py | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/psydac/api/fem_bilinear_form.py b/psydac/api/fem_bilinear_form.py index 8af05df83..44a15d187 100644 --- a/psydac/api/fem_bilinear_form.py +++ b/psydac/api/fem_bilinear_form.py @@ -1425,12 +1425,12 @@ def make_file(self, temps, ordered_stmts, field_derivatives, max_logical_derivat 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: + import os + if not os.path.isdir('__psydac__'): + os.makedirs('__psydac__') filename = f'__psydac__/assemble_{file_id}.py' f = open(filename, 'w') f.writelines(assembly_code) @@ -1981,26 +1981,23 @@ def construct_arguments_generate_assembly_file(self): 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: + # Root process writes the assembly code to a file + if comm is None or comm.rank == 0: + import os + if not os.path.isdir('__psydac__'): + os.makedirs('__psydac__') filename = '__psydac__/test_trial_computation.py' code = self.test_trial_template f = open(filename, 'w') f.writelines(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) + base_dirpath = os.getcwd() sys.path.insert(0, base_dirpath) From cc097a331b75440b3942bf84792e75c98b647765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 1 Oct 2025 13:05:04 +0200 Subject: [PATCH 021/129] Use Geometry.from_file in discretize_domain --- psydac/api/discretization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psydac/api/discretization.py b/psydac/api/discretization.py index d98ba1a51..9aeb4c87c 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, mpi_dims_mask=mpi_dims_mask) + return Geometry.from_file(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) From cd925fbafd47dc12d30c65c33f5fb6dcdc2aa368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 1 Oct 2025 13:07:30 +0200 Subject: [PATCH 022/129] Use correct parameter name (periods) in MultiPatchDomainDecomposition constructor --- psydac/cad/geometry.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 8a4c7ddac..46075f243 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -37,9 +37,12 @@ class Geometry: """ Distributed discrete geometry that works for single and multiple patches. - The Geometry object can be created in two ways: - - case 1 : through a geometry file whos name can be given to the constructor - - case 2 : provide the ncells, the periodicity and the mapping objects of each patch. + + The Geometry object can be created in four ways: + - case 0 : providing a `Domain` to `__init__` with ncells, periodicity and mapping for each patch. + - case 1 : passing the path to a geometry file to `from_file`. + - case 2 : passing a `SplineMapping` to `from_discrete_mapping` (single patch). + - case 3 : passing a `Domain`, ncells, and periodicity to `from_topological_domain` (single or multi-patch). Parameters ---------- @@ -121,16 +124,16 @@ def __init__(self, #name = domain.name name = interior_names[0] ddm = DomainDecomposition( - ncells[name], - periodic[name], - comm = comm, + ncells = ncells[name], + periods = periodic[name], + comm = comm, mpi_dims_mask = mpi_dims_mask, ) else: ddm = MultiPatchDomainDecomposition( - ncells = [ ncells[itr] for itr in interior_names], - periodic = [periodic[itr] for itr in interior_names], - comm = comm, + ncells = [ ncells[itr] for itr in interior_names], + periods = [periodic[itr] for itr in interior_names], + comm = comm, ) # Add attributes to the new object From f1b6bb15d746161ac3393ca6eaae39deedb124c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 7 Oct 2025 11:33:12 +0200 Subject: [PATCH 023/129] Add mandatory parameter pdim to Geometry.__init__ --- psydac/cad/geometry.py | 82 ++++++++++++++++++++----------- psydac/cad/tests/test_geometry.py | 6 +-- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 46075f243..fa4093216 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -15,6 +15,7 @@ from sympde.topology import Domain, Interface, Line, Square, Cube, NCubeInterior, Mapping, NCube from sympde.topology.basic import Union +from sympde.topology.callable_mapping import BasicCallableMapping from psydac.fem.splines import SplineSpace from psydac.fem.tensor import TensorFemSpace @@ -47,21 +48,24 @@ class Geometry: Parameters ---------- domain : Sympde.topology.Domain - The symbolic domain to be discretized. + The symbolic topological domain to be discretized. - ncells : list | tuple | dict - The number of cells of the discretized topological domain in each direction. + pdim : int + Number of physical dimensions of the Geometry object (pdim >= ldim). - periodic : list | tuple | dict + ncells : dict[str, Iterable[int]] + The number of cells of the discretized domain in each direction. + + periodic : dict[str, Iterable[bool]], optional The periodicity of the topological domain in each direction. - mappings : dict - The Mapping of each patch. + mappings : dict[str, BasicCallableMapping], optional + The discrete mappings of each patch. - comm: MPI.Intracomm + comm: MPI.Intracomm, optional MPI intra-communicator. - mpi_dims_mask: list of bool + mpi_dims_mask: Iterable[bool], optional 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. @@ -74,6 +78,7 @@ class Geometry: def __init__(self, domain : Domain, *, + pdim : int, ncells : dict[str, Iterable[int]], mappings : dict[str, SplineMapping | None] = None, periodic : dict[str, Iterable[bool]] = None, @@ -81,6 +86,7 @@ def __init__(self, mpi_dims_mask : Iterable[bool] = None): # Type checks + assert isinstance(pdim, int) assert isinstance(domain, Domain) assert isinstance(ncells, dict) assert isinstance(mappings, dict) @@ -93,6 +99,9 @@ def __init__(self, interior_names : list = domain.interior_names set_interior_names = set(interior_names) + # Check sanity of pdim + assert pdim >= ldim + # Check sanity of ncells assert set(ncells.keys()) == set_interior_names assert all(len(n) == ldim for n in ncells.values()) @@ -112,7 +121,8 @@ def __init__(self, mappings = {itr.name : None for itr in domain.interior} else: assert set(mappings.keys()) == set_interior_names - assert all(isinstance(m, (SplineMapping, NoneType)) for m in mappings.values()) + assert all(isinstance(m, (BasicCallableMapping, NoneType)) for m in mappings.values()) + assert all(m.pdim == pdim for m in mappings.values() if m is not None) # Check sanity of mpi_dims_mask if mpi_dims_mask is not None: @@ -139,10 +149,10 @@ def __init__(self, # Add attributes to the new object self._domain = domain self._ldim = domain.dim - self._pdim = domain.dim # TODO must be given => only dim is defined for a Domain + self._pdim = pdim self._ncells = ncells - self._periodic = periodic self._mappings = mappings + self._periodic = periodic self._comm = comm self._ddm = ddm self._cart = None @@ -169,7 +179,7 @@ def from_file(cls, comm: MPI.Intracomm, optional The MPI intra-communicator. - mpi_dims_mask: list[bool], optional + mpi_dims_mask: Iterable[bool], optional 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. @@ -193,8 +203,8 @@ def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=N Parameters ---------- - mapping : SplineMapping - The Mapping from the unit square to the physical domain. + mapping : BasicCallableMapping + The mapping from the unit square to the physical domain. comm : MPI.Comm MPI intra-communicator. @@ -204,8 +214,8 @@ def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=N If mpi_dims_mask[i]=False, the i-th dimension will not be decomposed. name : str - Optional name for the Mapping that will be created. - Needed to avoid conflicts in case several mappings are created + Optional name for the symbolic Mapping that will be created. + Needed to avoid conflicts in case several mappings are created. Returns ------- @@ -214,18 +224,25 @@ def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=N """ mapping_name = name if name else 'mapping' - dim = mapping.ldim - M = Mapping(mapping_name, dim = dim) + dim = mapping.ldim + M = Mapping(mapping_name, dim = dim) # this is a symbolic mapping domain = M(NCube(name = 'Omega', dim = dim, min_coords = [0.] * dim, max_coords = [1.] * dim)) M.set_callable_mapping(mapping) + pdim = mapping.pdim mappings = {domain.name: mapping} 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, mpi_dims_mask=mpi_dims_mask) + return Geometry(domain = domain, + pdim = pdim, + ncells = ncells, + periodic = periodic, + mappings = mappings, + comm = comm, + mpi_dims_mask = mpi_dims_mask) #-------------------------------------------------------------------------- # Option [3]: discrete topological line/square/cube @@ -238,24 +255,29 @@ def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mp for itr in interior: if not isinstance(itr, NCubeInterior): - msg = "Topological domain must be an NCube;"\ + msg = "The topological domain of each patch must be an NCube;"\ " got {} instead.".format(type(itr)) raise TypeError(msg) - mappings = {itr.name:None for itr in interior} + mappings = {itr.name : None for itr in interior} + pdim = next(iter(interior)).dim if isinstance(ncells, (list, tuple)): - ncells = {itr.name:ncells for itr in interior} + ncells = {itr.name : ncells for itr in interior} if periodic is None: - periodic = [False]*domain.dim + periodic = [False] * domain.dim if isinstance(periodic, (list, tuple)): - periodic = {itr.name:periodic for itr in interior} - - geo = Geometry(domain=domain, mappings=mappings, ncells=ncells, periodic=periodic, comm=comm, mpi_dims_mask=mpi_dims_mask) + periodic = {itr.name : periodic for itr in interior} - return geo + return Geometry(domain = domain, + pdim = pdim, + ncells = ncells, + periodic = periodic, + mappings = mappings, + comm = comm, + mpi_dims_mask = mpi_dims_mask) #-------------------------------------------------------------------------- @property @@ -443,15 +465,15 @@ def read(self, filename, comm=None, mpi_dims_mask=None): patch.mapping.set_callable_mapping(F) # ... + self._domain = domain self._ldim = ldim self._pdim = pdim + self._ncells = ncells self._mappings = mappings - self._domain = domain + self._periodic = periodic self._comm = comm self._ddm = ddm self._cart = None - self._ncells = ncells - self._periodic = periodic self._is_parallel = comm is not None # ... diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 07a30e77d..282f4db8f 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -41,7 +41,7 @@ def test_geometry_2d_1(): ncells = {domain.name:ncells} # create a geometry from a topological domain and the dict of mappings - geo = Geometry(domain=domain, ncells=ncells, mappings=mappings) + geo = Geometry(domain=domain, pdim=2, ncells=ncells, mappings=mappings) # export the geometry geo.export('geo.h5') @@ -90,7 +90,7 @@ def test_geometry_2d_2(): periodic = {domain.name:[space.periodic for space in mapping.space.spaces]} # create a geometry from a topological domain and the dict of mappings - geo = Geometry(domain=domain, ncells=ncells, periodic=periodic, mappings=mappings) + geo = Geometry(domain=domain, pdim=2, ncells=ncells, periodic=periodic, mappings=mappings) # export the geometry geo.export('quart_circle.h5') @@ -202,7 +202,7 @@ def test_from_file_with_mpi_dims_mask(): # 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 = Geometry(domain=domain, pdim=3, ncells=d_ncells, mappings=mappings, comm=comm) geo.export('geo_mpi_dims.h5') # Read geometry file in parallel, but using mpi_dims_mask From 27160655cb4d83f484f128dc6f279ccc183edb6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 7 Oct 2025 12:42:20 +0200 Subject: [PATCH 024/129] Clean up Geometry.read() --- psydac/cad/geometry.py | 43 +++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index fa4093216..7723fa904 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -321,8 +321,8 @@ def __len__(self): 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'): + _, ext = os.path.splitext(filename) + if ext != '.h5': raise ValueError('> Only h5 files are supported') # ... @@ -351,7 +351,7 @@ def read(self, filename, comm=None, mpi_dims_mask=None): # ... if n_patches == 0: h5.close() - raise ValueError( "Input file contains no patches." ) + raise ValueError("Input file contains no patches.") # ... # ... read patches @@ -370,9 +370,9 @@ def read(self, filename, comm=None, mpi_dims_mask=None): degree = [int (p) for p in patch.attrs['degree' ]] periodic_i = [bool(b) for b in patch.attrs['periodic']] - knots = [patch['knots_{}'.format(d)][:] for d in range( ldim )] - space_i = [SplineSpace( degree=p, knots=k, periodic=P ) - for p,k,P in zip( degree, knots, periodic_i )] + knots = [patch['knots_{}'.format(d)][:] for d in range(ldim)] + space_i = [SplineSpace(degree=p, knots=k, periodic=P) + for p, k, P in zip(degree, knots, periodic_i)] spaces[i_patch] = space_i @@ -413,26 +413,26 @@ def read(self, filename, comm=None, mpi_dims_mask=None): tensor_space = g_spaces[interiors[i_patch]] if dtype == 'SplineMapping': - mapping = SplineMapping.from_control_points( tensor_space, - patch['points'][..., :pdim] ) + mapping = SplineMapping.from_control_points(tensor_space, + patch['points'][..., :pdim]) elif dtype == 'NurbsMapping': - mapping = NurbsMapping.from_control_points_weights( tensor_space, - patch['points'][..., :pdim], - patch['weights'] ) + mapping = NurbsMapping.from_control_points_weights(tensor_space, + patch['points'][..., :pdim], + patch['weights']) - mapping.set_name( item['name'] ) + mapping.set_name(item['name']) mappings[patch_name] = mapping - if n_patches>1: - coeffs = [[e._coeffs for e in mapping._fields] for mapping in mappings.values()] - spaces = [[coeffs_ij.space for coeffs_ij in coeffs_i] for coeffs_i in coeffs] - spaces = [BlockVectorSpace(*space) for space in spaces] - w_spaces = [sp.spaces[0] for sp in spaces] - space = BlockVectorSpace(*spaces, connectivity=connectivity) - w_space = BlockVectorSpace(*w_spaces, connectivity=connectivity) - v = BlockVector(space) - w = BlockVector(w_space) + # ... Update ghost regions within each patch and across interfaces + if n_patches > 1: + coeffs = [[e.coeffs for e in mapping.fields] for mapping in mappings.values()] + patch_spaces = [BlockVectorSpace(*[c_ij.space for c_ij in c_i]) for c_i in coeffs] + patch_spaces_w = [c_i[0].space for c_i in coeffs] + space = BlockVectorSpace(*patch_spaces , connectivity=connectivity) + space_w = BlockVectorSpace(*patch_spaces_w, connectivity=connectivity) + v = BlockVector(space) + w = BlockVector(space_w) mapping_list = list(mappings.values()) for i in range(n_patches): for j in range(len(coeffs[i])): @@ -454,6 +454,7 @@ def read(self, filename, comm=None, mpi_dims_mask=None): if isinstance(mapping, NurbsMapping): mapping.weights_field.coeffs.update_ghost_regions() + # ... # ... close the h5 file h5.close() From 818be2a8a31a172e29a169f1444b6eba5df1cd6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 7 Oct 2025 12:43:50 +0200 Subject: [PATCH 025/129] Remove unused property is_parallel from Geometry --- psydac/cad/geometry.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 7723fa904..3176d995e 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -156,7 +156,6 @@ def __init__(self, self._comm = comm self._ddm = ddm self._cart = None - self._is_parallel = comm is not None #-------------------------------------------------------------------------- # Option [1]: from a file @@ -308,10 +307,6 @@ def domain(self): def ddm(self): return self._ddm - @property - def is_parallel(self): - return self._is_parallel - @property def mappings(self): return self._mappings @@ -475,7 +470,6 @@ def read(self, filename, comm=None, mpi_dims_mask=None): self._comm = comm self._ddm = ddm self._cart = None - self._is_parallel = comm is not None # ... def export( self, filename ): From cb8ccb5cead68499b5112658ccf242fe8beac847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 8 Oct 2025 11:09:51 +0200 Subject: [PATCH 026/129] Clean up module psydac.mapping.discrete_gallery: - Add function `get_available_mappings` - Clean up function `discrete_mapping` and add docstring to it --- psydac/mapping/discrete_gallery.py | 105 ++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 17 deletions(-) diff --git a/psydac/mapping/discrete_gallery.py b/psydac/mapping/discrete_gallery.py index 1a6f7a493..ef215ffdc 100644 --- a/psydac/mapping/discrete_gallery.py +++ b/psydac/mapping/discrete_gallery.py @@ -1,4 +1,5 @@ # coding: utf-8 +from typing import Iterable import numpy as np from mpi4py import MPI @@ -14,6 +15,11 @@ from psydac.ddm.cart import DomainDecomposition +__all__ = ( + 'get_available_mappings', + 'discrete_mapping', +) + class Collela3D( Mapping ): _expressions = {'x':'2.*(x1 + 0.1*sin(2.*pi*x1)*sin(2.*pi*x2)) - 1.', @@ -21,78 +27,143 @@ class Collela3D( Mapping ): 'z':'2.*x3 - 1.'} #============================================================================== -def discrete_mapping(mapping, ncells, degree, **kwargs): +def get_available_mappings(ldim): + """ + Get a list of `mapping` values accepted as argument to `discrete_mapping`. + + Parameters + ---------- + ldim : int + The number of logical dimensions of the topological domain. + + Returns + ------- + tuple + All the accepted values for the `mapping` parameter. + """ + assert isinstance(ldim, int), f'ldim must be int, got {type(ldim).__name__} instead' + assert ldim > 0, f'ldim must be > 0, got {ldim} instead' + + if ldim == 2: + return ('identity', 'collela', 'circle', 'annulus', 'quarter_annulus', + 'target', 'czarny') + elif ldim == 3: + return ('identity', 'collela', 'spherical shell') + else: + return () - comm = kwargs.pop('comm', MPI.COMM_WORLD) - return_space = kwargs.pop('return_space', False) +#============================================================================== +def discrete_mapping(mapping, ncells, degree, *, + comm = MPI.COMM_WORLD, + return_space = False): + """ + Create a SplineMapping by interpolating one of the available analytical mappings. + + Parameters + ---------- + mapping : str + The name of the mapping. See `available_mappings` to get the options. + + ncells : Iterable[int] + The number of cells along each logical dimension. + + degree : Iterable[int] + The spline degree along each logical dimension. + + comm : MPI.Intracomm, optional + The MPI intracommunicator. + + return_space : bool, optional + Whether this function should also return the discrete space it creates. + + Returns + ------- + map_discrete : SplineMapping + The spline mapping created. + + space : TensorFemSpace + The space of the components of the spline mapping. + Only returned if `return_space` is True. + """ + # Check types + assert isinstance(mapping, str) + assert isinstance(ncells, Iterable) + assert isinstance(degree, Iterable) + assert isinstance(comm, MPI.Intracomm) or comm is None + assert isinstance(return_space, bool) + + # Check consistency of ncells and degree + assert all(isinstance(n, int) and n >= 1 for n in ncells) + assert all(isinstance(d, int) and d >= 0 for d in degree) + assert len(ncells) == len(degree) mapping = mapping.lower() - dim = len(ncells) - if dim not in [2, 3]: + ldim = len(ncells) + if ldim not in [2, 3]: raise NotImplementedError('Only 2D and 3D mappings are available') # ... - if dim == 2: + if ldim == 2: # Input parameters if mapping == 'identity': - map_symbolic = IdentityMapping('M', dim=dim) + map_symbolic = IdentityMapping('M', dim=ldim) limits = ((0, 1), (0, 1)) periodic = (False, False) elif mapping == 'collela': default_params = dict(k1=1.0, k2=1.0, eps=0.1) - map_symbolic = CollelaMapping2D('M', dim=dim, **default_params) + map_symbolic = CollelaMapping2D('M', dim=ldim, **default_params) limits = ((0, 1), (0, 1)) periodic = (False, False) elif mapping == 'circle': default_params = dict(rmin=0.0, rmax=1.0, c1=0.0, c2=0.0) - map_symbolic = PolarMapping('M', dim=dim, **default_params) + map_symbolic = PolarMapping('M', dim=ldim, **default_params) limits = ((0, 1), (0, 2*np.pi)) periodic = (False, True) elif mapping == 'annulus': default_params = dict(rmin=0.0, rmax=1.0, c1=0.0, c2=0.0) - map_symbolic = PolarMapping('M', dim=dim, **default_params) + map_symbolic = PolarMapping('M', dim=ldim, **default_params) limits = ((1, 4), (0, 2*np.pi)) periodic = (False, True) elif mapping == 'quarter_annulus': default_params = dict(rmin=0.0, rmax=1.0, c1=0.0, c2=0.0) - map_symbolic = PolarMapping('M', dim=dim, **default_params) + map_symbolic = PolarMapping('M', dim=ldim, **default_params) limits = ((1, 4), (0, np.pi/2)) periodic = (False, False) elif mapping == 'target': default_params = dict(c1=0, c2=0, k=0.3, D=0.2) - map_symbolic = TargetMapping('M', dim=dim, **default_params) + map_symbolic = TargetMapping('M', dim=ldim, **default_params) limits = ((0, 1), (0, 2*np.pi)) periodic = (False, True) elif mapping == 'czarny': default_params = dict(c2=0, b=1.4, eps=0.3) - map_symbolic = CzarnyMapping('M', dim=dim, **default_params) + map_symbolic = CzarnyMapping('M', dim=ldim, **default_params) limits = ((0, 1), (0, 2*np.pi)) periodic = (False, True) else: raise ValueError("Required 2D mapping not available") - elif dim == 3: + elif ldim == 3: # Input parameters if mapping == 'identity': - map_symbolic = IdentityMapping('M', dim=dim) + map_symbolic = IdentityMapping('M', dim=ldim) limits = ((0, 1), (0, 1), (0, 1)) periodic = ( False, False, False) elif mapping == 'collela': - map_symbolic = Collela3D('M', dim=dim) + map_symbolic = Collela3D('M', dim=ldim) limits = ((0, 1), (0, 1), (0, 1)) periodic = ( False, False, False) elif mapping == 'spherical shell': - map_analytic = SphericalMapping('M', dim=dim) + map_analytic = SphericalMapping('M', dim=ldim) limits = ((1, 4), (0, np.pi), (0, np.pi/2)) periodic = ( False, False, False) From 2e56b104feb1557376dd924f32cd2be15e0c87eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 21 Oct 2025 17:29:00 +0200 Subject: [PATCH 027/129] Run fewer tests, only on Ubuntu with Python 3.11 --- .github/workflows/testing.yml | 160 +++++++++++++++++----------------- 1 file changed, 81 insertions(+), 79 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index c3c2da8a4..86df467ed 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -36,7 +36,8 @@ jobs: fail-fast: false matrix: os: [ ubuntu-24.04, macos-14 ] - python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] +# python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + python-version: [ '3.11' ] isMerge: - ${{ github.event_name == 'push' && github.ref == 'refs/heads/devel' }} exclude: @@ -121,46 +122,46 @@ jobs: echo $HDF5_DIR echo "HDF5_DIR=$HDF5_DIR" >> $GITHUB_ENV - - name: Cache PETSc - uses: actions/cache@v4 - id: cache-petsc - env: - cache-name: cache-PETSc - with: - path: "./petsc" - key: petsc-${{ matrix.os }}-${{ matrix.python-version }} - - - if: steps.cache-petsc.outputs.cache-hit != 'true' - name: Download a specific release of PETSc - run: | - 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 - working-directory: ./petsc - run: | - export PETSC_DIR=$(pwd) - export PETSC_ARCH=petsc-cmplx - ./configure --with-scalar-type=complex --with-fortran-bindings=0 --have-numpy=1 - make all - echo "PETSC_DIR=$PETSC_DIR" > petsc.env - echo "PETSC_ARCH=$PETSC_ARCH" >> petsc.env - - # This step is not really necessary and could be combined with PETSc install - # step; however it's good to verify if the cached PETSc installation really works! - - name: Test PETSc installation - working-directory: ./petsc - run: | - source petsc.env - make check - echo "PETSC_DIR=$PETSC_DIR" >> $GITHUB_ENV - echo "PETSC_ARCH=$PETSC_ARCH" >> $GITHUB_ENV - - - name: Install petsc4py - working-directory: ./petsc - run: | - python -m pip install wheel Cython numpy - python -m pip install src/binding/petsc4py +# - name: Cache PETSc +# uses: actions/cache@v4 +# id: cache-petsc +# env: +# cache-name: cache-PETSc +# with: +# path: "./petsc" +# key: petsc-${{ matrix.os }}-${{ matrix.python-version }} +# +# - if: steps.cache-petsc.outputs.cache-hit != 'true' +# name: Download a specific release of PETSc +# run: | +# 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 +# working-directory: ./petsc +# run: | +# export PETSC_DIR=$(pwd) +# export PETSC_ARCH=petsc-cmplx +# ./configure --with-scalar-type=complex --with-fortran-bindings=0 --have-numpy=1 +# make all +# echo "PETSC_DIR=$PETSC_DIR" > petsc.env +# echo "PETSC_ARCH=$PETSC_ARCH" >> petsc.env +# +# # This step is not really necessary and could be combined with PETSc install +# # step; however it's good to verify if the cached PETSc installation really works! +# - name: Test PETSc installation +# working-directory: ./petsc +# run: | +# source petsc.env +# make check +# echo "PETSC_DIR=$PETSC_DIR" >> $GITHUB_ENV +# echo "PETSC_ARCH=$PETSC_ARCH" >> $GITHUB_ENV +# +# - name: Install petsc4py +# working-directory: ./petsc +# run: | +# python -m pip install wheel Cython numpy +# python -m pip install src/binding/petsc4py - name: Install h5py in parallel mode run: | @@ -192,49 +193,50 @@ jobs: mkdir pytest cp mpi_tester.py pytest - - name: Run coverage tests on macOS - if: matrix.os == 'macos-14' - working-directory: ./pytest - run: >- - python -m pytest -n auto - --cov psydac - --cov-config $GITHUB_WORKSPACE/pyproject.toml - --cov-report xml - --pyargs psydac -m "not parallel and not petsc" +# - name: Run single-process tests with Pytest and coverage on macOS +# if: matrix.os == 'macos-14' +# working-directory: ./pytest +# run: >- +# python -m pytest -n auto +# --cov psydac +# --cov-config $GITHUB_WORKSPACE/pyproject.toml +# --cov-report xml +# --pyargs psydac -m "not parallel and not petsc" - name: Run single-process tests with Pytest on Ubuntu if: matrix.os == 'ubuntu-24.04' working-directory: ./pytest run: | - python -m pytest -n auto --pyargs psydac -m "not parallel and not petsc" - - - name: Upload coverage report to Codacy - if: matrix.os == 'macos-14' - uses: codacy/codacy-coverage-reporter-action@v1.3.0 - with: - project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} - coverage-reports: ./pytest/coverage.xml - - - name: Print detailed coverage results on macOS - if: matrix.os == 'macos-14' - working-directory: ./pytest - run: | - coverage report --ignore-errors --show-missing --sort=cover - - - name: Run MPI tests with Pytest - working-directory: ./pytest - run: | - python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and not petsc" - - - name: Run single-process PETSc tests with Pytest - working-directory: ./pytest - run: | - python -m pytest -n auto --pyargs psydac -m "not parallel and petsc" - - - name: Run MPI PETSc tests with Pytest - working-directory: ./pytest - run: | - python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and petsc" + python -m pytest --pyargs psydac.cad -x -ra -v +# python -m pytest -n auto --pyargs psydac -m "not parallel and not petsc" + +# - name: Upload coverage report to Codacy +# if: matrix.os == 'macos-14' +# uses: codacy/codacy-coverage-reporter-action@v1.3.0 +# with: +# project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} +# coverage-reports: ./pytest/coverage.xml +# +# - name: Print detailed coverage results on macOS +# if: matrix.os == 'macos-14' +# working-directory: ./pytest +# run: | +# coverage report --ignore-errors --show-missing --sort=cover +# +# - name: Run MPI tests with Pytest +# working-directory: ./pytest +# run: | +# python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and not petsc" +# +# - name: Run single-process PETSc tests with Pytest +# working-directory: ./pytest +# run: | +# python -m pytest -n auto --pyargs psydac -m "not parallel and petsc" +# +# - name: Run MPI PETSc tests with Pytest +# working-directory: ./pytest +# run: | +# python mpi_tester.py --mpirun="mpiexec -n 4 ${MPI_OPTS}" --pyargs psydac -m "parallel and petsc" - name: Remove test directory if: always() From b042a0a76088398939533357260187b6fcca92a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 26 Jan 2026 17:12:36 +0100 Subject: [PATCH 028/129] Reactivate single-process tests on Ubuntu and macOS --- .github/workflows/testing.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index fdbc88c2f..e977a7cf1 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -30,7 +30,7 @@ jobs: matrix: os: [ ubuntu-24.04, macos-14 ] # python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] - python-version: [ '3.11' ] + python-version: [ '3.11', '3.12' ] isMerge: - ${{ github.event_name == 'push' && github.ref == 'refs/heads/devel' }} exclude: @@ -139,13 +139,17 @@ jobs: # --cov-config $GITHUB_WORKSPACE/pyproject.toml # --cov-report xml # --pyargs psydac -m "not parallel and not petsc" +# +# - name: Run single-process tests with Pytest on Ubuntu +# if: matrix.os == 'ubuntu-24.04' +# working-directory: ./pytest +# run: | +# psydac test - - name: Run single-process tests with Pytest on Ubuntu - if: matrix.os == 'ubuntu-24.04' + - name: Run single-process tests with Pytest working-directory: ./pytest run: | psydac test --mod psydac.cad -x -v -# psydac test # - name: Upload coverage report to Codacy # if: matrix.os == 'macos-14' From 6b6653f75c6afacc20d0708e580f4c3d9a9b8b3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 26 Jan 2026 17:17:54 +0100 Subject: [PATCH 029/129] Remove duplicated imports from test_geometry.py --- psydac/cad/tests/test_geometry.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index f7ead795f..bc8269606 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -9,9 +9,6 @@ import numpy as np from mpi4py import MPI -import pytest -import numpy as np -from mpi4py import MPI from sympde.topology import Domain, Line, Square, Cube, Mapping from psydac.cad.geometry import Geometry, export_nurbs_to_hdf5, refine_nurbs From 8ea76a8b46ddee7608ebe8d587caf5b7216f0ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 27 Jan 2026 13:44:33 +0100 Subject: [PATCH 030/129] Improve docstring of Geometry class --- psydac/cad/geometry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 3f54026fb..384650789 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -44,7 +44,7 @@ class Geometry: Distributed discrete geometry that works for single and multiple patches. The Geometry object can be created in four ways: - - case 0 : providing a `Domain` to `__init__` with ncells, periodicity and mapping for each patch. + - case 0 : providing a `Domain` to `__init__` with detailed parameters for each patch. - case 1 : passing the path to a geometry file to `from_file`. - case 2 : passing a `SplineMapping` to `from_discrete_mapping` (single patch). - case 3 : passing a `Domain`, ncells, and periodicity to `from_topological_domain` (single or multi-patch). From 739aee8af1d9ec2f26edd9b4c940e960e4fee527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 27 Jan 2026 14:57:06 +0100 Subject: [PATCH 031/129] Add 'h5py' xdist_group to all unit tests in test_geometry.py --- psydac/cad/tests/test_geometry.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index bc8269606..18ad343b1 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -25,6 +25,7 @@ base_dir = os.path.dirname(os.path.realpath(__file__)) #============================================================================== +@pytest.mark.xdist_group('h5py') def test_geometry_2d_1(): ncells = [1,1] @@ -61,6 +62,7 @@ def test_geometry_2d_1(): geo_1.export('geo_1.h5') #============================================================================== +@pytest.mark.xdist_group('h5py') def test_geometry_2d_2(): # create a nurbs mapping @@ -111,6 +113,7 @@ def test_geometry_2d_2(): #============================================================================== # TODO to be removed +@pytest.mark.xdist_group('h5py') def test_geometry_2d_3(): # create a nurbs mapping @@ -144,6 +147,7 @@ def test_geometry_2d_3(): #============================================================================== # TODO to be removed +@pytest.mark.xdist_group('h5py') def test_geometry_2d_4(): # create a nurbs mapping @@ -270,6 +274,7 @@ def test_from_topological_domain(): #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) @pytest.mark.parametrize( 'degree', [[2,2], [3,2], [2,3], [3,3], [4,4]] ) +@pytest.mark.xdist_group('h5py') def test_export_nurbs_to_hdf5(ncells, degree): # create pipe geometry @@ -323,9 +328,9 @@ def test_export_nurbs_to_hdf5(ncells, degree): #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) @pytest.mark.parametrize( 'degree', [[2,2], [3,2], [2,3], [3,3], [4,4]] ) +@pytest.mark.xdist_group('h5py') def test_import_geopdes_to_nurbs(ncells, degree): - filename = os.path.join(base_dir, "geo_Lshaped_C1.txt") L_shaped = import_geopdes_to_nurbs(filename) From 66205e720cf328a1f5177a580ffcb3b18b35a56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 27 Jan 2026 16:16:41 +0100 Subject: [PATCH 032/129] Handle NumPy integers passed in ncells to Geometry constructor --- psydac/cad/geometry.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 384650789..333248ef5 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -109,9 +109,13 @@ def __init__(self, # Check sanity of ncells assert set(ncells.keys()) == set_interior_names assert all(len(n) == ldim for n in ncells.values()) - assert all(isinstance(ni, int) for ni in chain(*ncells.values())) + assert all(isinstance(ni, (int, np.integer)) for ni in chain(*ncells.values())) assert all(ni > 0 for ni in chain(*ncells.values())) + # Although we allow the iterable values in ncells to contain NumPy + # integers, we convert them to lists of Python integers for consistency + ncells = {patch: [int(ni) for ni in n] for patch, n in ncells.items()} + # Check sanity of periodic if periodic is None: periodic = {patch: [False]*len(ncells_i) for patch, ncells_i in ncells.items()} From ae6ebf87d0ae6dfb878c1f7e05f6ffddd960eb6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 27 Jan 2026 16:17:55 +0100 Subject: [PATCH 033/129] Minor cleanup in Geometry constructor --- psydac/cad/geometry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 333248ef5..1bc0490d0 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -118,7 +118,7 @@ def __init__(self, # Check sanity of periodic if periodic is None: - periodic = {patch: [False]*len(ncells_i) for patch, ncells_i in ncells.items()} + periodic = {patch: [False] * len(n) for patch, n in ncells.items()} else: assert set(periodic.keys()) == set_interior_names assert all(len(p) == ldim for p in periodic.values()) @@ -276,12 +276,12 @@ def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mp periodic = [False] * domain.dim else: if len(interior) > 1 and True in periodic: + import warnings msg = "Discretizing a multipatch domain with a periodic flag is not advised -- continue at your own risk." # [MCP 18.12.2025] the following line may be causing a strange error in the CI (MPI tests for macos-14/Python 3.10) # warnings.warn(msg, Warning) warnings.warn(msg, UserWarning) - if isinstance(periodic, (list, tuple)): periodic = {itr.name : periodic for itr in interior} From 7dff0212a2030a725f967fba9200f41e709c1fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 27 Jan 2026 16:47:41 +0100 Subject: [PATCH 034/129] Run unit tests in mapping, fem, and feec folders --- .github/workflows/testing.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index e977a7cf1..319e205cb 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -146,11 +146,26 @@ jobs: # run: | # psydac test - - name: Run single-process tests with Pytest + - name: Run single-process tests for CAD module working-directory: ./pytest run: | psydac test --mod psydac.cad -x -v + - name: Run single-process tests for Mapping module + working-directory: ./pytest + run: | + psydac test --mod psydac.mapping -x -v + + - name: Run single-process tests for FEM module + working-directory: ./pytest + run: | + psydac test --mod psydac.fem -x -v + + - name: Run single-process tests for FEEC module + working-directory: ./pytest + run: | + psydac test --mod psydac.feec -x -v + # - name: Upload coverage report to Codacy # if: matrix.os == 'macos-14' # uses: codacy/codacy-coverage-reporter-action@v1.3.0 From fdd357a423e093edf0352612d9f080499bbe2f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 27 Jan 2026 20:42:29 +0100 Subject: [PATCH 035/129] Run unit tests in api folder --- .github/workflows/testing.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 319e205cb..4ead8d3e7 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -166,6 +166,11 @@ jobs: run: | psydac test --mod psydac.feec -x -v + - name: Run single-process tests for API module + working-directory: ./pytest + run: | + psydac test --mod psydac.api -x -v + # - name: Upload coverage report to Codacy # if: matrix.os == 'macos-14' # uses: codacy/codacy-coverage-reporter-action@v1.3.0 From 001e5924a0a8a4a5fb34b9d27529a46cc9af6965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 27 Jan 2026 23:17:50 +0100 Subject: [PATCH 036/129] Revert "Do not create __psydac__ directory in parallel" This reverts commit 7111b8e4da96879f6afc91de7926fef1a0440c73. These changes will be included in another PR, hence we remove them from here to avoid future conflicts. --- psydac/api/fem_bilinear_form.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/psydac/api/fem_bilinear_form.py b/psydac/api/fem_bilinear_form.py index a7f97fdef..06b50b946 100644 --- a/psydac/api/fem_bilinear_form.py +++ b/psydac/api/fem_bilinear_form.py @@ -1440,12 +1440,12 @@ def make_file(self, temps, ordered_stmts, field_derivatives, max_logical_derivat 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: - import os - if not os.path.isdir('__psydac__'): - os.makedirs('__psydac__') filename = f'__psydac__/assemble_{file_id}.py' f = open(filename, 'w') f.writelines(assembly_code) @@ -1996,23 +1996,26 @@ def construct_arguments_generate_assembly_file(self): 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 - # Root process writes the assembly code to a file - if comm is None or comm.rank == 0: - import os - if not os.path.isdir('__psydac__'): - os.makedirs('__psydac__') + 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() - # 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) - base_dirpath = os.getcwd() sys.path.insert(0, base_dirpath) From b807f32b93d2747c82f4c4e8e8aafec288de62ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 28 Jan 2026 12:05:14 +0100 Subject: [PATCH 037/129] Update `CHANGELOG.md` with new and previous changes --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9faae0e02..0043750a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,17 @@ All notable changes to this project will be documented in this file. ### Added +- #567 Improve `psydac test` command (with several new features) +- #565 Expand editable install info in `README.md` + ### Fixed -- #565 Expand editable install info in `README.md` +- #567 Fix parallel creation of folder `__psydac__` in `psydac.api.fem_bilinear_form` - #566 Fix command `psydac test --mpi` on Ubuntu machines ### Changed +- #527 Improve `Geometry` class in module `psydac.cad.geometry` - [DEVELOPER] Run documentation workflow whenever `README.md` is modified - [DEVELOPER] Run testing workflow on PRs only when set to "ready for review" From a06a7d39bc8abc68f50e64e0511b1c6f23642f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 10 Feb 2026 14:15:15 +0100 Subject: [PATCH 038/129] Use SymPDE branch clean-multipatch-domain --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1882383a0..4ceb24fdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ 'termcolor', # Our packages from PyPi - 'sympde == 0.19.2', +# 'sympde == 0.19.2', + 'sympde @ https://github.com/pyccel/sympde/archive/refs/heads/clean-multipatch-domain.zip', 'pyccel >= 2.1.0', 'gelato == 0.12', From e968bc584b6e151199671f81497d12b3d91aa8dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 10 Feb 2026 22:56:50 +0100 Subject: [PATCH 039/129] Provide interface orientation to Domain.join --- psydac/api/tests/test_2d_complex.py | 4 +-- .../test_2d_multipatch_mapping_maxwell.py | 4 +-- .../test_2d_multipatch_mapping_poisson.py | 35 ++++++++++--------- .../api/tests/test_2d_multipatch_poisson.py | 22 ++++++------ psydac/api/tests/test_postprocessing.py | 11 +++--- 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/psydac/api/tests/test_2d_complex.py b/psydac/api/tests/test_2d_complex.py index ae916cd49..10eb5fdb8 100644 --- a/psydac/api/tests/test_2d_complex.py +++ b/psydac/api/tests/test_2d_complex.py @@ -367,7 +367,7 @@ def test_complex_poisson_2d_multipatch(): A = Square('A',bounds1=(0, 0.5), bounds2=(0, 1)) B = Square('B',bounds1=(0.5, 1.), bounds2=(0, 1)) - domain = Domain.join([A, B], [((0, 0, 1), (1, 0, -1))], 'domain') + domain = Domain.join([A, B], [((0, 0, 1), (1, 0, -1), 1)], 'domain') x, y = domain.coordinates @@ -495,7 +495,7 @@ def test_maxwell_2d_2_patch_dirichlet_parallel_0(): D1 = mapping_1(A) D2 = mapping_2(B) - domain = Domain.join([D1, D2], [((0, 1, 1), (1, 1, -1))], 'domain') + domain = Domain.join([D1, D2], [((0, 1, 1), (1, 1, -1), 1)], 'domain') x, y = domain.coordinates diff --git a/psydac/api/tests/test_2d_multipatch_mapping_maxwell.py b/psydac/api/tests/test_2d_multipatch_mapping_maxwell.py index 5620e2979..daabf8220 100644 --- a/psydac/api/tests/test_2d_multipatch_mapping_maxwell.py +++ b/psydac/api/tests/test_2d_multipatch_mapping_maxwell.py @@ -126,7 +126,7 @@ def test_maxwell_2d_2_patch_dirichlet_0(): D1 = mapping_1(A) D2 = mapping_2(B) - connectivity = [((0,1,1),(1,1,-1))] + connectivity = [((0, 1, 1), (1, 1,-1), 1)] patches = [D1,D2] domain = Domain.join(patches, connectivity, 'domain') @@ -203,7 +203,7 @@ def test_maxwell_2d_2_patch_dirichlet_parallel_0(): D1 = mapping_1(A) D2 = mapping_2(B) - connectivity = [((0,1,1),(1,1,-1))] + connectivity = [((0, 1, 1), (1, 1,-1), 1)] patches = [D1,D2] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates diff --git a/psydac/api/tests/test_2d_multipatch_mapping_poisson.py b/psydac/api/tests/test_2d_multipatch_mapping_poisson.py index d657b1fe9..e99916d17 100644 --- a/psydac/api/tests/test_2d_multipatch_mapping_poisson.py +++ b/psydac/api/tests/test_2d_multipatch_mapping_poisson.py @@ -105,11 +105,11 @@ def test_poisson_2d_2_patches_dirichlet_0(): mapping_1 = PolarMapping('M1',2, c1= 0., c2= 0., rmin = 0., rmax=1.) mapping_2 = PolarMapping('M2',2, c1= 0., c2= 0., rmin = 0., rmax=1.) - D1 = mapping_1(A) - D2 = mapping_2(B) + D1 = mapping_1(A) + D2 = mapping_2(B) - connectivity = [((0,1,1),(1,1,-1))] - patches = [D1,D2] + patches = [D1, D2] + connectivity = [((0, 1, 1), (1, 1,-1), 1)] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates @@ -133,11 +133,11 @@ def test_poisson_2d_2_patches_dirichlet_1(): mapping_1 = PolarMapping('M1',2, c1= 0., c2= 0., rmin = 0., rmax=1.) mapping_2 = PolarMapping('M2',2, c1= 0., c2= 0., rmin = 0., rmax=1.) - D1 = mapping_1(A) - D2 = mapping_2(B) + D1 = mapping_1(A) + D2 = mapping_2(B) - connectivity = [((0,1,1),(1,1,-1))] - patches = [D1,D2] + patches = [D1, D2] + connectivity = [((0, 1, 1), (1, 1,-1), 1)] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates @@ -163,12 +163,12 @@ def test_poisson_2d_2_patches_dirichlet_2(): B = Square('B',bounds1=(0.5, 1.), bounds2=(0, np.pi)) C = Square('C',bounds1=(0.5, 1.), bounds2=(np.pi-0.5, np.pi + 1)) - D1 = mapping_1(A) - D2 = mapping_2(B) - D3 = mapping_3(C) + D1 = mapping_1(A) + D2 = mapping_2(B) + D3 = mapping_3(C) - connectivity = [((0,1,1),(1,1,-1)), ((1,1,1),(2,1,-1))] patches = [D1, D2, D3] + connectivity = [((0, 1, 1), (1, 1,-1), 1), ((1, 1, 1), (2, 1,-1), 1)] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates @@ -274,8 +274,11 @@ def test_poisson_2d_4_patch_dirichlet_0(): D3 = mapping_3(C) D4 = mapping_4(D) - connectivity = [((0,1,1),(1,1,-1)), ((2,1,1),(3,1,-1)), ((0,0,1),(2,0,-1)),((1,0,1),(3,0,-1))] patches = [D1, D2, D3, D4] + connectivity = [((0, 1, 1), (1, 1,-1), 1), + ((2, 1, 1), (3, 1,-1), 1), + ((0, 0, 1), (2, 0,-1), 1), + ((1, 0, 1), (3, 0,-1), 1)] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates @@ -311,11 +314,11 @@ def test_poisson_2d_2_patches_dirichlet_parallel_0(): mapping_1 = PolarMapping('M1',2, c1= 0., c2= 0., rmin = 0., rmax=1.) mapping_2 = PolarMapping('M2',2, c1= 0., c2= 0., rmin = 0., rmax=1.) - D1 = mapping_1(A) - D2 = mapping_2(B) + D1 = mapping_1(A) + D2 = mapping_2(B) - connectivity = [((0,1,1),(1,1,-1))] patches = [D1, D2] + connectivity = [((0, 1, 1), (1, 1,-1), 1)] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates diff --git a/psydac/api/tests/test_2d_multipatch_poisson.py b/psydac/api/tests/test_2d_multipatch_poisson.py index c69a56c3f..c487a2b23 100644 --- a/psydac/api/tests/test_2d_multipatch_poisson.py +++ b/psydac/api/tests/test_2d_multipatch_poisson.py @@ -80,11 +80,11 @@ def run_poisson_2d(solution, f, domain, ncells, degree): #------------------------------------------------------------------------------ def test_poisson_2d_2_patch_dirichlet_0(): - A = Square('A',bounds1=(0, 0.5), bounds2=(0, 1)) - B = Square('B',bounds1=(0.5, 1.), bounds2=(0, 1)) + A = Square('A', bounds1=(0, 0.5), bounds2=(0, 1)) + B = Square('B', bounds1=(0.5, 1.), bounds2=(0, 1)) - connectivity = [((0,0,1),(1,0,-1))] - patches = [A,B] + patches = [A, B] + connectivity = [((0, 0, 1), (1, 0,-1), 1)] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates @@ -105,8 +105,8 @@ def test_poisson_2d_2_patch_dirichlet_1(): A = Square('A',bounds1=(0, 0.5), bounds2=(0, 1)) B = Square('B',bounds1=(0.5, 1.), bounds2=(0, 1)) - connectivity = [((0,0,1),(1,0,-1))] - patches = [A,B] + patches = [A, B] + connectivity = [((0, 0, 1), (1, 0,-1), 1)] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates @@ -126,7 +126,7 @@ def test_poisson_2d_2_patch_dirichlet_2(): A = Square('A',bounds1=(0, 0.5), bounds2=(0, 1)) B = Square('B',bounds1=(0.5, 1.), bounds2=(0, 1)) - connectivity = [((0,0,1),(1,0,-1))] + connectivity = [((0, 0, 1), (1, 0,-1), 1)] patches = [A,B] domain = Domain.join(patches, connectivity, 'domain') @@ -155,8 +155,8 @@ def test_poisson_2d_2_patch_dirichlet_3(): D1 = M1(A) D2 = M2(B) - connectivity = [((0,0,1),(1,0,1))] - patches = [D1,D2] + patches = [D1, D2] + connectivity = [((0, 0, 1), (1, 0, 1), 1)] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates @@ -182,8 +182,8 @@ def test_poisson_2d_2_patch_dirichlet_4(): D1 = M1(A) D2 = M2(B) - connectivity = [((0,0,-1),(1,0,-1))] - patches = [D1,D2] + patches = [D1, D2] + connectivity = [((0, 0, -1), (1, 0, -1), 1)] domain = Domain.join(patches, connectivity, 'domain') x,y = domain.coordinates diff --git a/psydac/api/tests/test_postprocessing.py b/psydac/api/tests/test_postprocessing.py index bdf482888..5501d9cd3 100644 --- a/psydac/api/tests/test_postprocessing.py +++ b/psydac/api/tests/test_postprocessing.py @@ -57,7 +57,7 @@ def build_2_mapped_squares(): D2 = mapping_2(B) patches = [D1, D2] - connectivity = [((0,1,1),(1,1,-1))] + connectivity = [((0, 1, 1), (1, 1,-1), 1)] return Domain.join(patches, connectivity, 'domain') @@ -66,7 +66,7 @@ def build_2_squares(): B = Square('B',bounds1=(0.5, 1.), bounds2=(np.pi/2, np.pi)) patches = [A, B] - connectivity = [((0,1,1),(1,1,-1))] + connectivity = [((0, 1, 1), (1, 1,-1), 1)] return Domain.join(patches, connectivity, 'domain') @@ -75,10 +75,9 @@ def build_2_cubes(): B = Cube('B',bounds1=(0.5, 1.), bounds2=(np.pi/2, np.pi), bounds3=(0, 1)) patches = [A, B] - connectivity = [((0,1,1),(1,1,-1))] + connectivity = [((0, 1, 1), (1, 1,-1), (1, 1, 1))] return Domain.join(patches, connectivity, 'domain') - ############################################################################### # Output Manager tests # ############################################################################### @@ -530,8 +529,8 @@ def test_reconstruct_multipatch(dtype): A = Square('A',bounds1=bounds1, bounds2=bounds2_A) B = Square('B',bounds1=bounds1, bounds2=bounds2_B) - connectivity = [((0,1,1),(1,1,-1))] - patches = [A,B] + patches = [A, B] + connectivity = [((0, 1, 1), (1, 1,-1), 1)] domain = Domain.join(patches, connectivity, 'domain') Va = ScalarFunctionSpace('Va', A) From a31e07ab3b4113d19ed51ce97ad6bdff6a469a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 10 Feb 2026 22:59:22 +0100 Subject: [PATCH 040/129] Add interface orientation to multipatch geometry files --- psydac/cad/mesh/multipatch/magnet.h5 | Bin 17517 -> 17783 bytes psydac/cad/mesh/multipatch/square.h5 | Bin 10400 -> 10296 bytes .../mesh/multipatch/square_repeated_knots.h5 | Bin 18656 -> 18616 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/psydac/cad/mesh/multipatch/magnet.h5 b/psydac/cad/mesh/multipatch/magnet.h5 index c18c688ecbce06dcdfa9c708df97a09b7295914d..9e497c2a0cfa2f27e151c597d3f40177d213bfd6 100644 GIT binary patch delta 1022 zcmb`FJ!n%=6vyv9X_9M5Okzx4N^9~$G)ZGV3}P&;P2v{{McXY|BnhI)R>46LB#WRF zO8YqAnz2xX_W2Y$X&l^yj*5tJ(#3*X!6ETA_g-l;Wb!TNfA0Oi!}*l!eLvTKt#5u)d9bDr~bRHl399k#vlKHKWR?+MFW=X3#{N?&$RaH|NAC6W46u%Mn z_9&i+GXL;ooMe#@TsBL6w==VF&CDJZZ=-5ChMT?$xW}U?Ga( z)LsAZqseJ3&`}`W2@_<)IYyZ1ZRey!ipM3=`NY23J;mVKl%~Ixa1fs~>VXWUOSqWm z!)6HenHLTv%_}{m*qw?&J8Og^=9ERZ^c{v$jo9$!j3)p0vZGmc!b2>p8OieVjAYr% zNf!I;K^8?Fuz>xnq33!g>A3;T)8Zi-_a$R>HUQ8nm@|*zZ`-uiVdyGs#l()AE6#uT gA4If@wbE^ae@X@uFFQPX-tf)n-4N=XRQA#zyJmt*dVk8gklhtfG`-L{J#nihAmXw z11i21P2E#Xhy<$+gpPo!bG3#rVCLmQ_{(W8+_qROlw6p8w&no1nNov>dH|`p3JMpHE{>uWCdoS$&w-wOn)UNJKBp)?i88F zG*@`?M^!bZucDI!Mb(&C#Wxo^1u!zXN`e$@Ua2m@$k;pimw4~wcr5`gS!IZw4vdpE zmF1XXB_^L@Q=6QxY%#fBf&;9g78)WBj1xDyY}S=z;$Y0(Y@qm`5#m1uh<;f3V*1aU z7Z!Fn-F{XY>h>wzEDRhF0~ul`e^6Ct+NuWj#8LIhj%sR5S2ZSX+z0ovs0`dkOtCVP ze~Y(Gu3{CK9I2VXICt_P&8d_56&0Ay>TNbu6k}pi)q(oVK@>f%$#U~vd6=7d5N_V9 z3l4cv1F)l24Z)5!wL*3@s{+E&rba+Vw@f_1w^`IUhl8oqWb$FnH3D1hU{M00C-Vy` zFtIvrPP74fW34UF8*vj4a7?~$w?Pz|8i9!sq7>67;fz>Qi!ay};MDTf9_(gcC$O7i Qogt}ZDv~p&nu44O0Lj+I(EtDd diff --git a/psydac/cad/mesh/multipatch/square.h5 b/psydac/cad/mesh/multipatch/square.h5 index ae3f87a271eca786f42e2bb3184cc7a51ba7db8e..781c05feb1fb31842a191dc32aa304f02118a1d5 100644 GIT binary patch delta 455 zcmZ1wxFcYK2BXDB&7Vw+tdpx*H7ENpn=mslteCiQ?PdcOFGh$c(?90Pe#|nGQ#lyG z;2%&4G5{%`Tp%tnc>x;-hrxq^VX`Z`KO@8BsqEepcPMP) zh+}3^P|#J_9K$`2Ws`sc(_{^HDdqs6GF72)pt4$_WM)PN*2%wx(j_PJK4hnrxud0(2&;GRRMuPB!L+XvgK`$$TPQOb3)FZj|GM*vr5m bF}YAxok>G|;>KripEu(%2CQhZI?Oi!#{*mK delta 571 zcmdlHupn@P2IGQ_nm?Hs*(O)BYEJfHHW3zL;9vj&Mj&=HZ%Sfdm@#qV+|33oUW^b` zOh1?>`!UOCPKBsr_`wWT4<)c`XJ%yJoZQVO%Xnk*LMHLa2iP(uD+oz3doZv}o-6J> zc>+5NBg^Ee?B0_#*d;dqU{7P-oWebaW#R$0O#%u`tUL?>K!vtK;Xp;P%E^=esc=o) z!8f@;oMV!3l4dUt#OeTKXE0#8qhjL5+Q}DG1U5^ESb@CqTPR)gE-89}_DwcWZ<_o{ zObkmXMNHfnJGp~Rf@_8n#32(HCvI%pEFrG!em3$mr%dfl42gjuQPD?696plb&vo6 diff --git a/psydac/cad/mesh/multipatch/square_repeated_knots.h5 b/psydac/cad/mesh/multipatch/square_repeated_knots.h5 index 471b157f30a8b39e4862b03efdda05167d709cdb..a1734329025e3eead35d56d94538f4ccb2291522 100644 GIT binary patch delta 466 zcmaDbk#WaF#t9mXJ2q-MGBYx4&SvIiG-4270D}!|5ZVGlF%*bH7>rQ9gARm|pf`CH z%P%GdmdSq1GF($RpwcXp&#>NM+%dUOTz>Kiwgf!^sJRM45Cu1&>UbDDz$``vHU=IB z35Mc~#DY|Y$?x>ECNB^ZUJH|H3HF)=OWLv~)poE#GBYM@&SvIiG)fR)0D}!|5ZVGlF)(mI7>rQ<0v!mWLVWTn zmS0T2m?!%g$Z|pz0eNRw?=aq&ypTzJ@&UGt$qGUoqUa{`FnBPqO#Z6p&&V>_Rw&p? zfB|B4gAl~z2B=Mp3~US_TP2`;n7RXc5CI3qi5u-Ual|oiPT`)zGVuW0Bmo6Rfz65n z{~0Gw5MlwEK36|^vYjE<#2tK-14Of!-t$b(G1$r|JaMDkWCuge$&MoGOc`PmH$DT& zGS1(;TU4KsF=6s+vDV26dJ;@C#3!F(i<@|$W3z;WhZxu^2+u>Dfa;ZSOe_8QHs=_G eF){t;+q_3qkCADC$>cX;EfWvuZLTx15&;0cf?1gW From 326952bac0d314b5c7e126932308a949dbb201d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 12 Feb 2026 11:32:08 +0100 Subject: [PATCH 041/129] Use `ubuntu_install` action in `documentation` workflow Use the custom action `ubuntu_install` for installing non-Python dependencies, like in the `testing` workflow. Add a separate step to install the packages `graphviz` and `pandoc` required for documentation. --- .github/workflows/documentation.yml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 499832421..5766202e0 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -25,9 +25,11 @@ permissions: jobs: build_docs: runs-on: ubuntu-latest + env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN}} OMP_NUM_THREADS: 2 + steps: - name: Checkout uses: actions/checkout@v5 @@ -36,18 +38,9 @@ jobs: uses: actions/setup-python@v5 with: python-version: '3.10' - - name: Install non-Python dependencies on Ubuntu - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: gfortran openmpi-bin libopenmpi-dev libhdf5-openmpi-dev - version: 1.0 - execute_install_scripts: true - - name: Reconfigure non-Python dependencies on Ubuntu - run: | - sudo apt-get update - sudo apt-get install --reinstall openmpi-bin libhdf5-openmpi-dev liblapack-dev libblas-dev - sudo apt install graphviz pandoc + - name: Install non-Python dependencies on Ubuntu + uses: ./.github/actions/ubuntu_install - name: Print information on MPI and HDF5 libraries run: | @@ -107,6 +100,10 @@ jobs: pip install .[test] pip freeze + - name: Install non-Python dependencies for Documentation + run: | + sudo apt install graphviz pandoc + - name: Install Python dependencies for Documentation run: | pip install -r docs/requirements.txt From 800e5069fdfc26f17c87c1e034b1cd3c8588f02d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 12 Feb 2026 11:45:00 +0100 Subject: [PATCH 042/129] Clone the latest release of PETSc (see PR #539) --- .github/workflows/documentation.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 5766202e0..27bedc081 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -61,9 +61,9 @@ jobs: key: petsc-${{ matrix.os }}-${{ matrix.python-version }} - if: steps.cache-petsc.outputs.cache-hit != 'true' - name: Download a specific release of PETSc + name: Download the latest release of PETSc run: | - git clone --depth 1 --branch v3.23.2 https://gitlab.com/petsc/petsc.git + git clone --depth 1 -b release https://gitlab.com/petsc/petsc.git - if: steps.cache-petsc.outputs.cache-hit != 'true' name: Install PETSc with complex support From 9f092d3bbb4e1fbc91290b18ebf6280e65736705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 12 Feb 2026 15:41:03 +0100 Subject: [PATCH 043/129] Install mpi4py >= 4.0.0 in `parallel_h5py` action --- .github/actions/parallel_h5py/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/parallel_h5py/action.yml b/.github/actions/parallel_h5py/action.yml index 192e857a1..8b88fed2d 100644 --- a/.github/actions/parallel_h5py/action.yml +++ b/.github/actions/parallel_h5py/action.yml @@ -20,6 +20,7 @@ runs: run: | export CC="mpicc" export HDF5_MPI="ON" + pip install 'mpi4py>=4.0.0' pip install h5py --no-cache-dir --no-binary h5py pip list From 3702e70d8029670f2d25b65f2ab686ba3f8ceeb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 21 Apr 2026 17:12:47 +0200 Subject: [PATCH 044/129] Add missing orientation (-1 or +1) to connectivity in test_plot_field --- psydac/fem/tests/test_plot_field_2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psydac/fem/tests/test_plot_field_2d.py b/psydac/fem/tests/test_plot_field_2d.py index a099aee02..0b247b40b 100644 --- a/psydac/fem/tests/test_plot_field_2d.py +++ b/psydac/fem/tests/test_plot_field_2d.py @@ -54,7 +54,7 @@ def test_plot_field(use_scalar_field, use_multipatch): mapping_2 = PolarMapping('M2',2, c1= 0., c2= 0., rmin = 0., rmax=1.) D2 = mapping_2(B) - connectivity = [((0,1,1),(1,1,-1))] + connectivity = [((0, 1, 1), (1, 1,-1), 1)] patches = [D1,D2] domain = Domain.join(patches, connectivity, 'domain') else: From d91b2bab6e569f0dd82fb80d2850efed55937d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 21 Apr 2026 17:13:59 +0200 Subject: [PATCH 045/129] Minor cleanup --- psydac/fem/tests/test_plot_field_2d.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/psydac/fem/tests/test_plot_field_2d.py b/psydac/fem/tests/test_plot_field_2d.py index 0b247b40b..bf870bdf7 100644 --- a/psydac/fem/tests/test_plot_field_2d.py +++ b/psydac/fem/tests/test_plot_field_2d.py @@ -46,16 +46,16 @@ def test_plot_field(use_scalar_field, use_multipatch): degree = [2, 2] A = Square('A',bounds1=(0.5, 1.), bounds2=(0, np.pi/2)) - mapping_1 = PolarMapping('M1',2, c1= 0., c2= 0., rmin = 0., rmax=1.) + mapping_1 = PolarMapping('M1', 2, c1= 0., c2= 0., rmin = 0., rmax=1.) D1 = mapping_1(A) if use_multipatch: B = Square('B',bounds1=(0.5, 1.), bounds2=(np.pi/2, np.pi)) - mapping_2 = PolarMapping('M2',2, c1= 0., c2= 0., rmin = 0., rmax=1.) - D2 = mapping_2(B) + mapping_2 = PolarMapping('M2', 2, c1= 0., c2= 0., rmin = 0., rmax=1.) + D2 = mapping_2(B) + patches = [D1, D2] connectivity = [((0, 1, 1), (1, 1,-1), 1)] - patches = [D1,D2] domain = Domain.join(patches, connectivity, 'domain') else: domain = D1 From 0b426e2666801506c68456dc536e9433a8df5020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 21 Apr 2026 18:23:45 +0200 Subject: [PATCH 046/129] Avoid deprecation warning about scipy.odr --- psydac/feec/tests/test_feec_conf_projectors_cart_2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py b/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py index cf64ecf27..1c45c4b33 100644 --- a/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py +++ b/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py @@ -43,7 +43,7 @@ def get_polynomial_function(degree, hom_bc_axes, domain): g0_y = (y - 0.75)**degree[1] expr = g0_x * g0_y - callable_function = lambdify(domain.coordinates, expr) + callable_function = lambdify(domain.coordinates, expr, modules=['numpy']) return expr, callable_function From 349eee8cab2d71f913b792a4e9d44fd421e29f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 21 Apr 2026 18:31:33 +0200 Subject: [PATCH 047/129] Check input domain type in Geometry.from_topological_domain --- psydac/cad/geometry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 1bc0490d0..5889231f0 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -256,6 +256,8 @@ def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=N #-------------------------------------------------------------------------- @classmethod def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mpi_dims_mask=None): + assert isinstance(domain, Domain) + interior = domain.interior if not isinstance(interior, Union): interior = [interior] From a84e24d55870699a7b4bcaf958b63f9c30e73875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 22 Apr 2026 16:16:51 +0200 Subject: [PATCH 048/129] Add docstring to translate() in psydac.cad.cad --- psydac/cad/cad.py | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index abc39baab..27457fde8 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -3,6 +3,8 @@ # LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # # for full license details. # #---------------------------------------------------------------------------# +from typing import Iterable + import numpy as np from psydac.fem.splines import SplineSpace @@ -12,26 +14,48 @@ from psydac.ddm.cart import DomainDecomposition #============================================================================== -def translate(mapping, displ): +def translate(mapping : SplineMapping, displ : Iterable[float]): + """ + Translate a CAD geometry by a given vector displacement. + + Translate a single-patch CAD geometry (given as a spline or NURBS mapping) + by adding a given displacement vector to the control points of the mapping. + + Parameters + ---------- + mapping : SplineMapping + The discrete mapping to be translated, which represents a CAD geometry. + + displ : Iterable[float] + The vector displacement by which to translate the geometry. + + Returns + ------- + SplineMapping + A new discrete mapping representing the translated geometry. + """ + assert isinstance(mapping, SplineMapping) + assert isinstance(displ, Iterable) + displ = np.array(displ) - assert( mapping.pdim == len(displ) ) + assert mapping.pdim == len(displ) pdim = mapping.pdim space = mapping.space control_points = mapping.control_points - fields = [FemField( space ) for d in range( pdim )] + fields = [FemField(space) for d in range(pdim)] # Get spline coefficients for each coordinate X_i starts = space.coeff_space.starts ends = space.coeff_space.ends - idx_to = tuple( slice( s, e+1 ) for s,e in zip( starts, ends ) ) - for i,field in enumerate( fields ): + idx_to = tuple(slice(s, e+1) for s, e in zip(starts, ends)) + for i, field in enumerate(fields): idx_from = tuple(list(idx_to)+[i]) field.coeffs[idx_to] = control_points[idx_from] + displ[i] field.coeffs.update_ghost_regions() - return SplineMapping( *fields ) + return SplineMapping(*fields) #============================================================================== def elevate(mapping, axis, times): From b4038196bf3d934f429714c82e8330b41f9bcdc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 9 Sep 2026 15:30:11 +0200 Subject: [PATCH 049/129] Test SymPDE version 0.20.0 (from GitHub) --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e0ae1db32..a309be93a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,8 +31,7 @@ dependencies = [ 'termcolor', # Our packages from PyPi -# 'sympde == 0.19.2', - 'sympde @ https://github.com/pyccel/sympde/archive/refs/heads/clean-multipatch-domain.zip', + 'sympde @ https://github.com/pyccel/sympde/archive/refs/tags/v0.20.0.zip', 'pyccel >= 2.2.3', 'gelato == 0.12', From ca148d09a3a6e4e2d8bb1380c5e6cea6c5c01134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 11 Sep 2026 18:18:54 +0200 Subject: [PATCH 050/129] Fix wrong action filename in docs workflow --- .github/workflows/documentation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index bfa20018a..e8582463b 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -40,7 +40,7 @@ jobs: python-version: '3.10' - name: Install non-Python dependencies on Ubuntu - uses: ./.github/actions/ubuntu_install + uses: ./.github/actions/ubuntu_installations - name: Print information on MPI and HDF5 libraries run: | From 43082abeb3dfad4ccf27542882209d39e3c15a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 11 Sep 2026 18:21:24 +0200 Subject: [PATCH 051/129] Give name to checkout step in testing workflow --- .github/workflows/testing.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 1b4b33c85..af9c84d7c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -41,7 +41,8 @@ jobs: OMP_NUM_THREADS: 2 steps: - - uses: actions/checkout@v6 + - name: Checkout + uses: actions/checkout@v6 with: submodules: true From 2cb29712633dd52b01ae56120ed190740415ab23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 11 Sep 2026 18:34:07 +0200 Subject: [PATCH 052/129] Fix module name in documentation: cmd.mesh >> cmd.psydac_mesh --- docs/source/modules/cmd.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/modules/cmd.rst b/docs/source/modules/cmd.rst index c1f33d93c..e927a384d 100644 --- a/docs/source/modules/cmd.rst +++ b/docs/source/modules/cmd.rst @@ -7,4 +7,4 @@ cmd :toctree: STUBDIR :template: autosummary/module.rst - cmd.mesh + cmd.psydac_mesh From 0d5d648d2bf01d6c48e49e48f6d8ed63e10cb5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 3 Oct 2026 11:09:42 +0200 Subject: [PATCH 053/129] Use SymPDE 0.20.0 from PyPI --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fde9392b0..ad867dc80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ 'termcolor', # Our packages from PyPi - 'sympde @ https://github.com/pyccel/sympde/archive/refs/tags/v0.20.0.zip', + 'sympde == 0.20.0', 'pyccel >= 2.2.3', 'gelato == 0.12', From 42f2b57f9fd4353faa4ed36a2840189e9dd3786a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 3 Oct 2026 11:11:43 +0200 Subject: [PATCH 054/129] Reactivate all Python versions in testing workflow --- .github/workflows/testing.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index af9c84d7c..428cf0d0c 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -24,8 +24,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-24.04, macos-14 ] -# python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] - python-version: [ '3.11', '3.12' ] + python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] isMerge: - ${{ github.event_name == 'push' && github.ref == 'refs/heads/devel' }} exclude: From 75eebd5c2c20c528b9dca2a217d862428fd36f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sun, 4 Oct 2026 10:34:01 +0200 Subject: [PATCH 055/129] Fix bad merge (wrong Python versions) in testing.yml --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 428cf0d0c..9a05f63a5 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -24,7 +24,7 @@ jobs: fail-fast: false matrix: os: [ ubuntu-24.04, macos-14 ] - python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] + python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14' ] isMerge: - ${{ github.event_name == 'push' && github.ref == 'refs/heads/devel' }} exclude: From 6bb18ec5efa5ba0828e9595411cc14d5e9f5a27e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sun, 4 Oct 2026 12:04:11 +0200 Subject: [PATCH 056/129] Mention SymPDE 0.20.0 in CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0120a82f..ad4d4aba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,10 @@ All notable changes to this project will be documented in this file. ### Fixed -- #576 : Fix bug in `TensorFemSpace.eval_field` caused by round-off at MPI subdomain boundaries +- #527 : Require `sympde==0.20.0` which changes how multipatch interfaces are defined - #576 : Require `sympde==0.19.3` which fixes a bug in the linearity checks - #579 : Require `h5py>=3.16` which installs correctly with `setuptools>=81.0` +- #576 : Fix bug in `TensorFemSpace.eval_field` caused by round-off at MPI subdomain boundaries - #579 : Don't run postprocessing unit tests with `pytest-xdist` because `h5py` is not thread-safe - #579 : Return error code on failure of the `psydac test` and `psydac compile` commands - #577 : Fix installation following release of Pyccel 2.2 From ec4dc64bb9bea2a8635de609603c88af836545cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 5 Oct 2026 12:47:28 +0200 Subject: [PATCH 057/129] First draft of CLAUDE.md configuration file --- CLAUDE.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..25c9dcfc6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# Project: PSYDAC + +## Overview +Python 3 library for isogeometric analysis (IGA). Solve general systems of partial +differential equations (PDEs) in weak form, defined using the domain-specific +language provided by SymPDE. Supports finite element exterior calculus (FEEC) +with tensor-product spline spaces. Handles multi-patch geometries in various ways; +usually broken-FEEC a.k.a. CONGA (conforming/non-conforming Galerkin). +Python code is automatically generated for the assembly of user-defined functionals, +linear forms, and bilinear forms. This Python code is then accelerated to C/Fortran +speed using Pyccel. The library enables large parallel computations on distributed- +memory supercomputers using MPI and OpenMP. + +## Tech stack +- Python 3.10+, type hints required everywhere +- meson-python + mesonpy for building +- pip with submodules (for igakit) +- pytest + pytest-cov + pytest-mpi + pytest-xdist for testing +- sympde for symbolic definition of weak formulations +- pyccel for translation of Python kernels to Fortran or C +- mpi4py for MPI parallelization +- h5py for parallel I/O +- petsc4py for direct linear solvers (optional install) + +## Code standards +- PEP 8 conventions +- Docstrings of public functions and classes follow Numpydoc conventions +- pylint + black + isort for linting/typing +- Computational kernels to be accelerated with Pyccel follow `_kernels.py` + +## Testing conventions +- Each library subpackage contains a `tests/` folder with an `__init__.py` file +- Unit tests are part of the library and shipped with it +- Unit tests can be run with `psydac test` CLI command (see `README.md`) +- Test file names follow `test_.py` +- Test function names follow `test_` or `test__` +- Parametrize unit tests with `@pytest.parametrize` to minimize code duplication +- Keep runtime at a minimum +- Aim for 100% coverage on newly committed code + +## File structure +- psydac/api - high-level Python interface +- psydac/cad - computer-aided design (CAD) functionality +- psydac/cmd - command-line interface (CLI) commands +- psydac/core - splines functionality +- psydac/ddm - MPI decomposition of domain, vectors of spline coefficients, and matrices +- psydac/feec - finite element exterior calculus (FEEC) +- psydac/fem - "finite element method" middle-level Python interface (spaces and fields) +- psydac/linalg - "linear algebra" low-level Python interface (vectors, linear operators, iterative solvers) +- psydac/polar - implementation of polar splines for H1 spaces +- psydac/pyccel - distillation of an old version of Pyccel for Python code generation (to be removed) +- psydac/utilities - various generic functions used across the library +- docs - Sphinx documentation +- examples - Various examples of library usage (mostly Jupyter notebooks) +- scripts - Various scripts for library maintenance +- subprojects - Git submodules + +## Always do +- Before claiming done: + . Run `python -m black --check $(git ls-files "*.py")` + . Run `python -m isort --check $(git ls-files "*.py" | grep -v "FOUND_DUPLICATED_IMPORT.py")` + . Run `pylint --disable=all --enable=unused-import ./psydac --ignore-paths="__pyccel*" --ignore-paths="__epyccel*"` +- Unless specified differently, assume process with `rank=0` in MPI communicator is root +- Use root MPI process to perform serial operations (e.g. open/save image) + +## Never do +- Commit secrets or `.env` files +- Add new dependencies without team discussion +- Use `eval` function together with `input` From c27f72e7245f74ada11769688c9fbf17ae3507c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 5 Oct 2026 14:57:57 +0200 Subject: [PATCH 058/129] Improve CLAUDE.md --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 25c9dcfc6..c2afe8461 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,8 @@ memory supercomputers using MPI and OpenMP. - Docstrings of public functions and classes follow Numpydoc conventions - pylint + black + isort for linting/typing - Computational kernels to be accelerated with Pyccel follow `_kernels.py` +- Minimize code duplication. Never add code that already exists +- Strive for concise, clean, and human-readable code. Avoid useless verbosity ## Testing conventions - Each library subpackage contains a `tests/` folder with an `__init__.py` file @@ -35,7 +37,7 @@ memory supercomputers using MPI and OpenMP. - Test file names follow `test_.py` - Test function names follow `test_` or `test__` - Parametrize unit tests with `@pytest.parametrize` to minimize code duplication -- Keep runtime at a minimum +- Keep run time of tests at a minimum - Aim for 100% coverage on newly committed code ## File structure From d9af9adba5b2675bd934fbe17fb7c6e47739fceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 5 Oct 2026 15:47:44 +0200 Subject: [PATCH 059/129] Expand 'Code standards' section of CLAUDE.md --- CLAUDE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2afe8461..cdddd7c4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,12 +23,15 @@ memory supercomputers using MPI and OpenMP. - petsc4py for direct linear solvers (optional install) ## Code standards -- PEP 8 conventions +- Follow PEP 8 style guide whenever possible - Docstrings of public functions and classes follow Numpydoc conventions - pylint + black + isort for linting/typing - Computational kernels to be accelerated with Pyccel follow `_kernels.py` - Minimize code duplication. Never add code that already exists - Strive for concise, clean, and human-readable code. Avoid useless verbosity +- Self-explanatory names for variables, functions, and classes +- Do not reassign value to an existing variable, especially if the type changes +- Use short comments (one-liners or inline) to explain "why" rather than "what" or "how" ## Testing conventions - Each library subpackage contains a `tests/` folder with an `__init__.py` file From 9adfa83fe990a40a142e8f2aa245e0ec76ecb9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 5 Oct 2026 16:32:22 +0200 Subject: [PATCH 060/129] Further improve CLAUDE.md --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index cdddd7c4b..d25c1bb03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ memory supercomputers using MPI and OpenMP. - Follow PEP 8 style guide whenever possible - Docstrings of public functions and classes follow Numpydoc conventions - pylint + black + isort for linting/typing -- Computational kernels to be accelerated with Pyccel follow `_kernels.py` +- Computational kernels to be accelerated with Pyccel named as `_kernels.py` - Minimize code duplication. Never add code that already exists - Strive for concise, clean, and human-readable code. Avoid useless verbosity - Self-explanatory names for variables, functions, and classes From 10a5088bd9c11e41700d63679f08f8553006962f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 5 Oct 2026 17:33:38 +0200 Subject: [PATCH 061/129] Remove blank imports from psydac/cad/__init__.py --- psydac/cad/__init__.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/psydac/cad/__init__.py b/psydac/cad/__init__.py index 6f39d9a03..419109b64 100644 --- a/psydac/cad/__init__.py +++ b/psydac/cad/__init__.py @@ -3,10 +3,3 @@ # LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # # for full license details. # #---------------------------------------------------------------------------# - -__all__ = ['geometry'] - -from psydac.cad import geometry -from psydac.cad import cad -from psydac.cad import gallery -from psydac.cad import utils From bc216cd15a5f6fc6507f8dc1ca33ae7f776d131c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 5 Oct 2026 17:37:18 +0200 Subject: [PATCH 062/129] Remove obsolete test_geometry_1() from psydac.cad.tests.test_geometry --- psydac/cad/tests/test_geometry.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 18ad343b1..1811fde14 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -365,14 +365,6 @@ def test_import_geopdes_to_nurbs(ncells, degree): if isinstance(mapping, NurbsMapping): assert np.allclose(L_shaped.weights.flatten(), mapping._weights_field.coeffs.toarray(), 1e-15, 1e-15) -#============================================================================== -@pytest.mark.xfail -def test_geometry_1(): - - line = Geometry.as_line(ncells=[10]) - square = Geometry.as_square(ncells=[10, 10]) - cube = Geometry.as_cube(ncells=[10, 10, 10]) - #============================================================================== # CLEAN UP SYMPY NAMESPACE #============================================================================== From 89e0b6b0c3e4d1aefbd877de8846a39b7dc3c02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 5 Oct 2026 18:24:28 +0200 Subject: [PATCH 063/129] Add Pylint configuration to pyproject.toml --- pyproject.toml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index ad867dc80..36c290a3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,3 +76,37 @@ psydac = "psydac.cmd.main:psydac_command" setup = ['--default-library=static'] dist = ['--include-subprojects'] install = ['--only=python-modules'] + +[tool.pylint.main] +py-version = '3.10' +ignore-paths = ['psydac/pyccel/.*', '.*/__e?pyccel__/.*', '.*/__psydac__/.*'] +ignored-modules = ['mpi4py'] # C extension, members not visible to Pylint + +[tool.pylint.basic] +no-docstring-rgx = '^(_|test_|Test)' + +[tool.pylint.'messages control'] +disable = [ + # formatting owned by black / isort + 'line-too-long', + 'trailing-whitespace', + 'bad-indentation', + 'multiple-statements', + 'missing-final-newline', + 'trailing-newlines', + 'wrong-import-order', + # style choices for mathematical code + 'invalid-name', + 'too-many-arguments', + 'too-many-positional-arguments', + 'too-many-locals', + 'too-many-branches', + 'too-many-statements', + 'too-many-instance-attributes', + 'too-few-public-methods', + 'too-many-nested-blocks', + # pytest fixtures shadow outer names by design + 'redefined-outer-name', + 'duplicate-code', + 'fixme', +] From bc6e145c9b1a066d08848971d6bf7f80315d1ecd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 5 Oct 2026 18:28:01 +0200 Subject: [PATCH 064/129] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad4d4aba0..f9dc0e1c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to this project will be documented in this file. - #567 : Improve `psydac test` command (with several new features) - #565 : Expand editable install info in `README.md` - [DEVELOPER] Create action `install_petsc4py` to install PETSc & `petsc4py` w/ complex support +- [DEVELOPER] Configure Pylint in `pyproject.toml` +- [DEVELOPER] Add `CLAUDE.md` with developer rules ### Fixed From a6e0484e519d2d8341903d1bdcadd67f2ec9eb39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 6 Oct 2026 11:29:23 +0200 Subject: [PATCH 065/129] Use Geometry.from_file in polar poisson_2d example `plot_solution` still called the old `Geometry(filename=...)` constructor, which raised a TypeError after the redesign of `Geometry.__init__` when running the example with a spline mapping (option -S). --- psydac/feec/polar/examples/poisson_2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psydac/feec/polar/examples/poisson_2d.py b/psydac/feec/polar/examples/poisson_2d.py index 688f821d1..b0f33cf49 100644 --- a/psydac/feec/polar/examples/poisson_2d.py +++ b/psydac/feec/polar/examples/poisson_2d.py @@ -394,7 +394,7 @@ def plot_solution(use_spline_mapping, model, ncells, periodic, V0_h, refine=10): from psydac.utilities.utils import refine_array_1d if use_spline_mapping: - geometry = Geometry(filename="geo.h5", comm=MPI.COMM_SELF) + geometry = Geometry.from_file("geo.h5", comm=MPI.COMM_SELF) map_discrete = [*geometry.mappings.values()].pop() Vnew = map_discrete.space map_plot = map_discrete From 66c4041e9a299a9789f86f96396082278f4bcb78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 6 Oct 2026 12:28:07 +0200 Subject: [PATCH 066/129] Move imports into functions in polar poisson_2d example The example is meant to be run as a script, so imports are made local to the functions that use them. Only the base classes PolarModel2D and LinearOperator, needed at class definition, remain at module level. Silence Pylint's import-outside-toplevel check for the whole file, with a comment explaining the reason. --- psydac/feec/polar/examples/poisson_2d.py | 34 +++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/psydac/feec/polar/examples/poisson_2d.py b/psydac/feec/polar/examples/poisson_2d.py index b0f33cf49..1f1558d62 100644 --- a/psydac/feec/polar/examples/poisson_2d.py +++ b/psydac/feec/polar/examples/poisson_2d.py @@ -19,18 +19,14 @@ `mpirun -n 6 python poisson_2d.py -S -d 3 3 -t disk -D 0.2 -m 'C0conga'` """ -from dataclasses import dataclass -from time import sleep, time +# Imports are local to keep the CLI fast (e.g. --help) +# pylint: disable=import-outside-toplevel -import numpy as np -from mpi4py import MPI -from pyccel import lambdify -from sympy import Rational, cos, pi, sin, symbols +from dataclasses import dataclass +# Base classes are needed at class definition from psydac.feec.polar.examples.polar_model_2d import PolarModel2D from psydac.linalg.basic import LinearOperator -from psydac.linalg.stencil import StencilMatrix, StencilVector -from psydac.utilities.operators import Laplacian # ============================================================================== @@ -56,6 +52,8 @@ class Poisson2D(PolarModel2D): """ def __init__(self, domain_log, mapping, phi_log, rho_log): + from pyccel import lambdify + super().__init__(domain_log, mapping) self._domain_log = domain_log @@ -93,8 +91,10 @@ def disk(R, shift_D): and source term. """ + import numpy as np from sympde.topology.analytical_mapping import TargetMapping from sympde.topology.domain import Square + from sympy import cos, pi, sin, symbols domain_log = Square("Omega", bounds1=(0, R), bounds2=(0, 2 * np.pi)) params = dict(c1=shift_D * R * R, c2=0, k=0, D=shift_D) @@ -134,8 +134,12 @@ def target(): $\phi(x,y) = (1 - s^8)\sin(k_x(x - 0.5))\cos(k_y y)$. """ + import numpy as np from sympde.topology.analytical_mapping import TargetMapping from sympde.topology.domain import Square + from sympy import Rational, cos, pi, sin + + from psydac.utilities.operators import Laplacian domain_log = Square("Omega", bounds1=(0, 1), bounds2=(0, 2 * np.pi)) params = dict(c1=0, c2=0, k=Rational(3, 10), D=Rational(2, 10)) @@ -167,8 +171,12 @@ def czarny(): $\phi(x,y) = (1 - s^8)\sin(\pi x)\cos(\pi y)$. """ + import numpy as np from sympde.topology.analytical_mapping import CzarnyMapping from sympde.topology.domain import Square + from sympy import Rational, cos, pi, sin + + from psydac.utilities.operators import Laplacian domain_log = Square("Omega", bounds1=(0, 1), bounds2=(0, 2 * np.pi)) params = dict(c1=0, c2=0, eps=Rational(1, 5), b=Rational(7, 5)) @@ -240,6 +248,7 @@ def __init__(self, S, M, P, alpha): C0PolarProjection_V0, C1PolarProjection_U0, ) + from psydac.linalg.stencil import StencilMatrix assert isinstance(S, StencilMatrix) assert isinstance(M, StencilMatrix) @@ -257,6 +266,8 @@ def __init__(self, S, M, P, alpha): self.W0 = W0 def dot(self, x, out=None): + from psydac.linalg.stencil import StencilVector + if out is None: y = self.M._domain.zeros() else: @@ -339,7 +350,7 @@ def compute_errors(phi, phi_ref, M, S): ErrorDiagnostics Reference L2 and H1 norms and relative L2 and H1 errors. """ - + import numpy as np # L2 and H1 norms ref_l2_2 = M.dot_inner(phi_ref.coeffs, phi_ref.coeffs) @@ -386,6 +397,8 @@ def plot_solution(use_spline_mapping, model, ncells, periodic, V0_h, refine=10): Refinement factor for plotting. """ import matplotlib.pyplot as plt + import numpy as np + from mpi4py import MPI from psydac.cad.geometry import Geometry from psydac.ddm.cart import DomainDecomposition @@ -488,7 +501,10 @@ def run_poisson_2d( mpi_comm, # given by function 'parallel_run_from_cli' ): import os + from time import sleep, time + import numpy as np + from mpi4py import MPI from sympde.calculus import dot, grad from sympde.expr import BilinearForm, LinearForm, integral from sympde.topology import ScalarFunctionSpace, elements_of From 3b324b3821dca29be6b0526a28b82348ad32e293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 6 Oct 2026 12:42:08 +0200 Subject: [PATCH 067/129] Allow Geometry.__init__ without mappings The `mappings` parameter defaults to None, meaning that no patch has a mapping, but the type check rejected None. The code handling None was therefore never reached, and it would have failed anyway for single-patch domains, where `domain.interior` is not iterable. Accept None in the type check and build the default dictionary from `domain.interior_names`. Add `test_geometry_init_without_mappings` for single- and two-patch domains. --- psydac/cad/geometry.py | 4 ++-- psydac/cad/tests/test_geometry.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 5889231f0..539b5d092 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -93,7 +93,7 @@ def __init__(self, assert isinstance(pdim, int) assert isinstance(domain, Domain) assert isinstance(ncells, dict) - assert isinstance(mappings, dict) + assert isinstance(mappings, (NoneType, dict)) assert isinstance(periodic, (NoneType, dict)) assert isinstance(comm, (NoneType, MPI.Intracomm)) assert isinstance(mpi_dims_mask, (NoneType, Iterable)) @@ -126,7 +126,7 @@ def __init__(self, # Check sanity of mappings if mappings is None: - mappings = {itr.name : None for itr in domain.interior} + mappings = {name : None for name in interior_names} else: assert set(mappings.keys()) == set_interior_names assert all(isinstance(m, (BasicCallableMapping, NoneType)) for m in mappings.values()) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 1811fde14..b7a4dd804 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -271,6 +271,24 @@ def test_from_topological_domain(): assert geo_from_domain.ddm.starts == expected_starts assert geo_from_domain.ddm.ends == expected_ends +# ============================================================================== +@pytest.mark.parametrize('npatches', [1, 2]) +def test_geometry_init_without_mappings(npatches: int) -> None: + + if npatches == 1: + domain = Square(name='A') + else: + A = Square('A', bounds1=(0, 1), bounds2=(0, 1)) + B = Square('B', bounds1=(1, 2), bounds2=(0, 1)) + domain = Domain.join(patches=[A, B], + connectivity=[((0, 0, 1), (1, 0, -1), 1)], + name='Omega') + + ncells = {name: [4, 4] for name in domain.interior_names} + geo = Geometry(domain, pdim=2, ncells=ncells) + + assert geo.mappings == {name: None for name in domain.interior_names} + #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) @pytest.mark.parametrize( 'degree', [[2,2], [3,2], [2,3], [3,3], [4,4]] ) From 194e8659db1720298a65342ed634ef96935ec3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 6 Oct 2026 12:43:30 +0200 Subject: [PATCH 068/129] Remove obsolete __main__ demo from psydac.cad.cad The demo used an API that no longer exists (`Geometry(patches=...)`, `geo.patches`) and a geometry file that is not in the repository. The functions in this module will be covered by unit tests in `psydac.cad.tests.test_cad`. --- psydac/cad/cad.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index 27457fde8..cd18ff4db 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -203,16 +203,3 @@ def refine(mapping, axis, values): return NurbsMapping( *fields ) return SplineMapping( *fields ) - - - -###################################### -if __name__ == '__main__': - from psydac.cad.geometry import Geometry - - geo = Geometry('square_0.h5') - mapping = geo.patches[0] - new = translate(mapping, [1., 0., 0.]) - - geo = Geometry(patches=[mapping, new]) - geo.export('square_mp.h5') From 2db333c8384b65456c6f56c7541b1ab03ab1fafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 6 Oct 2026 17:49:39 +0200 Subject: [PATCH 069/129] Check user-given domain in Geometry.from_file `discretize(domain, filename=...)` ignored the user's domain and used the one read from the geometry file. Add an optional `domain` argument to `Geometry.from_file` (and `read`), which `discretize_domain` now passes. The given domain is kept as the geometry's domain, after checking that it matches the file: each patch must have a non-analytical mapping (an analytical one would be used in assembly instead of the spline mapping), and the dimension, patch names, parametric bounds, and interfaces with their orientations must agree. Properties are compared one by one to work around pyccel/sympde#197 and pyccel/sympde#198. Update the tests in `psydac.api.ast.tests`, which passed a domain that does not match the geometry file, and add tests for accepted and rejected domains on geometry files generated in a temporary directory. --- psydac/api/ast/tests/test_poisson.py | 8 +-- psydac/api/ast/tests/test_system_1.py | 6 +- psydac/api/ast/tests/test_system_2.py | 6 +- psydac/api/ast/tests/test_system_3.py | 6 +- psydac/api/discretization.py | 2 +- psydac/cad/geometry.py | 80 ++++++++++++++++++++++++--- psydac/cad/tests/test_geometry.py | 57 ++++++++++++++++++- 7 files changed, 142 insertions(+), 23 deletions(-) diff --git a/psydac/api/ast/tests/test_poisson.py b/psydac/api/ast/tests/test_poisson.py index d6470a32f..c6025cd32 100644 --- a/psydac/api/ast/tests/test_poisson.py +++ b/psydac/api/ast/tests/test_poisson.py @@ -12,8 +12,7 @@ from sympde.calculus import grad, dot from sympde.topology import ScalarFunctionSpace from sympde.topology import elements_of, LogicalExpr -from sympde.topology import Square -from sympde.topology import Mapping, IdentityMapping, PolarMapping +from sympde.topology import Domain from sympde.expr import integral from sympde.expr import LinearForm from sympde.expr import BilinearForm @@ -38,9 +37,8 @@ #============================================================================== def test_codegen(): - domain = Square() - M = Mapping('M',2) - domain = M(domain) + domain = Domain.from_file(filename) + M = domain.mapping V = ScalarFunctionSpace('V', domain) u,v = elements_of(V, names='u,v') diff --git a/psydac/api/ast/tests/test_system_1.py b/psydac/api/ast/tests/test_system_1.py index 95fc81744..3694b5503 100644 --- a/psydac/api/ast/tests/test_system_1.py +++ b/psydac/api/ast/tests/test_system_1.py @@ -12,7 +12,7 @@ from sympde.topology import VectorFunctionSpace from sympde.topology import element_of from sympde.topology import Square -from sympde.topology import Mapping#, IdentityMapping, PolarMapping +from sympde.topology import Domain, Mapping from sympde.expr import integral from sympde.expr import LinearForm from sympde.expr import BilinearForm @@ -61,7 +61,9 @@ def test_codegen(): h1norm_F = SemiNorm(error, domain, kind='h1') # Create computational domain from topological domain - domain_h = discretize(domain, filename=filename) + # The spaces are defined on the logical domain, but the geometry needs + # the mapped domain defined in the file + domain_h = discretize(Domain.from_file(filename), filename=filename) # Discrete spaces Vh = discretize(V, domain_h) diff --git a/psydac/api/ast/tests/test_system_2.py b/psydac/api/ast/tests/test_system_2.py index 72ea2dbcd..3aecbddeb 100644 --- a/psydac/api/ast/tests/test_system_2.py +++ b/psydac/api/ast/tests/test_system_2.py @@ -12,7 +12,7 @@ from sympde.topology import ScalarFunctionSpace, VectorFunctionSpace from sympde.topology import element_of from sympde.topology import Square -from sympde.topology import Mapping +from sympde.topology import Domain, Mapping from sympde.expr import integral from sympde.expr import LinearForm from sympde.expr import BilinearForm @@ -59,7 +59,9 @@ def test_codegen(): l = LinearForm((q,v), int_0(f1*q[0]+f2*q[1]+v)) # Create computational domain from topological domain - domain_h = discretize(domain, filename=filename) + # The spaces are defined on the logical domain, but the geometry needs + # the mapped domain defined in the file + domain_h = discretize(Domain.from_file(filename), filename=filename) # Discrete spaces Vh = discretize(V1*V2, domain_h) diff --git a/psydac/api/ast/tests/test_system_3.py b/psydac/api/ast/tests/test_system_3.py index d5bc276a4..576df45b4 100644 --- a/psydac/api/ast/tests/test_system_3.py +++ b/psydac/api/ast/tests/test_system_3.py @@ -12,7 +12,7 @@ from sympde.topology import VectorFunctionSpace from sympde.topology import element_of from sympde.topology import Square -from sympde.topology import Mapping#, IdentityMapping, PolarMapping +from sympde.topology import Domain, Mapping from sympde.expr import integral from sympde.expr import BilinearForm from sympde.expr.evaluation import TerminalExpr @@ -54,7 +54,9 @@ def test_codegen(): b = BilinearForm((u,v), int_0(div(v)*div(u))) # Create computational domain from topological domain - domain_h = discretize(domain, filename=filename) + # The spaces are defined on the logical domain, but the geometry needs + # the mapped domain defined in the file + domain_h = discretize(Domain.from_file(filename), filename=filename) # Discrete spaces Vh = discretize(V, domain_h) diff --git a/psydac/api/discretization.py b/psydac/api/discretization.py index 98a1967aa..00e645461 100644 --- a/psydac/api/discretization.py +++ b/psydac/api/discretization.py @@ -578,7 +578,7 @@ def discretize_domain(domain, *, filename=None, ncells=None, periodic=None, comm raise ValueError("Cannot provide both 'filename' and 'ncells'") elif filename: - return Geometry.from_file(filename, comm=comm, mpi_dims_mask=mpi_dims_mask) + return Geometry.from_file(filename, domain=domain, 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 539b5d092..53f78d61e 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -172,6 +172,7 @@ def __init__(self, def from_file(cls, filename : str, *, + domain : Domain = None, comm : MPI.Intracomm = None, mpi_dims_mask : Iterable[bool] = None): @@ -183,6 +184,14 @@ def from_file(cls, filename: str The path to the geometry file. + domain: sympde.topology.Domain, optional + The topological domain of the geometry. It must match the domain + defined in the file: same dimension, patch names, patches and + interfaces. Each patch must have a non-analytical mapping, to which + the corresponding spline mapping is attached; hence the same domain + should not be used with different geometry files. If not given, the + domain is created from the file. + comm: MPI.Intracomm, optional The MPI intra-communicator. @@ -195,9 +204,15 @@ def from_file(cls, ------- Geometry The new instance. + + Raises + ------ + ValueError + If the given domain does not match the domain defined in the file, + or if one of its patches has no mapping or an analytical mapping. """ geo = super().__new__(cls) - geo.read(filename, comm=comm, mpi_dims_mask=mpi_dims_mask) + geo.read(filename, comm=comm, mpi_dims_mask=mpi_dims_mask, domain=domain) return geo #-------------------------------------------------------------------------- @@ -331,21 +346,22 @@ def mappings(self): def __len__(self): return len(self.domain) - def read(self, filename, comm=None, mpi_dims_mask=None): + def read(self, filename, comm=None, mpi_dims_mask=None, domain=None): # ... check extension of the file _, ext = os.path.splitext(filename) if ext != '.h5': raise ValueError('> Only h5 files are supported') # ... - # read the topological domain - domain = Domain.from_file(filename) - connectivity = construct_connectivity(domain) - - if len(domain) == 1: - interiors = [domain.interior] + # read the topological domain, or check the given one against it + file_domain = Domain.from_file(filename) + if domain is None: + domain = file_domain else: - interiors = list(domain.interior.args) + _check_domain_matches_file(domain, file_domain, filename) + + connectivity = construct_connectivity(domain) + interiors = _get_interiors(domain) if comm is not None: kwargs = dict(driver='mpio', comm=comm) if comm.size > 1 else {} @@ -972,3 +988,49 @@ def _read_patch(lines, i_patch, n_lines_per_patch, list_begin_line): nrb = NURBS(knots, control=points, weights=W) return nrb + +#============================================================================== +def _get_interiors(domain : Domain) -> list: + """Return the list of interior patches of a (multi-patch) domain.""" + return [domain.interior] if len(domain) == 1 else list(domain.interior.args) + +def _get_interface_keys(domain : Domain) -> set: + """Return the interfaces of a domain as hashable tuples, with orientation.""" + interfaces = domain.interfaces + if interfaces is None: + return set() + if not isinstance(interfaces, Union): + interfaces = [interfaces] + return {(i.minus.domain.name, i.minus.axis, i.minus.ext, + i.plus .domain.name, i.plus .axis, i.plus .ext, i.ornt) for i in interfaces} + +def _check_domain_matches_file(domain : Domain, file_domain : Domain, filename : str): + """Raise a ValueError if the domain cannot be used with the geometry file.""" + + # Properties are compared one by one, because domain == file_domain raises + # an error for multi-patch domains (see pyccel/sympde#197) + + # Analytical mappings would be used in assembly instead of the spline ones + for patch in _get_interiors(domain): + if patch.mapping is None or patch.mapping.is_analytical: + raise ValueError(f"Patch {patch.name} must have a non-analytical " + f"mapping to be used with geometry file {filename}") + + if domain.dim != file_domain.dim: + raise ValueError(f"Domain dimension {domain.dim} does not match " + f"dimension {file_domain.dim} in file {filename}") + + if domain.interior_names != file_domain.interior_names: + raise ValueError(f"Patch names {domain.interior_names} do not match " + f"{file_domain.interior_names} in file {filename}") + + # Patch equality ignores the parametric bounds (see pyccel/sympde#198) + for patch, file_patch in zip(_get_interiors(domain), _get_interiors(file_domain)): + bounds = (patch .logical_domain.min_coords, patch .logical_domain.max_coords) + file_bounds = (file_patch.logical_domain.min_coords, file_patch.logical_domain.max_coords) + if bounds != file_bounds: + raise ValueError(f"Parametric bounds {bounds} of patch {patch.name} " + f"do not match {file_bounds} in file {filename}") + + if _get_interface_keys(domain) != _get_interface_keys(file_domain): + raise ValueError(f"Interfaces of the domain do not match those in file {filename}") diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index b7a4dd804..1c9625488 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -9,11 +9,11 @@ import numpy as np from mpi4py import MPI -from sympde.topology import Domain, Line, Square, Cube, Mapping +from sympde.topology import Domain, Line, Square, Cube, Mapping, IdentityMapping from psydac.cad.geometry import Geometry, export_nurbs_to_hdf5, refine_nurbs from psydac.cad.geometry import import_geopdes_to_nurbs -from psydac.cad.cad import elevate, refine +from psydac.cad.cad import elevate, refine, translate from psydac.cad.gallery import quart_circle, circle from psydac.mapping.discrete import SplineMapping, NurbsMapping from psydac.mapping.discrete_gallery import discrete_mapping @@ -289,6 +289,59 @@ def test_geometry_init_without_mappings(npatches: int) -> None: assert geo.mappings == {name: None for name in domain.interior_names} +#============================================================================== +def make_domain(npatches: int, *, ornt: int = 1) -> Domain: + """Create a domain made of one or two unit squares, with generic mappings.""" + if npatches == 1: + return Mapping('G', dim=2)(Square('P')) + + A = Mapping('GA', dim=2)(Square('PA')) + B = Mapping('GB', dim=2)(Square('PB')) + return Domain.join([A, B], [((0, 0, 1), (1, 0, -1), ornt)], 'Omega') + +def export_geometry(domain: Domain, filename: str) -> None: + """Export a spline geometry on the domain, with patches side by side along x.""" + F = discrete_mapping('identity', ncells=[2, 2], degree=[2, 2]) + names = domain.interior_names + mappings = {name: translate(F, [float(i), 0.0]) for i, name in enumerate(names)} + ncells = {name: [2, 2] for name in names} + Geometry(domain, pdim=2, ncells=ncells, mappings=mappings).export(filename) + +#============================================================================== +@pytest.mark.parametrize('npatches', [1, 2]) +@pytest.mark.xdist_group('h5py') +def test_geometry_from_file_with_domain(npatches: int, tmp_path) -> None: + + domain = make_domain(npatches) + filename = str(tmp_path / 'geo.h5') + export_geometry(domain, filename) + + geo = Geometry.from_file(filename, domain=domain) + + # The given domain is kept, and the spline mappings are attached to it + assert geo.domain is domain + patches = [domain.interior] if npatches == 1 else domain.interior.args + for patch, F in zip(patches, geo.mappings.values()): + assert patch.mapping.get_callable_mapping() is F + +#============================================================================== +@pytest.mark.parametrize(('npatches', 'make_wrong_domain', 'message'), [ + (1, lambda: IdentityMapping('G', dim=2)(Square('P')), 'non-analytical'), + (1, lambda: Square('P'), 'non-analytical'), + (1, lambda: Mapping('G', dim=3)(Cube('P')), 'dimension'), + (1, lambda: Mapping('H', dim=2)(Square('P')), 'Patch names'), + (1, lambda: Mapping('G', dim=2)(Square('P', bounds1=(0, 2))), 'Parametric bounds'), + (2, lambda: make_domain(2, ornt=-1), 'Interfaces'), +], ids=['analytical', 'no-mapping', 'dimension', 'names', 'bounds', 'orientation']) +@pytest.mark.xdist_group('h5py') +def test_geometry_from_file_with_wrong_domain(npatches: int, make_wrong_domain, message: str, tmp_path) -> None: + + filename = str(tmp_path / 'geo.h5') + export_geometry(make_domain(npatches), filename) + + with pytest.raises(ValueError, match=message): + Geometry.from_file(filename, domain=make_wrong_domain()) + #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) @pytest.mark.parametrize( 'degree', [[2,2], [3,2], [2,3], [3,3], [4,4]] ) From d3a29737d218d15a655344ba2f4b51745a921ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Tue, 6 Oct 2026 17:53:20 +0200 Subject: [PATCH 070/129] Write plot of multipatch Poisson test to temporary directory `test_poisson_2d_3_patches_dirichlet_2` saved `uh_multipatch_poisson.pdf` in the working directory, leaving an untracked file behind after each test run. Save it in pytest's `tmp_path` instead; the plot call still serves as a smoke test of `plot_field`. --- psydac/api/tests/test_2d_multipatch_mapping_poisson.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/psydac/api/tests/test_2d_multipatch_mapping_poisson.py b/psydac/api/tests/test_2d_multipatch_mapping_poisson.py index 448e1f4c4..3f7a0de19 100644 --- a/psydac/api/tests/test_2d_multipatch_mapping_poisson.py +++ b/psydac/api/tests/test_2d_multipatch_mapping_poisson.py @@ -131,7 +131,7 @@ def test_poisson_2d_2_patches_dirichlet_1(): assert ( abs(h1_error - expected_h1_error) < 1e-7 ) #------------------------------------------------------------------------------ -def test_poisson_2d_3_patches_dirichlet_2(): +def test_poisson_2d_3_patches_dirichlet_2(tmp_path): mapping_1 = IdentityMapping('M1', 2) mapping_2 = PolarMapping ('M2', 2, c1 = 0., c2 = 0.5, rmin = 0., rmax=1.) @@ -155,7 +155,7 @@ def test_poisson_2d_3_patches_dirichlet_2(): l2_error, h1_error, uh = run_poisson_2d(solution, f, domain, ncells=[2**2,2**2], degree=[2,2]) - plot_fn=f'uh_multipatch_poisson.pdf' + plot_fn = str(tmp_path / 'uh_multipatch_poisson.pdf') plot_field(fem_field=uh, Vh=uh.space, domain=domain, title='uh', filename=plot_fn, hide_plot=True) expected_l2_error = 0.0019402242901236006 From 6a957dbc8b3a001139a47b1df5b4518a44b5fa1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 8 Oct 2026 12:34:14 +0200 Subject: [PATCH 071/129] Return Cartesian control points from psydac.cad.gallery `quart_circle`, `annulus` and `circle` returned homogeneous control points (multiplied by the weights), but `NurbsMapping.from_control_points_weights` expects Cartesian ones. The resulting geometries were distorted: e.g. the inner radius of the quarter annulus was 0.414 instead of 0.5 at mid-angle. Return Cartesian control points, and replace the truncated weight 0.707106781187 with 1/sqrt(2). Add `psydac.cad.tests.test_gallery`, which checks that the circular edges of each mapping have the expected radius, with and without center shift. See #608. --- psydac/cad/gallery.py | 33 +++++++++++-------------- psydac/cad/tests/test_gallery.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 psydac/cad/tests/test_gallery.py diff --git a/psydac/cad/gallery.py b/psydac/cad/gallery.py index f9f2d7732..2ad00e4ab 100644 --- a/psydac/cad/gallery.py +++ b/psydac/cad/gallery.py @@ -18,6 +18,8 @@ def quart_circle( rmin=0.5, rmax=1.0, center=None ): degrees = (2, 1) pdim = 2 + s = 1./np.sqrt(2) + knots = [[0.0 , 0.0 , 0.0 , 1.0 , 1.0 , 1.0], [0.0 , 0.0 , 1.0 , 1.0] ] @@ -38,16 +40,13 @@ def quart_circle( rmin=0.5, rmax=1.0, center=None ): weights = np.zeros((3,2)) j = 0 weights[0,j] = 1.0 - weights[1,j] = 0.707106781187 + weights[1,j] = s weights[2,j] = 1.0 j = 1 weights[0,j] = 1.0 - weights[1,j] = 0.707106781187 + weights[1,j] = s weights[2,j] = 1.0 - for i in range(pdim): - points[...,i] *= weights[...] - return degrees, knots, points, weights @@ -57,6 +56,8 @@ def annulus(rmin=0.5, rmax=1.0, center=None): degrees = (1, 2) pdim = 2 + s = 1./np.sqrt(2) + knots = [[0.0 , 0.0 , 1.0 , 1.0], [0.0 , 0.0 , 0.0 , 0.25 , 0.25 , 0.5 , 0.5 , 0.75 , 0.75 , 1.0 , 1.0 , 1.0] ] @@ -89,28 +90,25 @@ def annulus(rmin=0.5, rmax=1.0, center=None): weights = np.zeros((2,9)) j = 0 weights[j,0] = 1.0 - weights[j,1] = 0.707106781187 + weights[j,1] = s weights[j,2] = 1.0 - weights[j,3] = 0.707106781187 + weights[j,3] = s weights[j,4] = 1.0 - weights[j,5] = 0.707106781187 + weights[j,5] = s weights[j,6] = 1.0 - weights[j,7] = 0.707106781187 + weights[j,7] = s weights[j,8] = 1.0 j = 1 weights[j,0] = 1.0 - weights[j,1] = 0.707106781187 + weights[j,1] = s weights[j,2] = 1.0 - weights[j,3] = 0.707106781187 + weights[j,3] = s weights[j,4] = 1.0 - weights[j,5] = 0.707106781187 + weights[j,5] = s weights[j,6] = 1.0 - weights[j,7] = 0.707106781187 + weights[j,7] = s weights[j,8] = 1.0 - for i in range(pdim): - points[...,i] *= weights[...] - return degrees, knots, points, weights @@ -150,9 +148,6 @@ def circle(radius=1.0, center=None): weights[1,2] = s weights[2,2] = 1. - for i in range(pdim): - points[...,i] *= weights[...] - return degrees, knots, points, weights #============================================================================== diff --git a/psydac/cad/tests/test_gallery.py b/psydac/cad/tests/test_gallery.py new file mode 100644 index 000000000..65ae359ea --- /dev/null +++ b/psydac/cad/tests/test_gallery.py @@ -0,0 +1,41 @@ +#---------------------------------------------------------------------------# +# This file is part of PSYDAC which is released under MIT License. See the # +# LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # +# for full license details. # +#---------------------------------------------------------------------------# +import numpy as np +import pytest + +from psydac.cad.gallery import annulus, circle, quart_circle +from psydac.ddm.cart import DomainDecomposition +from psydac.fem.splines import SplineSpace +from psydac.fem.tensor import TensorFemSpace +from psydac.mapping.discrete import NurbsMapping + +#============================================================================== +def make_nurbs_mapping(degrees, knots, points, weights) -> NurbsMapping: + """Create a NURBS mapping from the output of a gallery function.""" + spaces = [SplineSpace(knots=k, degree=p) for k, p in zip(knots, degrees)] + ncells = [len(V.breaks) - 1 for V in spaces] + domain_decomposition = DomainDecomposition(ncells, [False] * len(spaces)) + space = TensorFemSpace(domain_decomposition, *spaces) + return NurbsMapping.from_control_points_weights(space, points, weights) + +#============================================================================== +# Each circular edge of the logical square is given as (axis, value, radius) +@pytest.mark.parametrize('center', [None, (1.0, -2.0)]) +@pytest.mark.parametrize(('gallery_function', 'kwargs', 'circular_edges'), [ + (quart_circle, dict(rmin=0.5, rmax=1.0), [(1, 0.0, 0.5), (1, 1.0, 1.0)]), + (annulus, dict(rmin=0.5, rmax=1.0), [(0, 0.0, 0.5), (0, 1.0, 1.0)]), + (circle, dict(radius=2.0), [(0, 0.0, 2.0), (0, 1.0, 2.0), (1, 0.0, 2.0), (1, 1.0, 2.0)]), +], ids=['quart_circle', 'annulus', 'circle']) +def test_gallery_circular_edges(gallery_function, kwargs: dict, circular_edges: list, center) -> None: + + F = make_nurbs_mapping(*gallery_function(**kwargs, center=center)) + x0 = np.zeros(2) if center is None else np.array(center) + + t = np.linspace(0.0, 1.0, 17) + for axis, value, radius in circular_edges: + eta = [(value, ti) if axis == 0 else (ti, value) for ti in t] + r = [np.linalg.norm(F(*e) - x0) for e in eta] + assert np.allclose(r, radius, rtol=0, atol=1e-14) From 8ba3869bd046ad1e0a21d4fc73e808841d798423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 8 Oct 2026 12:38:37 +0200 Subject: [PATCH 072/129] Use Cartesian control points in psydac.cad.elevate and refine The coordinate fields of a NurbsMapping hold Cartesian control points, but `elevate` and `refine` treated them as homogeneous: they divided them by the weights before passing them to igakit (together with the weights), and multiplied igakit's Cartesian output by the weights. This changed the geometry along directions in which the weights vary. Pass the Cartesian control points to igakit unchanged, and store its output as is. Add `psydac.cad.tests.test_cad`, which checks that elevation and knot insertion along each axis preserve the geometry of a NURBS mapping. See #608. --- psydac/cad/cad.py | 24 ++++---------------- psydac/cad/tests/test_cad.py | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 psydac/cad/tests/test_cad.py diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index cd18ff4db..6463043b2 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -89,10 +89,7 @@ def elevate(mapping, axis, times): if isinstance(mapping, NurbsMapping): weights = mapping._weights_field._coeffs.toarray().reshape(shape) - for i in range(pdim): - points[...,i] /= weights[...] - - # degree elevation using igakit + # degree elevation using igakit, which expects Cartesian control points nrb = NURBS(knots, points, weights=weights) nrb = nrb.clone().elevate(axis, times) @@ -106,12 +103,7 @@ def elevate(mapping, axis, times): idx_to = tuple( slice( s, e+1 ) for s,e in zip( starts, ends ) ) for i,field in enumerate( fields ): idx_from = tuple(list(idx_to)+[i]) - idw_from = tuple(idx_to) - if isinstance(mapping, NurbsMapping): - field.coeffs[idx_to] = nrb.points[idx_from] * nrb.weights[idw_from] - - else: - field.coeffs[idx_to] = nrb.points[idx_from] + field.coeffs[idx_to] = nrb.points[idx_from] field.coeffs.update_ghost_regions() @@ -162,10 +154,7 @@ def refine(mapping, axis, values): if isinstance(mapping, NurbsMapping): weights = mapping._weights_field._coeffs.toarray().reshape(shape) - for i in range(pdim): - points[...,i] /= weights[...] - - # degree elevation using igakit + # knot insertion using igakit, which expects Cartesian control points nrb = NURBS(knots, points, weights=weights) nrb = nrb.clone().refine(axis, values) @@ -184,12 +173,7 @@ def refine(mapping, axis, values): idx_to = tuple( slice( s, e+1 ) for s,e in zip( starts, ends ) ) for i,field in enumerate( fields ): idx_from = tuple(list(idx_to)+[i]) - idw_from = tuple(idx_to) - if isinstance(mapping, NurbsMapping): - field.coeffs[idx_to] = nrb.points[idx_from] * nrb.weights[idw_from] - - else: - field.coeffs[idx_to] = nrb.points[idx_from] + field.coeffs[idx_to] = nrb.points[idx_from] if isinstance(mapping, NurbsMapping): weights_field = FemField( space ) diff --git a/psydac/cad/tests/test_cad.py b/psydac/cad/tests/test_cad.py new file mode 100644 index 000000000..86dc9e49d --- /dev/null +++ b/psydac/cad/tests/test_cad.py @@ -0,0 +1,44 @@ +#---------------------------------------------------------------------------# +# This file is part of PSYDAC which is released under MIT License. See the # +# LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # +# for full license details. # +#---------------------------------------------------------------------------# +import numpy as np +import pytest + +from psydac.cad.cad import elevate, refine +from psydac.cad.gallery import quart_circle +from psydac.cad.tests.test_gallery import make_nurbs_mapping + +#============================================================================== +def assert_same_geometry(F, G) -> None: + """Check that two 2D mappings agree on a uniform grid of the unit square.""" + t = np.linspace(0.0, 1.0, 9) + x_F = [F(e1, e2) for e1 in t for e2 in t] + x_G = [G(e1, e2) for e1 in t for e2 in t] + assert np.allclose(x_F, x_G, rtol=0, atol=1e-14) + +#============================================================================== +# The weights of the quarter annulus vary along axis 0 only +@pytest.mark.parametrize('axis', [0, 1]) +def test_elevate(axis: int) -> None: + + F = make_nurbs_mapping(*quart_circle(rmin=0.5, rmax=1.0)) + G = elevate(F, axis=axis, times=1) + + expected_degree = list(F.space.degree) + expected_degree[axis] += 1 + assert list(G.space.degree) == expected_degree + assert_same_geometry(F, G) + +#============================================================================== +@pytest.mark.parametrize('axis', [0, 1]) +def test_refine(axis: int) -> None: + + values = [0.3, 0.6] + F = make_nurbs_mapping(*quart_circle(rmin=0.5, rmax=1.0)) + G = refine(F, axis=axis, values=values) + + expected_breaks = np.union1d(F.space.spaces[axis].breaks, values) + assert np.allclose(G.space.spaces[axis].breaks, expected_breaks, rtol=0, atol=1e-15) + assert_same_geometry(F, G) From ce0f528ba7e44bb6051871aac6d5f256d437f7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 8 Oct 2026 12:39:35 +0200 Subject: [PATCH 073/129] Fix misleading comment in NurbsMapping.from_control_points_weights The comment claimed that the coordinate fields store the homogeneous control points w*x, while they store the Cartesian ones (the multiplication by the weights was commented out). Fix the comment and remove the dead code. See #608. --- psydac/mapping/discrete.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/psydac/mapping/discrete.py b/psydac/mapping/discrete.py index 61f7e0ac9..debfd3962 100644 --- a/psydac/mapping/discrete.py +++ b/psydac/mapping/discrete.py @@ -843,14 +843,13 @@ def from_control_points_weights(cls, tensor_space, control_points, weights): fields += [FemField(tensor_space)] # Get spline coefficients for each coordinate X_i - # we store w*x where w is the weight and x is the control point + # we store the Cartesian control points x, not the homogeneous w*x starts = tensor_space.coeff_space.starts ends = tensor_space.coeff_space.ends idx_to = tuple(slice(s, e+1) for s,e in zip(starts, ends)) for i, field in enumerate(fields[:-1]): idx_from = (*idx_to, i) -# idw_from = tuple(idx_to) - field.coeffs[idx_to] = control_points[idx_from] #* weights[idw_from] + field.coeffs[idx_to] = control_points[idx_from] # weights idx_from = tuple(idx_to) From bc0afb235017f1b44dfefbf5afbd3a35cb056a08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 8 Oct 2026 12:42:07 +0200 Subject: [PATCH 074/129] Add asserts to test_geometry_2d_1 and test_geometry_2d_2 Both tests only checked that exporting and reading geometries did not crash. Add the helpers `assert_geometries_equal`, which compares two geometries patch by patch (dimensions, ncells, periodicity, mapping type, degrees, knots and coefficients), and `check_round_trips`, which checks that a geometry is preserved by export and read, by a second export and read, and by `Geometry.from_discrete_mapping`. Moreover, check that the geometry read from file is the identity map (2d_1) and a quarter annulus with the expected radii (2d_2); the latter fails without the fixes for #608. Write the HDF5 files to pytest's `tmp_path` instead of the working directory, and remove them from the cleanup list in `teardown_module`. --- psydac/cad/tests/test_geometry.py | 97 +++++++++++++++++-------------- 1 file changed, 53 insertions(+), 44 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 1c9625488..118f55546 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -24,49 +24,70 @@ base_dir = os.path.dirname(os.path.realpath(__file__)) +#============================================================================== +def assert_geometries_equal(geo: Geometry, ref: Geometry) -> None: + """Check that two geometries are equal, comparing their patches by position.""" + assert geo.ldim == ref.ldim + assert geo.pdim == ref.pdim + assert list(geo.ncells.values()) == list(ref.ncells.values()) + assert [list(p) for p in geo.periodic.values()] == [list(p) for p in ref.periodic.values()] + assert len(geo.mappings) == len(ref.mappings) + + for F, F_ref in zip(geo.mappings.values(), ref.mappings.values()): + assert type(F) is type(F_ref) + assert list(F.space.degree) == list(F_ref.space.degree) + assert all(np.array_equal(k, k_ref) for k, k_ref in zip(F.space.knots, F_ref.space.knots)) + + fields = [*F .fields, F .weights_field] if isinstance(F, NurbsMapping) else F .fields + fields_ref = [*F_ref.fields, F_ref.weights_field] if isinstance(F, NurbsMapping) else F_ref.fields + assert all(np.array_equal(f.coeffs.toarray(), f_ref.coeffs.toarray()) + for f, f_ref in zip(fields, fields_ref)) + +def check_round_trips(geo: Geometry, mapping: SplineMapping, tmp_path) -> Geometry: + """Check that a single-patch geometry is preserved by export/read and + by from_discrete_mapping, and return the geometry read from file.""" + filename = str(tmp_path / 'geo.h5') + geo.export(filename) + geo_read = Geometry.from_file(filename) + assert_geometries_equal(geo_read, geo) + + # A geometry read from file is exported again without changes + filename_again = str(tmp_path / 'geo_again.h5') + geo_read.export(filename_again) + assert_geometries_equal(Geometry.from_file(filename_again), geo) + + assert_geometries_equal(Geometry.from_discrete_mapping(mapping), geo) + + return geo_read + #============================================================================== @pytest.mark.xdist_group('h5py') -def test_geometry_2d_1(): +def test_geometry_2d_1(tmp_path): - ncells = [1,1] - degree = [2,2] # create an identity mapping - mapping = discrete_mapping('identity', ncells=ncells, degree=degree) + mapping = discrete_mapping('identity', ncells=[1, 1], degree=[2, 2]) # create a topological domain F = Mapping('F', dim=2) domain = F(Square(name='Omega')) - # associate the mapping to the topological domain - mappings = {domain.name: mapping} - - # Define ncells as a dict - ncells = {domain.name:ncells} - # create a geometry from a topological domain and the dict of mappings - geo = Geometry(domain=domain, pdim=2, ncells=ncells, mappings=mappings) + geo = Geometry(domain=domain, pdim=2, ncells={domain.name: [1, 1]}, mappings={domain.name: mapping}) - # export the geometry - geo.export('geo.h5') + geo_read = check_round_trips(geo, mapping, tmp_path) - # read it again - geo_0 = Geometry.from_file('geo.h5') - - # export it again - geo_0.export('geo_0.h5') - - # create a geometry from a discrete mapping - geo_1 = Geometry.from_discrete_mapping(mapping) - - # export it - geo_1.export('geo_1.h5') + # The mapping read from file is the identity + F_read = list(geo_read.mappings.values())[0] + t = np.linspace(0.0, 1.0, 5) + assert all(np.allclose(F_read(e1, e2), [e1, e2], rtol=0, atol=1e-15) for e1 in t for e2 in t) #============================================================================== @pytest.mark.xdist_group('h5py') -def test_geometry_2d_2(): +def test_geometry_2d_2(tmp_path): # create a nurbs mapping - degrees, knots, points, weights = quart_circle( rmin=0.5, rmax=1.0, center=None ) + rmin, rmax = 0.5, 1.0 + degrees, knots, points, weights = quart_circle( rmin=rmin, rmax=rmax, center=None ) # Create tensor spline space, distributed spaces = [SplineSpace( knots=k, degree=p ) for k,p in zip(knots, degrees)] @@ -96,20 +117,13 @@ def test_geometry_2d_2(): # create a geometry from a topological domain and the dict of mappings geo = Geometry(domain=domain, pdim=2, ncells=ncells, periodic=periodic, mappings=mappings) - # export the geometry - geo.export('quart_circle.h5') - - # read it again - geo_0 = Geometry.from_file('quart_circle.h5') - - # export it again - geo_0.export('quart_circle_0.h5') + geo_read = check_round_trips(geo, mapping, tmp_path) - # create a geometry from a discrete mapping - geo_1 = Geometry.from_discrete_mapping(mapping) - - # export it - geo_1.export('quart_circle_1.h5') + # The mapping read from file is a quarter annulus, radial along axis 1 + F_read = list(geo_read.mappings.values())[0] + t = np.linspace(0.0, 1.0, 9) + assert np.allclose([np.hypot(*F_read(e1, 0.0)) for e1 in t], rmin, rtol=0, atol=1e-14) + assert np.allclose([np.hypot(*F_read(e1, 1.0)) for e1 in t], rmax, rtol=0, atol=1e-14) #============================================================================== # TODO to be removed @@ -447,12 +461,7 @@ def teardown_module(): # Remove HDF5 files generated by Geometry.export() filenames = [ - 'geo.h5', - 'geo_0.h5', - 'geo_1.h5', 'quart_circle.h5', - 'quart_circle_0.h5', - 'quart_circle_1.h5', 'circle.h5', 'pipe.h5', 'L_shaped.h5', From a6c0ef4095471a356972b3baad2ef078458574e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 8 Oct 2026 15:32:44 +0200 Subject: [PATCH 075/129] Test elevate and refine on all NURBS mappings of the CAD gallery Parametrize `test_elevate` and `test_refine` over `quart_circle`, `annulus` and `circle`, whose weights vary along axis 0, axis 1, and both axes, respectively. Without the fix for #608, exactly the 8 cases where the weights vary along the refined/elevated axis fail. Insert four knots in `test_refine`, avoiding the existing double knots of `annulus`. --- psydac/cad/tests/test_cad.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/psydac/cad/tests/test_cad.py b/psydac/cad/tests/test_cad.py index 86dc9e49d..c4115a3b7 100644 --- a/psydac/cad/tests/test_cad.py +++ b/psydac/cad/tests/test_cad.py @@ -7,9 +7,13 @@ import pytest from psydac.cad.cad import elevate, refine -from psydac.cad.gallery import quart_circle +from psydac.cad.gallery import annulus, circle, quart_circle from psydac.cad.tests.test_gallery import make_nurbs_mapping +# The weights vary along axis 0 (quart_circle), axis 1 (annulus), or both (circle) +gallery_functions = pytest.mark.parametrize('gallery_function', [quart_circle, annulus, circle], + ids=['quart_circle', 'annulus', 'circle']) + #============================================================================== def assert_same_geometry(F, G) -> None: """Check that two 2D mappings agree on a uniform grid of the unit square.""" @@ -19,11 +23,11 @@ def assert_same_geometry(F, G) -> None: assert np.allclose(x_F, x_G, rtol=0, atol=1e-14) #============================================================================== -# The weights of the quarter annulus vary along axis 0 only +@gallery_functions @pytest.mark.parametrize('axis', [0, 1]) -def test_elevate(axis: int) -> None: +def test_elevate(axis: int, gallery_function) -> None: - F = make_nurbs_mapping(*quart_circle(rmin=0.5, rmax=1.0)) + F = make_nurbs_mapping(*gallery_function()) G = elevate(F, axis=axis, times=1) expected_degree = list(F.space.degree) @@ -32,11 +36,13 @@ def test_elevate(axis: int) -> None: assert_same_geometry(F, G) #============================================================================== +@gallery_functions @pytest.mark.parametrize('axis', [0, 1]) -def test_refine(axis: int) -> None: +def test_refine(axis: int, gallery_function) -> None: - values = [0.3, 0.6] - F = make_nurbs_mapping(*quart_circle(rmin=0.5, rmax=1.0)) + # Avoid the double knots of annulus, which already have multiplicity p + values = [0.1, 0.35, 0.6, 0.9] + F = make_nurbs_mapping(*gallery_function()) G = refine(F, axis=axis, values=values) expected_breaks = np.union1d(F.space.spaces[axis].breaks, values) From 295fe3085444cd46dc05918a220a9f0edd906259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 8 Oct 2026 15:35:40 +0200 Subject: [PATCH 076/129] Remove test_geometry_2d_3 and test_geometry_2d_4 Both tests were marked "TODO to be removed" and had no asserts: they only refined NURBS mappings from the CAD gallery and exported the resulting geometries. Export round trips of refined NURBS geometries are checked by `test_geometry_2d_2`, and the preservation of the gallery geometries under `elevate` and `refine` (including `circle`, whose weights vary along both axes) by `psydac.cad.tests.test_cad`. Remove the unused import of `circle` and the corresponding file names in `teardown_module`. --- psydac/cad/tests/test_geometry.py | 72 +------------------------------ 1 file changed, 1 insertion(+), 71 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 118f55546..e48e7c79d 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -14,7 +14,7 @@ from psydac.cad.geometry import Geometry, export_nurbs_to_hdf5, refine_nurbs from psydac.cad.geometry import import_geopdes_to_nurbs from psydac.cad.cad import elevate, refine, translate -from psydac.cad.gallery import quart_circle, circle +from psydac.cad.gallery import quart_circle from psydac.mapping.discrete import SplineMapping, NurbsMapping from psydac.mapping.discrete_gallery import discrete_mapping from psydac.fem.splines import SplineSpace @@ -125,74 +125,6 @@ def test_geometry_2d_2(tmp_path): assert np.allclose([np.hypot(*F_read(e1, 0.0)) for e1 in t], rmin, rtol=0, atol=1e-14) assert np.allclose([np.hypot(*F_read(e1, 1.0)) for e1 in t], rmax, rtol=0, atol=1e-14) -#============================================================================== -# TODO to be removed -@pytest.mark.xdist_group('h5py') -def test_geometry_2d_3(): - - # create a nurbs mapping - degrees, knots, points, weights = quart_circle( rmin=0.5, rmax=1.0, center=None ) - - # Create tensor spline space, distributed - spaces = [SplineSpace( knots=k, degree=p ) for k,p in zip(knots, degrees)] - ncells = [len(space.breaks)-1 for space in spaces] - domain_decomposition = DomainDecomposition(ncells=ncells, periods=[False]*2, comm=None) - - space = TensorFemSpace( domain_decomposition, *spaces ) - - mapping = NurbsMapping.from_control_points_weights( space, points, weights ) - - mapping = elevate( mapping, axis=1, times=1 ) - - n = 8 - t = np.linspace(0, 1, n+1)[1:-1] - - # TODO allow for 1d numpy array - t = list(t) - - for axis in [0, 1]: - mapping = refine( mapping, axis=axis, values=t ) - - # create a geometry from a discrete mapping - geo = Geometry.from_discrete_mapping(mapping) - - # export it - geo.export('quart_circle.h5') - -#============================================================================== -# TODO to be removed -@pytest.mark.xdist_group('h5py') -def test_geometry_2d_4(): - - # create a nurbs mapping - radius = np.sqrt(2)/2. - degrees, knots, points, weights = circle( radius=radius, center=None ) - - # Create tensor spline space, distributed - spaces = [SplineSpace( knots=k, degree=p ) for k,p in zip(knots, degrees)] - ncells = [len(space.breaks)-1 for space in spaces] - domain_decomposition = DomainDecomposition(ncells=ncells, periods=[False]*2, comm=None) - - space = TensorFemSpace( domain_decomposition, *spaces ) - - mapping = NurbsMapping.from_control_points_weights( space, points, weights ) - - n = 8 -# n = 32 - t = np.linspace(0, 1, n+1)[1:-1] - - # TODO allow for 1d numpy array - t = list(t) - - for axis in [0, 1]: - mapping = refine( mapping, axis=axis, values=t ) - - # create a geometry from a discrete mapping - geo = Geometry.from_discrete_mapping(mapping) - - # export it - geo.export('circle.h5') - #============================================================================== @pytest.mark.mpi def test_geometry_with_mpi_dims_mask(): @@ -461,8 +393,6 @@ def teardown_module(): # Remove HDF5 files generated by Geometry.export() filenames = [ - 'quart_circle.h5', - 'circle.h5', 'pipe.h5', 'L_shaped.h5', ] From 123ff811c61804a811f9a25f913cddef150cc0bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 9 Oct 2026 10:43:34 +0200 Subject: [PATCH 077/129] Keep the weights of NURBS mappings in psydac.cad.translate `translate` always returned a SplineMapping, so the weights of a NurbsMapping were lost and the result was not a translation of the original geometry. Since the control points are Cartesian, translating a NURBS mapping only moves the control points: return a NurbsMapping with a copy of the original weights field. Add `test_translate_2d`, for a spline mapping and for the NURBS mappings of the CAD gallery. Fixes #609. --- psydac/cad/cad.py | 8 +++++++- psydac/cad/tests/test_cad.py | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index 6463043b2..315c48150 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -20,6 +20,7 @@ def translate(mapping : SplineMapping, displ : Iterable[float]): Translate a single-patch CAD geometry (given as a spline or NURBS mapping) by adding a given displacement vector to the control points of the mapping. + The weights of a NURBS mapping are not changed. Parameters ---------- @@ -32,7 +33,8 @@ def translate(mapping : SplineMapping, displ : Iterable[float]): Returns ------- SplineMapping - A new discrete mapping representing the translated geometry. + A new discrete mapping representing the translated geometry. It is a + NurbsMapping if the input mapping is a NurbsMapping. """ assert isinstance(mapping, SplineMapping) assert isinstance(displ, Iterable) @@ -55,6 +57,10 @@ def translate(mapping : SplineMapping, displ : Iterable[float]): field.coeffs[idx_to] = control_points[idx_from] + displ[i] field.coeffs.update_ghost_regions() + # The control points are Cartesian, hence the weights are not affected + if isinstance(mapping, NurbsMapping): + return NurbsMapping(*fields, mapping.weights_field.copy()) + return SplineMapping(*fields) #============================================================================== diff --git a/psydac/cad/tests/test_cad.py b/psydac/cad/tests/test_cad.py index c4115a3b7..b5f708f6f 100644 --- a/psydac/cad/tests/test_cad.py +++ b/psydac/cad/tests/test_cad.py @@ -6,9 +6,10 @@ import numpy as np import pytest -from psydac.cad.cad import elevate, refine +from psydac.cad.cad import elevate, refine, translate from psydac.cad.gallery import annulus, circle, quart_circle from psydac.cad.tests.test_gallery import make_nurbs_mapping +from psydac.mapping.discrete_gallery import discrete_mapping # The weights vary along axis 0 (quart_circle), axis 1 (annulus), or both (circle) gallery_functions = pytest.mark.parametrize('gallery_function', [quart_circle, annulus, circle], @@ -22,6 +23,22 @@ def assert_same_geometry(F, G) -> None: x_G = [G(e1, e2) for e1 in t for e2 in t] assert np.allclose(x_F, x_G, rtol=0, atol=1e-14) +#============================================================================== +@pytest.mark.parametrize('make_mapping', [ + lambda: discrete_mapping('collela', ncells=[4, 4], degree=[2, 2]), + lambda: make_nurbs_mapping(*quart_circle()), + lambda: make_nurbs_mapping(*annulus()), + lambda: make_nurbs_mapping(*circle()), +], ids=['spline', 'quart_circle', 'annulus', 'circle']) +def test_translate_2d(make_mapping) -> None: + + displ = np.array([1.0, -2.0]) + F = make_mapping() + G = translate(F, displ) + + assert type(G) is type(F) + assert_same_geometry(lambda *eta: np.asarray(F(*eta)) + displ, G) + #============================================================================== @gallery_functions @pytest.mark.parametrize('axis', [0, 1]) From c460ed6e00e3a576aed48d344f8071db9ab0aceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 9 Oct 2026 10:58:52 +0200 Subject: [PATCH 078/129] Accept spline mappings in psydac.cad.elevate and refine Both functions checked `isinstance(mapping, (SplineSpace, NurbsMapping))` instead of `isinstance(mapping, SplineMapping)`, rejecting every spline mapping that is not a NURBS mapping. Check for SplineMapping, which also accepts NurbsMapping. Restructure `psydac.cad.tests.test_cad`: run the 2D tests of `translate`, `elevate` and `refine` on a spline mapping and on the NURBS mappings of the CAD gallery, add 3D tests of `elevate` and `refine` on a spline mapping, and check that the type of the mapping is preserved. Fixes #610. --- psydac/cad/cad.py | 4 +- psydac/cad/tests/test_cad.py | 79 ++++++++++++++++++++++-------------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index 315c48150..db7bb9be6 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -76,7 +76,7 @@ def elevate(mapping, axis, times): except: raise ImportError('Could not find igakit.') - assert( isinstance(mapping, (SplineSpace, NurbsMapping)) ) + assert isinstance(mapping, SplineMapping) assert( isinstance(times, int) ) assert( isinstance(axis, int) ) @@ -141,7 +141,7 @@ def refine(mapping, axis, values): except: raise ImportError('Could not find igakit.') - assert( isinstance(mapping, (SplineSpace, NurbsMapping)) ) + assert isinstance(mapping, SplineMapping) assert( isinstance(values, (list, tuple)) ) assert( isinstance(axis, int) ) diff --git a/psydac/cad/tests/test_cad.py b/psydac/cad/tests/test_cad.py index b5f708f6f..4fa372366 100644 --- a/psydac/cad/tests/test_cad.py +++ b/psydac/cad/tests/test_cad.py @@ -3,6 +3,8 @@ # LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # # for full license details. # #---------------------------------------------------------------------------# +from itertools import product + import numpy as np import pytest @@ -11,57 +13,72 @@ from psydac.cad.tests.test_gallery import make_nurbs_mapping from psydac.mapping.discrete_gallery import discrete_mapping -# The weights vary along axis 0 (quart_circle), axis 1 (annulus), or both (circle) -gallery_functions = pytest.mark.parametrize('gallery_function', [quart_circle, annulus, circle], - ids=['quart_circle', 'annulus', 'circle']) - -#============================================================================== -def assert_same_geometry(F, G) -> None: - """Check that two 2D mappings agree on a uniform grid of the unit square.""" - t = np.linspace(0.0, 1.0, 9) - x_F = [F(e1, e2) for e1 in t for e2 in t] - x_G = [G(e1, e2) for e1 in t for e2 in t] - assert np.allclose(x_F, x_G, rtol=0, atol=1e-14) - -#============================================================================== -@pytest.mark.parametrize('make_mapping', [ +# A spline mapping, and NURBS mappings whose weights vary along axis 0 +# (quart_circle), axis 1 (annulus), or both (circle) +mappings_2d = pytest.mark.parametrize('make_mapping', [ lambda: discrete_mapping('collela', ncells=[4, 4], degree=[2, 2]), lambda: make_nurbs_mapping(*quart_circle()), lambda: make_nurbs_mapping(*annulus()), lambda: make_nurbs_mapping(*circle()), ], ids=['spline', 'quart_circle', 'annulus', 'circle']) -def test_translate_2d(make_mapping) -> None: - displ = np.array([1.0, -2.0]) - F = make_mapping() - G = translate(F, displ) - - assert type(G) is type(F) - assert_same_geometry(lambda *eta: np.asarray(F(*eta)) + displ, G) +def make_mapping_3d(): + return discrete_mapping('collela', ncells=[2, 2, 2], degree=[2, 2, 2]) #============================================================================== -@gallery_functions -@pytest.mark.parametrize('axis', [0, 1]) -def test_elevate(axis: int, gallery_function) -> None: +def assert_same_geometry(F, G) -> None: + """Check that two mappings agree on a uniform grid of the logical domain of G.""" + eta = list(product(np.linspace(0.0, 1.0, 9), repeat=G.ldim)) + assert np.allclose([F(*e) for e in eta], [G(*e) for e in eta], rtol=0, atol=1e-14) - F = make_nurbs_mapping(*gallery_function()) +def check_elevate(F, axis: int) -> None: + """Check that degree elevation along an axis preserves the geometry.""" G = elevate(F, axis=axis, times=1) expected_degree = list(F.space.degree) expected_degree[axis] += 1 + assert type(G) is type(F) assert list(G.space.degree) == expected_degree assert_same_geometry(F, G) -#============================================================================== -@gallery_functions -@pytest.mark.parametrize('axis', [0, 1]) -def test_refine(axis: int, gallery_function) -> None: - +def check_refine(F, axis: int) -> None: + """Check that knot insertion along an axis preserves the geometry.""" # Avoid the double knots of annulus, which already have multiplicity p values = [0.1, 0.35, 0.6, 0.9] - F = make_nurbs_mapping(*gallery_function()) G = refine(F, axis=axis, values=values) expected_breaks = np.union1d(F.space.spaces[axis].breaks, values) + assert type(G) is type(F) assert np.allclose(G.space.spaces[axis].breaks, expected_breaks, rtol=0, atol=1e-15) assert_same_geometry(F, G) + +#============================================================================== +@mappings_2d +def test_translate_2d(make_mapping) -> None: + + displ = np.array([1.0, -2.0]) + F = make_mapping() + G = translate(F, displ) + + assert type(G) is type(F) + assert_same_geometry(lambda *eta: np.asarray(F(*eta)) + displ, G) + +#============================================================================== +@mappings_2d +@pytest.mark.parametrize('axis', [0, 1]) +def test_elevate_2d(axis: int, make_mapping) -> None: + check_elevate(make_mapping(), axis) + +@pytest.mark.parametrize('axis', [0, 1, 2]) +def test_elevate_3d(axis: int) -> None: + check_elevate(make_mapping_3d(), axis) + +#============================================================================== +@mappings_2d +@pytest.mark.parametrize('axis', [0, 1]) +def test_refine_2d(axis: int, make_mapping) -> None: + check_refine(make_mapping(), axis) + +@pytest.mark.parametrize('axis', [0, 1, 2]) +def test_refine_3d(axis: int) -> None: + check_refine(make_mapping_3d(), axis) From d968c3fa54ae77243698d9aeff9ad627fa06bb60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 9 Oct 2026 11:16:08 +0200 Subject: [PATCH 079/129] Avoid setting the shape of arrays in KroneckerLinearSolver Setting the `shape` attribute of a NumPy array is deprecated since NumPy 2.5. Replace the nine occurrences in `psydac.linalg.kron` with calls to `reshape(..., copy=False)`, which also guarantee that no data is copied (an error is raised if a view is not possible). --- psydac/linalg/kron.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/psydac/linalg/kron.py b/psydac/linalg/kron.py index 045c6d015..f67691f41 100644 --- a/psydac/linalg/kron.py +++ b/psydac/linalg/kron.py @@ -654,8 +654,7 @@ def _inslice_to_temp(self, inslice, target): Copies data to an internal, 1-dimensional temporary array. Does not allocate any new array. """ - targetview = target[:self._localsize] - targetview.shape = inslice.shape + targetview = target[:self._localsize].reshape(inslice.shape, copy=False) targetview[:] = inslice @@ -664,11 +663,9 @@ def _reorder_temp_to_temp(self, source, target, i): Reorders the dimensions of the temporary arrays, and copies data from one to another. Does not allocate any new array. """ - sourceview = source[:self._localsize] - sourceview.shape = self._shapes[i] + sourceview = source[:self._localsize].reshape(self._shapes[i], copy=False) - targetview = target[:self._localsize] - targetview.shape = self._shapes[i+1] + targetview = target[:self._localsize].reshape(self._shapes[i+1], copy=False) targetview[:] = sourceview.transpose(self._perm) @@ -677,8 +674,7 @@ def _reorder_temp_to_outslice(self, source, outslice): Reorders the dimensions of the temporary array for a final time, and copies it to the output. Does not allocate any new array. """ - sourceview = source[:self._localsize] - sourceview.shape = self._shapes[-1] + sourceview = source[:self._localsize].reshape(self._shapes[-1], copy=False) outslice[:] = sourceview.transpose(self._perm) @@ -729,8 +725,8 @@ def solve_pass(self, workmem, tempmem): Ignored, it exists for compatibility with the parallel solver. """ # reshape necessary memory in column-major - view = workmem[:self._datasize] - view.shape = (self._numrhs,self._dimrhs) + view = workmem[:self._datasize].reshape( + (self._numrhs, self._dimrhs), copy=False) # call solver in in-place mode self._solver.solve(view, out=view) @@ -879,11 +875,12 @@ def _blocked_to_contiguous(self, blocked, contiguous): Copies from a blocked view to a contiguous view. Equals roughly a partial transpose, if the block sizes in the cartesian grid are the same. """ - blocked_view = blocked[:self._datasize] - blocked_view.shape = (self._mlocal,self._nglobal) + blocked_view = blocked[:self._datasize].reshape( + (self._mlocal, self._nglobal), copy=False) for start, end in zip(self._cartstart, self._cartend): - contiguouspart = contiguous[start*self._mlocal:end*self._mlocal] - contiguouspart.shape = (self._mlocal,end-start) + contiguouspart = np.reshape( + contiguous[start*self._mlocal:end*self._mlocal], + (self._mlocal, end-start), copy=False) blocked_view[:,start:end] = contiguouspart def _contiguous_to_blocked(self, blocked, contiguous): @@ -891,11 +888,12 @@ def _contiguous_to_blocked(self, blocked, contiguous): Copies from a contiguous view to a blocked view. Equals roughly a partial transpose, if the block sizes in the cartesian grid are the same. """ - blocked_view = blocked[:self._datasize] - blocked_view.shape = (self._mlocal,self._nglobal) + blocked_view = blocked[:self._datasize].reshape( + (self._mlocal, self._nglobal), copy=False) for start, end in zip(self._cartstart, self._cartend): - contiguouspart = contiguous[start*self._mlocal:end*self._mlocal] - contiguouspart.shape = (self._mlocal,end-start) + contiguouspart = np.reshape( + contiguous[start*self._mlocal:end*self._mlocal], + (self._mlocal, end-start), copy=False) contiguouspart[:] = blocked_view[:,start:end] def solve_pass(self, workmem, tempmem): From 739a5a42388509f431325758be25f7074cb224e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 9 Oct 2026 15:37:01 +0200 Subject: [PATCH 080/129] Fix psydac.cad.elevate and refine for distributed mappings Both functions collected the control points and weights with `toarray()`, which in parallel only contains the coefficients owned by the current process (and zeros elsewhere), and passed them to igakit as global arrays. In `elevate` the domain decomposition does not change, hence each process only needs the coefficients owned by its neighbours: update the ghost regions of the input fields and use `toarray(with_pads=True)`. In `refine` the new balanced decomposition may move the subdomain boundaries by more than p cells: gather the global arrays on every process with an MPI reduction. Moreover, update the ghost regions of the coordinate fields returned by `refine`. Add MPI tests which compare the owned and ghost coefficients of the results with those obtained from a serial copy of the mapping. Fixes #611. --- psydac/cad/cad.py | 39 ++++++++++++++++++++++++------ psydac/cad/tests/test_cad.py | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index db7bb9be6..e57ed650b 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -6,10 +6,12 @@ from typing import Iterable import numpy as np +from mpi4py import MPI from psydac.fem.splines import SplineSpace from psydac.fem.tensor import TensorFemSpace from psydac.fem.basic import FemField +from psydac.linalg.stencil import StencilVector from psydac.mapping.discrete import SplineMapping, NurbsMapping from psydac.ddm.cart import DomainDecomposition @@ -87,13 +89,23 @@ def elevate(mapping, axis, times): knots = [V.knots for V in space.spaces] degree = [V.degree for V in space.spaces] shape = [V.nbasis for V in space.spaces] + + # The decomposition does not change, hence each process only needs the + # coefficients owned by its neighbours that are stored in its ghost regions + is_nurbs = isinstance(mapping, NurbsMapping) + input_fields = list(mapping.fields) + if is_nurbs: + input_fields.append(mapping.weights_field) + for f in input_fields: + f.coeffs.update_ghost_regions() + points = np.zeros(shape+[mapping.pdim]) - for i,f in enumerate( mapping._fields ): - points[...,i] = f._coeffs.toarray().reshape(shape) + for i, f in enumerate(mapping.fields): + points[..., i] = f.coeffs.toarray(with_pads=True).reshape(shape) weights = None - if isinstance(mapping, NurbsMapping): - weights = mapping._weights_field._coeffs.toarray().reshape(shape) + if is_nurbs: + weights = mapping.weights_field.coeffs.toarray(with_pads=True).reshape(shape) # degree elevation using igakit, which expects Cartesian control points nrb = NURBS(knots, points, weights=weights) @@ -152,13 +164,16 @@ def refine(mapping, axis, values): knots = [V.knots for V in space.spaces] degree = [V.degree for V in space.spaces] shape = [V.nbasis for V in space.spaces] + + # The new balanced decomposition may move the subdomain boundaries far + # away, hence each process needs all the coefficients points = np.zeros(shape+[mapping.pdim]) - for i,f in enumerate( mapping._fields ): - points[...,i] = f._coeffs.toarray().reshape(shape) + for i, f in enumerate(mapping.fields): + points[..., i] = _global_array(f.coeffs).reshape(shape) weights = None if isinstance(mapping, NurbsMapping): - weights = mapping._weights_field._coeffs.toarray().reshape(shape) + weights = _global_array(mapping.weights_field.coeffs).reshape(shape) # knot insertion using igakit, which expects Cartesian control points nrb = NURBS(knots, points, weights=weights) @@ -180,6 +195,7 @@ def refine(mapping, axis, values): for i,field in enumerate( fields ): idx_from = tuple(list(idx_to)+[i]) field.coeffs[idx_to] = nrb.points[idx_from] + field.coeffs.update_ghost_regions() if isinstance(mapping, NurbsMapping): weights_field = FemField( space ) @@ -193,3 +209,12 @@ def refine(mapping, axis, values): return NurbsMapping( *fields ) return SplineMapping( *fields ) + +#============================================================================== +def _global_array(coeffs: StencilVector) -> np.ndarray: + """Return the global array of coefficients on every process.""" + array = coeffs.toarray() + # In parallel, toarray() returns zeros outside of the block owned by the process + if coeffs.space.parallel: + coeffs.space.cart.comm.Allreduce(MPI.IN_PLACE, array, op=MPI.SUM) + return array diff --git a/psydac/cad/tests/test_cad.py b/psydac/cad/tests/test_cad.py index 4fa372366..9f3c056d0 100644 --- a/psydac/cad/tests/test_cad.py +++ b/psydac/cad/tests/test_cad.py @@ -7,6 +7,7 @@ import numpy as np import pytest +from mpi4py import MPI from psydac.cad.cad import elevate, refine, translate from psydac.cad.gallery import annulus, circle, quart_circle @@ -82,3 +83,49 @@ def test_refine_2d(axis: int, make_mapping) -> None: @pytest.mark.parametrize('axis', [0, 1, 2]) def test_refine_3d(axis: int) -> None: check_refine(make_mapping_3d(), axis) + +#============================================================================== +def make_mapping_parallel(comm: MPI.Comm): + return discrete_mapping('collela', ncells=[8, 8], degree=[2, 2], comm=comm) + +def assert_same_local_coeffs(F, G) -> None: + """Check that the local data (owned and ghost coefficients) of a distributed + mapping G agrees with the corresponding part of a serial mapping F.""" + for f, g in zip(F.fields, G.fields): + V = g.coeffs.space + everything = (slice(None),) * V.ndim + local_part = tuple(slice(s - m*p, e + m*p + 1) + for s, e, p, m in zip(V.starts, V.ends, V.pads, V.shifts)) + assert np.allclose(g.coeffs[everything], f.coeffs[local_part], + rtol=0, atol=1e-14) + +#============================================================================== +@pytest.mark.mpi +@pytest.mark.parametrize('axis', [0, 1]) +def test_elevate_parallel(axis: int) -> None: + + F = make_mapping_parallel(MPI.COMM_WORLD) + F_serial = make_mapping_parallel(MPI.COMM_SELF) + + G = elevate(F, axis=axis, times=1) + G_serial = elevate(F_serial, axis=axis, times=1) + + assert_same_local_coeffs(G_serial, G) + +#============================================================================== +# The shifted values move the subdomain boundaries by more than p cells +@pytest.mark.mpi +@pytest.mark.parametrize('axis', [0, 1]) +@pytest.mark.parametrize('values', [ + [0.1, 0.35, 0.6, 0.9], + [0.01, 0.04, 0.07, 0.1, 0.13, 0.16, 0.19, 0.22], +], ids=['spread', 'shifted']) +def test_refine_parallel(values: list, axis: int) -> None: + + F = make_mapping_parallel(MPI.COMM_WORLD) + F_serial = make_mapping_parallel(MPI.COMM_SELF) + + G = refine(F, axis=axis, values=values) + G_serial = refine(F_serial, axis=axis, values=values) + + assert_same_local_coeffs(G_serial, G) From d9fc803a3e9222c172a3b6f175bc970891b202cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 9 Oct 2026 18:46:44 +0200 Subject: [PATCH 081/129] Fix error message in get_max_degree_of_one_space For an unsupported type of space, the function raised a NameError instead of the intended TypeError, because the error message referred to an undefined variable `V` instead of the argument `Vh`. Report the type of `Vh` in the message, and fix a typo in the docstring. Add `psydac.api.tests.test_discretization`, with unit tests for `get_max_degree_of_one_space` and `get_max_degree`. --- psydac/api/discretization.py | 4 +- psydac/api/tests/test_discretization.py | 60 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 psydac/api/tests/test_discretization.py diff --git a/psydac/api/discretization.py b/psydac/api/discretization.py index 00e645461..b6f99c346 100644 --- a/psydac/api/discretization.py +++ b/psydac/api/discretization.py @@ -110,7 +110,7 @@ def get_max_degree_of_one_space(Vh): Returns ------- list[int] - The maximum polynomial degre of Vh with respect to each coordinate. + The maximum polynomial degree of Vh with respect to each coordinate. """ @@ -125,7 +125,7 @@ def get_max_degree_of_one_space(Vh): return [max(p) for p in zip(*degree)] else: - raise TypeError(f'Type({V}) not understood') + raise TypeError(f'Type {type(Vh).__name__} not understood') def get_max_degree(*spaces): diff --git a/psydac/api/tests/test_discretization.py b/psydac/api/tests/test_discretization.py new file mode 100644 index 000000000..27920c275 --- /dev/null +++ b/psydac/api/tests/test_discretization.py @@ -0,0 +1,60 @@ +#---------------------------------------------------------------------------# +# This file is part of PSYDAC which is released under MIT License. See the # +# LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # +# for full license details. # +#---------------------------------------------------------------------------# +import numpy as np +import pytest + +from psydac.api.discretization import get_max_degree, get_max_degree_of_one_space +from psydac.ddm.cart import DomainDecomposition +from psydac.fem.splines import SplineSpace +from psydac.fem.tensor import TensorFemSpace +from psydac.fem.vector import VectorFemSpace + +#============================================================================== +def make_tensor_space_2d(degree: tuple[int, int], + domain_decomposition: DomainDecomposition) -> TensorFemSpace: + """ + Create a 2D tensor-product spline space on a uniform grid. + + Parameters + ---------- + degree : tuple[int, int] + The spline degree along each direction. + + domain_decomposition : DomainDecomposition + The decomposition of the 2D grid, which also gives its number of cells. + + Returns + ------- + TensorFemSpace + The spline space. + """ + spaces = [SplineSpace(degree=p, grid=np.linspace(0.0, 1.0, n + 1)) + for p, n in zip(degree, domain_decomposition.ncells)] + return TensorFemSpace(domain_decomposition, *spaces) + +#============================================================================== +def test_get_max_degree_of_one_space() -> None: + + domain_decomposition = DomainDecomposition([3, 5], [False, False]) + V1 = make_tensor_space_2d((2, 3), domain_decomposition) + V2 = make_tensor_space_2d((3, 1), domain_decomposition) + + assert list(get_max_degree_of_one_space(V1)) == [2, 3] + assert list(get_max_degree_of_one_space(VectorFemSpace(V1, V2))) == [3, 3] + + with pytest.raises(TypeError, match='SplineSpace'): + get_max_degree_of_one_space(V1.spaces[0]) + +#============================================================================== +def test_get_max_degree() -> None: + + domain_decomposition = DomainDecomposition([3, 5], [False, False]) + V1 = make_tensor_space_2d((2, 3), domain_decomposition) + V2 = make_tensor_space_2d((3, 1), domain_decomposition) + V3 = make_tensor_space_2d((1, 4), domain_decomposition) + + assert list(get_max_degree(V1, V2)) == [3, 3] + assert list(get_max_degree(VectorFemSpace(V1, V2), V3)) == [3, 4] From d330670bf38b75e1a650f792e42afd7b12410530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 9 Oct 2026 19:45:41 +0200 Subject: [PATCH 082/129] Check the weights field in NurbsMapping.__init__ `SplineMapping.__init__` checks that all coordinate fields are FemField objects defined on the same space, but `NurbsMapping.__init__` stored its weights field without any check. A weights field defined on another space, or an object which is not a FemField, was accepted, and the error only appeared later during evaluation. Check that the weights are a FemField defined on the same space as the coordinate fields. Add `test_nurbs_mapping_wrong_weights`. Fixes #616. --- psydac/mapping/discrete.py | 4 ++++ psydac/mapping/tests/test_discrete_mapping.py | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/psydac/mapping/discrete.py b/psydac/mapping/discrete.py index debfd3962..f7af184fd 100644 --- a/psydac/mapping/discrete.py +++ b/psydac/mapping/discrete.py @@ -819,6 +819,10 @@ def __init__(self, *components, name=None): SplineMapping.__init__(self, *components, name=name) + # The weights must be defined on the same space as the coordinates + assert isinstance(weights, FemField) + assert weights.space is self._space + self._weights = NurbsMapping.Weights(self) self._weights_field = weights diff --git a/psydac/mapping/tests/test_discrete_mapping.py b/psydac/mapping/tests/test_discrete_mapping.py index 3bb74a641..85319d57d 100644 --- a/psydac/mapping/tests/test_discrete_mapping.py +++ b/psydac/mapping/tests/test_discrete_mapping.py @@ -16,6 +16,7 @@ from psydac.api.discretization import discretize from psydac.core.bsplines import cell_index +from psydac.fem.basic import FemField from psydac.fem.tensor import TensorFemSpace from psydac.fem.splines import SplineSpace from psydac.mapping.discrete import NurbsMapping @@ -309,3 +310,26 @@ def test_nurbs_circle(): J_i = disk.gradient(u=x1, v=x2) assert np.allclose(J_i[:2], J_p, atol=ATOL, rtol=RTOL) + +#============================================================================== +def make_tensor_space_2d(degree: tuple[int, int]) -> TensorFemSpace: + """Create a serial 2D spline space on a uniform grid of 3 x 5 cells.""" + ncells = [3, 5] + domain_decomposition = DomainDecomposition(ncells, [False, False]) + spaces = [SplineSpace(degree=p, grid=np.linspace(0.0, 1.0, n + 1)) + for p, n in zip(degree, ncells)] + return TensorFemSpace(domain_decomposition, *spaces) + +@pytest.mark.parametrize('weights_kind', ['other_space', 'array']) +def test_nurbs_mapping_wrong_weights(weights_kind: str) -> None: + + V = make_tensor_space_2d((2, 2)) + x, y = FemField(V), FemField(V) + + if weights_kind == 'other_space': + weights = FemField(make_tensor_space_2d((1, 3))) + else: + weights = np.ones(V.nbasis) + + with pytest.raises(AssertionError): + NurbsMapping(x, y, weights) From 6441cde36ea98c1264d4e90792966713e57baa2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 9 Oct 2026 20:46:05 +0200 Subject: [PATCH 083/129] Require a domain decomposition in Geometry.__init__ `Geometry.__init__` created its own domain decomposition from the number of cells, the periodicity, the communicator, and mpi_dims_mask, which may not match the decomposition on which the spline mappings are defined. For example, `Geometry.from_discrete_mapping(F)` with the default comm=None created a serial decomposition for a distributed mapping F. `__init__` now takes an existing decomposition `ddm`, from which it derives the number of cells and the periodicity of each patch (as dictionaries of tuples) and the communicator. Each spline mapping must be defined on the decomposition of its patch. The decomposition is created by the class methods: `from_topological_domain` uses the new private function `_make_ddm`, and `from_discrete_mapping` uses the decomposition of the mapping, hence it no longer accepts `comm` and `mpi_dims_mask`. Add the option `mpi_dims_mask` to `discrete_mapping`. Update the callers and tests accordingly. --- examples/old_examples/poisson_2d_mapping.py | 2 +- .../compare_3d_matrix_assembly_speed.py | 2 +- psydac/cad/geometry.py | 237 ++++++++++-------- psydac/cad/tests/test_geometry.py | 73 ++++-- psydac/feec/polar/examples/polar_model_2d.py | 5 +- psydac/mapping/discrete_gallery.py | 9 +- 6 files changed, 196 insertions(+), 132 deletions(-) diff --git a/examples/old_examples/poisson_2d_mapping.py b/examples/old_examples/poisson_2d_mapping.py index 565fcdebb..c64eaec03 100644 --- a/examples/old_examples/poisson_2d_mapping.py +++ b/examples/old_examples/poisson_2d_mapping.py @@ -632,7 +632,7 @@ def main(*, test_case, ncells, degree, nquads, map_discrete = SplineMapping.from_mapping(V, map_analytic) # Write discrete geometry to HDF5 file t0 = time() - geometry = Geometry.from_discrete_mapping(map_discrete, comm=mpi_comm) + geometry = Geometry.from_discrete_mapping(map_discrete) geometry.export('geo.h5') t1 = time() timing['export'] += t1-t0 diff --git a/examples/performance/compare_3d_matrix_assembly_speed.py b/examples/performance/compare_3d_matrix_assembly_speed.py index ebb04bf7f..ac2c68227 100644 --- a/examples/performance/compare_3d_matrix_assembly_speed.py +++ b/examples/performance/compare_3d_matrix_assembly_speed.py @@ -80,7 +80,7 @@ def make_square_torus_geometry_3d(ncells, degree, comm=None): mapping = SquareTorus('S') map_discrete = SplineMapping.from_mapping(V_h, mapping.get_callable_mapping()) - geometry = Geometry.from_discrete_mapping(map_discrete, comm=comm) + geometry = Geometry.from_discrete_mapping(map_discrete) if mpi_rank == 0: if not os.path.isdir('geometry'): diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 53f78d61e..666de2ebd 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -43,36 +43,31 @@ class Geometry: """ Distributed discrete geometry that works for single and multiple patches. - The Geometry object can be created in four ways: - - case 0 : providing a `Domain` to `__init__` with detailed parameters for each patch. - - case 1 : passing the path to a geometry file to `from_file`. - - case 2 : passing a `SplineMapping` to `from_discrete_mapping` (single patch). - - case 3 : passing a `Domain`, ncells, and periodicity to `from_topological_domain` (single or multi-patch). + The Geometry object should be created with one of the class methods: + - `from_file`: passing the path to a geometry file (single or multi-patch). + - `from_discrete_mapping`: passing a `SplineMapping` (single patch). + - `from_topological_domain`: passing a `Domain`, the number of cells, and + the periodicity (single or multi-patch). + + The constructor `__init__` requires an existing domain decomposition, from + which the number of cells and the periodicity of each patch are obtained. Parameters ---------- domain : Sympde.topology.Domain The symbolic topological domain to be discretized. + ddm : DomainDecomposition | MultiPatchDomainDecomposition + The decomposition of the domain across the MPI processes: a + `DomainDecomposition` for a single patch, otherwise a + `MultiPatchDomainDecomposition` with one decomposition per patch. + pdim : int Number of physical dimensions of the Geometry object (pdim >= ldim). - ncells : dict[str, Iterable[int]] - The number of cells of the discretized domain in each direction. - - periodic : dict[str, Iterable[bool]], optional - The periodicity of the topological domain in each direction. - mappings : dict[str, BasicCallableMapping], optional - The discrete mappings of each patch. - - comm: MPI.Intracomm, optional - MPI intra-communicator. - - mpi_dims_mask: Iterable[bool], optional - 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. - + The discrete mappings of each patch. A spline mapping must be defined + on the domain decomposition of its patch (the same object). """ _ldim = None _pdim = None @@ -80,88 +75,67 @@ class Geometry: _topology = None def __init__(self, - domain : Domain, + domain : Domain, *, + ddm : DomainDecomposition | MultiPatchDomainDecomposition, pdim : int, - ncells : dict[str, Iterable[int]], - mappings : dict[str, SplineMapping | None] = None, - periodic : dict[str, Iterable[bool]] = None, - comm : MPI.Intracomm = None, - mpi_dims_mask : Iterable[bool] = None): + mappings : dict[str, BasicCallableMapping | None] = None): # Type checks + assert isinstance(domain, Domain) + assert isinstance(ddm, (DomainDecomposition, MultiPatchDomainDecomposition)) assert isinstance(pdim, int) - assert isinstance(domain, Domain) - assert isinstance(ncells, dict) assert isinstance(mappings, (NoneType, dict)) - assert isinstance(periodic, (NoneType, dict)) - assert isinstance(comm, (NoneType, MPI.Intracomm)) - assert isinstance(mpi_dims_mask, (NoneType, Iterable)) # Extract info from domain ldim : int = domain.dim interior_names : list = domain.interior_names - set_interior_names = set(interior_names) # Check sanity of pdim assert pdim >= ldim - # Check sanity of ncells - assert set(ncells.keys()) == set_interior_names - assert all(len(n) == ldim for n in ncells.values()) - assert all(isinstance(ni, (int, np.integer)) for ni in chain(*ncells.values())) - assert all(ni > 0 for ni in chain(*ncells.values())) - - # Although we allow the iterable values in ncells to contain NumPy - # integers, we convert them to lists of Python integers for consistency - ncells = {patch: [int(ni) for ni in n] for patch, n in ncells.items()} - - # Check sanity of periodic - if periodic is None: - periodic = {patch: [False] * len(n) for patch, n in ncells.items()} + # Check that the domain decomposition matches the domain, and get the + # decomposition, number of cells, and periodicity of each patch + if len(domain) == 1: + assert isinstance(ddm, DomainDecomposition) + patch_ddms = [ddm] + patch_ncells = [ddm.ncells] + patch_periods = [ddm.periods] else: - assert set(periodic.keys()) == set_interior_names - assert all(len(p) == ldim for p in periodic.values()) - assert all(isinstance(pi, bool) for pi in chain(*periodic.values())) - - # Check sanity of mappings + assert isinstance(ddm, MultiPatchDomainDecomposition) + assert len(ddm.domains) == len(domain) + patch_ddms = ddm.domains + patch_ncells = ddm.ncells + patch_periods = ddm.periods + + assert all(len(n) == ldim for n in patch_ncells) + + # Store the number of cells and the periodicity as read-only tuples + ncells = {name: tuple(int(ni) for ni in n) + for name, n in zip(interior_names, patch_ncells)} + periodic = {name: tuple(bool(pi) for pi in p) + for name, p in zip(interior_names, patch_periods)} + + # Check sanity of mappings. A spline mapping must be defined on the + # domain decomposition of its patch if mappings is None: mappings = {name : None for name in interior_names} else: - assert set(mappings.keys()) == set_interior_names + assert set(mappings.keys()) == set(interior_names) assert all(isinstance(m, (BasicCallableMapping, NoneType)) for m in mappings.values()) assert all(m.pdim == pdim for m in mappings.values() if m is not None) - - # Check sanity of mpi_dims_mask - if mpi_dims_mask is not None: - assert len(mpi_dims_mask) == ldim - assert all(isinstance(mask, bool) for mask in mpi_dims_mask) - - # Create a (multi-patch) domain decomposition - if len(domain) == 1: - #name = domain.name - name = interior_names[0] - ddm = DomainDecomposition( - ncells = ncells[name], - periods = periodic[name], - comm = comm, - mpi_dims_mask = mpi_dims_mask, - ) - else: - ddm = MultiPatchDomainDecomposition( - ncells = [ ncells[itr] for itr in interior_names], - periods = [periodic[itr] for itr in interior_names], - comm = comm, - ) + assert all(mappings[name].space.domain_decomposition is patch_ddm + for name, patch_ddm in zip(interior_names, patch_ddms) + if isinstance(mappings[name], SplineMapping)) # Add attributes to the new object self._domain = domain - self._ldim = domain.dim + self._ldim = ldim self._pdim = pdim self._ncells = ncells self._mappings = mappings self._periodic = periodic - self._comm = comm + self._comm = ddm.comm self._ddm = ddm self._cart = None @@ -219,22 +193,19 @@ def from_file(cls, # Option [2]: from a discrete mapping #-------------------------------------------------------------------------- @classmethod - def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=None): + def from_discrete_mapping(cls, mapping, *, name=None): """ Create a single-patch Geometry instance from one discrete mapping. + The geometry uses the domain decomposition of the mapping's spline + space, hence its distribution across the MPI processes is that of the + mapping. + Parameters ---------- - mapping : BasicCallableMapping + mapping : SplineMapping The mapping from the unit square to the physical domain. - 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 : str Optional name for the symbolic Mapping that will be created. Needed to avoid conflicts in case several mappings are created. @@ -253,18 +224,11 @@ def from_discrete_mapping(cls, mapping, *, comm=None, mpi_dims_mask=None, name=N min_coords = [0.] * dim, max_coords = [1.] * dim)) M.set_callable_mapping(mapping) - pdim = mapping.pdim - mappings = {domain.name: mapping} - ncells = {domain.name: mapping.space.domain_decomposition.ncells} - periodic = {domain.name: mapping.space.domain_decomposition.periods} return Geometry(domain = domain, - pdim = pdim, - ncells = ncells, - periodic = periodic, - mappings = mappings, - comm = comm, - mpi_dims_mask = mpi_dims_mask) + ddm = mapping.space.domain_decomposition, + pdim = mapping.pdim, + mappings = {domain.name: mapping}) #-------------------------------------------------------------------------- # Option [3]: discrete topological line/square/cube @@ -302,13 +266,13 @@ def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mp if isinstance(periodic, (list, tuple)): periodic = {itr.name : periodic for itr in interior} + ddm = _make_ddm(domain, ncells, periodic, + comm=comm, mpi_dims_mask=mpi_dims_mask) + return Geometry(domain = domain, + ddm = ddm, pdim = pdim, - ncells = ncells, - periodic = periodic, - mappings = mappings, - comm = comm, - mpi_dims_mask = mpi_dims_mask) + mappings = mappings) #-------------------------------------------------------------------------- @property @@ -404,8 +368,8 @@ def read(self, filename, comm=None, mpi_dims_mask=None, domain=None): spaces[i_patch] = space_i - ncells [interiors[i_patch].name] = [sp.ncells for sp in space_i] - periodic[interiors[i_patch].name] = periodic_i + ncells [interiors[i_patch].name] = tuple(sp.ncells for sp in space_i) + periodic[interiors[i_patch].name] = tuple(periodic_i) if n_patches == 1: ddm = DomainDecomposition(ncells[domain.name], periodic[domain.name], comm=comm, mpi_dims_mask=mpi_dims_mask) @@ -1034,3 +998,74 @@ def _check_domain_matches_file(domain : Domain, file_domain : Domain, filename : if _get_interface_keys(domain) != _get_interface_keys(file_domain): raise ValueError(f"Interfaces of the domain do not match those in file {filename}") + +#============================================================================== +def _make_ddm(domain : Domain, + ncells : dict[str, Iterable[int]], + periodic : dict[str, Iterable[bool]] | None = None, + comm : MPI.Intracomm | None = None, + mpi_dims_mask : Iterable[bool] | None = None, + ) -> DomainDecomposition | MultiPatchDomainDecomposition: + """ + Create the domain decomposition of a single- or multi-patch domain. + + Parameters + ---------- + domain : Sympde.topology.Domain + The symbolic topological domain to be decomposed. + + ncells : dict[str, Iterable[int]] + The number of cells of each patch along each direction. + + periodic : dict[str, Iterable[bool]], optional + The periodicity of each patch along each direction (default: False). + + comm : MPI.Intracomm, optional + The MPI intra-communicator. + + mpi_dims_mask : Iterable[bool], optional + True if the dimension is to be used in the domain decomposition + (=default for each dimension). Only used for a single patch. + + Returns + ------- + DomainDecomposition | MultiPatchDomainDecomposition + The decomposition of a single patch, or of multiple patches. + """ + assert isinstance(domain, Domain) + assert isinstance(ncells, dict) + assert isinstance(periodic, (NoneType, dict)) + assert isinstance(comm, (NoneType, MPI.Intracomm)) + assert isinstance(mpi_dims_mask, (NoneType, Iterable)) + + ldim = domain.dim + interior_names = domain.interior_names + + # Check sanity of ncells + assert set(ncells.keys()) == set(interior_names) + assert all(len(n) == ldim for n in ncells.values()) + assert all(isinstance(ni, (int, np.integer)) for ni in chain(*ncells.values())) + assert all(ni > 0 for ni in chain(*ncells.values())) + + # Check sanity of periodic (no periodicity by default) + if periodic is None: + periods = {name: (False,) * ldim for name in interior_names} + else: + periods = periodic + assert set(periods.keys()) == set(interior_names) + assert all(len(p) == ldim for p in periods.values()) + assert all(isinstance(pi, bool) for pi in chain(*periods.values())) + + # Check sanity of mpi_dims_mask + if mpi_dims_mask is not None: + assert len(mpi_dims_mask) == ldim + assert all(isinstance(mask, bool) for mask in mpi_dims_mask) + + if len(domain) == 1: + name = interior_names[0] + return DomainDecomposition(ncells[name], periods[name], comm=comm, + mpi_dims_mask=mpi_dims_mask) + + return MultiPatchDomainDecomposition([ncells [name] for name in interior_names], + [periods[name] for name in interior_names], + comm=comm) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index e48e7c79d..fa8d37ef3 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -13,14 +13,15 @@ from psydac.cad.geometry import Geometry, export_nurbs_to_hdf5, refine_nurbs from psydac.cad.geometry import import_geopdes_to_nurbs -from psydac.cad.cad import elevate, refine, translate +from psydac.cad.cad import elevate, refine from psydac.cad.gallery import quart_circle from psydac.mapping.discrete import SplineMapping, NurbsMapping from psydac.mapping.discrete_gallery import discrete_mapping from psydac.fem.splines import SplineSpace from psydac.fem.tensor import TensorFemSpace from psydac.utilities.utils import refine_array_1d -from psydac.ddm.cart import DomainDecomposition +from psydac.ddm.cart import (DomainDecomposition, + MultiPatchDomainDecomposition) base_dir = os.path.dirname(os.path.realpath(__file__)) @@ -72,7 +73,8 @@ def test_geometry_2d_1(tmp_path): domain = F(Square(name='Omega')) # create a geometry from a topological domain and the dict of mappings - geo = Geometry(domain=domain, pdim=2, ncells={domain.name: [1, 1]}, mappings={domain.name: mapping}) + geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=2, + mappings={domain.name: mapping}) geo_read = check_round_trips(geo, mapping, tmp_path) @@ -109,13 +111,9 @@ def test_geometry_2d_2(tmp_path): # associate the mapping to the topological domain mappings = {domain.name: mapping} - # Define ncells as a dict - ncells = {domain.name:[len(space.breaks)-1 for space in mapping.space.spaces]} - - periodic = {domain.name:[space.periodic for space in mapping.space.spaces]} - # create a geometry from a topological domain and the dict of mappings - geo = Geometry(domain=domain, pdim=2, ncells=ncells, periodic=periodic, mappings=mappings) + geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=2, + mappings=mappings) geo_read = check_round_trips(geo, mapping, tmp_path) @@ -149,12 +147,10 @@ def test_geometry_with_mpi_dims_mask(): # 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, pdim=3, ncells=d_ncells, mappings=mappings, comm=comm) + geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=3, + mappings=mappings) geo.export('geo_mpi_dims.h5') # Read geometry file in parallel, but using mpi_dims_mask @@ -178,16 +174,18 @@ def test_from_discrete_mapping(): size = comm.size mpi_dims_mask = [False, False, True] # We will verify that this has an effect ncells = [4, 8, 2 * size] # Each process should have two cells along x3 - degree = [3, 3, 3] + degree = [3, 3, 2] # Each process must own at least `degree` cells 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 a mapping using mpi_dims_mask + mapping = discrete_mapping('identity', ncells=ncells, degree=degree, + mpi_dims_mask=mpi_dims_mask) - # 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) + # The geometry uses the domain decomposition of the mapping + geo_from_mapping = Geometry.from_discrete_mapping(mapping) + assert geo_from_mapping.ddm is mapping.space.domain_decomposition # Verify that the domain is distributed as expected assert geo_from_mapping.ddm.starts == expected_starts @@ -230,8 +228,18 @@ def test_geometry_init_without_mappings(npatches: int) -> None: connectivity=[((0, 0, 1), (1, 0, -1), 1)], name='Omega') - ncells = {name: [4, 4] for name in domain.interior_names} - geo = Geometry(domain, pdim=2, ncells=ncells) + ncells = [3, 5] + if npatches == 1: + ddm = DomainDecomposition(ncells, [False, False]) + else: + ddm = MultiPatchDomainDecomposition([ncells] * npatches, + [[False, False]] * npatches) + + geo = Geometry(domain, ddm=ddm, pdim=2) + + # The number of cells and the periodicity are obtained from the decomposition + assert geo.ncells == {name: (3, 5) for name in domain.interior_names} + assert geo.periodic == {name: (False, False) for name in domain.interior_names} assert geo.mappings == {name: None for name in domain.interior_names} @@ -247,11 +255,28 @@ def make_domain(npatches: int, *, ornt: int = 1) -> Domain: def export_geometry(domain: Domain, filename: str) -> None: """Export a spline geometry on the domain, with patches side by side along x.""" - F = discrete_mapping('identity', ncells=[2, 2], degree=[2, 2]) names = domain.interior_names - mappings = {name: translate(F, [float(i), 0.0]) for i, name in enumerate(names)} - ncells = {name: [2, 2] for name in names} - Geometry(domain, pdim=2, ncells=ncells, mappings=mappings).export(filename) + ncells, degree = [2, 3], [2, 2] + if len(domain) == 1: + ddm = DomainDecomposition(ncells, [False, False]) + patch_ddms = [ddm] + else: + ddm = MultiPatchDomainDecomposition([ncells] * len(names), + [[False, False]] * len(names)) + patch_ddms = ddm.domains + + # Spline mappings must be defined on the decomposition of their patch. The + # control points of the identity map are given by the Greville abscissae + mappings = {} + for i, (name, patch_ddm) in enumerate(zip(names, patch_ddms)): + spaces = [SplineSpace(degree=p, grid=np.linspace(0.0, 1.0, n + 1)) + for p, n in zip(degree, ncells)] + g1, g2 = [W.greville for W in spaces] + points = np.stack(np.meshgrid(g1 + i, g2, indexing='ij'), axis=-1) + V = TensorFemSpace(patch_ddm, *spaces) + mappings[name] = SplineMapping.from_control_points(V, points) + + Geometry(domain, ddm=ddm, pdim=2, mappings=mappings).export(filename) #============================================================================== @pytest.mark.parametrize('npatches', [1, 2]) diff --git a/psydac/feec/polar/examples/polar_model_2d.py b/psydac/feec/polar/examples/polar_model_2d.py index 40cd24dae..8e5742ada 100644 --- a/psydac/feec/polar/examples/polar_model_2d.py +++ b/psydac/feec/polar/examples/polar_model_2d.py @@ -138,10 +138,7 @@ def build_geometry( # In order to create a sympde.Domain object from this mapping we have # to create first a HDF5 file and then load as sympde.Domain.fromfile - geometry = Geometry.from_discrete_mapping( - map_discrete, - comm=mpi_comm, - ) + geometry = Geometry.from_discrete_mapping(map_discrete) geometry.export(filename) self._geometry_export_time = time() - t0 diff --git a/psydac/mapping/discrete_gallery.py b/psydac/mapping/discrete_gallery.py index e7a9f2820..c9a472f89 100644 --- a/psydac/mapping/discrete_gallery.py +++ b/psydac/mapping/discrete_gallery.py @@ -75,6 +75,7 @@ def get_available_mappings(ldim): #============================================================================== def discrete_mapping(mapping, ncells, degree, *, comm = MPI.COMM_WORLD, + mpi_dims_mask = None, return_space = False): """ Create a SplineMapping by interpolating one of the available analytical mappings. @@ -93,6 +94,11 @@ def discrete_mapping(mapping, ncells, degree, *, comm : MPI.Intracomm, optional The MPI intracommunicator. + mpi_dims_mask : Iterable[bool], optional + 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. + return_space : bool, optional Whether this function should also return the discrete space it creates. @@ -193,7 +199,8 @@ def discrete_mapping(mapping, ncells, degree, *, # ... # Create the domain decomposition - domain_decomposition = DomainDecomposition(ncells=ncells, periods=periodic, comm=comm) + domain_decomposition = DomainDecomposition(ncells=ncells, periods=periodic, + comm=comm, mpi_dims_mask=mpi_dims_mask) # Create 1D spline spaces, not distributed spaces_1d = [SplineSpace(grid=np.linspace(*lims, num=nc+1), degree=p, periodic=per) From f7d82d90845aefb0445dcefd059dae3389a7bacb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 9 Oct 2026 21:49:45 +0200 Subject: [PATCH 084/129] Create Geometry.from_file through __init__ and remove read() `Geometry.from_file` used the method `read`, which set the attributes of a new object directly instead of calling `__init__`. As a result, the geometries read from file were not checked like the others, the mappings were keyed by the logical patch names instead of the interior names used for `ncells` and `periodic`, and for multiple patches `periodic` was a list instead of a dictionary. `from_file` now reads the spline spaces with the new private function `_read_patch_spaces`, creates the domain decomposition with `_make_ddm`, builds the mappings on it with `_read_spline_mappings`, and calls `__init__`. The method `read` is removed. `__init__` stores the mappings in the order of the domain interiors, on which many functions rely. Since the mappings are now always keyed by the interior names, remove the fallback on logical names in `discretize`, and fix `PostProcessManager`, which silently fell back on the symbolic mappings. Update the tests, and add `test_geometry_from_file_multipatch`. --- psydac/api/discretization.py | 7 +- psydac/api/postprocessing.py | 7 +- psydac/api/tests/test_2d_navier_stokes.py | 2 +- psydac/api/tests/test_postprocessing.py | 4 + psydac/cad/geometry.py | 318 +++++++++++----------- psydac/cad/tests/test_geometry.py | 26 +- 6 files changed, 191 insertions(+), 173 deletions(-) diff --git a/psydac/api/discretization.py b/psydac/api/discretization.py index b6f99c346..294f002de 100644 --- a/psydac/api/discretization.py +++ b/psydac/api/discretization.py @@ -454,11 +454,8 @@ def discretize_space(V, domain_h, *, degree=None, multiplicity=None, knots=None, connectivity = construct_connectivity(domain) if isinstance(domain_h, Geometry) and all(domain_h.mappings.values()): - # from a discrete geoemtry - if interiors[0].name in domain_h.mappings: - mappings = [domain_h.mappings[inter.name] for inter in interiors] - else: - mappings = [domain_h.mappings[inter.logical_domain.name] for inter in interiors] + # from a discrete geometry + mappings = [domain_h.mappings[inter.name] for inter in interiors] # Get all the FEM spaces from the mapping and convert their coeff_space at the dtype needed spaces = [change_dtype(m.space, dtype) for m in mappings] diff --git a/psydac/api/postprocessing.py b/psydac/api/postprocessing.py index 55d088b0e..8a97b6ad1 100644 --- a/psydac/api/postprocessing.py +++ b/psydac/api/postprocessing.py @@ -1042,15 +1042,16 @@ def _process_domain(self): spl_maps = domain_h.mappings if domain_h.mappings is not None else {} if isinstance(domain.interior, InteriorDomain): - self._mappings[domain.name] = spl_maps.get(domain.logical_domain.name, domain.mapping) + self._mappings[domain.name] = spl_maps.get(domain.interior.name, + domain.mapping) else: if isinstance(domain.mapping, MultiPatchMapping): for interior in domain.interior.as_tuple(): - self._mappings[interior.name] = spl_maps.get(interior.logical_domain.name, \ + self._mappings[interior.name] = spl_maps.get(interior.name, \ domain.mapping.mappings[interior.logical_domain]) else: for interior in domain.interior.as_tuple(): - self._mappings[interior.name] = spl_maps.get(interior.logical_domain.name, \ + self._mappings[interior.name] = spl_maps.get(interior.name, \ interior.mapping) else: domain = self._domain diff --git a/psydac/api/tests/test_2d_navier_stokes.py b/psydac/api/tests/test_2d_navier_stokes.py index 8118f277a..8f309627e 100644 --- a/psydac/api/tests/test_2d_navier_stokes.py +++ b/psydac/api/tests/test_2d_navier_stokes.py @@ -479,7 +479,7 @@ def teardown_function(): solutions, p_h, domain, domain_h = run_time_dependent_navier_stokes_2d(filename, dt_h=dt_h, nt=nt, scipy=False) domain = domain.logical_domain - mapping = domain_h.mappings['patch_0'] + mapping, = domain_h.mappings.values() anim = animate_field(solutions, domain, mapping, res=(150,150), progress=True) anim.save('animated_fields_{}_{}.mp4'.format(str(Tf).replace('.','_'), str(dt_h).replace('.','_')), writer=animation.FFMpegWriter(fps=60)) diff --git a/psydac/api/tests/test_postprocessing.py b/psydac/api/tests/test_postprocessing.py index b592f2070..815b7140d 100644 --- a/psydac/api/tests/test_postprocessing.py +++ b/psydac/api/tests/test_postprocessing.py @@ -17,6 +17,7 @@ from psydac.api.discretization import discretize from psydac.fem.basic import FemField from psydac.fem.tensor import TensorFemSpace +from psydac.mapping.discrete import SplineMapping from psydac.utilities.utils import refine_array_1d from psydac.feec.pull_push import (push_2d_hcurl, push_2d_h1, @@ -494,6 +495,9 @@ def test_reconstruct_DerhamSequence_discrete_domain(geometry, seq, dtype): fields_file='test_reconstruct_DerhamSequence_discrete_domain.h5' ) + # The post-processor must use the spline mappings of the geometry file + assert all(isinstance(F, SplineMapping) for F in Pm._mappings.values()) + Om2 = OutputManager( 'test_reconstruct_DerhamSequence_discrete_domain_2.yml', 'test_reconstruct_DerhamSequence_discrete_domain.h5' diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 666de2ebd..ea57fee20 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -128,6 +128,10 @@ def __init__(self, for name, patch_ddm in zip(interior_names, patch_ddms) if isinstance(mappings[name], SplineMapping)) + # Many functions access the mappings by position: use the order of + # the domain interiors + mappings = {name: mappings[name] for name in interior_names} + # Add attributes to the new object self._domain = domain self._ldim = ldim @@ -185,9 +189,41 @@ def from_file(cls, If the given domain does not match the domain defined in the file, or if one of its patches has no mapping or an analytical mapping. """ - geo = super().__new__(cls) - geo.read(filename, comm=comm, mpi_dims_mask=mpi_dims_mask, domain=domain) - return geo + # Check the extension of the file + _, ext = os.path.splitext(filename) + if ext != '.h5': + raise ValueError('> Only h5 files are supported') + + # Read the topological domain, or check the given one against it + file_domain = Domain.from_file(filename) + if domain is None: + geometry_domain = file_domain + else: + _check_domain_matches_file(domain, file_domain, filename) + geometry_domain = domain + + parallel = comm is not None and comm.size > 1 + kwargs = dict(driver='mpio', comm=comm) if parallel else {} + with h5py.File(filename, mode='r', **kwargs) as h5: + yml = yaml.load(h5['geometry.yml'][()], Loader=yaml.SafeLoader) + if len(yml['patches']) == 0: + raise ValueError("Input file contains no patches.") + + # The patches in the file follow the order of the domain interiors + spaces = _read_patch_spaces(h5, yml) + names = geometry_domain.interior_names + ncells = {name: [W.ncells for W in s] for name, s in zip(names, spaces)} + periodic = {name: [W.periodic for W in s] for name, s in zip(names, spaces)} + + ddm = _make_ddm(geometry_domain, ncells, periodic, + comm=comm, mpi_dims_mask=mpi_dims_mask) + mappings = _read_spline_mappings(h5, yml, geometry_domain, ddm, spaces) + + # Add the spline mappings to the symbolic mappings of the domain + for itr in _get_interiors(geometry_domain): + itr.mapping.set_callable_mapping(mappings[itr.name]) + + return cls(geometry_domain, ddm=ddm, pdim=yml['pdim'], mappings=mappings) #-------------------------------------------------------------------------- # Option [2]: from a discrete mapping @@ -310,165 +346,6 @@ def mappings(self): def __len__(self): return len(self.domain) - def read(self, filename, comm=None, mpi_dims_mask=None, domain=None): - # ... check extension of the file - _, ext = os.path.splitext(filename) - if ext != '.h5': - raise ValueError('> Only h5 files are supported') - # ... - - # read the topological domain, or check the given one against it - file_domain = Domain.from_file(filename) - if domain is None: - domain = file_domain - else: - _check_domain_matches_file(domain, file_domain, filename) - - connectivity = construct_connectivity(domain) - interiors = _get_interiors(domain) - - if comm is not None: - kwargs = dict(driver='mpio', comm=comm) if comm.size > 1 else {} - else: - kwargs = {} - - h5 = h5py.File(filename, mode='r', **kwargs) - yml = yaml.load(h5['geometry.yml'][()], Loader=yaml.SafeLoader) - - ldim = yml['ldim'] - pdim = yml['pdim'] - - n_patches = len(yml['patches']) - - # ... - if n_patches == 0: - h5.close() - raise ValueError("Input file contains no patches.") - # ... - - # ... read patches - mappings = {} - ncells = {} - periodic = {} - spaces = [None] * n_patches - for i_patch in range(n_patches): - - item = yml['patches'][i_patch] - patch_name = item['name'] - mapping_id = item['mapping_id'] - dtype = item['type'] - patch = h5[mapping_id] - if dtype in ['SplineMapping', 'NurbsMapping']: - - degree = [int (p) for p in patch.attrs['degree' ]] - periodic_i = [bool(b) for b in patch.attrs['periodic']] - knots = [patch['knots_{}'.format(d)][:] for d in range(ldim)] - space_i = [SplineSpace(degree=p, knots=k, periodic=P) - for p, k, P in zip(degree, knots, periodic_i)] - - spaces[i_patch] = space_i - - ncells [interiors[i_patch].name] = tuple(sp.ncells for sp in space_i) - periodic[interiors[i_patch].name] = tuple(periodic_i) - - if n_patches == 1: - ddm = DomainDecomposition(ncells[domain.name], periodic[domain.name], comm=comm, mpi_dims_mask=mpi_dims_mask) - ddms = [ddm] - else: - ncells_ = [ncells[itr.name] for itr in interiors] - periodic = [periodic[itr.name] for itr in interiors] - ddm = MultiPatchDomainDecomposition(ncells_, periodic, comm=comm) - ddms = ddm.domains - - carts = create_cart(ddms, spaces) - g_spaces = {inter:TensorFemSpace(ddms[i], *spaces[i], cart=carts[i]) for i,inter in enumerate(interiors)} - - for i, j in connectivity: - minus = interiors[i] - plus = interiors[j] - max_ncells = [max(ni, nj) for ni, nj in zip(ncells[minus.name], ncells[plus.name])] - g_spaces[minus].add_refined_space(ncells=max_ncells) - g_spaces[plus ].add_refined_space(ncells=max_ncells) - - # ... construct interface spaces - construct_interface_spaces(ddm, g_spaces, carts, interiors, connectivity) - - for i_patch in range( n_patches ): - - item = yml['patches'][i_patch] - patch_name = item['name'] - mapping_id = item['mapping_id'] - dtype = item['type'] - patch = h5[mapping_id] - space_i = spaces[i_patch] - if dtype in ['SplineMapping', 'NurbsMapping']: - tensor_space = g_spaces[interiors[i_patch]] - - if dtype == 'SplineMapping': - mapping = SplineMapping.from_control_points(tensor_space, - patch['points'][..., :pdim]) - - elif dtype == 'NurbsMapping': - mapping = NurbsMapping.from_control_points_weights(tensor_space, - patch['points'][..., :pdim], - patch['weights']) - - mapping.set_name(item['name']) - mappings[patch_name] = mapping - - # ... Update ghost regions within each patch and across interfaces - if n_patches > 1: - coeffs = [[e.coeffs for e in mapping.fields] for mapping in mappings.values()] - patch_spaces = [BlockVectorSpace(*[c_ij.space for c_ij in c_i]) for c_i in coeffs] - patch_spaces_w = [c_i[0].space for c_i in coeffs] - space = BlockVectorSpace(*patch_spaces , connectivity=connectivity) - space_w = BlockVectorSpace(*patch_spaces_w, connectivity=connectivity) - v = BlockVector(space) - w = BlockVector(space_w) - mapping_list = list(mappings.values()) - for i in range(n_patches): - for j in range(len(coeffs[i])): - v[i][j] = coeffs[i][j] - - mapping = mapping_list[i] - if isinstance(mapping, NurbsMapping): - w[i] = mapping.weights_field.coeffs - else: - w[i] = v[i][0].space.zeros() - - v.update_ghost_regions() - w.update_ghost_regions() - - else: - mapping = list(mappings.values())[0] - for f in mapping._fields: - f.coeffs.update_ghost_regions() - - if isinstance(mapping, NurbsMapping): - mapping.weights_field.coeffs.update_ghost_regions() - # ... - - # ... close the h5 file - h5.close() - # ... - - # Add spline callable mappings to domain undefined mappings - # NOTE: We assume that interiors and mappings.values() use the same ordering - for patch, F in zip(interiors, mappings.values()): - patch.mapping.set_callable_mapping(F) - - # ... - self._domain = domain - self._ldim = ldim - self._pdim = pdim - self._ncells = ncells - self._mappings = mappings - self._periodic = periodic - self._comm = comm - self._ddm = ddm - self._cart = None - # ... - def export( self, filename ): """ Parameters @@ -1069,3 +946,120 @@ def _make_ddm(domain : Domain, return MultiPatchDomainDecomposition([ncells [name] for name in interior_names], [periods[name] for name in interior_names], comm=comm) + +#============================================================================== +def _read_patch_spaces(h5 : h5py.File, yml : dict) -> list[list[SplineSpace]]: + """ + Read the 1D spline spaces of each patch from an open geometry file. + + Parameters + ---------- + h5 : h5py.File + The open geometry file. + + yml : dict + The content of its `geometry.yml` section. + + Returns + ------- + list[list[SplineSpace]] + The 1D spline spaces along each direction, for each patch. + """ + spaces = [] + for item in yml['patches']: + if item['type'] not in ('SplineMapping', 'NurbsMapping'): + raise ValueError(f"Mapping type {item['type']} of patch {item['name']} " + "is not supported") + patch = h5[item['mapping_id']] + degree = [int (p) for p in patch.attrs['degree' ]] + periodic = [bool(b) for b in patch.attrs['periodic']] + knots = [patch[f'knots_{d}'][:] for d in range(yml['ldim'])] + spaces.append([SplineSpace(degree=p, knots=k, periodic=P) + for p, k, P in zip(degree, knots, periodic)]) + return spaces + +def _read_spline_mappings(h5 : h5py.File, + yml : dict, + domain : Domain, + ddm : DomainDecomposition | MultiPatchDomainDecomposition, + spaces : list[list[SplineSpace]], + ) -> dict[str, SplineMapping]: + """ + Create the spline mappings of all patches from an open geometry file, on + the given domain decomposition. + + Parameters + ---------- + h5 : h5py.File + The open geometry file. + + yml : dict + The content of its `geometry.yml` section. + + domain : Sympde.topology.Domain + The topological domain, whose interiors follow the order of the patches. + + ddm : DomainDecomposition | MultiPatchDomainDecomposition + The decomposition of the domain. + + spaces : list[list[SplineSpace]] + The 1D spline spaces along each direction, for each patch. + + Returns + ------- + dict[str, SplineMapping] + The spline mappings, keyed by the names of the domain interiors. + """ + interiors = _get_interiors(domain) + connectivity = construct_connectivity(domain) + ddms = [ddm] if len(domain) == 1 else ddm.domains + pdim = yml['pdim'] + + carts = create_cart(ddms, spaces) + g_spaces = {itr: TensorFemSpace(ddms[i], *spaces[i], cart=carts[i]) + for i, itr in enumerate(interiors)} + + # Add refined spaces on both sides of each interface + for i, j in connectivity: + max_ncells = [max(Wi.ncells, Wj.ncells) for Wi, Wj in zip(spaces[i], spaces[j])] + g_spaces[interiors[i]].add_refined_space(ncells=max_ncells) + g_spaces[interiors[j]].add_refined_space(ncells=max_ncells) + + construct_interface_spaces(ddm, g_spaces, carts, interiors, connectivity) + + mappings = {} + for itr, item in zip(interiors, yml['patches']): + patch = h5[item['mapping_id']] + points = patch['points'][..., :pdim] + if item['type'] == 'SplineMapping': + mapping = SplineMapping.from_control_points(g_spaces[itr], points) + else: + mapping = NurbsMapping.from_control_points_weights(g_spaces[itr], points, + patch['weights']) + mapping.set_name(item['name']) + mappings[itr.name] = mapping + + # Update the ghost regions within each patch and across interfaces + if len(domain) > 1: + coeffs = [[e.coeffs for e in m.fields] for m in mappings.values()] + patch_spaces = [BlockVectorSpace(*[c.space for c in c_i]) for c_i in coeffs] + patch_spaces_w = [c_i[0].space for c_i in coeffs] + v = BlockVector(BlockVectorSpace(*patch_spaces , connectivity=connectivity)) + w = BlockVector(BlockVectorSpace(*patch_spaces_w, connectivity=connectivity)) + for i, mapping in enumerate(mappings.values()): + for j, c_ij in enumerate(coeffs[i]): + v[i][j] = c_ij + if isinstance(mapping, NurbsMapping): + w[i] = mapping.weights_field.coeffs + else: + w[i] = v[i][0].space.zeros() + v.update_ghost_regions() + w.update_ghost_regions() + else: + mapping, = mappings.values() + for f in mapping.fields: + f.coeffs.update_ghost_regions() + if isinstance(mapping, NurbsMapping): + mapping.weights_field.coeffs.update_ghost_regions() + + return mappings diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index fa8d37ef3..e603ec08f 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -24,6 +24,8 @@ MultiPatchDomainDecomposition) +import psydac.cad.mesh as mesh_mod + base_dir = os.path.dirname(os.path.realpath(__file__)) #============================================================================== def assert_geometries_equal(geo: Geometry, ref: Geometry) -> None: @@ -243,6 +245,26 @@ def test_geometry_init_without_mappings(npatches: int) -> None: assert geo.mappings == {name: None for name in domain.interior_names} +#============================================================================== +@pytest.mark.xdist_group('h5py') +def test_geometry_from_file_multipatch() -> None: + + mesh_dir = os.path.dirname(mesh_mod.__file__) + filename = os.path.join(mesh_dir, 'multipatch', 'magnet.h5') + geo = Geometry.from_file(filename) + names = geo.domain.interior_names + + # Patch information is keyed by the interior names, in the same order + assert list(geo.ncells) == names + assert list(geo.periodic) == names + assert list(geo.mappings) == names + assert all(isinstance(n, tuple) and len(n) == 2 for n in geo.ncells.values()) + assert all(p == (False, False) for p in geo.periodic.values()) + + # Each spline mapping is defined on the decomposition of its patch + for F, patch_ddm in zip(geo.mappings.values(), geo.ddm.domains): + assert F.space.domain_decomposition is patch_ddm + #============================================================================== def make_domain(npatches: int, *, ornt: int = 1) -> Domain: """Create a domain made of one or two unit squares, with generic mappings.""" @@ -346,7 +368,7 @@ def test_export_nurbs_to_hdf5(ncells, degree): assert abs(max_coords[0] - pipe.breaks(0)[-1])<1e-15 assert abs(max_coords[1] - pipe.breaks(1)[-1])<1e-15 - mapping = geo.mappings[domain.logical_domain.name] + mapping = geo.mappings[domain.interior.name] assert isinstance(mapping, NurbsMapping) @@ -395,7 +417,7 @@ def test_import_geopdes_to_nurbs(ncells, degree): assert abs(max_coords[0] - L_shaped.breaks(0)[-1])<1e-15 assert abs(max_coords[1] - L_shaped.breaks(1)[-1])<1e-15 - mapping = geo.mappings[domain.logical_domain.name] + mapping = geo.mappings[domain.interior.name] space = mapping.space knots = space.knots From 9875e5424793fd07b6675b98f6f763f244786cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 00:39:54 +0200 Subject: [PATCH 085/129] Detail the changes to Geometry in CHANGELOG.md --- CHANGELOG.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9dc0e1c3..d489ddd56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- #527 : Add constructor `Geometry.from_file` with option `domain` to reuse an existing SymPDE domain, after checking that it matches the file - #576 : Add module `psydac.feec.polar.conga_projections` with broken FEEC polar projections in 2D - #576 : Add 2D Poisson and TE Maxwell examples on polar mapped domains in `psydac/feec/polar/examples` - #576 : Add module `psydac.utilities.parallel_utils` for parallel execution and gathering variable-length arrays @@ -20,6 +21,11 @@ All notable changes to this project will be documented in this file. ### Fixed - #527 : Require `sympde==0.20.0` which changes how multipatch interfaces are defined +- #527 : Fix `psydac.cad.elevate` and `refine` for NURBS mappings, see #608 +- #527 : Keep the NURBS weights in `psydac.cad.translate`, see #609 +- #527 : Fix type checks of input mappings in module `psydac.cad.cad`, see #610 +- #527 : Fix `psydac.cad.elevate` and `refine` for distributed mappings, see #611 +- #527 : Check the weights field in `NurbsMapping.__init__`, see #616 - #576 : Require `sympde==0.19.3` which fixes a bug in the linearity checks - #579 : Require `h5py>=3.16` which installs correctly with `setuptools>=81.0` - #576 : Fix bug in `TensorFemSpace.eval_field` caused by round-off at MPI subdomain boundaries @@ -36,7 +42,10 @@ All notable changes to this project will be documented in this file. ### Changed -- #527 : Improve `Geometry` class in module `psydac.cad.geometry` +- #527 : Pass a domain decomposition `ddm` and physical dimension `pdim` to `Geometry.__init__`; remove arguments `ncells`, `periodic`, `filename`, `comm` and `mpi_dims_mask` +- #527 : Use the domain decomposition of the mapping's space in `Geometry.from_discrete_mapping`; remove arguments `comm` and `mpi_dims_mask` +- #527 : Key `Geometry.mappings` by the names of the domain interiors +- #527 : Return Cartesian (not homogeneous) control points from the NURBS functions in `psydac.cad.gallery`, see #608 - #576 : Use latest version of Igakit (commit dalcinl/igakit@92ee097 of 2026/07/24) which supports NumPy >= 2.4 - #595 : Use PETSc 3.25.5 whose Python bindings `petsc4py` are built correctly with `cython>=3` - #580 : Use PETSc 3.25.0 whose Python bindings `petsc4py` install correctly with `setuptools>=81.0` @@ -55,6 +64,9 @@ All notable changes to this project will be documented in this file. ### Removed +- #527 : Remove method `Geometry.read`, use constructor `Geometry.from_file` instead +- #527 : Remove property `Geometry.is_parallel` + ## [1.0.0] - 2026-01-19 The first official release on PyPI. A complete overhaul since version 0.1. From d2c34bcd2086e456bd5074aecc1d544618bcbf89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 11:52:33 +0200 Subject: [PATCH 086/129] Fix psydac.cad.elevate and refine for mappings with pdim=1, see #618 --- psydac/cad/cad.py | 8 ++++--- psydac/cad/tests/test_cad.py | 45 ++++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index e57ed650b..b0cd652ee 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -99,7 +99,8 @@ def elevate(mapping, axis, times): for f in input_fields: f.coeffs.update_ghost_regions() - points = np.zeros(shape+[mapping.pdim]) + # igakit requires at least 2 coordinates, the extra one is zero + points = np.zeros(shape + [max(pdim, 2)]) for i, f in enumerate(mapping.fields): points[..., i] = f.coeffs.toarray(with_pads=True).reshape(shape) @@ -166,8 +167,9 @@ def refine(mapping, axis, values): shape = [V.nbasis for V in space.spaces] # The new balanced decomposition may move the subdomain boundaries far - # away, hence each process needs all the coefficients - points = np.zeros(shape+[mapping.pdim]) + # away, hence each process needs all the coefficients. + # igakit requires at least 2 coordinates, the extra one is zero + points = np.zeros(shape + [max(pdim, 2)]) for i, f in enumerate(mapping.fields): points[..., i] = _global_array(f.coeffs).reshape(shape) diff --git a/psydac/cad/tests/test_cad.py b/psydac/cad/tests/test_cad.py index 9f3c056d0..2603be344 100644 --- a/psydac/cad/tests/test_cad.py +++ b/psydac/cad/tests/test_cad.py @@ -12,8 +12,26 @@ from psydac.cad.cad import elevate, refine, translate from psydac.cad.gallery import annulus, circle, quart_circle from psydac.cad.tests.test_gallery import make_nurbs_mapping +from psydac.ddm.cart import DomainDecomposition +from psydac.fem.splines import SplineSpace +from psydac.fem.tensor import TensorFemSpace +from psydac.mapping.discrete import NurbsMapping, SplineMapping from psydac.mapping.discrete_gallery import discrete_mapping +def make_mapping_1d(weights=None) -> SplineMapping: + """Create a nonlinear 1D spline mapping, or a NURBS mapping if weights are given.""" + W = SplineSpace(degree=2, knots=[0.0, 0.0, 0.0, 0.3, 0.7, 1.0, 1.0, 1.0]) + V = TensorFemSpace(DomainDecomposition([W.ncells], [False]), W) + points = np.array([[0.0], [0.2], [0.9], [1.5], [2.0]]) + if weights is None: + return SplineMapping.from_control_points(V, points) + return NurbsMapping.from_control_points_weights(V, points, weights) + +mappings_1d = pytest.mark.parametrize('make_mapping', [ + make_mapping_1d, + lambda: make_mapping_1d(weights=np.array([1.0, 0.5, 2.0, 0.7, 1.0])), +], ids=['spline', 'nurbs']) + # A spline mapping, and NURBS mappings whose weights vary along axis 0 # (quart_circle), axis 1 (annulus), or both (circle) mappings_2d = pytest.mark.parametrize('make_mapping', [ @@ -32,6 +50,13 @@ def assert_same_geometry(F, G) -> None: eta = list(product(np.linspace(0.0, 1.0, 9), repeat=G.ldim)) assert np.allclose([F(*e) for e in eta], [G(*e) for e in eta], rtol=0, atol=1e-14) +def check_translate(F, displ: np.ndarray) -> None: + """Check that translation moves the geometry by the displacement.""" + G = translate(F, displ) + + assert type(G) is type(F) + assert_same_geometry(lambda *eta: np.asarray(F(*eta)) + displ, G) + def check_elevate(F, axis: int) -> None: """Check that degree elevation along an axis preserves the geometry.""" G = elevate(F, axis=axis, times=1) @@ -54,17 +79,19 @@ def check_refine(F, axis: int) -> None: assert_same_geometry(F, G) #============================================================================== +@mappings_1d +def test_translate_1d(make_mapping) -> None: + check_translate(make_mapping(), np.array([-2.0])) + @mappings_2d def test_translate_2d(make_mapping) -> None: - - displ = np.array([1.0, -2.0]) - F = make_mapping() - G = translate(F, displ) - - assert type(G) is type(F) - assert_same_geometry(lambda *eta: np.asarray(F(*eta)) + displ, G) + check_translate(make_mapping(), np.array([1.0, -2.0])) #============================================================================== +@mappings_1d +def test_elevate_1d(make_mapping) -> None: + check_elevate(make_mapping(), axis=0) + @mappings_2d @pytest.mark.parametrize('axis', [0, 1]) def test_elevate_2d(axis: int, make_mapping) -> None: @@ -75,6 +102,10 @@ def test_elevate_3d(axis: int) -> None: check_elevate(make_mapping_3d(), axis) #============================================================================== +@mappings_1d +def test_refine_1d(make_mapping) -> None: + check_refine(make_mapping(), axis=0) + @mappings_2d @pytest.mark.parametrize('axis', [0, 1]) def test_refine_2d(axis: int, make_mapping) -> None: From 9b567cee02a6c68f49fd1d6bd78a3afdec007728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 12:12:52 +0200 Subject: [PATCH 087/129] Fix reading multipatch geometries with pdim=1, see #617 --- psydac/cad/geometry.py | 24 ++++++++++------------ psydac/cad/tests/test_geometry.py | 33 ++++++++++++++++++------------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index ea57fee20..af470aa40 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -1041,20 +1041,16 @@ def _read_spline_mappings(h5 : h5py.File, # Update the ghost regions within each patch and across interfaces if len(domain) > 1: - coeffs = [[e.coeffs for e in m.fields] for m in mappings.values()] - patch_spaces = [BlockVectorSpace(*[c.space for c in c_i]) for c_i in coeffs] - patch_spaces_w = [c_i[0].space for c_i in coeffs] - v = BlockVector(BlockVectorSpace(*patch_spaces , connectivity=connectivity)) - w = BlockVector(BlockVectorSpace(*patch_spaces_w, connectivity=connectivity)) - for i, mapping in enumerate(mappings.values()): - for j, c_ij in enumerate(coeffs[i]): - v[i][j] = c_ij - if isinstance(mapping, NurbsMapping): - w[i] = mapping.weights_field.coeffs - else: - w[i] = v[i][0].space.zeros() - v.update_ghost_regions() - w.update_ghost_regions() + # One block vector per component, with one block per patch which + # references the coefficients of the mapping + mapping_list = list(mappings.values()) + components = [[m.fields[k].coeffs for m in mapping_list] for k in range(pdim)] + if any(isinstance(m, NurbsMapping) for m in mapping_list): + components.append([m.weights_field.coeffs if isinstance(m, NurbsMapping) + else m.fields[0].coeffs.space.zeros() for m in mapping_list]) + for blocks in components: + V = BlockVectorSpace(*[b.space for b in blocks], connectivity=connectivity) + BlockVector(V, blocks=blocks).update_ghost_regions() else: mapping, = mappings.values() for f in mapping.fields: diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index e603ec08f..05e7ee6a2 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -266,25 +266,28 @@ def test_geometry_from_file_multipatch() -> None: assert F.space.domain_decomposition is patch_ddm #============================================================================== -def make_domain(npatches: int, *, ornt: int = 1) -> Domain: - """Create a domain made of one or two unit squares, with generic mappings.""" +def make_domain(npatches: int, *, ldim: int = 2, ornt: int = 1) -> Domain: + """Create a domain made of one or two unit lines or squares, with generic mappings.""" + Patch = {1: Line, 2: Square}[ldim] if npatches == 1: - return Mapping('G', dim=2)(Square('P')) + return Mapping('G', dim=ldim)(Patch('P')) - A = Mapping('GA', dim=2)(Square('PA')) - B = Mapping('GB', dim=2)(Square('PB')) - return Domain.join([A, B], [((0, 0, 1), (1, 0, -1), ornt)], 'Omega') + A = Mapping('GA', dim=ldim)(Patch('PA')) + B = Mapping('GB', dim=ldim)(Patch('PB')) + # 1D interfaces have no orientation + return Domain.join([A, B], [((0, 0, 1), (1, 0, -1), ornt if ldim > 1 else None)], 'Omega') def export_geometry(domain: Domain, filename: str) -> None: """Export a spline geometry on the domain, with patches side by side along x.""" names = domain.interior_names - ncells, degree = [2, 3], [2, 2] + ldim = domain.dim + ncells, degree = [2, 3][:ldim], [2, 2][:ldim] if len(domain) == 1: - ddm = DomainDecomposition(ncells, [False, False]) + ddm = DomainDecomposition(ncells, [False] * ldim) patch_ddms = [ddm] else: ddm = MultiPatchDomainDecomposition([ncells] * len(names), - [[False, False]] * len(names)) + [[False] * ldim] * len(names)) patch_ddms = ddm.domains # Spline mappings must be defined on the decomposition of their patch. The @@ -293,19 +296,21 @@ def export_geometry(domain: Domain, filename: str) -> None: for i, (name, patch_ddm) in enumerate(zip(names, patch_ddms)): spaces = [SplineSpace(degree=p, grid=np.linspace(0.0, 1.0, n + 1)) for p, n in zip(degree, ncells)] - g1, g2 = [W.greville for W in spaces] - points = np.stack(np.meshgrid(g1 + i, g2, indexing='ij'), axis=-1) + grevilles = [W.greville for W in spaces] + grevilles[0] = grevilles[0] + i + points = np.stack(np.meshgrid(*grevilles, indexing='ij'), axis=-1) V = TensorFemSpace(patch_ddm, *spaces) mappings[name] = SplineMapping.from_control_points(V, points) - Geometry(domain, ddm=ddm, pdim=2, mappings=mappings).export(filename) + Geometry(domain, ddm=ddm, pdim=ldim, mappings=mappings).export(filename) #============================================================================== +@pytest.mark.parametrize('ldim', [1, 2]) @pytest.mark.parametrize('npatches', [1, 2]) @pytest.mark.xdist_group('h5py') -def test_geometry_from_file_with_domain(npatches: int, tmp_path) -> None: +def test_geometry_from_file_with_domain(npatches: int, ldim: int, tmp_path) -> None: - domain = make_domain(npatches) + domain = make_domain(npatches, ldim=ldim) filename = str(tmp_path / 'geo.h5') export_geometry(domain, filename) From 3797cea6493e1ad4f4235e98d9c08985df971078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 12:23:46 +0200 Subject: [PATCH 088/129] Add 1D unit tests for Geometry and rename the 2D ones --- psydac/cad/tests/test_geometry.py | 97 ++++++++++++++++++------------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 05e7ee6a2..b635bab04 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -4,6 +4,7 @@ # for full license details. # #---------------------------------------------------------------------------# import os +from itertools import product import pytest import numpy as np @@ -15,6 +16,7 @@ from psydac.cad.geometry import import_geopdes_to_nurbs from psydac.cad.cad import elevate, refine from psydac.cad.gallery import quart_circle +from psydac.cad.tests.test_cad import assert_same_geometry, make_mapping_1d from psydac.mapping.discrete import SplineMapping, NurbsMapping from psydac.mapping.discrete_gallery import discrete_mapping from psydac.fem.splines import SplineSpace @@ -27,6 +29,9 @@ import psydac.cad.mesh as mesh_mod base_dir = os.path.dirname(os.path.realpath(__file__)) + +# Unit patch of each logical dimension +PATCHES = {1: Line, 2: Square} #============================================================================== def assert_geometries_equal(geo: Geometry, ref: Geometry) -> None: """Check that two geometries are equal, comparing their patches by position.""" @@ -63,31 +68,53 @@ def check_round_trips(geo: Geometry, mapping: SplineMapping, tmp_path) -> Geomet return geo_read +def make_identity_mapping(ddm: DomainDecomposition, degree: list[int], shift: float = 0.0) -> SplineMapping: + """Create a spline mapping on the decomposition, equal to the identity + shifted along x. Its control points are given by the Greville abscissae.""" + spaces = [SplineSpace(degree=p, grid=np.linspace(0.0, 1.0, n + 1)) + for p, n in zip(degree, ddm.ncells)] + grevilles = [W.greville for W in spaces] + grevilles[0] = grevilles[0] + shift + points = np.stack(np.meshgrid(*grevilles, indexing='ij'), axis=-1) + return SplineMapping.from_control_points(TensorFemSpace(ddm, *spaces), points) + #============================================================================== +@pytest.mark.parametrize('ldim', [1, 2]) @pytest.mark.xdist_group('h5py') -def test_geometry_2d_1(tmp_path): +def test_geometry_identity_spline(ldim: int, tmp_path) -> None: - # create an identity mapping - mapping = discrete_mapping('identity', ncells=[1, 1], degree=[2, 2]) + ddm = DomainDecomposition([2, 3][:ldim], [False] * ldim) + mapping = make_identity_mapping(ddm, degree=[2] * ldim) + domain = Mapping('F', dim=ldim)(PATCHES[ldim](name='Omega')) - # create a topological domain - F = Mapping('F', dim=2) - domain = F(Square(name='Omega')) + geo = Geometry(domain, ddm=ddm, pdim=ldim, mappings={domain.name: mapping}) + geo_read = check_round_trips(geo, mapping, tmp_path) - # create a geometry from a topological domain and the dict of mappings - geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=2, - mappings={domain.name: mapping}) + # The mapping read from file is the identity + F_read, = geo_read.mappings.values() + eta = list(product(np.linspace(0.0, 1.0, 5), repeat=ldim)) + assert np.allclose([F_read(*e) for e in eta], eta, rtol=0, atol=1e-15) +#============================================================================== +@pytest.mark.xdist_group('h5py') +def test_geometry_nurbs_1d(tmp_path) -> None: + + mapping = make_mapping_1d(weights=np.array([1.0, 0.5, 2.0, 0.7, 1.0])) + mapping = elevate(mapping, axis=0, times=1) + mapping = refine(mapping, axis=0, values=[0.1, 0.5]) + domain = Mapping('F', dim=1)(Line(name='Omega')) + + geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=1, + mappings={domain.name: mapping}) geo_read = check_round_trips(geo, mapping, tmp_path) - # The mapping read from file is the identity - F_read = list(geo_read.mappings.values())[0] - t = np.linspace(0.0, 1.0, 5) - assert all(np.allclose(F_read(e1, e2), [e1, e2], rtol=0, atol=1e-15) for e1 in t for e2 in t) + # The mapping read from file is the original one + F_read, = geo_read.mappings.values() + assert_same_geometry(F_read, mapping) #============================================================================== @pytest.mark.xdist_group('h5py') -def test_geometry_2d_2(tmp_path): +def test_geometry_nurbs_quarter_annulus(tmp_path) -> None: # create a nurbs mapping rmin, rmax = 0.5, 1.0 @@ -218,30 +245,24 @@ def test_from_topological_domain(): assert geo_from_domain.ddm.ends == expected_ends # ============================================================================== +@pytest.mark.parametrize('ldim', [1, 2]) @pytest.mark.parametrize('npatches', [1, 2]) -def test_geometry_init_without_mappings(npatches: int) -> None: +def test_geometry_init_without_mappings(npatches: int, ldim: int) -> None: - if npatches == 1: - domain = Square(name='A') - else: - A = Square('A', bounds1=(0, 1), bounds2=(0, 1)) - B = Square('B', bounds1=(1, 2), bounds2=(0, 1)) - domain = Domain.join(patches=[A, B], - connectivity=[((0, 0, 1), (1, 0, -1), 1)], - name='Omega') + domain = make_domain(npatches, ldim=ldim) - ncells = [3, 5] + ncells = [3, 5][:ldim] if npatches == 1: - ddm = DomainDecomposition(ncells, [False, False]) + ddm = DomainDecomposition(ncells, [False] * ldim) else: ddm = MultiPatchDomainDecomposition([ncells] * npatches, - [[False, False]] * npatches) + [[False] * ldim] * npatches) - geo = Geometry(domain, ddm=ddm, pdim=2) + geo = Geometry(domain, ddm=ddm, pdim=ldim) # The number of cells and the periodicity are obtained from the decomposition - assert geo.ncells == {name: (3, 5) for name in domain.interior_names} - assert geo.periodic == {name: (False, False) for name in domain.interior_names} + assert geo.ncells == {name: tuple(ncells) for name in domain.interior_names} + assert geo.periodic == {name: (False,) * ldim for name in domain.interior_names} assert geo.mappings == {name: None for name in domain.interior_names} @@ -268,7 +289,7 @@ def test_geometry_from_file_multipatch() -> None: #============================================================================== def make_domain(npatches: int, *, ldim: int = 2, ornt: int = 1) -> Domain: """Create a domain made of one or two unit lines or squares, with generic mappings.""" - Patch = {1: Line, 2: Square}[ldim] + Patch = PATCHES[ldim] if npatches == 1: return Mapping('G', dim=ldim)(Patch('P')) @@ -281,7 +302,7 @@ def export_geometry(domain: Domain, filename: str) -> None: """Export a spline geometry on the domain, with patches side by side along x.""" names = domain.interior_names ldim = domain.dim - ncells, degree = [2, 3][:ldim], [2, 2][:ldim] + ncells = [2, 3][:ldim] if len(domain) == 1: ddm = DomainDecomposition(ncells, [False] * ldim) patch_ddms = [ddm] @@ -290,17 +311,9 @@ def export_geometry(domain: Domain, filename: str) -> None: [[False] * ldim] * len(names)) patch_ddms = ddm.domains - # Spline mappings must be defined on the decomposition of their patch. The - # control points of the identity map are given by the Greville abscissae - mappings = {} - for i, (name, patch_ddm) in enumerate(zip(names, patch_ddms)): - spaces = [SplineSpace(degree=p, grid=np.linspace(0.0, 1.0, n + 1)) - for p, n in zip(degree, ncells)] - grevilles = [W.greville for W in spaces] - grevilles[0] = grevilles[0] + i - points = np.stack(np.meshgrid(*grevilles, indexing='ij'), axis=-1) - V = TensorFemSpace(patch_ddm, *spaces) - mappings[name] = SplineMapping.from_control_points(V, points) + # Spline mappings must be defined on the decomposition of their patch + mappings = {name: make_identity_mapping(patch_ddm, degree=[2] * ldim, shift=i) + for i, (name, patch_ddm) in enumerate(zip(names, patch_ddms))} Geometry(domain, ddm=ddm, pdim=ldim, mappings=mappings).export(filename) From 9c299880d82c1385be0bdbe56a561de8dad2a351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 12:25:49 +0200 Subject: [PATCH 089/129] Mention fixes of #617 and #618 in CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d489ddd56..06f5bb506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ All notable changes to this project will be documented in this file. - #527 : Fix type checks of input mappings in module `psydac.cad.cad`, see #610 - #527 : Fix `psydac.cad.elevate` and `refine` for distributed mappings, see #611 - #527 : Check the weights field in `NurbsMapping.__init__`, see #616 +- #527 : Fix reading multipatch geometries with `pdim=1`, see #617 +- #527 : Fix `psydac.cad.elevate` and `refine` for mappings with `pdim=1`, see #618 - #576 : Require `sympde==0.19.3` which fixes a bug in the linearity checks - #579 : Require `h5py>=3.16` which installs correctly with `setuptools>=81.0` - #576 : Fix bug in `TensorFemSpace.eval_field` caused by round-off at MPI subdomain boundaries From 899e223b95a7784d9af910722dd8f34d65eb4dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 12:52:33 +0200 Subject: [PATCH 090/129] Test mappings of curves in 2D/3D and surfaces in 3D Rename the test helpers after the manifold they create: make_curve, make_surface, make_volume. --- psydac/cad/tests/test_cad.py | 63 ++++++++++++++++++++----------- psydac/cad/tests/test_geometry.py | 18 ++++++--- 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/psydac/cad/tests/test_cad.py b/psydac/cad/tests/test_cad.py index 2603be344..27d0ffbd9 100644 --- a/psydac/cad/tests/test_cad.py +++ b/psydac/cad/tests/test_cad.py @@ -18,31 +18,49 @@ from psydac.mapping.discrete import NurbsMapping, SplineMapping from psydac.mapping.discrete_gallery import discrete_mapping -def make_mapping_1d(weights=None) -> SplineMapping: - """Create a nonlinear 1D spline mapping, or a NURBS mapping if weights are given.""" +def make_curve(*, nurbs: bool = False, pdim: int = 1) -> SplineMapping: + """Create a nonlinear spline or NURBS curve in `pdim` dimensions.""" W = SplineSpace(degree=2, knots=[0.0, 0.0, 0.0, 0.3, 0.7, 1.0, 1.0, 1.0]) V = TensorFemSpace(DomainDecomposition([W.ncells], [False]), W) - points = np.array([[0.0], [0.2], [0.9], [1.5], [2.0]]) - if weights is None: - return SplineMapping.from_control_points(V, points) - return NurbsMapping.from_control_points_weights(V, points, weights) + points = np.array([[0.0, 0.0, 1.0], + [0.2, 0.5, 0.8], + [0.9, 0.7, 0.3], + [1.5, 0.4, -0.2], + [2.0, -0.1, 0.0]])[:, :pdim] + if nurbs: + weights = np.array([1.0, 0.5, 2.0, 0.7, 1.0]) + return NurbsMapping.from_control_points_weights(V, points, weights) + return SplineMapping.from_control_points(V, points) + +def make_surface(*, pdim: int = 2) -> NurbsMapping: + """Create a NURBS surface in `pdim` dimensions from the quarter annulus, + lifted to z = x * y if pdim = 3.""" + degrees, knots, points, weights = quart_circle() + if pdim == 3: + z = points[..., 0] * points[..., 1] + points = np.concatenate([points, z[..., None]], axis=-1) + return make_nurbs_mapping(degrees, knots, points, weights) + +def make_volume() -> SplineMapping: + """Create a nonlinear spline volume in 3D.""" + return discrete_mapping('collela', ncells=[2, 2, 2], degree=[2, 2, 2]) mappings_1d = pytest.mark.parametrize('make_mapping', [ - make_mapping_1d, - lambda: make_mapping_1d(weights=np.array([1.0, 0.5, 2.0, 0.7, 1.0])), -], ids=['spline', 'nurbs']) - -# A spline mapping, and NURBS mappings whose weights vary along axis 0 -# (quart_circle), axis 1 (annulus), or both (circle) + make_curve, + lambda: make_curve(nurbs=True), + lambda: make_curve(pdim=2), + lambda: make_curve(nurbs=True, pdim=3), +], ids=['spline', 'nurbs', 'spline_curve_2d', 'nurbs_curve_3d']) + +# A spline mapping, NURBS mappings whose weights vary along axis 0 +# (quart_circle), axis 1 (annulus), or both (circle), and a NURBS surface mappings_2d = pytest.mark.parametrize('make_mapping', [ lambda: discrete_mapping('collela', ncells=[4, 4], degree=[2, 2]), lambda: make_nurbs_mapping(*quart_circle()), lambda: make_nurbs_mapping(*annulus()), lambda: make_nurbs_mapping(*circle()), -], ids=['spline', 'quart_circle', 'annulus', 'circle']) - -def make_mapping_3d(): - return discrete_mapping('collela', ncells=[2, 2, 2], degree=[2, 2, 2]) + lambda: make_surface(pdim=3), +], ids=['spline', 'quart_circle', 'annulus', 'circle', 'surface_3d']) #============================================================================== def assert_same_geometry(F, G) -> None: @@ -50,8 +68,9 @@ def assert_same_geometry(F, G) -> None: eta = list(product(np.linspace(0.0, 1.0, 9), repeat=G.ldim)) assert np.allclose([F(*e) for e in eta], [G(*e) for e in eta], rtol=0, atol=1e-14) -def check_translate(F, displ: np.ndarray) -> None: - """Check that translation moves the geometry by the displacement.""" +def check_translate(F) -> None: + """Check that translation moves the geometry by a fixed displacement.""" + displ = np.array([1.0, -2.0, 0.5])[:F.pdim] G = translate(F, displ) assert type(G) is type(F) @@ -81,11 +100,11 @@ def check_refine(F, axis: int) -> None: #============================================================================== @mappings_1d def test_translate_1d(make_mapping) -> None: - check_translate(make_mapping(), np.array([-2.0])) + check_translate(make_mapping()) @mappings_2d def test_translate_2d(make_mapping) -> None: - check_translate(make_mapping(), np.array([1.0, -2.0])) + check_translate(make_mapping()) #============================================================================== @mappings_1d @@ -99,7 +118,7 @@ def test_elevate_2d(axis: int, make_mapping) -> None: @pytest.mark.parametrize('axis', [0, 1, 2]) def test_elevate_3d(axis: int) -> None: - check_elevate(make_mapping_3d(), axis) + check_elevate(make_volume(), axis) #============================================================================== @mappings_1d @@ -113,7 +132,7 @@ def test_refine_2d(axis: int, make_mapping) -> None: @pytest.mark.parametrize('axis', [0, 1, 2]) def test_refine_3d(axis: int) -> None: - check_refine(make_mapping_3d(), axis) + check_refine(make_volume(), axis) #============================================================================== def make_mapping_parallel(comm: MPI.Comm): diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index b635bab04..7df2c6be4 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -16,7 +16,7 @@ from psydac.cad.geometry import import_geopdes_to_nurbs from psydac.cad.cad import elevate, refine from psydac.cad.gallery import quart_circle -from psydac.cad.tests.test_cad import assert_same_geometry, make_mapping_1d +from psydac.cad.tests.test_cad import assert_same_geometry, make_curve, make_surface from psydac.mapping.discrete import SplineMapping, NurbsMapping from psydac.mapping.discrete_gallery import discrete_mapping from psydac.fem.splines import SplineSpace @@ -96,15 +96,21 @@ def test_geometry_identity_spline(ldim: int, tmp_path) -> None: assert np.allclose([F_read(*e) for e in eta], eta, rtol=0, atol=1e-15) #============================================================================== +@pytest.mark.parametrize('make_mapping', [ + lambda: make_curve(nurbs=True), + lambda: make_curve(nurbs=True, pdim=2), + lambda: make_curve(nurbs=True, pdim=3), + lambda: make_surface(pdim=3), +], ids=['line', 'curve_2d', 'curve_3d', 'surface_3d']) @pytest.mark.xdist_group('h5py') -def test_geometry_nurbs_1d(tmp_path) -> None: +def test_geometry_nurbs_manifold(make_mapping, tmp_path) -> None: - mapping = make_mapping_1d(weights=np.array([1.0, 0.5, 2.0, 0.7, 1.0])) - mapping = elevate(mapping, axis=0, times=1) + mapping = elevate(make_mapping(), axis=0, times=1) mapping = refine(mapping, axis=0, values=[0.1, 0.5]) - domain = Mapping('F', dim=1)(Line(name='Omega')) + ldim, pdim = mapping.ldim, mapping.pdim + domain = Mapping('F', ldim=ldim, pdim=pdim)(PATCHES[ldim](name='Omega')) - geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=1, + geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=pdim, mappings={domain.name: mapping}) geo_read = check_round_trips(geo, mapping, tmp_path) From 361d72388202def8cfec336de9aa33565f4bd079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 12:57:19 +0200 Subject: [PATCH 091/129] Add missing #527 entries to CHANGELOG.md --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f5bb506..8b017af4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. ### Added - #527 : Add constructor `Geometry.from_file` with option `domain` to reuse an existing SymPDE domain, after checking that it matches the file +- #527 : Add function `get_available_mappings` and option `mpi_dims_mask` to `discrete_mapping` in `psydac.mapping.discrete_gallery` - #576 : Add module `psydac.feec.polar.conga_projections` with broken FEEC polar projections in 2D - #576 : Add 2D Poisson and TE Maxwell examples on polar mapped domains in `psydac/feec/polar/examples` - #576 : Add module `psydac.utilities.parallel_utils` for parallel execution and gathering variable-length arrays @@ -28,6 +29,8 @@ All notable changes to this project will be documented in this file. - #527 : Check the weights field in `NurbsMapping.__init__`, see #616 - #527 : Fix reading multipatch geometries with `pdim=1`, see #617 - #527 : Fix `psydac.cad.elevate` and `refine` for mappings with `pdim=1`, see #618 +- #527 : Avoid setting the `shape` of NumPy arrays (deprecated since NumPy 2.5) in `KroneckerLinearSolver` +- #527 : Fix error message in `psydac.api.discretization.get_max_degree_of_one_space` - #576 : Require `sympde==0.19.3` which fixes a bug in the linearity checks - #579 : Require `h5py>=3.16` which installs correctly with `setuptools>=81.0` - #576 : Fix bug in `TensorFemSpace.eval_field` caused by round-off at MPI subdomain boundaries @@ -48,6 +51,8 @@ All notable changes to this project will be documented in this file. - #527 : Use the domain decomposition of the mapping's space in `Geometry.from_discrete_mapping`; remove arguments `comm` and `mpi_dims_mask` - #527 : Key `Geometry.mappings` by the names of the domain interiors - #527 : Return Cartesian (not homogeneous) control points from the NURBS functions in `psydac.cad.gallery`, see #608 +- #527 : Keep the given SymPDE domain in `discretize(domain, filename=...)`, after checking that it matches the geometry file +- #527 : Do not import the submodules of `psydac.cad` in its `__init__.py` - #576 : Use latest version of Igakit (commit dalcinl/igakit@92ee097 of 2026/07/24) which supports NumPy >= 2.4 - #595 : Use PETSc 3.25.5 whose Python bindings `petsc4py` are built correctly with `cython>=3` - #580 : Use PETSc 3.25.0 whose Python bindings `petsc4py` install correctly with `setuptools>=81.0` From 23b1f478dd53aaf5da96285493a76f31d7861ae6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 13:51:53 +0200 Subject: [PATCH 092/129] Use temporary directories instead of xdist groups in test_geometry.py --- psydac/cad/tests/test_geometry.py | 26 ++++---------------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 7df2c6be4..06acab25c 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -80,7 +80,6 @@ def make_identity_mapping(ddm: DomainDecomposition, degree: list[int], shift: fl #============================================================================== @pytest.mark.parametrize('ldim', [1, 2]) -@pytest.mark.xdist_group('h5py') def test_geometry_identity_spline(ldim: int, tmp_path) -> None: ddm = DomainDecomposition([2, 3][:ldim], [False] * ldim) @@ -102,7 +101,6 @@ def test_geometry_identity_spline(ldim: int, tmp_path) -> None: lambda: make_curve(nurbs=True, pdim=3), lambda: make_surface(pdim=3), ], ids=['line', 'curve_2d', 'curve_3d', 'surface_3d']) -@pytest.mark.xdist_group('h5py') def test_geometry_nurbs_manifold(make_mapping, tmp_path) -> None: mapping = elevate(make_mapping(), axis=0, times=1) @@ -119,7 +117,6 @@ def test_geometry_nurbs_manifold(make_mapping, tmp_path) -> None: assert_same_geometry(F_read, mapping) #============================================================================== -@pytest.mark.xdist_group('h5py') def test_geometry_nurbs_quarter_annulus(tmp_path) -> None: # create a nurbs mapping @@ -273,7 +270,6 @@ def test_geometry_init_without_mappings(npatches: int, ldim: int) -> None: assert geo.mappings == {name: None for name in domain.interior_names} #============================================================================== -@pytest.mark.xdist_group('h5py') def test_geometry_from_file_multipatch() -> None: mesh_dir = os.path.dirname(mesh_mod.__file__) @@ -326,7 +322,6 @@ def export_geometry(domain: Domain, filename: str) -> None: #============================================================================== @pytest.mark.parametrize('ldim', [1, 2]) @pytest.mark.parametrize('npatches', [1, 2]) -@pytest.mark.xdist_group('h5py') def test_geometry_from_file_with_domain(npatches: int, ldim: int, tmp_path) -> None: domain = make_domain(npatches, ldim=ldim) @@ -350,7 +345,6 @@ def test_geometry_from_file_with_domain(npatches: int, ldim: int, tmp_path) -> N (1, lambda: Mapping('G', dim=2)(Square('P', bounds1=(0, 2))), 'Parametric bounds'), (2, lambda: make_domain(2, ornt=-1), 'Interfaces'), ], ids=['analytical', 'no-mapping', 'dimension', 'names', 'bounds', 'orientation']) -@pytest.mark.xdist_group('h5py') def test_geometry_from_file_with_wrong_domain(npatches: int, make_wrong_domain, message: str, tmp_path) -> None: filename = str(tmp_path / 'geo.h5') @@ -362,8 +356,7 @@ def test_geometry_from_file_with_wrong_domain(npatches: int, make_wrong_domain, #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) @pytest.mark.parametrize( 'degree', [[2,2], [3,2], [2,3], [3,3], [4,4]] ) -@pytest.mark.xdist_group('h5py') -def test_export_nurbs_to_hdf5(ncells, degree): +def test_export_nurbs_to_hdf5(ncells, degree, tmp_path): # create pipe geometry from igakit.cad import circle, ruled, bilinear, join @@ -376,7 +369,7 @@ def test_export_nurbs_to_hdf5(ncells, degree): # refine the nurbs object new_pipe = refine_nurbs(pipe, ncells=ncells, degree=degree) - filename = "pipe.h5" + filename = str(tmp_path / "pipe.h5") export_nurbs_to_hdf5(filename, new_pipe) # read the geometry @@ -416,8 +409,7 @@ def test_export_nurbs_to_hdf5(ncells, degree): #============================================================================== @pytest.mark.parametrize( 'ncells', [[8,8], [12,12], [14,14]] ) @pytest.mark.parametrize( 'degree', [[2,2], [3,2], [2,3], [3,3], [4,4]] ) -@pytest.mark.xdist_group('h5py') -def test_import_geopdes_to_nurbs(ncells, degree): +def test_import_geopdes_to_nurbs(ncells, degree, tmp_path): filename = os.path.join(base_dir, "geo_Lshaped_C1.txt") L_shaped = import_geopdes_to_nurbs(filename) @@ -425,7 +417,7 @@ def test_import_geopdes_to_nurbs(ncells, degree): # refine the nurbs object L_shaped = refine_nurbs(L_shaped, ncells=ncells, degree=degree) - filename = "L_shaped.h5" + filename = str(tmp_path / "L_shaped.h5") export_nurbs_to_hdf5(filename, L_shaped) # read the geometry @@ -458,19 +450,9 @@ def test_import_geopdes_to_nurbs(ncells, degree): #============================================================================== def teardown_module(): - import os from sympy.core import cache cache.clear_cache() - # Remove HDF5 files generated by Geometry.export() - filenames = [ - 'pipe.h5', - 'L_shaped.h5', - ] - for fname in filenames: - if os.path.exists(fname): - os.remove(fname) - def teardown_function(): from sympy.core import cache cache.clear_cache() From 8525407941291839e826ccd7579bd73e794eeeef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 15:20:54 +0200 Subject: [PATCH 093/129] Reduce code duplication between psydac.cad.elevate and refine --- psydac/cad/cad.py | 191 +++++++++++++++++++++------------------------- 1 file changed, 86 insertions(+), 105 deletions(-) diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index b0cd652ee..452841147 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -3,7 +3,7 @@ # LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # # for full license details. # #---------------------------------------------------------------------------# -from typing import Iterable +from typing import Callable, Iterable import numpy as np from mpi4py import MPI @@ -73,71 +73,15 @@ def elevate(mapping, axis, times): Note: we are using igakit for the moment, until we implement the elevation degree algorithm in psydac """ - try: - from igakit.nurbs import NURBS - except: - raise ImportError('Could not find igakit.') - assert isinstance(mapping, SplineMapping) - assert( isinstance(times, int) ) - assert( isinstance(axis, int) ) - - space = mapping.space - domain_decomposition = space.domain_decomposition - pdim = mapping.pdim - - knots = [V.knots for V in space.spaces] - degree = [V.degree for V in space.spaces] - shape = [V.nbasis for V in space.spaces] + assert isinstance(times, int) + assert isinstance(axis, int) # The decomposition does not change, hence each process only needs the # coefficients owned by its neighbours that are stored in its ghost regions - is_nurbs = isinstance(mapping, NurbsMapping) - input_fields = list(mapping.fields) - if is_nurbs: - input_fields.append(mapping.weights_field) - for f in input_fields: - f.coeffs.update_ghost_regions() - - # igakit requires at least 2 coordinates, the extra one is zero - points = np.zeros(shape + [max(pdim, 2)]) - for i, f in enumerate(mapping.fields): - points[..., i] = f.coeffs.toarray(with_pads=True).reshape(shape) - - weights = None - if is_nurbs: - weights = mapping.weights_field.coeffs.toarray(with_pads=True).reshape(shape) - - # degree elevation using igakit, which expects Cartesian control points - nrb = NURBS(knots, points, weights=weights) - nrb = nrb.clone().elevate(axis, times) - - spaces = [SplineSpace(degree=p, knots=u) for p,u in zip( nrb.degree, nrb.knots )] - space = TensorFemSpace( domain_decomposition, *spaces ) - fields = [FemField( space ) for d in range( pdim )] - - # Get spline coefficients for each coordinate X_i - starts = space.coeff_space.starts - ends = space.coeff_space.ends - idx_to = tuple( slice( s, e+1 ) for s,e in zip( starts, ends ) ) - for i,field in enumerate( fields ): - idx_from = tuple(list(idx_to)+[i]) - field.coeffs[idx_to] = nrb.points[idx_from] - - field.coeffs.update_ghost_regions() - - if isinstance(mapping, NurbsMapping): - weights_field = FemField( space ) - - idx_from = idx_to - weights_field.coeffs[idx_to] = nrb.weights[idx_from] - weights_field.coeffs.update_ghost_regions() - - fields.append( weights_field ) + nrb = _to_igakit(mapping, _local_array).elevate(axis, times) - return NurbsMapping( *fields ) - - return SplineMapping( *fields ) + return _from_igakit(nrb, mapping.space.domain_decomposition, mapping) #============================================================================== @@ -149,70 +93,107 @@ def refine(mapping, axis, values): Note: we are using igakit for the moment, until we implement the knot insertion algorithm in psydac """ - try: - from igakit.nurbs import NURBS - except: - raise ImportError('Could not find igakit.') - assert isinstance(mapping, SplineMapping) - assert( isinstance(values, (list, tuple)) ) - assert( isinstance(axis, int) ) + assert isinstance(values, (list, tuple)) + assert isinstance(axis, int) - space = mapping.space - domain_decomposition = space.domain_decomposition - pdim = mapping.pdim + # The new balanced decomposition may move the subdomain boundaries far + # away, hence each process needs all the coefficients + nrb = _to_igakit(mapping, _global_array).refine(axis, values) + + ddm = mapping.space.domain_decomposition + ncells = list(ddm.ncells) + ncells[axis] += len(values) + new_ddm = DomainDecomposition(ncells, ddm.periods, comm=ddm.comm) - knots = [V.knots for V in space.spaces] - degree = [V.degree for V in space.spaces] - shape = [V.nbasis for V in space.spaces] + return _from_igakit(nrb, new_ddm, mapping) + +#============================================================================== +def _to_igakit(mapping: SplineMapping, to_array: Callable[[StencilVector], np.ndarray]): + """ + Create an igakit NURBS object from a spline or NURBS mapping. + + Parameters + ---------- + mapping : SplineMapping + The input mapping (possibly a NurbsMapping). + + to_array : Callable[[StencilVector], np.ndarray] + The function which returns the coefficients of a field as an array + with the global shape, which must be correct where it is used. + + Returns + ------- + igakit.nurbs.NURBS + The igakit object with the Cartesian control points and the weights + of the mapping. + """ + try: + from igakit.nurbs import NURBS + except ImportError as err: + raise ImportError('Could not find igakit.') from err + + knots = [V.knots for V in mapping.space.spaces] + shape = [V.nbasis for V in mapping.space.spaces] - # The new balanced decomposition may move the subdomain boundaries far - # away, hence each process needs all the coefficients. # igakit requires at least 2 coordinates, the extra one is zero - points = np.zeros(shape + [max(pdim, 2)]) + points = np.zeros(shape + [max(mapping.pdim, 2)]) for i, f in enumerate(mapping.fields): - points[..., i] = _global_array(f.coeffs).reshape(shape) + points[..., i] = to_array(f.coeffs).reshape(shape) weights = None if isinstance(mapping, NurbsMapping): - weights = _global_array(mapping.weights_field.coeffs).reshape(shape) + weights = to_array(mapping.weights_field.coeffs).reshape(shape) - # knot insertion using igakit, which expects Cartesian control points - nrb = NURBS(knots, points, weights=weights) - nrb = nrb.clone().refine(axis, values) + # igakit expects Cartesian control points + return NURBS(knots, points, weights=weights) - spaces = [SplineSpace(degree=p, knots=u) for p,u in zip( nrb.degree, nrb.knots )] +def _from_igakit(nrb, domain_decomposition: DomainDecomposition, mapping: SplineMapping) -> SplineMapping: + """ + Create a mapping of the same type and dimension as a given mapping, from + an igakit NURBS object and a domain decomposition. - ncells = list(domain_decomposition.ncells) - ncells[axis] += len(values) - domain_decomposition = DomainDecomposition(ncells, domain_decomposition.periods, comm=domain_decomposition.comm) + Parameters + ---------- + nrb : igakit.nurbs.NURBS + The igakit object, whose control points and weights are known on + every process. - space = TensorFemSpace( domain_decomposition, *spaces ) - fields = [FemField( space ) for d in range( pdim )] + domain_decomposition : DomainDecomposition + The decomposition of the new mapping's domain. - # Get spline coefficients for each coordinate X_i - starts = space.coeff_space.starts - ends = space.coeff_space.ends - idx_to = tuple( slice( s, e+1 ) for s,e in zip( starts, ends ) ) - for i,field in enumerate( fields ): - idx_from = tuple(list(idx_to)+[i]) - field.coeffs[idx_to] = nrb.points[idx_from] - field.coeffs.update_ghost_regions() + mapping : SplineMapping + The mapping whose type (SplineMapping or NurbsMapping) and physical + dimension are used. - if isinstance(mapping, NurbsMapping): - weights_field = FemField( space ) + Returns + ------- + SplineMapping + The new mapping. It is a NurbsMapping if `mapping` is a NurbsMapping. + """ + spaces = [SplineSpace(degree=p, knots=u) for p, u in zip(nrb.degree, nrb.knots)] + space = TensorFemSpace(domain_decomposition, *spaces) - idx_from = idx_to - weights_field.coeffs[idx_to] = nrb.weights[idx_from] - weights_field.coeffs.update_ghost_regions() + arrays = [nrb.points[..., i] for i in range(mapping.pdim)] + is_nurbs = isinstance(mapping, NurbsMapping) + if is_nurbs: + arrays.append(nrb.weights) - fields.append( weights_field ) + # Each process copies the coefficients that it owns + idx = tuple(slice(s, e + 1) for s, e in zip(space.coeff_space.starts, space.coeff_space.ends)) + fields = [FemField(space) for _ in arrays] + for field, array in zip(fields, arrays): + field.coeffs[idx] = array[idx] + field.coeffs.update_ghost_regions() - return NurbsMapping( *fields ) + return NurbsMapping(*fields) if is_nurbs else SplineMapping(*fields) - return SplineMapping( *fields ) +def _local_array(coeffs: StencilVector) -> np.ndarray: + """Return the array of coefficients owned by the process and its neighbours.""" + coeffs.update_ghost_regions() + # In parallel, the array is zero outside of the block and the ghost regions + return coeffs.toarray(with_pads=True) -#============================================================================== def _global_array(coeffs: StencilVector) -> np.ndarray: """Return the global array of coefficients on every process.""" array = coeffs.toarray() From 4d1d23fcdabcccfad1b837f9f8af2e7d75ea2e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 15:40:19 +0200 Subject: [PATCH 094/129] Fix psydac.cad.refine when inserting existing knots, see #619 --- psydac/cad/cad.py | 37 +++++++++++++++++++++--------------- psydac/cad/tests/test_cad.py | 10 +++++++--- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index 452841147..6f33da4fb 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -81,7 +81,7 @@ def elevate(mapping, axis, times): # coefficients owned by its neighbours that are stored in its ghost regions nrb = _to_igakit(mapping, _local_array).elevate(axis, times) - return _from_igakit(nrb, mapping.space.domain_decomposition, mapping) + return _from_igakit(nrb, mapping, mapping.space.domain_decomposition) #============================================================================== @@ -101,12 +101,7 @@ def refine(mapping, axis, values): # away, hence each process needs all the coefficients nrb = _to_igakit(mapping, _global_array).refine(axis, values) - ddm = mapping.space.domain_decomposition - ncells = list(ddm.ncells) - ncells[axis] += len(values) - new_ddm = DomainDecomposition(ncells, ddm.periods, comm=ddm.comm) - - return _from_igakit(nrb, new_ddm, mapping) + return _from_igakit(nrb, mapping) #============================================================================== def _to_igakit(mapping: SplineMapping, to_array: Callable[[StencilVector], np.ndarray]): @@ -148,10 +143,12 @@ def _to_igakit(mapping: SplineMapping, to_array: Callable[[StencilVector], np.nd # igakit expects Cartesian control points return NURBS(knots, points, weights=weights) -def _from_igakit(nrb, domain_decomposition: DomainDecomposition, mapping: SplineMapping) -> SplineMapping: +def _from_igakit(nrb, + mapping: SplineMapping, + domain_decomposition: DomainDecomposition | None = None) -> SplineMapping: """ Create a mapping of the same type and dimension as a given mapping, from - an igakit NURBS object and a domain decomposition. + an igakit NURBS object. Parameters ---------- @@ -159,12 +156,13 @@ def _from_igakit(nrb, domain_decomposition: DomainDecomposition, mapping: Spline The igakit object, whose control points and weights are known on every process. - domain_decomposition : DomainDecomposition - The decomposition of the new mapping's domain. - mapping : SplineMapping - The mapping whose type (SplineMapping or NurbsMapping) and physical - dimension are used. + The mapping whose type (SplineMapping or NurbsMapping), physical + dimension, periodicity, and MPI communicator are used. + + domain_decomposition : DomainDecomposition, optional + The decomposition of the new mapping's domain. If not given, a new + balanced decomposition is created for the new number of cells. Returns ------- @@ -172,7 +170,16 @@ def _from_igakit(nrb, domain_decomposition: DomainDecomposition, mapping: Spline The new mapping. It is a NurbsMapping if `mapping` is a NurbsMapping. """ spaces = [SplineSpace(degree=p, knots=u) for p, u in zip(nrb.degree, nrb.knots)] - space = TensorFemSpace(domain_decomposition, *spaces) + + if domain_decomposition is not None: + ddm = domain_decomposition + else: + # The number of cells is given by the spaces, because inserting a value + # which is already a knot only increases its multiplicity (see #619) + old_ddm = mapping.space.domain_decomposition + ddm = DomainDecomposition([W.ncells for W in spaces], old_ddm.periods, comm=old_ddm.comm) + + space = TensorFemSpace(ddm, *spaces) arrays = [nrb.points[..., i] for i in range(mapping.pdim)] is_nurbs = isinstance(mapping, NurbsMapping) diff --git a/psydac/cad/tests/test_cad.py b/psydac/cad/tests/test_cad.py index 27d0ffbd9..04c05ee99 100644 --- a/psydac/cad/tests/test_cad.py +++ b/psydac/cad/tests/test_cad.py @@ -86,10 +86,9 @@ def check_elevate(F, axis: int) -> None: assert list(G.space.degree) == expected_degree assert_same_geometry(F, G) -def check_refine(F, axis: int) -> None: +def check_refine(F, axis: int, values: tuple[float, ...] = (0.1, 0.35, 0.6, 0.9)) -> None: """Check that knot insertion along an axis preserves the geometry.""" - # Avoid the double knots of annulus, which already have multiplicity p - values = [0.1, 0.35, 0.6, 0.9] + # The default values avoid the double knots of annulus, which already have multiplicity p G = refine(F, axis=axis, values=values) expected_breaks = np.union1d(F.space.spaces[axis].breaks, values) @@ -125,6 +124,11 @@ def test_elevate_3d(axis: int) -> None: def test_refine_1d(make_mapping) -> None: check_refine(make_mapping(), axis=0) +# Values which only increase the multiplicity of a knot create no new cells +@pytest.mark.parametrize('values', [(0.3,), (0.5, 0.5)], ids=['existing_knot', 'repeated_value']) +def test_refine_multiplicity(values: tuple[float, ...]) -> None: + check_refine(make_curve(nurbs=True), axis=0, values=values) + @mappings_2d @pytest.mark.parametrize('axis', [0, 1]) def test_refine_2d(axis: int, make_mapping) -> None: From bfa790646628854905a64d6aaa66dfd7d6a4f38b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 15:40:34 +0200 Subject: [PATCH 095/129] Mention fix of #619 in CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b017af4a..31b93ba9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file. - #527 : Check the weights field in `NurbsMapping.__init__`, see #616 - #527 : Fix reading multipatch geometries with `pdim=1`, see #617 - #527 : Fix `psydac.cad.elevate` and `refine` for mappings with `pdim=1`, see #618 +- #527 : Fix `psydac.cad.refine` when inserting a value which is already a knot, see #619 - #527 : Avoid setting the `shape` of NumPy arrays (deprecated since NumPy 2.5) in `KroneckerLinearSolver` - #527 : Fix error message in `psydac.api.discretization.get_max_degree_of_one_space` - #576 : Require `sympde==0.19.3` which fixes a bug in the linearity checks From c0fc0f53c5ddf7e95c13f615cc73b27cd0ac9a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 17:17:16 +0200 Subject: [PATCH 096/129] Use mpi_tmp_path fixture in test_geometry_with_mpi_dims_mask --- psydac/cad/tests/test_geometry.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 06acab25c..0bf5a9ae8 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -157,7 +157,7 @@ def test_geometry_nurbs_quarter_annulus(tmp_path) -> None: #============================================================================== @pytest.mark.mpi -def test_geometry_with_mpi_dims_mask(): +def test_geometry_with_mpi_dims_mask(mpi_tmp_path): comm = MPI.COMM_WORLD rank = comm.rank @@ -183,20 +183,16 @@ def test_geometry_with_mpi_dims_mask(): # Here we allow for any distribution of the domain: mpi_dims_mask is not passed geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=3, mappings=mappings) - geo.export('geo_mpi_dims.h5') + filename = str(mpi_tmp_path / 'geo_mpi_dims.h5') + geo.export(filename) # Read geometry file in parallel, but using mpi_dims_mask - geo_from_file = Geometry.from_file(filename='geo_mpi_dims.h5', comm=comm, mpi_dims_mask=mpi_dims_mask) + geo_from_file = Geometry.from_file(filename=filename, 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.mpi def test_from_discrete_mapping(): From ff4b7c24191045d4e1197725c44135c0421cc704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 17:45:26 +0200 Subject: [PATCH 097/129] Restore MPI tests, PETSc tests and coverage report in testing workflow --- .github/workflows/testing.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 9a05f63a5..ffd83e900 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -126,17 +126,17 @@ jobs: if: matrix.os == 'macos-14' working-directory: ./scratch run: | - psydac test --mod psydac.mapping -x -v + coverage report --ignore-errors --show-missing --sort=cover - name: Run MPI tests with Pytest working-directory: ./scratch run: | - psydac test --mod psydac.fem -x -v + psydac test --mpi - name: Run single-process PETSc tests with Pytest working-directory: ./scratch run: | - psydac test --mod psydac.feec -x -v + psydac test --petsc - name: Run MPI PETSc tests with Pytest working-directory: ./scratch From dae164fdd9d490be92f8ba7d671d574238d0a824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 18:19:44 +0200 Subject: [PATCH 098/129] Add interface orientation to plate_with_hole multipatch geometry files --- .../cad/mesh/multipatch/plate_with_hole_mp.h5 | Bin 56047 -> 58689 bytes .../mesh/multipatch/plate_with_hole_mp_6.h5 | Bin 84367 -> 87009 bytes .../mesh/multipatch/plate_with_hole_mp_7.h5 | Bin 84367 -> 87009 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/psydac/cad/mesh/multipatch/plate_with_hole_mp.h5 b/psydac/cad/mesh/multipatch/plate_with_hole_mp.h5 index 168f193ce0dcdcb2c4adabc5c7a314409bda7d58..4c7b26c9025187b4d16502c6742d5ca77a988020 100644 GIT binary patch delta 88 zcmaFAmHFT)<_Q{1j!!pgRk1P!aZUDPmf?JVivbLRHlJoyxw?5ZCo2n+uEOLsy!V)O h6$~dQ^2<$L%C{fNz6++U1#Yt{C;(+Qiwd4%0ssSV8vy_S delta 37 qcmX?jiuwIk<_Q{1?{96?s$yj_;hOBnEW^2)lK~7&HlJoyxe5RT#S6dy diff --git a/psydac/cad/mesh/multipatch/plate_with_hole_mp_6.h5 b/psydac/cad/mesh/multipatch/plate_with_hole_mp_6.h5 index f6bee5b7fb55da52144cb0c6f37f5464143bf900..6e6cf745601cedad9593cb4040259b7aa6a34f89 100644 GIT binary patch delta 92 zcmeC5%=&OS>jVv^hrt`Qs#uwVxF-8C%W(F4GBSWbQ1fZl?Wb88k2r5$&B@Bbq^mG_ l4evc>T?NC*iTrYtm-6k0vhRXvYk}LW3JO5k&7y**m;e^a8w3CV delta 41 ucmaF3oV9;4>jVv^e$S0sRjf=VT$BBnWjI%JGJt_e^J&)Yr&$?~I0FC%ybH7d diff --git a/psydac/cad/mesh/multipatch/plate_with_hole_mp_7.h5 b/psydac/cad/mesh/multipatch/plate_with_hole_mp_7.h5 index cf56bfcadfc538ccd873a861f9d36c86801f6adc..fca6c426592b68753bdbf41a1b3e41152f6e8f67 100644 GIT binary patch delta 92 zcmeC5%=&OS>jVv^hrt`Qs#uwVxF-8C%W(F4GBSWbQ1fZl?Wb88k2r5$&B@Bbq^mG_ l4evc>T?NC*iTrYtm-6k0vhRXvYk}LW3JO5k&7y**m;e^a8w3CV delta 41 ucmaF3oV9;4>jVv^e$S0sRjf=VT$BBnWjI%JGJt_e^J&)Yr&$?~I0FC%ybH7d From 0ec6c9fc38532516857140f3328ca61285c015c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 19:00:32 +0200 Subject: [PATCH 099/129] Store mpi_dims_mask in DomainDecomposition and use it in refine, see #622 --- psydac/cad/cad.py | 3 ++- psydac/cad/tests/test_cad.py | 16 +++++++++---- psydac/ddm/cart.py | 9 +++++++- psydac/ddm/tests/test_cart.py | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 psydac/ddm/tests/test_cart.py diff --git a/psydac/cad/cad.py b/psydac/cad/cad.py index 6f33da4fb..1c3556682 100644 --- a/psydac/cad/cad.py +++ b/psydac/cad/cad.py @@ -177,7 +177,8 @@ def _from_igakit(nrb, # The number of cells is given by the spaces, because inserting a value # which is already a knot only increases its multiplicity (see #619) old_ddm = mapping.space.domain_decomposition - ddm = DomainDecomposition([W.ncells for W in spaces], old_ddm.periods, comm=old_ddm.comm) + ddm = DomainDecomposition([W.ncells for W in spaces], old_ddm.periods, comm=old_ddm.comm, + mpi_dims_mask=old_ddm.mpi_dims_mask) space = TensorFemSpace(ddm, *spaces) diff --git a/psydac/cad/tests/test_cad.py b/psydac/cad/tests/test_cad.py index 04c05ee99..9340c2995 100644 --- a/psydac/cad/tests/test_cad.py +++ b/psydac/cad/tests/test_cad.py @@ -139,8 +139,9 @@ def test_refine_3d(axis: int) -> None: check_refine(make_volume(), axis) #============================================================================== -def make_mapping_parallel(comm: MPI.Comm): - return discrete_mapping('collela', ncells=[8, 8], degree=[2, 2], comm=comm) +def make_mapping_parallel(comm: MPI.Comm, mpi_dims_mask: list[bool] | None = None): + return discrete_mapping('collela', ncells=[8, 8], degree=[2, 2], comm=comm, + mpi_dims_mask=mpi_dims_mask) def assert_same_local_coeffs(F, G) -> None: """Check that the local data (owned and ghost coefficients) of a distributed @@ -174,12 +175,19 @@ def test_elevate_parallel(axis: int) -> None: [0.1, 0.35, 0.6, 0.9], [0.01, 0.04, 0.07, 0.1, 0.13, 0.16, 0.19, 0.22], ], ids=['spread', 'shifted']) -def test_refine_parallel(values: list, axis: int) -> None: +@pytest.mark.parametrize('mpi_dims_mask', [None, [False, True], [True, False]]) +def test_refine_parallel(mpi_dims_mask: list[bool] | None, values: list, axis: int) -> None: - F = make_mapping_parallel(MPI.COMM_WORLD) + F = make_mapping_parallel(MPI.COMM_WORLD, mpi_dims_mask) F_serial = make_mapping_parallel(MPI.COMM_SELF) G = refine(F, axis=axis, values=values) G_serial = refine(F_serial, axis=axis, values=values) assert_same_local_coeffs(G_serial, G) + + # The directions excluded by the mask are not decomposed (see #622) + ddm = G.space.domain_decomposition + assert ddm.mpi_dims_mask == F.space.domain_decomposition.mpi_dims_mask + if mpi_dims_mask is not None: + assert all(n == 1 for n, use_dim in zip(ddm.nprocs, mpi_dims_mask) if not use_dim) diff --git a/psydac/ddm/cart.py b/psydac/ddm/cart.py index a43bceb9b..d565c3fa6 100644 --- a/psydac/ddm/cart.py +++ b/psydac/ddm/cart.py @@ -214,6 +214,7 @@ def __init__(self, ncells, periods, comm=None, global_comm=None, num_threads=Non self._ncells = tuple ( ncells ) self._periods = tuple ( periods ) + self._mpi_dims_mask = None if mpi_dims_mask is None else tuple(mpi_dims_mask) self._comm = comm self._global_comm = comm if global_comm is None else global_comm self._comm_cart = comm @@ -300,6 +301,11 @@ def ncells( self ): def periods( self ): return self._periods + @property + def mpi_dims_mask(self) -> tuple[bool, ...] | None: + """The directions which may be decomposed, or None if not specified (all directions).""" + return self._mpi_dims_mask + @property def size( self ): return self._size @@ -398,9 +404,10 @@ def refine(self, ncells, global_element_starts, global_element_ends): assert len( ncells ) == len( self.ncells ) assert all(nc>=snc for nc, snc in zip(ncells, self.ncells)) + # The mask is needed to recover the same process grid (see #622) domain = DomainDecomposition(self.ncells, self.periods, comm=self.comm, global_comm=self.global_comm, num_threads=self.num_threads, - size=self.size) + size=self.size, mpi_dims_mask=self.mpi_dims_mask) domain._ncells = tuple ( ncells ) # Store arrays with all the starts and ends along each direction for every process diff --git a/psydac/ddm/tests/test_cart.py b/psydac/ddm/tests/test_cart.py new file mode 100644 index 000000000..53ab1e14f --- /dev/null +++ b/psydac/ddm/tests/test_cart.py @@ -0,0 +1,42 @@ +#---------------------------------------------------------------------------# +# This file is part of PSYDAC which is released under MIT License. See the # +# LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # +# for full license details. # +#---------------------------------------------------------------------------# +import pytest +from mpi4py import MPI + +from psydac.ddm.cart import DomainDecomposition + +masks = pytest.mark.parametrize('mpi_dims_mask', [None, [False, True], [True, False]]) + +#============================================================================== +@masks +def test_DomainDecomposition_mpi_dims_mask(mpi_dims_mask) -> None: + + ddm = DomainDecomposition([3, 5], [False, False], mpi_dims_mask=mpi_dims_mask) + + expected = None if mpi_dims_mask is None else tuple(mpi_dims_mask) + assert ddm.mpi_dims_mask == expected + +#============================================================================== +@pytest.mark.mpi +@masks +def test_DomainDecomposition_refine(mpi_dims_mask) -> None: + + # Each direction can be decomposed among all processes + comm = MPI.COMM_WORLD + ncells = [2 * comm.size, 3 * comm.size] + ddm = DomainDecomposition(ncells, [False, False], comm=comm, mpi_dims_mask=mpi_dims_mask) + + # Split each cell into 2 x 2 cells, keeping the subdomain boundaries + global_starts = [2 * s for s in ddm.global_element_starts] + global_ends = [2 * e + 1 for e in ddm.global_element_ends] + new_ddm = ddm.refine([2 * n for n in ncells], global_starts, global_ends) + + # The refined decomposition has the same process grid (see #622) + assert new_ddm.mpi_dims_mask == ddm.mpi_dims_mask + assert list(new_ddm.nprocs) == list(ddm.nprocs) + assert list(new_ddm.comm_cart.Get_topo()[0]) == list(ddm.nprocs) + assert new_ddm.starts == tuple(2 * s for s in ddm.starts) + assert new_ddm.ends == tuple(2 * e + 1 for e in ddm.ends) From dc6c4fceda930027fe01bbad58dc54bdeae2b835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 19:01:20 +0200 Subject: [PATCH 100/129] Mention fix of #622 in CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b93ba9d..cfde4fa32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - #527 : Add constructor `Geometry.from_file` with option `domain` to reuse an existing SymPDE domain, after checking that it matches the file - #527 : Add function `get_available_mappings` and option `mpi_dims_mask` to `discrete_mapping` in `psydac.mapping.discrete_gallery` +- #527 : Add property `mpi_dims_mask` to `DomainDecomposition` - #576 : Add module `psydac.feec.polar.conga_projections` with broken FEEC polar projections in 2D - #576 : Add 2D Poisson and TE Maxwell examples on polar mapped domains in `psydac/feec/polar/examples` - #576 : Add module `psydac.utilities.parallel_utils` for parallel execution and gathering variable-length arrays @@ -30,6 +31,7 @@ All notable changes to this project will be documented in this file. - #527 : Fix reading multipatch geometries with `pdim=1`, see #617 - #527 : Fix `psydac.cad.elevate` and `refine` for mappings with `pdim=1`, see #618 - #527 : Fix `psydac.cad.refine` when inserting a value which is already a knot, see #619 +- #527 : Keep the `mpi_dims_mask` of a `DomainDecomposition` in `TensorFemSpace.add_refined_space` and `psydac.cad.cad.refine`, see #622 - #527 : Avoid setting the `shape` of NumPy arrays (deprecated since NumPy 2.5) in `KroneckerLinearSolver` - #527 : Fix error message in `psydac.api.discretization.get_max_degree_of_one_space` - #576 : Require `sympde==0.19.3` which fixes a bug in the linearity checks From 0d43b1461c2fe785abfac1079512a9846c066a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 19:24:17 +0200 Subject: [PATCH 101/129] Fix local_ncells in DomainDecomposition.refine, see #623 --- psydac/ddm/cart.py | 2 +- psydac/ddm/tests/test_cart.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/psydac/ddm/cart.py b/psydac/ddm/cart.py index d565c3fa6..79d5b5e95 100644 --- a/psydac/ddm/cart.py +++ b/psydac/ddm/cart.py @@ -419,7 +419,7 @@ def refine(self, ncells, global_element_starts, global_element_ends): domain._starts = tuple( domain._global_element_starts[axis][c] for axis,c in zip(range(self._ndims), self._coords) ) domain._ends = tuple( domain._global_element_ends [axis][c] for axis,c in zip(range(self._ndims), self._coords) ) - domain._local_ncells = tuple(e-s+1 for s,e in zip(self._starts, self._ends)) + domain._local_ncells = tuple(e-s+1 for s,e in zip(domain._starts, domain._ends)) return domain #================================================================================== diff --git a/psydac/ddm/tests/test_cart.py b/psydac/ddm/tests/test_cart.py index 53ab1e14f..6187225e7 100644 --- a/psydac/ddm/tests/test_cart.py +++ b/psydac/ddm/tests/test_cart.py @@ -40,3 +40,4 @@ def test_DomainDecomposition_refine(mpi_dims_mask) -> None: assert list(new_ddm.comm_cart.Get_topo()[0]) == list(ddm.nprocs) assert new_ddm.starts == tuple(2 * s for s in ddm.starts) assert new_ddm.ends == tuple(2 * e + 1 for e in ddm.ends) + assert new_ddm.local_ncells == tuple(2 * n for n in ddm.local_ncells) # see #623 From 0a3752d7ccc6a3aadf95ecd7f77a0edd0efe2dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 10 Oct 2026 19:30:15 +0200 Subject: [PATCH 102/129] Mention fix of #623 in CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfde4fa32..c86f38dc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ All notable changes to this project will be documented in this file. - #527 : Fix `psydac.cad.elevate` and `refine` for mappings with `pdim=1`, see #618 - #527 : Fix `psydac.cad.refine` when inserting a value which is already a knot, see #619 - #527 : Keep the `mpi_dims_mask` of a `DomainDecomposition` in `TensorFemSpace.add_refined_space` and `psydac.cad.cad.refine`, see #622 +- #527 : Fix `local_ncells` of the decomposition returned by `DomainDecomposition.refine`, see #623 - #527 : Avoid setting the `shape` of NumPy arrays (deprecated since NumPy 2.5) in `KroneckerLinearSolver` - #527 : Fix error message in `psydac.api.discretization.get_max_degree_of_one_space` - #576 : Require `sympde==0.19.3` which fixes a bug in the linearity checks From a811068917fd4a5c816bc394925d52e8c0d7bffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Wed, 14 Oct 2026 11:26:22 +0200 Subject: [PATCH 103/129] Update Igakit to dalcinl/igakit@2a0c9ee, which supports NumPy 2.5 --- CHANGELOG.md | 1 + subprojects/igakit | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c86f38dc0..518ef29e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ All notable changes to this project will be documented in this file. - #527 : Return Cartesian (not homogeneous) control points from the NURBS functions in `psydac.cad.gallery`, see #608 - #527 : Keep the given SymPDE domain in `discretize(domain, filename=...)`, after checking that it matches the geometry file - #527 : Do not import the submodules of `psydac.cad` in its `__init__.py` +- #527 : Use latest version of Igakit (commit dalcinl/igakit@2a0c9ee of 2026/10/14) which supports NumPy >= 2.5 - #576 : Use latest version of Igakit (commit dalcinl/igakit@92ee097 of 2026/07/24) which supports NumPy >= 2.4 - #595 : Use PETSc 3.25.5 whose Python bindings `petsc4py` are built correctly with `cython>=3` - #580 : Use PETSc 3.25.0 whose Python bindings `petsc4py` install correctly with `setuptools>=81.0` diff --git a/subprojects/igakit b/subprojects/igakit index 92ee0975d..2a0c9ee8f 160000 --- a/subprojects/igakit +++ b/subprojects/igakit @@ -1 +1 @@ -Subproject commit 92ee0975d7089e813c1171bd7dbf35a179cf31a4 +Subproject commit 2a0c9ee8f529b1bff86567adf2750bc86b0a9dcf From c97f639ceba7ee7c7cd4146dcf7ce6660f90dc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 15 Oct 2026 15:33:23 +0200 Subject: [PATCH 104/129] Use the physical dimension of the mappings in Geometry.from_topological_domain and from_discrete_mapping --- psydac/cad/geometry.py | 9 +++++++-- psydac/cad/tests/test_geometry.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index af470aa40..e7ea14f96 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -254,7 +254,7 @@ def from_discrete_mapping(cls, mapping, *, name=None): mapping_name = name if name else 'mapping' dim = mapping.ldim - M = Mapping(mapping_name, dim = dim) # this is a symbolic mapping + M = Mapping(mapping_name, ldim=dim, pdim=mapping.pdim) # this is a symbolic mapping domain = M(NCube(name = 'Omega', dim = dim, min_coords = [0.] * dim, @@ -284,7 +284,12 @@ def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mp raise TypeError(msg) mappings = {itr.name : None for itr in interior} - pdim = next(iter(interior)).dim + + # The physical dimension is given by the mappings of the patches, if any + pdims = {itr.mapping.pdim if itr.mapping is not None else itr.dim for itr in interior} + if len(pdims) > 1: + raise ValueError(f'The patches of the domain have different physical dimensions: {pdims}') + pdim = pdims.pop() if isinstance(ncells, (list, tuple)): ncells = {itr.name : ncells for itr in interior} diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 0bf5a9ae8..3678ec4bb 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -116,6 +116,9 @@ def test_geometry_nurbs_manifold(make_mapping, tmp_path) -> None: F_read, = geo_read.mappings.values() assert_same_geometry(F_read, mapping) + # The symbolic mapping has the physical dimension of the discrete one + assert Geometry.from_discrete_mapping(mapping).domain.mapping.pdim == pdim + #============================================================================== def test_geometry_nurbs_quarter_annulus(tmp_path) -> None: @@ -243,6 +246,32 @@ def test_from_topological_domain(): assert geo_from_domain.ddm.starts == expected_starts assert geo_from_domain.ddm.ends == expected_ends +# ============================================================================== +def join_squares(pdims: tuple[int, int]) -> Domain: + """Join two unit squares, with mappings of the given physical dimensions.""" + A = Mapping('FA', ldim=2, pdim=pdims[0])(Square('A')) + B = Mapping('FB', ldim=2, pdim=pdims[1])(Square('B')) + return Domain.join([A, B], [((0, 0, 1), (1, 0, -1), 1)], 'Omega') + +@pytest.mark.parametrize(('make_domain_2d', 'pdim'), [ + (lambda: Square('Omega'), 2), + (lambda: Mapping('F', dim=2)(Square('Omega')), 2), + (lambda: Mapping('F', ldim=2, pdim=3)(Square('Omega')), 3), + (lambda: join_squares((3, 3)), 3), +], ids=['no-mapping', 'mapping', 'surface-3d', 'multipatch-surface-3d']) +def test_from_topological_domain_pdim(make_domain_2d, pdim: int) -> None: + + # The physical dimension is given by the mappings of the patches, if any + geo = Geometry.from_topological_domain(make_domain_2d(), [3, 5]) + + assert geo.ldim == 2 + assert geo.pdim == pdim + +def test_from_topological_domain_different_pdims() -> None: + + with pytest.raises(ValueError, match='different physical dimensions'): + Geometry.from_topological_domain(join_squares((2, 3)), [3, 5]) + # ============================================================================== @pytest.mark.parametrize('ldim', [1, 2]) @pytest.mark.parametrize('npatches', [1, 2]) From 1fda9a147ef050c11b60408f5d012aae4a0c3314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 15 Oct 2026 18:10:31 +0200 Subject: [PATCH 105/129] Reject domains carrying discrete mappings in Geometry.from_topological_domain --- psydac/cad/geometry.py | 11 +++++++++++ psydac/cad/tests/test_geometry.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index e7ea14f96..09a940468 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -283,6 +283,17 @@ def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mp " got {} instead.".format(type(itr)) raise TypeError(msg) + # A discrete mapping is tied to its own domain decomposition, hence + # it cannot be reused on the new one + if itr.mapping is not None and not itr.mapping.is_analytical: + try: + F = itr.mapping.get_callable_mapping() + except ValueError: # no callable mapping attached + F = None + if isinstance(F, SplineMapping): + raise ValueError(f'Patch {itr.name} has a discrete mapping attached: use the' + ' Geometry it comes from, or Geometry.from_discrete_mapping') + mappings = {itr.name : None for itr in interior} # The physical dimension is given by the mappings of the patches, if any diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 3678ec4bb..75102a11d 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -11,6 +11,7 @@ from mpi4py import MPI from sympde.topology import Domain, Line, Square, Cube, Mapping, IdentityMapping +from sympde.topology.analytical_mapping import PolarMapping from psydac.cad.geometry import Geometry, export_nurbs_to_hdf5, refine_nurbs from psydac.cad.geometry import import_geopdes_to_nurbs @@ -272,6 +273,22 @@ def test_from_topological_domain_different_pdims() -> None: with pytest.raises(ValueError, match='different physical dimensions'): Geometry.from_topological_domain(join_squares((2, 3)), [3, 5]) +def test_from_topological_domain_analytical_mapping() -> None: + + domain = PolarMapping('P', dim=2, c1=0., c2=0., rmin=0.5, rmax=1.)(Square('Omega')) + geo = Geometry.from_topological_domain(domain, [3, 5]) + + assert geo.mappings == {domain.name: None} + +def test_from_topological_domain_discrete_mapping() -> None: + + # The domain carries the spline mapping attached by from_discrete_mapping + mapping = discrete_mapping('collela', ncells=[3, 5], degree=[2, 2]) + domain = Geometry.from_discrete_mapping(mapping).domain + + with pytest.raises(ValueError, match='discrete mapping attached'): + Geometry.from_topological_domain(domain, [3, 5]) + # ============================================================================== @pytest.mark.parametrize('ldim', [1, 2]) @pytest.mark.parametrize('npatches', [1, 2]) From 8e0f521f84dd1f0a13180c0e3d0fa99c7356a3d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 15 Oct 2026 18:20:24 +0200 Subject: [PATCH 106/129] Add docstring to Geometry.from_topological_domain --- psydac/cad/geometry.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 09a940468..a995d6108 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -271,6 +271,55 @@ def from_discrete_mapping(cls, mapping, *, name=None): #-------------------------------------------------------------------------- @classmethod def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mpi_dims_mask=None): + """ + Create a Geometry instance from a topological domain, without discrete mappings. + + The domain may have symbolic mappings (e.g. analytical mappings), but + they must not carry discrete mappings, and no discrete mappings are + created. All the patches of the domain must be `NCubeInterior` objects + of the same dimension, i.e. lines, squares, or cubes. The physical + dimension is given by the mappings of the patches if they have any, and + otherwise it equals the logical dimension. + + Parameters + ---------- + domain : sympde.topology.Domain + The single- or multi-patch topological domain, with or without + symbolic mappings. + + ncells : Iterable[int] | dict[str, Iterable[int]] + The number of cells along each direction. A list or tuple applies + to all patches; a dictionary gives the number of cells of each + patch, keyed by the names of the domain interiors. + + periodic : Iterable[bool] | dict[str, Iterable[bool]], optional + The periodicity along each direction, given in the same way as + `ncells` (default: no periodicity). A warning is issued if a + multipatch domain is periodic along any direction. + + comm : MPI.Intracomm, optional + The MPI intra-communicator. + + mpi_dims_mask : Iterable[bool], optional + 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. Only used for a single patch. + + Returns + ------- + Geometry + The new instance. + + Raises + ------ + TypeError + If a patch of the domain is not an `NCubeInterior`. + + ValueError + If the patches of the domain have different physical dimensions, or + if the mapping of a patch carries a discrete mapping (e.g. a domain + returned by `Geometry.from_file`). + """ assert isinstance(domain, Domain) interior = domain.interior From 4239bc6ff2edfce2c9b709c51af278e70a8d81ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Thu, 15 Oct 2026 18:22:27 +0200 Subject: [PATCH 107/129] Mention the pdim fix and the rejection of discrete mappings in CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad52556f..88278c306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ All notable changes to this project will be documented in this file. - #527 : Fix `psydac.cad.refine` when inserting a value which is already a knot, see #619 - #527 : Keep the `mpi_dims_mask` of a `DomainDecomposition` in `TensorFemSpace.add_refined_space` and `psydac.cad.cad.refine`, see #622 - #527 : Fix `local_ncells` of the decomposition returned by `DomainDecomposition.refine`, see #623 +- #527 : Use the physical dimension of the mappings in `Geometry.from_topological_domain` and `from_discrete_mapping` - #527 : Avoid setting the `shape` of NumPy arrays (deprecated since NumPy 2.5) in `KroneckerLinearSolver` - #527 : Fix error message in `psydac.api.discretization.get_max_degree_of_one_space` - #596 : Allow export to VTK with a prescribed grid containing exact domain bounds @@ -62,6 +63,7 @@ All notable changes to this project will be documented in this file. - #527 : Return Cartesian (not homogeneous) control points from the NURBS functions in `psydac.cad.gallery`, see #608 - #527 : Keep the given SymPDE domain in `discretize(domain, filename=...)`, after checking that it matches the geometry file - #527 : Do not import the submodules of `psydac.cad` in its `__init__.py` +- #527 : Reject domains with discrete mappings in `Geometry.from_topological_domain` - #527 : Use latest version of Igakit (commit dalcinl/igakit@2a0c9ee of 2026/10/14) which supports NumPy >= 2.5 - #576 : Use latest version of Igakit (commit dalcinl/igakit@92ee097 of 2026/07/24) which supports NumPy >= 2.4 - #595 : Use PETSc 3.25.5 whose Python bindings `petsc4py` are built correctly with `cython>=3` From dcd2e8c9314838dd636b2d117b15585a347a43ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 16 Oct 2026 08:33:53 +0200 Subject: [PATCH 108/129] Revert "Reject domains carrying discrete mappings in Geometry.from_topological_domain" This reverts commit 1fda9a14. PostProcessManager rebuilds the FEM spaces with discretize(domain, ncells=...), hence with from_topological_domain, from a domain whose symbolic mappings carry discrete mappings: these are used to evaluate the physical coordinates, so they must be accepted. --- psydac/cad/geometry.py | 11 ----------- psydac/cad/tests/test_geometry.py | 17 ----------------- 2 files changed, 28 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index a995d6108..bead1764d 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -332,17 +332,6 @@ def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mp " got {} instead.".format(type(itr)) raise TypeError(msg) - # A discrete mapping is tied to its own domain decomposition, hence - # it cannot be reused on the new one - if itr.mapping is not None and not itr.mapping.is_analytical: - try: - F = itr.mapping.get_callable_mapping() - except ValueError: # no callable mapping attached - F = None - if isinstance(F, SplineMapping): - raise ValueError(f'Patch {itr.name} has a discrete mapping attached: use the' - ' Geometry it comes from, or Geometry.from_discrete_mapping') - mappings = {itr.name : None for itr in interior} # The physical dimension is given by the mappings of the patches, if any diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 75102a11d..3678ec4bb 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -11,7 +11,6 @@ from mpi4py import MPI from sympde.topology import Domain, Line, Square, Cube, Mapping, IdentityMapping -from sympde.topology.analytical_mapping import PolarMapping from psydac.cad.geometry import Geometry, export_nurbs_to_hdf5, refine_nurbs from psydac.cad.geometry import import_geopdes_to_nurbs @@ -273,22 +272,6 @@ def test_from_topological_domain_different_pdims() -> None: with pytest.raises(ValueError, match='different physical dimensions'): Geometry.from_topological_domain(join_squares((2, 3)), [3, 5]) -def test_from_topological_domain_analytical_mapping() -> None: - - domain = PolarMapping('P', dim=2, c1=0., c2=0., rmin=0.5, rmax=1.)(Square('Omega')) - geo = Geometry.from_topological_domain(domain, [3, 5]) - - assert geo.mappings == {domain.name: None} - -def test_from_topological_domain_discrete_mapping() -> None: - - # The domain carries the spline mapping attached by from_discrete_mapping - mapping = discrete_mapping('collela', ncells=[3, 5], degree=[2, 2]) - domain = Geometry.from_discrete_mapping(mapping).domain - - with pytest.raises(ValueError, match='discrete mapping attached'): - Geometry.from_topological_domain(domain, [3, 5]) - # ============================================================================== @pytest.mark.parametrize('ldim', [1, 2]) @pytest.mark.parametrize('npatches', [1, 2]) From ab5c389ca7640e9ba0dc35132adac6aaa2ed0e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 16 Oct 2026 08:34:06 +0200 Subject: [PATCH 109/129] Update docstring and CHANGELOG after reverting the rejection of discrete mappings --- CHANGELOG.md | 1 - psydac/cad/geometry.py | 17 ++++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88278c306..4525aef5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,7 +63,6 @@ All notable changes to this project will be documented in this file. - #527 : Return Cartesian (not homogeneous) control points from the NURBS functions in `psydac.cad.gallery`, see #608 - #527 : Keep the given SymPDE domain in `discretize(domain, filename=...)`, after checking that it matches the geometry file - #527 : Do not import the submodules of `psydac.cad` in its `__init__.py` -- #527 : Reject domains with discrete mappings in `Geometry.from_topological_domain` - #527 : Use latest version of Igakit (commit dalcinl/igakit@2a0c9ee of 2026/10/14) which supports NumPy >= 2.5 - #576 : Use latest version of Igakit (commit dalcinl/igakit@92ee097 of 2026/07/24) which supports NumPy >= 2.4 - #595 : Use PETSc 3.25.5 whose Python bindings `petsc4py` are built correctly with `cython>=3` diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index bead1764d..79d73a286 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -274,12 +274,13 @@ def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mp """ Create a Geometry instance from a topological domain, without discrete mappings. - The domain may have symbolic mappings (e.g. analytical mappings), but - they must not carry discrete mappings, and no discrete mappings are - created. All the patches of the domain must be `NCubeInterior` objects - of the same dimension, i.e. lines, squares, or cubes. The physical - dimension is given by the mappings of the patches if they have any, and - otherwise it equals the logical dimension. + The domain may have symbolic mappings (e.g. analytical mappings), and + callable mappings attached to them are kept (e.g. the discrete mappings + of a domain returned by `Geometry.from_file`), but no discrete mappings + are created. All the patches of the domain must be `NCubeInterior` + objects of the same dimension, i.e. lines, squares, or cubes. The + physical dimension is given by the mappings of the patches if they + have any, and otherwise it equals the logical dimension. Parameters ---------- @@ -316,9 +317,7 @@ def from_topological_domain(cls, domain, ncells, *, periodic=None, comm=None, mp If a patch of the domain is not an `NCubeInterior`. ValueError - If the patches of the domain have different physical dimensions, or - if the mapping of a patch carries a discrete mapping (e.g. a domain - returned by `Geometry.from_file`). + If the patches of the domain have different physical dimensions. """ assert isinstance(domain, Domain) From 4b35c804926f03612c6289e58700c7e92a1e3abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 16 Oct 2026 10:14:40 +0200 Subject: [PATCH 110/129] Pair the patches of a geometry file with the domain interiors by name --- psydac/cad/geometry.py | 45 ++++++++++++++++++++++++++++++- psydac/cad/tests/test_geometry.py | 36 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 79d73a286..d55b5c273 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -209,7 +209,9 @@ def from_file(cls, if len(yml['patches']) == 0: raise ValueError("Input file contains no patches.") - # The patches in the file follow the order of the domain interiors + # Pair the patches with the domain interiors by name, because the + # orders may differ (e.g. 'patch_10' is sorted before 'patch_2') + yml['patches'] = _sort_patches(yml['patches'], geometry_domain) spaces = _read_patch_spaces(h5, yml) names = geometry_domain.interior_names ncells = {name: [W.ncells for W in s] for name, s in zip(names, spaces)} @@ -888,6 +890,47 @@ def _get_interiors(domain : Domain) -> list: """Return the list of interior patches of a (multi-patch) domain.""" return [domain.interior] if len(domain) == 1 else list(domain.interior.args) +def _sort_patches(patches : list[dict], domain : Domain) -> list[dict]: + """ + Sort the patches of a geometry file in the order of the domain interiors. + + Each patch is identified by its name, which is either the name of the + interior (e.g. 'mapping_0(patch_0)', as written by `Geometry.export`), or + the name of its logical domain (e.g. 'patch_0', as written by + `export_multipatch_nurbs_to_hdf5`). + + Parameters + ---------- + patches : list[dict] + The patches listed in the `geometry.yml` section of the file. + + domain : sympde.topology.Domain + The topological domain read from the file. + + Returns + ------- + list[dict] + The same patches, in the order of the domain interiors. + + Raises + ------ + ValueError + If the patch of an interior cannot be identified uniquely. + """ + by_name = {patch['name']: patch for patch in patches} + if len(by_name) != len(patches): + raise ValueError('The patches of the geometry file must have distinct names') + + sorted_patches = [] + for itr in _get_interiors(domain): + names = {itr.name} if itr.logical_domain is None else {itr.name, itr.logical_domain.name} + matches = [by_name[name] for name in names if name in by_name] + if len(matches) != 1: + raise ValueError(f'Cannot identify the patch of interior {itr.name} in the geometry file') + sorted_patches.append(matches[0]) + + return sorted_patches + def _get_interface_keys(domain : Domain) -> set: """Return the interfaces of a domain as hashable tuples, with orientation.""" interfaces = domain.interfaces diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 3678ec4bb..696666322 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -16,6 +16,7 @@ from psydac.cad.geometry import import_geopdes_to_nurbs from psydac.cad.cad import elevate, refine from psydac.cad.gallery import quart_circle +from psydac.cad.multipatch import export_multipatch_nurbs_to_hdf5 from psydac.cad.tests.test_cad import assert_same_geometry, make_curve, make_surface from psydac.mapping.discrete import SplineMapping, NurbsMapping from psydac.mapping.discrete_gallery import discrete_mapping @@ -294,6 +295,41 @@ def test_geometry_init_without_mappings(npatches: int, ldim: int) -> None: assert geo.mappings == {name: None for name in domain.interior_names} +#============================================================================== +def _export_strip(filename: str, ncells_x: list[int]) -> None: + """ + Export a strip of bilinear patches glued along x, where patch k maps the + unit square onto [k, k+1] x [0, 1] with ncells_x[k] cells along x. + """ + from igakit.nurbs import NURBS + + nurbs = [] + for k, nx in enumerate(ncells_x): + points = np.zeros((nx + 1, 2, 2)) + points[..., 0] = np.linspace(k, k + 1, nx + 1)[:, None] + points[..., 1] = [0, 1] + knots_x = np.concatenate([[0.], np.linspace(0, 1, nx + 1), [1.]]) + nurbs.append(NURBS([knots_x, [0, 0, 1, 1]], points)) + connectivity = {(k, k + 1): ((0, 1), (0, -1)) for k in range(len(ncells_x) - 1)} + + export_multipatch_nurbs_to_hdf5(filename, nurbs, connectivity) + +# SymPDE sorts the interiors by name (patch_0, patch_1, patch_10, patch_2, ...), +# while the file keeps the order patch_0, ..., patch_10 +@pytest.mark.parametrize('ncells_x', [[1] * 11, [1] * 10 + [2]], ids=['same-sizes', 'patch_10-refined']) +def test_geometry_from_file_more_than_10_patches(ncells_x: list[int], tmp_path) -> None: + + filename = str(tmp_path / 'strip.h5') + _export_strip(filename, ncells_x) + geo = Geometry.from_file(filename) + + # Each patch must be paired with its interior by name, not by position + for itr in geo.domain.interior.args: + k = int(itr.logical_domain.name.removeprefix('patch_')) + x, _ = geo.mappings[itr.name](0.5, 0.5) + assert np.isclose(x, k + 0.5), f'interior {itr.name} got the mapping of patch_{round(x - 0.5)}' + assert geo.ncells[itr.name] == (ncells_x[k], 1) + #============================================================================== def test_geometry_from_file_multipatch() -> None: From da23324dd6e112fabe5687fbf45138a0c831cd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 16 Oct 2026 10:17:13 +0200 Subject: [PATCH 111/129] Add a leading underscore to the private helpers of test_geometry.py --- psydac/cad/tests/test_geometry.py | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 696666322..84fa97fb6 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -34,7 +34,7 @@ # Unit patch of each logical dimension PATCHES = {1: Line, 2: Square} #============================================================================== -def assert_geometries_equal(geo: Geometry, ref: Geometry) -> None: +def _assert_geometries_equal(geo: Geometry, ref: Geometry) -> None: """Check that two geometries are equal, comparing their patches by position.""" assert geo.ldim == ref.ldim assert geo.pdim == ref.pdim @@ -52,24 +52,24 @@ def assert_geometries_equal(geo: Geometry, ref: Geometry) -> None: assert all(np.array_equal(f.coeffs.toarray(), f_ref.coeffs.toarray()) for f, f_ref in zip(fields, fields_ref)) -def check_round_trips(geo: Geometry, mapping: SplineMapping, tmp_path) -> Geometry: +def _check_round_trips(geo: Geometry, mapping: SplineMapping, tmp_path) -> Geometry: """Check that a single-patch geometry is preserved by export/read and by from_discrete_mapping, and return the geometry read from file.""" filename = str(tmp_path / 'geo.h5') geo.export(filename) geo_read = Geometry.from_file(filename) - assert_geometries_equal(geo_read, geo) + _assert_geometries_equal(geo_read, geo) # A geometry read from file is exported again without changes filename_again = str(tmp_path / 'geo_again.h5') geo_read.export(filename_again) - assert_geometries_equal(Geometry.from_file(filename_again), geo) + _assert_geometries_equal(Geometry.from_file(filename_again), geo) - assert_geometries_equal(Geometry.from_discrete_mapping(mapping), geo) + _assert_geometries_equal(Geometry.from_discrete_mapping(mapping), geo) return geo_read -def make_identity_mapping(ddm: DomainDecomposition, degree: list[int], shift: float = 0.0) -> SplineMapping: +def _make_identity_mapping(ddm: DomainDecomposition, degree: list[int], shift: float = 0.0) -> SplineMapping: """Create a spline mapping on the decomposition, equal to the identity shifted along x. Its control points are given by the Greville abscissae.""" spaces = [SplineSpace(degree=p, grid=np.linspace(0.0, 1.0, n + 1)) @@ -84,11 +84,11 @@ def make_identity_mapping(ddm: DomainDecomposition, degree: list[int], shift: fl def test_geometry_identity_spline(ldim: int, tmp_path) -> None: ddm = DomainDecomposition([2, 3][:ldim], [False] * ldim) - mapping = make_identity_mapping(ddm, degree=[2] * ldim) + mapping = _make_identity_mapping(ddm, degree=[2] * ldim) domain = Mapping('F', dim=ldim)(PATCHES[ldim](name='Omega')) geo = Geometry(domain, ddm=ddm, pdim=ldim, mappings={domain.name: mapping}) - geo_read = check_round_trips(geo, mapping, tmp_path) + geo_read = _check_round_trips(geo, mapping, tmp_path) # The mapping read from file is the identity F_read, = geo_read.mappings.values() @@ -111,7 +111,7 @@ def test_geometry_nurbs_manifold(make_mapping, tmp_path) -> None: geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=pdim, mappings={domain.name: mapping}) - geo_read = check_round_trips(geo, mapping, tmp_path) + geo_read = _check_round_trips(geo, mapping, tmp_path) # The mapping read from file is the original one F_read, = geo_read.mappings.values() @@ -151,7 +151,7 @@ def test_geometry_nurbs_quarter_annulus(tmp_path) -> None: geo = Geometry(domain, ddm=mapping.space.domain_decomposition, pdim=2, mappings=mappings) - geo_read = check_round_trips(geo, mapping, tmp_path) + geo_read = _check_round_trips(geo, mapping, tmp_path) # The mapping read from file is a quarter annulus, radial along axis 1 F_read = list(geo_read.mappings.values())[0] @@ -248,7 +248,7 @@ def test_from_topological_domain(): assert geo_from_domain.ddm.ends == expected_ends # ============================================================================== -def join_squares(pdims: tuple[int, int]) -> Domain: +def _join_squares(pdims: tuple[int, int]) -> Domain: """Join two unit squares, with mappings of the given physical dimensions.""" A = Mapping('FA', ldim=2, pdim=pdims[0])(Square('A')) B = Mapping('FB', ldim=2, pdim=pdims[1])(Square('B')) @@ -258,7 +258,7 @@ def join_squares(pdims: tuple[int, int]) -> Domain: (lambda: Square('Omega'), 2), (lambda: Mapping('F', dim=2)(Square('Omega')), 2), (lambda: Mapping('F', ldim=2, pdim=3)(Square('Omega')), 3), - (lambda: join_squares((3, 3)), 3), + (lambda: _join_squares((3, 3)), 3), ], ids=['no-mapping', 'mapping', 'surface-3d', 'multipatch-surface-3d']) def test_from_topological_domain_pdim(make_domain_2d, pdim: int) -> None: @@ -271,14 +271,14 @@ def test_from_topological_domain_pdim(make_domain_2d, pdim: int) -> None: def test_from_topological_domain_different_pdims() -> None: with pytest.raises(ValueError, match='different physical dimensions'): - Geometry.from_topological_domain(join_squares((2, 3)), [3, 5]) + Geometry.from_topological_domain(_join_squares((2, 3)), [3, 5]) # ============================================================================== @pytest.mark.parametrize('ldim', [1, 2]) @pytest.mark.parametrize('npatches', [1, 2]) def test_geometry_init_without_mappings(npatches: int, ldim: int) -> None: - domain = make_domain(npatches, ldim=ldim) + domain = _make_domain(npatches, ldim=ldim) ncells = [3, 5][:ldim] if npatches == 1: @@ -350,7 +350,7 @@ def test_geometry_from_file_multipatch() -> None: assert F.space.domain_decomposition is patch_ddm #============================================================================== -def make_domain(npatches: int, *, ldim: int = 2, ornt: int = 1) -> Domain: +def _make_domain(npatches: int, *, ldim: int = 2, ornt: int = 1) -> Domain: """Create a domain made of one or two unit lines or squares, with generic mappings.""" Patch = PATCHES[ldim] if npatches == 1: @@ -361,7 +361,7 @@ def make_domain(npatches: int, *, ldim: int = 2, ornt: int = 1) -> Domain: # 1D interfaces have no orientation return Domain.join([A, B], [((0, 0, 1), (1, 0, -1), ornt if ldim > 1 else None)], 'Omega') -def export_geometry(domain: Domain, filename: str) -> None: +def _export_geometry(domain: Domain, filename: str) -> None: """Export a spline geometry on the domain, with patches side by side along x.""" names = domain.interior_names ldim = domain.dim @@ -375,7 +375,7 @@ def export_geometry(domain: Domain, filename: str) -> None: patch_ddms = ddm.domains # Spline mappings must be defined on the decomposition of their patch - mappings = {name: make_identity_mapping(patch_ddm, degree=[2] * ldim, shift=i) + mappings = {name: _make_identity_mapping(patch_ddm, degree=[2] * ldim, shift=i) for i, (name, patch_ddm) in enumerate(zip(names, patch_ddms))} Geometry(domain, ddm=ddm, pdim=ldim, mappings=mappings).export(filename) @@ -385,9 +385,9 @@ def export_geometry(domain: Domain, filename: str) -> None: @pytest.mark.parametrize('npatches', [1, 2]) def test_geometry_from_file_with_domain(npatches: int, ldim: int, tmp_path) -> None: - domain = make_domain(npatches, ldim=ldim) + domain = _make_domain(npatches, ldim=ldim) filename = str(tmp_path / 'geo.h5') - export_geometry(domain, filename) + _export_geometry(domain, filename) geo = Geometry.from_file(filename, domain=domain) @@ -404,12 +404,12 @@ def test_geometry_from_file_with_domain(npatches: int, ldim: int, tmp_path) -> N (1, lambda: Mapping('G', dim=3)(Cube('P')), 'dimension'), (1, lambda: Mapping('H', dim=2)(Square('P')), 'Patch names'), (1, lambda: Mapping('G', dim=2)(Square('P', bounds1=(0, 2))), 'Parametric bounds'), - (2, lambda: make_domain(2, ornt=-1), 'Interfaces'), + (2, lambda: _make_domain(2, ornt=-1), 'Interfaces'), ], ids=['analytical', 'no-mapping', 'dimension', 'names', 'bounds', 'orientation']) def test_geometry_from_file_with_wrong_domain(npatches: int, make_wrong_domain, message: str, tmp_path) -> None: filename = str(tmp_path / 'geo.h5') - export_geometry(make_domain(npatches), filename) + _export_geometry(_make_domain(npatches), filename) with pytest.raises(ValueError, match=message): Geometry.from_file(filename, domain=make_wrong_domain()) From c52718c40950c779157de2568b56aa8c5a02494f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Fri, 16 Oct 2026 10:24:28 +0200 Subject: [PATCH 112/129] Mention the patch pairing fix in CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4525aef5e..02a175b17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ All notable changes to this project will be documented in this file. - #527 : Keep the `mpi_dims_mask` of a `DomainDecomposition` in `TensorFemSpace.add_refined_space` and `psydac.cad.cad.refine`, see #622 - #527 : Fix `local_ncells` of the decomposition returned by `DomainDecomposition.refine`, see #623 - #527 : Use the physical dimension of the mappings in `Geometry.from_topological_domain` and `from_discrete_mapping` +- #527 : Pair the patches of a geometry file with the domain interiors by name, not by position - #527 : Avoid setting the `shape` of NumPy arrays (deprecated since NumPy 2.5) in `KroneckerLinearSolver` - #527 : Fix error message in `psydac.api.discretization.get_max_degree_of_one_space` - #596 : Allow export to VTK with a prescribed grid containing exact domain bounds From 218c285aedee94f0682d65b481a73583b6b3e353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 17 Oct 2026 16:38:24 +0200 Subject: [PATCH 113/129] Document the Geometry constructors, and move the parameters of Geometry.__init__ to its own docstring --- psydac/cad/geometry.py | 61 +++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index d55b5c273..393c2aafc 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -43,31 +43,20 @@ class Geometry: """ Distributed discrete geometry that works for single and multiple patches. - The Geometry object should be created with one of the class methods: - - `from_file`: passing the path to a geometry file (single or multi-patch). - - `from_discrete_mapping`: passing a `SplineMapping` (single patch). - - `from_topological_domain`: passing a `Domain`, the number of cells, and - the periodicity (single or multi-patch). + A Geometry object is usually created by calling + `~psydac.api.discretization.discretize` on a SymPDE domain: + `discretize(domain, filename=...)` calls `Geometry.from_file`, and + `discretize(domain, ncells=...)` calls `Geometry.from_topological_domain`. - The constructor `__init__` requires an existing domain decomposition, from - which the number of cells and the periodicity of each patch are obtained. + It can also be created directly with one of the class methods: - Parameters - ---------- - domain : Sympde.topology.Domain - The symbolic topological domain to be discretized. - - ddm : DomainDecomposition | MultiPatchDomainDecomposition - The decomposition of the domain across the MPI processes: a - `DomainDecomposition` for a single patch, otherwise a - `MultiPatchDomainDecomposition` with one decomposition per patch. - - pdim : int - Number of physical dimensions of the Geometry object (pdim >= ldim). + - `Geometry.from_file`: from a geometry file (single or multi-patch). + - `Geometry.from_discrete_mapping`: from a + `~psydac.mapping.discrete.SplineMapping` (single patch). + - `Geometry.from_topological_domain`: from a topological domain, the number + of cells, and the periodicity (single or multi-patch). - mappings : dict[str, BasicCallableMapping], optional - The discrete mappings of each patch. A spline mapping must be defined - on the domain decomposition of its patch (the same object). + The constructor `__init__` is meant for internal use by these class methods. """ _ldim = None _pdim = None @@ -80,7 +69,35 @@ def __init__(self, ddm : DomainDecomposition | MultiPatchDomainDecomposition, pdim : int, mappings : dict[str, BasicCallableMapping | None] = None): + """ + Create a Geometry instance from an existing domain decomposition. + + This constructor is meant for internal use: the class methods should be + preferred. The number of cells and the periodicity of each patch are + obtained from the domain decomposition. + Parameters + ---------- + domain : Sympde.topology.Domain + The symbolic topological domain to be discretized. + + ddm : DomainDecomposition | MultiPatchDomainDecomposition + The decomposition of the domain across the MPI processes: a + `DomainDecomposition` for a single patch, otherwise a + `MultiPatchDomainDecomposition` with one decomposition per patch. + + pdim : int + Number of physical dimensions of the Geometry object (pdim >= ldim). + + mappings : dict[str, BasicCallableMapping | None], optional + The callable mapping of each patch, keyed by the names of the domain + interiors, or None if the patch has no callable mapping (default: + no patch has one). Each mapping must have physical dimension `pdim`. + If a mapping is a `SplineMapping` (or a `NurbsMapping`), its space + must be defined on the domain decomposition of its patch, i.e. on + the same object as `ddm` (single patch) or as the corresponding + element of `ddm.domains` (multiple patches). + """ # Type checks assert isinstance(domain, Domain) assert isinstance(ddm, (DomainDecomposition, MultiPatchDomainDecomposition)) From 06698167831a35768327c4af559b6977347b17a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 17 Oct 2026 17:24:09 +0200 Subject: [PATCH 114/129] Raise a ValueError when an MPI process owns too few coefficients --- psydac/fem/partitioning.py | 14 +++++++++----- psydac/fem/tests/test_partitioning.py | 28 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 psydac/fem/tests/test_partitioning.py diff --git a/psydac/fem/partitioning.py b/psydac/fem/partitioning.py index e9c587916..2ba87642b 100644 --- a/psydac/fem/partitioning.py +++ b/psydac/fem/partitioning.py @@ -67,11 +67,15 @@ def partition_coefficients(domain_decomposition, spaces, min_blocks=None): if min_blocks is None: min_blocks = [None] * ndims - for s, e, V, mb in zip(global_starts, global_ends, spaces, min_blocks): - if V.periodic or mb is None: - assert all(e-s+1 >= V.degree) - else: - assert all(e-s+1 >= mb) + # Each process must own enough coefficients along each axis. The global + # starts and ends are the same on all processes, hence so is the error + for axis, (s, e, V, mb) in enumerate(zip(global_starts, global_ends, spaces, min_blocks)): + min_coeffs = V.degree if V.periodic or mb is None else mb + owned_coeffs = int(min(e - s + 1)) + if owned_coeffs < min_coeffs: + raise ValueError(f'Along axis {axis}, an MPI process owns only {owned_coeffs} coefficients,' + f' but at least {min_coeffs} are needed: use fewer MPI processes along' + ' this axis (e.g. with mpi_dims_mask), or more cells') return global_starts, global_ends diff --git a/psydac/fem/tests/test_partitioning.py b/psydac/fem/tests/test_partitioning.py new file mode 100644 index 000000000..cfcdff92e --- /dev/null +++ b/psydac/fem/tests/test_partitioning.py @@ -0,0 +1,28 @@ +#---------------------------------------------------------------------------# +# This file is part of PSYDAC which is released under MIT License. See the # +# LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # +# for full license details. # +#---------------------------------------------------------------------------# +import numpy as np +import pytest +from mpi4py import MPI + +from psydac.ddm.cart import DomainDecomposition +from psydac.fem.splines import SplineSpace +from psydac.fem.tensor import TensorFemSpace + +#============================================================================== +# A single process owns all the coefficients, which are at least `degree` +@pytest.mark.mpi(min_size=2) +@pytest.mark.parametrize('periodic', [True, False]) +def test_partition_coefficients_too_few(periodic: bool) -> None: + + # Some processes own a single cell, hence less than `degree` coefficients + comm = MPI.COMM_WORLD + degree = 3 + ncells = max(comm.size, degree) + W = SplineSpace(degree=degree, grid=np.linspace(0, 1, ncells + 1), periodic=periodic) + ddm = DomainDecomposition([ncells], [periodic], comm=comm) + + with pytest.raises(ValueError, match=f'at least {degree} are needed'): + TensorFemSpace(ddm, W) From d473149e9bf5ce0d2b9d75bc27d22dc33c1f3adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 17 Oct 2026 17:26:25 +0200 Subject: [PATCH 115/129] Mention the explicit error about too few coefficients per process in CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a175b17..f24bb5a8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ All notable changes to this project will be documented in this file. - #527 : Keep the given SymPDE domain in `discretize(domain, filename=...)`, after checking that it matches the geometry file - #527 : Do not import the submodules of `psydac.cad` in its `__init__.py` - #527 : Use latest version of Igakit (commit dalcinl/igakit@2a0c9ee of 2026/10/14) which supports NumPy >= 2.5 +- #527 : Raise a `ValueError` with an explanation, instead of an `AssertionError`, when an MPI process owns too few coefficients - #576 : Use latest version of Igakit (commit dalcinl/igakit@92ee097 of 2026/07/24) which supports NumPy >= 2.4 - #595 : Use PETSc 3.25.5 whose Python bindings `petsc4py` are built correctly with `cython>=3` - #580 : Use PETSc 3.25.0 whose Python bindings `petsc4py` install correctly with `setuptools>=81.0` From 1347ad211bef59b5ba6f3a4fd1323e95d25ea607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 17 Oct 2026 17:36:25 +0200 Subject: [PATCH 116/129] Name the function in the CHANGELOG line about too few coefficients --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f24bb5a8e..15211eccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,7 @@ All notable changes to this project will be documented in this file. - #527 : Keep the given SymPDE domain in `discretize(domain, filename=...)`, after checking that it matches the geometry file - #527 : Do not import the submodules of `psydac.cad` in its `__init__.py` - #527 : Use latest version of Igakit (commit dalcinl/igakit@2a0c9ee of 2026/10/14) which supports NumPy >= 2.5 -- #527 : Raise a `ValueError` with an explanation, instead of an `AssertionError`, when an MPI process owns too few coefficients +- #527 : Raise a `ValueError` with an explanation, instead of an `AssertionError`, in `psydac.fem.partitioning.partition_coefficients` when an MPI process owns too few coefficients - #576 : Use latest version of Igakit (commit dalcinl/igakit@92ee097 of 2026/07/24) which supports NumPy >= 2.4 - #595 : Use PETSc 3.25.5 whose Python bindings `petsc4py` are built correctly with `cython>=3` - #580 : Use PETSc 3.25.0 whose Python bindings `petsc4py` install correctly with `setuptools>=81.0` From 9d88472d1ee3f7d57404de7c26996a89c3176836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Sat, 17 Oct 2026 18:04:13 +0200 Subject: [PATCH 117/129] Rename CLAUDE.md to AGENTS.md, which is read by several coding agents --- CLAUDE.md => AGENTS.md | 0 CHANGELOG.md | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename CLAUDE.md => AGENTS.md (100%) diff --git a/CLAUDE.md b/AGENTS.md similarity index 100% rename from CLAUDE.md rename to AGENTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 15211eccb..f202fcdad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ All notable changes to this project will be documented in this file. - #565 : Expand editable install info in `README.md` - [DEVELOPER] Create action `install_petsc4py` to install PETSc & `petsc4py` w/ complex support - [DEVELOPER] Configure Pylint in `pyproject.toml` -- [DEVELOPER] Add `CLAUDE.md` with developer rules +- [DEVELOPER] Add `AGENTS.md` with developer rules ### Fixed From 4a592a91ad091620b4d31da54864289225b41d90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 11:57:19 +0200 Subject: [PATCH 118/129] Raise a ValueError when exporting a Geometry without spline mappings (fixes #631) --- psydac/cad/geometry.py | 8 ++++++-- psydac/cad/tests/test_geometry.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index 393c2aafc..c004507f5 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -436,9 +436,13 @@ def export( self, filename ): yml['ldim'] = self.ldim yml['pdim'] = self.pdim + # Only spline mappings can be written to file + missing = [name for name, mapping in self.mappings.items() + if not isinstance(mapping, SplineMapping)] + if missing: + raise ValueError(f'Cannot export patches without a spline mapping: {missing}') + # ... information about the patches - if not( self.mappings ): - raise ValueError('No mappings were found') patches_info = [] i_mapping = 0 diff --git a/psydac/cad/tests/test_geometry.py b/psydac/cad/tests/test_geometry.py index 84fa97fb6..2a73935bd 100644 --- a/psydac/cad/tests/test_geometry.py +++ b/psydac/cad/tests/test_geometry.py @@ -273,6 +273,17 @@ def test_from_topological_domain_different_pdims() -> None: with pytest.raises(ValueError, match='different physical dimensions'): Geometry.from_topological_domain(_join_squares((2, 3)), [3, 5]) +@pytest.mark.parametrize('npatches', [1, 2]) +def test_geometry_export_without_spline_mappings(npatches: int, tmp_path) -> None: + + geo = Geometry.from_topological_domain(_make_domain(npatches), [3, 5]) + filename = tmp_path / 'geo.h5' + + # The error is raised before the file is created + with pytest.raises(ValueError, match='without a spline mapping'): + geo.export(str(filename)) + assert not filename.exists() + # ============================================================================== @pytest.mark.parametrize('ldim', [1, 2]) @pytest.mark.parametrize('npatches', [1, 2]) From 9da84d5a88241cb042514b6b51a0cac188729b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 11:58:58 +0200 Subject: [PATCH 119/129] Mention the fix of Geometry.export in CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f202fcdad..fcc106a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ All notable changes to this project will be documented in this file. - #527 : Fix `psydac.cad.refine` when inserting a value which is already a knot, see #619 - #527 : Keep the `mpi_dims_mask` of a `DomainDecomposition` in `TensorFemSpace.add_refined_space` and `psydac.cad.cad.refine`, see #622 - #527 : Fix `local_ncells` of the decomposition returned by `DomainDecomposition.refine`, see #623 +- #527 : Raise a `ValueError` in `Geometry.export` if a patch has no spline mapping, instead of an `AttributeError` after creating the file, see #631 - #527 : Use the physical dimension of the mappings in `Geometry.from_topological_domain` and `from_discrete_mapping` - #527 : Pair the patches of a geometry file with the domain interiors by name, not by position - #527 : Avoid setting the `shape` of NumPy arrays (deprecated since NumPy 2.5) in `KroneckerLinearSolver` From a72c626874ae616bccafd5b333ecd3f2c0d4ae40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 12:07:45 +0200 Subject: [PATCH 120/129] Remove unused imports in the Python files changed by this PR --- psydac/api/tests/test_2d_complex.py | 9 ++++----- psydac/api/tests/test_2d_navier_stokes.py | 19 +++++-------------- psydac/cad/gallery.py | 4 +--- psydac/cad/geometry.py | 2 +- .../test_feec_conf_projectors_cart_2d.py | 1 - psydac/mapping/discrete.py | 2 -- 6 files changed, 11 insertions(+), 26 deletions(-) diff --git a/psydac/api/tests/test_2d_complex.py b/psydac/api/tests/test_2d_complex.py index 014ef355f..1a637776f 100644 --- a/psydac/api/tests/test_2d_complex.py +++ b/psydac/api/tests/test_2d_complex.py @@ -9,8 +9,7 @@ from mpi4py import MPI import pytest -import numpy as np -from sympy import pi, cos, sin, symbols, conjugate, exp +from sympy import pi, cos, sin, symbols, exp from sympy import Tuple, Matrix from sympy import lambdify @@ -22,7 +21,7 @@ from sympde.topology import NormalVector from sympde.topology import Union from sympde.topology import Domain, Square -from sympde.topology import IdentityMapping, AffineMapping, PolarMapping +from sympde.topology import IdentityMapping from sympde.expr import BilinearForm, LinearForm, integral from sympde.expr import Norm, SemiNorm from sympde.expr import find, EssentialBC @@ -518,8 +517,8 @@ def teardown_function(): else: from psydac.fem.plotting_utilities import get_plotting_grid, get_grid_vals - from psydac.fem.plotting_utilities import get_patch_knots_gridlines, my_small_plot - from psydac.api.tests.build_domain import build_11_patch_pretzel + from psydac.fem.plotting_utilities import my_small_plot + from psydac.api.tests.build_domain import build_11_patch_pretzel from psydac.feec.pull_push import pull_2d_hcurl domain = build_11_patch_pretzel() diff --git a/psydac/api/tests/test_2d_navier_stokes.py b/psydac/api/tests/test_2d_navier_stokes.py index 8f309627e..bbd98ff77 100644 --- a/psydac/api/tests/test_2d_navier_stokes.py +++ b/psydac/api/tests/test_2d_navier_stokes.py @@ -8,17 +8,11 @@ import pytest import numpy as np -from sympy import pi, cos, sin, sqrt, exp, ImmutableDenseMatrix as Matrix, Tuple, lambdify +from sympy import pi, cos, sin, exp, ImmutableDenseMatrix as Matrix, Tuple from scipy.sparse.linalg import spsolve -from scipy.sparse.linalg import gmres as sp_gmres -from scipy.sparse.linalg import minres as sp_minres -from scipy.sparse.linalg import cg as sp_cg -from scipy.sparse.linalg import bicg as sp_bicg -from scipy.sparse.linalg import bicgstab as sp_bicgstab - -from sympde.calculus import grad, dot, inner, div, curl, cross -from sympde.calculus import Transpose, laplace -from sympde.topology import NormalVector + +from sympde.calculus import grad, dot, inner, div +from sympde.calculus import Transpose from sympde.topology import ScalarFunctionSpace, VectorFunctionSpace from sympde.topology import ProductSpace from sympde.topology import element_of, elements_of @@ -27,7 +21,6 @@ from sympde.expr import Norm from sympde.expr import find, EssentialBC from sympde.core import Constant -from sympde.expr import TerminalExpr from sympde.expr import linearize from psydac.api.essential_bc import apply_essential_bc @@ -39,7 +32,7 @@ from psydac.linalg.stencil import * from psydac.linalg.block import * from psydac.api.settings import PSYDAC_BACKEND_GPYCCEL -from psydac.utilities.utils import refine_array_1d, animate_field, split_space, split_field +from psydac.utilities.utils import animate_field, split_space, split_field from psydac.linalg.solvers import inverse from mpi4py import MPI @@ -467,9 +460,7 @@ def teardown_function(): else: - import matplotlib.pyplot as plt from matplotlib import animation - from time import time Tf = 3. dt_h = 0.05 diff --git a/psydac/cad/gallery.py b/psydac/cad/gallery.py index 2ad00e4ab..6cd71a344 100644 --- a/psydac/cad/gallery.py +++ b/psydac/cad/gallery.py @@ -3,13 +3,11 @@ # LICENSE file or go to https://github.com/pyccel/psydac/blob/devel/LICENSE # # for full license details. # #---------------------------------------------------------------------------# -import string -import random import numpy as np from psydac.fem.splines import SplineSpace from psydac.fem.tensor import TensorFemSpace -from psydac.mapping.discrete import SplineMapping, NurbsMapping +from psydac.mapping.discrete import NurbsMapping from psydac.cad.utils import plot_mapping #============================================================================== diff --git a/psydac/cad/geometry.py b/psydac/cad/geometry.py index c004507f5..ba905cac7 100644 --- a/psydac/cad/geometry.py +++ b/psydac/cad/geometry.py @@ -17,7 +17,7 @@ import yaml from mpi4py import MPI -from sympde.topology import Domain, Interface, Line, Square, Cube, NCubeInterior, Mapping, NCube +from sympde.topology import Domain, Line, Square, Cube, NCubeInterior, Mapping, NCube from sympde.topology.basic import Union from sympde.topology.callable_mapping import BasicCallableMapping diff --git a/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py b/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py index 1c45c4b33..b327278dd 100644 --- a/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py +++ b/psydac/feec/tests/test_feec_conf_projectors_cart_2d.py @@ -4,7 +4,6 @@ # for full license details. # #---------------------------------------------------------------------------# import pytest -from collections import OrderedDict import numpy as np from sympy import Tuple, lambdify diff --git a/psydac/mapping/discrete.py b/psydac/mapping/discrete.py index f7af184fd..d9e128506 100644 --- a/psydac/mapping/discrete.py +++ b/psydac/mapping/discrete.py @@ -4,8 +4,6 @@ # for full license details. # #---------------------------------------------------------------------------# from itertools import product -import string -import random import yaml import numpy as np From ac5b09c81ead1ae66da4c629e287ab816bc7c80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 12:37:17 +0200 Subject: [PATCH 121/129] Run lint checks on changed files only, and sort imports with the black profile of isort --- AGENTS.md | 10 ++++++---- pyproject.toml | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d25c1bb03..73e65116b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,10 +61,12 @@ memory supercomputers using MPI and OpenMP. - subprojects - Git submodules ## Always do -- Before claiming done: - . Run `python -m black --check $(git ls-files "*.py")` - . Run `python -m isort --check $(git ls-files "*.py" | grep -v "FOUND_DUPLICATED_IMPORT.py")` - . Run `pylint --disable=all --enable=unused-import ./psydac --ignore-paths="__pyccel*" --ignore-paths="__epyccel*"` +- Before claiming done, run these checks on the Python files you changed: + . Run `python -m black --check $(git diff --name-only --diff-filter=d devel -- "*.py")` + . Run `python -m isort --check $(git diff --name-only --diff-filter=d devel -- "*.py")` + . Run `pylint --disable=all --enable=unused-import $(git diff --name-only --diff-filter=d devel -- "*.py")` +- Until the code base is reformatted, black and isort fail on most existing files: + do not reformat code outside your change. The pylint check must pass. - Unless specified differently, assume process with `rank=0` in MPI communicator is root - Use root MPI process to perform serial operations (e.g. open/save image) diff --git a/pyproject.toml b/pyproject.toml index 36c290a3e..f192034ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,3 +110,6 @@ disable = [ 'duplicate-code', 'fixme', ] + +[tool.isort] +profile = 'black' # sort imports as black formats them From 8cf0c210044c43ca3139a3ef227beb41ada6bd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 12:42:08 +0200 Subject: [PATCH 122/129] Fix the pytest decorator and add psydac/mapping in AGENTS.md --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 73e65116b..9e3f0c25b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ memory supercomputers using MPI and OpenMP. - Unit tests can be run with `psydac test` CLI command (see `README.md`) - Test file names follow `test_.py` - Test function names follow `test_` or `test__` -- Parametrize unit tests with `@pytest.parametrize` to minimize code duplication +- Parametrize unit tests with `@pytest.mark.parametrize` to minimize code duplication - Keep run time of tests at a minimum - Aim for 100% coverage on newly committed code @@ -52,6 +52,7 @@ memory supercomputers using MPI and OpenMP. - psydac/feec - finite element exterior calculus (FEEC) - psydac/fem - "finite element method" middle-level Python interface (spaces and fields) - psydac/linalg - "linear algebra" low-level Python interface (vectors, linear operators, iterative solvers) +- psydac/mapping - spline and NURBS mappings, and a gallery of discrete mappings - psydac/polar - implementation of polar splines for H1 spaces - psydac/pyccel - distillation of an old version of Pyccel for Python code generation (to be removed) - psydac/utilities - various generic functions used across the library From df50ba6a4e53bb2a561c46f2d58832f5d832ff56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 12:42:17 +0200 Subject: [PATCH 123/129] Add testing conventions to AGENTS.md --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9e3f0c25b..87baaa76d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,13 @@ memory supercomputers using MPI and OpenMP. - Parametrize unit tests with `@pytest.mark.parametrize` to minimize code duplication - Keep run time of tests at a minimum - Aim for 100% coverage on newly committed code +- Every bugfix comes with a regression test that fails before the fix +- Use distinct sizes in each direction (e.g. 3 x 5 cells, not 4 x 4), + so that swapped indices are detected +- Write files only to the `tmp_path` fixture (`mpi_tmp_path` in MPI tests), + never to the working directory +- After changing a module, also run the tests of the subpackages which use it + (e.g. `psydac/api/tests` after changing `psydac/cad`) ## File structure - psydac/api - high-level Python interface From 6ce5094f23e02513726c7a5f15960b260f9a8c65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 12:45:27 +0200 Subject: [PATCH 124/129] Add code standards about docstrings, new code style and author credits to AGENTS.md --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 87baaa76d..f2c190af4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,12 @@ memory supercomputers using MPI and OpenMP. - Self-explanatory names for variables, functions, and classes - Do not reassign value to an existing variable, especially if the type changes - Use short comments (one-liners or inline) to explain "why" rather than "what" or "how" +- In docstrings, use single backticks for names and short code, + and double backticks only for code expressions +- Write new code in clean style (e.g. no superfluous parentheses, f-strings), + even if the surrounding legacy code does not +- Do not add author credits in code or comments; + credit goes in commit messages and PR descriptions ## Testing conventions - Each library subpackage contains a `tests/` folder with an `__init__.py` file From 9a23279db100ec362b676c51d7c9cb305ca717d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 12:48:12 +0200 Subject: [PATCH 125/129] Mention the documentation tools in AGENTS.md --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index f2c190af4..108dfb9a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,8 @@ memory supercomputers using MPI and OpenMP. - mpi4py for MPI parallelization - h5py for parallel I/O - petsc4py for direct linear solvers (optional install) +- sphinx + numpydoc + nbsphinx + myst-parser for documentation + (see `docs/requirements.txt`; build with `make -C docs html`) ## Code standards - Follow PEP 8 style guide whenever possible From dd65a60a779dd9e8a6b18a2220be7b90ccddc285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 12:50:56 +0200 Subject: [PATCH 126/129] Add a Workflow section to AGENTS.md --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 108dfb9a6..77976ba59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,16 @@ memory supercomputers using MPI and OpenMP. - scripts - Various scripts for library maintenance - subprojects - Git submodules +## Workflow +- PRs are squash-merged and the PR description becomes the commit message: + keep it complete, and write "(fixes #N)" next to each fix +- Every PR adds entries to `CHANGELOG.md` under Added/Changed/Fixed/Removed +- Format of the CHANGELOG entries: + . `- # : `, followed by `, see #` if there is one + . `- [DEVELOPER] ` for changes which only concern developers +- Issues describe bugs as they appear on `devel`, with a reproducer that runs on `devel` +- Do not commit changes to submodule pointers (`subprojects/`) unless intended + ## Always do - Before claiming done, run these checks on the Python files you changed: . Run `python -m black --check $(git diff --name-only --diff-filter=d devel -- "*.py")` From d563fc664d31e3e248e4c95ee96fe3c54a125ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 12:52:52 +0200 Subject: [PATCH 127/129] Mention the isort configuration in CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcc106a11..cb92b760e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file. - #565 : Expand editable install info in `README.md` - [DEVELOPER] Create action `install_petsc4py` to install PETSc & `petsc4py` w/ complex support - [DEVELOPER] Configure Pylint in `pyproject.toml` +- [DEVELOPER] Configure isort in `pyproject.toml`, with the profile of black - [DEVELOPER] Add `AGENTS.md` with developer rules ### Fixed From 03c9266485fe74624d6f82b118d0bbad9b694c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 15:46:56 +0200 Subject: [PATCH 128/129] Clarify the tech stack, the linting tools and the MPI tests in AGENTS.md --- AGENTS.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 77976ba59..3390dfeed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,8 @@ speed using Pyccel. The library enables large parallel computations on distribut memory supercomputers using MPI and OpenMP. ## Tech stack -- Python 3.10+, type hints required everywhere -- meson-python + mesonpy for building +- Python 3.10+, type hints required in new code +- meson-python (Meson build backend) for building - pip with submodules (for igakit) - pytest + pytest-cov + pytest-mpi + pytest-xdist for testing - sympde for symbolic definition of weak formulations @@ -27,7 +27,7 @@ memory supercomputers using MPI and OpenMP. ## Code standards - Follow PEP 8 style guide whenever possible - Docstrings of public functions and classes follow Numpydoc conventions -- pylint + black + isort for linting/typing +- pylint + black + isort for linting and formatting - Computational kernels to be accelerated with Pyccel named as `_kernels.py` - Minimize code duplication. Never add code that already exists - Strive for concise, clean, and human-readable code. Avoid useless verbosity @@ -45,6 +45,8 @@ memory supercomputers using MPI and OpenMP. - Each library subpackage contains a `tests/` folder with an `__init__.py` file - Unit tests are part of the library and shipped with it - Unit tests can be run with `psydac test` CLI command (see `README.md`) +- Parallel tests are marked with `@pytest.mark.mpi` (from pytest-mpi) and run with + `psydac test --mpi`; serial tests run with `psydac test` - Test file names follow `test_.py` - Test function names follow `test_` or `test__` - Parametrize unit tests with `@pytest.mark.parametrize` to minimize code duplication From a6d5aa8b6d5015b69c5a01b3eee0b8c16ad46c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yaman=20G=C3=BC=C3=A7l=C3=BC?= Date: Mon, 19 Oct 2026 18:29:47 +0200 Subject: [PATCH 129/129] Remove the mpi4py install from the install_h5py action, as on devel (h5py >= 3.16 requires mpi4py >= 4) --- .github/actions/install_h5py/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/actions/install_h5py/action.yml b/.github/actions/install_h5py/action.yml index e0a8fb30c..caa37a512 100644 --- a/.github/actions/install_h5py/action.yml +++ b/.github/actions/install_h5py/action.yml @@ -21,7 +21,6 @@ runs: run: | export CC="mpicc" export HDF5_MPI="ON" - pip install 'mpi4py>=4.0.0' pip install h5py --no-cache-dir --no-binary h5py pip list