peclet-coupling 0.2.0__tar.gz → 0.4.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.
Files changed (24) hide show
  1. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/CMakeLists.txt +10 -1
  2. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/PKG-INFO +10 -4
  3. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/README.md +9 -3
  4. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/cmake/PecletDeps.cmake +2 -2
  5. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/pyproject.toml +1 -1
  6. peclet_coupling-0.4.0/python/peclet_coupling/__init__.py +45 -0
  7. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/python/peclet_coupling/driver.py +164 -26
  8. peclet_coupling-0.4.0/python/peclet_coupling/resolved.py +176 -0
  9. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/src/coupling_bindings.cpp +54 -22
  10. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/src/coupling_kernels.hpp +172 -18
  11. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/src/drag.hpp +36 -1
  12. peclet_coupling-0.4.0/tests/smoothing_ref_eps.npy +0 -0
  13. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/test_fixed_bed_ergun.py +1 -1
  14. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/test_mpi_fixed_bed_ergun.py +1 -1
  15. peclet_coupling-0.4.0/tests/test_mpi_smoothing.py +114 -0
  16. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/test_terminal_velocity.py +2 -1
  17. peclet_coupling-0.2.0/python/peclet_coupling/__init__.py +0 -21
  18. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/.github/workflows/release.yml +0 -0
  19. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/.gitignore +0 -0
  20. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/LICENSE +0 -0
  21. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/examples/fluidized_bed.py +0 -0
  22. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/CMakeLists.txt +0 -0
  23. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/test_fixed_bed_ergun_porous.py +0 -0
  24. {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/test_mpi_moving_suspension.py +0 -0
@@ -32,10 +32,18 @@ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/__init__.py
32
32
  ${CMAKE_CURRENT_BINARY_DIR}/peclet/coupling/__init__.py COPYONLY)
33
33
  configure_file(${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/driver.py
34
34
  ${CMAKE_CURRENT_BINARY_DIR}/peclet/coupling/driver.py COPYONLY)
35
+ # resolved.py is imported by __init__.py, so omitting it makes a dev build unimportable.
36
+ configure_file(${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/resolved.py
37
+ ${CMAKE_CURRENT_BINARY_DIR}/peclet/coupling/resolved.py COPYONLY)
35
38
  target_include_directories(coupling PRIVATE src "${CORE_INC}")
36
39
  target_link_libraries(coupling PRIVATE Kokkos::kokkos)
37
40
  if(Kokkos_ENABLE_HIP)
38
41
  target_link_options(coupling PRIVATE -Wl,--no-gc-sections)
42
+ # HIP/lld experiment 1 (docs/RELEASE.md §8): nanobind sets CXX_VISIBILITY_PRESET hidden, and under hipcc
43
+ # the host objects then reference the Kokkos SharedAllocationRecord / shared_ptr control-block vtables
44
+ # as hidden symbols that nothing defines ("undefined hidden symbol: vtable for ..."). Default visibility
45
+ # on the HIP path only; CUDA/OpenMP builds are untouched.
46
+ set_target_properties(coupling PROPERTIES CXX_VISIBILITY_PRESET default)
39
47
  endif()
40
48
 
41
49
  option(PECLET_COUPLING_TESTS "Register the Python CFD-DEM integration tests as ctests" OFF)
@@ -45,12 +53,13 @@ if(PECLET_COUPLING_TESTS)
45
53
  endif()
46
54
 
47
55
  # --- pip / scikit-build-core install rule --------------------------------------------------------
48
- # Places the extension + the peclet/coupling package (__init__.py + driver.py) into the PEP-420
56
+ # Places the extension + the peclet/coupling package (__init__.py + driver.py + resolved.py) into the PEP-420
49
57
  # peclet namespace. In an sdist build the core headers + nanobind come from PecletDeps (fetched at
50
58
  # the pinned tag), so the source distribution is self-contained.
51
59
  if(DEFINED SKBUILD)
52
60
  install(TARGETS coupling LIBRARY DESTINATION peclet/coupling COMPONENT python)
53
61
  install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/__init__.py
54
62
  ${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/driver.py
63
+ ${CMAKE_CURRENT_SOURCE_DIR}/python/peclet_coupling/resolved.py
55
64
  DESTINATION peclet/coupling COMPONENT python)
56
65
  endif()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: peclet-coupling
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: peclet.coupling — unresolved point-particle CFD-DEM coupling of peclet.flow + peclet.dem
5
5
  Author-Email: Frank Peters <e.a.j.f.peters@gmail.com>
6
6
  License-Expression: MIT
@@ -32,9 +32,15 @@ architecture (Python is the composition layer).
32
32
  Per fluid step (`CfdDem.step()`):
33
33
  1. **Void fraction** — scatter each particle's volume onto the grid (trilinear, **wall-aware**: near
34
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).
35
+ fold the ghost deposits (periodic wrap on periodic axes; **same-side fold onto the boundary cell
36
+ at a non-periodic domain face** — a grain resting on the distributor scatters part of its volume
37
+ below z=0, and that hold-up belongs to the bottom cell, not to a ghost the fluid never owns), and
38
+ `ε = clamp(1 − Vsolid/Vcell, eps_min, 1)`. The floor `eps_min` defaults to 0.4 ≈ the
39
+ random-close-packing voidage (the drag correlations are invalid, and Ergun's `1/ε` powers
40
+ explosive, below a physical packing). A particle whose trilinear stencil falls **outside the
41
+ domain by more than one ghost layer** (e.g. pushed through a DEM wall by a violent contact solve)
42
+ is dropped from the exchange entirely — no deposit, zero drag — so a runaway escapee can never
43
+ feed a diverging `β·u_p` source into the boundary row.
38
44
  2. **Drag + feedback** — gather the fluid velocity and ε at each particle, evaluate the drag law
39
45
  (Stokes / Schiller–Naumann / Ergun / Di Felice / Wen & Yu / Gidaspow), write the drag force to the
40
46
  particles and deposit the reaction onto the fluid momentum source.
@@ -16,9 +16,15 @@ architecture (Python is the composition layer).
16
16
  Per fluid step (`CfdDem.step()`):
17
17
  1. **Void fraction** — scatter each particle's volume onto the grid (trilinear, **wall-aware**: near
18
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).
19
+ fold the ghost deposits (periodic wrap on periodic axes; **same-side fold onto the boundary cell
20
+ at a non-periodic domain face** — a grain resting on the distributor scatters part of its volume
21
+ below z=0, and that hold-up belongs to the bottom cell, not to a ghost the fluid never owns), and
22
+ `ε = clamp(1 − Vsolid/Vcell, eps_min, 1)`. The floor `eps_min` defaults to 0.4 ≈ the
23
+ random-close-packing voidage (the drag correlations are invalid, and Ergun's `1/ε` powers
24
+ explosive, below a physical packing). A particle whose trilinear stencil falls **outside the
25
+ domain by more than one ghost layer** (e.g. pushed through a DEM wall by a violent contact solve)
26
+ is dropped from the exchange entirely — no deposit, zero drag — so a runaway escapee can never
27
+ feed a diverging `β·u_p` source into the boundary row.
22
28
  2. **Drag + feedback** — gather the fluid velocity and ε at each particle, evaluate the drag law
