fenicsx_compat-0.4.0/.pre-commit-config.yaml0000644000000000000000000000126313615410400015720 0ustar00repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files args: ["--maxkb=3000"] - id: check-docstring-first - id: debug-statements - id: check-toml - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.16.7 hooks: - id: ruff args: ["--fix"] - id: ruff-format - repo: local hooks: - id: mypy name: mypy entry: python3 -m mypy --config-file pyproject.toml src tests language: system types: [python] pass_filenames: false fenicsx_compat-0.4.0/_config.yml0000644000000000000000000000326413615410400013571 0ustar00# Book settings # Learn more at https://jupyterbook.org/customize/config.html title: fenicsx-compat author: Henrik Finsberg and Jørgen Dokken copyright: "2026" only_build_toc_files: true execute: execute_notebooks: "off" # Information about where the book exists on the web repository: url: https://github.com/scientificcomputing/fenicsx-compat # Online location of your book branch: main html: use_issues_button: true use_repository_button: true parse: myst_enable_extensions: - amsmath - dollarmath - linkify - html_admonition sphinx: extra_extensions: - 'sphinx.ext.autodoc' - 'sphinx.ext.napoleon' - 'sphinx.ext.viewcode' - 'sphinx.ext.intersphinx' config: html_last_updated_fmt: "%b %d, %Y" # The docstrings mark code with single backticks, as in Markdown default_role: literal autodoc_member_order: bysource intersphinx_mapping: basix : ["https://docs.fenicsproject.org/basix/main/python/", null] ffcx : ["https://docs.fenicsproject.org/ffcx/main/", null] ufl : ["https://docs.fenicsproject.org/ufl/main/", null] dolfinx : ["https://docs.fenicsproject.org/dolfinx/main/python", null] petsc4py: ["https://petsc.org/release/petsc4py", null] mpi4py: ["https://mpi4py.readthedocs.io/en/stable", null] numpy: ["https://numpy.org/doc/stable/", null] scifem: ["https://scientificcomputing.github.io/scifem/", null] pyvista: ["https://docs.pyvista.org/", null] adios2: ["https://adios2.readthedocs.io/en/latest/", null] exclude_patterns: [".pytest_cache/*", "tests", "docs/superpowers", ".superpowers", "handoff-*.md", "third-party", "dist", venv, .vcode, .ruff_cache, .github, .git] fenicsx_compat-0.4.0/_toc.yml0000644000000000000000000000116713615410400013111 0ustar00# Table of contents # Learn more at https://jupyterbook.org/customize/toc.html format: jb-book root: index parts: - caption: Getting started chapters: - file: "docs/installation" - file: "docs/choosing" - caption: Function guide chapters: - file: "docs/guide/version" - file: "docs/guide/mesh_creation" - file: "docs/guide/mesh" - file: "docs/guide/index_maps" - file: "docs/guide/fem" - file: "docs/guide/petsc" - file: "docs/guide/ufl" - file: "docs/guide/io" - caption: Reference chapters: - file: "docs/native" - file: "docs/api.rst" fenicsx_compat-0.4.0/index.md0000644000000000000000000000243213615410400013067 0ustar00# fenicsx-compat ```{include} README.md :start-after: "# fenicsx-compat" :end-before: "# Installation" ``` ## Supported versions | | Supported | |---|---| | DOLFINx | 0.10, 0.11 and `main` (the nightly builds, currently 0.12.0.dev0) | | Python | 3.12 and newer | Throughout this guide, **main** means the current development branch of DOLFINx, which will become 0.12. ## How to use this guide - {doc}`docs/choosing` maps the cross-version problem you have to the function that solves it. Start there. - The **function guide** has one entry per function. Each entry says what the function does, how DOLFINx 0.10, 0.11 and main do the same thing natively, and how to call the function so that one piece of code runs on all three. - {doc}`docs/native` lists the things that look version-dependent but are not: from 0.10 on, plain DOLFINx calls work on every supported version, and there is no wrapper for them. - The {doc}`docs/api` has the full docstrings. Every function is importable from the top-level package: ```python import fenicsx_compat cmap = fenicsx_compat.cmap(mesh) ``` The examples in the guide are short, and assume ```python from mpi4py import MPI import dolfinx import numpy as np import fenicsx_compat ``` and, where they use one, an existing `mesh`, function space `V` or form. fenicsx_compat-0.4.0/.devcontainer/setup-claude-plugins.sh0000644000000000000000000000367313615410400020613 0ustar00#!/usr/bin/env bash set -uo pipefail # Ensures the superpowers and mattpocock-skills Claude Code plugins are # installed and enabled (user scope) whenever this devcontainer is attached # to. Idempotent: safe to re-run. # # Why this exists: plugins are installed into ~/.claude, which lives in the # container's root filesystem. A fresh devcontainer build starts with none # of that, so we reinstall on every attach rather than relying on it # persisting. # # The `claude` binary isn't on PATH in this container image; it ships # bundled inside the Claude Code VS Code extension, which is installed by # the editor as part of attaching (not guaranteed to exist yet at # postCreateCommand time), hence the short wait loop below. find_claude_bin() { find "$HOME/.vscode-server/extensions" -maxdepth 1 -iname 'anthropic.claude-code-*' -type d 2>/dev/null \ | sort -V | tail -1 | sed 's#$#/resources/native-binary/claude#' } CLAUDE_BIN="" for _ in $(seq 1 30); do candidate="$(find_claude_bin)" if [ -n "$candidate" ] && [ -x "$candidate" ]; then CLAUDE_BIN="$candidate" break fi sleep 2 done if [ -z "$CLAUDE_BIN" ]; then echo "setup-claude-plugins: Claude Code CLI binary not found after waiting; skipping plugin setup." >&2 exit 0 fi # `|| true`: marketplace/plugin already present is reported as an error by # the CLI, and that's a success state for an idempotent setup script. "$CLAUDE_BIN" plugin marketplace add obra/superpowers >/dev/null 2>&1 || true "$CLAUDE_BIN" plugin marketplace add mattpocock/skills >/dev/null 2>&1 || true "$CLAUDE_BIN" plugin marketplace add blader/humanizer >/dev/null 2>&1 || true "$CLAUDE_BIN" plugin install superpowers@superpowers-dev >/dev/null 2>&1 || true "$CLAUDE_BIN" plugin install mattpocock-skills@mattpocock >/dev/null 2>&1 || true "$CLAUDE_BIN" plugin install blader/humanizer@blader >/dev/null 2>&1 || true echo "setup-claude-plugins: ensured superpowers and mattpocock-skills plugins are installed." fenicsx_compat-0.4.0/.devcontainer/setup.sh0000755000000000000000000000111713615410400015673 0ustar00#!/usr/bin/env bash set -e # Exit immediately if a command exits with a non-zero status chsh -s $(which zsh) root || true if ! command -v starship &> /dev/null; then echo "Installing Starship prompt..." curl -sS https://starship.rs/install.sh | sh -s -- -y fi python3 -m pip install pkgconfig HDF5_MPI=ON HDF5_PKGCONFIG_NAME="hdf5" python3 -m pip install h5py --no-build-isolation --no-binary=h5py # python3 -m pip install scifem --no-build-isolation --no-binary=scifem # python3 -m pip install -e .[dev] # pre-commit install # 3. Print success message echo "Container setup complete!" fenicsx_compat-0.4.0/.devcontainer/nightly/devcontainer.json0000644000000000000000000000272413615410400021233 0ustar00{ "name": "fenicsx-compat-nightly", "image": "ghcr.io/fenics/dolfinx/dolfinx:nightly", "workspaceFolder": "/home/shared", "workspaceMount": "source=${localWorkspaceFolder},target=/home/shared,type=bind", "runArgs": [ "--name", "fenicsx-compat-nightly" ], "containerEnv": { "PYVISTA_OFF_SCREEN": "false", "PYVISTA_JUPYTER_BACKEND": "html", "CI": "0", "DISPLAY": ":99.0", "LIBGL_ALWAYS_SOFTWARE": "1" }, // Install Zsh as the default shell inside the container "features": { "ghcr.io/devcontainers/features/common-utils:2": { "installZsh": true, "configureZshAsDefaultShell": true } }, // Automatically run your editable install every time the container is built/started "postAttachCommand": "bash .devcontainer/setup.sh; bash .devcontainer/setup-claude-plugins.sh", // Recommended VS Code extensions to run inside the container "customizations": { "vscode": { "extensions": [ "ms-python.python", "ms-python.vscode-pylance", "tamasfe.even-better-toml", "streetsidesoftware.code-spell-checker", "anthropic.claude-code" ] } }, // Mount local Zsh configs into the container "mounts": [ "source=${localEnv:HOME}/.zshrc,target=/root/.zshrc,type=bind,readonly", "source=${localEnv:HOME}/.oh-my-zsh,target=/root/.oh-my-zsh,type=bind,readonly", "source=${localEnv:HOME}/.ssh,target=/root/.ssh,type=bind,readonly" ], "remoteUser": "root" } fenicsx_compat-0.4.0/.devcontainer/stable/devcontainer.json0000644000000000000000000000266013615410400021026 0ustar00{ "name": "fenicsx-compat-stable", "image": "ghcr.io/fenics/dolfinx/dolfinx:stable", "workspaceFolder": "/home/shared", "workspaceMount": "source=${localWorkspaceFolder},target=/home/shared,type=bind", "runArgs": [ "--name", "fenicsx-compat-stable" ], "containerEnv": { "PYVISTA_OFF_SCREEN": "false", "PYVISTA_JUPYTER_BACKEND": "html", "CI": "0", "DISPLAY": ":99.0", "LIBGL_ALWAYS_SOFTWARE": "1" }, // Install Zsh as the default shell inside the container "features": { "ghcr.io/devcontainers/features/common-utils:2": { "installZsh": true, "configureZshAsDefaultShell": true } }, // Automatically run your editable install every time the container is built/started "postAttachCommand": "bash .devcontainer/setup.sh; bash .devcontainer/setup-claude-plugins.sh", // Recommended VS Code extensions to run inside the container "customizations": { "vscode": { "extensions": [ "ms-python.python", "ms-python.vscode-pylance", "tamasfe.even-better-toml", "streetsidesoftware.code-spell-checker" ] } }, // Mount local Zsh configs into the container "mounts": [ "source=${localEnv:HOME}/.zshrc,target=/root/.zshrc,type=bind,readonly", "source=${localEnv:HOME}/.oh-my-zsh,target=/root/.oh-my-zsh,type=bind,readonly", "source=${localEnv:HOME}/.ssh,target=/root/.ssh,type=bind,readonly" ], "remoteUser": "root" } fenicsx_compat-0.4.0/.github/docs-version-flyout.js0000644000000000000000000000664513615410400017301 0ustar00// Read the Docs-style version flyout (bottom right). // Served from the gh-pages root and injected into every built page by // .github/workflows/deploy_docs.yml, so it also covers old release builds. (function () { // Not inside frames if (window.self !== window.top) return; const base = "/fenicsx-compat/"; const path = window.location.pathname; if (!path.startsWith(base)) return; const [current, ...rest] = path.slice(base.length).split("/"); const page = rest.join("/"); // Same-origin links: the github.io URL redirects to the custom domain, which // breaks the cross-origin existence check below const home = (v) => base + v.version + "/"; const css = ` #compat-flyout { position: fixed; right: 1rem; bottom: 1rem; z-index: 1000; font-size: 0.85rem; font-family: var(--pst-font-family-base, sans-serif); } #compat-flyout > button { cursor: pointer; border-radius: 0.35rem; padding: 0.35rem 0.75rem; border: 1px solid var(--pst-color-border, #ccc); background: var(--pst-color-surface, #f5f5f5); color: var(--pst-color-text-base, #222); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); } #compat-flyout > button::before { content: "v: "; color: var(--pst-color-primary, #0a7d91); } #compat-flyout ul { display: none; position: absolute; right: 0; bottom: calc(100% + 0.4rem); margin: 0; padding: 0.3rem 0; list-style: none; min-width: 10rem; border-radius: 0.35rem; border: 1px solid var(--pst-color-border, #ccc); background: var(--pst-color-surface, #f5f5f5); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); } #compat-flyout.open ul { display: block; } #compat-flyout li a { display: block; padding: 0.25rem 0.9rem; text-decoration: none; color: var(--pst-color-text-base, #222); } #compat-flyout li a:hover { background: var(--pst-color-background, #fff); } #compat-flyout li.current a { font-weight: bold; color: var(--pst-color-primary, #0a7d91); }`; fetch(base + "switcher.json") .then((r) => r.json()) .then((versions) => { const style = document.createElement("style"); style.textContent = css; document.head.appendChild(style); const root = document.createElement("div"); root.id = "compat-flyout"; const button = document.createElement("button"); const entry = versions.find((v) => v.version === current); button.textContent = entry ? entry.name : current; button.setAttribute("aria-label", "Choose documentation version"); const list = document.createElement("ul"); for (const v of versions) { const li = document.createElement("li"); if (v.version === current) li.className = "current"; const a = document.createElement("a"); a.textContent = v.name; a.href = home(v) + page + window.location.hash; // Same page may not exist in that version: fall back to its front page a.addEventListener("click", (e) => { e.preventDefault(); fetch(home(v) + page, { method: "HEAD" }) .then((r) => (window.location.href = r.ok ? a.href : home(v))) .catch(() => (window.location.href = home(v))); }); li.appendChild(a); list.appendChild(li); } button.addEventListener("click", () => root.classList.toggle("open")); document.addEventListener("click", (e) => { if (!root.contains(e.target)) root.classList.remove("open"); }); root.append(list, button); document.body.appendChild(root); }) .catch(() => {}); })(); fenicsx_compat-0.4.0/.github/docs-versions.yml0000644000000000000000000000135013615410400016315 0ustar00# .github/docs-versions.yml # # Maps each fenicsx-compat minor version series to: # - the fenicsx-compat git tag to build docs from (latest patch of that minor) # - the dolfinx Docker image to build them in # # "main" is always built against dolfinx nightly and is not listed here; # it is handled automatically by .github/workflows/deploy_docs.yml. # # When releasing a new minor version (e.g. v0.3.0): # 1. Add a new entry at the top with the new tag and a dolfinx image. # 2. Update the tag here whenever a new patch supersedes the previous one. # (Old patch docs are intentionally replaced — only one slot per minor.) versions: - tag: "v0.2.0" minor: "v0.2" dolfinx_image: "ghcr.io/fenics/dolfinx/dolfinx:v0.11.0" fenicsx_compat-0.4.0/.github/scripts/docs_site.py0000644000000000000000000001147513615410400017022 0ustar00"""Helpers for the versioned docs deployment (.github/workflows/deploy_docs.yml). Adapted from dolfinx_mpc (Jørgen S. Dokken, MIT). Versions are listed, newest first, in .github/docs-versions.yml. The built HTML of each version is kept as ``docs-.tar.gz`` on the ``docs-archive`` release. """ import argparse import json import os import subprocess import tarfile from pathlib import Path import yaml # Root-relative, so links stay on whichever domain serves the site BASE_URL = "/fenicsx-compat" # Release holding the built HTML, set by the workflow ARCHIVE = os.environ.get("ARCHIVE", "docs-archive") CONFIG = Path(__file__).parents[1] / "docs-versions.yml" MAIN = {"label": "main", "name": "main (nightly)", "tag": "main"} def versions() -> list[dict[str, str]]: """Return all documented versions, newest first, ``main`` included.""" with CONFIG.open() as f: config = yaml.safe_load(f) tagged = [ { "label": v["minor"], "name": v["minor"], "tag": v["tag"], "image": v["dolfinx_image"], } for v in config["versions"] ] return [MAIN | {"image": "ghcr.io/fenics/dolfinx/dolfinx:nightly"}, *tagged] def asset_name(tag: str) -> str: return f"docs-{tag}.tar.gz" def matrix(args: argparse.Namespace) -> None: """Write the build matrix to $GITHUB_OUTPUT: main, plus tags not yet archived.""" archived = set(args.archived.read_text().split()) jobs = [ {"label": v["label"], "git_ref": v["tag"], "dolfinx_image": v["image"]} for v in versions() if v["tag"] == "main" or asset_name(v["tag"]) not in archived ] print("Jobs to build:", [j["label"] for j in jobs]) with open(os.environ["GITHUB_OUTPUT"], "a") as f: f.write(f"matrix={json.dumps({'include': jobs})}\n") def write_redirects(site: Path) -> None: """Keep pre-versioning URLs working: ``.html`` -> ``main/.html``.""" for page in (site / "main").rglob("*.html"): rel = page.relative_to(site / "main") stub = site / rel if stub.exists() or rel.parts[0].startswith("_"): continue stub.parent.mkdir(parents=True, exist_ok=True) target = "../" * (len(rel.parts) - 1) + "main/" + rel.as_posix() stub.write_text( '\n\n' f'\n' f'\n' ) def assemble(args: argparse.Namespace) -> None: """Unpack every version into the site, fetching missing ones from the archive.""" entries = [] for v in versions(): tarball = args.fresh / asset_name(v["tag"]) if not tarball.exists(): fetched = subprocess.run( ["gh", "release", "download", ARCHIVE, "--pattern", tarball.name] + ["--dir", str(args.fresh)], check=False, ) if fetched.returncode != 0: print(f"::warning::No build of {v['tag']}, leaving {v['label']} out") continue with tarfile.open(tarball) as t: t.extractall(args.site / v["label"], filter="data") entries.append( { "name": v["name"], "version": v["label"], "url": f"{BASE_URL}/{v['label']}/", } ) if not any(e["version"] == "main" for e in entries): raise SystemExit("main docs are neither built nor archived") (args.site / "switcher.json").write_text(json.dumps(entries, indent=2)) print("switcher.json:", entries) write_redirects(args.site) def labels(args: argparse.Namespace) -> None: """Print the labels (site subdirectories) of the deployed versions.""" entries = json.loads((args.site / "switcher.json").read_text()) print(*(e["version"] for e in entries)) def assets(args: argparse.Namespace) -> None: """Print the archive assets still in use.""" print(*(asset_name(v["tag"]) for v in versions())) def main() -> None: parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(required=True) p = sub.add_parser("matrix", help=matrix.__doc__) p.add_argument("archived", type=Path, help="file listing archived asset names") p.set_defaults(func=matrix) p = sub.add_parser("assemble", help=assemble.__doc__) p.add_argument("fresh", type=Path, help="directory with freshly built tarballs") p.add_argument("site", type=Path, help="output directory") p.set_defaults(func=assemble) p = sub.add_parser("labels", help=labels.__doc__) p.add_argument("site", type=Path) p.set_defaults(func=labels) p = sub.add_parser("assets", help=assets.__doc__) p.set_defaults(func=assets) args = parser.parse_args() args.func(args) if __name__ == "__main__": main() fenicsx_compat-0.4.0/.github/workflows/build_docs.yml0000644000000000000000000000157113615410400017670 0ustar00name: Build documentation on: pull_request: branches: [main] workflow_call: workflow_dispatch: env: DEB_PYTHON_INSTALL_LAYOUT: deb_system jobs: build: name: Build on ${{ matrix.label }} runs-on: ubuntu-24.04 # The API reference imports fenicsx_compat, which imports dolfinx container: ghcr.io/fenics/dolfinx/dolfinx:${{ matrix.label }} strategy: fail-fast: false matrix: label: ["stable", "nightly"] env: PUBLISH_DIR: ./_build/html steps: - name: Checkout uses: actions/checkout@v7 - name: Install dependencies run: python3 -m pip install ".[docs]" - name: Build docs run: jupyter book build --keep-going . - name: Upload artifact uses: actions/upload-artifact@v7 with: path: ${{ env.PUBLISH_DIR }} name: ${{ matrix.label }}-docs fenicsx_compat-0.4.0/.github/workflows/check.yml0000644000000000000000000000133613615410400016635 0ustar00name: Code quality on: pull_request: push: branches: [main] jobs: check-code: name: Lint, format and type-check runs-on: ubuntu-24.04 container: ghcr.io/fenics/dolfinx/dolfinx:nightly env: DEB_PYTHON_INSTALL_LAYOUT: deb_system steps: - uses: actions/checkout@v7 - name: Update pip run: python3 -m pip install --upgrade pip - name: Install package run: python3 -m pip install ".[dev]" - name: Lint (ruff check) run: python3 -m ruff check src tests - name: Format check (ruff format) run: python3 -m ruff format --check src tests - name: Type-check (mypy) run: python3 -m mypy --config-file pyproject.toml src tests fenicsx_compat-0.4.0/.github/workflows/deploy_docs.yml0000644000000000000000000001274413615410400020071 0ustar00name: Publish documentation # Every version is deployed through GitHub Pages' Actions source. # Built HTML of each release is kept as an asset of the "docs-archive" # release, so tagged versions are built once and the repo never grows. on: push: branches: [main] # rebuilds the "main" docs on every push schedule: - cron: "0 4 * * *" # also run nightly in case dolfinx:nightly moved workflow_dispatch: # manual trigger (e.g. after editing docs-versions.yml) # Read-only by default; only the deploy job gets write access permissions: contents: read # Allow one concurrent deployment, without cancelling one that is in progress concurrency: group: "pages" cancel-in-progress: false env: ARCHIVE: docs-archive # release (and tag) holding the built HTML GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} jobs: # ── Step 1: read the version config and decide what to build ────────────── plan: runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - uses: actions/checkout@v7 - name: List archived builds run: | gh release view "$ARCHIVE" --json assets --jq '.assets[].name' > archived.txt \ || touch archived.txt # release doesn't exist before the first run cat archived.txt - name: Build job matrix id: set-matrix run: python3 .github/scripts/docs_site.py matrix archived.txt # ── Step 2: build each version that needs it ────────────────────────────── build: needs: plan runs-on: ubuntu-24.04 # The API reference imports fenicsx_compat, which imports dolfinx container: ${{ matrix.dolfinx_image }} strategy: matrix: ${{ fromJson(needs.plan.outputs.matrix) }} fail-fast: false # a broken nightly shouldn't block a tagged build env: DEB_PYTHON_INSTALL_LAYOUT: deb_system steps: - name: Checkout fenicsx-compat at ${{ matrix.git_ref }} uses: actions/checkout@v7 with: ref: ${{ matrix.git_ref }} - name: Install dependencies run: python3 -m pip install ".[docs]" - name: Build docs run: jupyter book build --keep-going . # A tarball keeps hidden files and permissions, and is what gets archived - name: Package HTML run: tar -czf "docs-${{ matrix.git_ref }}.tar.gz" -C _build/html . - name: Upload built HTML as artifact uses: actions/upload-artifact@v7 with: name: docs-${{ matrix.label }} path: docs-${{ matrix.git_ref }}.tar.gz if-no-files-found: error retention-days: 1 # ── Step 3: archive new builds, assemble all versions and deploy ────────── deploy: needs: [plan, build] permissions: contents: write # to upload to the docs-archive release pages: write # to deploy to GitHub Pages id-token: write # to verify the deployment originates from here # Publish whatever built, even if one version failed if: ${{ !cancelled() && needs.plan.result == 'success' }} environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout fenicsx-compat (version list and flyout) uses: actions/checkout@v7 with: sparse-checkout: .github - name: Download freshly built versions uses: actions/download-artifact@v8 with: pattern: docs-* path: fresh/ merge-multiple: true # main is archived too, so a failed nightly falls back to the last good one - name: Archive new builds run: | gh release view "$ARCHIVE" > /dev/null 2>&1 \ || gh release create "$ARCHIVE" --prerelease --latest=false \ --title "Documentation archive" \ --notes "Built HTML of each documented version, used by the docs deployment. Do not delete." shopt -s nullglob for f in fresh/docs-*.tar.gz; do gh release upload "$ARCHIVE" "$f" --clobber done - name: Assemble site run: python3 .github/scripts/docs_site.py assemble fresh site # Read the Docs-style injection: one root script serves every version - name: Inject version flyout run: | cp .github/docs-version-flyout.js site/version-flyout.js tag='' for label in $(python3 .github/scripts/docs_site.py labels site); do find "site/${label}" -name '*.html' -print0 \ | xargs -0 sed -i "s##${tag}#" done # Drop archived builds of tags no longer listed in docs-versions.yml - name: Prune archive run: | keep=$(python3 .github/scripts/docs_site.py assets) for asset in $(gh release view "$ARCHIVE" --json assets --jq '.assets[].name'); do case " ${keep} " in *" ${asset} "*) ;; *) echo "Removing ${asset}"; gh release delete-asset "$ARCHIVE" "$asset" --yes ;; esac done - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: path: site - name: Setup Pages uses: actions/configure-pages@v6 - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v5 fenicsx_compat-0.4.0/.github/workflows/pypi.yml0000644000000000000000000000133113615410400016534 0ustar00name: PyPI on: push: branches: - "main" tags: - "v*" pull_request: branches: - "main" jobs: dist: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Build SDist and wheel run: pipx run build - uses: actions/upload-artifact@v7 with: path: dist/* - name: Check metadata run: pipx run twine check dist/* publish: needs: [dist] runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags') environment: pypi permissions: id-token: write steps: - uses: actions/download-artifact@v8 with: name: artifact path: dist - uses: pypa/gh-action-pypi-publish@release/v1 fenicsx_compat-0.4.0/.github/workflows/test.yml0000644000000000000000000000154413615410400016540 0ustar00name: Test package on: pull_request: push: branches: [main] jobs: test-code: name: Test on ${{ matrix.label }} runs-on: ubuntu-24.04 container: ghcr.io/fenics/dolfinx/dolfinx:${{ matrix.label }} env: DEB_PYTHON_INSTALL_LAYOUT: deb_system OMPI_ALLOW_RUN_AS_ROOT: 1 OMPI_ALLOW_RUN_AS_ROOT_CONFIRM: 1 PRTE_MCA_rmaps_default_mapping_policy: :oversubscribe strategy: fail-fast: false matrix: label: ["nightly", "stable", "v0.10.0"] steps: - uses: actions/checkout@v7 - name: Update pip run: python3 -m pip install --upgrade pip - name: Install package run: python3 -m pip install ".[test]" - name: Run tests (serial) run: python3 -m pytest -v - name: Run tests (parallel) run: mpirun -n 2 python3 -m pytest -v -m "not serial" fenicsx_compat-0.4.0/docs/api.rst0000644000000000000000000000147713615410400013701 0ustar00############# API reference ############# Every public function is also importable from the top-level ``fenicsx_compat`` package. Version checks ############## .. automodule:: fenicsx_compat._dolfinx_version :members: at_least, before Meshes ###### .. automodule:: fenicsx_compat.mesh :members: Finite elements and interpolation ################################# .. automodule:: fenicsx_compat.fem :members: Index maps and vectors ###################### .. automodule:: fenicsx_compat.la :members: PETSc ##### .. automodule:: fenicsx_compat.petsc :members: UFL ### .. automodule:: fenicsx_compat.ufl :members: Input and output ################ .. automodule:: fenicsx_compat.io :members: Optional dependencies ##################### .. automodule:: fenicsx_compat.optional :members: fenicsx_compat-0.4.0/docs/choosing.md0000644000000000000000000001206313615410400014522 0ustar00# Which function do I need? Find what your code does in the first column. The last column says which DOLFINx versions behave differently, which is when the function matters. "None" means the native call is the same from 0.10 on, and the function is only a convenience. For things that need no function at all, see {doc}`native`. ## Meshes | If your code needs to | Use | Differs between | |---|---|---| | Create a mesh from arrays with a ghost mode | [`create_mesh`](fn-create-mesh) | 0.10, 0.11 / main | | Create a mesh from a gmsh model with a ghost mode | [`model_to_mesh`](fn-model-to-mesh) | 0.10, 0.11 / main | | Pass a ghosted partitioner to an API that takes no ghost mode | [`create_cell_partitioner`](fn-create-cell-partitioner) | 0.10, 0.11 only; raises on main | | Change the degree of the coordinate element | [`reconstruct_mesh`](fn-reconstruct-mesh) | 0.10 / 0.11, main | | Get the coordinate element | [`cmap`](fn-cmap) | 0.10 / 0.11, main | | Get the nodes of each cell | [`dofmap`](fn-dofmap) | 0.10 / 0.11, main | | Get the topology of a `MeshTags` as the Python object | [`meshtags_topology`](fn-meshtags-topology) | 0.10, 0.11 / main | | Get the cell permutation info | [`cell_permutation_info`](fn-cell-permutation-info) | 0.10, 0.11 / main | | Get the orientation of each facet as seen from each cell | [`facet_permutations`](fn-facet-permutations) | 0.10, 0.11 / main | | Create a Raviart-Thomas space on a manifold | [`create_facet_orientations`](fn-create-facet-orientations) | 0.10, 0.11 / main | | Move tags from a mesh to a submesh | [`transfer_meshtags_to_submesh`](fn-transfer-meshtags-to-submesh) | 0.10 / 0.11 / main | | Find the parent entities of submesh entities | [`submesh_to_parent`](fn-submesh-to-parent) | 0.10 / 0.11, main | ## Index maps and vectors | If your code needs to | Use | Differs between | |---|---|---| | Create an index map | [`create_index_map`](fn-create-index-map) | 0.10, 0.11 / main | | Pass an index map to a `dolfinx.cpp` function | [`unwrap_index_map`](fn-unwrap-index-map) | 0.10, 0.11 / main | | Create an index map for a subset of indices | [`create_sub_index_map`](fn-create-sub-index-map) | 0.10, 0.11 / main | | Find the other ranks that hold each index | [`index_to_dest_ranks`](fn-index-to-dest-ranks) | 0.10, 0.11 / main | | Split `range(N)` over ranks the way DOLFINx does | [`local_range`](fn-local-range) | 0.10, 0.11 / main | | Create a `dolfinx.la.Vector` | [`vector`](fn-vector) | None, if `dtype` is a keyword | ## Function spaces, elements and interpolation | If your code needs to | Use | Differs between | |---|---|---| | Create a real (global constant) function space | [`real_functionspace`](fn-real-functionspace) | 0.10 / 0.11, main | | Create a C++ finite element | [`finite_element_ctor_kwargs`](fn-finite-element-ctor-kwargs) | 0.10, 0.11 / main | | Interpolate values already evaluated at the interpolation points | [`interpolate`](fn-interpolate) | 0.10 / 0.11, main | | Evaluate an expression on cells or facets | [`expression_eval`](fn-expression-eval) | None | | Create an expression that uses coefficients from a parent mesh | [`interpolate_to_submesh_entity_maps`](fn-interpolate-to-submesh-entity-maps) | 0.10 / 0.11, main | | Evaluate on facets at points seen from the cell | [`permute_facet_quadrature`](fn-permute-facet-quadrature) | 0.10 / 0.11, main | | Reorder facet values to the facet's own orientation | [`permute_interpolation_data`](fn-permute-interpolation-data) | 0.10 / 0.11, main | ## Forms and PETSc | If your code needs to | Use | Differs between | |---|---|---| | Get the index map and block size of a linear form | [`form_map`](fn-form-map) | 0.10 / 0.11, main | | Wrap a hand-built C++ Dirichlet condition | [`dirichletbc_init_kwargs`](fn-dirichletbc-init-kwargs) | 0.10, 0.11 / main | | Sort boundary conditions by block | [`bcs_by_block`](fn-bcs-by-block) | 0.10, 0.11 / main, when spaces are mixed | | Set Dirichlet values in a PETSc vector | [`set_bc`](fn-set-bc) | None | | Apply lifting and set Dirichlet values in one call | [`apply_lifting_and_set_bc`](fn-apply-lifting-and-set-bc) | None | | Zero a PETSc vector, or update its ghosts | [`zero_petsc_vector`](fn-zero-petsc-vector), [`ghost_update`](fn-ghost-update) | None | ## UFL | If your code needs to | Use | Differs between | |---|---|---| | Get the domain of an expression | [`domain_of`](fn-domain-of) | None | | Map an expression back to the reference cell | [`apply_pullback_inverse`](fn-apply-pullback-inverse) | 0.10, 0.11 / main | ## Output and other packages | If your code needs to | Use | Differs between | |---|---|---| | Get the VTK cell type of a mesh cell | [`vtk_cell_type`](fn-vtk-cell-type) | 0.10, 0.11 / main | | Get ADIOS2's low-level bindings | [`resolve_adios_scope`](fn-resolve-adios-scope) | ADIOS2 2.9 / 2.10 and newer | | Let PyVista use VTK's snake_case names | [`pyvista_allow_snake_case`](fn-pyvista-allow-snake-case) | PyVista 0.45, 0.46 / 0.47 and newer | | Branch on the DOLFINx version | [`at_least`](fn-at-least), [`before`](fn-before) | None | | Import an optional module with a helpful error | [`require_module`](fn-require-module) | None | fenicsx_compat-0.4.0/docs/installation.md0000644000000000000000000000015313615410400015407 0ustar00# Installation ```{include} ../README.md :start-after: "# Installation" :end-before: "# Documentation" ``` fenicsx_compat-0.4.0/docs/native.md0000644000000000000000000001262713615410400014205 0ustar00# Already the same on every version From DOLFINx 0.10 on, these work the same way on 0.10, 0.11 and main, so fenicsx-compat has no wrapper for them. Call DOLFINx directly. Older code often still carries version branches for them, which can be deleted. | To | Call this on every version | Notes | |---|---|---| | Get the interpolation points of an element | {py:attr}`V.element.interpolation_points` | A property on the Python {py:class}`FiniteElement`. Only the C++ object has the method {py:meth}`V.element._cpp_object.interpolation_points()`. | | Build a C++ function space | {py:class}`dolfinx.cpp.fem.FunctionSpace_float64(mesh._cpp_object, element._cpp_object, dofmap._cpp_object)` | No `value_shape` argument. {py:class}`_float32` likewise. | | Count the dofs in an entity's closure | {py:meth}`len(layout.entity_closure_dofs(dim, i))` | [`ElementDofLayout.num_entity_closure_dofs`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.cpp.fem.html#dolfinx.cpp.fem.ElementDofLayout.num_entity_closure_dofs) and [`num_entity_dofs`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.cpp.fem.html#dolfinx.cpp.fem.ElementDofLayout.num_entity_dofs) only exist in 0.10. {py:meth}`entity_dofs` and {py:meth}`entity_closure_dofs` exist everywhere. | | Pack constants and coefficients | {py:func}`dolfinx.fem.pack_constants(form)`, {py:func}`dolfinx.fem.pack_coefficients(form)` | Public in {py:mod}`dolfinx.fem` everywhere. No need for `dolfinx.cpp.fem.pack_*(form._cpp_object)`. | | Read gmsh models | {py:mod}`dolfinx.io.gmsh` | [`dolfinx.io.gmshio`](https://docs.fenicsproject.org/dolfinx/v0.9.0/python/generated/dolfinx.io.gmshio.html#module-dolfinx.io.gmshio) no longer exists. {py:func}`model_to_mesh` returns a {py:class}`MeshData` named tuple, so the mesh is {py:attr}`.mesh`. For the ghost mode, see [`model_to_mesh`](fn-model-to-mesh). | | Find which process owns points | {py:func}`dolfinx.geometry.determine_point_ownership(mesh, points, padding=tol)` | Returns a {py:class}`PointOwnershipData` with {py:attr}`src_owner`, {py:attr}`dest_owner`, {py:attr}`dest_points` and {py:attr}`dest_cells`. `padding` has no default. | | Compute integration entities | {py:func}`dolfinx.fem.compute_integration_domains(integral_type, mesh.topology, entities)` | Three arguments, no dimension. Takes the Python {py:class}`topology`. Returns a flat array. | | Build a C++ topology | {py:class}`dolfinx.cpp.mesh.Topology(cell_type, vertex_map, cell_map, cells, original_index)` | The only constructor; it takes no communicator. The index maps and `cells` must be C++ objects, see [`unwrap_index_map`](fn-unwrap-index-map). | | Create and assemble blocked or nested PETSc objects | {py:func}`dolfinx.fem.petsc.create_vector(spaces, kind=...)`, {py:func}`create_matrix(a, kind=...)`, {py:func}`assemble_vector(L, kind=...)`, {py:func}`assemble_matrix(a, bcs=...)` | The [`*_block`](https://docs.fenicsproject.org/dolfinx/v0.9.0/python/generated/dolfinx.fem.petsc.html#module-dolfinx.fem.petsc) and [`*_nest`](https://docs.fenicsproject.org/dolfinx/v0.9.0/python/generated/dolfinx.fem.petsc.html#module-dolfinx.fem.petsc) functions are gone. `kind="nest"` gives a nest object; for one blocked object, `kind="mpi"` is used by scifem on 0.11 and main. | ## Differences with no wrapper These do differ between versions, and fenicsx-compat does not cover them. | What | 0.10, 0.11 | main | Portable | |---|---|---|---| | Set Dirichlet values in an array | [`dolfinx.fem.set_bc(b, bcs, x0, alpha)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.fem.html#dolfinx.fem.set_bc), deprecated in 0.10 | removed | {py:meth}`for bc in bcs: bc.set(b, x0, alpha)` with `b` a NumPy array. For a PETSc vector, see [`set_bc`](fn-set-bc). | | Newton solver problem class | [`dolfinx.fem.petsc.NewtonSolverNonlinearProblem`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.fem.petsc.html#dolfinx.fem.petsc.NewtonSolverNonlinearProblem) | removed | {py:class}`dolfinx.fem.petsc.NonlinearProblem`, which solves with PETSc SNES and has a different interface | | Point ownership search | [`determine_point_ownership(mesh, points, padding, cells=None)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.geometry.html#dolfinx.geometry.determine_point_ownership) | adds {py:func}`find_closest_cell=True` | Do not pass `find_closest_cell` | fenicsx_compat-0.4.0/docs/guide/fem.md0000644000000000000000000002076613615410400014566 0ustar00# Finite elements and interpolation (fn-real-functionspace)= ## `real_functionspace` ```python fenicsx_compat.real_functionspace(mesh, value_shape=()) -> dolfinx.fem.FunctionSpace ``` Creates a real function space: one global value per component, the same on the whole mesh. | DOLFINx | Native | |---|---| | 0.10 | None. {py:func}`basix.ufl.real_element` exists, but a function space cannot be built from it. Raises `NotImplementedError`; {py:func}`scifem.create_real_functionspace` has a compiled implementation. | | 0.11, main | {py:func}`dolfinx.fem.functionspace(mesh, basix.ufl.real_element(mesh.basix_cell(), value_shape, dtype=mesh.geometry.x.dtype))` | ```python R = fenicsx_compat.real_functionspace(mesh) R2 = fenicsx_compat.real_functionspace(mesh, (2,)) ``` This is one of the few functions that checks the DOLFINx version rather than the API, since on 0.10 nothing about {py:func}`real_element` shows that it will not work. (fn-finite-element-ctor-kwargs)= ## `finite_element_ctor_kwargs` ```python fenicsx_compat.finite_element_ctor_kwargs(constructor, basix_element, *, gdim, value_shape, symmetric=False) ``` Creates a C++ finite element ({py:class}`dolfinx.cpp.fem.FiniteElement_float32` or {py:class}`_float64`) from a C++ basix element. | DOLFINx | Native | |---|---| | 0.10, 0.11 | [`constructor(basix_element, block_shape, symmetric)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.cpp.fem.html#dolfinx.cpp.fem.FiniteElement_float64) | | main (DOLFINx PR #4511) | {py:class}`constructor(basix_element, gdim, block_shape, symmetric)`; `gdim` is the geometric dimension of the mesh | ```python el = basix.ufl.element("Lagrange", mesh.basix_cell(), 2, shape=(2,)) cpp_element = fenicsx_compat.finite_element_ctor_kwargs( dolfinx.cpp.fem.FiniteElement_float64, el.basix_element._e, gdim=mesh.geometry.dim, value_shape=el.reference_value_shape, ) ``` `basix_element` is the C++ basix element, `ufl_element.basix_element._e`. Pass `value_shape=None` for an element without blocks. (fn-dirichletbc-init-kwargs)= ## `dirichletbc_init_kwargs` ```python fenicsx_compat.dirichletbc_init_kwargs(V, g) -> dict ``` Returns the keyword arguments {py:class}`dolfinx.fem.DirichletBC` needs, besides the C++ condition, when wrapping a C++ Dirichlet condition built by hand. `V` is the constrained space and `g` the value; `V` need not be `g.function_space`. | DOLFINx | Native | |---|---| | 0.10, 0.11 | [`DirichletBC(bc)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.fem.html#dolfinx.fem.DirichletBC); space and value are read back from the C++ condition | | main (DOLFINx PR #4342) | {py:class}`DirichletBC(bc, V, g)`; the Python wrapper keeps the `V` and `g` it is given | ```python cpp_bc = dolfinx.cpp.fem.DirichletBC_float64(g._cpp_object, dofs) bc = dolfinx.fem.DirichletBC(cpp_bc, **fenicsx_compat.dirichletbc_init_kwargs(V, g)) ``` The same keywords work from a subclass, `super().__init__(cpp_bc, **kwargs)`. Use {py:func}`dolfinx.fem.dirichletbc` instead whenever it can build the condition. (fn-interpolate)= ## `interpolate` ```python fenicsx_compat.interpolate(cpp_function, values, cells) -> None ``` Interpolates values already evaluated at the interpolation points into a function, on the given cells. | DOLFINx | Native | |---|---| | 0.10 | [`u._cpp_object.interpolate(values, cells)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.cpp.fem.html#dolfinx.cpp.fem.Function_float64.interpolate) | | 0.11, main (DOLFINx PR #4049) | {py:meth}`u._cpp_object.interpolate_f(values, cells)` | ```python cells = np.arange(mesh.topology.index_map(mesh.topology.dim).size_local, dtype=np.int32) fenicsx_compat.interpolate(u._cpp_object, values, cells) ``` `values` is a C-contiguous array of shape `(value_size, num_points)`, with the points ordered cell by cell. (fn-expression-eval)= ## `expression_eval` ```python fenicsx_compat.expression_eval(expr, mesh, entities) -> numpy.ndarray ``` Evaluates a {py:class}`dolfinx.fem.Expression` on cells or facets. | DOLFINx | Native | |---|---| | 0.10, 0.11, main | {py:meth}`expr.eval(mesh, entities)`: `entities` is a 1D array of cells, or an `(N, 2)` array of `(cell, local facet)` pairs for facets | Not needed from 0.10 on: the 2D facet entities were introduced before 0.10 (DOLFINx PR #3625), and {py:meth}`expr.eval(mesh, entities)` works the same on every supported version. The function only adds a retry with the entities flattened, which is the shape older DOLFINx took. New code can call {py:meth}`expr.eval` directly. (fn-interpolate-to-submesh-entity-maps)= ## `interpolate_to_submesh_entity_maps` ```python fenicsx_compat.interpolate_to_submesh_entity_maps(expr, points, entity_maps=None) -> dolfinx.fem.Expression ``` Creates a {py:class}`dolfinx.fem.Expression`, passing `entity_maps` where DOLFINx accepts it. Entity maps let the expression use coefficients that live on another mesh, typically a parent mesh of a submesh. Despite its name, the function interpolates nothing itself. | DOLFINx | Native | |---|---| | 0.10 | [`dolfinx.fem.Expression(expr, points)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.fem.html#dolfinx.fem.Expression). Entity maps are not supported. | | 0.11, main (DOLFINx PR #4140) | {py:class}`dolfinx.fem.Expression(expr, points, entity_maps=entity_maps)` | ```python submesh, entity_map = dolfinx.mesh.create_submesh(mesh, tdim, cells)[:2] e = fenicsx_compat.interpolate_to_submesh_entity_maps( u, V_sub.element.interpolation_points, [entity_map] ) ``` On 0.10, `entity_maps` is dropped without a warning, so an expression that needs it cannot work there. (fn-permute-facet-quadrature)= ## `permute_facet_quadrature` ```python fenicsx_compat.permute_facet_quadrature(facet_type, points) -> list[numpy.ndarray] ``` Returns one copy of facet reference points for each facet permutation, so that an expression evaluated on a facet is evaluated at `points` as seen from the cell, whatever the facet's orientation. Entry `p` is for facets with permutation `p` (see [`facet_permutations`](fn-facet-permutations)): 2 entries for an interval, 6 for a triangle and 8 for a quadrilateral. | DOLFINx | What a facet {py:class}`Expression` evaluates at | |---|---| | 0.10 | The points in the cell's own orientation of the facet. All entries returned are `points`. | | 0.11, main (DOLFINx PR #4140, FFCx PR #836) | The points mapped through the facet's global orientation. Each entry is `points` with the inverse of that permutation applied. | ```python perms = fenicsx_compat.facet_permutations(mesh.topology) point_sets = fenicsx_compat.permute_facet_quadrature(basix.CellType.interval, points) expressions = [dolfinx.fem.Expression(f, X) for X in point_sets] # For a facet (cell, local_facet): values = expressions[perms[cell, local_facet]].eval( mesh, np.array([[cell, local_facet]], dtype=np.int32) ) ``` Evaluate the facets with the same permutation together, one call per expression. On 0.11 and main this uses `ffcx.ir.elementtables`, which is not public FFCx API. (fn-permute-interpolation-data)= ## `permute_interpolation_data` ```python fenicsx_compat.permute_interpolation_data(data, integration_entities, cell_permutation_info, basix_element, facet_type) -> None ``` Reorders facet values, in place, from the cell's ordering of the facet's dofs to the facet's own. Needed when values evaluated on parent-mesh facets are interpolated into a space on a facet submesh. | DOLFINx | Native | |---|---| | 0.10 | Permute each row of `data` with {py:meth}`basix_element.permute_subentity_closure_inv(perm, cell_info[cell], facet_type, local_facet)` | | 0.11, main (DOLFINx PR #4140, FFCx PR #836) | Nothing to do: DOLFINx applies the permutation. This function does nothing. | ```python fenicsx_compat.permute_interpolation_data( data, entities, fenicsx_compat.cell_permutation_info(mesh.topology), V.element.basix_element, basix.CellType.interval, ) ``` `data` has one row per entity in `entities`, an `(N, 2)` array of `(cell, local facet)`, and one column per dof on the facet's closure. fenicsx_compat-0.4.0/docs/guide/index_maps.md0000644000000000000000000001546013615410400016141 0ustar00# Index maps and vectors DOLFINx PR #4496 (after 0.11) wrapped {py:class}`IndexMap` in a Python class. On 0.10 and 0.11, {py:class}`dolfinx.common.IndexMap` is the C++ class itself; on main it is a Python wrapper around it. Most functions on this page exist because of that change. (fn-create-index-map)= ## `create_index_map` ```python fenicsx_compat.create_index_map(comm, num_local, ghosts, owners, tag=0) -> IndexMap ``` Creates an index map with `num_local` owned indices, and ghosts given by their global indices and owning ranks. | DOLFINx | Native | Returns | |---|---|---| | 0.10, 0.11 | [`dolfinx.common.IndexMap(comm, num_local, ghosts, owners, tag)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.common.html#dolfinx.common.IndexMap) | the C++ {py:class}`IndexMap` | | main | {py:func}`dolfinx.common.index_map(comm, num_local, (ghosts, owners), tag=tag)` | the Python {py:class}`IndexMap` | ```python comm = MPI.COMM_WORLD neighbour = (comm.rank + 1) % comm.size # Each rank owns 3 indices and ghosts the first index of the next rank ghosts = np.array([3 * neighbour] if comm.size > 1 else [], dtype=np.int64) owners = np.array([neighbour] if comm.size > 1 else [], dtype=np.int32) imap = fenicsx_compat.create_index_map(comm, 3, ghosts, owners, tag=101) ``` `tag` is the MPI tag of the exchange that builds the map. Give maps created at the same time different tags. (fn-unwrap-index-map)= ## `unwrap_index_map` ```python fenicsx_compat.unwrap_index_map(index_map) -> dolfinx.cpp.common.IndexMap ``` The C++ index map, given either the Python or the C++ one. | DOLFINx | Native | |---|---| | 0.10, 0.11 | Nothing to do: every index map is the C++ one. | | main | `index_map._cpp_object` | Use it wherever an index map goes into a `dolfinx.cpp` function or constructor: ```python dofmap = dolfinx.cpp.fem.DofMap(layout, fenicsx_compat.unwrap_index_map(imap), bs, cells, bs) ``` (fn-create-sub-index-map)= ## `create_sub_index_map` ```{versionadded} 0.2.0 ``` ```python fenicsx_compat.create_sub_index_map(index_map, indices, *, allow_owner_change=False) -> (sub_map, sub_to_parent) ``` Creates an index map for a subset of the local indices of `index_map`. `sub_to_parent` holds, for each local index of the sub-map, its local index in `index_map`. An index that is kept by a rank that ghosts it but not by its owner has to change owner. Unless `allow_owner_change` is set, that is an error. | DOLFINx | Native | Owner change | |---|---|---| | 0.10, 0.11 | `dolfinx.cpp.common.create_sub_index_map(cpp_map, indices, allow_owner_change)` returns `(sub_map, sub_to_parent)` | raises `RuntimeError`, only on the owning ranks, if not allowed | | main (DOLFINx PRs #4479, #4496) | {py:func}`dolfinx.common.create_sub_index_map(index_map, indices)` returns `(sub_map, sub_to_parent, owners_changed)` | always allowed. `owners_changed` is local to each rank. | ```python vertex_map = mesh.topology.index_map(0) keep = np.arange(vertex_map.size_local + vertex_map.num_ghosts, dtype=np.int32)[::2] sub_map, sub_to_parent = fenicsx_compat.create_sub_index_map(vertex_map, keep) ``` - `indices` must be sorted and unique: 0.10 and 0.11 require it. - `sub_map` is the C++ map on 0.10 and 0.11 and the Python one on main, like {py:func}`create_index_map`. - On main the owner-change error is raised on every rank together. On 0.10 and 0.11 it comes from DOLFINx, on the owning ranks only, and the other ranks carry on into their next collective call and hang. If an owner change is possible, choose `indices` so that it cannot happen, or pass `allow_owner_change=True`. (fn-index-to-dest-ranks)= ## `index_to_dest_ranks` ```python fenicsx_compat.index_to_dest_ranks(index_map, tag) -> (ranks, offsets) ``` For each local index, the other ranks that hold it: for an owned index, the ranks that ghost it; for a ghost, its owner and the other ranks that ghost it. The ranks of local index `i` are `ranks[offsets[i]:offsets[i + 1]]`. Collective. | DOLFINx | Native | |---|---| | 0.10, 0.11 | [`index_map.index_to_dest_ranks(tag)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.common.html#dolfinx.common.IndexMap.index_to_dest_ranks) returns an [`AdjacencyList`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.cpp.graph.html#dolfinx.cpp.graph.AdjacencyList_int32); use [`.array`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.cpp.graph.html#dolfinx.cpp.graph.AdjacencyList_int32.array) and [`.offsets`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.cpp.graph.html#dolfinx.cpp.graph.AdjacencyList_int32.offsets) | | main (DOLFINx PRs #4354, #4573) | {py:meth}`index_map.index_to_dest_ranks()` returns `(ranks, offsets)` | ```python ranks, offsets = fenicsx_compat.index_to_dest_ranks(mesh.topology.index_map(0), tag=102) ``` `tag` is only used on 0.10 and 0.11. Pass one that no other exchange in progress uses. Both the Python and the C++ index map are accepted. (fn-local-range)= ## `local_range` ```{versionadded} 0.2.0 ``` ```python fenicsx_compat.local_range(index, N, size) -> (int, int) ``` The half-open range of `[0, N)` held by block `index` when `[0, N)` is split into `size` blocks of almost equal size, as DOLFINx splits indices over ranks. The first `N % size` blocks hold one index more than the others. | DOLFINx | Native | |---|---| | 0.10, 0.11 | Not available in Python. ([`IndexMap.local_range`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.common.html#dolfinx.common.IndexMap.local_range) is a different thing: the owned range of that map.) | | main | `dolfinx.common.local_range(index, N, size)` | ```python start, stop = fenicsx_compat.local_range(comm.rank, N, comm.size) ``` (fn-vector)= ## `vector` ```python fenicsx_compat.vector(index_map, bs, dtype=np.float64) -> dolfinx.la.Vector ``` Creates a distributed vector over an index map with block size `bs`. | DOLFINx | Native | |---|---| | 0.10, 0.11 | [`dolfinx.la.vector(index_map, bs, dtype)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.la.html#dolfinx.la.vector) | | main (DOLFINx PRs #4489, #4496) | {py:func}`dolfinx.la.vector(index_map, bs, scatterer=None, *, dtype=...)`. `dtype` must be a keyword, and `index_map` must be the Python one. | ```python x = fenicsx_compat.vector(V.dofmap.index_map, V.dofmap.index_map_bs, dtype=np.float64) ``` Pass the index map as DOLFINx or {py:func}`create_index_map` returns it; on main the C++ one raises `AttributeError`. A native call with `dtype=` as a keyword works on every version too. fenicsx_compat-0.4.0/docs/guide/io.md0000644000000000000000000000507413615410400014421 0ustar00# Output and visualisation For reading gmsh models, see [`model_to_mesh`](fn-model-to-mesh). (fn-vtk-cell-type)= ## `vtk_cell_type` ```{versionadded} 0.3.0 ``` ```python fenicsx_compat.vtk_cell_type(cell_type, num_nodes) -> int ``` The VTK cell type identifier of a cell of type `cell_type` with `num_nodes` geometry nodes, e.g. for writing a mesh in a VTK format. | DOLFINx | Native | |---|---| | 0.10, 0.11 | `dolfinx.cpp.io.get_vtk_cell_type(cell_type, tdim)`, where the second argument is a dimension. `num_nodes` is not used. | | main (DOLFINx PR #4621) | `dolfinx.cpp.io.get_vtk_cell_type(cell_type, num_nodes)`. Passing the dimension there raises for quadrilaterals and hexahedra. | ```python num_nodes = fenicsx_compat.dofmap(mesh).shape[1] vtk_type = fenicsx_compat.vtk_cell_type(mesh.topology.cell_type, num_nodes) ``` For Lagrange geometry both return the arbitrary-degree Lagrange type, e.g. 72 for any hexahedron. Only main returns the serendipity types (23 for an 8-node quadrilateral, 25 for a 20-node hexahedron) and rejects node counts that no layout has. The next two functions depend on the version of ADIOS2 and PyVista, not on DOLFINx. (fn-resolve-adios-scope)= ## `resolve_adios_scope` ```python fenicsx_compat.resolve_adios_scope(adios2) -> module ``` Returns the namespace that holds ADIOS2's low-level Python bindings ({py:class}`ADIOS`, `Mode`, ...). Raises `ImportError` if ADIOS2 was built without MPI. | ADIOS2 | Low-level bindings | |---|---| | 2.9 | `adios2` | | 2.10 and newer | `adios2.bindings`. The top-level `adios2` is the new high-level API. | ```python adios2 = fenicsx_compat.resolve_adios_scope(fenicsx_compat.require_module("adios2")) adios = adios2.ADIOS(MPI.COMM_WORLD) ``` The MPI check uses `is_built_with_mpi`, which ADIOS2 2.9 does not have, so in practice it needs ADIOS2 2.10 or newer. (fn-pyvista-allow-snake-case)= ## `pyvista_allow_snake_case` ```python fenicsx_compat.pyvista_allow_snake_case(pyvista) -> None ``` Lets PyVista objects use the snake_case names of VTK methods, which PyVista otherwise refuses. | PyVista | Where the setting lives | |---|---| | 0.45, 0.46 | `pyvista._VTK_SNAKE_CASE_STATE` | | 0.47 and newer | `pyvista.core.vtk_snake_case._state` | ```python pyvista = fenicsx_compat.require_module("pyvista") fenicsx_compat.pyvista_allow_snake_case(pyvista) ``` PyVista 0.45 and newer also have a public call that does the same on every version, {py:data}`pyvista.vtk_snake_case("allow")`. PyVista 0.44 and older have no such guard, and this function raises `AttributeError` there. fenicsx_compat-0.4.0/docs/guide/mesh.md0000644000000000000000000002006413615410400014742 0ustar00# Geometry, topology and tags (fn-cmap)= ## `cmap` ```python fenicsx_compat.cmap(mesh) -> dolfinx.fem.CoordinateElement ``` The coordinate element of the mesh geometry. | DOLFINx | Native | |---|---| | 0.10 | [`mesh.geometry.cmap`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.mesh.html#dolfinx.mesh.Geometry.cmap) | | 0.11, main | {py:attr}`mesh.geometry.cmaps[0]`. {py:attr}`mesh.geometry.cmap` still works but emits a `DeprecationWarning`. | ```python cell = 0 nodes = mesh.geometry.x[fenicsx_compat.dofmap(mesh)[cell]] X = fenicsx_compat.cmap(mesh).pull_back(np.array([[0.2, 0.1, 0.0]]), nodes) ``` (fn-dofmap)= ## `dofmap` ```python fenicsx_compat.dofmap(mesh) -> numpy.ndarray ``` The geometry dofmap: the node indices of each cell, shape `(num_cells, num_nodes_per_cell)`. | DOLFINx | Native | |---|---| | 0.10 | [`mesh.geometry.dofmap`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.mesh.html#dolfinx.mesh.Geometry.dofmap) | | 0.11, main | {py:attr}`mesh.geometry.dofmaps[0]`. {py:attr}`mesh.geometry.dofmap` still works but emits a `DeprecationWarning`. | ```python cell_nodes = mesh.geometry.x[fenicsx_compat.dofmap(mesh)] # (num_cells, num_nodes_per_cell, 3) ``` (fn-meshtags-topology)= ## `meshtags_topology` ```{versionadded} 0.2.0 ``` ```python fenicsx_compat.meshtags_topology(tags) -> dolfinx.mesh.Topology ``` The topology a {py:class}`MeshTags` is defined on, always as the Python {py:class}`dolfinx.mesh.Topology`. | DOLFINx | {py:attr}`tags.topology` returns | Native Python topology | |---|---|---| | 0.10, 0.11 | the C++ topology | [`dolfinx.mesh.Topology(tags.topology)`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.mesh.html#dolfinx.mesh.Topology) | | main (DOLFINx PR #4350) | the Python topology | {py:attr}`tags.topology` | ```python topology = fenicsx_compat.meshtags_topology(facet_tags) cpp_topology = topology._cpp_object # the C++ object, on every version ``` (fn-cell-permutation-info)= ## `cell_permutation_info` ```python fenicsx_compat.cell_permutation_info(topology) -> numpy.ndarray # uint32 ``` Computes and returns the packed per-cell permutation info, ghost cells included, as used by dof transformations. | DOLFINx | Native | |---|---| | 0.10, 0.11 | [`topology.create_entity_permutations()`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.mesh.html#dolfinx.mesh.Topology.create_entity_permutations), then [`topology.get_cell_permutation_info()`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.mesh.html#dolfinx.mesh.Topology.get_cell_permutation_info) | | main (DOLFINx PR #3904) | {py:meth}`topology.create_cell_permutations()`, then {py:meth}`topology.get_cell_permutation_info()`. {py:meth}`create_entity_permutations` now takes a dimension. | ```python cell_info = fenicsx_compat.cell_permutation_info(mesh.topology) # e.g. for V.element.Tt_inv_apply(data, cell_info, n) ``` Calling it is also the portable way to make sure the permutations are computed before handing the topology to code that needs them. (fn-facet-permutations)= ## `facet_permutations` ```python fenicsx_compat.facet_permutations(topology) -> numpy.ndarray # uint8 ``` The permutation of every facet of every cell, shape `(num_cells, num_facets_per_cell)`, ghost cells included. These are the values FFCx kernels take as `quadrature_permutation`. | DOLFINx | Native | |---|---| | 0.10, 0.11 | [`topology.create_entity_permutations()`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.mesh.html#dolfinx.mesh.Topology.create_entity_permutations), then [`topology.get_facet_permutations()`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.mesh.html#dolfinx.mesh.Topology.get_facet_permutations), which is flat | | main (DOLFINx PR #3904) | {py:meth}`topology.create_entity_permutations(tdim - 1)`, then {py:meth}`topology.get_entity_permutations(tdim - 1)`, which is flat | ```python perms = fenicsx_compat.facet_permutations(mesh.topology) perm = perms[cell, local_facet] ``` On main, an interval mesh gives an empty array, since points have no orientation. (fn-create-facet-orientations)= ## `create_facet_orientations` ```{versionadded} 0.3.0 ``` ```python fenicsx_compat.create_facet_orientations(topology) -> None ``` Computes the facet orientations that Raviart-Thomas and other contravariant Piola spaces need on a manifold. Call it on every rank, before creating the space. | DOLFINx | Native | |---|---| | 0.10, 0.11 | No equivalent, and none needed: such spaces are created without it. Does nothing. | | main (DOLFINx PR #4625) | {py:meth}`topology.create_facet_orientations()`. Creating such a space on a manifold raises `RuntimeError` until it has been called. | ```python submesh = dolfinx.mesh.create_submesh(mesh, 2, boundary_facets)[0] fenicsx_compat.create_facet_orientations(submesh.topology) V = dolfinx.fem.functionspace(submesh, ("RT", 1)) ``` On main it raises `ValueError` for a topology whose dimension is not 2, as the native method does. Lagrange and Nédélec spaces do not need it. (fn-submesh-to-parent)= ## `submesh_to_parent` ```{versionadded} 0.4.0 ``` ```python fenicsx_compat.submesh_to_parent(entity_map, entities=None) -> numpy.ndarray ``` The parent entity of each of `entities` of a submesh, given one of the maps {py:func}`create_submesh` returns (the entity map or the vertex map). Without `entities`, all local (owned and ghost) entities of the submesh are mapped. | DOLFINx | {py:func}`create_submesh` returns the maps as | Native | |---|---|---| | 0.10 | arrays, the parent entity of each submesh entity | `entity_map[entities]` | | 0.11, main (DOLFINx PR #4140) | {py:class}`dolfinx.mesh.EntityMap` | {py:meth}`entity_map.sub_topology_to_topology(entities, inverse=False)` | ```python submesh, cell_map, vertex_map, _ = dolfinx.mesh.create_submesh(mesh, tdim, cells) parent_cells = fenicsx_compat.submesh_to_parent(cell_map) parent_vertices = fenicsx_compat.submesh_to_parent(vertex_map) ``` (fn-transfer-meshtags-to-submesh)= ## `transfer_meshtags_to_submesh` ```python fenicsx_compat.transfer_meshtags_to_submesh(entity_tag, submesh, cell_to_parent, vertex_to_parent) -> dolfinx.mesh.MeshTags ``` Transfers tags on a parent mesh to a submesh created with {py:func}`dolfinx.mesh.create_submesh`. | DOLFINx | Native | |---|---| | 0.10 | No equivalent. Raises `NotImplementedError`; {py:func}`scifem.transfer_meshtags_to_submesh` has a compiled implementation. | | 0.11 | [`dolfinx.mesh.transfer_meshtags_to_submesh(entity_tag, submesh, vertex_to_parent, cell_to_parent)`](https://docs.fenicsproject.org/dolfinx/v0.11.0.post0/python/generated/dolfinx.mesh.html#dolfinx.mesh.transfer_meshtags_to_submesh) | | main | {py:func}`dolfinx.mesh.transfer_meshtags_to_submesh(entity_tag, submesh, cell_to_parent, vertex_to_parent)` | The two maps swapped places between 0.11 and main while keeping their names, so a positional call that is right on one version passes the maps the wrong way round on the other. That does not raise. The wrapper forwards by keyword, and takes the maps in the order {py:func}`create_submesh` returns them: ```python submesh, cell_map, vertex_map, _ = dolfinx.mesh.create_submesh(mesh, tdim, cells) sub_tags = fenicsx_compat.transfer_meshtags_to_submesh(cell_tags, submesh, cell_map, vertex_map) ``` If you call DOLFINx directly, pass both maps by keyword: `cell_to_parent=cell_map, vertex_to_parent=vertex_map`. fenicsx_compat-0.4.0/docs/guide/mesh_creation.md0000644000000000000000000001471113615410400016630 0ustar00# Creating meshes Since DOLFINx PR #4403 (after 0.11), the ghost mode is an argument of {py:func}`create_mesh` and {py:func}`model_to_mesh`. In 0.10 and 0.11 it has to be built into the cell partitioner instead. The functions on this page take `ghost_mode` on every version and put it where the installed DOLFINx expects it. ```{important} {py:func}`fenicsx_compat.create_mesh` and {py:func}`fenicsx_compat.model_to_mesh` default to {py:attr}`GhostMode.shared_facet`, while DOLFINx defaults to {py:attr}`GhostMode.none` on every version. Pass `ghost_mode` explicitly if you rely on either. ``` (fn-create-mesh)= ## `create_mesh` ```python fenicsx_compat.create_mesh(comm, cells, e, x, *, partitioner=None, ghost_mode=GhostMode.shared_facet, max_facet_to_cell_links=2, num_threads=1) ``` Creates a distributed mesh from cell and node arrays with the given ghost mode. | DOLFINx | Native | |---|---| | 0.10, 0.11 | [`dolfinx.mesh.create_mesh(comm, cells, e, x, partitioner=dolfinx.mesh.create_cell_partitioner(ghost_mode, max_facet_to_cell_links))`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.mesh.html#dolfinx.mesh.create_mesh) | | main | {py:func}`dolfinx.mesh.create_mesh(comm, cells, e, x, ghost_mode=ghost_mode, max_facet_to_cell_links=..., num_threads=...)` | ```python from mpi4py import MPI import basix.ufl import dolfinx import numpy as np import ufl import fenicsx_compat comm = MPI.COMM_WORLD e = ufl.Mesh(basix.ufl.element("Lagrange", "triangle", 1, shape=(2,))) # The input is each rank's share: here all of it on rank 0, none on the others if comm.rank == 0: x = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]) cells = np.array([[0, 1, 2], [1, 3, 2]], dtype=np.int64) else: x = np.empty((0, 2)) cells = np.empty((0, 3), dtype=np.int64) mesh = fenicsx_compat.create_mesh(comm, cells, e, x, ghost_mode=dolfinx.mesh.GhostMode.shared_facet) ``` - `num_threads` only has an effect on main. - An explicit `partitioner` is always used as given. On 0.10 and 0.11 that means `ghost_mode` only applies if the partitioner was built with it, see [`create_cell_partitioner`](fn-create-cell-partitioner). - Passing the full arrays on every rank builds a mesh `comm.size` times too large. (fn-create-cell-partitioner)= ## `create_cell_partitioner` ```python fenicsx_compat.create_cell_partitioner(ghost_mode, max_facet_to_cell_links=2) ``` Builds a cell partitioner with the ghost mode built in, for 0.10 and 0.11. Raises `NotImplementedError` on main, where the partitioner no longer carries the ghost mode. | DOLFINx | Native | |---|---| | 0.10 | `dolfinx.mesh.create_cell_partitioner(ghost_mode, max_facet_to_cell_links=2)` | | 0.11 | [`dolfinx.mesh.create_cell_partitioner(ghost_mode, max_facet_to_cell_links)`](https://docs.fenicsproject.org/dolfinx/v0.11.0.post0/python/generated/dolfinx.mesh.html#dolfinx.mesh.create_cell_partitioner), with no default for the second argument | | main | Removed. Pass `ghost_mode` to {py:func}`create_mesh` or {py:func}`model_to_mesh`. | Prefer [`create_mesh`](fn-create-mesh) or [`model_to_mesh`](fn-model-to-mesh) with `ghost_mode`. Use this only for an API that accepts a partitioner but no ghost mode: ```python try: # DOLFINx 0.10 and 0.11 kwargs = { "partitioner": fenicsx_compat.create_cell_partitioner(dolfinx.mesh.GhostMode.shared_facet) } except NotImplementedError: # main kwargs = {"ghost_mode": dolfinx.mesh.GhostMode.shared_facet} ``` The raise on main is deliberate: returning a plain partitioner there would silently give an unghosted mesh. (fn-model-to-mesh)= ## `model_to_mesh` ```{versionadded} 0.2.0 ``` ```python fenicsx_compat.model_to_mesh(model, comm, rank, gdim=3, *, partitioner=None, ghost_mode=GhostMode.shared_facet, max_facet_to_cell_links=2, dtype=default_real_type) ``` Creates a mesh from a gmsh model with the given ghost mode, and returns the {py:class}`dolfinx.io.gmsh.MeshData` that DOLFINx builds, so the mesh is {py:attr}`.mesh`. | DOLFINx | Native | |---|---| | 0.10 | [`dolfinx.io.gmsh.model_to_mesh(model, comm, rank, gdim, partitioner=create_cell_partitioner(ghost_mode, max_facet_to_cell_links))`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.io.gmsh.html#dolfinx.io.gmsh.model_to_mesh); no `max_facet_to_cell_links` argument | | 0.11 | as 0.10, and `max_facet_to_cell_links` is also an argument of [`model_to_mesh`](https://docs.fenicsproject.org/dolfinx/v0.11.0.post0/python/generated/dolfinx.io.gmsh.html#dolfinx.io.gmsh.model_to_mesh) | | main | {py:func}`dolfinx.io.gmsh.model_to_mesh(model, comm, rank, gdim, ghost_mode=..., max_facet_to_cell_links=...)` | ```python import gmsh gmsh.initialize() if MPI.COMM_WORLD.rank == 0: gmsh.model.occ.addRectangle(0, 0, 0, 1, 1) gmsh.model.occ.synchronize() gmsh.model.addPhysicalGroup(2, [1], 1) gmsh.model.mesh.generate(2) mesh_data = fenicsx_compat.model_to_mesh( gmsh.model, MPI.COMM_WORLD, 0, gdim=2, ghost_mode=dolfinx.mesh.GhostMode.shared_facet ) gmsh.finalize() mesh = mesh_data.mesh ``` As for {py:func}`create_mesh`, an explicit `partitioner` is used as given, and on 0.10 and 0.11 the ghost mode then only applies if it was built into it. On 0.10, `max_facet_to_cell_links` only reaches the mesh through the partitioner built here, so it has no effect in serial. (fn-reconstruct-mesh)= ## `reconstruct_mesh` ```python fenicsx_compat.reconstruct_mesh(mesh, coordinate_element_degree) ``` Returns a copy of `mesh` whose geometry uses a coordinate element of another degree. The topology is shared with `mesh`. | DOLFINx | Native | |---|---| | 0.10 | No equivalent. The geometry is rebuilt by hand here. | | 0.11, main | {py:func}`dolfinx.fem.interpolate_geometry(mesh, dolfinx.fem.coordinate_element(mesh.topology.cell_type, degree, variant=mesh.geometry.cmaps[0].variant, dtype=mesh.geometry.x.dtype))` | ```python mesh = dolfinx.mesh.create_unit_square(MPI.COMM_WORLD, 4, 4) quadratic = fenicsx_compat.reconstruct_mesh(mesh, 2) assert fenicsx_compat.cmap(quadratic).degree == 2 ``` fenicsx_compat-0.4.0/docs/guide/petsc.md0000644000000000000000000001346013615410400015126 0ustar00# Forms and PETSc assembly The PETSc functions need petsc4py. Without it, {py:func}`set_bc`, {py:func}`apply_lifting_and_set_bc`, {py:func}`zero_petsc_vector` and {py:func}`ghost_update` raise `RuntimeError`; {py:func}`bcs_by_block` and {py:func}`form_map` work regardless. (fn-form-map)= ## `form_map` ```python fenicsx_compat.form_map(form) -> (IndexMap, int) ``` The index map and block size of the test space of a linear form. | DOLFINx | Native | |---|---| | 0.10 | [`form.function_spaces[0].dofmaps(0).index_map`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.fem.html#dolfinx.fem.FunctionSpace.dofmaps), [`.index_map_bs`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.fem.html#dolfinx.fem.DofMap.index_map_bs); [`dofmaps`](https://docs.fenicsproject.org/dolfinx/v0.10.0.post5/python/generated/dolfinx.fem.html#dolfinx.fem.FunctionSpace.dofmaps) is a method | | 0.11, main | {py:attr}`form.function_spaces[0].dofmaps[0].index_map`, {py:attr}`.index_map_bs`; {py:attr}`dofmaps` is a sequence | ```python imap, bs = fenicsx_compat.form_map(L) ``` For a mesh with a single cell type, {py:attr}`form.function_spaces[0].dofmap.index_map` and {py:attr}`.index_map_bs` work on every version, without this function. (fn-bcs-by-block)= ## `bcs_by_block` ```python fenicsx_compat.bcs_by_block(spaces, bcs) -> list[list[DirichletBC]] ``` Sorts boundary conditions by the space they constrain: one list per space in `spaces`, and an empty list where the space is `None`. | DOLFINx | Native | |---|---| | 0.10, 0.11, main | {py:func}`dolfinx.fem.bcs_by_block(spaces, bcs)` | The native call works as long as `spaces` and {py:attr}`bc.function_space` are both C++ objects (0.10, 0.11) or both Python objects (main), which is the case for {py:func}`dolfinx.fem.extract_function_spaces(a, i)`. Which kind each one is changed in DOLFINx PRs #4342 and #4350, so the native call fails when the two are mixed, for instance with a list of spaces built in Python on 0.11. This function accepts either kind on every version: ```python bcs1 = fenicsx_compat.bcs_by_block(dolfinx.fem.extract_function_spaces(a, 1), bcs) bcs0 = fenicsx_compat.bcs_by_block([V, Q], bcs) ``` (fn-set-bc)= ## `set_bc` ```python fenicsx_compat.set_bc(b, bcs, x0=None, alpha=1.0) -> None ``` Sets the Dirichlet values in a PETSc vector: `b[dofs] = alpha * (g - x0)`. | DOLFINx | Native | |---|---| | 0.10, 0.11, main | {py:func}`dolfinx.fem.petsc.set_bc(b, bcs, x0=x0, alpha=alpha)`, with `bcs` flat for a plain vector, and one list per block for a blocked or nested vector | The native call is the same everywhere. This function also accepts a single block given as `[[bc, ...]]` for a plain vector, which the native call rejects. (fn-apply-lifting-and-set-bc)= ## `apply_lifting_and_set_bc` ```python fenicsx_compat.apply_lifting_and_set_bc(b, a, bcs, x=None, alpha=1.0) -> None ``` Applies lifting to `b` and sets its Dirichlet values, with the ghost updates in between, for a single, blocked or nested system: - Free rows: `b - alpha * A (g - x)` - Constrained rows: `alpha * (g - x)` `a` is a bilinear form, or a 2D list of them for a blocked or nested system. `bcs` is always a flat list; for a blocked system they are sorted by block with [`bcs_by_block`](fn-bcs-by-block). | DOLFINx | Native, the same on every version | |---|---| | single form | {py:func}`apply_lifting(b, [a], bcs=[bcs], x0=[x], alpha=alpha)`, reverse ghost update, {py:func}`set_bc(b, bcs, x0=x, alpha=alpha)`, forward ghost update | | blocked or nested | {py:func}`apply_lifting(b, a, bcs=bcs_by_block(extract_function_spaces(a, 1), bcs), x0=x, alpha=alpha)`, reverse ghost update, {py:func}`set_bc(b, bcs_by_block(extract_function_spaces(a, 0), bcs), x0=x, alpha=alpha)`, forward ghost update | ```python b = dolfinx.fem.petsc.assemble_vector(L) fenicsx_compat.apply_lifting_and_set_bc(b, a, bcs) # Blocked, or nested with kind="nest" b = dolfinx.fem.petsc.assemble_vector(dolfinx.fem.form([L0, L1]), kind="mpi") fenicsx_compat.apply_lifting_and_set_bc(b, dolfinx.fem.form([[a00, a01], [a10, a11]]), bcs) ``` ```{versionchanged} 0.2.0 `x` works with a single form. In 0.1.0 that raises `AttributeError`; blocked and nested systems were not affected. ``` (fn-zero-petsc-vector)= ## `zero_petsc_vector` ```python fenicsx_compat.zero_petsc_vector(b) -> None ``` Sets a PETSc vector to zero, ghosts included. Nested vectors work too. | DOLFINx | Native | |---|---| | 0.10, 0.11, main | `dolfinx.la.petsc._zero_vector(b)`, which is private | It does not depend on the version. It exists so that code does not call the private DOLFINx function directly, and a rename only needs fixing here. (fn-ghost-update)= ## `ghost_update` ```python fenicsx_compat.ghost_update(x, insert_mode, scatter_mode) -> None ``` Updates the ghost values of a PETSc vector, sub-vector by sub-vector for a nested one. | DOLFINx | Native | |---|---| | 0.10, 0.11, main | `dolfinx.la.petsc._ghost_update(x, insert_mode, scatter_mode)`, which is private | ```python from petsc4py import PETSc fenicsx_compat.ghost_update(b, PETSc.InsertMode.ADD_VALUES, PETSc.ScatterMode.REVERSE) ``` Like {py:func}`zero_petsc_vector`, it does not depend on the version. fenicsx_compat-0.4.0/docs/guide/ufl.md0000644000000000000000000000270713615410400014600 0ustar00# UFL DOLFINx 0.10 pins UFL 2025.2, 0.11 pins UFL 2026.1, and main uses UFL 2026.2. (fn-domain-of)= ## `domain_of` ```python fenicsx_compat.domain_of(expr) -> ufl.Mesh ``` The UFL domain of an expression. Raises `ValueError` unless there is exactly one. | DOLFINx | Native | |---|---| | 0.10, 0.11, main | `ufl.domain.extract_domains(expr)`, which returns all domains, or `ufl.domain.extract_unique_domain(expr)`, which returns `None` if there is none | ```python domain = fenicsx_compat.domain_of(u) ``` It does not depend on the version. It is a shorthand that also checks that there is exactly one domain. (fn-apply-pullback-inverse)= ## `apply_pullback_inverse` ```python fenicsx_compat.apply_pullback_inverse(pullback, expr, domain) -> ufl.core.expr.Expr ``` Maps an expression on the physical cell to the reference cell, by inverting the element's pull back. | DOLFINx (UFL) | Native | |---|---| | 0.10 (2025.2), 0.11 (2026.1) | None. Closed forms are used here for the identity (`u`), contravariant Piola (`det(J) K u`) and covariant Piola (`J^T u`) pull backs. Other pull backs raise `NotImplementedError`. | | main (2026.2, UFL PR #511) | {py:meth}`pullback.apply_inverse(expr, domain)` | ```python v = ufl.TestFunction(V) v_ref = fenicsx_compat.apply_pullback_inverse(V.ufl_element().pullback, v, mesh.ufl_domain()) ``` The Piola closed forms assume a vector-valued expression, which is what Piola-mapped elements have. fenicsx_compat-0.4.0/docs/guide/version.md0000644000000000000000000000321413615410400015471 0ustar00# Version checks and optional modules Branch on behaviour rather than on version numbers where you can. The other functions in this guide do that for you. These three are for the remaining cases. They behave the same on every DOLFINx version. (fn-at-least)= ## `at_least` ```python fenicsx_compat.at_least(version: str) -> bool ``` Whether the installed DOLFINx is at least `version`, compared as `packaging` versions. (fn-before)= ## `before` ```python fenicsx_compat.before(version: str) -> bool ``` Whether the installed DOLFINx is strictly older than `version`. Always the opposite of {py:func}`at_least`. ```python if fenicsx_compat.before("0.11.0.dev0"): ... # 0.10, where facet dofs still have to be permuted by hand ``` Development builds sort before their release: `0.11.0.dev0 < 0.11.0`. To include the nightly builds that led up to a release, compare against `X.Y.0.dev0`, not `X.Y.0`. Use a version check only when the behaviour changed but the API did not, so there is nothing to detect. For example, {py:func}`basix.ufl.real_element` exists on 0.10, but a function space cannot be built from it until 0.11; see [`real_functionspace`](fn-real-functionspace). (fn-require-module)= ## `require_module` ```python fenicsx_compat.require_module(name: str, *, extra: str | None = None) -> types.ModuleType ``` Imports and returns the module `name`. If it is missing, raises an `ImportError` that says what to install: `pip install `, or `pip install ` without `extra`. ```python gmsh = fenicsx_compat.require_module("gmsh") h5py = fenicsx_compat.require_module("h5py", extra="scifem[h5py]") ``` fenicsx_compat-0.4.0/src/fenicsx_compat/__init__.py0000644000000000000000000000412713615410400017343 0ustar00from importlib.metadata import version as _version from ._dolfinx_version import at_least, before from .fem import ( dirichletbc_init_kwargs, expression_eval, finite_element_ctor_kwargs, interpolate, interpolate_to_submesh_entity_maps, permute_facet_quadrature, permute_interpolation_data, real_functionspace, ) from .io import model_to_mesh, pyvista_allow_snake_case, resolve_adios_scope, vtk_cell_type from .la import ( create_index_map, create_sub_index_map, index_to_dest_ranks, local_range, unwrap_index_map, vector, ) from .mesh import ( cell_permutation_info, cmap, create_cell_partitioner, create_facet_orientations, create_mesh, dofmap, facet_permutations, form_map, meshtags_topology, reconstruct_mesh, submesh_to_parent, transfer_meshtags_to_submesh, ) from .optional import require_module from .petsc import ( apply_lifting_and_set_bc, bcs_by_block, ghost_update, set_bc, zero_petsc_vector, ) from .ufl import apply_pullback_inverse, domain_of __version__ = _version("fenicsx-compat") __all__ = [ "__version__", "at_least", "before", "require_module", "cmap", "dofmap", "form_map", "cell_permutation_info", "facet_permutations", "create_cell_partitioner", "create_facet_orientations", "create_mesh", "reconstruct_mesh", "transfer_meshtags_to_submesh", "submesh_to_parent", "meshtags_topology", "create_index_map", "create_sub_index_map", "local_range", "unwrap_index_map", "index_to_dest_ranks", "vector", "real_functionspace", "finite_element_ctor_kwargs", "dirichletbc_init_kwargs", "interpolate", "expression_eval", "permute_facet_quadrature", "permute_interpolation_data", "interpolate_to_submesh_entity_maps", "bcs_by_block", "zero_petsc_vector", "ghost_update", "set_bc", "apply_lifting_and_set_bc", "domain_of", "apply_pullback_inverse", "resolve_adios_scope", "model_to_mesh", "vtk_cell_type", "pyvista_allow_snake_case", ] fenicsx_compat-0.4.0/src/fenicsx_compat/_dolfinx_version.py0000644000000000000000000000057513615410400021156 0ustar00import dolfinx from packaging.version import Version DOLFINX_VERSION = Version(dolfinx.__version__) def at_least(v: str) -> bool: """Whether the installed dolfinx is at least version `v`.""" return DOLFINX_VERSION >= Version(v) def before(v: str) -> bool: """Whether the installed dolfinx is strictly before version `v`.""" return DOLFINX_VERSION < Version(v) fenicsx_compat-0.4.0/src/fenicsx_compat/fem.py0000644000000000000000000002371313615410400016355 0ustar00import inspect import basix.ufl import dolfinx.fem import dolfinx.mesh import numpy as np import numpy.typing as npt from ._dolfinx_version import at_least, before def real_functionspace( mesh: dolfinx.mesh.Mesh, value_shape: tuple[int, ...] = () ) -> dolfinx.fem.FunctionSpace: """Create a real (single-constant-per-domain) function space. This is a deliberate exception to this project's prefer-introspection rule (spec §4): `basix.ufl.real_element` *exists* and *constructs* successfully on both sides of the dolfinx 0.10/0.11 boundary, so `hasattr` cannot distinguish them. What actually differs is deeper: at dolfinx v0.10.0 the element `real_element` returns is a non-functional stub whose `basix_element` property raises a bare, message-less `NotImplementedError` (confirmed by the v0.10.0 CI traceback: `dolfinx.fem.functionspace` accesses `ufl_e.basix_element` while building the space, which is exactly where it dies). From dolfinx 0.11 that property works and `dolfinx.fem.functionspace` succeeds (confirmed locally on this install, dolfinx 0.12.0.dev0). Since no `hasattr`/signature probe can see into that property without constructing a whole function space, we gate on `at_least("0.11.0")` instead - a hard version check, used here only because introspection genuinely cannot distinguish the behaviour. Do not "fix" this back to an introspection-only check. Separately, and unrelated to the above, `real_element` gained a `dtype` parameter in basix 0.11 (basix PR #1001). That part *is* detected by `inspect.signature`, and `dtype=` is only passed when the installed signature has that parameter. The `hasattr(basix.ufl, "real_element")` guard below covers a different failure mode: `basix` is a separate, independently installable package from `dolfinx`, so it can be version-skewed against it. A clear NotImplementedError beats an AttributeError deeper in the call. """ if not at_least("0.11.0"): raise NotImplementedError( "real_functionspace requires dolfinx>=0.11.0: at v0.10.0, " "basix.ufl.real_element exists but returns an element whose " "basix_element property raises NotImplementedError, so " "dolfinx.fem.functionspace cannot build a real space from it. " "Use scifem.create_real_functionspace() instead on dolfinx 0.10." ) if not hasattr(basix.ufl, "real_element"): raise NotImplementedError( "real_functionspace requires basix.ufl.real_element, which is not present " "in the installed basix. This indicates a basix/dolfinx version skew, not " "an unsupported dolfinx release." ) kwargs: dict = {"value_shape": value_shape} if "dtype" in inspect.signature(basix.ufl.real_element).parameters: kwargs["dtype"] = mesh.geometry.x.dtype el = basix.ufl.real_element(mesh.basix_cell(), **kwargs) try: return dolfinx.fem.functionspace(mesh, el) except NotImplementedError as e: raise NotImplementedError( "real_functionspace: dolfinx.fem.functionspace could not build a real " "function space from basix.ufl.real_element on this basix/dolfinx " "install (the underlying error was a bare, message-less " "NotImplementedError from basix's element stub). This is the same " "failure mode observed below dolfinx 0.11; if it is happening above " "that version, this is an unexpected basix/dolfinx pairing - consider " "scifem.create_real_functionspace() as a fallback." ) from e def finite_element_ctor_kwargs( constructor, basix_element, *, gdim: int, value_shape: tuple[int, ...] | None, symmetric: bool = False, ): """Construct a single-element cpp FiniteElement, across the `gdim` addition. dolfinx PR #4511 (post-0.11) added the mesh's geometric dimension as a required constructor argument, needed for elements on manifolds. 0.10 and 0.11 take `(element, block_shape, symmetric)` only, and `gdim` is ignored there. Dispatches on the `TypeError` nanobind raises for the unmatched overload, before any work is done. `constructor` is `dolfinx.cpp.fem.FiniteElement_float32` or `_float64`; `value_shape` is passed as dolfinx's `block_shape` (`None` for an unblocked element). """ try: return constructor(basix_element, gdim=gdim, block_shape=value_shape, symmetric=symmetric) except TypeError: return constructor(basix_element, block_shape=value_shape, symmetric=symmetric) def dirichletbc_init_kwargs( V: dolfinx.fem.FunctionSpace, g: dolfinx.fem.Function | dolfinx.fem.Constant ) -> dict: """Keyword arguments for wrapping a cpp DirichletBC in `dolfinx.fem.DirichletBC`. dolfinx PR #4342 (post-0.11) made the Python wrapper hold the Python space and value it is built with, so its initialiser takes `(bc, V, g)`. 0.10 and 0.11 take `(bc)` only and read both back from the cpp object. Unpack the result into the constructor, or into `super().__init__` from a subclass: `DirichletBC(cpp_bc, **kwargs)`. `V` is the constrained space, which need not be `g.function_space`. Detected from the initialiser's signature. """ if "V" in inspect.signature(dolfinx.fem.DirichletBC.__init__).parameters: return {"V": V, "g": g} return {} def interpolate(cpp_function, values: npt.NDArray, cells: npt.NDArray[np.int32]) -> None: """Interpolate raw values into a cpp Function, across the interpolate_f/interpolate rename.""" if hasattr(cpp_function, "interpolate_f"): cpp_function.interpolate_f(values, cells) else: cpp_function.interpolate(values, cells) def expression_eval(expr, domain, entity: npt.NDArray[np.int32]) -> npt.NDArray: """Evaluate an Expression at integration entities. Facet entities are a 2D `[[cell, local_entity]]` array from dolfinx PR #3625, which is in 0.10, so `expr.eval(domain, entity)` works the same on every supported version. The retry with the entities flattened is for the 1D shape of older dolfinx. """ try: return expr.eval(domain, entity) except (AttributeError, AssertionError): return expr.eval(domain, entity.flatten()) _FACET_PERMUTATION_COUNTS = { basix.CellType.interval: 2, basix.CellType.triangle: 6, basix.CellType.quadrilateral: 8, } def permute_facet_quadrature( facet_type: basix.CellType, points: npt.NDArray[np.floating] ) -> list[npt.NDArray[np.floating]]: """Build the per-permutation facet quadrature point table. Entry `p` holds the points to use for a facet with permutation `p`, so that an Expression evaluated on a facet is evaluated at `points` as seen from the cell. Before dolfinx PR #4140 (dolfinx<0.11.0.dev0), facet Expressions are not permuted at all, so every entry is `points`. From 0.11.0.dev0, dolfinx and FFCx (FFCx PR #836) map the points through the facet's global orientation, so each entry is `points` with the inverse of that permutation applied, computed with `ffcx.ir.elementtables` (an internal, non-public FFCx module). """ if before("0.11.0.dev0"): if facet_type not in _FACET_PERMUTATION_COUNTS: raise ValueError(f"Unsupported facet_type={facet_type!r}") return [points for _ in range(_FACET_PERMUTATION_COUNTS[facet_type])] try: from ffcx.ir.elementtables import ( permute_quadrature_interval, permute_quadrature_quadrilateral, permute_quadrature_triangle, ) except ImportError as e: raise NotImplementedError( "permute_facet_quadrature requires ffcx.ir.elementtables, an internal " "FFCx module; it may have moved in your installed ffcx version." ) from e if facet_type == basix.CellType.interval: return [permute_quadrature_interval(points, ref) for ref in range(2)] elif facet_type == basix.CellType.triangle: perms = [] for rot in range(3): for ref in range(2): rot_inv = (3 - rot) % 3 if ref == 0 else rot perms.append(permute_quadrature_triangle(points, ref, rot_inv)) return perms elif facet_type == basix.CellType.quadrilateral: perms = [] for rot in range(4): for ref in range(2): rot_inv = (4 - rot) % 4 if ref == 0 else rot perms.append(permute_quadrature_quadrilateral(points, ref, rot_inv)) return perms else: raise ValueError(f"Unsupported facet_type={facet_type!r}") def permute_interpolation_data( data: npt.NDArray, integration_entities: npt.NDArray[np.int32], cell_permutation_info: npt.NDArray[np.uint32], basix_element, facet_type: basix.CellType, ) -> None: """In-place permute interpolation data for the pre-dolfinx-PR-#4140 dof ordering. No-op from dolfinx>=0.11.0.dev0, where dolfinx applies this permutation internally. """ if not before("0.11.0.dev0"): return for i in range(integration_entities.shape[0]): perm = np.arange(data.shape[1], dtype=np.int32) basix_element.permute_subentity_closure_inv( perm, cell_permutation_info[integration_entities[i, 0]], facet_type, int(integration_entities[i, 1]), ) data[i] = data[i][perm] def interpolate_to_submesh_entity_maps( volume_function: dolfinx.fem.Function, points: npt.NDArray[np.floating], entity_maps=None, ) -> dolfinx.fem.Expression: """Build an Expression, across the `entity_maps`-kwarg-existence split on `Expression`. `entity_maps` is passed from dolfinx PR #4140 (0.11) on. dolfinx 0.10 has no such argument, so there it is dropped. """ if "entity_maps" in inspect.signature(dolfinx.fem.Expression).parameters: return dolfinx.fem.Expression(volume_function, points, entity_maps=entity_maps) return dolfinx.fem.Expression(volume_function, points) fenicsx_compat-0.4.0/src/fenicsx_compat/io.py0000644000000000000000000001022613615410400016210 0ustar00from __future__ import annotations import inspect from typing import TYPE_CHECKING import dolfinx import dolfinx.cpp.io import dolfinx.mesh from .mesh import create_cell_partitioner if TYPE_CHECKING: # Only for the return annotation of `model_to_mesh`, which imports # `dolfinx.io.gmsh` when called rather than here. import dolfinx.io.gmsh def vtk_cell_type(cell_type: dolfinx.mesh.CellType, num_nodes: int) -> int: """The VTK cell type identifier of a cell with `num_nodes` geometry nodes. dolfinx 0.10 and 0.11 have `dolfinx.cpp.io.get_vtk_cell_type(cell, dim)`, which returns the identifier of the dimension-`dim` sub-entity of `cell`. dolfinx PR #4621 (post-0.11) replaced `dim` with the number of nodes, so that it can tell the serendipity layouts apart and reject node counts no Lagrange layout has; passing the topological dimension there raises for quadrilaterals and hexahedra. For a Lagrange layout both return the same arbitrary-degree Lagrange identifier. On 0.10 and 0.11 `num_nodes` is not checked, and the whole cell's identifier is returned. Neither the argument count nor `inspect.signature` tells the two apart, so this dispatches on the parameter name in the nanobind signature, i.e. the first line of the docstring. """ get_vtk_cell_type = dolfinx.cpp.io.get_vtk_cell_type signature = (get_vtk_cell_type.__doc__ or "").split("\n", 1)[0] if "num_nodes" in signature: return get_vtk_cell_type(cell_type, num_nodes) return get_vtk_cell_type(cell_type, dolfinx.mesh.cell_dim(cell_type)) def resolve_adios_scope(adios2): """Resolve the ADIOS2 Python module scope, across the `adios2.bindings` submodule split.""" scope = adios2.bindings if hasattr(adios2, "bindings") else adios2 if not scope.is_built_with_mpi: raise ImportError("ADIOS2 must be built with MPI support") return scope def pyvista_allow_snake_case(pyvista) -> None: """Allow snake_case VTK attribute access. Across pyvista's `_VTK_SNAKE_CASE_STATE` rename (pyvista 0.47+). """ if hasattr(pyvista, "_VTK_SNAKE_CASE_STATE"): pyvista._VTK_SNAKE_CASE_STATE = "allow" else: pyvista.core.vtk_snake_case._state = "allow" def model_to_mesh( model, comm, rank: int, gdim: int = 3, *, partitioner=None, ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.shared_facet, max_facet_to_cell_links: int = 2, dtype=dolfinx.default_real_type, ) -> dolfinx.io.gmsh.MeshData: """Call `dolfinx.io.gmsh.model_to_mesh` with a given ghost mode. Like dolfinx's function, this returns a `dolfinx.io.gmsh.MeshData`, not a mesh: the mesh is its `.mesh`, next to the cell, facet and other entity tags and the physical groups read from the model. Across the same dolfinx PR #4403 split as `create_mesh`: from there, `model_to_mesh` takes `ghost_mode` and threads it into whichever partitioner is used. On 0.10 and 0.11 the ghost mode can only reach the mesh through a partitioner built with it, so one is built here when `partitioner` is not given. dolfinx 0.10 also has no `max_facet_to_cell_links` parameter on `model_to_mesh`, so there it only takes effect through that partitioner. Dispatches on `inspect.signature`. The default `ghost_mode` is `shared_facet`, as for `create_mesh`, while dolfinx's own default is `none` on every version. If `partitioner` is given explicitly it is always used as-is. On 0.10 and 0.11, `ghost_mode` (and on 0.10 `max_facet_to_cell_links`) then only apply if that partitioner was built with them. """ import dolfinx.io.gmsh params = inspect.signature(dolfinx.io.gmsh.model_to_mesh).parameters kwargs: dict = {} if "ghost_mode" in params: kwargs["ghost_mode"] = ghost_mode elif partitioner is None and comm.size > 1: partitioner = create_cell_partitioner(ghost_mode, max_facet_to_cell_links) if "max_facet_to_cell_links" in params: kwargs["max_facet_to_cell_links"] = max_facet_to_cell_links return dolfinx.io.gmsh.model_to_mesh( model, comm, rank, gdim=gdim, partitioner=partitioner, dtype=dtype, **kwargs ) fenicsx_compat-0.4.0/src/fenicsx_compat/la.py0000644000000000000000000001470113615410400016177 0ustar00from mpi4py import MPI import dolfinx.common import dolfinx.cpp.common import dolfinx.la import numpy as np import numpy.typing as npt def create_index_map( comm, num_local: int, ghosts: npt.NDArray[np.int64], owners: npt.NDArray[np.int32], tag: int = 0, ) -> dolfinx.common.IndexMap: """Build an IndexMap, across the class-constructor-to-factory-function rename. dolfinx PR #4496 (post-0.11) replaced the `dolfinx.common.IndexMap` class constructor with a `dolfinx.common.index_map` factory taking ghosts as a single `(ghosts, owners)` tuple kwarg, and returning the new Python `IndexMap` instead of the cpp one. The `tag` argument (used internally for MPI communication) is caller-specific — pass a value unique across index maps that may communicate concurrently. """ if hasattr(dolfinx.common, "index_map"): return dolfinx.common.index_map(comm, num_local, ghosts=(ghosts, owners), tag=tag) # dolfinx.common.IndexMap's constructor signature is the 0.10/0.11 shape # (positional ghosts/owners, `tag` required); typeshed here # reflects the newer factory-function shape, so this branch is only # reached -- and only type-checked against the wrong signature -- on # dolfinx versions where it is in fact the right call. return dolfinx.common.IndexMap(comm, num_local, ghosts, owners, tag=tag) # type: ignore[call-arg] def unwrap_index_map(index_map) -> dolfinx.cpp.common.IndexMap: """Return the underlying cpp IndexMap, whether given the Python wrapper or the cpp object itself. """ if isinstance(index_map, dolfinx.cpp.common.IndexMap): return index_map return index_map._cpp_object def index_to_dest_ranks(index_map, tag: int) -> tuple[npt.NDArray[np.int32], npt.NDArray[np.int32]]: """The ranks sharing each local index of `index_map`, as `(ranks, offsets)`. Ranks sharing local index `i` are `ranks[offsets[i]:offsets[i + 1]]`: for an owned index, the ranks that ghost it; for a ghost, its owner and the other ranks that ghost it. The calling rank is excluded. Collective. dolfinx 0.10 and 0.11 take an MPI `tag` for the consensus exchange and return an `AdjacencyList`. dolfinx PR #4354 (post-0.11) made it return a `(ranks, offsets)` tuple, and #4573 dropped the tag. `tag` is only used on the older versions; pass a value no other exchange in flight uses. Accepts either the Python wrapper or the cpp `IndexMap`. Dispatches on the `TypeError` raised while binding the arguments, which happens on every rank before any communication, so it cannot deadlock. """ try: dest = index_map.index_to_dest_ranks(tag) except TypeError: return index_map.index_to_dest_ranks() return dest.array, dest.offsets def vector(index_map, bs: int, dtype: npt.DTypeLike = np.float64) -> dolfinx.la.Vector: """Create a distributed vector compatible with an index map and block size. `dtype` must stay keyword — it became keyword-only from dolfinx 0.12 (dolfinx PR #4489). Pass `index_map` as dolfinx returns it: from dolfinx PR #4496 `dolfinx.la.vector` needs the Python `IndexMap`, and a cpp one raises `AttributeError`. Never pass `scatterer` — dolfinx 0.11 has no such parameter. """ return dolfinx.la.vector(index_map, bs, dtype=dtype) def create_sub_index_map( index_map, indices: npt.NDArray[np.int32], *, allow_owner_change: bool = False ) -> tuple[dolfinx.common.IndexMap, npt.NDArray[np.int32]]: """Build an index map for a subset of the local indices of `index_map`. Returns `(sub_map, sub_to_parent)`: the sub-map, and the local index in `index_map` of each of its local indices. `indices` must be sorted and unique, as dolfinx 0.10 and 0.11 require. An index included by a rank that ghosts it but not by its owner has to change owner; unless `allow_owner_change`, that raises `RuntimeError`. Collective. dolfinx 0.10 and 0.11 have this only as `dolfinx.cpp.common.create_sub_index_map(map, indices, allow_owner_change)`, which throws on the owning rank alone. dolfinx PR #4479 (post-0.11) dropped the flag in favour of always allowing the change and returning a rank-local flag saying whether one happened, and #4496 added the Python `dolfinx.common.create_sub_index_map`. There the flag is reduced here before raising, so every rank raises together. Accepts either the Python wrapper or the cpp `IndexMap`; `sub_map` is whichever type the installed dolfinx returns. Dispatches on `hasattr`. """ if not hasattr(dolfinx.common, "create_sub_index_map"): # The stubs describe the post-#4479 binding, which takes no flag and returns a # third value; this branch only runs on the 0.10/0.11 binding. return dolfinx.cpp.common.create_sub_index_map( # type: ignore[call-arg, return-value] unwrap_index_map(index_map), indices, allow_owner_change ) if isinstance(index_map, dolfinx.cpp.common.IndexMap): index_map = dolfinx.common.IndexMap(index_map) sub_map, sub_to_parent, owners_changed = dolfinx.common.create_sub_index_map(index_map, indices) if not allow_owner_change and index_map.comm.allreduce(owners_changed, op=MPI.LOR): raise RuntimeError( "Index owner change detected: an index is included by a rank that ghosts it but " "not by its owner. Pass allow_owner_change=True to let it change owner." ) return sub_map, sub_to_parent def local_range(index: int, N: int, size: int) -> tuple[int, int]: """The half-open range of `[0, N)` held by block `index` of `size` almost-equal blocks. The first `N % size` blocks hold one index more than the rest, the split dolfinx itself uses to distribute indices over ranks. dolfinx 0.10 and 0.11 do not expose the free function `dolfinx.common.local_range` to Python (only the `IndexMap` property of the same name); it is reproduced here where it is missing. Dispatches on `hasattr`. """ if hasattr(dolfinx.common, "local_range"): low, high = dolfinx.common.local_range(index, N, size) return int(low), int(high) return _local_range(index, N, size) def _local_range(index: int, N: int, size: int) -> tuple[int, int]: """`dolfinx::common::local_range`, for dolfinx versions that do not expose it.""" per_block, remainder = divmod(N, size) low = index * per_block + min(index, remainder) return low, low + per_block + (1 if index < remainder else 0) fenicsx_compat-0.4.0/src/fenicsx_compat/mesh.py0000644000000000000000000003323013615410400016535 0ustar00import inspect from collections.abc import Callable from mpi4py import MPI import basix.ufl import dolfinx import dolfinx.fem import dolfinx.graph import numpy as np import numpy.typing as npt import ufl def cmap(mesh: dolfinx.mesh.Mesh) -> dolfinx.fem.CoordinateElement: """Get the coordinate element for a mesh's geometry. dolfinx 0.10 has only the `geometry.cmap` property. From 0.11, `geometry.cmaps` exists and `cmap` emits a deprecation warning, so prefer `cmaps[0]` where it exists. Dispatches on `hasattr`. """ if hasattr(mesh.geometry, "cmaps"): return mesh.geometry.cmaps[0] return mesh.geometry.cmap def dofmap(mesh: dolfinx.mesh.Mesh) -> npt.NDArray[np.int32]: """Get the geometry dofmap for a mesh, across the same `dofmaps` split as `cmap`.""" if hasattr(mesh.geometry, "dofmaps"): return mesh.geometry.dofmaps[0] return mesh.geometry.dofmap def form_map(form: dolfinx.fem.Form) -> tuple[dolfinx.common.IndexMap, int]: """Get the index map and block size for a linear form's test space. `FunctionSpace.dofmaps` is a method on dolfinx 0.10 and a sequence from 0.11. Dispatches on the `TypeError` raised by calling it. For a mesh with a single cell type, `form.function_spaces[0].dofmap` gives the same on every version without this function. """ try: # dofmaps() is a callable method pre-dolfinx-0.11; on this install # dofmaps is a subscriptable sequence, so calling it is expected to # raise TypeError and fall through to the except branch below. return ( form.function_spaces[0].dofmaps(0).index_map, # type: ignore[operator] form.function_spaces[0].dofmaps(0).index_map_bs, # type: ignore[operator] ) except TypeError: return ( form.function_spaces[0].dofmaps[0].index_map, form.function_spaces[0].dofmaps[0].index_map_bs, ) def meshtags_topology(tags: dolfinx.mesh.MeshTags) -> dolfinx.mesh.Topology: """Get the topology a MeshTags is defined on, as the Python `dolfinx.mesh.Topology`. dolfinx 0.10 and 0.11 return the cpp topology from `MeshTags.topology`; dolfinx PR #4350 (post-0.11) returns the Python wrapper. The cpp object is the wrapper's `_cpp_object` on every version. Dispatches on `isinstance`. """ topology = tags.topology if isinstance(topology, dolfinx.cpp.mesh.Topology): return dolfinx.mesh.Topology(topology) return topology def cell_permutation_info(topology: dolfinx.mesh.Topology) -> npt.NDArray[np.uint32]: """Compute and return the packed per-cell permutation info, ghost cells included. dolfinx 0.10 and 0.11 compute it, together with the facet permutations, in a single argument-free `topology.create_entity_permutations()`. dolfinx PR #3904 (post-0.11) split that into `create_cell_permutations()` and a per-dimension `create_entity_permutations(dim)`. Dispatches on `hasattr`. """ if hasattr(topology, "create_cell_permutations"): topology.create_cell_permutations() else: topology.create_entity_permutations() # type: ignore[call-arg] return topology.get_cell_permutation_info() def facet_permutations(topology: dolfinx.mesh.Topology) -> npt.NDArray[np.uint8]: """Compute and return the permutation of every facet of every cell. Shape `(num_cells, num_facets_per_cell)`, ghost cells included. Each value encodes the facet's orientation as seen from the cell, as used for FFCx's `quadrature_permutation`. Across the same dolfinx PR #3904 split as `cell_permutation_info`: 0.10 and 0.11 expose these via `get_facet_permutations()` after an argument-free `create_entity_permutations()`; from #3904 they are `get_entity_permutations(tdim - 1)` after `create_entity_permutations(tdim - 1)`. Both return a flat array. """ fdim = topology.dim - 1 if hasattr(topology, "create_cell_permutations"): topology.create_entity_permutations(fdim) perms = topology.get_entity_permutations(fdim) else: topology.create_entity_permutations() # type: ignore[call-arg] perms = topology.get_facet_permutations() # type: ignore[attr-defined] num_facets_per_cell = dolfinx.cpp.mesh.cell_num_entities(topology.cell_type, fdim) return np.asarray(perms).reshape(-1, num_facets_per_cell) def create_facet_orientations(topology: dolfinx.mesh.Topology) -> None: """Compute the facet orientations that contravariant Piola spaces on a manifold need. dolfinx PR #4625 (post-0.11) refuses to create a Raviart-Thomas or other contravariant Piola space on a manifold until `topology.create_facet_orientations()` has been called, and that method does not exist on 0.10 and 0.11, which create such spaces without it. Calls it where it exists and does nothing otherwise. Dispatches on `hasattr`. Call it before creating the space, on every rank: it is collective, and changing the orientations afterwards invalidates functions on existing spaces. Where it exists it raises `ValueError` unless the topological dimension is 2, as the native method does. """ if hasattr(topology, "create_facet_orientations"): topology.create_facet_orientations() def create_cell_partitioner( ghost_mode: dolfinx.mesh.GhostMode, max_facet_to_cell_links: int = 2, ) -> Callable: """Build a cell partitioner with ghost mode baked in, for dolfinx 0.10 and 0.11. Not needed (and not called) on dolfinx generations where `create_cell_partitioner` was removed (post dolfinx PR #4403) — `create_mesh` takes `ghost_mode` directly on those. Raises `NotImplementedError` if called anyway, so a caller doesn't silently get an un-ghosted mesh. """ if not hasattr(dolfinx.mesh, "create_cell_partitioner"): raise NotImplementedError( "dolfinx.mesh.create_cell_partitioner was removed in this dolfinx version " "(see dolfinx PR #4403); pass ghost_mode directly to " "fenicsx_compat.mesh.create_mesh() instead of calling create_cell_partitioner()." ) return dolfinx.mesh.create_cell_partitioner(ghost_mode, max_facet_to_cell_links) def create_mesh( comm: MPI.Comm, cells: npt.NDArray[np.int64], e, x: npt.NDArray[np.floating], *, partitioner: Callable | None = None, ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.shared_facet, max_facet_to_cell_links: int = 2, num_threads: int = 1, ) -> dolfinx.mesh.Mesh: """Create a mesh from topology and geometry arrays, with a given ghost mode. Post dolfinx PR #4403 (post-0.11), `ghost_mode` and `num_threads` are `create_mesh`'s own kwargs and `create_cell_partitioner` is gone. On 0.10 and 0.11 the ghost mode must be baked into the partitioner via `create_cell_partitioner`; `num_threads` is ignored there. Dispatches on `inspect.signature`. If `partitioner` is given explicitly it is always used as-is (never silently replaced); `ghost_mode` is still passed to `create_mesh` on generations that take it. The default `ghost_mode` is `shared_facet`, while dolfinx's own default is `none` on every version. """ kwargs: dict = {} if "ghost_mode" in inspect.signature(dolfinx.mesh.create_mesh).parameters: kwargs = {"ghost_mode": ghost_mode, "num_threads": num_threads} if partitioner is None and comm.size > 1: partitioner = dolfinx.graph.partitioner() elif partitioner is None and comm.size > 1: partitioner = create_cell_partitioner(ghost_mode, max_facet_to_cell_links) return dolfinx.mesh.create_mesh( comm, cells, e, x, partitioner=partitioner, max_facet_to_cell_links=max_facet_to_cell_links, **kwargs, ) def reconstruct_mesh(mesh: dolfinx.mesh.Mesh, coordinate_element_degree: int) -> dolfinx.mesh.Mesh: """Copy a mesh, changing its coordinate element degree. The topology is shared with the original mesh; the geometry is reconstructed. Delegates to the native `dolfinx.fem.interpolate_geometry` from dolfinx>=0.11; for 0.10, reconstructs the geometry manually (no native equivalent exists at that version). """ if hasattr(dolfinx.fem, "interpolate_geometry"): new_cmap = dolfinx.fem.coordinate_element( mesh.topology.cell_type, coordinate_element_degree, dtype=mesh.geometry.x.dtype, variant=cmap(mesh).variant, ) return dolfinx.fem.interpolate_geometry(mesh, new_cmap) ud = mesh.ufl_domain() assert ud is not None c_el = ud.ufl_coordinate_element() family = c_el.family_name lvar = c_el.lagrange_variant ct = c_el.cell_type new_c_el = basix.ufl.element( family, ct, coordinate_element_degree, shape=(mesh.geometry.dim,), lagrange_variant=lvar, dtype=mesh.geometry.x.dtype, ) V_tmp = dolfinx.fem.functionspace(mesh, new_c_el) gdim = mesh.geometry.dim x = V_tmp.tabulate_dof_coordinates()[:, :gdim] geom_imap = V_tmp.dofmap.index_map geom_dofmap = V_tmp.dofmap.list num_nodes_local = geom_imap.size_local + geom_imap.num_ghosts original_input_indices = geom_imap.local_to_global(np.arange(num_nodes_local, dtype=np.int32)) coordinate_element = dolfinx.fem.coordinate_element( mesh.topology.cell_type, coordinate_element_degree, lvar, dtype=mesh.geometry.x.dtype ) # This package is deliberately generic over the mesh's float32/float64 # scalar type, while dolfinx's Geometry/Mesh/CoordinateElement cpp # bindings are separate per-scalar-type overloads (Geometry_float32 vs. # Geometry_float64, etc.) -- mypy cannot narrow which concrete overload # applies here, since both are valid depending on the input mesh's dtype. geom = dolfinx.mesh.Geometry( type(mesh.geometry._cpp_object)( geom_imap, # type: ignore[arg-type] geom_dofmap, coordinate_element._cpp_object, # type: ignore[arg-type] x, original_input_indices, ) ) new_top = mesh.topology cpp_mesh = type(mesh._cpp_object)( mesh.comm, new_top._cpp_object, geom._cpp_object, # type: ignore[arg-type] ) return dolfinx.mesh.Mesh(cpp_mesh, ufl.Mesh(new_c_el)) def submesh_to_parent( entity_map, entities: npt.NDArray[np.int32] | None = None ) -> npt.NDArray[np.int32]: """The parent entity of each of `entities` of a submesh. `entity_map` is one of the maps `dolfinx.mesh.create_submesh` returns (the entity map or the vertex map). dolfinx 0.10 returns these as arrays holding the parent entity of each submesh entity; dolfinx PR #4140 (0.11) made them `dolfinx.mesh.EntityMap` objects, which map with `sub_topology_to_topology`. If `entities` is not given, all local (owned and ghost) entities of the submesh are mapped. Dispatches on `isinstance`. """ if isinstance(entity_map, np.ndarray): parent = entity_map return parent if entities is None else parent[entities] if entities is None: imap = entity_map.sub_topology.index_map(entity_map.dim) entities = np.arange(imap.size_local + imap.num_ghosts, dtype=np.int32) return np.asarray(entity_map.sub_topology_to_topology(entities, inverse=False), dtype=np.int32) def transfer_meshtags_to_submesh( entity_tag: dolfinx.mesh.MeshTags, submesh: dolfinx.mesh.Mesh, cell_to_parent, vertex_to_parent, ) -> dolfinx.mesh.MeshTags: """Transfer mesh tags from a parent mesh to a submesh. Native since dolfinx 0.11.0 (dolfinx PR #4172, merged 2026-04-23, confirmed absent at the v0.10.0 tag). Raises NotImplementedError on 0.10 — scifem's own pre-0.11 fallback calls a compiled `_scifem` nanobind extension, and fenicsx-compat ships no compiled extensions (see fenicsx-compat design spec §2, §9 item 1). IMPORTANT: native dolfinx's own positional argument order for `cell_to_parent`/`vertex_to_parent` is NOT stable across the versions this wrapper supports. v0.11.0's signature is `(entity_tag, submesh, vertex_to_parent, cell_to_parent)`; nightly (0.12.0.dev0) swapped the two to `(entity_tag, submesh, cell_to_parent, vertex_to_parent)`. The parameter *names* are identical across the swap, so this wrapper forwards to native by keyword, never positionally — that is correct on both versions regardless of which one is installed. Forwarding positionally in nightly's order (as an earlier revision of this function did) is silently wrong on 0.11: it hands native the cell map where it expects the vertex map and vice versa, which does not raise a Python exception but corrupts internal state and crashes the process with SIGTERM (observed on this project's stable/0.11 CI leg). Do not "simplify" this back to a positional call. This wrapper's own declared parameter order, `(entity_tag, submesh, cell_to_parent, vertex_to_parent)`, is fixed and does not change with the installed dolfinx version — chosen to match nightly's order and `create_submesh`'s `(entity/cell map, vertex map)` return order, so callers can unpack `create_submesh`'s output and forward it directly. """ if not hasattr(dolfinx.mesh, "transfer_meshtags_to_submesh"): raise NotImplementedError( "transfer_meshtags_to_submesh requires dolfinx>=0.11.0. On dolfinx 0.10, " "use scifem.transfer_meshtags_to_submesh() instead, which provides a " "compiled fallback." ) return dolfinx.mesh.transfer_meshtags_to_submesh( entity_tag, submesh, cell_to_parent=cell_to_parent, vertex_to_parent=vertex_to_parent, ) fenicsx_compat-0.4.0/src/fenicsx_compat/optional.py0000644000000000000000000000063313615410400017427 0ustar00import importlib from types import ModuleType def require_module(name: str, *, extra: str | None = None) -> ModuleType: """Import `name`, raising a clear, actionable ImportError if it's missing.""" try: return importlib.import_module(name) except ImportError as e: raise ImportError( f"{name} is required for this feature: pip install {extra or name}" ) from e fenicsx_compat-0.4.0/src/fenicsx_compat/petsc.py0000644000000000000000000001550413615410400016723 0ustar00from __future__ import annotations from collections.abc import Sequence from typing import TYPE_CHECKING import dolfinx import dolfinx.fem if TYPE_CHECKING: # Gives the `else:` branch below (petsc4py absent) the same `PETSc.Vec` # annotations as the `if` branch, so mypy sees identical conditional # function signatures. Postponed evaluation (`from __future__ import # annotations`, above) means these annotations are never evaluated at # runtime, so this import being TYPE_CHECKING-only (never executed) does # not affect the `else:` branch's runtime behaviour. from petsc4py import PETSc if dolfinx.has_petsc4py: from petsc4py import PETSc # dolfinx.la.petsc's vector helpers are private API, wrapped here so a # rename upstream only needs fixing in one place. import dolfinx.la.petsc def zero_petsc_vector(b: PETSc.Vec) -> None: """Zero a PETSc vector, including ghosts.""" dolfinx.la.petsc._zero_vector(b) def ghost_update(x: PETSc.Vec, insert_mode, scatter_mode) -> None: """Ghost-update a PETSc vector.""" dolfinx.la.petsc._ghost_update(x, insert_mode, scatter_mode) import dolfinx.fem.petsc def set_bc(b: PETSc.Vec, bcs, x0=None, alpha: float = 1.0) -> None: """Set boundary condition values in a vector, across the nested-vs-single-form `set_bc` API.""" try: dolfinx.fem.petsc.set_bc(b, bcs, x0=x0, alpha=alpha) except AttributeError: for _bcs in bcs: dolfinx.fem.petsc.set_bc(b, _bcs, x0=x0, alpha=alpha) except TypeError: # bcs shouldn't be a nested list here (e.g. for a single form). assert len(bcs) == 1, "bcs should be a single DirichletBC or a list of DirichletBCs." dolfinx.fem.petsc.set_bc(b, bcs[0], x0=x0, alpha=alpha) def apply_lifting_and_set_bc( b: PETSc.Vec, a, bcs, x: PETSc.Vec | None = None, alpha: float = 1.0, ) -> None: r"""Apply lifting to a vector and set boundary conditions. Convenience function to apply lifting and set boundary conditions for single, blocked, or nested forms. Modifies `b` such that its free rows become `b - alpha * A (g - x)` and its constrained rows `alpha * (g - x)`, with `g` the boundary values and `A` the operator of `a`. Args: b: The vector to apply lifting to. a: Form or nested sequence of forms to apply lifting from. bcs: The boundary conditions to apply. x: Vector to subtract from the boundary conditions (e.g. in a Newton iteration). alpha: Scaling factor for the boundary conditions. """ # Ruling F6 (revised): use this module's own bcs_by_block (Task 21) # instead of scifem's `dolfinx.fem.bcs_by_block`, which on failure # hands every bc to every space unfiltered -- silently wrong for a # blocked/nested system. That much of scifem's try/except was a real # bug and stays deleted. # # But `dolfinx.fem.extract_function_spaces` also refuses an explicit # index for a bare single form (`ValueError: index must be None for # a single form`, confirmed), and for that case `bcs0 = bcs1 = bcs` # is not a workaround, it is the correct answer: a single form has # exactly one block, so every bc in `bcs` belongs to it. The # docstring above promises "single, blocked, or nested forms", so # single-form support is part of this function's contract and must # stay -- but selected by an explicit, positive check on `a`'s shape # (never by catching whatever exception extract_function_spaces # happens to raise, which could silently swallow an unrelated bug). if isinstance(a, (list, tuple)): # Blocked/nested: `a` is a 2D array of forms, one row per block. bcs0 = bcs_by_block(dolfinx.fem.extract_function_spaces(a, 0), bcs) bcs1 = bcs_by_block(dolfinx.fem.extract_function_spaces(a, 1), bcs) a_lifting = a x_lifting: PETSc.Vec | list[PETSc.Vec] | None = x else: # Single form: one block, so every bc applies to it. `set_bc` # (below) accepts `bcs` in its bare/flat shape directly, but # `dolfinx.fem.petsc.apply_lifting` always wants `a` and `bcs` one # level more nested than that for a plain (non-blocked, non-nest) # vector -- confirmed: a bare `a` raises `TypeError: 'Form' # object is not iterable`, and a flat `bcs` raises `TypeError: # 'DirichletBC' object is not iterable` inside apply_lifting. # The same goes for `x`, which apply_lifting iterates over: a # bare Vec raises `AttributeError` there, on every version. bcs0 = bcs bcs1 = [bcs] a_lifting = [a] x_lifting = None if x is None else [x] dolfinx.fem.petsc.apply_lifting(b, a_lifting, bcs=bcs1, x0=x_lifting, alpha=alpha) # type: ignore[arg-type] ghost_update(b, PETSc.InsertMode.ADD_VALUES, PETSc.ScatterMode.REVERSE) set_bc(b, bcs0, x0=x, alpha=alpha) ghost_update(b, PETSc.InsertMode.INSERT_VALUES, PETSc.ScatterMode.FORWARD) else: def zero_petsc_vector(b: PETSc.Vec) -> None: raise RuntimeError("petsc4py is not available. Cannot zero vector.") def ghost_update(x: PETSc.Vec, insert_mode, scatter_mode) -> None: raise RuntimeError("petsc4py is not available. Cannot ghost update vector.") def set_bc(b: PETSc.Vec, bcs, x0=None, alpha: float = 1.0) -> None: raise RuntimeError("petsc4py is not available. Cannot set boundary conditions.") def apply_lifting_and_set_bc( b: PETSc.Vec, a, bcs, x: PETSc.Vec | None = None, alpha: float = 1.0 ) -> None: raise RuntimeError( "petsc4py is not available. Cannot apply lifting and set boundary conditions." ) def bcs_by_block( spaces: Sequence[dolfinx.fem.FunctionSpace | None], bcs: Sequence[dolfinx.fem.DirichletBC], ) -> list[list[dolfinx.fem.DirichletBC]]: """Arrange boundary conditions by the space they constrain, across dolfinx 0.11/0.12. `dolfinx.fem.bcs_by_block` only works when the spaces and `bc.function_space` are the same flavour: both cpp objects on 0.10 and 0.11, both Python wrappers from dolfinx PRs #4342 and #4350. That holds for spaces from `dolfinx.fem.extract_function_spaces`, but not for spaces the caller builds a list of. Normalizing both sides to cpp objects and calling the cpp `contains` directly accepts either flavour on every version. """ def _cpp(space): return getattr(space, "_cpp_object", space) return [ [bc for bc in bcs if _cpp(V).contains(_cpp(bc.function_space))] if V is not None else [] for V in spaces ] fenicsx_compat-0.4.0/src/fenicsx_compat/py.typed0000644000000000000000000000000013615410400016713 0ustar00fenicsx_compat-0.4.0/src/fenicsx_compat/ufl.py0000644000000000000000000000403113615410400016364 0ustar00import ufl def domain_of(expr): """Get the single UFL domain an expression is defined on. `ufl.domain.extract_domains` is available across the whole 0.10+ floor; the `expr.ufl_domain()` fallback found duplicated across the source repos (some with a fallback, some without — see spec §5.6) is below-floor legacy and is not ported. """ domains = ufl.domain.extract_domains(expr) if len(domains) != 1: raise ValueError(f"Expected exactly one domain, got {len(domains)}") return domains[0] def _inverse_identity(expr, domain): return expr def _inverse_contravariant_piola(expr, domain): """`u = J u_ref / det J`, inverted: `u_ref = det J K u`.""" return ufl.JacobianDeterminant(domain) * ufl.dot(ufl.JacobianInverse(domain), expr) def _inverse_covariant_piola(expr, domain): """`u = K^T u_ref`, inverted: `u_ref = J^T u`.""" return ufl.dot(ufl.transpose(ufl.Jacobian(domain)), expr) _INVERSE_PULLBACKS = { "IdentityPullback": _inverse_identity, "ContravariantPiola": _inverse_contravariant_piola, "CovariantPiola": _inverse_covariant_piola, } def apply_pullback_inverse(pullback, expr, domain): """Map `expr` from the physical cell to the reference cell. Uses `pullback.apply_inverse`, added in FEniCS/ufl PR #511 (first released in UFL 2026.2.0), when the installed UFL has it. The UFL releases dolfinx 0.10 and 0.11 pin (2025.2, 2026.1) lack it, so a closed form is used for the identity, contravariant Piola and covariant Piola pullbacks; any other pullback raises `NotImplementedError` there. Dispatches on `hasattr`. """ if hasattr(pullback, "apply_inverse"): return pullback.apply_inverse(expr, domain) name = type(pullback).__name__ try: return _INVERSE_PULLBACKS[name](expr, domain) except KeyError: raise NotImplementedError( f"This UFL has no {name}.apply_inverse (added in FEniCS/ufl PR #511), and " "fenicsx-compat has no closed form for it. Upgrade UFL." ) from None fenicsx_compat-0.4.0/tests/conftest.py0000644000000000000000000000013613615410400014776 0ustar00from mpi4py import MPI import pytest @pytest.fixture def comm(): return MPI.COMM_WORLD fenicsx_compat-0.4.0/tests/test_docs.py0000644000000000000000000000156313615410400015145 0ustar00import re from pathlib import Path import fenicsx_compat GUIDE = Path(__file__).resolve().parents[1] / "docs" / "guide" def _documented_names() -> set[str]: """Names with an entry in the function guide, i.e. a heading of the form ### `name`.""" names: set[str] = set() for page in GUIDE.glob("*.md"): names.update(re.findall(r"^#{2,4} `(\w+)`", page.read_text(), flags=re.MULTILINE)) return names def test_every_public_function_has_a_guide_entry(): public = set(fenicsx_compat.__all__) - {"__version__"} missing = sorted(public - _documented_names()) assert not missing, f"No entry in docs/guide for: {missing}" def test_the_guide_documents_only_public_functions(): unknown = sorted(_documented_names() - set(fenicsx_compat.__all__)) assert not unknown, f"docs/guide has entries for names not in fenicsx_compat.__all__: {unknown}" fenicsx_compat-0.4.0/tests/test_dolfinx_version.py0000644000000000000000000000115513615410400017422 0ustar00from packaging.version import Version from fenicsx_compat._dolfinx_version import DOLFINX_VERSION, at_least, before def test_dolfinx_version_is_at_least_010(): assert DOLFINX_VERSION >= Version("0.10.0") def test_at_least_true_for_lower_bound(): assert at_least("0.1.0") is True def test_at_least_false_for_impossible_future_version(): assert at_least("999.0.0") is False def test_before_is_the_inverse_of_at_least(): assert before("999.0.0") is True assert before("0.1.0") is False for v in ("0.1.0", "0.10.0", "0.11.0", "0.12.0", "999.0.0"): assert before(v) is not at_least(v) fenicsx_compat-0.4.0/tests/test_fem.py0000644000000000000000000003012413615410400014757 0ustar00from mpi4py import MPI import basix.ufl import dolfinx.cpp.fem import dolfinx.fem import dolfinx.mesh import numpy as np import pytest import ufl from fenicsx_compat._dolfinx_version import at_least, before from fenicsx_compat.fem import ( dirichletbc_init_kwargs, expression_eval, finite_element_ctor_kwargs, interpolate, interpolate_to_submesh_entity_maps, permute_facet_quadrature, permute_interpolation_data, real_functionspace, ) from fenicsx_compat.mesh import cell_permutation_info # basix.ufl.real_element exists on every supported dolfinx generation # (confirmed by the v0.10.0 CI leg's traceback), so hasattr cannot tell the # versions apart. But at v0.10.0 the element it returns is a stub whose # basix_element property raises a bare NotImplementedError, which # dolfinx.fem.functionspace cannot consume - confirmed by that same # traceback. From dolfinx 0.11 it works (confirmed locally on nightly, # 0.12.0.dev0). Gate on the version boundary, not on hasattr. HAS_WORKING_REAL_ELEMENT = at_least("0.11.0") def test_real_functionspace_scalar(comm): msh = dolfinx.mesh.create_unit_square(comm, 2, 2) if HAS_WORKING_REAL_ELEMENT: V = real_functionspace(msh) assert V.dofmap.index_map.size_global == 1 else: with pytest.raises(NotImplementedError, match="0.11"): real_functionspace(msh) def test_real_functionspace_vector_valued(comm): msh = dolfinx.mesh.create_unit_square(comm, 2, 2) if HAS_WORKING_REAL_ELEMENT: V = real_functionspace(msh, value_shape=(2,)) assert V.dofmap.index_map.size_global * V.dofmap.index_map_bs == 2 else: with pytest.raises(NotImplementedError, match="0.11"): real_functionspace(msh, value_shape=(2,)) # Only `value_shape` and `space_dimension` exist on the cpp FiniteElement in every # supported generation; `value_size` is new in nightly. @pytest.mark.parametrize("value_shape, shape, space_dimension", [(None, (), 3), ((2,), (2,), 6)]) def test_finite_element_ctor_kwargs_blocks_the_element(value_shape, shape, space_dimension): ufl_el = basix.ufl.element("Lagrange", "triangle", 1) cpp_el = finite_element_ctor_kwargs( dolfinx.cpp.fem.FiniteElement_float64, ufl_el.basix_element._e, gdim=2, value_shape=value_shape, ) assert tuple(cpp_el.value_shape) == shape assert cpp_el.space_dimension == space_dimension def test_interpolate_sets_function_values(comm): msh = dolfinx.mesh.create_unit_square(comm, 2, 2) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) u = dolfinx.fem.Function(V) num_cells = msh.topology.index_map(msh.topology.dim).size_local cells = np.arange(num_cells, dtype=np.int32) # interpolate_f/interpolate take f shaped (value_size, num_cells * points_per_cell): # one column per interpolation point, per cell in `cells`, in that order. num_points_per_cell = V.element.interpolation_points.shape[0] values = np.full((1, num_cells * num_points_per_cell), 3.0) interpolate(u._cpp_object, values, cells) u.x.scatter_forward() assert np.allclose(u.x.array, 3.0) def test_expression_eval_matches_direct_eval_call(comm): msh = dolfinx.mesh.create_unit_square(comm, 2, 2) n = ufl.FacetNormal(msh) tdim = msh.topology.dim msh.topology.create_connectivity(tdim - 1, tdim) facet_indices = dolfinx.mesh.locate_entities_boundary( msh, tdim - 1, lambda x: np.isclose(x[0], 0.0) ) msh.topology.create_connectivity(tdim, tdim - 1) f_to_c = msh.topology.connectivity(tdim - 1, tdim) c_to_f = msh.topology.connectivity(tdim, tdim - 1) # On multiple ranks, a rank may own no facets on the x=0 boundary at all # (controller ruling F4, revised). The skip decision must be COLLECTIVE: # dolfinx.fem.Expression construction below calls jit.ffcx_jit(comm, ...), # which is collective on the mesh's communicator, so a per-rank skip would # let ranks with no boundary facets bail out early while the other ranks # block forever inside the collective JIT call, deadlocking the run. # Using MPI.LAND (not LOR) ensures every rank takes the same branch: if # any rank lacks a facet, all ranks skip together. if not comm.allreduce(len(facet_indices) > 0, op=MPI.LAND): pytest.skip("not every rank owns an x=0 boundary facet") facet = facet_indices[0] cell = f_to_c.links(facet)[0] local_facet = np.nonzero(c_to_f.links(cell) == facet)[0][0] # Points must be on the *facet* reference element (dimension tdim - 1), not # the cell's: with tdim-wide points, ffcx resolves entity_type="cell" and # rejects FacetNormal (RuntimeError) before expression_eval ever runs. # See scifem/src/scifem/bcs.py, which pulls interpolation points back onto # the facet reference element for exactly this reason. Deviation from the # brief's `np.zeros((1, tdim))`, which errors unconditionally. points = np.zeros((1, tdim - 1)) expr = dolfinx.fem.Expression(n, points) entity = np.array([[cell, local_facet]], dtype=np.int32) result = expression_eval(expr, msh, entity) assert result.shape[0] == 1 # These assertions hold on both sides of the dolfinx PR #4140 boundary: # pre-0.11 the tables are identity copies, post-0.11 they are real # permutations, but the count and shape are the same either way. The CI # matrix, not a patched version number, is what runs both branches. # # Beyond count-and-shape, we also assert on the actual values: from 0.11 the # returned tables must be pairwise distinct, which catches a regression that # makes two table entries collide (e.g. a formula that maps two different # (reflection, rotation) combinations onto the same permutation). Pre-0.11, # dolfinx applies the permutation internally, so the tables here are identity # copies of `points` instead - asserting *that* is what the pre-0.11 leg # guarantees. Distinctness would be false there (all rows equal `points`), so # it must be gated on the installed dolfinx side, not asserted unconditionally. # # KNOWN GAP: this only checks the SET of returned tables, not their ORDER. # Confirmed by direct experiment: patching the triangle branch's # `rot_inv = (3 - rot) % 3 if ref == 0 else rot` to the broken `rot_inv = rot` # still passes every assertion here, because both formulas are bijections # from the same six (rot, ref) loop iterations onto the same six # (reflection, rotation) argument pairs - the broken formula just pairs them # up differently, so the six output tables come back as the same set in a # different order. The `rot_inv` arithmetic is exactly what decides which # permutation lands at which index (the index dolfinx looks up via # `cell_permutation_info`), so a reordering bug here is real and silently # uncaught by this suite. Catching it would require a position-sensitive # oracle - e.g. re-deriving dolfinx/ffcx's cell-permutation-index convention # independently in the test - which would just restate the implementation # and pass for any consistent-but-wrong pairing; not attempted. An end-to-end # facet-integration test (not a table-shape one) would be the faithful way to # close this gap. def _assert_facet_quadrature_permutations(perms, points): if before("0.11.0.dev0"): for p in perms: assert np.array_equal(p, points) else: for i in range(len(perms)): for j in range(i + 1, len(perms)): assert not np.array_equal(perms[i], perms[j]), f"perms {i} and {j} are identical" def test_permute_facet_quadrature_interval_returns_two_permutations(): # 0.25 != 0.75, and interval reflection maps x -> 1 - x, so the two rows # are not symmetric under the reflection: this is not an accidental tie. points = np.array([[0.25], [0.75]]) perms = permute_facet_quadrature(basix.CellType.interval, points) assert len(perms) == 2 for p in perms: assert p.shape == points.shape _assert_facet_quadrature_permutations(perms, points) def test_permute_facet_quadrature_triangle_returns_six_permutations(): # Coordinate-asymmetric points: neither row is a fixed point of any # triangle-facet reflection/rotation (e.g. a point like [0.3, 0.7] whose # coordinates are complementary would collapse distinct permutations onto # each other - that is an artifact of the test point, not of the code). points = np.array([[0.1, 0.9], [0.2, 0.6]]) perms = permute_facet_quadrature(basix.CellType.triangle, points) assert len(perms) == 6 for p in perms: assert p.shape == points.shape _assert_facet_quadrature_permutations(perms, points) def test_permute_facet_quadrature_quadrilateral_returns_eight_permutations(): # Same asymmetric points as the triangle case, reused here since they are # equally non-degenerate under quadrilateral-facet reflections/rotations. points = np.array([[0.1, 0.9], [0.2, 0.6]]) perms = permute_facet_quadrature(basix.CellType.quadrilateral, points) assert len(perms) == 8 for p in perms: assert p.shape == points.shape _assert_facet_quadrature_permutations(perms, points) def test_permute_facet_quadrature_rejects_unsupported_cell_type(): points = np.array([[0.25], [0.75]]) with pytest.raises(ValueError, match="Unsupported"): permute_facet_quadrature(basix.CellType.tetrahedron, points) def test_permute_interpolation_data_permutes_within_each_row(comm): # Pre-0.11 this reorders each row; from 0.11 it is a no-op. Either way # each row must still hold the same multiset of values, which is what # this asserts, so the same test is meaningful on every CI leg. msh = dolfinx.mesh.create_unit_square(comm, 2, 2) V = dolfinx.fem.functionspace(msh, ("Lagrange", 2)) cell_info = cell_permutation_info(msh.topology) data = np.arange(6, dtype=np.float64).reshape(2, 3) original_rows = [sorted(row) for row in data] permute_interpolation_data( data, integration_entities=np.array([[0, 0], [1, 0]], dtype=np.int32), cell_permutation_info=cell_info, basix_element=V.element.basix_element, facet_type=basix.CellType.interval, ) assert data.shape == (2, 3) assert [sorted(row) for row in data] == original_rows def test_interpolate_to_submesh_entity_maps_builds_expression(comm): msh = dolfinx.mesh.create_unit_square(comm, 2, 2) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) u = dolfinx.fem.Function(V) points = V.element.interpolation_points expr = interpolate_to_submesh_entity_maps(u, points) assert isinstance(expr, dolfinx.fem.Expression) def _bc_on_V_with_value_on_W(comm): """A cpp bc whose value lives on W, a second space with the same layout as the constrained space V. Keeping the two apart catches a helper that takes the constrained space from the value instead of from V.""" msh = dolfinx.mesh.create_unit_square(comm, 4, 4) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) W = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) tdim = msh.topology.dim msh.topology.create_connectivity(tdim - 1, tdim) facets = dolfinx.mesh.exterior_facet_indices(msh.topology) dofs = dolfinx.fem.locate_dofs_topological(V, tdim - 1, facets) g = dolfinx.fem.Function(W) g.x.array[:] = 3.0 cpp_class = getattr(dolfinx.cpp.fem, f"DirichletBC_{np.dtype(g.x.array.dtype).name}") return V, g, dofs, cpp_class(g._cpp_object, dofs) def test_dirichletbc_init_kwargs_builds_a_working_bc(comm): V, g, dofs, cpp_bc = _bc_on_V_with_value_on_W(comm) bc = dolfinx.fem.DirichletBC(cpp_bc, **dirichletbc_init_kwargs(V, g)) x = np.zeros_like(g.x.array) bc.set(x) assert np.allclose(x[dofs], 3.0) assert np.count_nonzero(x) == len(dofs) def test_dirichletbc_init_kwargs_keeps_V_and_g_on_the_wrapper(comm): V, g, dofs, cpp_bc = _bc_on_V_with_value_on_W(comm) kwargs = dirichletbc_init_kwargs(V, g) if at_least("0.12.0.dev0"): # From dolfinx PR #4342 the wrapper holds the Python space and value it is given. bc = dolfinx.fem.DirichletBC(cpp_bc, **kwargs) assert bc.function_space is V assert bc.g is g else: # 0.10 and 0.11 read both back from the cpp object and take no keywords. assert kwargs == {} fenicsx_compat-0.4.0/tests/test_init.py0000644000000000000000000000246013615410400015155 0ustar00import fenicsx_compat def test_all_public_functions_are_re_exported_at_top_level(): expected = [ "at_least", "before", "require_module", "cmap", "dofmap", "form_map", "cell_permutation_info", "facet_permutations", "create_cell_partitioner", "create_facet_orientations", "create_mesh", "reconstruct_mesh", "transfer_meshtags_to_submesh", "submesh_to_parent", "meshtags_topology", "create_index_map", "create_sub_index_map", "local_range", "unwrap_index_map", "index_to_dest_ranks", "vector", "real_functionspace", "finite_element_ctor_kwargs", "interpolate", "expression_eval", "permute_facet_quadrature", "permute_interpolation_data", "interpolate_to_submesh_entity_maps", "bcs_by_block", "zero_petsc_vector", "ghost_update", "set_bc", "apply_lifting_and_set_bc", "domain_of", "apply_pullback_inverse", "resolve_adios_scope", "model_to_mesh", "pyvista_allow_snake_case", "vtk_cell_type", ] for name in expected: assert hasattr(fenicsx_compat, name), f"fenicsx_compat.{name} is not re-exported" fenicsx_compat-0.4.0/tests/test_io.py0000644000000000000000000001212213615410400014615 0ustar00from mpi4py import MPI import dolfinx import dolfinx.graph import dolfinx.mesh import pytest from fenicsx_compat.io import ( model_to_mesh, pyvista_allow_snake_case, resolve_adios_scope, vtk_cell_type, ) from fenicsx_compat.mesh import create_cell_partitioner, dofmap # gmsh is optional: the model_to_mesh tests run where it is installed and skip elsewhere. try: import gmsh except ImportError: gmsh = None # type: ignore[assignment] requires_gmsh = pytest.mark.skipif(gmsh is None, reason="gmsh is not installed") def test_resolve_adios_scope_returns_the_mpi_built_scope(): adios2 = pytest.importorskip("adios2") scope = resolve_adios_scope(adios2) assert scope.is_built_with_mpi def test_resolve_adios_scope_raises_when_not_built_with_mpi(): class FakeScope: is_built_with_mpi = False class FakeAdios2: bindings = FakeScope() with pytest.raises(ImportError, match="MPI"): resolve_adios_scope(FakeAdios2()) def test_pyvista_allow_snake_case_sets_new_style_attribute(): class FakePyvista: _VTK_SNAKE_CASE_STATE = "forbid" fake = FakePyvista() pyvista_allow_snake_case(fake) assert fake._VTK_SNAKE_CASE_STATE == "allow" def test_pyvista_allow_snake_case_falls_back_to_old_style_attribute(): class FakeState: _state = "forbid" class FakeVtkSnakeCase: _state = FakeState() class FakeCore: vtk_snake_case = FakeVtkSnakeCase() class FakePyvistaOld: core = FakeCore() fake = FakePyvistaOld() pyvista_allow_snake_case(fake) assert fake.core.vtk_snake_case._state == "allow" @pytest.fixture def unit_square_model(): """A meshed unit square in gmsh, the model built on rank 0 only.""" gmsh.initialize() gmsh.option.setNumber("General.Terminal", 0) if MPI.COMM_WORLD.rank == 0: gmsh.model.add("unit square") gmsh.model.occ.addRectangle(0, 0, 0, 1, 1) gmsh.model.occ.synchronize() gmsh.model.addPhysicalGroup(2, [1], 1) gmsh.option.setNumber("Mesh.MeshSizeMax", 0.2) gmsh.model.mesh.generate(2) try: yield gmsh.model finally: gmsh.finalize() @requires_gmsh @pytest.mark.parametrize( "ghost_mode", [dolfinx.mesh.GhostMode.none, dolfinx.mesh.GhostMode.shared_facet] ) def test_model_to_mesh_applies_the_ghost_mode(comm, unit_square_model, ghost_mode): if comm.size < 2: pytest.skip("ghost cells only exist with at least 2 ranks") mesh_data = model_to_mesh(unit_square_model, comm, 0, gdim=2, ghost_mode=ghost_mode) cell_map = mesh_data.mesh.topology.index_map(2) num_cells = comm.allreduce(cell_map.size_local, op=MPI.SUM) num_ghosts = comm.allreduce(cell_map.num_ghosts, op=MPI.SUM) assert num_cells == cell_map.size_global > 0 if ghost_mode == dolfinx.mesh.GhostMode.none: assert num_ghosts == 0 else: assert num_ghosts > 0 @requires_gmsh def test_model_to_mesh_respects_explicit_partitioner(comm, unit_square_model): calls = [] # The partitioner dolfinx expects differs as in create_mesh: a cell partitioner on # 0.10 and 0.11, the graph partitioner from dolfinx PR #4403 on. if hasattr(dolfinx.mesh, "create_cell_partitioner"): base = create_cell_partitioner(dolfinx.mesh.GhostMode.none) else: base = dolfinx.graph.partitioner() def spy_partitioner(*args, **kwargs): calls.append(1) return base(*args, **kwargs) mesh_data = model_to_mesh(unit_square_model, comm, 0, gdim=2, partitioner=spy_partitioner) assert mesh_data.mesh.topology.index_map(2).size_global > 0 if comm.size > 1: assert calls, "explicit partitioner must be used, not silently replaced" @pytest.mark.parametrize( "create, vtk_id", [ (lambda comm: dolfinx.mesh.create_unit_interval(comm, 4), 68), (lambda comm: dolfinx.mesh.create_unit_square(comm, 2, 2), 69), ( lambda comm: dolfinx.mesh.create_unit_square( comm, 2, 2, cell_type=dolfinx.mesh.CellType.quadrilateral ), 70, ), (lambda comm: dolfinx.mesh.create_unit_cube(comm, 2, 2, 2), 71), ( lambda comm: dolfinx.mesh.create_unit_cube( comm, 2, 2, 2, cell_type=dolfinx.mesh.CellType.hexahedron ), 72, ), ], ) def test_vtk_cell_type_is_the_arbitrary_lagrange_type_of_the_mesh_cell(comm, create, vtk_id): msh = create(comm) num_nodes = dofmap(msh).shape[1] # Passing the topological dimension as the node count, as callers did before # dolfinx PR #4621, raises for quadrilaterals and hexahedra there. assert vtk_cell_type(msh.topology.cell_type, num_nodes) == vtk_id @pytest.mark.parametrize( "cell_type, num_nodes, vtk_id", [ (dolfinx.mesh.CellType.triangle, 6, 69), (dolfinx.mesh.CellType.quadrilateral, 9, 70), (dolfinx.mesh.CellType.tetrahedron, 10, 71), (dolfinx.mesh.CellType.hexahedron, 27, 72), ], ) def test_vtk_cell_type_of_quadratic_lagrange_cells(cell_type, num_nodes, vtk_id): assert vtk_cell_type(cell_type, num_nodes) == vtk_id fenicsx_compat-0.4.0/tests/test_la.py0000644000000000000000000001431413615410400014607 0ustar00import dolfinx.common import dolfinx.la import dolfinx.mesh import numpy as np import pytest from fenicsx_compat.la import ( _local_range, create_index_map, create_sub_index_map, index_to_dest_ranks, local_range, unwrap_index_map, vector, ) def test_create_index_map_with_no_ghosts(comm): imap = create_index_map( comm, num_local=10, ghosts=np.array([], dtype=np.int64), owners=np.array([], dtype=np.int32) ) assert imap.size_local == 10 assert imap.num_ghosts == 0 def test_unwrap_index_map_returns_cpp_object(comm): imap = create_index_map( comm, num_local=10, ghosts=np.array([], dtype=np.int64), owners=np.array([], dtype=np.int32) ) cpp_imap = unwrap_index_map(imap) assert isinstance(cpp_imap, dolfinx.cpp.common.IndexMap) # idempotent: unwrapping an already-cpp object returns it unchanged assert unwrap_index_map(cpp_imap) is cpp_imap def test_vector_creates_distributed_vector_with_given_dtype(comm): imap = create_index_map( comm, num_local=10, ghosts=np.array([], dtype=np.int64), owners=np.array([], dtype=np.int32) ) vec = vector(imap, bs=1, dtype=np.float64) assert isinstance(vec, dolfinx.la.Vector) assert vec.array.dtype == np.float64 assert vec.array.shape[0] == 10 def test_vector_honours_non_default_dtype(comm): imap = create_index_map( comm, num_local=10, ghosts=np.array([], dtype=np.int64), owners=np.array([], dtype=np.int32) ) vec = vector(imap, bs=1, dtype=np.float32) assert vec.array.dtype == np.float32 @pytest.mark.parametrize("unwrap", [False, True]) def test_index_to_dest_ranks_matches_gathered_ghosts(comm, unwrap): msh = dolfinx.mesh.create_unit_square(comm, 5, 5) imap = msh.topology.index_map(msh.topology.dim) ranks, offsets = index_to_dest_ranks(unwrap_index_map(imap) if unwrap else imap, tag=1202) assert isinstance(ranks, np.ndarray) and ranks.dtype == np.int32 assert isinstance(offsets, np.ndarray) and offsets.dtype == np.int32 # Independent oracle: every rank's ghosts, by global index. ghosts_by_rank = comm.allgather(set(imap.ghosts.tolist())) size_local = imap.size_local globals_ = imap.local_to_global(np.arange(size_local + imap.num_ghosts, dtype=np.int32)) assert len(offsets) == size_local + imap.num_ghosts + 1 for i, g in enumerate(globals_): ghosted_by = {r for r, ghosts in enumerate(ghosts_by_rank) if g in ghosts} if i >= size_local: ghosted_by.add(int(imap.owners[i - size_local])) ghosted_by.discard(comm.rank) assert set(ranks[offsets[i] : offsets[i + 1]].tolist()) == ghosted_by, f"index {i}" def _ring_index_map(comm): """Each rank owns one index (global index = rank) and ghosts the next rank's. Local index 0 is owned, local index 1 (with two or more ranks) is the ghost. """ if comm.size == 1: ghosts = np.array([], dtype=np.int64) owners = np.array([], dtype=np.int32) else: neighbour = (comm.rank + 1) % comm.size ghosts = np.array([neighbour], dtype=np.int64) owners = np.array([neighbour], dtype=np.int32) return create_index_map(comm, 1, ghosts=ghosts, owners=owners, tag=1203) @pytest.mark.parametrize("unwrap", [False, True]) def test_create_sub_index_map_keeps_owner_and_ghost_when_both_included(comm, unwrap): imap = _ring_index_map(comm) indices = np.arange(imap.size_local + imap.num_ghosts, dtype=np.int32) sub_map, sub_to_parent = create_sub_index_map( unwrap_index_map(imap) if unwrap else imap, indices ) assert sub_map.size_global == comm.size assert sub_map.size_local == 1 assert sub_map.num_ghosts == imap.num_ghosts np.testing.assert_array_equal(sub_to_parent, indices) def test_create_sub_index_map_drops_a_ghost_its_holder_leaves_out(comm): imap = _ring_index_map(comm) sub_map, sub_to_parent = create_sub_index_map(imap, np.array([0], dtype=np.int32)) assert sub_map.size_global == comm.size assert sub_map.size_local == 1 assert sub_map.num_ghosts == 0 np.testing.assert_array_equal(sub_to_parent, [0]) def test_create_sub_index_map_refuses_an_owner_change_by_default(comm): if comm.size < 2: pytest.skip("an owner change needs a ghost, which needs at least 2 ranks") # Every rank keeps only its ghost, so every rank's owned index is wanted only by the # rank that ghosts it. Every rank then raises, on every dolfinx: a rank that did not # would carry on into the next collective alone. imap = _ring_index_map(comm) with pytest.raises(RuntimeError, match="owner change"): create_sub_index_map(imap, np.array([1], dtype=np.int32)) def test_create_sub_index_map_moves_the_owner_when_allowed(comm): if comm.size < 2: pytest.skip("an owner change needs a ghost, which needs at least 2 ranks") imap = _ring_index_map(comm) sub_map, sub_to_parent = create_sub_index_map( imap, np.array([1], dtype=np.int32), allow_owner_change=True ) # The former ghost is now owned here, and it is the next rank's index that moved. assert sub_map.size_global == comm.size assert sub_map.size_local == 1 assert sub_map.num_ghosts == 0 np.testing.assert_array_equal(sub_to_parent, [1]) np.testing.assert_array_equal( imap.local_to_global(sub_to_parent), [(comm.rank + 1) % comm.size] ) def _expected_local_ranges(N, size): """Independent oracle: the contiguous blocks `np.array_split` cuts `range(N)` into.""" blocks = np.array_split(np.arange(N), size) starts = np.cumsum([0] + [len(b) for b in blocks]) return [(int(starts[i]), int(starts[i + 1])) for i in range(size)] # `_local_range` is the fallback for dolfinx 0.10 and 0.11; it is checked directly as well, # so the formula is covered on every leg and not only where dolfinx lacks its own. @pytest.mark.parametrize("function", [local_range, _local_range]) @pytest.mark.parametrize("N", [0, 1, 7, 10, 13]) @pytest.mark.parametrize("size", [1, 2, 3, 4, 5]) def test_local_range_splits_into_almost_equal_contiguous_blocks(function, N, size): ranges = [function(index, N, size) for index in range(size)] assert ranges == _expected_local_ranges(N, size) assert all(type(v) is int for r in ranges for v in r) fenicsx_compat-0.4.0/tests/test_mesh.py0000644000000000000000000003140413615410400015146 0ustar00import warnings from mpi4py import MPI import basix import basix.ufl import dolfinx import dolfinx.fem import dolfinx.graph import dolfinx.mesh import numpy as np import pytest import ufl from fenicsx_compat.mesh import ( cell_permutation_info, cmap, create_cell_partitioner, create_facet_orientations, create_mesh, dofmap, facet_permutations, form_map, meshtags_topology, reconstruct_mesh, submesh_to_parent, transfer_meshtags_to_submesh, ) def test_cmap_returns_the_coordinate_element(comm): msh = dolfinx.mesh.create_unit_square(comm, 4, 4) with warnings.catch_warnings(): # From 0.11, `geometry.cmap` is deprecated in favour of `cmaps[0]`. warnings.simplefilter("error") result = cmap(msh) assert result.degree == 1 assert result.dim == 3 def test_dofmap_returns_an_array_of_node_indices(comm): msh = dolfinx.mesh.create_unit_square(comm, 4, 4) with warnings.catch_warnings(): warnings.simplefilter("error") result = dofmap(msh) assert result.ndim == 2 assert ( result.shape[0] == msh.topology.index_map(msh.topology.dim).size_local + msh.topology.index_map(msh.topology.dim).num_ghosts ) def test_form_map_returns_index_map_and_block_size(comm): msh = dolfinx.mesh.create_unit_square(comm, 4, 4) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) v = ufl.TestFunction(V) L = dolfinx.fem.form(v * ufl.dx) index_map, bs = form_map(L) assert index_map.size_local == V.dofmap.index_map.size_local assert bs == V.dofmap.index_map_bs def _two_square_input(comm): """Rank-0-only mesh input. `create_mesh` treats each rank's arrays as that rank's share of the input, so passing the full arrays on every rank concatenates them and builds a doubled mesh (8 cells instead of 4 at 2 ranks). dolfinx nightly tolerates that silently; v0.10.0 aborts in C++. Supplying the description on rank 0 and empty arrays elsewhere is the standard idiom and yields the intended 4-cell mesh at any rank count. """ if comm.rank == 0: x = np.array([[0, 0], [1, 0], [0, 1], [1, 1], [2, 0], [2, 1]], dtype=np.float64) cells = np.array([[0, 1, 2], [1, 3, 2], [1, 4, 3], [4, 5, 3]], dtype=np.int64) else: x = np.empty((0, 2), dtype=np.float64) cells = np.empty((0, 3), dtype=np.int64) return cells, x def test_create_mesh_default_ghost_mode_none_has_no_ghosts(comm): el = basix.ufl.element("Lagrange", "triangle", 1, shape=(2,)) cells, x = _two_square_input(comm) msh = create_mesh(comm, cells, el, x, ghost_mode=dolfinx.mesh.GhostMode.none) assert msh.topology.index_map(2).size_global == 4 assert msh.topology.index_map(2).num_ghosts == 0 def test_create_mesh_shared_facet_ghost_mode_has_ghosts_under_two_ranks(comm): if comm.size < 2: pytest.skip("requires at least 2 ranks") el = basix.ufl.element("Lagrange", "triangle", 1, shape=(2,)) cells, x = _two_square_input(comm) msh = create_mesh(comm, cells, el, x, ghost_mode=dolfinx.mesh.GhostMode.shared_facet) assert msh.topology.index_map(2).size_global == 4 assert msh.topology.index_map(2).num_ghosts > 0 def test_create_mesh_respects_explicit_partitioner(comm): el = basix.ufl.element("Lagrange", "triangle", 1, shape=(2,)) cells, x = _two_square_input(comm) calls = [] # Build the base partitioner the version-appropriate way: dolfinx 0.10/0.11 # expect create_mesh's `partitioner` to be a *cell* partitioner (what # create_cell_partitioner builds), not the raw graph partitioner nightly # accepts directly - passing the wrong shape raises TypeError inside # dolfinx's C++ layer when it invokes the spy. if hasattr(dolfinx.mesh, "create_cell_partitioner"): base = create_cell_partitioner(dolfinx.mesh.GhostMode.none) else: base = dolfinx.graph.partitioner() def spy_partitioner(*args, **kwargs): calls.append(1) return base(*args, **kwargs) msh = create_mesh(comm, cells, el, x, partitioner=spy_partitioner) assert msh.topology.index_map(2).size_global == 4 if comm.size > 1: assert calls, "explicit partitioner must be used, not silently replaced" def test_create_cell_partitioner_matches_the_installed_dolfinx_api(): # Which branch runs is decided by the installed dolfinx, not by a mock: # the v0.10.0 and stable CI legs build a real partitioner here, the # nightly leg (where dolfinx removed create_cell_partitioner) raises. if hasattr(dolfinx.mesh, "create_cell_partitioner"): partitioner = create_cell_partitioner(dolfinx.mesh.GhostMode.shared_facet) assert partitioner is not None else: with pytest.raises(NotImplementedError, match="removed"): create_cell_partitioner(dolfinx.mesh.GhostMode.shared_facet) def test_meshtags_topology_is_the_python_wrapper_of_the_mesh_topology(comm): msh = dolfinx.mesh.create_unit_square(comm, 4, 4) tdim = msh.topology.dim num_cells = msh.topology.index_map(tdim).size_local tags = dolfinx.mesh.meshtags( msh, tdim, np.arange(num_cells, dtype=np.int32), np.ones(num_cells, dtype=np.int32) ) topology = meshtags_topology(tags) assert isinstance(topology, dolfinx.mesh.Topology) assert topology._cpp_object is msh.topology._cpp_object def test_reconstruct_mesh_changes_coordinate_element_degree(comm): msh = dolfinx.mesh.create_unit_square(comm, 4, 4) new_msh = reconstruct_mesh(msh, coordinate_element_degree=2) assert new_msh.geometry.x.shape[0] > msh.geometry.x.shape[0] assert cmap(new_msh).degree == 2 def test_submesh_to_parent_maps_cells_and_vertices_to_the_same_points(comm): msh = dolfinx.mesh.create_unit_square(comm, 4, 4) tdim = msh.topology.dim cells = dolfinx.mesh.locate_entities(msh, tdim, lambda x: x[0] <= 0.5) submesh, cell_map, vertex_map, _ = dolfinx.mesh.create_submesh(msh, tdim, cells) num_sub_cells = submesh.topology.index_map(tdim).size_local num_sub_cells += submesh.topology.index_map(tdim).num_ghosts sub_cells = np.arange(num_sub_cells, dtype=np.int32) parent_cells = submesh_to_parent(cell_map) assert parent_cells.dtype == np.int32 assert len(parent_cells) == num_sub_cells np.testing.assert_array_equal(submesh_to_parent(cell_map, sub_cells[::2]), parent_cells[::2]) np.testing.assert_allclose( dolfinx.mesh.compute_midpoints(submesh, tdim, sub_cells), dolfinx.mesh.compute_midpoints(msh, tdim, parent_cells), ) num_sub_vertices = submesh.topology.index_map(0).size_local num_sub_vertices += submesh.topology.index_map(0).num_ghosts sub_vertices = np.arange(num_sub_vertices, dtype=np.int32) parent_vertices = submesh_to_parent(vertex_map) assert len(parent_vertices) == num_sub_vertices submesh.topology.create_connectivity(0, tdim) msh.topology.create_connectivity(0, tdim) np.testing.assert_allclose( dolfinx.mesh.compute_midpoints(submesh, 0, sub_vertices), dolfinx.mesh.compute_midpoints(msh, 0, parent_vertices), ) def test_transfer_meshtags_to_submesh_matches_the_installed_dolfinx(comm): msh = dolfinx.mesh.create_unit_square(comm, 4, 4) tdim = msh.topology.dim msh.topology.create_connectivity(tdim - 1, tdim) facet_indices = dolfinx.mesh.locate_entities(msh, tdim - 1, lambda x: np.isclose(x[0], 0.0)) values = np.full(len(facet_indices), 7, dtype=np.int32) entity_tag = dolfinx.mesh.meshtags(msh, tdim - 1, facet_indices, values) num_cells = msh.topology.index_map(tdim).size_local submesh, cell_map, vertex_map, _ = dolfinx.mesh.create_submesh( msh, tdim, np.arange(num_cells, dtype=np.int32) ) # dolfinx 0.11 made this native; on the v0.10.0 CI leg the compat # function has no pure-Python fallback and says so. Which branch runs # is decided by the installed dolfinx, not by a mock. if hasattr(dolfinx.mesh, "transfer_meshtags_to_submesh"): result = transfer_meshtags_to_submesh(entity_tag, submesh, cell_map, vertex_map) assert isinstance(result, dolfinx.mesh.MeshTags) # A merely-truthy isinstance check would still pass if the cell/vertex # maps were swapped internally (wrong entities tagged, or none at # all) -- that is exactly the failure this wrapper's keyword # forwarding was written to prevent. Check the transferred tags are # meaningful: correct dimension, and the tag value 7 actually made # it onto the submesh somewhere across all ranks collectively (a # single rank's local submesh may legitimately receive none of the # tagged facets, so this must be reduced across ranks -- a per-rank # skip/assert before a collective call would deadlock instead). assert result.dim == tdim - 1 # The x==0 boundary of a 4x4 unit-square mesh always has exactly 4 # facets, regardless of how many ranks the mesh is distributed # across -- verified empirically at 1, 2 and 3 ranks (LAND across a # per-rank "has tag 7" check is NOT safe here: at 3 ranks one rank's # local submesh legitimately holds none of the tagged facets, so # LAND would false-fail; SUM of local occurrences is the invariant # that actually holds at every rank count tried). local_tag_7_count = int(np.sum(result.values == 7)) total_tag_7_count = comm.allreduce(local_tag_7_count, op=MPI.SUM) assert total_tag_7_count == 4 else: with pytest.raises(NotImplementedError, match="0.11"): transfer_meshtags_to_submesh(entity_tag, submesh, cell_map, vertex_map) def _expected_edge_reflections(topology): """Reflection of each edge of each triangle, derived independently. dolfinx orients an entity by its vertices' global indices, low to high; an edge is reflected when the cell's reference ordering of its two vertices runs high to low. """ tdim = topology.dim num_cells = topology.index_map(tdim).size_local + topology.index_map(tdim).num_ghosts c_to_v = topology.connectivity(tdim, 0) vertex_map = topology.index_map(0) ref_edges = basix.topology(basix.CellType.triangle)[1] expected = np.zeros((num_cells, len(ref_edges)), dtype=np.uint8) for c in range(num_cells): gv = vertex_map.local_to_global(c_to_v.links(c).astype(np.int32)) for e, (a, b) in enumerate(ref_edges): expected[c, e] = gv[a] > gv[b] return expected def test_facet_permutations_match_global_vertex_ordering(comm): msh = dolfinx.mesh.create_unit_square(comm, 5, 5) result = facet_permutations(msh.topology) expected = _expected_edge_reflections(msh.topology) assert result.shape == expected.shape np.testing.assert_array_equal(result, expected) # Guard against a vacuous pass: the mesh has edges in both orientations. assert comm.allreduce(int(result.sum()), op=MPI.SUM) > 0 assert comm.allreduce(int((result == 0).sum()), op=MPI.SUM) > 0 def test_cell_permutation_info_encodes_edge_reflections(comm): # For a 2D cell, bit `e` of the packed info is the reflection of edge `e`. msh = dolfinx.mesh.create_unit_square(comm, 5, 5) info = cell_permutation_info(msh.topology) expected = _expected_edge_reflections(msh.topology) assert info.shape == (expected.shape[0],) bits = (info[:, None] >> np.arange(3, dtype=info.dtype)) & 1 np.testing.assert_array_equal(bits, expected) def _wall_of_unit_cube(comm): """The facets on x = 0 of a unit cube: a manifold, tdim 2 in gdim 3.""" msh = dolfinx.mesh.create_unit_cube(comm, 3, 3, 3) facets = dolfinx.mesh.locate_entities_boundary(msh, 2, lambda x: np.isclose(x[0], 0.0)) return dolfinx.mesh.create_submesh(msh, 2, facets)[0] def test_create_facet_orientations_lets_raviart_thomas_be_built_on_a_manifold(comm): submesh = _wall_of_unit_cube(comm) assert submesh.topology.dim == 2 and submesh.geometry.dim == 3 create_facet_orientations(submesh.topology) # Without the orientations, dolfinx with `create_facet_orientations` refuses this. V = dolfinx.fem.functionspace(submesh, ("RT", 1)) submesh.topology.create_entities(1) # One RT1 dof per edge of the 3 x 3 wall: 2 * 3 * 4 axis-aligned edges plus 9 diagonals. assert V.dofmap.index_map.size_global == submesh.topology.index_map(1).size_global == 33 if hasattr(submesh.topology, "has_facet_orientations"): assert submesh.topology.has_facet_orientations() def test_create_facet_orientations_matches_the_installed_dolfinx_on_a_volume_mesh(comm): msh = dolfinx.mesh.create_unit_cube(comm, 2, 2, 2) if hasattr(msh.topology, "create_facet_orientations"): # Native behaviour, passed through: only defined for tdim 2. with pytest.raises(ValueError): create_facet_orientations(msh.topology) else: create_facet_orientations(msh.topology) fenicsx_compat-0.4.0/tests/test_optional.py0000644000000000000000000000116013615410400016033 0ustar00import pytest from fenicsx_compat.optional import require_module def test_require_module_returns_the_module_when_present(): mod = require_module("json") assert mod.dumps({"a": 1}) == '{"a": 1}' def test_require_module_raises_actionable_error_when_missing(): with pytest.raises(ImportError, match="pip install"): require_module("this_module_does_not_exist_anywhere") def test_require_module_uses_extra_name_in_message(): with pytest.raises(ImportError, match="pip install some-other-package-name"): require_module("this_module_does_not_exist_anywhere", extra="some-other-package-name") fenicsx_compat-0.4.0/tests/test_package.py0000644000000000000000000000022313615410400015600 0ustar00import fenicsx_compat def test_has_version(): assert isinstance(fenicsx_compat.__version__, str) assert fenicsx_compat.__version__ != "" fenicsx_compat-0.4.0/tests/test_petsc.py0000644000000000000000000002161113615410400015327 0ustar00from petsc4py import PETSc import dolfinx.fem import dolfinx.fem.petsc # `import dolfinx.fem` alone does not expose `.petsc` import dolfinx.mesh import numpy as np import ufl from fenicsx_compat.petsc import ( apply_lifting_and_set_bc, bcs_by_block, ghost_update, set_bc, zero_petsc_vector, ) def test_bcs_by_block_assigns_bc_to_matching_space(comm): msh = dolfinx.mesh.create_unit_square(comm, 2, 2) V0 = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) V1 = dolfinx.fem.functionspace(msh, ("Lagrange", 2)) tdim = msh.topology.dim msh.topology.create_connectivity(tdim - 1, tdim) facets = dolfinx.mesh.exterior_facet_indices(msh.topology) dofs0 = dolfinx.fem.locate_dofs_topological(V0, tdim - 1, facets) bc0 = dolfinx.fem.dirichletbc(0.0, dofs0, V0) result = bcs_by_block([V0, V1], [bc0]) assert result[0] == [bc0] assert result[1] == [] def test_bcs_by_block_returns_empty_list_for_none_space(comm): msh = dolfinx.mesh.create_unit_square(comm, 2, 2) V0 = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) tdim = msh.topology.dim msh.topology.create_connectivity(tdim - 1, tdim) facets = dolfinx.mesh.exterior_facet_indices(msh.topology) dofs0 = dolfinx.fem.locate_dofs_topological(V0, tdim - 1, facets) bc0 = dolfinx.fem.dirichletbc(0.0, dofs0, V0) result = bcs_by_block([None, V0], [bc0]) assert result[0] == [] assert result[1] == [bc0] def test_zero_petsc_vector_zeroes_a_vector(comm): # NOTE: a plain `PETSc.Vec().createMPI((-1, 10), comm=comm)` (no ghost # padding) is NOT used here even though the brief specifies it verbatim: # `VecGhostGetLocalForm()` returns NULL for such a vector (documented # PETSc behaviour), and petsc4py's `Vec.localForm()` does not guard # against that NULL before wrapping it, so `zero_petsc_vector` (which # calls `dolfinx.la.petsc._zero_vector` -> `x.localForm()`) segfaults the # whole process (confirmed: SIGSEGV / MPI_ABORT, reproduces identically # under `mpirun -n 2`). `createGhost([], ...)` is a genuinely ghost-aware # vector (matching how dolfinx always constructs its own PETSc vectors, # even with zero ghosts in serial) and exercises the same code path # safely. See task-22-23-report.md for the full repro. vec = PETSc.Vec().createGhost([], (-1, 10), comm=comm) vec.array[:] = 5.0 zero_petsc_vector(vec) assert np.allclose(vec.array, 0.0) vec.destroy() def test_ghost_update_runs_without_error(comm): # See the note in test_zero_petsc_vector_zeroes_a_vector: a plain # `createMPI` vector is not ghosted, so `ghost_update` on it raises # `petsc4py.PETSc.Error: ... Vector is not ghosted` (confirmed) rather # than running "without error". `createGhost([], ...)` fixes this the # same way. vec = PETSc.Vec().createGhost([], (-1, 10), comm=comm) vec.array[:] = 3.0 ghost_update(vec, PETSc.InsertMode.INSERT_VALUES, PETSc.ScatterMode.FORWARD) assert np.allclose(vec.array, 3.0) vec.destroy() def test_ghost_update_accumulates_ghost_contributions(comm): # A genuinely ghosted vector: dofs shared with another rank each hold 1.0 # locally, so a reverse ADD_VALUES update accumulates them on the owner. msh = dolfinx.mesh.create_unit_square(comm, 4, 4) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) u = dolfinx.fem.Function(V) vec = u.x.petsc_vec with vec.localForm() as loc: loc.set(1.0) ghost_update(vec, PETSc.InsertMode.ADD_VALUES, PETSc.ScatterMode.REVERSE) owned = vec.array_r assert owned.min() == 1.0 if comm.size > 1: # Verified under `mpirun -n 2`: shared dofs accumulate to 2.0. assert owned.max() > 1.0 def test_set_bc_applies_dirichlet_value(comm): msh = dolfinx.mesh.create_unit_square(comm, 2, 2) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) tdim = msh.topology.dim msh.topology.create_connectivity(tdim - 1, tdim) facets = dolfinx.mesh.exterior_facet_indices(msh.topology) dofs = dolfinx.fem.locate_dofs_topological(V, tdim - 1, facets) bc = dolfinx.fem.dirichletbc(5.0, dofs, V) b = dolfinx.fem.petsc.create_vector(V) b.array[:] = 0.0 set_bc(b, [bc]) # `dofs` is local (owned + ghost); only owned entries land in b.array. owned_dofs = dofs[dofs < b.getLocalSize()] assert np.allclose(b.array[owned_dofs], 5.0) b.destroy() def test_apply_lifting_and_set_bc_runs_on_a_simple_poisson_system(comm): msh = dolfinx.mesh.create_unit_square(comm, 3, 3) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) u, v = ufl.TrialFunction(V), ufl.TestFunction(V) a = dolfinx.fem.form(ufl.inner(ufl.grad(u), ufl.grad(v)) * ufl.dx) L = dolfinx.fem.form(v * ufl.dx) tdim = msh.topology.dim msh.topology.create_connectivity(tdim - 1, tdim) facets = dolfinx.mesh.exterior_facet_indices(msh.topology) dofs = dolfinx.fem.locate_dofs_topological(V, tdim - 1, facets) # Ruling F5: a zero bc value can't distinguish "bc applied" from "vector # already zero there", so this uses a non-zero value the assertion below # can actually detect. bc = dolfinx.fem.dirichletbc(7.0, dofs, V) # This exercises the blocked/nested path (`a` is a list, `a=[[a]]`): # `b` is built as a genuine single-block vector via `kind="mpi"` so it # carries the `_blocks` attribute `apply_lifting`'s block-vector branch # expects to match a 2D `a`. See task-22-23-report.md (Fix round 1) for # why a plain `assemble_vector(L)` (no `_blocks`) does not pair with a # 2D `a`, and test_apply_lifting_and_set_bc_runs_on_a_single_form below # for the companion bare-form path. b = dolfinx.fem.petsc.assemble_vector([L], kind="mpi") apply_lifting_and_set_bc(b, [[a]], [bc]) # `dofs` is local (owned + ghost); only owned entries land in b.array. owned_dofs = dofs[dofs < b.getLocalSize()] assert np.allclose(b.array[owned_dofs], 7.0) b.destroy() def test_apply_lifting_and_set_bc_runs_on_a_single_form(comm): # Companion to the blocked-path test above: `a` is passed bare (not a # list), exercising the single-form branch of apply_lifting_and_set_bc, # where a single form has exactly one block so every bc in `bcs` applies # to it (`dolfinx.fem.extract_function_spaces` refuses an explicit index # for a bare single form, so this path is dispatched on `isinstance(a, # (list, tuple))` rather than by catching that). msh = dolfinx.mesh.create_unit_square(comm, 3, 3) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) u, v = ufl.TrialFunction(V), ufl.TestFunction(V) a = dolfinx.fem.form(ufl.inner(ufl.grad(u), ufl.grad(v)) * ufl.dx) L = dolfinx.fem.form(v * ufl.dx) tdim = msh.topology.dim msh.topology.create_connectivity(tdim - 1, tdim) facets = dolfinx.mesh.exterior_facet_indices(msh.topology) dofs = dolfinx.fem.locate_dofs_topological(V, tdim - 1, facets) bc = dolfinx.fem.dirichletbc(9.0, dofs, V) b = dolfinx.fem.petsc.assemble_vector(L) apply_lifting_and_set_bc(b, a, [bc]) # `dofs` is local (owned + ghost); only owned entries land in b.array. owned_dofs = dofs[dofs < b.getLocalSize()] assert np.allclose(b.array[owned_dofs], 9.0) b.destroy() def test_apply_lifting_and_set_bc_subtracts_x_for_a_single_form(comm): # Regression: with a single form, `x` reached `apply_lifting` as a bare Vec, # which it iterates over, so the call raised AttributeError on every version. msh = dolfinx.mesh.create_unit_square(comm, 3, 3) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) u, v = ufl.TrialFunction(V), ufl.TestFunction(V) a = dolfinx.fem.form(ufl.inner(ufl.grad(u), ufl.grad(v)) * ufl.dx) L = dolfinx.fem.form(v * ufl.dx) tdim = msh.topology.dim msh.topology.create_connectivity(tdim - 1, tdim) facets = dolfinx.mesh.exterior_facet_indices(msh.topology) dofs = dolfinx.fem.locate_dofs_topological(V, tdim - 1, facets) bc = dolfinx.fem.dirichletbc(9.0, dofs, V) # A non-constant `x` and alpha != 1, so that both visibly change the result x = dolfinx.fem.Function(V) x.interpolate(lambda p: 1.0 + p[0]) alpha = 2.0 b = dolfinx.fem.petsc.assemble_vector(L) apply_lifting_and_set_bc(b, a, [bc], x=x.x.petsc_vec, alpha=alpha) # Constrained rows are alpha * (g - x) owned_dofs = dofs[dofs < b.getLocalSize()] assert np.allclose(b.array[owned_dofs], alpha * (9.0 - x.x.array[owned_dofs])) # Every row matches the same steps done natively expected = dolfinx.fem.petsc.assemble_vector(L) dolfinx.fem.petsc.apply_lifting(expected, [a], bcs=[[bc]], x0=[x.x.petsc_vec], alpha=alpha) expected.ghostUpdate(addv=PETSc.InsertMode.ADD_VALUES, mode=PETSc.ScatterMode.REVERSE) dolfinx.fem.petsc.set_bc(expected, [bc], x0=x.x.petsc_vec, alpha=alpha) expected.ghostUpdate(addv=PETSc.InsertMode.INSERT_VALUES, mode=PETSc.ScatterMode.FORWARD) assert np.allclose(b.array, expected.array) b.destroy() expected.destroy() fenicsx_compat-0.4.0/tests/test_ufl.py0000644000000000000000000000651713615410400015007 0ustar00import dolfinx.fem import dolfinx.mesh import numpy as np import pytest import ufl as ufl_pkg from fenicsx_compat.ufl import _INVERSE_PULLBACKS, apply_pullback_inverse, domain_of def test_domain_of_returns_the_single_domain(comm): msh = dolfinx.mesh.create_unit_square(comm, 2, 2) V = dolfinx.fem.functionspace(msh, ("Lagrange", 1)) u = dolfinx.fem.Function(V) result = domain_of(u) assert result is not None def test_domain_of_raises_on_expression_with_no_domain(): # A bare constant is defined on zero domains: extract_domains(...) == (). with pytest.raises(ValueError, match="Expected exactly one domain"): domain_of(ufl_pkg.as_ufl(1.0)) _PULLBACKS = [ ("IdentityPullback", ufl_pkg.pullback.identity_pullback), ("ContravariantPiola", ufl_pkg.pullback.contravariant_piola), ("CovariantPiola", ufl_pkg.pullback.covariant_piola), ] def _distorted_quad_mesh(comm): # Non-affine cells, so the Jacobian varies within each cell and a wrong # Piola formula cannot agree with the right one by accident. msh = dolfinx.mesh.create_unit_square(comm, 3, 3, cell_type=dolfinx.mesh.CellType.quadrilateral) x = msh.geometry.x x[:, 0] += 0.15 * x[:, 1] ** 2 x[:, 1] += 0.1 * np.sin(3 * x[:, 0]) return msh def _eval_on_owned_cells(msh, expr): points = np.array([[0.2, 0.3], [0.7, 0.6]]) cells = np.arange(msh.topology.index_map(2).size_local, dtype=np.int32) return dolfinx.fem.Expression(expr, points).eval(msh, cells) @pytest.mark.parametrize("name, pullback", _PULLBACKS) def test_apply_pullback_inverse_closed_forms_match_ufl(comm, name, pullback): # The closed forms are only used where UFL lacks apply_inverse (before # UFL 2026.2), so check them against UFL's own on the legs that have it. if not hasattr(pullback, "apply_inverse"): pytest.skip("this UFL has no apply_inverse to compare against") msh = _distorted_quad_mesh(comm) X = ufl_pkg.SpatialCoordinate(msh) f = ufl_pkg.as_vector((X[0] ** 2 + 1, X[0] * X[1] - 2)) domain = msh.ufl_domain() closed_form = _eval_on_owned_cells(msh, _INVERSE_PULLBACKS[name](f, domain)) reference = _eval_on_owned_cells(msh, pullback.apply_inverse(f, domain)) np.testing.assert_allclose(closed_form, reference, rtol=1e-12, atol=1e-12) @pytest.mark.parametrize("name, pullback", _PULLBACKS) def test_apply_pullback_inverse_maps_to_reference_cell(comm, name, pullback): # Whichever branch this UFL takes, the values must be the inverse pullback. msh = _distorted_quad_mesh(comm) X = ufl_pkg.SpatialCoordinate(msh) f = ufl_pkg.as_vector((X[0] ** 2 + 1, X[0] * X[1] - 2)) domain = msh.ufl_domain() result = _eval_on_owned_cells(msh, apply_pullback_inverse(pullback, f, domain)) expected = _eval_on_owned_cells(msh, _INVERSE_PULLBACKS[name](f, domain)) np.testing.assert_allclose(result, expected, rtol=1e-12, atol=1e-12) def test_apply_pullback_inverse_without_closed_form(comm): pullback = ufl_pkg.pullback.double_covariant_piola msh = _distorted_quad_mesh(comm) f = ufl_pkg.as_matrix(((1.0, 0.0), (0.0, 1.0))) if hasattr(pullback, "apply_inverse"): assert apply_pullback_inverse(pullback, f, msh.ufl_domain()) is not None else: with pytest.raises(NotImplementedError, match="apply_inverse"): apply_pullback_inverse(pullback, f, msh.ufl_domain()) fenicsx_compat-0.4.0/.gitignore0000644000000000000000000000712513615410400013432 0ustar00third-party/ devcontainer-lock.json # Specs, plans, and other local-only planning docs (never committed) docs/superpowers/ # Created by https://www.toptal.com/developers/gitignore/api/python # Edit at https://www.toptal.com/developers/gitignore?templates=python ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ### Python Patch ### # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration poetry.toml # ruff .ruff_cache/ # LSP config files pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python .superpowers/ # Jupyter Book build output _build/ fenicsx_compat-0.4.0/LICENSE0000644000000000000000000000214413615410400012443 0ustar00MIT License Copyright (c) 2026 Simula Research Laboratory, Henrik N.T. Finsberg, Jørgen S. Dokken Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. fenicsx_compat-0.4.0/README.md0000644000000000000000000000276613615410400012727 0ustar00# fenicsx-compat Cross-version compatibility helpers for [DOLFINx](https://github.com/FEniCS/dolfinx). Centralizes workarounds for breaking API changes across dolfinx versions (nightly/main, stable, and back to release 0.10), previously duplicated across `dolfinx-adjoint`, `io4dolfinx`, and `scifem`. Pure Python, no compiled extensions. Supports dolfinx >= 0.10.0. # Installation `fenicsx-compat` requires Python 3.12 or newer and DOLFINx 0.10 or newer. ## conda-forge ```bash conda install conda-forge::fenicsx-compat ``` This also installs DOLFINx from conda-forge if it is not already in the environment. ## pip ```bash python3 -m pip install fenicsx-compat ``` Install DOLFINx first, for instance with conda, spack or one of the [FEniCS Docker images](https://github.com/FEniCS/dolfinx#docker-images). pip cannot build DOLFINx itself. To install the development version from GitHub, run ```bash python3 -m pip install git+https://github.com/scientificcomputing/fenicsx-compat.git ``` ## From source To build without build isolation, first install the build requirements from the `build` dependency group (requires pip >= 25.1), then install from a clone of the repository: ```bash python3 -m pip install --group build python3 -m pip install --check-build-dependencies --no-build-isolation . ``` # Documentation The [documentation](https://scientificcomputing.github.io/fenicsx-compat) lists every function in the package: what it does, what DOLFINx 0.10, 0.11 and main do natively, and how to call it. fenicsx_compat-0.4.0/pyproject.toml0000644000000000000000000000371413615410400014356 0ustar00[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "fenicsx-compat" version = "0.4.0" description = "Cross-version compatibility helpers for DOLFINx (FEniCSx)" readme = "README.md" requires-python = ">=3.12" license = "MIT" authors = [ { name = "Henrik N.T. Finsberg", email = "henriknf@simula.no" }, { name = "Jørgen S. Dokken", email = "dokken@simula.no" }, ] dependencies = ["fenics-dolfinx>=0.10.0", "packaging"] [project.optional-dependencies] dev = ["ruff", "mypy", "pre-commit", "bump-my-version"] test = ["pytest", "petsc4py"] docs = ["jupyter-book<2.0"] all = ["fenicsx-compat[dev,test,docs]"] [dependency-groups] # Keep in sync with [build-system].requires above. build = ["hatchling"] [tool.hatch.build] # Editable installs add src/ to sys.path with a .pth file, so they do not # need the editables package when building without build isolation. dev-mode-dirs = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = ["--import-mode=importlib", "--strict-markers"] markers = [ "serial: single-rank only, excluded from the mpirun leg", ] [tool.mypy] ignore_missing_imports = true [tool.ruff] line-length = 100 target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I001"] ignore = ["E402", "E741", "E743"] [tool.ruff.lint.isort] known-first-party = ["fenicsx_compat"] known-third-party = ["basix", "dolfinx", "ffcx", "ufl", "numpy", "pytest"] section-order = ["future", "standard-library", "mpi", "third-party", "first-party", "local-folder"] [tool.ruff.lint.isort.sections] mpi = ["mpi4py", "petsc4py"] [tool.bumpversion] allow_dirty = false commit = true message = "Bump version: {current_version} → {new_version}" tag = true sign_tags = false tag_name = "v{new_version}" tag_message = "Bump version: {current_version} → {new_version}" current_version = "0.4.0" [[tool.bumpversion.files]] filename = "pyproject.toml" search = 'version = "{current_version}"' replace = 'version = "{new_version}"' fenicsx_compat-0.4.0/PKG-INFO0000644000000000000000000000502613615410400012535 0ustar00Metadata-Version: 2.5 Name: fenicsx-compat Version: 0.4.0 Summary: Cross-version compatibility helpers for DOLFINx (FEniCSx) Author-email: "Henrik N.T. Finsberg" , "Jørgen S. Dokken" License-Expression: MIT License-File: LICENSE Requires-Python: >=3.12 Requires-Dist: fenics-dolfinx>=0.10.0 Requires-Dist: packaging Provides-Extra: all Requires-Dist: bump-my-version; extra == 'all' Requires-Dist: jupyter-book<2.0; extra == 'all' Requires-Dist: mypy; extra == 'all' Requires-Dist: petsc4py; extra == 'all' Requires-Dist: pre-commit; extra == 'all' Requires-Dist: pytest; extra == 'all' Requires-Dist: ruff; extra == 'all' Provides-Extra: dev Requires-Dist: bump-my-version; extra == 'dev' Requires-Dist: mypy; extra == 'dev' Requires-Dist: pre-commit; extra == 'dev' Requires-Dist: ruff; extra == 'dev' Provides-Extra: docs Requires-Dist: jupyter-book<2.0; extra == 'docs' Provides-Extra: test Requires-Dist: petsc4py; extra == 'test' Requires-Dist: pytest; extra == 'test' Description-Content-Type: text/markdown # fenicsx-compat Cross-version compatibility helpers for [DOLFINx](https://github.com/FEniCS/dolfinx). Centralizes workarounds for breaking API changes across dolfinx versions (nightly/main, stable, and back to release 0.10), previously duplicated across `dolfinx-adjoint`, `io4dolfinx`, and `scifem`. Pure Python, no compiled extensions. Supports dolfinx >= 0.10.0. # Installation `fenicsx-compat` requires Python 3.12 or newer and DOLFINx 0.10 or newer. ## conda-forge ```bash conda install conda-forge::fenicsx-compat ``` This also installs DOLFINx from conda-forge if it is not already in the environment. ## pip ```bash python3 -m pip install fenicsx-compat ``` Install DOLFINx first, for instance with conda, spack or one of the [FEniCS Docker images](https://github.com/FEniCS/dolfinx#docker-images). pip cannot build DOLFINx itself. To install the development version from GitHub, run ```bash python3 -m pip install git+https://github.com/scientificcomputing/fenicsx-compat.git ``` ## From source To build without build isolation, first install the build requirements from the `build` dependency group (requires pip >= 25.1), then install from a clone of the repository: ```bash python3 -m pip install --group build python3 -m pip install --check-build-dependencies --no-build-isolation . ``` # Documentation The [documentation](https://scientificcomputing.github.io/fenicsx-compat) lists every function in the package: what it does, what DOLFINx 0.10, 0.11 and main do natively, and how to call it.