flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_wetting_dynamic.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-V6 (rung V6) — the DYNAMIC contact angle and hysteresis: the gate battery, in Python.
3
4Every gate prints its numbers; nothing is summarised into an adjective. The MODEL (state it with
5every result, VOF_PLAN section 6):
6
7 theta_Delta^3 = theta_e^3 + 9 Ca_cl ln(Delta/lambda), Ca_cl = mu_l U_cl / sigma
8
9Afkhami, Zaleski & Bussmann, JCP 228:5370 (2009). `lambda` is an EXPLICIT slip length in cells and
10is never omitted from a reported number: a VoF contact line's numerical slip is proportional to the
11cell size, so without it the imposed angle is silently grid-dependent.
12
13 jurin G3-static. CAPILLARY RISE, STATIC. Two 8-cell plates with ROUNDED ends (a capsule SDF, so
14 there is no sharp 90-degree corner where |grad sdf| != 1) at QUARTER-integer placement,
15 bounding a gap `w`; the periodic outer channel is a second capillary of width `w_out`.
16 Both menisci rise, so the reference is the LEVEL DIFFERENCE. Jurin's
17 `2 sigma cos(theta)/(drho g) (1/w - 1/w_out)` is the Bond -> 0 asymptote and is NOT the
18 right reference at the Bond numbers a finite box affords; the exact reference is the
19 static meniscus ODE `sigma z''/(1+z'^2)^{3/2} = drho g (z - z_ref)` with `z'(w/2) =
20 cot(theta)`, solved here by shooting. Both are printed.
21
22 spread G2. SPREADING DROP vs Cox-Voinov. A drop released as a hemisphere on a wall with
23 `theta_e = 30`: the contact radius a(t) and the apparent angle theta_app(t) are measured
24 from the shape, `Ca_cl = mu (da/dt)/sigma` from the contact line's own motion, and
25 `theta_app^3 - theta_e^3` is fitted against `Ca_cl`. The composition property of
26 Cox-Voinov says the slope is `9 ln(a/lambda)`: the fill supplies `9 ln(Delta/lambda)` and
27 the resolved hydrodynamics supply `9 ln(a/Delta)`. Tanner's `a ~ t^(1/10)` at late times
28 is reported alongside.
29
30 rise G3-dynamics. The same plate scene from a FLAT interface, with the dynamic angle, over a
31 slip sweep: the rise curve h(t), the final height against the static equilibrium, and the
32 damping class (asymptotic vs oscillatory) — the two regimes Gruending et al. (AMM 86:142,
33 2020) separate.
34
35 incline G4. HYSTERESIS: a drop on a tilted wall (the wall is kept axis-aligned and GRAVITY is
36 tilted, which is the same problem and keeps the SDF exact). Below the critical Bond
37 number `Bo_c = cos(theta_r) - cos(theta_a)` the contact line must stay PINNED; above it
38 the drop slides with the front near theta_a and the rear near theta_r.
39
40Usage:
41 PYTHONPATH=$PWD/build_cuda python tests/study/vof_wetting_dynamic.py [jurin spread rise incline]
42 ... --quick shorter runs
43"""
44import math
45import sys
46
47import numpy as np
48
49import peclet.flow as pf
50
51PRESS_MAXIT = 300
52DEG = math.pi / 180.0
53
54
55# ------------------------------------------------------------------- the exact static meniscus
56_MEN_CACHE = {}
57
58
59def meniscus(w, theta_deg, dg, sigma=1.0, n=600):
60 """The exact 2-D static meniscus in a slot of width `w` with contact angle `theta` on both
61 walls, in gravity `dg = drho*g`:
62
63 sigma z''/(1 + z'^2)^{3/2} = dg (z - z_ref), z'(0) = 0, z'(w/2) = cot(theta)
64
65 measured from the flat far-field datum `z_ref = 0`. Shooting on z(0) (monotone: a higher
66 meniscus has a steeper wall slope). Returns (x, z) on [0, w/2] — the profile is symmetric.
67 Reduces to Jurin `z = 2 sigma cos(theta)/(dg w)` as Bond -> 0.
68 """
69 key = (round(w, 9), round(theta_deg, 9), round(dg, 12), round(sigma, 12), n)
70 if key in _MEN_CACHE:
71 return _MEN_CACHE[key]
72 target = 1.0 / math.tan(theta_deg * DEG)
73 h = 0.5 * w / n
74 k = dg / sigma
75
76 def shoot(z0):
77 z, zp = z0, 0.0
78 zs = np.empty(n + 1)
79 zs[0] = z0
80 for i in range(n):
81 def f(a, b):
82 return b, k * a * (1.0 + min(b * b, 1e12)) ** 1.5
83 a1, b1 = f(z, zp)
84 a2, b2 = f(z + 0.5 * h * a1, zp + 0.5 * h * b1)
85 a3, b3 = f(z + 0.5 * h * a2, zp + 0.5 * h * b2)
86 a4, b4 = f(z + h * a3, zp + h * b3)
87 z += h / 6.0 * (a1 + 2 * a2 + 2 * a3 + a4)
88 zp += h / 6.0 * (b1 + 2 * b2 + 2 * b3 + b4)
89 zs[i + 1] = z
90 if not (abs(z) < 1e8 and abs(zp) < 1e8):
91 zs[i + 1:] = z
92 return 1e9, zs
93 return zp, zs
94
95 lo, hi = 0.0, max(4.0 * abs(jurin(w, theta_deg, dg, sigma)), 1.0)
96 while shoot(hi)[0] < target:
97 hi *= 2.0
98 if hi > 1e6:
99 break
100 for _ in range(90):
101 mid = 0.5 * (lo + hi)
102 if shoot(mid)[0] < target:
103 lo = mid
104 else:
105 hi = mid
106 _, zs = shoot(0.5 * (lo + hi))
107 out = (np.arange(n + 1) * h, zs)
108 _MEN_CACHE[key] = out
109 return out
110
111
112def meniscus_mean(w, theta_deg, dg, sigma=1.0, xlo=None, xhi=None):
113 """The MEAN interface height over x in [xlo, xhi] (default the whole slot, x measured from the
114 LEFT wall) — the quantity the colour-integral level read-out measures."""
115 xs, zs = meniscus(w, theta_deg, dg, sigma)
116 x = np.concatenate([-xs[::-1], xs[1:]]) + 0.5 * w # 0 .. w
117 z = np.concatenate([zs[::-1], zs[1:]])
118 if xlo is None:
119 xlo, xhi = 0.0, w
120 m = (x >= xlo) & (x <= xhi)
121 if m.sum() < 2:
122 return float(np.interp(0.5 * (xlo + xhi), x, z))
123 return float(np.trapezoid(z[m], x[m]) / (x[m][-1] - x[m][0]))
124
125
126def jurin(w, theta_deg, dg, sigma=1.0):
127 return 2.0 * sigma * math.cos(theta_deg * DEG) / (dg * w)
128
129
130# --------------------------------------------------------------------------------------- helpers
131def max_u(s, zlo=None):
132 m = 0.0
133 for f in (s.get_u, s.get_v, s.get_w):
134 v = np.abs(f())
135 m = max(m, float(v.max()) if zlo is None else float(v[:, :, zlo:].max()))
136 return m
137
138
139def relax(s, steps, cfl=0.15, dt_cap=None, probe=0, cb=None, ramp=0.02):
140 """`ramp`: the first step is taken at `ramp * dt_cap`, because the interface-local Courant
141 number is read BEFORE the step and is 0 at rest — a full first step on a scene with a strong
142 initial capillary imbalance overshoots the Weymouth-Yue cap and the advector (correctly) throws."""
143 dt_cap = dt_cap if dt_cap is not None else 0.5 * s.capillary_dt()
144 dt = ramp * dt_cap
145 s.set_dt(dt)
146 maxit, capped, t = 0, 0, 0.0
147 trace = []
148 for i in range(steps):
149 c = s.vof_max_courant()
150 if c > cfl:
151 dt = min(dt_cap, dt * cfl / c)
152 s.set_dt(dt)
153 elif dt < dt_cap and c < 0.5 * cfl:
154 dt = min(dt_cap, 1.1 * dt)
155 s.set_dt(dt)
156 s.step()
157 cl = s.diagnostics.vof_last_courant()
158 if cl > 0.2: # the sweep that just ran came close to the WY cap: back off before the next
159 dt = max(dt * 0.5, 1e-6 * dt_cap)
160 s.set_dt(dt)
161 t += dt
162 it = s.diagnostics.last_pressure_iterations()
163 maxit = max(maxit, it)
164 capped += 1 if it >= PRESS_MAXIT else 0
165 if probe and (i + 1) % probe == 0:
166 trace.append((i + 1, t, cb(s) if cb else max_u(s)))
167 return dt, maxit, capped, trace, t
168
169
170def capsule_sdf(nx, ny, nz, xm, r, z0, z1):
171 """A plate of thickness 2r centred on x = xm, spanning z in [z0, z1] with SEMICIRCULAR ends:
172 the distance to the segment (xm, z0+r) -> (xm, z1-r) minus r. Flat vertical faces at xm +/- r,
173 no sharp corner anywhere (|grad sdf| = 1 everywhere outside the axis)."""
174 ax = (np.arange(nx) + 0.5)[:, None]
175 az = (np.arange(nz) + 0.5)[None, :]
176 qz = np.maximum((z0 + r) - az, az - (z1 - r))
177 d = np.hypot(np.abs(ax - xm), np.maximum(qz, 0.0)) - r
178 return np.broadcast_to(d[:, None, :], (nx, ny, nz))
179
180
181# ------------------------------------------------------------------------------- G3 static Jurin
182def jurin_scene(nx=96, ny=4, nz=112, w=16.0, plate=8.0, zp=(12.0, 100.0)):
183 """The two rounded plates. Returns (sdf, xm_gap, xm_outer, w_out, subsdf); the plate FACES sit
184 at quarter-integer x (WO-S finding 5: at a half-integer the wall cell's tangential MAC faces sit
185 on the SDF zero level and close, and the contact line cannot move). `subsdf(x, z)` is the same
186 SDF as a callable, for sub-cell sampling of the initial colour."""
187 r = 0.5 * plate
188 xm1 = 32.25 + r
189 xm2 = xm1 + plate + w
190 w_out = nx - (xm2 + r - (xm1 - r))
191
192 def subsdf(px, pz):
193 qz = np.maximum((zp[0] + r) - pz, pz - (zp[1] - r))
194 q = np.maximum(qz, 0.0)
195 return np.minimum(np.hypot(np.abs(px - xm1), q), np.hypot(np.abs(px - xm2), q)) - r
196
197 d = np.minimum(capsule_sdf(nx, ny, nz, xm1, r, zp[0], zp[1]),
198 capsule_sdf(nx, ny, nz, xm2, r, zp[0], zp[1]))
199 return np.asfortranarray(d.astype(float)), 0.5 * (xm1 + xm2), (xm2 + r + 0.5 * w_out) % nx, \
200 w_out, subsdf
201
202
203def jurin_colour(nx, ny, nz, subsdf, zi_of_x, sub=4):
204 """C = liquid fraction of the FLUID volume of each cell (VOF_PLAN rule 2), for an interface
205 z = zi(x). The FLUID restriction is not cosmetic: a sub-cell of a cut wall column that is
206 sampled as if it were fluid drops that column's C to `eps`, and a C of 0.75 in the middle of
207 the liquid is a FAKE INTERFACE that the V3 cascade turns into |kappa| ~ 3e6 (measured)."""
208 off = (np.arange(sub) + 0.5) / sub
209 px = (np.arange(nx)[:, None, None, None] + off[None, :, None, None]) # (nx, sub, 1, 1)
210 pz = (np.arange(nz)[None, None, :, None] + off[None, None, None, :]) # (1, 1, nz, sub)
211 fluid = subsdf(px, pz) > 0.0 # (nx, sub, nz, sub)
212 liq = fluid & (pz < zi_of_x(px))
213 nf = fluid.sum(axis=(1, 3))
214 c2 = np.where(nf > 0, liq.sum(axis=(1, 3)) / np.maximum(nf, 1), 0.0)
215 return np.asfortranarray(np.broadcast_to(c2[:, None, :], (nx, ny, nz)).astype(float).copy())
216
217
218def set_zero_mean_buoyancy(s, dg, ratio, wos=False):
219 """`force_z = g (rho_bar - rho)` with `g = dg/(rho_l - rho_g)`: the buoyancy that produces the
220 prescribed `drho g` AND has zero volume mean.
221
222 THIS IS THE FIX THAT MAKES THE JURIN SCENE WORK, and the defect it removes is what WO-S's G4
223 measured. The obvious "zero in the gas, -dg in the liquid" form (`[dg/(ratio-1), -dg/(1-1/ratio)]`,
224 which is what WO-S used) has a NON-ZERO volume mean, and in a fully periodic box a non-zero-mean
225 body force accelerates the whole fluid without bound (WO-Q finding 9). Worse than a drift: the
226 accelerating frame contributes its own `-rho a` body force, i.e. it CANCELS PART OF GRAVITY, so
227 the menisci relax towards a smaller level difference the longer the run goes — exactly the -83 %
228 WO-S recorded, with the accompanying non-decaying `max|u|`.
229 """
230 if wos: # the ABLATION: WO-S's G4 force model, zero in the gas and -dg in the liquid
231 s.set_property_model("force_z", "linear", "rho",
232 [dg / (ratio - 1.0), -dg / (1.0 - 1.0 / ratio)])
233 return None
234 rho_g, rho_l = 1.0 / ratio, 1.0
235 g = dg / (rho_l - rho_g)
236 eps = s.diagnostics.vof_geometry(0)
237 cc = s.get_vof()
238 cbar = float((cc * eps).sum()) / float(eps.sum())
239 rho_bar = rho_g + (rho_l - rho_g) * cbar
240 s.set_property_model("force_z", "linear", "rho", [g * rho_bar, -g])
241 return rho_bar
242
243
244def _channel_columns(sdf, nz):
245 """The fluid columns of the gap and of the periodic outer channel, as index arrays. EVERY fluid
246 column is included, cut ones too: the level read-out is eps-weighted, so the full-width mean is
247 what the exact identity below refers to."""
248 nx = sdf.shape[0]
249 solid = (sdf[:, 0, nz // 2] < 0.0)
250 px = np.where(solid)[0]
251 # the two plates, in x; the gap is between them
252 gaps = np.split(px, np.where(np.diff(px) > 1)[0] + 1)
253 left, right = gaps[0], gaps[-1]
254 xin = np.arange(left.max(), right.min() + 1)
255 xout = np.concatenate([np.arange(right.max(), nx), np.arange(0, left.min() + 1)])
256 return xin, xout
257
258
259def level_of(cc, eps, xs):
260 col = (cc[xs, :, :] * eps[xs, :, :]).sum(axis=(0, 1))
261 tot = eps[xs, :, :].sum(axis=(0, 1))
262 return float(np.where(tot > 0, col / np.maximum(tot, 1e-30), 0.0).sum())
263
264
265def g3_static(theta=30.0, steps=500, nx=96, ny=4, nz=112, w=16.0, plate=8.0, dg=3.0e-3, sigma=1.0,
266 mu=0.2, ratio=10.0, datum=28.0, zp=(12.0, 100.0), flat_start=False, wos=False):
267 sdf, xm_in, xm_out, w_out, subsdf = jurin_scene(nx, ny, nz, w, plate, zp=zp)
268 jin, jout = jurin(w, theta, dg, sigma), jurin(w_out, theta, dg, sigma)
269 zin, zout = meniscus_mean(w, theta, dg, sigma), meniscus_mean(w_out, theta, dg, sigma)
270 bo_in, bo_out = dg * w * w / sigma, dg * w_out * w_out / sigma
271 ref = jin - jout
272 print(f"\n=== G3-static JURIN on the FIXED scene — grid {nx}x{ny}x{nz}, plates {plate:.0f} "
273 f"cells thick with ROUNDED ends (capsule SDF), QUARTER-integer faces, gap w = {w:.0f}, "
274 f"periodic outer channel w_out = {w_out:.0f}, theta = {theta}, sigma = {sigma}, "
275 f"drho*g = {dg:g}, ratio {ratio:g}, {steps} steps, "
276 f"{'FLAT start (attraction)' if flat_start else 'equilibrium start (fixed point)'}"
277 f"{', WO-S NON-zero-mean buoyancy ABLATION' if wos else ''}")
278 print(f" Bond inner {bo_in:.3f} outer {bo_out:.3f}")
279 print(f" Jurin: inner {jin:.4f} outer {jout:.4f} DIFFERENCE {ref:.4f} cells")
280 print(f" the exact meniscus ODE's MEAN level: inner {zin:.4f} outer {zout:.4f} "
281 f"DIFFERENCE {zin - zout:.4f} — identical to Jurin BY AN EXACT IDENTITY at ANY Bond "
282 f"(integrate sigma d/dx[z'/sqrt(1+z'^2)] = drho g z over the slot: the flux term gives "
283 f"2 sigma cos(theta) and the source gives drho g w zbar), so the eps-weighted "
284 f"level integral is the RIGHT read-out and needs no low-Bond assumption")
285
286 xs_in, zs_in = meniscus(w, theta, dg, sigma)
287 xs_o, zs_o = meniscus(w_out, theta, dg, sigma)
288
289 def zi_of_x(x):
290 if flat_start:
291 return np.full_like(x, datum)
292 dxi = np.abs(x - xm_in)
293 dxo = np.abs(((x - xm_out + 0.5 * nx) % nx) - 0.5 * nx)
294 zi = np.interp(np.clip(dxi, 0, 0.5 * w), xs_in, zs_in) + datum
295 zo = np.interp(np.clip(dxo, 0, 0.5 * w_out), xs_o, zs_o) + datum
296 return np.where(dxi <= 0.5 * w, zi, zo)
297
298 c0 = jurin_colour(nx, ny, nz, subsdf, zi_of_x)
299 s = pf.Solver(nx, ny, nz)
300 s.set_rho(1.0)
301 s.set_mu(mu)
302 s.set_solid(sdf, cutcell_pressure=True)
303 s.enable_vof()
304 s.set_property_model("rho", "linear", "C", [1.0 / ratio, 1.0 - 1.0 / ratio])
305 s.set_property_model("mu", "linear", "C", [mu / ratio, mu - mu / ratio])
306 s.set_vof(c0)
307 set_zero_mean_buoyancy(s, dg, ratio, wos)
308 s.set_surface_tension(sigma)
309 s.set_contact_angle(theta)
310 v0 = s.diagnostics.vof_diagnostics()["volume"]
311 eps = s.diagnostics.vof_geometry(0)
312 xin, xout = _channel_columns(sdf, nz)
313 l0 = level_of(s.get_vof(), eps, xin) - level_of(s.get_vof(), eps, xout)
314 print(f" initial level DIFFERENCE {l0:.4f} (the initial condition IS the exact equilibrium "
315 f"for this datum when the run starts from it)")
316 dt, maxit, capped, trace, tend = relax(
317 s, steps, probe=max(1, steps // 10),
318 cb=lambda q: level_of(q.get_vof(), eps, xin) - level_of(q.get_vof(), eps, xout))
319 cc = s.get_vof()
320 lin, lout = level_of(cc, eps, xin), level_of(cc, eps, xout)
321 d = s.diagnostics.vof_diagnostics()
322 err = ((lin - lout) - ref) / ref
323 print(f" measured inner level {lin:.4f} outer {lout:.4f} DIFFERENCE {lin-lout:.4f} cells "
324 f"vs Jurin {ref:.4f} -> {100*err:+.2f} % (gate 5 %) "
325 f"{'PASS' if abs(err) <= 0.05 else 'FAIL'}")
326 print(f" dV/V {abs(d['volume']-v0)/v0:.3e} max|u| {max_u(s):.3e} iters {maxit} "
327 f"(capped {capped}) dt {dt:.4f} solid sum {d['solid_sum']:.1e} t_end {tend:.1f}")
328 print(" level-difference trace: " + " ".join(f"{t:.1f}:{v:.3f}" for _, t, v in trace))
329 return lin - lout, ref
330
331
332# ------------------------------------------------------------------------------------- G2 spread
333def cap_sphere(volume, theta_deg):
334 ct = math.cos(theta_deg * DEG)
335 Rc = (3.0 * volume / (math.pi * (1.0 - ct) ** 2 * (2.0 + ct))) ** (1.0 / 3.0)
336 return Rc, -Rc * ct
337
338
339def cap_colour(nx, ny, nz, cx, cy, zw, R, theta_deg=90.0):
340 Rc, zc = cap_sphere(2.0 / 3.0 * math.pi * R ** 3, theta_deg)
341 sub = (np.arange(4) + 0.5) / 4.0
342 px = np.arange(nx)[:, None, None, None, None, None] + sub[None, None, None, :, None, None]
343 py = np.arange(ny)[None, :, None, None, None, None] + sub[None, None, None, None, :, None]
344 pz = np.arange(nz)[None, None, :, None, None, None] + sub[None, None, None, None, None, :]
345 inside = ((px - cx) ** 2 + (py - cy) ** 2 + (pz - zw - zc) ** 2) < Rc * Rc
346 fluid = np.broadcast_to(pz >= zw, inside.shape)
347 tot = fluid.sum(axis=(3, 4, 5))
348 ins = (fluid & inside).sum(axis=(3, 4, 5))
349 return np.asfortranarray(np.where(tot > 0, ins / np.maximum(tot, 1), 0.0).astype(float))
350
351
352def g2_spread(theta_e=30.0, slip=0.1, steps=1500, probe=50, nx=64, nz=40, R=12.0, zw=4.25,
353 sigma=1.0, oh=0.1, dynamic=True, wall_slip=False):
354 mu = oh * math.sqrt(1.0 * sigma * 2.0 * R)
355 print(f"\n=== G2 SPREADING DROP vs Cox-Voinov — D/dx {2*R:.0f}, grid {nx}x{nx}x{nz}, wall "
356 f"z = {zw}, theta_e = {theta_e}, lambda = {slip} cells, sigma {sigma}, mu {mu:.4f} "
357 f"(Oh {oh}), {steps} steps, dynamic angle {'ON' if dynamic else 'OFF (static control)'}"
358 f", momentum Navier slip {'ON (WO-V6b)' if wall_slip else 'OFF'}")
359 z = (np.arange(nz) + 0.5)[None, None, :]
360 sdf = np.asfortranarray(
361 np.broadcast_to(np.minimum(z - zw, (nz - zw) - z), (nx, nx, nz)).astype(float))
362 c0 = cap_colour(nx, nx, nz, nx * 0.5, nx * 0.5, zw, R, 90.0) # released as a HEMISPHERE
363 s = pf.Solver(nx, nx, nz)
364 s.set_rho(1.0)
365 s.set_mu(mu)
366 s.set_solid(sdf, cutcell_pressure=True)
367 s.enable_vof()
368 s.set_vof(c0)
369 s.set_surface_tension(sigma)
370 if dynamic:
371 s.set_contact_angle_dynamic(True, theta_e, slip, mu, sigma)
372 else:
373 s.set_contact_angle(theta_e)
374 if wall_slip: # WO-V6b: the SAME lambda in the momentum wall closure
375 s.set_wall_slip_length(slip)
376 eps = s.diagnostics.vof_geometry(0)
377 ix = nx // 2
378 hist = []
379
380 def shape(sv):
381 cc = sv
382 h = float((cc[ix, ix, :] * eps[ix, ix, :]).sum())
383 V = s.diagnostics.vof_diagnostics()["volume"]
384 a = math.sqrt(max((6.0 * V / (math.pi * h) - h * h) / 3.0, 1e-12))
385 return h, a, 2.0 * math.degrees(math.atan2(h, a))
386
387 dt_cap = 0.5 * s.capillary_dt()
388 dt = dt_cap
389 s.set_dt(dt)
390 t, maxit, capped = 0.0, 0, 0
391 for i in range(steps):
392 c = s.vof_max_courant()
393 if c > 0.15:
394 dt = min(dt_cap, dt * 0.15 / c)
395 s.set_dt(dt)
396 elif dt < dt_cap and c < 0.075:
397 dt = min(dt_cap, 1.2 * dt)
398 s.set_dt(dt)
399 s.step()
400 t += dt
401 maxit = max(maxit, s.diagnostics.last_pressure_iterations())
402 capped += 1 if s.diagnostics.last_pressure_iterations() >= PRESS_MAXIT else 0
403 if (i + 1) % probe == 0:
404 h, a, th = shape(s.get_vof())
405 cd = s.diagnostics.contact_angle_diagnostics()
406 hist.append((t, h, a, th, cd.get("mean_imposed_theta", 0.0),
407 cd.get("max_Ca_cl", 0.0), cd.get("max_contact_speed", 0.0)))
408 print(f" {'t':>9} {'h':>7} {'a':>7} {'theta_app':>9} {'theta_imp':>9} {'Ca_cl(dadt)':>12} "
409 f"{'max Ca_cl':>10}")
410 rows = []
411 for k in range(1, len(hist)):
412 t1, h1, a1, th1, ti1, ca1, u1 = hist[k]
413 t0, _, a0, _, _, _, _ = hist[k - 1]
414 ca_meas = mu * (a1 - a0) / (t1 - t0) / sigma
415 print(f" {t1:9.3f} {h1:7.3f} {a1:7.3f} {th1:9.3f} {ti1:9.3f} {ca_meas:12.4e} {ca1:10.3e}")
416 rows.append((ca_meas, th1, a1))
417 # the fit over the window Ca in [1e-3, 1e-1]
418 sel = [(c, th, a) for c, th, a in rows if 1e-3 <= c <= 1e-1]
419 slope = want = am = float("nan")
420 if len(sel) >= 3:
421 x = np.array([c for c, _, _ in sel])
422 y = np.array([(th * DEG) ** 3 - (theta_e * DEG) ** 3 for _, th, _ in sel])
423 am = float(np.mean([a for _, _, a in sel]))
424 slope = float(np.polyfit(x, y, 1)[0])
425 want = 9.0 * math.log(am / slip)
426 print(f" fit over {len(sel)} points with Ca in [1e-3, 1e-1] (mean a = {am:.2f} cells): "
427 f"slope {slope:.3f} vs 9 ln(a/lambda) = {want:.3f} "
428 f"({100*(slope/want-1):+.1f} %, gate 25 %) "
429 f"{'PASS' if abs(slope/want - 1) <= 0.25 else 'FAIL'}")
430 print(f" effective inner cut-off implied by the measured slope: "
431 f"lambda_eff = a exp(-slope/9) = {am*math.exp(-slope/9.0):.4f} cells "
432 f"(prescribed lambda = {slip} cells)")
433 else:
434 print(f" fit: only {len(sel)} points fell in the Ca window — inconclusive")
435 # Tanner
436 late = [(t, a) for t, _, a, _, _, _, _ in hist[len(hist) // 2:]]
437 if len(late) >= 4:
438 lt = np.log(np.array([t for t, _ in late]))
439 la = np.log(np.array([a for _, a in late]))
440 n_t = float(np.polyfit(lt, la, 1)[0])
441 print(f" Tanner: fitted a ~ t^{n_t:.4f} over the late half (the law is 1/10 = 0.1)")
442 print(f" pressure iters max {maxit} (capped {capped}); final theta_app "
443 f"{hist[-1][3]:.3f} vs theta_e {theta_e}")
444 return slope, want, am
445
446
447# ---------------------------------------------------------------------------------- G3 dynamics
448def g3_rise(theta_e=30.0, slips=(0.05, 0.3), steps=1500, nx=96, ny=4, nz=112, w=16.0,
449 plate=8.0, dg=3.0e-3, sigma=1.0, mu=0.2, ratio=10.0, datum=28.0, zp=(12.0, 100.0),
450 static_control=True, wall_slip=False, probe=None, lw=False):
451 """G3-dynamics: the same plate scene from a FLAT interface, so the contact line has to travel.
452 The static equilibrium level difference is Jurin's, EXACTLY (see g3_static), so the gate is the
453 final height against it; the damping class (asymptotic vs oscillatory) is the second half of
454 the Gruending et al. (AMM 86:142, 2020) comparison."""
455 sdf, xm_in, xm_out, w_out, subsdf = jurin_scene(nx, ny, nz, w, plate, zp=zp)
456 ref = jurin(w, theta_e, dg, sigma) - jurin(w_out, theta_e, dg, sigma)
457 xin, xout = _channel_columns(sdf, nz)
458 print(f"\n=== G3-dynamics CAPILLARY RISE with the DYNAMIC angle — grid {nx}x{ny}x{nz}, "
459 f"gap {w:.0f}, outer {w_out:.0f}, theta_e {theta_e}, sigma {sigma}, mu_l {mu}, "
460 f"drho*g {dg:g}, ratio {ratio:g}, {steps} steps, FLAT start")
461 print(f" static equilibrium level DIFFERENCE (Jurin, exact for this read-out) {ref:.4f} "
462 f"cells; Oh = mu/sqrt(rho sigma w) = {mu/math.sqrt(1.0*sigma*w):.4f}")
463 cases = [("static (control)", None)] if static_control else []
464 cases += [(f"lambda = {sl:g} cells", sl) for sl in slips]
465 for label, sl in cases:
466 c0 = jurin_colour(nx, ny, nz, subsdf, lambda x: np.full_like(x, datum))
467 s = pf.Solver(nx, ny, nz)
468 s.set_rho(1.0)
469 s.set_mu(mu)
470 s.set_solid(sdf, cutcell_pressure=True)
471 s.enable_vof()
472 s.set_property_model("rho", "linear", "C", [1.0 / ratio, 1.0 - 1.0 / ratio])
473 s.set_property_model("mu", "linear", "C", [mu / ratio, mu - mu / ratio])
474 s.set_vof(c0)
475 set_zero_mean_buoyancy(s, dg, ratio)
476 s.set_surface_tension(sigma)
477 if sl is None:
478 s.set_contact_angle(theta_e)
479 else:
480 s.set_contact_angle_dynamic(True, theta_e, sl, mu, sigma)
481 if wall_slip and sl is not None: # WO-V6b: the SAME lambda in the momentum closure
482 s.set_wall_slip_length(sl)
483 eps = s.diagnostics.vof_geometry(0)
484 v0 = s.diagnostics.vof_diagnostics()["volume"]
485 dt, maxit, capped, trace, tend = relax(
486 s, steps, probe=probe if probe else max(1, steps // 15),
487 cb=lambda q: level_of(q.get_vof(), eps, xin) - level_of(q.get_vof(), eps, xout))
488 h = [v for _, _, v in trace]
489 final, peak = h[-1], max(h)
490 over = (peak - final) / max(abs(final), 1e-12)
491 cd = s.diagnostics.contact_angle_diagnostics() if sl is not None else None
492 d = s.diagnostics.vof_diagnostics()
493 print(f" {label:20s}: final {final:.3f} vs equilibrium {ref:.3f} "
494 f"({100*(final/ref-1):+.1f} %, gate 10 %) "
495 f"{'PASS' if abs(final/ref-1) <= 0.10 else 'FAIL'} | peak {peak:.3f} -> overshoot "
496 f"{100*over:.1f} % => {'OSCILLATORY' if over > 0.02 else 'ASYMPTOTIC'}")
497 if cd:
498 print(f" mean imposed theta {cd['mean_imposed_theta']:.2f} (theta_e {theta_e}), "
499 f"mean apparent {cd['mean_apparent_theta']:.2f}, max|Ca_cl| {cd['max_Ca_cl']:.3e}, "
500 f"max|U_cl| {cd['max_contact_speed']:.3e}, dynamic cells {cd['dynamic_cells']}")
501 print(f" dV/V {abs(d['volume']-v0)/v0:.2e} iters {maxit} (capped {capped}) "
502 f"t_end {tend:.1f} max|u| {max_u(s):.2e}")
503 print(" h(t): " + " ".join(f"{t:.0f}:{v:.2f}" for _, t, v in trace))
504 if lw:
505 # GATE 4 (WO-V6b): the Lucas-Washburn early-time law h^2 = (sigma w cos(theta)/(3 mu)) t
506 # for a slot of width w. Gravity is neglected in that law, so the fit window is the
507 # part of the trace with h <= 0.4 * the Jurin equilibrium (where the hydrostatic term
508 # is <= 40 % of the capillary drive) and h >= 2 cells (below that the meniscus is not
509 # yet formed). The slope of h^2 against t is the measured coefficient.
510 k_lw = sigma * w * math.cos(math.radians(theta_e)) / (3.0 * mu)
511 pts = [(t, v) for _, t, v in trace if 2.0 <= v <= 0.4 * ref]
512 if len(pts) >= 3:
513 tt = np.array([t for t, _ in pts])
514 hh = np.array([v for _, v in pts]) ** 2
515 k = float(np.polyfit(tt, hh, 1)[0])
516 print(f" LUCAS-WASHBURN: d(h^2)/dt measured {k:.4g} vs "
517 f"sigma w cos(theta)/(3 mu) = {k_lw:.4g} "
518 f"({100*(k/k_lw-1):+.1f} %, gate 20 %) "
519 f"{'PASS' if abs(k/k_lw - 1) <= 0.20 else 'FAIL'} "
520 f"[{len(pts)} points, h in {pts[0][1]:.2f}..{pts[-1][1]:.2f} cells]")
521 else:
522 print(f" LUCAS-WASHBURN: only {len(pts)} trace points in the window "
523 f"h in [2, {0.4*ref:.1f}] cells — the rise never got there")
524
525
526# ------------------------------------------------------------------------------------- G4 incline
527def g4_incline(theta_a=70.0, theta_r=50.0, bo_fracs=(0.5, 0.7, 1.5, 2.5), steps=1200, nx=48, nz=32,
528 R=8.0, zw=4.25, sigma=1.0, oh=0.2, slip=0.1):
529 """The wall is kept axis-aligned and GRAVITY is tilted — the same problem, an exact SDF.
530 Bo = drho g V^(2/3) sin(alpha) / sigma against Bo_c = cos(theta_r) - cos(theta_a)
531 (ElSherbini & Jacobi 2006's retention relation in its simplest form)."""
532 mu = oh * math.sqrt(1.0 * sigma * 2.0 * R)
533 V = 2.0 / 3.0 * math.pi * R ** 3
534 bo_c = math.cos(theta_r * DEG) - math.cos(theta_a * DEG)
535 print(f"\n=== G4 HYSTERESIS: a drop on an INCLINE — theta_a {theta_a}, theta_r {theta_r}, "
536 f"lambda {slip} cells, D/dx {2*R:.0f}, grid {nx}x{nx}x{nz}, wall z = {zw}, mu {mu:.4f} "
537 f"(Oh {oh}), {steps} steps")
538 print(f" Bo_c = cos(theta_r) - cos(theta_a) = {bo_c:.4f}; V^(2/3) = {V**(2/3):.2f}")
539 z = (np.arange(nz) + 0.5)[None, None, :]
540 sdf = np.asfortranarray(
541 np.broadcast_to(np.minimum(z - zw, (nz - zw) - z), (nx, nx, nz)).astype(float))
542 c0 = cap_colour(nx, nx, nz, nx * 0.5, nx * 0.5, zw, R, 0.5 * (theta_a + theta_r))
543 for fr in bo_fracs:
544 g_t = fr * bo_c * sigma / V ** (2.0 / 3.0) # the TANGENTIAL body force per unit volume
545 s = pf.Solver(nx, nx, nz)
546 s.set_rho(1.0)
547 s.set_mu(mu)
548 s.set_solid(sdf, cutcell_pressure=True)
549 s.enable_vof()
550 s.set_vof(c0)
551 s.set_surface_tension(sigma)
552 s.set_contact_angle(0.5 * (theta_a + theta_r))
553 s.set_contact_angle_dynamic(True, 0.5 * (theta_a + theta_r), slip, mu, sigma)
554 s.set_contact_angle_hysteresis(theta_a, theta_r)
555 # ZERO-MEAN tangential force: `g_t (C - Cbar)`. A non-zero-mean body force in a fully
556 # periodic box accelerates the whole fluid without bound (WO-Q finding 9), and the
557 # accelerating frame then cancels part of the very force this gate is measuring.
558 cc = s.get_vof()
559 eps = s.diagnostics.vof_geometry(0)
560 cbar = float((cc * eps).sum()) / float(eps.sum())
561 s.set_property_model("force_x", "linear", "C", [-g_t * cbar, g_t])
562 xg = (np.arange(nx) + 0.5)[:, None, None]
563 w0 = float((cc * eps).sum())
564 x0 = float((cc * eps * xg).sum()) / w0
565 dt, maxit, capped, trace, tend = relax(
566 s, steps, probe=max(1, steps // 8),
567 cb=lambda q: float((q.get_vof() * eps * xg).sum()) / max(float((q.get_vof() * eps).sum()), 1e-30))
568 cc1 = s.get_vof()
569 x1 = float((cc1 * eps * xg).sum()) / max(float((cc1 * eps).sum()), 1e-30)
570 cd = s.diagnostics.contact_angle_diagnostics()
571 moved = x1 - x0
572 pin_frac = cd["pinned_cells"] / max(cd["dynamic_cells"], 1)
573 verdict = "PINNED" if abs(moved) < 0.5 else "SLIDING"
574 print(f" Bo/Bo_c {fr:4.2f} (f_t {g_t:.4e}): centroid moved {moved:+7.3f} cells over "
575 f"t = {tend:.1f} -> {verdict} pinned {cd['pinned_cells']}/{cd['dynamic_cells']} "
576 f"({100*pin_frac:.0f} %) advancing {cd['advancing_cells']} receding "
577 f"{cd['receding_cells']} mean imposed {cd['mean_imposed_theta']:.2f} "
578 f"max|Ca_cl| {cd['max_Ca_cl']:.2e} iters {maxit} (capped {capped})")
579 print(" centroid trace: " + " ".join(f"{t:.1f}:{v:.2f}" for _, t, v in trace))
580
581
582# ------------------------------------------------------------------------------------------------
583if __name__ == "__main__":
584 args = [a for a in sys.argv[1:] if not a.startswith("--")]
585 quick = "--quick" in sys.argv
586 which = args or ["jurin"]
587 if "jurin" in which:
588 g3_static(theta=30.0, steps=200 if quick else 600)
589 if not quick:
590 g3_static(theta=60.0, steps=600)
591 g3_static(theta=30.0, steps=1200, flat_start=True)
592 if "jurin_wos" in which:
593 g3_static(theta=30.0, steps=600, wos=True)
594 if "spread" in which:
595 g2_spread(steps=1200 if quick else 2000, probe=50)
596 if "slipsweep" in which:
597 # THE gate on the MODEL rather than on the scheme's own inner cut-off: the Cox-Voinov
598 # slope must move by exactly 9 dln(1/lambda) when lambda changes, whatever the scheme's
599 # residual sub-grid slip adds on top of it.
600 print("\n=== G2b the SLIP SENSITIVITY of the fitted Cox-Voinov slope")
601 res = []
602 for sl in (0.02, 0.1, 0.5):
603 sp, wt, am = g2_spread(slip=sl, steps=800, probe=50)
604 res.append((sl, sp, wt, am))
605 print("\n lambda fitted slope 9 ln(a/lambda) difference")
606 for sl, sp, wt, am in res:
607 print(f" {sl:6.2f} {sp:12.3f} {wt:14.3f} {sp-wt:+10.3f}")
608 for i in range(1, len(res)):
609 d_meas = res[i][1] - res[i - 1][1]
610 d_pred = 9.0 * math.log(res[i - 1][0] / res[i][0])
611 print(f" lambda {res[i-1][0]} -> {res[i][0]}: d(slope) measured {d_meas:+.3f} vs "
612 f"9 dln(1/lambda) = {d_pred:+.3f} ({100*(d_meas/d_pred-1):+.1f} %, gate 25 %) "
613 f"{'PASS' if abs(d_meas/d_pred - 1) <= 0.25 else 'FAIL'}")
614 if "slipsweep_v6b" in which:
615 # WO-V6b gate 3: the SAME sweep with the momentum-side Navier slip ON, so the resolved
616 # hydrodynamics between the cell size and the contact radius can supply the part of
617 # 9 ln(a/lambda) that the band fill alone could not (WO-V6 finding 7).
618 print("\n=== G2b/V6b the SLIP SENSITIVITY with the MOMENTUM Navier closure ON")
619 res = []
620 for sl in (0.02, 0.1, 0.5):
621 sp, wt, am = g2_spread(slip=sl, steps=800, probe=50, wall_slip=True)
622 res.append((sl, sp, wt, am))
623 print("\n lambda fitted slope 9 ln(a/lambda) difference")
624 for sl, sp, wt, am in res:
625 print(f" {sl:6.2f} {sp:12.3f} {wt:14.3f} {sp-wt:+10.3f}")
626 for i in range(1, len(res)):
627 d_meas = res[i][1] - res[i - 1][1]
628 d_pred = 9.0 * math.log(res[i - 1][0] / res[i][0])
629 print(f" lambda {res[i-1][0]} -> {res[i][0]}: d(slope) measured {d_meas:+.3f} vs "
630 f"9 dln(1/lambda) = {d_pred:+.3f} ({100*(d_meas/d_pred-1):+.1f} %, gate 25 %) "
631 f"{'PASS' if abs(d_meas/d_pred - 1) <= 0.25 else 'FAIL'}")
632 if "lw" in which:
633 # WO-V6b gate 4: Lucas-Washburn on the repaired plate scene, momentum slip ON vs OFF.
634 g3_rise(steps=400 if quick else 1200, slips=(0.05, 0.3), probe=20, lw=True,
635 wall_slip=False, static_control=True)
636 g3_rise(steps=400 if quick else 1200, slips=(0.05, 0.3), probe=20, lw=True,
637 wall_slip=True, static_control=False)
638 if "rise" in which:
639 g3_rise(steps=800 if quick else 1500)
640 if "incline" in which:
641 g4_incline(steps=800 if quick else 1500)
g4_incline(theta_a=70.0, theta_r=50.0, bo_fracs=(0.5, 0.7, 1.5, 2.5), steps=1200, nx=48, nz=32, R=8.0, zw=4.25, sigma=1.0, oh=0.2, slip=0.1)
g2_spread(theta_e=30.0, slip=0.1, steps=1500, probe=50, nx=64, nz=40, R=12.0, zw=4.25, sigma=1.0, oh=0.1, dynamic=True, wall_slip=False)
capsule_sdf(nx, ny, nz, xm, r, z0, z1)
cap_colour(nx, ny, nz, cx, cy, zw, R, theta_deg=90.0)
jurin_scene(nx=96, ny=4, nz=112, w=16.0, plate=8.0, zp=(12.0, 100.0))
g3_static(theta=30.0, steps=500, nx=96, ny=4, nz=112, w=16.0, plate=8.0, dg=3.0e-3, sigma=1.0, mu=0.2, ratio=10.0, datum=28.0, zp=(12.0, 100.0), flat_start=False, wos=False)
meniscus_mean(w, theta_deg, dg, sigma=1.0, xlo=None, xhi=None)
meniscus(w, theta_deg, dg, sigma=1.0, n=600)
set_zero_mean_buoyancy(s, dg, ratio, wos=False)
relax(s, steps, cfl=0.15, dt_cap=None, probe=0, cb=None, ramp=0.02)
cap_sphere(volume, theta_deg)
jurin_colour(nx, ny, nz, subsdf, zi_of_x, sub=4)
g3_rise(theta_e=30.0, slips=(0.05, 0.3), steps=1500, nx=96, ny=4, nz=112, w=16.0, plate=8.0, dg=3.0e-3, sigma=1.0, mu=0.2, ratio=10.0, datum=28.0, zp=(12.0, 100.0), static_control=True, wall_slip=False, probe=None, lw=False)
jurin(w, theta_deg, dg, sigma=1.0)