peclet-coupling 0.2.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,41 @@
1
+ name: Release
2
+
3
+ # Publish peclet-coupling to PyPI on a version tag via Trusted Publishing (OIDC) — configure the
4
+ # publisher on PyPI first (environment: pypi); no API token secret needed. SDIST ONLY: the coupling
5
+ # kernels are Kokkos device code built from source against the consumer's backend (like peclet-core),
6
+ # so a portable binary wheel is not meaningful. Consumers `pip install peclet-coupling` (or
7
+ # `peclet[cfd-dem]`) and build against a Kokkos prefix, or use the suite containers.
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@v4
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@v7
25
+ with:
26
+ name: sdist
27
+ path: dist/*.tar.gz
28
+
29
+ publish:
30
+ needs: [sdist]
31
+ runs-on: ubuntu-latest
32
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
33
+ environment: pypi
34
+ permissions:
35
+ id-token: write # required for trusted publishing
36
+ steps:
37
+ - uses: actions/download-artifact@v8
38
+ with:
39
+ path: dist
40
+ merge-multiple: true
41
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,4 @@
1
+ build*/
2
+ __pycache__/
3
+ *.so
4
+ tests/.moving_ref.json
@@ -0,0 +1,56 @@
1
+ cmake_minimum_required(VERSION 3.24)
2
+ project(peclet_coupling LANGUAGES CXX)
3
+
4
+ # peclet.coupling -- CFD-DEM exchange kernels (particle<->grid deposition, drag, momentum feedback)
5
+ # as an importable Python module. Physics-free glue: the Python CfdDem driver (python/peclet_coupling)
6
+ # composes peclet.flow + peclet.dem, and this module runs the kernels in place on the arrays they
7
+ # expose. Header-only compute (core interp primitive + drag laws), Kokkos backend from the install
8
+ # prefix (extern/install/<backend>), nanobind via the shared SuiteNanobind helper.
9
+ #
10
+ # cmake -S . -B build -DCMAKE_PREFIX_PATH="$PWD/../extern/install/host-openmp"
11
+ # cmake --build build -j -> build/peclet/coupling/_coupling.*.so
12
+
13
+ set(CMAKE_CXX_STANDARD 20)
14
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
15
+ if(NOT CMAKE_BUILD_TYPE)
16
+ set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE)
17
+ endif()
18
+
19
+ # Dependencies via the vendored PecletDeps helper: an installed Kokkos prefix + sibling checkout for
20
+ # the dev/suite build, or FetchContent-built Kokkos + fetched core headers (v-pinned) for a
21
+ # self-contained sdist build (`pip install peclet-coupling`). See cmake/PecletDeps.cmake.
22
+ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
23
+ include(PecletDeps)
24
+ peclet_require_kokkos()
25
+ peclet_require_nanobind()
26
+ peclet_sibling_include(peclet-core "${PECLET_TPX_TAG}" "../core" CORE_INC)
27
+
28
+ nanobind_add_module(coupling NB_STATIC NOMINSIZE src/coupling_bindings.cpp)
29
+ set_target_properties(coupling PROPERTIES OUTPUT_NAME _coupling # -> peclet.coupling._coupling
30
+ LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/peclet/coupling")
31
+ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/__init__.py
32
+ ${CMAKE_CURRENT_BINARY_DIR}/peclet/coupling/__init__.py COPYONLY)
33
+ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/driver.py
34
+ ${CMAKE_CURRENT_BINARY_DIR}/peclet/coupling/driver.py COPYONLY)
35
+ target_include_directories(coupling PRIVATE src "${CORE_INC}")
36
+ target_link_libraries(coupling PRIVATE Kokkos::kokkos)
37
+ if(Kokkos_ENABLE_HIP)
38
+ target_link_options(coupling PRIVATE -Wl,--no-gc-sections)
39
+ endif()
40
+
41
+ option(PECLET_COUPLING_TESTS "Register the Python CFD-DEM integration tests as ctests" OFF)
42
+ if(PECLET_COUPLING_TESTS)
43
+ enable_testing()
44
+ add_subdirectory(tests)
45
+ endif()
46
+
47
+ # --- pip / scikit-build-core install rule --------------------------------------------------------
48
+ # Places the extension + the peclet/coupling package (__init__.py + driver.py) into the PEP-420
49
+ # peclet namespace. In an sdist build the core headers + nanobind come from PecletDeps (fetched at
50
+ # the pinned tag), so the source distribution is self-contained.
51
+ if(DEFINED SKBUILD)
52
+ install(TARGETS coupling LIBRARY DESTINATION peclet/coupling COMPONENT python)
53
+ install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/__init__.py
54
+ ${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/driver.py
55
+ DESTINATION peclet/coupling COMPONENT python)
56
+ 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,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: peclet-coupling
3
+ Version: 0.2.0
4
+ Summary: peclet.coupling — unresolved point-particle CFD-DEM coupling of peclet.flow + peclet.dem
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-coupling
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: numpy>=1.20
13
+ Requires-Dist: peclet-flow>=0.3.0
14
+ Requires-Dist: peclet-dem>=0.3.2
15
+ Description-Content-Type: text/markdown
16
+
17
+ # peclet.coupling — unresolved point-particle CFD-DEM
18
+
19
+ Two-way coupling of `peclet.flow` (Eulerian fluid) and `peclet.dem` (Lagrangian particles) for
20
+ dilute-to-dense point-particle suspensions and packed beds. Multiphysics Phase 6 — see
21
+ `../docs/MULTIPHYSICS_PLAN.md`.
22
+
23
+ ## Design
24
+
25
+ Physics-free glue. The compute kernels (particle↔grid deposition, drag laws, momentum feedback) live
26
+ in the `_coupling` nanobind extension and run **in place** on the arrays the two solvers already
27
+ expose — the fluid grid fields zero-copy through `flow.field_view(...)`, the particle drag
28
+ round-tripped through the dem host API. **There is no C++ link between flow and dem**: the Python
29
+ `CfdDem` driver (`python/peclet_coupling/driver.py`) composes them. This mirrors the suite's
30
+ architecture (Python is the composition layer).
31
+
32
+ Per fluid step (`CfdDem.step()`):
33
+ 1. **Void fraction** — scatter each particle's volume onto the grid (trilinear, **wall-aware**: near
34
+ an immersed solid the weights re-normalise over the fluid corners so no hold-up leaks into walls),
35
+ periodic-fold the ghost deposits, and `ε = clamp(1 − Vsolid/Vcell, eps_min, 1)`. The floor
36
+ `eps_min` defaults to 0.4 ≈ the random-close-packing voidage (the drag correlations are invalid,
37
+ and Ergun's `1/ε` powers explosive, below a physical packing).
38
+ 2. **Drag + feedback** — gather the fluid velocity and ε at each particle, evaluate the drag law
39
+ (Stokes / Schiller–Naumann / Ergun / Di Felice / Wen & Yu / Gidaspow), write the drag force to the
40
+ particles and deposit the reaction onto the fluid momentum source.
41
+ 3. **Advance** — apply the drag to the particles and sub-step dem `dem_substeps` times (drag held
42
+ constant), then advance the fluid one step (its RHS/operator now carry the feedback).
43
+
44
+ ### Implicit drag (the key stability piece)
45
+
46
+ An explicit reaction force `−β(u−u_p)` in the fluid RHS **diverges** for the stiff drag coefficient
47
+ β of a dense bed (β·dt/ρ ≫ 1; local β reaches ~10³). So the default feedback is **semi-implicit**:
48
+ the coupling deposits the linear-drag *coefficient* density onto flow's `drag_beta` field (added to
49
+ the momentum diagonal by `flow.enable_drag()`) and the target `β·u_p` onto `force_*` (the RHS), so
50
+ the fluid solve becomes `(ρ/dt + β)u = … + β u_p` — unconditionally stable for any β. The particle
51
+ side stays explicit (fine for moving particles at moderate β). `implicit_drag=False` selects the
52
+ explicit `−F/Vcell` feedback (dilute only).
53
+
54
+ ### Two fluid modes
55
+
56
+ - **`porous=True` — volume-averaged (use this for beds).** The fluid solves the full volume-averaged
57
+ continuity `∂ε/∂t + ∇·(εu) = 0` (u = the **interstitial** gas velocity) with a SIMPLE-like eps- and
58
+ drag-weighted pressure projection — scheme, defaults and validation in
59
+ `flow/doc/porous_drag_scheme.md`. The pressure-force split is **Model B**: the gas carries the full
60
+ `−∇p`, the particles get drag + gravity, and the literature (Model-A) drag closures are converted
61
+ once inside the kernel, `β_B = β_A/ε` (`model_b` flag). Gas convection (implicit FOU + explicit
62
+ deferred-correction TVD) is enabled by the driver by default (`advection=True`).
63
+ - **`porous=False` — dilute simplification.** The fluid stays incompressible (`div u = 0`); ε enters
64
+ the drag correlation only. Cheap and validated dilute→moderate. Note this is *not* "Model B":
65
+ Models A and B both use the full continuity and differ only in the `−ε∇p` vs `−∇p` split.
66
+
67
+ Other scope notes: deposition uses `atomic_add` ⇒ results are tolerance-, not bit-exact; the `"ergun"`
68
+ drag *kind* is the superficial-velocity form built for the incompressible mode — for porous beds use
69
+ `"gidaspow"` (its dense branch is the classic interstitial Ergun form).
70
+
71
+ ## Backends
72
+
73
+ `CfdDem` runs on whatever Kokkos backend `peclet.flow` was built for. On a **CUDA/HIP** build the
74
+ coupling kernels run on-device, so the driver array-programs through **CuPy** and takes the grid
75
+ fields (`flow.field_view`) and particle state (`dem.get_*_view`) zero-copy via DLPack; on a host
76
+ build it uses NumPy over the same buffers. Detected automatically from `peclet.flow.execution_space`.
77
+
78
+ ## Validation (`tests/`)
79
+
80
+ Both cases pass identically on **host-openmp and CUDA (RTX 5080)**:
81
+ - **`test_terminal_velocity.py`** — single settling sphere: the slip velocity matches Stokes to
82
+ **0.1–0.2 %** and Schiller–Naumann to **1.4–1.6 %** (the lab-frame speed is ~2× the slip because
83
+ the particle drags its own Stokeslet flow, so the physical comparison is the slip).
84
+ - **`test_fixed_bed_ergun.py`** — uniform fixed bed (one particle per cell, ε = 0.6): the measured
85
+ (f_drive, U) pair lands on the Ergun curve to **0.0 %** across the viscous, transition and inertial
86
+ (Re_p ≈ 6) regimes — validating ε deposition, both Ergun terms, and the stable two-way feedback.
87
+ - **`test_fixed_bed_ergun_porous.py`** — the same bed on the **volume-averaged (porous, Model B)**
88
+ path with the Gidaspow closure: (f_drive, U = ε·u_interstitial) lands on the Ergun curve to ~3 %
89
+ across all three regimes with no fitted factors — validating the eps-weighted projection, the
90
+ interstitial kinematics and the `β_B = β_A/ε` conversion together.
91
+ - **`test_mpi_fixed_bed_ergun.py`** — the fixed-bed Ergun benchmark run **distributed** (flow
92
+ `init_mpi`, each rank couples its ORB block; particle deposits fold across ranks + periodically via
93
+ the reverse/add-reduce halo `exchange_field_add`, deposit origin shifted by the block origin). The
94
+ superficial velocity U (reduced over ranks) lands on the Ergun curve to **0.0 %** and is
95
+ **bit-identical at np=1/2/4** — the distributed deposition + fold + solve reproduce the coupled
96
+ physics exactly.
97
+ - P2G/G2P conservation + the gather/scatter adjoint identity: `core` `test_particle_grid` (host + CUDA).
98
+
99
+ ## Multi-rank coupling
100
+
101
+ `CfdDem` runs distributed when the flow solver is decomposed (`flow.init_mpi(...)`, world size > 1):
102
+ each rank couples its **local block**, the deposit grid map is shifted by the block origin (so
103
+ particles in global coordinates land locally), and cross-rank + periodic ghost deposits (void
104
+ fraction + drag reaction) fold onto their owner with the reverse halo (`exchange_field_add`) instead
105
+ of the single-rank NumPy fold. `CfdDem.rebalance(gamma)` forms one weight field
106
+ (`1 + gamma * particle_count`) and redistributes BOTH codes onto the same weighted ORB
107
+ (`flow.rebalance_by_weights` + `dem.migrate_to_weights`). Give the flow + dem the same decomposition
108
+ (matching grid dims / domain) before constructing `CfdDem`.
109
+
110
+ **Moving particles** (`move_particles=True`): each fluid step `CfdDem` first migrates dem onto flow's
111
+ grid partition (`dem.migrate_to_weights`) so every owned particle sits in its rank's block, then runs
112
+ the DISTRIBUTED DEM substeps (`dem.step_mpi`, requires `dem.init_mpi` + `dem.enable_mpi_step`). A rank
113
+ that momentarily owns no particles still runs the halo collectives (the per-particle kernels are
114
+ skipped). Validated `test_mpi_fixed_bed_ergun.py` (static, bit-identical np 1/2/4) and
115
+ `test_mpi_moving_suspension.py` (drifting cloud crossing rank boundaries: the distributed
116
+ migrate + step + deposit-fold + gather reproduce single-rank to ~2e-7, np 1/2).
117
+
118
+ Two known limitations of the underlying dem distributed step (not the coupling — every distributed
119
+ coupling op is bit-identical to single-rank in isolation): (1) a rank with **zero owned particles but
120
+ an incoming ghost** deadlocks the dem step (affects very dilute clouds / np=4 of the moving test);
121
+ (2) a *sustained* dilute settling suspension in a triply-periodic box with no buoyancy is an ill-posed,
122
+ numerically unstable configuration — at np>1 the flow solve's reduction-floor non-determinism seeds
123
+ that instability. Well-posed cases (bounded / driven flow, denser beds) are unaffected.
124
+
125
+ Note: `dem.get_velocities()` (host copy getter) has a pre-existing failure after a *periodic* DEM
126
+ step on CUDA (a Kokkos strided-subview-after-resize limitation, unrelated to the coupling); the
127
+ driver uses the zero-copy device *views* throughout and exposes `last_slip` for inspection.
128
+
129
+ ## Build
130
+
131
+ ```bash
132
+ cmake -S . -B build -DCMAKE_PREFIX_PATH="$PWD/../extern/install/host-openmp"
133
+ cmake --build build -j # -> build/peclet/coupling/_coupling.*.so
134
+ # run the tests (all three build trees on PYTHONPATH):
135
+ PYTHONPATH="$PWD/build:$PWD/../flow/build:$PWD/../dem/build" \
136
+ python tests/test_fixed_bed_ergun.py
137
+ ```
138
+
139
+ ## Follow-ups
140
+
141
+ Kernel-width (vs trilinear) deposition smoothing; the `ρε` volume-averaged inertia and
142
+ `∇·[εμ(∇u+∇uᵀ)]` viscous forms in the gas momentum (accuracy — see
143
+ `flow/doc/porous_drag_scheme.md` §6); a PEA-style implicit particle-drag substep for very stiff
144
+ *moving* beds (`m_p/β < Δt` — the fluid side is already implicit).
@@ -0,0 +1,128 @@
1
+ # peclet.coupling — unresolved point-particle CFD-DEM
2
+
3
+ Two-way coupling of `peclet.flow` (Eulerian fluid) and `peclet.dem` (Lagrangian particles) for
4
+ dilute-to-dense point-particle suspensions and packed beds. Multiphysics Phase 6 — see
5
+ `../docs/MULTIPHYSICS_PLAN.md`.
6
+
7
+ ## Design
8
+
9
+ Physics-free glue. The compute kernels (particle↔grid deposition, drag laws, momentum feedback) live
10
+ in the `_coupling` nanobind extension and run **in place** on the arrays the two solvers already
11
+ expose — the fluid grid fields zero-copy through `flow.field_view(...)`, the particle drag
12
+ round-tripped through the dem host API. **There is no C++ link between flow and dem**: the Python
13
+ `CfdDem` driver (`python/peclet_coupling/driver.py`) composes them. This mirrors the suite's
14
+ architecture (Python is the composition layer).
15
+
16
+ Per fluid step (`CfdDem.step()`):
17
+ 1. **Void fraction** — scatter each particle's volume onto the grid (trilinear, **wall-aware**: near
18
+ an immersed solid the weights re-normalise over the fluid corners so no hold-up leaks into walls),
19
+ periodic-fold the ghost deposits, and `ε = clamp(1 − Vsolid/Vcell, eps_min, 1)`. The floor
20
+ `eps_min` defaults to 0.4 ≈ the random-close-packing voidage (the drag correlations are invalid,
21
+ and Ergun's `1/ε` powers explosive, below a physical packing).
22
+ 2. **Drag + feedback** — gather the fluid velocity and ε at each particle, evaluate the drag law
23
+ (Stokes / Schiller–Naumann / Ergun / Di Felice / Wen & Yu / Gidaspow), write the drag force to the
24
+ particles and deposit the reaction onto the fluid momentum source.
25
+ 3. **Advance** — apply the drag to the particles and sub-step dem `dem_substeps` times (drag held
26
+ constant), then advance the fluid one step (its RHS/operator now carry the feedback).
27
+
28
+ ### Implicit drag (the key stability piece)
29
+
30
+ An explicit reaction force `−β(u−u_p)` in the fluid RHS **diverges** for the stiff drag coefficient
31
+ β of a dense bed (β·dt/ρ ≫ 1; local β reaches ~10³). So the default feedback is **semi-implicit**:
32
+ the coupling deposits the linear-drag *coefficient* density onto flow's `drag_beta` field (added to
33
+ the momentum diagonal by `flow.enable_drag()`) and the target `β·u_p` onto `force_*` (the RHS), so
34
+ the fluid solve becomes `(ρ/dt + β)u = … + β u_p` — unconditionally stable for any β. The particle
35
+ side stays explicit (fine for moving particles at moderate β). `implicit_drag=False` selects the
36
+ explicit `−F/Vcell` feedback (dilute only).
37
+
38
+ ### Two fluid modes
39
+
40
+ - **`porous=True` — volume-averaged (use this for beds).** The fluid solves the full volume-averaged
41
+ continuity `∂ε/∂t + ∇·(εu) = 0` (u = the **interstitial** gas velocity) with a SIMPLE-like eps- and
42
+ drag-weighted pressure projection — scheme, defaults and validation in
43
+ `flow/doc/porous_drag_scheme.md`. The pressure-force split is **Model B**: the gas carries the full
44
+ `−∇p`, the particles get drag + gravity, and the literature (Model-A) drag closures are converted
45
+ once inside the kernel, `β_B = β_A/ε` (`model_b` flag). Gas convection (implicit FOU + explicit
46
+ deferred-correction TVD) is enabled by the driver by default (`advection=True`).
47
+ - **`porous=False` — dilute simplification.** The fluid stays incompressible (`div u = 0`); ε enters
48
+ the drag correlation only. Cheap and validated dilute→moderate. Note this is *not* "Model B":
49
+ Models A and B both use the full continuity and differ only in the `−ε∇p` vs `−∇p` split.
50
+
51
+ Other scope notes: deposition uses `atomic_add` ⇒ results are tolerance-, not bit-exact; the `"ergun"`
52
+ drag *kind* is the superficial-velocity form built for the incompressible mode — for porous beds use
53
+ `"gidaspow"` (its dense branch is the classic interstitial Ergun form).
54
+
55
+ ## Backends
56
+
57
+ `CfdDem` runs on whatever Kokkos backend `peclet.flow` was built for. On a **CUDA/HIP** build the
58
+ coupling kernels run on-device, so the driver array-programs through **CuPy** and takes the grid
59
+ fields (`flow.field_view`) and particle state (`dem.get_*_view`) zero-copy via DLPack; on a host
60
+ build it uses NumPy over the same buffers. Detected automatically from `peclet.flow.execution_space`.
61
+
62
+ ## Validation (`tests/`)
63
+
64
+ Both cases pass identically on **host-openmp and CUDA (RTX 5080)**:
65
+ - **`test_terminal_velocity.py`** — single settling sphere: the slip velocity matches Stokes to
66
+ **0.1–0.2 %** and Schiller–Naumann to **1.4–1.6 %** (the lab-frame speed is ~2× the slip because
67
+ the particle drags its own Stokeslet flow, so the physical comparison is the slip).
68
+ - **`test_fixed_bed_ergun.py`** — uniform fixed bed (one particle per cell, ε = 0.6): the measured
69
+ (f_drive, U) pair lands on the Ergun curve to **0.0 %** across the viscous, transition and inertial
70
+ (Re_p ≈ 6) regimes — validating ε deposition, both Ergun terms, and the stable two-way feedback.
71
+ - **`test_fixed_bed_ergun_porous.py`** — the same bed on the **volume-averaged (porous, Model B)**
72
+ path with the Gidaspow closure: (f_drive, U = ε·u_interstitial) lands on the Ergun curve to ~3 %
73
+ across all three regimes with no fitted factors — validating the eps-weighted projection, the
74
+ interstitial kinematics and the `β_B = β_A/ε` conversion together.
75
+ - **`test_mpi_fixed_bed_ergun.py`** — the fixed-bed Ergun benchmark run **distributed** (flow
76
+ `init_mpi`, each rank couples its ORB block; particle deposits fold across ranks + periodically via
77
+ the reverse/add-reduce halo `exchange_field_add`, deposit origin shifted by the block origin). The
78
+ superficial velocity U (reduced over ranks) lands on the Ergun curve to **0.0 %** and is
79
+ **bit-identical at np=1/2/4** — the distributed deposition + fold + solve reproduce the coupled
80
+ physics exactly.
81
+ - P2G/G2P conservation + the gather/scatter adjoint identity: `core` `test_particle_grid` (host + CUDA).
82
+
83
+ ## Multi-rank coupling
84
+
85
+ `CfdDem` runs distributed when the flow solver is decomposed (`flow.init_mpi(...)`, world size > 1):
86
+ each rank couples its **local block**, the deposit grid map is shifted by the block origin (so
87
+ particles in global coordinates land locally), and cross-rank + periodic ghost deposits (void
88
+ fraction + drag reaction) fold onto their owner with the reverse halo (`exchange_field_add`) instead
89
+ of the single-rank NumPy fold. `CfdDem.rebalance(gamma)` forms one weight field
90
+ (`1 + gamma * particle_count`) and redistributes BOTH codes onto the same weighted ORB
91
+ (`flow.rebalance_by_weights` + `dem.migrate_to_weights`). Give the flow + dem the same decomposition
92
+ (matching grid dims / domain) before constructing `CfdDem`.
93
+
94
+ **Moving particles** (`move_particles=True`): each fluid step `CfdDem` first migrates dem onto flow's
95
+ grid partition (`dem.migrate_to_weights`) so every owned particle sits in its rank's block, then runs
96
+ the DISTRIBUTED DEM substeps (`dem.step_mpi`, requires `dem.init_mpi` + `dem.enable_mpi_step`). A rank
97
+ that momentarily owns no particles still runs the halo collectives (the per-particle kernels are
98
+ skipped). Validated `test_mpi_fixed_bed_ergun.py` (static, bit-identical np 1/2/4) and
99
+ `test_mpi_moving_suspension.py` (drifting cloud crossing rank boundaries: the distributed
100
+ migrate + step + deposit-fold + gather reproduce single-rank to ~2e-7, np 1/2).
101
+
102
+ Two known limitations of the underlying dem distributed step (not the coupling — every distributed
103
+ coupling op is bit-identical to single-rank in isolation): (1) a rank with **zero owned particles but
104
+ an incoming ghost** deadlocks the dem step (affects very dilute clouds / np=4 of the moving test);
105
+ (2) a *sustained* dilute settling suspension in a triply-periodic box with no buoyancy is an ill-posed,
106
+ numerically unstable configuration — at np>1 the flow solve's reduction-floor non-determinism seeds
107
+ that instability. Well-posed cases (bounded / driven flow, denser beds) are unaffected.
108
+
109
+ Note: `dem.get_velocities()` (host copy getter) has a pre-existing failure after a *periodic* DEM
110
+ step on CUDA (a Kokkos strided-subview-after-resize limitation, unrelated to the coupling); the
111
+ driver uses the zero-copy device *views* throughout and exposes `last_slip` for inspection.
112
+
113
+ ## Build
114
+
115
+ ```bash
116
+ cmake -S . -B build -DCMAKE_PREFIX_PATH="$PWD/../extern/install/host-openmp"
117
+ cmake --build build -j # -> build/peclet/coupling/_coupling.*.so
118
+ # run the tests (all three build trees on PYTHONPATH):
119
+ PYTHONPATH="$PWD/build:$PWD/../flow/build:$PWD/../dem/build" \
120
+ python tests/test_fixed_bed_ergun.py
121
+ ```
122
+
123
+ ## Follow-ups
124
+
125
+ Kernel-width (vs trilinear) deposition smoothing; the `ρε` volume-averaged inertia and
126
+ `∇·[εμ(∇u+∇uᵀ)]` viscous forms in the gas momentum (accuracy — see
127
+ `flow/doc/porous_drag_scheme.md` §6); a PEA-style implicit particle-drag substep for very stiff
128
+ *moving* beds (`m_p/β < Δt` — the fluid side is already implicit).
@@ -0,0 +1,123 @@
1
+ # PecletDeps.cmake — self-contained dependency provisioning for a peclet compute package.
2
+ #
3
+ # A peclet compute wheel must build both ways:
4
+ # * DEV / suite build — Kokkos (+ArborX) come from an installed prefix on CMAKE_PREFIX_PATH
5
+ # (../extern/install/<backend> from tools/bootstrap_deps.sh) and the sibling headers
6
+ # (core, morton) from ../<sibling>/include. Fast; the developer workflow.
7
+ # * SELF-CONTAINED sdist/wheel (cibuildwheel) — the umbrella and siblings are ABSENT (the build runs on
8
+ # an isolated copy of this one repo), so everything is FetchContent-built at the suite-pinned
9
+ # versions. This is what makes `pip install peclet-flow` produce a working OpenMP CPU wheel.
10
+ #
11
+ # Selection is automatic (prefix/sibling present -> use it; else fetch), and can be forced off/on with
12
+ # -DPECLET_VENDOR_DEPS=ON. Vendored Kokkos is OpenMP+Serial only (portable CPU wheel); GPU/MPI builds use
13
+ # the prefix path. Keep PECLET_*_TAG in lockstep with tools/bootstrap_deps.sh.
14
+ include_guard(GLOBAL)
15
+ include(FetchContent)
16
+
17
+ set(PECLET_KOKKOS_TAG "5.1.1" CACHE STRING "Vendored Kokkos git tag")
18
+ set(PECLET_ARBORX_TAG "v2.1" CACHE STRING "Vendored ArborX git tag")
19
+ set(PECLET_TPX_TAG "v0.4.0" CACHE STRING "Vendored core git tag (headers)")
20
+ set(PECLET_MORTON_TAG "v0.2.0" CACHE STRING "Vendored morton git tag (headers)")
21
+ option(PECLET_VENDOR_DEPS "Force FetchContent-build of Kokkos/ArborX/siblings (self-contained wheel)" OFF)
22
+
23
+ # nanobind — found via the active interpreter (scikit-build-core supplies it as a build requirement),
24
+ # identical to the umbrella SuiteNanobind helper but vendored so an isolated sdist build needs no ../cmake.
25
+ # MUST be a macro: find_package(Python) sets variables nanobind reads at module-creation time in the
26
+ # caller's scope.
27
+ macro(peclet_require_nanobind)
28
+ if(NOT COMMAND nanobind_add_module)
29
+ find_package(Python 3.10 REQUIRED COMPONENTS Interpreter Development.Module)
30
+ if(NOT nanobind_DIR)
31
+ execute_process(COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
32
+ OUTPUT_VARIABLE _peclet_nb OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE _peclet_nb_rc)
33
+ if(_peclet_nb_rc EQUAL 0 AND EXISTS "${_peclet_nb}")
34
+ set(nanobind_DIR "${_peclet_nb}")
35
+ endif()
36
+ endif()
37
+ find_package(nanobind CONFIG REQUIRED)
38
+ message(STATUS "[peclet] nanobind from ${nanobind_DIR}")
39
+ endif()
40
+ endmacro()
41
+
42
+ # Vendored Kokkos/ArborX are *installed* to a staging prefix (not added as bare build-tree subprojects):
43
+ # ArborX REQUIREs an installed Kokkos package config (`find_package(Kokkos ... CONFIG)`), which a
44
+ # FetchContent subproject target does not provide — so a subproject Kokkos makes ArborX's own
45
+ # find_package(Kokkos) fail. Instead we build+install each into a shared staging prefix exactly like
46
+ # tools/bootstrap_deps.sh, then find_package() both (satisfying our code AND ArborX). Built once (marker).
47
+ set(PECLET_STAGE_PREFIX "${CMAKE_BINARY_DIR}/_peclet_deps" CACHE PATH "Vendored-deps staging install prefix")
48
+
49
+ function(_peclet_stage_build name url tag) # extra -D configure args via ARGN
50
+ FetchContent_Declare(${name} GIT_REPOSITORY "${url}" GIT_TAG "${tag}" GIT_SHALLOW TRUE)
51
+ FetchContent_GetProperties(${name})
52
+ if(NOT ${name}_POPULATED)
53
+ FetchContent_Populate(${name})
54
+ endif()
55
+ if(EXISTS "${PECLET_STAGE_PREFIX}/.peclet_${name}_installed")
56
+ return()
57
+ endif()
58
+ message(STATUS "[peclet] building+installing ${name} ${tag} -> ${PECLET_STAGE_PREFIX}")
59
+ execute_process(
60
+ COMMAND ${CMAKE_COMMAND} -S "${${name}_SOURCE_DIR}" -B "${${name}_BINARY_DIR}"
61
+ -DCMAKE_BUILD_TYPE=Release "-DCMAKE_INSTALL_PREFIX=${PECLET_STAGE_PREFIX}"
62
+ "-DCMAKE_PREFIX_PATH=${PECLET_STAGE_PREFIX}" -DCMAKE_CXX_STANDARD=20
63
+ -DCMAKE_POSITION_INDEPENDENT_CODE=ON ${ARGN}
64
+ RESULT_VARIABLE _rc)
65
+ if(NOT _rc EQUAL 0)
66
+ message(FATAL_ERROR "[peclet] ${name} configure failed (${_rc})")
67
+ endif()
68
+ execute_process(COMMAND ${CMAKE_COMMAND} --build "${${name}_BINARY_DIR}" --target install --parallel
69
+ RESULT_VARIABLE _rc)
70
+ if(NOT _rc EQUAL 0)
71
+ message(FATAL_ERROR "[peclet] ${name} build/install failed (${_rc})")
72
+ endif()
73
+ file(WRITE "${PECLET_STAGE_PREFIX}/.peclet_${name}_installed" "")
74
+ endfunction()
75
+
76
+ # Kokkos — prefix if present (unless forced), else vendored OpenMP+Serial installed to the staging prefix.
77
+ macro(peclet_require_kokkos)
78
+ if(NOT PECLET_VENDOR_DEPS)
79
+ find_package(Kokkos CONFIG QUIET)
80
+ endif()
81
+ if(Kokkos_FOUND)
82
+ message(STATUS "[peclet] Kokkos ${Kokkos_VERSION} from prefix (${Kokkos_DEVICES})")
83
+ else()
84
+ _peclet_stage_build(kokkos "https://github.com/kokkos/kokkos.git" "${PECLET_KOKKOS_TAG}"
85
+ -DKokkos_ENABLE_OPENMP=ON -DKokkos_ENABLE_SERIAL=ON)
86
+ list(APPEND CMAKE_PREFIX_PATH "${PECLET_STAGE_PREFIX}")
87
+ find_package(Kokkos CONFIG REQUIRED)
88
+ message(STATUS "[peclet] vendored Kokkos ${Kokkos_VERSION} @ ${PECLET_STAGE_PREFIX}")
89
+ endif()
90
+ endmacro()
91
+
92
+ # ArborX — header-only but REQUIREs an installed Kokkos config; built against + installed to the same prefix.
93
+ macro(peclet_require_arborx)
94
+ if(NOT PECLET_VENDOR_DEPS)
95
+ find_package(ArborX CONFIG QUIET)
96
+ endif()
97
+ if(NOT ArborX_FOUND)
98
+ _peclet_stage_build(arborx "https://github.com/arborx/ArborX.git" "${PECLET_ARBORX_TAG}")
99
+ list(APPEND CMAKE_PREFIX_PATH "${PECLET_STAGE_PREFIX}")
100
+ find_package(ArborX CONFIG REQUIRED)
101
+ message(STATUS "[peclet] vendored ArborX ${ArborX_VERSION} @ ${PECLET_STAGE_PREFIX}")
102
+ endif()
103
+ endmacro()
104
+
105
+ # Sibling header include dir (core / morton). Returns the sibling checkout if present, else a
106
+ # FetchContent-fetched source tree's include/ (header-only — declared but not built).
107
+ function(peclet_sibling_include repo tag sibling_reldir outvar)
108
+ set(_local "${CMAKE_CURRENT_SOURCE_DIR}/${sibling_reldir}/include")
109
+ if(EXISTS "${_local}" AND NOT PECLET_VENDOR_DEPS)
110
+ set(${outvar} "${_local}" PARENT_SCOPE)
111
+ return()
112
+ endif()
113
+ string(TOLOWER "peclet_sib_${repo}" _name)
114
+ FetchContent_Declare(${_name}
115
+ GIT_REPOSITORY "https://github.com/computational-chemical-engineering/${repo}.git"
116
+ GIT_TAG ${tag} GIT_SHALLOW TRUE)
117
+ FetchContent_GetProperties(${_name})
118
+ if(NOT ${_name}_POPULATED)
119
+ FetchContent_Populate(${_name})
120
+ endif()
121
+ set(${outvar} "${${_name}_SOURCE_DIR}/include" PARENT_SCOPE)
122
+ message(STATUS "[peclet] vendored ${repo} headers -> ${${_name}_SOURCE_DIR}/include")
123
+ endfunction()