Installation#

pyproject.toml plus uv are the source of truth for dependency resolution, and uv.lock records the exact resolution an environment was built from — a commit for each Git-sourced dependency, a version and artifact hashes for each one that comes from PyPI. DEVELOPMENT.md at the repository root carries the same recipe in the form a maintainer runs it; this page is the version a new user needs.

Prerequisites#

  • Python 3.12 — the version CI tests, and the only one exercised. requires-python is >=3.11 and nothing in Volumential itself needs 3.12. loopy used to import override from the standard-library typing module, which gained it only in 3.12, so import loopy failed outright under 3.11; at the revision uv.lock pins that import now comes from typing_extensions. Treat 3.11 as untested rather than as known-broken: no job runs it.

  • An OpenCL runtime. PoCL is the default tested backend; a vendor ICD (CUDA, ROCm) works too.

  • uv.

  • micromamba (or conda/mamba), which is how this recipe provides the OpenCL runtime. Skip it only if the host already has a working ICD and a 3.12 interpreter, in which case create the environment however you normally would and pick the recipe up at the git clone.

  • gfortran and ninja, only for the optional fmmlib extra.

Install#

curl -LsSf https://astral.sh/uv/install.sh | sh
"${SHELL}" <(curl -L micro.mamba.pm/install.sh)      # if micromamba is absent

The OpenCL runtime is easiest to obtain from conda-forge, so create the base environment there and let uv fill in the rest:

micromamba create -n volumential-dev -c conda-forge -c nodefaults \
  python=3.12 pyopencl pocl scipy numpy

eval "$(micromamba shell hook -s bash)"   # once per shell, if not shell-init'd
micromamba activate volumential-dev
export UV_PROJECT_ENVIRONMENT="$CONDA_PREFIX"

git clone https://github.com/xywei/volumential.git
cd volumential
uv sync --extra test --extra doc

Two lines there are load-bearing, and they fail differently if you skip them: without the eval, micromamba activate fails at once because the shell function does not exist yet; without UV_PROJECT_ENVIRONMENT, uv fails quietly and installs into a checkout-local .venv instead.

micromamba activate is a shell function, not a binary, so a fresh shell has to evaluate the hook before it exists. micromamba shell init -s bash makes it permanent; the eval line above is the per-shell form.

UV_PROJECT_ENVIRONMENT is what makes uv use the conda environment as the project environment. Activating conda sets CONDA_PREFIX, not VIRTUAL_ENV, and uv’s --active flag keys on VIRTUAL_ENV — so uv sync --active inside an activated conda environment does not target it: it creates .venv in the checkout and installs there, and every later uv run uses that same .venv. The result builds and imports, and has none of the conda-provided OpenCL runtime this recipe exists to supply. Export the variable once after activating and both uv sync and uv run do the right thing.

Why the dependencies come from Git#

[tool.uv.sources] points most of the inducer stack at its main branches, and uv.lock records the resolved commits: arraycontext, boxtree, cgen, genpy, gmsh_interop, loopy, meshmode, modepy, pyfmmlib, pymbolic, pytential, pytools, pyvisfile, sumpy. pyopencl is not among them — it resolves from PyPI, and uv.lock pins a release (2026.1.2) rather than a commit, so that is the version an audit of an evidence environment should expect to find. These projects release rarely, and released wheels have shipped defects that corrupt results silently, which is a different and worse failure than a crash.

The specific one that matters here: boxtree must be at or after the upstream commit that fixed refine_and_coarsen_tree_of_boxes (parent/child id remapping after the level reorder, the tile-versus-repeat parent assignment, and an np.inf sentinel on an integer array). Anything older — the 2024.10 wheel included — silently corrupts List 1 neighbour lists on reordered adaptive trees. Do not substitute PyPI wheels in an environment that produces evidence, and capture the locked commits in the metadata of any promoted result.

A patch that exists only inside one environment’s site-packages is an incident to remediate, never a fix: patches belong upstream, on a tracked fork branch, or vendored and committed.

The FMMLib backend#

uv sync --extra test --extra doc --extra fmmlib

uv sync is an exact sync: it uninstalls whatever the requested set does not include. Naming only --extra fmmlib would therefore remove the test and doc extras installed above, pytest included, so list every extra you want in the environment on each sync.

