simcon-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. simcon_toolkit/__init__.py +40 -0
  2. simcon_toolkit/__main__.py +7 -0
  3. simcon_toolkit/_kit/LICENSE +202 -0
  4. simcon_toolkit/_kit/NOTICE +37 -0
  5. simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
  6. simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
  7. simcon_toolkit/_kit/packages/.ruff.toml +10 -0
  8. simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
  9. simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
  10. simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
  11. simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
  12. simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
  13. simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
  14. simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
  15. simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
  16. simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
  17. simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
  18. simcon_toolkit/_kit/pyproject.toml +232 -0
  19. simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
  20. simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
  21. simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
  22. simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
  23. simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
  24. simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
  25. simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
  26. simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
  27. simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
  28. simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
  29. simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
  30. simcon_toolkit/_kit/templates/process-window/README.md +176 -0
  31. simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
  32. simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
  33. simcon_toolkit/_kit/templates/process-window/main.py +82 -0
  34. simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
  35. simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
  36. simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
  37. simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
  38. simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
  39. simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
  40. simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
  41. simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
  42. simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
  43. simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
  44. simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
  45. simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
  46. simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
  47. simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
  48. simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
  49. simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
  50. simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
  51. simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
  52. simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
  53. simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
  54. simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
  55. simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
  56. simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
  57. simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
  58. simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
  59. simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
  60. simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
  61. simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
  62. simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
  63. simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
  64. simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
  65. simcon_toolkit/_kit/templates/quoting/README.md +158 -0
  66. simcon_toolkit/_kit/templates/quoting/main.py +484 -0
  67. simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
  68. simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
  69. simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
  70. simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
  71. simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
  72. simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
  73. simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
  74. simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
  75. simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
  76. simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
  77. simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
  78. simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
  79. simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
  80. simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
  81. simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
  82. simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
  83. simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
  84. simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
  85. simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
  86. simcon_toolkit/_kit/templates.toml +83 -0
  87. simcon_toolkit/choices.py +11 -0
  88. simcon_toolkit/cli.py +381 -0
  89. simcon_toolkit/generate.py +590 -0
  90. simcon_toolkit/instructions.py +152 -0
  91. simcon_toolkit/manifest.py +86 -0
  92. simcon_toolkit/project.py +356 -0
  93. simcon_toolkit/wizard.py +160 -0
  94. simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
  95. simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
  96. simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
  97. simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,519 @@
