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. Withbackend="petsc"(thepetscextra) 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 = fwith 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.
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)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.
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)})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.
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%.
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.
With uv:
make install # uv sync --extra dev, plus the pre-commit hooks
uv run python examples/two_level_mesh.pyOr create and activate a Python environment (3.10 or newer):
python -m venv env
source env/bin/activate
pip install --upgrade pipInstall 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-amrOr install the code and requirements from a clone with pip:
pip install -e ".[plot]" # add ,mpi for MPI-enabled runsRun the code with:
python examples/two_level_mesh.pyThe plot extra installs Matplotlib. The test, docs and dev
extras install the test runner, documentation tooling and linters:
pip install -e ".[dev]"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 coverageCommit messages follow Conventional Commits; see CONTRIBUTING.md.
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/distREADME.md is rendered from README.qmd with
Quarto:
make readmeBefore 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.





