47 """Tight global index box of C > 0.5, grown by `pad`, clamped to the grid."""
48 idx = np.argwhere(C > 0.5)
49 lo = idx.min(axis=0) - pad
50 hi = idx.max(axis=0) + 1 + pad
51 lo = np.maximum(lo, 0)
52 hi = np.minimum(hi, C.shape)
53 return [int(lo[0]), int(lo[1]), int(lo[2]), int(hi[0]), int(hi[1]), int(hi[2])]
56HYSING1 = dict(rho1=1000.0, rho2=100.0, mu1=10.0, mu2=1.0, g=0.98, sigma=24.5,
61 """Hysing case 1, quasi-2D. `blocks` selects the container; the physics is identical."""
66 R = sc.len_to_cells(0.25)
67 s = pf.Solver(nx, ny, nz)
69 s.set_mu(sc.mu(p[
"mu1"]))
70 s.set_domain_bc(
"-z",
"wall", (0, 0, 0))
71 s.set_domain_bc(
"+z",
"wall", (0, 0, 0))
72 s.set_pressure_geometry(np.full((nx, ny, nz), 10.0, order=
"F"))
73 s.set_pressure_chebyshev(
True, 600, RTOL
or 1e-12)
75 B = cylinder_fractions((nx, ny, nz), R, nx / 2.0, sc.len_to_cells(0.5))
77 s.set_vof(np.asfortranarray(B))
78 s.set_property_model(
"rho",
"linear",
"C", [p[
"rho1"], p[
"rho2"] - p[
"rho1"]])
79 s.set_property_model(
"mu",
"linear",
"C",
80 [sc.mu(p[
"mu1"]), sc.mu(p[
"mu2"] - p[
"mu1"])])
81 s.set_surface_tension(sc.sigma(p[
"sigma"]))
82 s.set_property_model(
"force_z",
"linear",
"C",
83 [-sc.bodyforce(p[
"rho1"] * p[
"g"]),
84 -sc.bodyforce((p[
"rho2"] - p[
"rho1"]) * p[
"g"])])
85 s.enable_vof_blocks_from_field([
marker_box(B)])
86 s.enable_vof_block_csf()
88 s.set_vof(np.asfortranarray(1.0 - B))
89 s.set_property_model(
"rho",
"linear",
"C", [p[
"rho2"], p[
"rho1"] - p[
"rho2"]])
90 s.set_property_model(
"mu",
"linear",
"C",
91 [sc.mu(p[
"mu2"]), sc.mu(p[
"mu1"] - p[
"mu2"])])
92 s.set_surface_tension(sc.sigma(p[
"sigma"]))
93 s.set_property_model(
"force_z",
"linear",
"C",
94 [-sc.bodyforce(p[
"rho2"] * p[
"g"]),
95 -sc.bodyforce((p[
"rho1"] - p[
"rho2"]) * p[
"g"])])
101 zs = (np.arange(nz) + 0.5) / sc.s
103 dt = 0.4 * min(s.vof_step_limits()[
"cfl_dt"], s.capillary_dt())
105 t, i, vmax, tvmax, yc = 0.0, 0, 0.0, 0.0, 0.0
110 L = s.vof_step_limits()
111 dt = 0.4 * min(L[
"cfl_dt"], L[
"capillary_dt"])
118 B = C
if blocks
else 1.0 - C
122 yc = float((B.sum(axis=(0, 1)) * zs).sum() / vb)
123 vrise = float((B * s.get_w()).sum() / vb) / sc.s
125 vmax, tvmax = vrise, t
126 if verbose
and i % 200 == 0:
127 print(f
" t = {t:5.3f} dt = {dt:.3e} y_c = {yc:.4f} v = {vrise:.4f} "
128 f
"V/V0 = {vb/v0:.6f}")
129 return dict(vmax=vmax, tvmax=tvmax, yc=yc, steps=i, solve=h, vdrift=vb / v0 - 1.0,
130 wall=time.time() - t0, s=s)
165 """Grace (1973), as tabulated by Clift, Grace & Weber (1978) ch. 7, eqs (7-10)/(7-11):
167 H = (4/3) Eo Mo^-0.149 (mu_l/mu_w)^-0.14 mu_w = 0.9 cP
168 J = 0.94 H^0.757 (2 < H <= 59.3), 3.42 H^0.441 (H > 59.3)
169 U_T = (mu_l/(rho_l d)) Mo^-0.149 (J - 0.857)
171 THE TRAP, and it is the reason this gate was nearly reported wrong. The correlation contains a
172 DIMENSIONAL factor, `(mu_l/mu_water)^-0.14`, which a purely dimensionless (Eo, Mo, ratio)
173 specification does NOT determine: at Eo = 10, Mo = 1e-3 the "Grace-diagram terminal velocity"
174 is 0.959 cells/s if one silently sets that factor to 1 and 0.579 if the liquid is the one that
175 actually HAS Mo = 1e-3. It has to be the latter: Mo = 1e-3 at water-like rho and sigma forces
176 mu_l ~ 0.073 Pa s ~ 81 x water (water itself is Mo ~ 2.5e-11), so a factor of 1 describes no
177 liquid at all. `mu_ratio_to_water = None` therefore DERIVES the factor from the physical
178 system that the dimensionless pair implies at rho_l = 1000 kg/m^3, sigma = 0.065 N/m,
179 g = 9.81 m/s^2 -- a glycerol/water mixture, the standard Mo = 1e-3 fluid.
181 if mu_ratio_to_water
is None:
182 RHO_P, SIG_P, G_P, MU_W = 1000.0, 0.065, 9.81, 9.0e-4
183 mu_phys = (Mo * RHO_P * SIG_P ** 3 / G_P) ** 0.25
184 mu_ratio_to_water = mu_phys / MU_W
185 H = (4.0 / 3.0) * Eo * Mo ** -0.149 * mu_ratio_to_water ** -0.14
187 J = 0.94 * H ** 0.757
189 J = 3.42 * H ** 0.441
190 return (mu_l / (rho_l * d)) * Mo ** -0.149 * (J - 0.857)
194 print(
"\n" +
"=" * 96)
195 print(
"GATE 2 — a single 3-D bubble at Eo = 10, Mo = 1e-3 vs the Grace-diagram terminal velocity")
199 Eo, Mo, ratio = 10.0, 1e-3, 100.0
200 D = 12.0
if QUICK
else 16.0
202 rho_l, sigma = 1.0, 1.0
203 g = Eo * sigma / (rho_l * D * D)
204 mu_l = (Mo * rho_l ** 2 * sigma ** 3 / g) ** 0.25
205 rho_g, mu_g = rho_l / ratio, mu_l / ratio
206 nx = int(round(4 * D))
207 nz = int(round(9 * D))
209 print(f
" D = {D:g} cells, box {nx}x{ny}x{nz}, rho_l {rho_l}, rho_g {rho_g}, mu_l {mu_l:.4e}, "
210 f
"sigma {sigma}, g {g:.4e}")
212 Ut1 =
grace_terminal(Eo, Mo, D, rho_l, mu_l, sigma, g, mu_ratio_to_water=1.0)
213 Ub = math.sqrt(g * D)
214 mu_phys = (Mo * 1000.0 * 0.065 ** 3 / 9.81) ** 0.25
215 print(f
" the physical system this (Eo, Mo) implies at rho = 1000, sigma = 0.065, g = 9.81: "
216 f
"d = {math.sqrt(Eo*0.065/(1000*9.81))*1e3:.2f} mm, mu_l = {mu_phys*1e3:.1f} cP "
217 f
"({mu_phys/9e-4:.0f} x water)")
218 print(f
" Grace/Clift U_T = {Ut:.4f} cells/s (Re = {rho_l*Ut*D/mu_l:.1f}, "
219 f
"Fr = U/sqrt(gD) = {Ut/Ub:.3f})")
220 print(f
" [the same formula with the viscosity factor set to 1 -- i.e. pretending the liquid "
221 f
"is water, which cannot have Mo = 1e-3 -- reads {Ut1:.4f}; see grace_terminal()]")
222 s = pf.Solver(nx, ny, nz)
225 s.set_domain_bc(
"-z",
"wall", (0, 0, 0))
226 s.set_domain_bc(
"+z",
"wall", (0, 0, 0))
227 s.set_pressure_geometry(np.full((nx, ny, nz), 10.0, order=
"F"))
228 s.set_pressure_chebyshev(
True, 800, 1e-12)
230 B = sphere_fractions((nx, ny, nz), R, (nx / 2.0, ny / 2.0, 1.6 * D))
231 s.set_vof(np.asfortranarray(B))
232 s.set_property_model(
"rho",
"linear",
"C", [rho_l, rho_g - rho_l])
233 s.set_property_model(
"mu",
"linear",
"C", [mu_l, mu_g - mu_l])
234 s.set_surface_tension(sigma)
235 s.set_property_model(
"force_z",
"linear",
"C",
236 [-rho_l * g, -(rho_g - rho_l) * g])
237 s.enable_vof_blocks_from_field([
marker_box(B)])
238 s.enable_vof_block_csf()
239 T = (3.0
if QUICK
else 6.0) * D / Ut
247 L = s.vof_step_limits()
248 dt = 0.4 * min(L[
"cfl_dt"], L[
"capillary_dt"])
254 st = s.diagnostics.vof_block_stats()[0]
256 hist.append((t, st[
"centroid"][2], st[
"volume"], st[
"area"],
257 st[
"moments"][2], st[
"moments"][0]))
261 ur = float((C * w).sum() / C.sum())
262 print(f
" t = {t:8.2f} z_c = {hist[-1][1]:8.3f} U = {ur:.4f} "
263 f
"V/V0 = {hist[-1][2]/hist[0][2]:.6f} A = {hist[-1][3]:.1f}")
264 hist = np.array(hist)
267 U = np.polyfit(hist[-k:, 0], hist[-k:, 1], 1)[0]
269 E = math.sqrt(hist[-1, 5] / hist[-1, 4])
270 print(f
"\n measured terminal velocity {U:.4f} cells/s ({100*(U/Ut-1):+.1f} % of Grace)")
271 print(f
" Re = {rho_l*U*D/mu_l:.1f}; aspect ratio (sqrt(m_xx/m_zz)) {E:.3f} "
272 f
"(ellipsoidal regime: E > 1)")
273 print(f
" volume drift {hist[-1,2]/hist[0,2]-1:+.2e}; interface area "
274 f
"{hist[-1,3]:.1f} vs the sphere's {4*math.pi*R*R:.1f} cells^2")
275 print(f
" {i} steps, {h} ({time.time()-t0:.0f} s)")
276 print(f
" GATE: |U/U_Grace - 1| <= 10 % -> "
277 f
"{'PASS' if abs(U/Ut - 1) <= 0.10 else 'FAIL'}")
282 print(
"\n" +
"=" * 96)
283 print(
"GATE 3 — two bubbles in line, the larger catching the smaller: no numerical coalescence")
285 print(
" A LARGE bubble rising in line behind a SMALL one closes the gap deterministically (at")
286 print(
" fixed Mo the terminal velocity grows with D), so the collision happens on a schedule")
287 print(
" instead of waiting on the wake. In a single global colour field the two merge the")
288 print(
" moment their PLIC bands touch -- the film is sub-grid and the field has no way to")
289 print(
" represent two interfaces in one cell. With one marker each they cannot merge, and the")
290 print(
" union carries CELLS THAT BELONG TO BOTH. The control is the same case, same numbers,")
291 print(
" on the structured field.")
292 Eo, Mo, ratio = 10.0, 1e-3, 100.0
293 D = 10.0
if QUICK
else 12.0
294 Rs, Rl = 0.40 * D, 0.60 * D
295 rho_l, sigma = 1.0, 1.0
296 g = Eo * sigma / (rho_l * D * D)
297 mu_l = (Mo * rho_l ** 2 * sigma ** 3 / g) ** 0.25
298 rho_g, mu_g = rho_l / ratio, mu_l / ratio
299 nx = ny = int(round(5 * D))
300 nz = int(round(11 * D))
306 zc_s = zc_l + Rs + Rl + 2.0
307 print(f
" D_small = {2*Rs:.1f}, D_large = {2*Rl:.1f} cells; box {nx}x{ny}x{nz}; "
308 f
"centres z = {zc_l:.1f} / {zc_s:.1f} (gap {zc_s-zc_l-Rs-Rl:.1f} cells)")
311 s = pf.Solver(nx, ny, nz)
314 s.set_domain_bc(
"-z",
"wall", (0, 0, 0))
315 s.set_domain_bc(
"+z",
"wall", (0, 0, 0))
316 s.set_pressure_geometry(np.full((nx, ny, nz), 10.0, order=
"F"))
317 s.set_pressure_chebyshev(
True, 800, 1e-12)
319 B0 = sphere_fractions((nx, ny, nz), Rl, (nx / 2.0, ny / 2.0, zc_l))
320 B1 = sphere_fractions((nx, ny, nz), Rs, (nx / 2.0, ny / 2.0, zc_s))
321 s.set_vof(np.asfortranarray(np.maximum(B0, B1)))
322 s.set_property_model(
"rho",
"linear",
"C", [rho_l, rho_g - rho_l])
323 s.set_property_model(
"mu",
"linear",
"C", [mu_l, mu_g - mu_l])
324 s.set_surface_tension(sigma)
325 s.set_property_model(
"force_z",
"linear",
"C", [-rho_l * g, -(rho_g - rho_l) * g])
328 mid = (b0[5] + b1[2]) // 2
329 b0[5] = min(b0[5], mid)
330 b1[2] = max(b1[2], mid)
331 s.enable_vof_blocks_from_field([b0, b1])
332 s.enable_vof_block_csf()
333 return s, 4 / 3 * math.pi * Rl ** 3, 4 / 3 * math.pi * Rs ** 3
336 """Empty cells on the axis BETWEEN the two blobs; 0 = they have merged there."""
337 col = C[nx // 2, ny // 2, :]
338 occ = np.argwhere(col > 0.5).ravel()
341 inside = col[occ.min():occ.max() + 1]
342 return int((inside < 0.5).sum())
345 T = (3.0
if QUICK
else 6.0) * D / Ut
347 for name, blk
in ((
"blocks",
True), (
"single field (control)",
False)):
348 s, v0l, v0s = build(blk)
350 t, i, dt = 0.0, 0, 0.0
351 mingap, overlap_max = 10 ** 9, 0.0
356 L = s.vof_step_limits()
357 dt = 0.4 * min(L[
"cfl_dt"], L[
"capillary_dt"])
366 mingap = min(mingap, gap)
367 if gap == 0
and merged_at
is None:
370 vols = [b[
"volume"]
for b
in s.diagnostics.vof_block_stats()]
371 overlap_max = max(overlap_max, sum(vols) - float(C.sum()))
373 print(f
" t = {t:7.2f} gap {axis_gap(s.get_vof()):3d} cells "
374 f
"({time.time()-t0:.0f} s)")
376 info = dict(steps=i, solve=h, gap=axis_gap(C), mingap=mingap, merged=merged_at,
377 wall=time.time() - t0)
378 print(f
"\n -- {name}: {i} steps, {info['solve']} ({info['wall']:.0f} s)")
380 st = s.diagnostics.vof_block_stats()
381 info[
"vols"] = [b[
"volume"]
for b
in st]
382 info[
"zc"] = [b[
"centroid"][2]
for b
in st]
383 info[
"overlap"] = overlap_max
384 print(f
" marker volumes {info['vols'][0]:.4f} / {info['vols'][1]:.4f} "
385 f
"(seed {v0l:.4f} / {v0s:.4f}; drift "
386 f
"{info['vols'][0]/v0l-1:+.2e} / {info['vols'][1]/v0s-1:+.2e})")
387 print(f
" centroids z {info['zc'][0]:.2f} / {info['zc'][1]:.2f}; final film gap on "
388 f
"the axis {info['gap']} cells, MINIMUM over the run {mingap}")
389 print(f
" peak shared liquid (sum V_marker - sum C_union) {overlap_max:.3f} cells")
391 print(f
" final film gap on the axis {info['gap']} cells, MINIMUM {mingap}; "
392 f
"merged at t = {merged_at}")
394 b, c = res[
"blocks"], res[
"single field (control)"]
395 ok = (b[
"mingap"] >= 1)
and (c[
"mingap"] == 0)
396 print(f
"\n GATE: the control MERGES (gap -> 0) while the blocks keep two markers with their")
397 print(f
" own volumes and a film at least one cell thick.")
398 print(f
" blocks min gap {b['mingap']} cells (peak shared liquid "
399 f
"{b['overlap']:.3f}); control min gap {c['mingap']} cells "
400 f
"(merged at t = {c['merged']}) -> {'PASS' if ok else 'INCONCLUSIVE'}")
405 """The discriminating half of gate 3, when the film gate does not reach contact on its own.
407 Seed the two markers ALREADY TOUCHING -- the centre distance is one cell less than the sum of
408 the radii, so their PLIC bands share cells from step 0 -- and run a real two-phase step. A
409 single global colour field has one blob there and can never have two again; two markers have
410 two, each conserving its own volume, and the union carries the shared liquid explicitly.
412 print(
"\n" +
"=" * 96)
413 print(
"GATE 3b — two markers seeded IN CONTACT under a real NS step: the union carries cells")
414 print(
" that belong to both, and the control is one blob for ever")
416 Eo, Mo, ratio = 10.0, 1e-3, 100.0
418 Rs, Rl = 0.40 * D, 0.60 * D
419 rho_l, sigma = 1.0, 1.0
420 g = Eo * sigma / (rho_l * D * D)
421 mu_l = (Mo * rho_l ** 2 * sigma ** 3 / g) ** 0.25
422 rho_g, mu_g = rho_l / ratio, mu_l / ratio
423 nx = ny = int(round(4 * D))
424 nz = int(round(7 * D))
426 zc_s = zc_l + Rs + Rl - 1.0
427 steps = 100
if QUICK
else 400
428 print(f
" D_small = {2*Rs:.1f}, D_large = {2*Rl:.1f}; box {nx}x{ny}x{nz}; centre distance "
429 f
"{zc_s-zc_l:.1f} = R_s + R_l - 1")
432 s = pf.Solver(nx, ny, nz)
435 s.set_domain_bc(
"-z",
"wall", (0, 0, 0))
436 s.set_domain_bc(
"+z",
"wall", (0, 0, 0))
437 s.set_pressure_geometry(np.full((nx, ny, nz), 10.0, order=
"F"))
438 s.set_pressure_chebyshev(
True, 800, 1e-12)
440 B0 = sphere_fractions((nx, ny, nz), Rl, (nx / 2.0, ny / 2.0, zc_l))
441 B1 = sphere_fractions((nx, ny, nz), Rs, (nx / 2.0, ny / 2.0, zc_s))
442 s.set_vof(np.asfortranarray(np.maximum(B0, B1)))
443 s.set_property_model(
"rho",
"linear",
"C", [rho_l, rho_g - rho_l])
444 s.set_property_model(
"mu",
"linear",
"C", [mu_l, mu_g - mu_l])
445 s.set_surface_tension(sigma)
446 s.set_property_model(
"force_z",
"linear",
"C", [-rho_l * g, -(rho_g - rho_l) * g])
454 s.enable_vof_blocks([(nx / 2.0, ny / 2.0, zc_l, Rl), (nx / 2.0, ny / 2.0, zc_s, Rs)])
455 s.enable_vof_block_csf()
456 return s, 4 / 3 * math.pi * Rl ** 3, 4 / 3 * math.pi * Rs ** 3
459 for name, blk
in ((
"blocks",
True), (
"single field (control)",
False)):
460 s, v0l, v0s = build(blk)
464 for i
in range(steps):
466 L = s.vof_step_limits()
467 dt = 0.2 * min(L[
"cfl_dt"], L[
"capillary_dt"])
472 col = C[nx // 2, ny // 2, :]
473 occ = np.argwhere(col > 0.5).ravel()
476 for v
in (col > 0.5):
480 info = dict(blobs=blobs, union=float(C.sum()), solve=h, wall=time.time() - t0)
482 st = s.diagnostics.vof_block_stats()
483 info[
"vols"] = [b[
"volume"]
for b
in st]
484 info[
"shared"] = sum(info[
"vols"]) - info[
"union"]
485 info[
"cells_both"] =
None
487 print(f
"\n -- {name}: {steps} steps, {h} ({info['wall']:.0f} s)")
489 print(f
" TWO markers, volumes {info['vols'][0]:.3f} / {info['vols'][1]:.3f} "
490 f
"(seed {v0l:.3f} / {v0s:.3f}, drift {info['vols'][0]/v0l-1:+.2e} / "
491 f
"{info['vols'][1]/v0s-1:+.2e})")
492 print(f
" union {info['union']:.3f} cells; SHARED liquid "
493 f
"sum(V_marker) - sum(C_union) = {info['shared']:.3f} cells")
494 print(f
" blobs along the axis in the union field: {blobs}")
496 print(f
" ONE colour field: {blobs} blob(s) along the axis, "
497 f
"union {info['union']:.3f} cells")
498 b, c = out[
"blocks"], out[
"single field (control)"]
499 ok = b[
"shared"] > 0.0
500 print(f
"\n GATE: the blocks carry {b['shared']:.3f} cells of liquid that belong to BOTH "
501 f
"markers -- a state a single colour field cannot represent (the control's union is "
502 f
"{c['union']:.3f} cells in {c['blobs']} blob). -> {'PASS' if ok else 'FAIL'}")
507 print(
"\n" +
"=" * 96)
508 print(
"RUNG W1 — 64-bubble swarm: master assignment, per-bubble outputs, packing")
511 s = pf.Solver(n, n, n)
514 s.set_pressure_geometry(np.full((n, n, n), 10.0, order=
"F"))
516 s.set_vof(np.zeros((n, n, n), order=
"F"))
522 r = 2.0 + 7.0 * ((k % 8) / 7.0)
523 seeds.append(((i + 0.5) * n / 4, (j + 0.5) * n / 4, (m + 0.5) * n / 4, r))
525 s.enable_vof_blocks(seeds)
526 print(f
" {len(s.diagnostics.vof_block_stats())} markers seeded")
527 print(f
" {'mode':<16}{'imbalance (np = 1 here; the census is the replicated table)':<40}")
528 for mode, name
in ((0,
"round robin"), (1,
"LPT"), (2,
"weighted ORB")):
529 print(f
" {name:<16}{s.diagnostics.vof_block_imbalance_of(mode):.4f}")
530 st = s.diagnostics.vof_block_stats()
531 tot_v = sum(b[
"volume"]
for b
in st)
532 tot_a = sum(b[
"area"]
for b
in st)
533 print(f
" per-bubble outputs: total volume {tot_v:.2f} cells, total interface area "
534 f
"{tot_a:.2f} cells^2")
535 b = max(st, key=
lambda q: q[
"volume"])
536 R = (3 * b[
"volume"] / (4 * math.pi)) ** (1 / 3)
537 print(f
" largest marker id {b['id']}: V {b['volume']:.3f} -> R {R:.3f} cells, area "
538 f
"{b['area']:.2f} vs 4 pi R^2 {4*math.pi*R*R:.2f} ({100*(b['area']/(4*math.pi*R*R)-1):+.2f} %)")
539 print(f
" centroid {tuple(round(q,3) for q in b['centroid'])} moments "
540 f
"{tuple(round(q,3) for q in b['moments'])}")