flow
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
fv_wallflux_apriori.py
Go to the documentation of this file.
1"""A-priori test of the fully-FV cut-cell wall viscous flux with 7-point ingredients.
2
3Estimator (what the proposed FV collocated momentum assembly would compute):
4 F_est = mu * sum_cells sum_a W_a * g_a
5 - W_a = h^2 (o_{a-} - o_{a+}) : the wall fragment's area-weighted fluid-outward normal,
6 FREE from the face openness by the divergence theorem (no surface reconstruction).
7 - g_a : d(ux)/da at the wall from the UNIDIRECTIONAL wall-anchored quadratic along axis a
8 through {u(wall)=0, U_c, U_far} -- strictly 7-point data. Central difference where
9 the axis does not cross the wall; solid-center cells borrow the adjacent fluid
10 cell's gradient vector.
11Reference: F_exact = mu * closed-surface integral of grad(ux).n dA (n = fluid-outward = -rhat),
12by Fibonacci quadrature of the analytic Stokes solution. Note grad(ux) = n * d(ux)/dn EXACTLY
13on the wall (no-slip kills tangential derivatives), so the axis decomposition misses nothing
14structurally; the test measures fit truncation + evaluation-point displacement + attribution.
15Face openness computed semi-analytically (256-point chord integration of the exact disk cut),
16so the fragment identity is near-exact and the GRADIENT estimator is what is being tested.
17"""
18import numpy as np
19
20R = 0.3102
21C0 = np.array([0.013, -0.007, 0.004])
22MU = 0.1
23
24def ux(x, y, z):
25 dx, dy, dz = x - C0[0], y - C0[1], z - C0[2]
26 r = np.sqrt(dx*dx + dy*dy + dz*dz)
27 return 1.0 - 3*R/4*(1/r + dx*dx/r**3) - R**3/4*(1/r**3 - 3*dx*dx/r**5)
28
29def sdf(x, y, z):
30 return np.sqrt((x-C0[0])**2 + (y-C0[1])**2 + (z-C0[2])**2) - R
31
32def reference(M=200000, d=1e-6):
33 i = np.arange(M); ga = np.pi*(3-np.sqrt(5))
34 zz = 1 - 2*(i+0.5)/M; rr = np.sqrt(1-zz*zz); th = ga*i
35 nx, ny, nz = rr*np.cos(th), rr*np.sin(th), zz
36 px, py, pz = C0[0]+R*nx, C0[1]+R*ny, C0[2]+R*nz
37 gx = (ux(px+d,py,pz)-ux(px-d,py,pz))/(2*d)
38 gy = (ux(px,py+d,pz)-ux(px,py-d,pz))/(2*d)
39 gz = (ux(px,py,pz+d)-ux(px,py,pz-d))/(2*d)
40 return MU*np.mean(gx*(-nx)+gy*(-ny)+gz*(-nz))*4*np.pi*R*R
41
42def face_openness(N, axis, h):
43 """Fluid area fraction of every face perpendicular to `axis`, shape (N+1,N,N) in
44 (plane, t1, t2) order; t1,t2 = the other two axes in cyclic order. Semi-analytic:
45 the sphere cuts the face plane in a disk; per cut face integrate the chord (256 pts)."""
46 a1, a2 = (axis+1) % 3, (axis+2) % 3
47 w = np.arange(N+1)*h - 0.5 # plane coordinates
48 t1lo = np.arange(N)*h - 0.5 # square low edges
49 t2lo = np.arange(N)*h - 0.5
50 rho2 = R*R - (w - C0[axis])**2 # disk radius^2 per plane
51 O = np.ones((N+1, N, N))
52 c1, c2 = C0[a1], C0[a2]
53 for k in np.nonzero(rho2 > 0)[0]:
54 rho = np.sqrt(rho2[k])
55 # candidate squares: within rho+h*sqrt2 of the disk center
56 j1 = np.nonzero(np.abs(t1lo + h/2 - c1) < rho + h)[0]
57 j2 = np.nonzero(np.abs(t2lo + h/2 - c2) < rho + h)[0]
58 if len(j1) == 0 or len(j2) == 0:
59 continue
60 J1, J2 = np.meshgrid(j1, j2, indexing="ij")
61 lo1 = t1lo[J1.ravel()]; lo2 = t2lo[J2.ravel()]
62 # 256-point midpoint rule along t1; exact chord in t2
63 t = lo1[:, None] + (np.arange(256)[None, :] + 0.5)*(h/256)
64 s2 = rho*rho - (t - c1)**2 # chord half-width^2
65 half = np.sqrt(np.maximum(s2, 0.0))
66 zlo = np.maximum(c2 - half, lo2[:, None])
67 zhi = np.minimum(c2 + half, lo2[:, None] + h)
68 solid_frac = np.clip(zhi - zlo, 0.0, None).mean(axis=1)/h
69 O[k, J1.ravel(), J2.ravel()] = 1.0 - solid_frac
70 return O # (plane, t1, t2)
71
72def run(N, use_far2=False):
73 h = 1.0/N
74 c = (np.arange(N)+0.5)*h - 0.5
75 X, Y, Z = np.meshgrid(c, c, c, indexing="ij")
76 S = sdf(X, Y, Z)
77 fl = S >= 0
78 U = np.where(fl, ux(X, Y, Z), 0.0)
79
80 # face openness -> per-cell W_a = h^2 (o_minus - o_plus), axes ordered (x,y,z)
81 W = []
82 for a in range(3):
83 O = face_openness(N, a, h) # (plane, t1, t2) with t1,t2 = cyclic
84 om = O[:-1]; op = O[1:] # minus/plus face of each cell, cyclic order
85 Wa = h*h*(om - op) # (N,N,N) in (a, a+1, a+2) axis order
86 # reorder to (x,y,z)
87 Wa = np.moveaxis(Wa, [0, 1, 2], [a, (a+1) % 3, (a+2) % 3])
88 W.append(Wa)
89 sumW = [float(W[a].sum()) for a in range(3)]
90 area = float(np.sqrt(W[0]**2 + W[1]**2 + W[2]**2).sum())
91
92 # unidirectional wall gradients per axis (fluid-center cells)
93 g = [np.zeros((N, N, N)) for _ in range(3)]
94 gc = [np.zeros((N, N, N)) for _ in range(3)] # central-only baseline
95 for a in range(3):
96 up = np.roll(U, -1, a); um = np.roll(U, +1, a)
97 sp = np.roll(S, -1, a); sm = np.roll(S, +1, a)
98 solP = sp < 0; solM = sm < 0
99 thP = np.clip(np.where(solP, S/np.maximum(S-sp, 1e-30), 1.0), 1e-3, 1.0)
100 thM = np.clip(np.where(solM, S/np.maximum(S-sm, 1e-30), 1.0), 1e-3, 1.0)
101 # wall-anchored quadratic through (wall=0 at th), U_c at 0, U_far at -1 (7-point):
102 # alpha = [U_c (th+1)^2 - U_far th^2] / [th (th+1)] ; du/ds|wall = -alpha (s toward solid)
103 def alpha(th, Uc, Uf, farFluid):
104 quad = (Uc*(th+1.0)**2 - Uf*th*th)/(th*(th+1.0))
105 lin = Uc/th
106 return np.where(farFluid, quad, lin)
107 aP = alpha(thP, U, um, ~solM) # solid at +a -> far is -a neighbor
108 aM = alpha(thM, U, up, ~solP)
109 cen = (up - um)/(2*h)
110 both = solP & solM # two-walled along this axis (rare): average the two one-sided estimates
111 gA = np.where(solP & ~solM, -aP/h,
112 np.where(solM & ~solP, +aM/h,
113 np.where(both, 0.5*(-aP + aM)/h, cen)))
114 g[a] = np.where(fl, gA, 0.0)
115 gc[a] = np.where(fl, cen, 0.0)
116
117 # solid-center cells with wall fragments: borrow the gradient vector of the first
118 # fluid 6-neighbor (their fragments are small; measure their share)
119 need = (~fl) & ((np.abs(W[0]) + np.abs(W[1]) + np.abs(W[2])) > 0)
120 filled = np.zeros_like(need)
121 for a in range(3):
122 for d in (-1, +1):
123 nb_fl = np.roll(fl, -d, a)
124 m = need & ~filled & nb_fl
125 for q in range(3):
126 g[q][m] = np.roll(g[q], -d, a)[m]
127 gc[q][m] = np.roll(gc[q], -d, a)[m]
128 filled |= m
129 orphan_share = float(np.sqrt(sum(W[a][need & ~filled]**2 for a in range(3))).sum()/max(area, 1e-30))
130
131 Fest = MU*sum((W[a]*g[a]).sum() for a in range(3))
132 Fcen = MU*sum((W[a]*gc[a]).sum() for a in range(3))
133
134 # ---- Variant B: anchor each 1-D profile at the FRAGMENT CENTROID p* = x - sdf*grad(sdf)
135 # (the SDF closest-point projection). The axis line through p* meets the wall AT p*, so the
136 # wall-anchored one-sided quadratic needs only two fluid samples per axis at p* +/- sigma*h,
137 # +/- 2 sigma*h (sigma = fluid side = sign(n_a); both samples are provably on the fluid side).
138 # No solid-center borrowing needed: every fragment cell projects its own anchor. In-solver the
139 # two samples come from trilinear interpolation of cell values (deferred-correction candidate);
140 # here analytic u isolates the PLACEMENT question.
141 m = (np.abs(W[0]) + np.abs(W[1]) + np.abs(W[2])) > 0
142 xm, ym, zm = X[m], Y[m], Z[m]
143 rx, ry, rz = xm - C0[0], ym - C0[1], zm - C0[2]
144 rr = np.sqrt(rx*rx + ry*ry + rz*rz)
145 nxh, nyh, nzh = rx/rr, ry/rr, rz/rr
146 px, py, pz = C0[0] + R*nxh, C0[1] + R*nyh, C0[2] + R*nzh # p* on the surface
147 FB = 0.0
148 nh = [nxh, nyh, nzh]
149 for a in range(3):
150 sg = np.where(nh[a] >= 0, 1.0, -1.0)
151 d1 = [px.copy(), py.copy(), pz.copy()]; d1[a] = d1[a] + sg*h
152 d2 = [px.copy(), py.copy(), pz.copy()]; d2[a] = d2[a] + 2*sg*h
153 u1 = ux(d1[0], d1[1], d1[2])
154 u2 = ux(d2[0], d2[1], d2[2])
155 gB = sg*(2.0*u1 - 0.5*u2)/h # du/da at the wall (one-sided quadratic, u(0)=0)
156 FB += (W[a][m]*gB).sum()
157 FB *= MU
158 return dict(Fest=Fest, Fcen=Fcen, FB=FB, sumW=sumW, area=area, orphan=orphan_share)
159
160# ------------------------------------------------------------------------------------------------
161# Variant C: Basilisk `dirichlet_gradient` — TRUE-NORMAL image-point wall gradient.
162# Instead of decomposing into three axis-anchored 1-D quadratics, reconstruct the single scalar
163# wall-normal derivative d(ux)/dn along n̂ = ∇sdf directly, exactly as Basilisk embed.h does:
164# * boundary point p = SDF foot point x - sdf·n̂ (proxy for the fragment centroid), in cell units;
165# * along the DOMINANT axis (largest |n̂_a|) take two image points i=1,2 cells into the fluid;
166# * get u there by TRANSVERSE bi-quadratic interpolation of the cell-centred values (Basilisk's
167# `quadratic` Lagrange stencil), gated `defined` on the 3x3 transverse cells being fluid;
168# * fit the quadratic {u=0 at wall, v0 at d0, v1 at d1} for a 2nd-order d(ux)/dn;
169# * FALLBACKS (Basilisk): if only the near image point is defined -> 1-point linear (2nd order);
170# if neither is defined (sliver) -> the degenerate 1-point through the cell centre itself.
171# The wall flux is then F = -mu * Σ_frag A_frag · d(ux)/dn (A_frag = |W|, the fragment area;
172# n̂ = fluid-outward so d(ux)/dn > 0 and F < 0, matching the reference). This is the ingredient the
173# C++ embed operator will use; the test confirms it is O(h²) AND that the sliver fallback keeps it
174# well-behaved (fallback-cell share reported).
175
176def _quad(x, a1, a2, a3):
177 # Lagrange quadratic through (-1,a1),(0,a2),(+1,a3), evaluated at x (Basilisk `quadratic`).
178 return (a1 * (x - 1.0) + a3 * (x + 1.0)) * x / 2.0 - a2 * (x - 1.0) * (x + 1.0)
179
180def run_normal(N, analytic=False):
181 h = 1.0 / N
182 c = (np.arange(N) + 0.5) * h - 0.5
183 X, Y, Z = np.meshgrid(c, c, c, indexing="ij")
184 S = sdf(X, Y, Z)
185 fl = S >= 0
186 U = np.where(fl, ux(X, Y, Z), 0.0)
187
188 # fragment area per cell from the face openness (same W as variants A/B)
189 W = []
190 for a in range(3):
191 O = face_openness(N, a, h)
192 Wa = h * h * (O[:-1] - O[1:])
193 W.append(np.moveaxis(Wa, [0, 1, 2], [a, (a + 1) % 3, (a + 2) % 3]))
194 A = np.sqrt(W[0]**2 + W[1]**2 + W[2]**2) # fragment area per cell
195 m = A > 0 # fragment cells (fluid- AND solid-centred)
196
197 # unit inward-to-fluid normal n̂ = ∇sdf (analytic here; ∇sdf in the solver) at fragment cells
198 Im, Jm, Km = np.nonzero(m)
199 xm, ym, zm = X[m], Y[m], Z[m]
200 rx, ry, rz = xm - C0[0], ym - C0[1], zm - C0[2]
201 rr = np.sqrt(rx * rx + ry * ry + rz * rz)
202 nhat = np.stack([rx / rr, ry / rr, rz / rr], axis=1) # (M,3), fluid-outward = ∇sdf
203 sdm = S[m]
204 pcell = -sdm[:, None] / h * nhat # foot point, cell units, per axis (M,3)
205 cidx = np.stack([Im, Jm, Km], axis=1) # (M,3) integer cell coords
206 Ucell = U[m]
207 Aw = A[m]
208
209 Mn = len(Im)
210 grad = np.full(Mn, np.nan) # d(ux)/dn (into fluid)
211 used = np.zeros(Mn, dtype=int) # 0=degenerate coef, 1=near-only, 2=two-pt
212 bl = np.zeros(Mn, dtype=bool) # any image point used biased-linear interp
213 da = np.argmax(np.abs(nhat), axis=1) # dominant axis per cell
214
215 def gather(cc, a, oa, t1, ot1, t2, ot2):
216 idx = [cc[:, 0].copy(), cc[:, 1].copy(), cc[:, 2].copy()]
217 idx[a] = np.clip(idx[a] + oa, 0, N - 1)
218 idx[t1] = np.clip(idx[t1] + ot1, 0, N - 1)
219 idx[t2] = np.clip(idx[t2] + ot2, 0, N - 1)
220 return U[idx[0], idx[1], idx[2]], (S[idx[0], idx[1], idx[2]] >= 0)
221
222 for a in range(3):
223 g = np.nonzero(da == a)[0]
224 if len(g) == 0:
225 continue
226 t1, t2 = (a + 1) % 3, (a + 2) % 3
227 cc = cidx[g]
228 na, nt1, nt2 = nhat[g, a], nhat[g, t1], nhat[g, t2]
229 pa, pt1, pt2 = pcell[g, a], pcell[g, t1], pcell[g, t2]
230 sgn = np.where(na >= 0, 1.0, -1.0)
231 v = [None, None]
232 dd = [None, None]
233 defd = [None, None]
234 for l in (0, 1):
235 i = (l + 1) * sgn.astype(int) # ±1, ±2 into the fluid
236 dl = (i - pa) / na # distance (cells) to image plane
237 y1 = pt1 + dl * nt1
238 z1 = pt2 + dl * nt2
239 j = np.clip(np.round(y1), -1, 1).astype(int)
240 k = np.clip(np.round(z1), -1, 1).astype(int)
241 ly, lz = y1 - j, z1 - k
242 if analytic: # sample the exact field at the image point (isolate geometry+fit)
243 imx = [None, None, None]
244 imx[a] = X[cc[:, 0], cc[:, 1], cc[:, 2]] + i * h if a == 0 else None
245 # build physical image-point coords from cell center + (i, y1, z1)*h
246 base = [X[cc[:, 0], cc[:, 1], cc[:, 2]], Y[cc[:, 0], cc[:, 1], cc[:, 2]],
247 Z[cc[:, 0], cc[:, 1], cc[:, 2]]]
248 base[a] = base[a] + i * h
249 base[t1] = base[t1] + y1 * h
250 base[t2] = base[t2] + z1 * h
251 v[l] = ux(base[0], base[1], base[2])
252 dd[l] = dl
253 defd[l] = np.ones(len(g), dtype=bool)
254 else:
255 # gather the 3x3 transverse stencil (values + fluid flags) at column a=i
256 vv = {}
257 sf = {}
258 for dk in (-1, 0, 1):
259 for dj in (-1, 0, 1):
260 vv[(dj, dk)], sf[(dj, dk)] = gather(cc, a, i, t1, j + dj, t2, k + dk)
261 full = np.ones(len(g), dtype=bool)
262 for key in sf:
263 full &= sf[key]
264 # (1) full 3x3 fluid -> bi-quadratic (Basilisk `quadratic` x `quadratic`)
265 vbq = _quad(lz, _quad(ly, vv[(-1, -1)], vv[(0, -1)], vv[(1, -1)]),
266 _quad(ly, vv[(-1, 0)], vv[(0, 0)], vv[(1, 0)]),
267 _quad(ly, vv[(-1, 1)], vv[(0, 1)], vv[(1, 1)]))
268 # (2) home cell fluid but stencil straddles -> Basilisk `embed_interpolate`
269 # biased-linear: anchored at the fluid home cell, each transverse gradient
270 # biased toward whichever side (+ or -) is fluid.
271 home = sf[(0, 0)]
272
273 def biased(coord, keyP, keyM): # bias toward +side if fluid, else -side
274 return np.where(sf[keyP], np.abs(coord) * (vv[keyP] - vv[(0, 0)]),
275 np.where(sf[keyM], np.abs(coord) * (vv[(0, 0)] - vv[keyM]), 0.0))
276 # transverse t1 forward = sign(ly); t2 forward = sign(lz)
277 spj = ly >= 0
278 spk = lz >= 0
279 cj = np.where(spj, biased(ly, (1, 0), (-1, 0)), biased(ly, (-1, 0), (1, 0)))
280 ck = np.where(spk, biased(lz, (0, 1), (0, -1)), biased(lz, (0, -1), (0, 1)))
281 vbl = vv[(0, 0)] + cj + ck
282 v[l] = np.where(full, vbq, vbl)
283 dd[l] = dl
284 defd[l] = full | home
285 bl[g] |= (home & ~full) # this image point fell to biased-linear
286 d0, d1 = dd[0], dd[1]
287 v0, v1 = v[0], v[1]
288 two = defd[0] & defd[1]
289 near = defd[0] & ~defd[1]
290 deg = ~defd[0]
291 gg = np.zeros(len(g))
292 gg[two] = (v0[two] * d1[two] / d0[two] - v1[two] * d0[two] / d1[two]) / \
293 ((d1[two] - d0[two]) * h)
294 gg[near] = v0[near] / (d0[near] * h)
295 d0deg = np.maximum(1e-3, np.abs(pa[deg] / na[deg]))
296 gg[deg] = Ucell[g][deg] / (d0deg * h)
297 grad[g] = gg
298 used[g] = np.where(two, 2, np.where(near, 1, 0))
299
300 Fnorm = -MU * float(np.sum(Aw * grad))
301 frac_bl = float(np.mean(bl)) # share using biased-linear (straddling)
302 frac_deg = float(np.mean(used == 0)) # truly degenerate (coef path, u_cell)
303 return dict(Fnorm=Fnorm, frac_bl=frac_bl, frac_deg=frac_deg,
304 area=float(Aw.sum()), Mn=Mn)
305
307print(f"F_exact = {Fex:+.6e} (mu * closed-surface integral of grad(ux).n, n fluid-outward)")
308print(f"{'N':>5} | {'A: axis-intercept':>17} {'ord':>5} | {'B: centroid-anchored':>20} {'ord':>5} | "
309 f"{'central naive':>13} | {'sum|W|/4piR^2':>13}")
310pA = pB = pN = None
311for N in (32, 64, 128, 192):
312 r = run(N)
313 eA = 100*(r["Fest"]-Fex)/abs(Fex); eB = 100*(r["FB"]-Fex)/abs(Fex)
314 eC = 100*(r["Fcen"]-Fex)/abs(Fex)
315 oA = np.log2(abs(pA)/abs(eA)) if pA else float("nan")
316 oB = np.log2(abs(pB)/abs(eB)) if pB else float("nan")
317 fac = np.log2(N/pN) if pA else 1.0
318 print(f"{N:>5} | {eA:>+16.3f}% {oA/fac if pA else float('nan'):>5.2f} | "
319 f"{eB:>+19.4f}% {oB/fac if pB else float('nan'):>5.2f} | {eC:>+12.2f}% | "
320 f"{r['area']/(4*np.pi*R*R):>13.6f}")
321 pA, pB, pN = eA, eB, N
322
323# --- Variant C: true-normal Basilisk dirichlet_gradient (the C++ embed operator's ingredient) ---
324print()
325print("Variant C: true-normal image-point wall gradient (Basilisk dirichlet_gradient)")
326print(f"{'N':>5} | {'C: true-normal':>16} {'ord':>5} | {'biased-lin %':>12} | {'degen %':>8} | "
327 f"{'Ncut':>7}")
328pC = pN2 = None
329for N in (32, 64, 128, 192):
330 r = run_normal(N)
331 eC = 100 * (r["Fnorm"] - Fex) / abs(Fex)
332 oC = np.log2(abs(pC) / abs(eC)) / np.log2(N / pN2) if pC else float("nan")
333 print(f"{N:>5} | {eC:>+15.4f}% {oC:>5.2f} | {100*r['frac_bl']:>11.2f}% | "
334 f"{100*r['frac_deg']:>7.3f}% | {r['Mn']:>7}")
335 pC, pN2 = eC, N
reference(M=200000, d=1e-6)
run_normal(N, analytic=False)
run(N, use_far2=False)