23
29
  (Stokes / Schiller–Naumann / Ergun / Di Felice / Wen & Yu / Gidaspow), write the drag force to the
24
30
  particles and deposit the reaction onto the fluid momentum source.
@@ -16,8 +16,8 @@ include(FetchContent)
16
16
 
17
17
  set(PECLET_KOKKOS_TAG "5.1.1" CACHE STRING "Vendored Kokkos git tag")
18
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)")
19
+ set(PECLET_TPX_TAG "v0.6.0" CACHE STRING "Vendored core git tag (headers)")
20
+ set(PECLET_MORTON_TAG "v0.2.1" CACHE STRING "Vendored morton git tag (headers)")
21
21
  option(PECLET_VENDOR_DEPS "Force FetchContent-build of Kokkos/ArborX/siblings (self-contained wheel)" OFF)
22
22
 
23
23
  # nanobind — found via the active interpreter (scikit-build-core supplies it as a build requirement),
@@ -16,7 +16,7 @@ build-backend = "scikit_build_core.build"
16
16
 
17
17
  [project]
18
18
  name = "peclet-coupling"
19
- version = "0.2.0"
19
+ version = "0.4.0"
20
20
  description = "peclet.coupling — unresolved point-particle CFD-DEM coupling of peclet.flow + peclet.dem"
21
21
  readme = "README.md"
22
22
  requires-python = ">=3.10"
@@ -0,0 +1,45 @@
1
+ """peclet.coupling — CFD-DEM coupling, unresolved and resolved.
2
+
3
+ Composes peclet.flow (Eulerian fluid) + peclet.dem (Lagrangian particles).
4
+
5
+ CfdDem is the UNRESOLVED point-particle driver: a grain is a point with a drag closure, and the
6
+ compute kernels (particle<->grid deposition, drag laws, momentum feedback) live in the _coupling
7
+ extension, running in place on the arrays the two solvers expose (zero-copy grid fields; particle
8
+ forces round-tripped through the dem host API).
9
+
10
+ ResolvedCfdDem is the RESOLVED driver (Layer 4 of suite/docs/ANALYTIC_SDF_GEOMETRY.md): each grain
11
+ IS an analytic SDF instance in the flow solver's scene, the fluid resolves its surface, and the
12
+ coupling is a surface-traction exchange with no drag correlation in it. Pure Python -- it needs no
13
+ compiled kernels of its own.
14
+ """
15
+ # The compiled kernels are needed by the UNRESOLVED driver only. ResolvedCfdDem is pure Python
16
+ # composing peclet.flow + peclet.dem -- it has no C++ dependency at all -- so a missing extension
17
+ # must not make it unimportable. CfdDem re-imports _coupling in its own constructor, where the
18
+ # failure is still immediate and its message still points at the right thing.
19
+ try:
20
+ from . import _coupling # noqa: F401 (deposit_solid_volume, compute_void_fraction, ...)
21
+ except ImportError: # pragma: no cover
22
+ _coupling = None
23
+ from .driver import CfdDem # noqa: F401
24
+ from .resolved import ResolvedCfdDem # noqa: F401
25
+
26
+ DRAG_STOKES = 0
27
+ DRAG_SCHILLER_NAUMANN = 1
28
+ DRAG_ERGUN = 2
29
+ DRAG_DI_FELICE = 3
30
+ DRAG_WEN_YU = 4
31
+ DRAG_GIDASPOW = 5 # Ergun (dense) + Wen & Yu (dilute), switched at eps = 0.8
32
+ DRAG_BEETSTRA = 6 # Beetstra-van der Hoef-Kuipers (2007) DNS drag — the published "BVK2"
33
+ DRAG_TANG = 7 # Tang et al. (2015) DNS drag — what MFIX-Exa's "BVK2" option actually executes
34
+
35
+ # The installed distribution's metadata (pyproject.toml) is the single source of truth for the version;
36
+ # a build-tree import (PYTHONPATH=<build>) has no metadata and reports "0+unknown". This replaces a
37
+ # hand-maintained literal that had drifted behind pyproject.toml in every package at 0.6.0.
38
+ try:
39
+ from importlib.metadata import version as _dist_version
40
+ __version__ = _dist_version("peclet-coupling")
41
+ except Exception: # PackageNotFoundError (dev build), or a broken metadata install
42
+ __version__ = "0+unknown"
43
+
44
+ __all__ = ["CfdDem", "ResolvedCfdDem", "_coupling", "DRAG_STOKES", "DRAG_SCHILLER_NAUMANN", "DRAG_ERGUN",
45
+ "DRAG_DI_FELICE", "DRAG_WEN_YU", "DRAG_GIDASPOW", "DRAG_BEETSTRA", "DRAG_TANG"]
@@ -25,10 +25,11 @@ def _sl(axis, idx):
25
25
 
