flow 0.4.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
staggered_zh_exact_norms.py
Go to the documentation of this file.
1"""Zick & Homsy comparison with ANALYTIC-SDF exact IBM points: cut-cell vs ghost (1,1)/(1,2)/(2,2).
2
3Exact geometry for the simple-cubic sphere lattice (phi = 0.125), all in grid units:
4 * exact wall crossings t[c][k] (line-sphere intersection, nearest image) feed
5 set_exact_crossings -> exact theta in BOTH the momentum cut-cell stencil and the
6 ghost-projection closures (replaces the O(h^2) linear-interp anchoring);
7 * exact face apertures (analytic disk-chord integration) feed set_openness_override ->
8 the cut-cell projection's openness is exact.
9
10Reports, per scheme and N: Z&H drag error, and L1/L2/Linf norms of the velocity and pressure
11fields against a fine cut-cell-exact reference (default N=192), interpolated to the coarse
12sample points by periodic cubic splines (scipy map_coordinates, grid-wrap). Norms over fluid
13sample points, relative to the reference field's rms on the same mask; pressure mean-aligned.
14Linf additionally reported on the interior fluid (sdf > 2 h_coarse) because the reference
15spline carries O(h_ref^2) kink error in the first wall band. Pairwise Richardson orders.
16
17Run: SDFLOW_BUILD=build_cuda2 python tests/study/staggered_zh_exact_norms.py [--quick]
18 (--run-ref to (re)compute the reference; otherwise loaded from zh_exact_ref_N<ref>.npz)
19"""
20import argparse
21import os
22import sys
23import time
24
25sys.path.insert(0, os.path.abspath(os.environ.get("SDFLOW_BUILD", "build_cuda2")))
26import numpy as np
27from scipy.ndimage import map_coordinates
28
29from peclet import flow
30
31PHI = 0.125
32MU = 0.1
33F = 1e-3
34KREF = 4.2920
35HERE = os.path.dirname(os.path.abspath(__file__))
36
37
38def radius(N):
39 return (3 * PHI / (4 * np.pi)) ** (1 / 3) * N
40
41
42# ---------------------------------------------------------------- exact geometry (grid units)
43def point_grid(N, c):
44 """Staggered point coordinates for component c (or cell centers for c=None)."""
45 g = np.arange(N, dtype=np.float64)
46 off = [0.5, 0.5, 0.5]
47 if c is not None:
48 off[c] = 0.0
49 return np.meshgrid(g + off[0], g + off[1], g + off[2], indexing="ij")
50
51
52def nimg(d, N):
53 return d - N * np.round(d / N)
54
55
56def sdf_at(N, X, Y, Z):
57 C = 0.5 * N
58 return np.sqrt(nimg(X - C, N) ** 2 + nimg(Y - C, N) ** 2 + nimg(Z - C, N) ** 2) - radius(N)
59
60
62 """t[(c*3+k)*n + i]: exact crossing fraction from component-c staggered point i toward its
63 +k neighbour (NaN = no sign change). Line-sphere with nearest image; roots of
64 t^2 + 2 t d_k + |d|^2 - R^2 = 0 restricted to the sign-changing segment."""
65 R = radius(N)
66 C = 0.5 * N
67 out = np.full((3, 3, N, N, N), np.nan)
68 for c in range(3):
69 X, Y, Z = point_grid(N, c)
70 d = [nimg(X - C, N), nimg(Y - C, N), nimg(Z - C, N)]
71 d2 = d[0] ** 2 + d[1] ** 2 + d[2] ** 2
72 s_here = np.sqrt(d2) - R
73 for k in range(3):
74 s_nb = np.roll(s_here, -1, axis=k) # exact sdf at the +k neighbour point (periodic)
75 cross = (s_here < 0) != (s_nb < 0)
76 disc = d[k] ** 2 - (d2 - R * R)
77 ok = cross & (disc > 0)
78 sq = np.sqrt(np.where(ok, disc, 1.0))
79 t1 = -d[k] - sq
80 t2 = -d[k] + sq
81 eps = 1e-12
82 in1 = ok & (t1 >= -eps) & (t1 <= 1 + eps)
83 in2 = ok & (t2 >= -eps) & (t2 <= 1 + eps)
84 t = np.where(in1, t1, np.where(in2, t2, np.nan))
85 out[c, k] = np.clip(t, 0.0, 1.0)
86 miss = cross & ~np.isfinite(out[c, k])
87 if miss.any(): # tangent-grazing straddles: fall back to linear interp there
88 tl = s_here / (s_here - s_nb)
89 out[c, k][miss] = np.clip(tl[miss], 0.0, 1.0)
90 # solver expects flat x-fastest blocks, ordered [(c*3 + k)]
91 return np.concatenate([out[c, k].ravel(order="F") for c in range(3) for k in range(3)])
92
93
95 """Exact fluid area fraction of every -a face (analytic chord integral of the disk cut,
96 1024-pt midpoint rule; faces are unit squares in grid units)."""
97 R = radius(N)
98 C = 0.5 * N
99 M = 1024
100 res = []
101 for a in range(3):
102 o = np.ones((N, N, N))
103 t1a, t2a = (a + 1) % 3, (a + 2) % 3
104 w = nimg(np.arange(N, dtype=np.float64) - C, N) # face-plane coord along a (x=i etc.)
105 rho2 = R * R - w * w
106 lo = np.arange(N, dtype=np.float64) # square low edges (t1, t2)
107 for i in np.nonzero(rho2 > 0)[0]:
108 rho = np.sqrt(rho2[i])
109 j1 = np.nonzero(np.abs(nimg(lo + 0.5 - C, N)) < rho + 1.0)[0]
110 j2 = np.nonzero(np.abs(nimg(lo + 0.5 - C, N)) < rho + 1.0)[0]
111 if len(j1) == 0 or len(j2) == 0:
112 continue
113 J1, J2 = np.meshgrid(j1, j2, indexing="ij")
114 lo1 = nimg(lo[J1.ravel()] - C, N) # displaced to sphere frame
115 lo2 = nimg(lo[J2.ravel()] - C, N)
116 t = lo1[:, None] + (np.arange(M)[None, :] + 0.5) / M
117 half = np.sqrt(np.maximum(rho * rho - t * t, 0.0))
118 zlo = np.maximum(-half, lo2[:, None])
119 zhi = np.minimum(half, lo2[:, None] + 1.0)
120 solid = np.clip(zhi - zlo, 0.0, None).mean(axis=1)
121 idx = [None, None, None]
122 idx[a] = np.full(len(lo1), i)
123 idx[t1a] = J1.ravel()
124 idx[t2a] = J2.ravel()
125 o[idx[0], idx[1], idx[2]] -= solid
126 # axes of the array are (a, t1, t2) by construction order -> reorder to (x, y, z)
127 o = np.clip(np.moveaxis(o, [0, 1, 2], [a, t1a, t2a]), 0.0, 1.0)
128 # The scheme's flux DOF lives at the face's staggered velocity point and is PINNED to 0
129 # when that point is solid (maskVelocity). A nonzero aperture there would count flux the
130 # scheme cannot carry (and re-masking after the projection would break the just-projected
131 # divergence — measured 1.9e-3 residual). Exact-openness therefore means: exact aperture
132 # on faces whose velocity point is fluid, zero otherwise — same structure as the sampled
133 # ccFaceOpen, with the retained apertures exact.
134 Xf, Yf, Zf = point_grid(N, a)
135 o = np.where(sdf_at(N, Xf, Yf, Zf) >= 0.0, o, 0.0)
136 res.append(o)
137 return res
138
139
140# ---------------------------------------------------------------- solver run
141def run(N, scheme, warm_tol=1e-7, tail=40, max_steps=5000, dt=80.0):
142 X, Y, Z = point_grid(N, None)
143 sdf = sdf_at(N, X, Y, Z)
144 lv = max(2, int(np.log2(N)) - 1)
145 s = flow.Solver(N, N, N)
146 s.set_rho(1.0)
147 s.set_mu(MU)
148 s.set_dt(dt)
149 s.set_body_force(F, 0, 0)
150 s.set_advection(False)
151 s.set_velocity_solver_params(200)
152 s.set_pressure_multigrid(True, levels=lv)
153 s.set_pressure_pcg(True, 400, 1e-10)
154 s.set_exact_crossings(exact_crossings(N))
155 if scheme == "cutcell":
156 ox, oy, oz = exact_openness(N)
157 s.set_openness_override(ox.ravel(order="F"), oy.ravel(order="F"), oz.ravel(order="F"))
158 else:
159 mo, ro = {"g22": (2, 2), "g11": (1, 1), "g12": (1, 2)}[scheme]
160 s.set_ghost_projection(True, matrix_order=mo, rhs_order=ro)
161 s.set_solid(sdf, cutcell_pressure=True, pressure_coarse="rediscretized")
162 prev, warm, um, t0 = 0.0, None, [], time.time()
163 for it in range(max_steps):
164 s.step()
165 m = float(s.get_u().mean())
166 um.append(m)
167 if warm is None:
168 if it % 10 == 9:
169 if it > 10 and abs(m - prev) < warm_tol * (abs(m) + 1e-30):
170 warm = it
171 prev = m
172 elif it - warm >= tail:
173 break
174 K = F * N**3 / (6 * np.pi * MU * radius(N) * np.mean(um[-tail:]))
175 fields = dict(u=s.get_u(), v=s.get_v(), w=s.get_w(), p=s.get_p())
176 return dict(K=K, steps=it + 1, iters=s.last_pressure_iterations(),
177 div=s.max_open_divergence(), secs=time.time() - t0, **fields)
178
179
180# ---------------------------------------------------------------- norms vs reference
181def sample_ref(ref, refN, N, comp):
182 """Cubic-spline (periodic) sample of a reference field at the N-grid points of comp."""
183 r = refN / N
184 g = np.arange(N, dtype=np.float64)
185 off = [0.5, 0.5, 0.5]
186 if comp is not None:
187 off[comp] = 0.0
188 # reference field index coords: point (grid units, coarse) * r - offset of ref array coords
189 co = [None, None, None]
190 roff = [0.5, 0.5, 0.5]
191 if comp is not None:
192 roff[comp] = 0.0
193 for a in range(3):
194 co[a] = (g + off[a]) * r - roff[a]
195 CX, CY, CZ = np.meshgrid(co[0], co[1], co[2], indexing="ij")
196 return map_coordinates(ref, [CX, CY, CZ], order=3, mode="grid-wrap", prefilter=True)
197
198
199def norms(fields, refs, refN, N):
200 """Relative L1/L2/Linf of velocity (3 comps pooled) and pressure vs the reference.
201 The solver works in grid units (dx = 1), so each N is a RESCALED physical problem:
202 u ~ F N^2/mu and p ~ F N. Nondimensionalize by bringing the coarse fields to the
203 reference scale (u * (refN/N)^2, p * refN/N) before differencing."""
204 su = (refN / N) ** 2
205 sp = refN / N
206 fields = dict(u=fields["u"] * su, v=fields["v"] * su, w=fields["w"] * su,
207 p=fields["p"] * sp)
208 hC = 1.0 # grid units; interior band uses 2 cells of the COARSE grid = 2*refN/N ref units
209 out = {}
210 # velocity: pool the three components' fluid samples
211 num = {1: 0.0, 2: 0.0}
212 den = {1: 0.0, 2: 0.0}
213 linf = 0.0
214 linf_int = 0.0
215 npts = 0
216 for c, key in enumerate("uvw"):
217 X, Y, Z = point_grid(N, c)
218 sd = sdf_at(N, X, Y, Z)
219 m = sd > 0
220 mi = sd > 2.0 * hC
221 rf = sample_ref(refs[key], refN, N, c)
222 d = np.abs(fields[key] - rf)
223 num[1] += d[m].sum()
224 num[2] += (d[m] ** 2).sum()
225 den[1] += np.abs(rf[m]).sum()
226 den[2] += (rf[m] ** 2).sum()
227 linf = max(linf, float(d[m].max()))
228 if mi.any():
229 linf_int = max(linf_int, float(d[mi].max()))
230 npts += int(m.sum())
231 uref = np.sqrt(den[2] / npts)
232 out["u"] = (num[1] / den[1], np.sqrt(num[2] / npts) / uref, linf / uref, linf_int / uref)
233 # pressure: cell centers, mean-aligned on the fluid mask
234 X, Y, Z = point_grid(N, None)
235 sd = sdf_at(N, X, Y, Z)
236 m = sd > 0
237 mi = sd > 2.0 * hC
238 rf = sample_ref(refs["p"], refN, N, None)
239 dp = fields["p"] - rf
240 dp -= dp[m].mean()
241 rf0 = rf - rf[m].mean()
242 pref = np.sqrt((rf0[m] ** 2).mean())
243 d = np.abs(dp)
244 out["p"] = (d[m].sum() / np.abs(rf0[m]).sum(), np.sqrt((d[m] ** 2).mean()) / pref,
245 float(d[m].max()) / pref, float(d[mi].max()) / pref if mi.any() else 0.0)
246 return out
247
248
249def main():
250 ap = argparse.ArgumentParser()
251 ap.add_argument("--quick", action="store_true")
252 ap.add_argument("--run-ref", action="store_true")
253 ap.add_argument("--ref-n", type=int, default=None)
254 args = ap.parse_args()
255 refN = args.ref_n or (96 if args.quick else 192)
256 Ns = [16, 24, 32] if args.quick else [24, 32, 48, 64, 96]
257 ref_file = os.path.join(HERE, f"zh_exact_ref_N{refN}.npz")
258
259 if args.run_ref or not os.path.exists(ref_file):
260 print(f"[ref] running cut-cell-exact reference at N={refN} ...", flush=True)
261 r = run(refN, "cutcell")
262 np.savez_compressed(ref_file, u=r["u"], v=r["v"], w=r["w"], p=r["p"], K=r["K"])
263 print(f"[ref] done: K={r['K']:.4f} err={100 * (r['K'] - KREF) / KREF:+.3f}% "
264 f"steps={r['steps']} secs={r['secs']:.0f}", flush=True)
265 z = np.load(ref_file)
266 refs = {k: z[k] for k in "uvwp"}
267 print(f"[ref] N={refN}, K={float(z['K']):.4f} ({100 * (float(z['K']) - KREF) / KREF:+.3f}% "
268 f"vs Z&H)", flush=True)
269
270 schemes = ["cutcell", "g22", "g11", "g12"]
271 hdr = (f"{'scheme':>8} {'N':>4} | {'K err%':>8} {'ord':>5} | "
272 f"{'u L1':>8} {'u L2':>8} {'u Linf':>8} {'Linf>2h':>8} {'ordL2':>5} | "
273 f"{'p L1':>8} {'p L2':>8} {'p Linf':>8} {'Linf>2h':>8} {'ordL2':>5} | it secs")
274 print(hdr, flush=True)
275 for scheme in schemes:
276 prev = None
277 for N in Ns:
278 r = run(N, scheme)
279 nm = norms(r, refs, refN, N)
280 eK = 100 * (r["K"] - KREF) / KREF
281 oK = np.log(abs(prev["eK"]) / abs(eK)) / np.log(N / prev["N"]) if prev else float("nan")
282 oU = (np.log(prev["u2"] / nm["u"][1]) / np.log(N / prev["N"])) if prev else float("nan")
283 oP = (np.log(prev["p2"] / nm["p"][1]) / np.log(N / prev["N"])) if prev else float("nan")
284 print(f"{scheme:>8} {N:>4} | {eK:>+8.3f} {oK:>5.2f} | "
285 f"{nm['u'][0]:>8.2e} {nm['u'][1]:>8.2e} {nm['u'][2]:>8.2e} {nm['u'][3]:>8.2e} "
286 f"{oU:>5.2f} | "
287 f"{nm['p'][0]:>8.2e} {nm['p'][1]:>8.2e} {nm['p'][2]:>8.2e} {nm['p'][3]:>8.2e} "
288 f"{oP:>5.2f} | {r['iters']:>2d} {r['secs']:>5.0f}", flush=True)
289 prev = dict(eK=eK, u2=nm["u"][1], p2=nm["p"][1], N=N)
290 print(flush=True)
291
292
293if __name__ == "__main__":
294 main()
run(N, scheme, warm_tol=1e-7, tail=40, max_steps=5000, dt=80.0)