flow 0.4.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
flatwall_displacement.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""Flat-wall displacement sweep (Frank's isolation ladder, 2026-08-20): the collocated ceiling
3hunted in the simplest IBM geometry -- a plane channel whose walls sit at a FRACTIONAL grid offset
4s, so every cut cell has the same theta and the closure error is a coherent function of one
5parameter instead of an average over random incidences.
6
7Steady-state structure (doc/collocated_accuracy_ceiling.md + the joint-fixed-point argument): the
8collocated steady state satisfies nu*L_ibm(u) + F = G_gp(P) with D_alpha(avg_half(u)) = 0.
9In a wall-aligned channel these decouple, giving three isolating experiments:
10
11 E1 uniform Fx -> parabola. u = u(y) x-only => div == 0 discretely, P == 0: the
12 pressure machinery NEVER ENGAGES. Any error is the momentum IBM
13 closure L_ibm alone (suspect S4). (Caveat: 2nd-order closures can
14 be exact on parabolas -- a null here does not clear curved walls.)
15 E1b Fx(y)=F0 cos(k(y-yc)), k=pi/W -> u = F0/(mu k^2) cos(k(y-yc)), zero at both walls,
16 non-polynomial: the order of L_ibm is readable.
17 E2 E1's Fx PLUS Fy(y)=A sin(2pi(y-w_lo)/W) in the fluid -> exact answer: u IDENTICAL to E1,
18 v == 0, p = integral(Fy). The y-force must be absorbed ENTIRELY by
19 the discrete pressure. Any (u - u_E1, v != 0) response is a defect
20 of the (G_gp vs masked-half-average constraint) pair -- the
21 non-adjointness channel, with L_ibm differenced out.
22
23Sweep s in [0,1) x N (channel width in cells) x solver in {stag, gauge-exact (mode 9), plain
24(mode 0)}. Errors are reported on the conserved face flux (primary; mean(alpha_x*uf)) and the
25cell mean, both relative to the exact discharge.
26
27 SDFLOW_BUILD=build_omp3 python tests/study/flatwall_displacement.py # default sweep
28 ... flatwall_displacement.py --N 8,16,32,64 --s 0.1,0.3,0.5,0.7,0.9 --exp E1,E1b,E2
29"""
30import argparse
31import os
32import sys
33
34import numpy as np
35
36sys.path.insert(0, os.path.abspath(os.path.join(
37 os.path.dirname(__file__), "..", "..", os.environ.get("SDFLOW_BUILD", "build"))))
38from peclet import flow # noqa: E402
39
40MU, F0 = 0.1, 1e-3
41NX = NZ = 8 # periodic; the solution is x/z-invariant
42SLAB = 8 # total solid thickness in y (walls wrap periodically through it)
43TOL = float(os.environ.get("MARCH_TOL", "1e-9"))
44CAP = int(os.environ.get("MARCH_MAX", "8000"))
45DT = float(os.environ.get("DT", "60.0"))
46
47
48def channel(N, s):
49 """SDF (F-order (nx,ny,nz)) + wall positions for a width-N channel displaced by s cells."""
50 ny = N + SLAB
51 w_lo = SLAB / 2 + s
52 w_hi = w_lo + N
53 yc = np.arange(ny) + 0.5
54 sd = np.minimum(yc - w_lo, w_hi - yc) # planar, exact
55 sdf = np.asfortranarray(np.broadcast_to(sd[None, :, None], (NX, ny, NZ)).copy())
56 return sdf, ny, w_lo, w_hi
57
58
59def exact_profile(exp, y, w_lo, w_hi):
60 W = w_hi - w_lo
61 yc = 0.5 * (w_lo + w_hi)
62 if exp == "E1b":
63 k = np.pi / W
64 u = F0 / (MU * k * k) * np.cos(k * (y - yc))
65 else:
66 u = F0 / (2 * MU) * ((W / 2) ** 2 - (y - yc) ** 2)
67 return np.where((y > w_lo) & (y < w_hi), u, 0.0)
68
69
70def exact_discharge(exp, w_lo, w_hi):
71 W = w_hi - w_lo
72 if exp == "E1b":
73 k = np.pi / W
74 return F0 / (MU * k * k) * (2.0 / k) # integral of cos over [-pi/2..pi/2]/k
75 return F0 * W ** 3 / (12 * MU)
76
77
78def make_solver(kind, N, s):
79 sdf, ny, w_lo, w_hi = channel(N, s)
80 sv = flow.Solver if kind == "stag" else flow.SolverColocated
81 sol = sv(NX, ny, NZ)
82 sol.set_rho(1.0); sol.set_mu(MU); sol.set_dt(DT)
83 sol.set_advection(False)
84 sol.set_velocity_solver_params(200)
85 sol.set_pressure_multigrid(True, 3)
86 sol.set_pressure_pcg(True, 200, 1e-10)
87 if kind != "stag":
88 if hasattr(sol, "set_collocated_scheme"):
89 sol.set_collocated_scheme(kind)
90 else:
91 sol.set_face_interp({"gauge-exact": 9, "plain": 0}[kind])
92 sol.set_solid(sdf, cutcell_pressure=True, pressure_coarse="rediscretized")
93 return sol, sdf, ny, w_lo, w_hi
94
95
96def set_forces(sol, exp, ny, w_lo, w_hi):
97 y = np.arange(ny) + 0.5
98 W = w_hi - w_lo
99 yc = 0.5 * (w_lo + w_hi)
100 fluid = (y > w_lo) & (y < w_hi)
101 if exp == "E1":
102 sol.set_body_force(F0, 0.0, 0.0)
103 return
104 sol.enable_cell_force()
105 if exp == "E1b":
106 k = np.pi / W
107 fx = np.where(fluid, F0 * np.cos(k * (y - yc)), 0.0)
108 fy = np.zeros(ny)
109 else: # E2: E1's uniform Fx + a fluid-only sinusoidal Fy (exactly hydrostatic)
110 fx = np.where(fluid, F0, 0.0)
111 fy = np.where(fluid, 10 * F0 * np.sin(2 * np.pi * (y - w_lo) / W), 0.0)
112 zero = np.zeros((NX, ny, NZ), order="F")
113 sol.set_field("force_x", np.asfortranarray(np.broadcast_to(fx[None, :, None],
114 (NX, ny, NZ)).copy()))
115 sol.set_field("force_y", np.asfortranarray(np.broadcast_to(fy[None, :, None],
116 (NX, ny, NZ)).copy()))
117 sol.set_field("force_z", zero)
118
119
120def march(sol):
121 prev = 0.0
122 for it in range(CAP):
123 sol.step()
124 if it % 10 == 9:
125 um = float(np.asarray(sol.get_u()).mean())
126 if it > 20 and abs(um - prev) < TOL * (abs(um) + 1e-300):
127 return it + 1
128 prev = um
129 return CAP
130
131
132def run(kind, exp, N, s):
133 sol, sdf, ny, w_lo, w_hi = make_solver(kind, N, s)
134 set_forces(sol, exp, ny, w_lo, w_hi)
135 steps = march(sol)
136 U = np.asarray(sol.get_u())
137 V = np.asarray(sol.get_v())
138 if kind == "stag":
139 UF, ax = U, np.asarray(sol.get_ox())
140 else:
141 UF, ax = np.asarray(sol.get_uf()), np.asarray(sol.get_ox())
142 Qex = exact_discharge(exp, w_lo, w_hi)
143 q_flux = float((ax * UF).mean()) * ny # conserved-face-flux discharge
144 q_cell = float(U.mean()) * ny
145 y = np.arange(ny) + 0.5
146 uex = exact_profile(exp, y, w_lo, w_hi)
147 prof = U.mean(axis=(0, 2))
148 inner = (y > w_lo + 1.5) & (y < w_hi - 1.5) # interior cells: no cut-cell weighting question
149 l2 = float(np.sqrt(np.mean((prof[inner] - uex[inner]) ** 2)) / np.sqrt(np.mean(uex[inner]**2)))
150 return dict(steps=steps, e_flux=q_flux / Qex - 1, e_cell=q_cell / Qex - 1, l2=l2,
151 U=U, V=V, uscale=float(np.abs(uex).max()))
152
153
154if __name__ == "__main__":
155 ap = argparse.ArgumentParser()
156 ap.add_argument("--N", default="8,16,32")
157 ap.add_argument("--s", default="0.1,0.3,0.5,0.7,0.9")
158 ap.add_argument("--exp", default="E1,E1b,E2")
159 ap.add_argument("--solvers", default="stag,gauge-exact,plain")
160 a = ap.parse_args()
161 Ns = [int(x) for x in a.N.split(",")]
162 Ss = [float(x) for x in a.s.split(",")]
163 exps = a.exp.split(",")
164 kinds = a.solvers.split(",")
165 for exp in exps:
166 print(f"\n================ {exp} ================")
167 hdr = f"{'N':>4} {'s':>5} {'solver':>12} {'steps':>6} {'e_flux':>11} {'e_cell':>11} {'L2prof':>10}"
168 if exp == "E2":
169 hdr += f" {'sp_du':>10} {'sp_v':>10}"
170 print(hdr, flush=True)
171 for N in Ns:
172 for s in Ss:
173 base = {}
174 for kind in kinds:
175 r = run(kind, exp, N, s)
176 row = (f"{N:>4} {s:>5.2f} {kind:>12} {r['steps']:>6} "
177 f"{r['e_flux']:>+11.3e} {r['e_cell']:>+11.3e} {r['l2']:>10.3e}")
178 if exp == "E2":
179 # spurious response vs this solver's own E1 (hydrostatic leakage)
180 r1 = base.get(kind)
181 if r1 is None:
182 r1 = run(kind, "E1", N, s)
183 du = float(np.abs(r['U'] - r1['U']).max()) / r['uscale']
184 dv = float(np.abs(r['V']).max()) / r['uscale']
185 row += f" {du:>10.3e} {dv:>10.3e}"
186 print(row, flush=True)
exact_discharge(exp, w_lo, w_hi)
exact_profile(exp, y, w_lo, w_hi)
set_forces(sol, exp, ny, w_lo, w_hi)