flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
run_channel_18.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""VoF Part III rung W3 (WO-W3) — TBFsolver's `channel_18` to a statistically steady state.
3
4WO-W12 gate 4 transcribed the case and ran it for 0.84 eddy turnovers on one shared workstation
5GPU (`tests/study/vof_channel_18.py`). W3 is the same case run to ~20 eddy turnovers on Snellius
6H100s, which no single job's wall clock can hold, so this driver differs from that one in exactly
7three ways and in nothing else:
8
9 1. **it checkpoints and restarts** — velocity + every marker's OWN colour (`vof_block_color` /
10 `enable_vof_blocks_from_colors`, added for this rung: re-seeding from the union field is
11 NOT exact once two markers touch, WO-W12 open item 5),
12 2. **it accumulates the statistics across chunks** (running sums live in the checkpoint), with an
13 explicit transient window that is discarded, and
14 3. **it stops on a wall-clock budget** as well as on a step/turnover target, so a chunk always
15 ends with a checkpoint on disk.
16
17The physical case is WO-W12's transcription verbatim — see `tests/study/vof_channel_18.py` for the
18derivation of every number from the Fortran that consumes `channel_18/specs`:
19
20 geometry Lx = pi (streamwise, periodic), Ly = 2 (wall-normal, WALLS), Lz = pi/2 (spanwise,
21 periodic), h = 1; mapped onto an ISOTROPIC ny=80 grid -> 128 x 80 x 64.
22 fluids rho_l = 1, rho_g = 0.1; mu_l = mu_g = 3.3333e-4; sigma = 5e-3.
23 driving x-momentum source tau_w + (rho - <rho>) gCH, tau_w = (Re_tau/Re)^2 = 1.800588e-3,
24 gCH = 0.1 a STREAMWISE GRAVITY (buoyancy only) -> u_tau = 0.0424333 by construction.
25 The bubbles are driven against the mean flow: a DOWNFLOW channel.
26 bubbles 18 spheres of R = 0.125 h on the centreline, 6 x 3 in (x, z); void fraction 1.49 %.
27 IC `channel_18/0/{ux,uy,uz}`, TBFsolver's own converged single-phase snapshot,
28 trilinearly resampled onto our grid (U_b = 0.6113).
29
30One eddy turnover is h/u_tau; the driver reports `t u_tau / h` everywhere.
31
32STATISTICS (plane averages over x and z, per wall-normal row; sampled every `--sample` steps and
33accumulated only after `t u_tau/h >= --stats-start`):
34
35 <C>(y) the void fraction (C is the GAS indicator in this transcription:
36 rho = rho_l + (rho_g - rho_l) C)
37 <u>(y), <v>(y), <w>(y) cell-centred velocity means
38 <u'u'>, <v'v'>, <w'w'> rms components (from the accumulated squares)
39 <u'v'> the Reynolds shear stress
40 the same six conditioned on the LIQUID with the weight (1 - C) -> <u>_l etc.
41
42 u_tau is reported three ways: the imposed 0.0424333, the wall gradient of <u>_l at the first
43 cell, and the wall gradient at both walls separately (their spread is the convergence measure).
44
45Usage (one chunk):
46 PYTHONPATH=<build> python run_channel_18.py --ckpt ck.npz --steps 20000 --wall 39000 \
47 --turnovers 20 --stats-start 4 --out results.npz
48Re-running the same command resumes from `ck.npz` and stops when the turnover target is reached.
49"""
50import json
51import math
52import os
53import sys
54import time
55
56import numpy as np
57
58import peclet.flow as pf
59
60
61def arg(name, default, cast=float):
62 if name in sys.argv:
63 return cast(sys.argv[sys.argv.index(name) + 1])
64 return default
65
66
67TBF = str(arg("--tbf", "/home/frankp/Codes/TBFsolver/channel_18/0", str))
68NY = int(arg("--ny", 80, int))
69NSTEP = int(arg("--steps", 20000, int))
70CKPT = str(arg("--ckpt", "channel_18_ckpt.npz", str))
71OUT = str(arg("--out", "channel_18_stats.npz", str))
72LOGJ = str(arg("--chunklog", "channel_18_chunks.jsonl", str))
73WALL = float(arg("--wall", 1e18))
74TARGET = float(arg("--turnovers", 20.0))
75STATS_START = float(arg("--stats-start", 4.0))
76SAMPLE = int(arg("--sample", 10, int))
77CKEVERY = int(arg("--ckpt-every", 2000, int))
78DTEVERY = int(arg("--dt-every", 1, int))
79DTSAFE = float(arg("--dt-safety", 0.25))
80PRINTEVERY = int(arg("--print-every", 200, int))
81QUICK = "--quick" in sys.argv
82
83if QUICK: # build/plumbing validation, not physics
84 NY = int(arg("--ny", 32, int))
85 NSTEP = int(arg("--steps", 40, int))
86 TARGET = float(arg("--turnovers", 1e9))
87 STATS_START = float(arg("--stats-start", 0.0))
88 SAMPLE = int(arg("--sample", 2, int))
89 CKEVERY = int(arg("--ckpt-every", 20, int))
90
91# ---------------------------------------------------------------- the physical case
92LX, LY, LZ = math.pi, 2.0, math.pi / 2 # h = LY/2 = 1
93RHO_L, RHO_G = 1.0, 0.1
94MU = 3.33333333333333e-4
95SIGMA = 5.0e-3
96GCH = 0.1
97RET = 127.3
98RE = 1.0 / MU # rho_l h / mu_l
99TAUW = (RET / RE) ** 2 # = 1.800588e-3, the applied wall stress
100UTAU = RET * MU # = 0.0424333
101RBUB = 0.125
102NBX, NBY, NBZ = 6, 1, 3
103
104# ---------------------------------------------------------------- the isotropic grid
105S = NY / LY # cells per unit length; half-height h = NY/2 cells
106NX = int(round(LX * S / 16)) * 16
107NZ = int(round(LZ * S / 16)) * 16
108LXC, LZC = NX / S, NZ / S
109
110
111# ---------------------------------------------------------------- TBFsolver's own IC
112def tbf_read(path, shape):
113 with open(path, "rb") as f:
114 n = int(np.frombuffer(f.read(4), dtype="<i4")[0])
115 if n != shape[0] * shape[1] * shape[2]:
116 raise ValueError(f"{path}: count {n} != {shape}")
117 a = np.frombuffer(f.read(8 * n), dtype="<f8")
118 return np.asfortranarray(a.reshape(shape, order="F"))
119
120
121def interp_component(src, src_pos, dst_pos):
122 out = src
123 for d, wrap in enumerate((True, False, True)):
124 sp, dp = src_pos[d], dst_pos[d]
125 n = len(sp)
126 if wrap:
127 spx = np.concatenate([sp - 1.0, sp, sp + 1.0])
128 idx = np.clip(np.searchsorted(spx, dp) - 1, 0, len(spx) - 2)
129 w = (dp - spx[idx]) / (spx[idx + 1] - spx[idx])
130 i0, i1 = idx % n, (idx + 1) % n
131 else:
132 idx = np.clip(np.searchsorted(sp, dp) - 1, 0, n - 2)
133 w = np.clip((dp - sp[idx]) / (sp[idx + 1] - sp[idx]), 0.0, 1.0)
134 i0, i1 = idx, idx + 1
135 sl = [slice(None)] * 3
136 sl0, sl1 = list(sl), list(sl)
137 sl0[d], sl1[d] = i0, i1
138 shape = [1, 1, 1]
139 shape[d] = len(dp)
140 out = out[tuple(sl0)] * (1.0 - w.reshape(shape)) + out[tuple(sl1)] * w.reshape(shape)
141 return np.asfortranarray(out)
142
143
145 nxt, nyt, nzt = 192, 160, 96
146 ux = tbf_read(os.path.join(TBF, "ux"), (nxt + 1, nyt, nzt))
147 uy = tbf_read(os.path.join(TBF, "uy"), (nxt, nyt + 1, nzt))
148 uz = tbf_read(os.path.join(TBF, "uz"), (nxt, nyt, nzt + 1))
149 fx = np.arange(nxt + 1) / nxt
150 cx = (np.arange(nxt) + 0.5) / nxt
151 fy = np.arange(nyt + 1) / nyt
152 cy = (np.arange(nyt) + 0.5) / nyt
153 fz = np.arange(nzt + 1) / nzt
154 cz = (np.arange(nzt) + 0.5) / nzt
155 dfx = np.arange(NX) / NX
156 dcx = (np.arange(NX) + 0.5) / NX
157 dfy = np.arange(NY) / NY
158 dcy = (np.arange(NY) + 0.5) / NY
159 dfz = np.arange(NZ) / NZ
160 dcz = (np.arange(NZ) + 0.5) / NZ
161 u = interp_component(ux[:nxt], (fx[:nxt], cy, cz), (dfx, dcy, dcz))
162 v = interp_component(uy[:, :nyt], (cx, fy[:nyt], cz), (dcx, dfy, dcz))
163 w = interp_component(uz[:, :, :nzt], (cx, cy, fz[:nzt]), (dcx, dcy, dfz))
164 return (np.asfortranarray(u * S), np.asfortranarray(v * S), np.asfortranarray(w * S))
165
166
168 y = (np.arange(NY) + 0.5) / NY * LY
169 prof = 1.5 * 0.6113 * (1.0 - (y - 1.0) ** 2)
170 rng = np.random.default_rng(7)
171 u = np.broadcast_to(prof[None, :, None], (NX, NY, NZ)).copy()
172 u += 0.15 * 0.6113 * rng.standard_normal((NX, NY, NZ))
173 v = 0.05 * 0.6113 * rng.standard_normal((NX, NY, NZ))
174 w = 0.05 * 0.6113 * rng.standard_normal((NX, NY, NZ))
175 for a in (u, v, w):
176 a[:, 0, :] *= 0.0
177 a[:, -1, :] *= 0.0
178 return (np.asfortranarray(u * S), np.asfortranarray(v * S), np.asfortranarray(w * S))
179
180
181def sphere_frac(shape, R, c, sub=16):
182 nx, ny, nz = shape
183 ax = (np.arange(nx)[:, None] + (np.arange(sub)[None, :] + 0.5) / sub).ravel()
184 ay = (np.arange(ny)[:, None] + (np.arange(sub)[None, :] + 0.5) / sub).ravel()
185 X, Y = ax[:, None], ay[None, :]
186 half = np.sqrt(np.maximum(R * R - (X - c[0]) ** 2 - (Y - c[1]) ** 2, 0.0))
187 z0, z1 = c[2] - half, c[2] + half
188 C = np.zeros((nx, ny, nz))
189 for k in range(nz):
190 seg = np.maximum(np.minimum(z1, k + 1) - np.maximum(z0, k), 0.0)
191 C[:, :, k] = seg.reshape(nx, sub, ny, sub).mean(axis=(1, 3))
192 return np.asfortranarray(C)
193
194
195# ---------------------------------------------------------------- the accumulator
196KEYS = ("C", "u", "v", "w", "uu", "vv", "ww", "uv",
197 "L", "Lu", "Lv", "Lw", "Luu", "Lvv", "Lww", "Luv")
198
199
201 a = {k: np.zeros(NY) for k in KEYS}
202 a["_n"] = 0.0
203 a["_t0"] = -1.0
204 a["_t1"] = -1.0
205 return a
206
207
208def sample(acc, s, t):
209 """One plane-averaged sample. Velocities are interpolated to CELL CENTRES first.
210
211 flow's `u` is the LOW-x face of each cell and x is periodic, `v` the low-y face (the +y face of
212 the last row is the wall, v = 0), `w` the low-z face with z periodic — so the centred value is
213 the mean of the cell's two faces on each axis.
214 """
215 u = s.get_u()
216 v = s.get_v()
217 w = s.get_w()
218 uc = 0.5 * (u + np.roll(u, -1, axis=0))
219 vc = np.empty_like(v)
220 vc[:, :-1, :] = 0.5 * (v[:, :-1, :] + v[:, 1:, :])
221 vc[:, -1, :] = 0.5 * v[:, -1, :] # +y face of the last row is the wall (v = 0)
222 wc = 0.5 * (w + np.roll(w, -1, axis=2))
223 uc /= S
224 vc /= S
225 wc /= S
226 C = s.get_vof() if s.vof_blocks_enabled() else np.zeros_like(uc)
227 L = 1.0 - C
228 ax = (0, 2)
229 acc["C"] += C.mean(axis=ax)
230 acc["u"] += uc.mean(axis=ax)
231 acc["v"] += vc.mean(axis=ax)
232 acc["w"] += wc.mean(axis=ax)
233 acc["uu"] += (uc * uc).mean(axis=ax)
234 acc["vv"] += (vc * vc).mean(axis=ax)
235 acc["ww"] += (wc * wc).mean(axis=ax)
236 acc["uv"] += (uc * vc).mean(axis=ax)
237 acc["L"] += L.mean(axis=ax)
238 acc["Lu"] += (L * uc).mean(axis=ax)
239 acc["Lv"] += (L * vc).mean(axis=ax)
240 acc["Lw"] += (L * wc).mean(axis=ax)
241 acc["Luu"] += (L * uc * uc).mean(axis=ax)
242 acc["Lvv"] += (L * vc * vc).mean(axis=ax)
243 acc["Lww"] += (L * wc * wc).mean(axis=ax)
244 acc["Luv"] += (L * uc * vc).mean(axis=ax)
245 acc["_n"] += 1.0
246 if acc["_t0"] < 0.0:
247 acc["_t0"] = t
248 acc["_t1"] = t
249
250
251def profiles(acc):
252 """Turn the running sums into the reported profiles (dimensional, then in wall units)."""
253 n = max(acc["_n"], 1.0)
254 m = {k: acc[k] / n for k in KEYS}
255 out = {}
256 yq = (np.arange(NY) + 0.5) / S # wall-normal coordinate in h
257 out["y"] = yq
258 out["yplus"] = yq / (MU / UTAU)
259 out["alpha"] = m["C"] # void fraction
260 out["u"] = m["u"]
261 out["v"] = m["v"]
262 out["w"] = m["w"]
263 out["urms"] = np.sqrt(np.maximum(m["uu"] - m["u"] ** 2, 0.0))
264 out["vrms"] = np.sqrt(np.maximum(m["vv"] - m["v"] ** 2, 0.0))
265 out["wrms"] = np.sqrt(np.maximum(m["ww"] - m["w"] ** 2, 0.0))
266 out["uv"] = m["uv"] - m["u"] * m["v"]
267 Lw = np.maximum(m["L"], 1e-30)
268 ul = m["Lu"] / Lw
269 vl = m["Lv"] / Lw
270 out["u_liq"] = ul
271 out["v_liq"] = vl
272 out["w_liq"] = m["Lw"] / Lw
273 out["urms_liq"] = np.sqrt(np.maximum(m["Luu"] / Lw - ul ** 2, 0.0))
274 out["vrms_liq"] = np.sqrt(np.maximum(m["Lvv"] / Lw - vl ** 2, 0.0))
275 out["wrms_liq"] = np.sqrt(np.maximum(m["Lww"] / Lw - out["w_liq"] ** 2, 0.0))
276 out["uv_liq"] = m["Lvv"] * 0.0 + (m["Luv"] / Lw - ul * vl)
277 out["liquid_fraction"] = m["L"]
278 # u_tau from the wall gradient of the liquid mean, each wall separately
279 dy = 1.0 / S
280 utau_lo = math.sqrt(MU * abs(ul[0]) / (0.5 * dy))
281 utau_hi = math.sqrt(MU * abs(ul[-1]) / (0.5 * dy))
282 out["utau_lo"] = utau_lo
283 out["utau_hi"] = utau_hi
284 out["utau_wall"] = 0.5 * (utau_lo + utau_hi)
285 out["utau_imposed"] = UTAU
286 out["samples"] = acc["_n"]
287 out["window_turnovers"] = np.array([acc["_t0"] * UTAU, acc["_t1"] * UTAU])
288 return out
289
290
291# ---------------------------------------------------------------- checkpoint I/O
292def save_ckpt(path, s, step, t, acc, blocks, alpha0):
293 d = {"step": np.int64(step), "t": np.float64(t), "ny": np.int64(NY),
294 "alpha0": np.float64(alpha0), "nblocks": np.int64(len(blocks))}
295 d["u"] = s.get_u()
296 d["v"] = s.get_v()
297 d["w"] = s.get_w()
298 # The ACCUMULATED physical pressure is state, not a diagnostic: the incremental-rotational
299 # scheme carries P from step to step and the momentum RHS reads -(P(i) - P(i-s)). Dropping it
300 # at a chunk boundary costs a restart transient (measured: 1.6e-3 relative in u after 20 steps
301 # of a quick run, against 3e-14 with it restored). "p" is the registered name of that field.
302 d["p"] = s.get_field("p")
303 for k in KEYS:
304 d["acc_" + k] = acc[k]
305 d["acc_n"] = np.float64(acc["_n"])
306 d["acc_t0"] = np.float64(acc["_t0"])
307 d["acc_t1"] = np.float64(acc["_t1"])
308 for i, (box, col) in enumerate(blocks):
309 d[f"box{i}"] = np.asarray(box, dtype=np.int64)
310 d[f"col{i}"] = np.asarray(col, dtype=np.float64)
311 tmp = path + ".tmp.npz"
312 np.savez(tmp, **d)
313 os.replace(tmp, path) # atomic: a killed job never leaves a torn file
314
315
316def load_ckpt(path):
317 z = np.load(path)
318 if int(z["ny"]) != NY:
319 raise SystemExit(f"checkpoint {path} is ny={int(z['ny'])}, this run is ny={NY}")
320 acc = new_acc()
321 for k in KEYS:
322 acc[k] = np.array(z["acc_" + k])
323 acc["_n"] = float(z["acc_n"])
324 acc["_t0"] = float(z["acc_t0"])
325 acc["_t1"] = float(z["acc_t1"])
326 nb = int(z["nblocks"])
327 blocks = [(np.array(z[f"box{i}"]).tolist(), np.asfortranarray(z[f"col{i}"]))
328 for i in range(nb)]
329 pres = np.asfortranarray(z["p"]) if "p" in z.files else None
330 return int(z["step"]), float(z["t"]), acc, blocks, \
331 (np.asfortranarray(z["u"]), np.asfortranarray(z["v"]), np.asfortranarray(z["w"])), \
332 float(z["alpha0"]), pres
333
334
336 """{box, own colour} per marker — the complete state of the block container."""
337 out = []
338 for b in s.diagnostics.vof_block_stats():
339 lo, hi = b["lo"], b["hi"]
340 c = s.vof_block_color(b["id"])
341 out.append(([lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]], np.asfortranarray(c)))
342 return out
343
344
345# ---------------------------------------------------------------- the run
346def main():
347 Rc = RBUB * S
348 resume = os.path.exists(CKPT)
349 print("=" * 100)
350 print("TBFsolver channel_18 on the peclet BLOCK VoF container — WO-W3 (statistically steady)")
351 print("=" * 100)
352 print(f" grid {NX} x {NY} x {NZ} isotropic (TBFsolver: 192 x 160 x 96 anisotropic)")
353 print(f" box {LXC:.4f} x {LY:.4f} x {LZC:.4f} h (case: {LX:.4f} x {LY:.4f} x {LZ:.4f} h)")
354 print(f" Delta+ = {RET/(NY/2):.2f}, D = {2*Rc:.1f} cells, 18 bubbles, "
355 f"u_tau = {UTAU:.6f}, tau_w = {TAUW:.6e}")
356 print(f" target {TARGET} eddy turnovers; statistics start at t u_tau/h = {STATS_START}; "
357 f"sample every {SAMPLE} steps; wall budget {WALL:.0f} s")
358 print(f" checkpoint {CKPT} ({'RESUME' if resume else 'fresh start'}), out {OUT}")
359
360 s = pf.Solver(NX, NY, NZ)
361 s.set_rho(RHO_L)
362 s.set_mu(S * S * MU)
363 s.set_domain_bc("-y", "wall", (0, 0, 0)) # -y wall
364 s.set_domain_bc("+y", "wall", (0, 0, 0)) # +y wall
365 s.set_pressure_geometry(np.full((NX, NY, NZ), 10.0, order="F"))
366 s.set_pressure_chebyshev(True, 800, 1e-10)
367
368 step0, t, acc = 0, 0.0, new_acc()
369 alpha0, pres = None, None
370 if resume:
371 step0, t, acc, blocks, (u, v, w), alpha0, pres = load_ckpt(CKPT)
372 print(f" resumed at step {step0}, t u_tau/h = {t*UTAU:.4f}, "
373 f"{int(acc['_n'])} samples already accumulated, {len(blocks)} markers")
374 else:
375 if os.path.isdir(TBF):
376 u, v, w = load_tbf_ic()
377 print(f" initial condition: TBFsolver's own snapshot, resampled ({TBF})")
378 else:
379 u, v, w = laminar_ic()
380 print(" initial condition: a perturbed parabolic profile (snapshot not found)")
381 blocks = None
382 for c, a in enumerate((u, v, w)):
383 s.set_velocity(c, a)
384 print(f" bulk velocity: U_b = {float(u.mean())/S:.4f}")
385
386 s.enable_vof()
387 if blocks is None:
388 C = np.zeros((NX, NY, NZ), order="F")
389 boxes = []
390 for k in range(NBZ):
391 for i in range(NBX):
392 cx = (i + 0.5) * NX / NBX
393 cy = NY / 2.0
394 cz = (k + 0.5) * NZ / NBZ
395 B = sphere_frac((NX, NY, NZ), Rc, (cx, cy, cz))
396 C = np.maximum(C, B)
397 lo = [int(math.floor(q - Rc)) - 1 for q in (cx, cy, cz)]
398 hi = [int(math.ceil(q + Rc)) + 1 for q in (cx, cy, cz)]
399 boxes.append([lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]])
400 s.set_vof(np.asfortranarray(C))
401 alpha0 = float(C.sum()) / (NX * NY * NZ)
402 else:
403 C = np.zeros((NX, NY, NZ), order="F")
404 s.set_vof(C) # the union is rebuilt by the seeding scatter
405 rho_av = RHO_L + (RHO_G - RHO_L) * alpha0
406 f0 = S * (TAUW + (RHO_L - rho_av) * GCH)
407 f1 = S * (RHO_G - RHO_L) * GCH
408 s.set_property_model("rho", "linear", "C", [RHO_L, RHO_G - RHO_L])
409 s.set_property_model("mu", "linear", "C", [S * S * MU, 0.0])
410 s.set_property_model("force_x", "linear", "C", [f0, f1])
411 s.set_surface_tension(S ** 3 * SIGMA)
412 if blocks is None:
413 s.enable_vof_blocks_from_field(boxes)
414 else:
415 s.enable_vof_blocks_from_colors([b for b, _ in blocks], [c for _, c in blocks])
416 s.enable_vof_block_csf()
417 st0 = s.diagnostics.vof_block_stats()
418 vol0 = [b["volume"] for b in st0]
419 print(f" void fraction {100*alpha0:.3f} % (case: 1.492 %), <rho> = {rho_av:.6f}; "
420 f"{len(st0)} markers, V in [{min(vol0):.3f}, {max(vol0):.3f}] "
421 f"(sphere seed {4/3*math.pi*Rc**3:.3f})")
422
423 # AFTER the geometry (set_pressure_geometry zeroes P and phi when it rebuilds the operator).
424 if pres is not None:
425 s.set_field("p", pres)
426 print(" accumulated pressure restored from the checkpoint")
427 # The Weymouth-Yue SWEEP PERMUTATION is state too: the order is kWySweepPerm[n % 6] and the
428 # block container's counter runs from 0, one increment per block advection — i.e. it equals the
429 # step index. Resuming with it reset changes the colour at the splitting error (measured
430 # 6.2e-4 after ONE step, off a bitwise-identical velocity). set_vof_step_parity sets the
431 # solver's counter AND the block container's.
432 s.diagnostics.set_vof_step_parity(step0)
433
434 seedV = 4.0 / 3.0 * math.pi * Rc ** 3
435 if NSTEP == 0: # state round-trip diagnostic: checkpoint the restored state and stop
436 save_ckpt(CKPT + ".roundtrip.npz", s, step0, t, acc, block_state(s), alpha0)
437 print(f" --steps 0: wrote {CKPT}.roundtrip.npz and stopped")
438 return
439 maxit, maxdiv, dt = 0, 0.0, 0.0
440 wall0 = time.time()
441 stepped = 0
442 stop = "steps"
443 i = step0
444 while stepped < NSTEP:
445 if i % DTEVERY == 0: # ABSOLUTE cadence: a restart must not shift it
446 L = s.vof_step_limits()
447 dt = 0.2 / max(np.abs(s.get_u()).max(), np.abs(s.get_v()).max(),
448 np.abs(s.get_w()).max(), 1e-30)
449 # A NON-FINITE limit is a state failure, not a large dt, and Python's min() would let
450 # it through silently (min(x, nan) == x): the capillary limit is
451 # sqrt((rho_min + rho_max) h^3 / 4 pi sigma), so it goes -nan the moment the colour
452 # leaves [0,1] far enough to make a density negative. Refuse to step on it.
453 for nm in ("cfl_dt", "capillary_dt"):
454 if not (L[nm] > 0.0 and math.isfinite(L[nm])):
455 with open(CKPT + ".failed", "w") as f:
456 f.write(json.dumps({"step": i, "t": t, "turnovers": t * UTAU,
457 "limit": nm, "value": L[nm],
458 "vof": s.diagnostics.vof_diagnostics()}, default=float) + "\n")
459 raise SystemExit(
460 f" *** step {i}: {nm} = {L[nm]} at t u_tau/h = {t*UTAU:.4f} — the state "
461 f"is already broken (the colour has left [0,1]); the last checkpoint is "
462 f"untouched.")
463 dt = min(dt, DTSAFE * L["cfl_dt"], DTSAFE * L["capillary_dt"])
464 s.set_dt(dt)
465 t += dt
466 try:
467 s.step()
468 except Exception as exc: # a Weymouth-Yue boundedness throw, or worse
469 # step() is NOT atomic across that throw (VOF_PLAN 13.2 item 9: the momentum half has
470 # already advanced by the rejected dt), so catch-and-halve would desynchronise colour
471 # and momentum. Leave the LAST GOOD checkpoint untouched, record what happened, and
472 # fail the chunk so `--dependency=afterok` stops the chain instead of marching on.
473 with open(CKPT + ".failed", "w") as f:
474 f.write(json.dumps({"step": i, "t": t, "turnovers": t * UTAU, "dt": dt,
475 "error": repr(exc)}) + "\n")
476 print(f"\n *** step {i} FAILED at t u_tau/h = {t*UTAU:.4f}, dt = {dt:.3e}: {exc}")
477 print(f" the last checkpoint ({CKPT}) is untouched; restart from it with a smaller "
478 f"dt safety factor. Chain stopped.")
479 raise
480 i += 1
481 stepped += 1
482 it = s.diagnostics.last_pressure_iterations()
483 maxit = max(maxit, it)
484 maxdiv = max(maxdiv, s.max_open_divergence())
485 if t * UTAU >= STATS_START and (i % SAMPLE == 0):
486 sample(acc, s, t)
487 if i % PRINTEVERY == 0:
488 el = time.time() - wall0
489 D = s.diagnostics.vof_diagnostics()
490 cmx = max(float(D.get("max", 0.0)), float(D.get("max_fluid", 0.0)))
491 umx = max(np.abs(s.get_u()).max(), np.abs(s.get_v()).max(), np.abs(s.get_w()).max())
492 st = s.diagnostics.vof_block_stats()
493 cen = np.array([b["centroid"] for b in st])
494 box = np.array([NX, NY, NZ], dtype=float)
495 dmin = 1e30
496 for a in range(len(cen)):
497 for b2 in range(a + 1, len(cen)):
498 q = cen[a] - cen[b2]
499 q[0] -= box[0] * round(q[0] / box[0])
500 q[2] -= box[2] * round(q[2] / box[2])
501 dmin = min(dmin, float(np.hypot(np.hypot(q[0], q[1]), q[2])))
502 print(f" step {i:7d} t u_tau/h = {t*UTAU:8.4f} dt = {dt:.3e} "
503 f"U_b = {s.get_u().mean()/S:.4f} press {it:3d}/800 maxC {cmx:.6f} "
504 f"max|u| {umx:8.2f} dmin {dmin:6.2f} "
505 f"{1000*el/max(stepped,1):.0f} ms/step ({el:.0f} s)", flush=True)
506 # The colour leaving [0,1] is the Weymouth-Yue boundedness cap being exceeded, and it
507 # is QUIET: volume still telescopes exactly, so nothing else notices until a density
508 # goes negative and the capillary limit returns -nan. Stop while the checkpoint is
509 # still good.
510 if not (cmx <= 1.0 + 1e-6):
511 with open(CKPT + ".failed", "w") as f:
512 f.write(json.dumps({"step": i, "t": t, "turnovers": t * UTAU,
513 "max_C": cmx, "dt": dt}, default=float) + "\n")
514 raise SystemExit(f" *** step {i}: max C = {cmx} > 1 — Weymouth-Yue boundedness "
515 f"lost. Reduce --dt-safety and restart from the checkpoint.")
516 done_t = t * UTAU >= TARGET
517 done_w = (time.time() - wall0) > WALL
518 if done_t or done_w or (i % CKEVERY == 0) or stepped >= NSTEP:
519 save_ckpt(CKPT, s, i, t, acc, block_state(s), alpha0)
520 if done_t or done_w:
521 stop = "turnovers" if done_t else "wall"
522 break
523
524 el = time.time() - wall0
525 st = s.diagnostics.vof_block_stats()
526 vols = [b["volume"] for b in st]
527 capped = maxit >= 800
528 rec = {
529 "stop": stop, "step": i, "steps_this_chunk": stepped, "t": t,
530 "turnovers": t * UTAU, "wall_s": el, "ms_per_step": 1000 * el / max(stepped, 1),
531 "max_pressure_iterations": maxit, "pressure_cap": 800, "capped": bool(capped),
532 "max_open_divergence": maxdiv, "markers": len(st),
533 "volume_min": min(vols), "volume_max": max(vols), "volume_seed": seedV,
534 "volume_rel_err": max(abs(v / seedV - 1.0) for v in vols),
535 "area_total": sum(b["area"] for b in st),
536 "samples": acc["_n"], "grid": [NX, NY, NZ],
537 }
538 print("\n " + json.dumps(rec))
539 with open(LOGJ, "a") as f:
540 f.write(json.dumps(rec) + "\n")
541 if capped:
542 print(" *** PRESSURE CAPPED -> THIS CHUNK IS INVALID (rule 3b) ***")
543
544 if acc["_n"] > 0:
545 pr = profiles(acc)
546 np.savez(OUT, **{k: np.asarray(v) for k, v in pr.items()},
547 turnovers=np.float64(t * UTAU), step=np.int64(i),
548 grid=np.asarray([NX, NY, NZ]), utau=np.float64(UTAU))
549 print(f" profiles written to {OUT} ({int(acc['_n'])} samples over "
550 f"t u_tau/h {pr['window_turnovers'][0]:.3f} .. {pr['window_turnovers'][1]:.3f})")
551 print(f" u_tau: imposed {UTAU:.6f}, wall gradient {pr['utau_wall']:.6f} "
552 f"({100*(pr['utau_wall']/UTAU-1):+.1f} %; walls {pr['utau_lo']:.6f} / "
553 f"{pr['utau_hi']:.6f})")
554 print("\n y/h y+ <u>/u_tau <u>_liq/u_tau alpha u'/u_tau v'/u_tau "
555 "w'/u_tau -<u'v'>/u_tau^2")
556 for j in range(NY):
557 print(f" {pr['y'][j]:7.4f} {pr['yplus'][j]:8.2f} {pr['u'][j]/UTAU:11.4f} "
558 f"{pr['u_liq'][j]/UTAU:13.4f} {pr['alpha'][j]:9.5f} "
559 f"{pr['urms'][j]/UTAU:9.4f} {pr['vrms'][j]/UTAU:9.4f} "
560 f"{pr['wrms'][j]/UTAU:9.4f} {-pr['uv'][j]/UTAU**2:15.5f}")
561 else:
562 print(" no statistics yet (still inside the discarded transient)")
563 print(f" chunk ended on '{stop}' at step {i}, t u_tau/h = {t*UTAU:.4f}")
564 if stop == "turnovers":
565 # the marker a chained job looks for: the target is reached, later chunks do nothing
566 with open(CKPT + ".done", "w") as f:
567 f.write(json.dumps(rec) + "\n")
568 print(f" TARGET REACHED -> {CKPT}.done written")
569
570
571if __name__ == "__main__":
572 main()
tbf_read(path, shape)
arg(name, default, cast=float)
save_ckpt(path, s, step, t, acc, blocks, alpha0)
sample(acc, s, t)
interp_component(src, src_pos, dst_pos)
sphere_frac(shape, R, c, sub=16)
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)