26
26
  class CfdDem:
27
27
  def __init__(self, flow, dem, *, fluid_dt, mu, rho, radius, drag="schiller_naumann",
28
- dem_substeps=20, eps_min=0.4, periodic=(True, True, True), h=1.0,
29
- move_particles=True, implicit_drag=True, porous=False, advection=True):
28
+ dem_substeps=20, eps_min=0.25, smooth_width=0.0, periodic=(True, True, True), h=1.0,
29
+ move_particles=True, implicit_drag=True, porous=True, advection=True,
30
+ gravity=(0.0, 0.0, 0.0)):
30
31
  from . import (_coupling, DRAG_STOKES, DRAG_SCHILLER_NAUMANN, DRAG_ERGUN, DRAG_DI_FELICE,
31
- DRAG_WEN_YU, DRAG_GIDASPOW)
32
+ DRAG_WEN_YU, DRAG_GIDASPOW, DRAG_BEETSTRA, DRAG_TANG)
32
33
  self._c = _coupling
33
34
  self.flow = flow
34
35
  self.dem = dem
@@ -48,22 +49,53 @@ class CfdDem:
48
49
  self.fluid_dt = float(fluid_dt)
49
50
  self.dem_substeps = int(dem_substeps)
50
51
  self.dt_dem = self.fluid_dt / self.dem_substeps
51
- # Void-fraction floor. Default 0.4 ~ the random-close-packing voidage: a physical packing
52
- # cannot go below ~0.36, so deposits under the floor are local over-concentration of the
53
- # trilinear/wall-aware scatter, and the Ergun-branch drag (beta ~ (1-eps)^2/eps...) explodes
54
- # outside its validity range (a porous Model-B bed at eps_min=0.3 diverges; 0.4 is stable).
52
+ # Void-fraction floor (default 0.25): a PHYSICAL regularisation, not just a guard. Real
53
+ # voidage bottoms out near random close packing (~0.36 monodisperse, ~0.25 for wide bidisperse
54
+ # mixes); anything lower can only come from numerically interpenetrated particles or deposit
55
+ # artifacts, and must not reach the volume-averaged fluid — the eps-conservative projection
56
+ # legitimately amplifies the interstitial velocity by 1/eps, so junk eps -> junk gas. 0.25
57
+ # keeps the Ergun/drag fidelity over the physical range (the old 0.4 clamp under-predicted
58
+ # dense-bed drag ~3x; the interim 0.05 guard let interpenetration artifacts detonate a bed).
55
59
  self.eps_min = float(eps_min)
60
+ # Porosity smoothing length (grid cells), decoupled from the CFD cell size — MFIX's
61
+ # DES_DIFFUSE_WIDTH. 0 = off (plain trilinear deposit). For a coarse cell/dp bed set it ~1 cell
62
+ # (a few particle diameters) so the void fraction the drag sees is smooth and grid-independent;
63
+ # converted to `nsweeps` explicit diffusion sweeps (sigma = sqrt(2*alpha*nsweeps), alpha=1/6).
64
+ self.smooth_width = float(smooth_width)
65
+ # Volume-averaging validity: eps must be smooth over >~ the particle scale. With cells not
66
+ # much larger than d_p the raw trilinear deposit is not a proper volume filter — smooth it.
67
+ if np.isscalar(radius) and float(h) < 3.0 * (2.0 * float(radius)) \
68
+ and self.smooth_width * float(h) < 1.5 * (2.0 * float(radius)):
69
+ import warnings
70
+ warnings.warn(
71
+ f"CfdDem: cell size h={float(h):g} is < 3 particle diameters and smooth_width is "
72
+ f"below ~1.5 d_p — the deposited void fraction is not a proper volume average at "
73
+ f"this resolution. Set smooth_width so the smoothing length exceeds the particle "
74
+ f"diameter (e.g. smooth_width={1.5 * 2.0 * float(radius) / float(h):.1f}).")
75
+ self._smooth_alpha = 1.0 / 6.0
76
+ self._smooth_sweeps = (max(1, int(round(self.smooth_width ** 2 / (2.0 * self._smooth_alpha))))
77
+ if self.smooth_width > 0.0 else 0)
56
78
  self.move_particles = bool(move_particles) # False: fixed bed — skip DEM dynamics entirely
57
79
  self.implicit_drag = bool(implicit_drag) # beta on the fluid diagonal (stable for stiff beds)
