flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
disc_bc.py
Go to the documentation of this file.
1"""2-D prototype: immersed-surface scalar BCs on a pipe cross-section (disc of radius R=1 in a
2square Cartesian box, fluid INSIDE the disc). Every gate has an exact answer:
3
4 Neumann : first non-zero eigenvalue of -lap on the disc j'_11^2 = 3.389957
5 Taylor-Aris closure -<u'b> = 1/48 (D=U=R=1) (D_ax/alpha = 1 + Pe^2/48)
6 Dirichlet: decay eigenvalue j_01^2 = 5.783186
7 Graetz total-flux Nu_F = Pe*lambda*R at Pe = 10, 1000 (1F1 root, Peters eq. 43-44)
8
9Discretizations compared (sparse assembly here; every one is a <=5-point row, i.e. 7-point in 3-D):
10 Neumann:
11 FV-ap/kappa : aperture two-point fluxes, storage/source weighted by fluid fraction kappa
12 FV-ap/1 : same fluxes, unit storage/source (what flow's scalar path does today)
13 stair : binary apertures, centre-in-fluid cells only (pure staircase baseline)
14 Dirichlet:
15 mask-ap : flow's per-cell Dirichlet mask (centre-in-solid cells pinned to 0) + apertures
16 ghost-lin : Gibou 2002 linear ghost, symmetric (diagonal += 1/theta)
17 ghost-quad : Robust-Scaled scheme-0 quadratic ghost (flow cut_cell_closure.hpp), non-symmetric
18 FV-wall : cut-cell FV, all kappa>0 cells, wall flux D*A_w*(c_G - c_P)/d(centroid), mass kappa
19"""
20import numpy as np
21import scipy.sparse as sp
22import scipy.sparse.linalg as spla
23from scipy.optimize import brentq
24from scipy.special import hyp1f1, jn_zeros, jnp_zeros
25
26R = 1.0
27J01SQ = jn_zeros(0, 1)[0] ** 2
28JP11SQ = jnp_zeros(1, 1)[0] ** 2
29
30
31def graetz_ref(Pe):
32 f = lambda s: hyp1f1((1 - s - s**3 / Pe**2) / 2, 1, 2 * s)
33 ss = np.linspace(0.05, 3, 600)
34 v = [f(s) for s in ss]
35 for k in range(len(ss) - 1):
36 if v[k] * v[k + 1] < 0:
37 s = brentq(f, ss[k], ss[k + 1], xtol=1e-14)
38 return 2 * s * s # Nu_F = Pe*lambda*R = 2 s^2
39 raise RuntimeError
40
41
42class Geo:
43 def __init__(self, ND, off):
44 h = 2 * R / ND
45 n = ND + 6
46 self.h, self.n = h, n
47 x0 = -n * h / 2 + off[0] * h
48 y0 = -n * h / 2 + off[1] * h
49 self.xe = x0 + h * np.arange(n + 1) # cell edges
50 self.ye = y0 + h * np.arange(n + 1)
51 self.xc = 0.5 * (self.xe[1:] + self.xe[:-1])
52 self.yc = 0.5 * (self.ye[1:] + self.ye[:-1])
53 X, Y = np.meshgrid(self.xc, self.yc, indexing="ij")
54 self.sdf = R - np.hypot(X, Y) # >0 fluid
55 # x-face apertures ax[i, j]: face at xe[i], cell row j (i = 0..n)
56 s = np.sqrt(np.maximum(R**2 - self.xe**2, 0))[:, None]
57 lo, hi = self.ye[None, :-1], self.ye[None, 1:]
58 self.ax = np.clip(np.minimum(hi, s) - np.maximum(lo, -s), 0, h) / h
59 s = np.sqrt(np.maximum(R**2 - self.ye**2, 0))[None, :]
60 lo, hi = self.xe[:-1, None], self.xe[1:, None]
61 self.ay = np.clip(np.minimum(hi, s) - np.maximum(lo, -s), 0, h) / h
62 # fluid fraction, fluid centroid, fluid-average of u=2(1-r^2) by Gauss quadrature
63 g, w = np.polynomial.legendre.leggauss(48)
64 self.kap = np.zeros((n, n))
65 self.cx = X.copy(); self.cy = Y.copy()
66 self.ubar = np.zeros((n, n))
67 cut = (np.abs(self.sdf) < 1.5 * h)
68 full = (self.sdf >= 1.5 * h)
69 self.kap[full] = 1.0
70 self.ubar[full] = 2 * (1 - X[full] ** 2 - Y[full] ** 2) - (h * h / 3) # exact cell average
71 for i, j in zip(*np.nonzero(cut)):
72 xs = self.xc[i] + 0.5 * h * g
73 ys = self.yc[j] + 0.5 * h * g
74 XX, YY = np.meshgrid(xs, ys, indexing="ij")
75 m = (XX**2 + YY**2 < R**2)
76 W = np.outer(w, w) * 0.25 * m
77 a = W.sum()
78 self.kap[i, j] = a
79 if a > 0:
80 self.cx[i, j] = (W * XX).sum() / a
81 self.cy[i, j] = (W * YY).sum() / a
82 self.ubar[i, j] = (W * 2 * (1 - XX**2 - YY**2)).sum() / a
83 # wall length per cell from the aperture area vector (flow: A = -(o[a+1]-o[a]))
84 Awx = -(self.ax[1:, :] - self.ax[:-1, :]) * h
85 Awy = -(self.ay[:, 1:] - self.ay[:, :-1]) * h
86 self.Aw = np.hypot(Awx, Awy)
87 self.dwall = R - np.hypot(self.cx, self.cy) # centroid-to-wall distance
88
89
90def assemble_fv(G, ap_x, ap_y, cells, wall_coef=None):
91 """-sum_f a_f (cQ-cP)/h^2 on the index set `cells`; optional extra diagonal (wall flux)."""
92 n, h = G.n, G.h
93 idx = -np.ones((n, n), int)
94 idx[cells] = np.arange(cells.sum())
95 rows, cols, vals = [], [], []
96 diag = np.zeros(cells.sum())
97 # x faces between (i-1,j) and (i,j): aperture ap_x[i,j], i=1..n-1
98 for (A, sh) in ((ap_x, (1, 0)), (ap_y, (0, 1))):
99 if sh == (1, 0):
100 a = A[1:-1, :]; P = idx[:-1, :]; Q = idx[1:, :]
101 else:
102 a = A[:, 1:-1]; P = idx[:, :-1]; Q = idx[:, 1:]
103 m = (a > 0) & (P >= 0) & (Q >= 0)
104 a, P, Q = a[m] / h**2, P[m], Q[m]
105 rows += [P, Q]; cols += [Q, P]; vals += [-a, -a]
106 np.add.at(diag, P, a); np.add.at(diag, Q, a)
107 if wall_coef is not None:
108 diag += wall_coef[cells]
109 rows.append(np.arange(len(diag))); cols.append(np.arange(len(diag))); vals.append(diag)
110 K = sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))),
111 shape=(len(diag),) * 2)
112 return K, idx
113
114
115def assemble_ghost(G, kind, theta_min=1e-3):
116 """Cell-centred 5-point -lap on centre-in-fluid cells, Dirichlet 0 at the SDF crossing."""
117 n, h = G.n, G.h
118 cells = G.sdf > 0
119 idx = -np.ones((n, n), int)
120 idx[cells] = np.arange(cells.sum())
121 N = cells.sum()
122 K = sp.lil_matrix((N, N))
123 for i, j in zip(*np.nonzero(cells)):
124 r = idx[i, j]
125 for (di, dj) in ((1, 0), (-1, 0), (0, 1), (0, -1)):
126 q = idx[i + di, j + dj]
127 if q >= 0:
128 K[r, q] -= 1 / h**2; K[r, r] += 1 / h**2
129 continue
130 th = G.sdf[i, j] / (G.sdf[i, j] - G.sdf[i + di, j + dj])
131 th = max(th, theta_min)
132 far = idx[i - di, j - dj]
133 if kind == "lin" or far < 0:
134 K[r, r] += 1 / (th * h**2) # ghost = cP + (0-cP)/th
135 else: # quadratic Lagrange {far, near, wall} -> ghost; D*g = Nc*near + Nnb*far
136 D = th * (1 + th); Nc = 2 * (th * th - 1); Nnb = th * (1 - th)
137 # contribution (g - cP)/h^2 with g = (Nc cP + Nnb cF)/D, sign: K = -lap
138 K[r, r] -= (Nc / D - 1) / h**2
139 K[r, far] -= (Nnb / D) / h**2
140 return K.tocsr(), idx
141
142
143def smallest_eig(K, Mdiag, k=2):
144 """Smallest eigenvalues of K v = mu M v (M diagonal > 0)."""
145 s = 1 / np.sqrt(Mdiag)
146 A = sp.diags(s) @ K @ sp.diags(s)
147 sym = abs(A - A.T).max() < 1e-9 * abs(A).max()
148 if sym:
149 w = spla.eigsh(A, k=k, sigma=-1e-3, which="LM", return_eigenvectors=False)
150 else:
151 w = spla.eigs(A, k=k, sigma=-1e-3, which="LM", return_eigenvectors=False).real
152 return np.sort(w)
153
154
155def graetz(K, Mdiag, udiag, Pe):
156 """Smallest lambda>0 with K v = (lambda^2 M + lambda Pe M u) v (D=1, U=Pe, R=1)."""
157 def g(lam):
158 B = Mdiag * (lam**2 + lam * Pe * udiag)
159 return smallest_eig(K, B, k=1)[0] - 1.0
160 lam_hi = 10.0 / Pe + 3.0 / np.sqrt(Pe + 1)
161 while g(lam_hi) > 0:
162 lam_hi *= 2
163 lam = brentq(g, 1e-6, lam_hi, xtol=1e-12)
164 return Pe * lam
165
166
167def run(ND, off, Pes=(10.0, 1000.0)):
168 G = Geo(ND, off)
169 out = {}
170 # ---------------- Neumann ----------------
171 cells = G.kap > 1e-12
172 K, idx = assemble_fv(G, G.ax, G.ay, cells)
173 kap = G.kap[cells]; ub = G.ubar[cells]
174 for tag, M in (("FV-ap/kappa", kap), ("FV-ap/1", np.ones_like(kap))):
175 w = smallest_eig(K, M, k=2)
176 out[("N-eig", tag)] = w[1]
177 # Taylor-Aris: lap b = u' (D=1) -> K b = -M u'; -<u'b> = 1/48
178 U = (M * ub).sum() / M.sum()
179 rhs = -M * (ub - U)
180 b = spla.spsolve((K + 1e-14 * sp.eye(K.shape[0])).tocsc(), rhs)
181 b -= (M * b).sum() / M.sum()
182 out[("TA", tag)] = -(M * (ub - U) * b).sum() / M.sum()
183 sc = G.sdf > 0
184 axb = np.zeros_like(G.ax); ayb = np.zeros_like(G.ay)
185 axb[1:-1, :] = (sc[1:, :] & sc[:-1, :]); ayb[:, 1:-1] = (sc[:, 1:] & sc[:, :-1])
186 Ks, _ = assemble_fv(G, axb, ayb, sc)
187 w = smallest_eig(Ks, np.ones(sc.sum()), k=2)
188 out[("N-eig", "stair")] = w[1]
189 u_c = 2 * (1 - G.sdf * 0 - (R - G.sdf) ** 2) # u at cell centre
190 U = u_c[sc].mean()
191 bb = spla.spsolve((Ks + 1e-14 * sp.eye(Ks.shape[0])).tocsc(), -(u_c[sc] - U))
192 bb -= bb.mean()
193 out[("TA", "stair")] = -((u_c[sc] - U) * bb).mean()
194 # ---------------- Dirichlet ----------------
195 # mask-ap: unknowns centre-fluid; masked neighbours (value 0) via the aperture coupling
196 # = FV rows with apertures, diag keeps the coupling to the masked neighbour.
197 n = G.n
198 wallc = np.zeros((n, n))
199 for (di, dj, A, ii, jj) in ((1, 0, G.ax, 1, 0), (-1, 0, G.ax, 0, 0), (0, 1, G.ay, 0, 1), (0, -1, G.ay, 0, 0)):
200 pass
201 # explicit loop for clarity
202 for i, j in zip(*np.nonzero(sc)):
203 for (di, dj, a) in ((1, 0, G.ax[i + 1, j]), (-1, 0, G.ax[i, j]), (0, 1, G.ay[i, j + 1]), (0, -1, G.ay[i, j])):
204 if not sc[i + di, j + dj]:
205 wallc[i, j] += a / G.h**2
206 Km, _ = assemble_fv(G, G.ax, G.ay, sc, wall_coef=wallc)
207 meth = {"mask-ap": (Km, np.ones(sc.sum()), u_c[sc])}
208 for kind in ("lin", "quad"):
209 Kg, _ = assemble_ghost(G, kind)
210 meth["ghost-" + kind] = (Kg, np.ones(sc.sum()), u_c[sc])
211 wc = np.where(G.kap > 1e-12, G.Aw / (G.h**2 * np.maximum(G.dwall, 1e-3 * G.h)), 0)
212 # wall_coef is per unit cell area (rows are /h^2 already) -> divide A_w*D/d by h^2
213 Kf, _ = assemble_fv(G, G.ax, G.ay, cells, wall_coef=wc)
214 meth["FV-wall"] = (Kf, kap, ub)
215 for tag, (KK, M, u) in meth.items():
216 out[("D-eig", tag)] = smallest_eig(KK, M, k=1)[0]
217 for Pe in Pes:
218 out[(f"NuF{Pe:g}", tag)] = graetz(KK, M, u, Pe)
219 return out
220
221
222if __name__ == "__main__":
223 import sys
224 rng = np.random.default_rng(1)
225 offs = rng.random((int(sys.argv[1]) if len(sys.argv) > 1 else 3, 2))
226 NDs = [int(a) for a in sys.argv[2:]] or [16, 32, 64, 128]
227 ref = {"N-eig": JP11SQ, "TA": 1 / 48, "D-eig": J01SQ,
228 "NuF10": graetz_ref(10.0), "NuF1000": graetz_ref(1000.0)}
229 print("references:", {k: round(v, 6) for k, v in ref.items()})
230 res = {}
231 for ND in NDs:
232 for o in offs:
233 for k, v in run(ND, o).items():
234 res.setdefault((k, ND), []).append(v)
235 keys = sorted({k for (k, _) in res})
236 for (q, tag) in keys:
237 line = f"{q:8s} {tag:12s}"
238 prev = None
239 for ND in NDs:
240 e = np.array(res[((q, tag), ND)]) / ref[q] - 1
241 em = np.sqrt((e**2).mean())
242 line += f" | N={ND:3d} rms {em:9.2e} spread {e.max()-e.min():8.1e}"
243 if prev is not None:
244 line += f" p={np.log(prev/em)/np.log(2):4.1f}"
245 prev = em
246 print(line)
__init__(self, ND, off)
Definition disc_bc.py:43
assemble_ghost(G, kind, theta_min=1e-3)
Definition disc_bc.py:115
graetz(K, Mdiag, udiag, Pe)
Definition disc_bc.py:155
graetz_ref(Pe)
Definition disc_bc.py:31
run(ND, off, Pes=(10.0, 1000.0))
Definition disc_bc.py:167
static double mean(const std::vector< double > &a)