1"""Quantitative validation of pnm.extract_network_flow against a peclet.flow DNS.
3Geometry: a periodic chamber-tube chain — two spherical chambers connected by a straight
4cylindrical tube along x (also through the periodic boundary). The watershed segments this into
54 pores (2 chambers + 2 tube segments) in a single loop, so by mass conservation EVERY pore-pore
6throat must carry the full DNS flux F (measured independently as the openness-weighted x-flux
7through a grid plane). Checks:
8 1. per-pore residual (signed flux over the whole pore boundary) ~ solver tolerance,
9 2. |Q_throat| == F for every throat (small leakage through near-wall faces allowed),
10 3. Q and the total-pressure drop dp agree in sign on every throat (g = Q/dp > 0),
11 4. the DNS plane flux is x-independent (steady, converged).
13Run: PYTHONPATH=<pnm/build>:<flow/build> python scripts/verify_network_flow.py
20import peclet.pnm
as pnm
23NX, NY, NZ = 48, 32, 32
25CY, CZ = NY / 2.0, NZ / 2.0
26CENTERS_X = (12.0, 36.0)
28x, y, z = np.meshgrid(np.arange(NX), np.arange(NY), np.arange(NZ), indexing=
"ij")
29x = x.astype(np.float64); y = y.astype(np.float64); z = z.astype(np.float64)
30sdf = np.full((NX, NY, NZ), -1e30)
33 dx = np.minimum(dx, NX - dx)
34 d = np.sqrt(dx**2 + (y - CY) ** 2 + (z - CZ) ** 2)
35 sdf = np.maximum(sdf, RC - d)
36sdf = np.maximum(sdf, RT - np.sqrt((y - CY) ** 2 + (z - CZ) ** 2))
40s = peclet.flow.Solver(NX, NY, NZ)
44s.set_body_force((FX, 0.0, 0.0))
45s.set_solid(np.asfortranarray(sdf), cutcell_pressure=
True)
49u, v, w = s.get_uf(), s.get_vf(), s.get_wf()
51ox, oy, oz = s.get_ox(), s.get_oy(), s.get_oz()
54F_planes = np.array([(ox[i, :, :] * u[i, :, :]).sum()
for i
in range(NX)])
56plane_dev = np.abs(F_planes - F).max() / abs(F)
57print(f
"DNS flux F = {F:.6e} (max plane deviation {plane_dev:.2e})")
60net = pnm.extract_network_flow(
61 np.ascontiguousarray(sdf.T, dtype=np.float32), [0.0, 0.0, 0.0], [1.0, 1.0, 1.0],
62 np.ascontiguousarray(u.T), np.ascontiguousarray(v.T), np.ascontiguousarray(w.T),
63 np.ascontiguousarray(p.T), np.ascontiguousarray(ox.T), np.ascontiguousarray(oy.T),
64 np.ascontiguousarray(oz.T), grad_p_zyx=[0.0, 0.0, -FX])
67Q = np.array(net[
"throat_flow"])
68dp = np.array(net[
"throat_dp"])
69res = np.array(net[
"pore_residual"])
70print(f
"pores: {len(pores)} throats: {len(net['throats'])}")
71for k, po
in enumerate(pores):
72 print(f
" pore {k+1}: x=({po.x:6.2f},{po.y:6.2f},{po.z:6.2f}) r={po.radius:5.2f} "
73 f
"p={net['pore_pressure'][k]: .4e} residual={res[k]: .3e}")
74for t, (a, b)
in enumerate(net[
"throats"]):
75 print(f
" throat {a}-{b}: Q={Q[t]: .6e} dp={dp[t]: .4e} A={net['throat_area'][t]:7.2f} "
76 f
"g=Q/dp={Q[t]/dp[t]: .4e}")
82 print(f
" [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
83 fails += 0
if ok
else 1
85check(
"steady plane flux", plane_dev < 1e-6, f
"max deviation {plane_dev:.2e}")
88check(
"pore count", len(pores) == 3, f
"{len(pores)} (expect 3: 2 chambers + wrap tube segment)")
89check(
"single loop", len(net[
"throats"]) == len(pores),
90 f
"{len(net['throats'])} throats vs {len(pores)} pores")
91check(
"mass residuals", np.abs(res).max() < 1e-4 * abs(F),
92 f
"max |residual|/F = {np.abs(res).max()/abs(F):.2e}")
93check(
"throat flux = DNS flux", np.abs(np.abs(Q) - abs(F)).max() < 1e-3 * abs(F),
94 f
"max ||Q|-F|/F = {np.abs(np.abs(Q)-abs(F)).max()/abs(F):.2e}")
95check(
"Q, dp sign-consistent (g>0)", bool(np.all(Q * dp > 0)),
96 f
"signs: {np.sign(Q*dp).astype(int).tolist()}")
102print(
"\n=== case 2: asymmetric tube lattice ===")
104NX2, NY2, NZ2 = 48, 48, 48
105sdf2 = np.full((NX2, NY2, NZ2), -1e30)
106x, y, z = np.meshgrid(*(np.arange(d, dtype=np.float64)
for d
in (NX2, NY2, NZ2)), indexing=
"ij")
108for (cy, cz, rt)
in ((12.0, 12.0, 3.0), (12.0, 36.0, 4.0), (36.0, 12.0, 5.0), (36.0, 36.0, 6.0)):
109 for cx
in (12.0, 36.0):
110 dx = np.abs(x - cx); dx = np.minimum(dx, NX2 - dx)
111 sdf2 = np.maximum(sdf2, 9.0 - np.sqrt(dx**2 + (y - cy) ** 2 + (z - cz) ** 2))
112 sdf2 = np.maximum(sdf2, rt - np.sqrt((y - cy) ** 2 + (z - cz) ** 2))
113s = peclet.flow.Solver(NX2, NY2, NZ2)
114s.set_rho(1.0); s.set_mu(MU); s.set_dt(100.0)
115s.set_body_force((FX, 0.0, 0.0))
116s.set_solid(np.asfortranarray(sdf2), cutcell_pressure=
True)
119u, v, w, p = s.get_uf(), s.get_vf(), s.get_wf(), s.get_p()
120ox, oy, oz = s.get_ox(), s.get_oy(), s.get_oz()
121net2 = pnm.extract_network_flow(
122 np.ascontiguousarray(sdf2.T, dtype=np.float32), [0.0, 0.0, 0.0], [1.0, 1.0, 1.0],
123 np.ascontiguousarray(u.T), np.ascontiguousarray(v.T), np.ascontiguousarray(w.T),
124 np.ascontiguousarray(p.T), np.ascontiguousarray(ox.T), np.ascontiguousarray(oy.T),
125 np.ascontiguousarray(oz.T), grad_p_zyx=[0.0, 0.0, -FX])
126Q2 = np.array(net2[
"throat_flow"])
127dp2 = np.array(net2[
"throat_dp"])
128res2 = np.array(net2[
"pore_residual"])
129F2 = np.array([(ox[i, :, :] * u[i, :, :]).sum()
for i
in range(NX2)]).mean()
130print(f
"pores {len(net2['pores'])}, throats {len(Q2)}, F = {F2:.4e}")
131for t, (a, b)
in enumerate(net2[
"throats"]):
132 print(f
" throat {a}-{b}: Q={Q2[t]: .4e} dp={dp2[t]: .4e} g={Q2[t]/dp2[t]: .4e}")
133check(
"case2 mass residuals", np.abs(res2).max() < 1e-4 * abs(F2),
134 f
"max |residual|/F = {np.abs(res2).max()/abs(F2):.2e}")
135check(
"case2 g>0 on all throats", bool(np.all(Q2 * dp2 > 0)),
136 f
"{int((Q2*dp2 > 0).sum())}/{len(Q2)}")
139check(
"case2 total flux", abs(np.abs(Q2).sum() / 3.0 - abs(F2)) < 1e-2 * abs(F2),
140 f
"sum|Q|/3 = {np.abs(Q2).sum()/3.0:.4e} vs F = {F2:.4e}")
146print(
"\n=== case 3: chamber-tube chain, ghost-cell IBM ===")
148s = peclet.flow.Solver(NX, NY, NZ)
149s.set_rho(1.0); s.set_mu(MU); s.set_dt(100.0)
150s.set_body_force((FX, 0.0, 0.0))
151s.set_ghost_projection(
True)
152s.set_solid(np.asfortranarray(sdf), cutcell_pressure=
True)
155u, v, w, p = s.get_uf(), s.get_vf(), s.get_wf(), s.get_p()
156ox, oy, oz = s.get_ox_proj(), s.get_oy_proj(), s.get_oz_proj()
157F3_planes = np.array([(ox[i, :, :] * u[i, :, :]).sum()
for i
in range(NX)])
159plane_dev3 = np.abs(F3_planes - F3).max() / abs(F3)
160net3 = pnm.extract_network_flow(
161 np.ascontiguousarray(sdf.T, dtype=np.float32), [0.0, 0.0, 0.0], [1.0, 1.0, 1.0],
162 np.ascontiguousarray(u.T), np.ascontiguousarray(v.T), np.ascontiguousarray(w.T),
163 np.ascontiguousarray(p.T), np.ascontiguousarray(ox.T), np.ascontiguousarray(oy.T),
164 np.ascontiguousarray(oz.T), grad_p_zyx=[0.0, 0.0, -FX])
165Q3 = np.array(net3[
"throat_flow"])
166dp3 = np.array(net3[
"throat_dp"])
167res3 = np.array(net3[
"pore_residual"])
168print(f
"ghost DNS flux F = {F3:.6e} (vs cut-cell {F:.6e}); "
169 f
"{len(net3['pores'])} pores, {len(Q3)} throats")
170for t, (a, b)
in enumerate(net3[
"throats"]):
171 print(f
" throat {a}-{b}: Q={Q3[t]: .6e} dp={dp3[t]: .4e}")
177check(
"case3 steady plane flux (truncation)", plane_dev3 < 5e-2,
178 f
"max deviation {plane_dev3:.2e} (wall transpiration, converges ~O(h^2))")
179check(
"case3 mass residuals (truncation)", np.abs(res3).max() < 5e-2 * abs(F3),
180 f
"max |residual|/F = {np.abs(res3).max()/abs(F3):.2e}")
181check(
"case3 throat flux = DNS flux (truncation)",
182 np.abs(np.abs(Q3) - abs(F3)).max() < 6e-2 * abs(F3),
183 f
"max ||Q|-F|/F = {np.abs(np.abs(Q3)-abs(F3)).max()/abs(F3):.2e}")
184check(
"case3 g>0 on all throats", bool(np.all(Q3 * dp3 > 0)),
185 f
"signs: {np.sign(Q3*dp3).astype(int).tolist()}")
186check(
"case3 flux agrees with cut-cell IBM", abs(F3 - F) < 0.10 * abs(F),
187 f
"rel diff {(F3-F)/F:.2%} (coarse tube: r = 4 cells)")
194print(
"\n=== case 4: parallel capsule throats ===")
196NX4, NY4, NZ4 = 48, 32, 32
197x, y, z = np.meshgrid(*(np.arange(d, dtype=np.float64)
for d
in (NX4, NY4, NZ4)), indexing=
"ij")
198sdf4 = np.full((NX4, NY4, NZ4), -1e30)
199for cx
in (12.0, 36.0):
200 dx = np.abs(x - cx); dx = np.minimum(dx, NX4 - dx)
201 sdf4 = np.maximum(sdf4, 8.0 - np.sqrt(dx**2 + (y - 16.0) ** 2 + (z - 16.0) ** 2))
204for (cy, rt)
in ((12.0, 3.0), (20.0, 4.2)):
205 xs = np.clip(x, 12.0, 36.0)
206 sdf4 = np.maximum(sdf4, rt - np.sqrt((x - xs) ** 2 + (y - cy) ** 2 + (z - 16.0) ** 2))
207xw = np.where(x < 12.0, x + NX4, x)
208xs = np.clip(xw, 36.0, 12.0 + NX4)
209sdf4 = np.maximum(sdf4, 4.0 - np.sqrt((xw - xs) ** 2 + (y - 16.0) ** 2 + (z - 16.0) ** 2))
211s = peclet.flow.Solver(NX4, NY4, NZ4)
212s.set_rho(1.0); s.set_mu(MU); s.set_dt(100.0)
213s.set_body_force((FX, 0.0, 0.0))
214s.set_solid(np.asfortranarray(sdf4), cutcell_pressure=
True)
217u, v, w, p = s.get_uf(), s.get_vf(), s.get_wf(), s.get_p()
218ox, oy, oz = s.get_ox(), s.get_oy(), s.get_oz()
219F4 = np.array([(ox[i, :, :] * u[i, :, :]).sum()
for i
in range(NX4)]).mean()
220net4 = pnm.extract_network_flow(
221 np.ascontiguousarray(sdf4.T, dtype=np.float32), [0.0, 0.0, 0.0], [1.0, 1.0, 1.0],
222 np.ascontiguousarray(u.T), np.ascontiguousarray(v.T), np.ascontiguousarray(w.T),
223 np.ascontiguousarray(p.T), np.ascontiguousarray(ox.T), np.ascontiguousarray(oy.T),
224 np.ascontiguousarray(oz.T), grad_p_zyx=[0.0, 0.0, -FX])
225Q4 = np.array(net4[
"throat_flow"])
226dp4 = np.array(net4[
"throat_dp"])
227res4 = np.array(net4[
"pore_residual"])
228th4 = net4[
"throats"].tolist()
229print(f
"pores {len(net4['pores'])}, throats {len(Q4)}, F = {F4:.4e}")
230for t, (a, b)
in enumerate(th4):
231 print(f
" throat {a}-{b}: Q={Q4[t]: .6e} dp={dp4[t]: .4e} A={net4['throat_area'][t]:7.2f}")
232par = [t
for t, ab
in enumerate(th4)
if ab == [1, 2]]
233check(
"case4 pore count", len(net4[
"pores"]) == 3, f
"{len(net4['pores'])} (2 chambers + wrap pore)")
234check(
"case4 parallel pair (1,2) twice", len(par) == 2,
235 f
"pair (1,2) appears {len(par)}x of {len(Q4)} throats")
236check(
"case4 mass residuals", np.abs(res4).max() < 1e-4 * abs(F4),
237 f
"max |residual|/F = {np.abs(res4).max()/abs(F4):.2e}")
239 qA, qB = Q4[par[0]], Q4[par[1]]
240 check(
"case4 parallel split sums to F", abs(qA + qB - F4) < 1e-3 * abs(F4),
241 f
"Q_A + Q_B = {qA+qB:.6e} vs F = {F4:.6e}")
242 check(
"case4 both capsules carry forward flow", qA > 0
and qB > 0,
243 f
"Q_A = {qA:.3e}, Q_B = {qB:.3e}")
244 check(
"case4 fatter capsule carries more", max(qA, qB) > 1.5 * min(qA, qB),
245 f
"ratio {max(qA,qB)/min(qA,qB):.2f} (radii 4.5 vs 3)")
246wrapT = [t
for t, ab
in enumerate(th4)
if ab != [1, 2]]
247check(
"case4 wrap throats carry F", bool(np.all(np.abs(np.abs(Q4[wrapT]) - abs(F4)) < 1e-3 * abs(F4))),
248 f
"|Q| = {np.abs(Q4[wrapT]).tolist()}")
249check(
"case4 g>0 on all throats", bool(np.all(Q4 * dp4 > 0)),
250 f
"signs: {np.sign(Q4*dp4).astype(int).tolist()}")
252print(
"PASS" if fails == 0
else f
"FAIL ({fails})")
254sys.exit(1
if fails
else 0)