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.
@@ -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()
@@ -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.