Skip to content
max-modelsPublic

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

patchwork

Tests Static analysis Docs codecov PyPI Python

Patchwork is a small 1D and 2D block-structured adaptive mesh library. It stores cell-centered or node-centered fields on nested rectangular patches using mpiarray. The same API works without MPI installed or launched; under mpiexec, after installing the optional mpi extra, each patch lives on the ranks you give it (one for a small patch, several for a large one) with a communicator of its own, and exchange_halos fills every patch’s halo from its neighbours and the level below, wherever they are. Matplotlib plots make the level layout and each patch’s cell grid visible.

It provides hierarchy construction, patch validation, mpiarray-backed field storage, plotting, and composite linear solves:

  • solve_poisson: cell-centered finite volumes with one constant Dirichlet value on the domain boundary;
  • NodalPoisson: node-centered, the five-point Laplacian wherever the mesh is uniform, with Dirichlet and Neumann faces, periodic axes and embedded bodies at fixed potentials. It is factorized once and solved many times, which is what a particle-in-cell code needs. With backend="petsc" (the petsc extra) every rank assembles only the rows of its patches’ nodes and PETSc solves them together (a Krylov method with algebraic multigrid).
  • CellOperator: cell-centered, -div(a grad u) + c u = f with a variable diffusion coefficient and a real or complex reaction (a skin layer, an inductive field), the same faces, periodic axes and bodies, quadratic interpolation along coarse-fine faces (symmetric, or conservative face by face), factorized once; fixed and floating bodies (EmbeddedFloating, one unknown with a given net flux), bodies made of cells (cell_body), and the gradient and curl of a solution from the operator’s own faces.

A hierarchy can also be the axisymmetric r–z plane (AMRHierarchy(geometry="cylindrical")): cells are rings, integral, restrict and conservative regridding weight them by their volumes, and CellOperator takes the curl-curl of an azimuthal component (component="azimuthal"), the operator of a magnetoquasistatic field.

regrid places the patches itself: tag the cells that need finer cells (by a field’s change across a cell, a function of the position, or by hand), and it covers them with properly nested patches (Berger-Rigoutsos clustering) and moves the fields over.

Markers live on the patches too: locate finds the finest patch of each point (a compiled kernel with the pyccel extra), owner and migrate put each marker on the rank that holds its cell, and deposit spreads the charge of the markers over the nodes (cloud-in-cell) patch by patch. accumulate_halos then brings it to the composite nodes: the halos of the blocks and the copies of a node in several patches are added to one copy, the one CompositeNodes.from_patches reads, so the charge is that of CompositeNodes.deposit, to round-off. interpolate is the transpose: each marker reads a node field in its own patch (after exchange_halos), so a field computed patch by patch is seen as its patch has it. Both run mpifield’s compiled linear weighting on each patch block. regrid can fill the halos afterwards (fill_halos), keep patches off given cells (forbid, for example a periodic seam) and off one-cell-wide shapes (min_patch_size); a patch can be placed by a function (placement) and a large one split over ranks (max_cells_per_rank).

A distributed composite solve is planned next.

Quick example

from patchwork import AMRHierarchy, plot_patches

mesh = AMRHierarchy(base_shape=(32, 24))
fine = mesh.add_patch(level=1, box=((16, 48), (12, 36)))
mesh.allocate_field("density")
fine.field("density").local[:] = 1.0
fig, ax = plot_patches(mesh, field="density")
fig.savefig("mesh.png", dpi=180)

Two-level mesh showing the coarse grid, fine patch, and a sample density field

Node-centered Poisson solve

import numpy as np
from patchwork import AMRHierarchy, Dirichlet, EmbeddedDirichlet, Neumann, NodalPoisson

mesh = AMRHierarchy(base_shape=(32, 32), periodic=(False, True))
mesh.add_patch(level=1, box=((24, 40), (24, 40)))
solver = NodalPoisson(
    mesh,
    faces={"x_lower": Neumann(0.0), "x_upper": Dirichlet(100.0)},
    embedded=[
        EmbeddedDirichlet(
            inside=lambda p: np.hypot(p[0] - 0.5, p[1] - 0.5) < 0.05, value=-20.0
        )
    ],
)
x, y = solver.nodes.coordinates.T
potential = solver.solve(np.exp(-((x - 0.3) ** 2 + (y - 0.5) ** 2) / 0.01))

solver.solve can be called again with another source at the cost of two triangular solves. Nodes of a fine patch in the middle of a coarser cell’s edge take the interpolated value of the edge’s ends, so the potential is continuous across patch boundaries.

