flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
force_gate.py
Go to the documentation of this file.
1"""Layer 4 rung 2 gate: the HYDRODYNAMIC FORCE, checked against the momentum balance the solved
2field must already satisfy -- BOTH evaluations:
3
4 * hydro_force_torque_reaction (route b, THE COUPLING FORCE): the discrete reaction. Its identity
5 is sum F_c = f_c * N_c with N_c the solver's own count of fluid momentum cells
6 (fluid_momentum_cells), and it must hold to the momentum solver's residual -- this is the
7 IMPLEMENTATION-COMPLETENESS check, not an accuracy check (the identity is a tautology of a
8 correctly assembled budget; if it holds only to percent, a term is missing).
9 * diagnostics.hydro_force_torque_traction (traction, DIAGNOSTIC): the reconstructed surface integral, kept so its
10 resolution-independent ~29% under-read stays visible.
11
12Setup: Stokes flow through a fixed periodic sphere array driven by a uniform body force f (force
13per unit volume, applied to the fluid). At steady state the momentum balance over the periodic cell
14is exact and closes with no empirical input:
15
16 sum over spheres of F_hydro = f * V_fluid
17
18-- the body force fed into the fluid has nowhere to go but the grain surfaces. So integrating the
19traction and comparing it against f * V_fluid is a genuine SELF-CONSISTENCY test of the surface
20integral against the same field the velocity-based permeability is read from, exactly as the
21Layer-4 spec asks; it needs no Zick-Homsy table to state, and it fails loudly if the aperture
22wall-area vector, the traction, or the owner attribution is wrong.
23
24V_fluid is reported two ways, because they differ at O(h) and the reader should see which is
25which: the ANALYTIC fluid volume of the sphere array, and the DISCRETE one the solver's own
26cut-cell apertures imply.
27
28Also reported: the Darcy permeability k = mu <u> / f measured from the velocity field, so the run
29can be lined up against validate_zick_homsy_sdflow.py's numbers, and the per-sphere force spread
30(the array is symmetric, so four equal spheres must carry equal drag -- a direct check that the
31owner attribution partitions the surface correctly).
32"""
33import os
34import sys
35import numpy as np
36from peclet import flow as sdflow
37
38MU, RHO, DT = float(os.environ.get("FORCE_MU", "0.1")), 1.0, float(os.environ.get("FORCE_DT", "60"))
39F = float(os.environ.get("FORCE_F", "1e-3"))
40STEPS = int(os.environ.get("FORCE_STEPS", "400"))
41# R0: the same identity with the explicit advective term in the budget. Re is reported from the
42# measured superficial velocity so the run can be placed on the plan's "Re ~ 30" target.
43ADVECT = os.environ.get("FORCE_ADVECT", "0") == "1"
44KN_R, KI_I, KI_R = 16, 2, 17
45F_SCALE = os.environ.get("FORCE_F_SCALE", "1") == "1"
46
47
48def run(N, RF=0.18, wallgrad=False): # wallgrad kept only for the log line
49 R = RF * N
50 # ---- the advective leg must hold the CELL Reynolds number fixed across the ladder ---------
51 # set_advection() puts the advective term in the RHS, lagged at the Picard iterate -- it is
52 # EXPLICIT (set_implicit_advection moves it into the operator, but the reaction budget refuses
53 # that path). The step is therefore the textbook semi-implicit one, whose linear stability
54 # limit for a mode k is |u.k|^2 <= 2*nu*k^2/dt + (nu*k^2)^2, i.e. at large dt just the CELL
55 # REYNOLDS NUMBER Re_h = rho*|u|*h/mu <~ pi.
56 # With f and mu held fixed this lattice's permeability grows like N^2 (cells^2), so |u| and
57 # Re_h grow like N^2 too: Re_h = 0.81 at N=16 (stable at every dt tried, up to 240) but 3.1 at
58 # N=32 and 12.5 at N=64. That is why a fixed f NaNs at every N >= 24: the momentum field
59 # diverges over ~70 steps and the pressure preconditioner then returns non-finite z. It is a
60 # property of the scheme, not a solver defect -- no code change moves it.
61 # Scaling f like N^-2 pins Re_h at the N=16 value (0.81, a factor ~4 below the limit) for every
62 # rung, and lets Re_d = rho*|u|*2R/mu grow like N -- ~12 at N=64, ~23 at N=128, i.e. the plan's
63 # "Re ~ 30" target lands on the default ladder. FORCE_F_SCALE=0 restores the raw (unstable) f.
64 f = F * (16.0 / N) ** 2 if (ADVECT and F_SCALE) else F
65 CEN = [(0.25*N, 0.25*N, 0.25*N), (0.75*N, 0.75*N, 0.25*N),
66 (0.75*N, 0.25*N, 0.75*N), (0.25*N, 0.75*N, 0.75*N)]
67 node_ints = np.array([1, -1, -1], dtype=np.int32)
68 node_reals = np.zeros(KN_R); node_reals[0] = R
69 node_reals[14] = 1.0; node_reals[15] = 1.0
70 ii = np.zeros((len(CEN), KI_I), dtype=np.int32)
71 ir = np.zeros((len(CEN), KI_R))
72 for m, c in enumerate(CEN):
73 ii[m] = (0, -1); ir[m, 0:3] = c; ir[m, 6] = 1.0; ir[m, 7] = 1.0
74
75 s = sdflow.Solver(N, N, N)
76 s.set_rho(RHO); s.set_mu(MU); s.set_dt(DT)
77 s.set_body_force(f, 0.0, 0.0)
78 s.set_advection(ADVECT)
79 s.set_velocity_solver_params(100)
80 s.set_pressure_solver_params(25)
81 # MG depth 4, not 1. levels=1 leaves the 'coarse' solve on the FULL grid, which on
82 # CUDA costs 920 ms/step at N=64 against 19.5 ms at depth 4 (measured). Depth changes the
83 # solver path, not the converged answer, and both runs of a comparison use the same depth.
84 s.set_pressure_multigrid(True, levels=4)
85 s.set_scene(node_ints, node_reals, ii.ravel(), ir.ravel(), periodic=True)
86 s.set_solid_from_scene(True)
87 for _ in range(STEPS):
88 s.step()
89 u = np.asarray(s.get_u())
90 ft = np.asarray(s.diagnostics.hydro_force_torque_traction())
91 Fh, Th, Fp, Fv = ft[0], ft[1], ft[2], ft[3]
92 fr = np.asarray(s.hydro_force_torque_reaction())
93 FR, TR = fr[0], fr[1]
94 Nc = s.fluid_momentum_cells()
95
96 # analytic fluid volume (spheres do not overlap at RF=0.18 on this lattice)
97 Vsolid = len(CEN) * 4.0 / 3.0 * np.pi * R**3
98 Vfluid_an = N**3 - Vsolid
99 # discrete fluid volume from the solver's own cell volume fractions
100 g = np.arange(N).astype(float)
101 X, Y, Z = np.meshgrid(g, g, g, indexing="ij")
102 sd = np.full((N, N, N), 1e30)
103 for (cx, cy, cz) in CEN:
104 dx = np.abs(X-cx); dx = np.minimum(dx, N-dx)
105 dy = np.abs(Y-cy); dy = np.minimum(dy, N-dy)
106 dz = np.abs(Z-cz); dz = np.minimum(dz, N-dz)
107 sd = np.minimum(sd, np.sqrt(dx*dx+dy*dy+dz*dz) - R)
108 Vfluid_di = float(np.clip(0.5 + sd, 0.0, 1.0).sum())
109
110 k = MU * float(u.mean()) / f
111 Re = RHO * float(np.abs(u).mean()) * (2 * R) / MU
112 tot = Fh.sum(axis=0)
113 exp_an, exp_di = f * Vfluid_an, f * Vfluid_di
114 spread = (Fh[:, 0].max() - Fh[:, 0].min()) / abs(Fh[:, 0].mean())
115 probe = np.asarray(s.wall_area_probe())
116 print(" N=%3d porosity %.4f k = mu<u>/f = %.4e max|div| %.2e advect=%s Re_d=%.2f"
117 " f=%.4e Re_h=%.2f (semi-implicit limit ~pi)"
118 % (N, Vfluid_an / N**3, k, s.max_open_divergence(), ADVECT, Re, f,
119 RHO * float(np.abs(u).max()) / MU))
120 print(" A_wall probe / -V_solid = %.5f %.5f %.5f (exactly 1 if the aperture wall-area "
121 "vectors are right)" % tuple(probe / -Vsolid))
122 print(" sum F_hydro = (%+.6e, %+.6e, %+.6e)" % tuple(tot))
123 print(" f * V_fluid analytic = %+.6e -> ratio %.5f (err %+.2f%%)"
124 % (exp_an, tot[0] / exp_an, 100 * (tot[0] / exp_an - 1)))
125 print(" f * V_fluid discrete = %+.6e -> ratio %.5f (err %+.2f%%)"
126 % (exp_di, tot[0] / exp_di, 100 * (tot[0] / exp_di - 1)))
127 print(" transverse leakage = %.2e (relative to F_x; symmetry says 0)"
128 % (max(abs(tot[1]), abs(tot[2])) / abs(tot[0])))
129 print(" per-sphere F_x spread = %.2e (4 identical spheres -> owner attribution splits "
130 "the surface evenly)" % spread)
131 print(" split: pressure %+.6e viscous %+.6e (of the %+.6e needed)"
132 % (Fp[:, 0].sum(), Fv[:, 0].sum(), exp_di))
133 print(" sum torque = (%+.2e, %+.2e, %+.2e)" % tuple(Th.sum(axis=0)))
134 rtot = FR.sum(axis=0)
135 bt = np.asarray(s.reaction_budget_terms()) # [0]=unsteady, [1]=advective, per component
136 unst, advs = bt[0], bt[1]
137 exp_re = f * Nc[0] + advs[0] - unst[0]
138 rspread = (FR[:, 0].max() - FR[:, 0].min()) / abs(FR[:, 0].mean())
139 print(" REACTION (route b): sum F = (%+.6e, %+.2e, %+.2e)" % tuple(rtot))
140 print(" identity target = %+.6e ratio-1 = %+.3e (must be solver residual, "
141 "NOT percent -- a percent-level miss means a budget term is missing)"
142 % (exp_re, rtot[0] / exp_re - 1))
143 print(" terms: f*N_fluid %+.6e sum A (advective wall flux) %+.3e (%+.3f%% of f*N)"
144 " sum unsteady %+.3e (%+.3f%%)"
145 % (f * Nc[0], advs[0], 100 * advs[0] / (f * Nc[0]), unst[0],
146 100 * unst[0] / (f * Nc[0])))
147 print(" per-sphere spread %.2e transverse %.2e sum torque (%+.2e, %+.2e, %+.2e)"
148 % (rspread, max(abs(rtot[1]), abs(rtot[2])) / abs(rtot[0]), *TR.sum(axis=0)))
149 ok = abs(rtot[0] / exp_re - 1) < 1e-4
150 print(" REACTION GATE %s" % ("PASS" if ok else "FAIL"))
151 return abs(tot[0] / exp_di - 1), spread, abs(rtot[0] / exp_re - 1), ok
152
153
154if __name__ == "__main__":
155 Ns = [int(x) for x in (sys.argv[1:] or ["64", "128"])]
156 wg = os.environ.get("FORCE_WALLGRAD", "0") == "1"
157 errs = [run(N, wallgrad=wg) for N in Ns]
158 print(" traction (diagnostic) momentum-balance error:")
159 for N, r in zip(Ns, errs):
160 print(" N=%3d %.3f%%" % (N, 100 * r[0]))
161 print(" reaction (route b) identity residual:")
162 allok = True
163 for N, r in zip(Ns, errs):
164 print(" N=%3d %.3e" % (N, r[2]))
165 allok = allok and r[3]
166 print("GATE %s [reaction identity to solver residual at every N]"
167 % ("PASS" if allok else "FAIL"))
run(N, RF=0.18, wallgrad=False)
Definition force_gate.py:48
static double mean(const std::vector< double > &a)