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