flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_surface_tension.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""VoF rung V4 (WO-P) — the balanced-force CSF physics battery.
3
4The *algebraic* gates of this rung (the exactness gate, the operator ablation, Young-Laplace,
5the capillary dt, inertness, the wisp guard) live in `tests/kokkos/test_vof_surface_tension.cpp`
6and run in the ctest. What is here is the PHYSICS: the cases where surface tension has to produce
7a number somebody else has already published.
8
9 static spurious currents of a stationary droplet: Ca = mu max|u| / sigma vs resolution,
10 and the wisp-guard ablation at a resolution where it is destructive.
11 wave a small-amplitude standing capillary wave against the analytical dispersion
12 relation omega^2 = sigma k^3 / (rho1 + rho2) and the 2 nu k^2 viscous decay rate
13 (Prosperetti 1981; Popinet 2009 section 4.2).
14 lamb a droplet oscillating in mode n = 2 against Lamb's (1932, art. 275) frequency
15 omega^2 = n(n-1)(n+1)(n+2) sigma / (R^3 ((n+1) rho_in + n rho_out)), AND (2026-09-02)
16 the exact viscous mode of vof_capillary_references.py.
17 hysing the Hysing et al. (IJNMF 60:1259, 2009) rising-bubble benchmark, both cases,
18 against the published reference quantities. READ THE CAVEAT it prints.
19 falling the falling-drop terminal velocity WO-K had to defer, re-run WITH surface tension
20 and with the momentum solve driven to a tolerance instead of a fixed sweep count.
21 limits step economics: does the capillary dt or the Weymouth-Yue CFL bind, and at what
22 cost in steps?
23
24UNITS. The solver's cell size is 1 and its time unit is the physical second, so a physical
25problem of size L on nx cells is mapped with s = nx / L cells per unit length:
26
27 rho' = rho mu' = s^2 mu sigma' = s^3 sigma (rho g)' = s rho g u' = s u t' = t
28
29(derived by substituting x = x'/s, u = u'/s, t = t' into the momentum equation and requiring
30every term to scale alike; `Scale` below is that map, and every case states its physical numbers).
31
32EVERY gate that reports a functional records the max pressure-iteration count against its cap and
33the flux divergence, and marks a capped run INVALID rather than merely flagging it (shared
34preamble rule 3b).
35
36Usage: PYTHONPATH=<build> python tests/study/vof_surface_tension.py [gate ...] [--quick]
37"""
38import math
39import sys
40import time
41
42import numpy as np
43
44import peclet.flow as pf
45
46sys.path.insert(0, __import__('os').path.dirname(__import__('os').path.abspath(__file__)))
47from vof_capillary_references import wave_mode, drop_mode # noqa: E402
48
49QUICK = "--quick" in sys.argv
50# --rtol R: the static gate's pressure-driver rtol (default 1e-14); the momentum solve follows it.
51# An instrument for the tolerance study (tests/study/vof_perf/d1_tolerance.py), which also reads
52# STATIC_RESULTS: one dict per resolution rung of the last gate_static() call.
53RTOL = float(sys.argv[sys.argv.index("--rtol") + 1]) if "--rtol" in sys.argv else None
54GATES = [a for i, a in enumerate(sys.argv[1:], 1)
55 if not a.startswith("--") and sys.argv[i - 1] != "--rtol"]
56STATIC_RESULTS = []
57
58
59# --------------------------------------------------------------------------- helpers
60class Scale:
61 """Physical <-> solver (cell size 1, time in seconds) unit map."""
62
63 def __init__(self, cells_per_length):
64 self.s = float(cells_per_length)
65
66 def mu(self, mu):
67 return self.s ** 2 * mu
68
69 def sigma(self, sigma):
70 return self.s ** 3 * sigma
71
72 def bodyforce(self, rho_g): # a force per unit volume, e.g. rho*g
73 return self.s * rho_g
74
75 def vel_to_phys(self, u):
76 return u / self.s
77
78 def len_to_cells(self, x):
79 return self.s * x
80
81
82def maxvel(s):
83 return max(np.abs(s.get_u()).max(), np.abs(s.get_v()).max(), np.abs(s.get_w()).max())
84
85
86class Solve:
87 """A run's solver health, per the shared preamble's rule 3b."""
88
89 def __init__(self, cap):
90 self.cap = cap
91 self.iters = 0
92 self.div = 0.0
93
94 def sample(self, s):
95 self.iters = max(self.iters, s.diagnostics.last_pressure_iterations())
96 self.div = max(self.div, s.max_open_divergence())
97
98 @property
99 def valid(self):
100 return self.iters < self.cap
101
102 def __str__(self):
103 return (f"pressure {self.iters}/{self.cap} "
104 f"{'OK' if self.valid else '*** CAPPED -> RUN INVALID ***'}, "
105 f"max|div(open u)| {self.div:.2e}")
106
107
108def sphere_fractions(shape, R, c, sub=24):
109 """Volume fractions of a sphere: exact in z, sub x sub subsampled in (x,y)."""
110 nx, ny, nz = shape
111 ax = (np.arange(nx)[:, None] + (np.arange(sub)[None, :] + 0.5) / sub).ravel()
112 ay = (np.arange(ny)[:, None] + (np.arange(sub)[None, :] + 0.5) / sub).ravel()
113 X, Y = ax[:, None], ay[None, :]
114 half = np.sqrt(np.maximum(R * R - (X - c[0]) ** 2 - (Y - c[1]) ** 2, 0.0))
115 z0, z1 = c[2] - half, c[2] + half
116 C = np.zeros((nx, ny, nz))
117 for k in range(nz):
118 seg = np.maximum(np.minimum(z1, k + 1) - np.maximum(z0, k), 0.0)
119 C[:, :, k] = seg.reshape(nx, sub, ny, sub).mean(axis=(1, 3))
120 return np.asfortranarray(C)
121
122
123def cylinder_fractions(shape, R, cx, cz, sub=32):
124 """Volume fractions of a cylinder with its axis along y (a 2-D disc), exact in z."""
125 nx, ny, nz = shape
126 ax = (np.arange(nx)[:, None] + (np.arange(sub)[None, :] + 0.5) / sub).ravel()
127 half = np.sqrt(np.maximum(R * R - (ax - cx) ** 2, 0.0))
128 z0, z1 = cz - half, cz + half
129 C = np.zeros((nx, ny, nz))
130 for k in range(nz):
131 seg = np.maximum(np.minimum(z1, k + 1) - np.maximum(z0, k), 0.0)
132 col = seg.reshape(nx, sub).mean(axis=1)
133 C[:, :, k] = col[:, None]
134 return np.asfortranarray(C)
135
136
138 """Times of the sign changes of y (linear interpolation)."""
139 out = []
140 for i in range(len(y) - 1):
141 if y[i] == 0.0:
142 out.append(t[i])
143 elif y[i] * y[i + 1] < 0.0:
144 out.append(t[i] + (t[i + 1] - t[i]) * y[i] / (y[i] - y[i + 1]))
145 return np.array(out)
146
147
148# --------------------------------------------------------------------------- static droplet
150 print("\n" + "=" * 96)
151 print("STATIC DROPLET — the spurious capillary number Ca = mu max|u| / sigma")
152 print("=" * 96)
153 print(" The balanced-force identity itself is EXACT (the ctest's P1: max|u| ~ 1e-17 with a")
154 print(" constant kappa, at every resolution and viscosity). What is left here is the")
155 print(" curvature error, and Ca is essentially its cell-unit magnitude.\n")
156 rungs = [(16, 4.0), (32, 8.0), (48, 12.0)] if QUICK else [(16, 4.0), (32, 8.0), (48, 12.0),
157 (64, 16.0)]
158 prev = None
159 STATIC_RESULTS.clear()
160 for n, R in rungs:
161 s = pf.Solver(n, n, n)
162 s.set_rho(1.0)
163 s.set_mu(0.1)
164 s.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
165 s.set_pressure_chebyshev(True, 500, RTOL or 1e-14)
166 s.enable_vof()
167 s.set_vof(sphere_fractions((n, n, n), R, (n / 2 + 0.13, n / 2 + 0.27, n / 2 + 0.11)))
168 s.set_property_model("rho", "linear", "C", [1.0, 0.0])
169 s.set_surface_tension(1.0)
170 s.set_dt(0.5 * s.capillary_dt())
171 h = Solve(500)
172 for _ in range(60):
173 s.step()
174 h.sample(s)
175 ca = 0.1 * maxvel(s) / 1.0
176 STATIC_RESULTS.append(dict(n=n, R=R, max_u=maxvel(s), ca=ca, p_iters_max=h.iters,
177 div_max=h.div, capped=not h.valid))
178 br, ka = s.vof_curvature_branch(), s.vof_curvature()
179 d = (br > 0.5) & (br < 5.5)
180 dk = np.abs(ka[d] - 2.0 / R)
181 d2 = s.diagnostics.csf_diagnostics()
182 print(f" D/dx = {2*R:5.1f} Ca = {ca:.3e}"
183 + (f" ({prev/ca:.2f} x the coarser rung)" if prev else " ")
184 + f" dkappa rms {dk.std():.2e} max {dk.max():.2e}"
185 f" orphan faces {d2['orphan_faces']}")
186 print(f" {h}")
187 prev = ca
188
189 print("\n WISP-GUARD ABLATION at 64^3 (set_vof_interface_eps(0) — the rung-V3 predicate):")
190 for eps in (1e-8, 0.0):
191 n, R = 64, 16.0
192 s = pf.Solver(n, n, n)
193 s.set_rho(1.0)
194 s.set_mu(0.1)
195 s.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
196 s.set_pressure_chebyshev(True, 500, RTOL or 1e-14)
197 s.enable_vof()
198 s.set_vof(sphere_fractions((n, n, n), R, (n / 2 + 0.13, n / 2 + 0.27, n / 2 + 0.11)))
199 s.set_property_model("rho", "linear", "C", [1.0, 0.0])
200 s.set_surface_tension(1.0)
201 s.diagnostics.set_vof_interface_eps(eps)
202 s.set_dt(0.5 * s.capillary_dt())
203 u = []
204 try:
205 for _ in range(60):
206 s.step()
207 u.append(maxvel(s))
208 except RuntimeError as e:
209 print(f" eps = {eps:g}: DIVERGED at step {len(u)} — {str(e)[:70]}")
210 continue
211 br, ka = s.vof_curvature_branch(), s.vof_curvature()
212 d = (br > 0.5) & (br < 5.5)
213 print(f" eps = {eps:<6g} max|u| step 1 {u[0]:.3e} -> step 60 {u[-1]:.3e}"
214 f" cells served {d.sum()} max|kappa| {np.abs(ka[d]).max():.3e} (2/R = {2/R:.4f})")
215
216
217# --------------------------------------------------------------------------- capillary wave
219 print("\n" + "=" * 96)
220 print("CAPILLARY WAVE — dispersion omega^2 = sigma k^3 / (rho1 + rho2) (Prosperetti 1981)")
221 print("=" * 96)
222 print(" Quasi-2D: a standing wave of wavelength lambda = Lx along x on a flat interface at")
223 print(" mid-height, periodic in x and y, walls +-z. Small amplitude (a0 = lambda/100) and")
224 print(" matched kinematic viscosities, so the inviscid dispersion relation is the leading")
225 print(" behaviour and the decay rate is 2 nu k^2. Solver units throughout (cell = 1).\n")
226 print(f" {'nx':>4} {'lambda':>7} {'nu':>8} {'omega_num':>11} {'omega_th':>11} {'err':>8}"
227 f" {'gamma_num':>10} {'gamma_th':>10} {'err':>8}")
228 rungs = [(32, 0.005)] if QUICK else [(32, 0.005), (64, 0.005), (32, 0.02)]
229 for nx, nu in rungs:
230 nz = nx
231 ny = 4
232 rho = 1.0
233 sigma = 1.0
234 lam = float(nx)
235 k = 2 * math.pi / lam
236 a0 = lam / 100.0
237 omega_th = math.sqrt(sigma * k ** 3 / (2 * rho))
238 gamma_th = 2 * nu * k * k
239 # 2026-09-02: the inviscid omega_th and the free-surface 2 nu k^2 are the WRONG references
240 # for a two-fluid interface (VOF_NEXT_SESSION Item 2) -- the exact viscous normal mode is
241 # in vof_capillary_references.py and is what the gate reports against.
242 s_ex, _ = wave_mode(k, nu, sigma, rho, H=nz / 2.0)
243 omega_ex, gamma_ex = s_ex.imag, -s_ex.real
244
245 s = pf.Solver(nx, ny, nz)
246 s.set_rho(rho)
247 s.set_mu(nu * rho)
248 s.set_domain_bc("-z", "wall", (0, 0, 0))
249 s.set_domain_bc("+z", "wall", (0, 0, 0))
250 s.set_pressure_geometry(np.full((nx, ny, nz), 10.0, order="F"))
251 s.set_pressure_chebyshev(True, 500, 1e-11)
252 s.enable_vof()
253 # exact fractions of a cosine interface z = nz/2 + a0 cos(k x)
254 xe = np.arange(nx + 1)
255 zi = nz / 2.0 + a0 * (np.sin(k * xe[1:]) - np.sin(k * xe[:-1])) / (k * 1.0)
256 C = np.zeros((nx, ny, nz))
257 for kk in range(nz):
258 C[:, :, kk] = np.clip(zi - kk, 0.0, 1.0)[:, None]
259 s.set_vof(np.asfortranarray(C))
260 s.set_property_model("rho", "linear", "C", [rho, 0.0])
261 s.set_surface_tension(sigma)
262 dt = 0.5 * s.capillary_dt()
263 s.set_dt(dt)
264
265 period = 2 * math.pi / omega_th
266 nsteps = int(2.5 * period / dt)
267 h = Solve(500)
268 t, amp = [], []
269 for i in range(nsteps):
270 s.step()
271 h.sample(s)
272 Cf = s.get_vof()
273 # interface height from the column sum (exact for a single-valued interface)
274 col = Cf.sum(axis=2)[:, 0]
275 zint = col - (nz / 2.0)
276 t.append((i + 1) * dt)
277 amp.append(float(np.dot(zint, np.cos(k * (np.arange(nx) + 0.5))) * 2.0 / nx))
278 t = np.array(t)
279 amp = np.array(amp)
280 zc = zero_crossings(t, amp)
281 if len(zc) >= 3:
282 omega_num = math.pi / np.mean(np.diff(zc))
283 else:
284 omega_num = float("nan")
285 # decay rate from the successive extrema
286 pk = [i for i in range(1, len(amp) - 1)
287 if abs(amp[i]) > abs(amp[i - 1]) and abs(amp[i]) > abs(amp[i + 1])]
288 if len(pk) >= 2:
289 gamma_num = -math.log(abs(amp[pk[-1]]) / abs(amp[pk[0]])) / (t[pk[-1]] - t[pk[0]])
290 else:
291 gamma_num = float("nan")
292 print(f" {nx:>4} {lam:>7.0f} {nu:>8.4g} {omega_num:>11.5f} {omega_th:>11.5f}"
293 f" {100*(omega_num/omega_th-1):>7.2f}% {gamma_num:>10.3e} {gamma_th:>10.3e}"
294 f" {100*(gamma_num/gamma_th-1):>7.1f}%")
295 print(f" EXACT viscous two-fluid mode: omega {omega_ex:.5f} (sim {100*(omega_num/omega_ex-1):+.2f}%), "
296 f"gamma {gamma_ex:.3e} (sim {100*(gamma_num/gamma_ex-1):+.1f}%)")
297 print(f" a0 = {a0:.3f} cells, {nsteps} steps over 2.5 periods; {h}")
298
299
300# --------------------------------------------------------------------------- Lamb oscillation
302 print("\n" + "=" * 96)
303 print("OSCILLATING DROPLET — Lamb (1932) art. 275 mode n = 2")
304 print("=" * 96)
305 print(" omega_n^2 = n(n-1)(n+1)(n+2) sigma / (R^3 ((n+1) rho_in + n rho_out))")
306 print(" A prolate perturbation of a sphere, amplitude 5% of R, released from rest; the")
307 print(" frequency is read off the zero crossings of the n = 2 moment of the colour field.\n")
308 print(f" {'n':>4} {'R':>5} {'phi':>7} {'ratio':>7} {'omega_num':>11} {'omega_th':>11}"
309 f" {'err':>8}")
310 # Two sweeps: (a) fixed drop-to-box ratio phi = 6.5 % at two resolutions -- the DISCRETIZATION
311 # rung; (b) the same drop R = 8 in a bigger and bigger periodic box -- the CONFINEMENT rung.
312 # Lamb's frequency is for a drop in an UNBOUNDED outer fluid, and the periodic images add
313 # outer-fluid inertia, which lowers omega. The ratio-100 row is the same statement from the
314 # other side: with a 100x lighter outer fluid there is much less added mass to confine.
315 rungs = [(32, 8.0, 1.0), (48, 8.0, 1.0)] if QUICK else [(32, 8.0, 1.0), (48, 12.0, 1.0),
316 (48, 8.0, 1.0), (64, 8.0, 1.0),
317 (48, 12.0, 100.0)]
318 for n, R, ratio in rungs:
319 sigma = 1.0
320 rho_in, rho_out = ratio, 1.0
321 mu = 0.02
322 eps = 0.05
323 nn = 2
324 omega_th = math.sqrt(nn * (nn - 1) * (nn + 1) * (nn + 2) * sigma
325 / (R ** 3 * ((nn + 1) * rho_in + nn * rho_out)))
326 # prolate spheroid of the same volume: a = R(1+eps), b = c = R/sqrt(1+eps)
327 a = R * (1 + eps)
328 b = R / math.sqrt(1 + eps)
329 cx = cy = cz = n / 2 + 0.137
330 sub = 16
331 ax = (np.arange(n)[:, None] + (np.arange(sub)[None, :] + 0.5) / sub).ravel()
332 X, Y = ax[:, None], ax[None, :]
333 q = 1.0 - ((X - cx) ** 2 + (Y - cy) ** 2) / b ** 2
334 half = a * np.sqrt(np.maximum(q, 0.0))
335 C = np.zeros((n, n, n))
336 for kk in range(n):
337 seg = np.maximum(np.minimum(cz + half, kk + 1) - np.maximum(cz - half, kk), 0.0)
338 C[:, :, kk] = seg.reshape(n, sub, n, sub).mean(axis=(1, 3))
339
340 s = pf.Solver(n, n, n)
341 s.set_rho(rho_in)
342 s.set_mu(mu)
343 s.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
344 s.set_pressure_chebyshev(True, 500, 1e-11)
345 s.enable_vof()
346 s.set_vof(np.asfortranarray(C))
347 s.set_property_model("rho", "linear", "C", [rho_out, rho_in - rho_out])
348 s.set_surface_tension(sigma)
349 dt = 0.5 * s.capillary_dt()
350 s.set_dt(dt)
351 period = 2 * math.pi / omega_th
352 nsteps = int(2.5 * period / dt)
353 xs = (np.arange(n) + 0.5) - cx
354 ys = (np.arange(n) + 0.5) - cy
355 zs = (np.arange(n) + 0.5) - cz
356 XX = xs[:, None, None] ** 2
357 YY = ys[None, :, None] ** 2
358 ZZ = zs[None, None, :] ** 2
359 h = Solve(500)
360 t, m2 = [], []
361 for i in range(nsteps):
362 s.step()
363 h.sample(s)
364 Cf = s.get_vof()
365 v = Cf.sum()
366 # the n = 2 shape moment <2z^2 - x^2 - y^2>
367 m = float((Cf * (2 * ZZ - XX - YY)).sum() / v)
368 t.append((i + 1) * dt)
369 m2.append(m)
370 t = np.array(t)
371 m2 = np.array(m2) - np.mean(np.array(m2)[len(m2) // 2:])
372 zc = zero_crossings(t, m2)
373 omega_num = math.pi / np.mean(np.diff(zc)) if len(zc) >= 3 else float("nan")
374 phi = (4 / 3) * math.pi * R ** 3 / n ** 3
375 print(f" {n:>4} {R:>5.1f} {100*phi:>6.2f}% {ratio:>7.0f} {omega_num:>11.5f}"
376 f" {omega_th:>11.5f} {100*(omega_num/omega_th-1):>7.2f}%")
377 if ratio == 1.0: # 2026-09-02: the exact viscous mode (equal fluids); see Item 2 addendum
378 try:
379 s_ex, _ = drop_mode(nn, R, mu / rho_in, sigma, rho_in)
380 print(f" EXACT viscous mode: omega {s_ex.imag:.5f} (sim {100*(omega_num/s_ex.imag-1):+.2f}%), "
381 f"gamma {-s_ex.real:.3e}; the residual after the viscous shift is the OPEN inviscid deficit")
382 except ImportError:
383 print(" (mpmath missing: exact viscous drop mode not computed)")
384 print(f" {nsteps} steps over 2.5 periods, dt = {dt:.4g}; {h}")
385
386
387# --------------------------------------------------------------------------- Hysing bubble
388HYSING = {
389 1: dict(rho1=1000.0, rho2=100.0, mu1=10.0, mu2=1.0, g=0.98, sigma=24.5,
390 ref_vmax=0.2417, ref_tvmax=0.9215, ref_yc=1.081, ref_circ=0.9013),
391 2: dict(rho1=1000.0, rho2=1.0, mu1=10.0, mu2=0.1, g=0.98, sigma=1.96,
392 ref_vmax=0.2502, ref_tvmax=0.7317, ref_yc=1.1376, ref_circ=0.5869),
393}
394
395
396def gate_hysing(case=1, nx=64):
397 p = HYSING[case]
398 print("\n" + "=" * 96)
399 print(f"HYSING RISING BUBBLE, case {case} (Hysing et al., IJNMF 60:1259, 2009), nx = {nx}")
400 print("=" * 96)
401 print(" LATERAL BC. The benchmark prescribes FREE-SLIP side walls, which this solver does not")
402 print(" have (mac_bc.hpp offers periodic / no-slip / Dirichlet / outflow). The lateral")
403 print(" condition here is PERIODIC — and for a bubble on the centreline that is not an")
404 print(" approximation: mirroring about x = 0 and x = 1 puts images at -0.5 and 1.5, i.e. at")
405 print(" spacing 1, and the mirror of a laterally symmetric bubble IS its translate. Case 1")
406 print(" stays symmetric, so the two conditions are equivalent there; case 2 develops skirts")
407 print(" and filaments that break the symmetry, so for case 2 it is an approximation.")
408 print(" NOT reported: circularity. It needs the PLIC interface length, which the solver does")
409 print(" not expose today; y_c(3) and max v_rise are the two the reference tabulates that can")
410 print(" be computed from the colour field alone.\n")
411 Lx, Lz = 1.0, 2.0
412 nz = 2 * nx
413 ny = 4
414 sc = Scale(nx / Lx)
415 R = sc.len_to_cells(0.25)
416 s = pf.Solver(nx, ny, nz)
417 s.set_rho(p["rho1"])
418 s.set_mu(sc.mu(p["mu1"]))
419 s.set_domain_bc("-z", "wall", (0, 0, 0))
420 s.set_domain_bc("+z", "wall", (0, 0, 0)) # no-slip top/bottom, as the benchmark prescribes
421 s.set_pressure_geometry(np.full((nx, ny, nz), 10.0, order="F"))
422 s.set_pressure_chebyshev(True, 600, 1e-12)
423 s.enable_vof()
424 # C = 1 in the HEAVY fluid (the surrounding liquid), 0 in the bubble
425 C = 1.0 - cylinder_fractions((nx, ny, nz), R, nx / 2.0, sc.len_to_cells(0.5))
426 s.set_vof(np.asfortranarray(C))
427 s.set_property_model("rho", "linear", "C", [p["rho2"], p["rho1"] - p["rho2"]])
428 s.set_property_model("mu", "linear", "C", [sc.mu(p["mu2"]), sc.mu(p["mu1"] - p["mu2"])])
429 s.set_surface_tension(sc.sigma(p["sigma"]))
430 # Momentum-consistent transport (V2b): the same geometric fluxes carry rho^c u_c. At case 1's
431 # density ratio 10 it moves the peak rise velocity by 15 % (see the findings entry) and at
432 # case 2's ratio 1000 it is what the rung is FOR.
433 s.enable_vof_momentum(p["rho2"], p["rho1"])
434 # buoyancy f_z = -rho(C) g, as a LinearMix on C (the +-z walls carry the net force)
435 s.set_property_model("force_z", "linear", "C",
436 [-sc.bodyforce(p["rho2"] * p["g"]),
437 -sc.bodyforce((p["rho1"] - p["rho2"]) * p["g"])])
438 T = 3.0
439 dt = 0.5 * s.capillary_dt()
440 s.set_dt(dt)
441 lim = s.vof_step_limits()
442 print(f" h = 1/{nx}, dt_sigma = {s.capillary_dt():.4e} s")
443 print(f" step limits at t=0: WY CFL dt {lim['cfl_dt']:.4g}, capillary dt "
444 f"{lim['capillary_dt']:.4g} -> {'CAPILLARY' if lim['capillary_binds'] else 'CFL'} binds")
445 print(f" ADAPTIVE dt = 0.4 x min(WY CFL, capillary), re-picked every 10 steps: the bubble")
446 print(f" accelerates, so the WY cap takes over from the capillary limit part-way through.")
447 zs = (np.arange(nz) + 0.5) / sc.s
448 h = Solve(600)
449 vmax, tvmax, yc = 0.0, 0.0, 0.0
450 t0 = time.time()
451 t = 0.0
452 i = 0
453 ncap, ncfl = 0, 0
454 while t < T:
455 if i % 10 == 0:
456 L = s.vof_step_limits()
457 dt = 0.4 * min(L["cfl_dt"], L["capillary_dt"])
458 s.set_dt(dt)
459 ncap += int(L["capillary_binds"])
460 ncfl += int(not L["capillary_binds"])
461 t += dt
462 i += 1
463 nsteps = i
464 s.step()
465 h.sample(s)
466 Cf = s.get_vof()
467 B = 1.0 - Cf # the bubble indicator
468 vb = B.sum()
469 ycm = float((B.sum(axis=(0, 1)) * zs).sum() / vb)
470 w = s.get_w()
471 vrise = float((B * w).sum() / vb) / sc.s
472 if vrise > vmax:
473 vmax, tvmax = vrise, t
474 yc = ycm
475 if i % 100 == 0:
476 print(f" t = {t:5.3f} dt = {dt:.3e} y_c = {ycm:.4f} v_rise = {vrise:.4f} "
477 f"V/V0 = {vb/ (math.pi*0.25**2*sc.s**2*ny):.6f}")
478 print(f" measured: v_rise max {vmax:.4f} at t = {tvmax:.3f}; y_c(3) = {yc:.4f}")
479 print(f" reference: v_rise max {p['ref_vmax']:.4f} at t = {p['ref_tvmax']:.3f}; "
480 f"y_c(3) = {p['ref_yc']:.4f}")
481 print(f" deviation: v_rise {100*(vmax/p['ref_vmax']-1):+.1f} %, "
482 f"y_c {100*(yc/p['ref_yc']-1):+.1f} %")
483 print(f" {nsteps} steps to t = {T}; the binding limit was CAPILLARY on {ncap} of the "
484 f"{ncap+ncfl} dt re-picks and the WY CFL on {ncfl}")
485 print(f" {h} ({time.time()-t0:.0f} s)")
486
487
488# --------------------------------------------------------------------------- falling drop
490 print("\n" + "=" * 96)
491 print("FALLING DROP — the terminal velocity WO-K deferred, re-run with surface tension")
492 print("=" * 96)
493 print(" WO-K reached 9 % of Hadamard-Rybczynski and named an under-resolved momentum solve")
494 print(" as the suspected cause. BOTH halves of that turn out to be wrong, and the gate as")
495 print(" written measured the wrong quantity:")
496 print("")
497 print(" (1) A periodic box driven by a ZERO-MEAN body force conserves total MOMENTUM, not")
498 print(" total volume flux. The box's mean velocity is a free mode of the projection, and")
499 print(" with rho_l/rho_g = 800 at phi = 1.6 % the light ambient recoils at ~13 x the")
500 print(" drop's speed (rho_g (1-phi) U_a = -rho_l phi U_d). So the LAB-FRAME drop velocity")
501 print(" is a near-cancellation of two much larger numbers and is not the settling")
502 print(" velocity. The quantity Hadamard-Rybczynski predicts is U_drop - U_ambient.")
503 print(" (2) Driving the momentum smoother to a tolerance instead of a fixed sweep count does")
504 print(" NOT rescue the lab-frame number -- it moves it further, precisely because that")
505 print(" number is a cancellation. Both are reported below.")
506 print("")
507 print(" Also corrected here: Hadamard-Rybczynski is U = (2/3)(d rho) g R^2/mu_o *")
508 print(" (mu_o + mu_i)/(2 mu_o + 3 mu_i) -- the denominator carries 3 mu_i, and the rigid")
509 print(" limit mu_i -> inf must return Stokes' 2 (d rho) g R^2/(9 mu_o).\n")
510 rho_l, rho_g = 800.0, 1.0
511 mu_out = 50.0
512 mu_ratio = 100.0
513 sigma = 16.0
514 g = 3.4e-4
515
516 def hr(R, mo, lam):
517 return (2.0 / 3.0) * (rho_l - rho_g) * g * R * R / mo * (1 + lam) / (2 + 3 * lam)
518
519 def run(n, D, cap, rtol, label):
520 R = D / 2
521 phi = (4 / 3) * math.pi * R ** 3 / n ** 3
522 s = pf.Solver(n, n, n)
523 s.set_rho(rho_l)
524 s.set_mu(mu_out)
525 s.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
526 s.set_pressure_chebyshev(True, 400, 1e-12)
527 s.enable_vof()
528 s.set_vof(sphere_fractions((n, n, n), R, (n / 2 + 0.13, n / 2 + 0.27, n / 2 + 0.11)))
529 s.set_property_model("rho", "linear", "C", [rho_g, rho_l - rho_g])
530 s.set_property_model("mu", "linear", "C", [mu_out, mu_ratio * mu_out - mu_out])
531 s.set_surface_tension(sigma)
532 s.enable_vof_momentum(rho_g, rho_l)
533 s.set_property_model("force_z", "linear", "C",
534 [g * (rho_l - rho_g) * phi, -g * (rho_l - rho_g)])
535 if rtol > 0:
536 s.diagnostics.set_velocity_solver_params(cap, rtol, 2)
537 else:
538 s.diagnostics.set_velocity_solver_params(cap)
539 dt = 0.5 * s.capillary_dt()
540 s.set_dt(dt)
541 tau = 2.0 * R * R * rho_l / (9.0 * mu_out)
542 nsteps = int((2.0 if QUICK else 6.0) * tau / dt)
543 h = Solve(400)
544 sweeps = 0
545 hist = []
546 for i in range(nsteps):
547 s.step()
548 h.sample(s)
549 sweeps = max(sweeps, s.diagnostics.last_step_timers()["momentum_sweeps"])
550 C = s.get_vof()
551 w = s.get_w()
552 ud = float((C * w).sum() / C.sum())
553 ua = float(((1 - C) * w).sum() / (1 - C).sum())
554 hist.append((ud, ua))
555 tail = hist[-max(10, nsteps // 20):]
556 Ud = float(np.mean([a for a, _ in tail]))
557 Ua = float(np.mean([b for _, b in tail]))
558 Urel = Ud - Ua
559 U0 = hr(R, mu_out, mu_ratio)
560 # Hasimoto (1959) simple-cubic array drag correction, in its DENOMINATOR form
561 # F = 6 pi mu a U / (1 - 1.7601 c^{1/3} + c - 1.5593 c^2)
562 # -- not the linearised 1 + 1.7601 c^{1/3}, which under-corrects by 20 % already at
563 # c = 0.016 (1.443 against 1.746) and is what WO-K's substitute quoted.
564 K = 1.0 / (1.0 - 1.7601 * phi ** (1 / 3) + phi - 1.5593 * phi ** 2)
565 print(f" {label}")
566 print(f" D/h {D:.0f} phi {phi:.4f} U_HR {U0:.4e} K {K:.3f} U_HR/K {U0/K:.4e}")
567 print(f" U_drop(lab) {Ud:.4e} -> {Ud/(-U0/K):.4f} x "
568 f"U_ambient {Ua:.4e} recoil ratio {-Ua/Ud:.1f}")
569 print(f" U_rel = U_drop - U_ambient = {Urel:.4e} -> "
570 f"**{Urel/(-U0/K):.4f} x (U_HR/K)**")
571 print(f" dt {dt:.3g} = 0.5 dt_sigma, {nsteps} steps = "
572 f"{nsteps*dt/tau:.1f} tau, max sweeps/step {sweeps}; {h}")
573 return Urel / (-U0 / K)
574
575 print(" (a) the momentum-solve hypothesis, at D/h = 15:")
576 run(48, 15.0, 20, 0.0, "fixed 20 sweeps (WO-K's setting)")
577 run(48, 15.0, 400, 1e-4, "tolerance 1e-4, cap 400")
578 if not QUICK:
579 run(48, 15.0, 2000, 1e-6, "tolerance 1e-6, cap 2000")
580 print("\n (b) resolution, with the momentum solve converged (tolerance 1e-4):")
581 for n, D in ((48, 10.0), (48, 15.0), (64, 20.0)) if not QUICK else ((48, 10.0), (48, 15.0),):
582 run(n, D, 400, 1e-4, f"n = {n}, D/h = {D:.0f}")
583
584
585# --------------------------------------------------------------------------- step economics
587 print("\n" + "=" * 96)
588 print("STEP ECONOMICS — does the capillary dt or the Weymouth-Yue CFL bind?")
589 print("=" * 96)
590 print(" A pore-scale configuration in physical units: water/air (sigma = 0.072 N/m,")
591 print(" rho 1000/1.2, mu 1e-3/1.8e-5), a pore of diameter d driven at a capillary number")
592 print(" Ca = mu U / sigma. The two explicit limits are")
593 print(" capillary dt < sqrt((rho1+rho2) h^3 / (4 pi sigma))")
594 print(" transport dt < CFL_WY h / |u|, CFL_WY = 0.25 (Weymouth's 3-D bound)")
595 print(" so their ratio is dt_cfl/dt_sigma = (CFL/|u'|) sqrt(4 pi sigma' / (rho_sum))")
596 print(" dt_sigma shrinks as h^{3/2} and dt_cfl only as h, so REFINING makes the capillary")
597 print(" limit MORE binding, not less: their ratio grows as h^{-1/2}.\n")
598 rho1, rho2 = 1000.0, 1.2
599 sigma_p = 0.072
600 mu1 = 1e-3
601 print(f" {'d [um]':>8} {'cells/d':>8} {'Ca':>8} {'U [m/s]':>10} {'dt_sig [s]':>12}"
602 f" {'dt_cfl [s]':>12} {'binds':>10} {'steps for 1 pore':>18}")
603 for d_um in (50.0, 200.0):
604 for cells in (16, 32, 64):
605 for Ca in (1e-6, 1e-4, 1e-2):
606 d = d_um * 1e-6
607 sc = Scale(cells / d)
608 U = Ca * sigma_p / mu1
609 sig_s = sc.sigma(sigma_p)
610 dt_sig = math.sqrt((rho1 + rho2) * 1.0 / (4 * math.pi * sig_s))
611 dt_cfl = 0.25 / (sc.s * U)
612 binds = "capillary" if dt_sig < dt_cfl else "CFL"
613 t_pore = d / U
614 nst = t_pore / min(dt_sig, dt_cfl)
615 print(f" {d_um:>8.0f} {cells:>8d} {Ca:>8.0e} {U:>10.3e} {dt_sig:>12.3e}"
616 f" {dt_cfl:>12.3e} {binds:>10} {nst:>18.3e}")
617 print("\n And the same read-off from the solver itself on the static-droplet case:")
618 n, R = 48, 12.0
619 s = pf.Solver(n, n, n)
620 s.set_rho(1000.0)
621 s.set_mu(1.0)
622 s.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
623 s.set_pressure_chebyshev(True, 400, 1e-12)
624 s.enable_vof()
625 s.set_vof(sphere_fractions((n, n, n), R, (n / 2 + 0.13, n / 2 + 0.27, n / 2 + 0.11)))
626 s.set_property_model("rho", "linear", "C", [1.0, 999.0])
627 s.set_surface_tension(50.0)
628 s.set_dt(0.5 * s.capillary_dt())
629 U = 0.05
630 s.set_state(np.full((n, n, n), U, order="F"), np.zeros((n, n, n), order="F"),
631 np.zeros((n, n, n), order="F"))
632 lim = s.vof_step_limits()
633 print(f" uniform |u| = {U}: WY CFL dt {lim['cfl_dt']:.4g}, capillary dt "
634 f"{lim['capillary_dt']:.4g}, binding {lim['binding']:.4g} "
635 f"({'capillary' if lim['capillary_binds'] else 'CFL'})")
636
637
638# --------------------------------------------------------------------------- main
639ALL = {
640 "static": gate_static,
641 "wave": gate_wave,
642 "lamb": gate_lamb,
643 "hysing1": lambda: gate_hysing(1, 32 if QUICK else 64),
644 "hysing2": lambda: gate_hysing(2, 32 if QUICK else 64),
645 "falling": gate_falling,
646 "limits": gate_limits,
647}
648
649if __name__ == "__main__":
650 run = GATES or list(ALL)
651 for g in run:
652 if g not in ALL:
653 print(f"unknown gate {g!r}; known: {', '.join(ALL)}")
654 sys.exit(2)
655 for g in run:
656 ALL[g]()
657 print("\ndone.")
__init__(self, cells_per_length)
static double mean(const std::vector< double > &a)