flow 0.4.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
sdflow_regression.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Single-GPU accuracy + efficiency regression suite for the `sdflow` cut-cell IBM Stokes solver.
3
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.
8
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
13value f_inf.
14
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.
17
18Usage:
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)
24"""
25import argparse
26import json
27import os
28import sys
29import time
30
31import numpy as np
32
33HERE = os.path.dirname(os.path.abspath(__file__))
34ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
35BASELINE = os.path.join(HERE, "perf_baseline.json")
36
37# Zick & Homsy (1982), simple cubic: solid fraction c -> Stokes drag factor K.
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]
40
41
42def zh_ref(phi):
43 return float(np.interp(phi, ZH_PHI, ZH_K))
44
45
46# --------------------------------------------------------------------------- geometry (sdf[x,y,z], <0 solid)
47def _grid(N):
48 g = np.arange(N) + 0.5 # cell centres
49 return np.meshgrid(g, g, g, indexing="ij")
50
51
52def _minimg(d, N):
53 return d - N * np.round(d / N)
54
55
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
59 X, Y, Z = _grid(N)
60 c = N / 2.0
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)}
63
64
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)
69 R = r_frac * N
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
73 X, Y, Z = _grid(N)
74 sdf = np.full((N, N, N), 1e30)
75 for cx, cy, cz in centres:
76 dx = _minimg(X - cx, N); dy = _minimg(Y - cy, N); dz = _minimg(Z - cz, N)
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}
79
80
81def _hollow_cyl_sdf(X, Y, Z, c, axis, r_out, r_in, H, N):
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)
84 dx = _minimg(X - c[0], N); dy = _minimg(Y - c[1], N); dz = _minimg(Z - c[2], N)
85 z = dx * ax[0] + dy * ax[1] + dz * ax[2] # axial coord
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) # radial distance from the axis
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) # <0 inside the ring wall
91
92
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))]
99 X, Y, Z = _grid(N)
100 sdf = np.full((N, N, N), 1e30)
101 for c, axis in rings:
102 sdf = np.minimum(sdf, _hollow_cyl_sdf(X, Y, Z, c, axis, rO, rI, H, N))
103 return sdf, {"r_out": rO, "r_in": rI, "H": H, "n_rings": len(rings)}
104
105
106CASES = {
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*"},
110}
111
112# Fixed solver config shared by every case (so the recorded efficiency is comparable across runs).
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)
115
116
117# --------------------------------------------------------------------------- run one (case, N)
118def run_case(name, N, cfg, quiet=True, solver="staggered", scheme="gauge-exact"):
119 from peclet import flow as sdflow
120 spec = CASES[name]
121 sdf, info = spec["sdf"](N)
122 levels = max(2, int(np.floor(np.log2(N))) - 1)
123
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) # creeping Stokes
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 if solver == "colocated":
133 s.set_collocated_scheme(scheme) # ALWAYS explicit: baselines pin schemes, not defaults
134 # (the shipped default is AUTO = ghost since 2026-08-25)
135 s.set_solid(sdf, cutcell_pressure=True, pressure_coarse=cfg["coarse"])
136
137 deep_solid = sdf < -2.0
138 t0 = time.time()
139 prev, steps, p_iters = 0.0, 0, []
140 for it in range(cfg["max_steps"]):
141 s.step()
142 steps += 1
143 p_iters.append(s.last_pressure_iterations())
144 if it % cfg["check_every"] == cfg["check_every"] - 1:
145 m = float(s.get_u().mean())
146 if it >= cfg["min_steps"] and abs(m - prev) < cfg["conv_tol"] * (abs(m) + 1e-30):
147 break
148 prev = m
149 wall = time.time() - t0
150
151 u = s.get_u()
152 umean = float(u.mean())
153 div = float(s.max_open_divergence())
154 u_solid = float(np.abs(u[deep_solid]).max()) if deep_solid.any() else 0.0
155 if spec["metric"] == "K": # Zick & Homsy drag factor (dimensionless)
156 metric = cfg["F"] * N ** 3 / (6.0 * np.pi * cfg["mu"] * info["R"] * umean)
157 else: # dimensionless permeability k* = k/L^2 = mu <u> / (F N^2)
158 metric = cfg["mu"] * umean / (cfg["F"] * N ** 2)
159 half = p_iters[len(p_iters) // 2:]
160 return {
161 "N": N, "metric": float(metric),
162 "pressure_iters_total": int(sum(p_iters)),
163 "pressure_iters_per_step": float(np.median(half)),
164 "outer_iters": int(s.last_outer_iterations()),
165 "steps": int(steps), "divergence": div, "max_u_solid": u_solid,
166 "walltime_s": float(wall),
167 }
168
169
170def fit_order(Ns, vals):
171 """Fit f(N) = f_inf + C N^-p (grid-search p, linear LS for f_inf,C). Returns (order p, f_inf)."""
172 Ns = np.asarray(Ns, float); vals = np.asarray(vals, float)
173 best = None
174 for p in np.linspace(0.3, 4.0, 371):
175 A = np.vstack([np.ones_like(Ns), Ns ** (-p)]).T
176 coef, *_ = np.linalg.lstsq(A, vals, rcond=None)
177 ssr = float(((vals - A @ coef) ** 2).sum())
178 if best is None or ssr < best[0]:
179 best = (ssr, float(p), float(coef[0]))
180 return best[1], best[2] # order, extrapolated f_inf
181
182
183def run_all(cfg, cases, solver="staggered", scheme="gauge-exact"):
184 out = {}
185 for name in cases:
186 grids = CASES[name]["grids"]
187 per = {}
188 print(f"\n[{name}] ({solver}) grids {grids} ...", flush=True)
189 for N in grids:
190 r = run_case(name, N, cfg, solver=solver, scheme=scheme)
191 per[str(N)] = r
192 print(f" N={N:3d} {CASES[name]['metric']}={r['metric']:.5g} "
193 f"p_iters_tot={r['pressure_iters_total']:5d} (/step {r['pressure_iters_per_step']:.0f}) "
194 f"steps={r['steps']:3d} div={r['divergence']:.1e} {r['walltime_s']:.1f}s", flush=True)
195 Ns = grids
196 vals = [per[str(N)]["metric"] for N in Ns]
197 order, finf = fit_order(Ns, vals)
198 entry = {"grids": grids, "metric_name": CASES[name]["metric"], "per_grid": per,
199 "order": order, "extrapolated": finf}
200 if name == "zh_sphere":
201 entry["reference"] = zh_ref(0.216)
202 entry["errors_pct"] = {str(N): 100.0 * abs(per[str(N)]["metric"] - entry["reference"]) /
203 entry["reference"] for N in Ns}
204 out[name] = entry
205 print(f" -> order p={order:.2f}, extrapolated {CASES[name]['metric']}_inf={finf:.5g}", flush=True)
206 return out
207
208
209# --------------------------------------------------------------------------- baseline compare
210TOL = dict(metric_rel=0.015, order_abs=0.4, extrap_rel=0.02,
211 piter_total_rel=0.25, piter_step_abs=2.0, steps_rel=0.35, div_floor=1e-7)
212
213
214def compare(base, cur, solver="staggered"):
215 ok = True
216 lines = []
217 for name in cur:
218 if name not in base:
219 lines.append(f"[{name}] NEW case (no baseline) -- record with --update"); ok = False; continue
220 b, c = base[name], cur[name]
221 mname = c["metric_name"]
222 lines.append(f"\n[{name}] (metric={mname})")
223 # order + extrapolated value. COLLOCATED: the fitted order is ADVISORY, not a gate --
224 # the attractor campaign measured collocated errors crossing zero inside these grid
225 # ranges (doc/collocated_paper_plan.md row 22 trap), where Richardson fits are noise:
226 # a ~1e-6 metric shift (any rebuild) flips p by O(1) while every value stays [ok].
227 d_ord = abs(c["order"] - b["order"])
228 if solver == "colocated":
229 s_ord = "ok" if d_ord <= TOL["order_abs"] else "warn"
230 else:
231 s_ord = "ok" if d_ord <= TOL["order_abs"] else "FAIL"; ok &= s_ord == "ok"
232 lines.append(f" order p: base {b['order']:.2f} cur {c['order']:.2f} (d={d_ord:.2f}) [{s_ord}]")
233 d_ext = abs(c["extrapolated"] - b["extrapolated"]) / (abs(b["extrapolated"]) + 1e-30)
234 s_ext = "ok" if d_ext <= TOL["extrap_rel"] else "FAIL"; ok &= s_ext == "ok"
235 lines.append(f" {mname}_inf: base {b['extrapolated']:.5g} cur {c['extrapolated']:.5g} "
236 f"(rel={d_ext*100:.2f}%) [{s_ext}]")
237 for N in [g for g in c["grids"] if str(g) in b.get("per_grid", {})]:
238 bg, cg = b["per_grid"][str(N)], c["per_grid"][str(N)]
239 dm = abs(cg["metric"] - bg["metric"]) / (abs(bg["metric"]) + 1e-30)
240 sm = "ok" if dm <= TOL["metric_rel"] else "FAIL"; ok &= sm == "ok"
241 di = abs(cg["pressure_iters_total"] - bg["pressure_iters_total"]) / (bg["pressure_iters_total"] + 1e-30)
242 si = "ok" if di <= TOL["piter_total_rel"] else "FAIL"; ok &= si == "ok"
243 dps = abs(cg["pressure_iters_per_step"] - bg["pressure_iters_per_step"])
244 sps = "ok" if dps <= TOL["piter_step_abs"] else "FAIL"; ok &= sps == "ok"
245 div_lim = max(TOL["div_floor"], 3.0 * bg["divergence"])
246 sd = "ok" if cg["divergence"] <= div_lim else "FAIL"; ok &= sd == "ok"
247 lines.append(
248 f" N={N:3d} {mname} {cg['metric']:.5g} ({dm*100:+.2f}%)[{sm}] "
249 f"p_iter_tot {cg['pressure_iters_total']} ({di*100:+.1f}%)[{si}] "
250 f"/step {cg['pressure_iters_per_step']:.0f}[{sps}] "
251 f"div {cg['divergence']:.1e}[{sd}] "
252 f"steps {cg['steps']} vs {bg['steps']} "
253 f"t {cg['walltime_s']:.1f}s vs {bg['walltime_s']:.1f}s")
254 return ok, "\n".join(lines)
255
256
257def main():
258 ap = argparse.ArgumentParser()
259 ap.add_argument("--update", action="store_true", help="(re)write the baseline instead of checking")
260 ap.add_argument("--cases", default=",".join(CASES), help="comma-separated subset of cases")
261 ap.add_argument("--build", default="build", help="sdflow build dir under the repo root")
262 ap.add_argument("--solver", default="staggered", choices=["staggered", "colocated"],
263 help="which grid variant to run (sdflow.Solver / sdflow.SolverColocated)")
264 ap.add_argument("--scheme", default="gauge-exact",
265 help="colocated scheme (set_collocated_scheme name; 'ghost' = route 2b); "
266 "each non-default scheme gets its own baseline file")
267 ap.add_argument("--quick", action="store_true", help="coarser grids + looser march (fast smoke)")
268 args = ap.parse_args()
269
270 sys.path.insert(0, os.path.join(ROOT, args.build))
271 baseline = BASELINE if args.solver == "staggered" else os.path.join(
272 HERE, "perf_baseline_colocated.json" if args.scheme == "gauge-exact"
273 else f"perf_baseline_colocated_{args.scheme.replace('-', '_')}.json")
274 cases = [c.strip() for c in args.cases.split(",") if c.strip()]
275 cfg = dict(CFG)
276 if args.quick:
277 cfg.update(max_steps=120, conv_tol=3e-4)
278 for c in CASES.values():
279 c["grids"] = c["grids"][:3]
280
281 t0 = time.time()
282 cur = run_all(cfg, cases, solver=args.solver, scheme=args.scheme)
283 print(f"\n(total {time.time()-t0:.0f}s)")
284
285 if args.update:
286 payload = {"_meta": {"generated": time.strftime("%Y-%m-%d %H:%M"), "solver": args.solver, "scheme": args.scheme,
287 "config": cfg, "tol": TOL}, **cur}
288 with open(baseline, "w") as f:
289 json.dump(payload, f, indent=2)
290 print(f"\nwrote baseline -> {baseline}")
291 return 0
292
293 if not os.path.exists(baseline):
294 print(f"\nNO baseline at {baseline}; run with --update first.")
295 return 1
296 base = json.load(open(baseline))
297 ok, report = compare(base, cur, solver=args.solver)
298 print(report)
299 print(f"\n=== regression: {'PASS' if ok else 'FAIL'} ===")
300 return 0 if ok else 1
301
302
303if __name__ == "__main__":
304 sys.exit(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)