58
- # Volume-averaged continuity d(eps)/dt+div(eps u)=0 (proper unresolved CFD-DEM). When off the
59
- # fluid is solved incompressible (eps only in the drag) — cheaper, fine for dilute/steady beds.
80
+ # Volume-averaged continuity d(eps)/dt+div(eps u)=0 (proper unresolved CFD-DEM) — the DEFAULT:
81
+ # porosity must enter the volume-averaged Navier-Stokes equations, not just the drag closure.
82
+ # (MFIX-Exa likewise advects with the superficial velocity and projects div(eps u)=0; its
83
+ # d(eps)/dt constraint term is optional/"under development" — ours keeps it.) porous=False
84
+ # solves plain incompressible NS (eps only in the drag): a cheaper approximation for
85
+ # dilute/steady beds, not a faithful CFD-DEM.
60
86
  self.porous = bool(porous)
61
87
  self.h = float(h)
62
88
  self.inv_vcell = 1.0 / (self.h ** 3)
89
+ # Constant external acceleration dem applies per substep (its set_gravity vector; dem has
90
+ # no getter, so pass it here too). Feeds the stiff-safe drag cap's gravity-exact correction
91
+ # F -= m g (1 - beta_eff/beta), which restores the physical steady-state slip m g / beta.
92
+ self.gravity = tuple(float(c) for c in gravity)
63
93
  self.periodic = tuple(bool(p) for p in periodic)
64
94
  self.drag_kind = {"stokes": DRAG_STOKES, "schiller_naumann": DRAG_SCHILLER_NAUMANN,
65
95
  "ergun": DRAG_ERGUN, "di_felice": DRAG_DI_FELICE,
66
- "wen_yu": DRAG_WEN_YU, "gidaspow": DRAG_GIDASPOW}[drag]
96
+ "wen_yu": DRAG_WEN_YU, "gidaspow": DRAG_GIDASPOW,
97
+ "beetstra": DRAG_BEETSTRA, "bvk": DRAG_BEETSTRA,
98
+ "tang": DRAG_TANG, "bvk2": DRAG_TANG}[drag]
67
99
 
68
100
  nx, ny, nz = flow.get_resolution() # LOCAL block dims under MPI
69
101
  self.g = flow.ghost_width()
@@ -87,6 +119,22 @@ class CfdDem:
87
119
  self._ox, self._oy, self._oz = bo[0] * self.h, bo[1] * self.h, bo[2] * self.h
88
120
  gnx, gny, gnz = flow.global_resolution() if self.mpi else (nx, ny, nz)
89
121
  self.gnx, self.gny, self.gnz = gnx, gny, gnz
122
+ # Smoothing under MPI: a local block face that is an INTERIOR rank boundary is not a wall —
123
+ # the diffusion sweep must read the halo ghost there (bit set), while faces on the GLOBAL
124
+ # domain boundary keep the validated single-rank zero-flux closed-box behaviour (bit clear,
125
+ # also on periodic axes, matching the single-rank path byte-for-byte). The sweep loop in
126
+ # update_void_fraction halo-refreshes solidvol before every sweep, so multi-rank smoothing
127
+ # reproduces the single-rank arithmetic exactly.
128
+ self._smooth_open = 0
129
+ if self.mpi:
130
+ lo = (bo[0], bo[1], bo[2])
131
+ dims = (nx, ny, nz)
132
+ gdims = (gnx, gny, gnz)
133
+ for a in range(3):
134
+ if lo[a] > 0:
135
+ self._smooth_open |= 1 << (2 * a)
136
+ if lo[a] + dims[a] < gdims[a]:
137
+ self._smooth_open |= 1 << (2 * a + 1)
90
138
  # The CURRENT shared decomposition, as an x-fastest per-cell weight field. Uniform => the
91
139
  # default equal-cell ORB flow's init_mpi built; rebalance() overwrites it. dem is migrated onto
92
140
  # this each moving step so its ownership tracks flow's grid partition (the deposit stays
@@ -106,6 +154,15 @@ class CfdDem:
106
154
  self._solidvol = xp.zeros((self.ex, self.ey, self.ez), dtype=xp.float64, order="F")
107
155
  self._eps = xp.ones((self.ex, self.ey, self.ez), dtype=xp.float64, order="F")
108
156
  if self.porous:
157
+ # The porous projection lives on the cut-cell pressure operator (eps-weighted
158
+ # coefficients ride the openness rails). A domain-BC-only box (no set_solid /
159
+ # set_pressure_geometry) has no such operator, and flow would silently solve plain
160
+ # div(u)=0 — the gas never accelerates to the interstitial velocity in the bed and the
161
+ # drag is far too weak to fluidize. Auto-install an all-fluid geometry in that case
162
+ # (flow's project() now also throws rather than silently degrade).
163
+ if hasattr(flow, "has_cutcell_pressure") and not flow.has_cutcell_pressure():
164
+ allfluid = np.full((self.nx, self.ny, self.nz), 1e6, dtype=np.float64)
165
+ flow.set_pressure_geometry(allfluid.flatten(order="F"))
109
166
  flow.set_porous_continuity(True) # projection enforces d(eps)/dt + div(eps u) = 0
110
167
  # Gas convection ON by default: fully-implicit FOU operator + explicit deferred-correction
111
168
  # TVD (unconditionally stable at the large coupled dt on both the periodic and domain-BC
@@ -163,24 +220,68 @@ class CfdDem:
163
220
  vel = np.ascontiguousarray(self.dem.get_velocities(), dtype=np.float32)
164
221
  return pos, vel
165
222
 
