flow 0.4.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
ghost_projection_apriori.py
Go to the documentation of this file.
1"""A-priori validation of the directional ghost-cell IBM projection (plan Phase 0).
2
3The proposed scheme (staggered, point-based, NO openness): the divergence of a fluid-centered
4pressure cell uses plain face differences; a face whose staggered velocity point is solid gets its
5velocity from the momentum IBM's 1-D wall-anchored quadratic along the face's own axis
6(poly_D/poly_Nc/poly_N_nb/poly_Nbc of src/cut_cell_ibm.hpp, reused verbatim):
7
8 poly_D(th) * u_ghost = 2*u_bc + poly_Nc(th)*u_near + poly_N_nb(th)*u_far
9 th = sdf_near/(sdf_near - sdf_ghost) in (0,1], clamped [1e-4, 1]
10 (wall at distance th below the near point; ghost point at distance 1; unit grid spacing)
11
12Substituting corrected velocities u = u* - grad(phi) makes the closure implicit in phi: the
13Poisson row gains couplings to phi(+/-1), phi(+/-2) along the axis (13-point, nonsymmetric).
14Per-row conditioning rescale rho = min(1, min_f D_f) — the D_rescale analog.
15
16Face-state cascade per (cell, axis, side) [sdf >= 0 fluid]:
17 COUPLED face point fluid AND neighbor center fluid -> standard +/- (phi_i - phi_nb)
18 (sandwich) both face points of the axis solid, center fluid -> BC_ONLY both sides
19 GHOST_QUAD face point solid, near+far sources exist -> quadratic closure, th in (0,1]
20 GHOST_LIN face point solid, only near source -> th*u_g = u_bc + (th-1)*u_near
21 SLIVER face point fluid but neighbor center solid: same quadratic with EXTENDED
22 th = 1 + sdf_g/(sdf_g - sdf_beyond) in (1,2) (evaluation INSIDE the data hull,
23 D = th(1+th) > 2: well conditioned); falls back like QUAD->LIN
24 BC_ONLY no usable fluid source (or sandwich) -> u_face = u_bc, no phi coupling
25 EXPLICIT sliver with no crossing on the u-line -> face flux = u* (no phi term)
26
27Tests (gates in main):
28 1. extrapolation accuracy at ghost faces vs the analytic Stokes-sphere field, anchored at the
29 linearized-SDF crossing (the scheme; expect O(h^2)) AND at the exact sphere crossing
30 (pure polynomial truncation; expect O(h^3))
31 2. closed divergence of the exact solenoidal field on near-IB cells (physical units):
32 localized boundary truncation, expect O(h) — the SAME structure as the momentum IBM,
33 global 2nd order then comes from elliptic damping and is measured by test 3
34 3. assembled sparse solve: u* = u_periodic_exact + discrete_grad(phi_man); solve
35 A phi = -div*(u*) with inhomogeneous u_bc = exact wall values; expect phi and the corrected
36 velocity 2nd order; verify diagnostic div(u_corr) == residual identically (round-off)
37 4. solver probes (dense, small N): A@1 = 0 on active rows; left-null compatibility gap;
38 spectrum of the binary-openness-MG-surrogate-preconditioned operator (BiCGStab health);
39 deferred-correction rate max|1-lambda|
40 5. degenerate geometries: slab channel, sandwich slit, one-cell gap, wall through a face point
41
42This file is the reference implementation for src/ghost_projection.hpp: the C++ gpFillEntry must
43reproduce these closure coefficients to float tolerance.
44"""
45import argparse
46import numpy as np
47import scipy.sparse as sp
48import scipy.sparse.linalg as spla
49
50# ---------------------------------------------------------------- analytic fields
51R = 0.3102
52C0 = np.array([0.013, -0.007, 0.004]) # off-lattice center (no symmetry luck)
53THETA_MIN = 1e-4
54
55def sdf_sphere(x, y, z):
56 return np.sqrt((x - C0[0])**2 + (y - C0[1])**2 + (z - C0[2])**2) - R
57
58def stokes_u(x, y, z):
59 """Exact Stokes flow past the sphere, U_inf = (1,0,0). Solenoidal, no-slip at r=R."""
60 dx, dy, dz = x - C0[0], y - C0[1], z - C0[2]
61 r2 = dx*dx + dy*dy + dz*dz
62 r = np.sqrt(r2)
63 A = 3.0*R/(4.0*r)
64 B = R**3/(4.0*r**3)
65 ux = 1.0 - A*(1.0 + dx*dx/r2) - B*(1.0 - 3.0*dx*dx/r2)
66 uy = -(A - 3.0*B)*dx*dy/r2
67 uz = -(A - 3.0*B)*dx*dz/r2
68 return ux, uy, uz
69
70def periodic_u(x, y, z):
71 """Manufactured periodic solenoidal field (period-1 box), nonzero at the sphere wall.
72 Each component varies along its own axis (non-degenerate discrete divergence)."""
73 tp = 2.0*np.pi
74 return (np.sin(tp*x)*np.cos(tp*y),
75 -np.cos(tp*x)*np.sin(tp*y) + np.sin(tp*y)*np.cos(tp*z),
76 -np.cos(tp*y)*np.sin(tp*z))
77
78def phi_man(x, y, z):
79 tp = 2.0*np.pi
80 return (np.sin(tp*x)*np.cos(tp*y) + np.sin(tp*y)*np.cos(tp*z)
81 + np.sin(tp*z)*np.cos(tp*x))
82
83# ---------------------------------------------------------------- closure polynomials (verbatim)
84def poly_D(t):
85 return t*(1.0 + t)
86
87def poly_Nc(t):
88 return 2.0*(t*t - 1.0)
89
90def poly_Nnb(t):
91 return t*(1.0 - t)
92
93# state codes
94COUPLED, QUAD, LIN, BC_ONLY, EXPLICIT = 0, 1, 2, 3, 4
95STATE_NAMES = {COUPLED: "COUPLED", QUAD: "QUAD", LIN: "LIN",
96 BC_ONLY: "BC_ONLY", EXPLICIT: "EXPLICIT"}
97
98# ---------------------------------------------------------------- geometry + classification
99def build_geo(N, sdf=sdf_sphere, mode="center"):
100 """Classify every (cell, axis, side) face. Returns dict with sdf samples, states, thetas.
101
102 mode="center" (the shipped scheme): a cell carries a pressure unknown iff its CENTRE is fluid,
103 and a face is COUPLED iff the face point is fluid AND both centres are fluid. A fluid face
104 whose neighbour centre is solid therefore has nowhere to couple, which is what the SLIVER /
105 EXTENDED-theta / EXPLICIT branches exist to paper over.
106
107 mode="face" (the proposal, and the rule in doc/Robust_Scaled_IBM_Solver.tex: "pressure values
108 are required in cells where any face contains a fluid velocity"): a cell carries a pressure
109 unknown iff at least one of its six faces has a FLUID velocity point, and a face is COUPLED iff
110 its own velocity point is fluid. Every fluid face then has a live unknown on both sides, so the
111 sliver branches vanish; the wall-anchored closure is used only where it is really needed, at a
112 SOLID face point. Solid-centred cells that own fluid faces (the throats threading between
113 spheres) get their continuity equation back.
114 """
115 h = 1.0/N
116 c = (np.arange(N) + 0.5)*h - 0.5
117 Xc = np.meshgrid(c, c, c, indexing="ij")
118 Sc = sdf(*Xc)
119
120 # face-point sdf per axis: Sf[a][i,j,k] = minus-face of cell (i,j,k) along axis a
121 Sf = []
122 Pf = [] # face-point coordinates (3 arrays each)
123 for a in range(3):
124 P = [Xc[0].copy(), Xc[1].copy(), Xc[2].copy()]
125 P[a] = P[a] - 0.5*h
126 Sf.append(sdf(*P))
127 Pf.append(P)
128
129 def cent(q, a): # Sc[i+q] along axis a
130 return np.roll(Sc, -q, axis=a)
131
132 def face(m, a): # Sf[a][i+m] along axis a
133 return np.roll(Sf[a], -m, axis=a)
134
135 if mode == "face":
136 # a cell owns a pressure unknown iff any of its six face points is fluid
137 active = np.zeros(Sc.shape, bool)
138 for a in range(3):
139 active |= (face(0, a) >= 0) | (face(1, a) >= 0)
140 else:
141 active = Sc >= 0.0
142
143 states = {}
144 for a in range(3):
145 for side in (-1, +1):
146 # roll offsets relative to cell index i (see derivation in module docstring)
147 if side < 0:
148 mg, mn, mf, mb = 0, 1, 2, -1 # ghost, near, far, beyond-ghost faces
149 qnb = -1 # neighbor center
150 qn1, qn2 = 1, 2 # phi cells needed by near/far face gradients
151 else:
152 mg, mn, mf, mb = 1, 0, -1, 2
153 qnb = +1
154 qn1, qn2 = -1, -2
155 Sg, Sn, Sfar, Sb = face(mg, a), face(mn, a), face(mf, a), face(mb, a)
156 Snb = cent(qnb, a)
157 C1, C2 = cent(qn1, a), cent(qn2, a)
158
159 st = np.full(Sc.shape, COUPLED, dtype=np.int8)
160 th = np.ones(Sc.shape)
161
162 if mode == "face":
163 # A fluid face point is COUPLED, full stop: both adjacent cells own an unknown
164 # (they share this fluid face), so the plain +-(phi_i - phi_nb) difference is
165 # available and the operator row stays symmetric. Only a SOLID face point needs
166 # the wall-anchored closure, and its sources are the fluid face points along the
167 # same axis -- whose own cells are active for the same reason.
168 sandwich = (face(0, a) < 0) & (face(1, a) < 0)
169 ghost = (Sg < 0) & ~sandwich
170 th_g = np.where(ghost, Sn/np.where(ghost, Sn - Sg, 1.0), 1.0)
171 src1 = Sn >= 0 # near face point fluid
172 src2 = Sfar >= 0 # far face point fluid
173 st[sandwich] = BC_ONLY
174 st[ghost & ~src1] = BC_ONLY
175 st[ghost & src1 & src2] = QUAD
176 st[ghost & src1 & ~src2] = LIN
177 th = np.where(ghost, np.clip(th_g, THETA_MIN, 1.0), th)
178 st[Sg >= 0] = COUPLED
179 st[~active] = BC_ONLY # dead cells: no row, no flux
180 states[(a, side)] = (st, th)
181 continue
182
183 coupled = (Sg >= 0) & (Snb >= 0)
184 sandwich = (face(0, a) < 0) & (face(1, a) < 0) # both faces of THIS cell solid
185 ghost = (Sg < 0) & ~sandwich # near face fluid guaranteed
186 sliver = (Sg >= 0) & (Snb < 0)
187
188 # ghost theta (standard, wall between ghost and near face points)
189 th_g = np.where(ghost, Sn/np.where(ghost, Sn - Sg, 1.0), 1.0)
190 # sliver theta (extended, wall between ghost and beyond face points), needs Sb < 0
191 has_x = sliver & (Sb < 0)
192 th_s = np.where(has_x, 1.0 + Sg/np.where(has_x, Sg - Sb, 1.0), 1.0)
193
194 src1 = (Sn >= 0) & (C1 >= 0) # near source usable
195 src2 = (Sfar >= 0) & (C2 >= 0) # far source usable
196
197 st[sandwich] = BC_ONLY
198 st[ghost & ~src1] = BC_ONLY
199 st[ghost & src1 & src2] = QUAD
200 st[ghost & src1 & ~src2] = LIN
201 st[sliver & ~has_x] = EXPLICIT
202 st[sliver & has_x & ~src1] = BC_ONLY
203 st[sliver & has_x & src1 & src2] = QUAD
204 st[sliver & has_x & src1 & ~src2] = LIN
205 th = np.where(ghost, np.clip(th_g, THETA_MIN, 1.0), th)
206 th = np.where(sliver & has_x, np.clip(th_s, 1.0 + THETA_MIN, 2.0), th)
207 st[coupled] = COUPLED
208 states[(a, side)] = (st, th)
209
210 return dict(N=N, h=h, Xc=Xc, Sc=Sc, Sf=Sf, Pf=Pf, active=active, states=states, sdf=sdf,
211 mode=mode)
212
214 """Active cells with at least one non-COUPLED face."""
215 m = np.zeros_like(geo["active"])
216 for (a, side), (st, _) in geo["states"].items():
217 m |= (st != COUPLED)
218 return m & geo["active"]
219
220# crossing point (u_bc anchor) for a set of flat cell indices, given axis/side/state
221def crossing_points(geo, a, side, cells_flat):
222 N, h = geo["N"], geo["h"]
223 st, th = geo["states"][(a, side)]
224 stf, thf = st.ravel()[cells_flat], th.ravel()[cells_flat]
225 mn = 1 if side < 0 else 0
226 P = [np.roll(p, -mn, axis=a).ravel()[cells_flat] for p in geo["Pf"][a]] # near-face point
227 Pc = [p.copy() for p in P]
228 Pc[a] = Pc[a] + side*thf*h # near - th*h (minus) / near + th*h (plus)
229 # BC_ONLY (incl. sandwich): crossing between own face point and cell center
230 bo = stf == BC_ONLY
231 if np.any(bo):
232 mg = 0 if side < 0 else 1
233 Sg = np.roll(geo["Sf"][a], -mg, axis=a).ravel()[cells_flat]
234 Scf = geo["Sc"].ravel()[cells_flat]
235 t = np.clip(Scf/np.where(np.abs(Scf - Sg) > 0, Scf - Sg, 1.0), 0.0, 1.0)
236 for q in range(3):
237 Pc[q][bo] = geo["Xc"][q].ravel()[cells_flat][bo]
238 Pc[a][bo] = Pc[a][bo] + side*t[bo]*(0.5*h)
239 return Pc
240
241def closure_weights(stf, thf):
242 """(w_bc, w_n1, w_n2, D) for QUAD/LIN/BC_ONLY flat state/theta arrays."""
243 D = np.ones_like(thf)
244 wbc = np.zeros_like(thf)
245 w1 = np.zeros_like(thf)
246 w2 = np.zeros_like(thf)
247 q = stf == QUAD
248 Dq = poly_D(thf[q])
249 D[q] = Dq
250 wbc[q] = 2.0/Dq
251 w1[q] = poly_Nc(thf[q])/Dq
252 w2[q] = poly_Nnb(thf[q])/Dq
253 l = stf == LIN
254 D[l] = thf[l]
255 wbc[l] = 1.0/thf[l]
256 w1[l] = (thf[l] - 1.0)/thf[l]
257 b = stf == BC_ONLY
258 wbc[b] = 1.0
259 return wbc, w1, w2, D
260
261def as_order(stf, order):
262 """Closure ORDER selection, mirroring gpOrderWeights: order 1 evaluates a QUAD face with the
263 linear closure (that is what matrix_order=1 does to the implicit phi couplings)."""
264 if order >= 2:
265 return stf
266 out = stf.copy()
267 out[out == QUAD] = LIN
268 return out
269
270def row_rescale(geo, order=2):
271 """rho = min(1, min over ghost faces of D_f) per cell (flat array). D comes from the MATRIX
272 weights, so `order` is matrix_order."""
273 N = geo["N"]
274 rho = np.ones(N**3)
275 for (a, side), (st, th) in geo["states"].items():
276 stf, thf = as_order(st.ravel(), order), th.ravel()
277 _, _, _, D = closure_weights(stf, thf)
278 m = (stf == QUAD) | (stf == LIN)
279 rho[m] = np.minimum(rho[m], D[m])
280 return rho
281
282# ---------------------------------------------------------------- assembly + divergence
283def gather_face(u, a, m, cells_flat):
284 return np.roll(u, -m, axis=a).ravel()[cells_flat]
285
286def divergence(geo, u3, ubc_fn, rho=None, u_explicit=None):
287 """Closed point divergence (grid units: sum of face differences) on active cells,
288 row-rescaled by rho. u3 = 3 face fields; ubc_fn(x,y,z)->(3 components); u_explicit
289 supplies the field read at EXPLICIT faces (defaults to u3)."""
290 N = geo["N"]
291 if u_explicit is None:
292 u_explicit = u3
293 d = np.zeros(N**3)
294 for a in range(3):
295 for side in (-1, +1):
296 st, th = geo["states"][(a, side)]
297 stf = st.ravel()
298 sgn = float(side) if side > 0 else -1.0
299 # COUPLED faces: plain difference contribution
300 mcp = stf == COUPLED
301 mg = 0 if side < 0 else 1
302 uf = np.roll(u3[a], -mg, axis=a).ravel()
303 d[mcp] += sgn*uf[mcp]
304 # closures
305 for state in (QUAD, LIN, BC_ONLY):
306 cells = np.nonzero(stf == state)[0]
307 if len(cells) == 0:
308 continue
309 thf = th.ravel()[cells]
310 wbc, w1, w2, _ = closure_weights(np.full(len(cells), state, np.int8), thf)
311 Pc = crossing_points(geo, a, side, cells)
312 ub = ubc_fn(Pc[0], Pc[1], Pc[2])[a]
313 val = wbc*ub
314 if state != BC_ONLY:
315 mn = 1 if side < 0 else 0
316 mf = 2 if side < 0 else -1
317 val = val + w1*gather_face(u3[a], a, mn, cells)
318 if state == QUAD:
319 val = val + w2*gather_face(u3[a], a, mf, cells)
320 d[cells] += sgn*val
321 # explicit faces: read the supplied field at the own face point
322 mex = np.nonzero(stf == EXPLICIT)[0]
323 if len(mex):
324 d[mex] += sgn*gather_face(u_explicit[a], a, mg, mex)
325 if rho is not None:
326 d *= rho
327 d[~geo["active"].ravel()] = 0.0
328 return d
329
330def assemble(geo, rho, order=2):
331 """Sparse A (N^3 x N^3): binary-openness base + closure deltas, overlay rows scaled by rho.
332 Inactive rows = identity. Convention: A phi = -div(u*) (positive diagonal)."""
333 N = geo["N"]
334 n = N**3
335 IDX = np.arange(n).reshape(N, N, N)
336 rows, cols, vals = [], [], []
337 activef = geo["active"].ravel()
338
339 def add(r, c, v):
340 rows.append(r)
341 cols.append(c)
342 vals.append(v)
343
344 for a in range(3):
345 for side in (-1, +1):
346 st, th = geo["states"][(a, side)]
347 stf, thf = as_order(st.ravel(), order), th.ravel()
348 sgn = float(side) if side > 0 else -1.0
349 # face at roll-offset m couples cells (i+m-1, i+m); div term sgn*c_f*u(face m)
350 # A[r, cp] -= rho_r*sgn*c_f ; A[r, cm] += rho_r*sgn*c_f
351 def add_face(cells, m, cf):
352 r = cells
353 cp = np.roll(IDX, -m, axis=a).ravel()[cells]
354 cm = np.roll(IDX, -(m - 1), axis=a).ravel()[cells]
355 w = rho[cells]*sgn*cf
356 add(r, cp, -w)
357 add(r, cm, +w)
358
359 mcp = np.nonzero((stf == COUPLED) & activef)[0]
360 add_face(mcp, 0 if side < 0 else 1, np.ones(len(mcp)))
361 for state in (QUAD, LIN):
362 cells = np.nonzero((stf == state) & activef)[0]
363 if len(cells) == 0:
364 continue
365 _, w1, w2, _ = closure_weights(np.full(len(cells), state, np.int8), thf[cells])
366 mn = 1 if side < 0 else 0
367 add_face(cells, mn, w1)
368 if state == QUAD:
369 mf = 2 if side < 0 else -1
370 add_face(cells, mf, w2)
371 # inactive rows: identity
372 inact = np.nonzero(~activef)[0]
373 add(inact, inact, np.ones(len(inact)))
374 A = sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows).astype(np.int64),
375 np.concatenate(cols).astype(np.int64))),
376 shape=(n, n))
377 A.sum_duplicates()
378 return A
379
381 """The symmetric MG surrogate: 7-point op with o=1 on COUPLED faces, 0 otherwise."""
382 N = geo["N"]
383 n = N**3
384 IDX = np.arange(n).reshape(N, N, N)
385 rows, cols, vals = [], [], []
386 activef = geo["active"].ravel()
387 for a in range(3):
388 for side in (-1, +1):
389 st, _ = geo["states"][(a, side)]
390 cells = np.nonzero((st.ravel() == COUPLED) & activef)[0]
391 m = 0 if side < 0 else 1
392 nb = np.roll(IDX, -(m - 1) if side < 0 else -m, axis=a).ravel()[cells]
393 # neighbor cell across the face: minus side -> i-1 (m-1 roll of IDX at m=0), plus -> i+1
394 rows += [cells, cells]
395 cols += [cells, nb]
396 vals += [np.ones(len(cells)), -np.ones(len(cells))]
397 inact = np.nonzero(~activef)[0]
398 rows.append(inact)
399 cols.append(inact)
400 vals.append(np.ones(len(inact)))
401 M = sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows).astype(np.int64),
402 np.concatenate(cols).astype(np.int64))),
403 shape=(n, n))
404 M.sum_duplicates()
405 return M
406
407# ---------------------------------------------------------------- tests
408def face_fields(geo, ufun):
409 return [ufun(*geo["Pf"][a])[a] for a in range(3)]
410
412 """QUAD ghost-face closures vs the smooth continuation of the analytic Stokes field.
413 Variant 'scheme': u_bc = 0 (what the solver knows) -> O(h^2) (wall-anchoring error)
414 Variant 'consistent': u_bc = field at the linearized anchor -> O(h^3) (pure poly truncation)"""
415 print("\n[1] ghost-face extrapolation vs analytic Stokes field (QUAD faces)")
416 print(f"{'N':>5} {'scheme ubc=0':>13} {'ord':>6} {'consistent':>12} {'ord':>6} "
417 f"{'nQUAD':>7} {'nSLIV':>6} {'nLIN':>5}")
418 prev = {}
419 slopes = {}
420 for N in Ns:
421 geo = build_geo(N)
422 u3 = face_fields(geo, stokes_u)
423 errs = {"scheme": [], "consistent": []}
424 nq = nl = ns = 0
425 for a in range(3):
426 for side in (-1, +1):
427 st, th = geo["states"][(a, side)]
428 stf, thf = st.ravel(), th.ravel()
429 nl += int(np.sum(stf == LIN))
430 cells = np.nonzero(stf == QUAD)[0]
431 if len(cells) == 0:
432 continue
433 tt = thf[cells]
434 nq += int(np.sum(tt <= 1.0))
435 ns += int(np.sum(tt > 1.0))
436 wbc, w1, w2, _ = closure_weights(np.full(len(cells), QUAD, np.int8), tt)
437 mg = 0 if side < 0 else 1
438 mn = 1 if side < 0 else 0
439 mf = 2 if side < 0 else -1
440 base = (w1*gather_face(u3[a], a, mn, cells)
441 + w2*gather_face(u3[a], a, mf, cells))
442 # truth = smooth continuation of the fluid solution at the closed face point
443 truth = gather_face(u3[a], a, mg, cells)
444 Pc = crossing_points(geo, a, side, cells)
445 ub_c = stokes_u(Pc[0], Pc[1], Pc[2])[a]
446 errs["scheme"].append(np.abs(base - truth)) # u_bc = 0
447 errs["consistent"].append(np.abs(base + wbc*ub_c - truth))
448 e_s = max(float(e.max()) for e in errs["scheme"])
449 e_c = max(float(e.max()) for e in errs["consistent"])
450 o_s = np.log2(prev["s"]/e_s)/np.log2(N/prev["N"]) if prev else float("nan")
451 o_c = np.log2(prev["c"]/e_c)/np.log2(N/prev["N"]) if prev else float("nan")
452 print(f"{N:>5} {e_s:>13.3e} {o_s:>6.2f} {e_c:>12.3e} {o_c:>6.2f} "
453 f"{nq:>7} {ns:>6} {nl:>5}")
454 prev = {"s": e_s, "c": e_c, "N": N}
455 slopes = {"scheme": o_s, "consistent": o_c}
456 return slopes
457
459 print("\n[2] closed divergence of the exact solenoidal Stokes field, physical units (/h)")
460 print(" (near-IB rows: localized boundary truncation, expect ~O(h); bulk: O(h^2))")
461 print(f"{'N':>5} {'max near-IB':>13} {'ord':>6} {'max bulk':>12} {'ord':>6}")
462 prev = None
463 slope = float("nan")
464 for N in Ns:
465 geo = build_geo(N)
466 u3 = face_fields(geo, stokes_u)
467 d = divergence(geo, u3, stokes_u)/geo["h"] # unscaled rows (rho=None): raw truncation
468 ov = overlay_cells(geo).ravel()
469 # the Stokes field is NOT periodic: exclude the box-boundary wrap layer from the metrics
470 interior = ((np.abs(geo["Xc"][0]) < 0.5 - 2*geo["h"])
471 & (np.abs(geo["Xc"][1]) < 0.5 - 2*geo["h"])
472 & (np.abs(geo["Xc"][2]) < 0.5 - 2*geo["h"])).ravel()
473 ov &= interior
474 bulk = geo["active"].ravel() & ~ov & interior
475 e_ib = float(np.abs(d[ov]).max())
476 e_bk = float(np.abs(d[bulk]).max())
477 o_ib = np.log2(prev[0]/e_ib)/np.log2(N/prev[2]) if prev else float("nan")
478 o_bk = np.log2(prev[1]/e_bk)/np.log2(N/prev[2]) if prev else float("nan")
479 print(f"{N:>5} {e_ib:>13.3e} {o_ib:>6.2f} {e_bk:>12.3e} {o_bk:>6.2f}")
480 prev = (e_ib, e_bk, N)
481 slope = o_ib
482 return slope
483
484def solve_system(A, b, n_active):
485 try:
486 return spla.spsolve(A.tocsc(), b)
487 except MemoryError:
488 ilu = spla.spilu(A.tocsc(), drop_tol=1e-5, fill_factor=20)
489 M = spla.LinearOperator(A.shape, ilu.solve)
490 x, info = spla.lgmres(A, b, M=M, rtol=1e-12, maxiter=2000)
491 if info != 0:
492 raise RuntimeError(f"lgmres failed: info={info}")
493 return x
494
495def test_solve(Ns, mode="center"):
496 print("\n[3] assembled projection solve, manufactured field (THE global-order gate)")
497 print(" u* = u_exact + Dgrad(phi_man); expect phi and corrected u ~ O(h^2)")
498 print(f"{'N':>5} {'max|phi err|':>13} {'ord':>6} {'max|u err|':>12} {'ord':>6} "
499 f"{'max near-IB':>12} {'L2|u err|':>12} {'ord':>6} {'diag==res':>10} {'rho_min':>8}")
500 prev = None
501 out = {}
502 for N in Ns:
503 geo = build_geo(N, mode=mode)
504 h = geo["h"]
505 rho = row_rescale(geo)
506 activef = geo["active"].ravel()
507 pm = phi_man(*geo["Xc"]) # cell-centered manufactured phi
508 u3 = face_fields(geo, periodic_u)
509 ustar = [u3[a] + (pm - np.roll(pm, +1, axis=a)) for a in range(3)]
510
511 A = assemble(geo, rho)
512 b = -divergence(geo, ustar, periodic_u, rho=rho)
513 # sanity: active rows never touch inactive columns
514 sub = A[activef, :][:, ~activef]
515 assert sub.nnz == 0, "active row references decoupled phi"
516 # Singular (Neumann-like) system: dump the incompatibility UNIFORMLY (residual = lambda*e,
517 # the direct-solve equivalent of the iterative mean-removal the real solver uses). Row
518 # pinning alone dumps it LOCALLY (a spurious O(h^0) spike at the pin — measured), and a
519 # bordered [A e; e^T 0] system is sparse-LU-hostile (the dense multiplier column explodes
520 # the fill-in — also measured, catastrophically). So: factor the pinned matrix once, solve
521 # for b and for the uniform vector e, and pick lambda to make the pinned row consistent:
522 # phi = phi_b - lambda*phi_e, lambda = (A_r0.phi_b - b_r0)/(A_r0.phi_e - 1)
523 # => A phi = b - lambda*e exactly (uniform dump), sparse-friendly.
524 r0 = int(np.nonzero(activef)[0][0])
525 arow = A.getrow(r0)
526 Apin = A.tolil()
527 Apin.rows[r0], Apin.data[r0] = [r0], [1.0]
528 lu = spla.splu(Apin.tocsc())
529 bp = b.copy()
530 bp[r0] = 0.0
531 ev = activef.astype(float)
532 evp = ev.copy()
533 evp[r0] = 0.0
534 phi_b = lu.solve(bp)
535 phi_e = lu.solve(evp)
536 lam = (float((arow @ phi_b)[0]) - b[r0]) / (float((arow @ phi_e)[0]) - 1.0)
537 phi = phi_b - lam * phi_e
538 pmf = pm.ravel()
539
540 dphi = phi - pmf
541 dphi -= dphi[activef].mean()
542 e_phi = float(np.abs(dphi[activef]).max()) # grid units, vs the O(1) target pm
543
544 ucorr = [ustar[a] - (phi.reshape(geo["Sc"].shape)
545 - np.roll(phi.reshape(geo["Sc"].shape), +1, axis=a))
546 for a in range(3)]
547 ovm = overlay_cells(geo)
548 e_u, e_u_ib, s2, n_f = 0.0, 0.0, 0.0, 0
549 for a in range(3):
550 st_m, _ = geo["states"][(a, -1)]
551 # faces the scheme constrains: COUPLED faces owned by an ACTIVE cell.
552 # (COUPLED faces of solid-centered cells get a one-sided phi-0 correction in
553 # projectCorrect — watch item for the coupled solver, not an a-priori gate.)
554 fluid_face = (st_m == COUPLED) & geo["active"]
555 near = fluid_face & (ovm | np.roll(ovm, +1, axis=a))
556 err = np.abs(ucorr[a] - u3[a])
557 if np.any(fluid_face):
558 e_u = max(e_u, float(err[fluid_face].max()))
559 s2 += float((err[fluid_face]**2).sum())
560 n_f += int(fluid_face.sum())
561 if np.any(near):
562 e_u_ib = max(e_u_ib, float(err[near].max()))
563 e_u_l2 = np.sqrt(s2/max(n_f, 1))
564
565 # diagnostic == residual identity (closure consistency by construction): the closed
566 # divergence of the corrected field is EXACTLY A phi - b = -(b - A phi). (Max-abs is what
567 # the solver diagnostic reports, so the sign is irrelevant there.)
568 diag = divergence(geo, ucorr, periodic_u, rho=rho, u_explicit=ustar)
569 res = b - A@phi # = lambda*e: the uniform incompat. dump
570 ident = float(np.abs(diag[activef] + res[activef]).max())
571 scale = max(1.0, float(np.abs(b[activef]).max()))
572
573 o_p = np.log2(prev[0]/e_phi)/np.log2(N/prev[3]) if prev else float("nan")
574 o_u = np.log2(prev[1]/e_u)/np.log2(N/prev[3]) if prev else float("nan")
575 o_l2 = np.log2(prev[2]/e_u_l2)/np.log2(N/prev[3]) if prev else float("nan")
576 ok = "OK" if ident < 1e-10*scale else f"{ident:.1e}"
577 print(f"{N:>5} {e_phi:>13.3e} {o_p:>6.2f} {e_u:>12.3e} {o_u:>6.2f} "
578 f"{e_u_ib:>12.3e} {e_u_l2:>12.3e} {o_l2:>6.2f} {ok:>10} "
579 f"{rho[overlay_cells(geo).ravel()].min():>8.1e}")
580 prev = (e_phi, e_u, e_u_l2, N)
581 out = {"o_phi": o_p, "o_u": o_u, "o_u_l2": o_l2, "ident": ident < 1e-10*scale}
582 return out
583
585 print(f"\n[4] solver probes at N={N} (dense)")
586 geo = build_geo(N)
587 rho = row_rescale(geo)
588 A = assemble(geo, rho)
589 M = binary_openness_op(geo)
590 activef = geo["active"].ravel()
591 ii = np.nonzero(activef)[0]
592 Aa = A[np.ix_(ii, ii)].toarray()
593 Ma = M[np.ix_(ii, ii)].toarray()
594 na = len(ii)
595
596 e1 = float(np.abs(Aa.sum(axis=1)).max())
597 print(f" A@1 on active rows: {e1:.2e} (constants right-null: want ~0)")
598
599 # left null vector + compatibility gap against a physical RHS
600 w, V = np.linalg.eig(Aa.T)
601 k = int(np.argmin(np.abs(w)))
602 wn = np.real(V[:, k])
603 u3 = face_fields(geo, periodic_u)
604 pmf = phi_man(*geo["Xc"])
605 ustar = [u3[a] + (pmf - np.roll(pmf, +1, axis=a)) for a in range(3)]
606 b = (-divergence(geo, ustar, periodic_u, rho=rho))[ii]
607 gap = abs(wn @ b)/(np.linalg.norm(wn)*np.linalg.norm(b))
608 ones_ang = abs(wn @ np.ones(na))/(np.linalg.norm(wn)*np.sqrt(na))
609 print(f" left-null eigenvalue |lam|: {abs(w[k]):.2e}")
610 print(f" |w.1|/(|w||1|): {ones_ang:.4f} (1.0 would mean w = constants)")
611 print(f" compatibility gap |w.b|/|w||b|: {gap:.2e} (small => mean-removal-style OK)")
612
613 # spectrum of the surrogate-preconditioned operator (constant mode pinned via rank-1)
614 cshift = np.mean(np.diag(Ma))
615 e = np.ones((na, 1))/np.sqrt(na)
616 G = np.linalg.solve(Ma + cshift*(e@e.T), Aa + cshift*(e@e.T))
617 lam = np.linalg.eigvals(G)
618 lam = lam[np.argsort(np.abs(lam - 1.0))] # drop the pinned ~1 constant mode last
619 re, im = lam.real, lam.imag
620 print(f" spec(M^-1 A): Re in [{re.min():.3f}, {re.max():.3f}], max|Im| = "
621 f"{np.abs(im).max():.3f}, n = {na}")
622 dc = float(np.abs(1.0 - lam).max())
623 print(f" deferred-correction rate max|1-lam| = {dc:.3f} (<1 => DC converges)")
624 return dict(gap=gap, re_min=float(re.min()), dc=dc)
625
626# ------------------------------------------------- phase A4/A5: sparse split + compatibility
627# (doc/ghost_hardening_plan.md). test_probes above answers the same questions densely on a single
628# analytic sphere at N=12; these run on geometries big enough to carry real thin-gap statistics —
629# a DEM-grown periodic bed (pack_bed.py npz, cubic box) or a two-sphere near-tangent pair.
630
631def sdf_bed(npz_path, jitter=0.0):
632 """Periodic union-of-spheres SDF from a pack_bed.py packing, mapped onto the unit box.
633
634 Requires a CUBIC packing box (s100/s101/s108); the harness grid is the unit cube, so the
635 sphere radius in cells is N/box. Periodic images are included, so the geometry the harness
636 classifies is the same one the solver sees on that bed at that resolution.
637 """
638 pk = np.load(npz_path)
639 box = np.asarray(pk["box"], float)
640 if not np.allclose(box, box[0]):
641 raise SystemExit(f"{npz_path}: box {box} is not cubic (the harness grid is the unit cube)")
642 c = np.asarray(pk["centers"], float)/box[0] # -> [0,1)
643 r = np.asarray(pk["scales"], float)/box[0]
644 if jitter:
645 rng = np.random.default_rng(0)
646 c = c + jitter*rng.standard_normal(c.shape)/box[0]
647
648 def f(x, y, z):
649 d = np.full(np.shape(x), 1e30)
650 for sh in np.stack(np.meshgrid(*[[-1.0, 0.0, 1.0]]*3, indexing="ij"), -1).reshape(-1, 3):
651 for (cx, cy, cz), rr in zip(c + sh, r):
652 if (cx + rr < -0.55 or cx - rr > 0.55 or cy + rr < -0.55 or cy - rr > 0.55
653 or cz + rr < -0.55 or cz - rr > 0.55):
654 continue # the harness box is [-0.5, 0.5)^3
655 d = np.minimum(d, np.sqrt((x - (cx - 0.5))**2 + (y - (cy - 0.5))**2
656 + (z - (cz - 0.5))**2) - rr)
657 return d
658 f.n_spheres = len(c)
659 f.r_unit = float(r[0])
660 return f
661
662def sdf_pair(gap, rad=0.22):
663 """Two spheres on the x-axis separated by `gap` (in units of the box side), periodic images
664 included. gap -> 0 is the near-tangent pathological configuration."""
665 off = rad + 0.5*gap
666
667 def f(x, y, z):
668 d = np.full(np.shape(x), 1e30)
669 for sh in ([-1.0, 0.0, 1.0] if True else []):
670 for cx in (-off + sh, off + sh):
671 d = np.minimum(d, np.sqrt((x - cx)**2 + y*y + z*z) - rad)
672 return d
673 return f
674
675def solve_set(geo, M):
676 """The cells the solver actually solves on: active, phi-coupled, and in the LARGEST connected
677 component of the COUPLED graph (flow's fragmentation guard, flow_ibm.hpp set_solid)."""
678 import scipy.sparse.csgraph as csg
679 activef = geo["active"].ravel()
680 coupled = np.zeros(geo["N"]**3, bool)
681 for (a, side), (st, _) in geo["states"].items():
682 coupled |= (st.ravel() == COUPLED)
683 cand = np.nonzero(activef & coupled)[0]
684 G = M.tocsr()[cand, :].tocsc()[:, cand]
685 ncomp, lab = csg.connected_components(G, directed=False)
686 keep = cand[lab == np.argmax(np.bincount(lab))]
687 return np.sort(keep), ncomp
688
689def probe_split(geo, verbose=True, power_iters=200, tol=1e-6, order=2):
690 """A4: rho(S^-1 N) by power iteration, S = binary-openness 7-point op, N = gp overlay delta.
691 A5: the compatibility bias of the nonsymmetric A under the solver's mean removal.
692 Sparse throughout, so it runs on beds the dense probe cannot touch."""
693 rho = row_rescale(geo, order)
694 A = assemble(geo, rho, order)
695 M = binary_openness_op(geo)
696 ii, ncomp = solve_set(geo, M)
697 n = len(ii)
698 S = sp.csc_matrix(M.tocsr()[ii, :].tocsc()[:, ii])
699 Aa = sp.csc_matrix(A.tocsr()[ii, :].tocsc()[:, ii])
700 Nn = (Aa - S).tocsr()
701
702 # gauge: S is singular (constants). Pin one dof, then project the mean out of every iterate --
703 # exactly what the solver's removeMean does around the V-cycle preconditioner.
704 jp = int(np.argmax(np.asarray(S.diagonal())))
705 Sp = S.tolil()
706 Sp[jp, :] = 0.0
707 Sp[:, jp] = 0.0
708 Sp[jp, jp] = 1.0
709 lu = spla.splu(sp.csc_matrix(Sp))
710
711 def Sinv(v):
712 v = v - v.mean()
713 v[jp] = 0.0
714 z = lu.solve(v)
715 return z - z.mean()
716
717 # B0 probe (doc/ghost_hardening_findings_A.md): what the split looks like if the
718 # PRECONDITIONER also carries the row rescale. A = diag(rho)*S + N, so preconditioning with
719 # S' = diag(rho)*S gives S'^-1 A = S^-1 (diag(rho)^-1 A), i.e. the rescale cancels out of the
720 # Krylov entirely -- rho only ever helped cut-cell because there it is baked into the same
721 # stencil the smoother reads.
722 Nu = (sp.diags(1.0/np.asarray(rho)[ii]) @ Aa - S).tocsr()
723
724 def power(Nmat):
725 rng = np.random.default_rng(7)
726 x = rng.standard_normal(n)
727 x -= x.mean()
728 x /= np.linalg.norm(x)
729 nrm = 0.0
730 for k in range(power_iters):
731 y = Sinv(Nmat @ x)
732 nrm = np.linalg.norm(y)
733 if nrm < 1e-300:
734 return 0.0
735 x = y/nrm
736 return float(nrm)
737
738 rho_b0 = power(Nu)
739
740 # smallest |eigenvalue| of the PRECONDITIONED operator M^-1 A (M = the binary-openness V-cycle
741 # surrogate): 1/rho(A^-1 M) by power iteration. Together with lam_max ~ 1 + rho(S^-1 N) this is
742 # the BiCGStab health metric -- a near-zero lam_min is the signature of rows the preconditioner
743 # cannot serve (the phase-A "B0" question).
744 lam_min = float("nan")
745 try:
746 Ap_ = sp.csc_matrix(Aa)
747 Ap_ = Ap_ + 1e-13*sp.identity(n, format="csc")
748 luA = spla.splu(Ap_)
749 rng2 = np.random.default_rng(3)
750 xx = rng2.standard_normal(n)
751 xx -= xx.mean()
752 xx /= np.linalg.norm(xx)
753 gmax = 0.0
754 for _ in range(120):
755 yy = luA.solve(S @ xx)
756 yy -= yy.mean()
757 gmax = np.linalg.norm(yy)
758 if gmax < 1e-300 or not np.isfinite(gmax):
759 break
760 xx = yy/gmax
761 lam_min = 1.0/gmax if gmax > 0 else float("nan")
762 except Exception as e:
763 print(f" (lam_min probe skipped: {type(e).__name__})")
764 rng = np.random.default_rng(7)
765 x = rng.standard_normal(n)
766 x -= x.mean()
767 x /= np.linalg.norm(x)
768 lam = 0.0
769 for k in range(power_iters):
770 y = Sinv(Nn @ x)
771 nrm = np.linalg.norm(y)
772 if nrm < 1e-300:
773 lam = 0.0
774 break
775 lam_new = float(x @ y) # Rayleigh quotient (x is unit)
776 x = y/nrm
777 if k > 20 and abs(lam_new - lam) < tol*max(1.0, abs(lam_new)):
778 lam = lam_new
779 break
780 lam = lam_new
781 rho_sn = float(nrm) # |S^-1 N x| at the fixed point = spectral radius estimate
782
783 # A5: how far the LEFT null vector is from the constants. 1^T A is exactly the column sums;
784 # the solver removes the mean (projects on 1), so whatever 1^T A leaves behind is the bias
785 # channel. Normalised by the row scale so it reads as a relative defect.
786 colsum = np.asarray(Aa.sum(axis=0)).ravel()
787 rowabs = np.asarray(abs(Aa).sum(axis=1)).ravel()
788 scale = float(np.mean(rowabs))
789 l1 = float(np.abs(colsum).sum())/(scale*n)
790 linf = float(np.abs(colsum).max())/scale
791 rowsum = np.asarray(Aa.sum(axis=1)).ravel() # A@1: must be ~0 (constants right-null)
792 if verbose:
793 print(f" solve set {n} cells ({ncomp} coupled components; "
794 f"{100.0*n/geo['N']**3:.1f} % of the grid)")
795 print(f" rho(S^-1 N) = {rho_sn:.4f} "
796 f"({'DC converges' if rho_sn < 1 else 'DC DIVERGES'}; Rayleigh {lam:+.4f})")
797 print(f" rho(S^-1 N) with rho-aware S = {rho_b0:.4f} "
798 f"(B0: the preconditioner carries the row rescale too)")
799 print(f" spec(M^-1 A): |lam|_min = {lam_min:.4e} "
800 f"lam_max ~ {1.0 + rho_sn:.3f} spread ~ {(1.0 + rho_sn)/max(lam_min,1e-300):.3e}")
801 print(f" A@1 max = {np.abs(rowsum).max():.2e} "
802 f"(right null = constants)")
803 print(f" |1^T A|_1/(n*scale) = {l1:.3e} "
804 f"|1^T A|_inf/scale = {linf:.3e} (left null != constants)")
805 return dict(n=n, ncomp=ncomp, rho_sn=rho_sn, rho_b0=rho_b0, lam_min=lam_min,
806 l1=l1, linf=linf,
807 rowsum=float(np.abs(rowsum).max()), A=Aa, S=S, ii=ii, geo=geo, rho=rho)
808
809def compat_bias(pr, geo, exact_leftnull=True):
810 """A5 (second half): the per-solve bias the mean removal leaves behind. Build the physical RHS
811 b = -div(u*) the solver would see, project it the way the solver does (remove the mean), and
812 measure the component that the TRUE left null vector still sees -- that part is unreachable by
813 any Krylov iteration and is what the incremental-rotational pressure accumulates."""
814 Aa, ii = pr["A"], pr["ii"]
815 n = len(ii)
816 u3 = face_fields(geo, periodic_u)
817 pmf = phi_man(*geo["Xc"])
818 ustar = [u3[a] + (pmf - np.roll(pmf, +1, axis=a)) for a in range(3)]
819 b_raw = (-divergence(geo, ustar, periodic_u, rho=pr["rho"]))[ii]
820 b = b_raw - b_raw.mean() # the solver's mean removal
821 # Left null vector by inverse iteration on A^T (A is numerically singular, so the LU solve
822 # amplifies exactly the null direction). svds(which="SM") is unreliable here -- cross-checked
823 # against the dense eigendecomposition of test_probes: same vector to 1e-6.
824 if not exact_leftnull: # the LU of the 13-point A^T is the expensive part; skip it on big N
825 print(" (left-null vector skipped: --no-leftnull)")
826 return dict(gap=float("nan"), gap_raw=float("nan"), ones_ang=float("nan"),
827 lnres=float("nan"))
828 At = sp.csc_matrix(Aa.T)
829 lu = spla.splu(At + 1e-12*sp.identity(n, format="csc"))
830 w = np.ones(n)/np.sqrt(n)
831 for _ in range(30):
832 z = lu.solve(w)
833 nz = np.linalg.norm(z)
834 if not np.isfinite(nz) or nz == 0.0:
835 break
836 w = z/nz
837 resid = float(np.linalg.norm(At @ w))
838 gap = abs(float(w @ b))/max(np.linalg.norm(b), 1e-300)
839 gap_raw = abs(float(w @ b_raw))/max(np.linalg.norm(b_raw), 1e-300)
840 ones_ang = abs(float(w @ np.ones(n)))/np.sqrt(n)
841 print(f" left-null residual |A^T w| = {resid:.3e}")
842 print(f" |w.1|/(|w||1|) = {ones_ang:.4f} (1.0 => w IS the constants)")
843 print(f" compat gap |w.b|/|b| = {gap:.3e} after mean removal "
844 f"({gap_raw:.3e} raw) (Krylov cannot reduce this)")
845 return dict(gap=gap, gap_raw=gap_raw, ones_ang=ones_ang, lnres=resid)
846
847def test_split(args):
848 """Phase-A4/A5 driver: run the sparse split + compatibility probes on the requested geometry."""
849 cases = []
850 if args.bed:
851 f = sdf_bed(args.bed)
852 for N in args.split_n:
853 cases.append((f"bed {args.bed.split('/')[-1]} N={N} (R={N*f.r_unit:.1f} cells)", N, f))
854 elif args.pair is not None:
855 for N in args.split_n:
856 cases.append((f"two spheres gap={args.pair:g} N={N} "
857 f"(gap={args.pair*N:.2f} cells)", N, sdf_pair(args.pair)))
858 else:
859 for N in args.split_n:
860 cases.append((f"single analytic sphere N={N}", N, sdf_sphere))
861 out = []
862 for name, N, f in cases:
863 print(f"\n[A4/A5] {name} matrix_order={args.matrix_order}")
864 geo = build_geo(N, sdf=f, mode=args.classify)
865 pr = probe_split(geo, order=args.matrix_order)
866 cb = compat_bias(pr, geo, exact_leftnull=not args.no_leftnull)
867 out.append(dict(name=name, N=N, **{k: pr[k] for k in
868 ("n", "ncomp", "rho_sn", "rho_b0", "lam_min",
869 "l1", "linf")},
870 **cb))
871 return out
872
874 print("\n[5] degenerate geometries (classification + null-space sanity)")
875 N = 24
876 cases = {
877 "slab channel |y|<0.30": lambda x, y, z: 0.30 - np.abs(y),
878 "offset slab (th generic)": lambda x, y, z: 0.30 + 0.31/N - np.abs(y),
879 "sandwich slit gap=0.8h": lambda x, y, z: 0.4/N - np.abs(y - 0.021),
880 "one-cell gap=1.6h": lambda x, y, z: 0.8/N - np.abs(y - 0.021),
881 "wall AT a face point": lambda x, y, z: -y, # y=0 face plane exactly on the wall
882 }
883 ok = True
884 for name, f in cases.items():
885 geo = build_geo(N, sdf=f)
886 rho = row_rescale(geo)
887 A = assemble(geo, rho)
888 activef = geo["active"].ravel()
889 counts = {s: 0 for s in STATE_NAMES}
890 for (a, side), (st, _) in geo["states"].items():
891 stf = st.ravel()[activef]
892 for s in STATE_NAMES:
893 counts[s] += int(np.sum(stf == s))
894 e1 = float(np.abs(np.asarray(A[activef].sum(axis=1))).max())
895 cross = A[activef, :][:, ~activef].nnz
896 finite = np.all(np.isfinite(A.data))
897 stat = "ok" if (e1 < 1e-10 and cross == 0 and finite) else "FAIL"
898 ok &= stat == "ok"
899 cs = " ".join(f"{STATE_NAMES[s][:4]}={c}" for s, c in counts.items() if c)
900 print(f" {name:<26} A@1={e1:.1e} cross={cross} {stat} [{cs}]")
901 return ok
902
903# ---------------------------------------------------------------- main
904if __name__ == "__main__":
905 ap = argparse.ArgumentParser()
906 ap.add_argument("--quick", action="store_true", help="smaller grids")
907 ap.add_argument("--probe-n", type=int, default=12)
908 # phase A4/A5 of doc/ghost_hardening_plan.md (sparse, runs on real beds; skips tests 1-5)
909 ap.add_argument("--split", action="store_true",
910 help="run ONLY the A4/A5 probes: rho(S^-1 N) + compatibility bias")
911 ap.add_argument("--bed", default="", help="pack_bed.py npz (cubic box) as the geometry")
912 ap.add_argument("--pair", type=float, default=None,
913 help="two near-tangent spheres separated by this gap (box units)")
914 ap.add_argument("--split-n", type=int, nargs="+", default=[32],
915 help="grid sizes for --split")
916 ap.add_argument("--classify", choices=["center", "face"], default="center",
917 help="pressure-cell classification: shipped centre-based, or face-based")
918 ap.add_argument("--matrix-order", type=int, default=2,
919 help="closure order of the IMPLICIT phi couplings (1 = the mixed mode)")
920 ap.add_argument("--no-leftnull", action="store_true",
921 help="skip the (expensive) exact left-null vector; keep |1^T A| only")
922 args = ap.parse_args()
923
924 if args.split:
925 test_split(args)
926 raise SystemExit(0)
927
928 Ns_eval = [16, 32, 64] if args.quick else [16, 32, 64, 128]
929 Ns_solve = [16, 24, 32] if args.quick else [16, 24, 32, 48]
930
931 s1 = test_extrapolation(Ns_eval)
932 s2 = test_divergence(Ns_eval)
933 s3 = test_solve(Ns_solve, mode=args.classify)
934 s4 = test_probes(args.probe_n)
936
937 print("\n==== gates ====")
938 gates = [
939 ("extrapolation order (consistent) >= 2.5", s1["consistent"] >= 2.5),
940 ("extrapolation order (scheme) >= 1.7", s1["scheme"] >= 1.7),
941 ("near-IB divergence order >= 0.8", s2 >= 0.8),
942 ("solve: phi order >= 1.5", s3["o_phi"] >= 1.5),
943 ("solve: corrected-velocity order >= 1.7", s3["o_u"] >= 1.7),
944 ("diagnostic == residual identity", s3["ident"]),
945 ("preconditioned spectrum Re > 0", s4["re_min"] > 0.0),
946 ("compatibility gap < 1e-3", s4["gap"] < 1e-3),
947 ("degenerate geometries sane", s5),
948 ]
949 npass = 0
950 for name, okk in gates:
951 print(f" {'PASS' if okk else 'FAIL'} {name}")
952 npass += okk
953 print(f"{npass}/{len(gates)} gates passed")
build_geo(N, sdf=sdf_sphere, mode="center")
probe_split(geo, verbose=True, power_iters=200, tol=1e-6, order=2)
divergence(geo, u3, ubc_fn, rho=None, u_explicit=None)
compat_bias(pr, geo, exact_leftnull=True)
crossing_points(geo, a, side, cells_flat)