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,184 @@
|
|
|
1
|
+
"""G0 geometry inspection. Free, local, and it predicts the answer before any simulation.
|
|
2
|
+
|
|
3
|
+
The previous gating study's playbook makes this step 1 for a reason: bbox, volume, wall
|
|
4
|
+
thickness and flow-length ratio already tell you whether a part is thermally marginal, and
|
|
5
|
+
therefore whether a feasibility sweep will discriminate at all. On a thick-walled part it
|
|
6
|
+
predicts an all-green sweep before you spend a single cloud run.
|
|
7
|
+
|
|
8
|
+
Run: python main.py inspect [--part a.stl b.stl ...]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
import cadmould
|
|
20
|
+
from cadmould import mesh, tools
|
|
21
|
+
from cadmould_geometry import stl
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cavity_count(pts: np.ndarray, faces: np.ndarray) -> int:
|
|
27
|
+
"""Number of separate bodies in the STL — i.e. how many cavities are actually modelled.
|
|
28
|
+
|
|
29
|
+
Reported because a briefing document said "4 of 32 cavities" while the geometry was a single
|
|
30
|
+
body, and that claim propagated into every caveat before anyone checked it. It costs
|
|
31
|
+
milliseconds, so there is no reason not to state it up front.
|
|
32
|
+
"""
|
|
33
|
+
return stl.body_count(pts, faces)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def inspect(stl_path: Path, *, session_open: bool = False) -> dict:
|
|
37
|
+
pts, faces = stl.read_stl(stl_path)
|
|
38
|
+
dims = pts.max(0) - pts.min(0)
|
|
39
|
+
volume_cm3 = stl.volume_mm3(pts, faces) / 1000.0
|
|
40
|
+
long_axis = int(np.argmax(dims))
|
|
41
|
+
n_bodies = cavity_count(pts, faces)
|
|
42
|
+
|
|
43
|
+
def _mesh_it():
|
|
44
|
+
meshed = (
|
|
45
|
+
mesh.Mesher(mesh.Mesh(pts.ravel(), faces.ravel()))
|
|
46
|
+
.with_surface_triangulation()
|
|
47
|
+
.with_thickness_and_w2w()
|
|
48
|
+
.build()
|
|
49
|
+
)
|
|
50
|
+
gates = [[float(p.x), float(p.y), float(p.z)] for p in tools.suggest_gates(meshed).points]
|
|
51
|
+
return meshed, gates
|
|
52
|
+
|
|
53
|
+
if session_open:
|
|
54
|
+
meshed, gates = _mesh_it()
|
|
55
|
+
else:
|
|
56
|
+
with cadmould.Session.user_based():
|
|
57
|
+
meshed, gates = _mesh_it()
|
|
58
|
+
|
|
59
|
+
# Flow length from the suggested gate to the furthest mesh node, along the surface's
|
|
60
|
+
# bounding geometry. A straight-line distance understates a wrapped path but is the right
|
|
61
|
+
# order of magnitude and needs no geodesic solver.
|
|
62
|
+
g = np.array(gates[0]) if gates else pts.mean(0)
|
|
63
|
+
flow_len = float(np.linalg.norm(pts - g, axis=1).max())
|
|
64
|
+
|
|
65
|
+
# Wall thickness: the mesher measures it, but the wheel exposes no getter, so estimate it
|
|
66
|
+
# from the closed-surface identity volume ~= area/2 * thickness for a thin shell.
|
|
67
|
+
area_mm2 = stl.surface_area_mm2(pts, faces)
|
|
68
|
+
open_edges = stl.open_edge_count(pts, faces)
|
|
69
|
+
t_est = 2.0 * volume_cm3 * 1000.0 / area_mm2 if area_mm2 else float("nan")
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"name": stl_path.stem,
|
|
73
|
+
"path": str(stl_path),
|
|
74
|
+
"bodies_in_stl": n_bodies,
|
|
75
|
+
"nodes": meshed.node_count(),
|
|
76
|
+
"elements": meshed.element_count(),
|
|
77
|
+
"volume_cm3": volume_cm3,
|
|
78
|
+
"area_mm2": area_mm2,
|
|
79
|
+
"bbox_mm": [round(float(d), 1) for d in dims],
|
|
80
|
+
"long_axis": "xyz"[long_axis],
|
|
81
|
+
"long_mm": float(dims[long_axis]),
|
|
82
|
+
"wall_est_mm": t_est,
|
|
83
|
+
"flow_length_mm": flow_len,
|
|
84
|
+
"L_over_t": flow_len / t_est if t_est else float("nan"),
|
|
85
|
+
"watertight": open_edges == 0,
|
|
86
|
+
"open_edges": open_edges,
|
|
87
|
+
"suggested_gate_mm": gates[0] if gates else None,
|
|
88
|
+
"n_suggested_gates": len(gates),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def verdict(d: dict) -> list[str]:
|
|
93
|
+
"""What the geometry alone says about whether a feasibility sweep will discriminate."""
|
|
94
|
+
out = []
|
|
95
|
+
lt = d["L_over_t"]
|
|
96
|
+
# PP fills to roughly L/t ~ 200-250 before pressure becomes the limit; well under that and
|
|
97
|
+
# a single gate is comfortable, which also means freeze-off is unlikely to bite.
|
|
98
|
+
if np.isfinite(lt):
|
|
99
|
+
if lt < 80:
|
|
100
|
+
out.append(
|
|
101
|
+
f"L/t = {lt:.0f}: very easy to fill (PP tolerates ~200+). A fill-phase "
|
|
102
|
+
"feasibility sweep will likely find the whole process window feasible."
|
|
103
|
+
)
|
|
104
|
+
elif lt < 150:
|
|
105
|
+
out.append(f"L/t = {lt:.0f}: moderate. Some of the process window should be marginal.")
|
|
106
|
+
else:
|
|
107
|
+
out.append(f"L/t = {lt:.0f}: demanding for PP. Expect a real infeasible region.")
|
|
108
|
+
if d["wall_est_mm"] < 1.0:
|
|
109
|
+
out.append(
|
|
110
|
+
f"mean wall ~{d['wall_est_mm']:.2f} mm: thin. Thin walls freeze fast, so "
|
|
111
|
+
"freeze-off is a live constraint and the no-flow gate should bite."
|
|
112
|
+
)
|
|
113
|
+
elif d["wall_est_mm"] > 2.5:
|
|
114
|
+
out.append(
|
|
115
|
+
f"mean wall ~{d['wall_est_mm']:.2f} mm: thick. Slow to freeze; the fill phase "
|
|
116
|
+
"will be forgiving and packing/cooling will dominate instead."
|
|
117
|
+
)
|
|
118
|
+
if not d["watertight"]:
|
|
119
|
+
out.append(
|
|
120
|
+
f"NOT watertight ({d['open_edges']} open edges) — the volume and wall estimate "
|
|
121
|
+
"are unreliable; check the STL before trusting derived flow rates."
|
|
122
|
+
)
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
DEFAULT_PART = ROOT / "sample" / "simple_plate.stl"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main() -> int:
|
|
130
|
+
ap = argparse.ArgumentParser(description="G0 geometry inspection")
|
|
131
|
+
# Named, like every other template's geometry. It takes more than one because
|
|
132
|
+
# comparing candidate parts is the point of inspecting before a campaign; a single
|
|
133
|
+
# --part behaves exactly as it does elsewhere in the kit.
|
|
134
|
+
ap.add_argument(
|
|
135
|
+
"--part",
|
|
136
|
+
nargs="+",
|
|
137
|
+
type=Path,
|
|
138
|
+
default=[DEFAULT_PART],
|
|
139
|
+
help=f"STL geometry to inspect; pass several to compare them (default: {DEFAULT_PART.name} under sample/)",
|
|
140
|
+
)
|
|
141
|
+
args = ap.parse_args()
|
|
142
|
+
|
|
143
|
+
missing = [p for p in args.part if not p.is_file()]
|
|
144
|
+
if missing:
|
|
145
|
+
for p in missing:
|
|
146
|
+
print(f"geometry not found: {p}", file=sys.stderr)
|
|
147
|
+
print("Pass your own STL with --part, or put one back at sample/simple_plate.stl.", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
|
|
150
|
+
rows = []
|
|
151
|
+
with cadmould.Session.user_based():
|
|
152
|
+
for s in args.part:
|
|
153
|
+
rows.append(inspect(Path(s), session_open=True))
|
|
154
|
+
|
|
155
|
+
for d in rows:
|
|
156
|
+
print(f"\n=== {d['name']} ===")
|
|
157
|
+
print(
|
|
158
|
+
f" bodies in STL {d['bodies_in_stl']}"
|
|
159
|
+
+ (" <- cavities actually modelled; state this, do not inherit it" if d["bodies_in_stl"] >= 1 else "")
|
|
160
|
+
)
|
|
161
|
+
print(f" mesh {d['nodes']} nodes / {d['elements']} elements")
|
|
162
|
+
print(f" bbox {d['bbox_mm']} mm (long axis {d['long_axis']}, {d['long_mm']:.0f} mm)")
|
|
163
|
+
print(f" volume {d['volume_cm3']:.2f} cm3 surface {d['area_mm2']:.0f} mm2")
|
|
164
|
+
print(f" mean wall (est) {d['wall_est_mm']:.2f} mm")
|
|
165
|
+
print(f" flow length {d['flow_length_mm']:.0f} mm from suggested gate")
|
|
166
|
+
print(f" L/t {d['L_over_t']:.0f}")
|
|
167
|
+
print(f" watertight {d['watertight']}" + ("" if d["watertight"] else f" ({d['open_edges']} open edges)"))
|
|
168
|
+
print(f" suggested gate {np.round(d['suggested_gate_mm'], 2).tolist()} ({d['n_suggested_gates']} gate(s))")
|
|
169
|
+
for line in verdict(d):
|
|
170
|
+
print(f" -> {line}")
|
|
171
|
+
|
|
172
|
+
if len(rows) > 1:
|
|
173
|
+
print("\n=== comparison ===")
|
|
174
|
+
print(f" {'part':32s} {'vol cm3':>9s} {'long mm':>8s} {'wall mm':>8s} {'L/t':>6s}")
|
|
175
|
+
for d in rows:
|
|
176
|
+
print(
|
|
177
|
+
f" {d['name'][:32]:32s} {d['volume_cm3']:9.2f} {d['long_mm']:8.0f} "
|
|
178
|
+
f"{d['wall_est_mm']:8.2f} {d['L_over_t']:6.0f}"
|
|
179
|
+
)
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""KPI extraction from one AI-solver filling result.
|
|
2
|
+
|
|
3
|
+
Implements exactly the KPI set in the brief, and nothing else. Two design rules run
|
|
4
|
+
through all of it:
|
|
5
|
+
|
|
6
|
+
**Dimensionless by construction where possible.** Guardrail 1 forbids absolute thresholds,
|
|
7
|
+
because the AI solver under-predicts absolute cavity pressure on multi-gate parts (~0.3x on
|
|
8
|
+
8 gates) and we model 1 of 32 cavities with no runner. Rather than police that downstream, the
|
|
9
|
+
are formed as *ratios* — normalised by a temperature difference the process itself defines
|
|
10
|
+
(melt minus wall, or melt minus no-flow). A ratio has no scale to be wrong about, so it
|
|
11
|
+
transfers across cavity counts in a way a temperature in Kelvin does not. `p80` is the one
|
|
12
|
+
KPI that stays in physical units, and it is used for **ordering only**.
|
|
13
|
+
|
|
14
|
+
**The feasibility gate is freeze-off, not fill completeness.** A short shot is a
|
|
15
|
+
*consequence* of the melt reaching its no-flow temperature before the cavity fills. The
|
|
16
|
+
no-flow margin is therefore the causal quantity, it is continuous (so boundary refinement has
|
|
17
|
+
a gradient to follow), and it is immune to the known incomplete-fill bug in the AI model.
|
|
18
|
+
Fill completeness is still measured on every run and reported — as a diagnostic.
|
|
19
|
+
|
|
20
|
+
Weld detection is reused from the sibling gate-study template (`metrics`) rather than
|
|
21
|
+
reimplemented: three plausible detectors were tried there and failed, and the working one
|
|
22
|
+
(convergence of the flow-direction field) is calibrated and documented.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import sys
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import h5py
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
# Reused from templates/gate-study rather than vendored, so there is one source of truth for
|
|
35
|
+
# the weld detector and its calibration. It is imported directly from the shared package.
|
|
36
|
+
from cadmould_scoring import metrics as M
|
|
37
|
+
|
|
38
|
+
from .units import UnitError, pressure_field_to_bar
|
|
39
|
+
|
|
40
|
+
DOF_THRESH = 0.5 # cloud results: sharp 0->1 front; arrival = first crossing
|
|
41
|
+
|
|
42
|
+
# Bump whenever a KPI definition changes. It is part of every run id, so a redefinition
|
|
43
|
+
# invalidates existing records instead of silently reusing scores computed under the old
|
|
44
|
+
# definition — a record is (inputs -> KPIs), so changing the KPIs changes what it means.
|
|
45
|
+
# v1: initial set
|
|
46
|
+
# v2: added the end-of-fill channel-closure reading of the no-flow margin (F16)
|
|
47
|
+
KPI_VERSION = "v2"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# low-level readers
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
def _steps(f: h5py.File) -> list[str]:
|
|
54
|
+
return sorted(k for k in f if k.startswith("TimeStep_"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _phi_axis(f: h5py.File, steps: list[str], reached_frac: np.ndarray) -> tuple[np.ndarray, str]:
|
|
58
|
+
"""Injected-volume fraction phi per time step, and where it came from.
|
|
59
|
+
|
|
60
|
+
**Do not trust the solver's ``filling_level`` attribute.** On cloud AI results it is
|
|
61
|
+
written as a constant ``1.0`` at every step, which silently collapses the whole phi axis
|
|
62
|
+
onto a single point: `argmin(|phi - 0.8|)` then returns step 0, so `p80` becomes the
|
|
63
|
+
pressure before anything has filled and every switchover read-out is identical. Verified
|
|
64
|
+
on a 50-step production result. It is checked for variation here and rejected if flat.
|
|
65
|
+
|
|
66
|
+
The correct axis for a **constant-Q** run is ``t / t_end``: injected volume is Q*t, so
|
|
67
|
+
phi = Q*t/V = t/t_fill exactly. This is the same reparameterisation the filling-profile
|
|
68
|
+
POC is built on, and it inherits the same incompressible-melt caveat — injected volume is
|
|
69
|
+
equated with filled cavity volume, which is sub-percent during fill and worst as phi -> 1.
|
|
70
|
+
The AI solver only ever runs constant-Q, so the assumption always holds here.
|
|
71
|
+
|
|
72
|
+
Node-reached fraction is the last resort: it is a *geometric* proxy, not a volume
|
|
73
|
+
fraction, and on a part with uneven wall thickness the two differ materially.
|
|
74
|
+
"""
|
|
75
|
+
lv = [f[s].attrs.get("filling_level") for s in steps]
|
|
76
|
+
if all(v is not None for v in lv):
|
|
77
|
+
arr = np.asarray([float(v) for v in lv], dtype=np.float64)
|
|
78
|
+
if float(np.ptp(arr)) > 0.01:
|
|
79
|
+
return arr, "filling_level"
|
|
80
|
+
|
|
81
|
+
times = np.asarray([float(f[s].attrs["Time"]) for s in steps], dtype=np.float64)
|
|
82
|
+
t_end = float(times[-1])
|
|
83
|
+
if t_end > 0:
|
|
84
|
+
return times / t_end, "time_over_t_end_constant_Q"
|
|
85
|
+
return reached_frac, "reached_fraction_proxy"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _core_temp_series(f: h5py.File, steps: list[str]) -> np.ndarray:
|
|
89
|
+
"""(T, N) melt temperature at the wall's mid-plane (core layer), in K.
|
|
90
|
+
|
|
91
|
+
The core is the right layer for both thermal KPIs. Skin layers are quenched by the
|
|
92
|
+
mould by design, so they say more about the wall temperature than about the melt; the
|
|
93
|
+
core is what is still flowing, and it is the core freezing that stops the fill.
|
|
94
|
+
"""
|
|
95
|
+
ds0 = f[steps[0]]["Temperature"]
|
|
96
|
+
core = ds0.shape[1] // 2
|
|
97
|
+
return np.stack([np.asarray(f[s]["Temperature"][:, core], dtype=np.float32) for s in steps])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fill_state(f: h5py.File) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
101
|
+
"""``(t_fill, arrival_step, reached, reached_frac_per_step)``.
|
|
102
|
+
|
|
103
|
+
Arrival is the first step at which ``Degree_of_filling`` crosses 0.5. Keying off
|
|
104
|
+
``isfinite`` instead — as the old hand-authored files invite — makes every node appear
|
|
105
|
+
to arrive at t=0 and silently destroys every time-resolved KPI.
|
|
106
|
+
"""
|
|
107
|
+
steps = _steps(f)
|
|
108
|
+
n = f[steps[0]]["Degree_of_filling"].shape[0]
|
|
109
|
+
t_fill = np.full(n, np.inf, np.float64)
|
|
110
|
+
arr_step = np.full(n, -1, np.int32)
|
|
111
|
+
reached = np.zeros(n, bool)
|
|
112
|
+
frac = np.zeros(len(steps), np.float64)
|
|
113
|
+
for i, s in enumerate(steps):
|
|
114
|
+
dof = np.asarray(f[s]["Degree_of_filling"][()], dtype=np.float32)
|
|
115
|
+
now = np.isfinite(dof) & (dof > DOF_THRESH)
|
|
116
|
+
newly = now & ~reached
|
|
117
|
+
t_fill[newly] = float(f[s].attrs["Time"])
|
|
118
|
+
arr_step[newly] = i
|
|
119
|
+
reached |= now
|
|
120
|
+
frac[i] = reached.mean()
|
|
121
|
+
return t_fill, arr_step, reached, frac
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _max_pressure_bar_at(f: h5py.File, step_name: str) -> float:
|
|
125
|
+
ds = f[step_name]["Pressure"]
|
|
126
|
+
unit = ds.attrs.get("unit")
|
|
127
|
+
if unit is None:
|
|
128
|
+
raise UnitError(f"{step_name}/Pressure has no 'unit' attribute; refusing to infer Pa vs bar.")
|
|
129
|
+
pr = pressure_field_to_bar(np.asarray(ds[()], dtype=np.float64), unit)
|
|
130
|
+
fin = pr[np.isfinite(pr)]
|
|
131
|
+
return float(fin.max()) if fin.size else float("nan")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# result
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
@dataclass
|
|
138
|
+
class Kpis:
|
|
139
|
+
# --- primary continuous KPI: ORDERING ONLY, never a threshold in bar ---
|
|
140
|
+
p80_bar: float
|
|
141
|
+
p80_phi_actual: float # the phi actually available nearest 0.80
|
|
142
|
+
|
|
143
|
+
# --- feasibility: two readings of "distance to no-flow" ---
|
|
144
|
+
# at ARRIVAL: is the melt still molten when it gets there? Weak, because fountain flow keeps
|
|
145
|
+
# the front hot (F15) — retained for continuity and because it is the literal reading.
|
|
146
|
+
no_flow_margin: float # (T_core,min_at_front - T_noflow)/(T_melt - T_noflow)
|
|
147
|
+
no_flow_margin_K: float # same in K, for the engineer's intuition
|
|
148
|
+
# at END OF FILL: did the flow channel stay open? This is the reading that discriminates
|
|
149
|
+
# (F16). A robust 5th percentile, not the minimum: the minimum is dominated by the mesher's
|
|
150
|
+
# degenerate sub-0.05 mm slivers and swings 90 K on a handful of nodes.
|
|
151
|
+
no_flow_margin_end_K: float # p5(T_core at end of fill) - T_noflow
|
|
152
|
+
frozen_fraction_end: float # share of filled nodes with core below no-flow (DIAGNOSTIC)
|
|
153
|
+
|
|
154
|
+
# --- fill-phase quality proxy ---
|
|
155
|
+
weld_front_temp_K: float
|
|
156
|
+
weld_front_temp_ratio: float # (T_weld - T_wall)/(T_melt - T_wall)
|
|
157
|
+
n_weld_clusters: int
|
|
158
|
+
|
|
159
|
+
# --- warpage RISK flag (dimensionless) ---
|
|
160
|
+
hotspot: float # in-plane core-temperature spread / (T_melt - T_wall)
|
|
161
|
+
|
|
162
|
+
# --- diagnostics, NOT gates ---
|
|
163
|
+
fill_residual: float # unfilled node fraction at end of fill (known bug)
|
|
164
|
+
race_anomaly_tail: float # ANOMALY DETECTOR ONLY - must never screen
|
|
165
|
+
|
|
166
|
+
# --- switchover read-out (free: state AT switchover, not effect OF switching) ---
|
|
167
|
+
switchover: dict = field(default_factory=dict)
|
|
168
|
+
|
|
169
|
+
# --- provenance / honesty flags ---
|
|
170
|
+
phi_axis_source: str = "filling_level"
|
|
171
|
+
flags: list = field(default_factory=list)
|
|
172
|
+
|
|
173
|
+
def as_dict(self) -> dict:
|
|
174
|
+
d = dict(self.__dict__)
|
|
175
|
+
for k, v in d.items():
|
|
176
|
+
if isinstance(v, float):
|
|
177
|
+
d[k] = None if not np.isfinite(v) else round(v, 6)
|
|
178
|
+
return d
|
|
179
|
+
|
|
180
|
+
def summary(self) -> str:
|
|
181
|
+
return (
|
|
182
|
+
f"p80={self.p80_bar:7.1f}bar(phi{self.p80_phi_actual:.2f}) "
|
|
183
|
+
f"nfEnd={self.no_flow_margin_end_K:+7.1f}K(froz={self.frozen_fraction_end:.3f}) "
|
|
184
|
+
f"noflow={self.no_flow_margin:+.3f}({self.no_flow_margin_K:+.1f}K) "
|
|
185
|
+
f"weldT={self.weld_front_temp_K:6.1f}K(r={self.weld_front_temp_ratio:.3f},"
|
|
186
|
+
f"n={self.n_weld_clusters}) hotspot={self.hotspot:.4f} "
|
|
187
|
+
f"resid={self.fill_residual:.3f}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
def extract(
|
|
193
|
+
path: str | Path,
|
|
194
|
+
*,
|
|
195
|
+
melt_K: float,
|
|
196
|
+
wall_K: float,
|
|
197
|
+
no_flow_K: float | None = None,
|
|
198
|
+
switchover_phi: tuple[float, ...] = (0.90, 0.95, 0.98, 0.99),
|
|
199
|
+
long_axis: int = 1,
|
|
200
|
+
knn_k: int = 10,
|
|
201
|
+
) -> Kpis:
|
|
202
|
+
"""Compute the full KPI set from one result file.
|
|
203
|
+
|
|
204
|
+
``melt_K`` / ``wall_K`` are the *requested* process values (the result file omits the
|
|
205
|
+
``/input`` group), and are what the ratio KPIs normalise against — so the
|
|
206
|
+
normalisation reflects the process point we asked for, not one inferred from the field.
|
|
207
|
+
"""
|
|
208
|
+
path = Path(path)
|
|
209
|
+
flags: list[str] = []
|
|
210
|
+
|
|
211
|
+
with h5py.File(path, "r") as f:
|
|
212
|
+
steps = _steps(f)
|
|
213
|
+
if not steps:
|
|
214
|
+
raise ValueError(f"{path.name}: no TimeStep_* groups")
|
|
215
|
+
coords = M.read_coords(f)
|
|
216
|
+
t_fill, arr_step, reached, reached_frac = _fill_state(f)
|
|
217
|
+
phi, phi_src = _phi_axis(f, steps, reached_frac)
|
|
218
|
+
if phi_src == "reached_fraction_proxy":
|
|
219
|
+
flags.append("phi_axis_is_geometric_proxy")
|
|
220
|
+
if phi_src == "time_over_t_end_constant_Q":
|
|
221
|
+
flags.append("filling_level_attr_flat_ignored")
|
|
222
|
+
core_T = _core_temp_series(f, steps) # (T, N) K
|
|
223
|
+
|
|
224
|
+
# --- p80: primary KPI, at the step nearest 80 % injected volume -------
|
|
225
|
+
i80 = int(np.argmin(np.abs(phi - 0.80)))
|
|
226
|
+
p80 = _max_pressure_bar_at(f, steps[i80])
|
|
227
|
+
p80_phi = float(phi[i80])
|
|
228
|
+
if abs(p80_phi - 0.80) > 0.05:
|
|
229
|
+
flags.append(f"p80_phi_off_by_{abs(p80_phi - 0.80):.3f}")
|
|
230
|
+
|
|
231
|
+
# --- switchover read-out ---------------------------------------------
|
|
232
|
+
switch: dict = {}
|
|
233
|
+
for ps in switchover_phi:
|
|
234
|
+
i = int(np.argmin(np.abs(phi - ps)))
|
|
235
|
+
filled_now = (arr_step >= 0) & (arr_step <= i)
|
|
236
|
+
# Two DIFFERENT temperatures, kept apart on purpose. Reporting only the minimum
|
|
237
|
+
# over all filled nodes is actively misleading: it is dominated by the
|
|
238
|
+
# earliest-filled material near the gate, which has been cooling the longest and
|
|
239
|
+
# is legitimately below the no-flow temperature while the part is still filling
|
|
240
|
+
# perfectly well. Placed next to the no-flow margin it reads as infeasibility
|
|
241
|
+
# when nothing is wrong.
|
|
242
|
+
# front_* = melt that has just arrived -> can the rest of the cavity still be
|
|
243
|
+
# filled? This is the freeze-off-relevant number at switchover.
|
|
244
|
+
# coldest_*= coldest point anywhere already filled -> gate/skin freeze proxy.
|
|
245
|
+
front = arr_step == i
|
|
246
|
+
switch[f"phi_{ps:.2f}"] = {
|
|
247
|
+
"phi_actual": float(phi[i]),
|
|
248
|
+
"p_max_bar": _max_pressure_bar_at(f, steps[i]),
|
|
249
|
+
"unfilled_fraction": float(1.0 - filled_now.mean()),
|
|
250
|
+
"front_core_T_min_K": (float(np.nanmin(core_T[i][front])) if front.any() else None),
|
|
251
|
+
"coldest_filled_core_T_K": (float(np.nanmin(core_T[i][filled_now])) if filled_now.any() else None),
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# --- weld detection (reused, calibrated prior art) --------------------
|
|
255
|
+
knn = M.knn_indices(coords, k=knn_k)
|
|
256
|
+
score, _angle, clusters = M.detect_welds(
|
|
257
|
+
coords, t_fill, knn, np.asarray([float(f[s].attrs["Time"]) for s in steps]), long_axis=long_axis
|
|
258
|
+
)
|
|
259
|
+
weld_mask = M.cluster_member_mask(coords, score, knn)
|
|
260
|
+
|
|
261
|
+
# --- weld-front temperature: core temp at the moment the fronts met ------
|
|
262
|
+
if weld_mask.any():
|
|
263
|
+
idx = np.where(weld_mask & (arr_step >= 0))[0]
|
|
264
|
+
weld_T = float(np.nanmean(core_T[arr_step[idx], idx])) if idx.size else float("nan")
|
|
265
|
+
else:
|
|
266
|
+
weld_T = float("nan")
|
|
267
|
+
flags.append("no_weld_clusters_detected")
|
|
268
|
+
|
|
269
|
+
dT = melt_K - wall_K
|
|
270
|
+
if dT <= 0:
|
|
271
|
+
raise UnitError(f"melt ({melt_K} K) must exceed wall ({wall_K} K)")
|
|
272
|
+
weld_ratio = (weld_T - wall_K) / dT if np.isfinite(weld_T) else float("nan")
|
|
273
|
+
|
|
274
|
+
# --- no-flow margin: coldest core melt on the front, when it arrived -----
|
|
275
|
+
filled = arr_step >= 0
|
|
276
|
+
T_at_arrival = core_T[arr_step[filled], np.where(filled)[0]]
|
|
277
|
+
T_at_arrival = T_at_arrival[np.isfinite(T_at_arrival)]
|
|
278
|
+
|
|
279
|
+
# Sanity check on the temperature field's scale. The core cannot sit far above the
|
|
280
|
+
# melt the solver was asked for: shear heating adds some, but tens of K in the *mean*
|
|
281
|
+
# is not shear heating, it is a scale or offset problem — and a temperature field that
|
|
282
|
+
# is wrong by a constant makes the weld-front and no-flow KPIs quietly meaningless
|
|
283
|
+
# while still producing plausible-looking numbers. Flagged loudly, not swallowed.
|
|
284
|
+
if T_at_arrival.size:
|
|
285
|
+
excess = float(np.median(T_at_arrival)) - melt_K
|
|
286
|
+
if excess > 30.0:
|
|
287
|
+
flags.append(f"core_T_median_{excess:+.0f}K_vs_requested_melt__VERIFY_FIELD_SCALE")
|
|
288
|
+
|
|
289
|
+
# End-of-fill core temperature over filled nodes: the channel-closure reading.
|
|
290
|
+
end_core = core_T[-1][filled]
|
|
291
|
+
end_core = end_core[np.isfinite(end_core)]
|
|
292
|
+
|
|
293
|
+
if no_flow_K is None:
|
|
294
|
+
nf_ratio = nf_K = nf_end_K = frozen_frac = float("nan")
|
|
295
|
+
flags.append("no_flow_temp_unavailable")
|
|
296
|
+
else:
|
|
297
|
+
T_min = float(np.nanmin(T_at_arrival)) if T_at_arrival.size else float("nan")
|
|
298
|
+
nf_K = T_min - no_flow_K
|
|
299
|
+
span = melt_K - no_flow_K
|
|
300
|
+
nf_ratio = nf_K / span if span > 0 else float("nan")
|
|
301
|
+
if end_core.size:
|
|
302
|
+
nf_end_K = float(np.percentile(end_core, 5)) - no_flow_K
|
|
303
|
+
frozen_frac = float((end_core < no_flow_K).mean())
|
|
304
|
+
else:
|
|
305
|
+
nf_end_K = frozen_frac = float("nan")
|
|
306
|
+
|
|
307
|
+
# --- hotspot / thermal heterogeneity at end of fill ----------------------
|
|
308
|
+
# In-plane spread of core temperature at the start of packing is what drives
|
|
309
|
+
# DIFFERENTIAL shrinkage, and differential shrinkage is what warps a part. Uniformly
|
|
310
|
+
# hot is not a warpage risk; unevenly hot is. Normalised by (melt - wall) so it is a
|
|
311
|
+
# ratio: no absolute scale to be wrong about, and it transfers across cavity counts.
|
|
312
|
+
# This is the one KPI the brief left open. Robust percentile spread (p95-p05) rather
|
|
313
|
+
# than max-min, because the field is float16 and a single quantisation outlier at the
|
|
314
|
+
# gate singularity would otherwise set the metric.
|
|
315
|
+
endT = core_T[-1][filled]
|
|
316
|
+
endT = endT[np.isfinite(endT)]
|
|
317
|
+
hotspot = float(np.percentile(endT, 95) - np.percentile(endT, 5)) / dT if endT.size else float("nan")
|
|
318
|
+
|
|
319
|
+
# --- diagnostics ---------------------------------------------------------
|
|
320
|
+
residual = float(1.0 - reached.mean())
|
|
321
|
+
bal = M.fill_balance(t_fill, reached)
|
|
322
|
+
race = float(bal["tail_fraction"])
|
|
323
|
+
|
|
324
|
+
return Kpis(
|
|
325
|
+
p80_bar=p80,
|
|
326
|
+
p80_phi_actual=p80_phi,
|
|
327
|
+
no_flow_margin=nf_ratio,
|
|
328
|
+
no_flow_margin_K=nf_K,
|
|
329
|
+
no_flow_margin_end_K=nf_end_K,
|
|
330
|
+
frozen_fraction_end=frozen_frac,
|
|
331
|
+
weld_front_temp_K=weld_T,
|
|
332
|
+
weld_front_temp_ratio=weld_ratio,
|
|
333
|
+
n_weld_clusters=len(clusters),
|
|
334
|
+
hotspot=hotspot,
|
|
335
|
+
fill_residual=residual,
|
|
336
|
+
race_anomaly_tail=race,
|
|
337
|
+
switchover=switch,
|
|
338
|
+
phi_axis_source=phi_src,
|
|
339
|
+
flags=flags,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
import json
|
|
345
|
+
|
|
346
|
+
# A diagnostic, not a campaign stage: score one result file from the shell.
|
|
347
|
+
if len(sys.argv) < 2:
|
|
348
|
+
raise SystemExit("usage: python -m process_window.kpis <result.h5> [melt_K] [wall_K] [no_flow_K]")
|
|
349
|
+
p = sys.argv[1]
|
|
350
|
+
melt = float(sys.argv[2]) if len(sys.argv) > 2 else 513.15
|
|
351
|
+
wall = float(sys.argv[3]) if len(sys.argv) > 3 else 313.15
|
|
352
|
+
nf = float(sys.argv[4]) if len(sys.argv) > 4 else None
|
|
353
|
+
k = extract(p, melt_K=melt, wall_K=wall, no_flow_K=nf)
|
|
354
|
+
print(k.summary())
|
|
355
|
+
print(json.dumps(k.as_dict(), indent=2, default=str))
|