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.
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/CMakeLists.txt +10 -1
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/PKG-INFO +10 -4
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/README.md +9 -3
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/cmake/PecletDeps.cmake +2 -2
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/pyproject.toml +1 -1
- peclet_coupling-0.4.0/python/peclet_coupling/__init__.py +45 -0
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/python/peclet_coupling/driver.py +164 -26
- peclet_coupling-0.4.0/python/peclet_coupling/resolved.py +176 -0
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/src/coupling_bindings.cpp +54 -22
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/src/coupling_kernels.hpp +172 -18
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/src/drag.hpp +36 -1
- peclet_coupling-0.4.0/tests/smoothing_ref_eps.npy +0 -0
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/test_fixed_bed_ergun.py +1 -1
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/test_mpi_fixed_bed_ergun.py +1 -1
- peclet_coupling-0.4.0/tests/test_mpi_smoothing.py +114 -0
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/test_terminal_velocity.py +2 -1
- peclet_coupling-0.2.0/python/peclet_coupling/__init__.py +0 -21
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/.github/workflows/release.yml +0 -0
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/.gitignore +0 -0
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/LICENSE +0 -0
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/examples/fluidized_bed.py +0 -0
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/CMakeLists.txt +0 -0
- {peclet_coupling-0.2.0 → peclet_coupling-0.4.0}/tests/test_fixed_bed_ergun_porous.py +0 -0
- {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.
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
and
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
and
|
|
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.
|
|
20
|
-
set(PECLET_MORTON_TAG "v0.2.
|
|
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.
|
|
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.
|
|
29
|
-
move_particles=True, implicit_drag=True, porous=
|
|
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
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
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)
|
|
59
|
-
#
|
|
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
|
|
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
|
-
# ---
|
|
167
|
-
def _fold(self, f):
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
183
|
-
|
|
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,
|
|
221
|
-
db, fx, fy, fz, *gm, self.mu, self.rho,
|
|
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,
|
|
225
|
-
fx, fy, fz, *gm, self.mu, self.rho,
|
|
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
|