flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_sucking.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-P23 rung P2 — the SUCKING INTERFACE (Welch & Wilson, JCP 160:662, 2000).
3
4A planar interface with SATURATED VAPOUR on one side and SUPERHEATED LIQUID on the other. All the
5latent heat comes from the liquid, so this is the first rung on which the LIQUID half of the
6one-sided gradient fit, the per-phase k(C) / rho c_p(C) closures and the consistent rho c_p T
7transport are all live at once (rung P1's liquid was saturated and pinned, so none of them entered).
8
9GEOMETRY. Vapour occupies x < X(t) against a wall held at T_sat; liquid occupies x > X(t) and is
10superheated to T_inf far away. The interface moves INTO the liquid and the liquid is pushed AWAY
11from it at u_l = mdot (1/rho_v - 1/rho_l), so the domain needs an OUTLET at the liquid end: the net
12vapour production is a real volume flux that has to leave. (WO-P01's closed-column trick with a
13balancing sink cannot be used here — the sink removes VOLUME from cells whose colour is 1, so the
14colour drops there, and over a run this long the required removal is many cells' worth.)
15
16WHY THE OUTLET IS SOUND AT HIGH DENSITY RATIO. `applyBoundaryOpenness` re-imposes the literal 1.0
17at a boundary face instead of the variable-density coefficient `open * rho0/rho_f` (WO-R2 item 1).
18Here the outlet sits in PURE LIQUID, so choosing the reference density `set_rho(rho_l)` makes
19`rho0/rho_f == 1` exactly at that face and the overwrite is the correct coefficient — the defect is
20side-stepped by construction rather than tolerated. Reported with every run.
21
22THE EXACT SOLUTION (derived in the docstring of `exact_b`, checked against Welch & Wilson):
23
24 X(t) = 2 b sqrt(alpha_l t) , b = Ja exp(-g^2) / (sqrt(pi) erfc(g)) , g = b rho_v/rho_l
25 Ja = rho_l c_pl (T_inf - T_sat) / (rho_v h_lv)
26 T(x,t) = T_inf - (T_inf - T_sat) erfc(s)/erfc(g), s = x/(2 sqrt(alpha_l t)) - b(1 - rho_v/rho_l)
27
28Usage:
29 PYTHONPATH=$PWD/build_cuda python tests/study/vof_sucking.py [--ratio 10] [--ns 64,128,256]
30"""
31import argparse
32import math
33import sys
34
35import numpy as np
36
37import peclet.flow as pf
38
39
40def exact_b(ja, rr):
41 """Solve b = Ja exp(-g^2)/(sqrt(pi) erfc(g)), g = b*rr, rr = rho_v/rho_l.
42
43 Derivation. The vapour is isothermal at T_sat (it is bounded by a wall at T_sat), so it is at
44 rest and mdot = rho_v Xdot. The liquid moves at the uniform u_l = Xdot (1 - rho_v/rho_l), so in
45 the Galilean frame x' = x - integral(u_l) the liquid temperature obeys the plain heat equation
46 and is an erfc in x'/(2 sqrt(alpha_l t)). Matching T(X) = T_sat fixes the amplitude and the
47 Stefan condition mdot h_lv = k_l dT/dx|_X gives the fixed point above.
48 """
49 b = ja / math.sqrt(math.pi)
50 for _ in range(200):
51 g = b * rr
52 b = ja * math.exp(-g * g) / (math.sqrt(math.pi) * math.erfc(g))
53 return b
54
55
56def run(n, ratio, ja, x0p=0.10, xep=0.25, alpha_l=1.0, cfl=0.2, fo=0.5, nt=4,
57 kratio=None, rcpratio=None, consistent=True, plane=True, quad=False, muscl=False,
58 verbose=True, energy_order=1, deposit=None):
59 """One resolution. The PHYSICAL problem is fixed (domain length 1, alpha_l = 1, the vapour
60 layer growing from x0p to xep of the domain) and only h = 1/n changes. `nt` is the transverse
61 extent: the problem is 1-D so 4 is enough physically, but it decides how far the pressure
62 multigrid can coarsen (see the findings)."""
63 ny = nz = nt
64 rr = 1.0 / ratio # rho_v / rho_l
65 b = exact_b(ja, rr)
66 t0 = (x0p / (2 * b)) ** 2 / alpha_l
67 te = (xep / (2 * b)) ** 2 / alpha_l
68
69 # solver (cell) units: h = 1, so alpha in cells^2/s is alpha_l * n^2
70 al = alpha_l * n * n
71 rho_l, rho_v = 1.0, rr
72 cpl = 1.0
73 k_l = al * rho_l * cpl # alpha_l = k/(rho c_p)
74 rcp_l = rho_l * cpl
75 rcp_v = rcp_l / (rcpratio if rcpratio else ratio) # default: c_p equal, so rcp ~ rho
76 k_v = k_l / (kratio if kratio else ratio)
77 dT = 1.0
78 h_lv = rho_l * cpl * dT / (rho_v * ja)
79
80 X0 = x0p * n
81 s = pf.Solver(n, ny, nz)
82 s.set_rho(rho_l) # reference density = the OUTLET phase; see the module docstring
83 s.set_mu(1e-3)
84 s.set_domain_bc("-x", "wall") # -x wall (the vapour side)
85 s.set_domain_bc("+x", "outflow") # +x outflow (the liquid leaves)
86 s.set_pressure_geometry(np.full((n, ny, nz), 1.0, order="F"))
87 s.enable_vof()
88 c = np.zeros((n, ny, nz), order="F")
89 for i in range(n):
90 c[i, :, :] = min(1.0, max(0.0, (i + 1) - X0)) # liquid on the HIGH side
91 s.set_vof(c)
92 s.set_property_model("rho", "linear", "C", [rho_v, rho_l - rho_v])
93 s.set_pressure_fcg(True, 4000, 1e-10) # AFTER the rho closure (CLAUDE.md)
94
95 # temperature: T_sat = 0 at the wall, the similarity profile in the liquid
96 t = np.zeros((n, ny, nz), order="F")
97 for i in range(n):
98 x = (i + 0.5) / n
99 if x > x0p:
100 sv = x / (2 * math.sqrt(alpha_l * t0)) - b * (1.0 - rr)
101 t[i, :, :] = dT - dT * math.erfc(sv) / math.erfc(b * rr)
102 # The far-field Dirichlet is the EXACT profile value at x = 1, refreshed every step. The domain
103 # is finite and the similarity solution is not: at t_end the liquid thermal layer reaches
104 # erfc(s) ~ 8e-3 at the outlet, so a fixed T = T_inf there is a 0.86 % TRUNCATION error that
105 # does not converge under grid refinement (measured: |T - T_exact| flat at 0.838 % / 0.834 % at
106 # N = 64 / 128 with the fixed value). Imposing the exact far-field value removes a
107 # domain-truncation error, not a discretisation error.
108 def farT(tt):
109 sv = 1.0 / (2 * math.sqrt(alpha_l * tt)) - b * (1.0 - rr)
110 return dT - dT * math.erfc(sv) / math.erfc(b * rr)
111
112 s.add_scalar("T", al, "koren", 60)
113 s.set_scalar_bc("T", "-x", "dirichlet", 0.0) # saturated vapour against the wall
114 s.set_scalar_bc("T", "+x", "dirichlet", farT(t0)) # superheated far field (exact, refreshed per step)
115 s.set_field("T", t)
116 s.enable_phase_change(rho_v, rho_l, h_lv)
117 s.set_phase_change_thermal(True, "T", 0.0, k_v, k_l, 0.0)
118 s.diagnostics.set_phase_change_plane_dirichlet(plane)
119 s.diagnostics.set_phase_change_quadratic_fit(quad)
120 if consistent:
121 s.set_phase_change_energy(True, rcp_v, rcp_l)
122 s.diagnostics.set_phase_change_energy_muscl(muscl)
123 # WO-P3g: the second-order interfacial energy operator (default 1 = the shipped scheme, bitwise)
124 if energy_order != 1:
125 s.diagnostics.set_phase_change_energy_order(energy_order)
126 if deposit is not None:
127 s.diagnostics.set_phase_change_deposit_fallback(bool(deposit))
128
129 # initial liquid velocity (the projection would find it anyway; this removes a startup transient)
130 Xdot = b * math.sqrt(alpha_l / t0) * n # cells/s
131 ul = Xdot * (1.0 - rr)
132 u = np.zeros((n, ny, nz), order="F")
133 u[int(X0) + 2:, :, :] = ul
134 s.set_field("u", u)
135
136 # dt from a FIXED diffusive number Fo = alpha dt/h^2 (so dt ~ h^2 and the backward-Euler /
137 # explicit-mdot time error cannot contaminate a first- or second-order SPATIAL statement — the
138 # same protocol as the P1 Stefan ladder), capped by the interface-local Courant number.
139 tcur, nsteps, itmax, capped = t0, 0, 0, 0
140 while tcur < te:
141 Xd = b * math.sqrt(alpha_l / tcur) * n
142 dt = min(cfl / max(Xd * (1.0 - rr), 1e-30), fo / al)
143 dt = min(dt, (te - tcur))
144 s.set_dt(dt)
145 s.set_scalar_bc("T", "+x", "dirichlet", farT(tcur + dt))
146 s.step()
147 tcur += dt
148 nsteps += 1
149 it = s.diagnostics.last_pressure_iterations()
150 itmax = max(itmax, it)
151 if it >= 4000:
152 capped += 1
153 layer = n - s.get_vof().sum() / (ny * nz) # vapour thickness in cells
154 exact = 2 * b * math.sqrt(alpha_l * te) * n
155 d = s.diagnostics.phase_change_diagnostics()
156 # temperature profile error against the similarity solution, over the LIQUID
157 tnum = s.get_field("T")[:, 0, 0]
158 terr, tn = 0.0, 0
159 for i in range(n):
160 x = (i + 0.5) / n
161 xg = 2 * b * math.sqrt(alpha_l * te)
162 if x > xg + 2.0 / n:
163 sv = x / (2 * math.sqrt(alpha_l * te)) - b * (1.0 - rr)
164 ex = dT - dT * math.erfc(sv) / math.erfc(b * rr)
165 terr = max(terr, abs(tnum[i] - ex))
166 tn += 1
167 if verbose:
168 print(f" N = {n:4d} steps {nsteps:5d} b {b:.6f} layer {layer:9.5f} cells "
169 f"(exact {exact:9.5f}) rel {100*(layer-exact)/exact:+8.4f} %")
170 print(f" |T - T_exact|_inf over the liquid = {terr:.4e} ({100*terr/dT:.3f} % of "
171 f"dT, {tn} cells) C in [{d['min_C']:.2e}, {d['max_C']:.6f}]")
172 print(f" pressure iters max {itmax}/4000 (capped steps {capped}), "
173 f"band_div {d['band_div']:.3e}, T in [{d['T_min']:.4e}, {d['T_max']:.6f}], "
174 f"unresolved {d['unresolved']:.1e}, fallback {d['fallback_cells']}")
175 return abs((layer - exact) / exact), terr / dT, capped, itmax
176
177
178def main():
179 ap = argparse.ArgumentParser()
180 ap.add_argument("--ratio", type=float, default=10.0)
181 ap.add_argument("--ja", type=float, default=1.0)
182 ap.add_argument("--ns", type=str, default="64,128,256")
183 ap.add_argument("--kratio", type=float, default=None)
184 ap.add_argument("--rcpratio", type=float, default=None)
185 ap.add_argument("--no-consistent", action="store_true")
186 ap.add_argument("--no-plane", action="store_true")
187 ap.add_argument("--no-quad", action="store_true")
188 ap.add_argument("--fo", type=float, default=0.5)
189 ap.add_argument("--muscl", action="store_true")
190 ap.add_argument("--nt", type=int, default=4)
191 ap.add_argument("--energy-order", type=int, default=1,
192 help="WO-P3g: 2 = the second-order interfacial energy operator")
193 ap.add_argument("--deposit-fallback", type=int, default=None,
194 help="WO-P3f open item 6: the 5^3 divergence-source deposit fallback")
195 a = ap.parse_args()
196 ns = [int(x) for x in a.ns.split(",")]
197 print(f"P2 sucking interface: rho_l/rho_v = {a.ratio:g}, Ja = {a.ja:g}, "
198 f"k ratio {a.kratio or a.ratio:g}, rho*c_p ratio {a.rcpratio or a.ratio:g}, "
199 f"consistent energy {not a.no_consistent}, plane Dirichlet {not a.no_plane}, "
200 f"quadratic fit {not a.no_quad}, Fo {a.fo:g}, WO-P3g energy order {a.energy_order}, "
201 f"deposit fallback {a.deposit_fallback}")
202 errs, terrs = [], []
203 for n in ns:
204 e, te_, capped, itmax = run(n, a.ratio, a.ja, kratio=a.kratio, rcpratio=a.rcpratio,
205 consistent=not a.no_consistent, plane=not a.no_plane,
206 quad=not a.no_quad, fo=a.fo, muscl=a.muscl, nt=a.nt,
207 energy_order=a.energy_order, deposit=a.deposit_fallback)
208 errs.append(e)
209 terrs.append(te_)
210 if capped:
211 print(f" *** {capped} CAPPED pressure solves at N = {n}: rule 3b, run INVALID")
212 for x, y, na, nb in zip(errs[:-1], errs[1:], ns[:-1], ns[1:]):
213 print(f" observed order {na} -> {nb}: {math.log2(x/y):.3f}")
214 if len(errs) > 2:
215 print(f" observed order {ns[0]} -> {ns[-1]} (fit): "
216 f"{math.log2(errs[0]/errs[-1])/math.log2(ns[-1]/ns[0]):.3f}")
217 print(f" GATE: order >= 1.4 and |T - T_exact| <= 1 % of dT at N = {ns[-1]} "
218 f"(got {100*terrs[-1]:.3f} %)")
219 return 0
220
221
222if __name__ == "__main__":
223 sys.exit(main())
exact_b(ja, rr)
run(n, ratio, ja, x0p=0.10, xep=0.25, alpha_l=1.0, cfl=0.2, fo=0.5, nt=4, kratio=None, rcpratio=None, consistent=True, plane=True, quad=False, muscl=False, verbose=True, energy_order=1, deposit=None)