2"""Cut-cell scalar transport: the accuracy and iteration gates of WO-3 to WO-6 (doc/scalar_ibm_design.md §11).
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.
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
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.
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
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.
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).
80 print(
"SKIP: peclet.flow not importable")
83FACES = (
"-x",
"+x",
"-y",
"+y",
"-z",
"+z")
84OFFSETS = np.random.default_rng(20261003).uniform(-0.5, 0.5, size=(3, 3))
89 print((
" ok " if cond
else " FAIL ") + msg)
95 v = np.asarray(v, dtype=float)
96 return float(np.sqrt(np.mean(v * v)))
100 return math.log2(ec / ef)
if ec > 0
and ef > 0
else float(
"inf")
104 x, y, z = s.cell_centers()
105 return np.meshgrid(x, y, z, indexing=
"ij")
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
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
123def walled(cells, extent, origin=(0.0, 0.0, 0.0), periodic_z=
False):
124 s = pf.Solver(cells, extent=extent, origin=origin)
126 if not (periodic_z
and f
in (
"-z",
"+z")):
127 s.set_domain_bc(f,
"wall")
133def add_cc(s, D, box="neumann", periodic_z=False):
134 s.add_scalar(
"c", diffusivity=D, cutcell=
True)
136 if periodic_z
and f
in (
"-z",
"+z"):
138 s.set_scalar_bc(
"c", f, box)
142 """§11 G-iter on a ladder: {rung: [iterations per offset]} -> <= 20 each, growth <= 3."""
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})")
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)))
159 c = s.diagnostics.scalar_census(
"c")
160 return c[
"krylov_iterations"], c[
"krylov_converged"], c[
"probe_rungs"][
"fluid"]
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))
171 c0 = np.array([0.5 * L + off[0] * h, 0.5 * L + off[1] * h, 0.5 * L + off[2] * hz])
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")
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)
185 b = s.diagnostics.scalar_budget(
"c")
187 return dict(err=nu / 2.0 - 1.0, l1=l1, linf=linf, it=it, conv=conv, rungs=rungs, b=b, flux=flux)
191 print(
"G1 Dirichlet sphere (steady), R/h in {8, 16, 32}, box 4R")
193 for Rh
in (8, 16, 32):
194 rows = [
g1_case(Rh, off)
for off
in OFFSETS]
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}")
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']})")
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}")
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]}")
229 check(r[
"conv"], f
"aniso R/h={Rh}: the solve converged ({r['it']} iterations)")
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})")
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)
242 nr[16 * k + 14] = 1.0
243 nr[16 * k + 15] = 1.0
245 nr[16 + 0] = nr[16 + 1] = nr[16 + 2] = 1e3
247 ii = np.array([0, -1, 3, -1], dtype=np.int32)
248 ir = np.zeros(2 * 18)
250 ir[18 * k:18 * k + 3] = c0
253 return ni, nr, ii, ir
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)."""
260 return ((n + m - 1) // m) * m
264 Ri, D, q, co = 1.0, 0.8, 0.3, 0.0
267 n = int(math.ceil(2.0 * Ro / h)) + 6
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
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)
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")
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)
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]}")
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")
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]}")
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)]})")
329 n = int(round(L / h))
330 s =
walled((n, n, n), (L, L, L))
332 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
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")
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)
346 return dict(err=sh / (2.0 * A) - 1.0, it=it, conv=conv, rungs=rungs)
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):
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]}")
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.)")
372 f =
lambda l: l * math.cos(l) - (1.0 - Bi) * math.sin(l)
373 a, b = 1e-9, math.pi - 1e-12
388 s =
walled((n, n, n), (L, L, L))
390 mu = D * lam * lam / (R * R)
393 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
395 r = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2 + (Z - c0[2]) ** 2)
396 s.set_solid(np.asfortranarray(R - r))
397 add_cc(s, D, box=
"neumann")
399 s.set_scalar_wall(
"c",
"dirichlet", 0.0)
401 s.set_scalar_wall(
"c",
"robin", 0.0, coefficient=Bi * D / 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):
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)
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)
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):
423 for Rh
in (8, 16, 32):
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)")
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.)")
441J01_SQ = 5.783185962946784
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
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
462 t, s, k = 1.0, 1.0, 0
463 while abs(t) > 1e-17:
465 t *= -(x * x / 4.0) / (k * k)
473 while abs(t) > 1e-17:
475 t *= -(x * x / 4.0) / (k * (k + 1))
482 s, dx, dy, rr =
pipe(Rh, off, D)
483 eig = J11P_SQ
if neumann
else J01_SQ
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)
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)
496 c = np.vectorize(j0)(math.sqrt(J01_SQ) * rr)
497 mom = np.ones_like(dx)
498 s.set_field(
"c", np.asfortranarray(c))
500 for k
in range(nsteps):
502 vals.append(float(np.sum(kap * mom * s.get_field(
"c"))))
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)
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)
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)
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")
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)))
528 if k > 3
and abs(bnew - beta) <= 1e-12 * abs(bnew):
532 nu = beta * 1.0 * 1.0 / D
533 return dict(err=nu / GRAETZ - 1.0, its=its, conv=conv, rungs=rungs)
537 print(
"G7a pipe decay (BE ratio, dt mu = 1, 30 steps), R/h in {16, 32, 64}")
538 for neumann
in (
False,
True):
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)}")
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}")
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)}")
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.)")
568 """G1's sphere, transient at dt D/h^2 = 1 from c = 0 (Dirichlet wall c = 1, box c = 0)."""
572 n = int(round(L / h))
573 s =
walled((n, n, n), (L, L, L))
575 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
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)
582 for k
in range(nsteps):
584 c = s.diagnostics.scalar_census(
"c")
585 its.append(c[
"krylov_iterations"])
586 lv.append(c[
"mg_levels"])
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)."""
595 s = pf.Solver((n, n, n), extent=(L, L, L))
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
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
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):
615 print(f
" R/h={Rh:3d} iterations/step {[its for its, _ in rows]} mg levels {rows[0][1][0]}")
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)")
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]}")
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})")
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)
647 """Coaxial cylinders R_i = 0.4 < r < R_o = 1, an SDF along z, nz = 4, every face periodic."""
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))
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)
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")
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
675 shut_y = s.get_oy_proj()[:, :, 0] <= 0.0
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)
679 fy = -(np.roll(P, -1, axis=0) - P) / (h * h)
680 return fx[:, :,
None] * np.ones(4), fy[:, :,
None] * np.ones(4)
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))))
698def g9_case(Rh, off, scheme, cr, rtol=None, nsteps=None):
700 geo = s.diagnostics.scalar_geometry(
"c")
701 unk = geo[
"unknown"] > 0.5
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)
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))
711 nrev = int(math.ceil(T * float(np.max(out[full])) / cr))
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))
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
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"]
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
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)
751 band |= np.roll(cut, 1, axis=a) | np.roll(cut, -1, axis=a)
753 st[
"cutshare"] = float(err[cut].sum() / err.sum())
754 st[
"bandshare"] = float(err[band].sum() / err.sum())
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)
764 off = rng.uniform(-0.5, 0.5, size=2)
766 geo = s.diagnostics.scalar_geometry(
"c")
767 if float(geo[
"kappa"][geo[
"unknown"] > 0.5].min()) < 1e-2:
769 if len(found) == want:
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))
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])
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)))
789 s.set_field(
"c", np.asfortranarray(np.exp(-((X - 0.5) ** 2 + (Y - 0.5) ** 2) / (2 * 0.08**2))))
791 for _
in range(int(round(1.0 / (U[0] * dt)))):
793 mn = min(mn, float(s.get_field(
"c").min()))
794 return mn, s.diagnostics.scalar_census(
"c")[
"bulk_courant"]
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):
802 print(f
" control, NO solid, uniform translation, koren at C_bulk {cb:.3f}: min c = {mn:.2e} "
804 for Rh
in (16, 32, 64):
806 check(len(off) == 1, f
"R_o/h={Rh}: an offset with a sliver (min kappa < 1e-2) exists")
808 for scheme
in (
"fou",
"koren"):
809 for cr
in (0.5, 0.9):
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")
822 check(st[
"min"] >= -1e-12 * st[
"cmax0"], f
"{tag}: (iii) min c {st['min']:.1e} >= -1e-12 max c0")
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")
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")
842 cls = pf.Solver
if kind ==
"staggered" else pf.SolverColocated
843 s = cls((n, n, n), extent=(1.0, 1.0, 1.0))
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)
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))
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)))
860 for _
in range(nsteps):
862 dev = max(dev, float(np.max(np.abs(s.get_field(
"c")[unk] - 1.0))))
863 return dev, s.diagnostics.scalar_census(
"c")
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")):
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)")
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")
898 s.set_domain_bc(
"-x",
"inflow", velocity=(1.0, 0.0, 0.0))
899 s.set_domain_bc(
"+x",
"outflow")
901 s.set_domain_bc(f,
"wall")
903 sdf = np.sqrt((X - 0.71) ** 2 + (Y - 0.48) ** 2 + (Z - 0.53) ** 2) - 0.23
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)
912 s.set_scalar_bc(
"c",
"+x",
"neumann")
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
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."""
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):
933 b = s.diagnostics.scalar_budget(
"c")
935 dm = V * math.fsum((kap * (c - cp))[unk].ravel())
936 flows = dt * (b[
"wall_in"] + b[
"boundary_in"] + b[
"source_in"])
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"])
941 st[
"defects"] += b[
"defect"]
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"])
953 m1 = V * math.fsum((kap * cp)[unk].ravel())
954 st[
"cons"] = abs(m1 - m0 - st[
"flows"] + st[
"defects"]) / m0
956 st[
"outlet"] = float(np.sum((kap * cp)[-1][last]) / np.sum(kap[-1][last]))
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")
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,
969 for tag, kw, nsteps, bounded, small
in rows:
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")
982 check(st[
"small"] > 0
and st[
"impl"] > 0, f
"{tag}: small cells and implicit faces occur")
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)")
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")
997 s.add_scalar(
"d", diffusivity=0.5, cutcell=
True)
998 s.set_scalar_bc(
"d",
"-x",
"dirichlet", 1.0)
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")
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")
1011 "collocated: an open domain face is refused for a cut-cell scalar (open after WO-5b)")
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")
1020 """Run fn() with the C-level stderr captured; returns (fn's result, the text)."""
1023 with tempfile.TemporaryFile(mode=
"w+b")
as tmp:
1026 os.dup2(tmp.fileno(), 2)
1033 return r, tmp.read().decode(errors=
"replace")
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))
1047 s.set_domain_bc(
"-x",
"outflow")
1048 s.set_domain_bc(
"+x",
"outflow")
1050 s.set_domain_bc(f,
"wall")
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):
1057 s.add_scalar(
"b", diffusivity=0.05, cutcell=
True)
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)))
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})")
1077 check(
"BACKFLOW" not in err2,
"backflow: the warning is printed once per scalar")
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)
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."""
1102 idx = [
slice(
None)] * 3
1105 U.append(0.5 * (f + np.roll(f, -1, axis=a)))
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)
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)
1127 idx = [
slice(
None)] * 3
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
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())
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)
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."""
1166 n = nbox
or 2 * Rh + 6
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
1171 rr = np.sqrt((X - c0[0]) ** 2 + (Y - c0[1]) ** 2)
1172 s.set_solid(np.asfortranarray(R - rr), cutcell_pressure=
True)
1176 w = np.maximum(2.0 * U * (1.0 - rr * rr / (R * R)), 0.0)
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)
1183 Jc = np.asarray(s.scalar_mean_flux(
"c"))
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)
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")
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}")
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, "
1218 for Rh
in (16, 32, 64):
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]}")
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})")
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)."""
1235 s = pf.Solver((n, n, n), extent=(L, L, L))
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
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])))
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))
1259 s.set_solid(np.asfortranarray(np.ones((n, n, n))), cutcell_pressure=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")
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)
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}")
1285 check(r[
"conv"], f
"n={n}: converged ({r['its']} iterations)")
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])])
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)")
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]}")
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")
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])
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")
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))
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)
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."""
1362 s = pf.Solver((n, n, n), extent=(L, L, L))
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))
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)
1377 s.solve_scalar_steady(
"c")
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
1385 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
1389 R = (0.3 * 3.0 / (4.0 * math.pi)) ** (1.0 / 3.0)
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))
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
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))
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)
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)
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."""
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))
1442 s.solve_scalar_steady(
"c")
1444 s.set_scalar_wall(
"c",
"dirichlet", 0.0)
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")
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)
1463 s.add_scalar(
"p", diffusivity=D0, cutcell=
True)
1464 s.set_scalar_bc(
"p",
"-x",
"dirichlet", 1.0)
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)
1472 s.set_scalar_bc(
"d", f,
"neumann")
1473 s.solve_scalar_steady(
"d")
1477GADV_PE = (0.1, 1.0, 10.0)
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)
1488 ident = abs(b[
"identity_error"]) / rate
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")
1505 check(g <= 1.7, f
"{tag} Pe_h {pe}: (iii) growth per doubling {lo[pe]} -> {hi[pe]} = {g:.2f}x <= 1.7x")
1509 print(
"G-adv: steady advection on the advective surrogate (design Amendment A2), "
1510 "Pe_h = census max_cell_peclet")
1515 bounds = {16: (16, 25, 40), 32: (20, 34, 55)}
1519 for pe, bd
in zip(GADV_PE, bounds[Rh]):
1522 its[Rh][pe] =
gadv_row(f
"(a) R/h={Rh} ({4 * Rh}^3)", s,
"c", pe, bd)
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")):
1533 for pe, bd
in zip(GADV_PE, bounds_b[wall][n]):
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)
1540 print(f
" [{nm}: {time.time() - t0:.0f} s]")
1542 for pe, bd
in ((1.0, 16), (10.0, 18)):
1544 gadv_row(
"(c) G9c channel, open faces", s,
"d", pe, bd, rate=1.0)
1545 print(f
" [(c): {time.time() - t0:.0f} s]")
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) +
"]"
1555 """A walled box of physical size L (no flow)."""
1556 return walled((n, n, n), (L, L, L))
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")
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)
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),
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
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."""
1600 c0 = np.array([0.5 * L, 0.5 * L, 0.5 * L]) + np.asarray(off) * h
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")
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
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)
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
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())})")
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")
1651 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
1654 R = 0.5 * (0.3 * 6.0 / math.pi) ** (1.0 / 3.0)
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)")
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))
1674 for ratio
in (1e-2, 1.0, 10.0, 1e3):
1675 e[ratio], its[ratio] = {}, {}
1676 for Rh
in (6, 12, 24):
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)
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"],
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]}")
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']})")
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)")
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
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")
1725 """k* of perfectly conducting spheres on the simple-cubic lattice, Andrianov & Topol eq. 157
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
1736 s = pf.Solver((n, n, n), extent=(L, L, L))
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
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,
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}")
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]}")
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']})")
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)):
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}")
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())."""
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):
1808 if det(a) * det(m) <= 0:
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")
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))
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})
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
1835 n = int(math.ceil(2.0 * Ro / h)) + 6
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
1840 s.set_solid_from_scene(cutcell_pressure=
False)
1841 mu, (P, B, C), ks, kf =
g6_exact(Ls, CK, Rc)
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,
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
1854 for _
in range(nsteps):
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
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)
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:
1877 mu =
g6_exact(case[0], case[1], case[3])[0]
1879 for Rh
in (8, 16, 32):
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)")
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")
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}")
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")
1908 for pe
in (1.0, 10.0):
1910 ci = si.diagnostics.scalar_census(
"c")
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))
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']}")
1934 except Exception
as e:
1935 check(
False, f
"{msg} (raised {type(e).__name__}: {e})")
1937 check(
False, msg +
" (did not raise)")
1941 """`raises`, and the message names every one of `needles` (the remedy)."""
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 ""))
1948 except Exception
as e:
1949 check(
False, f
"{msg} (raised {type(e).__name__}: {e})")
1951 check(
False, msg +
" (did not raise)")
1955 print(
"API: the opt-in, the refusals (§8.1) and the name checks (ruling D-WO3-2)")
1957 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
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)
1983 c = s.diagnostics.scalar_census(
"c")
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)
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))
1996 sw = pf.Solver((n, n, n), extent=(1.0, 1.0, 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))
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")
2015 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
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)")
2026 s = pf.SolverColocated((n, n, n), extent=(1.0, 1.0, 1.0))
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)
2035 (
"'gauge-exact'",
"'plain'",
"'embed'",
"staggered Solver"),
2036 "the 'ghost' scheme is refused, listing gauge-exact, plain, embed and the staggered "
2042 for seed
in (
"set_field",
"set_velocity"):
2043 s = pf.SolverColocated((n, n, n), extent=(1.0, 1.0, 1.0))
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)")
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)")
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)
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})")
2082 for scheme
in (
"koren",
"fou"):
2083 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
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)))
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)")
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))
2100 s.add_scalar(
"c", 1.0, cutcell=
True)
2102 errs.append(
capture_stderr(
lambda: s.solve_scalar_steady(
"c"))[1])
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]})")
2109 s = pf.Solver((n, n, n), extent=(1.0, 1.0, 1.0))
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}
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))))
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)
2141 check(s.diagnostics.scalar_census(
"c")[
"num_solid_unknowns"] == 0,
"set_scalar_wall(instance=None) ends the conjugate path")
2145G13_CASES = (
"gap",
"contact",
"conj")
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)
2153 nr[14] = nr[15] = 1.0
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
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
2173 cells = (6 * Rh, 4 * Rh, 4 * Rh)
2174 L = (6.0 * R, 4.0 * R, 4.0 * R)
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]
2180 s.set_solid_from_scene(cutcell_pressure=
False)
2182 add_cc(s, D, box=
"neumann")
2185 s.set_scalar_bc(
"c", f,
"dirichlet", P[0] - xc[0])
2186 s.set_scalar_solid(
"c", diffusivity=100.0 * D)
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")
2194 c = s.get_field(
"c")
2195 F = float(np.sum(2.0 * D * ((P[0] - xc[0]) - c[0, :, :]) * h))
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]))))
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
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)."""
2219 s =
walled((n, n, n), (L, L, L))
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:
2228 os.dup2(tmp.fileno(), 2)
2230 s.solve_scalar_steady(
"c")
2235 err = tmp.read().decode(errors=
"replace")
2236 cen = s.diagnostics.scalar_census(
"c")
2241 print(
"G13 contacts: two spheres R = 1 in a box (6R, 4R, 4R), R/h in {8, 16, 32}, steady")
2245 info = case ==
"conj"
2247 print(
" OPEN: contact model pending (§13 Q4, Frank) -- the conjugate contact row is INFO")
2249 def check(cond, msg, _info=info):
2251 print((
" info ok " if cond
else " info MISS ") + msg)
2253 globals()[
"check"](cond, msg)
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:
2267 print(f
" info gap R/h={Rh}: two-sided {r['cen']['num_two_sided']} (the gap is >= h)")
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} %)")
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})")
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")
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)):
2288 GATES[f
"g6_{_ci + 1}"] = (
lambda c=_ci:
gate_g6(cases=(c,)))
2290if __name__ ==
"__main__":
2291 names = sys.argv[1:]
or list(GATES)
2295 print(f
"[{nm}: {time.time() - t0:.0f} s]")
2297 print(f
"{len(failures)} FAILED")
low_face_flux(s, name, a)
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)
solid_isolated(s, name="c")
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))
g2_case(Rih, off, mgbox=False)
two_body_scene(c0, Ri, Ro)
raises_naming(exc, fn, needles, msg)
interior_gradient(X, Y, Z, psi, mask)
rescale_peclet(s, name, target)
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))
sphere_pair_scene(c1, c2, R)
g1_case(Rh, off, aniso=False)
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)
g5b_geometry_case(n, off)
g3b_case(Rh, off, Bi, nsteps=30)
g5a_case(n, off, D=0.6, ratio=1e4)
g7b_case(Rh, off, iters=25)
giter_transient_case(Rh, off, nsteps=6)
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)
backflow_steady_row(n=12, nsteps=10)
set_annulus_velocity(s, fx, fy)
mean_flux_fields(s, name, D, G, periodic)
closure_rhs_density(s, name, D, G)
koren_bulk_control(cr, n=32)
static std::vector< double > slice(const std::vector< double > &g, int ox, int oy, int oz, int lnx, int lny, int lnz)