peclet-pnm 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- peclet_pnm-0.1.0/.github/workflows/ci.yml +74 -0
- peclet_pnm-0.1.0/.github/workflows/docs.yml +59 -0
- peclet_pnm-0.1.0/.github/workflows/release.yml +56 -0
- peclet_pnm-0.1.0/.gitignore +37 -0
- peclet_pnm-0.1.0/CITATION.cff +19 -0
- peclet_pnm-0.1.0/CLAUDE.md +91 -0
- peclet_pnm-0.1.0/CMakeLists.txt +79 -0
- peclet_pnm-0.1.0/LICENSE +21 -0
- peclet_pnm-0.1.0/PKG-INFO +150 -0
- peclet_pnm-0.1.0/README.md +136 -0
- peclet_pnm-0.1.0/cmake/PecletDeps.cmake +123 -0
- peclet_pnm-0.1.0/docs/Doxyfile +69 -0
- peclet_pnm-0.1.0/packaging/pnm_init.py +7 -0
- peclet_pnm-0.1.0/pyproject.toml +58 -0
- peclet_pnm-0.1.0/scripts/demo_network_flow_packing.py +69 -0
- peclet_pnm-0.1.0/scripts/test_extraction.py +69 -0
- peclet_pnm-0.1.0/scripts/verify_network_flow.py +254 -0
- peclet_pnm-0.1.0/scripts/verify_segmentation.py +66 -0
- peclet_pnm-0.1.0/scripts/vti.py +80 -0
- peclet_pnm-0.1.0/src/pnm_bindings.cpp +318 -0
- peclet_pnm-0.1.0/src/pore_extraction.hpp +1029 -0
- peclet_pnm-0.1.0/src/pore_extraction_mpi.hpp +1836 -0
- peclet_pnm-0.1.0/src/sdf_reader.cpp +152 -0
- peclet_pnm-0.1.0/src/sdf_reader.h +25 -0
- peclet_pnm-0.1.0/tests/kokkos_mpi/CMakeLists.txt +37 -0
- peclet_pnm-0.1.0/tests/kokkos_mpi/test_pnm_flow_mpi.cpp +166 -0
- peclet_pnm-0.1.0/tests/kokkos_mpi/test_pnm_mpi.cpp +174 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
# Build the Kokkos (OpenMP/Serial) backend from source, then build the peclet.pnm module and run the
|
|
4
|
+
# extraction smoke test on a synthetic sphere-lattice SDF. GPU (CUDA/HIP) backends are not
|
|
5
|
+
# exercisable on free GitHub runners; the portable host build catches the vast majority of
|
|
6
|
+
# regressions (the Kokkos source is backend-agnostic).
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
branches: [main, "**"]
|
|
11
|
+
pull_request:
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
env:
|
|
15
|
+
KOKKOS_TAG: "5.1.1" # keep in lockstep with ../tools/bootstrap_deps.sh
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
build-test:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
|
|
23
|
+
- name: Install toolchain
|
|
24
|
+
run: |
|
|
25
|
+
sudo apt-get update
|
|
26
|
+
sudo apt-get install -y cmake ninja-build libomp-dev python3-dev python3-pip
|
|
27
|
+
python3 -m pip install --upgrade pip nanobind numpy
|
|
28
|
+
|
|
29
|
+
- name: Cache Kokkos install
|
|
30
|
+
id: kokkos-cache
|
|
31
|
+
uses: actions/cache@v6
|
|
32
|
+
with:
|
|
33
|
+
path: ${{ github.workspace }}/kokkos-install
|
|
34
|
+
key: kokkos-${{ env.KOKKOS_TAG }}-openmp-${{ runner.os }}
|
|
35
|
+
|
|
36
|
+
- name: Build Kokkos (OpenMP + Serial)
|
|
37
|
+
if: steps.kokkos-cache.outputs.cache-hit != 'true'
|
|
38
|
+
run: |
|
|
39
|
+
git clone --depth 1 --branch "$KOKKOS_TAG" https://github.com/kokkos/kokkos.git /tmp/kokkos
|
|
40
|
+
cmake -S /tmp/kokkos -B /tmp/kokkos/build -G Ninja \
|
|
41
|
+
-DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STANDARD=20 \
|
|
42
|
+
-DCMAKE_POSITION_INDEPENDENT_CODE=ON \
|
|
43
|
+
-DKokkos_ENABLE_OPENMP=ON -DKokkos_ENABLE_SERIAL=ON \
|
|
44
|
+
-DCMAKE_INSTALL_PREFIX="$GITHUB_WORKSPACE/kokkos-install"
|
|
45
|
+
cmake --build /tmp/kokkos/build -j
|
|
46
|
+
cmake --install /tmp/kokkos/build
|
|
47
|
+
|
|
48
|
+
- name: Build the pnm module
|
|
49
|
+
run: |
|
|
50
|
+
cmake -S . -B build \
|
|
51
|
+
-DCMAKE_PREFIX_PATH="$GITHUB_WORKSPACE/kokkos-install"
|
|
52
|
+
cmake --build build -j
|
|
53
|
+
|
|
54
|
+
- name: Extraction smoke test (synthetic sphere-lattice SDF)
|
|
55
|
+
run: |
|
|
56
|
+
PYTHONPATH=$PWD/build python3 - <<'EOF'
|
|
57
|
+
import numpy as np
|
|
58
|
+
import peclet.pnm as pnm
|
|
59
|
+
print("peclet.pnm on", pnm.execution_space)
|
|
60
|
+
# 2x2x2 lattice of solid spheres in a periodic-ish box: pores at the interstices.
|
|
61
|
+
n, R = 32, 10.0
|
|
62
|
+
z, y, x = np.meshgrid(*(np.arange(n, dtype=np.float32),) * 3, indexing="ij")
|
|
63
|
+
sdf = np.full((n, n, n), np.inf, dtype=np.float32)
|
|
64
|
+
for cz in (8, 24):
|
|
65
|
+
for cy in (8, 24):
|
|
66
|
+
for cx in (8, 24):
|
|
67
|
+
d = np.sqrt((x - cx) ** 2 + (y - cy) ** 2 + (z - cz) ** 2) - R
|
|
68
|
+
sdf = np.minimum(sdf, d.astype(np.float32))
|
|
69
|
+
pores, seg, conns = pnm.extract_pore_network(sdf, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0])
|
|
70
|
+
print(f"{len(pores)} pores, {len(set(seg))} segments, {len(conns)} connections")
|
|
71
|
+
assert len(pores) > 0 and len(conns) > 0
|
|
72
|
+
# Staged path agrees on the pore count.
|
|
73
|
+
assert len(pnm.extract_pores(sdf, [0.0] * 3, [1.0] * 3)) == len(pores)
|
|
74
|
+
EOF
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
name: Documentation
|
|
2
|
+
|
|
3
|
+
# Build the Doxygen API documentation (Kokkos C++ in src/, Python in scripts/ & tests/) and publish it
|
|
4
|
+
# to GitHub Pages. Requires Pages to be enabled for the repository with "Source: GitHub Actions"
|
|
5
|
+
# (Settings -> Pages). Runs on every push to main; can also be triggered manually. Doxygen only parses
|
|
6
|
+
# the sources (no Kokkos toolchain needed), so this runs on a plain ubuntu runner.
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
branches: [main]
|
|
11
|
+
paths:
|
|
12
|
+
- 'src/**'
|
|
13
|
+
- 'scripts/**'
|
|
14
|
+
- 'tests/**'
|
|
15
|
+
- 'docs/**'
|
|
16
|
+
- 'README.md'
|
|
17
|
+
- '.github/workflows/docs.yml'
|
|
18
|
+
workflow_dispatch:
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
pages: write
|
|
23
|
+
id-token: write
|
|
24
|
+
|
|
25
|
+
# allow one concurrent deployment; cancel an in-progress run for a newer push
|
|
26
|
+
concurrency:
|
|
27
|
+
group: pages
|
|
28
|
+
cancel-in-progress: true
|
|
29
|
+
|
|
30
|
+
jobs:
|
|
31
|
+
build:
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- name: Checkout
|
|
35
|
+
uses: actions/checkout@v7
|
|
36
|
+
|
|
37
|
+
- name: Install Doxygen + Graphviz
|
|
38
|
+
run: |
|
|
39
|
+
sudo apt-get update
|
|
40
|
+
sudo apt-get install -y doxygen graphviz
|
|
41
|
+
|
|
42
|
+
- name: Build documentation
|
|
43
|
+
run: doxygen docs/Doxyfile
|
|
44
|
+
|
|
45
|
+
- name: Upload Pages artifact
|
|
46
|
+
uses: actions/upload-pages-artifact@v5
|
|
47
|
+
with:
|
|
48
|
+
path: docs/html
|
|
49
|
+
|
|
50
|
+
deploy:
|
|
51
|
+
needs: build
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
environment:
|
|
54
|
+
name: github-pages
|
|
55
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
56
|
+
steps:
|
|
57
|
+
- name: Deploy to GitHub Pages
|
|
58
|
+
id: deployment
|
|
59
|
+
uses: actions/deploy-pages@v5
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Build + publish peclet-pnm to PyPI on a version tag: sdist + self-contained OpenMP CPU wheels
|
|
4
|
+
# (FetchContent Kokkos OpenMP+Serial, PECLET_VENDOR_DEPS=ON from [tool.cibuildwheel]).
|
|
5
|
+
# Uses PyPI Trusted Publishing (OIDC): register this workflow as the publisher for `peclet-pnm` on
|
|
6
|
+
# PyPI first — no API token secret needed. (A CUDA wheel variant à la peclet-flow-cu13 can be added
|
|
7
|
+
# later; the CMake rpath hook PECLET_CUDA_RUNTIME_WHEEL is already in place.)
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
push:
|
|
11
|
+
tags: ["v*"]
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
sdist:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v7
|
|
19
|
+
- uses: actions/setup-python@v6
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
- run: pip install build
|
|
23
|
+
- run: python -m build --sdist
|
|
24
|
+
- uses: actions/upload-artifact@v4
|
|
25
|
+
with:
|
|
26
|
+
name: sdist
|
|
27
|
+
path: dist/*.tar.gz
|
|
28
|
+
|
|
29
|
+
wheels:
|
|
30
|
+
name: wheels (manylinux_2_28 x86_64)
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v7
|
|
34
|
+
# cibuildwheel reads [tool.cibuildwheel] from pyproject.toml (PECLET_VENDOR_DEPS=ON): each wheel
|
|
35
|
+
# compiles Kokkos as a subproject, so builds are slow but self-contained.
|
|
36
|
+
- uses: pypa/cibuildwheel@v2.21
|
|
37
|
+
- uses: actions/upload-artifact@v4
|
|
38
|
+
with:
|
|
39
|
+
name: wheels-linux
|
|
40
|
+
path: wheelhouse/*.whl
|
|
41
|
+
|
|
42
|
+
publish:
|
|
43
|
+
needs: [sdist, wheels]
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
|
46
|
+
environment: pypi
|
|
47
|
+
permissions:
|
|
48
|
+
id-token: write # required for trusted publishing
|
|
49
|
+
steps:
|
|
50
|
+
- uses: actions/download-artifact@v4
|
|
51
|
+
with:
|
|
52
|
+
path: dist
|
|
53
|
+
merge-multiple: true
|
|
54
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
55
|
+
with:
|
|
56
|
+
skip-existing: true
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Build directories
|
|
2
|
+
build/
|
|
3
|
+
build_*/
|
|
4
|
+
*.so
|
|
5
|
+
*.a
|
|
6
|
+
*.o
|
|
7
|
+
*.log
|
|
8
|
+
|
|
9
|
+
# Python
|
|
10
|
+
__pycache__/
|
|
11
|
+
*.pyc
|
|
12
|
+
.venv
|
|
13
|
+
venv/
|
|
14
|
+
*.egg-info/
|
|
15
|
+
dist/
|
|
16
|
+
|
|
17
|
+
# IDE/Editor
|
|
18
|
+
.vscode/
|
|
19
|
+
.idea/
|
|
20
|
+
*.swp
|
|
21
|
+
*~
|
|
22
|
+
.DS_Store
|
|
23
|
+
|
|
24
|
+
# CMake
|
|
25
|
+
CMakeCache.txt
|
|
26
|
+
CMakeFiles/
|
|
27
|
+
cmake_install.cmake
|
|
28
|
+
_deps/
|
|
29
|
+
.cache/
|
|
30
|
+
|
|
31
|
+
# Data / output assets
|
|
32
|
+
data/
|
|
33
|
+
*.vti
|
|
34
|
+
*.vtp
|
|
35
|
+
*.edges
|
|
36
|
+
*.png
|
|
37
|
+
output/
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it using the metadata below."
|
|
3
|
+
title: "peclet-pnm: GPU pore-network extraction from signed-distance-field geometry"
|
|
4
|
+
abstract: >-
|
|
5
|
+
Pore-network extraction from a signed-distance-field (SDF) description of a porous solid: pore
|
|
6
|
+
detection (local SDF maxima + weighted centroids), marker-controlled watershed segmentation of the
|
|
7
|
+
pore space (union-find CCL + flood fill), and pore-to-pore throat topology. Written in Kokkos C++
|
|
8
|
+
(CUDA/HIP/OpenMP) and exposed to Python via nanobind as peclet.pnm. Part of the peclet suite;
|
|
9
|
+
split out of peclet-flow (its former peclet.flow.pnm module).
|
|
10
|
+
type: software
|
|
11
|
+
authors:
|
|
12
|
+
- family-names: Peters
|
|
13
|
+
given-names: Frank
|
|
14
|
+
email: e.a.j.f.peters@gmail.com
|
|
15
|
+
affiliation: "Eindhoven University of Technology"
|
|
16
|
+
version: 0.1.0
|
|
17
|
+
date-released: 2026-07-25
|
|
18
|
+
license: MIT
|
|
19
|
+
repository-code: "https://github.com/computational-chemical-engineering/peclet-pnm"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
`peclet.pnm` — pore-network extraction from SDF geometry, as an importable Python module. Split out
|
|
8
|
+
of `peclet-flow` (2026-07; it was `peclet.flow.pnm`, the original "pnm_from_sdf" feature). The
|
|
9
|
+
compute is **Kokkos** (CUDA / HIP / OpenMP — backend selected by the install prefix); the VTI reader
|
|
10
|
+
is pure C++. Part of the peclet suite (see `../CLAUDE.md` and `../docs/` for suite-wide conventions).
|
|
11
|
+
|
|
12
|
+
**Sources** (all under `src/`):
|
|
13
|
+
- `pore_extraction.hpp` — `namespace pnm`, header-only Kokkos compute: pore detection (local SDF
|
|
14
|
+
maxima + weighted centroid), marker-controlled watershed segmentation (marker init → union-find
|
|
15
|
+
CCL → flood fill), gradient-path pore basins, boundary-pair throat topology. Device kernels live
|
|
16
|
+
in the `.hpp` compiled as C++ (never `.cu`).
|
|
17
|
+
- `pnm_bindings.cpp` — the nanobind module `peclet.pnm._pnm`: `SDFReader`, `extract_pores`,
|
|
18
|
+
`segment_volume`, `extract_topology_gpu`, and the fused `extract_pore_network` (SDF uploaded once,
|
|
19
|
+
segmentation device-resident across stages). Uses core's zero-copy View↔ndarray bridge.
|
|
20
|
+
- `sdf_reader.{h,cpp}` — pure-C++ VTI (VTK ImageData) reader, backend-free.
|
|
21
|
+
- `pore_extraction.hpp` also holds **`extract_network_flow_k`** (binding `extract_network_flow`):
|
|
22
|
+
throat flow rates + pore-center pressures from a peclet.flow MAC field — the method from the
|
|
23
|
+
Voronoi PNM (`~/Codes/pnm_voronoi`). KEY FACTS: flow's `u(i,j,k)` is the **-x face** of cell
|
|
24
|
+
(i,j,k) and the conserved flux is `ox·u·A` (openness-weighted; `cutcell_pressure=True`
|
|
25
|
+
REQUIRED or all openness is 0); fluxes accumulate on **flow basins** (gradient ascent from
|
|
26
|
+
EVERY cell — seg-keyed accumulation lets near-wall staircase flux bypass throats, measured 6%);
|
|
27
|
+
throat dp uses a throat-anchored two-leg min-image (single min-image is ambiguous at L/2);
|
|
28
|
+
per-pore residual ~ solver tolerance is the built-in correctness check. Validated:
|
|
29
|
+
`scripts/verify_network_flow.py` (|Q|=F to 1e-11 on tube networks, g>0, Kirchhoff exact).
|
|
30
|
+
On loose packings per-throat g=Q/dp scatters (intra-pore viscous variation ~ throat drops —
|
|
31
|
+
real physics, not a bug; the p field's grid-scale roughness μ∇²u·h ≈ 20× the macro gradient).
|
|
32
|
+
GHOST-CELL IBM (`set_ghost_projection`): pass flow's `get_ox_proj/...` (binary COUPLED
|
|
33
|
+
openness); bookkeeping is truncation-accurate there (ghost IBM is NOT locally conservative at
|
|
34
|
+
the wall — pore_residual = wall leak, 3.2e-2·F at r=4h, order ~2.7). MPI:
|
|
35
|
+
`extract_network_flow_mpi` (pore_extraction_mpi.hpp) — flow-basin labels resolved by
|
|
36
|
+
propagating the LABEL with the hold-at-ghost finalization trick; global network identical on
|
|
37
|
+
every rank. GOTCHA: periodic-image decisions (face min-image + the throat dp image count) MUST
|
|
38
|
+
anchor on integer peak-voxel coords / snapped-integer arithmetic — float-centroid anchoring
|
|
39
|
+
flips images at exactly L/2 under CUDA FMA wobble (measured flaky np4 failures on symmetric
|
|
40
|
+
lattices). THROATS ARE PER-PATCH (parallel throats resolved): face-CCL keyed by min global
|
|
41
|
+
face id (fid = 3*gid+d); CORE tier = both cells FLUID-centered (an OPENNESS threshold cannot
|
|
42
|
+
separate throats bridged by wall films — staircase faces reach opn ~0.7; the fluid-centered
|
|
43
|
+
criterion is geometric and parameter-free); films attach to the min reachable core patch by
|
|
44
|
+
Jacobi min-propagation (cannot bridge two cores), unreachable films form their own patches.
|
|
45
|
+
- `pore_extraction_mpi.hpp` — the **distributed** pipeline (gated `PECLET_PNM_MPI`): core ORB
|
|
46
|
+
decomposition + g=1 `GridHalo` exchange; labels are GLOBAL voxel ids so every fixpoint is
|
|
47
|
+
decomposition-independent → **bit-exact to single-rank**. Stage design: local union-find CCL +
|
|
48
|
+
one-shot boundary-graph merge (allgathered surface pairs, host union-find); Jacobi flood
|
|
49
|
+
(sweep-for-sweep = the single-rank Jacobi flood); gradient roots via hold-at-ghost pointer
|
|
50
|
+
jumping with a `~root` finalization marker (NEVER store a remote mid-chain gid — that strands
|
|
51
|
+
the chase outside the ghost ring and fragments basins; measured on plateau-heavy fields);
|
|
52
|
+
renumber by global min-appearance gid (= single-rank first-encounter order).
|
|
53
|
+
|
|
54
|
+
## Build & test
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# nanobind found via the active interpreter; Kokkos from the suite prefix
|
|
58
|
+
# (../extern/install/<backend>, built once by ../tools/bootstrap_deps.sh).
|
|
59
|
+
cmake -S . -B build -DCMAKE_PREFIX_PATH="$PWD/../extern/install/nvidia-cuda"
|
|
60
|
+
cmake --build build -j # -> build/peclet/pnm/_pnm.*.so
|
|
61
|
+
PYTHONPATH=$PWD/build python scripts/test_extraction.py <sdf.vti> # pore extraction smoke test
|
|
62
|
+
PYTHONPATH=$PWD/build python scripts/verify_segmentation.py <sdf.vti> # watershed + topology
|
|
63
|
+
# Canonical install: CMAKE_PREFIX_PATH=... pip install . (-> peclet.pnm)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Without a prefix, `cmake/PecletDeps.cmake` vendors Kokkos (OpenMP+Serial) + the peclet-core headers
|
|
67
|
+
via FetchContent (self-contained wheel path, `PECLET_VENDOR_DEPS=ON` in cibuildwheel). Keep the
|
|
68
|
+
`PECLET_*_TAG` pins in lockstep with `../tools/bootstrap_deps.sh`.
|
|
69
|
+
|
|
70
|
+
Multi-rank (MPI): add `-DPECLET_PNM_MPI=ON` to expose `mpi_rank`/`mpi_size`/`mpi_block` +
|
|
71
|
+
`extract_pore_network_mpi` (collective; see README). The C++ MPI ctests (np=1,2,4, distributed vs
|
|
72
|
+
single-rank oracle, bit-exact):
|
|
73
|
+
```bash
|
|
74
|
+
cmake -S tests/kokkos_mpi -B build_kmpi -DCMAKE_PREFIX_PATH=$PWD/../extern/install/nvidia-cuda \
|
|
75
|
+
-DMPIEXEC_EXECUTABLE=/usr/bin/mpirun # FORCE mpirun — ParaView's mpiexec runs singletons
|
|
76
|
+
cmake --build build_kmpi -j && ctest --test-dir build_kmpi --output-on-failure
|
|
77
|
+
```
|
|
78
|
+
GPU pore-centroid caveat: nvcc FMA-contracts the centroid accumulation differently in the oracle
|
|
79
|
+
vs distributed kernels, so pore POSITIONS are compared to 1e-5·spacing on CUDA (bitwise on
|
|
80
|
+
OpenMP); seg ids, radii and connections are bitwise everywhere. The single-rank flood fill is
|
|
81
|
+
deliberately Jacobi (double-buffered) — deterministic AND what the distributed flood matches
|
|
82
|
+
sweep-for-sweep; don't "optimize" it back to in-place.
|
|
83
|
+
|
|
84
|
+
## Conventions
|
|
85
|
+
|
|
86
|
+
- SDF sign: **negative inside solid**, positive in the pore space (suite-wide).
|
|
87
|
+
- Python arrays are `(Nz, Ny, Nx)` C-order (x fastest — contiguous with the flat x-fastest layout);
|
|
88
|
+
`origin`/`spacing` tuples are **z-y-x**. `segment_volume` returns a flat label vector.
|
|
89
|
+
- `Kokkos::initialize` happens at import; `Kokkos::finalize` is registered via `atexit`
|
|
90
|
+
(REQUIRED on CUDA — see the comment in `pnm_bindings.cpp`).
|
|
91
|
+
- Test VTI inputs live in the suite (e.g. `../flow/data/packing_ring.vti`), not in this repo.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
cmake_minimum_required(VERSION 3.24)
|
|
2
|
+
project(peclet_pnm LANGUAGES CXX)
|
|
3
|
+
|
|
4
|
+
# peclet.pnm — pore-network extraction from SDF geometry (Kokkos GPU compute + the pure-C++ SDFReader
|
|
5
|
+
# VTI reader), as an importable Python module. Split out of peclet-flow (2026-07): the CFD solve lives
|
|
6
|
+
# in peclet.flow; this package is the "pnm_from_sdf" extraction feature on its own.
|
|
7
|
+
#
|
|
8
|
+
# The Kokkos backend (CUDA / HIP / OpenMP) is selected by the install prefix
|
|
9
|
+
# (extern/install/<backend>, built by ../tools/bootstrap_deps.sh), exactly like flow/dem.
|
|
10
|
+
#
|
|
11
|
+
# Build (nanobind is found via the active interpreter):
|
|
12
|
+
# cmake -S . -B build -DCMAKE_PREFIX_PATH="$PWD/../extern/install/nvidia-cuda"
|
|
13
|
+
# cmake --build build -j -> build/peclet/pnm/_pnm.*.so (import as peclet.pnm)
|
|
14
|
+
|
|
15
|
+
set(CMAKE_CXX_STANDARD 20)
|
|
16
|
+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
17
|
+
if(NOT CMAKE_BUILD_TYPE)
|
|
18
|
+
set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
|
|
19
|
+
endif()
|
|
20
|
+
|
|
21
|
+
# Redistributable single-GPU CUDA wheel: libcudart is provided by the `nvidia-cuda-runtime` PyPI
|
|
22
|
+
# dependency (installed to site-packages/nvidia/cu13/lib), not the system. Mirrors peclet-flow.
|
|
23
|
+
option(PECLET_CUDA_RUNTIME_WHEEL "RPATH the module to the nvidia-cuda-runtime wheel's libcudart" OFF)
|
|
24
|
+
|
|
25
|
+
# Multi-rank: -DPECLET_PNM_MPI=ON links MPI + exposes mpi_rank/mpi_size/mpi_block and
|
|
26
|
+
# extract_pore_network_mpi (the distributed extraction on the core ORB decomposition, bit-exact to
|
|
27
|
+
# single-rank). OFF (default) leaves the single-rank module byte-identical. Mirrors PECLET_FLOW_MPI.
|
|
28
|
+
option(PECLET_PNM_MPI "Build the pnm module with the distributed (MPI) extraction exposed" OFF)
|
|
29
|
+
|
|
30
|
+
# Dependencies via the vendored PecletDeps helper: an installed Kokkos prefix + sibling checkout for
|
|
31
|
+
# the dev/suite build, or FetchContent-built Kokkos + fetched core headers for a self-contained
|
|
32
|
+
# sdist/wheel (cibuildwheel). See cmake/PecletDeps.cmake.
|
|
33
|
+
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
|
|
34
|
+
include(PecletDeps)
|
|
35
|
+
peclet_require_kokkos()
|
|
36
|
+
peclet_require_nanobind()
|
|
37
|
+
peclet_sibling_include(peclet-core "${PECLET_TPX_TAG}" "../core" TPX_INC)
|
|
38
|
+
|
|
39
|
+
# pnm — pore-network extraction (Kokkos compute + pure-C++ SDFReader VTI reader).
|
|
40
|
+
# NB_STATIC: bundle nanobind's runtime into the module. NOMINSIZE: nanobind's -Os is rejected by nvcc
|
|
41
|
+
# since the Kokkos device sources compile as CXX through the launch compiler. The extension is
|
|
42
|
+
# assembled into the PEP-420 peclet namespace as `peclet.pnm` (private `_pnm` re-exported by
|
|
43
|
+
# peclet/pnm/__init__.py, kept as a plain file under packaging/ and staged into <build>/peclet/... so
|
|
44
|
+
# `PYTHONPATH=<build> python …` works in the dev loop too).
|
|
45
|
+
nanobind_add_module(pnm NB_STATIC NOMINSIZE src/pnm_bindings.cpp src/sdf_reader.cpp)
|
|
46
|
+
set_target_properties(pnm PROPERTIES OUTPUT_NAME _pnm # -> peclet.pnm._pnm (NB_MODULE(_pnm))
|
|
47
|
+
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/peclet/pnm")
|
|
48
|
+
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/packaging/pnm_init.py
|
|
49
|
+
${CMAKE_CURRENT_BINARY_DIR}/peclet/pnm/__init__.py COPYONLY)
|
|
50
|
+
target_include_directories(pnm PRIVATE src "${TPX_INC}")
|
|
51
|
+
target_link_libraries(pnm PRIVATE Kokkos::kokkos)
|
|
52
|
+
# HIP/lld strictness: nanobind's --gc-sections can drop Kokkos SharedAllocationRecord<HIPSpace>
|
|
53
|
+
# vtables it only later finds referenced. Keep sections on HIP (nvcc/ld is lenient).
|
|
54
|
+
if(Kokkos_ENABLE_HIP)
|
|
55
|
+
target_link_options(pnm PRIVATE -Wl,--no-gc-sections)
|
|
56
|
+
endif()
|
|
57
|
+
|
|
58
|
+
# Distributed (MPI) extraction: compile the binding TU with the gating flag + link MPI (the core
|
|
59
|
+
# grid halo lives in the header-only TPX_INC). Mirrors flow's PECLET_FLOW_MPI wiring.
|
|
60
|
+
if(PECLET_PNM_MPI)
|
|
61
|
+
find_package(MPI REQUIRED COMPONENTS CXX)
|
|
62
|
+
target_compile_definitions(pnm PRIVATE PECLET_PNM_MPI=1)
|
|
63
|
+
target_link_libraries(pnm PRIVATE MPI::MPI_CXX)
|
|
64
|
+
message(STATUS "pnm: distributed extraction ENABLED (MPI)")
|
|
65
|
+
endif()
|
|
66
|
+
|
|
67
|
+
# Redistributable CUDA wheel: point the module at the nvidia-cuda-runtime wheel's libcudart via a
|
|
68
|
+
# relative $ORIGIN RPATH (peclet/pnm -> site-packages -> nvidia/cu13/lib).
|
|
69
|
+
if(PECLET_CUDA_RUNTIME_WHEEL)
|
|
70
|
+
set_target_properties(pnm PROPERTIES
|
|
71
|
+
INSTALL_RPATH "$ORIGIN/../../nvidia/cu13/lib" INSTALL_RPATH_USE_LINK_PATH OFF)
|
|
72
|
+
endif()
|
|
73
|
+
|
|
74
|
+
# --- pip / scikit-build-core install rule -------------------------------------------------------
|
|
75
|
+
if(DEFINED SKBUILD)
|
|
76
|
+
install(TARGETS pnm LIBRARY DESTINATION peclet/pnm COMPONENT python)
|
|
77
|
+
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/packaging/pnm_init.py
|
|
78
|
+
DESTINATION peclet/pnm RENAME __init__.py COMPONENT python)
|
|
79
|
+
endif()
|
peclet_pnm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frank Peters
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: peclet-pnm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: peclet.pnm — Kokkos pore-network extraction from SDF geometry (pores, watershed segmentation, throat topology)
|
|
5
|
+
Author-Email: Frank Peters <e.a.j.f.peters@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Project-URL: Homepage, https://github.com/computational-chemical-engineering/peclet
|
|
9
|
+
Project-URL: Documentation, https://github.com/computational-chemical-engineering/peclet
|
|
10
|
+
Project-URL: Source, https://github.com/computational-chemical-engineering/peclet-pnm
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: numpy>=1.20
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# peclet-pnm
|
|
16
|
+
|
|
17
|
+
**`peclet.pnm` — GPU pore-network extraction from SDF geometry.**
|
|
18
|
+
|
|
19
|
+
Given a signed-distance-field (SDF) description of a porous solid (negative inside the solid,
|
|
20
|
+
positive in the pore space), `peclet.pnm` extracts the pore network:
|
|
21
|
+
|
|
22
|
+
- **`SDFReader`** — pure-C++ VTI (VTK ImageData) reader for SDF volumes.
|
|
23
|
+
- **`extract_pores`** — pore detection: local maxima of the SDF + weighted centroids and radii.
|
|
24
|
+
- **`segment_volume`** — marker-controlled watershed segmentation of the pore space
|
|
25
|
+
(marker init → union-find connected-component labelling → flood fill).
|
|
26
|
+
- **`extract_topology_gpu`** — pore-to-pore connectivity (throats) from boundary pairs between basins.
|
|
27
|
+
- **`extract_pore_network`** — the fused pipeline (SDF uploaded once, segmentation device-resident
|
|
28
|
+
across all three stages): returns `(pores, segmentation, connections)` in one call.
|
|
29
|
+
|
|
30
|
+
The compute is [Kokkos](https://github.com/kokkos/kokkos) — the same source runs on **CUDA, HIP, and
|
|
31
|
+
OpenMP** backends, selected at build time by the install prefix. Part of the
|
|
32
|
+
[peclet](https://github.com/computational-chemical-engineering/peclet) suite; split out of
|
|
33
|
+
[peclet-flow](https://github.com/computational-chemical-engineering/peclet-flow) (its former
|
|
34
|
+
`peclet.flow.pnm` module — the repo's original "pnm_from_sdf" feature).
|
|
35
|
+
|
|
36
|
+
## Install / build
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# From the peclet suite checkout (Kokkos prefix bootstrapped once by ../tools/bootstrap_deps.sh):
|
|
40
|
+
CMAKE_PREFIX_PATH="$PWD/../extern/install/nvidia-cuda" pip install .
|
|
41
|
+
|
|
42
|
+
# Or a dev cmake build (nanobind found via the active interpreter):
|
|
43
|
+
cmake -S . -B build -DCMAKE_PREFIX_PATH="$PWD/../extern/install/nvidia-cuda"
|
|
44
|
+
cmake --build build -j # -> build/peclet/pnm/_pnm.*.so ; PYTHONPATH=$PWD/build to import
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Without a Kokkos prefix on `CMAKE_PREFIX_PATH`, the build vendors Kokkos (OpenMP+Serial) via
|
|
48
|
+
FetchContent, so `pip install .` works standalone on any Linux with a C++20 toolchain.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import peclet.pnm as pnm
|
|
54
|
+
|
|
55
|
+
sdf_3d, origin_zyx, spacing_zyx = pnm.SDFReader.read_vti("packing.vti") # (Nz,Ny,Nx) C-order
|
|
56
|
+
pores = pnm.extract_pores(sdf_3d, origin_zyx, spacing_zyx) # Pore(x,y,z,radius) list
|
|
57
|
+
seg = pnm.segment_volume(sdf_3d, spacing_zyx) # flat per-voxel pore label
|
|
58
|
+
conns = pnm.extract_topology_gpu(seg, list(sdf_3d.shape)) # [(label_a, label_b), ...]
|
|
59
|
+
|
|
60
|
+
# or fused (SDF uploaded once, segmentation stays device-resident across stages):
|
|
61
|
+
pores, seg, conns = pnm.extract_pore_network(sdf_3d, origin_zyx, spacing_zyx)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Conventions: the SDF array is `(Nz, Ny, Nx)` C-order (x fastest), `origin`/`spacing` are z-y-x;
|
|
65
|
+
SDF sign is negative inside the solid — see the suite's `docs/CONVENTIONS.md`.
|
|
66
|
+
|
|
67
|
+
Smoke tests: `python scripts/test_extraction.py <sdf.vti>` and
|
|
68
|
+
`python scripts/verify_segmentation.py <sdf.vti>` (writes a labelled `.vti` + a pore-pair edge list).
|
|
69
|
+
|
|
70
|
+
## Network flow: throat flow rates + pore pressures from a DNS
|
|
71
|
+
|
|
72
|
+
`extract_network_flow` turns a converged [peclet-flow](https://github.com/computational-chemical-engineering/peclet-flow)
|
|
73
|
+
velocity/pressure field on the same grid into pore-network flow data — the method carried over
|
|
74
|
+
from the Voronoi-tessellation PNM of sphere packings (`pnm_voronoi`), where the throat flow was
|
|
75
|
+
∫u·n over the Voronoi facet and the pore pressure a trilinear sample at the pore center:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
s = peclet.flow.Solver(nx, ny, nz)
|
|
79
|
+
...; s.set_body_force(fx, 0, 0); s.set_solid(sdf_xyz, cutcell_pressure=True); ...steps...
|
|
80
|
+
net = pnm.extract_network_flow(
|
|
81
|
+
sdf_zyx, origin_zyx, spacing_zyx,
|
|
82
|
+
s.get_uf().T, s.get_vf().T, s.get_wf().T, s.get_p().T, # zero-copy transposes to zyx
|
|
83
|
+
s.get_ox().T, s.get_oy().T, s.get_oz().T, # cut-cell face openness
|
|
84
|
+
grad_p_zyx=[0, 0, -fx]) # body force f == -grad p_macro
|
|
85
|
+
net["throat_flow"] # Q through each pore-pore interface (o·u·A summed over MAC faces)
|
|
86
|
+
net["pore_pressure"] # periodic p interpolated at each pore center (basin SDF peak)
|
|
87
|
+
net["throat_dp"] # total-pressure drop P_i - P_j (periodic parts + macro gradient
|
|
88
|
+
# along the throat-anchored min-image path)
|
|
89
|
+
net["pore_residual"] # signed flux over each pore's whole boundary — ~ solver tolerance
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
On the voxel network the throat integral is exact: a throat is a set of grid-aligned MAC faces
|
|
93
|
+
and the openness-weighted face velocity is the discrete flux carrier, so per-pore mass balance
|
|
94
|
+
holds to the pressure-solve tolerance (`pore_residual` is the built-in check). Fluxes are
|
|
95
|
+
accumulated on **flow basins** (gradient-ascent assignment of *every* cell, including cut cells
|
|
96
|
+
whose center is inside the solid) — keyed on the segmentation labels alone, the near-wall
|
|
97
|
+
staircase flux would bypass the interface (measured 6% on a tube).
|
|
98
|
+
|
|
99
|
+
**Both IBM variants are supported.** With the cut-cell IBM the bookkeeping is machine-exact
|
|
100
|
+
(pass `get_ox()...`). With the **ghost-cell IBM** (`set_ghost_projection(True)`) pass flow's
|
|
101
|
+
`get_ox_proj()/get_oy_proj()/get_oz_proj()` — the binary (COUPLED) openness the ghost projection
|
|
102
|
+
conserves. Ghost-cell IBM is pointwise 2nd-order but not locally mass-conserving at the wall, so
|
|
103
|
+
there the network data is truncation-accurate: `pore_residual` becomes the per-pore wall leak
|
|
104
|
+
(measured 3.2e-2·F at a 4-cell tube radius, converging at order ~2.7 under refinement).
|
|
105
|
+
|
|
106
|
+
**MPI:** `extract_network_flow_mpi(sdf_local, global_shape_zyx, ..., u_local, ...)` runs the whole
|
|
107
|
+
pipeline distributed on the core ORB blocks (fields from a distributed peclet.flow run on the
|
|
108
|
+
same decomposition); every rank returns the identical global network. Matches the single-rank
|
|
109
|
+
result to accumulation-order tolerance (`tests/kokkos_mpi/test_pnm_flow_mpi`, np = 1, 2, 4).
|
|
110
|
+
|
|
111
|
+
Validated in `scripts/verify_network_flow.py` (chamber-tube chain + asymmetric tube lattice +
|
|
112
|
+
the ghost-IBM chain, DNS by peclet.flow): every throat carries the DNS flux to ~1e-11 relative
|
|
113
|
+
(cut-cell), residuals ~1e-12·F, g = Q/dp > 0 on all throats, and the dp sum around each loop
|
|
114
|
+
equals the macroscopic drop.
|
|
115
|
+
`scripts/demo_network_flow_packing.py` runs the pipeline on a real sphere packing.
|
|
116
|
+
|
|
117
|
+
**Throats are per-patch:** a throat is a *connected patch* of interface faces (CCL over the
|
|
118
|
+
interface, core faces = both cells fluid-centered, wall-film faces attached by propagation), so
|
|
119
|
+
two disjoint interfaces between the same two pores — e.g. two parallel tubes, or a direct contact
|
|
120
|
+
plus one through the periodic wrap — are separate parallel throats and the throat list can repeat
|
|
121
|
+
a pore pair (validated: two capsules of different radii report two (1,2) throats whose fluxes sum
|
|
122
|
+
to the DNS flux exactly). Remaining caveat: on loose packings (porosity ≳ 0.6) intra-pore
|
|
123
|
+
pressure variation is comparable to throat drops, so per-throat g = Q/dp scatters — a property of
|
|
124
|
+
the point-pressure PNM abstraction, not of the extraction.
|
|
125
|
+
|
|
126
|
+
## Distributed (MPI) extraction
|
|
127
|
+
|
|
128
|
+
Built with `-DPECLET_PNM_MPI=ON`, the module also runs the whole pipeline **multi-rank**: the SDF
|
|
129
|
+
is decomposed over ranks by the shared peclet-core ORB (the same deterministic partition flow/dem
|
|
130
|
+
use), every stage runs per-rank on a 1-cell ghost layer (core `GridHalo` exchange), and the result
|
|
131
|
+
is **bit-exact to the single-rank pipeline** — labels are global voxel ids, so the CCL fixpoint,
|
|
132
|
+
the watershed flood (Jacobi), the gradient-path pore basins, and the renumbering are all
|
|
133
|
+
decomposition-independent.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
# mpirun -np 4 python extract.py
|
|
137
|
+
import peclet.pnm as pnm
|
|
138
|
+
origin, shape = pnm.mpi_block(global_shape_zyx) # this rank's ORB block of the global grid
|
|
139
|
+
local = sdf[origin[0]:origin[0]+shape[0], origin[1]:origin[1]+shape[1], origin[2]:origin[2]+shape[2]]
|
|
140
|
+
pores, seg, conns = pnm.extract_pore_network_mpi(local, global_shape_zyx, origin_zyx, spacing_zyx)
|
|
141
|
+
# pores: the pores whose peak this rank owns; seg: this rank's block; conns: global (identical everywhere)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Validated by `tests/kokkos_mpi` (ctest, np = 1, 2, 4, OpenMP + CUDA): per-voxel segmentation ids,
|
|
145
|
+
the pore set, and the connection list all match the single-rank oracle exactly (pore centroid
|
|
146
|
+
positions to 1e-5·spacing on GPU — FMA contraction noise; radii and everything integer bitwise).
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT.
|