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,367 @@
|
|
|
1
|
+
"""Which point is cheapest to produce at? Cycle time against robustness.
|
|
2
|
+
|
|
3
|
+
**Read this before quoting any number out of it.** Cost per part for a 32-cavity medical tool is
|
|
4
|
+
dominated by cycle time and by scrap. The fill-phase sweep answers neither directly:
|
|
5
|
+
|
|
6
|
+
* **Cycle time is dominated by COOLING, which the AI solver does not model at all.** Cooling time
|
|
7
|
+
is computed here analytically, from the material card's own thermal data, using the *same*
|
|
8
|
+
slab-conduction form that was cross-checked against the campaign to 8.3 % (F17/F20). That is
|
|
9
|
+
the only reason it is defensible to put a cycle time on the page — and it is still an estimate,
|
|
10
|
+
not a simulation.
|
|
11
|
+
* **Scrap is not modelled either.** Rather than invent a scrap-rate curve, robustness is
|
|
12
|
+
expressed as the freeze-off margin that survives realistic machine drift, and the economic
|
|
13
|
+
answer is stated as "cheapest point that keeps that margin", with the drift assumptions
|
|
14
|
+
exposed as inputs.
|
|
15
|
+
|
|
16
|
+
The interesting result is structural: the dominant cycle-time lever is **mould temperature**, which
|
|
17
|
+
the fill-phase screening ranked last of the three variables. A fill-phase DOE alone cannot find the
|
|
18
|
+
economic optimum, and saying so is more useful than pretending otherwise.
|
|
19
|
+
|
|
20
|
+
Run: python main.py economics configs/simple-plate.yaml
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
from . import surrogate as S
|
|
33
|
+
from .run_screening import (
|
|
34
|
+
GATE_KPI,
|
|
35
|
+
NO_FLOW_END_MIN_K,
|
|
36
|
+
VARS,
|
|
37
|
+
load_records,
|
|
38
|
+
)
|
|
39
|
+
from .setup_campaign import (
|
|
40
|
+
load_config,
|
|
41
|
+
resolve_config,
|
|
42
|
+
runs_dir_for,
|
|
43
|
+
stage_dir_for,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Assumptions, all stated so they can be argued with.
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
N_CAVITIES = 32 # the real tool; we simulated 1 (F21)
|
|
52
|
+
HANDLING_S = 2.0 # mould open + eject + close. Not modelled anywhere; a constant, so it
|
|
53
|
+
# shifts every cycle time equally and cannot change the optimum.
|
|
54
|
+
# Realistic machine drift, the basis of the robustness test. A production machine holds melt to
|
|
55
|
+
# roughly +/-2 K and mould water to about +/-1 K; screw metering repeats to well under a percent.
|
|
56
|
+
DRIFT = {"melt_C": 2.0, "wall_C": 1.0, "flow_rel": 0.01}
|
|
57
|
+
|
|
58
|
+
# The recommendation this script argues for. Stated as a constant so the report, the page and the
|
|
59
|
+
# candidate table cannot drift apart from each other.
|
|
60
|
+
RECOMMENDED = {"melt_C": 236.0, "wall_C": 20.0, "flow_cm3_s": 2.40}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cooling_time_s(
|
|
64
|
+
melt_C: float, wall_C: float, *, wall_mm: float, eject_K: float, lam: float, rho: float, cp: float
|
|
65
|
+
) -> float:
|
|
66
|
+
"""Time for the thickest section's core to fall from melt to ejection temperature.
|
|
67
|
+
|
|
68
|
+
One-term slab solution, the same form validated against the campaign's freeze-off boundary:
|
|
69
|
+
theta = (T_eject - T_wall) / (T_melt - T_wall)
|
|
70
|
+
theta = (4/pi) * exp(-(pi/2)^2 * Fo) -> Fo = -ln(theta*pi/4) / (pi/2)^2
|
|
71
|
+
t = Fo * h^2 / alpha
|
|
72
|
+
The THICKEST section governs ejection, not the mean — a part is ejected when its slowest
|
|
73
|
+
region is rigid enough. Using the mean wall would under-state cooling roughly sixfold here.
|
|
74
|
+
"""
|
|
75
|
+
alpha = lam / (rho * cp)
|
|
76
|
+
h = (wall_mm / 1000.0) / 2.0
|
|
77
|
+
theta = (eject_K - (wall_C + 273.15)) / ((melt_C + 273.15) - (wall_C + 273.15))
|
|
78
|
+
if not (0 < theta < 4 / np.pi):
|
|
79
|
+
return float("nan")
|
|
80
|
+
Fo = -np.log(theta * np.pi / 4) / (np.pi / 2) ** 2
|
|
81
|
+
return float(Fo * h * h / alpha)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cycle_time_s(melt_C, wall_C, flow, *, volume_cm3, pack_s, **cool_kw) -> float:
|
|
85
|
+
"""fill + max(pack, cooling) + handling.
|
|
86
|
+
|
|
87
|
+
Packing runs *inside* the cooling phase — the part is cooling while it is being held — so the
|
|
88
|
+
two do not add. Whichever is longer sets the in-mould time.
|
|
89
|
+
"""
|
|
90
|
+
fill = volume_cm3 / flow
|
|
91
|
+
cool = cooling_time_s(melt_C, wall_C, **cool_kw)
|
|
92
|
+
return fill + max(pack_s, cool) + HANDLING_S
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
ap = argparse.ArgumentParser(description="Cycle time vs robustness")
|
|
97
|
+
ap.add_argument(
|
|
98
|
+
"config",
|
|
99
|
+
nargs="?",
|
|
100
|
+
help="campaign config; defaults to configs/simple-plate.yaml while that is unambiguous",
|
|
101
|
+
)
|
|
102
|
+
ap.add_argument("--grid", type=int, default=33)
|
|
103
|
+
args = ap.parse_args()
|
|
104
|
+
|
|
105
|
+
cfg = load_config(resolve_config(args.config))
|
|
106
|
+
runs_dir, stage_dir = runs_dir_for(cfg), stage_dir_for(cfg)
|
|
107
|
+
g3 = json.loads((stage_dir / "g3_refinement.json").read_text())
|
|
108
|
+
floors = json.loads((stage_dir / "noise_floor.json").read_text())["per_kpi"]
|
|
109
|
+
con = json.loads((stage_dir / "g4_confirmation.json").read_text())
|
|
110
|
+
state = json.loads((runs_dir / "campaign_state.json").read_text())
|
|
111
|
+
|
|
112
|
+
# Material thermal data — from the catalogue, the same numbers used in F17/F20.
|
|
113
|
+
MAT = {"lam": 0.19, "rho": 750.0, "cp": 2500.0, "eject_K": 368.15}
|
|
114
|
+
WALL_MAX_MM = 1.788 # thickest measured section; the mesher reports it
|
|
115
|
+
volume_cm3 = state["volume_cm3"]
|
|
116
|
+
pack_s = con["pack_nominal"]["total_s"]
|
|
117
|
+
|
|
118
|
+
recs = load_records(runs_dir, "G1_wide_sweep") + load_records(runs_dir, "G3_refinement")
|
|
119
|
+
X = np.array([[r["spec"][v] for v in VARS] for r in recs], float)
|
|
120
|
+
y = np.array([r["kpis"][GATE_KPI] for r in recs], float)
|
|
121
|
+
yp = np.array([r["kpis"]["p80_bar"] for r in recs], float)
|
|
122
|
+
box = np.array([[X[:, i].min(), X[:, i].max()] for i in range(3)])
|
|
123
|
+
surr = S.fit(X, y, box, noise_floor=floors[GATE_KPI]["floor"])
|
|
124
|
+
surr_p = S.fit(X, yp, box, noise_floor=floors["p80_bar"]["floor"])
|
|
125
|
+
floor = floors[GATE_KPI]["floor"]
|
|
126
|
+
print(
|
|
127
|
+
f" surrogate on {len(recs)} runs, LOO {100 * surr.loo_error()['nrmse_range']:.2f} %, "
|
|
128
|
+
f"gate {NO_FLOW_END_MIN_K} K, floor {floor:.3g} K"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
rbox = {v: g3["recommended_box"][v] for v in VARS}
|
|
132
|
+
print(" searching the recommended box: " + ", ".join(f"{v} {rbox[v][0]:.3g}..{rbox[v][1]:.3g}" for v in VARS))
|
|
133
|
+
|
|
134
|
+
# ---- grid over the recommended box ---------------------------------
|
|
135
|
+
n = args.grid
|
|
136
|
+
axes = [np.linspace(rbox[v][0], rbox[v][1], n) for v in VARS]
|
|
137
|
+
M, W, Q = np.meshgrid(*axes, indexing="ij")
|
|
138
|
+
pts = np.stack([M.ravel(), W.ravel(), Q.ravel()], 1)
|
|
139
|
+
|
|
140
|
+
def predict(P):
|
|
141
|
+
u = (P - box[:, 0]) / (box[:, 1] - box[:, 0])
|
|
142
|
+
return surr.predict_unit(u)
|
|
143
|
+
|
|
144
|
+
nominal = predict(pts)
|
|
145
|
+
|
|
146
|
+
# ---- robustness: the margin that survives drift --------------------
|
|
147
|
+
# Worst corner of the drift box around each point. 8 corners in 3 variables; the margin is
|
|
148
|
+
# monotone enough in each variable that the worst case is a corner, and checking all 8 costs
|
|
149
|
+
# nothing.
|
|
150
|
+
worst = np.full(len(pts), np.inf)
|
|
151
|
+
for dm in (-DRIFT["melt_C"], DRIFT["melt_C"]):
|
|
152
|
+
for dw in (-DRIFT["wall_C"], DRIFT["wall_C"]):
|
|
153
|
+
for dq in (-DRIFT["flow_rel"], DRIFT["flow_rel"]):
|
|
154
|
+
P = pts.copy()
|
|
155
|
+
P[:, 0] += dm
|
|
156
|
+
P[:, 1] += dw
|
|
157
|
+
P[:, 2] *= 1 + dq
|
|
158
|
+
P = np.clip(P, box[:, 0], box[:, 1])
|
|
159
|
+
worst = np.minimum(worst, predict(P))
|
|
160
|
+
|
|
161
|
+
cyc = np.array(
|
|
162
|
+
[cycle_time_s(m, w, q, volume_cm3=volume_cm3, pack_s=pack_s, wall_mm=WALL_MAX_MM, **MAT) for m, w, q in pts]
|
|
163
|
+
)
|
|
164
|
+
p80 = surr_p.predict_unit((pts - box[:, 0]) / (box[:, 1] - box[:, 0]))
|
|
165
|
+
|
|
166
|
+
ok = worst >= NO_FLOW_END_MIN_K
|
|
167
|
+
print(
|
|
168
|
+
f"\n {ok.sum()} of {len(pts)} grid points keep the gate under drift "
|
|
169
|
+
f"(melt +/-{DRIFT['melt_C']} K, wall +/-{DRIFT['wall_C']} K, "
|
|
170
|
+
f"flow +/-{DRIFT['flow_rel']:.0%})"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# ---- where does cycle time actually come from? ---------------------
|
|
174
|
+
print("\n cycle-time sensitivity, one variable at a time from the box centre:")
|
|
175
|
+
mid = [(rbox[v][0] + rbox[v][1]) / 2 for v in VARS]
|
|
176
|
+
base = cycle_time_s(*mid, volume_cm3=volume_cm3, pack_s=pack_s, wall_mm=WALL_MAX_MM, **MAT)
|
|
177
|
+
sens = {}
|
|
178
|
+
for i, v in enumerate(VARS):
|
|
179
|
+
lo, hi = list(mid), list(mid)
|
|
180
|
+
lo[i], hi[i] = rbox[v][0], rbox[v][1]
|
|
181
|
+
a = cycle_time_s(*lo, volume_cm3=volume_cm3, pack_s=pack_s, wall_mm=WALL_MAX_MM, **MAT)
|
|
182
|
+
b = cycle_time_s(*hi, volume_cm3=volume_cm3, pack_s=pack_s, wall_mm=WALL_MAX_MM, **MAT)
|
|
183
|
+
sens[v] = abs(b - a)
|
|
184
|
+
print(
|
|
185
|
+
f" {v:12s} {rbox[v][0]:7.3g} -> {rbox[v][1]:7.3g} : "
|
|
186
|
+
f"cycle {a:6.3f} -> {b:6.3f} s swing {abs(b - a):5.3f} s"
|
|
187
|
+
)
|
|
188
|
+
print(
|
|
189
|
+
f" baseline at box centre: {base:.3f} s (fill {volume_cm3 / mid[2]:.3f} + cool/pack + handling {HANDLING_S})"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# ---- the Pareto front ---------------------------------------------
|
|
193
|
+
print("\n Pareto front — cheapest cycle at each robustness level:")
|
|
194
|
+
rows = []
|
|
195
|
+
for req in [NO_FLOW_END_MIN_K, 15, 20, 25, 30, 35, 40, 50]:
|
|
196
|
+
m = worst >= req
|
|
197
|
+
if not m.any():
|
|
198
|
+
continue
|
|
199
|
+
j = int(np.argmin(np.where(m, cyc, np.inf)))
|
|
200
|
+
rows.append(
|
|
201
|
+
{
|
|
202
|
+
"required_worst_margin_K": float(req),
|
|
203
|
+
"melt_C": round(float(pts[j, 0]), 2),
|
|
204
|
+
"wall_C": round(float(pts[j, 1]), 2),
|
|
205
|
+
"flow_cm3_s": round(float(pts[j, 2]), 4),
|
|
206
|
+
"cycle_s": round(float(cyc[j]), 3),
|
|
207
|
+
"fill_s": round(volume_cm3 / float(pts[j, 2]), 4),
|
|
208
|
+
"cool_s": round(cooling_time_s(float(pts[j, 0]), float(pts[j, 1]), wall_mm=WALL_MAX_MM, **MAT), 3),
|
|
209
|
+
"nominal_margin_K": round(float(nominal[j]), 2),
|
|
210
|
+
"worst_margin_K": round(float(worst[j]), 2),
|
|
211
|
+
"margin_in_floors": round(float(worst[j] - NO_FLOW_END_MIN_K) / floor, 2),
|
|
212
|
+
"p80_bar": round(float(p80[j]), 1),
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
r = rows[-1]
|
|
216
|
+
print(
|
|
217
|
+
f" >= {req:4.0f} K : melt {r['melt_C']:6.1f} wall {r['wall_C']:5.1f} "
|
|
218
|
+
f"Q {r['flow_cm3_s']:6.3f} | cycle {r['cycle_s']:6.3f} s "
|
|
219
|
+
f"(cool {r['cool_s']:.2f}) | worst margin {r['worst_margin_K']:5.1f} K "
|
|
220
|
+
f"= {r['margin_in_floors']:5.2f} floors over the gate | p80 {r['p80_bar']:.0f} bar"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# ---- what actually opposes a cold, fast process? -------------------
|
|
224
|
+
# The freeze-off gate turns out NOT to oppose it: the cycle-optimal corner is also far from
|
|
225
|
+
# that boundary. So the trade-off, if there is one, must live in the quality KPIs. Fit them and
|
|
226
|
+
# compare candidate operating points on every KPI at once.
|
|
227
|
+
extra = {}
|
|
228
|
+
for kpi in ("weld_front_temp_K", "hotspot", "p80_bar"):
|
|
229
|
+
yy = np.array([r["kpis"].get(kpi) for r in recs], float)
|
|
230
|
+
m = np.isfinite(yy)
|
|
231
|
+
extra[kpi] = S.fit(X[m], yy[m], box, noise_floor=floors[kpi]["floor"])
|
|
232
|
+
|
|
233
|
+
def ev(sur, P):
|
|
234
|
+
return float(sur.predict_unit((np.array([P]) - box[:, 0]) / (box[:, 1] - box[:, 0]))[0])
|
|
235
|
+
|
|
236
|
+
cheapest = pts[int(np.argmin(np.where(ok, cyc, np.inf)))]
|
|
237
|
+
centre = np.array([float(g3["centre"][v]) for v in VARS])
|
|
238
|
+
# The recommendation. Mould at its minimum (the largest cycle lever, and near-inert for every
|
|
239
|
+
# fill KPI), flow near the top (buys robustness and a little cycle), melt high enough to recover
|
|
240
|
+
# most of the weld-front temperature the cold/fast corner gives away — see the front below.
|
|
241
|
+
cands = {
|
|
242
|
+
"RECOMMENDED": np.array([RECOMMENDED["melt_C"], RECOMMENDED["wall_C"], RECOMMENDED["flow_cm3_s"]]),
|
|
243
|
+
"cycle-optimal corner": cheapest,
|
|
244
|
+
"G3 robustness centre": centre,
|
|
245
|
+
"card nominal": np.array([240.0, 40.0, 1.6009]),
|
|
246
|
+
"slowest / hottest": np.array([rbox["melt_C"][1], rbox["wall_C"][1], rbox["flow_cm3_s"][0]]),
|
|
247
|
+
}
|
|
248
|
+
print("\n candidate operating points on EVERY KPI (not just the gate):")
|
|
249
|
+
print(f" {'point':22s} {'cycle':>7s} {'gate':>7s} {'weldT':>8s} {'hotspot':>8s} {'p80':>7s}")
|
|
250
|
+
|
|
251
|
+
def drifted(P):
|
|
252
|
+
w = np.inf
|
|
253
|
+
for dm in (-DRIFT["melt_C"], DRIFT["melt_C"]):
|
|
254
|
+
for dw in (-DRIFT["wall_C"], DRIFT["wall_C"]):
|
|
255
|
+
for dq in (-DRIFT["flow_rel"], DRIFT["flow_rel"]):
|
|
256
|
+
Qp = np.clip([P[0] + dm, P[1] + dw, P[2] * (1 + dq)], box[:, 0], box[:, 1])
|
|
257
|
+
w = min(w, ev(surr, Qp))
|
|
258
|
+
return w
|
|
259
|
+
|
|
260
|
+
cand_rows = []
|
|
261
|
+
for name, P in cands.items():
|
|
262
|
+
cyc_i = cycle_time_s(*P, volume_cm3=volume_cm3, pack_s=pack_s, wall_mm=WALL_MAX_MM, **MAT)
|
|
263
|
+
row = {
|
|
264
|
+
"point": name,
|
|
265
|
+
"melt_C": round(float(P[0]), 2),
|
|
266
|
+
"wall_C": round(float(P[1]), 2),
|
|
267
|
+
"flow_cm3_s": round(float(P[2]), 4),
|
|
268
|
+
"cycle_s": round(cyc_i, 3),
|
|
269
|
+
"gate_margin_K": round(ev(surr, P), 1),
|
|
270
|
+
"weld_front_temp_K": round(ev(extra["weld_front_temp_K"], P), 1),
|
|
271
|
+
"hotspot": round(ev(extra["hotspot"], P), 4),
|
|
272
|
+
"p80_bar": round(ev(extra["p80_bar"], P), 1),
|
|
273
|
+
"parts_per_hour": round(3600.0 / cyc_i * N_CAVITIES),
|
|
274
|
+
"gate_margin_drifted_K": round(drifted(P), 1),
|
|
275
|
+
}
|
|
276
|
+
cand_rows.append(row)
|
|
277
|
+
print(
|
|
278
|
+
f" {name:22s} {row['cycle_s']:7.3f} {row['gate_margin_K']:7.1f} "
|
|
279
|
+
f"{row['weld_front_temp_K']:8.1f} {row['hotspot']:8.4f} {row['p80_bar']:7.1f}"
|
|
280
|
+
)
|
|
281
|
+
print(" (weldT higher = better bonding; hotspot lower = less differential shrinkage; p80 ordering only)")
|
|
282
|
+
|
|
283
|
+
# ---- the REAL trade-off: cycle time vs weld-front temperature ------
|
|
284
|
+
# Freeze-off does not oppose a cold, fast process, and neither does hotspot — a fast fill has
|
|
285
|
+
# less time to build thermal gradients, so it scores BETTER there. Weld-front temperature is the
|
|
286
|
+
# only KPI that genuinely opposes, because melt drives it almost 1:1. So that is the front worth
|
|
287
|
+
# drawing, and it is the trade the process engineer is actually making.
|
|
288
|
+
wt = extra["weld_front_temp_K"].predict_unit((pts - box[:, 0]) / (box[:, 1] - box[:, 0]))
|
|
289
|
+
hs = extra["hotspot"].predict_unit((pts - box[:, 0]) / (box[:, 1] - box[:, 0]))
|
|
290
|
+
print("\n the real trade-off — cheapest cycle at each weld-front temperature:")
|
|
291
|
+
print(
|
|
292
|
+
f" {'weldT >=':>10s} {'melt':>6s} {'wall':>5s} {'Q':>6s} {'cycle':>7s} "
|
|
293
|
+
f"{'gate':>6s} {'hotspot':>8s} {'p80':>6s}"
|
|
294
|
+
)
|
|
295
|
+
front = []
|
|
296
|
+
for req in [494, 498, 500, 502, 504, 506, 508, 509]:
|
|
297
|
+
m = ok & (wt >= req)
|
|
298
|
+
if not m.any():
|
|
299
|
+
continue
|
|
300
|
+
j = int(np.argmin(np.where(m, cyc, np.inf)))
|
|
301
|
+
r = {
|
|
302
|
+
"weld_req_K": req,
|
|
303
|
+
"melt_C": round(float(pts[j, 0]), 1),
|
|
304
|
+
"wall_C": round(float(pts[j, 1]), 1),
|
|
305
|
+
"flow_cm3_s": round(float(pts[j, 2]), 4),
|
|
306
|
+
"cycle_s": round(float(cyc[j]), 3),
|
|
307
|
+
"gate_margin_K": round(float(worst[j]), 1),
|
|
308
|
+
"hotspot": round(float(hs[j]), 4),
|
|
309
|
+
"p80_bar": round(float(p80[j]), 1),
|
|
310
|
+
"weld_front_temp_K": round(float(wt[j]), 1),
|
|
311
|
+
}
|
|
312
|
+
front.append(r)
|
|
313
|
+
print(
|
|
314
|
+
f" {req:10.0f} {r['melt_C']:6.1f} {r['wall_C']:5.1f} {r['flow_cm3_s']:6.3f} "
|
|
315
|
+
f"{r['cycle_s']:7.3f} {r['gate_margin_K']:6.1f} {r['hotspot']:8.4f} "
|
|
316
|
+
f"{r['p80_bar']:6.1f}"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# ---- compare with the robustness centre ---------------------------
|
|
320
|
+
c = [float(g3["centre"][v]) for v in VARS]
|
|
321
|
+
c_cyc = cycle_time_s(*c, volume_cm3=volume_cm3, pack_s=pack_s, wall_mm=WALL_MAX_MM, **MAT)
|
|
322
|
+
c_worst = np.inf
|
|
323
|
+
for dm in (-DRIFT["melt_C"], DRIFT["melt_C"]):
|
|
324
|
+
for dw in (-DRIFT["wall_C"], DRIFT["wall_C"]):
|
|
325
|
+
for dq in (-DRIFT["flow_rel"], DRIFT["flow_rel"]):
|
|
326
|
+
P = np.array([[c[0] + dm, c[1] + dw, c[2] * (1 + dq)]])
|
|
327
|
+
P = np.clip(P, box[:, 0], box[:, 1])
|
|
328
|
+
c_worst = min(c_worst, float(predict(P)[0]))
|
|
329
|
+
print("\n the G3 robustness centre, for comparison:")
|
|
330
|
+
print(f" melt {c[0]:.1f} wall {c[1]:.1f} Q {c[2]:.3f} | cycle {c_cyc:.3f} s | worst margin {c_worst:.1f} K")
|
|
331
|
+
|
|
332
|
+
out = {
|
|
333
|
+
"generated_at": datetime.now(UTC).isoformat(),
|
|
334
|
+
"scope_warning": (
|
|
335
|
+
"Cycle time is NOT from the AI solver -- it does not model cooling. "
|
|
336
|
+
"Cooling is an analytic slab estimate from the material card, using the "
|
|
337
|
+
"same form cross-checked to 8.3 % against the campaign. Scrap is not "
|
|
338
|
+
"modelled at all; robustness stands in for it."
|
|
339
|
+
),
|
|
340
|
+
"assumptions": {
|
|
341
|
+
"n_cavities_real_tool": N_CAVITIES,
|
|
342
|
+
"handling_s": HANDLING_S,
|
|
343
|
+
"pack_s": pack_s,
|
|
344
|
+
"wall_max_mm": WALL_MAX_MM,
|
|
345
|
+
"eject_temperature_K": MAT["eject_K"],
|
|
346
|
+
"drift": DRIFT,
|
|
347
|
+
"material_thermal": {k: MAT[k] for k in ("lam", "rho", "cp")},
|
|
348
|
+
},
|
|
349
|
+
"cycle_time_sensitivity_s": {k: round(v, 4) for k, v in sens.items()},
|
|
350
|
+
"pareto": rows,
|
|
351
|
+
"candidates": cand_rows,
|
|
352
|
+
"cycle_vs_weld_front": front,
|
|
353
|
+
"g3_robustness_centre": {
|
|
354
|
+
"melt_C": c[0],
|
|
355
|
+
"wall_C": c[1],
|
|
356
|
+
"flow_cm3_s": c[2],
|
|
357
|
+
"cycle_s": round(c_cyc, 3),
|
|
358
|
+
"worst_margin_K": round(c_worst, 2),
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
(stage_dir / "economics.json").write_text(json.dumps(out, indent=2))
|
|
362
|
+
print(f"\n wrote {(stage_dir / 'economics.json').relative_to(ROOT)}")
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
raise SystemExit(main())
|