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.
- peclet_coupling-0.2.0/.github/workflows/release.yml +41 -0
- peclet_coupling-0.2.0/.gitignore +4 -0
- peclet_coupling-0.2.0/CMakeLists.txt +56 -0
- peclet_coupling-0.2.0/LICENSE +21 -0
- peclet_coupling-0.2.0/PKG-INFO +144 -0
- peclet_coupling-0.2.0/README.md +128 -0
- peclet_coupling-0.2.0/cmake/PecletDeps.cmake +123 -0
- peclet_coupling-0.2.0/examples/fluidized_bed.py +234 -0
- peclet_coupling-0.2.0/pyproject.toml +44 -0
- peclet_coupling-0.2.0/python/peclet_coupling/__init__.py +21 -0
- peclet_coupling-0.2.0/python/peclet_coupling/driver.py +316 -0
- peclet_coupling-0.2.0/src/coupling_bindings.cpp +169 -0
- peclet_coupling-0.2.0/src/coupling_kernels.hpp +224 -0
- peclet_coupling-0.2.0/src/drag.hpp +95 -0
- peclet_coupling-0.2.0/tests/CMakeLists.txt +13 -0
- peclet_coupling-0.2.0/tests/test_fixed_bed_ergun.py +71 -0
- peclet_coupling-0.2.0/tests/test_fixed_bed_ergun_porous.py +69 -0
- peclet_coupling-0.2.0/tests/test_mpi_fixed_bed_ergun.py +94 -0
- peclet_coupling-0.2.0/tests/test_mpi_moving_suspension.py +98 -0
- peclet_coupling-0.2.0/tests/test_terminal_velocity.py +72 -0
|
@@ -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,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()
|