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)
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):
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)
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())
81def bed_sdf(P, nx=NX, ny=NY, nz=NZ, Rg=R_SDF, z0=Z_BED):
84 Q[:, 2] += z0 - 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)
93 phi = np.minimum(phi, np.sqrt(dx * dx + dy * dy + dz * dz) - Rg)
94 return np.asfortranarray(phi)
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)
107 val[i, j, 0] = sub[i, j, 0]
108 h.append((-sub[i, j, 0], i, j, 0))
112 nv, i, j, k = heapq.heappop(h)
114 if v < val[i, j, k] - 1e-12:
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:
124 a, b, c = (i + di) % nx, (j + dj) % ny, k + dk
125 if not (0 <= c < nk):
127 w = min(v, sub[a, b, c])
128 if w > val[a, b, c] + 1e-12:
130 heapq.heappush(h, (-w, a, b, c))
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)
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))
168 while parent[x] != x:
169 parent[x] = parent[parent[x]]
174 ra, rb = find(a), find(b)
176 parent[max(ra, rb)] = min(ra, rb)
177 root = np.array([find(i)
if i
else 0
for i
in range(n + 1)])
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)
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}")
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}")
199 s = pf.Solver(NX, NY, NZ)
203 s.set_domain_bc(
"-z",
"inflow", (0.0, 0.0, U))
204 s.set_domain_bc(
"+z",
"outflow", (0.0, 0.0, 0.0))
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)
213 c0 = np.zeros((NX, NY, NZ), order=
"F")
214 c0[:, :, : klo - 2] = 1.0
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)
223 s.set_vof_inflow(
"-z", 1.0)
224 s.set_vof_backflow(
"+z", 0.0)
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)")
239 dt = CAP_CFL * L0[
"capillary_dt"]
240 sample_every = sample_every
or max(steps // 150, 5)
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)
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:
267 stop_at = i + 1 + int(after * (i + 1))
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:
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 ***")
276 wall = time.time() - t0
277 C = np.asarray(s.get_vof())
278 S = float((C[:, :, klo:khi] * eps[:, :, klo:khi]).sum() / pore)
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}")
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)
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=
"")
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).")
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())
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")
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}")
342 thetas = [float(x)
for x
in a.theta.split(
",")]
343 cas = [float(x)
for x
in a.ca.split(
",")]
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)
352 steps = min(steps, 600)
353 rows.append(
run(th, ca, sdf, klo, khi, steps, budget=a.budget
or None,
355 print(
"\n" +
"=" * 100)
356 print(
"SUMMARY — imbibition into the packing")
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}")
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}")
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