2"""Single-GPU accuracy + efficiency regression suite for the `sdflow` cut-cell IBM Stokes solver.
4Three creeping-flow (Stokes) cases, each as a GRID-CONVERGENCE study:
5 * zh_sphere -- simple-cubic single sphere; drag factor K vs Zick & Homsy (1982) (external ref);
6 * random_spheres -- a small packed bed of (reproducibly) jittered spheres; Darcy permeability k;
7 * hollow_rings -- a small packed bed of Raschig rings (hollow cylinders); Darcy permeability k.
9For each grid N we record the ACCURACY metric (K or k) and the EFFICIENCY counters the solver exposes:
10total pressure-solver (MG-PCG) iterations, per-step pressure iterations, Picard outer iterations, the
11number of steps to steady state, the cut-cell flux divergence, and the wall-clock time. Across the grid
12sweep we fit the observed order of convergence p (f(N) = f_inf + C N^-p) and the Richardson-extrapolated
15All numbers are saved to perf_baseline.json. Re-running compares against that baseline within tolerances,
16so a code change that degrades accuracy OR efficiency is caught.
19 python tests/regression/sdflow_regression.py # run + check against the baseline (exit 0/1)
20 python tests/regression/sdflow_regression.py --update # run + (re)write the baseline
21 python tests/regression/sdflow_regression.py --cases zh_sphere,random_spheres
22 python tests/regression/sdflow_regression.py --build build_mpi # pick the sdflow build dir
23 python tests/regression/sdflow_regression.py --quick # coarser grids, looser march (fast smoke)
33HERE = os.path.dirname(os.path.abspath(__file__))
34ROOT = os.path.abspath(os.path.join(HERE,
"..",
".."))
35BASELINE = os.path.join(HERE,
"perf_baseline.json")
38ZH_PHI = [0.000125, 0.001, 0.008, 0.027, 0.064, 0.125, 0.216, 0.343, 0.45, 0.5236]
39ZH_K = [1.096, 1.212, 1.525, 2.008, 2.810, 4.292, 7.442, 15.4, 28.1, 42.1]
43 return float(np.interp(phi, ZH_PHI, ZH_K))
48 g = np.arange(N) + 0.5
49 return np.meshgrid(g, g, g, indexing=
"ij")
53 return d - N * np.round(d / N)
56def sdf_zh_sphere(N, phi=0.216):
57 """Single SC sphere centred in the periodic cube; returns (sdf, info)."""
58 R = (phi * 3.0 / (4.0 * np.pi)) ** (1.0 / 3.0) * N
61 sdf = np.sqrt((X - c) ** 2 + (Y - c) ** 2 + (Z - c) ** 2) - R
62 return sdf, {
"R": R,
"phi": phi,
"K_ref": zh_ref(phi)}
65def sdf_random_spheres(N, n=8, r_frac=0.18, jit=0.06, seed=12345):
66 """Small packed bed: `n` spheres of radius r_frac*N on a jittered 2x2x2 lattice (fixed seed). The shape
67 is self-similar in N (same relative geometry, finer grid) -> a true grid-convergence study of k*."""
68 rng = np.random.default_rng(seed)
70 base = np.array([[(i + 0.5) / 2.0, (j + 0.5) / 2.0, (k + 0.5) / 2.0]
71 for i
in range(2)
for j
in range(2)
for k
in range(2)])
72 centres = ((base + jit * rng.standard_normal(base.shape)) % 1.0) * N
74 sdf = np.full((N, N, N), 1e30)
75 for cx, cy, cz
in centres:
77 sdf = np.minimum(sdf, np.sqrt(dx * dx + dy * dy + dz * dz) - R)
78 return sdf, {
"R": R,
"n": n,
"r_frac": r_frac}
82 """SDF of one Raschig ring (hollow cylinder): annulus [r_in,r_out] x slab |axial|<=H/2, CSG-intersection."""
83 ax = np.asarray(axis, float); ax = ax / np.linalg.norm(ax)
85 z = dx * ax[0] + dy * ax[1] + dz * ax[2]
86 rx = dx - z * ax[0]; ry = dy - z * ax[1]; rz = dz - z * ax[2]
87 rho = np.sqrt(rx * rx + ry * ry + rz * rz)
88 d_annulus = np.maximum(r_in - rho, rho - r_out)
89 d_slab = np.abs(z) - 0.5 * H
90 return np.maximum(d_annulus, d_slab)
94 """Small packed bed of 3 Raschig rings at fixed positions/orientations (reproducible)."""
95 rO, rI, H = 0.22 * N, 0.12 * N, 0.34 * N
96 rings = [((0.30 * N, 0.32 * N, 0.30 * N), (1, 0, 0)),
97 ((0.70 * N, 0.68 * N, 0.55 * N), (0, 1, 0)),
98 ((0.45 * N, 0.50 * N, 0.78 * N), (0, 0, 1))]
100 sdf = np.full((N, N, N), 1e30)
101 for c, axis
in rings:
103 return sdf, {
"r_out": rO,
"r_in": rI,
"H": H,
"n_rings": len(rings)}
107 "zh_sphere": {
"sdf": sdf_zh_sphere,
"grids": [16, 24, 32, 48, 64],
"metric":
"K"},
108 "random_spheres": {
"sdf": sdf_random_spheres,
"grids": [24, 32, 48, 64],
"metric":
"k*"},
109 "hollow_rings": {
"sdf": sdf_hollow_rings,
"grids": [24, 32, 48, 64],
"metric":
"k*"},
113CFG = dict(rho=1.0, mu=0.1, dt=60.0, F=1e-3, vel_sweeps=80, pcg_maxit=300, pcg_rtol=1e-8,
114 coarse=
"rediscretized", conv_tol=1e-5, check_every=5, max_steps=400, min_steps=15)
118def run_case(name, N, cfg, quiet=True, solver="staggered"):
119 from peclet
import flow
as sdflow
121 sdf, info = spec[
"sdf"](N)
122 levels = max(2, int(np.floor(np.log2(N))) - 1)
124 SolverCls = sdflow.SolverColocated
if solver ==
"colocated" else sdflow.Solver
125 s = SolverCls(N, N, N)
126 s.set_rho(cfg[
"rho"]); s.set_mu(cfg[
"mu"]); s.set_dt(cfg[
"dt"])
127 s.set_body_force(cfg[
"F"], 0.0, 0.0)
128 s.set_advection(
False)
129 s.set_velocity_solver_params(cfg[
"vel_sweeps"])
130 s.set_pressure_multigrid(
True, levels=levels)
131 s.set_pressure_pcg(
True, cfg[
"pcg_maxit"], cfg[
"pcg_rtol"])
132 s.set_solid(sdf, cutcell_pressure=
True, pressure_coarse=cfg[
"coarse"])
134 deep_solid = sdf < -2.0
136 prev, steps, p_iters = 0.0, 0, []
137 for it
in range(cfg[
"max_steps"]):
140 p_iters.append(s.last_pressure_iterations())
141 if it % cfg[
"check_every"] == cfg[
"check_every"] - 1:
142 m = float(s.get_u().mean())
143 if it >= cfg[
"min_steps"]
and abs(m - prev) < cfg[
"conv_tol"] * (abs(m) + 1e-30):
146 wall = time.time() - t0
149 umean = float(u.mean())
150 div = float(s.max_open_divergence())
151 u_solid = float(np.abs(u[deep_solid]).max())
if deep_solid.any()
else 0.0
152 if spec[
"metric"] ==
"K":
153 metric = cfg[
"F"] * N ** 3 / (6.0 * np.pi * cfg[
"mu"] * info[
"R"] * umean)
155 metric = cfg[
"mu"] * umean / (cfg[
"F"] * N ** 2)
156 half = p_iters[len(p_iters) // 2:]
158 "N": N,
"metric": float(metric),
159 "pressure_iters_total": int(sum(p_iters)),
160 "pressure_iters_per_step": float(np.median(half)),
161 "outer_iters": int(s.last_outer_iterations()),
162 "steps": int(steps),
"divergence": div,
"max_u_solid": u_solid,
163 "walltime_s": float(wall),
167def fit_order(Ns, vals):
168 """Fit f(N) = f_inf + C N^-p (grid-search p, linear LS for f_inf,C). Returns (order p, f_inf)."""
169 Ns = np.asarray(Ns, float); vals = np.asarray(vals, float)
171 for p
in np.linspace(0.3, 4.0, 371):
172 A = np.vstack([np.ones_like(Ns), Ns ** (-p)]).T
173 coef, *_ = np.linalg.lstsq(A, vals, rcond=
None)
174 ssr = float(((vals - A @ coef) ** 2).sum())
175 if best
is None or ssr < best[0]:
176 best = (ssr, float(p), float(coef[0]))
177 return best[1], best[2]
183 grids = CASES[name][
"grids"]
185 print(f
"\n[{name}] ({solver}) grids {grids} ...", flush=
True)
187 r = run_case(name, N, cfg, solver=solver)
189 print(f
" N={N:3d} {CASES[name]['metric']}={r['metric']:.5g} "
190 f
"p_iters_tot={r['pressure_iters_total']:5d} (/step {r['pressure_iters_per_step']:.0f}) "
191 f
"steps={r['steps']:3d} div={r['divergence']:.1e} {r['walltime_s']:.1f}s", flush=
True)
193 vals = [per[str(N)][
"metric"]
for N
in Ns]
194 order, finf = fit_order(Ns, vals)
195 entry = {
"grids": grids,
"metric_name": CASES[name][
"metric"],
"per_grid": per,
196 "order": order,
"extrapolated": finf}
197 if name ==
"zh_sphere":
198 entry[
"reference"] = zh_ref(0.216)
199 entry[
"errors_pct"] = {str(N): 100.0 * abs(per[str(N)][
"metric"] - entry[
"reference"]) /
200 entry[
"reference"]
for N
in Ns}
202 print(f
" -> order p={order:.2f}, extrapolated {CASES[name]['metric']}_inf={finf:.5g}", flush=
True)
207TOL = dict(metric_rel=0.015, order_abs=0.4, extrap_rel=0.02,
208 piter_total_rel=0.25, piter_step_abs=2.0, steps_rel=0.35, div_floor=1e-7)
216 lines.append(f
"[{name}] NEW case (no baseline) -- record with --update"); ok =
False;
continue
217 b, c = base[name], cur[name]
218 mname = c[
"metric_name"]
219 lines.append(f
"\n[{name}] (metric={mname})")
221 d_ord = abs(c[
"order"] - b[
"order"])
222 s_ord =
"ok" if d_ord <= TOL[
"order_abs"]
else "FAIL"; ok &= s_ord ==
"ok"
223 lines.append(f
" order p: base {b['order']:.2f} cur {c['order']:.2f} (d={d_ord:.2f}) [{s_ord}]")
224 d_ext = abs(c[
"extrapolated"] - b[
"extrapolated"]) / (abs(b[
"extrapolated"]) + 1e-30)
225 s_ext =
"ok" if d_ext <= TOL[
"extrap_rel"]
else "FAIL"; ok &= s_ext ==
"ok"
226 lines.append(f
" {mname}_inf: base {b['extrapolated']:.5g} cur {c['extrapolated']:.5g} "
227 f
"(rel={d_ext*100:.2f}%) [{s_ext}]")
228 for N
in [g
for g
in c[
"grids"]
if str(g)
in b.get(
"per_grid", {})]:
229 bg, cg = b[
"per_grid"][str(N)], c[
"per_grid"][str(N)]
230 dm = abs(cg[
"metric"] - bg[
"metric"]) / (abs(bg[
"metric"]) + 1e-30)
231 sm =
"ok" if dm <= TOL[
"metric_rel"]
else "FAIL"; ok &= sm ==
"ok"
232 di = abs(cg[
"pressure_iters_total"] - bg[
"pressure_iters_total"]) / (bg[
"pressure_iters_total"] + 1e-30)
233 si =
"ok" if di <= TOL[
"piter_total_rel"]
else "FAIL"; ok &= si ==
"ok"
234 dps = abs(cg[
"pressure_iters_per_step"] - bg[
"pressure_iters_per_step"])
235 sps =
"ok" if dps <= TOL[
"piter_step_abs"]
else "FAIL"; ok &= sps ==
"ok"
236 div_lim = max(TOL[
"div_floor"], 3.0 * bg[
"divergence"])
237 sd =
"ok" if cg[
"divergence"] <= div_lim
else "FAIL"; ok &= sd ==
"ok"
239 f
" N={N:3d} {mname} {cg['metric']:.5g} ({dm*100:+.2f}%)[{sm}] "
240 f
"p_iter_tot {cg['pressure_iters_total']} ({di*100:+.1f}%)[{si}] "
241 f
"/step {cg['pressure_iters_per_step']:.0f}[{sps}] "
242 f
"div {cg['divergence']:.1e}[{sd}] "
243 f
"steps {cg['steps']} vs {bg['steps']} "
244 f
"t {cg['walltime_s']:.1f}s vs {bg['walltime_s']:.1f}s")
245 return ok,
"\n".join(lines)
249 ap = argparse.ArgumentParser()
250 ap.add_argument(
"--update", action=
"store_true", help=
"(re)write the baseline instead of checking")
251 ap.add_argument(
"--cases", default=
",".join(CASES), help=
"comma-separated subset of cases")
252 ap.add_argument(
"--build", default=
"build", help=
"sdflow build dir under the repo root")
253 ap.add_argument(
"--solver", default=
"staggered", choices=[
"staggered",
"colocated"],
254 help=
"which grid variant to run (sdflow.Solver / sdflow.SolverColocated)")
255 ap.add_argument(
"--quick", action=
"store_true", help=
"coarser grids + looser march (fast smoke)")
256 args = ap.parse_args()
258 sys.path.insert(0, os.path.join(ROOT, args.build))
259 baseline = BASELINE
if args.solver ==
"staggered" else os.path.join(HERE,
"perf_baseline_colocated.json")
260 cases = [c.strip()
for c
in args.cases.split(
",")
if c.strip()]
263 cfg.update(max_steps=120, conv_tol=3e-4)
264 for c
in CASES.values():
265 c[
"grids"] = c[
"grids"][:3]
268 cur =
run_all(cfg, cases, solver=args.solver)
269 print(f
"\n(total {time.time()-t0:.0f}s)")
272 payload = {
"_meta": {
"generated": time.strftime(
"%Y-%m-%d %H:%M"),
"solver": args.solver,
273 "config": cfg,
"tol": TOL}, **cur}
274 with open(baseline,
"w")
as f:
275 json.dump(payload, f, indent=2)
276 print(f
"\nwrote baseline -> {baseline}")
279 if not os.path.exists(baseline):
280 print(f
"\nNO baseline at {baseline}; run with --update first.")
282 base = json.load(open(baseline))
283 ok, report =
compare(base, cur)
285 print(f
"\n=== regression: {'PASS' if ok else 'FAIL'} ===")
286 return 0
if ok
else 1
289if __name__ ==
"__main__":
_hollow_cyl_sdf(X, Y, Z, c, axis, r_out, r_in, H, N)
run_all(cfg, cases, solver="staggered")