flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_channel_18.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""VoF Part III rung W2 gate 4 (WO-W12) — TBFsolver's `channel_18` on the block container.
3
4TBFsolver (Cifani et al., *Computers & Fluids* 2018; `~/Codes/TBFsolver`) ships one case:
5a MINIMAL turbulent channel at Re_tau = 127.3 loaded with 18 deformable bubbles. This is the
6transcription, read off `channel_18/specs/*` and the source that consumes them.
7
8THE CASE (every number verified against the Fortran, not the file names):
9
10 geometry Lx = pi (streamwise, periodic), Ly = 2 (wall-normal, WALLS), Lz = pi/2 (spanwise,
11 periodic); half-height h = 1. TBFsolver's grid is 192 x 160 x 96 (anisotropic:
12 dx+ = 2.08, dy+ = 1.59). flow's cells are cubic, so the transcription is an
13 ISOTROPIC grid and the box aspect is rounded to it (below).
14 fluids rho_l = 1, rho_g = 0.1 (ratio 10); mu_l = mu_g = 3.3333e-4 (ratio 1); sigma = 5e-3.
15 driving `flowCtrl 1` + `Ret 127.3`. In `auxiliaryRoutines::setPressGrad` this is
16 tau_w = (Ret nu_l / h)^2 / nu_l^2 ... = (Ret/Re)^2 with Re = 1/nu_l = 3000,
17 i.e. tau_w = 1.800588e-3, and the applied mean pressure gradient is
18 fs = tau_w - <rho> gCH. The x-momentum source is then
19 fs + rho gCH = tau_w + (rho - <rho>) gCH,
20 so **gCH = 0.1 is a streamwise GRAVITY that contributes only buoyancy** and does
21 NOT enter Re_tau: u_tau = Re_tau nu_l / h = 0.0424333 exactly, by construction.
22 (`g 0.d0` in the specs is the WALL-NORMAL gravity, and it is off.) The bubbles are
23 therefore driven AGAINST the mean flow: this is a downflow channel.
24 bubbles `initBubbles method 2, nbx 6, nby 1, nbz 3, R 0.125, random_distr .FALSE.` ->
25 `vofBlocks::readBubblesArray` puts 18 bubbles on the CENTRELINE y = 1 at
26 x0 = (i-1/2) Lx/6, z0 = (k-1/2) Lz/3. Void fraction 1.49 %. D/h = 0.25.
27 BCs no-slip on both walls; zero normal gradient for the colour and the pressure.
28 IC `channel_18/0/{ux,uy,uz}` is a converged SINGLE-PHASE turbulent channel snapshot
29 (raw Fortran STREAM: one int32 count then the internal field, x fastest, no ghosts,
30 staggered sizes 193x160x96 / 192x161x96 / 192x160x97; a 96-byte trailer of stale
31 patch records that the reader ignores). psi and phi0* are identically zero.
32 This script INTERPOLATES that snapshot onto its own grid when it is present -- the
33 nearest thing to the same initial condition that a different grid admits -- and
34 otherwise starts from a perturbed laminar profile and spins up.
35
36DIMENSIONLESS GROUPS: Eo = rho_l gCH D^2 / sigma = 1.25, Mo = gCH mu_l^4/(rho_l^2 sigma^3) =
379.88e-9, density ratio 10, viscosity ratio 1, D+ = 31.8, void fraction 1.49 %.
38
39REFERENCE DATA: **the repository ships none** -- 60 tracked files, no statistics, no profiles, no
40post-processing. The only published figure is qualitative (a streamwise-velocity contour in
41`user_guide.pdf`). So the profiles below are reported as OUR first datum, not as a comparison.
42
43Usage:
44 PYTHONPATH=<build> python tests/study/vof_channel_18.py [--ny 80] [--steps N] [--single]
45 [--tbf DIR] [--out FILE]
46"""
47import math
48import os
49import sys
50import time
51
52import numpy as np
53
54import peclet.flow as pf
55
56
57def arg(name, default, cast=float):
58 if name in sys.argv:
59 return cast(sys.argv[sys.argv.index(name) + 1])
60 return default
61
62
63TBF = str(arg("--tbf", "/home/frankp/Codes/TBFsolver/channel_18/0", str))
64NY = int(arg("--ny", 80, int))
65NSTEP = int(arg("--steps", 4000, int))
66SINGLE = "--single" in sys.argv
67OUT = str(arg("--out", "", str))
68
69# ---------------------------------------------------------------- the physical case
70LX, LY, LZ = math.pi, 2.0, math.pi / 2 # h = LY/2 = 1
71RHO_L, RHO_G = 1.0, 0.1
72MU = 3.33333333333333e-4
73SIGMA = 5.0e-3
74GCH = 0.1
75RET = 127.3
76RE = 1.0 / MU # rho_l h / mu_l
77TAUW = (RET / RE) ** 2 # = 1.800588e-3, the applied wall stress
78UTAU = RET * MU # = 0.0424333
79RBUB = 0.125
80NBX, NBY, NBZ = 6, 1, 3
81
82# ---------------------------------------------------------------- the isotropic grid
83# flow's cells are cubic, so the box aspect must be an integer ratio. ny cells across Ly = 2
84# fixes the cell size; nx and nz are the nearest choices with plenty of factors of two (the
85# pressure multigrid's depth is decided by those -- suite/docs/DECOMPOSITION_AND_MULTIGRID.md).
86S = NY / LY # cells per unit length; half-height h = NY/2 cells
87NX = int(round(LX * S / 16)) * 16
88NZ = int(round(LZ * S / 16)) * 16
89LXC, LZC = NX / S, NZ / S # the box we actually get
90
91
92def tbf_read(path, shape):
93 """One `channel_18/0` binary: int32 count, then count float64s, x fastest, no ghosts."""
94 with open(path, "rb") as f:
95 n = int(np.frombuffer(f.read(4), dtype="<i4")[0])
96 if n != shape[0] * shape[1] * shape[2]:
97 raise ValueError(f"{path}: count {n} != {shape}")
98 a = np.frombuffer(f.read(8 * n), dtype="<f8")
99 return np.asfortranarray(a.reshape(shape, order="F"))
100
101
102def interp_component(src, src_pos, dst_pos):
103 """Trilinear resample of one staggered component.
104
105 `src_pos[d]` and `dst_pos[d]` are the PHYSICAL coordinates of the sample points along axis d,
106 normalised to [0,1) of the respective box. x and z wrap (periodic), y clamps (walls).
107 """
108 out = src
109 for d, wrap in enumerate((True, False, True)):
110 sp, dp = src_pos[d], dst_pos[d]
111 n = len(sp)
112 if wrap:
113 spx = np.concatenate([sp - 1.0, sp, sp + 1.0])
114 idx = np.searchsorted(spx, dp) - 1
115 idx = np.clip(idx, 0, len(spx) - 2)
116 w = (dp - spx[idx]) / (spx[idx + 1] - spx[idx])
117 i0, i1 = idx % n, (idx + 1) % n
118 else:
119 idx = np.clip(np.searchsorted(sp, dp) - 1, 0, n - 2)
120 w = np.clip((dp - sp[idx]) / (sp[idx + 1] - sp[idx]), 0.0, 1.0)
121 i0, i1 = idx, idx + 1
122 sl = [slice(None)] * 3
123 sl0, sl1 = list(sl), list(sl)
124 sl0[d], sl1[d] = i0, i1
125 shape = [1, 1, 1]
126 shape[d] = len(dp)
127 out = out[tuple(sl0)] * (1.0 - w.reshape(shape)) + out[tuple(sl1)] * w.reshape(shape)
128 return np.asfortranarray(out)
129
130
132 """TBFsolver's turbulent snapshot, resampled onto our grid, in CELL units (u' = S u)."""
133 nxt, nyt, nzt = 192, 160, 96
134 ux = tbf_read(os.path.join(TBF, "ux"), (nxt + 1, nyt, nzt))
135 uy = tbf_read(os.path.join(TBF, "uy"), (nxt, nyt + 1, nzt))
136 uz = tbf_read(os.path.join(TBF, "uz"), (nxt, nyt, nzt + 1))
137 # source sample positions, as fractions of the box
138 fx = np.arange(nxt + 1) / nxt # ux on x-faces (0 .. 1 inclusive)
139 cx = (np.arange(nxt) + 0.5) / nxt
140 fy = np.arange(nyt + 1) / nyt
141 cy = (np.arange(nyt) + 0.5) / nyt
142 fz = np.arange(nzt + 1) / nzt
143 cz = (np.arange(nzt) + 0.5) / nzt
144 # destination: flow's u(i) is the LOW x-face of cell i
145 dfx = np.arange(NX) / NX
146 dcx = (np.arange(NX) + 0.5) / NX
147 dfy = np.arange(NY) / NY
148 dcy = (np.arange(NY) + 0.5) / NY
149 dfz = np.arange(NZ) / NZ
150 dcz = (np.arange(NZ) + 0.5) / NZ
151 u = interp_component(ux[:nxt], (fx[:nxt], cy, cz), (dfx, dcy, dcz))
152 v = interp_component(uy[:, :nyt], (cx, fy[:nyt], cz), (dcx, dfy, dcz))
153 w = interp_component(uz[:, :, :nzt], (cx, cy, fz[:nzt]), (dcx, dcy, dfz))
154 return (np.asfortranarray(u * S), np.asfortranarray(v * S), np.asfortranarray(w * S))
155
156
158 """Perturbed parabolic fallback (mean flow set to the shipped snapshot's bulk, U_b = 0.6113)."""
159 y = (np.arange(NY) + 0.5) / NY * LY
160 prof = 1.5 * 0.6113 * (1.0 - (y - 1.0) ** 2)
161 rng = np.random.default_rng(7)
162 u = np.broadcast_to(prof[None, :, None], (NX, NY, NZ)).copy()
163 u += 0.15 * 0.6113 * rng.standard_normal((NX, NY, NZ))
164 v = 0.05 * 0.6113 * rng.standard_normal((NX, NY, NZ))
165 w = 0.05 * 0.6113 * rng.standard_normal((NX, NY, NZ))
166 for a in (u, v, w):
167 a[:, 0, :] *= 0.0
168 a[:, -1, :] *= 0.0
169 return (np.asfortranarray(u * S), np.asfortranarray(v * S), np.asfortranarray(w * S))
170
171
172def sphere_frac(shape, R, c, sub=16):
173 nx, ny, nz = shape
174 ax = (np.arange(nx)[:, None] + (np.arange(sub)[None, :] + 0.5) / sub).ravel()
175 ay = (np.arange(ny)[:, None] + (np.arange(sub)[None, :] + 0.5) / sub).ravel()
176 X, Y = ax[:, None], ay[None, :]
177 half = np.sqrt(np.maximum(R * R - (X - c[0]) ** 2 - (Y - c[1]) ** 2, 0.0))
178 z0, z1 = c[2] - half, c[2] + half
179 C = np.zeros((nx, ny, nz))
180 for k in range(nz):
181 seg = np.maximum(np.minimum(z1, k + 1) - np.maximum(z0, k), 0.0)
182 C[:, :, k] = seg.reshape(nx, sub, ny, sub).mean(axis=(1, 3))
183 return np.asfortranarray(C)
184
185
186def main():
187 Rc = RBUB * S
188 print("=" * 100)
189 print("TBFsolver channel_18 on the peclet BLOCK VoF container (WO-W12 gate 4)")
190 print("=" * 100)
191 print(f" grid {NX} x {NY} x {NZ} isotropic (TBFsolver: 192 x 160 x 96 anisotropic)")
192 print(f" box {LXC:.4f} x {LY:.4f} x {LZC:.4f} h (case: {LX:.4f} x {LY:.4f} x {LZ:.4f} h; "
193 f"the cubic-cell rounding costs {100*(LXC/LX-1):+.1f} % in x and "
194 f"{100*(LZC/LZ-1):+.1f} % in z)")
195 print(f" h = {NY/2:.0f} cells, delta_nu = h/Re_tau = {NY/2/RET:.4f} cells -> "
196 f"Delta+ = {RET/(NY/2):.2f} (TBFsolver: 2.08 / 1.59 / 2.08)")
197 print(f" D = {2*Rc:.1f} cells (D/Delta = {2*Rc:.1f}, D+ = {2*RBUB*RET:.1f}); 18 bubbles")
198 print(f" u_tau = {UTAU:.6f}, tau_w = {TAUW:.6e}, Re_tau = {RET}")
199
200 s = pf.Solver(NX, NY, NZ)
201 s.set_rho(RHO_L)
202 s.set_mu(S * S * MU)
203 s.set_domain_bc("-y", "wall", (0, 0, 0)) # -y wall
204 s.set_domain_bc("+y", "wall", (0, 0, 0)) # +y wall
205 s.set_pressure_geometry(np.full((NX, NY, NZ), 10.0, order="F"))
206 s.set_pressure_chebyshev(True, 800, 1e-10)
207
208 if os.path.isdir(TBF):
209 u, v, w = load_tbf_ic()
210 src = f"TBFsolver's own snapshot, resampled ({TBF})"
211 else:
212 u, v, w = laminar_ic()
213 src = "a perturbed parabolic profile (TBFsolver's snapshot not found)"
214 for c, a in enumerate((u, v, w)):
215 s.set_velocity(c, a)
216 print(f" initial condition: {src}")
217 ub = float(u.mean()) / S
218 print(f" bulk velocity of the IC: U_b = {ub:.4f} (Re_b = {ub/MU:.0f}, U_b+ = {ub/UTAU:.2f})")
219
220 if not SINGLE:
221 s.enable_vof()
222 C = np.zeros((NX, NY, NZ), order="F")
223 boxes = []
224 for k in range(NBZ):
225 for i in range(NBX):
226 cx = (i + 0.5) * NX / NBX
227 cy = NY / 2.0
228 cz = (k + 0.5) * NZ / NBZ
229 B = sphere_frac((NX, NY, NZ), Rc, (cx, cy, cz))
230 C = np.maximum(C, B)
231 lo = [int(math.floor(q - Rc)) - 1 for q in (cx, cy, cz)]
232 hi = [int(math.ceil(q + Rc)) + 1 for q in (cx, cy, cz)]
233 boxes.append([lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]])
234 s.set_vof(np.asfortranarray(C))
235 alpha = float(C.sum()) / (NX * NY * NZ)
236 rho_av = RHO_L + (RHO_G - RHO_L) * alpha
237 print(f" void fraction {100*alpha:.3f} % (case: 1.492 %), <rho> = {rho_av:.6f}")
238 # x-momentum source per unit volume, in cell units: tau_w + (rho(C) - <rho>) gCH
239 f0 = S * (TAUW + (RHO_L - rho_av) * GCH)
240 f1 = S * (RHO_G - RHO_L) * GCH
241 s.set_property_model("rho", "linear", "C", [RHO_L, RHO_G - RHO_L])
242 s.set_property_model("mu", "linear", "C", [S * S * MU, 0.0])
243 s.set_property_model("force_x", "linear", "C", [f0, f1])
244 s.set_surface_tension(S ** 3 * SIGMA)
245 s.enable_vof_blocks_from_field(boxes)
246 s.enable_vof_block_csf()
247 print(f" markers seeded: {len(s.diagnostics.vof_block_stats())}; force_x = {f0:.6e} {f1:+.6e} C")
248 else:
249 s.set_property_model("force_x", "const", "C", [S * TAUW])
250 print(f" SINGLE PHASE: force_x = {S*TAUW:.6e}")
251
252 # ---- march
253 yq = (np.arange(NY) + 0.5) / S # wall-normal coordinate, in h
254 accU = np.zeros(NY)
255 accC = np.zeros(NY)
256 nacc = 0
257 t, wall0 = 0.0, time.time()
258 maxit, maxdiv, dt = 0, 0.0, 0.0
259 for i in range(NSTEP):
260 if i % 10 == 0:
261 L = s.vof_step_limits() if not SINGLE else None
262 dt = 0.2 / max(np.abs(s.get_u()).max(), np.abs(s.get_v()).max(),
263 np.abs(s.get_w()).max(), 1e-30)
264 if L is not None:
265 dt = min(dt, 0.4 * L["cfl_dt"], 0.4 * L["capillary_dt"])
266 s.set_dt(dt)
267 t += dt
268 s.step()
269 maxit = max(maxit, s.diagnostics.last_pressure_iterations())
270 maxdiv = max(maxdiv, s.max_open_divergence())
271 if i >= NSTEP // 2: # average over the second half of the window
272 accU += s.get_u().mean(axis=(0, 2))
273 if not SINGLE:
274 accC += s.get_vof().mean(axis=(0, 2))
275 nacc += 1
276 if i % 100 == 0:
277 um = s.get_u().mean() / S
278 print(f" step {i:6d} t u_tau/h = {t*UTAU:7.4f} dt = {dt:.3e} U_b = {um:.4f} "
279 f"press {s.diagnostics.last_pressure_iterations():3d}/800 ({time.time()-wall0:.0f} s)")
280 if not SINGLE:
281 st = s.diagnostics.vof_block_stats()
282 v = [b["volume"] for b in st]
283 print(f" markers {len(st)}: V min {min(v):.2f} max {max(v):.2f} "
284 f"(seed {4/3*math.pi*Rc**3:.2f}), total area "
285 f"{sum(b['area'] for b in st):.0f} cells^2")
286 U = accU / max(nacc, 1) / S
287 Cp = accC / max(nacc, 1)
288 print(f"\n {NSTEP} steps to t u_tau/h = {t*UTAU:.4f} in {time.time()-wall0:.0f} s; "
289 f"pressure {maxit}/800 "
290 f"{'OK' if maxit < 800 else '*** CAPPED -> RUN INVALID ***'}, "
291 f"max|div(open u)| {maxdiv:.2e}")
292 utau_m = math.sqrt(MU * abs(U[0]) / (yq[0]))
293 print(f" wall-gradient u_tau (first cell) {utau_m:.6f} vs the imposed {UTAU:.6f} "
294 f"({100*(utau_m/UTAU-1):+.1f} %)")
295 print("\n y/h y+ <u>/u_tau void fraction")
296 for j in range(0, NY // 2):
297 print(f" {yq[j]:7.4f} {yq[j]/ (MU/UTAU):8.2f} {U[j]/UTAU:12.4f} {Cp[j]:14.5f}")
298 if OUT:
299 np.savetxt(OUT, np.c_[yq, U / UTAU, Cp],
300 header="y/h <u>/u_tau void_fraction (peclet block VoF, channel_18)")
301 print(f"\n written {OUT}")
302
303
304if __name__ == "__main__":
305 main()
tbf_read(path, shape)
interp_component(src, src_pos, dst_pos)
sphere_frac(shape, R, c, sub=16)
arg(name, default, cast=float)
static std::vector< double > slice(const std::vector< double > &g, int ox, int oy, int oz, int lnx, int lny, int lnz)
static double mean(const std::vector< double > &a)