flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_scalar_cutcell_gates.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""Cut-cell scalar transport: the accuracy and iteration gates of WO-3 to WO-6 (doc/scalar_ibm_design.md §11).
3
4Single phase; diffusion, and advection by the projection's face flux (WO-5); BiCGStab preconditioned by one ScalarMG V-cycle (WO-4; level 0 alone
5under the transient level rule). Every case is in PHYSICAL units (an extent), so the unit
6conversions of §1.2 are on the path.
7
8 api the opt-in, the refusals and the name checks (cheap).
9 g1 Dirichlet sphere, steady: Nu -> 2 (order >= 1.7), field L1 / Linf orders (>= 1.8 / 1.5),
10 the steady budget identity, R0 = 100 %; plus the anisotropic h' = (1, 1, 2) ladder.
11 g2 concentric shells, steady: Neumann flux q on the inner sphere, an immersed Dirichlet outer
12 sphere (a two-instance scene): area-mean wall value c_Gamma (order >= 1.7), field L1.
13 g3a Robin external sphere, steady, Da = kR/D in {0.1, 1, 10, 100}: Sh = 2 Da/(1 + Da).
14 g3b Robin interior sphere, transient, Bi in {0.1, 1, 10, 100, inf}: the backward-Euler
15 late-time ratio of the total mass gives the spatial eigenvalue mu = D lam^2/R^2,
16 lam cot lam = 1 - Bi; the budget identity every step.
17 g7 pipe (an SDF along z, nz = 4 periodic): Dirichlet decay j01^2 and Neumann dipole decay
18 j'11^2 (BE ratio), and the Graetz number Nu_T = 3.656793 by inverse iteration with
19 solve_scalar_steady (G7a, G7b).
20 g9 advection in an annulus (WO-5): solid-body rotation carrying a Gaussian blob one revolution,
21 fou / koren at bulk Courant 0.5 / 0.9: per-step budget identity, conservation against the
22 accounted defects, positivity (koren gated at bulk Courant <= 1/2, ruling D-WO5-1; its 0.9
23 row is information), finiteness, slivers (min kappa < 1e-2).
24 g9c open domain faces (WO-5b, ruling D-WO5-3): a channel with a solid sphere, inflow / walls /
25 outflow, the scalar carried by flow's own projection: the per-step budget, boundary
26 advective and diffusive fluxes included, closes to <= 1e-13 of the mass change; constant
27 preservation; boundedness; a steady row; the collocated grid is refused (open question).
28 g9b constant preservation under flow's own projection (Stokes through an SC array, c = 1):
29 staggered and the collocated 'gauge-exact', 'plain', 'embed' pass; the ghost projection
30 is refused (§13 Q13).
31 gadv §11 G-adv (WO-5c, design Amendment A2: the steady advective surrogate), Pe_h = the census
32 max_cell_peclet 0.1 / 1 / 10 by rescaling the face field: (a) WO-5's C3 problem (periodic
33 box 4R, a Dirichlet sphere with a source, a projected Stokes field) at R/h 16 and 32;
34 (b) the closure on G9b's SC array (Neumann spheres, singular, the mean-gradient mode of
35 WO-6 with G = e_x) and (b')
36 the same with Dirichlet spheres, 32^3 and 64^3; (c) G9c's channel (open faces), Pe_h 1 and
37 10 (set through D). (i) converged, (ii) iteration bounds (Q20: min(prov., 2x measured)), (iii) growth per
38 doubling <= 1.7x, (v) the steady budget identity <= 1e-11 of the gross budget (D-WO5c-1). (iv) C4 is in the
39 `scalar_mg` ctest, (vi) a one-time logged check.
40 g8 Taylor-Aris (WO-6): G7's pipe, frozen face-averaged Poiseuille fluxes (D-WO6-1), insulating walls, the
41 mean-gradient mode G = e_z, steady (advecting, singular): -<u'theta> -> Pe^2 D/48 at
42 Pe = 10 (order >= 1.8, <= 1e-3 at R/h 32), scalar_mean_flux = its field sums to 1e-10;
43 G-iter (<= 30, growth <= 5) on multigrid-friendly boxes (ruling D-WO4-1).
44 g5b the insulating periodic SC array (c = 0.3) in the mean-gradient mode (WO-6): k* =
45 -J_x/(D G_x) from scalar_mean_flux, self-convergence order >= 1.7, k* below the
46 Hashin-Shtrikman bound; G-iter on multigrid-friendly boxes; the no-solid identity k* = 1
47 to 1e-12 (at rest, and under a uniform flow: the moving frame).
48 giter §11 G-iter, the rows not carried by g1/g2/g3a: transient at dt D/h^2 = 1 (<= 10 per warm
49 step, ruling D-WO4-3 restating the provisional 8; <= 12 on the cold first step, ruling
50 D-WO9-3, which moved the level-rule switch so that this row runs level 0 alone) and the singular
51 steady problem on G5b's geometry (periodic simple-cubic array, c = 0.3, insulating + flux +
52 source; <= 30, growth <= 5 per rung) on multigrid-friendly n.
53
54 g13 contacts (WO-8): two spheres R in a box (6R, 4R, 4R), R/h 8 / 16 / 32, steady: Dirichlet
55 spheres at a gap R/32 and in contact (overlap R/64), far field 0, and the contacting pair
56 conjugate at Lam_s/Lam_f = 100 under a far-field gradient: converged, <= 50 iterations,
57 census two-sided > 0 (where the gap is under a cell), R2 <= 1 % of the probes, total-flux
58 self-convergence order >= 1.0; the 0.4 h thin plate: num_thin_solid > 0 and the §9
59 warning printed.
60
61Every gate runs its FULL resolution ladder (WO-4; ruling D-WO3-1 had deferred the steady ones).
62"Order" is between successive rungs of the RMS error over 3 random grid offsets; every successive
63pair is gated. G-iter on g1/g2/g3a (Da = 1): <= 20 BiCGStab iterations at every rung, growth <= 3
64per doubling (the max over the offsets of each rung). Ruling Q-C (WO-4): G-iter is gated on
65multigrid-friendly boxes (n rounded up to a multiple of 2^(rungs + 1) = 16); a box with few factors
66of two stays gated for correctness only (G2's native box). Iteration counts are printed.
67
68Run: OMP_NUM_THREADS=4 PYTHONPATH=<build> python tests/python/test_scalar_cutcell_gates.py <gate>
69Exit 0 pass, 1 fail, 77 skipped (no module).
70"""
71import math
72import sys
73import time
74
75import numpy as np
76
77try:
78 import peclet.flow as pf
79except ImportError:
80 print("SKIP: peclet.flow not importable")
81 sys.exit(77)
82
83FACES = ("-x", "+x", "-y", "+y", "-z", "+z")
84OFFSETS = np.random.default_rng(20261003).uniform(-0.5, 0.5, size=(3, 3))
85failures = []
86
87
88def check(cond, msg):
89 print((" ok " if cond else " FAIL ") + msg)
90 if not cond:
91 failures.append(msg)
92
93
94def rms(v):
95 v = np.asarray(v, dtype=float)
96 return float(np.sqrt(np.mean(v * v)))
97
98
99def order(ec, ef):
100 return math.log2(ec / ef) if ec > 0 and ef > 0 else float("inf")
101
102
103def grid(s):
104 x, y, z = s.cell_centers()
105 return np.meshgrid(x, y, z, indexing="ij")
106
107
108def face_points(s, face):
109 """Face-centre coordinates (X, Y, Z) of a domain face, shaped (N_t1, N_t2)."""
110 x, y, z = s.cell_centers()
111 lo = [x[0] - 0.5 * (x[1] - x[0]), y[0] - 0.5 * (y[1] - y[0]), z[0] - 0.5 * (z[1] - z[0])]
112 hi = [x[-1] + 0.5 * (x[1] - x[0]), y[-1] + 0.5 * (y[1] - y[0]), z[-1] + 0.5 * (z[1] - z[0])]
113 a, side = FACES.index(face) // 2, FACES.index(face) % 2
114 c = [x, y, z]
115 t = [k for k in range(3) if k != a]
116 T1, T2 = np.meshgrid(c[t[0]], c[t[1]], indexing="ij")
117 P = [None, None, None]
118 P[a] = np.full(T1.shape, hi[a] if side else lo[a])
119 P[t[0]], P[t[1]] = T1, T2
120 return P
121
122
123def walled(cells, extent, origin=(0.0, 0.0, 0.0), periodic_z=False):
124 s = pf.Solver(cells, extent=extent, origin=origin)
125 for f in FACES:
126 if not (periodic_z and f in ("-z", "+z")):
127 s.set_domain_bc(f, "wall")
128 s.set_rho(1.0)
129 s.set_mu(1.0)
130 return s
131
132
133def add_cc(s, D, box="neumann", periodic_z=False):
134 s.add_scalar("c", diffusivity=D, cutcell=True)
135 for f in FACES:
136 if periodic_z and f in ("-z", "+z"):
137 continue
138 s.set_scalar_bc("c", f, box)
139
140
141def giter_check(tag, its):
142 """§11 G-iter on a ladder: {rung: [iterations per offset]} -> <= 20 each, growth <= 3."""
143 rungs = sorted(its)
144 mx = [max(its[k]) for k in rungs]
145 check(max(mx) <= 20, f"{tag}: G-iter <= 20 iterations at every rung ({mx})")
146 gr = [b - a for a, b in zip(mx, mx[1:])]
147 check(all(g <= 3 for g in gr), f"{tag}: G-iter growth <= 3 per doubling ({gr})")
148
149
150def field_errors(s, exact):
151 geo = s.diagnostics.scalar_geometry("c")
152 unk = geo["unknown"] > 0.5
153 w = geo["kappa"][unk]
154 e = (s.get_field("c") - exact)[unk]
155 return float(np.sum(w * np.abs(e)) / np.sum(w)), float(np.max(np.abs(e)))
156
157
159 c = s.diagnostics.scalar_census("c")
160 return c["krylov_iterations"], c["krylov_converged"], c["probe_rungs"]["fluid"]
161
162
163# ---------------------------------------------------------------------------------------- G1 ----
164def g1_case(Rh, off, aniso=False):
165 R, D = 1.0, 0.7
166 L = 4.0 * R
167 h = R / Rh
168 cells = (int(round(L / h)), int(round(L / h)), int(round(L / (2 * h if aniso else h))))
169 s = walled(cells, (L, L, L))
170 hz = L / cells[2]
171 c0 = np.array([0.5 * L + off[0] * h, 0.5 * L + off[1] * h, 0.5 * L + off[2] * hz])
172 X, Y, Z = grid(s)
173 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
174 s.set_solid(np.asfortranarray(r - R))
175 add_cc(s, D, box="neumann")
176 for f in FACES:
177 P = face_points(s, f)
178 rf = np.sqrt((P[0] - c0[0]) ** 2 + (P[1] - c0[1]) ** 2 + (P[2] - c0[2]) ** 2)
179 s.set_scalar_bc("c", f, "dirichlet", R / rf)
180 s.set_scalar_wall("c", "dirichlet", 1.0)
181 s.solve_scalar_steady("c")
182 flux = float(s.scalar_wall_flux("c")[0])
183 nu = flux / (2.0 * math.pi * R * D)
184 l1, linf = field_errors(s, R / r)
185 b = s.diagnostics.scalar_budget("c")
186 it, conv, rungs = solve_info(s)
187 return dict(err=nu / 2.0 - 1.0, l1=l1, linf=linf, it=it, conv=conv, rungs=rungs, b=b, flux=flux)
188
189
191 print("G1 Dirichlet sphere (steady), R/h in {8, 16, 32}, box 4R")
192 res = {}
193 for Rh in (8, 16, 32):
194 rows = [g1_case(Rh, off) for off in OFFSETS]
195 res[Rh] = rows
196 idr = max(abs(r["b"]["identity_error"]) / abs(r["b"]["wall_in"]) for r in rows)
197 dfr = max(abs(r["b"]["defect"]) / abs(r["b"]["wall_in"]) for r in rows)
198 print(f" R/h={Rh:3d} Nu/2-1 = {[format(r['err'], '+.3e') for r in rows]} L1 {rms([r['l1'] for r in rows]):.3e}"
199 f" Linf {rms([r['linf'] for r in rows]):.3e} iters {[r['it'] for r in rows]}"
200 f" identity/|wall| <= {idr:.1e} defect/|wall| <= {dfr:.1e}")
201 for r in rows:
202 check(r["conv"], f"R/h={Rh}: the solve converged ({r['it']} iterations)")
203 check(r["rungs"][1] + r["rungs"][2] + r["rungs"][3] == 0, f"R/h={Rh}: R0 = 100 % ({r['rungs']})")
204 b = r["b"]
205 ident = abs(b["wall_in"] + b["boundary_in"] - b["defect"])
206 check(ident <= 1e-12 * abs(b["wall_in"]),
207 f"R/h={Rh}: |wall - box - defect| = {ident:.2e} <= 1e-12 |wall| ({abs(b['wall_in']):.3e})")
208 check(abs(b["defect"]) <= 1e-6 * abs(b["wall_in"]),
209 f"R/h={Rh}: |defect| = {abs(b['defect']):.2e} <= 1e-6 |wall|")
210 check(abs(b["identity_error"]) <= 1e-12 * abs(b["wall_in"]),
211 f"R/h={Rh}: identity_error {b['identity_error']:.2e}")
212 e = {k: rms([r["err"] for r in v]) for k, v in res.items()}
213 l1 = {k: rms([r["l1"] for r in v]) for k, v in res.items()}
214 li = {k: rms([r["linf"] for r in v]) for k, v in res.items()}
215 for a, b in ((8, 16), (16, 32)):
216 check(order(e[a], e[b]) >= 1.7, f"Nu order {a}->{b} {order(e[a], e[b]):.2f} >= 1.7 (rms {e[a]:.3e} -> {e[b]:.3e})")
217 check(order(l1[a], l1[b]) >= 1.8, f"field L1 order {a}->{b} {order(l1[a], l1[b]):.2f} >= 1.8 ({l1[a]:.3e} -> {l1[b]:.3e})")
218 check(order(li[a], li[b]) >= 1.5, f"field Linf order {a}->{b} {order(li[a], li[b]):.2f} >= 1.5 ({li[a]:.3e} -> {li[b]:.3e})")
219 check(e[32] <= 2e-3, f"|Nu/2 - 1| {e[32]:.2e} <= 2e-3 at R/h = 32 (prov.)")
220 giter_check("G1", {k: [r["it"] for r in v] for k, v in res.items()})
221 print("G1 anisotropic h' = (1, 1, 2), R/h_min in {16, 32}")
222 ea = {}
223 for Rh in (16, 32):
224 rows = [g1_case(Rh, off, aniso=True) for off in OFFSETS]
225 ea[Rh] = rms([r["err"] for r in rows])
226 print(f" R/h={Rh:3d} Nu/2-1 = {[format(r['err'], '+.3e') for r in rows]} iters {[r['it'] for r in rows]}"
227 f" rungs {[r['rungs'] for r in rows]}")
228 for r in rows:
229 check(r["conv"], f"aniso R/h={Rh}: the solve converged ({r['it']} iterations)")
230 # ruling D-WO2-2: on h' = (1, 1, 2) R0 >= 99.9 % of the facets and no R2
231 check(r["rungs"][0] >= 0.999 * sum(r["rungs"]) and r["rungs"][3] == 0,
232 f"aniso R/h={Rh}: R0 >= 99.9 %, no R2 ({r['rungs']})")
233 check(order(ea[16], ea[32]) >= 1.7, f"aniso Nu order {order(ea[16], ea[32]):.2f} >= 1.7 (rms {ea[16]:.3e} -> {ea[32]:.3e})")
234
235
236# ---------------------------------------------------------------------------------------- G2 ----
237def two_body_scene(c0, Ri, Ro):
238 """Instance 0: a sphere Ri. Instance 1: a huge box minus a sphere Ro (solid outside Ro)."""
239 ni = np.array([1, -1, -1, 3, -1, -1, 1, -1, -1, 34, 1, 2], dtype=np.int32)
240 nr = np.zeros(4 * 16)
241 for k in range(4):
242 nr[16 * k + 14] = 1.0 # rotation w
243 nr[16 * k + 15] = 1.0 # scale
244 nr[0] = Ri
245 nr[16 + 0] = nr[16 + 1] = nr[16 + 2] = 1e3
246 nr[32 + 0] = Ro
247 ii = np.array([0, -1, 3, -1], dtype=np.int32)
248 ir = np.zeros(2 * 18)
249 for k in range(2):
250 ir[18 * k:18 * k + 3] = c0
251 ir[18 * k + 6] = 1.0
252 ir[18 * k + 7] = 1.0
253 return ni, nr, ii, ir
254
255
256def mg_box(n, rungs=3):
257 """Ruling Q-C (WO-4): G-iter is gated on a multigrid-friendly box — n rounded UP to a multiple
258 of 2^(levels + 1) per axis, read as levels = the number of ladder rungs (3 -> 16)."""
259 m = 2 ** (rungs + 1)
260 return ((n + m - 1) // m) * m
261
262
263def g2_case(Rih, off, mgbox=False):
264 Ri, D, q, co = 1.0, 0.8, 0.3, 0.0
265 Ro = 2.5 * Ri
266 h = Ri / Rih
267 n = int(math.ceil(2.0 * Ro / h)) + 6
268 if mgbox: # the added cells lie outside Ro: solid (the record's identity rows)
269 n = mg_box(n)
270 L = n * h
271 s = walled((n, n, n), (L, L, L))
272 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
273 s.set_scene(*two_body_scene(c0, Ri, Ro), periodic=False)
274 s.set_solid_from_scene(cutcell_pressure=False)
275 add_cc(s, D, box="neumann")
276 s.set_scalar_wall("c", "neumann", q, instance=0)
277 s.set_scalar_wall("c", "dirichlet", co, instance=1)
278 s.solve_scalar_steady("c")
279 fac = s.diagnostics.scalar_facets("c")
280 inner = fac["instance"] == 0
281 cg = float(np.sum(fac["area"][inner] * fac["wall_value"][inner]) / np.sum(fac["area"][inner]))
282 exact_g = co + (q * Ri / D) * (1.0 - Ri / Ro)
283 X, Y, Z = grid(s)
284 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
285 l1, linf = field_errors(s, co + (q * Ri * Ri / D) * (1.0 / r - 1.0 / Ro))
286 flux = s.scalar_wall_flux("c")
287 it, conv, rungs = solve_info(s)
288 # the Neumann flux is imposed exactly on the DISCRETE area (q times the facet areas)
289 return dict(err=cg / exact_g - 1.0, l1=l1, linf=linf, it=it, conv=conv, rungs=rungs,
290 fin=float(flux[0]) / (q * float(np.sum(fac["area"][inner]))) - 1.0)
291
292
294 print("G2 concentric shells (steady), Neumann q inner, Dirichlet outer Ro = 2.5 Ri, Ri/h in {6, 12, 24}")
295 e, l1, its = {}, {}, {}
296 for Rih in (6, 12, 24):
297 rows = [g2_case(Rih, off) for off in OFFSETS]
298 e[Rih] = rms([r["err"] for r in rows])
299 l1[Rih] = rms([r["l1"] for r in rows])
300 its[Rih] = [r["it"] for r in rows]
301 print(f" Ri/h={Rih:3d} c_Gamma rel err {[format(r['err'], '+.3e') for r in rows]} L1 {l1[Rih]:.3e}"
302 f" inner flux/(q area)-1 {[format(r['fin'], '+.1e') for r in rows]} iters {[r['it'] for r in rows]}")
303 for r in rows:
304 check(r["conv"], f"Ri/h={Rih}: converged ({r['it']} iterations)")
305 check(r["rungs"][1] + r["rungs"][2] + r["rungs"][3] == 0, f"Ri/h={Rih}: R0 = 100 % ({r['rungs']})")
306 check(abs(r["fin"]) <= 1e-12, f"Ri/h={Rih}: inner flux = q x discrete area ({r['fin']:+.1e})")
307 for a, b in ((6, 12), (12, 24)):
308 check(order(e[a], e[b]) >= 1.7, f"c_Gamma order {a}->{b} {order(e[a], e[b]):.2f} >= 1.7 (rms {e[a]:.3e} -> {e[b]:.3e})")
309 check(order(l1[a], l1[b]) >= 1.8, f"field L1 order {a}->{b} {order(l1[a], l1[b]):.2f} >= 1.8 ({l1[a]:.3e} -> {l1[b]:.3e})")
310 check(abs(e[24]) <= 2e-3, f"|c_Gamma err| {e[24]:.2e} <= 2e-3 at Ri/h = 24 (prov.)")
311 print(" G-iter on multigrid-friendly boxes (ruling Q-C): n rounded up to a multiple of 16")
312 itm = {}
313 for Rih in (6, 12, 24):
314 rows = [g2_case(Rih, off, mgbox=True) for off in OFFSETS]
315 itm[Rih] = [r["it"] for r in rows]
316 print(f" Ri/h={Rih:3d} n={mg_box(int(math.ceil(5.0 * Rih)) + 6)} c_Gamma rel err "
317 f"{[format(r['err'], '+.3e') for r in rows]} iters {itm[Rih]}")
318 for r in rows:
319 check(r["conv"], f"Ri/h={Rih} (mg box): converged ({r['it']} iterations)")
320 print(f" (native boxes, correctness only: iters {[max(its[k]) for k in sorted(its)]})")
321 giter_check("G2 (mg box)", itm)
322
323
324# --------------------------------------------------------------------------------------- G3a ----
325def g3a_case(Rh, off, Da):
326 R, D = 1.0, 0.6
327 L = 4.0 * R
328 h = R / Rh
329 n = int(round(L / h))
330 s = walled((n, n, n), (L, L, L))
331 s.set_dt(0.37) # pins tRef != 1, so the Robin k goes through speedToInt
332 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
333 X, Y, Z = grid(s)
334 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
335 s.set_solid(np.asfortranarray(r - R))
336 add_cc(s, D, box="neumann")
337 A = Da / (1.0 + Da)
338 for f in FACES:
339 P = face_points(s, f)
340 rf = np.sqrt((P[0] - c0[0]) ** 2 + (P[1] - c0[1]) ** 2 + (P[2] - c0[2]) ** 2)
341 s.set_scalar_bc("c", f, "dirichlet", 1.0 - A * R / rf)
342 s.set_scalar_wall("c", "robin", 0.0, coefficient=Da * D / R)
343 s.solve_scalar_steady("c")
344 sh = -float(s.scalar_wall_flux("c")[0]) / (2.0 * math.pi * R * D)
345 it, conv, rungs = solve_info(s)
346 return dict(err=sh / (2.0 * A) - 1.0, it=it, conv=conv, rungs=rungs)
347
348
350 print("G3a Robin external sphere (steady), R/h in {8, 16, 32}")
351 for Da in (0.1, 1.0, 10.0, 100.0):
352 e, its = {}, {}
353 for Rh in (8, 16, 32):
354 rows = [g3a_case(Rh, off, Da) for off in OFFSETS]
355 e[Rh] = rms([r["err"] for r in rows])
356 its[Rh] = [r["it"] for r in rows]
357 print(f" Da={Da:6.1f} R/h={Rh:3d} Sh rel err {[format(r['err'], '+.3e') for r in rows]} iters {[r['it'] for r in rows]}")
358 for r in rows:
359 check(r["conv"], f"Da={Da} R/h={Rh}: converged ({r['it']} iterations)")
360 check(r["rungs"][1] + r["rungs"][2] + r["rungs"][3] == 0, f"Da={Da} R/h={Rh}: R0 = 100 %")
361 for a, b in ((8, 16), (16, 32)):
362 check(order(e[a], e[b]) >= 1.7, f"Da={Da}: Sh order {a}->{b} {order(e[a], e[b]):.2f} >= 1.7 (rms {e[a]:.3e} -> {e[b]:.3e})")
363 check(e[32] <= 3e-3, f"Da={Da}: |Sh err| {e[32]:.2e} <= 3e-3 at R/h = 32 (prov.)")
364 if Da == 1.0:
365 giter_check("G3a Da=1", its)
366
367
368# --------------------------------------------------------------------------------------- G3b ----
370 if math.isinf(Bi):
371 return math.pi
372 f = lambda l: l * math.cos(l) - (1.0 - Bi) * math.sin(l) # lam cot lam = 1 - Bi
373 a, b = 1e-9, math.pi - 1e-12
374 for _ in range(200):
375 m = 0.5 * (a + b)
376 if f(a) * f(m) <= 0:
377 b = m
378 else:
379 a = m
380 return 0.5 * (a + b)
381
382
383def g3b_case(Rh, off, Bi, nsteps=30):
384 R, D = 1.0, 0.9
385 h = R / Rh
386 n = 2 * Rh + 6
387 L = n * h
388 s = walled((n, n, n), (L, L, L))
389 lam = robin_root(Bi)
390 mu = D * lam * lam / (R * R)
391 dt = 1.0 / mu
392 s.set_dt(dt)
393 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
394 X, Y, Z = grid(s)
395 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
396 s.set_solid(np.asfortranarray(R - r)) # the fluid is INSIDE the sphere
397 add_cc(s, D, box="neumann")
398 if math.isinf(Bi):
399 s.set_scalar_wall("c", "dirichlet", 0.0)
400 else:
401 s.set_scalar_wall("c", "robin", 0.0, coefficient=Bi * D / R)
402 x = lam * r / R
403 s.set_field("c", np.asfortranarray(np.where(x > 1e-12, np.sin(x) / np.where(x > 1e-12, x, 1.0), 1.0)))
404 masses, idmax, its = [], 0.0, []
405 for k in range(nsteps):
406 s.advance_scalars()
407 b = s.diagnostics.scalar_budget("c")
408 masses.append(b["mass"])
409 scale = max(abs(b["d_mass"]), abs(b["mass"]) * 1e-300)
410 idmax = max(idmax, abs(b["identity_error"]) / scale)
411 it, conv, rungs = solve_info(s)
412 its.append(it)
413 if not conv:
414 return dict(err=float("nan"), idrel=idmax, its=its, conv=False, rungs=rungs)
415 mu_h = (masses[-2] / masses[-1] - 1.0) / dt
416 return dict(err=mu_h / mu - 1.0, idrel=idmax, its=its, conv=True, rungs=rungs)
417
418
420 print("G3b Robin interior sphere (transient, BE late-time ratio, dt mu = 1, 30 steps), R/h in {8, 16, 32}")
421 for Bi in (0.1, 1.0, 10.0, 100.0, math.inf):
422 e = {}
423 for Rh in (8, 16, 32):
424 t0 = time.time()
425 rows = [g3b_case(Rh, off, Bi) for off in OFFSETS]
426 e[Rh] = rms([r["err"] for r in rows])
427 print(f" Bi={Bi:6.1f} R/h={Rh:3d} mu rel err {[format(r['err'], '+.3e') for r in rows]}"
428 f" identity/|dM| <= {max(r['idrel'] for r in rows):.1e}"
429 f" iters/step {min(min(r['its']) for r in rows)}..{max(max(r['its']) for r in rows)}"
430 f" ({time.time() - t0:.0f} s)")
431 for r in rows:
432 check(r["conv"], f"Bi={Bi} R/h={Rh}: every step converged")
433 check(r["idrel"] <= 1e-13, f"Bi={Bi} R/h={Rh}: budget identity {r['idrel']:.1e} <= 1e-13 |d_mass|")
434 check(r["rungs"][1] + r["rungs"][2] + r["rungs"][3] == 0, f"Bi={Bi} R/h={Rh}: R0 = 100 %")
435 o1, o2 = order(e[8], e[16]), order(e[16], e[32])
436 check(o1 >= 1.7 and o2 >= 1.7, f"Bi={Bi}: orders {o1:.2f}, {o2:.2f} >= 1.7")
437 check(e[32] <= 2e-3, f"Bi={Bi}: |err| {e[32]:.2e} <= 2e-3 at R/h = 32 (prov.)")
438
439
440# ---------------------------------------------------------------------------------------- G7 ----
441J01_SQ = 5.783185962946784
442J11P_SQ = 3.389957
443GRAETZ = 3.656793
444
445
446def pipe(Rh, off, D):
447 R = 1.0
448 h = R / Rh
449 n = 2 * Rh + 6
450 L = n * h
451 s = walled((n, n, 4), (L, L, 4 * h), periodic_z=True)
452 c0 = np.array([0.5 * L, 0.5 * L]) + np.asarray(off[:2]) * h
453 X, Y, Z = grid(s)
454 rr = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2)
455 s.set_solid(np.asfortranarray(R - rr))
456 add_cc(s, D, box="neumann", periodic_z=True)
457 return s, X - c0[0], Y - c0[1], rr
458
459
460def j0(x):
461 # series, |x| <= 2.5 here
462 t, s, k = 1.0, 1.0, 0
463 while abs(t) > 1e-17:
464 k += 1
465 t *= -(x * x / 4.0) / (k * k)
466 s += t
467 return s
468
469
470def j1(x):
471 t = x / 2.0
472 s, k = t, 0
473 while abs(t) > 1e-17:
474 k += 1
475 t *= -(x * x / 4.0) / (k * (k + 1))
476 s += t
477 return s
478
479
480def g7a_case(Rh, off, neumann, nsteps=30):
481 D = 0.5
482 s, dx, dy, rr = pipe(Rh, off, D)
483 eig = J11P_SQ if neumann else J01_SQ
484 mu = D * eig
485 dt = 1.0 / mu
486 s.set_dt(dt)
487 s.set_scalar_wall("c", "neumann" if neumann else "dirichlet", 0.0)
488 geo = s.diagnostics.scalar_geometry("c")
489 kap = geo["kappa"] * (geo["unknown"] > 0.5)
490 if neumann:
491 jv = np.vectorize(j1)(math.sqrt(J11P_SQ) * rr)
492 c = jv * np.where(rr > 0, dx / np.where(rr > 0, rr, 1.0), 0.0)
493 c -= np.sum(kap * c) / np.sum(kap)
494 mom = dx
495 else:
496 c = np.vectorize(j0)(math.sqrt(J01_SQ) * rr)
497 mom = np.ones_like(dx)
498 s.set_field("c", np.asfortranarray(c))
499 vals, its = [], []
500 for k in range(nsteps):
501 s.advance_scalars()
502 vals.append(float(np.sum(kap * mom * s.get_field("c"))))
503 it, conv, rungs = solve_info(s)
504 its.append(it)
505 mu_h = (vals[-2] / vals[-1] - 1.0) / dt
506 return dict(err=mu_h / mu - 1.0, its=its, conv=conv, rungs=rungs)
507
508
509def g7b_case(Rh, off, iters=25):
510 D = 0.5
511 s, dx, dy, rr = pipe(Rh, off, D)
512 s.set_scalar_wall("c", "dirichlet", 0.0)
513 uz = np.maximum(2.0 * (1.0 - rr * rr), 0.0) # Poiseuille, mean U = 1, R = 1
514 geo = s.diagnostics.scalar_geometry("c")
515 kap = geo["kappa"] * (geo["unknown"] > 0.5)
516 v = np.maximum(1.0 - rr * rr, 0.0)
517 beta, its = 0.0, []
518 for k in range(iters):
519 s.set_scalar_source("c", np.asfortranarray(uz * v))
520 s.set_field("c", np.asfortranarray(v))
521 s.solve_scalar_steady("c")
522 w = s.get_field("c")
523 it, conv, rungs = solve_info(s)
524 its.append(it)
525 bnew = float(np.sum(kap * uz * v * v) / np.sum(kap * uz * v * w))
526 nrm = math.sqrt(float(np.sum(kap * w * w)))
527 v = w / nrm
528 if k > 3 and abs(bnew - beta) <= 1e-12 * abs(bnew):
529 beta = bnew
530 break
531 beta = bnew
532 nu = beta * 1.0 * 1.0 / D # Nu_T = beta U R^2 / D
533 return dict(err=nu / GRAETZ - 1.0, its=its, conv=conv, rungs=rungs)
534
535
537 print("G7a pipe decay (BE ratio, dt mu = 1, 30 steps), R/h in {16, 32, 64}")
538 for neumann in (False, True):
539 e = {}
540 for Rh in (16, 32, 64):
541 rows = [g7a_case(Rh, off, neumann) for off in OFFSETS]
542 e[Rh] = rms([r["err"] for r in rows])
543 print(f" {'neumann j11p^2' if neumann else 'dirichlet j01^2'} R/h={Rh:3d} rel err "
544 f"{[format(r['err'], '+.3e') for r in rows]} iters/step {min(min(r['its']) for r in rows)}..{max(max(r['its']) for r in rows)}")
545 for r in rows:
546 check(r["conv"], f"R/h={Rh}: converged")
547 check(r["rungs"][1] + r["rungs"][2] + r["rungs"][3] == 0, f"R/h={Rh}: R0 = 100 %")
548 tag = "neumann" if neumann else "dirichlet"
549 for a, b in ((16, 32), (32, 64)):
550 check(order(e[a], e[b]) >= 1.8, f"{tag}: order {a}->{b} {order(e[a], e[b]):.2f} >= 1.8 (rms {e[a]:.3e} -> {e[b]:.3e})")
551 check(e[32] <= 5e-4, f"{tag}: |err| {e[32]:.2e} <= 5e-4 at R/h = 32")
552 print("G7b Graetz Nu_T (inverse iteration with solve_scalar_steady), R/h in {16, 32, 64}")
553 e = {}
554 for Rh in (16, 32, 64):
555 rows = [g7b_case(Rh, off) for off in OFFSETS]
556 e[Rh] = rms([r["err"] for r in rows])
557 print(f" R/h={Rh:3d} Nu_T rel err {[format(r['err'], '+.3e') for r in rows]} iters/solve "
558 f"{min(min(r['its']) for r in rows)}..{max(max(r['its']) for r in rows)}")
559 for r in rows:
560 check(r["conv"], f"R/h={Rh}: converged")
561 for a, b in ((16, 32), (32, 64)):
562 check(order(e[a], e[b]) >= 1.7, f"Graetz order {a}->{b} {order(e[a], e[b]):.2f} >= 1.7 (rms {e[a]:.3e} -> {e[b]:.3e})")
563 check(e[32] <= 1e-3, f"Graetz |err| {e[32]:.2e} <= 1e-3 at R/h = 32 (prov.)")
564
565
566# ------------------------------------------------------------------------------------- G-iter ----
567def giter_transient_case(Rh, off, nsteps=6):
568 """G1's sphere, transient at dt D/h^2 = 1 from c = 0 (Dirichlet wall c = 1, box c = 0)."""
569 R, D = 1.0, 0.7
570 L = 4.0 * R
571 h = R / Rh
572 n = int(round(L / h))
573 s = walled((n, n, n), (L, L, L))
574 s.set_dt(h * h / D)
575 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
576 X, Y, Z = grid(s)
577 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
578 s.set_solid(np.asfortranarray(r - R))
579 add_cc(s, D, box="dirichlet")
580 s.set_scalar_wall("c", "dirichlet", 1.0)
581 its, lv = [], []
582 for k in range(nsteps):
583 s.advance_scalars()
584 c = s.diagnostics.scalar_census("c")
585 its.append(c["krylov_iterations"])
586 lv.append(c["mg_levels"])
587 return its, lv
588
589
591 """G5b's geometry (periodic simple-cubic array, c = 0.3) on an n^3 grid, insulating walls with a
592 flux and a source, steady: the singular case of §5.1 (same surrogate as G5b's closure)."""
593 L = 1.0
594 h = L / n
595 s = pf.Solver((n, n, n), extent=(L, L, L))
596 s.set_rho(1.0)
597 s.set_mu(1.0)
598 R = 0.5 * L * (0.3 * 6.0 / math.pi) ** (1.0 / 3.0)
599 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
600 X, Y, Z = grid(s)
601 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
602 s.set_solid(np.asfortranarray(r - R))
603 s.add_scalar("c", diffusivity=0.6, cutcell=True)
604 s.set_scalar_wall("c", "neumann", 0.2)
605 s.set_scalar_source("c", -0.5)
606 s.solve_scalar_steady("c")
607 c = s.diagnostics.scalar_census("c")
608 return c["krylov_iterations"], c["krylov_converged"], c["mg_levels"], 2.0 * R / h
609
610
612 print("G-iter transient: G1's sphere at dt D/h^2 = 1, 6 steps from c = 0, R/h in {8, 16, 32}")
613 for Rh in (8, 16, 32):
614 rows = [giter_transient_case(Rh, off) for off in OFFSETS]
615 print(f" R/h={Rh:3d} iterations/step {[its for its, _ in rows]} mg levels {rows[0][1][0]}")
616 # rulings D-WO4-3 (the provisional <= 8 restated as <= 10) and D-WO9-3 (cold first step <= 12)
617 cold = max(its[0] for its, _ in rows)
618 warm = max(max(its[1:]) for its, _ in rows)
619 check(cold <= 12, f"R/h={Rh}: <= 12 iterations on the cold first step ({cold})")
620 check(warm <= 10, f"R/h={Rh}: <= 10 iterations per warm step ({warm})")
621 print("G-iter singular steady: G5b's geometry (periodic SC array, c = 0.3), n in {32, 48, 80}"
622 " (n = 20/40/80 rounded up to multiples of 16, ruling Q-C)")
623 mx = []
624 for n in (mg_box(20), mg_box(40), mg_box(80)):
625 rows = [g5b_geometry_case(n, off) for off in OFFSETS]
626 mx.append(max(r[0] for r in rows))
627 print(f" n={n:3d} (ND {rows[0][3]:.1f}) iterations {[r[0] for r in rows]} mg levels {rows[0][2]}")
628 for r in rows:
629 check(r[1], f"n={n}: converged ({r[0]} iterations)")
630 check(max(mx) <= 30, f"singular: <= 30 iterations at every rung ({mx})")
631 gr = [b - a for a, b in zip(mx, mx[1:])]
632 check(all(g <= 5 for g in gr), f"singular: growth <= 5 per doubling ({gr})")
633
634
635# ---------------------------------------------------------------------------------------- G9 ----
636R_I, R_O = 0.4, 1.0
637
638
639def psi_node(X, Y):
640 """Stream function of solid-body rotation (Omega = 1, u = d psi/dy, v = -d psi/dx), clamped to
641 its wall values inside the solids."""
642 r = np.clip(np.hypot(X, Y), R_I, R_O)
643 return -0.5 * r * r
644
645
646def annulus_case(Rh, off, scheme="koren"):
647 """Coaxial cylinders R_i = 0.4 < r < R_o = 1, an SDF along z, nz = 4, every face periodic."""
648 h = R_O / Rh
649 n = 2 * Rh + 4
650 L = n * h
651 s = pf.Solver((n, n, 4), extent=(L, L, 4 * h), origin=(-0.5 * L + off[0] * h,
652 -0.5 * L + off[1] * h, 0.0))
653 s.set_rho(1.0)
654 s.set_mu(1.0)
655 X, Y, Z = grid(s)
656 r = np.hypot(X, Y)
657 s.set_solid(np.asfortranarray(np.minimum(r - R_I, R_O - r)), cutcell_pressure=True)
658 s.add_scalar("c", diffusivity=0.0, scheme=scheme, cutcell=True)
659 return s, h
660
661
663 """Physical F/V through the low x and y faces of every cell: the differences of a NODE
664 potential (round 6's exact stream-function fluxes), so every cell's net flux telescopes to zero.
665 The potential is the clamped psi, set to its wall value on both nodes of every face the
666 projection closes (openness 0): the field is then discretely divergence-free in the openness
667 the predicate reads (the solver's gated cut-cell openness), as a projected field is."""
668 x, y, _ = s.cell_centers()
669 xl, yl = np.asarray(x) - 0.5 * h, np.asarray(y) - 0.5 * h
670 Xn, Yn = np.meshgrid(xl, yl, indexing="ij") # node (x_lo, y_lo) of cell (i, j)
671 P = psi_node(Xn, Yn)
672 rn = np.hypot(Xn, Yn)
673 wall = np.where(rn < 0.5 * (R_I + R_O), -0.5 * R_I * R_I, -0.5 * R_O * R_O)
674 shut_x = s.get_ox_proj()[:, :, 0] <= 0.0 # x face (i, j): nodes (i, j), (i, j + 1)
675 shut_y = s.get_oy_proj()[:, :, 0] <= 0.0 # y face (i, j): nodes (i, j), (i + 1, j)
676 mark = shut_x | np.roll(shut_x, 1, axis=1) | shut_y | np.roll(shut_y, 1, axis=0)
677 P = np.where(mark, wall, P)
678 fx = (np.roll(P, -1, axis=1) - P) / (h * h) # int u dy over the low x face, per volume
679 fy = -(np.roll(P, -1, axis=0) - P) / (h * h)
680 return fx[:, :, None] * np.ones(4), fy[:, :, None] * np.ones(4)
681
682
683def set_annulus_velocity(s, fx, fy):
684 """Face velocities (internal index velocity) with open * vel = F/V * tRef, the openness the
685 projection conserves; returns max |F| on faces whose openness is 0 (lost) over max |F|."""
686 tref = s.unit_scales["t_ref"]
687 ox, oy = s.get_ox_proj(), s.get_oy_proj()
688 vx = np.where(ox > 0.0, fx * tref / np.where(ox > 0.0, ox, 1.0), 0.0)
689 vy = np.where(oy > 0.0, fy * tref / np.where(oy > 0.0, oy, 1.0), 0.0)
690 s.set_field("u", np.asfortranarray(vx))
691 s.set_field("v", np.asfortranarray(vy))
692 s.set_field("w", np.asfortranarray(np.zeros_like(vx)))
693 lost = max(float(np.max(np.abs(np.where(ox > 0.0, 0.0, fx)))),
694 float(np.max(np.abs(np.where(oy > 0.0, 0.0, fy)))))
695 return lost / max(float(np.max(np.abs(fx))), float(np.max(np.abs(fy))))
696
697
698def g9_case(Rh, off, scheme, cr, rtol=None, nsteps=None):
699 s, h = annulus_case(Rh, off, scheme)
700 geo = s.diagnostics.scalar_geometry("c")
701 unk = geo["unknown"] > 0.5
702 kap = geo["kappa"]
703 ax, ay, az = geo["aperture_x"], geo["aperture_y"], geo["aperture_z"]
704 full = unk & (kap >= 1.0)
705 for a, ap in ((0, ax), (1, ay), (2, az)):
706 full &= (ap >= 1.0) & (np.roll(ap, -1, axis=a) >= 1.0)
707 fx, fy = annulus_fluxes(s, h)
708 out = (np.maximum(np.roll(fx, -1, axis=0), 0) + np.maximum(-fx, 0) +
709 np.maximum(np.roll(fy, -1, axis=1), 0) + np.maximum(-fy, 0))
710 T = 2.0 * math.pi
711 nrev = int(math.ceil(T * float(np.max(out[full])) / cr))
712 dt = T / nrev
713 s.set_dt(dt)
714 lost = set_annulus_velocity(s, fx, fy)
715 X, Y, _ = grid(s)
716 c0 = np.where(unk, np.exp(-((X - 0.7) ** 2 + Y * Y) / (2 * 0.08**2)), 0.0)
717 s.set_field("c", np.asfortranarray(c0))
718 if rtol is not None:
719 s.set_scalar_tolerance("c", rtol)
720 m0 = float(np.sum(kap * c0) * h**3)
721 cmax0 = float(c0.max())
722 st = dict(min=0.0, max=cmax0, ident=0.0, defects=0.0, finite=True, small=0, impl=0.0,
723 cb=0.0, its=0, lost=lost, minkap=float(kap[unk].min()), steps=0)
724 nst = nrev if nsteps is None else nsteps
725 for k in range(nst):
726 s.advance_scalars()
727 b = s.diagnostics.scalar_budget("c")
728 st["ident"] = max(st["ident"], abs(b["identity_error"]) / m0)
729 st["defects"] += b["defect"]
730 c = s.get_field("c")[unk]
731 st["finite"] = st["finite"] and bool(np.all(np.isfinite(c)))
732 st["min"] = min(st["min"], float(c.min()))
733 st["max"] = max(st["max"], float(c.max()))
734 cen = s.diagnostics.scalar_census("c")
735 st["small"] = max(st["small"], cen["num_small_cells"])
736 st["impl"] = max(st["impl"], cen["num_implicit_faces"] / max(1, cen["num_flux_faces"]))
737 st["cb"] = max(st["cb"], cen["bulk_courant"])
738 st["its"] = max(st["its"], cen["krylov_iterations"])
739 st["guarded"] = cen["num_guarded_flux_faces"]
740 st["steps"] += 1
741 c = s.get_field("c")
742 m1 = float(np.sum(kap * c) * h**3)
743 st["m0"], st["m1"], st["cmax0"] = m0, m1, cmax0
744 st["cons"] = abs(m1 - m0 + st["defects"]) / m0 # d_mass = -defect per step (scalar_budget)
745 st["drift"] = abs(m1 - m0) / m0
746 err = np.where(unk, kap * np.abs(c - c0), 0.0)
747 st["L1"] = float(err.sum() / np.sum(kap * c0))
748 cut = unk & (kap < 1.0)
749 band = cut.copy()
750 for a in (0, 1):
751 band |= np.roll(cut, 1, axis=a) | np.roll(cut, -1, axis=a)
752 band &= unk
753 st["cutshare"] = float(err[cut].sum() / err.sum())
754 st["bandshare"] = float(err[band].sum() / err.sum())
755 return st
756
757
758def g9_offsets(Rh, want=3):
759 """Grid offsets from a fixed sequence whose geometry carries a sliver (min kappa < 1e-2, gate
760 (v)); the first `want` that do."""
761 rng = np.random.default_rng(9000 + Rh)
762 found = []
763 for _ in range(40):
764 off = rng.uniform(-0.5, 0.5, size=2)
765 s, _h = annulus_case(Rh, off)
766 geo = s.diagnostics.scalar_geometry("c")
767 if float(geo["kappa"][geo["unknown"] > 0.5].min()) < 1e-2:
768 found.append(off)
769 if len(found) == want:
770 break
771 return found
772
773
774def koren_bulk_control(cr, n=32):
775 """No solid: a periodic box, uniform translation (1, 0.5) for one x-period, Koren at bulk Courant
776 cr. Returns (min c, C_bulk) — the bulk scheme's own boundedness, for G9 (iii)'s reading."""
777 s = pf.Solver((n, n, 4), extent=(1.0, 1.0, 4.0 / n))
778 s.set_rho(1.0)
779 s.set_mu(1.0)
780 s.set_pressure_geometry(np.asfortranarray(np.ones((n, n, 4))))
781 s.add_scalar("c", diffusivity=0.0, scheme="koren", cutcell=True)
782 h, U = 1.0 / n, (1.0, 0.5)
783 dt = cr * h / (U[0] + U[1])
784 s.set_dt(dt)
785 tr = s.unit_scales["t_ref"]
786 s.set_field("u", np.asfortranarray(np.full((n, n, 4), U[0] * tr / h)))
787 s.set_field("v", np.asfortranarray(np.full((n, n, 4), U[1] * tr / h)))
788 X, Y, _ = grid(s)
789 s.set_field("c", np.asfortranarray(np.exp(-((X - 0.5) ** 2 + (Y - 0.5) ** 2) / (2 * 0.08**2))))
790 mn = 0.0
791 for _ in range(int(round(1.0 / (U[0] * dt)))):
792 s.advance_scalars()
793 mn = min(mn, float(s.get_field("c").min()))
794 return mn, s.diagnostics.scalar_census("c")["bulk_courant"]
795
796
798 print("G9: advection in an annulus (R_i 0.4, R_o 1, nz 4), solid-body rotation, a Gaussian blob "
799 "carried one revolution, D = 0 (round 6's set-up)")
800 for cr in (0.5, 0.9):
801 mn, cb = koren_bulk_control(cr)
802 print(f" control, NO solid, uniform translation, koren at C_bulk {cb:.3f}: min c = {mn:.2e} "
803 f"(max c0 = 1)")
804 for Rh in (16, 32, 64):
805 off = g9_offsets(Rh, 1)
806 check(len(off) == 1, f"R_o/h={Rh}: an offset with a sliver (min kappa < 1e-2) exists")
807 off = off[0]
808 for scheme in ("fou", "koren"):
809 for cr in (0.5, 0.9):
810 t0 = time.time()
811 st = g9_case(Rh, off, scheme, cr)
812 tag = f"R_o/h={Rh} {scheme:5s} C={cr}"
813 print(f" {tag}: {st['steps']} steps, C_bulk {st['cb']:.3f}, small cells <= {st['small']}, "
814 f"implicit faces <= {100 * st['impl']:.2f} %, BiCGStab <= {st['its']}, min kappa "
815 f"{st['minkap']:.1e}, lost flux {st['lost']:.1e}, guarded faces {st['guarded']}, "
816 f"L1 {st['L1']:.3e} (cut cells {100 * st['cutshare']:.1f} %, cut band "
817 f"{100 * st['bandshare']:.1f} %), min {st['min']:.2e}, max {st['max']:.6f} "
818 f"[{time.time() - t0:.0f} s]")
819 check(st["ident"] <= 1e-13, f"{tag}: (i) budget identity per step {st['ident']:.1e} M0 <= 1e-13")
820 check(st["cons"] <= 1e-12, f"{tag}: (ii) |M_end - M0 + sum defect| = {st['cons']:.1e} M0 <= 1e-12")
821 if scheme == "fou":
822 check(st["min"] >= -1e-12 * st["cmax0"], f"{tag}: (iii) min c {st['min']:.1e} >= -1e-12 max c0")
823 elif cr <= 0.5: # ruling D-WO5-1: koren's (iii) is gated at bulk Courant <= 1/2
824 check(st["min"] >= -1e-3 * st["cmax0"] and st["max"] <= (1 + 1e-3) * st["cmax0"],
825 f"{tag}: (iii) min c {st['min']:.1e} >= -1e-3, max c {st['max']:.6f} <= 1.001 max c0")
826 else: # information only (D-WO5-1): forward Euler + the legacy Koren reconstruction is
827 # TVD only to C <= 1/2 — it fails in the bulk with no solid at all
828 # (koren_bulk_control above), so no small-cell treatment can meet it.
829 print(f" INFO {tag}: (iii) not gated above bulk Courant 1/2 (D-WO5-1): min c "
830 f"{st['min']:.1e}, max c {st['max']:.6f} (max c0 {st['cmax0']:.6f})")
831 check(st["finite"], f"{tag}: (iv) finite throughout")
832 check(st["minkap"] < 1e-2, f"{tag}: (v) min kappa {st['minkap']:.1e} < 1e-2")
833 check(abs(st["cb"] - cr) <= 0.02 * cr, f"{tag}: bulk Courant {st['cb']:.3f} ~ {cr}")
834 st = g9_case(Rh, off, "fou", 0.9, rtol=1e-13)
835 print(f" R_o/h={Rh} fou C=0.9 rtol 1e-13: |dM|/M0 = {st['drift']:.1e}, "
836 f"|dM + sum defect| = {st['cons']:.1e} M0")
837 check(st["drift"] <= 1e-8, f"R_o/h={Rh}: (ii) sanity: raw drift {st['drift']:.1e} <= 1e-8 at rtol 1e-13")
838
839
840# --------------------------------------------------------------------------------------- G9b ----
841def g9b_case(kind, scheme, n=32, nsteps=50):
842 cls = pf.Solver if kind == "staggered" else pf.SolverColocated
843 s = cls((n, n, n), extent=(1.0, 1.0, 1.0))
844 s.set_rho(1.0)
845 s.set_mu(1.0)
846 s.set_dt(0.01)
847 if scheme == "staggered-ghost":
848 s.diagnostics.set_ghost_projection(True)
849 elif scheme is not None:
850 s.set_collocated_scheme(scheme)
851 R = (0.3 * 3.0 / (4.0 * math.pi)) ** (1.0 / 3.0) # SC array, solid fraction 0.3
852 X, Y, Z = grid(s)
853 s.set_solid(np.asfortranarray(np.sqrt((X - 0.513) ** 2 + (Y - 0.479) ** 2 + (Z - 0.507) ** 2) - R),
854 cutcell_pressure=True)
855 s.set_body_force((30.0, 9.0, 0.0)) # Stokes (advection off): bulk Courant ~0.47 at step 50
856 s.add_scalar("c", diffusivity=0.0, cutcell=True)
857 unk = s.diagnostics.scalar_geometry("c")["unknown"] > 0.5
858 s.set_field("c", np.asfortranarray(np.where(unk, 1.0, 0.0)))
859 dev = 0.0
860 for _ in range(nsteps):
861 s.step()
862 dev = max(dev, float(np.max(np.abs(s.get_field("c")[unk] - 1.0))))
863 return dev, s.diagnostics.scalar_census("c")
864
865
867 print("G9b: constant preservation under flow's own projection (Stokes through an SC sphere "
868 "array, c = 1, 50 steps, D = 0)")
869 for kind, scheme in (("staggered", None), ("collocated", "gauge-exact"), ("collocated", "plain"),
870 ("collocated", "embed")):
871 dev, cen = g9b_case(kind, scheme)
872 tag = f"{kind} {scheme or ''}".strip()
873 print(f" {tag}: max|c - 1| = {dev:.2e}; C_bulk {cen['bulk_courant']:.3f}, flux faces "
874 f"{cen['num_flux_faces']}, implicit {cen['num_implicit_faces']}, guarded "
875 f"{cen['num_guarded_flux_faces']}")
876 check(dev <= 1e-6, f"{tag}: max|c - 1| = {dev:.1e} <= 1e-6")
877 check(cen["bulk_courant"] > 0.3, f"{tag}: the flow advects (C_bulk {cen['bulk_courant']:.3f})")
878 for kind, scheme in (("collocated", "ghost"), ("staggered", "staggered-ghost")):
879 raises(RuntimeError, lambda k=kind, sc=scheme: g9b_case(k, sc, nsteps=1),
880 f"{kind} {scheme}: refused for a cut-cell scalar (Q13: no divergence-free face flux)")
881
882
883# --------------------------------------------------------------------------------------- G9c ----
884def channel_case(kind="staggered", n=24, dt=0.008, D=0.01, scheme="koren", out_bc="neumann",
885 c0=0.2, cin=1.0, cap=True):
886 """A 2 x 1 x 1 channel (2n x n x n cells): inflow U = 1 on -x, outflow on +x, no-slip walls on y
887 and z, mu 0.05; a solid sphere (R 0.23) off the axis and, with `cap`, a second one (R 0.2)
888 centred just outside the outlet, cutting the outflow face (slivers in the outlet column). The
889 cut-cell scalar: Dirichlet `cin` on the inflow face, `out_bc` on the outflow face (Dirichlet
890 0.5), Neumann on the walls and the solids; `c0` in the fluid."""
891 cls = pf.Solver if kind == "staggered" else pf.SolverColocated
892 s = cls((2 * n, n, n), extent=(2.0, 1.0, 1.0))
893 if kind != "staggered":
894 s.set_collocated_scheme("gauge-exact") # not the refused 'ghost' (D-WO5-4)
895 s.set_rho(1.0)
896 s.set_mu(0.05)
897 s.set_dt(dt)
898 s.set_domain_bc("-x", "inflow", velocity=(1.0, 0.0, 0.0))
899 s.set_domain_bc("+x", "outflow")
900 for f in FACES[2:]:
901 s.set_domain_bc(f, "wall")
902 X, Y, Z = grid(s)
903 sdf = np.sqrt((X - 0.71) ** 2 + (Y - 0.48) ** 2 + (Z - 0.53) ** 2) - 0.23
904 if cap:
905 sdf = np.minimum(sdf, np.sqrt((X - 2.07) ** 2 + (Y - 0.31) ** 2 + (Z - 0.64) ** 2) - 0.2)
906 s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
907 s.add_scalar("c", diffusivity=D, scheme=scheme, cutcell=True)
908 s.set_scalar_bc("c", "-x", "dirichlet", cin)
909 if out_bc == "dirichlet":
910 s.set_scalar_bc("c", "+x", "dirichlet", 0.5)
911 else:
912 s.set_scalar_bc("c", "+x", "neumann")
913 for f in FACES[2:]:
914 s.set_scalar_bc("c", f, "neumann")
915 geo = s.diagnostics.scalar_geometry("c")
916 unk = geo["unknown"] > 0.5
917 s.set_field("c", np.asfortranarray(np.where(unk, c0, 0.0)))
918 return s, unk, geo["kappa"], dt, (1.0 / n) ** 3
919
920
921def g9c_run(nsteps, **kw):
922 """Step the channel; per step the budget identity with the mass change summed exactly
923 (math.fsum of kappa V (c - c^n) over the unknowns: the budget's own d_mass differences two
924 O(M) reductions whose round-off, ~1e-14 M, is ~1e-12 of a step's change in a long channel),
925 and the run's conservation, bounds and census."""
926 s, unk, kap, dt, V = channel_case(**kw)
927 cp = s.get_field("c")
928 m0 = V * math.fsum((kap * cp)[unk].ravel())
929 st = dict(ident=0.0, internal=0.0, flows=0.0, defects=0.0, min=float(cp[unk].min()),
930 max=float(cp[unk].max()), finite=True, small=0, impl=0, cb=0.0, its=0, steps=0)
931 for _ in range(nsteps):
932 s.step()
933 b = s.diagnostics.scalar_budget("c")
934 c = s.get_field("c")
935 dm = V * math.fsum((kap * (c - cp))[unk].ravel())
936 flows = dt * (b["wall_in"] + b["boundary_in"] + b["source_in"])
937 if dm != 0.0: # (the constant run changes nothing)
938 st["ident"] = max(st["ident"], abs(dm - flows + b["defect"]) / abs(dm))
939 st["internal"] = max(st["internal"], abs(b["identity_error"]) / b["mass"])
940 st["flows"] += flows
941 st["defects"] += b["defect"]
942 cu = c[unk]
943 st["finite"] = st["finite"] and bool(np.all(np.isfinite(cu)))
944 st["min"] = min(st["min"], float(cu.min()))
945 st["max"] = max(st["max"], float(cu.max()))
946 cen = s.diagnostics.scalar_census("c")
947 st["small"] = max(st["small"], cen["num_small_cells"])
948 st["impl"] = max(st["impl"], cen["num_implicit_faces"])
949 st["cb"] = max(st["cb"], cen["bulk_courant"])
950 st["its"] = max(st["its"], cen["krylov_iterations"])
951 st["steps"] += 1
952 cp = c
953 m1 = V * math.fsum((kap * cp)[unk].ravel())
954 st["cons"] = abs(m1 - m0 - st["flows"] + st["defects"]) / m0
955 last = unk[-1]
956 st["outlet"] = float(np.sum((kap * cp)[-1][last]) / np.sum(kap[-1][last]))
957 st["s"] = s
958 return st
959
960
962 print("G9c: open domain faces (WO-5b, D-WO5-3): a 2 x 1 x 1 channel, inflow U = 1 / no-slip walls "
963 "/ outflow, a sphere + a cap cutting the outlet, the scalar on flow's own projection")
964 # (tag, settings, steps (t ~ 2, one flow-through, for the bounded rows), bounded, small cells)
965 rows = (("koren C<=1/2", dict(scheme="koren", dt=0.007), 286, True, False),
966 ("fou C~0.9", dict(scheme="fou", dt=0.0135), 148, True, True),
967 ("koren, Dirichlet outlet", dict(scheme="koren", dt=0.007, out_bc="dirichlet"), 40, False,
968 False))
969 for tag, kw, nsteps, bounded, small in rows:
970 t0 = time.time()
971 st = g9c_run(nsteps, **kw)
972 print(f" {tag}: {st['steps']} steps, C_bulk <= {st['cb']:.3f}, small cells <= {st['small']}, "
973 f"implicit faces <= {st['impl']}, BiCGStab <= {st['its']}; identity <= {st['ident']:.1e} "
974 f"|dM| (internal identity_error <= {st['internal']:.1e} M), run conservation "
975 f"{st['cons']:.1e} M0; c - [0.2, 1] in [{st['min'] - 0.2:.1e}, {st['max'] - 1.0:.1e}]; outlet mean "
976 f"{st['outlet']:.3f} [{time.time() - t0:.0f} s]")
977 check(st["ident"] <= 1e-13, f"{tag}: per-step budget identity {st['ident']:.1e} |dM| <= 1e-13")
978 check(st["internal"] <= 1e-12, f"{tag}: identity_error {st['internal']:.1e} M <= 1e-12 (sanity)")
979 check(st["cons"] <= 1e-12, f"{tag}: |M_end - M0 - sum dt flows + sum defects| {st['cons']:.1e} M0 <= 1e-12")
980 check(st["finite"], f"{tag}: finite throughout")
981 if small:
982 check(st["small"] > 0 and st["impl"] > 0, f"{tag}: small cells and implicit faces occur")
983 if bounded: # fou: to the solve's tolerance (rtol 1e-10, implicit diffusion); koren: G9's 1e-3
984 tol = 1e-10 if kw["scheme"] == "fou" else 1e-3
985 check(st["min"] >= 0.2 - tol and st["max"] <= 1.0 + tol,
986 f"{tag}: bounded by the data, c in [{st['min']:.6f}, {st['max']:.6f}] within {tol:.0e}")
987 check(st["outlet"] > 0.3, f"{tag}: the front reached the outlet (mean {st['outlet']:.3f})")
988 if kw["scheme"] == "koren":
989 check(st["cb"] <= 0.5, f"{tag}: bulk Courant {st['cb']:.3f} <= 1/2 (D-WO5-1)")
990 # constant preservation: c = cin = 1
991 st = g9c_run(20, scheme="koren", dt=0.008, c0=1.0, cin=1.0)
992 dev = max(abs(st["min"] - 1.0), abs(st["max"] - 1.0))
993 print(f" constant c = 1 (inflow 1): max|c - 1| = {dev:.2e}")
994 check(dev <= 1e-6, f"constant preservation through the open faces: max|c - 1| = {dev:.1e} <= 1e-6")
995 # steady (implicit FOU everywhere, §6.7) at a low cell Peclet number: the rate balance
996 s = st["s"] # the developed flow of the last run; a second scalar, solved in place
997 s.add_scalar("d", diffusivity=0.5, cutcell=True)
998 s.set_scalar_bc("d", "-x", "dirichlet", 1.0)
999 for f in FACES[1:]:
1000 s.set_scalar_bc("d", f, "neumann")
1001 s.solve_scalar_steady("d")
1002 b = s.diagnostics.scalar_budget("d")
1003 cen = s.diagnostics.scalar_census("d")
1004 rate = 1.0 # the inflow's advective rate Q cin = U A cin
1005 print(f" steady, D = 0.5 (Pe_h {1.0 / 24 / 0.5:.3f}): {cen['krylov_iterations']} iterations, "
1006 f"boundary_in {b['boundary_in']:.3e}, identity_error {b['identity_error']:.1e} (rate {rate})")
1007 check(cen["krylov_converged"], "steady with open faces converges")
1008 check(abs(b["identity_error"]) <= 1e-12 * rate, f"steady rate balance {abs(b['identity_error']):.1e} <= 1e-12 Q cin")
1009 # the collocated grid is refused (open after WO-5b: no constrained high-side face flux to capture)
1010 raises(RuntimeError, lambda: channel_case(kind="collocated")[0].step(),
1011 "collocated: an open domain face is refused for a cut-cell scalar (open after WO-5b)")
1012 # before any projection there is no open-face flux to advect with: refused, naming step()
1013 s, unk, _k, _dt, _V = channel_case(n=12)
1014 s.set_field("u", np.asfortranarray(np.full(unk.shape, 1.0)))
1015 raises(RuntimeError, s.advance_scalars, "open faces, a moving fluid and no projection yet: refused")
1017
1018
1020 """Run fn() with the C-level stderr captured; returns (fn's result, the text)."""
1021 import os
1022 import tempfile
1023 with tempfile.TemporaryFile(mode="w+b") as tmp:
1024 sys.stderr.flush()
1025 fd = os.dup(2)
1026 os.dup2(tmp.fileno(), 2)
1027 try:
1028 r = fn()
1029 finally:
1030 os.dup2(fd, 2)
1031 os.close(fd)
1032 tmp.seek(0)
1033 return r, tmp.read().decode(errors="replace")
1034
1035
1036def backflow_steady_row(n=12, nsteps=10):
1037 """Review finding 2 (rulings D-WOR-2a/b/c): open faces but NO inflow face -- both x ends
1038 'outflow', the flow driven by a body force, so the whole -x face backflows; walls on y and z, a
1039 Neumann sphere. A steady scalar, insulating everywhere, no source, from a non-uniform start:
1040 the problem is not the uniform-mean singular case (no compatibility projection, no gauge), the
1041 solve converges -- to a constant, the null vector --, and the backflow rows are counted and
1042 warned about once. Returns the census."""
1043 s = pf.Solver((2 * n, n, n), extent=(2.0, 1.0, 1.0))
1044 s.set_rho(1.0)
1045 s.set_mu(0.05)
1046 s.set_dt(0.01)
1047 s.set_domain_bc("-x", "outflow")
1048 s.set_domain_bc("+x", "outflow")
1049 for f in FACES[2:]:
1050 s.set_domain_bc(f, "wall")
1051 X, Y, Z = grid(s)
1052 s.set_solid(np.asfortranarray(np.sqrt((X - 0.97) ** 2 + (Y - 0.48) ** 2 + (Z - 0.53) ** 2) - 0.23),
1053 cutcell_pressure=True)
1054 s.set_body_force((1.0, 0.0, 0.0))
1055 for _ in range(nsteps):
1056 s.step()
1057 s.add_scalar("b", diffusivity=0.05, cutcell=True)
1058 for f in FACES:
1059 s.set_scalar_bc("b", f, "neumann")
1060 unk = s.diagnostics.scalar_geometry("b")["unknown"] > 0.5
1061 s.set_field("b", np.asfortranarray(np.where(unk, 0.5 + 0.3 * np.sin(3.0 * X) * np.cos(2.0 * Y), 0.0)))
1062 _, err = capture_stderr(lambda: s.solve_scalar_steady("b"))
1063 cen = s.diagnostics.scalar_census("b")
1064 c = s.get_field("b")[unk]
1065 spread = float(c.max() - c.min())
1066 print(f" backflow (both x ends outflow, body force): {cen['num_backflow_faces']} backflow rows, "
1067 f"{cen['krylov_iterations']} iterations, residual {cen['krylov_residual']:.1e}, "
1068 f"incompatibility {cen['steady_incompatibility']:.1e}, max - min c = {spread:.1e}; "
1069 f"stderr: {err.strip()!r}")
1070 check(cen["num_backflow_faces"] > 0, f"backflow: census num_backflow_faces > 0 ({cen['num_backflow_faces']})")
1071 check("BACKFLOW" in err and "num_backflow_faces" in err, "backflow: the steady solve warns, naming the census key")
1072 check(cen["krylov_converged"], f"backflow, no inflow face: the steady solve converges "
1073 f"({cen['krylov_iterations']} iterations, D-WOR-2b)")
1074 check(cen["steady_incompatibility"] == 0.0, "backflow: not treated as the uniform-mean singular case")
1075 check(spread <= 1e-6, f"backflow: the converged field is the null vector, a constant (spread {spread:.1e})")
1076 _, err2 = capture_stderr(lambda: s.solve_scalar_steady("b"))
1077 check("BACKFLOW" not in err2, "backflow: the warning is printed once per scalar")
1078 return cen
1079
1080
1081# ------------------------------------------------------------------------- WO-6: closures ----
1082def low_face_flux(s, name, a):
1083 """The scalar's advective face flux per unit area through the LOW a-face of each cell
1084 (physical, c-free: open * u), from fields: the projection's openness (get_ox/oy/oz, the
1085 staggered non-ghost openness of §6.1) times the face velocity (get_field, internal, converted),
1086 zero unless both cells are fluid unknowns (the guard of §6.1). Periodic roll; a non-periodic
1087 axis' low boundary face is zeroed by the caller's mask."""
1088 geo = s.diagnostics.scalar_geometry(name)
1089 unk = geo["unknown"] > 0.5
1090 o = (s.get_ox, s.get_oy, s.get_oz)[a]()
1091 v = s.get_field("uvw"[a]) / s.unit_scales["velocity_to_internal"][a]
1092 return np.where(unk & np.roll(unk, 1, axis=a), o * v, 0.0)
1093
1094
1095def cell_velocity(s, name, periodic=(True, True, True)):
1096 """U_i/V (§1.5) per cell, physical: sum_f F_out (x_f - x_i) / V = the mean of the cell's two
1097 a-face fluxes per area, per axis."""
1098 U = []
1099 for a in range(3):
1100 f = low_face_flux(s, name, a)
1101 if not periodic[a]:
1102 idx = [slice(None)] * 3
1103 idx[a] = 0
1104 f[tuple(idx)] = 0.0
1105 U.append(0.5 * (f + np.roll(f, -1, axis=a)))
1106 return U
1107
1108
1109def mean_flux_fields(s, name, D, G, periodic):
1110 """scalar_mean_flux recomputed from fields (§8.1): the diffusive face sum of -D a (c_j - c_i)/h
1111 (c = theta + G.x, the true displacement) over the coupled low faces, plus the advective
1112 sum (U_i/V - kappa_i Ubar) theta_i, both over the whole box's cell count. Returns
1113 (J (3,), the advective part (3,), Ubar (3,), phi = sum kappa / N)."""
1114 geo = s.diagnostics.scalar_geometry(name)
1115 unk = geo["unknown"] > 0.5
1116 kap = np.where(unk, geo["kappa"], 0.0)
1117 th = s.get_field(name)
1118 h = s.spacing
1119 N = th.size
1120 U = cell_velocity(s, name, periodic)
1121 ksum = math.fsum(kap[unk].ravel())
1122 ub = [math.fsum(U[a][unk].ravel()) / ksum for a in range(3)]
1123 J, Ja = np.zeros(3), np.zeros(3)
1124 for a, ap in enumerate((geo["aperture_x"], geo["aperture_y"], geo["aperture_z"])):
1125 cpl = unk & np.roll(unk, 1, axis=a)
1126 if not periodic[a]:
1127 idx = [slice(None)] * 3
1128 idx[a] = 0
1129 cpl[tuple(idx)] = False
1130 dc = th - np.roll(th, 1, axis=a) + G[a] * h[a]
1131 J[a] = math.fsum((-D * ap * dc / h[a])[cpl].ravel()) / N
1132 Ja[a] = math.fsum(((U[a] - kap * ub[a]) * th)[unk].ravel()) / N
1133 return J + Ja, Ja, ub, ksum / N
1134
1135
1136def poiseuille_face_average(s, c0, R, U, oz, m=24):
1137 """G8's velocity (ruling D-WO6-1): the Poiseuille flux through the open part of each z-face
1138 (m x m midpoint sub-samples of the face), divided by oz A, so that F = oz w A carries no
1139 point-sampling error."""
1140 x, y, _ = (np.asarray(v) for v in s.cell_centers())
1141 h = x[1] - x[0]
1142 g = (np.arange(m) + 0.5) / m - 0.5
1143 GX, GY = np.meshgrid(g * h, g * h, indexing="ij")
1144 flux = np.zeros((x.size, y.size))
1145 for i in range(x.size):
1146 px = x[i] + GX - c0[0]
1147 for j in range(y.size):
1148 py = y[j] + GY - c0[1]
1149 r2 = (px * px + py * py) / (R * R)
1150 flux[i, j] = np.mean(np.where(r2 < 1.0, 2.0 * U * (1.0 - r2), 0.0))
1151 o = oz[:, :, :1][:, :, 0]
1152 w2 = np.where(o > 0, flux / np.where(o > 0, o, 1.0), 0.0)
1153 return np.repeat(w2[:, :, None], oz.shape[2], axis=2)
1154
1155
1156def g8_case(Rh, off, U=1.0, Pe=10.0, nbox=None, nz=4, exact_flux=True):
1157 """G8: G7's pipe (R = 1, nz = 4 periodic), frozen Poiseuille z-face velocities (set_field;
1158 ruling D-WO6-1: the FACE-AVERAGED flux over the open part of each face; divergence-free
1159 because z-invariant), insulating walls, mean gradient e_z, steady. Returns the Taylor-Aris coefficient -<u'theta> (interstitial, from fields) over
1160 its exact value Pe^2 D/48, Pe = U R/D. `nbox`/`nz` give G-iter's multigrid-friendly box (ruling
1161 D-WO4-1; the problem is z-invariant, so nz changes nothing but the cell count);
1162 `exact_flux=False` the INFO variant: face-centre point samples, clipped >= 0."""
1163 R = 1.0
1164 D = U * R / Pe
1165 h = R / Rh
1166 n = nbox or 2 * Rh + 6
1167 L = n * h
1168 s = walled((n, n, nz), (L, L, nz * h), periodic_z=True)
1169 c0 = np.array([0.5 * L, 0.5 * L]) + np.asarray(off[:2]) * h
1170 X, Y, Z = grid(s)
1171 rr = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2)
1172 s.set_solid(np.asfortranarray(R - rr), cutcell_pressure=True)
1173 if exact_flux:
1174 w = poiseuille_face_average(s, c0, R, U, s.get_oz())
1175 else:
1176 w = np.maximum(2.0 * U * (1.0 - rr * rr / (R * R)), 0.0) # z-face centres share (x, y)
1177 s.set_field("w", np.asfortranarray(w * s.unit_scales["velocity_to_internal"][2]))
1178 add_cc(s, D, box="neumann", periodic_z=True)
1179 s.set_scalar_mean_gradient("c", (0.0, 0.0, 1.0))
1180 s.solve_scalar_steady("c")
1181 per = (False, False, True)
1182 J, Ja, ub, phi = mean_flux_fields(s, "c", D, (0.0, 0.0, 1.0), per)
1183 Jc = np.asarray(s.scalar_mean_flux("c"))
1184 val = -Ja[2] / phi # -<u' theta> interstitial, G = 1
1185 c = s.diagnostics.scalar_census("c")
1186 return dict(err=48.0 * val / (Pe * Pe * D) - 1.0, its=c["krylov_iterations"],
1187 conv=c["krylov_converged"], pe_h=c["max_cell_peclet"], ubar=ub[2],
1188 jdiff=float(np.max(np.abs(Jc - J)) / np.max(np.abs(Jc))), J=Jc, phi=phi,
1189 incompat=c["steady_incompatibility"], s=s)
1190
1191
1193 print("G8 Taylor-Aris: pipe R/h in {16, 32, 64}, face-averaged Poiseuille fluxes (D-WO6-1), insulating walls, "
1194 "mean gradient e_z, steady; -<u'theta> -> Pe^2 D/48 at Pe = UR/D = 10")
1195 e = {}
1196 for Rh in (16, 32, 64):
1197 rows = [g8_case(Rh, off) for off in OFFSETS]
1198 e[Rh] = rms([r["err"] for r in rows])
1199 print(f" R/h={Rh:3d} rel err {[format(r['err'], '+.3e') for r in rows]} iterations "
1200 f"{[r['its'] for r in rows]} census Pe_h {rows[0]['pe_h']:.3f} Ubar/U - 1 "
1201 f"{rows[0]['ubar'] - 1.0:+.2e} |J - J_fields| / |J| <= {max(r['jdiff'] for r in rows):.1e}"
1202 f" incompatibility {max(r['incompat'] for r in rows):.1e}")
1203 for r in rows:
1204 check(r["conv"], f"R/h={Rh}: converged ({r['its']} iterations; native box: correctness "
1205 f"only, ruling D-WO4-1)")
1206 check(r["jdiff"] <= 1e-10, f"R/h={Rh}: scalar_mean_flux = the field sums to "
1207 f"{r['jdiff']:.1e} <= 1e-10")
1208 for a, b in ((16, 32), (32, 64)):
1209 check(order(e[a], e[b]) >= 1.8, f"Taylor-Aris order {a}->{b} {order(e[a], e[b]):.2f} >= 1.8 "
1210 f"(rms {e[a]:.3e} -> {e[b]:.3e})")
1211 check(e[32] <= 1e-3, f"Taylor-Aris |err| {e[32]:.2e} <= 1e-3 at R/h = 32")
1212 ex = {Rh: rms([g8_case(Rh, off, exact_flux=False)["err"] for off in OFFSETS]) for Rh in (16, 32)}
1213 print(f" INFO face-centre point samples (an O(h^2) flux-quadrature error): rms {ex[16]:.3e} -> "
1214 f"{ex[32]:.3e}, order {order(ex[16], ex[32]):.2f}")
1215 print("G-iter singular steady on G8, multigrid-friendly boxes (ruling D-WO4-1): n = 48 / 80 / 144, "
1216 "nz = 16")
1217 its = []
1218 for Rh in (16, 32, 64):
1219 rows = [g8_case(Rh, off, nbox=mg_box(2 * Rh + 6), nz=mg_box(4)) for off in OFFSETS]
1220 its.append(max(r["its"] for r in rows))
1221 print(f" R/h={Rh:3d} iterations {[r['its'] for r in rows]} rel err "
1222 f"{[format(r['err'], '+.3e') for r in rows]}")
1223 for r in rows:
1224 check(r["conv"], f"R/h={Rh} mg box: converged")
1225 check(max(its) <= 30, f"G8 singular steady: <= 30 iterations at every rung ({its})")
1226 gr = [b - a for a, b in zip(its, its[1:])]
1227 check(all(g <= 5 for g in gr), f"G8 singular steady: growth <= 5 per doubling ({gr})")
1228
1229
1230def g5b_case(n, off, D=0.6):
1231 """G5b: the periodic simple-cubic array, solid fraction 0.3, on an n^3 grid of the unit
1232 period, insulating spheres, mean gradient e_x, steady (singular). k* = -J_x/(D G_x)."""
1233 L = 1.0
1234 h = L / n
1235 s = pf.Solver((n, n, n), extent=(L, L, L))
1236 s.set_rho(1.0)
1237 s.set_mu(1.0)
1238 R = 0.5 * L * (0.3 * 6.0 / math.pi) ** (1.0 / 3.0)
1239 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
1240 X, Y, Z = grid(s)
1241 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
1242 s.set_solid(np.asfortranarray(r - R))
1243 s.add_scalar("c", diffusivity=D, cutcell=True)
1244 s.set_scalar_mean_gradient("c", (1.0, 0.0, 0.0))
1245 s.solve_scalar_steady("c")
1246 J = np.asarray(s.scalar_mean_flux("c"))
1247 c = s.diagnostics.scalar_census("c")
1248 Jf, _, _, _ = mean_flux_fields(s, "c", D, (1.0, 0.0, 0.0), (True, True, True))
1249 return dict(k=-J[0] / D, its=c["krylov_iterations"], conv=c["krylov_converged"],
1250 lv=c["mg_levels"], nd=2.0 * R / h, jdiff=float(np.max(np.abs(J - Jf)) / abs(J[0])))
1251
1252
1253def no_solid_case(G, vel=None, D=0.6, n=16):
1254 """The no-solid identity: a box with no solid (SDF > 0 everywhere), mean gradient G, at rest
1255 or with a uniform velocity; the closure solution is theta = const and J = -D G exactly."""
1256 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
1257 s.set_rho(1.0)
1258 s.set_mu(1.0)
1259 s.set_solid(np.asfortranarray(np.ones((n, n, n))), cutcell_pressure=vel is not None)
1260 if vel is not None:
1261 for a, nm in enumerate("uvw"):
1262 s.set_field(nm, np.asfortranarray(np.full((n, n, n), vel[a] *
1263 s.unit_scales["velocity_to_internal"][a])))
1264 s.add_scalar("c", diffusivity=D, cutcell=True)
1265 s.set_field("c", np.asfortranarray(np.random.default_rng(7).uniform(-1, 1, (n, n, n))))
1266 s.set_scalar_mean_gradient("c", G)
1267 s.solve_scalar_steady("c")
1268 J = np.asarray(s.scalar_mean_flux("c"))
1269 c = s.diagnostics.scalar_census("c")
1270 return J, c
1271
1272
1274 print("G5b insulating periodic SC array, c = 0.3, mean gradient e_x: k* = -J_x/(D G_x) via "
1275 "scalar_mean_flux; ND = 16.6 / 33.2 / 66.4 (n = 20 / 40 / 80, doublings)")
1276 hs = (1.0 - 0.3) / (1.0 + 0.15)
1277 k = {}
1278 for n in (20, 40, 80):
1279 rows = [g5b_case(n, off) for off in OFFSETS]
1280 k[n] = [r["k"] for r in rows]
1281 print(f" n={n:3d} (ND {rows[0]['nd']:.1f}) k* {[f'{v:.7f}' for v in k[n]]} iterations "
1282 f"{[r['its'] for r in rows]} mg levels {rows[0]['lv']} |J - J_fields|/|J_x| <= "
1283 f"{max(r['jdiff'] for r in rows):.1e}")
1284 for r in rows:
1285 check(r["conv"], f"n={n}: converged ({r['its']} iterations)")
1286 if n >= 40: # ruling D-WO6-2: the bound of the exact k*, gated from ND 32 up
1287 check(r["k"] <= hs, f"n={n}: k* {r['k']:.6f} <= Hashin-Shtrikman {hs:.6f}")
1288 check(r["jdiff"] <= 1e-10, f"n={n}: scalar_mean_flux = the field sums ({r['jdiff']:.1e})")
1289 d1 = rms([a - b for a, b in zip(k[20], k[40])])
1290 d2 = rms([a - b for a, b in zip(k[40], k[80])])
1291 p = order(d1, d2)
1292 check(p >= 1.7, f"self-convergence order {p:.2f} >= 1.7 (rms |k_20 - k_40| {d1:.3e}, "
1293 f"|k_40 - k_80| {d2:.3e})")
1294 kx = [c + (c - b) / (2.0 ** p - 1.0) for b, c in zip(k[40], k[80])]
1295 print(f" Richardson-extrapolated k* {[f'{v:.6f}' for v in kx]}; ND 16.6 rung "
1296 f"{max(k[20]) - hs:+.2e} against the bound (INFO: the bound is of the exact k*)")
1297 check(max(kx) <= hs, f"extrapolated k* {max(kx):.6f} <= Hashin-Shtrikman {hs:.6f}")
1298 print("G-iter singular steady on the G5b closure, multigrid-friendly n = 32 / 48 / 80 (ruling Q-C)")
1299 mx = []
1300 for n in (mg_box(20), mg_box(40), mg_box(80)):
1301 rows = [g5b_case(n, off) for off in OFFSETS]
1302 mx.append(max(r["its"] for r in rows))
1303 print(f" n={n:3d} iterations {[r['its'] for r in rows]} k* {[format(r['k'], '.6f') for r in rows]}")
1304 for r in rows:
1305 check(r["conv"], f"n={n}: converged")
1306 check(max(mx) <= 30, f"G5b singular: <= 30 iterations at every rung ({mx})")
1307 gr = [b - a for a, b in zip(mx, mx[1:])]
1308 check(all(g <= 5 for g in gr), f"G5b singular: growth <= 5 per rung ({gr})")
1309 print("no-solid identity: k* = 1 to 1e-12")
1310 J, c = no_solid_case((1.0, 0.0, 0.0))
1311 ks = -J[0] / 0.6
1312 print(f" at rest, G = e_x: k* - 1 = {ks - 1.0:+.1e}, J_y, J_z = {J[1]:+.1e}, {J[2]:+.1e}; "
1313 f"{c['krylov_iterations']} iterations")
1314 check(abs(ks - 1.0) <= 1e-12 and abs(J[1]) <= 1e-12 and abs(J[2]) <= 1e-12,
1315 "no solid, at rest: k* = 1 to 1e-12, transverse flux 0")
1316 G = np.array([0.3, -0.5, 0.8])
1317 J, c = no_solid_case(tuple(G), vel=(0.2, 0.1, -0.3))
1318 dev = float(np.max(np.abs(J + 0.6 * G)) / (0.6 * np.linalg.norm(G)))
1319 print(f" uniform flow (0.2, 0.1, -0.3), G = (0.3, -0.5, 0.8): max|J + D G| / (D|G|) = {dev:.1e}; "
1320 f"census Pe_h {c['max_cell_peclet']:.3g}, {c['krylov_iterations']} iterations")
1321 check(dev <= 1e-12, "no solid, uniform flow (moving frame): J = -D G to 1e-12")
1322
1323
1324# -------------------------------------------------------------------------------------- G-adv ----
1325def rescale_peclet(s, name, target):
1326 """G-adv's parametrization (A2): one probe steady solve at the present face field, then u, v, w
1327 times target / (census max_cell_peclet), so the next solve's census reads `target`."""
1328 s.solve_scalar_steady(name)
1329 k = target / s.diagnostics.scalar_census(name)["max_cell_peclet"]
1330 for nm in ("u", "v", "w"):
1331 s.set_field(nm, np.asfortranarray(s.get_field(nm) * k))
1332
1333
1334def gadv_budget(s, name):
1335 """(v), ruling D-WO5c-1: the steady rate balance relative to the GROSS budget — the sum of the
1336 terms' absolute values, the source taken gross (V sum |r| over the unknowns, r the per-cell
1337 source density of the right-hand side: kappa s for a per-cell source, the mean-gradient terms
1338 in closure mode; |source_in| for a uniform one) — bound 1e-11. Returns (that ratio, the ratio
1339 against the largest NET term: INFO, O(1) for the mean-free closure source of (b) / (b'))."""
1340 b = s.diagnostics.scalar_budget(name)
1341 net = max(abs(b["wall_in"]), abs(b["source_in"]), abs(b["boundary_in"]))
1342 src = abs(b["source_in"])
1343 if name in GADV_SRC:
1344 geo = s.diagnostics.scalar_geometry(name)
1345 unk = geo["unknown"] > 0.5
1346 x, y, z = s.cell_centers()
1347 V = (x[1] - x[0]) * (y[1] - y[0]) * (z[1] - z[0])
1348 src = V * math.fsum(np.abs(GADV_SRC[name])[unk].ravel())
1349 gross = abs(b["wall_in"]) + src + abs(b["boundary_in"])
1350 ie = abs(b["identity_error"])
1351 return ie / gross, (ie / net if net > 0.0 else None) # closure mode: every net term is 0
1352
1353
1354def gadv_a_case(Rh, pe):
1355 """(a) WO-5's C3 problem: a periodic box 4R, a Dirichlet sphere (c = 1) R = 1 with a source,
1356 D = 0.7, the projected Stokes field of that geometry (20 steps under a body force, mu dt/h^2 =
1357 1), rescaled to census Pe_h = pe; steady."""
1358 R, D = 1.0, 0.7
1359 n = 4 * Rh
1360 L = 4.0 * R
1361 h = L / n
1362 s = pf.Solver((n, n, n), extent=(L, L, L))
1363 s.set_rho(1.0)
1364 s.set_mu(1.0)
1365 s.set_dt(h * h)
1366 X, Y, Z = grid(s)
1367 c0 = np.array([0.5 * L + 0.37 * h, 0.5 * L - 0.22 * h, 0.5 * L + 0.11 * h])
1368 s.set_solid(np.asfortranarray(np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2) - R),
1369 cutcell_pressure=True)
1370 s.set_body_force((1.0, 0.4, 0.2))
1371 for _ in range(20):
1372 s.step()
1373 s.add_scalar("c", diffusivity=D, cutcell=True)
1374 s.set_scalar_wall("c", "dirichlet", 1.0)
1375 s.set_scalar_source("c", -0.3)
1376 rescale_peclet(s, "c", pe)
1377 s.solve_scalar_steady("c")
1378 return s
1379
1380
1382 """G9b's geometry and flow: the periodic simple-cubic sphere array, solid fraction 0.3, one
1383 sphere per unit period on n^3 cells; Stokes through it from step() (G9b's body force, 50
1384 steps)."""
1385 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
1386 s.set_rho(1.0)
1387 s.set_mu(1.0)
1388 s.set_dt(0.01)
1389 R = (0.3 * 3.0 / (4.0 * math.pi)) ** (1.0 / 3.0)
1390 X, Y, Z = grid(s)
1391 s.set_solid(np.asfortranarray(np.sqrt((X - 0.513) ** 2 + (Y - 0.479) ** 2 + (Z - 0.507) ** 2) - R),
1392 cutcell_pressure=True)
1393 s.set_body_force((30.0, 9.0, 0.0))
1394 for _ in range(50):
1395 s.step()
1396 return s
1397
1398
1399def closure_source(s, name):
1400 """The B-field source for G = e_x (until WO-6's mean-gradient mode): s = u_x - <u_x> per
1401 unknown cell, u_x the cell average of its two x-face velocities (physical), <.> the kappa-
1402 weighted mean over the fluid unknowns; 0 elsewhere."""
1403 geo = s.diagnostics.scalar_geometry(name)
1404 unk = geo["unknown"] > 0.5
1405 kap = geo["kappa"]
1406 u = s.get_u()
1407 ux = 0.5 * (u + np.roll(u, -1, axis=0))
1408 mean = float(np.sum((kap * ux)[unk]) / np.sum(kap[unk]))
1409 return np.asfortranarray(np.where(unk, ux - mean, 0.0))
1410
1411
1412def closure_rhs_density(s, name, D, G):
1413 """The mean-gradient terms of the right-hand side per unit volume (§1.5), physical, per cell:
1414 the faces' D G_a (a_a^+ - a_a^-)/h_a over the coupled faces, minus G.(U_i/V - kappa_i Ubar);
1415 the gross scale of (v) in closure mode."""
1416 geo = s.diagnostics.scalar_geometry(name)
1417 unk = geo["unknown"] > 0.5
1418 kap = np.where(unk, geo["kappa"], 0.0)
1419 h = s.spacing
1420 U = cell_velocity(s, name)
1421 ksum = math.fsum(kap[unk].ravel())
1422 r = np.zeros(unk.shape)
1423 for a, ap in enumerate((geo["aperture_x"], geo["aperture_y"], geo["aperture_z"])):
1424 lo = np.where(unk & np.roll(unk, 1, axis=a), ap, 0.0)
1425 hi = np.roll(lo, -1, axis=a)
1426 ub = math.fsum(U[a][unk].ravel()) / ksum
1427 r += D * G[a] * (hi - lo) / h[a] - G[a] * (U[a] - kap * ub)
1428 return np.where(unk, r, 0.0)
1429
1430
1431def gadv_b_case(n, pe, wall):
1432 """(b) the closure problem on the SC array (insulating spheres: steady, singular) in the
1433 mean-gradient mode of WO-6 (theta, G = e_x), or (b') the reactive bed (Dirichlet spheres,
1434 c = 0) with the per-cell source u_x - <u_x> (WO-5c's form), D = 0.05; census Pe_h = pe."""
1435 s = sc_array(n)
1436 s.add_scalar("c", diffusivity=0.05, cutcell=True)
1437 if wall == "neumann":
1438 s.set_scalar_wall("c", "neumann", 0.0)
1439 s.set_scalar_mean_gradient("c", (1.0, 0.0, 0.0))
1440 rescale_peclet(s, "c", pe)
1441 GADV_SRC["c"] = closure_rhs_density(s, "c", 0.05, (1.0, 0.0, 0.0))
1442 s.solve_scalar_steady("c")
1443 return s
1444 s.set_scalar_wall("c", "dirichlet", 0.0)
1445 s.set_scalar_source("c", closure_source(s, "c"))
1446 rescale_peclet(s, "c", pe)
1447 src = closure_source(s, "c") # the source of the rescaled field
1448 s.set_scalar_source("c", src)
1449 geo = s.diagnostics.scalar_geometry("c")
1450 GADV_SRC["c"] = geo["kappa"] * src
1451 s.solve_scalar_steady("c")
1452 return s
1453
1454
1456 """(c) G9c's channel (inflow U = 1 / no-slip walls / outflow, a sphere + a cap cutting the
1457 outlet), the flow developed by 20 NS steps; a steady scalar, Dirichlet 1 at the inlet, Neumann
1458 elsewhere. The captured open-face flux cannot be rescaled through set_field, so the census
1459 Pe_h is set through D instead (Pe_h ~ 1/D: the same operator up to a factor)."""
1460 st = g9c_run(20, scheme="koren", dt=0.008)
1461 s = st["s"]
1462 D0 = 0.5
1463 s.add_scalar("p", diffusivity=D0, cutcell=True)
1464 s.set_scalar_bc("p", "-x", "dirichlet", 1.0)
1465 for f in FACES[1:]:
1466 s.set_scalar_bc("p", f, "neumann")
1467 s.solve_scalar_steady("p")
1468 p0 = s.diagnostics.scalar_census("p")["max_cell_peclet"]
1469 s.add_scalar("d", diffusivity=D0 * p0 / pe, cutcell=True)
1470 s.set_scalar_bc("d", "-x", "dirichlet", 1.0)
1471 for f in FACES[1:]:
1472 s.set_scalar_bc("d", f, "neumann")
1473 s.solve_scalar_steady("d")
1474 return s
1475
1476
1477GADV_PE = (0.1, 1.0, 10.0)
1478GADV_SRC = {} # the per-cell source of the last (b) / (b') case, for the gross scale of (v)
1479
1480
1481def gadv_row(tag, s, name, pe, bound, rate=None):
1482 """(i), (ii), (v) of one row; returns the iteration count."""
1483 c = s.diagnostics.scalar_census(name)
1484 its = c["krylov_iterations"]
1485 b = s.diagnostics.scalar_budget(name)
1486 netr = None
1487 if rate: # (c): the inflow rate Q c_in, a lower bound of the gross budget (in + out)
1488 ident = abs(b["identity_error"]) / rate
1489 else:
1490 ident, netr = gadv_budget(s, name)
1491 extra = f" (INFO: {netr:.1e} of the largest net term)" if netr is not None else ""
1492 print(f" {tag} Pe_h {c['max_cell_peclet']:.4g}: {its} iterations (<= {bound}), residual "
1493 f"{c['krylov_residual']:.1e}, {c['mg_levels']} levels; budget identity {ident:.1e}{extra}")
1494 check(abs(c["max_cell_peclet"] / pe - 1.0) <= 1e-9, f"{tag}: census Pe_h {c['max_cell_peclet']:.6g} = {pe}")
1495 check(c["krylov_converged"] and c["krylov_residual"] <= 1e-10,
1496 f"{tag} Pe_h {pe}: (i) converged to rtol 1e-10 within 200 ({its} iterations)")
1497 check(its <= bound, f"{tag} Pe_h {pe}: (ii) {its} <= {bound} iterations (Q20, D-WOR-8)")
1498 check(ident <= 1e-11, f"{tag} Pe_h {pe}: (v) steady budget identity {ident:.1e} <= 1e-11 of the gross budget")
1499 return its
1500
1501
1502def gadv_growth(tag, lo, hi):
1503 for pe in GADV_PE:
1504 g = hi[pe] / lo[pe]
1505 check(g <= 1.7, f"{tag} Pe_h {pe}: (iii) growth per doubling {lo[pe]} -> {hi[pe]} = {g:.2f}x <= 1.7x")
1506
1507
1509 print("G-adv: steady advection on the advective surrogate (design Amendment A2), "
1510 "Pe_h = census max_cell_peclet")
1511 t0 = time.time()
1512 # (ii), ruling Q20 / D-WOR-8: min(the provisional bound, 2x the count measured on the WO-R tree,
1513 # doc/scalar_ibm_log.md 2026-10-04 WO-R) -- tightened, never loosened. Provisional: (a) 20/25/40
1514 # and 25/35/55, (b) and (b') 15/20/30 at both sizes, (c) 40.
1515 bounds = {16: (16, 25, 40), 32: (20, 34, 55)}
1516 its = {}
1517 for Rh in (16, 32):
1518 its[Rh] = {}
1519 for pe, bd in zip(GADV_PE, bounds[Rh]):
1520 GADV_SRC.clear()
1521 s = gadv_a_case(Rh, pe)
1522 its[Rh][pe] = gadv_row(f"(a) R/h={Rh} ({4 * Rh}^3)", s, "c", pe, bd)
1523 gadv_growth("(a)", its[16], its[32])
1524 print(f" [(a): {time.time() - t0:.0f} s]")
1525 bounds_b = {"neumann": {32: (10, 16, 24), 64: (12, 20, 30)},
1526 "dirichlet": {32: (15, 20, 30), 64: (15, 20, 30)}}
1527 for wall, nm in (("neumann", "(b) closure, Neumann spheres, mean gradient e_x"),
1528 ("dirichlet", "(b') Dirichlet spheres")):
1529 t0 = time.time()
1530 its = {}
1531 for n in (32, 64):
1532 its[n] = {}
1533 for pe, bd in zip(GADV_PE, bounds_b[wall][n]):
1534 s = gadv_b_case(n, pe, wall)
1535 if wall == "neumann":
1536 check(s.diagnostics.scalar_census("c")["steady_incompatibility"] >= 0.0 and
1537 s.diagnostics.scalar_census("c")["mg_levels"] > 1, f"{nm} n={n}: full table")
1538 its[n][pe] = gadv_row(f"{nm} n={n}", s, "c", pe, bd)
1539 gadv_growth(nm, its[32], its[64])
1540 print(f" [{nm}: {time.time() - t0:.0f} s]")
1541 t0 = time.time()
1542 for pe, bd in ((1.0, 16), (10.0, 18)):
1543 s = gadv_c_case(pe)
1544 gadv_row("(c) G9c channel, open faces", s, "d", pe, bd, rate=1.0)
1545 print(f" [(c): {time.time() - t0:.0f} s]")
1546
1547
1548# --------------------------------------------------------------------------- conjugate (WO-7) ----
1549def fmt(v, spec):
1550 """A list of numbers formatted with `spec` (no nested f-string quotes: Python 3.10)."""
1551 return "[" + ", ".join(format(x, spec) for x in v) + "]"
1552
1553
1554def conj_box(n, L):
1555 """A walled box of physical size L (no flow)."""
1556 return walled((n, n, n), (L, L, L))
1557
1558
1559def sealed_check(tag, cen):
1560 """Ruling D-WO7-1: the sealed solid volume (kappa_s > 0, conjugate, no open solid face and no
1561 conjugate facet: not a solid unknown) is <= 1e-6 of the conjugate solid volume (as D-WO2-1)."""
1562 r = cen["sealed_solid_volume"] / cen["solid_volume"] if cen["solid_volume"] > 0 else 0.0
1563 check(r <= 1e-6, f"{tag}: sealed solid volume {r:.1e} <= 1e-6 of the solid volume")
1564 return r
1565
1566
1567def solid_isolated(s, name="c"):
1568 """Solid unknowns that no solid face and no facet couple: kappa_s > 0, every solid aperture
1569 1 - a^snap = 0 (all six fluid apertures snapped to 1, hence no facet). Ruling D-WO7-1 makes
1570 them sealed (not unknowns): this count must be 0."""
1571 geo = s.diagnostics.scalar_geometry(name)
1572 us = geo["solid_unknown"] > 0.5
1573 ax, ay, az = geo["aperture_x"], geo["aperture_y"], geo["aperture_z"]
1574 allopen = np.ones(us.shape, dtype=bool)
1575 for a, ap in ((0, ax), (1, ay), (2, az)):
1576 allopen &= (ap >= 1.0) & (np.roll(ap, -1, axis=a) >= 1.0)
1577 return us & allopen
1578
1579
1580def maxwell_fields(X, Y, Z, c0, R, G, ratio):
1581 """Maxwell's conducting sphere in a uniform gradient G (psi form, Lam_f = 1, Lam_s = ratio):
1582 outside G.x' (1 + A R^3/r^3), inside B G.x', A = (1 - ratio)/(ratio + 2), B = 3/(ratio + 2),
1583 x' = x - c0."""
1584 xs, ys, zs = X - c0[0], Y - c0[1], Z - c0[2]
1585 r = np.sqrt(xs * xs + ys * ys + zs * zs)
1586 gx = G[0] * xs + G[1] * ys + G[2] * zs
1587 A, B = (1.0 - ratio) / (ratio + 2.0), 3.0 / (ratio + 2.0)
1588 out = gx * (1.0 + A * R**3 / np.maximum(r, 1e-300) ** 3)
1589 return np.where(r >= R, out, B * gx), B * gx, out
1590
1591
1592def g4_case(Rh, off, ratio, K=1.0, rtol=None, G=(0.3, -0.5, 0.8)):
1593 """G4: a conjugate sphere R = 1 (Lam_s = ratio, C_s = 1, partition K) in a box 6R, Lam_f = D =
1594 1, the exact exterior field as the box Dirichlet profile, steady."""
1595 R, D = 1.0, 1.0
1596 h = R / Rh
1597 n = 6 * Rh
1598 L = n * h
1599 s = conj_box(n, L)
1600 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
1601 X, Y, Z = grid(s)
1602 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
1603 s.set_solid(np.asfortranarray(r - R))
1604 add_cc(s, D, box="neumann")
1605 for f in FACES:
1606 P = face_points(s, f)
1607 _, _, out = maxwell_fields(P[0], P[1], P[2], c0, R, G, ratio)
1608 s.set_scalar_bc("c", f, "dirichlet", out)
1609 s.set_scalar_solid("c", diffusivity=ratio * D / K, capacity=1.0, partition=K)
1610 if rtol is not None:
1611 s.set_scalar_tolerance("c", rtol)
1612 s.solve_scalar_steady("c")
1613 geo = s.diagnostics.scalar_geometry("c")
1614 cen = s.diagnostics.scalar_census("c")
1615 return s, X, Y, Z, c0, geo, cen
1616
1617
1618def interior_gradient(X, Y, Z, psi, mask):
1619 """Least-squares fit psi = a + g.x over the masked cells; returns g."""
1620 A = np.stack([np.ones(mask.sum()), X[mask], Y[mask], Z[mask]], axis=1)
1621 sol, *_ = np.linalg.lstsq(A, psi[mask], rcond=None)
1622 return sol[1:]
1623
1624
1626 """The k_s = k_f checks (the brief's early unit test): with Lam_s = Lam_f, K = 1, R_c = 0 the
1627 composite must reproduce the homogeneous solution — a linear field to round-off (§1.6.3, G4(a))
1628 and, in the closure mode, k* = 1 (theta = const)."""
1629 print("conjugate unit: Lam_s = Lam_f, K = 1, R_c = 0 reproduces the homogeneous solution")
1630 G = (0.3, -0.5, 0.8)
1631 for K in (1.0, 3.0):
1632 s, X, Y, Z, c0, geo, cen = g4_case(6, OFFSETS[0], 1.0, K=K, rtol=1e-12, G=G)
1633 ex = G[0] * (X - c0[0]) + G[1] * (Y - c0[1]) + G[2] * (Z - c0[2])
1634 uf = geo["unknown"] > 0.5
1635 us = geo["solid_unknown"] > 0.5
1636 iso = solid_isolated(s)
1637 ef = float(np.max(np.abs(s.get_field("c") - ex)[uf]))
1638 cs = s.get_scalar_solid("c")
1639 es = float(np.max(np.abs(cs / K - ex)[us]))
1640 scale = float(np.linalg.norm(G)) * 6.0
1641 print(f" K={K}: max|psi_f - G.x| {ef:.2e}, max|psi_s - G.x| {es:.2e} (every solid unknown); "
1642 f"{int(us.sum())} solid unknowns, sealed solid volume {cen['sealed_solid_volume']:.1e} "
1643 f"of {cen['solid_volume']:.3e}; {cen['krylov_iterations']} it, rungs {cen['probe_rungs']}")
1644 check(cen["krylov_converged"], f"K={K}: converged")
1645 check(int(iso.sum()) == 0, f"K={K}: no uncoupled solid unknown (D-WO7-1; {int(iso.sum())})")
1646 sealed_check(f"K={K}", cen)
1647 check(ef <= 1e-10 * scale and es <= 1e-10 * scale,
1648 f"K={K}: linear field reproduced to 1e-10 |G| L in both phases ({ef:.1e}, {es:.1e})")
1649 check(bool(np.all(np.isnan(cs[~us]))), f"K={K}: get_scalar_solid NaN outside the solid unknowns")
1650 n, D = 24, 0.6
1651 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
1652 s.set_rho(1.0)
1653 s.set_mu(1.0)
1654 R = 0.5 * (0.3 * 6.0 / math.pi) ** (1.0 / 3.0)
1655 X, Y, Z = grid(s)
1656 s.set_solid(np.asfortranarray(np.sqrt((X - 0.51) ** 2 + (Y - 0.49) ** 2 + (Z - 0.505) ** 2) - R))
1657 s.add_scalar("c", diffusivity=D, cutcell=True)
1658 s.set_scalar_solid("c", diffusivity=D)
1659 s.set_scalar_mean_gradient("c", (1.0, 0.0, 0.0))
1660 s.solve_scalar_steady("c")
1661 J = np.asarray(s.scalar_mean_flux("c"))
1662 th = s.get_field("c")
1663 print(f" closure, SC array c = 0.3: k* - 1 = {-J[0] / D - 1.0:+.2e}, J_y,z = {J[1]:+.1e}, {J[2]:+.1e},"
1664 f" max|theta| {float(np.max(np.abs(th))):.1e}")
1665 check(abs(-J[0] / D - 1.0) <= 1e-12 and abs(J[1]) <= 1e-12 and abs(J[2]) <= 1e-12,
1666 "closure at Lam_s = Lam_f: k* = 1 to 1e-12 (the mean-gradient conjugate terms, §1.5)")
1667
1668
1670 print("G4 Maxwell conjugate sphere (steady), box 6R, R/h in {6, 12, 24}")
1671 G = (0.3, -0.5, 0.8)
1672 Gn = float(np.linalg.norm(G))
1673 e, its = {}, {}
1674 for ratio in (1e-2, 1.0, 10.0, 1e3):
1675 e[ratio], its[ratio] = {}, {}
1676 for Rh in (6, 12, 24):
1677 rows = []
1678 for off in OFFSETS:
1679 s, X, Y, Z, c0, geo, cen = g4_case(Rh, off, ratio, G=G)
1680 us = geo["solid_unknown"] > 0.5
1681 full = us & (geo["kappa_solid"] >= 1.0)
1682 gfit = interior_gradient(X, Y, Z, s.get_field("c_solid"), full)
1683 Bg = 3.0 / (ratio + 2.0) * np.asarray(G)
1684 rows.append(dict(err=float(np.linalg.norm(gfit - Bg) / np.linalg.norm(Bg)),
1685 it=cen["krylov_iterations"], conv=cen["krylov_converged"],
1686 rungs=cen["probe_rungs"], iso=int(solid_isolated(s).sum()),
1687 sealed=sealed_check(f"ratio={ratio} R/h={Rh}", cen)))
1688 e[ratio][Rh] = rms([r["err"] for r in rows])
1689 its[ratio][Rh] = [r["it"] for r in rows]
1690 print(f" ratio={ratio:g} R/h={Rh:3d} interior gradient rel err {fmt([r['err'] for r in rows], '.3e')}"
1691 f" iters {its[ratio][Rh]} rungs {rows[0]['rungs']} isolated solid {[r['iso'] for r in rows]}")
1692 for r in rows:
1693 check(r["conv"], f"ratio={ratio} R/h={Rh}: converged ({r['it']} iterations)")
1694 check(r["it"] <= 30, f"ratio={ratio} R/h={Rh}: G-iter conjugate <= 30 ({r['it']})")
1695 if ratio == 1.0: # (a): the composite is exact here (a linear field), so no order exists
1696 check(max(e[ratio].values()) <= 1e-9,
1697 f"ratio=1: interior gradient exact to round-off ({max(e[ratio].values()):.1e} <= 1e-9)")
1698 continue
1699 o1, o2 = order(e[ratio][6], e[ratio][12]), order(e[ratio][12], e[ratio][24])
1700 check(o1 >= 1.7 and o2 >= 1.7, f"ratio={ratio}: interior-gradient orders {o1:.2f}, {o2:.2f} >= 1.7")
1701 check(e[ratio][24] <= 5e-3, f"ratio={ratio}: |err| {e[ratio][24]:.2e} <= 5e-3 at R/h = 24 (prov.)")
1702 for ratio in (1e-2, 1e3):
1703 for Rh in (6, 12, 24):
1704 b = 2 * max(its[1.0][Rh]) + 5
1705 check(max(its[ratio][Rh]) <= b,
1706 f"(d) ratio={ratio} R/h={Rh}: iterations {max(its[ratio][Rh])} <= 2 x ratio-1 + 5 = {b}")
1707 print("G4(c) K = 3 with the same Lam_s: psi identical, c_s = 3 psi_s")
1708 for ratio in (10.0,):
1709 s1, X, Y, Z, c0, geo, _ = g4_case(12, OFFSETS[0], ratio, K=1.0, rtol=1e-13, G=G)
1710 s3, _, _, _, _, _, _ = g4_case(12, OFFSETS[0], ratio, K=3.0, rtol=1e-13, G=G)
1711 us = geo["solid_unknown"] > 0.5
1712 iso = solid_isolated(s1)
1713 p1, p3 = s1.get_field("c_solid"), s3.get_field("c_solid")
1714 d = float(np.max(np.abs(p1 - p3)[us & ~iso]) / np.max(np.abs(p1)[us]))
1715 df = float(np.max(np.abs(s1.get_field("c") - s3.get_field("c"))) / np.max(np.abs(s1.get_field("c"))))
1716 c3 = s3.get_scalar_solid("c")
1717 k3 = float(np.max(np.abs(c3[us] - 3.0 * p3[us])))
1718 print(f" ratio={ratio}: max|psi_s(K=3) - psi_s(K=1)| {d:.1e}, fluid {df:.1e} (rel); "
1719 f"max|c_s - 3 psi_s| {k3:.1e}")
1720 check(d <= 1e-9 and df <= 1e-9, f"K = 3: psi identical to K = 1 within 1e-9 ({d:.1e}, {df:.1e})")
1721 check(k3 == 0.0, "K = 3: get_scalar_solid = 3 psi_s exactly")
1722
1723
1725 """k* of perfectly conducting spheres on the simple-cubic lattice, Andrianov & Topol eq. 157
1726 (L3 §2 B6)."""
1727 a1, a2, a3, a4, a5, a6 = 1.305, 0.231, 0.405, 0.0723, 0.153, 0.0105
1728 den = (-1.0 + c + a1 * c ** (10 / 3) * (1.0 + a2 * c ** (11 / 3)) / (1.0 - a3 * c ** (7 / 3)) +
1729 a4 * c ** (14 / 3) + a5 * c**6 + a6 * c ** (22 / 3))
1730 return 1.0 - 3.0 * c / den
1731
1732
1733def g5a_case(n, off, D=0.6, ratio=1e4):
1734 L = 1.0
1735 h = L / n
1736 s = pf.Solver((n, n, n), extent=(L, L, L))
1737 s.set_rho(1.0)
1738 s.set_mu(1.0)
1739 R = 0.5 * L * (0.3 * 6.0 / math.pi) ** (1.0 / 3.0)
1740 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
1741 X, Y, Z = grid(s)
1742 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
1743 s.set_solid(np.asfortranarray(r - R))
1744 s.add_scalar("c", diffusivity=D, cutcell=True)
1745 s.set_scalar_solid("c", diffusivity=ratio * D)
1746 s.set_scalar_mean_gradient("c", (1.0, 0.0, 0.0))
1747 s.solve_scalar_steady("c")
1748 J = np.asarray(s.scalar_mean_flux("c"))
1749 c = s.diagnostics.scalar_census("c")
1750 return dict(k=-J[0] / D, its=c["krylov_iterations"], conv=c["krylov_converged"], nd=2.0 * R / h,
1751 iso=int(solid_isolated(s).sum()), res=c["krylov_residual"],
1752 sealed=sealed_check(f"G5a n={n}", c))
1753
1754
1756 ref = a_t_keff_sc(0.3)
1757 print(f"G5a conducting periodic SC array, c = 0.3, Lam_s/Lam_f = 1e4, mean gradient e_x; "
1758 f"reference k* (Andrianov-Topol eq. 157, perfect conductor) = {ref:.5f}")
1759 k, its = {}, {}
1760 for n in (20, 40, 80):
1761 rows = [g5a_case(n, off) for off in OFFSETS]
1762 k[n] = float(np.mean([r["k"] for r in rows]))
1763 its[n] = [r["its"] for r in rows]
1764 print(f" n={n} (ND {rows[0]['nd']:.1f}) k* {fmt([r['k'] for r in rows], '.6f')} "
1765 f"rel err vs ref {k[n] / ref - 1.0:+.2e} iters {its[n]} residual {fmt([r['res'] for r in rows], '.1e')}"
1766 f" isolated solid {[r['iso'] for r in rows]}")
1767 for r in rows:
1768 check(r["conv"], f"n={n}: converged ({r['its']} iterations)")
1769 check(r["its"] <= 30, f"n={n}: G-iter singular steady <= 30 ({r['its']})")
1770 # ruling D-WO7-2: (i) order >= 1.7 over the ladder, (ii) the Richardson extrapolate (the
1771 # ladder's own order) within 5e-4 of the reference (orchestrator 2026-10-04: the reference is the
1772 # perfectly conducting limit while the run uses contrast 1e4, an offset of ~2e-4, plus a 1-2e-4
1773 # spread between the fitted-order and order-2 extrapolates; 1e-4 was tighter than the reference
1774 # itself), (iii) a regression bound of 2x the value
1775 # measured after D-WO7-1 at each rung (the provisional 3e-3 at ND 32 is retired; log WO-7)
1776 p = math.log2(abs(k[20] - k[40]) / abs(k[40] - k[80])) if k[40] != k[80] else float("inf")
1777 check(p >= 1.7, f"(i) self-convergence order {p:.2f} >= 1.7")
1778 kx = k[80] + (k[80] - k[40]) / (2.0**p - 1.0)
1779 check(abs(kx / ref - 1.0) <= 5e-4,
1780 f"(ii) extrapolate {kx:.6f}: |k*/ref - 1| {abs(kx / ref - 1.0):.2e} <= 5e-4 (order-2 extrapolate "
1781 f"{k[80] + (k[80] - k[40]) / 3.0:.6f}, INFO)")
1782 for n, b in ((20, 6.64e-2), (40, 1.82e-2), (80, 4.86e-3)): # 2 x 3.32e-2, 9.09e-3, 2.43e-3
1783 check(abs(k[n] / ref - 1.0) <= b, f"(iii) n={n}: |k*/ref - 1| {abs(k[n] / ref - 1.0):.2e} <= {b:.2e}")
1784
1785
1786def g6_exact(Ls, CK, Rc, R=1.0, Ro=2.0):
1787 """The slowest decay rate mu and the mode (P, B, C) of the composite sphere in the psi form
1788 (Lam_f = C_f = 1): solid r < R, psi_s = P sin(k_s r)/r; fluid R < r < Ro, psi_f = (B sin(k_f r)
1789 + C cos(k_f r))/r; psi_f(Ro) = 0, Lam_s psi_s' = psi_f' and psi_s - psi_f + R_c Lam_s psi_s' = 0
1790 at R (the l = 0 analogue of conj.py's exact())."""
1791 def mat(mu):
1792 ks, kf = math.sqrt(CK * mu / Ls), math.sqrt(mu)
1793 S = lambda k, r: math.sin(k * r) / r
1794 C = lambda k, r: math.cos(k * r) / r
1795 dS = lambda k, r: (k * math.cos(k * r) * r - math.sin(k * r)) / (r * r)
1796 dC = lambda k, r: (-k * math.sin(k * r) * r - math.cos(k * r)) / (r * r)
1797 return np.array([[0.0, S(kf, Ro), C(kf, Ro)],
1798 [Ls * dS(ks, R), -dS(kf, R), -dC(kf, R)],
1799 [S(ks, R) + Rc * Ls * dS(ks, R), -S(kf, R), -C(kf, R)]])
1800 det = lambda m: float(np.linalg.det(mat(m)))
1801 mus = np.linspace(1e-4, 40.0, 40000)
1802 d = [det(m) for m in mus]
1803 for i in range(len(mus) - 1):
1804 if d[i] * d[i + 1] < 0:
1805 a, b = mus[i], mus[i + 1]
1806 for _ in range(200):
1807 m = 0.5 * (a + b)
1808 if det(a) * det(m) <= 0:
1809 b = m
1810 else:
1811 a = m
1812 mu = 0.5 * (a + b)
1813 _, _, vt = np.linalg.svd(mat(mu))
1814 return mu, vt[-1], math.sqrt(CK * mu / Ls), math.sqrt(mu)
1815 raise RuntimeError("no root")
1816
1817
1818G6_CASES = ((10.0, 1.0, 1.0, 0.0), (0.1, 1.0, 1.0, 0.0), (100.0, 0.5, 1.0, 0.0), (3.0, 3.0, 3.0, 0.0),
1819 (1.0, 1.0, 1.0, 0.2), (5.0, 1.0, 0.5, 0.5))
1820
1821
1822# D-WO7-2 (iii): 2x the RMS error measured after D-WO7-1 (log WO-7), per case and rung
1823G6_BOUND = ({8: 1.48e-2, 16: 3.77e-3, 32: 9.56e-4}, {8: 3.37e-2, 16: 8.45e-3, 32: 2.14e-3},
1824 {8: 1.62e-2, 16: 4.16e-3, 32: 1.06e-3}, {8: 3.49e-3, 16: 9.12e-4, 32: 2.37e-4},
1825 {8: 7.55e-3, 16: 2.01e-3, 32: 5.16e-4}, {8: 7.41e-3, 16: 1.90e-3, 32: 4.82e-4})
1826
1827
1828def g6_case(Rh, off, case, nsteps=30):
1829 """G6: a conjugate core R = 1 (instance 0) inside an immersed Dirichlet sphere Ro = 2 (instance
1830 1, psi = 0); psi form (Lam_s, C_s K, K, R_c), Lam_f = C_f = 1; BE late-time ratio of the total
1831 mass (fluid + solid) with dt mu = 1, the exact mode as the initial condition."""
1832 Ls, CK, K, Rc = case
1833 R, Ro = 1.0, 2.0
1834 h = R / Rh
1835 n = int(math.ceil(2.0 * Ro / h)) + 6
1836 L = n * h
1837 s = walled((n, n, n), (L, L, L))
1838 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
1839 s.set_scene(*two_body_scene(c0, R, Ro), periodic=False)
1840 s.set_solid_from_scene(cutcell_pressure=False)
1841 mu, (P, B, C), ks, kf = g6_exact(Ls, CK, Rc)
1842 dt = 1.0 / mu
1843 s.set_dt(dt)
1844 add_cc(s, 1.0, box="neumann")
1845 s.set_scalar_wall("c", "dirichlet", 0.0, instance=1)
1846 s.set_scalar_solid("c", diffusivity=Ls / CK, capacity=CK / K, partition=K, contact_resistance=Rc,
1847 instance=0)
1848 X, Y, Z = grid(s)
1849 r = np.maximum(np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2), 1e-12)
1850 s.set_field("c", np.asfortranarray((B * np.sin(kf * r) + C * np.cos(kf * r)) / r))
1851 s.set_field("c_solid", np.asfortranarray(P * np.sin(ks * r) / r))
1852 masses, mfl, its, idmax = [], [], [], 0.0
1853 iso = int(solid_isolated(s).sum())
1854 for _ in range(nsteps):
1855 s.advance_scalars()
1856 b = s.diagnostics.scalar_budget("c")
1857 masses.append(b["mass"] + b["mass_solid"])
1858 mfl.append(b["mass"])
1859 idmax = max(idmax, abs(b["identity_error"]) / max(abs(b["d_mass"]), 1e-300))
1860 cen = s.diagnostics.scalar_census("c")
1861 its.append(cen["krylov_iterations"])
1862 if not cen["krylov_converged"]:
1863 return dict(err=float("nan"), its=its, conv=False, idrel=idmax, rungs=cen["probe_rungs"])
1864 mu_h = (masses[-2] / masses[-1] - 1.0) / dt
1865 mu_f = (mfl[-2] / mfl[-1] - 1.0) / dt # INFO: the fluid mass alone (same mode, no solid cells)
1866 sealed_check(f"G6 case {G6_CASES.index(case) + 1} R/h={Rh}", cen)
1867 return dict(err=mu_h / mu - 1.0, errf=mu_f / mu - 1.0, its=its, conv=True, idrel=idmax,
1868 rungs=cen["probe_rungs"], nsolid=cen["num_solid_unknowns"], iso=iso)
1869
1870
1871def gate_g6(cases=None):
1872 print("G6 transient composite sphere (conjugate core R, immersed Dirichlet sphere 2R), "
1873 "BE late-time ratio, dt mu = 1, 30 steps; R/h in {8, 16, 32}")
1874 for ci, case in enumerate(G6_CASES):
1875 if cases is not None and ci not in cases:
1876 continue
1877 mu = g6_exact(case[0], case[1], case[3])[0]
1878 e = {}
1879 for Rh in (8, 16, 32):
1880 t0 = time.time()
1881 rows = [g6_case(Rh, off, case) for off in OFFSETS]
1882 e[Rh] = rms([r["err"] for r in rows])
1883 print(f" case {ci + 1} (Lam_s, CK, K, Rc) = {case} mu = {mu:.6f} R/h={Rh:3d} "
1884 f"mu rel err {fmt([r['err'] for r in rows], '+.3e')} (INFO fluid mass: "
1885 f"{fmt([r.get('errf', float('nan')) for r in rows], '+.3e')}; isolated solid "
1886 f"{[r.get('iso') for r in rows]}) identity/|dM| <= "
1887 f"{max(r['idrel'] for r in rows):.1e} iters/step {min(min(r['its']) for r in rows)}.."
1888 f"{max(max(r['its']) for r in rows)} rungs {rows[0]['rungs']} ({time.time() - t0:.0f} s)")
1889 for r in rows:
1890 check(r["conv"], f"case {ci + 1} R/h={Rh}: every step converged")
1891 check(r["idrel"] <= 1e-13, f"case {ci + 1} R/h={Rh}: budget identity {r['idrel']:.1e} <= 1e-13 |d_mass|")
1892 check(max(r["its"]) <= 30, f"case {ci + 1} R/h={Rh}: G-iter conjugate <= 30 ({max(r['its'])})")
1893 o1, o2 = order(e[8], e[16]), order(e[16], e[32])
1894 check(o1 >= 1.7 and o2 >= 1.7, f"case {ci + 1}: (i) orders {o1:.2f}, {o2:.2f} >= 1.7")
1895 # ruling D-WO7-2 (iii): a regression bound of 2x the value measured after D-WO7-1, at every
1896 # rung (the provisional 2e-3 at R/h 16 is retired; old and measured values in the log)
1897 for Rh in (8, 16, 32):
1898 b = G6_BOUND[ci][Rh]
1899 check(e[Rh] <= b, f"case {ci + 1}: (iii) |err| {e[Rh]:.3e} <= {b:.2e} at R/h = {Rh}")
1900
1901
1903 """WO-7's extra G-adv row (design A2): G-adv(b) with conducting spheres, Lam_s/Lam_f = 10, K = 1,
1904 at Pe_h 1 and 10 (the mean-gradient mode, G = e_x): converged, in <= 1.5x the insulating (b)
1905 count of the same field."""
1906 print("G-adv(b) conjugate: conducting spheres Lam_s/Lam_f = 10 vs insulating, Pe_h 1 and 10")
1907 for n in (32, 64):
1908 for pe in (1.0, 10.0):
1909 si = gadv_b_case(n, pe, "neumann")
1910 ci = si.diagnostics.scalar_census("c")
1911 s = sc_array(n)
1912 s.add_scalar("c", diffusivity=0.05, cutcell=True)
1913 s.set_scalar_solid("c", diffusivity=0.5)
1914 s.set_scalar_mean_gradient("c", (1.0, 0.0, 0.0))
1915 rescale_peclet(s, "c", pe)
1916 s.solve_scalar_steady("c")
1917 cc = s.diagnostics.scalar_census("c")
1918 b = s.diagnostics.scalar_budget("c")
1919 print(f" n={n} Pe_h {cc['max_cell_peclet']:.3g}: conjugate {cc['krylov_iterations']} it "
1920 f"(converged {cc['krylov_converged']}, residual {cc['krylov_residual']:.1e}), insulating "
1921 f"{ci['krylov_iterations']} it; identity_error {b['identity_error']:.1e}")
1922 check(cc["krylov_converged"], f"n={n} Pe_h={pe}: conjugate converged")
1923 check(cc["krylov_iterations"] <= 1.5 * ci["krylov_iterations"],
1924 f"n={n} Pe_h={pe}: {cc['krylov_iterations']} <= 1.5 x {ci['krylov_iterations']}")
1925
1926
1927# --------------------------------------------------------------------------------------- API ----
1928def raises(exc, fn, msg):
1929 try:
1930 fn()
1931 except exc:
1932 check(True, msg)
1933 return
1934 except Exception as e: # noqa: BLE001 - report the wrong type
1935 check(False, f"{msg} (raised {type(e).__name__}: {e})")
1936 return
1937 check(False, msg + " (did not raise)")
1938
1939
1940def raises_naming(exc, fn, needles, msg):
1941 """`raises`, and the message names every one of `needles` (the remedy)."""
1942 try:
1943 fn()
1944 except exc as e:
1945 missing = [w for w in needles if w not in str(e)]
1946 check(not missing, f"{msg}" + (f" (message lacks {missing}: {e})" if missing else ""))
1947 return
1948 except Exception as e: # noqa: BLE001 - report the wrong type
1949 check(False, f"{msg} (raised {type(e).__name__}: {e})")
1950 return
1951 check(False, msg + " (did not raise)")
1952
1953
1955 print("API: the opt-in, the refusals (§8.1) and the name checks (ruling D-WO3-2)")
1956 n = 12
1957 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
1958 s.set_rho(1.0)
1959 s.set_mu(1.0)
1960 s.set_dt(0.1)
1961 X, Y, Z = grid(s)
1962 s.set_solid(np.asfortranarray(np.sqrt((X - 0.5) ** 2 + (Y - 0.5) ** 2 + (Z - 0.5) ** 2) - 0.2))
1963 raises(ValueError, lambda: s.add_scalar("bad", 1.0, cutcell=True, iters=5),
1964 "add_scalar(cutcell=True, iters=5) is a ValueError")
1965 s.add_scalar("legacy", 1.0)
1966 s.add_scalar("c", 1.0, cutcell=True)
1967 for nm, fn in (("scalar_geometry", lambda: s.diagnostics.scalar_geometry("legacy")),
1968 ("scalar_census", lambda: s.diagnostics.scalar_census("legacy")),
1969 ("scalar_budget", lambda: s.diagnostics.scalar_budget("legacy")),
1970 ("set_scalar_wall", lambda: s.set_scalar_wall("legacy", "dirichlet", 1.0)),
1971 ("set_scalar_source", lambda: s.set_scalar_source("legacy", 1.0)),
1972 ("solve_scalar_steady", lambda: s.solve_scalar_steady("legacy"))):
1973 raises(ValueError, fn, f"{nm} on a legacy scalar is a ValueError")
1974 raises(ValueError, lambda: s.set_scalar_wall("c", "dirichlet", 1.0, instance=0),
1975 "set_scalar_wall(instance=0) without a scene is a ValueError")
1976 raises(ValueError, lambda: s.set_scalar_wall("c", "robin", 0.0, coefficient=-1.0),
1977 "a negative robin coefficient is a ValueError")
1978 raises(ValueError, lambda: s.set_scalar_tolerance("c", 0.0), "rtol = 0 is a ValueError")
1979 raises(ValueError, lambda: s.set_scalar_bc("c", "-x", "dirichlet", np.zeros((n, n + 1))),
1980 "a profile of the wrong shape is a ValueError")
1981 s.set_scalar_wall("c", "dirichlet", 1.0)
1982 s.advance_scalars() # the periodic box: one step runs
1983 c = s.diagnostics.scalar_census("c")
1984 # dt D/h^2 = 14.4 -> kappa_A > 13: the full ScalarMG table, 12 -> 6 -> 3
1985 check(c["krylov_converged"] and c["mg_levels"] == 3,
1986 f"one step converged ({c['krylov_iterations']} iterations, {c['mg_levels']} MG levels)")
1987 s.set_scalar_bc("c", "-x", "dirichlet", 0.5) # the flow -x face is periodic
1988 raises(RuntimeError, s.advance_scalars, "a scalar Dirichlet face against a periodic flow face is refused")
1989 s.set_scalar_bc("c", "-x", "periodic")
1990 raises(ValueError, lambda: s.set_scalar_mean_gradient("c", (float("nan"), 0.0, 0.0)),
1991 "a non-finite mean gradient is a ValueError")
1992 raises(ValueError, lambda: s.set_scalar_mean_gradient("legacy", (1.0, 0.0, 0.0)),
1993 "set_scalar_mean_gradient on a legacy scalar is a ValueError")
1994 s.set_scalar_mean_gradient("c", (1.0, 0.0, 0.0))
1995 s.advance_scalars() # periodic box: the closure mode steps
1996 sw = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
1997 sw.set_rho(1.0)
1998 sw.set_mu(1.0)
1999 sw.set_domain_bc("-y", "wall")
2000 sw.set_domain_bc("+y", "wall")
2001 sw.set_solid(np.asfortranarray(np.sqrt((X - 0.5) ** 2 + (Y - 0.5) ** 2 + (Z - 0.5) ** 2) - 0.2))
2002 sw.add_scalar("c", 1.0, cutcell=True)
2003 sw.set_scalar_bc("c", "-y", "neumann")
2004 sw.set_scalar_bc("c", "+y", "neumann")
2005 sw.set_scalar_mean_gradient("c", (1.0, 0.0, 0.5)) # x, z periodic: accepted
2006 sw.solve_scalar_steady("c")
2007 sw.set_scalar_mean_gradient("c", (0.0, 1.0, 0.0))
2008 raises_naming(RuntimeError, lambda: sw.solve_scalar_steady("c"), ("along y", "-y"),
2009 "a mean gradient along a non-periodic axis is refused, naming the axis and face")
2010 s.set_scalar_mean_gradient("c", (0.0, 0.0, 0.0))
2011 s.set_porous_continuity(True)
2012 raises(RuntimeError, s.advance_scalars, "porous continuity is refused")
2013 # ruling D-WO5-2: a moving fluid without the cut-cell projection is refused, naming the remedy
2014 # (a fluid at rest keeps WO-3/WO-4's behaviour: the periodic box above stepped)
2015 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
2016 s.set_rho(1.0)
2017 s.set_mu(1.0)
2018 s.set_dt(0.1)
2019 X, Y, Z = grid(s)
2020 s.set_solid(np.asfortranarray(np.sqrt((X - 0.5) ** 2 + (Y - 0.5) ** 2 + (Z - 0.5) ** 2) - 0.2))
2021 s.add_scalar("c", 1.0, cutcell=True)
2022 s.set_field("u", np.asfortranarray(np.full((n, n, n), 0.5)))
2023 raises_naming(RuntimeError, s.advance_scalars, ("cutcell_pressure=True", "set_pressure_geometry"),
2024 "a moving fluid without cutcell_pressure is refused, naming the remedy (D-WO5-2)")
2025 # ruling D-WO5-4: the ghost projection is refused, the message listing the accepted ones
2026 s = pf.SolverColocated((n, n, n), extent=(1.0, 1.0, 1.0))
2027 s.set_rho(1.0)
2028 s.set_mu(1.0)
2029 s.set_dt(0.1)
2030 s.set_collocated_scheme("ghost")
2031 s.set_solid(np.asfortranarray(np.sqrt((X - 0.5) ** 2 + (Y - 0.5) ** 2 + (Z - 0.5) ** 2) - 0.2),
2032 cutcell_pressure=True)
2033 s.add_scalar("c", 1.0, cutcell=True)
2034 raises_naming(RuntimeError, s.advance_scalars,
2035 ("'gauge-exact'", "'plain'", "'embed'", "staggered Solver"),
2036 "the 'ghost' scheme is refused, listing gauge-exact, plain, embed and the staggered "
2037 "Solver (D-WO5-4)")
2038 # review finding 1 (ruling D-WOR-1): collocated, a raw set_field('u') never builds the projected
2039 # face field, so a moving fluid before the first step() is refused, naming the remedy (the
2040 # reviewer's probe: it silently returned the pure-diffusion solution); seeded through
2041 # set_velocity, the same case advects
2042 for seed in ("set_field", "set_velocity"):
2043 s = pf.SolverColocated((n, n, n), extent=(1.0, 1.0, 1.0))
2044 s.set_rho(1.0)
2045 s.set_mu(1.0)
2046 s.set_dt(0.1)
2047 s.set_collocated_scheme("gauge-exact")
2048 s.set_solid(np.asfortranarray(np.sqrt((X - 0.5) ** 2 + (Y - 0.5) ** 2 + (Z - 0.5) ** 2) - 0.2),
2049 cutcell_pressure=True)
2050 s.add_scalar("c", 1.0, cutcell=True)
2051 s.set_scalar_wall("c", "dirichlet", 1.0)
2052 if seed == "set_field":
2053 s.set_field("u", np.asfortranarray(np.full((n, n, n), 0.5)))
2054 for fn, nm in ((lambda: s.solve_scalar_steady("c"), "solve_scalar_steady"),
2055 (s.advance_scalars, "advance_scalars")):
2056 raises_naming(RuntimeError, fn, ("FACE field", "set_velocity", "step()"),
2057 f"collocated {nm} with an unbuilt face field and a moving fluid is "
2058 "refused, naming the remedy (D-WOR-1)")
2059 else:
2060 s.set_velocity(0, np.asfortranarray(np.full((n, n, n), 0.5)))
2061 s.solve_scalar_steady("c")
2062 cen = s.diagnostics.scalar_census("c")
2063 check(cen["max_cell_peclet"] > 0.0 and cen["num_flux_faces"] > 0,
2064 f"collocated, seeded by set_velocity: the steady solve advects (Pe_h "
2065 f"{cen['max_cell_peclet']:.3g}, {cen['num_flux_faces']} flux faces)")
2066 # review finding 4 (ruling D-WOR-4): a level table that stops above the pressure's bottom extent
2067 # (an odd box cannot halve: level 0 alone, 15^3) warns once per scalar; a factor-of-two box not
2068 for m, want in ((15, True), (16, False)):
2069 s = pf.Solver((m, m, m), extent=(1.0, 1.0, 1.0))
2070 Xm, Ym, Zm = grid(s)
2071 s.set_solid(np.asfortranarray(np.sqrt((Xm - 0.5) ** 2 + (Ym - 0.5) ** 2 + (Zm - 0.5) ** 2) - 0.2))
2072 s.add_scalar("c", 1.0, cutcell=True)
2073 s.set_scalar_wall("c", "dirichlet", 1.0)
2074 _, err = capture_stderr(lambda: s.solve_scalar_steady("c"))
2075 _, err2 = capture_stderr(lambda: s.solve_scalar_steady("c"))
2076 got = "ScalarMG level table stops" in err
2077 check(got == want and "ScalarMG" not in err2,
2078 f"{m}^3 steady: the shallow-bottom warning is {'printed once' if want else 'absent'} "
2079 f"(D-WOR-4; {err.strip()[:90]!r})")
2080 # review finding 7 (ruling D-WOR-7): koren above bulk Courant 1/2 warns once (fou never); a
2081 # rebuilt geometry re-issues the §9 resolution warnings (a 0.4 h plate: thin solid)
2082 for scheme in ("koren", "fou"):
2083 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
2084 s.set_dt(0.05)
2085 s.set_solid(np.asfortranarray(np.sqrt((X - 0.5) ** 2 + (Y - 0.5) ** 2 + (Z - 0.5) ** 2) - 0.2),
2086 cutcell_pressure=True)
2087 s.add_scalar("c", 0.01, scheme=scheme, cutcell=True)
2088 s.set_field("u", np.asfortranarray(np.full((n, n, n), 0.75)))
2089 _, err = capture_stderr(s.advance_scalars)
2090 _, err2 = capture_stderr(s.advance_scalars)
2091 cb = s.diagnostics.scalar_census("c")["bulk_courant"]
2092 want = scheme == "koren"
2093 check(cb > 0.5 and ("'koren' at bulk Courant" in err) == want and "Courant" not in err2,
2094 f"{scheme} at bulk Courant {cb:.3f}: the koren warning is "
2095 f"{'printed once' if want else 'absent'} (D-WOR-7)")
2096 h = 1.0 / n
2097 plate = np.asfortranarray(np.abs(X - (n // 2 + 0.55) * h) - 0.2 * h)
2098 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
2099 s.set_solid(plate)
2100 s.add_scalar("c", 1.0, cutcell=True)
2101 errs = [capture_stderr(lambda: s.solve_scalar_steady("c"))[1]]
2102 errs.append(capture_stderr(lambda: s.solve_scalar_steady("c"))[1])
2103 s.set_solid(plate)
2104 errs.append(capture_stderr(lambda: s.solve_scalar_steady("c"))[1])
2105 check(["thin-solid" in e for e in errs] == [True, False, True],
2106 f"the thin-solid warning: once per geometry, again after set_solid (D-WOR-7; "
2107 f"{['thin-solid' in e for e in errs]})")
2108 # WO-7 (§8.1): set_scalar_solid's validation, the registered solid field, get_scalar_solid
2109 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
2110 s.set_rho(1.0)
2111 s.set_mu(1.0)
2112 s.set_dt(0.1)
2113 X, Y, Z = grid(s)
2114 s.set_solid(np.asfortranarray(np.sqrt((X - 0.5) ** 2 + (Y - 0.5) ** 2 + (Z - 0.5) ** 2) - 0.2))
2115 s.add_scalar("legacy", 1.0)
2116 s.add_scalar("c", 1.0, cutcell=True)
2117 raises(ValueError, lambda: s.set_scalar_solid("legacy", 1.0), "set_scalar_solid on a legacy scalar")
2118 for kw, nm in (({"diffusivity": -1.0}, "diffusivity < 0"), ({"capacity": 0.0}, "capacity = 0"),
2119 ({"partition": 0.0}, "partition = 0"), ({"contact_resistance": -1.0}, "R_c < 0"),
2120 ({"instance": 0}, "instance= without a scene")):
2121 args = {"diffusivity": 1.0}
2122 args.update(kw)
2123 raises(ValueError, lambda a=args: s.set_scalar_solid("c", **a), f"set_scalar_solid: {nm} is a ValueError")
2124 check("c_solid" not in s.field_names(), "no solid field before set_scalar_solid")
2125 s.set_scalar_solid("c", 2.0, capacity=0.5, partition=3.0)
2126 check("c_solid" in s.field_names(), "set_scalar_solid registers 'c_solid'")
2127 check(bool(np.all(np.isnan(s.get_scalar_solid("c")))), "get_scalar_solid before a solve: all NaN")
2128 s.set_field("c", np.asfortranarray(np.ones((n, n, n))))
2129 s.set_field("c_solid", np.asfortranarray(np.ones((n, n, n)))) # psi = 1 in both: equilibrium
2130 s.advance_scalars()
2131 c = s.diagnostics.scalar_census("c")
2132 cs = s.get_scalar_solid("c")
2133 geo = s.diagnostics.scalar_geometry("c")
2134 us = geo["solid_unknown"] > 0.5
2135 check(c["krylov_converged"] and c["num_solid_unknowns"] == int(us.sum()) > 0,
2136 f"a conjugate step converged ({c['krylov_iterations']} it, {c['num_solid_unknowns']} solid unknowns)")
2137 check(float(np.max(np.abs(cs[us] - 3.0))) <= 1e-9 and float(np.max(np.abs(s.get_field("c")[geo["unknown"] > 0.5] - 1.0))) <= 1e-9,
2138 "psi = 1 in both phases is an equilibrium: c = 1, c_s = K = 3")
2139 s.set_scalar_wall("c", "neumann", 0.0) # every body a wall again: the single-phase path
2140 s.advance_scalars()
2141 check(s.diagnostics.scalar_census("c")["num_solid_unknowns"] == 0, "set_scalar_wall(instance=None) ends the conjugate path")
2142
2143
2144# --------------------------------------------------------------------------------------- G13 ----
2145G13_CASES = ("gap", "contact", "conj")
2146
2147
2148def sphere_pair_scene(c1, c2, R):
2149 """Two sphere instances (body ids 0 and 1) of radius R centred at c1, c2."""
2150 ni = np.array([1, -1, -1], dtype=np.int32)
2151 nr = np.zeros(16)
2152 nr[0] = R
2153 nr[14] = nr[15] = 1.0 # rotation w, scale
2154 ii = np.array([0, -1, 0, -1], dtype=np.int32)
2155 ir = np.zeros(2 * 18)
2156 for k, c in enumerate((c1, c2)):
2157 ir[18 * k:18 * k + 3] = c
2158 ir[18 * k + 6] = 1.0
2159 ir[18 * k + 7] = 1.0
2160 return ni, nr, ii, ir
2161
2162
2163def g13_case(Rh, off, case):
2164 """§11 G13: two spheres R = 1 along x in a box (6R, 4R, 4R) with Dirichlet box faces, steady.
2165 'gap' / 'contact': Dirichlet spheres c = 1 at a surface gap R/32 / an overlap R/64, far field 0;
2166 the functional is the total wall flux into the fluid. 'conj': the contacting pair conjugate at
2167 Lam_s/Lam_f = 100 (K = 1), the box faces at the far-field profile c = x - x_c (the pair alone at
2168 rest would carry no flux); the functional is the heat entering through the -x box face,
2169 Q = sum 2 D (g - c_0) h over its face cells (the operator's own boundary row; no solid touches
2170 the box faces)."""
2171 R, D = 1.0, 0.7
2172 h = R / Rh
2173 cells = (6 * Rh, 4 * Rh, 4 * Rh)
2174 L = (6.0 * R, 4.0 * R, 4.0 * R)
2175 s = walled(cells, L)
2176 xc = np.array([0.5 * L[0], 0.5 * L[1], 0.5 * L[2]]) + np.asarray(off) * h
2177 d = 2.0 * R + (R / 32.0 if case == "gap" else -R / 64.0)
2178 c1, c2 = xc - [0.5 * d, 0.0, 0.0], xc + [0.5 * d, 0.0, 0.0]
2179 s.set_scene(*sphere_pair_scene(c1, c2, R), periodic=False)
2180 s.set_solid_from_scene(cutcell_pressure=False)
2181 if case == "conj":
2182 add_cc(s, D, box="neumann")
2183 for f in FACES:
2184 P = face_points(s, f)
2185 s.set_scalar_bc("c", f, "dirichlet", P[0] - xc[0])
2186 s.set_scalar_solid("c", diffusivity=100.0 * D)
2187 else:
2188 add_cc(s, D, box="dirichlet")
2189 s.set_scalar_wall("c", "dirichlet", 1.0)
2190 s.solve_scalar_steady("c")
2191 cen = s.diagnostics.scalar_census("c")
2192 if case == "conj":
2193 P = face_points(s, "-x")
2194 c = s.get_field("c")
2195 F = float(np.sum(2.0 * D * ((P[0] - xc[0]) - c[0, :, :]) * h))
2196 else:
2197 F = float(np.sum(s.scalar_wall_flux("c")))
2198 b = s.diagnostics.scalar_budget("c")
2199 return dict(F=F, it=cen["krylov_iterations"], conv=cen["krylov_converged"], cen=cen, b=b,
2200 finite=bool(np.all(np.isfinite(s.get_field("c")[s.diagnostics.scalar_geometry("c")["unknown"] > 0.5]))))
2201
2202
2203def g13_rungs(cen):
2204 """(R2 fraction, rungs) over both phases' probes (the solid tuple on a conjugate scalar)."""
2205 pr = cen["probe_rungs"]
2206 tot = sum(pr["fluid"]) + (sum(pr["solid"]) if "solid" in pr else 0)
2207 r2 = pr["fluid"][3] + (pr["solid"][3] if "solid" in pr else 0)
2208 return (r2 / tot if tot else 0.0), pr
2209
2210
2212 """§11 G13 thin plate: a solid slab 0.4 h thick across the box, its mid-plane 0.05 h off a
2213 cell-centre plane (a slab between two centre planes leaves every PL sample positive); the census
2214 counts thin-solid cells and the §9 warning is printed (stderr, captured at the fd)."""
2215 import os
2216 import tempfile
2217 n, L = 16, 1.0
2218 h = L / n
2219 s = walled((n, n, n), (L, L, L))
2220 X, _, _ = grid(s)
2221 x0 = (n // 2 + 0.5) * h + 0.05 * h
2222 s.set_solid(np.asfortranarray(np.abs(X - x0) - 0.2 * h))
2223 add_cc(s, 0.7, box="dirichlet")
2224 s.set_scalar_wall("c", "dirichlet", 1.0)
2225 with tempfile.TemporaryFile(mode="w+b") as tmp:
2226 sys.stderr.flush()
2227 fd = os.dup(2)
2228 os.dup2(tmp.fileno(), 2)
2229 try:
2230 s.solve_scalar_steady("c")
2231 finally:
2232 os.dup2(fd, 2)
2233 os.close(fd)
2234 tmp.seek(0)
2235 err = tmp.read().decode(errors="replace")
2236 cen = s.diagnostics.scalar_census("c")
2237 return cen, err
2238
2239
2240def gate_g13(cases=G13_CASES, rungs=(8, 16, 32)):
2241 print("G13 contacts: two spheres R = 1 in a box (6R, 4R, 4R), R/h in {8, 16, 32}, steady")
2242 for case in cases:
2243 # ruling D-WO8-2: the conjugate contact row is INFO (its discrete solid bridge is
2244 # pre-asymptotic; §13 Q4 triggered); the Dirichlet rows and the thin plate stay gated
2245 info = case == "conj"
2246 if info:
2247 print(" OPEN: contact model pending (§13 Q4, Frank) -- the conjugate contact row is INFO")
2248
2249 def check(cond, msg, _info=info):
2250 if _info:
2251 print((" info ok " if cond else " info MISS ") + msg)
2252 else:
2253 globals()["check"](cond, msg)
2254 F, its = {}, {}
2255 for Rh in rungs:
2256 rows = [g13_case(Rh, off, case) for off in OFFSETS]
2257 F[Rh] = float(np.mean([r["F"] for r in rows]))
2258 its[Rh] = [r["it"] for r in rows]
2259 fr = [g13_rungs(r["cen"]) for r in rows]
2260 print(f" {case:7s} R/h={Rh:3d} F = {[format(r['F'], '.6e') for r in rows]} (mean {F[Rh]:.6e}) iters {its[Rh]}"
2261 f" two-sided {[r['cen']['num_two_sided'] for r in rows]} thin {[r['cen']['num_thin_solid'] for r in rows]}"
2262 f" rungs {[p for _, p in fr]}")
2263 for r, (r2, _) in zip(rows, fr):
2264 check(r["conv"] and r["finite"], f"{case} R/h={Rh}: converged and finite ({r['it']} iterations)")
2265 check(r["it"] <= 50, f"{case} R/h={Rh}: <= 50 iterations ({r['it']})")
2266 if case == "gap" and Rh >= 32: # gap R/32 >= h: no cell holds both walls (reading, WO-8)
2267 print(f" info gap R/h={Rh}: two-sided {r['cen']['num_two_sided']} (the gap is >= h)")
2268 else:
2269 check(r["cen"]["num_two_sided"] > 0, f"{case} R/h={Rh}: census two-sided > 0 ({r['cen']['num_two_sided']})")
2270 check(r2 <= 0.01, f"{case} R/h={Rh}: R2 <= 1 % of the probes ({100 * r2:.2f} %)")
2271 if len(rungs) == 3:
2272 a, b, c = (F[k] for k in rungs)
2273 p = order(abs(a - b), abs(b - c))
2274 check(p >= 1.0, f"{case}: total-flux self-convergence order {p:.2f} >= 1.0 "
2275 f"(|F8 - F16| {abs(a - b):.3e}, |F16 - F32| {abs(b - c):.3e})")
2276 cen, err = g13_thin_plate()
2277 print(f" thin plate 0.4h: num_thin_solid {cen['num_thin_solid']}, two-sided {cen['num_two_sided']}, "
2278 f"rungs {cen['probe_rungs']}; stderr: {err.strip()!r}")
2279 check(cen["num_thin_solid"] > 0, f"thin plate: num_thin_solid > 0 ({cen['num_thin_solid']})")
2280 check("thin" in err, "thin plate: the §9 thin-solid warning is printed")
2281
2282
2283GATES = {"api": gate_api, "g1": gate_g1, "g2": gate_g2, "g3a": gate_g3a, "g3b": gate_g3b, "g7": gate_g7,
2284 "giter": gate_giter, "g9": gate_g9, "g9b": gate_g9b, "g9c": gate_g9c, "gadv": gate_gadv,
2285 "g8": gate_g8, "g5b": gate_g5b, "conj_unit": gate_conj_unit, "g4": gate_g4, "g5a": gate_g5a,
2286 "g6": gate_g6, "gadv_conj": gate_gadv_conj, "g13": gate_g13}
2287for _ci in range(len(G6_CASES)): # one ctest per G6 case (each runs the full ladder)
2288 GATES[f"g6_{_ci + 1}"] = (lambda c=_ci: gate_g6(cases=(c,)))
2289
2290if __name__ == "__main__":
2291 names = sys.argv[1:] or list(GATES)
2292 for nm in names:
2293 t0 = time.time()
2294 GATES[nm]()
2295 print(f"[{nm}: {time.time() - t0:.0f} s]")
2296 if failures:
2297 print(f"{len(failures)} FAILED")
2298 sys.exit(1)
2299 print("PASS")
channel_case(kind="staggered", n=24, dt=0.008, D=0.01, scheme="koren", out_bc="neumann", c0=0.2, cin=1.0, cap=True)
gadv_row(tag, s, name, pe, bound, rate=None)
g8_case(Rh, off, U=1.0, Pe=10.0, nbox=None, nz=4, exact_flux=True)
maxwell_fields(X, Y, Z, c0, R, G, ratio)
g4_case(Rh, off, ratio, K=1.0, rtol=None, G=(0.3, -0.5, 0.8))
add_cc(s, D, box="neumann", periodic_z=False)
cell_velocity(s, name, periodic=(True, True, True))
g6_case(Rh, off, case, nsteps=30)
g6_exact(Ls, CK, Rc, R=1.0, Ro=2.0)
gate_g13(cases=G13_CASES, rungs=(8, 16, 32))
g7a_case(Rh, off, neumann, nsteps=30)
g9b_case(kind, scheme, n=32, nsteps=50)
g9_case(Rh, off, scheme, cr, rtol=None, nsteps=None)
no_solid_case(G, vel=None, D=0.6, n=16)
g5a_case(n, off, D=0.6, ratio=1e4)
annulus_case(Rh, off, scheme="koren")
poiseuille_face_average(s, c0, R, U, oz, m=24)
walled(cells, extent, origin=(0.0, 0.0, 0.0), periodic_z=False)
mean_flux_fields(s, name, D, G, periodic)
static std::vector< double > slice(const std::vector< double > &g, int ox, int oy, int oz, int lnx, int lny, int lnz)