166
- # --- periodic ghost handling on a padded (ex,ey,ez) buffer -------------------------------
167
- def _fold(self, f): # deposits that landed one cell into the ghost wrap back to the inner edge
223
+ # --- ghost handling on a padded (ex,ey,ez) buffer ----------------------------------------
224
+ def _fold(self, f):
225
+ """Fold ghost-layer deposits back onto owned cells. Periodic axis: wrap to the opposite
226
+ inner edge. Non-periodic axis: fold into the SAME-side boundary cell — a grain resting on
227
+ the distributor scatters part of its volume (and drag beta / feedback) one layer below
228
+ z=0; dropping it would lose hold-up exactly where the bed is densest and leave the
229
+ deposit's leakage in ghosts the fluid reads."""
168
230
  g = self.g
169
231
  for a, (n, per) in enumerate(zip((self.nx, self.ny, self.nz), self.periodic)):
170
- if not per:
171
- continue
172
- f[_sl(a, n + g - 1)] += f[_sl(a, g - 1)]
173
- f[_sl(a, g)] += f[_sl(a, n + g)]
232
+ if per:
233
+ f[_sl(a, n + g - 1)] += f[_sl(a, g - 1)]
234
+ f[_sl(a, g)] += f[_sl(a, n + g)]
235
+ else:
236
+ f[_sl(a, g)] += f[_sl(a, g - 1)]
237
+ f[_sl(a, n + g - 1)] += f[_sl(a, n + g)]
174
238
  f[_sl(a, g - 1)] = 0.0
175
239
  f[_sl(a, n + g)] = 0.0
176
240
 
177
- def _fill(self, f): # fill the one ghost layer the gather stencil reads (periodic wrap)
241
+ def _fold_domain(self, f):
242
+ """MPI: same-side fold of the non-periodic GLOBAL-domain-boundary ghosts of the local
243
+ block (the reverse halo never touches them). Call BEFORE exchange_field_add."""
244
+ g = self.g
245
+ lo = (int(self._ox / self.h), int(self._oy / self.h), int(self._oz / self.h))
246
+ dims = (self.nx, self.ny, self.nz)
247
+ gdims = (self.gnx, self.gny, self.gnz)
248
+ for a in range(3):
249
+ if self.periodic[a]:
250
+ continue
251
+ if lo[a] == 0:
252
+ f[_sl(a, g)] += f[_sl(a, g - 1)]
253
+ f[_sl(a, g - 1)] = 0.0
254
+ if lo[a] + dims[a] == gdims[a]:
255
+ f[_sl(a, dims[a] + g - 1)] += f[_sl(a, dims[a] + g)]
256
+ f[_sl(a, dims[a] + g)] = 0.0
257
+
258
+ def _fill(self, f):
259
+ """Fill the one ghost layer the gather stencil reads: periodic wrap, or zero-gradient at a
260
+ non-periodic domain face (a grain near the floor must see the LOCAL bed eps/velocity, not
261
+ the deposit's stale ghost values)."""
178
262
  g = self.g
179
263
  for a, (n, per) in enumerate(zip((self.nx, self.ny, self.nz), self.periodic)):
180
- if not per:
264
+ if per:
265
+ f[_sl(a, g - 1)] = f[_sl(a, n + g - 1)]
266
+ f[_sl(a, n + g)] = f[_sl(a, g)]
267
+ else:
268
+ f[_sl(a, g - 1)] = f[_sl(a, g)]
269
+ f[_sl(a, n + g)] = f[_sl(a, n + g - 1)]
270
+
271
+ def _fill_domain(self, f):
272
+ """MPI: zero-gradient fill of the non-periodic global-domain-boundary ghosts (the halo
273
+ fill never touches them). Call AFTER exchange_field."""
274
+ g = self.g
275
+ lo = (int(self._ox / self.h), int(self._oy / self.h), int(self._oz / self.h))
276
+ dims = (self.nx, self.ny, self.nz)
277
+ gdims = (self.gnx, self.gny, self.gnz)
278
+ for a in range(3):
279
+ if self.periodic[a]:
181
280
  continue
182
- f[_sl(a, g - 1)] = f[_sl(a, n + g - 1)]
183
- f[_sl(a, n + g)] = f[_sl(a, g)]
281
+ if lo[a] == 0:
282
+ f[_sl(a, g - 1)] = f[_sl(a, g)]
283
+ if lo[a] + dims[a] == gdims[a]:
284
+ f[_sl(a, dims[a] + g)] = f[_sl(a, dims[a] + g - 1)]
184
285
 
185
286
  def update_void_fraction(self, pos):
186
287
  # deposit target: a registered flow field under MPI (so the halo folds ghost deposits + fills
@@ -194,14 +295,35 @@ class CfdDem:
194
295
  # With no geometry set sdf is all-zero -> every corner fluid -> plain trilinear deposit.
195
296
  self._c.deposit_solid_volume(pos, self._rad, sv, self._fv("sdf"), *self._gm())
196
297
  if self.mpi:
298
+ self._fold_domain(sv) # non-periodic domain-boundary ghosts (halo never folds them)
197
299
  self.flow.exchange_field_add("solidvol") # fold cross-rank + periodic ghost deposits
198
300
  else:
199
301
  self._fold(sv)