1
+ """Injection-molding metrics from an AI-Solver filling result (HDF5).
2
+
3
+ Everything an early-stage gating decision needs, read straight from one
4
+ ``*_prediction.h5`` produced by the cloud AI filling solver:
5
+
6
+ * **max filling pressure** [bar] - the real per-node ``Pressure`` field
7
+ (the platform's summary ``max_pressure_Pa`` is 0 for this model).
8
+ * **fill completeness** - did the part fill, or is there a short shot?
9
+ * **fill-time map** ``t_fill[node]`` - when the flow front first reached each
10
+ node; the basis for evenness and weld-line analysis.
11
+ * **fill evenness / balance** - how simultaneously the part finishes filling
12
+ (racetracking and stranded regions show up as a long tail).
13
+ * **weld lines** - where two flow fronts meet head-on. Detected from the
14
+ fill-time field: at a node reached from two near-opposite directions by
15
+ fronts that arrive at about the same time, the fronts collided -> weld.
16
+ Clustered into lines and located in part space so we can judge visibility.
17
+
18
+ The only inputs are the result file and the gate positions used (so we can mark
19
+ them and measure flow lengths). Pure read-only; no cloud, no SDK.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ import h5py
28
+ import numpy as np
29
+ from scipy.spatial import cKDTree
30
+
31
+
32
+ # --------------------------------------------------------------------------
33
+ # low-level readers
34
+ # --------------------------------------------------------------------------
35
+ def _timestep_names(f: h5py.File) -> list[str]:
36
+ return sorted(k for k in f if k.startswith("TimeStep_"))
37
+
38
+
39
+ def read_coords(f: h5py.File) -> np.ndarray:
40
+ """Midplane node coordinates (N, 3) in mm (geometry group has a trailing space)."""
41
+ key = next(k for k in f if k.strip() == "geometry3DF")
42
+ return np.asarray(f[key]["Coordinates"][()], dtype=np.float64)
43
+
44
+
45
+ def fill_time(f: h5py.File, dof_thresh: float = 0.5) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
46
+ """Per-node first-arrival time of the flow front.
47
+
48
+ Returns ``(t_fill, reached, times)`` where ``t_fill[i]`` is the time the
49
+ front first reached node ``i`` (inf if never), ``reached`` is the bool mask
50
+ of filled nodes, and ``times`` is the time axis.
51
+
52
+ In the cloud AI result ``Degree_of_filling`` is a sharp 0->1 front present at
53
+ every node (0 = not filled yet, ramps to 1 once the front passes) - *not*
54
+ NaN-masked. A node counts as reached the first step its value crosses
55
+ ``dof_thresh``; the field is monotone, so "ever crossed" == "filled".
56
+ """
57
+ steps = _timestep_names(f)
58
+ times = np.array([float(f[s].attrs["Time"]) for s in steps], dtype=np.float64)
59
+ n = f[steps[0]]["Degree_of_filling"].shape[0]
60
+ t_fill = np.full(n, np.inf, dtype=np.float64)
61
+ reached = np.zeros(n, dtype=bool)
62
+ for s, t in zip(steps, times, strict=True):
63
+ dof = np.asarray(f[s]["Degree_of_filling"][()], dtype=np.float32)
64
+ now = np.isfinite(dof) & (dof > dof_thresh)
65
+ newly = now & ~reached
66
+ t_fill[newly] = t
67
+ reached |= now
68
+ return t_fill, reached, times
69
+
70
+
71
+ def max_pressure_bar(f: h5py.File) -> float:
72
+ """Peak per-node cavity pressure over all time steps, returned in **bar**.
73
+
74
+ Honours the field's ``unit`` attribute: newer platform results store Pressure
75
+ in ``Pa``, older files in ``bar`` — both come back as bar (Pa is /1e5).
76
+ """
77
+ pmax = 0.0
78
+ unit = ""
79
+ for s in _timestep_names(f):
80
+ g = f[s]
81
+ if "Pressure" in g:
82
+ ds = g["Pressure"]
83
+ if not unit:
84
+ u = ds.attrs.get("unit", "")
85
+ unit = (u.decode() if isinstance(u, bytes) else str(u)).strip().lower()
86
+ pr = np.asarray(ds[()], dtype=np.float64)
87
+ if np.isfinite(pr).any():
88
+ pmax = max(pmax, float(np.nanmax(pr)))
89
+ if unit == "pa":
90
+ pmax /= 1.0e5
91
+ return pmax
92
+
93
+
94
+ def max_shear_rate(f: h5py.File) -> float:
95
+ """Peak shear rate over the fill [1/s].
96
+
97
+ Handles both result formats: a straight ``Shear_rate`` field [1/s] (current)
98
+ used as-is, or the legacy log-encoded ``Log(1 + Shear_rate)`` (de-logged with
99
+ expm1; its values are in units of 100/s, hence the x100). High shear risks
100
+ material degradation, so it bounds how hard the flow rate can be pushed.
101
+ """
102
+ steps = _timestep_names(f)
103
+ if not steps:
104
+ return 0.0
105
+ g0 = f[steps[0]]
106
+ if "Shear_rate" in g0:
107
+ name, is_log, scale = "Shear_rate", False, 1.0
108
+ elif "Log(1 + Shear_rate)" in g0:
109
+ name, is_log, scale = "Log(1 + Shear_rate)", True, 100.0
110
+ else:
111
+ return 0.0
112
+ smax = 0.0
113
+ for s in steps:
114
+ sr = np.asarray(f[s][name][()], dtype=np.float64)
115
+ if is_log:
116
+ sr = np.expm1(sr)
117
+ if np.isfinite(sr).any():
118
+ smax = max(smax, float(np.nanmax(sr)))
119
+ return smax * scale
120
+
121
+
122
+ # --------------------------------------------------------------------------
123
+ # neighbour graph (scipy KD-tree)
124
+ # --------------------------------------------------------------------------
125
+ def knn_indices(coords: np.ndarray, k: int = 10) -> np.ndarray:
126
+ """(N, k) indices of each node's k nearest neighbours (self excluded).
127
+
128
+ Asks the tree for k+1 and drops the node itself, because a nearest-neighbour
129
+ query always returns the query point first. Taking k directly would leave every
130
+ node with k-1 real neighbours plus itself, which shifts the weld clusters.
131
+
132
+ On *coincident* nodes the chosen neighbours are arbitrary and no two libraries
133
+ need agree, since every candidate sits at distance zero. That is a property of
134
+ the question, not of this implementation; a real mesh essentially never ties.
135
+ """
136
+ coords = np.ascontiguousarray(coords, dtype=np.float64)
137
+ n = len(coords)
138
+ _dist, idx = cKDTree(coords).query(coords, k=k + 1)
139
+ idx = np.atleast_2d(idx)
140
+ out = np.zeros((n, k), dtype=np.int64)
141
+ for i in range(n):
142
+ # `j < n` is load-bearing, not defensive: when the mesh holds fewer than k+1
143
+ # nodes, query() pads the row with index n -- one past the end -- at distance
144
+ # infinity. Keeping those would emit out-of-range indices that only fail later,
145
+ # wherever a caller indexes coords with them.
146
+ nn = [int(j) for j in idx[i] if j != i and j < n]
147
+ # Short rows only on a degenerate mesh (too few nodes, or duplicates). Pad
148
+ # rather than raise: a ragged row would break the (N, k) shape every caller
149
+ # indexes into.
150
+ if len(nn) < k:
151
+ nn += [nn[-1]] * (k - len(nn)) if nn else [i] * k
152
+ out[i] = nn[:k]
153
+ return out
154
+
155
+
156
+ # --------------------------------------------------------------------------
157
+ # weld-line detection
158
+ # --------------------------------------------------------------------------
159
+ @dataclass
160
+ class WeldCluster:
161
+ """One weld line: a group of kNN-adjacent weld nodes, summarised in part space."""
162
+
163
+ n_nodes: int
164
+ centroid: np.ndarray # (3,) mm
165
+ extent_mm: float # bounding diagonal of the cluster
166
+ mean_angle_deg: float # mean front meeting angle (180 = head-on)
167
+ fill_time_s: float # when the fronts met (mean t_fill of the cluster)
168
+ axis_pos: float # 0..1 position along the part's long axis
169
+
170
+
171
+ def flow_field(coords: np.ndarray, t_fill: np.ndarray, knn: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
172
+ """Unit flow direction at each node and the gradient magnitude.
173
+
174
+ Flow runs from low to high fill time, so the direction is the (normalised)
175
+ least-squares gradient of ``t_fill`` over each node's neighbourhood. The
176
+ magnitude is ~1/front-speed; it collapses toward 0 where the front
177
+ terminates (a flow-time ridge), which is exactly the weld neighbourhood.
178
+ """
179
+ n = len(coords)
180
+ f = np.zeros((n, 3))
181
+ mag = np.zeros(n)
182
+ for i in range(n):
183
+ if not np.isfinite(t_fill[i]):
184
+ continue
185
+ nbr = knn[i]
186
+ ok = np.isfinite(t_fill[nbr])
187
+ if np.count_nonzero(ok) < 3:
188
+ continue
189
+ dX = coords[nbr[ok]] - coords[i]
190
+ dt = t_fill[nbr[ok]] - t_fill[i]
191
+ g, *_ = np.linalg.lstsq(dX, dt, rcond=None)
192
+ m = float(np.linalg.norm(g))
193
+ mag[i] = m
194
+ if m > 1e-9:
195
+ f[i] = g / m
196
+ return f, mag
197
+
198
+
199
+ def detect_welds(
200
+ coords: np.ndarray,
201
+ t_fill: np.ndarray,
202
+ knn: np.ndarray,
203
+ times: np.ndarray,
204
+ *,
205
+ min_angle_deg: float = 120.0,
206
+ conv_min: float = 0.45,
207
+ long_axis: int = 1,
208
+ ) -> tuple[np.ndarray, np.ndarray, list[WeldCluster]]:
209
+ """Find weld nodes (flow fronts colliding) and cluster them into lines.
210
+
211
+ Built on the flow-direction field (gradient of fill time). Two signals
212
+ must agree, which is what makes it robust on a fine mesh where neighbouring
213
+ nodes fill barely a step apart:
214
+
215
+ * **Flow convergence** - the neighbours' flow directions point *into* this
216
+ node from both sides (``conv >= conv_min``). In smooth flow the
217
+ upstream side flows toward the node and the downstream side flows away,
218
+ so convergence averages to ~0; only where two fronts run into each
219
+ other do all sides point inward. (A lone front ending at a free edge
220
+ can also converge, so we also require:)
221
+ * **Opposed incoming fronts** - the two earlier neighbours that are most
222
+ opposed meet at >= ``min_angle_deg`` (a genuine head-on collision).
223
+
224
+ Returns ``(weld_score, weld_angle_deg, clusters)``; ``weld_score`` in 0..1.
225
+ """
226
+ n = len(coords)
227
+ f, mag = flow_field(coords, t_fill, knn)
228
+ cos_thresh = np.cos(np.radians(min_angle_deg))
229
+
230
+ score = np.zeros(n)
231
+ angle = np.zeros(n)
232
+ for i in range(n):
233
+ ti = t_fill[i]
234
+ if not np.isfinite(ti):
235
+ continue
236
+ nbr = knn[i]
237
+ # --- flow convergence: do neighbours' flow vectors point toward i? ---
238
+ u = coords[i] - coords[nbr]
239
+ un = np.linalg.norm(u, axis=1, keepdims=True)
240
+ u = u / np.maximum(un, 1e-9)
241
+ has_dir = mag[nbr] > 1e-9
242
+ if np.count_nonzero(has_dir) < 3:
243
+ continue
244
+ conv = float(np.mean(np.sum(f[nbr][has_dir] * u[has_dir], axis=1)))
245
+ if conv < conv_min:
246
+ continue
247
+ # --- opposed incoming fronts (head-on meeting angle) ---
248
+ tn = t_fill[nbr]
249
+ earlier = np.isfinite(tn) & (tn <= ti)
250
+ if np.count_nonzero(earlier) < 2:
251
+ continue
252
+ dirs = u[earlier] # unit dirs from earlier nbrs into i
253
+ g = dirs @ dirs.T
254
+ np.fill_diagonal(g, 1.0)
255
+ cos_ab = float(np.min(g))
256
+ if cos_ab > cos_thresh:
257
+ continue
258
+ score[i] = min(1.0, conv)
259
+ angle[i] = float(np.degrees(np.arccos(np.clip(cos_ab, -1, 1))))
260
+
261
+ clusters = _cluster_welds(coords, score, angle, t_fill, knn, long_axis, score_min=conv_min)
262
+ return score, angle, clusters
263
+
264
+
265
+ def cluster_member_mask(
266
+ coords: np.ndarray,
267
+ score: np.ndarray,
268
+ knn: np.ndarray,
269
+ *,
270
+ score_min: float = 0.45,
271
+ min_size: int = 4,
272
+ ) -> np.ndarray:
273
+ """Bool mask of weld nodes that belong to a real cluster (>= min_size).
274
+
275
+ Filters out the isolated specks along the midsurface's free rim (a known
276
+ edge artifact of convergence-based detection) so only genuine weld *lines*
277
+ remain - the same nodes that back the reported WeldCluster list.
278
+ """
279
+ weld = np.where(score >= score_min)[0]
280
+ mask = np.zeros(len(coords), dtype=bool)
281
+ if len(weld) == 0:
282
+ return mask
283
+ wset = set(weld.tolist())
284
+ parent = {i: i for i in wset}
285
+
286
+ def find(x: int) -> int:
287
+ while parent[x] != x:
288
+ parent[x] = parent[parent[x]]
289
+ x = parent[x]
290
+ return x
291
+
292
+ for i in weld:
293
+ for j in knn[i]:
294
+ if j in wset:
295
+ parent[find(int(i))] = find(int(j))
296
+ groups: dict[int, list[int]] = {}
297
+ for i in wset:
298
+ groups.setdefault(find(i), []).append(i)
299
+ for members in groups.values():
300
+ if len(members) >= min_size:
301
+ mask[members] = True
302
+ return mask
303
+
304
+
305
+ def _cluster_welds(
306
+ coords: np.ndarray,
307
+ score: np.ndarray,
308
+ angle: np.ndarray,
309
+ t_fill: np.ndarray,
310
+ knn: np.ndarray,
311
+ long_axis: int,
312
+ score_min: float = 0.34,
313
+ ) -> list[WeldCluster]:
314
+ """Union-find weld nodes that are kNN-adjacent into weld lines."""
315
+ weld = np.where(score >= score_min)[0]
316
+ if len(weld) == 0:
317
+ return []
318
+ wset = set(weld.tolist())
319
+ parent = {i: i for i in wset}
320
+
321
+ def find(x: int) -> int:
322
+ while parent[x] != x:
323
+ parent[x] = parent[parent[x]]
324
+ x = parent[x]
325
+ return x
326
+
327
+ def union(x: int, y: int) -> None:
328
+ parent[find(x)] = find(y)
329
+
330
+ for i in weld:
331
+ for j in knn[i]:
332
+ if j in wset:
333
+ union(int(i), int(j))
334
+
335
+ groups: dict[int, list[int]] = {}
336
+ for i in wset:
337
+ groups.setdefault(find(i), []).append(i)
338
+
339
+ lo = coords[:, long_axis].min()
340
+ hi = coords[:, long_axis].max()
341
+ span = max(hi - lo, 1e-9)
342
+
343
+ out: list[WeldCluster] = []
344
+ for members in groups.values():
345
+ if len(members) < 4: # ignore specks
346
+ continue
347
+ m = np.array(members)
348
+ c = coords[m]
349
+ cen = c.mean(0)
350
+ ext = float(np.linalg.norm(c.max(0) - c.min(0)))
351
+ out.append(
352
+ WeldCluster(
353
+ n_nodes=len(members),
354
+ centroid=cen,
355
+ extent_mm=ext,
356
+ mean_angle_deg=float(np.mean(angle[m])),
357
+ fill_time_s=float(np.nanmean(t_fill[m])),
358
+ axis_pos=float((cen[long_axis] - lo) / span),
359
+ )
360
+ )
361
+ out.sort(key=lambda w: w.n_nodes, reverse=True)
362
+ return out
363
+
364
+
365
+ # --------------------------------------------------------------------------
366
+ # fill-evenness metrics
367
+ # --------------------------------------------------------------------------
368
+ def fill_balance(t_fill: np.ndarray, reached: np.ndarray) -> dict:
369
+ """How simultaneously the part finishes filling.
370
+
371
+ A balanced fill finishes everywhere at about the same time; racetracking or
372
+ a stranded pocket shows up as a long tail in t_fill. ``tail_fraction`` is
373
+ the share of total fill time spent filling the last 10% of the part - near
374
+ 0.1 is balanced, large means the end lags badly (imbalance / possible air
375
+ trap at the last-filled spot).
376
+ """
377
+ tf = t_fill[reached & np.isfinite(t_fill)]
378
+ if tf.size == 0:
379
+ return {
380
+ "total_fill_time_s": float("nan"),
381
+ "tail_fraction": float("nan"),
382
+ "t90_s": float("nan"),
383
+ "t100_s": float("nan"),
384
+ }
385
+ t100 = float(tf.max())
386
+ t90 = float(np.percentile(tf, 90))
387
+ tail = (t100 - t90) / t100 if t100 > 0 else 0.0
388
+ return {"total_fill_time_s": t100, "t90_s": t90, "t100_s": t100, "tail_fraction": float(tail)}
389
+
390
+
391
+ def last_filled_region(
392
+ coords: np.ndarray,
393
+ t_fill: np.ndarray,
394
+ reached: np.ndarray,
395
+ long_axis: int = 1,
396
+ frac: float = 0.02,
397
+ ) -> tuple[np.ndarray, float]:
398
+ """Centroid + axis position of the last ``frac`` of the part to fill.
399
+
400
+ This is where the part finishes (and where any unvented air ends up).
401
+ """
402
+ tf = t_fill.copy()
403
+ tf[~(reached & np.isfinite(tf))] = -np.inf
404
+ k = max(1, int(frac * reached.sum()))
405
+ last = np.argpartition(tf, -k)[-k:]
406
+ cen = coords[last].mean(0)
407
+ lo, hi = coords[:, long_axis].min(), coords[:, long_axis].max()
408
+ return cen, float((cen[long_axis] - lo) / max(hi - lo, 1e-9))
409
+
410
+
411
+ # --------------------------------------------------------------------------
412
+ # top-level analysis
413
+ # --------------------------------------------------------------------------
414
+ @dataclass
415
+ class Analysis:
416
+ """Every gating metric derived from one filling result, plus the inputs to read them by."""
417
+
418
+ path: Path
419
+ max_pressure_bar: float
420
+ max_shear_rate_1s: float
421
+ fill_pct: float
422
+ short_shot: bool
423
+ total_fill_time_s: float
424
+ tail_fraction: float
425
+ n_welds: int
426
+ weld_clusters: list[WeldCluster]
427
+ last_fill_axis_pos: float
428
+ last_fill_centroid: np.ndarray
429
+ gate_positions_mm: list[list[float]] = field(default_factory=list)
430
+ long_axis: int = 1
431
+
432
+ def summary(self) -> str:
433
+ """One console line of the headline metrics; the weld list reads ``none`` when empty."""
434
+ wl = (
435
+ " ".join(f"[{c.n_nodes}n @axis{c.axis_pos:.2f} ang{c.mean_angle_deg:.0f}deg]" for c in self.weld_clusters)
436
+ or "none"
437
+ )
438
+ return (
439
+ f"P={self.max_pressure_bar:6.1f}bar fill={self.fill_pct:5.1f}%"
440
+ f"{' SHORT-SHOT' if self.short_shot else ''} "
441
+ f"t_fill={self.total_fill_time_s:.2f}s tail={self.tail_fraction:.2f} "
442
+ f"welds={self.n_welds}: {wl}"
443
+ )
444
+
445
+ def as_dict(self) -> dict:
446
+ """JSON-safe view, with every float rounded to its reporting precision."""
447
+ return {
448
+ "path": str(self.path),
449
+ "max_pressure_bar": round(self.max_pressure_bar, 1),
450
+ "max_shear_rate_1s": round(self.max_shear_rate_1s, 1),
451
+ "fill_pct": round(self.fill_pct, 2),
452
+ "short_shot": self.short_shot,
453
+ "total_fill_time_s": round(self.total_fill_time_s, 3),
454
+ "tail_fraction": round(self.tail_fraction, 3),
455
+ "n_welds": self.n_welds,
456
+ "welds": [
457
+ {
458
+ "n_nodes": c.n_nodes,
459
+ "axis_pos": round(c.axis_pos, 3),
460
+ "centroid_mm": [round(float(x), 1) for x in c.centroid],
461
+ "mean_angle_deg": round(c.mean_angle_deg, 1),
462
+ "extent_mm": round(c.extent_mm, 1),
463
+ "fill_time_s": round(c.fill_time_s, 3),
464
+ }
465
+ for c in self.weld_clusters
466
+ ],
467
+ "last_fill_axis_pos": round(self.last_fill_axis_pos, 3),
468
+ }
469
+
470
+
471
+ def analyze(
472
+ path: str | Path, gate_positions_mm: list[list[float]] | None = None, *, k: int = 10, long_axis: int = 1
473
+ ) -> Analysis:
474
+ """Read one prediction file and derive every gating metric from it.
475
+
476
+ Args:
477
+ path: A ``*_prediction.h5`` written by the cloud AI filling solver.
478
+ gate_positions_mm: Gate coordinates used for the run. Carried through for
479
+ reporting only; they change no metric.
480
+ k: Neighbours per node in the graph weld detection runs on.
481
+ long_axis: Coordinate column treated as the part's long axis.
482
+ """
483
+ path = Path(path)
484
+ with h5py.File(path, "r") as f:
485
+ coords = read_coords(f)
486
+ t_fill, reached, times = fill_time(f)
487
+ pmax = max_pressure_bar(f)
488
+ smax = max_shear_rate(f)
489
+ knn = knn_indices(coords, k=k)
490
+ score, angle, clusters = detect_welds(coords, t_fill, knn, times, long_axis=long_axis)
491
+ bal = fill_balance(t_fill, reached)
492
+ last_cen, last_pos = last_filled_region(coords, t_fill, reached, long_axis=long_axis)
493
+ fill_pct = 100.0 * float(reached.mean())
494
+ return Analysis(
495
+ path=path,
496
+ max_pressure_bar=pmax,
497
+ max_shear_rate_1s=smax,
498
+ fill_pct=fill_pct,
499
+ short_shot=fill_pct < 99.0,
500
+ total_fill_time_s=bal["total_fill_time_s"],
501
+ tail_fraction=bal["tail_fraction"],
502
+ n_welds=len(clusters),
503
+ weld_clusters=clusters,
504
+ last_fill_axis_pos=last_pos,
505
+ last_fill_centroid=last_cen,
506
+ gate_positions_mm=gate_positions_mm or [],
507
+ long_axis=long_axis,
508
+ )
509
+
510
+
511
+ if __name__ == "__main__":
512
+ import sys
513
+
514
+ p = sys.argv[1]
515
+ a = analyze(p)
516
+ print(a.summary())
517
+ import json
518
+
519
+ print(json.dumps(a.as_dict(), indent=2))