100 """Classify every (cell, axis, side) face. Returns dict with sdf samples, states, thetas.
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.
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.
116 c = (np.arange(N) + 0.5)*h - 0.5
117 Xc = np.meshgrid(c, c, c, indexing=
"ij")
124 P = [Xc[0].copy(), Xc[1].copy(), Xc[2].copy()]
130 return np.roll(Sc, -q, axis=a)
133 return np.roll(Sf[a], -m, axis=a)
137 active = np.zeros(Sc.shape, bool)
139 active |= (face(0, a) >= 0) | (face(1, a) >= 0)
145 for side
in (-1, +1):
148 mg, mn, mf, mb = 0, 1, 2, -1
152 mg, mn, mf, mb = 1, 0, -1, 2
155 Sg, Sn, Sfar, Sb = face(mg, a), face(mn, a), face(mf, a), face(mb, a)
157 C1, C2 = cent(qn1, a), cent(qn2, a)
159 st = np.full(Sc.shape, COUPLED, dtype=np.int8)
160 th = np.ones(Sc.shape)
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)
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
180 states[(a, side)] = (st, th)
183 coupled = (Sg >= 0) & (Snb >= 0)
184 sandwich = (face(0, a) < 0) & (face(1, a) < 0)
185 ghost = (Sg < 0) & ~sandwich
186 sliver = (Sg >= 0) & (Snb < 0)
189 th_g = np.where(ghost, Sn/np.where(ghost, Sn - Sg, 1.0), 1.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)
194 src1 = (Sn >= 0) & (C1 >= 0)
195 src2 = (Sfar >= 0) & (C2 >= 0)
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)
210 return dict(N=N, h=h, Xc=Xc, Sc=Sc, Sf=Sf, Pf=Pf, active=active, states=states, sdf=sdf,
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]]
227 Pc = [p.copy()
for p
in P]
228 Pc[a] = Pc[a] + side*thf*h
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)
237 Pc[q][bo] = geo[
"Xc"][q].ravel()[cells_flat][bo]
238 Pc[a][bo] = Pc[a][bo] + side*t[bo]*(0.5*h)
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)."""
291 if u_explicit
is None:
295 for side
in (-1, +1):
296 st, th = geo[
"states"][(a, side)]
298 sgn =
float(side)
if side > 0
else -1.0
301 mg = 0
if side < 0
else 1
302 uf = np.roll(u3[a], -mg, axis=a).ravel()
303 d[mcp] += sgn*uf[mcp]
305 for state
in (QUAD, LIN, BC_ONLY):
306 cells = np.nonzero(stf == state)[0]
309 thf = th.ravel()[cells]
310 wbc, w1, w2, _ =
closure_weights(np.full(len(cells), state, np.int8), thf)
312 ub = ubc_fn(Pc[0], Pc[1], Pc[2])[a]
315 mn = 1
if side < 0
else 0
316 mf = 2
if side < 0
else -1
322 mex = np.nonzero(stf == EXPLICIT)[0]
324 d[mex] += sgn*
gather_face(u_explicit[a], a, mg, mex)
327 d[~geo[
"active"].ravel()] = 0.0
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)."""
335 IDX = np.arange(n).reshape(N, N, N)
336 rows, cols, vals = [], [], []
337 activef = geo[
"active"].ravel()
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
351 def add_face(cells, m, cf):
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
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]
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)
369 mf = 2
if side < 0
else -1
370 add_face(cells, mf, w2)
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))),
381 """The symmetric MG surrogate: 7-point op with o=1 on COUPLED faces, 0 otherwise."""
384 IDX = np.arange(n).reshape(N, N, N)
385 rows, cols, vals = [], [], []
386 activef = geo[
"active"].ravel()
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]
394 rows += [cells, cells]
396 vals += [np.ones(len(cells)), -np.ones(len(cells))]
397 inact = np.nonzero(~activef)[0]
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))),
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}")
506 activef = geo[
"active"].ravel()
509 ustar = [u3[a] + (pm - np.roll(pm, +1, axis=a))
for a
in range(3)]
512 b = -
divergence(geo, ustar, periodic_u, rho=rho)
514 sub = A[activef, :][:, ~activef]
515 assert sub.nnz == 0,
"active row references decoupled phi"
524 r0 =
int(np.nonzero(activef)[0][0])
527 Apin.rows[r0], Apin.data[r0] = [r0], [1.0]
528 lu = spla.splu(Apin.tocsc())
531 ev = activef.astype(float)
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
541 dphi -= dphi[activef].mean()
542 e_phi =
float(np.abs(dphi[activef]).max())
544 ucorr = [ustar[a] - (phi.reshape(geo[
"Sc"].shape)
545 - np.roll(phi.reshape(geo[
"Sc"].shape), +1, axis=a))
548 e_u, e_u_ib, s2, n_f = 0.0, 0.0, 0.0, 0
550 st_m, _ = geo[
"states"][(a, -1)]
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())
562 e_u_ib = max(e_u_ib,
float(err[near].max()))
563 e_u_l2 = np.sqrt(s2/max(n_f, 1))
568 diag =
divergence(geo, ucorr, periodic_u, rho=rho, u_explicit=ustar)
570 ident =
float(np.abs(diag[activef] + res[activef]).max())
571 scale = max(1.0,
float(np.abs(b[activef]).max()))
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}
585 print(f
"\n[4] solver probes at N={N} (dense)")
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()
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)")
600 w, V = np.linalg.eig(Aa.T)
601 k =
int(np.argmin(np.abs(w)))
602 wn = np.real(V[:, k])
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)")
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))]
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)
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."""
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()
704 jp =
int(np.argmax(np.asarray(S.diagonal())))
709 lu = spla.splu(sp.csc_matrix(Sp))
722 Nu = (sp.diags(1.0/np.asarray(rho)[ii]) @ Aa - S).tocsr()
725 rng = np.random.default_rng(7)
726 x = rng.standard_normal(n)
728 x /= np.linalg.norm(x)
730 for k
in range(power_iters):
732 nrm = np.linalg.norm(y)
744 lam_min =
float(
"nan")
746 Ap_ = sp.csc_matrix(Aa)
747 Ap_ = Ap_ + 1e-13*sp.identity(n, format=
"csc")
749 rng2 = np.random.default_rng(3)
750 xx = rng2.standard_normal(n)
752 xx /= np.linalg.norm(xx)
755 yy = luA.solve(S @ xx)
757 gmax = np.linalg.norm(yy)
758 if gmax < 1e-300
or not np.isfinite(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)
767 x /= np.linalg.norm(x)
769 for k
in range(power_iters):
771 nrm = np.linalg.norm(y)
775 lam_new =
float(x @ y)
777 if k > 20
and abs(lam_new - lam) < tol*max(1.0, abs(lam_new)):
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()
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,
807 rowsum=
float(np.abs(rowsum).max()), A=Aa, S=S, ii=ii, geo=geo, rho=rho)
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"]
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()
824 if not exact_leftnull:
825 print(
" (left-null vector skipped: --no-leftnull)")
826 return dict(gap=
float(
"nan"), gap_raw=
float(
"nan"), ones_ang=
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)
833 nz = np.linalg.norm(z)
834 if not np.isfinite(nz)
or nz == 0.0:
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)
874 print(
"\n[5] degenerate geometries (classification + null-space sanity)")
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,
884 for name, f
in cases.items():
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"
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}]")