flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
micromodel_2d.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-V7 case 3 — a ZHAO-2019-STYLE 2-D MICROMODEL: wettability and the invasion pattern.
3
4A disordered array of ~56 SDF cylinders (a jittered lattice of posts, the geometry class Zhao,
5MacMinn & Juanes, *PNAS* 116:13799 (2019) used) fills a quasi-2D cell that is gas-filled at t = 0.
6Liquid is injected through the -x face at a fixed capillary number and leaves at +x. The sweep is
7over the contact angle only: theta = 45 (imbibition), 90 (neutral), 135 (drainage).
8
9Zhao et al.'s qualitative result, which this page reproduces or fails to:
10**wettability controls the pattern**. Strong imbibition gives a COMPACT, high-saturation
11displacement (cooperative pore filling, corner films run ahead); drainage gives a FINGERED,
12low-saturation one (capillary fingering / invasion percolation). The quantitative read-outs are
13the saturation at breakthrough and the box-counting fractal dimension of the invaded region.
14
15CONVENTIONS AND CAVEATS (WO-V7 preamble; stated on every run):
16 * STATIC contact angle (V5b). Ca is APPARENT: mu_l U_inlet / sigma.
17 * density ratio 100; inflow/outflow (never a periodic net force); the posts are clear of both
18 open faces; a capped pressure solve makes the run INVALID.
19 * **corner films are NOT represented.** VOF_PLAN 4/V7 names this as the lesson to design
20 against: the strong-imbibition end of Zhao's benchmark is decided by films in the corners of
21 the posts, which a 5.7-cell-radius post on a unit-spacing grid cannot resolve. Expect the
22 compact/fingered TREND, not the saturations.
23
24Usage:
25 PYTHONPATH=$PWD/build_cuda python tests/study/pore_scale/micromodel_2d.py [--quick]
26 [--theta 45,90,135] [--ca 1e-3] [--budget 1800] [--npy DIR]
27"""
28import argparse
29import json
30import math
31import os
32import sys
33import time
34
35import numpy as np
36
37import peclet.flow as pf
38
39# ------------------------------------------------------------------------------------- the scene
40NX, NY, NZ = 128, 128, 4
41# The work order asks for ~60 posts at porosity ~0.6 on a 128^2 grid. MEASURED: that array (56
42# posts, R = 5.7, narrowest throat 3.08 cells -- a square array at porosity 0.6 has throats of
43# 0.286 x the lattice spacing) ran to PV = 0.121 and then emitted
44# `CutcellMG::solveFCG: preconditioner produced non-finite z` with max|u| at 93 x the inlet
45# velocity. The array below trades post COUNT for throat WIDTH -- 30 posts, narrowest throat 6.4
46# cells, porosity 0.712 -- and survives further. Two candidate mechanisms for the 3-cell failure,
47# NOT separated by this campaign: (a) the theta-fill writes a three-cell band into the solid on
48# each side of a throat, so at 3 cells the two posts' bands meet in the middle of it (the same
49# overlap WO-S recorded as making its 4-cell-plate Jurin scene inconclusive); (b) a Haines jump
50# through a throat whose meniscus radius is ~1.5 cells is simply unresolved -- the local velocity
51# of a real pore-filling event runs up towards the capillary velocity sigma/mu_l = 25, which is
52# 1000 x the inlet velocity here, and a wider throat resolves it better. Separating them needs a
53# theta-sweep at fixed throat width and is left for V9/V6b.
54NCOL, NROW = 5, 6 # 30 posts on a staggered, jittered lattice
55X0, DX = 20.0, 21.5 # first post column centre and the column spacing
56DY = NY / NROW # 21.33
57R_POST = 6.5 # -> pi R^2 / (DX*DY) = 0.289 solid, porosity ~0.71
58JITTER = 1.5
59MIN_GAP = 6.0 # surface-to-surface, enforced by rejection: TWO wetting
60 # bands of three cells each must fit inside a throat
61X_IN = 10.0 # prefill: liquid up to here at t = 0 (clear of every post)
62X_BT = 118.0 # breakthrough plane
63
64SIGMA = 100.0
65RHO_L, RHO_G = 100.0, 1.0
66MU_L, MU_G = 4.0, 0.04
67CAP_CFL = 0.5
68PRESS_CAP = 400
69PRESS_RTOL = 1e-8
70MG_LEVELS = 5
71VEL_SWEEPS = 20
72
73
74def posts(seed=3):
75 """A staggered lattice of post centres with bounded jitter, rejected until every pair is at
76 least MIN_GAP apart at the surfaces (y periodic)."""
77 rng = np.random.default_rng(seed)
78 base = []
79 for i in range(NCOL):
80 for j in range(NROW):
81 base.append((X0 + i * DX, (j + 0.5 * (i % 2)) * DY))
82 base = np.array(base)
83 for _ in range(20000):
84 P = base + rng.uniform(-JITTER, JITTER, base.shape)
85 P[:, 1] %= NY
86 d = P[None, :, :] - P[:, None, :]
87 d[:, :, 1] -= NY * np.round(d[:, :, 1] / NY)
88 r = np.sqrt((d ** 2).sum(-1))
89 np.fill_diagonal(r, 1e9)
90 if (r - 2 * R_POST).min() >= MIN_GAP:
91 return P, float((r - 2 * R_POST).min()), float(np.sort((r - 2*R_POST).ravel())[:len(P)*3].mean())
92 raise RuntimeError("no admissible jittered lattice found — relax MIN_GAP")
93
94
95def post_sdf(P):
96 x = (np.arange(NX) + 0.5)[:, None]
97 y = (np.arange(NY) + 0.5)[None, :]
98 phi = np.full((NX, NY), 1e30)
99 for cx, cy in P:
100 dy = y - cy
101 dy -= NY * np.round(dy / NY)
102 phi = np.minimum(phi, np.sqrt((x - cx) ** 2 + dy ** 2) - R_POST)
103 return np.asfortranarray(np.broadcast_to(phi[:, :, None], (NX, NY, NZ)).astype(float).copy())
104
105
106class Health:
107 def __init__(self, cap=PRESS_CAP):
108 self.cap, self.iters, self.div, self.capped = cap, 0, 0.0, 0
109 self.failed = 0 # ISSUES sweep item 6: preconditioner breakdowns
110
111 def sample(self, s):
112 it = s.diagnostics.last_pressure_iterations()
113 self.iters = max(self.iters, it)
114 self.capped += int(it >= self.cap)
115 # ISSUES sweep item 6. A solve that gives up on a non-finite preconditioner output used to
116 # print one line to stdout, zero the correction and report 0 iterations -- so this very
117 # check said "no capped solve, run valid" while the projection had been handed NOTHING.
118 # It now reports the CAP (so `capped` sees it) and raises this flag, which is the one that
119 # names the mechanism.
120 self.failed += int(s.diagnostics.pressure_solve_failed())
121 self.div = max(self.div, s.max_open_divergence_projected())
122
123 @property
124 def valid(self):
125 return self.capped == 0 and self.failed == 0
126
127 def __str__(self):
128 return (f"pressure {self.iters}/{self.cap} ({self.capped} capped, "
129 f"{self.failed} preconditioner breakdowns) "
130 f"{'OK' if self.valid else '*** INVALID RUN ***'}, "
131 f"max|div(open u)|_projected {self.div:.3e}")
132
133
134def box_dimension(mask, sizes=(1, 2, 4, 8, 16, 32)):
135 """Box-counting dimension of a 2-D boolean set; returns (D, [(eps, N)])."""
136 pts = []
137 nx, ny = mask.shape
138 for e in sizes:
139 mx, my = (nx // e) * e, (ny // e) * e
140 blk = mask[:mx, :my].reshape(mx // e, e, my // e, e).any(axis=(1, 3))
141 n = int(blk.sum())
142 if n > 0:
143 pts.append((e, n))
144 if len(pts) < 3:
145 return float("nan"), pts
146 x = np.log(np.array([1.0 / p[0] for p in pts]))
147 y = np.log(np.array([float(p[1]) for p in pts]))
148 return float(np.polyfit(x, y, 1)[0]), pts
149
150
151def front_stats(inv):
152 """Roughness of the invasion front, the discriminator Zhao's experiment is really about.
153
154 `inv` is the boolean invaded set of the array region in the mid-plane, indexed (x, y). For
155 every transverse row y that the liquid reached, the front is the furthest x it reached; the
156 MEAN of that is how far the displacement has got and its STANDARD DEVIATION is how ragged it
157 is. A compact (cooperative-filling) displacement has a small std and reaches every row; a
158 fingered one has a large std, a large max/mean ratio, and leaves rows untouched.
159 """
160 from scipy import ndimage
161 reach = np.array([np.max(np.nonzero(inv[:, j])[0]) if inv[:, j].any() else -1
162 for j in range(inv.shape[1])])
163 ok = reach >= 0
164 lab, n = ndimage.label(inv)
165 return dict(cells=int(inv.sum()), rows=int(ok.sum()), nrows=int(inv.shape[1]),
166 mean=float(reach[ok].mean()) if ok.any() else 0.0,
167 std=float(reach[ok].std()) if ok.any() else 0.0,
168 max=int(reach.max()), clusters=int(n))
169
170
171def run(theta_deg, ca, sdf, steps, budget=None, npy_dir=""):
172 U = ca * SIGMA / MU_L
173 Re = RHO_L * U * 2 * R_POST / MU_L
174 print("\n" + "=" * 100)
175 print(f"MICROMODEL theta = {theta_deg:g} deg Ca(apparent) = {ca:g}")
176 print("=" * 100)
177 print(f" grid {NX}x{NY}x{NZ} (quasi-2D, periodic in y and z); inlet U = {U:.6g} cells/s "
178 f"-> post Reynolds {Re:.4g}")
179
180 s = pf.Solver(NX, NY, NZ)
181 s.set_rho(RHO_L)
182 s.set_mu(MU_L)
183 s.set_dt(0.05)
184 s.set_domain_bc("-x", "inflow", (U, 0.0, 0.0))
185 s.set_domain_bc("+x", "outflow", (0.0, 0.0, 0.0))
186 s.diagnostics.set_velocity_solver_params(VEL_SWEEPS)
187 s.set_pressure_multigrid(True, levels=MG_LEVELS)
188 s.set_pressure_solver_params(80)
189 s.set_solid(sdf, cutcell_pressure=True)
190 s.enable_vof()
191 c0 = np.zeros((NX, NY, NZ), order="F")
192 c0[: int(X_IN), :, :] = 1.0
193 s.set_vof(c0)
194 s.set_property_model("rho", "linear", "C", [RHO_G, RHO_L - RHO_G])
195 s.set_property_model("mu", "linear", "C", [MU_G, MU_L - MU_G])
196 s.enable_vof_momentum(RHO_G, RHO_L)
197 s.set_surface_tension(SIGMA)
198 s.set_capillary_cfl(CAP_CFL)
199 s.set_contact_angle(theta_deg)
200 s.set_pressure_fcg(True, PRESS_CAP, PRESS_RTOL) # driver LAST
201 s.set_vof_inflow("-x", 1.0)
202 s.set_vof_backflow("+x", 0.0)
203
204 eps = np.asarray(s.diagnostics.vof_geometry(0))
205 arr = slice(int(X_IN), int(X_BT))
206 pore = float(eps[arr].sum())
207 L0 = s.vof_step_limits()
208 print(f" dt census at t = 0: capillary_dt {L0['capillary_dt']:.5g} (x{CAP_CFL}), "
209 f"WY cfl_dt {L0['cfl_dt']:.5g} -> "
210 f"{'CAPILLARY' if L0['capillary_binds'] else 'CFL'} binds "
211 f"(capillary_dt is state-dependent; dt is re-picked every step)")
212
213 h = Health()
214 hist = []
215 bt = None
216 ncfl = 0
217 t = 0.0
218 n = 0
219 every = max(steps // 600, 5)
220 t0 = time.time()
221 partial = False
222 Cbt = None
223 # the injected liquid volume in PORE VOLUMES of the array: the comparable clock when a run
224 # cannot afford to reach breakthrough
225 pv_stop = 0.10
226 for i in range(steps):
227 L = s.vof_step_limits()
228 dtc = CAP_CFL * L["capillary_dt"]
229 dt = min(dtc, 0.8 * L["cfl_dt"]) if L["cfl_dt"] > 0 else dtc
230 ncfl += int(dt < dtc * 0.999)
231 s.set_dt(dt)
232 s.step()
233 t += dt
234 n += 1
235 h.sample(s)
236 if (i + 1) % every == 0 or i == steps - 1:
237 C = np.asarray(s.get_vof())
238 S = float((C[arr] * eps[arr]).sum() / pore)
239 front = float(C[int(X_BT) - 1, :, :].max())
240 pv = U * NY * NZ * t / pore
241 hist.append((t, S, front, pv))
242 if len(hist) % 5 == 1:
243 um = max(float(np.abs(np.asarray(f())).max())
244 for f in (s.get_u, s.get_v, s.get_w))
245 print(f" step {i+1:7d} t {t:10.4g} dt {dt:9.3g} PV {pv:.3f} S {S:.4f} "
246 f"front {front:.3f} max|u| {um:9.2e} ({time.time()-t0:.0f} s, "
247 f"{1000*(time.time()-t0)/(i+1):.0f} ms/step)", flush=True)
248 if bt is None and front > 0.5:
249 bt = (t, i + 1, S)
250 Cbt = C.copy()
251 print(f" breakthrough at t = {t:.5g} (step {i+1}), array saturation "
252 f"S_bt = {S:.4f}")
253 break
254 if pv >= pv_stop:
255 partial = True
256 print(f" *** {pv_stop:.2f} pore volumes injected at step {i+1} "
257 f"(t = {t:.5g}, S = {S:.4f}) without breakthrough - stopping here so the "
258 f"three angles are compared at the SAME injected volume ***")
259 break
260 if budget and time.time() - t0 > budget:
261 partial = True
262 print(f" *** wall-clock budget {budget:.0f} s reached at step {i+1} "
263 f"(t = {t:.5g}, S = {S:.4f}) — PARTIAL, no breakthrough ***")
264 break
265 wall = time.time() - t0
266 C = np.asarray(s.get_vof()) if Cbt is None else Cbt
267 S = float((C[arr] * eps[arr]).sum() / pore)
268 mid = NZ // 2
269 fluid2 = eps[:, :, mid] > 0.0
270 inv = (C[:, :, mid] > 0.5) & fluid2
271 D, pts = box_dimension(inv[arr])
272 fs = front_stats(inv[arr])
273 frac_pore_area = float((eps[arr][:, :, mid] > 0).mean())
274 print(f"\n ran {n} steps to t = {t:.5g} s in {wall:.0f} s "
275 f"({1000*wall/max(n,1):.2f} ms/step, SHARED GPU)")
276 print(f" dt final {dt:.5g}; the WY CFL limit displaced the capillary one on {ncfl} of "
277 f"{n} steps ({100.0*ncfl/max(n,1):.1f} %)")
278 print(f" injected liquid: {U*NY*NZ*t/pore:.4f} pore volumes of the array")
279 print(f" saturation of the post array: {S:.4f}" + (" (AT BREAKTHROUGH)" if bt else
280 " (partial run, no breakthrough)"))
281 print(f" box-counting dimension of the invaded region in the mid-plane: D = {D:.4f}")
282 print(" " + " ".join(f"eps={e}:N={m}" for e, m in pts))
283 print(f" (a compact invasion of this pore space would read D -> 2; the pore area itself "
284 f"is {frac_pore_area:.3f} of the array)")
285 print(f" front roughness: reached {fs['rows']}/{fs['nrows']} transverse rows; front position "
286 f"mean {fs['mean']:.2f} cells, std {fs['std']:.2f}, deepest finger {fs['max']}; "
287 f"{fs['clusters']} connected invaded cluster(s)")
288 print(" (a COMPACT displacement reaches every row with a small std; a FINGERED one has a "
289 "large std, a deep max and leaves rows untouched)")
290 d = s.diagnostics.vof_diagnostics()
291 print(f" colour: solid_sum {d['solid_sum']:.3e}, min/max over uncut fluid "
292 f"{d['min_fluid']:.3e}/{d['max_fluid']:.6f}, clipped {d['clipped_volume']:.3e}")
293 print(f" {h}")
294 if npy_dir:
295 os.makedirs(npy_dir, exist_ok=True)
296 np.save(os.path.join(npy_dir, f"micromodel_C_theta{int(theta_deg)}.npy"), C[:, :, mid])
297 np.save(os.path.join(npy_dir, "micromodel_eps.npy"), eps[:, :, mid])
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, S_bt=bt[2] if bt else None, S=S, D=D,
301 boxes=pts, front=fs, iters=h.iters, capped=h.capped, div=h.div, valid=h.valid,
302 pv=U * NY * NZ * t / pore,
303 partial=partial, 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("--theta", default="45,90,135")
310 ap.add_argument("--ca", default="1e-3")
311 ap.add_argument("--budget", type=float, default=0.0)
312 ap.add_argument("--npy", default="")
313 ap.add_argument("--out", default="")
314 # ISSUES sweep item 6: restore the ORIGINAL 56-post array (narrowest throat 3.08 cells) that
315 # emitted `CutcellMG::solveFCG: preconditioner produced non-finite z` four times in a row and
316 # then kept running. It is the reproducer for the visibility gate, not a scene to publish.
317 ap.add_argument("--reproduce-wov7", action="store_true")
318 a = ap.parse_args()
319 if a.reproduce_wov7:
320 global NCOL, NROW, X0, DX, DY, R_POST, MIN_GAP
321 NCOL, NROW = 7, 8
322 X0, DX = 16.0, 16.0
323 DY = NY / NROW
324 R_POST = 5.7
325 MIN_GAP = 3.0
326 print("*** --reproduce-wov7: the 56-post / 3.08-cell-throat array (ISSUES item 6) ***")
327 print("WO-V7 case 3 — a Zhao-2019-style 2-D micromodel. Static contact angle (V5b); the")
328 print("capillary number is APPARENT (mu_l U_inlet / sigma).")
329 P, gmin, gmean = posts()
330 sdf = post_sdf(P)
331 por = float((sdf[:, :, 0][int(X_IN):int(X_BT)] > 0).mean())
332 print(f"\n {len(P)} posts of radius {R_POST} on a staggered jittered lattice "
333 f"(spacing {DX:g} x {DY:g}, jitter +-{JITTER})")
334 print(f" porosity of the array region x = {X_IN:g} .. {X_BT:g}: {por:.4f}")
335 print(f" narrowest throat (surface-to-surface) {gmin:.3f} cells; mean of the tightest "
336 f"{3*len(P)} pair gaps {gmean:.3f} cells")
337 print(f" the meniscus radius in a throat of half-width {gmin/2:.2f} cells is at or below the "
338 f"~2.5-cell floor where the height-function cascade always falls back to the")
339 print(" PLIC-volumetric paraboloid fit (V3 finding) — read the pattern, not the curvature")
340 rows = []
341 for th in [float(x) for x in a.theta.split(",")]:
342 for ca in [float(x) for x in a.ca.split(",")]:
343 U = ca * SIGMA / MU_L
344 ttrav = (X_BT - X_IN) / (U / max(por, 0.2))
345 dt0 = CAP_CFL * math.sqrt((RHO_L + RHO_G) / (4 * math.pi * SIGMA))
346 steps = int(3.0 * ttrav / dt0)
347 if a.quick:
348 steps = min(steps, 600)
349 rows.append(run(th, ca, sdf, steps, budget=a.budget or None, npy_dir=a.npy))
350 print("\n" + "=" * 100)
351 print("SUMMARY — micromodel")
352 print("=" * 100)
353 print(f"{'theta':>6} {'Ca':>8} {'t_bt':>10} {'S_bt':>8} {'D_box':>8} {'front std':>10} "
354 f"{'iters':>6} {'ms/step':>8} {'valid':>6}")
355 for r in rows:
356 tb = f"{r['t_bt']:.4g}" if r["t_bt"] else "-"
357 sb = f"{r['S_bt']:.4f}" if r["S_bt"] else f"({r['S']:.4f})"
358 print(f"{r['theta']:6g} {r['ca']:8g} {tb:>10} {sb:>8} {r['D']:8.4f} "
359 f"{r['front']['std']:10.2f} {r['iters']:6d} "
360 f"{r['ms_per_step']:8.2f} {'yes' if r['valid'] else 'NO':>6}")
361 if a.out:
362 with open(a.out, "w") as fh:
363 json.dump(rows, fh, indent=1)
364 print(f"\n wrote {a.out}")
365 return 0 if all(r["valid"] for r in rows) else 1
366
367
368if __name__ == "__main__":
369 sys.exit(main())
__init__(self, cap=PRESS_CAP)
run(theta_deg, ca, sdf, steps, budget=None, npy_dir="")
box_dimension(mask, sizes=(1, 2, 4, 8, 16, 32))
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)