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.
- simcon_toolkit/__init__.py +40 -0
- simcon_toolkit/__main__.py +7 -0
- simcon_toolkit/_kit/LICENSE +202 -0
- simcon_toolkit/_kit/NOTICE +37 -0
- simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
- simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
- simcon_toolkit/_kit/packages/.ruff.toml +10 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
- simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
- simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
- simcon_toolkit/_kit/pyproject.toml +232 -0
- simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
- simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
- simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
- simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
- simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
- simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
- simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
- simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
- simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
- simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
- simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
- simcon_toolkit/_kit/templates/process-window/README.md +176 -0
- simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
- simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
- simcon_toolkit/_kit/templates/process-window/main.py +82 -0
- simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
- simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
- simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
- simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
- simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
- simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
- simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
- simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
- simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
- simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
- simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
- simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
- simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
- simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
- simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
- simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
- simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
- simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
- simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
- simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
- simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
- simcon_toolkit/_kit/templates/quoting/README.md +158 -0
- simcon_toolkit/_kit/templates/quoting/main.py +484 -0
- simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
- simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
- simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
- simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
- simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
- simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
- simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
- simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
- simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
- simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
- simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
- simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
- simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
- simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
- simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
- simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
- simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
- simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
- simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
- simcon_toolkit/_kit/templates.toml +83 -0
- simcon_toolkit/choices.py +11 -0
- simcon_toolkit/cli.py +381 -0
- simcon_toolkit/generate.py +590 -0
- simcon_toolkit/instructions.py +152 -0
- simcon_toolkit/manifest.py +86 -0
- simcon_toolkit/project.py +356 -0
- simcon_toolkit/wizard.py +160 -0
- simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
- simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
- simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
- 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))
|