flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_cutcell.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-Q (rung V5a) — VoF transport in cut cells: the gate battery, in Python.
3
4Every gate prints its numbers; nothing is summarised into an adjective. The three that matter:
5
6 G2 CONSERVATION THROUGH A PACKING (kinematic). The single-phase solver is run to a Stokes steady
7 state on a periodic sphere array, that PROJECTED velocity is frozen, and a liquid slab is
8 advected by `advect_vof` alone. The conserved functional of the openness-weighted scheme is
9 `sum eps_eff * C` (`src/vof/cutcell.hpp` rule 1) and its floor is the projection's own
10 `max_open_divergence()` — both are reported, together with the raw `sum eps * C`, the colour
11 in solid cells (0 by construction), the boundedness in UNCUT fluid cells and the clipped
12 volume.
13
14 G4 COUPLED DRAINING. The same packing at density ratio 10 with a ZERO-MEAN buoyancy force,
15 `enable_vof` + the LinearMix closures, optionally `enable_vof_momentum`. Gates: no NaN, the
16 colour drift against the projection floor, zero colour in solid cells, `max|u|` bounded, and
17 the pressure iteration count against its cap (a capped solve makes a run INVALID).
18
19 G5 THE 90-DEGREE NEUTRAL FILL. A liquid cap on a flat SDF wall at a HALF-INTEGER z, so the wall
20 cells are genuinely cut, with surface tension. The apparent contact angle is measured from the
21 cap height on the axis and the contact radius on the first fluid plane
22 (`theta = 2 atan(h/a)`, the spherical-cap relation), and the Young-Laplace jump against
23 `2 sigma / R`.
24
25Usage:
26 PYTHONPATH=$PWD/build_cuda python tests/study/vof_cutcell.py [g2 g4 g5 ...]
27"""
28import math
29import sys
30
31import numpy as np
32
33import peclet.flow as pf
34
35PRESS_MAXIT = 300
36
37
38def sphere_array_sdf(n, centres, radius):
39 """Periodic array of spheres; > 0 in fluid (flow's SDF convention), cell centres at i + 1/2."""
40 ax = (np.arange(n) + 0.5)[:, None, None]
41 ay = (np.arange(n) + 0.5)[None, :, None]
42 az = (np.arange(n) + 0.5)[None, None, :]
43 best = np.full((n, n, n), 1e30)
44 for cx, cy, cz in centres:
45 for px in (-n, 0, n):
46 for py in (-n, 0, n):
47 for pz in (-n, 0, n):
48 d = np.sqrt((ax - (cx + px)) ** 2 + (ay - (cy + py)) ** 2
49 + (az - (cz + pz)) ** 2) - radius
50 best = np.minimum(best, d)
51 return np.asfortranarray(best)
52
53
54DEFAULT_CENTRES = [(6.0, 7.0, 8.0), (20.0, 9.0, 23.0), (11.0, 24.0, 19.0), (26.0, 22.0, 6.0)]
55
56
57def packing(n):
58 f = n / 32.0
59 return sphere_array_sdf(n, [(a * f, b * f, c * f) for a, b, c in DEFAULT_CENTRES], 6.0 * f)
60
61
62def stokes_state(n, steps=60, mu=0.2, force=(2e-3, 1e-3, 5e-4)):
63 s = pf.Solver(n, n, n)
64 s.set_rho(1.0)
65 s.set_mu(mu)
66 s.set_dt(1.0)
67 s.set_body_force(force)
68 s.set_solid(packing(n), cutcell_pressure=True)
69 it = 0
70 for _ in range(steps):
71 s.step()
72 it = max(it, s.diagnostics.last_pressure_iterations())
73 return s, it
74
75
76# --------------------------------------------------------------------------------------- G2
77def g2(n=48, nadv=500, cfl=0.2):
78 s, it = stokes_state(n)
79 div = s.max_open_divergence()
80 s.enable_vof()
81 c0 = np.zeros((n, n, n), order="F")
82 c0[:, :, : n // 2] = 1.0
83 s.set_vof(c0)
84 d0 = s.diagnostics.vof_diagnostics()
85 dt = cfl / s.vof_max_courant()
86 clip = 0.0
87 mn, mx = 1e30, -1e30
88 solid = 0.0
89 for _ in range(nadv):
90 s.advect_vof(dt)
91 d = s.diagnostics.vof_diagnostics()
92 clip += d["clipped_volume"]
93 mn = min(mn, d["min_fluid"])
94 mx = max(mx, d["max_fluid"])
95 solid = max(solid, abs(d["solid_sum"]))
96 d1 = s.diagnostics.vof_diagnostics()
97 drift = (d1["volume"] - d0["volume"]) / d0["volume"]
98 raw = (d1["raw_volume"] - d0["raw_volume"]) / d0["raw_volume"]
99 print(f"G2 packing {n}^3, {nadv} kinematic steps at interface CFL {cfl} (dt = {dt:.5g})")
100 print(f" max|div(open u)| {div:.3e} pressure iters {it}")
101 print(f" sum eps_eff C {d0['volume']:.15e} -> {d1['volume']:.15e} drift {drift:.3e}")
102 print(f" sum eps C {d0['raw_volume']:.15e} -> {d1['raw_volume']:.15e} drift {raw:.3e}")
103 print(f" solid cells {d1['solid_cells']}, cut cells {d1['cut_cells']}, "
104 f"clamped faces last step {d1['clamped_faces']}")
105 print(f" max|sum C over solid cells| {solid:.3e} (must be exactly 0)")
106 print(f" min/max C over UNCUT fluid cells {mn:.3e} / {mx:.17g}")
107 print(f" clipped liquid volume over the run {clip:.6e} "
108 f"({clip / d0['volume']:.3e} of the liquid volume)")
109 ok = abs(drift) <= 1e-11 and solid == 0.0 and mn >= -1e-12 and mx <= 1 + 1e-12
110 print(f" G2 {'PASS' if ok else 'FAIL'}")
111 return ok
112
113
114# --------------------------------------------------------------------------------------- G4
115def g4(n=48, steps=400, ratio=10.0, grav=2e-3, momentum=True):
116 s = pf.Solver(n, n, n)
117 s.set_rho(1.0)
118 s.set_mu(0.05)
119 s.set_dt(0.5)
120 s.set_solid(packing(n), cutcell_pressure=True)
121 s.enable_vof()
122 c0 = np.zeros((n, n, n), order="F")
123 c0[:, :, 3 * n // 4:] = 1.0 # a liquid layer resting on top of the packing
124 s.set_vof(c0)
125 liq = 0.25
126 rho_mean = liq * ratio + (1.0 - liq)
127 s.set_property_model("rho", "linear", "C", [1.0, ratio - 1.0])
128 s.set_property_model("mu", "linear", "C", [0.05, 0.45])
129 # ZERO-MEAN buoyancy: a periodic box has no way to absorb a net body force, so `-g rho` would
130 # accelerate the whole fluid without bound and there would be no state to measure a drift in.
131 s.set_property_model("force_z", "linear", "rho", [grav * rho_mean, -grav])
132 if momentum:
133 s.enable_vof_momentum(1.0, ratio)
134 v0 = s.diagnostics.vof_diagnostics()["volume"]
135 maxit, capped, clip = 0, 0, 0.0
136 for _ in range(steps):
137 s.step()
138 it = s.diagnostics.last_pressure_iterations()
139 maxit = max(maxit, it)
140 capped += it >= PRESS_MAXIT
141 clip += s.diagnostics.vof_diagnostics()["clipped_volume"]
142 d1 = s.diagnostics.vof_diagnostics()
143 umax = max(abs(np.asarray(g())).max() for g in (s.get_u, s.get_v, s.get_w))
144 drift = (d1["volume"] - v0) / v0
145 print(f"G4 packing {n}^3 draining, ratio {ratio}, {steps} coupled steps, "
146 f"momentum consistency {'ON' if momentum else 'off'}")
147 print(f" colour drift {drift:.3e} ({drift / steps:.3e} per step); "
148 f"max|div(open u)| {s.max_open_divergence():.3e}")
149 print(f" pressure iters max {maxit} (cap {PRESS_MAXIT}, capped steps {capped})")
150 print(f" min/max C over uncut fluid {d1['min_fluid']:.3e} / {d1['max_fluid']:.17g}; "
151 f"clipped volume total {clip:.3e}")
152 print(f" max|u| {umax:.4e}; sum C over solid cells {d1['solid_sum']:.3e}")
153 ok = (not math.isnan(umax) and capped == 0 and abs(drift) / steps <= 1e-10
154 and d1["solid_sum"] == 0.0)
155 print(f" G4 {'PASS' if ok else 'FAIL (a capped solve makes the run INVALID)'}")
156 return ok
157
158
159# --------------------------------------------------------------------------------------- G5
160def g5(diam=24, steps=200, sigma=1.0, mu=0.05, cfl=0.5):
161 nx = ny = int(1.7 * diam)
162 nz = int(1.2 * diam)
163 zw = 3.5 # HALF-INTEGER: the wall cells are genuinely cut
164 r = 0.5 * diam
165 z = (np.arange(nz) + 0.5)[None, None, :]
166 sdf = np.asfortranarray(np.broadcast_to(z - zw, (nx, ny, nz)).astype(float).copy())
167 cx, cy = nx * 0.5, ny * 0.5
168 # 4^3 midpoint subsampling of the FLUID part of each cell (C is a fraction of the fluid volume)
169 sub = (np.arange(4) + 0.5) / 4.0
170 c0 = np.zeros((nx, ny, nz))
171 for a in sub:
172 for b in sub:
173 for c in sub:
174 px = (np.arange(nx) + a)[:, None, None]
175 py = (np.arange(ny) + b)[None, :, None]
176 pz = (np.arange(nz) + c)[None, None, :]
177 fluid = pz >= zw
178 inside = ((px - cx) ** 2 + (py - cy) ** 2 + (pz - zw) ** 2 < r * r) & fluid
179 c0 += np.where(inside, 1.0, 0.0)
180 tot = np.zeros((nx, ny, nz))
181 for c in sub:
182 pz = (np.arange(nz) + c)[None, None, :]
183 tot += np.broadcast_to(np.where(pz >= zw, 16.0, 0.0), (nx, ny, nz))
184 c0 = np.asfortranarray(np.where(tot > 0, c0 / np.maximum(tot, 1e-30), 0.0))
185
186 s = pf.Solver(nx, ny, nz)
187 s.set_rho(1.0)
188 s.set_mu(mu)
189 s.set_solid(sdf, cutcell_pressure=True)
190 s.enable_vof()
191 s.set_vof(c0)
192 s.set_surface_tension(sigma)
193 dts = s.capillary_dt()
194 s.set_dt(cfl * dts)
195 v0 = s.diagnostics.vof_diagnostics()["volume"]
196 maxit, capped = 0, 0
197 for _ in range(steps):
198 s.step()
199 it = s.diagnostics.last_pressure_iterations()
200 maxit = max(maxit, it)
201 capped += it >= PRESS_MAXIT
202 cc = np.asarray(s.get_vof())
203 d1 = s.diagnostics.vof_diagnostics()
204 z0 = int(math.ceil(zw))
205 # h: the liquid column on the axis, counting only the FLUID part of the cut wall cell
206 frac = np.clip(np.arange(nz) + 1 - zw, 0.0, 1.0)
207 h = float((cc[nx // 2, ny // 2, :] * frac).sum())
208 a = math.sqrt(float(cc[:, :, z0].sum()) / math.pi)
209 theta = math.degrees(2.0 * math.atan2(h, a))
210 rc = (a * a + h * h) / (2.0 * h)
211 p = np.asarray(s.get_field("p"))
212 px = (np.arange(nx) + 0.5)[:, None, None] - cx
213 py = (np.arange(ny) + 0.5)[None, :, None] - cy
214 pz = (np.arange(nz) + 0.5)[None, None, :] - zw
215 rr = np.sqrt(px ** 2 + py ** 2 + pz ** 2)
216 inner = (rr < 0.55 * r) & (pz > 0.5)
217 outer = (rr > 1.5 * r) & (rr < 1.8 * r) & (pz > 0.5)
218 dp = float(p[inner].mean() - p[outer].mean())
219 umax_open = max(abs(np.asarray(g())[:, :, z0 + 1:]).max() for g in (s.get_u, s.get_v, s.get_w))
220 umax_all = max(abs(np.asarray(g())).max() for g in (s.get_u, s.get_v, s.get_w))
221 print(f"G5 liquid cap on a CUT flat wall, D/dx = {diam}, {nx}x{ny}x{nz}, {steps} steps at "
222 f"{cfl} dt_sigma = {cfl * dts:.5g}")
223 print(f" volume {v0:.6f} -> {d1['volume']:.6f} (drift {(d1['volume'] - v0) / v0:.3e}); "
224 f"analytic hemisphere {2 * math.pi * r ** 3 / 3:.6f}")
225 print(f" h {h:.4f} a {a:.4f} -> apparent theta {theta:.3f} deg (target 90)")
226 print(f" cap sphere radius {rc:.4f} (target {r}); Young-Laplace dP {dp:.6f} vs "
227 f"2 sigma/R {2 * sigma / rc:.6f} (rel {abs(dp - 2 * sigma / rc) / (2 * sigma / rc):.3e})")
228 print(f" spurious currents: max|u| in the OPEN fluid {umax_open:.3e} -> "
229 f"Ca = mu max|u|/sigma = {mu * umax_open / sigma:.3e}; "
230 f"max|u| including the wall band {umax_all:.3e}")
231 print(f" (V4 free-droplet reference at the same D/dx: Ca 1.4e-5 ... 2.6e-5)")
232 print(f" pressure iters max {maxit} (capped steps {capped}); "
233 f"sum C over solid cells {d1['solid_sum']:.3e}")
234 ok = (abs(theta - 90.0) <= 3.0
235 and abs(dp - 2 * sigma / rc) / (2 * sigma / rc) <= 0.01
236 and d1["solid_sum"] == 0.0 and capped == 0)
237 print(f" G5 {'PASS' if ok else 'FAIL'}")
238 return ok
239
240
241GATES = {"g2": g2, "g4": g4, "g5": g5}
242
243if __name__ == "__main__":
244 names = [a for a in sys.argv[1:] if not a.startswith("-")] or list(GATES)
245 quick = "--quick" in sys.argv
246 results = {}
247 for nm in names:
248 fn = GATES[nm]
249 if quick and nm == "g2":
250 results[nm] = fn(n=32, nadv=200)
251 elif quick and nm == "g4":
252 results[nm] = fn(n=32, steps=150)
253 elif quick and nm == "g5":
254 results[nm] = fn(diam=16, steps=100)
255 else:
256 results[nm] = fn()
257 print()
258 print("summary: " + " ".join(f"{k}={'PASS' if v else 'FAIL'}" for k, v in results.items()))
259 sys.exit(0 if all(results.values()) else 1)
sphere_array_sdf(n, centres, radius)
g2(n=48, nadv=500, cfl=0.2)
g5(diam=24, steps=200, sigma=1.0, mu=0.05, cfl=0.5)
stokes_state(n, steps=60, mu=0.2, force=(2e-3, 1e-3, 5e-4))
g4(n=48, steps=400, ratio=10.0, grav=2e-3, momentum=True)
static double mean(const std::vector< double > &a)