77def box2d(px, pz, xlo, xhi, zlo, zhi):
78 """Exact signed distance (outside) to an axis-aligned box in the (x, z) plane."""
79 cx, cz = 0.5 * (xlo + xhi), 0.5 * (zlo + zhi)
80 ex, ez = 0.5 * (xhi - xlo), 0.5 * (zhi - zlo)
81 qx, qz = np.abs(px - cx) - ex, np.abs(pz - cz) - ez
82 out = np.sqrt(np.maximum(qx, 0.0) ** 2 + np.maximum(qz, 0.0) ** 2)
83 return out + np.minimum(np.maximum(qx, qz), 0.0)
87 """> 0 in the fluid (flow's convention). Three solid boxes, all clear of x = 0 and x = NX."""
88 x = (np.arange(NX) + 0.5)[:,
None]
89 z = (np.arange(NZ) + 0.5)[
None, :]
92 d = np.minimum.reduce([
93 box2d(x, z, X1 + q, X2 + q, -BIG, ZW_LO + q),
94 box2d(x, z, X1 + q, X2 + q, ZW_HI + q, ZN_LO + q),
95 box2d(x, z, X1 + q, X2 + q, ZN_HI + q, NZ + BIG),
97 return np.asfortranarray(np.broadcast_to(d[:,
None, :], (NX, NY, NZ)).astype(float).copy())
122def build(theta_deg, U, momentum=True, slip=0.0):
123 s = pf.Solver(NX, NY, NZ)
127 s.set_domain_bc(
"-x",
"inflow", (U, 0.0, 0.0))
128 s.set_domain_bc(
"+x",
"outflow", (0.0, 0.0, 0.0))
129 s.set_domain_bc(
"-z",
"wall", (0.0, 0.0, 0.0))
130 s.set_domain_bc(
"+z",
"wall", (0.0, 0.0, 0.0))
132 s.diagnostics.set_velocity_solver_params(VEL_SWEEPS)
133 s.set_pressure_multigrid(
True, levels=MG_LEVELS)
134 s.set_pressure_solver_params(80)
142 s.set_wall_slip_length(slip)
147 c0 = np.zeros((NX, NY, NZ), order=
"F")
150 s.set_property_model(
"rho",
"linear",
"C", [RHO_G, RHO_L - RHO_G])
151 s.set_property_model(
"mu",
"linear",
"C", [MU_G, MU_L - MU_G])
153 s.enable_vof_momentum(RHO_G, RHO_L)
154 s.set_surface_tension(SIGMA)
155 s.set_capillary_cfl(CAP_CFL)
156 s.set_contact_angle(theta_deg)
159 s.set_pressure_fcg(
True, PRESS_CAP, PRESS_RTOL)
160 s.set_vof_inflow(
"-x", 1.0)
161 s.set_vof_backflow(
"+x", 0.0)
172 """Two front positions per branch, in cells from the branch entrance X1, plus the branch
175 `tip` the leading edge: the last x, contiguous from the entrance, at which ANY cell of the
176 cross-section is more than half liquid.
177 `bar` the cross-section-AVERAGED front: the last x, contiguous from the entrance, at which the
178 openness-weighted mean colour of the cross-section exceeds a half.
180 Both are reported because they differ by the length of the meniscus, and that length scales
181 with the CHANNEL WIDTH: at theta = 135 the meniscus in the 32-cell branch is about twice as
182 long as in the 16-cell one, so `bar` alone carries a systematic bias of order w/2 in favour of
183 the NARROW branch. Breakthrough is declared on `tip`, which has no such bias.
186 for name, (zlo, zhi)
in ((
"narrow", (ZN_LO, ZN_HI)), (
"wide", (ZW_LO, ZW_HI))):
187 c = C[X1:X2, :, zlo:zhi]
188 e = eps[X1:X2, :, zlo:zhi]
189 wet = ((c > 0.5) & (e > 0.0)).any(axis=(1, 2))
190 col = (c * e).sum(axis=(1, 2)) / np.maximum(e.sum(axis=(1, 2)), 1e-30)
193 sat = float((c * e).sum() / max(e.sum(), 1e-30))
194 out[name] = (tip, bar, sat)
198def run(theta_deg, ca, steps, sample_every, label, momentum=True, budget=None, slip=0.0):
199 U = ca * SIGMA / MU_L
200 Re = RHO_L * U * W_NARROW / MU_L
201 print(
"\n" +
"=" * 100)
202 print(f
"PORE DOUBLET theta = {theta_deg:g} deg Ca(apparent) = {ca:g} [{label}]")
204 print(f
" grid {NX}x{NY}x{NZ}; branches x in [{X1}, {X2}), narrow w = {W_NARROW} "
205 f
"z in [{ZN_LO}, {ZN_HI}), wide 2w = {W_WIDE} z in [{ZW_LO}, {ZW_HI})")
206 print(f
" sigma {SIGMA:g}, rho {RHO_L:g}/{RHO_G:g} (ratio {RHO_L/RHO_G:g}), "
207 f
"mu {MU_L:g}/{MU_G:g} (ratio {MU_L/MU_G:g}), momentum consistency "
208 f
"{'ON' if momentum else 'off'}")
209 print(f
" inlet U = Ca*sigma/mu_l = {U:.6g} cells/s -> channel Reynolds "
210 f
"rho_l U w / mu_l = {Re:.4g}")
211 ct = math.cos(math.radians(theta_deg))
212 pc_n, pc_w = 2 * SIGMA * ct / W_NARROW, 2 * SIGMA * ct / W_WIDE
214 ubr = U * NZ / (W_NARROW + W_WIDE)
215 dpv = 12.0 * MU_L * ubr * BRANCH / W_WIDE ** 2
216 print(f
" capillary pressure 2 sigma cos(theta)/w: narrow {pc_n:+.4g}, wide {pc_w:+.4g}, "
217 f
"difference {pc_n - pc_w:+.4g}")
218 print(f
" viscous scale 12 mu_l u_branch L / (2w)^2 = {dpv:.4g} -> "
219 f
"|dPc| / dPvisc = {abs(pc_n - pc_w)/dpv:.4g} "
220 f
"({'CAPILLARY' if abs(pc_n-pc_w) > dpv else 'VISCOUS'}-dominated by this estimate)")
222 print(f
" wall Navier slip lambda = {slip:g} cells "
223 f
"({'ON (WO-V6b)' if slip > 0 else 'OFF — no-slip wall, WO-V7 configuration'})")
224 s = build(theta_deg, U, momentum, slip)
226 print(f
" wall_slip_length() = {s.wall_slip_length():g}; one-cell-gap axes kept no-slip "
227 f
"{s.diagnostics.wall_slip_sandwich_cells()}")
228 L = s.vof_step_limits()
229 print(f
" dt census at t = 0: capillary_dt {L['capillary_dt']:.5g} (x{CAP_CFL} = "
230 f
"{CAP_CFL*L['capillary_dt']:.5g}), WY cfl_dt {L['cfl_dt']:.5g}, binding "
231 f
"{L['binding']:.5g} -> {'CAPILLARY' if L['capillary_binds'] else 'CFL'} binds")
232 print(
" (capillary_dt is a function of the CURRENT density field: with the domain still all")
233 print(
" gas it reads the gas value; it relaxes once liquid is present. dt is therefore")
234 print(
" re-picked from the solver's own limiter EVERY step.)")
235 dt = CAP_CFL * L[
"capillary_dt"]
239 eps = np.asarray(s.diagnostics.vof_geometry(0))
241 bt = {
"narrow":
None,
"wide":
None}
247 for i
in range(steps):
249 L = s.vof_step_limits()
250 dtc = CAP_CFL * L[
"capillary_dt"]
251 dt = min(dtc, 0.8 * L[
"cfl_dt"])
if L[
"cfl_dt"] > 0
else dtc
252 ncap_cfl += int(dt < dtc * 0.999)
258 if (i + 1) % sample_every == 0
or i == steps - 1:
259 C = np.asarray(s.get_vof())
261 hist.append((t, f[
"narrow"][0], f[
"narrow"][1], f[
"narrow"][2],
262 f[
"wide"][0], f[
"wide"][1], f[
"wide"][2]))
263 if len(hist) % 10 == 1:
264 print(f
" step {i+1:7d} t {t:10.4g} tip n/w {f['narrow'][0]:5.0f}/"
265 f
"{f['wide'][0]:5.0f} bar n/w {f['narrow'][1]:5.0f}/{f['wide'][1]:5.0f} "
266 f
"S n/w {f['narrow'][2]:.3f}/{f['wide'][2]:.3f} "
267 f
"({time.time()-t0:.0f} s, {1000*(time.time()-t0)/(i+1):.0f} ms/step)",
269 for k
in (
"narrow",
"wide"):
270 if bt[k]
is None and f[k][0] >= BRANCH - 1:
273 xg = int(min(max(f[
"narrow"][0], 0.0), max(f[
"wide"][0], 0.0))) + X1 + 6
275 g = max(np.abs(np.asarray(fn())[xg:X2, :, :]).max()
276 for fn
in (s.get_u, s.get_v, s.get_w))
277 umax_gas = max(umax_gas, float(g))
278 if bt[
"narrow"]
and bt[
"wide"]:
280 if budget
and time.time() - t0 > budget:
281 print(f
" *** wall-clock budget {budget:.0f} s reached at step {i+1} "
282 f
"(t = {t:.5g}) — the run is reported as a PARTIAL fill ***")
284 wall = time.time() - t0
285 C = np.asarray(s.get_vof())
287 d = s.diagnostics.vof_diagnostics()
288 print(f
"\n ran {nsteps} steps to t = {t:.4g} s in {wall:.0f} s "
289 f
"({1000*wall/max(nsteps,1):.2f} ms/step, SHARED GPU)")
290 print(f
" dt: final {dt:.5g}; the WY CFL limit displaced the capillary one on "
291 f
"{ncap_cfl} of the {nsteps} steps ({100.0*ncap_cfl/max(nsteps,1):.1f} % -> the "
292 f
"capillary limit binds on the rest)")
293 print(f
" final fronts (cells into the branch, of {BRANCH}): TIP narrow {f['narrow'][0]:.0f}"
294 f
" / wide {f['wide'][0]:.0f}; MEAN narrow {f['narrow'][1]:.0f} / wide "
295 f
"{f['wide'][1]:.0f}")
296 print(f
" final branch liquid saturation: narrow {f['narrow'][2]:.4f} "
297 f
"wide {f['wide'][2]:.4f}")
298 for k
in (
"narrow",
"wide"):
299 print(f
" breakthrough {k:6s}: " + (f
"t = {bt[k][0]:.5g} s at step {bt[k][1]}"
300 if bt[k]
else "NOT reached in this run"))
302 if bt[
"narrow"]
and bt[
"wide"]:
303 winner =
"narrow" if bt[
"narrow"][0] < bt[
"wide"][0]
else "wide"
308 elif f[
"narrow"][0] != f[
"wide"][0]:
309 winner =
"narrow" if f[
"narrow"][0] > f[
"wide"][0]
else "wide"
310 elif f[
"narrow"][2] != f[
"wide"][2]:
311 winner =
"narrow" if f[
"narrow"][2] > f[
"wide"][2]
else "wide"
312 print(f
" FILLS FIRST: {winner if winner else 'undecided (neither front advanced)'}")
313 print(f
" max|u| in the gas ahead of both fronts {umax_gas:.4e} = {umax_gas/U:.3f} U "
314 f
"-> Ca = mu_l max|u|/sigma = {MU_L*umax_gas/SIGMA:.3e} against the imposed {ca:g}.")
315 print(
" This is an UPPER BOUND on the spurious current, not a measurement of it: the gas")
316 print(
" ahead of the front is genuinely being displaced, so it carries the real flow too.")
317 print(f
" colour: sum {d['sum']:.6g}, solid_sum {d['solid_sum']:.3e}, "
318 f
"min/max over uncut fluid {d['min_fluid']:.3e} / {d['max_fluid']:.6f}, "
319 f
"clipped {d['clipped_volume']:.3e}")
322 os.makedirs(NPY_DIR, exist_ok=
True)
323 np.save(os.path.join(NPY_DIR,
324 f
"doublet_C_theta{int(theta_deg)}_ca{ca:g}.npy"), C[:, NY // 2, :])
325 np.save(os.path.join(NPY_DIR,
"doublet_eps.npy"), eps[:, NY // 2, :])
326 return dict(theta=theta_deg, ca=ca, U=U, Re=Re, steps=nsteps, t=t, wall=wall,
327 ms_per_step=1000 * wall / max(nsteps, 1), dt=dt,
328 tip_narrow=f[
"narrow"][0], tip_wide=f[
"wide"][0],
329 bar_narrow=f[
"narrow"][1], bar_wide=f[
"wide"][1],
330 sat_narrow=f[
"narrow"][2], sat_wide=f[
"wide"][2],
331 bt_narrow=bt[
"narrow"][0]
if bt[
"narrow"]
else None,
332 bt_wide=bt[
"wide"][0]
if bt[
"wide"]
else None,
333 winner=winner, iters=h.iters, capped=h.capped, div=h.div,
334 valid=h.valid, umax_gas=umax_gas, ca_spurious=MU_L * umax_gas / SIGMA,
335 pc_narrow=pc_n, pc_wide=pc_w, dp_visc=dpv, hist=hist)
339 ap = argparse.ArgumentParser()
340 ap.add_argument(
"--quick", action=
"store_true")
341 ap.add_argument(
"--theta", default=
"45,135")
342 ap.add_argument(
"--ca", default=
"1e-4,1e-3,1e-2")
343 ap.add_argument(
"--slip", type=float, default=0.0,
344 help=
"WO-V6b Navier slip length in cells (0 = the WO-V7 no-slip wall)")
345 ap.add_argument(
"--no-momentum", action=
"store_true")
346 ap.add_argument(
"--budget", type=float, default=0.0,
347 help=
"wall-clock seconds per (theta, Ca); 0 = no limit")
348 ap.add_argument(
"--npy", default=
"")
349 ap.add_argument(
"--out", default=
"")
353 thetas = [float(x)
for x
in a.theta.split(
",")]
354 cas = [float(x)
for x
in a.ca.split(
",")]
355 print(
"WO-V7 case 1 — the pore doublet. Static contact angle (V5b); the capillary numbers")
356 print(
"are APPARENT (mu_l U_inlet / sigma), because contact-line mobility is set by the")
357 print(
"solver's numerical slip until V6b lands (VOF_PLAN 13.2 item 5).")
362 U = ca * SIGMA / MU_L
363 ttrav = BRANCH * (W_NARROW + W_WIDE) / (NZ * U)
364 dt0 = CAP_CFL * math.sqrt((RHO_L + RHO_G) / (4 * math.pi * SIGMA))
365 steps = int(1.7 * ttrav / dt0)
367 steps = min(steps, 400)
368 rows.append(
run(th, ca, steps, max(steps // 120, 5),
369 "quick" if a.quick
else "full",
370 momentum=
not a.no_momentum,
371 budget=a.budget
or None, slip=a.slip))
372 print(
"\n" +
"=" * 100)
373 print(
"SUMMARY — pore doublet")
375 print(f
"{'theta':>6} {'Ca':>8} {'Re':>7} {'t_bt narrow':>12} {'t_bt wide':>12} "
376 f
"{'fills first':>12} {'tip_n':>6} {'tip_w':>6} {'S_n':>7} {'S_w':>7} {'iters':>6} "
377 f
"{'ms/step':>8} {'valid':>6}")
379 bn = f
"{r['bt_narrow']:.4g}" if r[
"bt_narrow"]
else "-"
380 bw = f
"{r['bt_wide']:.4g}" if r[
"bt_wide"]
else "-"
381 print(f
"{r['theta']:6g} {r['ca']:8g} {r['Re']:7.3g} {bn:>12} {bw:>12} "
382 f
"{str(r['winner']):>12} {r['tip_narrow']:6.0f} {r['tip_wide']:6.0f} "
383 f
"{r['sat_narrow']:7.4f} {r['sat_wide']:7.4f} "
384 f
"{r['iters']:6d} {r['ms_per_step']:8.2f} "
385 f
"{'yes' if r['valid'] else 'NO':>6}")
387 with open(a.out,
"w")
as fh:
388 json.dump(rows, fh, indent=1)
389 print(f
"\n wrote {a.out}")
390 return 0
if all(r[
"valid"]
for r
in rows)
else 1