pnm 1.0.3
peclet.pnm — pore-network extraction from SDF geometry (Kokkos)
Loading...
Searching...
No Matches
verify_network_flow.py
Go to the documentation of this file.
1"""Quantitative validation of pnm.extract_network_flow against a peclet.flow DNS.
2
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).
12
13Run: PYTHONPATH=<pnm/build>:<flow/build> python scripts/verify_network_flow.py
14"""
15import sys
16
17import numpy as np
18
19import peclet.flow
20import peclet.pnm as pnm
21
22# ---- geometry: two chambers + periodic tube (pnm/flow convention: sdf > 0 in the PORE space) ---
23NX, NY, NZ = 48, 32, 32
24RC, RT = 9.0, 4.0
25CY, CZ = NY / 2.0, NZ / 2.0
26CENTERS_X = (12.0, 36.0)
27
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)
31for cx in CENTERS_X:
32 dx = np.abs(x - cx)
33 dx = np.minimum(dx, NX - dx) # periodic in x
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)) # tube along x
37
38# ---- DNS: Stokes flow driven by a body force in x (grad_p_macro = -f) ----------------------
39MU, FX = 0.05, 1.0e-3
40s = peclet.flow.Solver(NX, NY, NZ)
41s.set_rho(1.0)
42s.set_mu(MU)
43s.set_dt(100.0)
44s.set_body_force((FX, 0.0, 0.0)) # one 3-sequence since 1.0.0 (the clean break)
45s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True) # openness-weighted flux o*u*A
46for _ in range(60):
47 s.step()
48
49u, v, w = s.get_uf(), s.get_vf(), s.get_wf()
50p = s.get_p()
51ox, oy, oz = s.get_ox(), s.get_oy(), s.get_oz()
52
53# DNS flux through every x-plane (openness-weighted -x face fluxes of plane i)
54F_planes = np.array([(ox[i, :, :] * u[i, :, :]).sum() for i in range(NX)])
55F = F_planes.mean()
56plane_dev = np.abs(F_planes - F).max() / abs(F)
57print(f"DNS flux F = {F:.6e} (max plane deviation {plane_dev:.2e})")
58
59# ---- network extraction ------------------------------------------------------------------------
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])
65
66pores = net["pores"]
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}")
77
78# ---- checks ------------------------------------------------------------------------------------
79fails = 0
80def check(name, ok, detail):
81 global fails
82 print(f" [{'PASS' if ok else 'FAIL'}] {name}: {detail}")
83 fails += 0 if ok else 1
84
85check("steady plane flux", plane_dev < 1e-6, f"max deviation {plane_dev:.2e}")
86# Watershed topology: the chambers + the wrap tube segment (the mid-tube plateau drains into the
87# chamber basins — only the wrap segment keeps a gid-seam peak). A single loop: #throats == #pores.
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()}")
97
98# ================= case 2: asymmetric chamber lattice (a genuine network) ======================
99# 4 chambers in a 2x2 (y,z) arrangement, connected along x by tubes of DIFFERENT radii through
100# the periodic boundary — a multi-loop resistor network with real constrictions. On such
101# geometry the throat data must behave like a network: g = Q/dp > 0 on every throat.
102print("\n=== case 2: asymmetric tube lattice ===")
103del s
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")
107tubes = [] # (cy, cz, radius) — one x-tube per chamber column, different radii
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)) # one 3-sequence since 1.0.0 (the clean break)
116s.set_solid(np.asfortranarray(sdf2), cutcell_pressure=True)
117for _ in range(60):
118 s.step()
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)}")
137# the four tube columns are independent parallel channels of 3 throats each, every throat in a
138# column carrying that column's full flux: sum |Q| = 3 * F_total
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}")
141
142# ================= case 3: ghost-cell IBM variant (set_ghost_projection) =======================
143# Same chamber-tube chain, solved with the directional ghost-cell projection. The projection
144# there conserves fluxes through the BINARY (COUPLED) openness — flow's get_*_proj getters
145# return the operative openness in either mode, so the same extraction call works for both IBMs.
146print("\n=== case 3: chamber-tube chain, ghost-cell IBM ===")
147del s
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)) # one 3-sequence since 1.0.0 (the clean break)
151s.set_ghost_projection(True)
152s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
153for _ in range(60):
154 s.step()
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)])
158F3 = F3_planes.mean()
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}")
172# Ghost-cell IBM is pointwise 2nd-order but NOT locally mass-conserving at the wall (the
173# closure rows absorb truncation error as wall transpiration), so the network bookkeeping is
174# truncation-accurate here, not machine-exact: pore_residual becomes the per-pore wall leak.
175# Measured on this geometry: residual/F 3.2e-2 (h) -> 4.9e-3 (h/2), order ~2.7; ||Q|-F|/F
176# order ~1.8. Tolerances below are set for THIS resolution (tube radius = 4 cells).
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)")
188
189# ================= case 4: PARALLEL throats (per-patch resolution) =============================
190# Two chambers connected by TWO capsule tubes of different radii (same pore pair, two disjoint
191# interfaces -> two parallel throats) + a wrap tube as the return path. Pair-keyed throats would
192# merge the capsules into one entry; per-patch must report pair (1,2) twice, with Q_A + Q_B = F
193# and the fatter capsule carrying more.
194print("\n=== case 4: parallel capsule throats ===")
195del s
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): # chambers
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))
202# capsule ridges must DRAIN into the chamber basins (no own pore): needs chamber sdf >= r one
203# cell off the ridge at the junction, i.e. 8 - |16-cy| + 1 >= r — hence cy near the axis.
204for (cy, rt) in ((12.0, 3.0), (20.0, 4.2)): # capsules between the chambers (no wrap)
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) # wrap capsule: the return path, own pore at the gid seam
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))
210
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)) # one 3-sequence since 1.0.0 (the clean break)
214s.set_solid(np.asfortranarray(sdf4), cutcell_pressure=True)
215for _ in range(60):
216 s.step()
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() # (M,2) int32 array -> [[a, b], ...] for the == tests below
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}")
238if len(par) == 2:
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()}")
251
252print("PASS" if fails == 0 else f"FAIL ({fails})")
253del s # release the solver's device Views BEFORE interpreter exit (flow's finalize contract)
254sys.exit(1 if fails else 0)
check(name, ok, detail)