flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vof_stefan.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-P01 (Part II, rungs P0/P1) — phase change in planar form: the gate battery, in Python.
3
4Every gate prints its numbers; nothing is summarised into an adjective.
5
6 p0a REGRESSION ONLY (rho_g = rho_l = 1, no divergence source). A planar interface under a
7 uniform prescribed mdot, 1000 kinematic steps. The interface position must follow
8 x(t) = x0 - mdot t / rho_l EXACTLY (it is a plane shift by a constant volume per step,
9 so the only error is round-off), the colour must stay in [0, 1], and the run must cross
10 twenty cell boundaries — which is what exercises the clip-and-redistribute.
11
12 p0b THE DIVERGENCE SOURCE at density ratio 100. A CLOSED column (walls on +-x, periodic in
13 y/z) with the net vapour production balanced by a prescribed sink plane, so the Poisson
14 problem is compatible without an outflow face. WHY CLOSED AND NOT AN OUTFLOW: the
15 variable-density outflow OPERATOR is inconsistent by the density ratio until WO-R2 lands
16 (`flow/CLAUDE.md`, rung V-BC), so an outflow at ratio 100 would measure that defect, not
17 this one. WHY WALLS AND NOT A PERIODIC BOX: in a periodic box nothing anchors the frame,
18 the constraint is zero net momentum, and the liquid recoils at
19 -u_g rho_g L_g / (rho_l L_l + rho_g L_g) -- a real effect, but it turns the "gas velocity"
20 gate into a statement about a near-cancellation. With walls the liquid is at rest and the
21 1-D solution is u = 0 up to the deposit cell, u = mdot(1/rho_g - 1/rho_l) beyond it, and
22 0 again past the sink.
23
24 p1 THE STEFAN PROBLEM, and the convergence ladder 64/128/256. A vapour layer growing from a
25 superheated wall into saturated liquid; mdot from the one-sided pure-cell gradients of a
26 transported temperature, interfacial cells pinned at T_sat by the per-cell Dirichlet mask.
27 x(t) = 2 lambda sqrt(alpha_g t) with lambda e^{lambda^2} erf(lambda) = St/sqrt(pi).
28 The ladder holds the PHYSICAL problem fixed (domain length 1, alpha = 1, St = 1, the layer
29 growing from 0.10 to 0.25 of the domain) and refines h = 1/N with the diffusive number
30 Fo = alpha dt/h^2 fixed, so dt ~ h^2 and the backward-Euler time error is O(h^2) --
31 it cannot contaminate a first- or second-order spatial statement.
32 At N = 256 the vapour layer is 64 cells thick at the gate time, which is the WO's rung.
33 Run with rho_g = rho_l: the classical Stefan similarity solution has the vapour at rest
34 against the wall and the LIQUID moving at mdot(1/rho_g - 1/rho_l), i.e. a ratio /= 1 needs
35 an outlet at the liquid end -- the same WO-R2 dependence as p0b, and the thermal/regression
36 kernels this rung exists to measure are identical either way.
37
38Usage:
39 PYTHONPATH=$PWD/build_cuda python tests/study/vof_stefan.py [p0a p0b p1 ...]
40"""
41import math
42import sys
43
44import numpy as np
45
46import peclet.flow as pf
47
48
50 """lambda e^{lambda^2} erf(lambda) = St / sqrt(pi) (bisection; no scipy dependency)."""
51 rhs = st / math.sqrt(math.pi)
52 lo, hi = 1e-9, 5.0
53 for _ in range(200):
54 mid = 0.5 * (lo + hi)
55 if mid * math.exp(mid * mid) * math.erf(mid) > rhs:
56 hi = mid
57 else:
58 lo = mid
59 return 0.5 * (lo + hi)
60
61
62def planar_colour(nx, ny, nz, xg, liquid_low=True):
63 c = np.zeros((nx, ny, nz), order="F")
64 for i in range(nx):
65 c[i, :, :] = min(1.0, max(0.0, xg - i)) if liquid_low else min(1.0, max(0.0, (i + 1) - xg))
66 return c
67
68
69# ------------------------------------------------------------------ p0a
70def gate_p0a(nx=64, ny=4, nz=4, x0=32.25, mdot=0.02, dt=1.0, nsteps=1000):
71 s = pf.Solver(nx, ny, nz)
72 s.set_rho(1.0)
73 s.set_mu(0.01)
74 s.set_dt(dt)
75 s.enable_vof()
76 s.set_vof(planar_colour(nx, ny, nz, x0))
77 s.enable_phase_change(1.0, 1.0, 1.0)
78 s.set_mass_flux_uniform(mdot)
79 worst, lo, hi, clips, redist = 0.0, 1e30, -1e30, 0, 0.0
80 for k in range(1, nsteps + 1):
81 s.apply_phase_change(dt)
82 x = s.get_vof().sum() / (ny * nz)
83 worst = max(worst, abs(x - (x0 - mdot * dt * k)))
84 d = s.diagnostics.phase_change_diagnostics()
85 lo, hi = min(lo, d["min_C"]), max(hi, d["max_C"])
86 clips += d["deficit_cells"]
87 redist += d["redistributed"]
88 print(f"p0a grid {nx}x{ny}x{nz}, mdot {mdot}, {nsteps} steps")
89 print(f" max |x_num - (x0 - mdot t/rho_l)| = {worst:.3e} [gate 1e-12]")
90 print(f" C in [{lo!r}, {hi!r}] cell-crossings clipped {clips}, "
91 f"|redistributed| {redist:.6g}")
92 return worst < 1e-12 and lo >= 0.0 and hi <= 1.0 and clips > 0
93
94
95# ------------------------------------------------------------------ p0b
96def gate_p0b(nx=64, ny=4, nz=4, rg=1.0, rl=100.0, mdot=0.01, x0=32.25, nsteps=20):
97 s = pf.Solver(nx, ny, nz)
98 s.set_rho(rg)
99 s.set_mu(1e-3)
100 s.set_dt(1.0)
101 s.set_domain_bc("-x", "wall")
102 s.set_domain_bc("+x", "wall")
103 s.set_pressure_geometry(np.full((nx, ny, nz), 1.0, order="F"))
104 s.enable_vof()
105 s.set_vof(planar_colour(nx, ny, nz, x0))
106 s.set_property_model("rho", "linear", "C", [rg, rl - rg])
107 s.enable_phase_change(rg, rl, 1.0)
108 s.set_mass_flux_uniform(mdot)
109 ug = mdot * (1.0 / rg - 1.0 / rl)
110 sink = np.zeros((nx, ny, nz), order="F")
111 sink[nx - 6, :, :] = -ug # A = 1 per interfacial cell on a grid-aligned plane
112 s.set_divergence_source(sink)
113 s.set_pressure_fcg(True, 400, 1e-12)
114 for _ in range(nsteps):
115 s.step()
116 u, v, w = s.get_u(), s.get_v(), s.get_w()
117 src = s.get_field("pc_source") + s.get_field("div_source")
118 div = np.zeros_like(u)
119 div[:-1] += u[1:] - u[:-1]
120 div[:, :-1] += v[:, 1:] - v[:, :-1]
121 div[:, :, :-1] += w[:, :, 1:] - w[:, :, :-1]
122 dmax = np.abs(div - src)[:-1, :-1, :-1].max()
123 plateau = u[35:56, 0, 0]
124 liq = np.abs(u[2:30, 0, 0]).max()
125 d = s.diagnostics.phase_change_diagnostics()
126 print(f"p0b ratio {rl/rg:g}, closed column + balanced sink, {nsteps} steps")
127 print(f" u_gas = {plateau.mean():.17g} exact {ug:.17g} rel {(plateau.mean()-ug)/ug:+.3e}"
128 f" [gate 1e-10]")
129 print(f" plateau spread {plateau.max()-plateau.min():.3e}, max|u_liquid| {liq:.3e}")
130 print(f" interfacial-cell faces u = {u[32,0,0]:.3e}, {u[33,0,0]:.3e} (the LIQUID velocity)")
131 print(f" max|div(u) - S| = {dmax:.3e} source sum {d['source_sum']:.6g} into "
132 f"{d['source_cells']} cells, fallback {d['fallback_cells']}")
133 print(f" pressure iterations {s.diagnostics.last_pressure_iterations()} / cap 400 "
134 f"(a capped solve makes the run INVALID)")
135 return abs((plateau.mean() - ug) / ug) < 1e-10 and dmax < 1e-12 \
136 and s.diagnostics.last_pressure_iterations() < 400
137
138
139# ------------------------------------------------------------------ p1
140def stefan_run(n, st=1.0, alpha=1.0, x0p=0.10, xep=0.25, fo=0.5, ny=4, nz=4):
141 lam = stefan_lambda(st)
142 t0 = (x0p / (2 * lam)) ** 2 / alpha
143 te = (xep / (2 * lam)) ** 2 / alpha
144 d = alpha * n * n # diffusivity in CELL units (h = 1/n physically)
145 nsteps = int(round((te - t0) / (fo / d)))
146 dt = (te - t0) / nsteps
147 s = pf.Solver(n, ny, nz)
148 s.set_rho(1.0)
149 s.set_mu(1e-3)
150 s.set_dt(dt)
151 s.set_domain_bc("-x", "wall")
152 s.set_domain_bc("+x", "wall")
153 s.set_pressure_geometry(np.full((n, ny, nz), 1.0, order="F"))
154 s.enable_vof()
155 s.set_vof(planar_colour(n, ny, nz, x0p * n, liquid_low=False))
156 t = np.zeros((n, ny, nz), order="F")
157 for i in range(n):
158 x = (i + 0.5) / n
159 t[i, :, :] = (1.0 - math.erf(lam * x / x0p) / math.erf(lam)) if x < x0p else 0.0
160 s.add_scalar("T", d, "koren", 60)
161 s.set_scalar_bc("T", "-x", "dirichlet", 1.0) # superheated wall, T_w - T_sat = 1
162 s.set_scalar_bc("T", "+x", "dirichlet", 0.0) # saturated far field
163 s.set_field("T", t)
164 s.enable_phase_change(1.0, 1.0, 1.0) # h_lv = 1, c_p = 1 => St = 1
165 s.set_phase_change_thermal(True, "T", 0.0, d, d, 0.0)
166 for _ in range(nsteps):
167 s.apply_phase_change(dt)
168 s.advance_scalars()
169 layer = n - s.get_vof().sum() / (ny * nz)
170 exact = 2 * lam * math.sqrt(alpha * te) * n
171 return layer, exact, nsteps, lam, s.diagnostics.phase_change_diagnostics()
172
173
174def gate_p1(ns=(64, 128, 256)):
175 print("p1 Stefan problem, St = 1, layer 0.10 -> 0.25 of the domain, Fo = 0.5 (dt ~ h^2)")
176 errs = []
177 for n in ns:
178 layer, exact, nsteps, lam, d = stefan_run(n)
179 rel = (layer - exact) / exact
180 errs.append(abs(rel))
181 print(f" N = {n:4d} steps {nsteps:5d} lambda {lam:.6f} layer {layer:9.5f} cells "
182 f"(exact {exact:9.5f}) rel {100*rel:+8.4f} % C in [{d['min_C']:.2e}, "
183 f"{d['max_C']:.6f}] unresolved {d['unresolved']:.2e} fallback {d['fallback_cells']}")
184 for a, b, na, nb in zip(errs[:-1], errs[1:], ns[:-1], ns[1:]):
185 print(f" observed order {na} -> {nb}: {math.log2(a/b):.3f}")
186 if len(errs) > 2:
187 print(f" observed order {ns[0]} -> {ns[-1]} (fit): "
188 f"{math.log2(errs[0]/errs[-1])/math.log2(ns[-1]/ns[0]):.3f}")
189 print(f" GATE: |rel| at N = {ns[-1]} is {100*errs[-1]:.4f} % [gate 0.5 %; "
190 f"Malan et al. 2021 report 0.23 %]")
191 return errs[-1] < 0.005
192
193
194if __name__ == "__main__":
195 want = sys.argv[1:] or ["p0a", "p0b", "p1"]
196 ok = True
197 for g in want:
198 ok &= {"p0a": gate_p0a, "p0b": gate_p0b, "p1": gate_p1}[g]()
199 print()
200 print("ALL GATES PASSED" if ok else "SOME GATES FAILED")
201 sys.exit(0 if ok else 1)
gate_p0a(nx=64, ny=4, nz=4, x0=32.25, mdot=0.02, dt=1.0, nsteps=1000)
Definition vof_stefan.py:70
gate_p0b(nx=64, ny=4, nz=4, rg=1.0, rl=100.0, mdot=0.01, x0=32.25, nsteps=20)
Definition vof_stefan.py:96
gate_p1(ns=(64, 128, 256))
stefan_lambda(st)
Definition vof_stefan.py:49
stefan_run(n, st=1.0, alpha=1.0, x0p=0.10, xep=0.25, fo=0.5, ny=4, nz=4)
planar_colour(nx, ny, nz, xg, liquid_low=True)
Definition vof_stefan.py:62