flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
mg_advection.py
Go to the documentation of this file.
1"""2-D prototype (architect, Amendment A2 to doc/scalar_ibm_design.md): how should the STEADY cut-cell
2scalar solve with implicit first-order upwind (FOU) advection be preconditioned?
3
4Operator (per unit cell volume, D = 1, steady, periodic box [0,1]^2, n x n cells):
5 A = aperture 5-point diffusion (a D / h^2 per face) + lumped Dirichlet wall sink W = alpha / s0
6 (A1's level-0 term; Neumann discs: W = 0, singular) + conservative FOU advection from a
7 discretely divergence-free face flux f = F/V (node stream function, zero inside the discs,
8 no-slip ramp outside them). The probe off-diagonals are left out on purpose: they are the
9 SAME gap at every Pe and the WO-3/4 evidence already prices them (A = S here).
10Preconditioners (one V-cycle, BiCGStab on A, rtol 1e-10; the 3-D code's cycle: RB-GS from global
11parity, pre nu R->B, post nu B->R, average restriction, bilinear prolongation, pinned re-zeroed,
12coarse faces rediscretized, coarse wall = A1 average at s0; bottom 4x4 exact):
13 l0only: level 0 = A (FOU couplings), coarse levels = the 'sym' ones (is coarse advection needed?)
14 sym : the shipped WO-5 surrogate - symmetric diffusion + wall + LUMPED outflow on the diagonal,
15 the outflow restricted by average onto the coarse levels (WO-5's restrictAvg of omega)
16 split : FOU couplings IN the surrogate; coarse advection = the piecewise-constant Galerkin value:
17 per coarse face the summed positive parts of the sub-face fluxes, each direction kept
18 (q+_C = sum_sub max(f, 0) / N_ratio, q-_C likewise), so every level is a conservative
19 M-matrix with zero column sums
20 net : FOU couplings in the surrogate; coarse face flux = summed SIGNED sub-face fluxes, upwinded
21 split-pc / split-nu3: piecewise-constant prolongation / 3 + 3 sweeps (variants, not shipped)
22Cases: P1 periodic + Dirichlet disc R = L/4 (WO-5's C3 analogue), P2 Neumann 2x2 disc array (closure,
23singular), P4 closed-streamline eddies without a solid (singular), P3 the P2 array with Dirichlet discs.
24Output per case: BiCGStab iterations [V-cycle contraction rho(I - M^-1 A), power estimate];
25"n!(r)" = not converged in 300, true relative residual r.
26Run: python mg_advection.py [n ...] [case prefixes] [variants] [pe=a,b,..] [gmres] [wcycle|fcycle]
27 [bottom=sweeps] [nmin=16] (OMP_NUM_THREADS=4; n = 64..256 for all cases: ~35 s)
28Results: doc/scalar_ibm_design.md, Amendment A2 (under ยง6.7). 'split' is what A2 ships.
29"""
30import numpy as np, scipy.sparse as sp, scipy.sparse.linalg as spla, sys, time
31
32SNAP = 1e-3
33
34
35# ------------------------------------------------------------------ geometry and flow (vectorized)
36def edge_frac(pa, pb):
37 """fluid fraction of an edge from its end-node SDF values (> 0 fluid), snapped at 1e-3."""
38 out = np.where((pa > 0) & (pb > 0), 1.0, 0.0)
39 mixed = (pa > 0) != (pb > 0)
40 p = np.where(pa > 0, pa, pb); q = np.where(pa > 0, pb, pa)
41 with np.errstate(invalid="ignore", divide="ignore"):
42 fr = np.where(mixed, p / (p - q), 0.0)
43 out = np.where(mixed, fr, out)
44 return np.where(out < SNAP, 0.0, np.where(out > 1 - SNAP, 1.0, out))
45
46
48 s = np.clip(s, 0.0, 1.0)
49 return s * s * (3 - 2 * s)
50
51
52class Case:
53 """n x n periodic cells on [0,1]^2; discs (cx, cy, R); flow direction d; wall 'dir' or 'neu'."""
54 def __init__(self, n, discs, wall, d=(1.0, 0.3), delta_frac=0.8, cells=0.0):
55 self.n, self.h = n, 1.0 / n
56 h = self.h
57 xn = np.arange(n + 1) * h
58 X, Y = np.meshgrid(xn, xn, indexing="ij") # nodes (n+1)^2
59 sdf = np.full(X.shape, np.inf)
60 Ux, Uy = d
61 lin = lambda x, y: Ux * y - Uy * x
62 psi = lin(X, Y)
63 if cells: # closed-streamline eddies (4 per period) on top of the mean flow
64 psi = psi + cells * np.hypot(Ux, Uy) * np.sin(2 * np.pi * X) * np.sin(2 * np.pi * Y) / (2 * np.pi)
65 for (cx, cy, R) in discs:
66 for ox in (-1, 0, 1):
67 for oy in (-1, 0, 1):
68 r = np.hypot(X - cx - ox, Y - cy - oy)
69 sdf = np.minimum(sdf, r - R)
70 # ramp width: delta_frac of the smallest clearance between disc surfaces (incl. images)
71 cl = np.inf
72 for k, (cx, cy, R) in enumerate(discs):
73 for m_, (dx, dy, Rm) in enumerate(discs):
74 for ox in (-1, 0, 1):
75 for oy in (-1, 0, 1):
76 if k == m_ and ox == 0 and oy == 0:
77 continue
78 cl = min(cl, np.hypot(cx - dx - ox, cy - dy - oy) - R - Rm)
79 self.delta = delta_frac * 0.5 * cl if np.isfinite(cl) else 1.0
80 for (cx, cy, R) in discs:
81 for ox in (-1, 0, 1):
82 for oy in (-1, 0, 1):
83 r = np.hypot(X - cx - ox, Y - cy - oy)
84 m = smoothstep((r - R) / self.delta)
85 psi = psi + (1 - m) * (lin(cx + ox, cy + oy) - lin(X, Y))
86 # apertures of the LOW faces of cell (i, j): ax[i, j] = x-face at x_i, ay[i, j] = y-face at y_j
87 self.ax = edge_frac(sdf[:-1, :-1], sdf[:-1, 1:]) # nodes (i, j) - (i, j+1)
88 self.ay = edge_frac(sdf[:-1, :-1], sdf[1:, :-1]) # nodes (i, j) - (i+1, j)
89 hi = lambda a, ax_: np.roll(a, -1, axis=ax_)
90 self.unknown = (self.ax + hi(self.ax, 0) + self.ay + hi(self.ay, 1)) > 0
91 Axv = (hi(self.ax, 0) - self.ax) * h; Ayv = (hi(self.ay, 1) - self.ay) * h
92 Aw = np.hypot(Axv, Ayv)
93 with np.errstate(invalid="ignore", divide="ignore"):
94 nx = np.where(Aw > 0, Axv / np.maximum(Aw, 1e-300), 0); ny = np.where(Aw > 0, Ayv / np.maximum(Aw, 1e-300), 0)
95 alpha = np.where(self.unknown & (Aw > 1e-14 * h), Aw / h**2, 0.0)
96 s0 = 1.1 * 0.5 * (np.abs(nx) + np.abs(ny)) * h
97 self.W0 = np.where(alpha > 0, alpha / np.maximum(s0, 1e-300), 0.0) if wall == "dir" else np.zeros((n, n))
98 self.singular = wall == "neu"
99 # face fluxes F (per depth) of the low faces, from node psi; f = F / h^2 per unit volume
100 Fx = psi[:-1, 1:] - psi[:-1, :-1]
101 Fy = -(psi[1:, :-1] - psi[:-1, :-1])
102 unk = self.unknown
103 gx = unk & np.roll(unk, 1, axis=0); gy = unk & np.roll(unk, 1, axis=1) # both cells unknown
104 self.dropped = max(np.abs(np.where(gx, 0, Fx)).max(), np.abs(np.where(gy, 0, Fy)).max()) / max(np.abs(Fx).max(), 1e-300)
105 Fx = np.where(gx, Fx, 0.0); Fy = np.where(gy, Fy, 0.0)
106 div = (np.roll(Fx, -1, 0) - Fx) + (np.roll(Fy, -1, 1) - Fy)
107 self.divrel = np.abs(div).max() / np.abs(Fx).max()
108 self.Fmax = max(np.abs(Fx).max(), np.abs(Fy).max())
109 self.fx0 = Fx / h**2; self.fy0 = Fy / h**2
110
111 def flux(self, pe):
112 """per-unit-volume face fluxes scaled to peak cell Peclet pe = max|u| h / D (|u| = |F| / h)."""
113 s = pe / self.Fmax if self.Fmax > 0 else 0.0 # max|F| = pe (D = 1): max f = pe / h^2
114 return self.fx0 * s, self.fy0 * s
115
116
117# ------------------------------------------------------------------ level operators
118def level_op(n, h, tx, ty, px, mx, py, my, unk, W, omega=None):
119 """per-volume operator; tx/ty diffusion coefficients (already / h^2), px/mx (py/my) advective
120 coefficients of the LOW faces: p = flow toward +axis (low cell -> high cell), m = toward -axis.
121 omega: lumped extra diagonal (the 'sym' baseline). Identity rows on pinned cells."""
122 idx = np.arange(n * n).reshape(n, n)
123 gx = unk & np.roll(unk, 1, 0); gy = unk & np.roll(unk, 1, 1)
124 tx = np.where(gx, tx, 0); ty = np.where(gy, ty, 0)
125 px = np.where(gx, px, 0); mx = np.where(gx, mx, 0); py = np.where(gy, py, 0); my = np.where(gy, my, 0)
126 up = lambda a, ax_: np.roll(a, -1, ax_) # the HIGH face of cell (i, j)
127 diag = W + tx + up(tx, 0) + ty + up(ty, 1) + mx + up(px, 0) + my + up(py, 1)
128 if omega is not None:
129 diag = diag + omega
130 rows, cols, vals = [], [], []
131 for (coef, sh) in ((tx + px, (1, 0)), (up(tx, 0) + up(mx, 0), (-1, 0)),
132 (ty + py, (0, 1)), (up(ty, 1) + up(my, 1), (0, -1))):
133 nb = np.roll(idx, sh, axis=(0, 1)) # neighbour index: roll(+1) gives the LOW neighbour
134 m = unk & (coef != 0)
135 rows.append(idx[m]); cols.append(nb[m]); vals.append(-coef[m])
136 diag = np.where(unk, diag, 1.0)
137 rows.append(idx.ravel()); cols.append(idx.ravel()); vals.append(diag.ravel())
138 r = np.concatenate(rows); c = np.concatenate(cols); v = np.concatenate(vals)
139 return sp.csr_matrix((v, (r, c)), shape=(n * n, n * n))
140
141
142def transfers(n, prol):
143 nc = n // 2
144 I, J = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
145 fine = (I * n + J).ravel()
146 R = sp.csr_matrix((np.full(n * n, 0.25), (((I // 2) * nc + J // 2).ravel(), fine)), shape=(nc * nc, n * n))
147 if prol == "pc":
148 return R, (4 * R).T.tocsr()
149 si = np.where(I % 2, 1, -1); sj = np.where(J % 2, 1, -1)
150 rows, cols, vals = [], [], []
151 for (dI, dJ, w) in ((0, 0, 9 / 16), (1, 0, 3 / 16), (0, 1, 3 / 16), (1, 1, 1 / 16)):
152 II = (I // 2 + dI * si) % nc; JJ = (J // 2 + dJ * sj) % nc
153 rows.append(fine); cols.append((II * nc + JJ).ravel()); vals.append(np.full(n * n, w))
154 P = sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n * n, nc * nc))
155 return R, P
156
157
158NMIN = 4 # bottom grid edge (the 3-D distributed table can stop at a few cells per RANK)
159
160
161def hierarchy(C, pe, mode, prol, nmin=None):
162 nmin = nmin or NMIN
163 n, h = C.n, C.h
164 fx, fy = C.flux(pe)
165 ax, ay, unk = C.ax, C.ay, C.unknown
166 W0 = C.W0
167 px, mx, py, my = np.maximum(fx, 0), np.maximum(-fx, 0), np.maximum(fy, 0), np.maximum(-fy, 0)
168 # the TRUE operator (A = S with FOU couplings, lumped wall)
169 A = level_op(n, h, ax / h**2, ay / h**2, px, mx, py, my, unk, W0)
170 levels = []
171 if mode in ("sym", "l0only"):
172 Z = np.zeros((n, n))
173 om = (mx + np.roll(px, -1, 0) + my + np.roll(py, -1, 1)) * unk # lumped outflow per cell
174 S = level_op(n, h, ax / h**2, ay / h**2, Z, Z, Z, Z, unk, W0, omega=om)
175 if mode != "sym":
176 S = A
177 hh, L = h, 0
178 while True:
179 levels.append(dict(A=S, unk=unk.ravel(), n=n))
180 if n // 2 < nmin:
181 break
182 R, P = transfers(n, prol)
183 levels[-1].update(R=R, P=P)
184 nc = n // 2; H = hh * 2
185 unk_c = unk.reshape(nc, 2, nc, 2).any(axis=(1, 3))
186 axc = 0.5 * (ax[0::2, 0::2] + ax[0::2, 1::2]); ayc = 0.5 * (ay[0::2, 0::2] + ay[1::2, 0::2])
187 f = 2 ** (L + 1)
188 Wc = W0.reshape(nc, f, nc, f).mean(axis=(1, 3))
189 if mode in ("sym", "l0only"):
190 om = om.reshape(nc, 2, nc, 2).mean(axis=(1, 3))
191 Z = np.zeros((nc, nc))
192 S = level_op(nc, H, axc / H**2, ayc / H**2, Z, Z, Z, Z, unk_c, Wc, omega=om)
193 else:
194 if mode == "split":
195 px = 0.25 * (px[0::2, 0::2] + px[0::2, 1::2]); mx = 0.25 * (mx[0::2, 0::2] + mx[0::2, 1::2])
196 py = 0.25 * (py[0::2, 0::2] + py[1::2, 0::2]); my = 0.25 * (my[0::2, 0::2] + my[1::2, 0::2])
197 else: # net
198 fx = 0.25 * (fx[0::2, 0::2] + fx[0::2, 1::2]); fy = 0.25 * (fy[0::2, 0::2] + fy[1::2, 0::2])
199 px, mx, py, my = np.maximum(fx, 0), np.maximum(-fx, 0), np.maximum(fy, 0), np.maximum(-fy, 0)
200 S = level_op(nc, H, axc / H**2, ayc / H**2, px, mx, py, my, unk_c, Wc)
201 ax, ay, unk, n, hh, L = axc, ayc, unk_c, nc, H, L + 1
202 return A, levels
203
204
205# ------------------------------------------------------------------ cycle
206def prep(levels):
207 for lv in levels:
208 n = lv["n"]
209 I, J = np.meshgrid(np.arange(n), np.arange(n), indexing="ij")
210 col = ((I + J) % 2).ravel()
211 A = lv["A"]
212 lv["sets"] = []
213 for c in (0, 1):
214 s = np.nonzero(col == c)[0]
215 lv["sets"].append((s, A[s].tocsr(), A.diagonal()[s]))
216 if "R" not in lv:
217 lv["dense"] = np.linalg.pinv(A.toarray()) if True else None
218
219
220def sweep(lv, x, b, order):
221 for c in order:
222 s, As, Ds = lv["sets"][c]
223 x[s] += (b[s] - As @ x) / Ds
224 return x
225
226
227GAMMA = 1 # 2: W-cycle, 3: F-cycle (prototype levers only)
228BOTTOM = "exact" # or "sweeps": the 3-D code's 16 sweeps (8 R->B + 8 B->R) + mean removal
229
230
231def vcycle(levels, l, b, singular, nu, kind=None):
232 kind = kind or ("W" if GAMMA == 2 else ("F" if GAMMA == 3 else "V"))
233 lv = levels[l]
234 if "R" not in lv:
235 if BOTTOM == "sweeps":
236 x = np.zeros_like(b)
237 for _ in range(8):
238 x = sweep(lv, x, b, (0, 1))
239 for _ in range(8):
240 x = sweep(lv, x, b, (1, 0))
241 else:
242 x = lv["dense"] @ b
243 if singular:
244 u = lv["unk"]; x[u] -= x[u].mean()
245 return x
246 x = np.zeros_like(b)
247 for _ in range(nu):
248 x = sweep(lv, x, b, (0, 1))
249 r = b - lv["A"] @ x
250 rc = lv["R"] @ r
251 if singular:
252 u = levels[l + 1]["unk"]; rc[u] -= rc[u].mean(); rc[~u] = 0
253 ec = vcycle(levels, l + 1, rc, singular, nu, kind)
254 if kind in ("W", "F") and "R" in levels[l + 1]: # W: second W visit; F: F then one V visit
255 A1_ = levels[l + 1]["A"]
256 ec = ec + vcycle(levels, l + 1, rc - A1_ @ ec, singular, nu, "W" if kind == "W" else "V")
257 x += lv["P"] @ ec
258 x[~lv["unk"]] = 0
259 for _ in range(nu):
260 x = sweep(lv, x, b, (1, 0))
261 return x
262
263
264def precond(levels, singular, nu):
265 u = levels[0]["unk"]
266 def apply(r):
267 z = vcycle(levels, 0, r.copy(), singular, nu)
268 if singular:
269 z[u] -= z[u].mean()
270 return z
271 return apply
272
273
274def contraction(A, levels, singular, nu, its=40):
275 u = levels[0]["unk"]; M = precond(levels, singular, nu)
276 rng = np.random.default_rng(3)
277 e = np.where(u, rng.standard_normal(A.shape[0]), 0.0)
278 hist = []
279 for _ in range(its):
280 if singular:
281 e[u] -= e[u].mean()
282 nrm = np.linalg.norm(e)
283 e = e - M(A @ e); e[~u] = 0
284 if singular:
285 e[u] -= e[u].mean()
286 hist.append(np.linalg.norm(e) / nrm)
287 if not np.isfinite(hist[-1]) or hist[-1] > 1e6:
288 return float("inf")
289 return float(np.exp(np.mean(np.log(hist[-10:]))))
290
291
292def krylov_its(A, levels, singular, nu, method="bicgstab", maxit=300):
293 u = levels[0]["unk"]
294 rng = np.random.default_rng(5)
295 b = np.where(u, 1 + 0.3 * rng.standard_normal(A.shape[0]), 0.0)
296 if singular:
297 b[u] -= b[u].mean()
298 M = spla.LinearOperator(A.shape, matvec=precond(levels, singular, nu))
299 cnt = [0]
300 cb = lambda _: cnt.__setitem__(0, cnt[0] + 1)
301 if method == "bicgstab":
302 x, info = spla.bicgstab(A, b, M=M, rtol=1e-10, atol=0.0, maxiter=maxit, callback=cb)
303 else:
304 x, info = spla.gmres(A, b, M=M, rtol=1e-10, atol=0.0, restart=maxit, maxiter=1, callback=cb, callback_type="pr_norm")
305 rel = np.linalg.norm(b - A @ x) / np.linalg.norm(b)
306 return cnt[0] if (info == 0 and rel < 1e-8) else f"{cnt[0]}!({rel:.0e})"
307
308
309CASES = {
310 # WO-5's C3 analogue: periodic, one Dirichlet disc R = L/4 (R/h = 16 at n = 64), uniform-ish flow
311 "P1 Dirichlet disc R=L/4": dict(discs=[(0.5 + 0.37 / 64, 0.5 + 0.37 / 64, 0.25)], wall="dir"),
312 # closure-like: periodic 2x2 array of Neumann discs, porosity ~0.50, singular
313 "P2 Neumann 2x2 array (closure)": dict(discs=[(0.25 + i * 0.5 + 0.37 / 64, 0.25 + j * 0.5 + 0.37 / 64, 0.2) for i in (0, 1) for j in (0, 1)], wall="neu"),
314 # closed streamlines (the hard case for advective MG): eddies of amplitude 5x the mean flow,
315 # no solid, singular (closure-like)
316 "P4 cellular eddies, no solid": dict(discs=[], wall="neu", cells=5.0),
317 # reactive packed bed: the same array with Dirichlet discs
318 "P3 Dirichlet 2x2 array": dict(discs=[(0.25 + i * 0.5 + 0.37 / 64, 0.25 + j * 0.5 + 0.37 / 64, 0.2) for i in (0, 1) for j in (0, 1)], wall="dir"),
319}
320
321VARIANTS = { # name: (mode, prolongation, nu)
322 "sym": ("sym", "bil", 2),
323 "split": ("split", "bil", 2),
324 "net": ("net", "bil", 2),
325 "split-pc": ("split", "pc", 2),
326 "split-nu3": ("split", "bil", 3),
327 "l0only": ("l0only", "bil", 2), # FOU couplings at level 0 only; coarse = the 'sym' levels
328}
329
330if __name__ == "__main__":
331 ns = [int(a) for a in sys.argv[1:] if a.isdigit()] or [64, 128]
332 pes = [0.0, 0.1, 1.0, 10.0]
333 for a in sys.argv[1:]:
334 if a.startswith("pe="):
335 pes = [float(v) for v in a[3:].split(",")]
336 vsel = [a for a in sys.argv[1:] if a in VARIANTS] or list(VARIANTS)
337 csel = [k for k in CASES if any(k.startswith(a) for a in sys.argv[1:])] or list(CASES)
338 gm = "gmres" in sys.argv
339 for a in sys.argv[1:]:
340 if a.startswith("bottom="):
341 BOTTOM = a[7:]
342 if a == "wcycle":
343 GAMMA = 2
344 if a == "fcycle":
345 GAMMA = 3
346 if a.startswith("nmin="):
347 NMIN = int(a[5:])
348 for name in csel:
349 for n in ns:
350 C = Case(n, **CASES[name])
351 print(f"\n{name}, n = {n}: div/max|F| = {C.divrel:.1e}, dropped flux {C.dropped:.1e}, "
352 f"unknowns {C.unknown.sum()}, ramp {C.delta / C.h:.1f} h", flush=True)
353 for v in vsel:
354 mode, prol, nu = VARIANTS[v]
355 line = f" {v:10s}"
356 for pe in pes:
357 t = time.time()
358 A, lv = hierarchy(C, pe, mode, prol)
359 prep(lv)
360 its = krylov_its(A, lv, C.singular, nu)
361 rho = contraction(A, lv, C.singular, nu)
362 extra = ""
363 if gm:
364 extra = f" g{krylov_its(A, lv, C.singular, nu, 'gmres')}"
365 line += f" | Pe_h {pe:g}: {its} [{rho:.3f}]{extra}"
366 print(line, flush=True)
__init__(self, n, discs, wall, d=(1.0, 0.3), delta_frac=0.8, cells=0.0)
sweep(lv, x, b, order)
vcycle(levels, l, b, singular, nu, kind=None)
krylov_its(A, levels, singular, nu, method="bicgstab", maxit=300)
contraction(A, levels, singular, nu, its=40)
edge_frac(pa, pb)
transfers(n, prol)
hierarchy(C, pe, mode, prol, nmin=None)
precond(levels, singular, nu)
level_op(n, h, tx, ty, px, mx, py, my, unk, W, omega=None)
static double mean(const std::vector< double > &a)