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 flow build dir
23 # (only consulted if peclet.flow
24 # isn't already importable, same
25 # as PECLET_FLOW_BUILD)
26 python tests/regression/sdflow_regression.py --quick # coarser grids, looser march (fast smoke)
36HERE = os.path.dirname(os.path.abspath(__file__))
37ROOT = os.path.abspath(os.path.join(HERE,
"..",
".."))
38BASELINE = os.path.join(HERE,
"perf_baseline.json")
40sys.path.insert(0, os.path.join(ROOT,
"scripts"))
41from _bootstrap
import ensure_flow
44ZH_PHI = [0.000125, 0.001, 0.008, 0.027, 0.064, 0.125, 0.216, 0.343, 0.45, 0.5236]
45ZH_K = [1.096, 1.212, 1.525, 2.008, 2.810, 4.292, 7.442, 15.4, 28.1, 42.1]
49 return float(np.interp(phi, ZH_PHI, ZH_K))
54 g = np.arange(N) + 0.5
55 return np.meshgrid(g, g, g, indexing=
"ij")
59 return d - N * np.round(d / N)
62def sdf_zh_sphere(N, phi=0.216):
63 """Single SC sphere centred in the periodic cube; returns (sdf, info)."""
64 R = (phi * 3.0 / (4.0 * np.pi)) ** (1.0 / 3.0) * N
67 sdf = np.sqrt((X - c) ** 2 + (Y - c) ** 2 + (Z - c) ** 2) - R
68 return sdf, {
"R": R,
"phi": phi,
"K_ref": zh_ref(phi)}
71def sdf_random_spheres(N, n=8, r_frac=0.18, jit=0.06, seed=12345):
72 """Small packed bed: `n` spheres of radius r_frac*N on a jittered 2x2x2 lattice (fixed seed). The shape
73 is self-similar in N (same relative geometry, finer grid) -> a true grid-convergence study of k*."""
74 rng = np.random.default_rng(seed)
76 base = np.array([[(i + 0.5) / 2.0, (j + 0.5) / 2.0, (k + 0.5) / 2.0]
77 for i
in range(2)
for j
in range(2)
for k
in range(2)])
78 centres = ((base + jit * rng.standard_normal(base.shape)) % 1.0) * N
80 sdf = np.full((N, N, N), 1e30)
81 for cx, cy, cz
in centres:
83 sdf = np.minimum(sdf, np.sqrt(dx * dx + dy * dy + dz * dz) - R)
84 return sdf, {
"R": R,
"n": n,
"r_frac": r_frac}
88 """SDF of one Raschig ring (hollow cylinder): annulus [r_in,r_out] x slab |axial|<=H/2, CSG-intersection."""
89 ax = np.asarray(axis, float); ax = ax / np.linalg.norm(ax)
91 z = dx * ax[0] + dy * ax[1] + dz * ax[2]
92 rx = dx - z * ax[0]; ry = dy - z * ax[1]; rz = dz - z * ax[2]
93 rho = np.sqrt(rx * rx + ry * ry + rz * rz)
94 d_annulus = np.maximum(r_in - rho, rho - r_out)
95 d_slab = np.abs(z) - 0.5 * H
96 return np.maximum(d_annulus, d_slab)
100 """Small packed bed of 3 Raschig rings at fixed positions/orientations (reproducible)."""
101 rO, rI, H = 0.22 * N, 0.12 * N, 0.34 * N
102 rings = [((0.30 * N, 0.32 * N, 0.30 * N), (1, 0, 0)),
103 ((0.70 * N, 0.68 * N, 0.55 * N), (0, 1, 0)),
104 ((0.45 * N, 0.50 * N, 0.78 * N), (0, 0, 1))]
106 sdf = np.full((N, N, N), 1e30)
107 for c, axis
in rings:
109 return sdf, {
"r_out": rO,
"r_in": rI,
"H": H,
"n_rings": len(rings)}
113 "zh_sphere": {
"sdf": sdf_zh_sphere,
"grids": [16, 24, 32, 48, 64],
"metric":
"K"},
114 "random_spheres": {
"sdf": sdf_random_spheres,
"grids": [24, 32, 48, 64],
"metric":
"k*"},
115 "hollow_rings": {
"sdf": sdf_hollow_rings,
"grids": [24, 32, 48, 64],
"metric":
"k*"},
119CFG = dict(rho=1.0, mu=0.1, dt=60.0, F=1e-3, vel_sweeps=80, pcg_maxit=300, pcg_rtol=1e-8,
120 coarse=
"rediscretized", conv_tol=1e-5, check_every=5, max_steps=400, min_steps=15)
124def run_case(name, N, cfg, quiet=True, solver="staggered", scheme="gauge-exact"):
125 from peclet
import flow
as sdflow
127 sdf, info = spec[
"sdf"](N)
128 levels = max(2, int(np.floor(np.log2(N))) - 1)
130 SolverCls = sdflow.SolverColocated
if solver ==
"colocated" else sdflow.Solver
131 s = SolverCls(N, N, N)
132 s.set_rho(cfg[
"rho"]); s.set_mu(cfg[
"mu"]); s.set_dt(cfg[
"dt"])
133 s.set_body_force((cfg[
"F"], 0.0, 0.0))
134 s.set_advection(
False)
135 s.diagnostics.set_velocity_solver_params(cfg[
"vel_sweeps"])
136 s.set_pressure_multigrid(
True, levels=levels)
137 s.set_pressure_pcg(
True, cfg[
"pcg_maxit"], cfg[
"pcg_rtol"])
138 if solver ==
"colocated":
139 s.set_collocated_scheme(scheme)
141 s.set_solid(sdf, cutcell_pressure=
True)
143 deep_solid = sdf < -2.0
145 prev, steps, p_iters = 0.0, 0, []
146 for it
in range(cfg[
"max_steps"]):
149 p_iters.append(s.diagnostics.last_pressure_iterations())
150 if it % cfg[
"check_every"] == cfg[
"check_every"] - 1:
151 m = float(s.get_u().
mean())
152 if it >= cfg[
"min_steps"]
and abs(m - prev) < cfg[
"conv_tol"] * (abs(m) + 1e-30):
155 wall = time.time() - t0
158 umean = float(u.mean())
159 div = float(s.max_open_divergence())
160 u_solid = float(np.abs(u[deep_solid]).max())
if deep_solid.any()
else 0.0
161 if spec[
"metric"] ==
"K":
162 metric = cfg[
"F"] * N ** 3 / (6.0 * np.pi * cfg[
"mu"] * info[
"R"] * umean)
164 metric = cfg[
"mu"] * umean / (cfg[
"F"] * N ** 2)
165 half = p_iters[len(p_iters) // 2:]
167 "N": N,
"metric": float(metric),
168 "pressure_iters_total": int(sum(p_iters)),
169 "pressure_iters_per_step": float(np.median(half)),
170 "outer_iters": int(s.diagnostics.last_outer_iterations()),
171 "steps": int(steps),
"divergence": div,
"max_u_solid": u_solid,
172 "walltime_s": float(wall),
176def fit_order(Ns, vals):
177 """Fit f(N) = f_inf + C N^-p (grid-search p, linear LS for f_inf,C). Returns (order p, f_inf)."""
178 Ns = np.asarray(Ns, float); vals = np.asarray(vals, float)
180 for p
in np.linspace(0.3, 4.0, 371):
181 A = np.vstack([np.ones_like(Ns), Ns ** (-p)]).T
182 coef, *_ = np.linalg.lstsq(A, vals, rcond=
None)
183 ssr = float(((vals - A @ coef) ** 2).sum())
184 if best
is None or ssr < best[0]:
185 best = (ssr, float(p), float(coef[0]))
186 return best[1], best[2]
189def run_all(cfg, cases, solver="staggered", scheme="gauge-exact"):
192 grids = CASES[name][
"grids"]
194 print(f
"\n[{name}] ({solver}) grids {grids} ...", flush=
True)
196 r = run_case(name, N, cfg, solver=solver, scheme=scheme)
198 print(f
" N={N:3d} {CASES[name]['metric']}={r['metric']:.5g} "
199 f
"p_iters_tot={r['pressure_iters_total']:5d} (/step {r['pressure_iters_per_step']:.0f}) "
200 f
"steps={r['steps']:3d} div={r['divergence']:.1e} {r['walltime_s']:.1f}s", flush=
True)
202 vals = [per[str(N)][
"metric"]
for N
in Ns]
203 order, finf = fit_order(Ns, vals)
204 entry = {
"grids": grids,
"metric_name": CASES[name][
"metric"],
"per_grid": per,
205 "order": order,
"extrapolated": finf}
206 if name ==
"zh_sphere":
207 entry[
"reference"] = zh_ref(0.216)
208 entry[
"errors_pct"] = {str(N): 100.0 * abs(per[str(N)][
"metric"] - entry[
"reference"]) /
209 entry[
"reference"]
for N
in Ns}
211 print(f
" -> order p={order:.2f}, extrapolated {CASES[name]['metric']}_inf={finf:.5g}", flush=
True)
216TOL = dict(metric_rel=0.015, order_abs=0.4, extrap_rel=0.02,
217 piter_total_rel=0.25, piter_step_abs=2.0, steps_rel=0.35, div_floor=1e-7)
225 lines.append(f
"[{name}] NEW case (no baseline) -- record with --update"); ok =
False;
continue
226 b, c = base[name], cur[name]
227 mname = c[
"metric_name"]
228 lines.append(f
"\n[{name}] (metric={mname})")
233 d_ord = abs(c[
"order"] - b[
"order"])
234 if solver ==
"colocated":
235 s_ord =
"ok" if d_ord <= TOL[
"order_abs"]
else "warn"
237 s_ord =
"ok" if d_ord <= TOL[
"order_abs"]
else "FAIL"; ok &= s_ord ==
"ok"
238 lines.append(f
" order p: base {b['order']:.2f} cur {c['order']:.2f} (d={d_ord:.2f}) [{s_ord}]")
239 d_ext = abs(c[
"extrapolated"] - b[
"extrapolated"]) / (abs(b[
"extrapolated"]) + 1e-30)
240 s_ext =
"ok" if d_ext <= TOL[
"extrap_rel"]
else "FAIL"; ok &= s_ext ==
"ok"
241 lines.append(f
" {mname}_inf: base {b['extrapolated']:.5g} cur {c['extrapolated']:.5g} "
242 f
"(rel={d_ext*100:.2f}%) [{s_ext}]")
243 for N
in [g
for g
in c[
"grids"]
if str(g)
in b.get(
"per_grid", {})]:
244 bg, cg = b[
"per_grid"][str(N)], c[
"per_grid"][str(N)]
245 dm = abs(cg[
"metric"] - bg[
"metric"]) / (abs(bg[
"metric"]) + 1e-30)
246 sm =
"ok" if dm <= TOL[
"metric_rel"]
else "FAIL"; ok &= sm ==
"ok"
247 di = abs(cg[
"pressure_iters_total"] - bg[
"pressure_iters_total"]) / (bg[
"pressure_iters_total"] + 1e-30)
248 si =
"ok" if di <= TOL[
"piter_total_rel"]
else "FAIL"; ok &= si ==
"ok"
249 dps = abs(cg[
"pressure_iters_per_step"] - bg[
"pressure_iters_per_step"])
250 sps =
"ok" if dps <= TOL[
"piter_step_abs"]
else "FAIL"; ok &= sps ==
"ok"
251 div_lim = max(TOL[
"div_floor"], 3.0 * bg[
"divergence"])
252 sd =
"ok" if cg[
"divergence"] <= div_lim
else "FAIL"; ok &= sd ==
"ok"
254 f
" N={N:3d} {mname} {cg['metric']:.5g} ({dm*100:+.2f}%)[{sm}] "
255 f
"p_iter_tot {cg['pressure_iters_total']} ({di*100:+.1f}%)[{si}] "
256 f
"/step {cg['pressure_iters_per_step']:.0f}[{sps}] "
257 f
"div {cg['divergence']:.1e}[{sd}] "
258 f
"steps {cg['steps']} vs {bg['steps']} "
259 f
"t {cg['walltime_s']:.1f}s vs {bg['walltime_s']:.1f}s")
260 return ok,
"\n".join(lines)
264 ap = argparse.ArgumentParser()
265 ap.add_argument(
"--update", action=
"store_true", help=
"(re)write the baseline instead of checking")
266 ap.add_argument(
"--cases", default=
",".join(CASES), help=
"comma-separated subset of cases")
267 ap.add_argument(
"--build", default=
None,
268 help=
"flow build dir under the repo root, tried only if peclet.flow isn't "
269 "already importable (same standing as PECLET_FLOW_BUILD -- an "
270 "already-importable peclet.flow, e.g. via PYTHONPATH, always wins)")
271 ap.add_argument(
"--solver", default=
"staggered", choices=[
"staggered",
"colocated"],
272 help=
"which grid variant to run (sdflow.Solver / sdflow.SolverColocated)")
273 ap.add_argument(
"--scheme", default=
"gauge-exact",
274 help=
"colocated scheme (set_collocated_scheme name; 'ghost' = route 2b); "
275 "each non-default scheme gets its own baseline file")
276 ap.add_argument(
"--quick", action=
"store_true", help=
"coarser grids + looser march (fast smoke)")
277 args = ap.parse_args()
279 explicit_build = os.path.join(ROOT, args.build)
if args.build
else None
280 ensure_flow(explicit_build=explicit_build)
281 baseline = BASELINE
if args.solver ==
"staggered" else os.path.join(
282 HERE,
"perf_baseline_colocated.json" if args.scheme ==
"gauge-exact"
283 else f
"perf_baseline_colocated_{args.scheme.replace('-', '_')}.json")
284 cases = [c.strip()
for c
in args.cases.split(
",")
if c.strip()]
287 cfg.update(max_steps=120, conv_tol=3e-4)
288 for c
in CASES.values():
289 c[
"grids"] = c[
"grids"][:3]
292 cur =
run_all(cfg, cases, solver=args.solver, scheme=args.scheme)
293 print(f
"\n(total {time.time()-t0:.0f}s)")
296 payload = {
"_meta": {
"generated": time.strftime(
"%Y-%m-%d %H:%M"),
"solver": args.solver,
"scheme": args.scheme,
297 "config": cfg,
"tol": TOL}, **cur}
298 with open(baseline,
"w")
as f:
299 json.dump(payload, f, indent=2)
300 print(f
"\nwrote baseline -> {baseline}")
303 if not os.path.exists(baseline):
304 print(f
"\nNO baseline at {baseline}; run with --update first.")
306 base = json.load(open(baseline))
307 ok, report =
compare(base, cur, solver=args.solver)
309 print(f
"\n=== regression: {'PASS' if ok else 'FAIL'} ===")
310 return 0
if ok
else 1
313if __name__ ==
"__main__":
compare(base, cur, solver="staggered")
run_all(cfg, cases, solver="staggered", scheme="gauge-exact")
_hollow_cyl_sdf(X, Y, Z, c, axis, r_out, r_in, H, N)
static double mean(const std::vector< double > &a)