flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
vardensity_solver_probe.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""WO-B (VoF rung S0) -- pressure-driver measurement battery on STATIC two-phase coefficients.
3
4Purpose (see `doc/vof_workorders.md` WO-B and `suite/docs/VOF_PLAN.md` section 5, the S-ladder):
5decide, from measurement rather than from the single anecdote in
6`doc/variable_density_projection.md` section 2, how far up the S-ladder the variable-density
7pressure solver work has to climb. NO VoF is needed: the density field is manufactured and
8static, so this probes the *linear solver* on the exact coefficient operator a VoF interface
9would present.
10
11What is swept
12-------------
13 geometry x rho-shape x edge x density ratio x driver x forcing case
14
15 geometry `box` open box, no immersed solid (`set_pressure_geometry`), walls +-z
16 `cyl` immersed cylinder along y, R = 0.25 N (cut-cell IBM)
17 `rings` 3 Raschig rings (the regression suite's `hollow_rings` bed) -- a genuinely
18 tortuous cut-cell pore geometry
19 `pack` `data/packing_ring.vti` strided onto the run grid (opt-in, N = 64)
20 rho-shape `slab` flat film normal to z, heavy below (the only true hydrostatic rest state)
21 `blob` sphere of heavy fluid at the domain centre
22 `tilt` film with normal (1,0,1)/sqrt2 -- deliberately NOT aligned with the MG
23 coarsening axes
24 `const` THE CONTROL: constant density, `set_density_mode` never called, gravity as
25 a uniform body force. Same geometry, same BCs, same driver, unit
26 coefficient -- so `pack/const` vs `pack/slab@1e4` is exactly the
27 "geometry alone" vs "geometry x coefficient" separation. Recorded with
28 ratio 1.
29 edge `smooth` tanh over ~2 cells `sharp` a one-cell step (what a real VoF
30 interface presents to the operator)
31 ratio 1e2, 1e3, 1e4
32 driver `cheb` (`set_pressure_chebyshev`) `pcg` (`set_pressure_pcg`)
33 `fcg` (`set_pressure_fcg`, added by WO-C / rung S1) -- the SAME V-cycle-preconditioned
34 CG as `pcg` with the same cap/tolerance/stopping estimate, differing only in
35 the beta recurrence (Polak-Ribiere instead of Fletcher-Reeves). `fcg` is NOT
36 in the default `--drivers` set, so the WO-B battery reproduces unchanged; ask
37 for it with `--drivers pcg,fcg` (or `cheb,pcg,fcg`).
38
39**Driver selection (historical trap, REPAIRED 2026-08-30 by WO-H).** `set_pressure_pcg(on, ...)`
40now genuinely selects MG-PCG (it clears both `useChebyshev_` and `useFcg_`), so the plain
41`set_pressure_pcg(True, cap, rtol)` after `set_density_mode` is correct. Until WO-H its `on` flag
42was **discarded** -- `IbmSolver::setPressurePcg(bool /*on*/, ...)` only stored
43`pcgMaxit_`/`pcgRtol_` -- so a "PCG" run after `set_density_mode("variable")` silently measured
44**Chebyshev** (the tell: it capped at exactly **120**, `chebMaxit_`'s default, not the cap passed),
45and the only working spelling was `set_pressure_chebyshev(False, ...)`. `run_one` still issues that
46`set_pressure_chebyshev(False, ...)` before `set_pressure_pcg` -- redundant now, kept so this script
47reproduces WO-B's and WO-C's recorded batteries byte-for-byte on an older build.
48 case `hydro` quiescent + gravity closure force_z = -g*rho (the hydrostatic setup);
49 periodic x/y, walls +-z
50 `lid` lid-driven flow: walls -x/+x/-z, the +z face translating at U in x,
51 periodic y.
52 `per` THE PRODUCTION CONFIGURATION: fully periodic + a uniform body force in x
53 -- pore-scale flow through the packing, no domain BC anywhere. This case
54 is where the two drivers swap places, so it is not optional. It is
55 skipped for (box, const) and (box, slab), where a z-only stratification in
56 a periodic box leaves u* already x-uniform, div(u*) = 0 identically, and
57 the pressure solve returns after 0 iterations -- a degenerate cell, not a
58 measurement.
59
60What is recorded, per configuration
61-----------------------------------
62 * pressure iterations per step (`last_pressure_iterations()`) -- median / max / total;
63 * "final residual" = `max_open_divergence()` = max|div(open*u)| after the step. This IS the
64 residual of the pressure system in the divergence normalisation: the projection solves
65 div(open*(rho0/rho_f) grad phi) = div(open*u*) and then subtracts the gradient, so whatever
66 divergence survives is exactly what the linear solve failed to remove;
67 * wall time: whole step and the `projection` phase alone (device-fenced per-phase timers);
68 * converged: whether the driver stayed strictly below its iteration cap on every step.
69
70Both drivers get the SAME rtol (1e-8) and the SAME cap (200), so "hit the cap" means the same
71thing for both and a stalling configuration terminates instead of burning the device.
72
73Run:
74 OMP_NUM_THREADS=8 OMP_PROC_BIND=false PYTHONPATH=<build> \
75 python tests/study/vardensity_solver_probe.py --out tests/study/vardensity_solver_probe.json
76 ... --pack add the `data/packing_ring.vti` sub-sweep (N=64)
77 ... --trace print the PCG residual history of one stalling configuration
78 ... --geoms box,cyl restrict the sweep
79"""
80import argparse
81import json
82import os
83import platform
84import sys
85import time
86
87import numpy as np
88
89# --------------------------------------------------------------------------- knobs
90N_DEFAULT = 32
91N_PACK = 64
92STEPS = 20
93MAXIT = 200 # same cap for both drivers -- a stall terminates instead of running away
94RTOL = 1e-8 # same tolerance for both drivers
95MU = 0.01
96DT = 1.0
97GRAV = 0.1 # hydrostatic case: force_z = -g*rho
98ULID = 1.0 # lid case: +z face translating at U in x
99FBODY = 1e-3 # per case: uniform body force in x (creeping pore-scale flow)
100VEL_SWEEPS = 40 # RB-GS momentum sweeps (velocity MG is disabled under varRho)
101
102RATIOS = (1e2, 1e3, 1e4)
103SHAPES = ("slab", "blob", "tilt")
104GEOM_ORDER = ("box", "cyl", "rings", "pack")
105SHAPE_ORDER = ("const", "slab", "blob", "tilt")
106EDGES = ("smooth", "sharp")
107DRIVERS = ("cheb", "pcg", "fcg")
108DRIVERS_DEFAULT = ("cheb", "pcg") # WO-B's battery; `fcg` (WO-C) is opt-in via --drivers
109CASES = ("hydro", "lid", "per")
110
111
112# --------------------------------------------------------------------------- geometry (sdf[x,y,z], <0 solid)
113def _grid(N):
114 g = np.arange(N) + 0.5
115 return np.meshgrid(g, g, g, indexing="ij")
116
117
118def _minimg(d, N):
119 return d - N * np.round(d / N)
120
121
122def sdf_box(N):
123 """No immersed solid: an all-fluid field for `set_pressure_geometry`."""
124 return None
125
126
127def sdf_cyl(N):
128 """Immersed cylinder along y, centred, R = 0.25 N."""
129 X, _, Z = _grid(N)
130 c, R = N / 2.0, 0.25 * N
131 return np.sqrt((X - c) ** 2 + (Z - c) ** 2) - R
132
133
134def _hollow_cyl_sdf(X, Y, Z, c, axis, r_out, r_in, H, N):
135 """One Raschig ring (hollow cylinder): annulus [r_in,r_out] intersect the slab |axial| <= H/2."""
136 ax = np.asarray(axis, float)
137 ax = ax / np.linalg.norm(ax)
138 dx = _minimg(X - c[0], N)
139 dy = _minimg(Y - c[1], N)
140 dz = _minimg(Z - c[2], N)
141 z = dx * ax[0] + dy * ax[1] + dz * ax[2]
142 rx, ry, rz = dx - z * ax[0], dy - z * ax[1], dz - z * ax[2]
143 rho = np.sqrt(rx * rx + ry * ry + rz * rz)
144 return np.maximum(np.maximum(r_in - rho, rho - r_out), np.abs(z) - 0.5 * H)
145
146
148 """The regression suite's `hollow_rings` bed: 3 Raschig rings, fixed positions/orientations."""
149 rO, rI, H = 0.22 * N, 0.12 * N, 0.34 * N
150 rings = [((0.30 * N, 0.32 * N, 0.30 * N), (1, 0, 0)),
151 ((0.70 * N, 0.68 * N, 0.55 * N), (0, 1, 0)),
152 ((0.45 * N, 0.50 * N, 0.78 * N), (0, 0, 1))]
153 X, Y, Z = _grid(N)
154 sdf = np.full((N, N, N), 1e30)
155 for c, axis in rings:
156 sdf = np.minimum(sdf, _hollow_cyl_sdf(X, Y, Z, c, axis, rO, rI, H, N))
157 return sdf
158
159
160def sdf_pack(N):
161 """`data/packing_ring.vti` (256^3, physical units) strided onto an N^3 grid, in CELL units."""
162 import pyvista as pv
163 here = os.path.dirname(os.path.abspath(__file__))
164 path = os.path.join(here, "..", "..", "data", "packing_ring.vti")
165 g = pv.read(path)
166 dims = g.dimensions
167 a = g.point_data["SDF"].reshape(dims, order="F")
168 if dims[0] % N:
169 raise SystemExit(f"packing_ring.vti is {dims[0]}^3; N={N} must divide it")
170 st = dims[0] // N
171 return np.ascontiguousarray(a[::st, ::st, ::st]) / (g.spacing[0] * st)
172
173
174GEOMS = {"box": sdf_box, "cyl": sdf_cyl, "rings": sdf_rings, "pack": sdf_pack}
175
176
177# --------------------------------------------------------------------------- manufactured rho
178def level_set(shape, N):
179 """psi > 0 in the HEAVY fluid, in units of cells (so `psi/w` is a cell-width argument)."""
180 X, Y, Z = _grid(N)
181 if shape == "slab": # flat film normal to z, heavy below -> a true rest state
182 return N / 2.0 - Z
183 if shape == "blob": # sphere of heavy fluid at the centre
184 c, R = N / 2.0, 0.25 * N
185 return R - np.sqrt((X - c) ** 2 + (Y - c) ** 2 + (Z - c) ** 2)
186 if shape == "tilt": # film whose normal is NOT a grid/coarsening axis
187 c = N / 2.0
188 return ((c - X) + (c - Z)) / np.sqrt(2.0)
189 raise ValueError(shape)
190
191
192def rho_field(shape, edge, ratio, N):
193 psi = level_set(shape, N)
194 if edge == "smooth": # tanh over ~2 cells (psi/w, w = 1 cell)
195 h = 0.5 * (1.0 + np.tanh(psi / 1.0))
196 elif edge == "sharp": # a one-cell step: what a real VoF interface presents
197 h = (psi > 0.0).astype(np.float64)
198 else:
199 raise ValueError(edge)
200 return 1.0 + (ratio - 1.0) * h
201
202
203# --------------------------------------------------------------------------- one configuration
204def run_one(geom, shape, edge, ratio, driver, case, N, quiet=True, bottom=None, levels=None):
205 from peclet import flow as F
206
207 sdf = GEOMS[geom](N)
208 levels = levels or max(2, int(np.floor(np.log2(N))) - 1)
209
210 s = F.Solver(N, N, N)
211 s.set_rho(1.0)
212 s.set_mu(MU)
213 s.set_dt(DT)
214 s.set_advection(False) # creeping Stokes: isolate the pressure solve
215 s.diagnostics.set_velocity_solver_params(VEL_SWEEPS)
216 if case == "hydro": # periodic x/y, walls +-z
217 s.set_domain_bc("-z", "wall", (0, 0, 0))
218 s.set_domain_bc("+z", "wall", (0, 0, 0))
219 elif case == "lid": # walls -x/+x/-z, lid at +z, periodic y
220 s.set_domain_bc("-x", "wall", (0, 0, 0))
221 s.set_domain_bc("+x", "wall", (0, 0, 0))
222 s.set_domain_bc("-z", "wall", (0, 0, 0))
223 s.set_domain_bc("+z", "inflow", (ULID, 0.0, 0.0))
224 elif case == "per": # fully periodic + uniform body force in x
225 s.set_body_force((FBODY, 0.0, 0.0))
226 else:
227 raise ValueError(case)
228 s.set_pressure_multigrid(True, levels=levels)
229 if bottom:
230 s.set_pressure_bottom(bottom)
231
232 if sdf is None:
233 s.set_pressure_geometry(np.asfortranarray(np.full((N, N, N), 10.0)))
234 else:
235 s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
236
237 if shape == "const": # the constant-density control: varRho never on
238 ratio = 1.0
239 if case == "hydro":
240 s.set_body_force((0.0, 0.0, -GRAV)) # uniform gravity replaces the rho closure
241 else:
242 s.add_field("rho")
243 s.set_field("rho", np.asfortranarray(rho_field(shape, edge, ratio, N)))
244 s.diagnostics.set_density_mode("variable") # (defaults the driver to Chebyshev)
245 if case == "hydro":
246 s.set_property_model("force_z", "linear", "rho", [0.0, -GRAV])
247
248 # Driver selection AFTER set_density_mode. All three setters genuinely select since WO-H; the
249 # extra `set_pressure_chebyshev(False, ...)` in the `pcg` branch is the pre-WO-H spelling, kept
250 # so this script also selects correctly on an older build (see the module docstring).
251 if driver == "cheb":
252 s.set_pressure_pcg(True, MAXIT, RTOL)
253 s.set_pressure_chebyshev(True, MAXIT, RTOL)
254 elif driver == "pcg":
255 s.set_pressure_chebyshev(False, MAXIT, RTOL)
256 s.set_pressure_pcg(True, MAXIT, RTOL)
257 elif driver == "fcg":
258 # set_pressure_fcg has always honoured its flag (it clears useChebyshev_ itself), so unlike
259 # the `pcg` branch above no set_pressure_chebyshev(False) dance is needed on any build.
260 s.set_pressure_fcg(True, MAXIT, RTOL)
261 else:
262 raise ValueError(driver)
263
264 iters, divs, t_step, t_proj = [], [], [], []
265 t_wall0 = time.perf_counter()
266 for _ in range(STEPS):
267 s.step()
268 iters.append(int(s.diagnostics.last_pressure_iterations()))
269 tm = s.diagnostics.last_step_timers()
270 t_step.append(float(tm["step"]))
271 t_proj.append(float(tm["projection"]))
272 divs.append(float(s.max_open_divergence()))
273 wall = time.perf_counter() - t_wall0
274 umax = max(float(np.abs(s.get_u()).max()), float(np.abs(s.get_v()).max()),
275 float(np.abs(s.get_w()).max()))
276
277 # A low iteration count does NOT imply a good solve: PCG's breakdown guard (`pAp <= 1e-300`
278 # or a non-finite recurrence scalar in `CutcellMG::solvePCG`) exits early and keeps the last
279 # finite iterate. So health is judged on the PHYSICS: max|div(open*u)| after the step,
280 # normalised by the case's own velocity scale.
281 it = np.asarray(iters, float)
282 uscale = max(umax, {"lid": ULID, "hydro": GRAV * DT, "per": FBODY * DT}[case])
283 div_rel = divs[-1] / uscale
284 rec = dict(
285 geom=geom, shape=shape, edge=edge, ratio=ratio, driver=driver, case=case, N=N,
286 levels=levels, bottom=bottom or "auto", steps=STEPS, maxit=MAXIT, rtol=RTOL,
287 iters=iters,
288 it_median=float(np.median(it)), it_max=int(it.max()), it_min=int(it.min()),
289 it_total=int(it.sum()),
290 capped_steps=int((it >= MAXIT).sum()),
291 converged=bool((it < MAXIT).all()),
292 div_final=divs[-1], div_max=float(max(divs)), div_rel=float(div_rel),
293 healthy=bool((it < MAXIT).all() and np.isfinite(umax) and div_rel < 1e-8),
294 wall_s=wall,
295 t_step_median=float(np.median(t_step)), t_proj_median=float(np.median(t_proj)),
296 t_proj_min=float(np.min(t_proj)), t_proj_total=float(np.sum(t_proj)),
297 umax=umax,
298 )
299 if not quiet:
300 print(f" {geom:5s} {shape:4s} {edge:6s} r={ratio:<7.0e} {driver:4s} {case:6s} "
301 f"it med {rec['it_median']:6.1f} max {rec['it_max']:4d} "
302 f"{'CAPPED' if not rec['converged'] else ('ok ' if rec['healthy'] else 'BADDIV')} "
303 f"div/u {rec['div_rel']:.1e} proj {rec['t_proj_min']*1e3:7.1f} ms "
304 f"(med {rec['t_proj_median']*1e3:7.1f})", flush=True)
305 return rec
306
307
308# --------------------------------------------------------------------------- reporting
309def markdown(records):
310 out = []
311 out.append("`proj ms` is the FASTEST of the 20 steps' projection times, not the median: this "
312 "host is shared with other agents, and a median picks up their load.\n")
313 out.append("`ok` = stayed below the iteration cap AND left max|div(open u)|/u_scale < 1e-8; "
314 "`CAP` = hit the cap; `BAD` = finished but the projection did not close.\n")
315 # Columns follow whichever drivers are present, in DRIVERS order (cheb, pcg, fcg): a cheb+pcg
316 # run reproduces WO-B's table exactly, a --drivers pcg,fcg run gets the FCG trio instead.
317 label = {"cheb": "Cheb", "pcg": "PCG", "fcg": "FCG"}
318 present = [d for d in DRIVERS if any(r["driver"] == d for r in records)]
319 head = ["geom", "shape", "edge", "ratio", "case"]
320 for d in present:
321 head += [f"{label[d]} its (med/max)", label[d], f"{label[d]} div/u", f"{label[d]} proj ms"]
322 out.append("| " + " | ".join(head) + " |")
323 out.append("|" + "---|" * len(head))
324 key = lambda r: (r["geom"], r["shape"], r["edge"], r["ratio"], r["case"])
325 idx = {}
326 for r in records:
327 idx.setdefault(key(r), {})[r["driver"]] = r
328 for k in sorted(idx, key=lambda k: (GEOM_ORDER.index(k[0]), SHAPE_ORDER.index(k[1]),
329 ("smooth", "sharp").index(k[2]), k[3],
330 CASES.index(k[4]))):
331 g, sh, e, ra, ca = k
332 def cell(r):
333 if r is None:
334 return ["-", "-", "-", "-"]
335 state = ("ok" if r["healthy"]
336 else (f"**CAP** ({r['capped_steps']}/{r['steps']})"
337 if not r["converged"] else "**BAD**"))
338 return [f"{r['it_median']:.0f}/{r['it_max']}", state,
339 f"{r['div_rel']:.1e}", f"{r['t_proj_min']*1e3:.1f}"]
340 row = [g, sh, e, f"{ra:.0e}", ca]
341 for d in present:
342 row += cell(idx[k].get(d))
343 out.append("| " + " | ".join(row) + " |")
344 return "\n".join(out)
345
346
347def summarize(records):
348 """The two questions the S-ladder decision turns on."""
349 lines = []
350 for ca in CASES:
351 for drv in DRIVERS:
352 lines.append(f"\ncase {ca}, driver {drv}: median-of-medians / worst-step iterations, "
353 f"aggregated over rho-shape")
354 lines.append("| geom | edge | ratio 1e2 | ratio 1e3 | ratio 1e4 |")
355 lines.append("|---|---|---|---|---|")
356 for g in ("box", "cyl", "rings", "pack"):
357 for e in EDGES:
358 cells, any_here = [], False
359 for ra in RATIOS:
360 sel = [r for r in records if r["driver"] == drv and r["geom"] == g
361 and r["edge"] == e and r["ratio"] == ra and r["case"] == ca]
362 if not sel:
363 cells.append("-")
364 continue
365 any_here = True
366 med = np.median([r["it_median"] for r in sel])
367 mx = max(r["it_max"] for r in sel)
368 bad = sum(0 if r["healthy"] else 1 for r in sel)
369 cells.append(f"{med:.0f} / {mx}"
370 + (f" (**{bad}/{len(sel)} unhealthy**)" if bad else ""))
371 if any_here:
372 lines.append("| " + " | ".join([g, e] + cells) + " |")
373
374 # The separation the S-ladder decision turns on: how much of the iteration count is the
375 # GEOMETRY (constant-density control on the same grid) and how much is the rho jump on top.
376 lines.append("\ngeometry vs coefficient: median iterations, constant-rho control vs the "
377 "sharp-edge varRho runs")
378 lines.append("| geom | case | driver | const rho | sharp 1e2 | sharp 1e3 | sharp 1e4 | "
379 "jump cost (1e4 / const) |")
380 lines.append("|---|---|---|---|---|---|---|---|")
381 for g in GEOM_ORDER:
382 for ca in CASES:
383 for drv in DRIVERS:
384 ctrl = [r for r in records if r["geom"] == g and r["shape"] == "const"
385 and r["case"] == ca and r["driver"] == drv]
386 if not ctrl:
387 continue
388 c0 = np.median([r["it_median"] for r in ctrl])
389 cells = []
390 for ra in RATIOS:
391 sel = [r for r in records if r["geom"] == g and r["shape"] != "const"
392 and r["edge"] == "sharp" and r["case"] == ca
393 and r["driver"] == drv and r["ratio"] == ra]
394 cells.append(f"{np.median([r['it_median'] for r in sel]):.0f}" if sel else "-")
395 last = [r for r in records if r["geom"] == g and r["shape"] != "const"
396 and r["edge"] == "sharp" and r["case"] == ca and r["driver"] == drv
397 and r["ratio"] == RATIOS[-1]]
398 fac = (np.median([r["it_median"] for r in last]) / c0) if (last and c0) else 0.0
399 lines.append("| " + " | ".join([g, ca, drv, f"{c0:.0f}"] + cells
400 + [f"{fac:.2f}x" if fac else "-"]) + " |")
401 return "\n".join(lines)
402
403
404def cheb_overhead(geom, N, ratio=1e3):
405 """Quantify what rung S2 (bound amortization) would actually buy.
406
407 Under varRho the coefficient operator is rebuilt every step and `chebBoundsSet_` is
408 invalidated with it (`flow_ibm.hpp` project()), so `estimateEigenvalues(..., iters=15, ...)`
409 runs EVERY step -- two 15-step power loops, each applying M^{-1}A once, i.e. **30 extra
410 V-cycles per step** on top of the ~15-25 the solve itself needs. Constant density estimates
411 the bounds once (step 1) and reuses them, so the A/B below isolates the cost.
412 """
413 from peclet import flow as F
414
415 def run(varrho, driver):
416 sdf = GEOMS[geom](N)
417 levels = max(2, int(np.floor(np.log2(N))) - 1)
418 s = F.Solver(N, N, N)
419 s.set_rho(1.0)
420 s.set_mu(MU)
421 s.set_dt(DT)
422 s.set_advection(False)
423 s.diagnostics.set_velocity_solver_params(VEL_SWEEPS)
424 s.set_domain_bc("-x", "wall", (0, 0, 0))
425 s.set_domain_bc("+x", "wall", (0, 0, 0))
426 s.set_domain_bc("-z", "wall", (0, 0, 0))
427 s.set_domain_bc("+z", "inflow", (ULID, 0.0, 0.0))
428 s.set_pressure_multigrid(True, levels=levels)
429 if sdf is None:
430 s.set_pressure_geometry(np.asfortranarray(np.full((N, N, N), 10.0)))
431 else:
432 s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
433 if varrho:
434 s.add_field("rho")
435 s.set_field("rho", np.asfortranarray(rho_field("slab", "sharp", ratio, N)))
436 s.diagnostics.set_density_mode("variable")
437 if driver == "cheb":
438 s.set_pressure_chebyshev(True, MAXIT, RTOL)
439 else:
440 s.set_pressure_chebyshev(False, MAXIT, RTOL) # the actual switch (see the docstring)
441 s.set_pressure_pcg(True, MAXIT, RTOL)
442 tp, it = [], []
443 for _ in range(STEPS):
444 s.step()
445 tp.append(float(s.diagnostics.last_step_timers()["projection"]))
446 it.append(int(s.diagnostics.last_pressure_iterations()))
447 return np.asarray(tp), np.asarray(it)
448
449 out = {}
450 for varrho in (False, True):
451 for drv in ("cheb", "pcg"):
452 tp, it = run(varrho, drv)
453 k = f"{'varrho' if varrho else 'constrho'}_{drv}"
454 out[k] = dict(t_first=float(tp[0]), t_median=float(np.median(tp[1:])),
455 it_first=int(it[0]), it_median=float(np.median(it[1:])))
456 c = out["constrho_cheb"]
457 est = c["t_first"] - c["t_median"] # the one-off bound estimate, isolated
458 v = out["varrho_cheb"]
459 print("\n--- Chebyshev bound-estimation overhead (rung S2's target) ---")
460 for k, r in out.items():
461 print(f" {k:14s} step1 {r['t_first']*1e3:7.1f} ms ({r['it_first']:3d} it) "
462 f"steady {r['t_median']*1e3:7.1f} ms ({r['it_median']:5.1f} it)")
463 print(f" isolated bound estimate (const-rho step1 - steady): {est*1e3:.1f} ms")
464 print(f" varRho Chebyshev pays it EVERY step: {est/max(v['t_median'],1e-12)*100:.0f} % of "
465 f"its steady projection time; amortizing it (S2) would leave "
466 f"{(v['t_median']-est)*1e3:.1f} ms")
467 out["estimate_cost_s"] = est
468 out["geom"] = geom
469 out["N"] = N
470 out["ratio"] = ratio
471 return out
472
473
474def trace_pcg(geom, shape, edge, ratio, case, N):
475 """Re-run one configuration with the MG residual trace on (PCG only -- Chebyshev has none)."""
476 os.environ["PECLET_FLOW_MG_DEBUG"] = "2"
477 os.environ["PECLET_FLOW_MG_DEBUG_SOLVES"] = "3"
478 print(f"\n--- PCG residual trace: {geom}/{shape}/{edge}/ratio {ratio:.0e}/{case} ---",
479 flush=True)
480 run_one(geom, shape, edge, ratio, "pcg", case, N, quiet=False)
481 os.environ.pop("PECLET_FLOW_MG_DEBUG", None)
482
483
484# --------------------------------------------------------------------------- main
485def main():
486 ap = argparse.ArgumentParser()
487 ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)),
488 "vardensity_solver_probe.json"))
489 ap.add_argument("--md", default=None, help="also write the markdown table to this path")
490 ap.add_argument("--n", type=int, default=N_DEFAULT)
491 ap.add_argument("--geoms", default="box,cyl,rings")
492 ap.add_argument("--shapes", default="const," + ",".join(SHAPES))
493 ap.add_argument("--edges", default=",".join(EDGES))
494 ap.add_argument("--ratios", default=",".join(f"{r:.0e}" for r in RATIOS))
495 ap.add_argument("--drivers", default=",".join(DRIVERS_DEFAULT),
496 help="cheb, pcg, fcg (comma-separated); default cheb,pcg = WO-B's battery")
497 ap.add_argument("--cases", default=",".join(CASES))
498 ap.add_argument("--pack", action="store_true",
499 help="append the data/packing_ring.vti sub-sweep at N=64 (shapes slab,blob)")
500 ap.add_argument("--trace", action="store_true")
501 ap.add_argument("--cheb-overhead", action="store_true",
502 help="also measure the per-step Chebyshev bound-estimation cost (rung S2)")
503 ap.add_argument("--overhead-geoms", default="box,rings",
504 help="geometries for --cheb-overhead (independent of --geoms)")
505 ap.add_argument("--tag", default="", help="label stored in the JSON (e.g. the backend)")
506 ap.add_argument("--bottom", default=None,
507 help="set_pressure_bottom mode ('auto'/'smoother'/'agglomerated'); "
508 "default = the shipped 'auto'")
509 ap.add_argument("--levels", type=int, default=None,
510 help="pressure MG depth (default floor(log2 N) - 1)")
511 args = ap.parse_args()
512
513 geoms = [g for g in args.geoms.split(",") if g]
514 shapes = [s for s in args.shapes.split(",") if s]
515 edges = [e for e in args.edges.split(",") if e]
516 ratios = [float(r) for r in args.ratios.split(",") if r]
517 drivers = [d for d in args.drivers.split(",") if d]
518 cases = [c for c in args.cases.split(",") if c]
519
520 def expand(gs, shs, n):
521 # `const` is the unit-coefficient control: edge and ratio are meaningless there, so it is
522 # emitted once per (geom, driver, case) instead of once per (edge, ratio).
523 out = []
524 for g in gs:
525 for sh in shs:
526 ee = [edges[0]] if sh == "const" else edges
527 rr = [1.0] if sh == "const" else ratios
528 cc = [c for c in cases
529 # degenerate: a z-only rho in a periodic box gives div(u*) == 0 exactly
530 if not (c == "per" and g == "box" and sh in ("const", "slab"))]
531 out += [(g, sh, e, ra, d, ca, n)
532 for e in ee for ra in rr for d in drivers for ca in cc]
533 return out
534
535 plan = expand(geoms, shapes, args.n)
536 if args.pack:
537 plan += expand(["pack"], [s for s in ("const", "slab", "blob") if s in shapes], N_PACK)
538
539 print(f"{len(plan)} configurations x {STEPS} steps", flush=True)
540 records = []
541 t0 = time.perf_counter()
542 for i, (g, sh, e, ra, d, ca, n) in enumerate(plan):
543 print(f"[{i+1}/{len(plan)}]", end=" ", flush=True)
544 records.append(run_one(g, sh, e, ra, d, ca, n, quiet=False,
545 bottom=args.bottom, levels=args.levels))
546 wall = time.perf_counter() - t0
547
548 overhead = None
549 if args.cheb_overhead:
550 overhead = [cheb_overhead(g, args.n)
551 for g in args.overhead_geoms.split(",") if g]
552
553 md = markdown(records)
554 print("\n" + md)
555 print(summarize(records))
556 print(f"\ntotal wall {wall:.1f} s")
557
558 meta = dict(tag=args.tag, steps=STEPS, maxit=MAXIT, rtol=RTOL, mu=MU, dt=DT,
559 g=GRAV, u_lid=ULID, vel_sweeps=VEL_SWEEPS, wall_s=wall,
560 python=sys.version.split()[0], host=platform.node(),
561 argv=" ".join(sys.argv[1:]))
562 if os.path.exists(args.out):
563 with open(args.out) as f:
564 blob = json.load(f)
565 else:
566 blob = {"runs": []}
567 blob.setdefault("runs", []).append({"meta": meta, "records": records,
568 "cheb_overhead": overhead})
569 with open(args.out, "w") as f:
570 json.dump(blob, f, indent=1)
571 print(f"wrote {args.out} ({len(blob['runs'])} run(s))")
572 if args.md:
573 with open(args.md, "w") as f:
574 f.write(md + "\n" + summarize(records) + "\n")
575
576 if args.trace:
577 trace_pcg("box", "slab", "sharp", 1e3, "hydro", args.n)
578
579
580if __name__ == "__main__":
581 main()
run_one(geom, shape, edge, ratio, driver, case, N, quiet=True, bottom=None, levels=None)
_hollow_cyl_sdf(X, Y, Z, c, axis, r_out, r_in, H, N)
trace_pcg(geom, shape, edge, ratio, case, N)
rho_field(shape, edge, ratio, N)
static int run(int bc)