flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
galilean_gate.py
Go to the documentation of this file.
1"""Layer 3 rungs 2-3 gate: GALILEAN INVARIANCE of the moving-wall cut-cell solver.
2
3Stokes flow through a periodic sphere bed, driven by a body force, solved twice:
4
5 A (lab frame) spheres static, wall velocity 0 -> u_A
6 B (boosted frame) IDENTICAL geometry, every sphere given linVel=-V -> u_B
7
8In a frame translating at V the same physical flow reads u' = u - V and the walls move at -V, so
9the discrete claim is u_B + V == u_A in every fluid cell.
10
11Why this gates rung 3 and not just rung 2. The true boosted solution has
12 div_open(u_A - V) = -V_x (oE - oW) != 0 at every cut cell,
13which is exactly cancelled by the wall term u_w . A_wall with A_wall = -(oE-oW, oN-oS, oT-oB).
14Drop the wall term and the projection wrongly forces div_open(u)=0, so the run is repeated with
15set_wall_flux_divergence(False) to EXHIBIT the failure rather than assert it.
16
17Stokes (advection off) is required: with the convective term, a fixed geometry plus a moving wall
18is not a Galilean boost of the static problem -- the missing -(V.grad)u term is real physics, not
19a discretisation error.
20"""
21import sys
22import numpy as np
23from peclet import flow as sdflow
24
25N = int(sys.argv[1]) if len(sys.argv) > 1 else 48
26RF = 0.18
27R = RF * N
28V = 0.7 # boost speed, cell units per time
29F = 1e-3
30import os
31MU, RHO, DT = 0.1, 1.0, 60.0
32STEPS = int(os.environ.get("GAL_STEPS", "260"))
33
34CEN = [(0.25*N, 0.25*N, 0.25*N), (0.75*N, 0.75*N, 0.25*N),
35 (0.75*N, 0.25*N, 0.75*N), (0.25*N, 0.75*N, 0.75*N)]
36
37KN_R, KI_I, KI_R = 16, 2, 17
38node_ints = np.array([1, -1, -1], dtype=np.int32)
39node_reals = np.zeros(KN_R); node_reals[0] = R
40node_reals[14] = 1.0; node_reals[15] = 1.0
41inst_ints = np.zeros((len(CEN), KI_I), dtype=np.int32)
42inst_reals = np.zeros((len(CEN), KI_R))
43for m, c in enumerate(CEN):
44 inst_ints[m] = (0, -1)
45 inst_reals[m, 0:3] = c
46 inst_reals[m, 6] = 1.0
47 inst_reals[m, 7] = 1.0
48
49
50def run(boost, wall_flux=True):
51 s = sdflow.Solver(N, N, N)
52 s.set_rho(RHO); s.set_mu(MU); s.set_dt(DT)
53 s.set_body_force(F, 0.0, 0.0)
54 s.set_advection(False)
55 s.set_velocity_solver_params(80)
56 s.set_pressure_solver_params(20)
57 # MG depth 4, not 1. levels=1 leaves the 'coarse' solve on the FULL grid, which on
58 # CUDA costs 920 ms/step at N=64 against 19.5 ms at depth 4 (measured). Depth changes the
59 # solver path, not the converged answer, and both runs of a comparison use the same depth.
60 s.set_pressure_multigrid(True, levels=4)
61 s.set_scene(node_ints, node_reals, inst_ints.ravel(), inst_reals.ravel(), periodic=True)
62 if boost:
63 s.set_wall_flux_divergence(wall_flux)
64 for i in range(len(CEN)):
65 s.set_instance_motion(i, lin_vel=[-V, 0.0, 0.0], ang_vel=[0.0, 0.0, 0.0])
66 s.set_solid_from_scene(True)
67 prev = 0.0
68 for it in range(STEPS):
69 s.step()
70 um = float(np.asarray(s.get_u()).mean())
71 prev = um # fixed step count: both runs get IDENTICAL solver work, so the
72 # residual difference below is a convergence level, not a stopping artefact
73 return (np.asarray(s.get_u()), np.asarray(s.get_v()), np.asarray(s.get_w()),
74 s.max_open_divergence(), s.wall_flux_imbalance(), it + 1,
75 np.asarray(s.diagnostics.hydro_force_torque_traction()),
76 np.asarray(s.hydro_force_torque_reaction()))
77
78
79# PER-COMPONENT masks. u lives at (i-1/2,j,k), v at (i,j-1/2,k), w at (i,j,k-1/2); a cell whose
80# CENTRE is fluid can have its u-point inside the solid, where the solver stores a masked 0 in both
81# runs -- so a cell-centred mask reports a spurious error of exactly V/max|u_A| there. (That is what
82# the first version of this gate measured, and it looked like a failure.)
83def sdf_at(offx, offy, offz):
84 g = np.arange(N).astype(float)
85 X, Y, Z = np.meshgrid(g + offx, g + offy, g + offz, indexing="ij")
86 d = np.full((N, N, N), 1e30)
87 for (cx, cy, cz) in CEN:
88 dx = np.abs(X-cx); dx = np.minimum(dx, N-dx)
89 dy = np.abs(Y-cy); dy = np.minimum(dy, N-dy)
90 dz = np.abs(Z-cz); dz = np.minimum(dz, N-dz)
91 d = np.minimum(d, np.sqrt(dx*dx+dy*dy+dz*dz) - R)
92 return d
93
94sdU = sdf_at(-0.5, 0.0, 0.0)
95sdV = sdf_at(0.0, -0.5, 0.0)
96sdW = sdf_at(0.0, 0.0, -0.5)
97mU, mV, mW = sdU > 0.0, sdV > 0.0, sdW > 0.0 # every fluid unknown, cut band included
98bU, bV, bW = mU & (sdU < 1.5), mV & (sdV < 1.5), mW & (sdW < 1.5) # the cut band alone
99dU, dV, dW = sdU > 2.0, sdV > 2.0, sdW > 2.0 # deep fluid
100
101uA, vA, wA, divA, imbA, itA, ftA, frA = run(False)
102print("A static : steps=%3d max|div|=%.3e <u>=%.6e" % (itA, divA, uA.mean()))
103print(" sum F_hydro = %+.6e (traction diagnostic, LAB frame)"
104 % ftA[0][:, 0].sum())
105print(" reaction F = %+.6e (route b, LAB frame)" % frA[0][:, 0].sum())
106
107for wf in (True, False):
108 uB, vB, wB, divB, imbB, itB, ftB, frB = run(True, wall_flux=wf)
109 du = (uB + V) - uA
110 dv = vB - vA
111 dw = wB - wA
112 scale = max(abs(uA[mU]).max(), 1e-30)
113
114 def worst(masks):
115 return max(abs(du[masks[0]]).max(), abs(dv[masks[1]]).max(),
116 abs(dw[masks[2]]).max()) / scale
117
118 def l2(masks):
119 n = masks[0].sum() + masks[1].sum() + masks[2].sum()
120 ssq = (du[masks[0]]**2).sum() + (dv[masks[1]]**2).sum() + (dw[masks[2]]**2).sum()
121 return np.sqrt(ssq / n) / scale
122
123 e_all, e_band, e_deep = worst((mU, mV, mW)), worst((bU, bV, bW)), worst((dU, dV, dW))
124 print("B boosted : wall_flux=%-5s steps=%3d max|div|=%.3e wall-flux imbalance=%.3e"
125 % (wf, itB, divB, imbB))
126 # FORCE IS FRAME-INVARIANT. A boost shifts u by a constant, changing neither p nor grad u,
127 # so the traction integral must return the SAME force. If it does not, the moving-wall force is
128 # wrong -- and a resolved CFD-DEM loop driven by it runs away instead of settling, which is
129 # exactly the failure this check was added to localise.
130 print(" sum F_hydro = %+.6e ratio to the lab frame %.6f (traction)"
131 % (ftB[0][:, 0].sum(), ftB[0][:, 0].sum() / ftA[0][:, 0].sum()))
132 print(" reaction F = %+.6e ratio to the lab frame %.9f (route b -- at steady "
133 "state the reaction is IDENTICAL between frames up to solver residual: Delta u and the "
134 "viscous differences of u* are boost-invariant)"
135 % (frB[0][:, 0].sum(), frB[0][:, 0].sum() / frA[0][:, 0].sum()))
136 print(" max|u_B + V - u_A| / max|u_A|: all fluid %.3e cut band %.3e "
137 "deep %.3e L2(all) %.3e" % (e_all, e_band, e_deep, l2((mU, mV, mW))))
138 if wf:
139 keep = e_all
140 ok = e_all < 1e-4
141 print("GATE (rung 3 ON) %s [claim: Galilean invariant to solver tolerance]"
142 % ("PASS" if ok else "FAIL"))
143 else:
144 print("CONTRAST (rung 3 OFF) all-fluid error %.3e = %.0fx the rung-3-ON error -- the wall "
145 "flux term is load bearing" % (e_all, e_all / max(keep, 1e-300)))
run(boost, wall_flux=True)
sdf_at(offx, offy, offz)
static double mean(const std::vector< double > &a)