flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
imbibition_packing.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-V7 case 2 — IMBIBITION INTO A SPHERE PACKING.
3
4A random loose packing of ~36 SDF spheres sits in a laterally periodic column that is gas-filled at
5t = 0. Liquid is fed through the bottom face at a fixed superficial velocity and leaves through the
6top. Measured: the liquid saturation of the bed against time, the breakthrough time, and the
7fraction of the pore space left as gas DISCONNECTED from the outlet once the front has broken
8through (the trapped-gas fraction).
9
10The packing is generated in this file (a soft-sphere settling in numpy, deterministic seed), so the
11script has no dependency on `dem`; the SDF grain radius is 0.85 of the contact radius, the same
12device the `bubble-through-packing` gallery page uses, because grains that touch leave throats the
13grid cannot resolve.
14
15CONVENTIONS AND CAVEATS (WO-V7 preamble; stated on every run):
16 * STATIC contact angle (V5b). The capillary number is APPARENT: mu_l U_superficial / sigma.
17 * density ratio 100 (V2b's float-momentum rating; WO-R's G3 caveat at 1000).
18 * inflow/outflow, never a periodic net body force (VOF_PLAN 13.2 item 6).
19 * the packing is kept clear of the inflow and outflow planes (flow's open-BC x cut-cell defect).
20 * a capped pressure solve makes the run INVALID (rule 3b).
21
22Usage:
23 PYTHONPATH=$PWD/build_cuda python tests/study/pore_scale/imbibition_packing.py [--quick]
24 [--theta 30,60] [--ca 1e-3] [--probe] # --probe = ms/step only, 60 steps
25"""
26import argparse
27import heapq
28import json
29import math
30import sys
31import time
32
33import numpy as np
34
35import peclet.flow as pf
36
37# ------------------------------------------------------------------------------------- the scene
38NX = NY = 48
39NZ = 96
40Z_BED = 16.0 # bottom of the bed (inlet plenum below it)
41N_GRAIN = 36
42R_CONTACT = 8.0 # radius the settling uses
43F_SDF = 0.85 # SDF radius / contact radius -> resolvable throats
44R_SDF = F_SDF * R_CONTACT
45
46SIGMA = 100.0
47RHO_L, RHO_G = 100.0, 1.0
48MU_L, MU_G = 4.0, 0.04
49CAP_CFL = 0.5
50PRESS_CAP = 400
51
52
53# ------------------------------------------------------------------------------- packing (numpy)
54def settle(n=N_GRAIN, L=float(NX), R=R_CONTACT, seed=7, iters=4000, dz=0.02):
55 """A soft-sphere deposit: gravity + pairwise overlap resolution + a floor, x/y periodic."""
56 rng = np.random.default_rng(seed)
57 P = np.zeros((n, 3))
58 P[:, :2] = rng.uniform(0.0, L, (n, 2))
59 P[:, 2] = R + 2.1 * R * (np.arange(n) // 6) + rng.uniform(-0.4, 0.4, n)
60 for _ in range(iters):
61 P[:, 2] -= dz
62 for _ in range(4):
63 d = P[None, :, :] - P[:, None, :]
64 d[:, :, 0] -= L * np.round(d[:, :, 0] / L)
65 d[:, :, 1] -= L * np.round(d[:, :, 1] / L)
66 r = np.sqrt((d ** 2).sum(-1))
67 np.fill_diagonal(r, 1e9)
68 ov = np.maximum(2.0 * R - r, 0.0)
69 u = d / np.maximum(r, 1e-9)[:, :, None]
70 P -= 0.5 * (ov[:, :, None] * u).sum(axis=1)
71 P[:, 2] = np.maximum(P[:, 2], R)
72 P[:, :2] %= L
73 d = P[None, :, :] - P[:, None, :]
74 d[:, :, 0] -= L * np.round(d[:, :, 0] / L)
75 d[:, :, 1] -= L * np.round(d[:, :, 1] / L)
76 r = np.sqrt((d ** 2).sum(-1))
77 np.fill_diagonal(r, 1e9)
78 return P, float(np.maximum(2.0 * R - r, 0.0).max())
79
80
81def bed_sdf(P, nx=NX, ny=NY, nz=NZ, Rg=R_SDF, z0=Z_BED):
82 L = float(nx)
83 Q = P.copy()
84 Q[:, 2] += z0 - R_CONTACT # the lowest grain CENTRE sits at z0 + (R_contact - R_contact)
85 g = np.arange(nx) + 0.5
86 gz = np.arange(nz) + 0.5
87 X, Y, Z = np.meshgrid(g, np.arange(ny) + 0.5, gz, indexing="ij")
88 phi = np.full((nx, ny, nz), 1e30)
89 for k in range(len(Q)):
90 dx = X - Q[k, 0]; dx -= L * np.round(dx / L)
91 dy = Y - Q[k, 1]; dy -= L * np.round(dy / L)
92 dz = Z - Q[k, 2]
93 phi = np.minimum(phi, np.sqrt(dx * dx + dy * dy + dz * dz) - Rg)
94 return np.asfortranarray(phi)
95
96
97def percolation_throat(phi, klo, khi):
98 """Max over bottom->top paths of the minimum SDF along the path (26-connected, x/y periodic):
99 the radius of the largest sphere that can be pushed through the bed."""
100 sub = phi[:, :, klo:khi]
101 nx, ny, nk = sub.shape
102 val = np.full(sub.shape, -np.inf)
103 h = []
104 for i in range(nx):
105 for j in range(ny):
106 if sub[i, j, 0] > 0:
107 val[i, j, 0] = sub[i, j, 0]
108 h.append((-sub[i, j, 0], i, j, 0))
109 heapq.heapify(h)
110 best = -np.inf
111 while h:
112 nv, i, j, k = heapq.heappop(h)
113 v = -nv
114 if v < val[i, j, k] - 1e-12:
115 continue
116 if k == nk - 1:
117 best = max(best, v)
118 break
119 for di in (-1, 0, 1):
120 for dj in (-1, 0, 1):
121 for dk in (-1, 0, 1):
122 if di == dj == dk == 0:
123 continue
124 a, b, c = (i + di) % nx, (j + dj) % ny, k + dk
125 if not (0 <= c < nk):
126 continue
127 w = min(v, sub[a, b, c])
128 if w > val[a, b, c] + 1e-12:
129 val[a, b, c] = w
130 heapq.heappush(h, (-w, a, b, c))
131 return best
132
133
134class Health:
135 def __init__(self, cap=PRESS_CAP):
136 self.cap, self.iters, self.div, self.capped = cap, 0, 0.0, 0
137
138 def sample(self, s):
139 it = s.diagnostics.last_pressure_iterations()
140 self.iters = max(self.iters, it)
141 self.capped += int(it >= self.cap)
142 self.div = max(self.div, s.max_open_divergence_projected())
143
144 @property
145 def valid(self):
146 return self.capped == 0
147
148 def __str__(self):
149 return (f"pressure {self.iters}/{self.cap} ({self.capped} capped) "
150 f"{'OK' if self.valid else '*** CAPPED -> RUN INVALID ***'}, "
151 f"max|div(open u)|_projected {self.div:.3e}")
152
153
154def trapped_gas(C, eps, klo, khi):
155 """Fraction of the bed pore volume held as gas NOT connected to the outlet plenum.
156 6-connected labelling with x/y periodic wrap; the outlet plenum is the seed."""
157 from scipy import ndimage
158 gas = (C < 0.5) & (eps > 0.0)
159 lab, n = ndimage.label(gas)
160 # stitch the x and y periodic seams
161 eq = set()
162 for a, b in ((lab[0], lab[-1]), (lab[:, 0], lab[:, -1])):
163 m = (a > 0) & (b > 0)
164 eq.update(zip(a[m].ravel().tolist(), b[m].ravel().tolist()))
165 parent = list(range(n + 1))
166
167 def find(x):
168 while parent[x] != x:
169 parent[x] = parent[parent[x]]
170 x = parent[x]
171 return x
172
173 for a, b in eq:
174 ra, rb = find(a), find(b)
175 if ra != rb:
176 parent[max(ra, rb)] = min(ra, rb)
177 root = np.array([find(i) if i else 0 for i in range(n + 1)])
178 lab = root[lab]
179 open_roots = set(np.unique(lab[:, :, khi:]).tolist()) - {0}
180 bed = slice(klo, khi)
181 pore = float(eps[:, :, bed].sum())
182 gsel = gas[:, :, bed] & ~np.isin(lab[:, :, bed], list(open_roots))
183 trapped = float((eps[:, :, bed] * (1.0 - C[:, :, bed]) * gsel).sum())
184 return trapped / max(pore, 1e-30), len(open_roots)
185
186
187def run(theta_deg, ca, sdf, klo, khi, steps, probe=False, after=0.25, sample_every=None,
188 budget=None, npy_dir=""):
189 U = ca * SIGMA / MU_L
190 Re = RHO_L * U * 2 * R_SDF / MU_L
191 print("\n" + "=" * 100)
192 print(f"IMBIBITION INTO THE PACKING theta = {theta_deg:g} deg Ca(apparent) = {ca:g}")
193 print("=" * 100)
194 print(f" grid {NX}x{NY}x{NZ}; bed core z = {klo} .. {khi}; sigma {SIGMA:g}, "
195 f"rho {RHO_L:g}/{RHO_G:g}, mu {MU_L:g}/{MU_G:g}")
196 print(f" inlet superficial U = Ca*sigma/mu_l = {U:.6g} cells/s -> grain Reynolds "
197 f"rho_l U D / mu_l = {Re:.4g}")
198
199 s = pf.Solver(NX, NY, NZ)
200 s.set_rho(RHO_L)
201 s.set_mu(MU_L)
202 s.set_dt(0.05)
203 s.set_domain_bc("-z", "inflow", (0.0, 0.0, U)) # -z inflow (liquid)
204 s.set_domain_bc("+z", "outflow", (0.0, 0.0, 0.0)) # +z outflow
205 s.diagnostics.set_velocity_solver_params(60)
206 s.set_pressure_multigrid(True, levels=8)
207 s.set_pressure_solver_params(80)
208 s.set_solid(sdf, cutcell_pressure=True)
209 s.enable_vof()
210 # t = 0 is the front at the BED FACE: the inlet plenum below the bed is already liquid. It is
211 # not a shortcut for its own sake - filling the plenum from the inlet costs more steps than the
212 # bed itself and carries no physics (the plenum is an open channel).
213 c0 = np.zeros((NX, NY, NZ), order="F")
214 c0[:, :, : klo - 2] = 1.0
215 s.set_vof(c0)
216 s.set_property_model("rho", "linear", "C", [RHO_G, RHO_L - RHO_G])
217 s.set_property_model("mu", "linear", "C", [MU_G, MU_L - MU_G])
218 s.enable_vof_momentum(RHO_G, RHO_L)
219 s.set_surface_tension(SIGMA)
220 s.set_capillary_cfl(CAP_CFL)
221 s.set_contact_angle(theta_deg)
222 s.set_pressure_fcg(True, PRESS_CAP, 1e-11) # driver LAST (set_density_mode reselects)
223 s.set_vof_inflow("-z", 1.0)
224 s.set_vof_backflow("+z", 0.0)
225
226 eps = np.asarray(s.diagnostics.vof_geometry(0))
227 pore = float(eps[:, :, klo:khi].sum())
228 L0 = s.vof_step_limits()
229 print(f" dt census at t = 0: capillary_dt {L0['capillary_dt']:.5g} (x{CAP_CFL}), "
230 f"WY cfl_dt {L0['cfl_dt']:.5g} -> "
231 f"{'CAPILLARY' if L0['capillary_binds'] else 'CFL'} binds "
232 f"(capillary_dt is state-dependent; dt is re-picked every step)")
233
234 h = Health()
235 hist = []
236 bt = None
237 ncfl = 0
238 t = 0.0
239 dt = CAP_CFL * L0["capillary_dt"]
240 sample_every = sample_every or max(steps // 150, 5)
241 t0 = time.time()
242 n = 0
243 stop_at = None
244 for i in range(steps):
245 L = s.vof_step_limits()
246 dtc = CAP_CFL * L["capillary_dt"]
247 dt = min(dtc, 0.8 * L["cfl_dt"]) if L["cfl_dt"] > 0 else dtc
248 ncfl += int(dt < dtc * 0.999)
249 s.set_dt(dt)
250 s.step()
251 t += dt
252 n += 1
253 h.sample(s)
254 if (i + 1) % sample_every == 0 or i == steps - 1:
255 C = np.asarray(s.get_vof())
256 S = float((C[:, :, klo:khi] * eps[:, :, klo:khi]).sum() / pore)
257 top = float((C[:, :, khi - 1] * eps[:, :, khi - 1]).max())
258 hist.append((t, S, top))
259 if len(hist) % 10 == 1:
260 um = max(float(np.abs(np.asarray(f())).max())
261 for f in (s.get_u, s.get_v, s.get_w))
262 print(f" step {i+1:7d} t {t:10.4g} dt {dt:9.3g} S {S:.4f} top {top:.3f}"
263 f" max|u| {um:9.2e} ({time.time()-t0:.0f} s, "
264 f"{1000*(time.time()-t0)/(i+1):.0f} ms/step)", flush=True)
265 if bt is None and top > 0.5:
266 bt = (t, i + 1, S)
267 stop_at = i + 1 + int(after * (i + 1)) # run `after` x t_bt further
268 print(f" breakthrough at t = {t:.5g} (step {i+1}), bed saturation "
269 f"{S:.4f}; continuing to step {stop_at}")
270 if stop_at is not None and i + 1 >= stop_at:
271 break
272 if budget and time.time() - t0 > budget:
273 print(f" *** wall-clock budget {budget:.0f} s reached at step {i+1} "
274 f"(t = {t:.5g}, S = {S:.4f}) - PARTIAL ***")
275 break
276 wall = time.time() - t0
277 C = np.asarray(s.get_vof())
278 S = float((C[:, :, klo:khi] * eps[:, :, klo:khi]).sum() / pore)
279 trap, nclust = trapped_gas(C, eps, klo, khi)
280 if npy_dir:
281 import os
282 os.makedirs(npy_dir, exist_ok=True)
283 np.save(os.path.join(npy_dir, f"packing_C_theta{int(theta_deg)}.npy"), C)
284 np.save(os.path.join(npy_dir, "packing_eps.npy"), eps)
285 d = s.diagnostics.vof_diagnostics()
286 print(f"\n ran {n} steps to t = {t:.5g} s in {wall:.0f} s "
287 f"({1000*wall/max(n,1):.2f} ms/step, SHARED GPU)")
288 print(f" dt final {dt:.5g}; the WY CFL limit displaced the capillary one on {ncfl} of "
289 f"{n} steps ({100.0*ncfl/max(n,1):.1f} %)")
290 print(f" breakthrough: " + (f"t = {bt[0]:.5g} s at step {bt[1]}, saturation at "
291 f"breakthrough S_bt = {bt[2]:.4f}"
292 if bt else "NOT reached"))
293 print(f" final bed saturation S = {S:.4f}; trapped (outlet-disconnected) gas "
294 f"{trap:.4f} of the pore volume, in {nclust} outlet-connected cluster(s)")
295 print(f" colour: solid_sum {d['solid_sum']:.3e}, min/max over uncut fluid "
296 f"{d['min_fluid']:.3e}/{d['max_fluid']:.6f}, clipped {d['clipped_volume']:.3e}")
297 print(f" {h}")
298 return dict(theta=theta_deg, ca=ca, U=U, Re=Re, steps=n, t=t, wall=wall,
299 ms_per_step=1000 * wall / max(n, 1),
300 t_bt=bt[0] if bt else None, step_bt=bt[1] if bt else None,
301 S_bt=bt[2] if bt else None, S_final=S, trapped=trap,
302 iters=h.iters, capped=h.capped, div=h.div, valid=h.valid,
303 cfl_fraction=ncfl / max(n, 1), hist=hist)
304
305
306def main():
307 ap = argparse.ArgumentParser()
308 ap.add_argument("--quick", action="store_true")
309 ap.add_argument("--probe", action="store_true", help="60 steps, ms/step only")
310 ap.add_argument("--theta", default="30,60")
311 ap.add_argument("--ca", default="1e-3")
312 ap.add_argument("--budget", type=float, default=0.0)
313 ap.add_argument("--npy", default="")
314 ap.add_argument("--out", default="")
315 a = ap.parse_args()
316 print("WO-V7 case 2 — imbibition into an SDF sphere packing. Static contact angle (V5b);")
317 print("the capillary numbers are APPARENT (mu_l U_superficial / sigma).")
318 t0 = time.time()
319 P, ov = settle()
320 sdf = bed_sdf(P)
321 top = P[:, 2].max() - R_CONTACT + Z_BED + R_SDF
322 bot = Z_BED + (R_CONTACT - R_SDF) - R_CONTACT + P[:, 2].min()
323 klo, khi = int(math.ceil(bot + R_SDF)), int(math.floor(top - R_SDF))
324 eps_bed = float((sdf[:, :, klo:khi] > 0).mean())
325 thr = percolation_throat(sdf, klo, khi)
326 print(f"\n {N_GRAIN} grains settled in {time.time()-t0:.0f} s, max contact overlap "
327 f"{ov:.4f} cells (contact radius {R_CONTACT}, SDF radius {R_SDF})")
328 print(f" bed surfaces span z = {bot:.1f} .. {top:.1f} ({(top-bot)/(2*R_SDF):.1f} SDF grain "
329 f"diameters); bed CORE z = {klo} .. {khi}")
330 print(f" porosity over the bed core: eps = {eps_bed:.4f}")
331 print(f" percolation throat radius (max-min path, bottom -> top): {thr:.3f} cells "
332 f"-> the entry pressure scale 2 sigma cos(theta)/r_throat is "
333 f"{2*SIGMA/max(thr,1e-9):.3g} x cos(theta)")
334 print(f" inlet plenum z = 0 .. {int(bot)} and outlet plenum z = {int(top)} .. {NZ} keep the "
335 f"solid clear of both open faces")
336
337 if a.probe:
338 r = run(30.0, 1e-3, sdf, klo, khi, 60, probe=True, sample_every=30)
339 print(f"\n PROBE: {r['ms_per_step']:.2f} ms/step on {NX}x{NY}x{NZ}")
340 return 0
341
342 thetas = [float(x) for x in a.theta.split(",")]
343 cas = [float(x) for x in a.ca.split(",")]
344 rows = []
345 for th in thetas:
346 for ca in cas:
347 U = ca * SIGMA / MU_L
348 ttrav = (khi - klo + 6) / (U / max(eps_bed, 0.2))
349 dt0 = CAP_CFL * math.sqrt((RHO_L + RHO_G) / (4 * math.pi * SIGMA))
350 steps = int(2.5 * ttrav / dt0)
351 if a.quick:
352 steps = min(steps, 600)
353 rows.append(run(th, ca, sdf, klo, khi, steps, budget=a.budget or None,
354 npy_dir=a.npy))
355 print("\n" + "=" * 100)
356 print("SUMMARY — imbibition into the packing")
357 print("=" * 100)
358 print(f"{'theta':>6} {'Ca':>8} {'Re':>7} {'t_bt':>10} {'S_bt':>7} {'S_final':>8} "
359 f"{'trapped':>8} {'iters':>6} {'ms/step':>8} {'valid':>6}")
360 for r in rows:
361 tb = f"{r['t_bt']:.4g}" if r["t_bt"] else "-"
362 sb = f"{r['S_bt']:.4f}" if r["S_bt"] else "-"
363 print(f"{r['theta']:6g} {r['ca']:8g} {r['Re']:7.3g} {tb:>10} {sb:>7} {r['S_final']:8.4f} "
364 f"{r['trapped']:8.4f} {r['iters']:6d} {r['ms_per_step']:8.2f} "
365 f"{'yes' if r['valid'] else 'NO':>6}")
366 if a.out:
367 with open(a.out, "w") as fh:
368 json.dump(rows, fh, indent=1)
369 print(f"\n wrote {a.out}")
370 return 0 if all(r["valid"] for r in rows) else 1
371
372
373if __name__ == "__main__":
374 sys.exit(main())
__init__(self, cap=PRESS_CAP)
run(theta_deg, ca, sdf, klo, khi, steps, probe=False, after=0.25, sample_every=None, budget=None, npy_dir="")
bed_sdf(P, nx=NX, ny=NY, nz=NZ, Rg=R_SDF, z0=Z_BED)
trapped_gas(C, eps, klo, khi)
percolation_throat(phi, klo, khi)
settle(n=N_GRAIN, L=float(NX), R=R_CONTACT, seed=7, iters=4000, dz=0.02)
static std::vector< double > slice(const std::vector< double > &g, int ox, int oy, int oz, int lnx, int lny, int lnz)
static double mean(const std::vector< double > &a)