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)
81 base.append((X0 + i * DX, (j + 0.5 * (i % 2)) * DY))
83 for _
in range(20000):
84 P = base + rng.uniform(-JITTER, JITTER, base.shape)
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")
96 x = (np.arange(NX) + 0.5)[:,
None]
97 y = (np.arange(NY) + 0.5)[
None, :]
98 phi = np.full((NX, NY), 1e30)
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())
135 """Box-counting dimension of a 2-D boolean set; returns (D, [(eps, N)])."""
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))
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
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}")
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}")
180 s = pf.Solver(NX, NY, NZ)
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)
191 c0 = np.zeros((NX, NY, NZ), order=
"F")
192 c0[: int(X_IN), :, :] = 1.0
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)
201 s.set_vof_inflow(
"-x", 1.0)
202 s.set_vof_backflow(
"+x", 0.0)
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)")
219 every = max(steps // 600, 5)
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)
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:
251 print(f
" breakthrough at t = {t:.5g} (step {i+1}), array saturation "
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 ***")
260 if budget
and time.time() - t0 > budget:
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 ***")
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)
269 fluid2 = eps[:, :, mid] > 0.0
270 inv = (C[:, :, mid] > 0.5) & fluid2
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}")
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)
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=
"")
317 ap.add_argument(
"--reproduce-wov7", action=
"store_true")
320 global NCOL, NROW, X0, DX, DY, R_POST, MIN_GAP
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()
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")
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)
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")
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}")
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}")
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