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,370 @@
1
+ """G2: screen the sweep. Rank the variables, drop the inert ones, map what is red and why.
2
+
3
+ Reads the per-run records straight from `cache/runs/<part-key>/<stage>/`, so it works on a partial sweep too —
4
+ useful while G1 is still going, and the reason the harness writes one file per run.
5
+
6
+ The acceptance criteria applied here are *ours to invent and justify* (the brief says so
7
+ explicitly). Each is stated with its physical reasoning and what would change our mind, and each
8
+ is a rank or a dimensionless ratio so the guardrail lint passes.
9
+
10
+ Run: python main.py screen [configs/simple-plate.yaml]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+
22
+ from . import surrogate as S
23
+ from .guardrails import check_criteria
24
+ from .setup_campaign import (
25
+ load_config,
26
+ resolve_config,
27
+ runs_dir_for,
28
+ stage_dir_for,
29
+ )
30
+
31
+ ROOT = Path(__file__).resolve().parents[1]
32
+
33
+ VARS = ("melt_C", "wall_C", "flow_cm3_s")
34
+ KPIS = (
35
+ "p80_bar",
36
+ "no_flow_margin_end_K",
37
+ "frozen_fraction_end",
38
+ "no_flow_margin_K",
39
+ "no_flow_margin",
40
+ "weld_front_temp_K",
41
+ "weld_front_temp_ratio",
42
+ "hotspot",
43
+ )
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Acceptance criteria. Each carries its reasoning and what would change our mind.
47
+ # ---------------------------------------------------------------------------
48
+ GATE_KPI = "no_flow_margin_end_K"
49
+ NO_FLOW_END_MIN_K = 10.0 # superheat above no-flow at end of fill, 5th percentile, in K
50
+ NO_FLOW_MIN_K = 15.0 # at-arrival reading, kept as a reported secondary
51
+ HOTSPOT_WORST_FRACTION = 0.10
52
+ P80_WORST_FRACTION = 0.10
53
+
54
+ CRITERIA = [
55
+ {
56
+ "name": "no_flow_guard_end_of_fill",
57
+ "kind": "comparison",
58
+ "kpi": GATE_KPI,
59
+ "expression": f"no_flow_margin_end_K >= {NO_FLOW_END_MIN_K}",
60
+ "absolute": False,
61
+ "role": "hard feasibility gate",
62
+ "reasoning": (
63
+ "A short shot is what happens when the flow channel closes before the cavity is full, "
64
+ "so the causal quantity is whether the melt CHANNEL stays open — not whether the front "
65
+ "is hot on arrival. This reading is the 5th percentile of core temperature at end of "
66
+ "fill, minus the no-flow temperature: at 0, a twentieth of the part's core has "
67
+ "solidified while material still had to pass through it. Requiring 10 K of headroom "
68
+ "covers what the model does not see: 1 of 32 cavities fed by a single gate, with no "
69
+ "runner system at all — so every pressure drop upstream of the gate is missing, the "
70
+ "hot runner contributes no heat, and cavity-to-cavity feed imbalance is not "
71
+ "represented even in principle. "
72
+ "For honesty about how much protection that is: the derived floor for this KPI on this "
73
+ "part is 2.69 K, so 10 K is only 3.7x the resolution limit — thinner cover than the "
74
+ "at-arrival gate had, and a reason the G4 boundary probes matter."
75
+ ),
76
+ "why_the_5th_percentile_not_the_minimum": (
77
+ "The minimum is dominated by the mesher's degenerate slivers — it reports 0.010 mm as "
78
+ "the smallest measured thickness — and swings ~90 K on a handful of nodes. Measured on "
79
+ "the nominal point: minimum -16.6 K while the fraction of nodes actually below no-flow "
80
+ "is ~0 %. A robust percentile is the same physical quantity without the extremum "
81
+ "fragility, and it is the same reason `hotspot` uses p95-p05."
82
+ ),
83
+ "why_this_replaced_the_at_arrival_reading": (
84
+ "The at-arrival reading was measured NOT to discriminate on this part: over 250 sweep "
85
+ "runs it spanned 32.9-108.8 K against a 15 K gate, never approaching it, because "
86
+ "fountain flow continuously replenishes the front with core melt so the front stays "
87
+ "near melt temperature (F15). Corner probes then showed this reading swinging from "
88
+ "+61.6 K at nominal to -7.6 K at the cold/slow corner — it changes sign where the "
89
+ "at-arrival reading barely moves (F16). Both are 'distance to the material's no-flow "
90
+ "temperature', so this is a second reading of the approved KPI, not a new one; the "
91
+ "at-arrival value is still reported."
92
+ ),
93
+ "why_kelvin_is_allowed": (
94
+ "Guardrail 1 forbids absolute *pressure* thresholds for a measured reason: the AI "
95
+ "solver under-predicts absolute cavity pressure on multi-gate parts (~0.3x on 8 "
96
+ "gates), so a pressure in bar is a number known to be wrong. No such bias is "
97
+ "documented for temperature, and F7 measured the field tracking the requested melt to "
98
+ "within 1 K on this part. The guardrail targets a known multiplicative bias, not units."
99
+ ),
100
+ "judgement": True,
101
+ "would_change_my_mind": (
102
+ "A measured cavity-to-cavity melt-temperature spread for the real 32-cavity tool would "
103
+ "set this properly. A numerical confirmation run that fills cleanly at 0 K margin would "
104
+ "show 10 K is too conservative — G4 probes just outside this edge for that reason."
105
+ ),
106
+ },
107
+ {
108
+ "name": "no_flow_at_arrival_secondary",
109
+ "kind": "comparison",
110
+ "kpi": "no_flow_margin_K",
111
+ "expression": f"no_flow_margin_K >= {NO_FLOW_MIN_K}",
112
+ "absolute": False,
113
+ "role": "reported secondary, NOT a gate",
114
+ "reasoning": (
115
+ "Retained because it is the literal reading of the approved KPI and because it answers "
116
+ "a different question — did the melt arrive still molten. Measured not to discriminate "
117
+ "on this part (see above), so it informs rather than screens."
118
+ ),
119
+ "judgement": False,
120
+ "would_change_my_mind": "A part where the front genuinely cools before arriving.",
121
+ },
122
+ {
123
+ "name": "hotspot_risk_flag",
124
+ "kind": "rank",
125
+ "kpi": "hotspot",
126
+ "expression": f"hotspot_rank <= {1 - HOTSPOT_WORST_FRACTION}",
127
+ "absolute": False,
128
+ "role": "warpage risk flag (amber, not a hard gate)",
129
+ "reasoning": (
130
+ "In-plane spread of core temperature at end of fill, normalised by (melt - wall). "
131
+ "Differential shrinkage drives warpage, and differential shrinkage is driven by "
132
+ "temperature *differences* across the part at the start of packing — uniformly hot "
133
+ "is not a warpage risk, unevenly hot is. Used as a rank, not a threshold, because "
134
+ "the brief forbids predicting warpage: this flags risk, it does not quantify it. "
135
+ f"Amber on the worst {HOTSPOT_WORST_FRACTION:.0%}. "
136
+ "Held deliberately weak because this KPI has a 4.45 % resolution floor — an order of "
137
+ "magnitude worse than the others (F8) — so only coarse distinctions are honest."
138
+ ),
139
+ "judgement": True,
140
+ "would_change_my_mind": (
141
+ "A numerical pack+cool+warp run on two points of equal p80 but very different "
142
+ "hotspot. If measured warpage does not separate them, this metric is not earning its "
143
+ "place and should be dropped rather than reported."
144
+ ),
145
+ },
146
+ {
147
+ "name": "pressure_ordering",
148
+ "kind": "rank",
149
+ "kpi": "p80_bar",
150
+ "expression": f"p80_rank <= {1 - P80_WORST_FRACTION}",
151
+ "absolute": False,
152
+ "role": "preference (amber), never feasibility",
153
+ "reasoning": (
154
+ "p80 orders points and nothing more. The AI solver under-predicts absolute cavity "
155
+ "pressure on multi-gate parts (~0.3x on 8 gates) and the real tool is a 32-cavity "
156
+ "hot-runner system while we model 1 cavity with no runner, so "
157
+ "any comparison against a pressure in bar — including the 1500 bar machine limit — "
158
+ "would be a wrong number against a right one. Rank is preserved under that bias; a "
159
+ "threshold is not. Amber on the worst decile only."
160
+ ),
161
+ "judgement": False,
162
+ "would_change_my_mind": "Nothing short of a validated absolute-pressure calibration.",
163
+ },
164
+ ]
165
+
166
+
167
+ def load_records(runs_dir: Path, stage: str, *, quiet: bool = False) -> list[dict]:
168
+ """Load scored records for a stage, skipping any from a superseded KPI definition.
169
+
170
+ Records produced under an earlier `KPI_VERSION` are kept on disk as history, so the loader has
171
+ to filter rather than assume. Filtering on the *presence of the required KPIs* rather than only
172
+ on the version string also covers records written before `kpi_version` was recorded at all.
173
+ """
174
+ out, stale = [], 0
175
+ for p in sorted((runs_dir / stage).glob("*.json")):
176
+ try:
177
+ d = json.loads(p.read_text())
178
+ except (OSError, ValueError):
179
+ continue
180
+ k = d.get("kpis")
181
+ if not k:
182
+ continue
183
+ if any(key not in k for key in KPIS):
184
+ stale += 1
185
+ continue
186
+ out.append(d)
187
+ if stale and not quiet:
188
+ print(f" ({stale} record(s) in {stage} skipped: superseded KPI definition)")
189
+ return out
190
+
191
+
192
+ def main() -> int:
193
+ ap = argparse.ArgumentParser(description="G2 screening")
194
+ ap.add_argument(
195
+ "config",
196
+ nargs="?",
197
+ help="campaign config; defaults to configs/simple-plate.yaml while that is unambiguous",
198
+ )
199
+ ap.add_argument("--stage", default="G1_wide_sweep")
200
+ args = ap.parse_args()
201
+
202
+ cfg = load_config(resolve_config(args.config))
203
+ runs_dir = runs_dir_for(cfg)
204
+ stage_dir = stage_dir_for(cfg)
205
+ recs = load_records(runs_dir, args.stage)
206
+ if len(recs) < 40:
207
+ print(f" only {len(recs)} scored runs in {args.stage}; need >=40 for a useful surrogate")
208
+ return 1
209
+
210
+ check_criteria(CRITERIA) # guardrail lint BEFORE anything is filtered
211
+ print(f" guardrail lint passed on {len(CRITERIA)} criteria")
212
+
213
+ # The floor is part-specific by construction (guardrail 2), so it is read from this part's
214
+ # own stages folder. Importing another part's would be exactly the violation the rule names.
215
+ floor_path = stage_dir / "noise_floor.json"
216
+ if not floor_path.exists():
217
+ print(
218
+ f" no derived noise floor at {floor_path.relative_to(ROOT)} — "
219
+ "run `python main.py epsilon-floor` for THIS part first (guardrail 2)."
220
+ )
221
+ return 1
222
+ floor = json.loads(floor_path.read_text())
223
+ X = np.array([[r["spec"][v] for v in VARS] for r in recs], float)
224
+ box = np.array([[X[:, i].min(), X[:, i].max()] for i in range(len(VARS))])
225
+ print(f"\n {len(recs)} scored runs; observed box:")
226
+ for i, v in enumerate(VARS):
227
+ print(f" {v:12s} {box[i, 0]:8.2f} .. {box[i, 1]:8.2f}")
228
+
229
+ # ---- per-KPI surrogate, ranking, inert test --------------------------
230
+ per_kpi: dict[str, dict] = {}
231
+ print("\n per-KPI surrogate and effect shares")
232
+ for kpi in KPIS:
233
+ y = np.array([r["kpis"].get(kpi) for r in recs], float)
234
+ ok = np.isfinite(y)
235
+ if ok.sum() < 40:
236
+ print(f" {kpi:22s} SKIP ({int(ok.sum())} finite values)")
237
+ continue
238
+ f_abs = (floor["per_kpi"].get(kpi) or {}).get("floor")
239
+ f_rel = (floor["per_kpi"].get(kpi) or {}).get("rel_floor_pct")
240
+ surr = S.fit(X[ok], y[ok], box, noise_floor=f_abs)
241
+ loo = surr.loo_error()
242
+ sh = S.effect_shares(surr, n=8192)
243
+ spans = S.effect_span(surr, len(VARS))
244
+
245
+ # The inert test uses the RELATIVE floor (F8): quantisation is floating-point, so the
246
+ # absolute floor scales with the KPI value. A fixed absolute floor would under-protect
247
+ # the high-value end of the box and declare effects real that are not.
248
+ med = float(np.median(y[ok]))
249
+ floor_here = (abs(med) * f_rel / 100.0) if f_rel else (f_abs or 0.0)
250
+ inert = [bool(s < floor_here) for s in spans]
251
+
252
+ per_kpi[kpi] = {
253
+ "n": int(ok.sum()),
254
+ "median": round(med, 6),
255
+ "range": [round(float(y[ok].min()), 6), round(float(y[ok].max()), 6)],
256
+ "loo": {k: round(v, 6) for k, v in loo.items()},
257
+ "floor_absolute_at_centre": f_abs,
258
+ "floor_relative_pct": f_rel,
259
+ "floor_applied_here": round(floor_here, 6),
260
+ "effect_span": {v: round(s, 6) for v, s in zip(VARS, spans, strict=True)},
261
+ "share": {v: round(s, 4) for v, s in zip(VARS, sh["share"], strict=True)},
262
+ "S1": {v: round(s, 4) for v, s in zip(VARS, sh["S1"], strict=True)},
263
+ "ST": {v: round(s, 4) for v, s in zip(VARS, sh["ST"], strict=True)},
264
+ "inert": dict(zip(VARS, inert, strict=True)),
265
+ "rank": [VARS[i] for i in np.argsort(spans)[::-1]],
266
+ }
267
+ print(f" {kpi:22s} LOO nrmse {loo['nrmse_range'] * 100:5.2f}% floor {floor_here:.4g}")
268
+ for i, v in enumerate(VARS):
269
+ mark = " INERT" if inert[i] else ""
270
+ print(f" {v:12s} span {spans[i]:10.4g} share {sh['share'][i]:.3f} ST {sh['ST'][i]:+.3f}{mark}")
271
+
272
+ # ---- inert overall: below floor for EVERY KPI ------------------------
273
+ inert_all = {v: all(per_kpi[k]["inert"][v] for k in per_kpi) for v in VARS}
274
+ print("\n variables inert across ALL KPIs (fixed, not ranked low — guardrail 2):")
275
+ for v, b in inert_all.items():
276
+ print(f" {v:12s} {'INERT -> fix' if b else 'active -> keep'}")
277
+
278
+ # ---- feasibility map -------------------------------------------------
279
+ nf = np.array([r["kpis"][GATE_KPI] for r in recs], float)
280
+ nf_arr = np.array([r["kpis"]["no_flow_margin_K"] for r in recs], float)
281
+ froz = np.array([r["kpis"]["frozen_fraction_end"] for r in recs], float)
282
+ hot = np.array([r["kpis"]["hotspot"] for r in recs], float)
283
+ p80 = np.array([r["kpis"]["p80_bar"] for r in recs], float)
284
+ resid = np.array([r["kpis"]["fill_residual"] for r in recs], float)
285
+
286
+ red_freeze = nf < NO_FLOW_END_MIN_K
287
+ hot_cut = float(np.quantile(hot, 1 - HOTSPOT_WORST_FRACTION))
288
+ p80_cut = float(np.quantile(p80, 1 - P80_WORST_FRACTION))
289
+ amber_hot = (hot > hot_cut) & ~red_freeze
290
+ amber_p80 = (p80 > p80_cut) & ~red_freeze & ~amber_hot
291
+ green = ~red_freeze & ~amber_hot & ~amber_p80
292
+
293
+ print(f"\n feasibility map over {len(recs)} points")
294
+ print(
295
+ f" {GATE_KPI} observed {nf.min():.1f} .. {nf.max():.1f} K "
296
+ f"frozen fraction {froz.min():.3f} .. {froz.max():.3f}"
297
+ )
298
+ print(f" at-arrival reading (secondary) {nf_arr.min():.1f} .. {nf_arr.max():.1f} K")
299
+ print(
300
+ f" RED freeze-off ({GATE_KPI} < {NO_FLOW_END_MIN_K:.0f} K) {red_freeze.sum():5d}"
301
+ f" {100 * red_freeze.mean():5.1f}%"
302
+ )
303
+ print(
304
+ f" AMBER hotspot worst {HOTSPOT_WORST_FRACTION:.0%} (> {hot_cut:.4f}) "
305
+ f"{amber_hot.sum():5d} {100 * amber_hot.mean():5.1f}%"
306
+ )
307
+ print(
308
+ f" AMBER p80 worst {P80_WORST_FRACTION:.0%} rank (> {p80_cut:.1f} bar) "
309
+ f"{amber_p80.sum():5d} {100 * amber_p80.mean():5.1f}%"
310
+ )
311
+ print(f" GREEN {green.sum():5d} {100 * green.mean():5.1f}%")
312
+ print(f" (short shot is NOT a gate: residual max {resid.max():.4f} — see decisions D4)")
313
+
314
+ if green.sum():
315
+ print("\n green-region extent (survivors only):")
316
+ for i, v in enumerate(VARS):
317
+ print(f" {v:12s} {X[green, i].min():8.2f} .. {X[green, i].max():8.2f}")
318
+
319
+ out = {
320
+ "stage": "G2_screening",
321
+ "generated_at": datetime.now(UTC).isoformat(),
322
+ "source_stage": args.stage,
323
+ "n_runs": len(recs),
324
+ "observed_box": {v: [round(float(box[i, 0]), 4), round(float(box[i, 1]), 4)] for i, v in enumerate(VARS)},
325
+ "acceptance_criteria": CRITERIA,
326
+ "per_kpi": per_kpi,
327
+ "inert_across_all_kpis": inert_all,
328
+ "feasibility": {
329
+ "red_freeze_off": int(red_freeze.sum()),
330
+ "amber_hotspot": int(amber_hot.sum()),
331
+ "amber_pressure_rank": int(amber_p80.sum()),
332
+ "green": int(green.sum()),
333
+ "green_fraction": round(float(green.mean()), 4),
334
+ "gate_kpi": GATE_KPI,
335
+ "cuts": {
336
+ "no_flow_end_min_K": NO_FLOW_END_MIN_K,
337
+ "no_flow_at_arrival_secondary_K": NO_FLOW_MIN_K,
338
+ "hotspot_cut": round(hot_cut, 6),
339
+ "p80_cut_bar": round(p80_cut, 3),
340
+ },
341
+ "gate_observed_K": [round(float(nf.min()), 3), round(float(nf.max()), 3)],
342
+ "frozen_fraction_observed": [round(float(froz.min()), 5), round(float(froz.max()), 5)],
343
+ "at_arrival_observed_K": [round(float(nf_arr.min()), 3), round(float(nf_arr.max()), 3)],
344
+ "green_extent": (
345
+ {
346
+ v: [round(float(X[green, i].min()), 4), round(float(X[green, i].max()), 4)]
347
+ for i, v in enumerate(VARS)
348
+ }
349
+ if green.sum()
350
+ else None
351
+ ),
352
+ },
353
+ "fill_completeness_diagnostic": {
354
+ "residual_max": round(float(resid.max()), 6),
355
+ "residual_flow_dependence": None if resid.max() <= 0 else "see correlation below",
356
+ "used_as_gate": False,
357
+ "note": (
358
+ "Residual unfilled fraction is a known model bug and is identically 0 here, "
359
+ "so it carries no information. Gate is the no-flow margin (D4)."
360
+ ),
361
+ },
362
+ }
363
+ dest = stage_dir / "g2_screening.json"
364
+ dest.write_text(json.dumps(out, indent=2))
365
+ print(f"\n wrote {dest.relative_to(ROOT)}")
366
+ return 0
367
+
368
+
369
+ if __name__ == "__main__":
370
+ raise SystemExit(main())
@@ -0,0 +1,166 @@
1
+ """G1: the wide Sobol sweep over the ±box, live against the AI solver.
2
+
3
+ Resumable by construction — every run's id is a hash of its inputs, so re-running this command
4
+ after a crash picks up exactly where it stopped. Sobol is incremental, so `--n` can be raised
5
+ later without invalidating anything already computed (`sobol(2n)[:n] == sobol(n)`, asserted in
6
+ `test_design.py`).
7
+
8
+ **No raw results are kept.** The storage policy says keep boundary runs, but which runs are on
9
+ the boundary is not known until G2 has screened. Guessing now would keep the wrong ones. Since
10
+ the solver is deterministic, re-running a specific point later returns a bit-identical result
11
+ for ~10 s — strictly better than retaining 30 GB on a guess. G2 re-requests the handful it needs
12
+ with `keep_h5=True`.
13
+
14
+ Run: python main.py sweep [--n 1024] [configs/simple-plate.yaml]
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+
26
+ from .design import scale_to_box, sobol
27
+ from .harness import RunSpec
28
+ from .setup_campaign import build, resolve_config
29
+
30
+ ROOT = Path(__file__).resolve().parents[1]
31
+
32
+ STAGE = "G1_wide_sweep"
33
+ ORDER = ("melt_C", "wall_C", "flow_cm3_s")
34
+
35
+
36
+ def main() -> int:
37
+ ap = argparse.ArgumentParser(description="G1 wide Sobol sweep")
38
+ ap.add_argument(
39
+ "config",
40
+ nargs="?",
41
+ help="campaign config; defaults to configs/simple-plate.yaml while that is unambiguous",
42
+ )
43
+ ap.add_argument("--n", type=int, default=None, help="override sweep_points")
44
+ ap.add_argument("--workers", type=int, default=None, help="override max_concurrency")
45
+ args = ap.parse_args()
46
+
47
+ campaign, info = build(resolve_config(args.config))
48
+ try:
49
+ cfg = info["cfg"]
50
+ n = int(args.n or cfg["budget"]["sweep_points"])
51
+ workers = int(args.workers or cfg["solver"]["max_concurrency"])
52
+ box = {k: tuple(info["box"][k]) for k in ORDER}
53
+
54
+ print(f"\n G1 wide box (basis: {info['box']['basis']}):")
55
+ for k in ORDER:
56
+ print(f" {k:12s} {box[k][0]:8.2f} .. {box[k][1]:8.2f}")
57
+
58
+ unit = sobol(n, len(ORDER))
59
+ cols = scale_to_box(unit, box, ORDER)
60
+ specs = [
61
+ RunSpec(
62
+ melt_C=float(cols["melt_C"][i]),
63
+ wall_C=float(cols["wall_C"][i]),
64
+ flow_cm3_s=round(float(cols["flow_cm3_s"][i]), 4),
65
+ stage=STAGE,
66
+ label=f"sobol{i:04d}",
67
+ meta={"sobol_index": i},
68
+ )
69
+ for i in range(n)
70
+ ]
71
+
72
+ # Process() rejects wall >= melt. The box cannot produce that (mould max 60 C < melt
73
+ # min 220 C), but assert rather than assume — a different material card could.
74
+ bad = [s for s in specs if s.wall_C >= s.melt_C]
75
+ if bad:
76
+ raise SystemExit(f"{len(bad)} design points have wall >= melt; check the box")
77
+
78
+ print(
79
+ f"\n {n} Sobol points, concurrency {workers}, ~29 MB per result "
80
+ f"(downloaded to scratch outside OneDrive, scored, deleted)"
81
+ )
82
+ records = campaign.run_batch(
83
+ specs,
84
+ stage=STAGE,
85
+ max_workers=workers,
86
+ description=f"G1 wide sweep: {n}-point Sobol over the material-card temperature "
87
+ f"window and nominal +/-50 % flow. Incremental and resumable.",
88
+ )
89
+
90
+ ok = [r for r in records if r.ok]
91
+ print(f"\n {len(ok)}/{len(records)} scored")
92
+ if ok:
93
+ p80 = np.array([r.kpis["p80_bar"] for r in ok], float)
94
+ nf = np.array([r.kpis["no_flow_margin"] for r in ok], float)
95
+ res = np.array([r.kpis["fill_residual"] for r in ok], float)
96
+ hot = np.array([r.kpis["hotspot"] for r in ok], float)
97
+ print(
98
+ f" p80 {p80.min():8.2f} .. {p80.max():8.2f} bar "
99
+ f"(median {np.median(p80):.2f}, spread x{p80.max() / max(p80.min(), 1e-9):.2f})"
100
+ )
101
+ print(
102
+ f" no_flow_margin {nf.min():8.4f} .. {nf.max():8.4f} "
103
+ f"(negative = freeze-off: {int((nf < 0).sum())} runs)"
104
+ )
105
+ print(f" hotspot {hot.min():8.4f} .. {hot.max():8.4f}")
106
+ print(f" fill_residual {res.min():8.4f} .. {res.max():8.4f} (>0 on {int((res > 0).sum())} runs)")
107
+
108
+ manifest = {
109
+ "stage": STAGE,
110
+ "generated_at": datetime.now(UTC).isoformat(),
111
+ "design": {
112
+ "type": "sobol",
113
+ "n": n,
114
+ "dims": list(ORDER),
115
+ "skip": 0,
116
+ "incremental": "sobol(2n)[:n] == sobol(n)",
117
+ },
118
+ "box": {k: list(box[k]) for k in ORDER},
119
+ "box_basis": info["box"]["basis"],
120
+ "centre": info["box"]["centre_C"],
121
+ "solver": {
122
+ "model_version": campaign.model_version,
123
+ "num_timesteps": campaign.num_timesteps,
124
+ "htc_W_m2K": campaign.htc_W_m2K,
125
+ },
126
+ "geometry_id": info["geometry_id"],
127
+ "project_id": info["project_id"],
128
+ "n_ok": len(ok),
129
+ "n_total": len(records),
130
+ "runs": [
131
+ {
132
+ "run_id": r.run_id,
133
+ "sobol_index": (r.spec.get("meta") or {}).get("sobol_index"),
134
+ "melt_C": r.spec["melt_C"],
135
+ "wall_C": r.spec["wall_C"],
136
+ "flow_cm3_s": r.spec["flow_cm3_s"],
137
+ "simulation_id": r.simulation_id,
138
+ "ok": r.ok,
139
+ "error": r.error or None,
140
+ "kpis": r.kpis,
141
+ }
142
+ for r in records
143
+ ],
144
+ }
145
+ dest = info["stage_dir"] / "g1_sweep.json"
146
+ dest.write_text(json.dumps(manifest, indent=2))
147
+ print(f"\n wrote {dest.relative_to(ROOT)}")
148
+
149
+ if ok:
150
+ campaign.note(
151
+ f"G1 wide sweep: {len(ok)}/{len(records)} Sobol points over melt "
152
+ f"{box['melt_C'][0]:.0f}-{box['melt_C'][1]:.0f} C (material card window), wall "
153
+ f"{box['wall_C'][0]:.0f}-{box['wall_C'][1]:.0f} C, flow "
154
+ f"{box['flow_cm3_s'][0]:.2f}-{box['flow_cm3_s'][1]:.2f} cm3/s (nominal +/-50 %). "
155
+ f"p80 spans {p80.min():.1f}-{p80.max():.1f} bar (ordering only). "
156
+ f"{int((nf < 0).sum())} points show freeze-off (negative no-flow margin). "
157
+ f"Raw results discarded after scoring; deterministic solver makes targeted "
158
+ f"re-runs free."
159
+ )
160
+ finally:
161
+ info["api"].close()
162
+ return 0
163
+
164
+
165
+ if __name__ == "__main__":
166
+ raise SystemExit(main())