flow 0.4.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
ghost_collocated_apriori.py
Go to the documentation of this file.
1"""A-priori validation of the directional ghost-cell projection on the COLLOCATED grid.
2
3Extends ghost_projection_apriori.py (staggered, 9/9 gates) to Solver<Colocated> — the open
4problem of doc/collocated_second_order_open_problem.md. Structure of the collocated scheme under
5test (the steady fixed point feels ONLY the momentum grad(P) operator and the constraint, since
6the incremental phi -> 0 there):
7
8 constraint C = ghost-closed point divergence of the 1/2-1/2 face-AVERAGED cell field
9 (the closures/matrix are IDENTICAL to the staggered ghost scheme: the face
10 correction uf -= grad(phi) is the same substitution, so assemble() is reused
11 verbatim and every staggered matrix gate carries over);
12 grad(P) / cell correction = directional cell gradient: central difference where both axis
13 neighbours are fluid-centered, 2nd-order ONE-SIDED (-3P_i+4P_{i+1}-P_{i+2})/2
14 toward the fluid where the neighbour center is solid (falls back to the
15 2-point one-sided when i+2 is solid too; 0 when sandwiched). NEVER reads a
16 solid-centered cell's P/phi — those rows are decoupled (0), and reading them
17 is a GAUGE-DEPENDENT O(1) gradient error (the shipping mode-0 central
18 difference and the o-weighted kernels all make it; measured here in [C2]).
19
20Tests (gates in main):
21 [C1] constraint truncation (open-problem doc T1): ghost-closed divergence of the face-averaged
22 exact solenoidal Stokes field. Near-IB rows O(h) localized truncation (same structure the
23 staggered scheme damps to global 2nd order), bulk O(h^2). Contrast column: the mode-0
24 openness divergence o_f * faceavg on the same field.
25 [C2] cell-gradient operator ladder on a smooth pressure at cut cells (fluid center, solid
26 axis-neighbour): central-reading-solid-0 and the o-weighted kernels are O(1) (and gauge-
27 dependent where they read solid P); the directional one-sided gradient is O(h^2) and
28 exactly gauge-independent.
29 [C3] the full projection chain, manufactured (open-problem doc T2 analog): perturb the CELL
30 field with Gc(phi_man), face-average, ghost-divergence, pinned singular solve, face
31 correction (plain grad) + CELL correction (Gc). Gates: phi ~O(h^2), corrected CELL
32 velocity ~O(h^2), diagnostic == residual identity. Ladder comparison of the cell
33 correction shows the one-sided directional variant is required.
34
35Run: python tests/study/ghost_collocated_apriori.py [--quick]
36"""
37import argparse
38import os
39import sys
40
41import numpy as np
42import scipy.sparse.linalg as spla
43
44sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
45from ghost_projection_apriori import ( # noqa: E402
46 C0, COUPLED, assemble, build_geo, divergence, overlay_cells, phi_man, periodic_u,
47 row_rescale, sdf_sphere, stokes_u)
48
49TP = 2.0 * np.pi
50
51
52def phi_man_grad(x, y, z):
53 """Analytic gradient of phi_man (sin/cos triple)."""
54 gx = TP * np.cos(TP * x) * np.cos(TP * y) - TP * np.sin(TP * z) * np.sin(TP * x)
55 gy = -TP * np.sin(TP * x) * np.sin(TP * y) + TP * np.cos(TP * y) * np.cos(TP * z)
56 gz = -TP * np.sin(TP * y) * np.sin(TP * z) + TP * np.cos(TP * z) * np.cos(TP * x)
57 return gx, gy, gz
58
59
60def cell_fields(geo, ufun):
61 """Cell-centered samples, masked to 0 at solid centers (maskVelocity model)."""
62 act = geo["active"]
63 return [np.where(act, ufun(*geo["Xc"])[a], 0.0) for a in range(3)]
64
65
66def face_avg(U3):
67 """Minus-face field of the cell field: uf_a(i) = 1/2 (U_a(i) + U_a(i-1)), periodic."""
68 return [0.5 * (U3[a] + np.roll(U3[a], +1, axis=a)) for a in range(3)]
69
70
71def face_openness(geo, ns=6):
72 """Sampled area fraction of the minus face per axis (mode-0 contrast only)."""
73 h = geo["h"]
74 t = (np.arange(ns) + 0.5) / ns - 0.5
75 o = []
76 for a in range(3):
77 b, c = [q for q in range(3) if q != a]
78 acc = np.zeros_like(geo["Pf"][a][0])
79 for tb in t:
80 for tc in t:
81 Q = [p.copy() for p in geo["Pf"][a]]
82 Q[b] = Q[b] + tb * h
83 Q[c] = Q[c] + tc * h
84 acc += (geo["sdf"](*Q) >= 0.0)
85 o.append(acc / (ns * ns))
86 return o
87
88
89def cell_grad(geo, P, mode, o_real=None):
90 """Per-axis discrete cell-center gradient (grid units) of a cell field P that is only
91 defined on fluid cells (solid cells read as 0 — what the solver's decoupled rows hold).
92 central : plain central difference (mode-0 predictor; reads the solid 0)
93 pcc : projectCorrectCenter — 1/2 (g- + g+), closed-face gradient zeroed (binary)
94 open : centerGradOpen with binary openness — full-weight open-face gradient
95 open_real: centerGradOpen with the REAL sampled openness (reads the solid 0 through
96 partially-open faces — the shipping mode-6 kernel)
97 ghost : central where both neighbours fluid; 2nd-order one-sided else; 2-point
98 one-sided fallback; 0 when sandwiched. Never reads solid cells."""
99 act = geo["active"]
100 Pm = np.where(act, P, 0.0)
101 out = []
102 for a in range(3):
103 Pp1 = np.roll(Pm, -1, axis=a)
104 Pn1 = np.roll(Pm, +1, axis=a)
105 ap1 = np.roll(act, -1, axis=a)
106 an1 = np.roll(act, +1, axis=a)
107 if mode == "central":
108 g = 0.5 * (Pp1 - Pn1)
109 elif mode == "pcc":
110 g = 0.5 * (np.where(an1, Pm - Pn1, 0.0) + np.where(ap1, Pp1 - Pm, 0.0))
111 elif mode == "open":
112 om, op = an1.astype(float), ap1.astype(float)
113 g = (om * (Pm - Pn1) + op * (Pp1 - Pm)) / np.maximum(om + op, 1e-12)
114 elif mode == "open_real":
115 om = o_real[a]
116 op = np.roll(o_real[a], -1, axis=a)
117 g = (om * (Pm - Pn1) + op * (Pp1 - Pm)) / (om + op + 1e-12)
118 elif mode == "ghost":
119 Pp2 = np.roll(Pm, -2, axis=a)
120 Pn2 = np.roll(Pm, +2, axis=a)
121 ap2 = np.roll(act, -2, axis=a)
122 an2 = np.roll(act, +2, axis=a)
123 g = 0.5 * (Pp1 - Pn1)
124 osm = act & ~an1 & ap1 # minus neighbour solid -> one-sided toward +
125 g = np.where(osm & ap2, 0.5 * (-3.0 * Pm + 4.0 * Pp1 - Pp2),
126 np.where(osm, Pp1 - Pm, g))
127 osp = act & ~ap1 & an1 # plus neighbour solid -> one-sided toward -
128 g = np.where(osp & an2, 0.5 * (3.0 * Pm - 4.0 * Pn1 + Pn2),
129 np.where(osp, Pm - Pn1, g))
130 g = np.where(act & ~an1 & ~ap1, 0.0, g)
131 else:
132 raise ValueError(mode)
133 out.append(np.where(act, g, 0.0))
134 return out
135
136
137def order(prev, cur, Nprev, N):
138 return np.log2(prev / cur) / np.log2(N / Nprev) if prev is not None else float("nan")
139
140
141# ---------------------------------------------------------------- [C1] constraint truncation
143 print("\n[C1] ghost-closed divergence of the FACE-AVERAGED exact Stokes field (/h)")
144 print(" (near-IB: localized boundary truncation ~O(h); bulk: O(h^2) — gated on the FIXED")
145 print(" shell r in [0.5,0.7]: the all-bulk max hugs the surface, where |d3 u| is ~100x")
146 print(" larger, so its max-norm order approaches 2 only asymptotically (measured);")
147 print(" mode-0 contrast: openness divergence o_f*faceavg on the same field, O(1) at cuts)")
148 print(f"{'N':>5} {'near-IB':>12} {'ord':>6} {'bulk(all)':>12} {'ord':>6} "
149 f"{'bulk(shell)':>12} {'ord':>6} {'mode0 IB':>12} {'ord':>6}")
150 prev = None
151 slopes = {}
152 for N in Ns:
153 geo = build_geo(N)
154 U3 = cell_fields(geo, stokes_u)
155 uf = face_avg(U3)
156 d = divergence(geo, uf, stokes_u) / geo["h"]
157 # mode-0 constraint on the same field: sum_a (o+ uf+ - o- uf-), real sampled openness
158 o = face_openness(geo)
159 d0 = np.zeros_like(d)
160 for a in range(3):
161 om, op = o[a], np.roll(o[a], -1, axis=a)
162 ufp = np.roll(uf[a], -1, axis=a)
163 d0 += (op * ufp - om * uf[a]).ravel()
164 d0 /= geo["h"]
165 ov = overlay_cells(geo).ravel()
166 interior = ((np.abs(geo["Xc"][0]) < 0.5 - 2 * geo["h"])
167 & (np.abs(geo["Xc"][1]) < 0.5 - 2 * geo["h"])
168 & (np.abs(geo["Xc"][2]) < 0.5 - 2 * geo["h"])).ravel()
169 ov &= interior
170 bulk = geo["active"].ravel() & ~ov & interior
171 rr = np.sqrt(sum((geo["Xc"][q] - C0[q]) ** 2 for q in range(3))).ravel()
172 shell = bulk & (rr >= 0.5) & (rr < 0.7)
173 e_ib = float(np.abs(d[ov]).max())
174 e_bk = float(np.abs(d[bulk]).max())
175 e_sh = float(np.abs(d[shell]).max())
176 e_m0 = float(np.abs(d0[ov & (np.abs(d0) < 1e30)]).max())
177 o_ib = order(prev and prev[0], e_ib, prev and prev[4], N)
178 o_bk = order(prev and prev[1], e_bk, prev and prev[4], N)
179 o_sh = order(prev and prev[2], e_sh, prev and prev[4], N)
180 o_m0 = order(prev and prev[3], e_m0, prev and prev[4], N)
181 print(f"{N:>5} {e_ib:>12.3e} {o_ib:>6.2f} {e_bk:>12.3e} {o_bk:>6.2f} "
182 f"{e_sh:>12.3e} {o_sh:>6.2f} {e_m0:>12.3e} {o_m0:>6.2f}")
183 prev = (e_ib, e_bk, e_sh, e_m0, N)
184 slopes = {"ib": o_ib, "bulk": o_sh}
185 return slopes
186
187
188# ---------------------------------------------------------------- [C2] gradient ladder
190 print("\n[C2] cell-gradient operators on a smooth P at CUT cells (fluid center, solid")
191 print(" axis-neighbour); error vs the analytic gradient, physical units. gauge = +5")
192 print(" added to P (a constant MUST not change a gradient).")
193 hdr = f"{'N':>5}"
194 modes = ["central", "pcc", "open", "open_real", "ghost"]
195 for m in modes:
196 hdr += f" {m:>11} {'ord':>5}"
197 hdr += f" {'ghost gauge':>12}"
198 print(hdr)
199 prev = {}
200 slopes = {}
201 gauge_ok = True
202 for N in Ns:
203 geo = build_geo(N)
204 act = geo["active"]
205 P = phi_man(*geo["Xc"])
206 gex = phi_man_grad(*geo["Xc"])
207 o_real = face_openness(geo)
208 # cut cells per axis: active with a solid axis-neighbour
209 row = f"{N:>5}"
210 for m in modes:
211 G3 = cell_grad(geo, P, m, o_real)
212 e = 0.0
213 for a in range(3):
214 cut = act & (~np.roll(act, -1, axis=a) | ~np.roll(act, +1, axis=a))
215 if np.any(cut):
216 e = max(e, float(np.abs(G3[a][cut] / geo["h"] - gex[a][cut]).max()))
217 o = order(prev.get(m), e, prev.get("N"), N)
218 row += f" {e:>11.2e} {o:>5.2f}"
219 prev[m] = e
220 slopes[m] = o
221 if m == "ghost":
222 G3g = cell_grad(geo, P + 5.0, m, o_real)
223 dg = max(float(np.abs(G3g[a] - G3[a])[act].max()) for a in range(3))
224 row += f" {dg:>12.1e}"
225 gauge_ok &= dg < 1e-12
226 prev["N"] = N
227 print(row)
228 return slopes, gauge_ok
229
230
231# ---------------------------------------------------------------- [C3] full chain
232def pinned_solve(A, b, activef):
233 """Direct solve of the singular Neumann-like system with the incompatibility dumped
234 UNIFORMLY (residual = lambda*e), the sparse-friendly equivalent of mean removal.
235 See ghost_projection_apriori.test_solve for the derivation + measured failure modes."""
236 r0 = int(np.nonzero(activef)[0][0])
237 arow = A.getrow(r0)
238 Apin = A.tolil()
239 Apin.rows[r0], Apin.data[r0] = [r0], [1.0]
240 lu = spla.splu(Apin.tocsc())
241 bp = b.copy()
242 bp[r0] = 0.0
243 ev = activef.astype(float)
244 evp = ev.copy()
245 evp[r0] = 0.0
246 phi_b = lu.solve(bp)
247 phi_e = lu.solve(evp)
248 lam = (float((arow @ phi_b)[0]) - b[r0]) / (float((arow @ phi_e)[0]) - 1.0)
249 return phi_b - lam * phi_e, lam
250
251
252def run_chain(geo, cc_mode):
253 """One manufactured projection chain; returns error metrics."""
254 act = geo["active"]
255 actf = act.ravel()
256 rho = row_rescale(geo)
257 U3 = cell_fields(geo, periodic_u)
258 pm = phi_man(*geo["Xc"])
259 Gpm = cell_grad(geo, pm, cc_mode)
260 ustar = [np.where(act, U3[a] + Gpm[a], 0.0) for a in range(3)]
261 uf = face_avg(ustar)
262
263 A = assemble(geo, rho)
264 b = -divergence(geo, uf, periodic_u, rho=rho)
265 phi, _ = pinned_solve(A, b, actf)
266 phig = phi.reshape(act.shape)
267
268 # phi error (gauge-removed)
269 dphi = phi - pm.ravel()
270 dphi -= dphi[actf].mean()
271 e_phi = float(np.abs(dphi[actf]).max())
272
273 # face correction (plain grad, all faces) -> COUPLED-face error
274 ufc = [uf[a] - (phig - np.roll(phig, +1, axis=a)) for a in range(3)]
275 uf_ex = face_avg(cell_fields(geo, periodic_u))
276 e_f = 0.0
277 for a in range(3):
278 st_m, _ = geo["states"][(a, -1)]
279 fluid_face = (st_m == COUPLED) & act
280 if np.any(fluid_face):
281 e_f = max(e_f, float(np.abs(ufc[a] - uf_ex[a])[fluid_face].max()))
282
283 # cell correction -> corrected CELL velocity error (THE gate)
284 Gphi = cell_grad(geo, phig, cc_mode)
285 ucorr = [np.where(act, ustar[a] - Gphi[a], 0.0) for a in range(3)]
286 ovm = overlay_cells(geo)
287 e_c, e_c_ib, s2, nf = 0.0, 0.0, 0.0, 0
288 for a in range(3):
289 err = np.abs(ucorr[a] - U3[a])
290 e_c = max(e_c, float(err[act].max()))
291 s2 += float((err[act] ** 2).sum())
292 nf += int(act.sum())
293 near = act & (ovm | np.roll(ovm, +1, axis=a) | np.roll(ovm, -1, axis=a))
294 if np.any(near):
295 e_c_ib = max(e_c_ib, float(err[near].max()))
296 e_c_l2 = float(np.sqrt(s2 / max(nf, 1)))
297
298 # diagnostic == residual identity on the corrected FACE field
299 diag = divergence(geo, ufc, periodic_u, rho=rho, u_explicit=uf)
300 res = b - A @ phi
301 ident = float(np.abs(diag[actf] + res[actf]).max())
302 scale = max(1.0, float(np.abs(b[actf]).max()))
303 return dict(e_phi=e_phi, e_f=e_f, e_c=e_c, e_c_ib=e_c_ib, e_c_l2=e_c_l2,
304 ident=ident < 1e-10 * scale)
305
306
307def test_chain(Ns, modes=("ghost", "open", "pcc")):
308 print("\n[C3] full collocated projection chain, manufactured (THE global-order gate)")
309 print(" u*_cell = u_exact + Gc(phi_man); face-average; ghost-div; solve; correct")
310 print(" faces (plain grad) + cells (Gc). Cell-correction ladder:")
311 out = {}
312 for m in modes:
313 print(f" --- cell gradient: {m}")
314 print(f"{'N':>7} {'|phi err|':>11} {'ord':>6} {'|u_cell err|':>13} {'ord':>6} "
315 f"{'near-IB':>11} {'L2':>11} {'ord':>6} {'|uf err|':>11} {'ord':>6} {'diag==res':>10}")
316 prev = None
317 for N in Ns:
318 geo = build_geo(N)
319 r = run_chain(geo, m)
320 o_p = order(prev and prev["e_phi"], r["e_phi"], prev and prev["N"], N)
321 o_c = order(prev and prev["e_c"], r["e_c"], prev and prev["N"], N)
322 o_l2 = order(prev and prev["e_c_l2"], r["e_c_l2"], prev and prev["N"], N)
323 o_f = order(prev and prev["e_f"], r["e_f"], prev and prev["N"], N)
324 print(f"{N:>7} {r['e_phi']:>11.3e} {o_p:>6.2f} {r['e_c']:>13.3e} {o_c:>6.2f} "
325 f"{r['e_c_ib']:>11.3e} {r['e_c_l2']:>11.3e} {o_l2:>6.2f} "
326 f"{r['e_f']:>11.3e} {o_f:>6.2f} {'OK' if r['ident'] else 'FAIL':>10}")
327 prev = dict(r, N=N)
328 out[m] = dict(o_phi=o_p, o_c=o_c, o_l2=o_l2, o_f=o_f, ident=prev["ident"])
329 return out
330
331
332# ---------------------------------------------------------------- main
333if __name__ == "__main__":
334 ap = argparse.ArgumentParser()
335 ap.add_argument("--quick", action="store_true")
336 args = ap.parse_args()
337
338 Ns_eval = [16, 32, 64] if args.quick else [16, 32, 64, 128]
339 Ns_solve = [16, 24, 32] if args.quick else [16, 24, 32, 48]
340
341 c1 = test_constraint(Ns_eval)
342 c2, gauge_ok = test_grad_ladder(Ns_eval)
343 c3 = test_chain(Ns_solve)
344
345 g = c3["ghost"]
346 print("\n==== gates ====")
347 gates = [
348 ("C1 constraint near-IB truncation order >= 0.8", c1["ib"] >= 0.8),
349 ("C1 constraint bulk order >= 1.7", c1["bulk"] >= 1.7),
350 ("C2 ghost gradient cut-cell order >= 1.7", c2["ghost"] >= 1.7),
351 ("C2 ghost gradient gauge-independent", gauge_ok),
352 # phi max is dominated by a near-IB layer that converges at ~O(h^1.4) (the averaging/
353 # cell-gradient perturbation mismatch is an O(h) surface source); the quantities the
354 # physics feels — corrected cell velocity, COUPLED faces — are ~O(h^2) (gates below).
355 ("C3 ghost chain: phi order >= 1.3", g["o_phi"] >= 1.3),
356 ("C3 ghost chain: cell-velocity order >= 1.7", g["o_c"] >= 1.7),
357 ("C3 ghost chain: COUPLED-face order >= 1.7", g["o_f"] >= 1.7),
358 ("C3 diagnostic == residual identity", g["ident"]),
359 ]
360 npass = 0
361 for name, ok in gates:
362 print(f" {'PASS' if ok else 'FAIL'} {name}")
363 npass += ok
364 print(f"{npass}/{len(gates)} gates passed")
test_chain(Ns, modes=("ghost", "open", "pcc"))
cell_grad(geo, P, mode, o_real=None)