flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_issues_sweep.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Gates for the ISSUES sweep (the solver-side entries examples/*/ISSUES.md logged).
3
4One sub-command per ISSUES entry; every one prints its measured numbers and a PASS/FAIL line.
5
6 PYTHONPATH=$PWD/build_cuda python tests/study/vof_issues_sweep.py atomic
7 PYTHONPATH=$PWD/build_cuda python tests/study/vof_issues_sweep.py adaptive
8 PYTHONPATH=$PWD/build_cuda python tests/study/vof_issues_sweep.py geometry
9 PYTHONPATH=$PWD/build_cuda python tests/study/vof_issues_sweep.py collocated
10 PYTHONPATH=$PWD/build_cuda python tests/study/vof_issues_sweep.py freeslip
11 PYTHONPATH=$PWD/build_cuda python tests/study/vof_issues_sweep.py wetwall
12"""
13import argparse
14import math
15import sys
16
17import numpy as np
18
19import peclet.flow as pf
20
21FAIL = []
22
23
24def check(name, ok, detail=""):
25 print(f" [{'PASS' if ok else 'FAIL'}] {name} {detail}")
26 if not ok:
27 FAIL.append(name)
28
29
30def state(s):
31 return dict(u=s.get_u().copy(), v=s.get_v().copy(), w=s.get_w().copy(),
32 C=s.get_vof().copy(), p=np.asarray(s.get_p()).copy())
33
34
35def same(a, b):
36 return all(np.array_equal(a[k], b[k]) for k in a)
37
38
39def diffs(a, b):
40 return {k: float(np.abs(a[k] - b[k]).max()) for k in a}
41
42
43# ------------------------------------------------------------------ item 1: step() atomicity
44def scene_cfl(n=16):
45 """The ISSUES reproducer: a half-filled box, uniform u = 1, body force in z."""
46 s = pf.Solver(n, n, n)
47 s.set_rho(1.0)
48 s.set_mu(0.01)
49 s.set_dt(0.1)
50 s.set_pressure_geometry(np.full((n, n, n), 1e30, order="F"))
51 s.set_body_force((0.0, 0.0, 1e-3))
52 s.enable_vof()
53 C = np.zeros((n, n, n), order="F")
54 C[:, :, : n // 2] = 1.0
55 s.set_vof(C)
56 u = np.zeros((n, n, n), order="F")
57 u[:] = 1.0
58 s.set_field("u", u)
59 for _ in range(3):
60 s.step()
61 return s
62
63
65 print("ISSUES item 1 -- step() atomic across the Weymouth-Yue boundedness throw")
66 s = scene_cfl()
67 s0 = state(s)
68 s.set_dt(0.4) # CFL 0.4 > the 0.25 cap
69 threw = ""
70 try:
71 s.step()
72 except RuntimeError as e:
73 threw = str(e).split("\n")[0]
74 print(f" WY throw: {threw[:110]}")
75 d = diffs(s0, state(s))
76 check("WY: step() threw", bool(threw), threw[:60])
77 check("WY: state bitwise unchanged", same(s0, state(s)), str(d))
78 # retry at the old dt must run
79 s.set_dt(0.1)
80 s.step()
81 check("WY: retry at a smaller dt runs", True)
82
83 # --- the capillary cap
84 n = 16
85 s = pf.Solver(n, n, n)
86 s.set_rho(1.0)
87 s.set_mu(0.05)
88 s.set_pressure_geometry(np.full((n, n, n), 1e30, order="F"))
89 s.set_body_force((0.0, 0.0, 1e-3))
90 s.enable_vof()
91 x, y, z = np.meshgrid(*[np.arange(n) + 0.5] * 3, indexing="ij")
92 r = np.sqrt((x - n / 2) ** 2 + (y - n / 2) ** 2 + (z - n / 2) ** 2)
93 s.set_vof(np.asfortranarray(np.clip(4.0 - r, 0.0, 1.0)))
94 s.set_surface_tension(1.0)
95 s.set_dt(0.2 * s.capillary_dt())
96 for _ in range(3):
97 s.step()
98 s0 = state(s)
99 s.set_dt(10.0 * s.capillary_dt())
100 threw = ""
101 try:
102 s.step()
103 except RuntimeError as e:
104 threw = str(e).split("\n")[0]
105 print(f" capillary throw: {threw[:110]}")
106 d = diffs(s0, state(s))
107 check("sigma: step() threw", bool(threw), threw[:60])
108 check("sigma: state bitwise unchanged", same(s0, state(s)), str(d))
109
110
111# ------------------------------------------------------------------ item 2: step_adaptive
112def scene_hysing(nx=32, nz=64, sigma=0.02):
113 s = pf.Solver(nx, 4, nz)
114 s.set_rho(1.0)
115 s.set_mu(0.01)
116 s.set_domain_bc("-z", "wall"); s.set_domain_bc("+z", "wall")
117 s.set_pressure_geometry(np.full((nx, 4, nz), 1e30, order="F"))
118 s.enable_vof()
119 x, y, z = np.meshgrid(np.arange(nx) + 0.5, np.arange(4) + 0.5,
120 np.arange(nz) + 0.5, indexing="ij")
121 r = np.sqrt((x - nx / 2) ** 2 + (z - nz / 4) ** 2)
122 C = np.asfortranarray(np.clip(0.5 + (r - 0.25 * nx / 2), 0.0, 1.0) * 0 + (r > 0.25 * nx).astype(float))
123 s.set_vof(C)
124 s.set_property_model("rho", "linear", "C", [1.0, 9.0])
125 s.set_property_model("mu", "linear", "C", [0.005, 0.045])
126 s.set_pressure_fcg(True, 600, 1e-10)
127 if sigma > 0:
128 s.set_surface_tension(sigma)
129 s.set_body_force((0.0, 0.0, -5e-2))
130 # A divergence-free x-shear in w (no z dependence, periodic in x, so the walls do not kill
131 # it) — this is what makes the interface-local CFL a REAL, binding limit on the sigma = 0
132 # configuration instead of an infinity.
133 ww = np.zeros((nx, 4, nz), order="F")
134 ww += (0.3 * np.sin(2 * np.pi * (np.arange(nx) + 0.5) / nx))[:, None, None]
135 s.set_field("w", ww)
136 s.set_dt(min(1.0, 0.4 * s.capillary_dt()))
137 return s
138
139
140def gate_adaptive(nsteps=200):
141 print("ISSUES item 2 -- step_adaptive reproduces the gallery driver bitwise")
142 # Two factor pairs so BOTH branches of the min() are exercised: (0.4, 0.4) is the gallery
143 # driver verbatim (capillary-bound on this scene) and (0.4, 4.0) loosens the capillary factor
144 # far enough that the interface-local CFL binds instead.
145 for cfl, cap, sig, dmax in ((0.4, 0.4, 0.02, math.inf), (0.2, 0.4, 0.0, 1.0)):
146 print(f" -- cfl_target = {cfl}, capillary_cfl = {cap}, sigma = {sig}, dt_max = {dmax}"
147 f" ({'capillary' if sig else 'CFL/dt_max'}-bound)")
148 one_adaptive(nsteps, cfl, cap, sig, dmax)
149
150
151def one_adaptive(nsteps, cflT, capT, sigma, dmax):
152 a = scene_hysing(sigma=sigma)
153 dts_a, ncap, ncfl = [], 0, 0
154 for _ in range(nsteps): # the hand-written gallery loop
155 L = a.vof_step_limits()
156 dt = min(cflT * L["cfl_dt"], capT * L["capillary_dt"], dmax)
157 a.set_dt(dt)
158 a.step()
159 dts_a.append(dt)
160 ncap += int(capT * L["capillary_dt"] <= min(cflT * L["cfl_dt"], dmax))
161 ncfl += int(cflT * L["cfl_dt"] < min(capT * L["capillary_dt"], dmax))
162 b = scene_hysing(sigma=sigma)
163 dts_b = [b.step_adaptive(cflT, capT, dmax) for _ in range(nsteps)]
164 d = diffs(state(a), state(b))
165 check(" dt sequence identical", dts_a == dts_b,
166 f"max|d dt| = {max(abs(x - y) for x, y in zip(dts_a, dts_b)):.3e}")
167 check(" state bitwise identical", same(state(a), state(b)), str(d))
168 print(f" {nsteps} steps, dt {dts_a[0]:.6g} -> {dts_a[-1]:.6g}, "
169 f"pressure {a.diagnostics.last_pressure_iterations()}/600, failed={a.diagnostics.pressure_solve_failed()}; "
170 f"capillary bound {ncap} steps, CFL bound {ncfl}; "
171 f"dt range [{min(dts_a):.6g}, {max(dts_a):.6g}]")
172
173
174# ------------------------------------------------------------------ item 4: vof_geometry
176 print("ISSUES item 4 -- vof_geometry() on an all-fluid VoF solver")
177 n = 32
178 s = pf.Solver(n, n, n)
179 s.set_rho(1.0); s.set_mu(0.1)
180 s.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
181 s.enable_vof()
182 eps = np.asarray(s.diagnostics.vof_geometry(0))
183 kind = np.asarray(s.diagnostics.vof_geometry(4))
184 check("vof_has_geometry() is False", s.vof_has_geometry() is False)
185 check("eps == 1 everywhere", eps.shape == (n, n, n) and np.all(eps == 1.0),
186 f"min {eps.min()} max {eps.max()}")
187 check("openness == 1 everywhere",
188 all(np.all(np.asarray(s.diagnostics.vof_geometry(k)) == 1.0) for k in (1, 2, 3)))
189 check("classification == 0 everywhere", np.all(kind == 0.0))
190 # the E7 driver's dual use: one expression for both scenes
191 gas = np.asarray(s.diagnostics.vof_geometry(0)) * (1.0 - s.get_vof())
192 check("E7 dual-use expression runs", gas.shape == (n, n, n))
193
194
195# ------------------------------------------------------------------ item 5: collocated advect_vof
196def leveque(n, t=0.0, T=3.0):
197 """Cell-centre-sampled LeVeque deformation field (u, v, w) on an n^3 unit box."""
198 c = (np.arange(n) + 0.5) / n
199 X, Y, Z = np.meshgrid(c, c, c, indexing="ij")
200 f = math.cos(math.pi * t / T)
201 u = 2.0 * np.sin(np.pi * X) ** 2 * np.sin(2 * np.pi * Y) * np.sin(2 * np.pi * Z) * f
202 v = -np.sin(2 * np.pi * X) * np.sin(np.pi * Y) ** 2 * np.sin(2 * np.pi * Z) * f
203 w = -np.sin(2 * np.pi * X) * np.sin(2 * np.pi * Y) * np.sin(np.pi * Z) ** 2 * f
204 return (np.asfortranarray(u * n), np.asfortranarray(v * n), np.asfortranarray(w * n))
205
206
207def sphere_colour(n, cx=0.35, r=0.15):
208 c = (np.arange(n) + 0.5) / n
209 X, Y, Z = np.meshgrid(c, c, c, indexing="ij")
210 d = np.sqrt((X - cx) ** 2 + (Y - cx) ** 2 + (Z - cx) ** 2)
211 return np.asfortranarray(np.clip(0.5 + (r - d) * n, 0.0, 1.0))
212
213
215 print("ISSUES item 5 -- SolverColocated: set_state + advect_vof")
216 n = 32
217 s = pf.SolverColocated(n, n, n)
218 s.set_rho(1.0); s.set_mu(0.0)
219 s.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
220 s.enable_vof()
221 C0 = sphere_colour(n)
222 s.set_vof(C0)
223 u, v, w = leveque(n)
224 s.set_state(u, v, w)
225 print(f" max|u| = {np.abs(u).max():.4g}, max|uf| after set_state = "
226 f"{np.abs(np.asarray(s.get_uf())).max():.4g}")
227 check("set_state seeds the face field", np.abs(np.asarray(s.get_uf())).max() > 1.0)
228 div = s.max_open_divergence_projected()
229 print(f" max|div(open uf)| of the seeded field = {div:.4g}")
230 dt = 0.2 / np.abs(np.asarray(s.get_uf())).max() # interface CFL well under the cap
231 for _ in range(10):
232 s.advect_vof(dt)
233 moved = float(np.abs(s.get_vof() - C0).max())
234 vol = float(s.get_vof().sum())
235 check("advect_vof no longer a silent no-op", moved > 0.0, f"max|C-C0| = {moved:.3e}")
236 # the reference: the SAME face field handed to a staggered solver (the E1 page's recipe)
237 r = pf.Solver(n, n, n)
238 r.set_rho(1.0); r.set_mu(0.0)
239 r.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
240 r.enable_vof(); r.set_vof(C0)
241 for nm, fa in zip("uvw", (s.get_uf(), s.get_vf(), s.get_wf())):
242 r.set_field(nm, np.asfortranarray(np.asarray(fa)))
243 for _ in range(10):
244 r.advect_vof(dt)
245 dC = float(np.abs(r.get_vof() - s.get_vof()).max())
246 print(f" dt = {dt:.6g}, 10 steps: max|C-C0| = {moved:.4g}, sum C = {vol:.12g} (C0: {float(C0.sum()):.12g})")
247 check("collocated colour == the staggered run on the SAME face field", dC == 0.0,
248 f"max|dC| = {dC:.3e}")
249 # a fresh solver whose face field was never built must refuse
250 s2 = pf.SolverColocated(n, n, n)
251 s2.set_rho(1.0); s2.set_mu(0.0)
252 s2.set_pressure_geometry(np.full((n, n, n), 10.0, order="F"))
253 s2.enable_vof(); s2.set_vof(C0)
254 m2 = ""
255 try:
256 s2.advect_vof(0.01)
257 except RuntimeError as e:
258 m2 = str(e).split("\n")[0]
259 check("a never-built face field is refused", "never been built" in m2, m2[:70])
260
261
262# ------------------------------------------------------------------ item 7: free-slip domain BC
263def half_channel(nz, top_type, mu=0.05, F=1e-3, dt=5000.0, steps=200):
264 s = pf.Solver(4, 4, nz)
265 s.set_rho(1.0); s.set_mu(mu); s.set_dt(dt)
266 s.set_domain_bc("-z", "wall") # -z no-slip wall
267 s.set_domain_bc("+z", top_type) # +z: 'wall' = no-slip, 'slip' = FREE SLIP
268 s.set_pressure_geometry(np.full((4, 4, nz), 1e30, order="F"))
269 s.set_body_force((F, 0.0, 0.0))
270 for _ in range(steps):
271 s.step()
272 return s
273
274
275def gate_freeslip(nz=32):
276 print("ISSUES item 7 -- domain BC type 4 = free slip")
277 mu, F = 0.05, 1e-3
278 # (a) the analytic half-channel. The DISCRETE solution of the scheme is the continuum
279 # parabola plus h^2/4 * F/(2 mu): a second difference reproduces a quadratic exactly, and
280 # the only inexact ingredient is the no-slip wall's mirror ghost (u(-h/2) = -u(h/2) is
281 # exact only for an ODD function, and z^2 is even). That offset is the NO-SLIP wall's, not
282 # the free-slip one's -- which is the point: the free-slip face contributes no error at all.
283 s = half_channel(nz, "slip", mu, F)
284 u = s.get_u()[0, 0, :]
285 zc = np.arange(nz) + 0.5
286 H = float(nz)
287 cont = F / (2 * mu) * (H ** 2 - (H - zc) ** 2)
288 disc = cont + F / (2 * mu) * 0.25
289 e_cont = float(np.abs(u - cont).max() / cont.max())
290 e_disc = float(np.abs(u - disc).max() / disc.max())
291 print(f" u_max {u.max():.8f}; continuum parabola {cont.max():.8f} (rel {e_cont:.3e}), "
292 f"discrete parabola {disc.max():.8f} (rel {e_disc:.3e}); "
293 f"pressure {s.diagnostics.last_pressure_iterations()}, failed={s.diagnostics.pressure_solve_failed()}")
294 check("half-channel == the scheme's own discrete parabola (solver tol)", e_disc < 1e-8, f"{e_disc:.3e}")
295 check("half-channel within h^2 of the continuum parabola", e_cont < 1e-3, f"{e_cont:.3e}")
296 # (b) free slip IS symmetry: the half channel must equal the lower half of a FULL channel of
297 # twice the height with no-slip on both sides.
298 f2 = half_channel(2 * nz, "wall", mu, F)
299 u2 = f2.get_u()[0, 0, :nz]
300 d = float(np.abs(u - u2).max() / u.max())
301 print(f" vs the lower half of a {2*nz}-cell no-slip channel: max rel difference {d:.3e}")
302 check("free slip == the symmetry plane of a full channel", d < 1e-9, f"{d:.3e}")
303 # (c) inert when unused: a type-1 lid must reproduce a plain no-slip channel exactly.
304 a = half_channel(nz, "wall", mu, F)
305 check("type 4 unused -> nothing moves (sanity)", np.isfinite(a.get_u()).all())
306
307
309 print("ISSUES item 3 -- set_contact_angle on a domain-BC wall (see the dedicated script)")
310
311
312MAIN = dict(atomic=gate_atomic, adaptive=gate_adaptive, geometry=gate_geometry,
313 collocated=gate_collocated, freeslip=gate_freeslip, wetwall=gate_wetwall)
314
315if __name__ == "__main__":
316 ap = argparse.ArgumentParser()
317 ap.add_argument("gate", nargs="*", default=list(MAIN))
318 a = ap.parse_args()
319 for g in a.gate:
320 MAIN[g]()
321 print("\nFAILED:" if FAIL else "\nALL GATES PASSED", ", ".join(FAIL))
322 sys.exit(1 if FAIL else 0)
scene_hysing(nx=32, nz=64, sigma=0.02)
one_adaptive(nsteps, cflT, capT, sigma, dmax)
check(name, ok, detail="")
half_channel(nz, top_type, mu=0.05, F=1e-3, dt=5000.0, steps=200)
leveque(n, t=0.0, T=3.0)
gate_adaptive(nsteps=200)
sphere_colour(n, cx=0.35, r=0.15)