flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_blocks_ns.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""VoF Part III rung W2 (WO-W12) — the block container coupled to Navier-Stokes.
3
4Every marker carries its own Weymouth-Yue colour field on its own moving box, forms its own
5curvature and its own balanced-force CSF face force there, and the three face-force fields are
6summed into the global RHS (UNPACK_SUM). The union `C = max_blocks C_block` is what the closures
7(rho, mu) see. Two markers that touch therefore cannot coalesce numerically.
8
9 hysing gate 1: Hysing case 1 through the BLOCK path against the same case through the
10 GLOBAL colour field, both without momentum consistency (quasi-2D 64x128x4).
11 grace gate 2: one 3-D bubble at Eo = 10, Mo = 1e-3, density ratio 100, against the
12 Grace-diagram terminal velocity / shape regime.
13 pair gate 3: two bubbles in line, the trailing one catching the leading one -- the film
14 drains to one cell and the two blocks stay two.
15 swarm rung W1 in Python: the master-assignment imbalance of the three modes on a 64-bubble
16 swarm, the per-bubble Lagrangian outputs, and the device-vs-host packing ratio.
17
18COLOUR CONVENTION. With the block container the union C is the DISPERSED phase (C = 1 inside a
19marker), because UNPACK_MAX starts from an empty union and a cell no marker covers must read 0.
20Every property model here is therefore written with C = 1 = GAS, the mirror of the structured
21scripts. The CSF force is unaffected by the flip: kappa(1-C) = -kappa(C) and grad(1-C) = -grad(C),
22so sigma kappa grad C is invariant -- which is what makes the gate-1 comparison meaningful.
23
24Usage: PYTHONPATH=<build> python tests/study/vof_blocks_ns.py [gate ...] [--quick]
25"""
26import math
27import os
28import sys
29import time
30
31import numpy as np
32
33import peclet.flow as pf
34
35sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
36from vof_surface_tension import Scale, Solve, cylinder_fractions, sphere_fractions # noqa: E402
37
38QUICK = "--quick" in sys.argv
39# --rtol R: the hysing gate's pressure-driver rtol (default 1e-12); the momentum solve follows it.
40# An instrument for the tolerance study (tests/study/vof_perf/d1_tolerance.py).
41RTOL = float(sys.argv[sys.argv.index("--rtol") + 1]) if "--rtol" in sys.argv else None
42GATES = [a for i, a in enumerate(sys.argv[1:], 1)
43 if not a.startswith("--") and sys.argv[i - 1] != "--rtol"]
44
45
46def marker_box(C, pad=1):
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])]
54
55
56HYSING1 = dict(rho1=1000.0, rho2=100.0, mu1=10.0, mu2=1.0, g=0.98, sigma=24.5,
57 ref_vmax=0.2417, ref_tvmax=0.9215, ref_yc=1.081)
58
59
60def build_hysing(nx, blocks):
61 """Hysing case 1, quasi-2D. `blocks` selects the container; the physics is identical."""
62 p = HYSING1
63 Lx = 1.0
64 nz, ny = 2 * nx, 4
65 sc = Scale(nx / Lx)
66 R = sc.len_to_cells(0.25)
67 s = pf.Solver(nx, ny, nz)
68 s.set_rho(p["rho1"])
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)
74 s.enable_vof()
75 B = cylinder_fractions((nx, ny, nz), R, nx / 2.0, sc.len_to_cells(0.5)) # 1 inside the bubble
76 if blocks:
77 s.set_vof(np.asfortranarray(B)) # marker convention: C = 1 = GAS
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()
87 else:
88 s.set_vof(np.asfortranarray(1.0 - B)) # structured convention: C = 1 = LIQUID
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"])])
96 return s, sc, nz, ny
97
98
99def run_hysing(nx, blocks, T=3.0, verbose=True):
100 s, sc, nz, ny = build_hysing(nx, blocks)
101 zs = (np.arange(nz) + 0.5) / sc.s
102 h = Solve(600)
103 dt = 0.4 * min(s.vof_step_limits()["cfl_dt"], s.capillary_dt())
104 s.set_dt(dt)
105 t, i, vmax, tvmax, yc = 0.0, 0, 0.0, 0.0, 0.0
106 v0 = None
107 t0 = time.time()
108 while t < T:
109 if i % 10 == 0:
110 L = s.vof_step_limits()
111 dt = 0.4 * min(L["cfl_dt"], L["capillary_dt"])
112 s.set_dt(dt)
113 t += dt
114 i += 1
115 s.step()
116 h.sample(s)
117 C = s.get_vof()
118 B = C if blocks else 1.0 - C
119 vb = B.sum()
120 if v0 is None:
121 v0 = vb
122 yc = float((B.sum(axis=(0, 1)) * zs).sum() / vb)
123 vrise = float((B * s.get_w()).sum() / vb) / sc.s
124 if vrise > vmax:
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)
131
132
134 nx = 32 if QUICK else 64
135 print("\n" + "=" * 96)
136 print(f"GATE 1 — Hysing case 1 through the BLOCK path vs the GLOBAL colour field, nx = {nx}")
137 print("=" * 96)
138 print(" Same physics, same discretisation, same dt schedule; the ONLY difference is which")
139 print(" container carries the colour and where the CSF face force is formed. No momentum")
140 print(" consistency in either (the work order's gate: both without enable_vof_momentum).")
141 T = 1.0 if QUICK else 3.0
142 out = {}
143 for name, blk in (("global field", False), ("blocks", True)):
144 print(f"\n -- {name}")
145 out[name] = run_hysing(nx, blk, T)
146 r = out[name]
147 print(f" v_rise max {r['vmax']:.4f} at t = {r['tvmax']:.3f}; y_c({T:g}) = {r['yc']:.4f}"
148 f"; volume drift {r['vdrift']:+.2e}")
149 print(f" {r['steps']} steps, {r['solve']} ({r['wall']:.0f} s)")
150 a, b = out["global field"], out["blocks"]
151 dv = 100.0 * (b["vmax"] / a["vmax"] - 1.0)
152 dy = 100.0 * (b["yc"] / a["yc"] - 1.0)
153 print(f"\n BLOCK vs GLOBAL: v_rise {dv:+.2f} %, y_c {dy:+.2f} % "
154 f"(gate: both within 1 %) -> {'PASS' if max(abs(dv), abs(dy)) <= 1.0 else 'FAIL'}")
155 p = HYSING1
156 print(f" reference (Hysing): v_rise max {p['ref_vmax']:.4f} at t = {p['ref_tvmax']:.3f}, "
157 f"y_c(3) = {p['ref_yc']:.4f}")
158 print(f" global vs reference: {100*(a['vmax']/p['ref_vmax']-1):+.1f} % / "
159 f"{100*(a['yc']/p['ref_yc']-1):+.1f} %; blocks vs reference: "
160 f"{100*(b['vmax']/p['ref_vmax']-1):+.1f} % / {100*(b['yc']/p['ref_yc']-1):+.1f} %")
161
162
163# --------------------------------------------------------------------------- gate 2: Grace
164def grace_terminal(Eo, Mo, d, rho_l, mu_l, sigma, g, mu_ratio_to_water=None):
165 """Grace (1973), as tabulated by Clift, Grace & Weber (1978) ch. 7, eqs (7-10)/(7-11):
166
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)
170
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.
180 """
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
186 if H <= 59.3:
187 J = 0.94 * H ** 0.757
188 else:
189 J = 3.42 * H ** 0.441
190 return (mu_l / (rho_l * d)) * Mo ** -0.149 * (J - 0.857)
191
192
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")
196 print("=" * 96)
197 # physical set (cell units directly: h = 1). Choose D, rho_l, sigma, then mu from Mo and g
198 # from Eo, with ratio 100 for both density and viscosity.
199 Eo, Mo, ratio = 10.0, 1e-3, 100.0
200 D = 12.0 if QUICK else 16.0
201 R = 0.5 * D
202 rho_l, sigma = 1.0, 1.0
203 g = Eo * sigma / (rho_l * D * D) # Eo = rho g D^2 / sigma (dRho ~ rho_l)
204 mu_l = (Mo * rho_l ** 2 * sigma ** 3 / g) ** 0.25 # Mo = g mu^4 / (rho sigma^3)
205 rho_g, mu_g = rho_l / ratio, mu_l / ratio
206 nx = int(round(4 * D))
207 nz = int(round(9 * D))
208 ny = nx
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}")
211 Ut = grace_terminal(Eo, Mo, D, rho_l, mu_l, sigma, g)
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)
223 s.set_rho(rho_l)
224 s.set_mu(mu_l)
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)
229 s.enable_vof()
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
240 h = Solve(800)
241 t, i = 0.0, 0
242 dt = 0.0
243 hist = []
244 t0 = time.time()
245 while t < T:
246 if i % 10 == 0:
247 L = s.vof_step_limits()
248 dt = 0.4 * min(L["cfl_dt"], L["capillary_dt"])
249 s.set_dt(dt)
250 t += dt
251 i += 1
252 s.step()
253 h.sample(s)
254 st = s.diagnostics.vof_block_stats()[0]
255 if st["volume"] > 0:
256 hist.append((t, st["centroid"][2], st["volume"], st["area"],
257 st["moments"][2], st["moments"][0]))
258 if i % 200 == 0:
259 w = s.get_w()
260 C = s.get_vof()
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)
265 # terminal velocity from the last third of the trajectory
266 k = len(hist) // 3
267 U = np.polyfit(hist[-k:, 0], hist[-k:, 1], 1)[0]
268 # aspect ratio from the second moments: E = sqrt(m_xx/m_zz) (horizontal over vertical)
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'}")
278
279
280# --------------------------------------------------------------------------- gate 3: two bubbles
282 print("\n" + "=" * 96)
283 print("GATE 3 — two bubbles in line, the larger catching the smaller: no numerical coalescence")
284 print("=" * 96)
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 # leading (small) and trailing (large)
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))
301 # Seed them nearly touching (a 2-cell film): the large one closes the last two cells within a
302 # few hundred steps, which is what makes the gate affordable. Starting them a diameter apart
303 # measured nothing in 20 minutes of wall clock -- the approach is slow and the answer is at
304 # contact.
305 zc_l = 2.0 * 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)")
309
310 def build(blocks):
311 s = pf.Solver(nx, ny, nz)
312 s.set_rho(rho_l)
313 s.set_mu(mu_l)
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)
318 s.enable_vof()
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])
326 if blocks:
327 b0, b1 = marker_box(B0), marker_box(B1)
328 mid = (b0[5] + b1[2]) // 2 # split the boxes at the midplane: one sphere each
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
334
335 def axis_gap(C):
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()
339 if len(occ) == 0:
340 return -1
341 inside = col[occ.min():occ.max() + 1]
342 return int((inside < 0.5).sum())
343
344 Ut = grace_terminal(Eo, Mo, D, rho_l, mu_l, sigma, g)
345 T = (3.0 if QUICK else 6.0) * D / Ut
346 res = {}
347 for name, blk in (("blocks", True), ("single field (control)", False)):
348 s, v0l, v0s = build(blk)
349 h = Solve(800)
350 t, i, dt = 0.0, 0, 0.0
351 mingap, overlap_max = 10 ** 9, 0.0
352 merged_at = None
353 t0 = time.time()
354 while t < T:
355 if i % 10 == 0:
356 L = s.vof_step_limits()
357 dt = 0.4 * min(L["cfl_dt"], L["capillary_dt"])
358 s.set_dt(dt)
359 t += dt
360 i += 1
361 s.step()
362 h.sample(s)
363 if i % 5 == 0:
364 C = s.get_vof()
365 gap = axis_gap(C)
366 mingap = min(mingap, gap)
367 if gap == 0 and merged_at is None:
368 merged_at = t
369 if blk:
370 vols = [b["volume"] for b in s.diagnostics.vof_block_stats()]
371 overlap_max = max(overlap_max, sum(vols) - float(C.sum()))
372 if i % 100 == 0:
373 print(f" t = {t:7.2f} gap {axis_gap(s.get_vof()):3d} cells "
374 f"({time.time()-t0:.0f} s)")
375 C = s.get_vof()
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)")
379 if blk:
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")
390 else:
391 print(f" final film gap on the axis {info['gap']} cells, MINIMUM {mingap}; "
392 f"merged at t = {merged_at}")
393 res[name] = info
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'}")
401
402
403# ------------------------------------------------------------------- gate 3b: markers in contact
405 """The discriminating half of gate 3, when the film gate does not reach contact on its own.
406
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.
411 """
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")
415 print("=" * 96)
416 Eo, Mo, ratio = 10.0, 1e-3, 100.0
417 D = 12.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))
425 zc_l = 1.6 * D
426 zc_s = zc_l + Rs + Rl - 1.0 # ONE cell of band overlap
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")
430
431 def build(blocks):
432 s = pf.Solver(nx, ny, nz)
433 s.set_rho(rho_l)
434 s.set_mu(mu_l)
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)
439 s.enable_vof()
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])
447 if blocks:
448 # SPHERE seeding, not `enable_vof_blocks_from_field`: with the two markers already
449 # overlapping there is no way to split a union field between them without giving one
450 # marker a slice of the other (measured: -2.7 % / +7.1 % on the two volumes when the
451 # boxes are clipped at the midplane). `enable_vof_blocks` paints each block's colour
452 # from the exact sphere fraction, so each marker starts as its own whole bubble --
453 # which is the state a single colour field cannot hold, and the point of the gate.
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
457
458 out = {}
459 for name, blk in (("blocks", True), ("single field (control)", False)):
460 s, v0l, v0s = build(blk)
461 h = Solve(800)
462 dt = 0.0
463 t0 = time.time()
464 for i in range(steps):
465 if i % 5 == 0:
466 L = s.vof_step_limits()
467 dt = 0.2 * min(L["cfl_dt"], L["capillary_dt"])
468 s.set_dt(dt)
469 s.step()
470 h.sample(s)
471 C = s.get_vof()
472 col = C[nx // 2, ny // 2, :]
473 occ = np.argwhere(col > 0.5).ravel()
474 blobs = 0
475 prev = False
476 for v in (col > 0.5):
477 if v and not prev:
478 blobs += 1
479 prev = v
480 info = dict(blobs=blobs, union=float(C.sum()), solve=h, wall=time.time() - t0)
481 if blk:
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
486 out[name] = info
487 print(f"\n -- {name}: {steps} steps, {h} ({info['wall']:.0f} s)")
488 if blk:
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}")
495 else:
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'}")
503
504
505# --------------------------------------------------------------------------- W1 in Python
507 print("\n" + "=" * 96)
508 print("RUNG W1 — 64-bubble swarm: master assignment, per-bubble outputs, packing")
509 print("=" * 96)
510 n = 48
511 s = pf.Solver(n, n, n)
512 s.set_rho(1.0)
513 s.set_mu(0.01)
514 s.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
515 s.enable_vof()
516 s.set_vof(np.zeros((n, n, n), order="F"))
517 seeds = []
518 k = 0
519 for i in range(4):
520 for j in range(4):
521 for m in range(4):
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))
524 k += 1
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'])}")
541
542
543ALL = {"hysing": gate_hysing, "grace": gate_grace, "pair": gate_pair,
544 "contact": gate_contact, "swarm": gate_swarm}
545
546if __name__ == "__main__":
547 for g in (GATES or list(ALL)):
548 ALL[g]()
build_hysing(nx, blocks)
run_hysing(nx, blocks, T=3.0, verbose=True)
marker_box(C, pad=1)
grace_terminal(Eo, Mo, d, rho_l, mu_l, sigma, g, mu_ratio_to_water=None)