Potential around a small electrode, resolved by two nested patches

Domains that are not rectangles

The base level can be several boxes of cells. Their union is the domain, and the edges inside the bounding rectangle are a boundary of their own, "inner":

mesh = AMRHierarchy(
    base_shape=(32, 32),
    bounds=((-1.0, 1.0), (-1.0, 1.0)),
    base_boxes=[((0, 16), (0, 32)), ((16, 32), (16, 32))],  # an L
)
solver = NodalPoisson(mesh, faces={"x_upper": Dirichlet(1.0), "inner": Neumann(0.0)})

Potential on an L-shaped domain with nested patches at its inner corner

Patch boxes use half-open integer cell bounds on their own refinement level. The default refinement ratio is two. For runnable layouts and a manufactured composite solve, see examples/two_level_mesh.py, examples/nested_patches.py, examples/composite_poisson.py, examples/nodal_electrode.py, examples/l_shaped_domain.py, examples/t_shaped_domain.py, and examples/regrid_moving_charge.py.

Regridding

Tag the cells that need finer cells, and regrid covers them with properly nested patches and moves the fields over:

# the cells to refine: also function_tags(...), or a boolean array per level
tags = gradient_tags(mesh, "rho", threshold=10.0)
mesh = mesh.regrid(tags, max_patch_size=32)  # a new hierarchy, with the fields
solver = NodalPoisson(mesh)  # numbered and factorized again
potential = solver.solve("rho")

A field allocated with conservative=True (a density) keeps its integral through every regrid. In examples/regrid_moving_charge.py a charge crosses a grounded box and the mesh is regridded at every step: four levels on a 32 x 32 base, about 5,000 unknowns instead of 261,121 for a uniform mesh as fine as the finest patches. Tagging, regridding, rebuilding the solver and solving take about 21 ms per step on one core of an Apple M1, as long as one solve on the uniform mesh, which also needs 0.9 s to factorize; the two potentials agree to 0.13%.

The potential of a moving charge, with the patches that follow it

Profiling

patchwork ships scope-profiler regions around its expensive operations (halo exchanges, restriction, deposit and migration, tagging and regridding, building and solving the Poisson problems), named patchwork.<operation>. They are off by default, so an application that profiles itself records only its own regions. Turn them on with the patchwork tag:

import scope_profiler as sp

sp.setup(enable_tags=["patchwork"])  # or sp.session(enable_tags=["patchwork"])

or, without editing the code, SCOPE_PROFILER_ENABLE_TAGS=patchwork, scope-profiler run --enable-tag patchwork, or enable_tags in the TOML configuration. Switched off, a region costs less than 0.2 µs per call.

Install

With uv:

make install    # uv sync --extra dev, plus the pre-commit hooks
uv run python examples/two_level_mesh.py

Or create and activate a Python environment (3.10 or newer):

python -m venv env
source env/bin/activate
pip install --upgrade pip

Install the release from PyPI, where the package is called patchwork-amr (patchwork there is an unrelated project); the import is import patchwork:

pip install patchwork-amr

Or install the code and requirements from a clone with pip:

pip install -e ".[plot]"  # add ,mpi for MPI-enabled runs

Run the code with:

python examples/two_level_mesh.py

The plot extra installs Matplotlib. The test, docs and dev extras install the test runner, documentation tooling and linters:

pip install -e ".[dev]"

Development

Formatting and linting use ruff, type checking pyright, run by pre-commit and in CI:

make lint     # ruff check, ruff format --check, pyright
make test     # pytest with coverage

Commit messages follow Conventional Commits; see CONTRIBUTING.md.

Build docs

The documentation in docs/ is an Astro + Starlight site: hand-written pages, the notebooks in tutorials/ executed and published as pages, and the API reference generated from the docstrings with starlight-pydocs. It needs Node 22 or newer.

make docs-install     # npm packages and the Python docs extra
make docs-notebooks   # execute tutorials/*.ipynb and convert them to pages
make docs-dev         # live preview at http://localhost:4321/patchwork/
make docs-build       # the static site in docs/dist

Build the README

README.md is rendered from README.qmd with Quarto:

make readme

Releases

Before merging a release to main, update the version in pyproject.toml, src/patchwork/__init__.py and CITATION.cff (including its release date), and add the release notes to CHANGELOG.md. The push to main creates a GitHub release with a vX.Y.Z tag and publishes the package to PyPI with trusted publishing (OIDC). The one-time PyPI and GitHub configuration is described in the publishing guide.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages