PeriDEM 0.3.0
PeriDEM -- Peridynamics-based high-fidelity model for granular media
Loading...
Searching...
No Matches
check_health.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Health gates for ellipse × tip-up triangle.
3
4Desired: tip-seeded crack that grows into two spatially separated pieces
5(not a glued tip tether, not fragment spray).
6"""
7
8from __future__ import annotations
9
10import re
11import sys
12from pathlib import Path
13
14import meshio
15import numpy as np
16
17MAX_V = 25.0
18EARLY_DAMAGE_CAP = 0.98
19EARLY_MEAN_DAMAGE_CAP = 0.25
20MIN_FINAL_DAMAGE = 0.85
21MAX_FINAL_MEAN_DAMAGE = 0.50
22MIN_DAMAGE_RISE_FRAMES = 1
23MAX_COM_RADIUS = 0.012
24MAX_COM_DRIFT = 0.012
25# Tip tether: fully damaged ellipse node stuck on tip while COM has lifted.
26TETHER_DIST = 1.5e-4 # 0.15 mm
27TETHER_COM_LIFT = 5.0e-4
28TETHER_DAMAGE = 0.9
29# Two-piece: spatial clusters after fracture.
30CLUSTER_LINK = 1.9e-4 # ~1.9 * mesh_size
31MIN_CLUSTER_FRAC = 0.15
32MIN_CLUSTER_SEP = 6.0e-4 # 0.6 mm between piece COMs
33MESH_SIZE = 1.0e-4
34
35
36def frame_index(p: Path) -> int:
37 m = re.search(r"output_(\d+)\.vtu$", p.name)
38 return int(m.group(1)) if m else -1
39
40
42 X: np.ndarray, D: np.ndarray | None = None, link: float = CLUSTER_LINK, dam_cut: float = 0.75
43) -> list[tuple[np.ndarray, int]]:
44 """Union-find clusters. Highly damaged nodes are removed so a crack is a cut."""
45 if len(X) == 0:
46 return []
47 if D is not None:
48 alive = D < dam_cut
49 if alive.sum() < 4:
50 alive = np.ones(len(X), dtype=bool)
51 X = X[alive]
52 n = len(X)
53 parent = np.arange(n)
54
55 def find(a: int) -> int:
56 while parent[a] != a:
57 parent[a] = parent[parent[a]]
58 a = parent[a]
59 return a
60
61 def union(a: int, b: int) -> None:
62 ra, rb = find(a), find(b)
63 if ra != rb:
64 parent[rb] = ra
65
66 for i in range(n):
67 for j in range(i + 1, n):
68 if np.hypot(X[i, 0] - X[j, 0], X[i, 1] - X[j, 1]) < link:
69 union(i, j)
70
71 groups: dict[int, list[int]] = {}
72 for i in range(n):
73 r = find(i)
74 groups.setdefault(r, []).append(i)
75 out = []
76 for idxs in groups.values():
77 xi = X[np.asarray(idxs)]
78 out.append((xi.mean(axis=0), len(idxs)))
79 out.sort(key=lambda t: t[1], reverse=True)
80 return out
81
82
83def load_frames(out: Path):
84 files = sorted(out.glob("output_*.vtu"), key=frame_index)
85 files = [p for p in files if frame_index(p) >= 0]
86 rows = []
87 for p in files:
88 m = meshio.read(p)
89 pid = m.point_data["Particle_ID"].ravel().astype(int)
90 X = m.points[:, :2]
91 D_all = np.asarray(m.point_data["Damage"]).ravel()
92 V_all = np.asarray(m.point_data["Velocity"])
93 ell = pid == 1
94 tri = pid == 0
95 tip = X[tri][np.argmax(X[tri, 1])]
96 D = D_all[ell]
97 V = V_all[ell]
98 Xe = X[ell]
99 com = Xe.mean(axis=0)
100 R = np.hypot(Xe[:, 0] - com[0], Xe[:, 1] - com[1])
101 rmax = float(np.percentile(R, 99)) if len(R) else 0.0
102 d_tip = np.linalg.norm(Xe - tip, axis=1)
103 j = int(np.argmin(d_tip))
104 clusters = spatial_clusters(Xe, D, CLUSTER_LINK)
105 n_ref = max(1, int((D < 0.75).sum()) if len(D) else len(Xe))
106 n_big = sum(1 for _, sz in clusters if sz >= MIN_CLUSTER_FRAC * n_ref)
107 sep = 0.0
108 if len(clusters) >= 2 and clusters[1][1] >= MIN_CLUSTER_FRAC * n_ref:
109 sep = float(np.linalg.norm(clusters[0][0] - clusters[1][0]))
110 rows.append(
111 {
112 "t_idx": frame_index(p),
113 "dmax": float(D.max()) if len(D) else 0.0,
114 "dmean": float(D.mean()) if len(D) else 0.0,
115 "nd05": int((D > 0.05).sum()),
116 "nd50": int((D > 0.5).sum()),
117 "vmax": float(np.linalg.norm(V, axis=1).max()) if len(V) else 0.0,
118 "rmax": rmax,
119 "com": com,
120 "tip": tip,
121 "min_d_tip": float(d_tip[j]),
122 "stuck_dam": float(D[j]),
123 "n": int(ell.sum()),
124 "n_big_clusters": n_big,
125 "cluster_sep": sep,
126 "cluster_sizes": [sz for _, sz in clusters[:4]],
127 }
128 )
129 return rows
130
131
132def main() -> int:
133 out = Path(sys.argv[1] if len(sys.argv) > 1 else "runs").resolve()
134 rows = load_frames(out)
135 if len(rows) < 6:
136 print(f"FAIL: need ≥6 VTU frames in {out}, got {len(rows)}")
137 return 1
138
139 print(
140 "fr DamMax DamMean n>0.05 n>0.5 |v|max Rcom d_tip stuckDam "
141 "nClus sep_mm sizes"
142 )
143 for i, r in enumerate(rows):
144 print(
145 f"{i:3d} {r['dmax']:.4f} {r['dmean']:.4f} {r['nd05']:5d} {r['nd50']:5d} "
146 f"{r['vmax']:6.2f} {r['rmax']:.4f} {r['min_d_tip']*1e3:5.2f}mm "
147 f"{r['stuck_dam']:.3f} {r['n_big_clusters']:3d} "
148 f"{r['cluster_sep']*1e3:5.2f} {r['cluster_sizes']}"
149 )
150
151 fails = []
152 early = rows[1]
153 late = rows[-1]
154 peak_v = max(r["vmax"] for r in rows)
155 peak_r = max(r["rmax"] for r in rows)
156 com0 = rows[0]["com"]
157 com_drift = float(np.linalg.norm(late["com"] - com0))
158
159 rise = sum(1 for i in range(1, len(rows)) if rows[i]["dmax"] > rows[i - 1]["dmax"] + 1e-6)
160
161 if early["dmax"] >= EARLY_DAMAGE_CAP and early["dmean"] >= EARLY_MEAN_DAMAGE_CAP:
162 fails.append(
163 f"early DamMax={early['dmax']:.3f} DamMean={early['dmean']:.3f} "
164 f"(mass rupture at impact)"
165 )
166 if late["dmax"] < MIN_FINAL_DAMAGE:
167 fails.append(f"final DamMax={late['dmax']:.3f} < {MIN_FINAL_DAMAGE} (no through crack)")
168 if late["dmean"] > MAX_FINAL_MEAN_DAMAGE:
169 fails.append(
170 f"final DamMean={late['dmean']:.3f} > {MAX_FINAL_MEAN_DAMAGE} (ring pulverized)"
171 )
172 if rise < MIN_DAMAGE_RISE_FRAMES:
173 fails.append(f"damage rise frames={rise} < {MIN_DAMAGE_RISE_FRAMES}")
174 if peak_v > MAX_V:
175 fails.append(f"peak |v|={peak_v:.1f} > {MAX_V} (fragment spray)")
176 if peak_r > MAX_COM_RADIUS:
177 fails.append(f"peak node-COM radius={peak_r:.4f} > {MAX_COM_RADIUS} (blown apart)")
178 if com_drift > MAX_COM_DRIFT:
179 fails.append(f"ellipse COM drift={com_drift:.4f} > {MAX_COM_DRIFT}")
180
181 # Abrupt full-field jump is bad; tip-seeded DamMax jump with low mean is OK.
182 for i in range(1, len(rows)):
183 if (
184 rows[i - 1]["dmax"] < 0.05
185 and rows[i]["dmax"] > 0.95
186 and rows[i]["dmean"] > 0.3
187 ):
188 fails.append(
189 f"Damage jump {rows[i-1]['dmax']:.2f}→{rows[i]['dmax']:.2f} "
190 f"DamMean={rows[i]['dmean']:.2f} at frame {i}"
191 )
192 break
193
194 # Two-piece bipartition in the last third of the run.
195 split_ok = False
196 best_sep = 0.0
197 for r in rows[len(rows) // 2 :]:
198 best_sep = max(best_sep, r["cluster_sep"])
199 if r["n_big_clusters"] >= 2 and r["cluster_sep"] >= MIN_CLUSTER_SEP:
200 split_ok = True
201 break
202 if not split_ok:
203 fails.append(
204 f"no two-piece split (need ≥2 clusters ≥{MIN_CLUSTER_FRAC*100:.0f}% nodes "
205 f"with COM sep≥{MIN_CLUSTER_SEP*1e3:.1f}mm; best_sep={best_sep*1e3:.2f}mm, "
206 f"late sizes={late['cluster_sizes']})"
207 )
208
209 tether_frames = 0
210 for r in rows[len(rows) // 3 :]:
211 lift = r["com"][1] - r["tip"][1]
212 if (
213 r["min_d_tip"] < TETHER_DIST
214 and r["stuck_dam"] >= TETHER_DAMAGE
215 and lift > TETHER_COM_LIFT
216 ):
217 tether_frames += 1
218 if tether_frames >= 3:
219 fails.append(
220 f"tip tether: {tether_frames} frames with Dam≥{TETHER_DAMAGE} node "
221 f"stuck within {TETHER_DIST*1e3:.2f}mm of tip while COM lifted"
222 )
223
224 if fails:
225 print("FAIL:")
226 for f in fails:
227 print(" ", f)
228 return 1
229 print(
230 f"PASS: two-piece DamMax {early['dmax']:.3f}→{late['dmax']:.3f}, "
231 f"DamMean={late['dmean']:.3f}, peak|v|={peak_v:.2f}, "
232 f"cluster_sep={late['cluster_sep']*1e3:.2f}mm, sizes={late['cluster_sizes']}"
233 )
234 return 0
235
236
237if __name__ == "__main__":
238 raise SystemExit(main())
int frame_index(Path p)
list[tuple[np.ndarray, int]] spatial_clusters(np.ndarray X, np.ndarray|None D=None, float link=CLUSTER_LINK, float dam_cut=0.75)
load_frames(Path out)