flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_open_boundaries.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""VoF rung V-BC (WO-R) — two-phase open boundaries: the physics battery.
3
4The *algebraic* gates of this rung (the out-of-domain mask, the bitwise inertness of the boundary
5flux rule, the backflow rule, the inflow property ghost, the variable-density outflow correction
6and the exact colour budget under a prescribed divergence-free field) live in
7`tests/kokkos/test_vof_bc.cpp` and run in the `vof_bc` ctest. What is here is the part that needs a
8COUPLED solve and a number somebody else published:
9
10 budget WO-R gate G1, COUPLED: the same slug injection driven by step(), so the advecting
11 field is the projection's own output and the budget's floor is the projection's
12 divergence residual rather than zero. Records the pressure iteration count.
13 nusselt WO-R gate G2: a falling liquid film fed by a prescribed Nusselt inlet profile,
14 against the analytical flow rate. Run at density ratio 100 (V2a regime) and at
15 1000 with momentum consistency. This is the gate on the 1/rho_f outflow
16 correction: `--no-outflow-rho` restores the pre-WO-R correction and the
17 script reports BOTH columns.
18 pool WO-R gate G3: gas blown over a resting liquid pool at ratio 1000. Gates the pool's
19 volume, the velocity it is allowed to pick up, and the inflow ghost DENSITY.
20
21Units: cell size 1, time in seconds, exactly as `vof_surface_tension.py`; the cases here are
22stated directly in solver units because the reference (Nusselt) is a similarity solution and only
23the dimensionless groups matter.
24
25EVERY gate that reports a functional records the max pressure-iteration count against its cap and
26marks a capped run INVALID rather than merely flagging it (shared preamble rule 3b).
27
28Usage: PYTHONPATH=<build> python tests/study/vof_open_boundaries.py [gate ...] [--quick]
29"""
30import os
31import sys
32import time
33
34import numpy as np
35
36import peclet.flow as pf
37
38QUICK = "--quick" in sys.argv
39GATES = [a for a in sys.argv[1:] if not a.startswith("--")]
40
41
42class Solve:
43 """A run's solver health, per the shared preamble's rule 3b."""
44
45 def __init__(self, cap):
46 self.cap = cap
47 self.iters = 0
48 self.div = 0.0
49
50 def sample(self, s):
51 self.iters = max(self.iters, s.diagnostics.last_pressure_iterations())
52 # max_open_divergence() RE-IMPOSES the zero-gradient outflow face before measuring, which
53 # both destroys bcCorrectOutflow's correction (so calling it once per step changes the run)
54 # and reports a field the solver never used. On an open boundary use the projected sibling.
55 self.div = max(self.div, s.max_open_divergence_projected())
56
57 @property
58 def valid(self):
59 return self.iters < self.cap
60
61 def __str__(self):
62 return (f"pressure {self.iters}/{self.cap} "
63 f"{'OK' if self.valid else '*** CAPPED -> RUN INVALID ***'}, "
64 f"max|div(open u)| {self.div:.2e}")
65
66
67def to_host(view):
68 """`field_view` returns the PADDED buffer — NumPy on a host build, DLPack on a device one."""
69 try:
70 a = np.asarray(view)
71 if a.ndim == 3:
72 return a
73 except Exception:
74 pass
75 import cupy as cp
76 return cp.asnumpy(cp.from_dlpack(view))
77
78
79def maxvel(s):
80 return max(np.abs(s.get_u()).max(), np.abs(s.get_v()).max(), np.abs(s.get_w()).max())
81
82
83# --------------------------------------------------------------------------- G1 coupled budget
85 print("\n" + "=" * 96)
86 print("G1 COLOUR BUDGET, COUPLED (WO-R gate G1 driven by step(), not by a prescribed field)")
87 print("=" * 96)
88 print(" A liquid slug is injected through a uniform -z inlet and flushed out of the +z")
89 print(" outlet, with the full momentum + projection step in between. The gate is")
90 print(" sum(C)(t) - integral(inflow liquid) + integral(outflow liquid) = const,")
91 print(" the fluxes being the ADVECTOR's own boundary face fluxes (vof_bc_volumes). Unlike the")
92 print(" kinematic ctest twin, the advecting field here is the projection's output, so the")
93 print(" floor is the projection's discrete divergence residual (WO-J finding 3).\n")
94 nx = ny = 32 if not QUICK else 16
95 nz = 64 if not QUICK else 32
96 nslug, nafter = (60, 740) if not QUICK else (20, 120)
97 W, dt = 0.5, 0.2
98 s = pf.Solver(nx, ny, nz)
99 s.set_rho(1.0)
100 s.set_mu(0.5)
101 s.set_dt(dt)
102 for f in ("-x", "+x", "-y", "+y"):
103 s.set_domain_bc(f, "wall", (0, 0, 0))
104 s.set_domain_bc("-z", "inflow", (0.0, 0.0, W))
105 s.set_domain_bc("+z", "outflow", (0, 0, 0))
106 # the open-boundary duct settings the validated channel case uses
107 # (scripts/verify_channel_sdflow.py): a deep semi-coarsening hierarchy, enough momentum
108 # sweeps, and the all-fluid SDF far from every cell. With the defaults this configuration
109 # diverges — see the findings entry.
110 s.diagnostics.set_velocity_solver_params(60)
111 s.set_pressure_multigrid(True, levels=8)
112 s.set_pressure_solver_params(80)
113 s.set_pressure_geometry(np.full((nx, ny, nz), 1e30, order="F"))
114 s.set_pressure_chebyshev(True, 400, 1e-12)
115 s.enable_vof()
116 s.set_vof(np.zeros((nx, ny, nz), order="F"))
117 s.set_vof_inflow("-z", 1.0)
118 s.set_vof_backflow("+z", 0.0)
119 h = Solve(400)
120 ledger, budget0, drift = 0.0, None, 0.0
121 cmin, cmax = 1.0, 0.0
122 t0 = time.time()
123 for i in range(nslug + nafter):
124 if i == nslug:
125 s.set_vof_inflow("-z", 0.0)
126 s.step()
127 h.sample(s)
128 d = s.diagnostics.vof_diagnostics()
129 ledger += sum(s.vof_bc_volumes())
130 b = d["sum"] - ledger
131 if budget0 is None:
132 budget0 = b
133 drift = max(drift, abs(b - budget0))
134 cmin, cmax = min(cmin, d["min"]), max(cmax, d["max"])
135 tot = s.vof_bc_volumes_total()
136 injected, left = tot[4], -tot[5]
137 d = s.diagnostics.vof_diagnostics()
138 print(f" injected {injected:.10g}, left {left:.10g}, remaining sum(C) {d['sum']:.3e}")
139 print(f" |budget drift| {drift:.3e} (relative to the injected volume {drift/injected:.3e})")
140 print(f" C in [{cmin:.3e}, {cmax:.17g}]")
141 print(f" {h} ({time.time()-t0:.0f} s)")
142 ok = (drift / injected < 1e-10) and h.valid and cmin > -1e-12 and cmax < 1 + 1e-12
143 print(f" GATE: {'PASS' if ok else 'FAIL'} (budget <= 1e-10 relative, C bounded, no cap)")
144 return ok
145
146
147# ------------------------------------------------------------------------ G2 Nusselt falling film
148# Both legs keep rho_l = 100, mu_l = 50, delta = 8 and the SAME film Reynolds number 5 and the
149# same Nusselt flow rate Q: only the gas is made lighter and the body force is rescaled by
150# 99/drho, so the two rows differ in the density RATIO and in nothing else. dt is set by the WY
151# Courant cap on the film's own velocity (the same u_max in both rows) with margin for the gas.
152NUSSELT = {
153 100: dict(rho_l=100.0, rho_g=1.0, mu_l=50.0, mu_g=1.0, dt=0.5, mom=True, cap=400),
154 1000: dict(rho_l=100.0, rho_g=0.1, mu_l=50.0, mu_g=1.0, dt=0.25, mom=True, cap=2000),
155}
156
157
158def gate_nusselt(ratio=100, steps=None, outflow_rho=True):
159 p = NUSSELT[ratio]
160 nx, ny, nz = 32, 4, 64
161 delta = 8.0
162 drho = p["rho_l"] - p["rho_g"]
163 p = dict(p, g=4.93e-3 * 99.0 / drho)
164 umax = drho * p["g"] * delta ** 2 / (2 * p["mu_l"])
165 Q = drho * p["g"] * delta ** 3 / (3 * p["mu_l"]) # flow rate per unit width, cells^2/s
166 Re = p["rho_l"] * umax * delta / p["mu_l"]
167 steps = steps or (900 if QUICK else 2400)
168 print("\n" + "=" * 96)
169 print(f"G2 NUSSELT FALLING FILM, density ratio {ratio}, mu ratio {p['mu_l']/p['mu_g']:.0f}"
170 f"{'' if outflow_rho else ' [ABLATION: --no-outflow-rho]'}")
171 print("=" * 96)
172 print(" A liquid film of thickness delta = 8 cells runs down the -x wall of a quasi-2D duct,")
173 print(" fed at the +z inlet with the exact Nusselt profile and leaving through the -z outlet.")
174 print(" The body force is the REDUCED gravity -(rho_l - rho_g) g C: the gas is then exactly")
175 print(" force-free and hydrostatic, which is what the Nusselt similarity solution assumes")
176 print(" (with gravity on the gas too, the Dirichlet p=0 outlet would accelerate the gas")
177 print(" column instead of supporting it — a different problem).")
178 print(f" u_max {umax:.5g} cells/s, Q {Q:.5g} cells^2/s, film Reynolds {Re:.2f}\n")
179 s = pf.Solver(nx, ny, nz)
180 s.set_rho(p["rho_l"])
181 s.set_mu(p["mu_l"])
182 s.set_dt(p["dt"])
183 s.set_domain_bc("-x", "wall", (0, 0, 0))
184 s.set_domain_bc("+x", "wall", (0, 0, 0)) # walls +-x (the film wall and the far wall)
185 s.set_domain_bc("-z", "outflow", (0, 0, 0)) # outflow at the BOTTOM
186 # inlet velocity profile on face 5 (+z): axes (b, c) = (x, y)
187 xc = np.arange(nx) + 0.5
188 wprof = np.where(xc < delta, -(drho * p["g"] / p["mu_l"]) * (delta * xc - 0.5 * xc ** 2), 0.0)
189 prof = np.zeros((nx, ny, 3))
190 prof[:, :, 2] = wprof[:, None]
191 s.set_domain_bc_profile("+z", np.ascontiguousarray(prof))
192 # the open-boundary duct settings the validated channel case uses
193 # (scripts/verify_channel_sdflow.py): a deep semi-coarsening hierarchy, enough momentum
194 # sweeps, and the all-fluid SDF far from every cell. With the defaults this configuration
195 # diverges — see the findings entry.
196 s.diagnostics.set_velocity_solver_params(60)
197 s.set_pressure_multigrid(True, levels=8)
198 s.set_pressure_solver_params(80)
199 s.set_pressure_geometry(np.full((nx, ny, nz), 1e30, order="F"))
200 s.enable_vof()
201 # the initial state IS the developed film, so the run only has to hold it
202 C = np.zeros((nx, ny, nz), order="F")
203 frac = np.clip(delta - np.arange(nx), 0.0, 1.0) # exact fractions of the x < delta slab
204 C[:, :, :] = frac[:, None, None]
205 s.set_vof(np.asfortranarray(C))
206 s.set_property_model("rho", "linear", "C", [p["rho_g"], p["rho_l"] - p["rho_g"]])
207 s.set_property_model("mu", "linear", "C", [p["mu_g"], p["mu_l"] - p["mu_g"]])
208 s.set_property_model("force_z", "linear", "C", [0.0, -drho * p["g"]])
209 if p["mom"]:
210 s.enable_vof_momentum(p["rho_g"], p["rho_l"])
211 # THE DRIVER IS SELECTED LAST, AND IT IS FCG. Two measured reasons, both recorded in the
212 # findings: (i) `set_property_model("rho", ...)` fires `set_density_mode`, which reselects
213 # Chebyshev — a driver chosen BEFORE the closure is silently discarded (this is the WO-H
214 # "capped at 120" tell, alive and well); (ii) on this configuration Chebyshev DIVERGES (the
215 # film accelerates until the WY cap throws) and MG-PCG burns any cap, while FCG converges.
216 s.set_pressure_fcg(True, p["cap"], 1e-11)
217 # the inlet carries the same film: colour profile 1 in the film, 0 in the gas
218 s.set_vof_inflow_profile("+z", np.ascontiguousarray(np.repeat(frac[:, None], ny, axis=1)))
219 s.set_vof_backflow("-z", 0.0)
220 # seed the interior with the analytical velocity so the transient is short
221 w0 = np.zeros((nx, ny, nz), order="F")
222 w0[:, :, :] = wprof[:, None, None]
223 s.set_field("w", np.asfortranarray(w0))
224
225 h = Solve(p["cap"])
226 hist = []
227 t0 = time.time()
228 # ADAPTIVE dt, re-picked every 20 steps from the solver's own interface-local Weymouth-Yue
229 # limit — the same pattern `vof_surface_tension.py::gate_hysing` uses, and necessary rather
230 # than cosmetic: the film's free surface speeds up as it develops and a fixed dt sized on the
231 # ANALYTICAL u_max trips the WY boundedness cap part-way through the run.
232 dt = p["dt"]
233 ncap = 0
234 for i in range(steps):
235 if i % 20 == 0:
236 L = s.vof_step_limits()
237 dt = min(p["dt"], 0.4 * L["cfl_dt"]) if L["cfl_dt"] > 0 else p["dt"]
238 s.set_dt(dt)
239 ncap += int(dt < p["dt"])
240 s.step()
241 h.sample(s)
242 hist.append(s.diagnostics.vof_diagnostics()["sum"])
243 C = s.get_vof()
244 w = s.get_w()
245 zmid = nz // 2
246 dmeas = float(C[:, :, zmid].sum() / ny)
247 qmeas = float(-(C[:, :, zmid] * w[:, :, zmid]).sum() / ny)
248 tail = np.array(hist[-200:])
249 steady = float(np.abs(tail - tail.mean()).max() / max(tail.mean(), 1e-300))
250 print(f" at z = nz/2: film thickness {dmeas:.4f} cells (target {delta}) "
251 f"[error {dmeas-delta:+.4f}]")
252 print(f" flow rate {qmeas:.6g} (Nusselt {Q:.6g}) "
253 f"[{100*(qmeas/Q-1):+.2f} %]")
254 print(f" sum(C) steadiness over the last 200 steps: {steady:.3e} relative")
255 print(f" dt: nominal {p['dt']}, throttled by the WY cap on {ncap} of the "
256 f"{(steps + 19)//20} re-picks; final {dt:.4g}")
257 print(f" {h} ({time.time()-t0:.0f} s)")
258 ok = (abs(qmeas / Q - 1) < 0.03 and abs(dmeas - delta) < 0.5 and steady < 1e-4 and h.valid)
259 print(f" GATE: {'PASS' if ok else 'FAIL'} (Q within 3 %, delta within 0.5 cell, "
260 f"sum(C) steady to 1e-4, no cap)")
261 return ok, qmeas / Q - 1, dmeas - delta, steady, h.iters
262
263
264# ------------------------------------------------------------------------- G3 gas over a pool
266 print("\n" + "=" * 96)
267 print("G3 GAS OVER A RESTING POOL, density ratio 1000, momentum-consistent transport")
268 print("=" * 96)
269 print(" Gas enters at -x above a liquid pool that fills the lower half, and leaves at +x. The")
270 print(" pool must not move and must not lose volume, and the inflow ghost DENSITY must be the")
271 print(" INLET fluid's — gas above the pool, liquid at the pool's own inlet plane.")
272 print("")
273 print(" NOTE ON THE GATE AS WRITTEN. WO-R asks for the ghost density 'through get_field(rho)")
274 print(" on the first inner plane'. That measures the wrong quantity: get_field returns the")
275 print(" INNER cells, whose rho is rho(C_inner) whatever the ghost policy does. The ghost is")
276 print(" what the inlet FACE density is built from, and it is reachable only through")
277 print(" field_view('rho'), which returns the padded buffer. That is what is gated below.\n")
278 nx = 64 if not QUICK else 32
279 ny = 4
280 nz = 32 if not QUICK else 16
281 steps = 500 if not QUICK else 150
282 rho_l, rho_g = 1000.0, 1.0
283 mu_l, mu_g = 10.0, 0.1
284 U = 0.4
285 s = pf.Solver(nx, ny, nz)
286 s.set_rho(rho_l)
287 s.set_mu(mu_l)
288 s.set_dt(0.2)
289 s.set_domain_bc("-z", "wall", (0, 0, 0))
290 s.set_domain_bc("+z", "wall", (0, 0, 0)) # walls +-z (the pool floor and the lid)
291 s.set_domain_bc("+x", "outflow", (0, 0, 0)) # outflow at +x
292 # inflow at -x: gas only above the pool. Face 0 -> (b, c) = (y, z).
293 zc = np.arange(nz) + 0.5
294 gas = zc >= nz / 2
295 prof = np.zeros((ny, nz, 3))
296 prof[:, :, 0] = np.where(gas, U, 0.0)[None, :]
297 s.set_domain_bc_profile("-x", np.ascontiguousarray(prof))
298 # the open-boundary duct settings the validated channel case uses
299 # (scripts/verify_channel_sdflow.py): a deep semi-coarsening hierarchy, enough momentum
300 # sweeps, and the all-fluid SDF far from every cell. With the defaults this configuration
301 # diverges — see the findings entry.
302 s.diagnostics.set_velocity_solver_params(60)
303 s.set_pressure_multigrid(True, levels=8)
304 s.set_pressure_solver_params(80)
305 s.set_pressure_geometry(np.full((nx, ny, nz), 1e30, order="F"))
306 s.enable_vof()
307 C = np.zeros((nx, ny, nz), order="F")
308 C[:, :, : nz // 2] = 1.0
309 s.set_vof(np.asfortranarray(C))
310 s.set_property_model("rho", "linear", "C", [rho_g, rho_l - rho_g])
311 s.set_property_model("mu", "linear", "C", [mu_g, mu_l - mu_g])
312 s.enable_vof_momentum(rho_g, rho_l)
313 s.set_pressure_fcg(True, 800, 1e-11) # driver LAST — see gate_nusselt
314 # the inlet colour is the pool's own: liquid below, gas above
315 cprof = np.where(gas, 0.0, 1.0)
316 s.set_vof_inflow_profile("-x", np.ascontiguousarray(np.repeat(cprof[None, :], ny, axis=0)))
317 s.set_vof_backflow("+x", 0.0)
318
319 h = Solve(800)
320 v0 = s.diagnostics.vof_diagnostics()["sum"]
321 t0 = time.time()
322 for _ in range(steps):
323 s.step()
324 h.sample(s)
325 v1 = s.diagnostics.vof_diagnostics()["sum"]
326 C = s.get_vof()
327 u, v, w = s.get_u(), s.get_v(), s.get_w()
328 liq = C > 0.999
329 umax_liq = float(np.max([np.abs(f[liq]).max() for f in (u, v, w)]))
330 rho = to_host(s.diagnostics.field_view("rho"))
331 G = (rho.shape[0] - nx) // 2
332 ghost = rho[G - 1, G + ny // 2, :] # the -x inflow ghost column
333 gh_gas = float(np.mean(ghost[G + nz // 2: G + nz]))
334 gh_liq = float(np.mean(ghost[G: G + nz // 2]))
335 print(f" pool volume {v0:.10g} -> {v1:.10g} (relative change {abs(v1-v0)/v0:.3e})")
336 print(f" max|u| in the liquid {umax_liq:.4e} ({umax_liq/U:.3e} of the gas inlet speed)")
337 print(f" inflow ghost rho: {gh_gas:.6g} above the pool (want {rho_g}), "
338 f"{gh_liq:.6g} at the pool (want {rho_l})")
339 print(f" {h} ({time.time()-t0:.0f} s)")
340 ok = (abs(v1 - v0) / v0 < 1e-10 and umax_liq / U < 1e-3 and h.valid
341 and abs(gh_gas - rho_g) < 1e-9 * rho_l and abs(gh_liq - rho_l) < 1e-9 * rho_l)
342 print(f" GATE: {'PASS' if ok else 'FAIL'}")
343 return ok
344
345
346if __name__ == "__main__":
347 todo = GATES or ["budget", "nusselt", "pool"]
348 results = {}
349 if "budget" in todo:
350 results["budget"] = gate_budget()
351 if "nusselt" in todo:
352 ablation = "--no-outflow-rho" in sys.argv
353 rows = []
354 for r in (100, 1000):
355 ok, dq, dd, st, it = gate_nusselt(r, outflow_rho=not ablation)
356 results[f"nusselt{r}"] = ok
357 rows.append((r, dq, dd, st, it))
358 print("\n ratio dQ/Q d(delta) sum(C) steadiness pressure iters"
359 f" [1/rho_f outflow correction: {'OFF (ablation)' if ablation else 'ON'}]")
360 for r, dq, dd, st, it in rows:
361 print(f" {r:5d} {100*dq:+7.2f} % {dd:+8.4f} {st:.3e} {it}")
362 if "pool" in todo:
363 results["pool"] = gate_pool()
364 print("\n" + "=" * 96)
365 for k, v in results.items():
366 print(f" {k:12s} {'PASS' if v else 'FAIL'}")
367 sys.exit(0 if all(results.values()) else 1)
gate_nusselt(ratio=100, steps=None, outflow_rho=True)