flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
verify_bfs_sdflow.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Verification (sdflow): the backward-facing step (BFS) -- the canonical separated-flow benchmark, and
3the showcase for the per-face INLET PROFILE. Geometry is the Gartling (1990) expansion-ratio-2 step: a
4channel of full height H = 2S (S = step height) with no-slip walls top/bottom; flow enters ONLY the upper
5half (y in [S, 2S]) with the developed parabolic channel profile and is zero over the lower half (the step
6face); it leaves through the outflow. Behind the step a recirculation bubble forms on the bottom wall; the
7reattachment length x_r/S grows with Re. Quasi-2D (periodic z).
8
9The step is realized purely as the inlet condition via set_domain_bc_profile (no immersed solid): the
10parabola vanishes at y=S (the step lip) and y=2S (top wall), exactly the developed upper-channel profile.
11
12We de-risk in the laminar regime (Re_S = U_in*S/nu = 100, 200) -- a bubble must form, x_r/S must grow with
13Re, mass is conserved and the flow stays divergence-free -- then push toward Gartling Re=800 and report the
14reattachment for comparison (Gartling lower-wall reattachment ~6.1 H = ~12 S). Re_S = U_in * S / nu, with
15U_in the mean inlet velocity over the open upper half.
16"""
17import os
18import sys
19import time
20
21import numpy as np
22
23from _bootstrap import ensure_flow # noqa: E402
24sdflow = ensure_flow()
25
26
27def inlet_profile(H, S, nz, U_in):
28 """Developed parabola over the open upper half y in [S, 2S], zero over the step face y in [0, S]."""
29 prof = np.zeros((H, nz, 3))
30 yc = np.arange(H) + 0.5
31 eta = (yc - S) / S # 0 at the step lip, 1 at the top wall
32 up = (yc > S)
33 prof[up, :, 0] = (6.0 * U_in * eta * (1.0 - eta))[up, None]
34 return prof
35
36
37def reattachment(u_bottom):
38 """End of the primary bubble = the first - -> + crossing of the near-bottom-wall streamwise velocity
39 AFTER the flow has actually reversed behind the step (skip any thin positive sliver at the step lip)."""
40 reversed_yet = False
41 for i in range(1, len(u_bottom)):
42 if u_bottom[i] < 0.0:
43 reversed_yet = True
44 elif reversed_yet and u_bottom[i] >= 0.0:
45 return (i - 1) + u_bottom[i - 1] / (u_bottom[i - 1] - u_bottom[i])
46 return 0.0
47
48
49def run(Re, S=16, Lr=12, U_in=1.0, nz=4, dt=0.2, max_steps=24000):
50 """Run the backward-facing step at Reynolds number `Re` (Re_S = U_in*S/nu) and return its diagnostics.
51
52 Step height S, full channel height 2S, channel length Lr*S; a partial parabolic inlet feeds the upper
53 half, the lower half is the step face. Marches to steady state and returns a dict with the lower-wall
54 reattachment length (x_r/S, x_r/H), bubble presence, mass-conservation error and max divergence.
55 Returns None off root.
56 """
57 # dt=0.2 (was 0.4): the step-lip shear layer is stiff for the explicit (SOU) deferred-correction
58 # advection -- at dt=0.4 the anti-diffusive correction was marginally unstable (a roundoff-sensitive
59 # transient divergence spike that could tip to NaN, worse on finer grids). Halving dt keeps it well
60 # under that explicit stability limit while retaining full 2nd-order accuracy (x_r/S unchanged).
61 # (Alternatives if speed matters: set_deferred_correction(False) for robust 1st-order, or
62 # set_outer_iterations(>1) to converge the correction -- both leave the SOU default intact.)
63 # Re-measured 2026-09-04 on main (implicit FOU + deferred correction + backflow stabilization,
64 # OpenMP 4 threads and CUDA): S=16 at dt = 0.4, 0.8 and 1.6 (Re_S = 100), dt = 0.4 at Re_S = 200
65 # and 800, all finite and steady over 6000 steps (x_r/S 5.31 / 8.1 / 12 = L), divergence ~1e-16.
66 # The only energy-injecting configuration is a REVERSED outlet (Re_S = 800, x_r = L): a bounded
67 # transient excursion of max|u| to 1.28x the inlet peak on the outlet column, with beta = 0 and
68 # with the default 0.2 alike, finite over 6000 steps. step() warns when reversal appears with
69 # the stabilization off and outflow_backflow() monitors it. dt = 0.2 is kept for the script's
70 # recorded numbers, not for stability.
71 H = 2 * S
72 L = Lr * S
73 nu = U_in * S / Re
74 s = sdflow.Solver(L, H, nz)
75 s.set_rho(1.0); s.set_mu(nu); s.set_dt(dt); s.set_advection(True)
76 s.set_domain_bc_profile("-x", inlet_profile(H, S, nz, U_in)) # -x partial parabolic inlet (-> inflow)
77 s.set_domain_bc("+x", "outflow") # +x outflow
78 s.set_domain_bc("-y", "wall"); s.set_domain_bc("+y", "wall") # -y, +y no-slip walls
79 s.diagnostics.set_velocity_solver_params(60)
80 if os.environ.get("SDFLOW_BFS_VMG") == "1": # opt-in: velocity-MG (diffusion-only) on the BFS
81 s.set_velocity_multigrid(True, 8, 4) # -- exercises the outflow -beta fold + inflow
82 s.set_pressure_multigrid(True, levels=8) # semi-coarsening MG (z frozen, x/y deep; capped)
83 s.set_pressure_solver_params(80)
84 s.set_pressure_geometry(np.full((L, H, nz), 1e30)) # all-fluid + BC pressure faces
85
86 verbose = os.environ.get("SDFLOW_BFS_VERBOSE") == "1"
87 t0 = time.time()
88 prev = 0.0
89 steps = max_steps
90 for it in range(max_steps):
91 s.step()
92 if it % 100 == 99:
93 u = s.get_u()
94 xr = reattachment(u[:, 0, nz // 2]) if s.rank() == 0 else 0.0
95 # converge only once a real bubble has formed (x_r > S) AND its length has stopped drifting --
96 # early in the transient x_r sits near 0 ("stable" but undeveloped), which must NOT trigger.
97 done = it > 3000 and xr > S and abs(xr - prev) < 1e-3 * S
98 if verbose and s.rank() == 0:
99 print(f" [Re={Re} it={it+1} x_r/S={xr/S:.2f} t={time.time()-t0:.0f}s]", flush=True)
100 prev = xr
101 if s.diagnostics.bcast_from_root(done):
102 steps = it + 1
103 break
104 u = s.get_u(); div = s.max_open_divergence()
105 if s.rank() != 0:
106 return None
107 xr = reattachment(u[:, 0, nz // 2])
108 flux_in = float(u[2, :, nz // 2].sum())
109 flux_out = float(u[L - 3, :, nz // 2].sum())
110 mass_err = abs(flux_out - flux_in) / (abs(flux_in) + 1e-30)
111 has_bubble = bool((u[1:S, 0, nz // 2] < 0).any()) # reverse flow just behind the step
112 return dict(Re=Re, S=S, H=H, L=L, steps=steps, secs=time.time() - t0, xr_S=xr / S, xr_H=xr / H,
113 div=div, mass_err=mass_err, bubble=has_bubble)
114
115
116def main():
117 """Run the laminar de-risk (Re_S 100, 200) and optional Re=800 push; print results and set exit code."""
118 print("=== sdflow: backward-facing step (Gartling expansion ratio 2) ===")
119 # laminar de-risk: a bubble must form, x_r/S must grow with Re, flow stays mass-conserving & div-free
120 laminar = [run(100), run(200)]
121 ok = True
122 xr_prev = -1.0
123 for r in laminar:
124 if r is None:
125 return
126 print(f" Re_S={r['Re']:<4d} S={r['S']} L={r['L']} x_r/S={r['xr_S']:.2f} bubble={r['bubble']} "
127 f"mass_err={r['mass_err']:.1e} div={r['div']:.1e} ({r['steps']} steps, {r['secs']:.0f}s)")
128 ok &= (r["bubble"] and r["mass_err"] < 1e-3 and r["div"] < 1e-6 and 0.5 < r["xr_S"] < 12.0
129 and r["xr_S"] > xr_prev)
130 xr_prev = r["xr_S"]
131 # push toward Re=800 (long: ~30 min, opt in with `--re800`). NB our Re_S = U_in*S/nu, whereas
132 # Gartling's Re=800 uses the full height H=2S (~ our Re_S=400, where x_r/H ~ 6 would match his ~6.1).
133 # At Re_S=800 the bubble is large and slow: measured x_r/H ~ 7.3, still creeping at 36k steps -- a
134 # textbook slow open-flow transient near the steady/unsteady boundary. The laminar points above are the
135 # quantitative validation; this is a stress/illustration run (PASS gate = monotone growth + invariants).
136 if "--re800" in sys.argv:
137 r = run(800, S=32, Lr=22, dt=0.3, max_steps=36000)
138 if r is not None:
139 print(f" Re_S={r['Re']:<4d} S={r['S']} L={r['L']} x_r/S={r['xr_S']:.2f} x_r/H={r['xr_H']:.2f}"
140 f" (Gartling ~6.1 H) mass_err={r['mass_err']:.1e} div={r['div']:.1e} "
141 f"({r['steps']} steps, {r['secs']:.0f}s)")
142 ok &= (r["bubble"] and r["mass_err"] < 1e-3 and r["div"] < 1e-6 and r["xr_S"] > xr_prev)
143 print(f" result: {'PASS' if ok else 'FAIL'} (recirculation grows with Re, mass-conserving, div-free)")
144 sys.exit(0 if ok else 1)
145
146
147if __name__ == "__main__":
148 main()
inlet_profile(H, S, nz, U_in)
run(Re, S=16, Lr=12, U_in=1.0, nz=4, dt=0.2, max_steps=24000)