302
+ if self._smooth_sweeps:
303
+ # Diffusive smoothing of the deposited solid volume (MFIX DES_DIFFUSE_WIDTH): decouple the
304
+ # porosity smoothing length from the CFD cell so a coarse cell/dp bed sees a smooth,
305
+ # grid-independent void fraction. Volume-conserving (zero-flux at the global walls).
306
+ if self.mpi:
307
+ # Interior rank faces diffuse across the block boundary: refresh the halo before
308
+ # every Jacobi sweep so open faces read the neighbour's pre-sweep values — this
309
+ # reproduces the single-rank closed-box arithmetic exactly (global faces stay
310
+ # zero-flux, flux across rank faces is antisymmetric => volume conserved).
311
+ for _ in range(self._smooth_sweeps):
312
+ self.flow.exchange_field("solidvol")
313
+ self._c.smooth_solid_volume(sv, *self._gm(), 1, self._smooth_alpha,
314
+ self._smooth_open)
315
+ else:
316
+ self._c.smooth_solid_volume(sv, *self._gm(), self._smooth_sweeps, self._smooth_alpha)
200
317
  self._c.compute_void_fraction(sv, ep, self.inv_vcell, self.eps_min)
201
318
  if self.mpi:
202
319
  self.flow.exchange_field("eps") # fill the ghosts the gather stencil reads
320
+ self._fill_domain(ep)
203
321
  else:
204
322
  self._fill(ep)
323
+ # Non-periodic ghost fills extrapolate and can leave eps outside [eps_min, 1] (observed
324
+ # 1.95 at a freeboard boundary); the porous coefficients and the gather stencil read those
325
+ # ghosts, so clamp the whole padded block to the physical range.
326
+ self.xp.clip(ep, self.eps_min, 1.0, out=ep)
205
327
  self._eps = ep # compute_forces reads this at the particles
206
328
 
207
329
  def compute_forces(self, pos, vel):
@@ -213,19 +335,34 @@ class CfdDem:
213
335
  gm = self._gm()
214
336
  has_p = pos.shape[0] > 0 # a rank may own no particles under MPI (skip the per-particle
215
337
  db = self._fv("drag_beta") if self.implicit_drag else None # kernels, keep the collectives)
338
+ # per-particle inverse mass (zero-copy dem view) for the stiff-safe exponential-integrator
339
+ # drag cap: beta_eff = (m/dt)(1 - exp(-beta dt/m)) — exact for linear drag over the coupling
340
+ # interval, unconditionally stable for any drag stiffness (the explicit per-substep force
341
+ # application otherwise blows up once beta*dt/m ~ 1).
342
+ im = self.xp.from_dlpack(self.dem.get_inv_mass_view()) if self.device \
343
+ else np.asarray(self.dem.get_inv_mass_view())
344
+ # The cap models the PARTICLE momentum update over the coupling interval; a fixed bed
345
+ # (move_particles=False) integrates no particles, so the cap must be off — dt_exch=0 makes
346
+ # effectiveBeta return the raw beta on both sides of the exchange. (This also guards against
347
+ # dem's set_positions (N,4) convention, which remaps w==0 to inv_mass=1: "fixed" bed
348
+ # particles otherwise look like unit-mass movers and the cap floors the dense-bed drag.)
349
+ dt_exch = self.fluid_dt if self.move_particles else 0.0
216
350
  # porous (volume-averaged, Model B: the fluid carries the full -grad p) converts the drag
217
351
  # closures beta_B = beta_A/eps inside the kernel (model_b flag); the incompressible mode
218
352
  # keeps the literature Model-A forms unchanged.
219
353
  if has_p and self.implicit_drag:
220
- self._c.compute_drag_implicit(pos, vel, self._rad, uf, vf, wf, self._eps, sd, self._fdrag,
221
- db, fx, fy, fz, *gm, self.mu, self.rho, self.inv_vcell,
222
- self.drag_kind, self.porous)
354
+ self._c.compute_drag_implicit(pos, vel, self._rad, im, uf, vf, wf, self._eps, sd,
355
+ self._fdrag, db, fx, fy, fz, *gm, self.mu, self.rho,
356
+ self.inv_vcell, self.drag_kind, self.porous,
357
+ dt_exch, *self.gravity)
223
358
  elif has_p:
224
- self._c.compute_drag_feedback(pos, vel, self._rad, uf, vf, wf, self._eps, sd, self._fdrag,
225
- fx, fy, fz, *gm, self.mu, self.rho, self.inv_vcell,
226
- self.drag_kind, self.porous)
359
+ self._c.compute_drag_feedback(pos, vel, self._rad, im, uf, vf, wf, self._eps, sd,
360
+ self._fdrag, fx, fy, fz, *gm, self.mu, self.rho,
361
+ self.inv_vcell, self.drag_kind, self.porous,
362
+ dt_exch, *self.gravity)
227
363
  if self.implicit_drag:
228
364
  if self.mpi:
365
+ self._fold_domain(db)
229
366
  self.flow.exchange_field_add("drag_beta")
230
367
  else:
231
368
  self._fold(db)
@@ -235,6 +372,7 @@ class CfdDem:
235
372
  # fold the reaction feedback (force_*) onto owners: reverse halo under MPI, periodic wrap else.
236
373
  for nm, f in (("force_x", fx), ("force_y", fy), ("force_z", fz)):
237
374
  if self.mpi:
375
+ self._fold_domain(f)
238
376
  self.flow.exchange_field_add(nm)
239
377
  else:
240
378
  self._fold(f)
