flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
settling_gate.py
Go to the documentation of this file.
1"""Layer 4 rung 3 gate: the SETTLING SPHERE, against a terminal velocity predicted from the drag
2coefficient MEASURED IN THE SAME BOX.
3
4Two runs in one periodic box, so every periodicity/blockage correction is common to both and
5cancels -- no literature wall-correction fit is involved, which is the point:
6
7 (a) CALIBRATION. Sphere held fixed; a uniform body force f drives the fluid past it. At steady
8 state the box's own drag coefficient is
9 lambda := F_drag / (6 pi mu R <u>)
10 with <u> the superficial (whole-box volume-average) velocity. For an isolated sphere in
11 unbounded Stokes flow lambda would be 1; in a periodic box at this solid fraction it is
12 several, and that IS the number the settling run must reproduce.
13
14 (b) SETTLING. The same sphere, now free, carrying a body force F_g, with a compensating fluid
15 body force -F_g / V_fluid so total momentum is conserved and a steady state exists (without
16 it the whole periodic system accelerates forever and there is no terminal velocity to find).
17 At terminal state the hydrodynamic force balances F_g, so the prediction is
18 |<u> - U_particle| = F_g / (6 pi mu R lambda).
19
20The dem solver integrates the particle (one grain, no contacts) exactly as it would in a real
21resolved bed, so this gates the whole loop: scene bridge -> geometry rebuild -> flow step ->
22traction integral -> dem sub-steps -> new state.
23"""
24import os
25import sys
26import numpy as np
27from peclet import flow as sdflow
28from peclet import dem as pdem
29from peclet_coupling.resolved import ResolvedCfdDem
30
31N = int(os.environ.get("SETTLE_N", "48"))
32RF = 0.12
33R = RF * N
34MU, RHO_F = 1.0, 1.0
35RHO_P = 5.0
36# Two timesteps, for two different limits.
37# - CALIBRATION has no particle degree of freedom, so it wants a LARGE dt: backward Euler then
38# reaches the steady Stokes solution in few steps.
39# - SETTLING is a WEAK EXPLICIT exchange between the grain and the fluid it drags. Its stability
40# limit is dt * k / m_reduced < 2 with k = 6 pi mu R lambda the drag rate and m_reduced the
41# reduced mass of grain and entrained fluid. At dt=20 that product was 1.7 -- just inside the
42# bound on paper, and the run blew up to 1e16 in 60 steps. dt=2 puts it at 0.17.
43DT_CAL = float(os.environ.get("SETTLE_DT_CAL", "20.0"))
44DT = float(os.environ.get("SETTLE_DT", "2.0"))
45NSTEP = int(os.environ.get("SETTLE_STEPS", "600"))
46NCAL = int(os.environ.get("SETTLE_CAL_STEPS", "400"))
47KN_R, KI_I, KI_R = 16, 2, 17
48V_SPHERE = 4.0 / 3.0 * np.pi * R**3
49V_FLUID = N**3 - V_SPHERE
50CEN = (0.5 * N, 0.5 * N, 0.5 * N)
51
52
53def make_flow(body_force, dt=None):
54 node_ints = np.array([1, -1, -1], dtype=np.int32)
55 node_reals = np.zeros(KN_R); node_reals[0] = R
56 node_reals[14] = 1.0; node_reals[15] = 1.0
57 ii = np.zeros((1, KI_I), dtype=np.int32); ii[0] = (0, -1)
58 ir = np.zeros((1, KI_R)); ir[0, 0:3] = CEN; ir[0, 6] = 1.0; ir[0, 7] = 1.0
59 s = sdflow.Solver(N, N, N)
60 s.set_rho(RHO_F); s.set_mu(MU); s.set_dt(DT if dt is None else dt)
61 s.set_advection(False)
62 s.set_velocity_solver_params(80)
63 s.set_pressure_solver_params(20)
64 # MG depth 4, not 1. levels=1 leaves the 'coarse' solve on the FULL grid, which on
65 # CUDA costs 920 ms/step at N=64 against 19.5 ms at depth 4 (measured). Depth changes the
66 # solver path, not the converged answer, and both runs of a comparison use the same depth.
67 s.set_pressure_multigrid(True, levels=4)
68 s.set_body_force(body_force, 0.0, 0.0)
69 s.set_scene(node_ints, node_reals, ii.ravel(), ir.ravel(), periodic=True)
70 return s
71
72
73def calibrate(f=1e-3, steps=None):
74 s = make_flow(f, dt=DT_CAL)
75 s.set_solid_from_scene(True)
76 for _ in range(steps or NCAL):
77 s.step()
78 u = np.asarray(s.get_u())
79 # REACTION force (route b) -- the same method the driver feeds dem, so the drag coefficient
80 # measured here and the force felt in the settling run are one number, and the gate's
81 # self-consistency claim is real. (With the traction the two halves used the same *biased*
82 # method too, but its frame error broke the cancellation for a moving wall.)
83 Fd = np.asarray(s.hydro_force_torque_reaction())[0][0]
84 Ftr = np.asarray(s.diagnostics.hydro_force_torque_traction())[0][0]
85 Nc = s.fluid_momentum_cells()
86 umean = float(u.mean())
87 lam = Fd[0] / (6.0 * np.pi * MU * R * umean)
88 print("(a) calibration: %d steps at dt=%.1f f=%.1e <u>=%.6e F_drag=(%+.4e,%+.1e,%+.1e)"
89 % (steps or NCAL, DT_CAL, f, umean, *Fd))
90 print(" REACTION identity F / (f*N_fluid) - 1 = %+.3e (must be ~unsteady tail + solver "
91 "residual; percent-level means a budget term is missing)" % (Fd[0] / (f * Nc[0]) - 1))
92 print(" [traction diagnostic on the same run: F / (f*V_fluid) = %.5f -- the ~29%% under-read "
93 "kept visible]" % (Ftr[0] / (f * V_FLUID)))
94 print(" lambda = F_drag / (6 pi mu R <u>) = %.4f (1 would be an isolated sphere)" % lam)
95 return lam, umean
96
97
98def settle(lam, Fg=None):
99 # F_g chosen so the predicted slip is a comfortable fraction of a cell per fluid step
100 if Fg is None:
101 Fg = 6.0 * np.pi * MU * R * lam * 0.02
102 fcomp = -Fg / V_FLUID # compensating fluid body force: momentum conserved
103 s = make_flow(fcomp)
104
105 d = pdem.Simulation(1)
106 d.initialize(shape_type=1, radius=R) # sphere
107 d.set_domain((0.0, 0.0, 0.0), (float(N), float(N), float(N)))
108 d.enable_periodicity(True, True, True)
109 d.set_gravity(0.0, 0.0, 0.0) # gravity arrives as an external force instead
110 pos = np.zeros((1, 4), dtype=np.float32)
111 pos[0, 0:3] = CEN
112 pos[0, 3] = 1.0 # w = 1: a zero w silently remaps the position
113 d.set_positions(pos)
114 d.set_velocities(np.zeros((1, 4), dtype=np.float32))
115 # dem assigns unit mass regardless of size. A unit-mass sphere of radius ~6 cells makes the
116 # WEAK explicit coupling unstable (the drag impulse per dem sub-step, dt*6*pi*mu*R*lambda/m,
117 # must stay below 2 and would be ~650 here), so set a physical mass through set_inv_mass:
118 # m = rho_p * V. This is a real requirement of resolved coupling, not a tuning knob -- a grain
119 # lighter than the fluid it displaces is the classic added-mass instability.
120 rho_p = RHO_P
121 m = rho_p * V_SPHERE
122 d.set_inv_mass(np.array([1.0 / m], dtype=np.float32))
123 if os.environ.get("SEED_FRESH", "0") == "1":
124 s.set_fresh_cell_seed(True)
125 drv = ResolvedCfdDem(s, d, radius=R, mu=MU, rho_f=RHO_F, fluid_dt=DT, dem_substeps=10,
126 periodic=True, gravity=(Fg / m, 0.0, 0.0), rho_p=rho_p, buoyancy=False,
127 move=True)
128 pred = Fg / (6.0 * np.pi * MU * R * lam)
129 hist = []
130 mom = []
131 for k in range(NSTEP):
132 drv.step()
133 up = float(np.asarray(d.get_velocities())[0, 0])
134 uf = float(np.asarray(s.get_u()).mean())
135 hist.append(abs(uf - up)) # MAGNITUDE: the particle leads, so uf - up is negative
136 # ACTION-REACTION DIAGNOSTIC. The external forces sum to zero by construction (F_g on the
137 # grain, -F_g/V_fluid spread over the fluid), so the total x-momentum must be constant. It
138 # is not, because the force REPORTED to the grain is an independent reconstruction of the
139 # traction rather than the discrete reaction the operator applied to the fluid -- the same
140 # gap that makes the static drag 29% low. The drift rate measures it directly.
141 mom.append(m * up + RHO_F * uf * N**3)
142 if k % max(1, NSTEP // 10) == max(1, NSTEP // 10) - 1:
143 print(" step %3d |U_slip|=%.6e (pred %.6e, ratio %.4f) U_p=%+.4e <u>=%+.4e "
144 "p_tot=%+.4e" % (k + 1, hist[-1], pred, hist[-1] / pred, up, uf, mom[-1]))
145 slip = hist[-1]
146 tail = max(1, NSTEP // 20)
147 drift = abs(hist[-1] - hist[-1 - tail]) / abs(pred)
148 print("(b) settling: F_g=%.4e m=%.4e (rho_p=%.3f)" % (Fg, m, rho_p))
149 print(" terminal |<u> - U_p| = %.6e" % slip)
150 print(" predicted from the MEASURED lambda = %.6e" % pred)
151 print(" ratio %.4f (slip drift over the last %d steps: %.2e of the prediction)"
152 % (slip / pred, tail, drift))
153 # With the REACTION force the action-reaction gap is zero by construction; the residual drift
154 # here is the SETUP's own O(h) mismatch -- the compensating fluid body force is -F_g/V_fluid
155 # with the ANALYTIC volume, while the discrete fluid receives f*N_fluid-cells -- plus rebuild
156 # fresh-cell blips. Measured 44x smaller than the traction-force leak and of setup origin.
157 print(" momentum: p_tot %+.4e -> %+.4e over %d steps (%.2e F_g per unit time; the "
158 "traction-force version leaked 0.58 F_g per unit time)"
159 % (mom[0], mom[-1], NSTEP,
160 abs(mom[-1] - mom[0]) / (NSTEP * DT * max(abs(Fg), 1e-300))))
161 ok = abs(slip / pred - 1.0) < 0.05
162 print("GATE %s [claim: terminal velocity matches the same box's measured drag within 5%%]"
163 % ("PASS" if ok else "FAIL"))
164 return ok
165
166
167if __name__ == "__main__":
168 lam, _ = calibrate()
169 settle(lam)
settle(lam, Fg=None)
make_flow(body_force, dt=None)
calibrate(f=1e-3, steps=None)
static double mean(const std::vector< double > &a)