flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_wetting.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-S (rung V5b) — the static contact angle on SDF solids: the gate battery, in Python.
3
4Every gate prints its numbers; nothing is summarised into an adjective.
5
6 G1 DROP ON A FLAT SDF WALL. A liquid cap on a flat wall at a HALF-INTEGER z (so the wall cells
7 are genuinely cut) with surface tension and a prescribed theta, run to rest. The equilibrium
8 of a wetting condition is a spherical cap, so the apparent angle is read off the cap
9 relations: h from the colour column on the axis (weighted by the cell FLUID fraction, since C
10 is a fraction of that), the conserved liquid volume V from `vof_diagnostics()['volume']`,
11 then a = sqrt((6V/(pi h) - h^2)/3) and theta = 2 atan(h/a). Reading `a` off the first fluid
12 plane instead biases it (that plane's colour is the mean over z in [z_wall, z_wall+eps], not
13 the section AT the wall), and the bias grows as the cap flattens — both are printed.
14 Reported alongside: the near-wall spurious Ca in the OPEN fluid and the raw max|u| including
15 the wall band (WO-Q finding 8's open question), the band census, the volume drift and the
16 pressure iteration count against its cap.
17
18 G2 DROP ON AN SDF SPHERE. A liquid drop resting on a solid sphere of radius Rs. The equilibrium
19 free surface is a spherical cap of radius Rc whose sphere meets the solid sphere at theta;
20 the law of cosines gives the centre distance d^2 = Rs^2 + Rc^2 - 2 Rs Rc cos(theta) and the
21 liquid volume is (4/3) pi Rc^3 minus the two-sphere lens, so (V, theta) -> Rc by a 1-D root
22 solve. The run is measured the other way round: (V, apex height H = d + Rc) -> (Rc, d) ->
23 theta.
24
25 G4 CAPILLARY RISE / JURIN. Two SDF plates a gap `w` apart in a periodic box whose OUTER channel
26 has width `w_out`, with a liquid bath below them and gravity. Both channels rise, so the
27 measured level difference is Jurin's `2 sigma cos(theta) / (drho g) * (1/w - 1/w_out)`.
28
29Usage:
30 PYTHONPATH=$PWD/build_cuda python tests/study/vof_wetting.py [g1 g2 g4 ...]
31 PYTHONPATH=$PWD/build_cuda python tests/study/vof_wetting.py g1 --quick
32"""
33import math
34import sys
35
36import numpy as np
37
38import peclet.flow as pf
39
40PRESS_MAXIT = 300
41DEG = math.pi / 180.0
42
43
44# --------------------------------------------------------------------------------------- helpers
45def cap_sphere(volume, theta_deg):
46 """The sphere (radius Rc, centre offset below the wall) whose spherical cap of contact angle
47 `theta` above the wall has the given volume: h = Rc(1 - cos t), a = Rc sin t, and
48 V = pi Rc^3 (1-cos t)^2 (2 + cos t)/3."""
49 ct = math.cos(theta_deg * DEG)
50 Rc = (3.0 * volume / (math.pi * (1.0 - ct) ** 2 * (2.0 + ct))) ** (1.0 / 3.0)
51 return Rc, -Rc * ct # (radius, centre z relative to the wall)
52
53
54def cap_colour(nx, ny, nz, cx, cy, zw, R, theta_deg=90.0):
55 """A liquid spherical cap of the same VOLUME as a hemisphere of radius R, resting on the wall
56 z = zw with contact angle `theta`, by 4^3 subsampling of the FLUID part of each cell (C is the
57 liquid fraction of the fluid volume, `VOF_PLAN` rule 2)."""
58 Rc, zc = cap_sphere(2.0 / 3.0 * math.pi * R ** 3, theta_deg)
59 sub = (np.arange(4) + 0.5) / 4.0
60 px = np.arange(nx)[:, None, None, None, None, None] + sub[None, None, None, :, None, None]
61 py = np.arange(ny)[None, :, None, None, None, None] + sub[None, None, None, None, :, None]
62 pz = np.arange(nz)[None, None, :, None, None, None] + sub[None, None, None, None, None, :]
63 inside = ((px - cx) ** 2 + (py - cy) ** 2 + (pz - zw - zc) ** 2) < Rc * Rc
64 fluid = np.broadcast_to(pz >= zw, inside.shape)
65 tot = fluid.sum(axis=(3, 4, 5))
66 ins = (fluid & inside).sum(axis=(3, 4, 5))
67 return np.asfortranarray(np.where(tot > 0, ins / np.maximum(tot, 1), 0.0).astype(float))
68
69
70def max_u(s, zlo=None):
71 m = 0.0
72 for f in (s.get_u, s.get_v, s.get_w):
73 v = np.abs(f())
74 m = max(m, float(v.max()) if zlo is None else float(v[:, :, zlo:].max()))
75 return m
76
77
78def relax(s, steps, cfl=0.15, dt_cap=None, probe=0):
79 """Run `steps` steps with a dt kept under the WY interface CFL and the capillary limit."""
80 dt_cap = dt_cap if dt_cap is not None else 0.5 * s.capillary_dt()
81 dt = dt_cap
82 s.set_dt(dt)
83 maxit, capped = 0, 0
84 trace = []
85 for i in range(steps):
86 c = s.vof_max_courant()
87 if c > cfl:
88 dt = min(dt_cap, dt * cfl / c)
89 s.set_dt(dt)
90 elif dt < dt_cap and c < 0.5 * cfl:
91 dt = min(dt_cap, 1.2 * dt)
92 s.set_dt(dt)
93 s.step()
94 it = s.diagnostics.last_pressure_iterations()
95 maxit = max(maxit, it)
96 capped += 1 if it >= PRESS_MAXIT else 0
97 if probe and (i + 1) % probe == 0:
98 trace.append((i + 1, max_u(s)))
99 return dt, maxit, capped, trace
100
101
102# ------------------------------------------------------------------------------------------- G1
103def g1(thetas=(30.0, 60.0, 90.0, 120.0, 150.0), ratio=1.0, steps=600, R=12.0, nx=64, nz=40,
104 zw=4.25, sigma=1.0, oh=0.1, pivot=0, init_offset=0.0, init_abs=None):
105 """`init_offset`: the initial cap is at `theta_set + init_offset` (clamped to [10,170]) so the
106 run has to MOVE the contact line to the prescribed angle; `init_abs` overrides it with a fixed
107 initial angle (90 = the hemisphere, the largest excursion).
108
109 `zw` is a QUARTER-integer, not the half-integer the work order asks for, and that is a measured
110 correction rather than a convenience: at exactly k + 1/2 the tangential MAC faces of the
111 wall-adjacent cell sit ON the SDF zero level, `buildOpenness` (sdf > 0 is fluid) closes them,
112 and the cell is tangentially isolated — the contact line then cannot move at all and the
113 unrelieved Young force appears as a large velocity on those closed-face DOFs. At k + 1/4 the
114 wall cell is still genuinely CUT (eps = 3/4, face openness 3/4) and the contact line is mobile.
115 See the WO-S findings and `g1_wall_placement`."""
116 mu = oh * math.sqrt(1.0 * sigma * 2.0 * R)
117 print(f"\n=== G1 drop on a FLAT SDF wall — D/dx {2*R:.0f}, grid {nx}x{nx}x{nz}, wall z = {zw}, "
118 f"sigma {sigma}, mu {mu:.4f} (Oh {oh}), rho ratio {ratio:g}, {steps} steps, pivot {pivot}, "
119 f"init {'theta_set%+.0f' % init_offset if init_abs is None else '%.0f deg' % init_abs}")
120 # A SLIT, not a single wall: the box is periodic, so a lone ramp SDF would make the wrap plane
121 # a spurious fluid/solid seam three cells from the band. With a second wall at nz - zw the wrap
122 # sits deep inside the solid and the SDF is continuous across it.
123 z = (np.arange(nz) + 0.5)[None, None, :]
124 sdf = np.asfortranarray(
125 np.broadcast_to(np.minimum(z - zw, (nz - zw) - z), (nx, nx, nz)).astype(float))
126 rows = []
127 for th in thetas:
128 th0 = init_abs if init_abs is not None else min(170.0, max(10.0, th + init_offset))
129 c0 = cap_colour(nx, nx, nz, nx * 0.5, nx * 0.5, zw, R, th0)
130 s = pf.Solver(nx, nx, nz)
131 s.set_rho(1.0)
132 s.set_mu(mu)
133 s.set_solid(sdf, cutcell_pressure=True)
134 s.enable_vof()
135 if ratio != 1.0:
136 s.set_property_model("rho", "linear", "C", [1.0 / ratio, 1.0 - 1.0 / ratio])
137 s.set_property_model("mu", "linear", "C", [mu / ratio, mu - mu / ratio])
138 s.set_vof(c0)
139 s.set_surface_tension(sigma)
140 if pivot:
141 s.diagnostics.set_contact_angle_pivot(
142 ("volume", "centroid", "projected-centroid", "contact-line")[pivot])
143 s.set_contact_angle(th)
144 v0 = s.diagnostics.vof_diagnostics()["volume"]
145 dt, maxit, capped, _ = relax(s, steps)
146 d = s.diagnostics.vof_diagnostics()
147 cd = s.diagnostics.contact_angle_diagnostics()
148 cc = s.get_vof()
149 eps = s.diagnostics.vof_geometry(0)
150 ix = iy = nx // 2
151 h = float((cc[ix, iy, :] * eps[ix, iy, :]).sum())
152 V = d["volume"]
153 a = math.sqrt(max((6.0 * V / (math.pi * h) - h * h) / 3.0, 1e-12))
154 theta = 2.0 * math.degrees(math.atan2(h, a))
155 z0 = int(math.ceil(zw))
156 a_dir = math.sqrt(float(cc[:, :, z0].sum()) / math.pi)
157 th_dir = 2.0 * math.degrees(math.atan2(h, a_dir))
158 um_open = max_u(s, z0 + 1)
159 um_all = max_u(s)
160 print(f" theta_set {th:5.1f} (init {th0:5.1f}) -> theta {theta:7.3f} (err {theta-th:+6.3f}) "
161 f"h {h:7.3f} a {a:7.3f} | contour a {a_dir:7.3f} -> theta {th_dir:7.3f} | "
162 f"dV/V {abs(V-v0)/v0:.2e} Ca(open) {mu*um_open/sigma:.3e} max|u| all {um_all:.3e} | "
163 f"band th/nbr/pure/par/neu/unf {cd['contact_cells']}/{cd['neighbour_cells']}/"
164 f"{cd['pure_cells']}/{cd['parallel_cells']}/{cd['neutral_cells']}/{cd['unfilled_cells']}"
165 f" apparent {cd['mean_apparent_angle']:6.2f}"
166 f" | iters {maxit} (capped {capped}) dt {dt:.4f} solid sum {d['solid_sum']:.1e}")
167 rows.append((th, theta, abs(V - v0) / v0, mu * um_open / sigma, um_all, maxit, capped))
168 worst = max(abs(t - s_) for s_, t, *_ in rows)
169 print(f" G1 worst |theta - theta_set| = {worst:.3f} deg (gate 3.0) "
170 f"{'PASS' if worst <= 3.0 else 'FAIL'}")
171 return rows
172
173
174
175# ---------------------------------------------------- G1D: the same drop on a DOMAIN-BC wall
176def g1_domain(thetas=(60.0, 90.0, 120.0), ratio=1.0, steps=500, R=12.0, nx=64, nz=40,
177 sigma=1.0, oh=0.1):
178 """ISSUES sweep item 3. The G1 scene with the wall as a DOMAIN FACE (bc type 1) instead of an
179 SDF slab: the wall plane is z = 0 exactly, the wall-adjacent cell is WHOLE (no cut cell at
180 all), and the theta band is the colour block's three ghost layers below z = 0.
181
182 Reference: `g1` on the same drop against an SDF wall at a quarter-integer z. The two are
183 different discretisations of the same physics -- one cuts the wall cell, the other does not --
184 so the gate is agreement, not identity."""
185 mu = oh * math.sqrt(1.0 * sigma * 2.0 * R)
186 print(f"\n=== G1D drop on a DOMAIN-BC wall (bc type 1 at z = 0) — D/dx {2*R:.0f}, grid "
187 f"{nx}x{nx}x{nz}, sigma {sigma}, mu {mu:.4f} (Oh {oh}), rho ratio {ratio:g}, "
188 f"{steps} steps")
189 rows = []
190 for th in thetas:
191 c0 = cap_colour(nx, nx, nz, nx * 0.5, nx * 0.5, 0.0, R, th)
192 s = pf.Solver(nx, nx, nz)
193 s.set_rho(1.0)
194 s.set_mu(mu)
195 s.set_domain_bc("-z", "wall") # -z: the wetting wall
196 s.set_domain_bc("+z", "wall") # +z: a lid, far from the drop
197 s.set_pressure_geometry(np.full((nx, nx, nz), 1e30, order="F"))
198 s.enable_vof()
199 if ratio != 1.0:
200 s.set_property_model("rho", "linear", "C", [1.0 / ratio, 1.0 - 1.0 / ratio])
201 s.set_property_model("mu", "linear", "C", [mu / ratio, mu - mu / ratio])
202 s.set_vof(c0)
203 s.set_surface_tension(sigma)
204 s.set_contact_angle(th)
205 v0 = s.diagnostics.vof_diagnostics()["volume"]
206 dt, maxit, capped, _ = relax(s, steps)
207 d = s.diagnostics.vof_diagnostics()
208 cd = s.diagnostics.contact_angle_diagnostics()
209 cc = s.get_vof()
210 eps = s.diagnostics.vof_geometry(0)
211 ix = iy = nx // 2
212 h = float((cc[ix, iy, :] * eps[ix, iy, :]).sum())
213 V = d["volume"]
214 a = math.sqrt(max((6.0 * V / (math.pi * h) - h * h) / 3.0, 1e-12))
215 theta = 2.0 * math.degrees(math.atan2(h, a))
216 a_dir = math.sqrt(float(cc[:, :, 0].sum()) / math.pi)
217 th_dir = 2.0 * math.degrees(math.atan2(h, a_dir))
218 print(f" theta_set {th:5.1f} -> theta {theta:7.3f} (err {theta-th:+6.3f}) "
219 f"h {h:7.3f} a {a:7.3f} | contour a {a_dir:7.3f} -> theta {th_dir:7.3f} | "
220 f"dV/V {abs(V-v0)/v0:.2e} max|u| {max_u(s):.3e} | "
221 f"band th/nbr/pure/par/neu/unf {cd['contact_cells']}/{cd['neighbour_cells']}/"
222 f"{cd['pure_cells']}/{cd['parallel_cells']}/{cd['neutral_cells']}/"
223 f"{cd['unfilled_cells']} apparent {cd['mean_apparent_angle']:6.2f}"
224 f" | iters {maxit} (capped {capped}) dt {dt:.4f}")
225 rows.append((th, theta))
226 return rows
227
228
229def g1_domain_vs_sdf(thetas=(60.0, 90.0, 120.0), steps=500, gate=1.0):
230 """The ISSUES-sweep item-3 gate: the domain-wall equilibrium against the SDF-wall one."""
231 ref = {t: v for t, v in ((r[0], r[1]) for r in g1(thetas=thetas, steps=steps))}
232 dom = {t: v for t, v in g1_domain(thetas=thetas, steps=steps)}
233 print(f"\n --- item 3 gate: DOMAIN wall vs SDF wall, {steps} steps")
234 worst = 0.0
235 for t in thetas:
236 d = dom[t] - ref[t]
237 worst = max(worst, abs(d))
238 print(f" theta_set {t:5.1f}: SDF {ref[t]:7.3f} DOMAIN {dom[t]:7.3f} "
239 f"difference {d:+6.3f} deg")
240 print(f" worst |domain - SDF| = {worst:.3f} deg (gate {gate}) "
241 f"{'PASS' if worst <= gate else 'FAIL'}")
242 return worst
243
244
245# --------------------------------------------------------------------- the wall-placement sweep
246def g1_wall_placement(theta=60.0, steps=1200, fracs=(0.0, 0.25, 0.5, 0.75)):
247 """WHERE the SDF wall sits inside the cell decides whether the contact line can move at all.
248 One theta, four wall placements, everything else identical; the run starts from a hemisphere so
249 the contact line HAS to travel."""
250 print(f"\n=== G1w wall placement — theta {theta}, initial cap 90 deg, {steps} steps")
251 for f in fracs:
252 rows = g1(thetas=(theta,), steps=steps, zw=4.0 + f, init_abs=90.0)
253 print(f" ^ wall at z = {4.0 + f}")
254
255
256# ------------------------------------------------------------------------------------------- G2
257def lens_volume(Rs, Rc, d):
258 """Volume of the intersection of spheres (0, Rs) and (d, Rc)."""
259 if d >= Rs + Rc:
260 return 0.0
261 if d <= abs(Rs - Rc):
262 return 4.0 / 3.0 * math.pi * min(Rs, Rc) ** 3
263 return (math.pi * (Rs + Rc - d) ** 2
264 * (d * d + 2 * d * Rc - 3 * Rc * Rc + 2 * d * Rs + 6 * Rc * Rs - 3 * Rs * Rs)
265 / (12.0 * d))
266
267
268def drop_volume(Rs, Rc, d):
269 return 4.0 / 3.0 * math.pi * Rc ** 3 - lens_volume(Rs, Rc, d)
270
271
272def cap_from_volume(Rs, V, theta_deg):
273 """(V, theta) -> the cap sphere radius Rc and centre distance d (the reference shape)."""
274 ct = math.cos(theta_deg * DEG)
275 lo, hi = 1e-3, 50.0 * Rs
276 for _ in range(200):
277 mid = 0.5 * (lo + hi)
278 d = math.sqrt(max(Rs * Rs + mid * mid - 2 * Rs * mid * ct, 0.0))
279 if drop_volume(Rs, mid, d) < V:
280 lo = mid
281 else:
282 hi = mid
283 Rc = 0.5 * (lo + hi)
284 return Rc, math.sqrt(max(Rs * Rs + Rc * Rc - 2 * Rs * Rc * ct, 0.0))
285
286
288 """(V, apex height H = d + Rc) -> (Rc, d, theta)."""
289 lo, hi = 1e-3, H
290 for _ in range(200):
291 Rc = 0.5 * (lo + hi)
292 d = H - Rc
293 if drop_volume(Rs, Rc, max(d, 0.0)) < V:
294 lo = Rc
295 else:
296 hi = Rc
297 Rc = 0.5 * (lo + hi)
298 d = H - Rc
299 ct = (Rs * Rs - d * d + Rc * Rc) / (2.0 * Rs * Rc)
300 return Rc, d, math.degrees(math.acos(max(-1.0, min(1.0, ct))))
301
302
303def g2(thetas=(60.0, 90.0, 120.0), steps=600, Rs=12.0, Rd=8.0, n=64, sigma=1.0, oh=0.1):
304 mu = oh * math.sqrt(1.0 * sigma * 2.0 * Rd)
305 cx = cy = cz = n * 0.5
306 ax = (np.arange(n) + 0.5)[:, None, None]
307 ay = (np.arange(n) + 0.5)[None, :, None]
308 az = (np.arange(n) + 0.5)[None, None, :]
309 sdf = np.asfortranarray(np.sqrt((ax - cx) ** 2 + (ay - cy) ** 2 + (az - cz) ** 2) - Rs)
310 V = 4.0 / 3.0 * math.pi * Rd ** 3
311 print(f"\n=== G2 drop on an SDF SPHERE — Rs {Rs}, drop volume of a sphere R {Rd} (V {V:.2f}), "
312 f"grid {n}^3, sigma {sigma}, mu {mu:.4f}, {steps} steps")
313 rows = []
314 for th in thetas:
315 Rc0, d0 = cap_from_volume(Rs, V, th)
316 # initial condition: the reference cap itself (the gate is that it STAYS, and that a
317 # different theta relaxes to its own cap — the 90-degree row starts off-equilibrium too
318 # because the fill is a plane, not a sphere)
319 sub = (np.arange(4) + 0.5) / 4.0
320 px = np.arange(n)[:, None, None, None, None, None] + sub[None, None, None, :, None, None]
321 py = np.arange(n)[None, :, None, None, None, None] + sub[None, None, None, None, :, None]
322 pz = np.arange(n)[None, None, :, None, None, None] + sub[None, None, None, None, None, :]
323 rs2 = (px - cx) ** 2 + (py - cy) ** 2 + (pz - cz) ** 2
324 rc2 = (px - cx) ** 2 + (py - cy) ** 2 + (pz - (cz + d0)) ** 2
325 fluid = rs2 > Rs * Rs
326 liq = fluid & (rc2 < Rc0 * Rc0)
327 tot = fluid.sum(axis=(3, 4, 5))
328 c0 = np.asfortranarray(np.where(tot > 0, liq.sum(axis=(3, 4, 5)) / np.maximum(tot, 1), 0.0)
329 .astype(float))
330 s = pf.Solver(n, n, n)
331 s.set_rho(1.0)
332 s.set_mu(mu)
333 s.set_solid(sdf, cutcell_pressure=True)
334 s.enable_vof()
335 s.set_vof(c0)
336 s.set_surface_tension(sigma)
337 s.set_contact_angle(th)
338 v0 = s.diagnostics.vof_diagnostics()["volume"]
339 dt, maxit, capped, _ = relax(s, steps)
340 d = s.diagnostics.vof_diagnostics()
341 cd = s.diagnostics.contact_angle_diagnostics()
342 cc = s.get_vof()
343 eps = s.diagnostics.vof_geometry(0)
344 ix = iy = int(cx)
345 col = cc[ix, iy, :] * eps[ix, iy, :]
346 H = float(col[int(cz):].sum()) + Rs # apex height above the solid centre
347 Vm = d["volume"]
348 Rc, dd, theta = theta_from_volume_apex(Rs, Vm, H)
349 print(f" theta_set {th:5.1f} -> theta {theta:7.3f} (err {theta-th:+6.3f}) "
350 f"Rc {Rc:6.3f} vs ref {Rc0:6.3f} ({100*(Rc-Rc0)/Rc0:+5.2f} %) H {H:6.3f} "
351 f"(ref {d0+Rc0:6.3f}) | dV/V {abs(Vm-v0)/v0:.2e} "
352 f"Ca {mu*max_u(s)/sigma:.3e} | band th/nbr/pure/par/neu {cd['contact_cells']}/"
353 f"{cd['pure_cells']}/{cd['parallel_cells']}/{cd['neutral_cells']} "
354 f"apparent {cd['mean_apparent_angle']:6.2f} | iters {maxit} (capped {capped}) "
355 f"solid sum {d['solid_sum']:.1e}")
356 rows.append((th, theta, Rc, Rc0))
357 worst = max(abs(t - s_) for s_, t, *_ in rows)
358 worstR = max(abs(rc - r0) / r0 for *_, rc, r0 in rows)
359 print(f" G2 worst |theta - theta_set| = {worst:.3f} deg (gate 3.0), worst cap-radius error "
360 f"{100*worstR:.2f} % (gate 3 %) {'PASS' if worst <= 3.0 and worstR <= 0.03 else 'FAIL'}")
361 return rows
362
363
364# ------------------------------------------------------------------------------------------- G4
365def box_sdf(ax, ay, az, xlo, xhi, zlo, zhi):
366 qx = np.maximum(xlo - ax, ax - xhi)
367 qz = np.maximum(zlo - az, az - zhi)
368 out = np.sqrt(np.maximum(qx, 0.0) ** 2 + np.maximum(qz, 0.0) ** 2)
369 return out + np.minimum(np.maximum(qx, qz), 0.0)
370
371
372def g4(theta=30.0, steps=4000, nx=80, ny=4, nz=96, gap=24, plate=4, sigma=1.0, ratio=10.0,
373 drho_g=2.4e-3, zbath=40.0, zplate=(16.0, 88.0), mu=0.2):
374 """Jurin. Plates of thickness `plate` bounding a gap `gap` wide; the periodic OUTER channel is
375 `nx - gap - 2*plate` wide. Both menisci rise, so the reference is the DIFFERENCE."""
376 x1 = 0.5 * (nx - gap) - plate
377 x2 = 0.5 * (nx + gap)
378 w_out = nx - gap - 2 * plate
379 ax = (np.arange(nx) + 0.5)[:, None, None]
380 az = (np.arange(nz) + 0.5)[None, None, :]
381 d1 = box_sdf(ax, None, az, x1, x1 + plate, zplate[0], zplate[1])
382 d2 = box_sdf(ax, None, az, x2, x2 + plate, zplate[0], zplate[1])
383 sdf = np.asfortranarray(np.broadcast_to(np.minimum(d1, d2), (nx, ny, nz)).astype(float))
384 c0 = np.asfortranarray(np.broadcast_to(
385 np.clip(zbath - (np.arange(nz) + 0.5), 0.0, 1.0)[None, None, :], (nx, ny, nz)).astype(float))
386 h_pred = 2.0 * sigma * math.cos(theta * DEG) / drho_g * (1.0 / gap - 1.0 / w_out)
387 bo = drho_g * gap * gap / sigma
388 print(f"\n=== G4 Jurin — gap {gap}, outer channel {w_out}, plates x [{x1},{x1+plate}] and "
389 f"[{x2},{x2+plate}], z [{zplate[0]},{zplate[1]}], grid {nx}x{ny}x{nz}, theta {theta}, "
390 f"sigma {sigma}, drho*g {drho_g:g}, Bond {bo:.3f}, ratio {ratio:g}\n"
391 f" predicted level DIFFERENCE 2 sigma cos(theta)/(drho g) (1/w - 1/w_out) = "
392 f"{h_pred:.3f} cells")
393 s = pf.Solver(nx, ny, nz)
394 s.set_rho(1.0)
395 s.set_mu(mu)
396 s.set_solid(sdf, cutcell_pressure=True)
397 s.enable_vof()
398 s.set_property_model("rho", "linear", "C", [1.0 / ratio, 1.0 - 1.0 / ratio])
399 s.set_property_model("mu", "linear", "C", [mu / ratio, mu - mu / ratio])
400 # ZERO-MEAN buoyancy: in a fully periodic box a non-zero-mean force accelerates the whole fluid
401 # without bound (WO-Q finding 9). Jurin depends only on drho, so subtracting a constant from the
402 # force is exact for this problem.
403 s.set_property_model("force_z", "linear", "rho", [drho_g / (ratio - 1.0), -drho_g / (1.0 - 1.0 / ratio)])
404 s.set_vof(c0)
405 s.set_surface_tension(sigma)
406 s.set_contact_angle(theta)
407 v0 = s.diagnostics.vof_diagnostics()["volume"]
408 dt, maxit, capped, trace = relax(s, steps, probe=max(1, steps // 8))
409 cc = s.get_vof()
410 eps = s.diagnostics.vof_geometry(0)
411 zc = np.arange(nz) + 0.5
412
413 def level(xs):
414 col = (cc[xs, :, :] * eps[xs, :, :]).sum(axis=(0, 1))
415 tot = eps[xs, :, :].sum(axis=(0, 1))
416 # the level is the integral of the liquid area fraction: sum over z of (liquid/fluid area)
417 f = np.where(tot > 0, col / np.maximum(tot, 1e-30), 0.0)
418 return float(f.sum())
419
420 xin = np.arange(int(x1 + plate) + 2, int(x2) - 1)
421 xout = np.concatenate([np.arange(0, int(x1) - 1), np.arange(int(x2 + plate) + 2, nx)])
422 lin, lout = level(xin), level(xout)
423 d = s.diagnostics.vof_diagnostics()
424 print(f" inner level {lin:.3f} outer level {lout:.3f} difference {lin-lout:.3f} cells "
425 f"vs Jurin {h_pred:.3f} ({100*((lin-lout)-h_pred)/h_pred:+.2f} %)")
426 print(f" dV/V {abs(d['volume']-v0)/v0:.3e} max|u| {max_u(s):.3e} iters {maxit} "
427 f"(capped {capped}) dt {dt:.4f} solid sum {d['solid_sum']:.1e}")
428 print(" max|u| trace: " + " ".join(f"{n}:{u:.2e}" for n, u in trace))
429 err = abs((lin - lout) - h_pred) / abs(h_pred)
430 print(f" G4 relative error {100*err:.2f} % (gate 5 %) {'PASS' if err <= 0.05 else 'FAIL'}")
431
432
433# ------------------------------------------------------------------------------------------------
434if __name__ == "__main__":
435 args = [a for a in sys.argv[1:] if not a.startswith("--")]
436 quick = "--quick" in sys.argv
437 which = args or ["g1", "g2"]
438 if "g1" in which:
439 # (a) the FIXED POINT: start at the prescribed angle; the equilibrium must stay there.
440 g1(steps=200 if quick else 500)
441 if not quick:
442 g1(ratio=100.0, steps=500)
443 if "g1b" in which:
444 # (b) ATTRACTION: start from a hemisphere and let the contact line travel to the prescribed
445 # angle. Slow — the driving vanishes as the angle approaches its target — so only the
446 # two mid angles are run to convergence.
447 g1(thetas=(60.0, 120.0), steps=1000 if quick else 3000, init_abs=90.0)
448 if "g1d" in which:
449 g1_domain_vs_sdf(steps=200 if quick else 500)
450 if "g1w" in which:
451 g1_wall_placement(steps=400 if quick else 1200)
452 if "g2" in which:
453 g2(steps=200 if quick else 600)
454 if "g4" in which:
455 g4(steps=800 if quick else 4000)
g1_domain_vs_sdf(thetas=(60.0, 90.0, 120.0), steps=500, gate=1.0)
drop_volume(Rs, Rc, d)
cap_from_volume(Rs, V, theta_deg)
relax(s, steps, cfl=0.15, dt_cap=None, probe=0)
g2(thetas=(60.0, 90.0, 120.0), steps=600, Rs=12.0, Rd=8.0, n=64, sigma=1.0, oh=0.1)
theta_from_volume_apex(Rs, V, H)
g1(thetas=(30.0, 60.0, 90.0, 120.0, 150.0), ratio=1.0, steps=600, R=12.0, nx=64, nz=40, zw=4.25, sigma=1.0, oh=0.1, pivot=0, init_offset=0.0, init_abs=None)
g1_wall_placement(theta=60.0, steps=1200, fracs=(0.0, 0.25, 0.5, 0.75))
box_sdf(ax, ay, az, xlo, xhi, zlo, zhi)
g4(theta=30.0, steps=4000, nx=80, ny=4, nz=96, gap=24, plate=4, sigma=1.0, ratio=10.0, drho_g=2.4e-3, zbath=40.0, zplate=(16.0, 88.0), mu=0.2)
max_u(s, zlo=None)
cap_sphere(volume, theta_deg)
cap_colour(nx, ny, nz, cx, cy, zw, R, theta_deg=90.0)
g1_domain(thetas=(60.0, 90.0, 120.0), ratio=1.0, steps=500, R=12.0, nx=64, nz=40, sigma=1.0, oh=0.1)
lens_volume(Rs, Rc, d)