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,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())