flow 1.3.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 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)
27"""
28import argparse
29import json
30import os
31import sys
32import time
33
34import numpy as np
35
36HERE = os.path.dirname(os.path.abspath(__file__))
37ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
38BASELINE = os.path.join(HERE, "perf_baseline.json")
39
40sys.path.insert(0, os.path.join(ROOT, "scripts"))
41from _bootstrap import ensure_flow # noqa: E402
42
43# Zick & Homsy (1982), simple cubic: solid fraction c -> Stokes drag factor K.
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]
46
47
48def zh_ref(phi):
49 return float(np.interp(phi, ZH_PHI, ZH_K))
50
51
52# --------------------------------------------------------------------------- geometry (sdf[x,y,z], <0 solid)
53def _grid(N):
54 g = np.arange(N) + 0.5 # cell centres
55 return np.meshgrid(g, g, g, indexing="ij")
56
57
58def _minimg(d, N):
59 return d - N * np.round(d / N)
60
61
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
65 X, Y, Z = _grid(N)
66 c = N / 2.0
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)}
69
70
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)
75 R = r_frac * N
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
79 X, Y, Z = _grid(N)
80 sdf = np.full((N, N, N), 1e30)
81 for cx, cy, cz in centres:
82 dx = _minimg(X - cx, N); dy = _minimg(Y - cy, N); dz = _minimg(Z - cz, N)
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}
85
86
87def _hollow_cyl_sdf(X, Y, Z, c, axis, r_out, r_in, H, N):
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)
90 dx = _minimg(X - c[0], N); dy = _minimg(Y - c[1], N); dz = _minimg(Z - c[2], N)
91 z = dx * ax[0] + dy * ax[1] + dz * ax[2] # axial coord
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) # radial distance from the axis
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) # <0 inside the ring wall
97
98
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))]
105 X, Y, Z = _grid(N)
106 sdf = np.full((N, N, N), 1e30)
107 for c, axis in rings:
108 sdf = np.minimum(sdf, _hollow_cyl_sdf(X, Y, Z, c, axis, rO, rI, H, N))
109 return sdf, {"r_out": rO, "r_in": rI, "H": H, "n_rings": len(rings)}
110
111
112CASES = {
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*"},
116}
117
118# Fixed solver config shared by every case (so the recorded efficiency is comparable across runs).
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)
121
122
123# --------------------------------------------------------------------------- run one (case, N)
124def run_case(name, N, cfg, quiet=True, solver="staggered", scheme="gauge-exact"):
125 from peclet import flow as sdflow
126 spec = CASES[name]
127 sdf, info = spec["sdf"](N)
128 levels = max(2, int(np.floor(np.log2(N))) - 1)
129
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) # creeping Stokes
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) # ALWAYS explicit: baselines pin schemes, not defaults
140 # (the shipped default is AUTO = ghost since 2026-08-25)
141 s.set_solid(sdf, cutcell_pressure=True)
142
143 deep_solid = sdf < -2.0
144 t0 = time.time()
145 prev, steps, p_iters = 0.0, 0, []
146 for it in range(cfg["max_steps"]):
147 s.step()
148 steps += 1
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):
153 break
154 prev = m
155 wall = time.time() - t0
156
157 u = s.get_u()
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": # Zick & Homsy drag factor (dimensionless)
162 metric = cfg["F"] * N ** 3 / (6.0 * np.pi * cfg["mu"] * info["R"] * umean)
163 else: # dimensionless permeability k* = k/L^2 = mu <u> / (F N^2)
164 metric = cfg["mu"] * umean / (cfg["F"] * N ** 2)
165 half = p_iters[len(p_iters) // 2:]
166 return {
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),
173 }
174
175
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)
179 best = None
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] # order, extrapolated f_inf
187
188
189def run_all(cfg, cases, solver="staggered", scheme="gauge-exact"):
190 out = {}
191 for name in cases:
192 grids = CASES[name]["grids"]
193 per = {}
194 print(f"\n[{name}] ({solver}) grids {grids} ...", flush=True)
195 for N in grids:
196 r = run_case(name, N, cfg, solver=solver, scheme=scheme)
197 per[str(N)] = r
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)
201 Ns = grids
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}
210 out[name] = entry
211 print(f" -> order p={order:.2f}, extrapolated {CASES[name]['metric']}_inf={finf:.5g}", flush=True)
212 return out
213
214
215# --------------------------------------------------------------------------- baseline compare
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)
218
219
220def compare(base, cur, solver="staggered"):
221 ok = True
222 lines = []
223 for name in cur:
224 if name not in base:
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})")
229 # order + extrapolated value. COLLOCATED: the fitted order is ADVISORY, not a gate --
230 # the attractor campaign measured collocated errors crossing zero inside these grid
231 # ranges (doc/collocated_paper_plan.md row 22 trap), where Richardson fits are noise:
232 # a ~1e-6 metric shift (any rebuild) flips p by O(1) while every value stays [ok].
233 d_ord = abs(c["order"] - b["order"])
234 if solver == "colocated":
235 s_ord = "ok" if d_ord <= TOL["order_abs"] else "warn"
236 else:
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"
253 lines.append(
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)
261
262
263def main():
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()
278
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()]
285 cfg = dict(CFG)
286 if args.quick:
287 cfg.update(max_steps=120, conv_tol=3e-4)
288 for c in CASES.values():
289 c["grids"] = c["grids"][:3]
290
291 t0 = time.time()
292 cur = run_all(cfg, cases, solver=args.solver, scheme=args.scheme)
293 print(f"\n(total {time.time()-t0:.0f}s)")
294
295 if args.update:
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}")
301 return 0
302
303 if not os.path.exists(baseline):
304 print(f"\nNO baseline at {baseline}; run with --update first.")
305 return 1
306 base = json.load(open(baseline))
307 ok, report = compare(base, cur, solver=args.solver)
308 print(report)
309 print(f"\n=== regression: {'PASS' if ok else 'FAIL'} ===")
310 return 0 if ok else 1
311
312
313if __name__ == "__main__":
314 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)
static double mean(const std::vector< double > &a)