flow
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"):
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 s.set_solid(sdf, cutcell_pressure=True, pressure_coarse=cfg["coarse"])
133
134 deep_solid = sdf < -2.0
135 t0 = time.time()
136 prev, steps, p_iters = 0.0, 0, []
137 for it in range(cfg["max_steps"]):
138 s.step()
139 steps += 1
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):
144 break
145 prev = m
146 wall = time.time() - t0
147
148 u = s.get_u()
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": # Zick & Homsy drag factor (dimensionless)
153 metric = cfg["F"] * N ** 3 / (6.0 * np.pi * cfg["mu"] * info["R"] * umean)
154 else: # dimensionless permeability k* = k/L^2 = mu <u> / (F N^2)
155 metric = cfg["mu"] * umean / (cfg["F"] * N ** 2)
156 half = p_iters[len(p_iters) // 2:]
157 return {
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),
164 }
165
166
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)
170 best = None
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] # order, extrapolated f_inf
178
179
180def run_all(cfg, cases, solver="staggered"):
181 out = {}
182 for name in cases:
183 grids = CASES[name]["grids"]
184 per = {}
185 print(f"\n[{name}] ({solver}) grids {grids} ...", flush=True)
186 for N in grids:
187 r = run_case(name, N, cfg, solver=solver)
188 per[str(N)] = r
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)
192 Ns = grids
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}
201 out[name] = entry
202 print(f" -> order p={order:.2f}, extrapolated {CASES[name]['metric']}_inf={finf:.5g}", flush=True)
203 return out
204
205
206# --------------------------------------------------------------------------- baseline compare
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)
209
210
211def compare(base, cur):
212 ok = True
213 lines = []
214 for name in cur:
215 if name not in base:
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})")
220 # order + extrapolated value
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"
238 lines.append(
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)
246
247
248def main():
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()
257
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()]
261 cfg = dict(CFG)
262 if args.quick:
263 cfg.update(max_steps=120, conv_tol=3e-4)
264 for c in CASES.values():
265 c["grids"] = c["grids"][:3]
266
267 t0 = time.time()
268 cur = run_all(cfg, cases, solver=args.solver)
269 print(f"\n(total {time.time()-t0:.0f}s)")
270
271 if args.update:
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}")
277 return 0
278
279 if not os.path.exists(baseline):
280 print(f"\nNO baseline at {baseline}; run with --update first.")
281 return 1
282 base = json.load(open(baseline))
283 ok, report = compare(base, cur)
284 print(report)
285 print(f"\n=== regression: {'PASS' if ok else 'FAIL'} ===")
286 return 0 if ok else 1
287
288
289if __name__ == "__main__":
290 sys.exit(main())
_hollow_cyl_sdf(X, Y, Z, c, axis, r_out, r_in, H, N)
run_all(cfg, cases, solver="staggered")