flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_collocated_stability_guard.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""Guard: every COLLOCATED density path is stable at large mu*dt and has a dt-independent steady
3state (doc/collocated_varrho_forces.md ยง7.6; suite-wide register "Collocated forces stay in the
4implicit predictor").
5
6Why a gate and not a grep. The defect this guards against is a property of the discrete STEP: where
7the lagged pressure enters relative to the implicit momentum operator. Added as a face acceleration
8AFTER the viscous solve (Basilisk centered.h; flow's rung V8/WO-T until 2026-09-25), the lagged
9pressure lies exactly in the range the projection removes, so the velocity update is
10non-incremental: with the rotational update the pressure obeys P^{n+1} = -4 kappa dt S P^n / rho,
11growing ~12 kappa dt/(rho h^2) per step at (pi, pi, pi) once mu dt/(rho h^2) > 1/12 (G2); with
12kappa = 0 the steady state depends on dt (G3). The form can come back under any name -- a
13centered.h port, an "explicit pressure correction after the viscous solve", a defect-correction
14stage that moves -G P^n to a later explicit update -- and none share a token; both signatures are
15unconditional.
16
17Paths:
18 constant rho: 'ghost' (PINNED, and the build must print no AUTO fallback notice: G2 in the
19 periodic all-fluid box, G3 with the +-y walls made by an immersed solid, because the ghost v1
20 does not take domain-BC walls and an AUTO request there silently runs gauge-exact),
21 'gauge-exact', 'plain', 'embed';
22 V8 variable rho: a uniform rho field and a frozen ratio-1000 slab, balanced-force projection
23 OFF and ON; V8 constant-rho CSF (enable_vof, sigma > 0, C re-set before every step = frozen,
24 constant kappa), OFF and ON. The ON variants are reported as unavailable on a build without
25 set_balanced_force_projection.
26A throw FAILS the path, at any step, unless the path is in REFUSAL_WHITELIST (a documented scope
27refusal; empty today: every path above is in scope). A refusal used to count as a pass, which let a
28path that throws at its first step pass silently.
29
30Protocol, Stokes (advection off, so the step is affine and a perturbation evolves exactly by the
31step operator); mu = 1, rho_min = 1, h = 1; a small Taylor-Green cell force keeps the twin non-trivial.
32 G2 growth: seed P with eps (-1)^(x+y+z) + eps * U(-1, 1) and run 100 steps at dt in
33 {0.1, 100} beside the unseeded twin (periodic; the ratio-1000 slab OFF also WALLED at +-y
34 at dt = 100, so the weight sum, the high-face openness and the Neumann rho ghosts are in it --
35 once: the option does not touch the homogeneous step operator that G2 measures, ON and OFF
36 give the same growth numbers, and OFF also runs on a build without the option). Measured: the (pi,pi,pi) FFT amplitude of P - P_twin
37 and max|u - u_twin|. Pass iff at step 100 both are <= 2x their reference and <= (1 + 1e-6)x
38 their step-50 value. Reference: the seeded amplitude for P; for u (zero before the first step)
39 its value after step 1. Values below a round-off floor FLOOR*eps count as the floor (a decayed
40 transient is noise, not growth).
41 G3 dt-independence: walled at +-y, the Taylor-Green force, dt in {100, 10, 1}. The dt = 100 run
42 marches to max|du| <= 1e-10 max|u| -- from rest, or for a V8 ON path from the OFF path's
43 converged dt = 100 state (u, v, w, P without P_b: the first ON step re-splits); the dt = 10
44 and dt = 1 runs are WARM-STARTED from the dt = 100 state (u, v, w, P, and P_b when the option
45 is on) and march to the same criterion. A dt-independent fixed point is then already
46 converged (one or two steps); a dt-dependent one drifts. Pass iff
47 max|u(dt_i) - u(dt_j)| <= 1e-7 max|u| -- the Chorin signature this catches is 2.5e-2 to 0.5,
48 so the gate keeps five decades of margin above the 1e-10 march tolerance. (Warm starting is
49 what makes the ratio-1000 slab affordable: from rest its heavy layer relaxes at rate
50 mu k^2/rho ~ 1e-4 per unit time.) Every march must converge within G3_MAX_STEPS or the path
51 fails. Pressure rtol 1e-12 throughout.
52
53Run: OMP_NUM_THREADS=4 OMP_PROC_BIND=false PYTHONPATH=<build> \
54 python tests/python/test_collocated_stability_guard.py
55Exit 0 pass, 1 fail, 77 skipped (no module).
56"""
57import os
58import sys
59import tempfile
60import time
61
62import numpy as np
63
64try:
65 import peclet.flow as pf
66except ImportError:
67 print("SKIP: peclet.flow not importable")
68 sys.exit(77)
69
70N, NZ = 16, 4
71MU = 1.0
72EPS = 1e-3 # seeding amplitude (the step is affine: the amplitude only sets the floor)
73FLOOR = 1e-9 # round-off floor, relative to EPS
74A_TG = 1e-4 # Taylor-Green cell-force amplitude (keeps the VoF CFL << 0.25 at dt = 100)
75SIGMA = 1e-6 # CSF path: capillary dt = sqrt(2/(4 pi sigma)) ~ 400 > the largest dt
76KAPPA = 0.25
77G2_DTS = (0.1, 100.0)
78G2_WALLED_DT = 100.0
79G3_DTS = (100.0, 10.0, 1.0)
80G2_STEPS = 100
81G3_MAX_STEPS = 3000
82G3_TOL = 1e-10
83G3_GATE = 1e-7
84P_RTOL = 1e-12
85FALLBACK = "AUTO scheme fell back"
86# Paths allowed to refuse their configuration (name -> the scope statement that makes the refusal
87# correct). Any other throw fails the path. Empty: every guarded path is in scope.
88REFUSAL_WHITELIST = {}
89
90
91def F(a):
92 return np.asfortranarray(np.ascontiguousarray(a, dtype=np.float64))
93
94
96 k = 2.0 * np.pi / N
97 xc = np.arange(N) + 0.5
98 X, Y = np.meshgrid(xc, xc, indexing="ij")
99 rep = lambda a: np.repeat(a[:, :, None], NZ, axis=2) # noqa: E731
100 return rep(A_TG * np.sin(k * X) * np.cos(k * Y)), rep(-A_TG * np.cos(k * X) * np.sin(k * Y))
101
102
103def slab_rho(ratio):
104 y = np.arange(N)
105 r = np.where((y >= N // 4) & (y < 3 * N // 4), ratio, 1.0)
106 return np.repeat(np.repeat(r[None, :, None], N, 0), NZ, 2)
107
108
110 """Volume fractions of a sphere, R = 4, 4x4x4 sub-sampling (frozen interface for the CSF)."""
111 sub = 4
112 off = (np.arange(sub) + 0.5) / sub
113 c = np.array([N / 2 + 0.13, N / 2 + 0.27, NZ / 2 + 0.11])
114 out = np.zeros((N, N, NZ))
115 for a in off:
116 for b in off:
117 for d in off:
118 X, Y, Z = np.meshgrid(np.arange(N) + a, np.arange(N) + b, np.arange(NZ) + d,
119 indexing="ij")
120 out += ((X - c[0]) ** 2 + (Y - c[1]) ** 2 + (Z - c[2]) ** 2 < 16.0)
121 return out / sub ** 3
122
123
125 """Capture the C-level stderr (fd 2) of a block: the solver's notices are fprintf(stderr)."""
126
127 def __enter__(self):
128 sys.stderr.flush()
129 self._tmp = tempfile.TemporaryFile(mode="w+b")
130 self._saved = os.dup(2)
131 os.dup2(self._tmp.fileno(), 2)
132 self.text = ""
133 return self
134
135 def __exit__(self, *exc):
136 sys.stderr.flush()
137 os.dup2(self._saved, 2)
138 os.close(self._saved)
139 self._tmp.seek(0)
140 self.text = self._tmp.read().decode(errors="replace")
141 self._tmp.close()
142 # the expected V8 / domain-BC fallback notices are dropped; anything else is passed on
143 rest = [ln for ln in self.text.splitlines() if FALLBACK not in ln]
144 if rest:
145 print("\n".join(rest), file=sys.stderr)
146 return False
147
148
150 """Immersed-solid walls at y = 1 and y = N - 1 (face-aligned), fluid between: the ghost
151 scheme's G3 walls (it does not take domain-BC walls)."""
152 yc = np.arange(N) + 0.5
153 d = np.minimum(yc - 1.0, (N - 1.0) - yc)
154 return np.repeat(np.repeat(d[None, :, None], N, 0), NZ, 2)
155
156
157class Path:
158 """One collocated density path: builds a configured solver, and runs the per-step hook."""
159
160 def __init__(self, name, scheme=None, rho=None, csf=False, bfp=None, off_twin=None,
161 g2_walled=False):
162 self.name, self.scheme, self.rho, self.csf, self.bfp = name, scheme, rho, csf, bfp
163 self.c0 = drop_colour() if csf else None
164 self.off_twin = off_twin # the OFF path whose converged G3 state seeds this ON path
165 self.g2_walled = g2_walled # also run G2 walled (at G2_WALLED_DT)
166 self.g3_state = None # this path's converged dt = G3_DTS[0] state (u, v, w, p)
167 self.solid_walls = scheme == "ghost"
168
169 def build(self, dt, walls):
170 with StderrCapture() as cap:
171 s = self._build(dt, walls)
172 if self.scheme == "ghost" and FALLBACK in cap.text:
173 raise AssertionError("the pinned ghost scheme printed an AUTO fallback notice")
174 return s
175
176 def _build(self, dt, walls):
177 s = pf.SolverColocated(N, N, NZ)
178 s.set_rho(1.0)
179 s.set_mu(MU)
180 s.set_dt(dt)
181 s.set_advection(False)
182 s.diagnostics.set_velocity_solver_params(400, 1e-13, 2)
183 if walls and not self.solid_walls:
184 s.set_domain_bc("-y", "wall")
185 s.set_domain_bc("+y", "wall")
186 if self.scheme is not None:
187 s.set_collocated_scheme(self.scheme)
188 if walls and self.solid_walls:
189 s.set_solid(F(channel_sdf()), True)
190 else:
191 s.set_pressure_geometry(F(np.full((N, N, NZ), 10.0)))
192 s.enable_cell_force()
193 fx, fy = tg_force()
194 for name, a in (("force_x", fx), ("force_y", fy), ("force_z", np.zeros_like(fx))):
195 s.set_field(name, F(a))
196 s.diagnostics.exchange_field(name)
197 if self.rho is not None:
198 s.add_field("rho")
199 s.set_field("rho", F(self.rho))
200 s.diagnostics.exchange_field("rho")
201 s.diagnostics.set_density_mode("variable")
202 if self.csf:
203 s.enable_vof()
204 s.set_vof(F(self.c0))
205 s.set_surface_tension(SIGMA)
206 s.diagnostics.set_vof_kappa_constant(KAPPA)
207 if self.bfp is not None:
208 if hasattr(s, "set_balanced_force_projection"):
209 s.set_balanced_force_projection(self.bfp)
210 elif self.bfp: # a build without the option runs OFF by construction
211 raise NotImplementedError("set_balanced_force_projection not in this build")
212 # the driver LAST (set_density_mode re-selects Chebyshev)
213 if self.rho is not None:
214 s.set_pressure_chebyshev(True, 2000, P_RTOL)
215 else:
216 s.set_pressure_pcg(True, 400, P_RTOL)
217 return s
218
219 def step(self, s):
220 if self.csf:
221 s.set_vof(F(self.c0)) # frozen interface: the step stays affine in (u, P)
222 s.step()
223
224
225def vel(s):
226 return np.stack([np.asarray(s.get_u()), np.asarray(s.get_v()), np.asarray(s.get_w())])
227
228
229def cb_amp(d):
230 """(pi, pi, pi) Fourier amplitude of a cell field."""
231 return abs(np.fft.fftn(d)[N // 2, N // 2, NZ // 2]) / d.size
232
233
234def g2(path, dt, walls=False):
235 """Returns (ok, text)."""
236 a, b = path.build(dt, walls), path.build(dt, walls)
237 rng = np.random.default_rng(12345)
238 x, y, z = np.meshgrid(np.arange(N), np.arange(N), np.arange(NZ), indexing="ij")
239 seed = EPS * (1.0 - 2.0 * ((x + y + z) % 2)) + EPS * rng.uniform(-1.0, 1.0, (N, N, NZ))
240 b.set_field("p", F(np.asarray(b.get_field("p")) + seed))
241 b.diagnostics.exchange_field("p")
242 p_ref = cb_amp(seed)
243 u_ref = p50 = u50 = None
244 fl = FLOOR * EPS
245 for k in range(1, G2_STEPS + 1):
246 path.step(a)
247 path.step(b)
248 if k in (1, 50, G2_STEPS):
249 dp = cb_amp(np.asarray(b.get_p()) - np.asarray(a.get_p()))
250 du = float(np.max(np.abs(vel(b) - vel(a))))
251 if not (np.isfinite(dp) and np.isfinite(du)):
252 return False, f"dt {dt:<5g} NON-FINITE at step {k}"
253 if k == 1:
254 u_ref = du
255 if k == 50:
256 p50, u50 = dp, du
257 p100, u100 = dp, du
258 m = lambda v: max(v, fl) # noqa: E731
259 ok = (m(p100) <= 2.0 * m(p_ref) and m(p100) <= (1 + 1e-6) * m(p50) and
260 m(u100) <= 2.0 * m(u_ref) and m(u100) <= (1 + 1e-6) * m(u50))
261 return ok, (f"dt {dt:<5g}{' walled' if walls else ''} P_cb {p_ref:.2e} -> {p50:.2e} -> {p100:.2e} "
262 f"|du| {u_ref:.2e} -> {u50:.2e} -> {u100:.2e} {'ok' if ok else 'FAIL'}")
263
264
265def march(path, s):
266 prev = vel(s)
267 for k in range(1, G3_MAX_STEPS + 1):
268 path.step(s)
269 cur = vel(s)
270 if not np.all(np.isfinite(cur)):
271 return cur, k, False
272 if np.max(np.abs(cur - prev)) <= G3_TOL * max(np.max(np.abs(cur)), 1e-300):
273 return cur, k, True
274 prev = cur
275 return cur, G3_MAX_STEPS, False
276
277
278def restore(s, state):
279 for name, a in state.items():
280 s.set_field(name, F(a))
281 s.diagnostics.exchange_field(name)
282
283
284def g3(path):
285 sols, info = {}, []
286 state = None
287 for dt in G3_DTS:
288 s = path.build(dt, True)
289 seeded = ""
290 if state is not None:
291 restore(s, state)
292 elif path.off_twin is not None and path.off_twin.g3_state is not None:
293 # the OFF path's converged state (no P_b: the first ON step re-splits); the fixed point
294 # is the same ON and OFF, so a dt-independent ON path converges in a few steps
295 restore(s, path.off_twin.g3_state)
296 seeded = " from OFF"
297 u, k, conv = march(path, s)
298 if state is None:
299 # the WHOLE state: with the balanced-force projection on, P's split P_b is state too
300 # (restoring P without it re-splits, which is correct but not a pure continuation)
301 state = {n: np.asarray(s.get_field(n)) for n in ("u", "v", "w", "p")}
302 path.g3_state = dict(state)
303 if "p_balanced" in s.field_names():
304 state["p_balanced"] = np.asarray(s.get_field("p_balanced"))
305 sols[dt] = u
306 info.append(f"dt {dt:g}{seeded}: {k} steps{'' if conv else ' (NOT converged)'}")
307 scale = max(np.max(np.abs(sols[G3_DTS[0]])), 1e-300)
308 worst = 0.0
309 for i, di in enumerate(G3_DTS):
310 for dj in G3_DTS[i + 1:]:
311 d = float(np.max(np.abs(sols[di] - sols[dj])))
312 worst = max(worst, d if np.isfinite(d) else np.inf)
313 # every march must reach the criterion: an unconverged run measures the march, not the fixed point
314 ok = all("NOT" not in t for t in info) and worst <= G3_GATE * scale
315 return ok, f"max|u(dt_i) - u(dt_j)|/max|u| = {worst / scale:.2e} [{'; '.join(info)}] " + \
316 ("ok" if ok else "FAIL")
317
318
319def paths():
320 uni = np.ones((N, N, NZ))
321 out = [Path("const-rho ghost (pinned)", "ghost"), Path("const-rho gauge-exact", "gauge-exact"),
322 Path("const-rho plain", "plain"), Path("const-rho embed", "embed")]
323 off = [Path("V8 var-rho uniform OFF", rho=uni, bfp=False),
324 Path("V8 var-rho slab 1000 OFF", rho=slab_rho(1000.0), bfp=False, g2_walled=True),
325 Path("V8 const-rho CSF OFF", csf=True, bfp=False)]
326 on = [Path("V8 var-rho uniform ON ", rho=uni, bfp=True, off_twin=off[0]),
327 Path("V8 var-rho slab 1000 ON ", rho=slab_rho(1000.0), bfp=True, off_twin=off[1]),
328 Path("V8 const-rho CSF ON ", csf=True, bfp=True, off_twin=off[2])]
329 return out + off + on
330
331
332def guarded(path, fn, *args):
333 """Run one gate; a throw fails it unless the path is whitelisted to refuse."""
334 try:
335 return fn(path, *args)
336 except RuntimeError as exc:
337 if path.name in REFUSAL_WHITELIST:
338 return True, f"refused (whitelisted: {REFUSAL_WHITELIST[path.name]}): {exc}"
339 return False, f"THREW: {exc} FAIL"
340
341
342def main(argv):
343 only = argv[1] if len(argv) > 1 else None
344 ok = True
345 t_all = time.perf_counter()
346 for path in paths():
347 if only and only not in path.name:
348 continue
349 print(f"--- {path.name}", flush=True)
350 t0 = time.perf_counter()
351 try:
352 path.build(1.0, False)
353 except NotImplementedError as exc:
354 print(f" unavailable: {exc}")
355 continue
356 except RuntimeError as exc:
357 good = path.name in REFUSAL_WHITELIST
358 ok &= good
359 print(f" refused at build: {exc} {'(whitelisted)' if good else 'FAIL'}")
360 continue
361 for dt in G2_DTS:
362 good, text = guarded(path, g2, dt)
363 ok &= good
364 print(f" G2 {text}", flush=True)
365 if path.g2_walled:
366 good, text = guarded(path, g2, G2_WALLED_DT, True)
367 ok &= good
368 print(f" G2 {text}", flush=True)
369 good, text = guarded(path, g3)
370 ok &= good
371 print(f" G3 {text} ({time.perf_counter() - t0:.0f} s for the path)", flush=True)
372 print(f"{'PASS' if ok else 'FAIL'} ({time.perf_counter() - t_all:.0f} s)")
373 return 0 if ok else 1
374
375
377 assert main([]) == 0
378
379
380if __name__ == "__main__":
381 sys.exit(main(sys.argv))
__init__(self, name, scheme=None, rho=None, csf=False, bfp=None, off_twin=None, g2_walled=False)
int main()
Definition dbg_scene.cpp:13
static constexpr double F