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))
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).
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}")
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))
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)
133 s.set_solid(sdf, cutcell_pressure=
True)
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])
139 s.set_surface_tension(sigma)
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()
149 eps = s.diagnostics.vof_geometry(0)
151 h = float((cc[ix, iy, :] * eps[ix, iy, :]).sum())
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)
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'}")
176def g1_domain(thetas=(60.0, 90.0, 120.0), ratio=1.0, steps=500, R=12.0, nx=64, nz=40,
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.
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}, "
191 c0 =
cap_colour(nx, nx, nz, nx * 0.5, nx * 0.5, 0.0, R, th)
192 s = pf.Solver(nx, nx, nz)
195 s.set_domain_bc(
"-z",
"wall")
196 s.set_domain_bc(
"+z",
"wall")
197 s.set_pressure_geometry(np.full((nx, nx, nz), 1e30, order=
"F"))
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])
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()
210 eps = s.diagnostics.vof_geometry(0)
212 h = float((cc[ix, iy, :] * eps[ix, iy, :]).sum())
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))
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")
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)
330 s = pf.Solver(n, n, n)
333 s.set_solid(sdf, cutcell_pressure=
True)
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()
343 eps = s.diagnostics.vof_geometry(0)
345 col = cc[ix, iy, :] * eps[ix, iy, :]
346 H = float(col[int(cz):].sum()) + Rs
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'}")
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)
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)
396 s.set_solid(sdf, cutcell_pressure=
True)
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])
403 s.set_property_model(
"force_z",
"linear",
"rho", [drho_g / (ratio - 1.0), -drho_g / (1.0 - 1.0 / ratio)])
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))
410 eps = s.diagnostics.vof_geometry(0)
411 zc = np.arange(nz) + 0.5
414 col = (cc[xs, :, :] * eps[xs, :, :]).sum(axis=(0, 1))
415 tot = eps[xs, :, :].sum(axis=(0, 1))
417 f = np.where(tot > 0, col / np.maximum(tot, 1e-30), 0.0)
418 return float(f.sum())
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'}")
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)
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)