@@ -0,0 +1,176 @@
1
+ """ResolvedCfdDem -- resolved (geometry-resolving) CFD-DEM.
2
+
3
+ Layer 4 of suite/docs/ANALYTIC_SDF_GEOMETRY.md. Where `CfdDem` treats a grain as a point with a
4
+ drag closure, this driver makes each grain an ANALYTIC SDF INSTANCE in the flow solver's scene: the
5
+ fluid resolves the actual surface, no-slip is enforced on the moving wall by the cut-cell IBM
6
+ (Layer 3 rung 2), the projection carries the wall's own volume flux (rung 3), and the coupling is a hydrodynamic-load exchange (rung L4-R2) with no drag correlation anywhere in it:
7
+ by default the DISCRETE REACTION (route (b) of the design note's OPEN FOR REVIEW 1) -- the momentum
8
+ the fluid actually lost to each grain, exactly conservative -- with the reconstructed traction
9
+ integral available as force_method="traction" for diagnostics.
10
+
11
+ Python-composed, like `CfdDem` and for the same reason: dem and flow stay separate method codes and
12
+ nothing links them in C++. Per coupling step:
13
+
14
+ 1. pull dem state (positions / quaternions / velocities / angular velocities) and push it into
15
+ flow's scene -- instance transforms plus rung-2 rigid-body motion;
16
+ 2. flow.rebuild_geometry() -- re-derive SDF, cut-cell overlay, apertures and pressure operator;
17
+ the velocity and pressure fields survive it;
18
+ 3. flow.step();
19
+ 4. flow.hydro_force_torque() -> per-grain force; add gravity/buoyancy; hand to dem;
20
+ 5. dem sub-steps at the DEM timestep, holding the fluid force constant.
21
+
22
+ WEAK, EXPLICIT COUPLING (spec: L4-R3). The fluid force is lagged by one fluid step. That is the
23
+ documented v1; a strongly-coupled variant would iterate 3-5 inside the step.
24
+
25
+ FLOAT/DOUBLE BOUNDARY. dem carries float32 state, flow's scene is float64. The per-step instance
26
+ rebuild converts; "zero-copy" is not literal across that divide, and does not need to be -- the
27
+ instance array is a few hundred bytes per grain against a geometry rebuild measured in tens of ms.
28
+
29
+ UNITS. Everything is in flow's grid units: cell spacing 1, cell (i,j,k) centred at (i,j,k), so dem
30
+ positions and radii must be expressed in cells.
31
+
32
+ HYDRODYNAMIC TORQUE (validated 2026-08-31). flow's reaction torque now carries the
33
+ transposed-stress wall term (flow `16e91ec`) and is GATED: a spinning sphere reproduces the exact
34
+ Stokes torque 8*pi*mu*a^3*Omega to +3.5/+2.4/+2.2% (converging with the aperture first moment),
35
+ and THIS loop with apply_torque=True decays a freely spinning sphere at 1.039x the same box's
36
+ calibrated rotational drag (rotation_gate.py part B). Before that term the torque was a structural
37
+ -31% -- the missing traction mu*(n x Omega) integrates to zero in the FORCE, which is why no
38
+ force-based gate ever saw it.
39
+
40
+ apply_torque stays **off by default** for one remaining reason: dem assigns a DEFAULT inverse
41
+ inertia unrelated to the grain's size, so handing a torque to a grain whose inertia was never set
42
+ spins it up at an arbitrary rate (the settling gate diverged to 1e+09 that way). Set the physical
43
+ principal inertia FIRST -- (2/5) m R^2 for a sphere, or `scene_particle`'s `inv_inertia_unit` --
44
+ then enable. The torque is computed and reported through `torques()` either way.
45
+
46
+ """
47
+ import numpy as np
48
+
49
+
50
+ class ResolvedCfdDem:
51
+ def __init__(self, flow, dem, *, radius, mu, rho_f, fluid_dt, dem_substeps=20,
52
+ periodic=True, gravity=(0.0, 0.0, 0.0), rho_p=None, move=True,
53
+ buoyancy=True, apply_torque=False,
54
+ force_method="reaction"):
55
+ self.flow = flow
56
+ self.dem = dem
57
+ self.mu = float(mu)
58
+ self.rho_f = float(rho_f)
59
+ self.fluid_dt = float(fluid_dt)
60
+ self.dem_substeps = int(dem_substeps)
61
+ self.dt_dem = self.fluid_dt / self.dem_substeps
62
+ self.gravity = np.asarray(gravity, dtype=np.float64)
63
+ self.move = bool(move)
64
+ self.buoyancy = bool(buoyancy)
65
+ self.radius = float(radius)
66
+ self.rho_p = float(rho_p) if rho_p is not None else None
67
+ self.periodic = bool(periodic)
68
+ # Hand the reaction TORQUE to dem as well as the force (dem R2, set_external_torques).
69
+ # The torque is VALIDATED (rotating-sphere + spin-decay gates, see the class docstring);
70
+ # off by default only because dem's default inverse inertia is not the grain's -- set a
71
+ # physical principal inertia first, then enable.
72
+ self.apply_torque = bool(apply_torque)
73
+ # "reaction" (default): the discrete-reaction force -- exactly conservative (the momentum
74
+ # the fluid lost IS the momentum the grain gains) and as accurate as the flow solution it
75
+ # sustains. "traction": the reconstructed surface integral, kept as a diagnostic; it
76
+ # under-reads the drag by a resolution-independent ~29% (measured), which in this loop
77
+ # shows up as a total-momentum leak. See suite/docs/ANALYTIC_SDF_GEOMETRY.md OPEN FOR
78
+ # REVIEW 1.
79
+ if force_method not in ("reaction", "traction"):
80
+ raise ValueError("force_method must be 'reaction' or 'traction'")
81
+ self.force_method = force_method
82
+ self.n = int(dem.num_particles())
83
+ self.last_force = np.zeros((self.n, 3))
84
+ self.last_torque = np.zeros((self.n, 3))
85
+ flow.set_dt(self.fluid_dt)
86
+ dem.set_dt(self.dt_dem) # kept in sync, though step(dt) below passes it explicitly
87
+ self._install_scene()
88
+
89
+ # --- L4-R1: the dem -> scene bridge --------------------------------------------------------
90
+ _KN_R, _KI_I, _KI_R = 16, 2, 17
91
+
92
+ def _install_scene(self):
93
+ """One kSphere node, one instance per grain. Spheres first, per the spec; a shaped grain is
94
+ the same bridge with the grain's own node tree in place of the sphere leaf."""
95
+ node_ints = np.array([1, -1, -1], dtype=np.int32) # kSphere
96
+ node_reals = np.zeros(self._KN_R)
97
+ node_reals[0] = self.radius
98
+ node_reals[14] = 1.0 # quaternion w
99
+ node_reals[15] = 1.0 # scale
100
+ ii, ir = self._instance_arrays()
101
+ self.flow.set_scene(node_ints, node_reals, ii.ravel(), ir.ravel(), periodic=self.periodic)
102
+ self._push_motion()
103
+ self.flow.set_solid_from_scene(True)
104
+
105
+ def _instance_arrays(self):
106
+ pos = np.asarray(self.dem.get_positions(), dtype=np.float64)
107
+ quat = np.asarray(self.dem.get_quaternions(), dtype=np.float64)
108
+ ii = np.zeros((self.n, self._KI_I), dtype=np.int32)
109
+ ir = np.zeros((self.n, self._KI_R))
110
+ ii[:, 0] = 0 # shapeRoot
111
+ ii[:, 1] = -1 # materialId
112
+ ir[:, 0:3] = pos[:, 0:3]
113
+ ir[:, 3:7] = quat[:, 0:4] # (x,y,z,w)
114
+ ir[:, 7] = 1.0 # scale
115
+ return ii, ir
116
+
117
+ def _push_motion(self):
118
+ """Instance transforms + rigid-body velocities, straight from dem state."""
119
+ pos = np.asarray(self.dem.get_positions(), dtype=np.float64)
120
+ quat = np.asarray(self.dem.get_quaternions(), dtype=np.float64)
121
+ vel = np.asarray(self.dem.get_velocities(), dtype=np.float64)
122
+ omg = np.asarray(self.dem.get_angular_velocities(), dtype=np.float64)
123
+ for i in range(self.n):
124
+ self.flow.set_instance_transform(i, pos[i, 0:3].tolist(), quat[i, 0:4].tolist())
125
+ self.flow.set_instance_motion(i, lin_vel=vel[i, 0:3].tolist(),
126
+ ang_vel=omg[i, 0:3].tolist())
127
+
128
+ # --- L4-R3: the motion loop ----------------------------------------------------------------
129
+ def step(self):
130
+ if self.move:
131
+ self._push_motion()
132
+ self.flow.rebuild_geometry()
133
+ self.flow.step()
134
+ if self.force_method == "reaction":
135
+ ft = np.asarray(self.flow.hydro_force_torque_reaction()) # (2, n, 3): F, tau
136
+ self.last_force = np.array(ft[0][: self.n], dtype=np.float64)
137
+ self.last_torque = np.array(ft[1][: self.n], dtype=np.float64)
138
+ self.last_force_pressure = None # the reaction has no pressure/viscous split
139
+ self.last_force_viscous = None
140
+ else:
141
+ ft = np.asarray(self.flow.hydro_force_torque()) # (4, n, 3): F, tau, F_p, F_visc
142
+ self.last_force = np.array(ft[0][: self.n], dtype=np.float64)
143
+ self.last_torque = np.array(ft[1][: self.n], dtype=np.float64)
144
+ self.last_force_pressure = np.array(ft[2][: self.n], dtype=np.float64)
145
+ self.last_force_viscous = np.array(ft[3][: self.n], dtype=np.float64)
146
+ if not self.move:
147
+ return
148
+ F = self.last_force.copy()
149
+ if self.buoyancy and self.rho_p is not None:
150
+ # The resolved traction already contains the hydrostatic part of the pressure field, so
151
+ # what is added here is only the BODY force on the grain itself. Net gravity on a grain
152
+ # of density rho_p displacing rho_f is (rho_p - rho_f) V g when the fluid carries the
153
+ # hydrostatic gradient; when it does not (the usual periodic set-up, no gravity in the
154
+ # fluid), the full rho_p V g applies. buoyancy=False selects the latter.
155
+ V = 4.0 / 3.0 * np.pi * self.radius**3
156
+ F += (self.rho_p - self.rho_f) * V * self.gravity
157
+ elif self.rho_p is not None:
158
+ V = 4.0 / 3.0 * np.pi * self.radius**3
159
+ F += self.rho_p * V * self.gravity
160
+ self.dem.set_external_forces(np.ascontiguousarray(F, dtype=np.float32))
161
+ if self.apply_torque:
162
+ # World-frame torque; dem rotates it into the body frame in the predictor. Held
163
+ # constant over the sub-steps, exactly like the force.
164
+ self.dem.set_external_torques(
165
+ np.ascontiguousarray(self.last_torque, dtype=np.float32))
166
+ for _ in range(self.dem_substeps):
167
+ # dt MUST be passed explicitly. dem's step(dt=0) is a dynamics-free relaxation step
168
+ # (overlap removal only), so step() with no argument advances nothing and the driver
169
+ # would run happily with a frozen particle.
170
+ self.dem.step(self.dt_dem)
171
+
172
+ def forces(self):
173
+ return self.last_force
174
+
175
+ def torques(self):
176
+ return self.last_torque