This builds pyfmmlib from upstream main, which carries both the restored OpenMP feature option (inducer/pyfmmlib#93) and the batched {l,h}{2,3}dformmp_imany wrappers (inducer/pyfmmlib#94). The PyPI 2024.1.1 release has neither. Since #135 pyfmmlib has a [tool.uv.sources] entry like the inducer packages, so the extra resolves to the commit uv.lock pins; installing it by hand with uv pip install "pyfmmlib @ git+..." still works but bypasses the lock, and two hosts provisioned on different days then end up on different revisions.

The openmp feature option defaults to auto, so a host with a usable OpenMP toolchain needs no extra build flag. Verify the build before trusting any FMMLib timing — FPNDFMMLibExpansionWrangler falls back to the serial per-box path without complaining when the batched entry points are missing, so a mis-provisioned environment is correct but slow:

# Batched wrappers present.  The backend picks {l,h}{2,3}dformmp_imany from the
# equation and the dimension, so check all four: a successful 3D Laplace import
# does not rule out a 2D or Helmholtz fallback.  This covers charge sources
# only.
python -c "from pyfmmlib import \
    h2dformmp_imany, h3dformmp_imany, l2dformmp_imany, l3dformmp_imany"
# Dipole sources (a DirectionalSourceDerivative kernel, i.e. a dipole_vec)
# take a different P2M wrapper, {l,h}{2,3}dformmp_dp_imany, and fall back to
# the per-box routine just as silently when it is absent.  pyfmmlib generates
# both families at the revision uv.lock pins, so an import failure here means
# the dipole P2M will run per box, not that the charge path is broken.
python -c "from pyfmmlib import \
    h2dformmp_dp_imany, h3dformmp_dp_imany, \
    l2dformmp_dp_imany, l3dformmp_dp_imany"
python - <<'PY'
import pathlib
import platform
import subprocess

import pyfmmlib

so = next(pathlib.Path(pyfmmlib.__file__).parent.glob("_internal*.so"))
print(so)
if platform.system() == "Darwin":
    # macOS has no ldd, and the OpenMP runtime is libomp rather than libgomp.
    subprocess.run(["otool", "-L", str(so)], check=True)
else:
    subprocess.run(["ldd", str(so)], check=True)
PY

Expect a line for an OpenMP runtime: libgomp for a GCC build, libomp for Clang (the usual case on macOS, and a possible one on Linux), libiomp for Intel. The name follows the compiler, not the operating system, so do not read a missing libgomp on Linux as a missing OpenMP. A missing runtime and an unavailable ldd look the same otherwise, which is why the check branches instead of assuming Linux.

Batched P2M is bit-identical to the per-box path and GEMM L2P agrees at roundoff (test/test_fmmlib_batched_stages.py), so adopting them needs no accuracy re-measurement — but any FMMLib-stage timing narrative must be re-measured after adoption.

Verify the environment#

Imports and quick tests#

These need UV_PROJECT_ENVIRONMENT exported, as above — uv run otherwise creates and uses .venv rather than the conda environment.

uv run pytest -q test/test_import.py
uv run pytest -q test/test_public_surface.py
uv run pytest -q test/test_duffy_tanh_sinh.py

The traversal check#

Run this once after creating an environment, and again after any inducer-stack update, before the environment is used for evidence. The driver that builds the graded 3D case (q_order, initial levels, adapt steps) = (3, 4, 3) and reports its List 1 diagnostics left the tree with the rest of the benchmark drivers (Benchmarks and reproducibility), but it is one git archive away: 7c75ed1 is the last revision of main that carries it, and it imports only its sibling adaptive_timing.py besides the installed library.

mkdir -p build/traversal-check
git archive 7c75ed1 benchmarks | tar -x -C build/traversal-check
python build/traversal-check/benchmarks/adaptive_timing_3d.py --mode full \
  --out build/traversal-check/adaptive-timing-3d.csv

In the laplace3d-q3-l4-a3 row of that CSV, cross_level_list1_fraction must equal 0.16588653810147913, that is n_cross_level_list1_interactions = 3544 out of n_list1_interactions = 21364. A different value means the tree-of-boxes refinement is the broken one: stop and re-provision. The driver also fails loudly if the adaptive tree comes out uniform, unbalanced, or free of cross-level List 1 work; that is the same verdict.

This check is the reason the Git sources above are not optional. It is cheap, it is decisive, and skipping it is how a corrupted neighbour list reaches a plot.

Next#

  • A first volume potential — a volume potential in about twenty lines.

  • Device selection — make sure the run lands on the device you meant.

  • DEVELOPMENT.md and Development — the maintainer-facing version of all of the above, plus lint, types and the test tiers.