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,322 @@
1
+ """Feedback path: anchor calibration and self-diagnosis, on fake-real data.
2
+
3
+ The brief asks for a step that takes a small set of "real" results, computes the AI-to-reality
4
+ offset at 3 anchor points, and classifies the spread green / warn / alarm — with thresholds
5
+ derived from the data, not imported.
6
+
7
+ **How "reality" is faked, and what that does and does not prove.** There is no machine data, and
8
+ numerical execution is out of scope this session. So reality is generated by running the solver
9
+ with **hidden input deviations the DOE side never sees**: the DOE believes it asked for one thing,
10
+ reality was actually something else. Three deviations, as the brief specifies:
11
+
12
+ * `melt_shift` — the barrel really runs 6 K colder than the setpoint says
13
+ * `moved_gate` — the gate is not where the model puts it
14
+ * `material_swap` — a different grade than the one on the datasheet
15
+
16
+ This exercises the whole calibration and classification path and derives real thresholds. Its
17
+ limitation must be stated wherever the result is used: **it measures the pipeline's sensitivity to
18
+ input misspecification, not the AI-to-physics offset.** When the numerical solver is available the
19
+ source of "reality" swaps and nothing else changes.
20
+
21
+ **What the classifier actually tests.** Not "is the AI accurate" — the whole method is
22
+ ordering-based, so accuracy is the wrong question. Two things matter:
23
+
24
+ 1. **Is the offset a constant?** If reality differs from the AI by a fixed amount, every rank,
25
+ ratio and comparison survives and the method is unaffected. The test is whether the spread of
26
+ offsets across anchors is distinguishable from the KPI's own derived resolution floor.
27
+ 2. **Does the ordering survive?** Pairwise sign agreement between AI and reality across the
28
+ anchors. This is the property every claim in the dossier rests on, so it is checked directly
29
+ rather than inferred.
30
+
31
+ Run: python main.py feedback configs/simple-plate.yaml
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import itertools
38
+ import json
39
+ from datetime import UTC, datetime
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+
44
+ from .harness import RunSpec
45
+ from .run_screening import GATE_KPI, VARS
46
+ from .setup_campaign import build, resolve_config
47
+
48
+ ROOT = Path(__file__).resolve().parents[1]
49
+
50
+ STAGE = "G6_feedback"
51
+ MELT_SHIFT_K = -6.0 # a plausible, invisible barrel/setpoint discrepancy
52
+ CALIB_KPIS = ("p80_bar", GATE_KPI, "hotspot")
53
+
54
+
55
+ def anchors_from_g3(g3: dict, box: dict) -> list[dict]:
56
+ """Three anchors: the centre and two interior points well separated from it.
57
+
58
+ Spread matters more than count here — three anchors that sit almost on top of each other
59
+ cannot distinguish a constant offset from a distortion, which is the whole question.
60
+ """
61
+ c = {v: float(g3["centre"][v]) for v in VARS}
62
+ rb = {v: [float(x) for x in g3["recommended_box"][v]] for v in VARS}
63
+
64
+ def lerp(v, f):
65
+ return rb[v][0] + f * (rb[v][1] - rb[v][0])
66
+
67
+ return [
68
+ {"id": "A0_centre", **c},
69
+ {
70
+ "id": "A1_cool_slow",
71
+ "melt_C": lerp("melt_C", 0.2),
72
+ "wall_C": lerp("wall_C", 0.2),
73
+ "flow_cm3_s": lerp("flow_cm3_s", 0.2),
74
+ },
75
+ {
76
+ "id": "A2_hot_fast",
77
+ "melt_C": lerp("melt_C", 0.8),
78
+ "wall_C": lerp("wall_C", 0.8),
79
+ "flow_cm3_s": lerp("flow_cm3_s", 0.8),
80
+ },
81
+ ]
82
+
83
+
84
+ def classify(offsets: np.ndarray, floor: float) -> tuple[str, dict]:
85
+ """green / warn / alarm from the spread of offsets, in units of the DERIVED floor.
86
+
87
+ The unit is derived from this part's own epsilon experiment — that is what makes the
88
+ thresholds data-derived rather than imported. The multipliers are a stated judgement:
89
+
90
+ spread <= 1 floor -> the offset is indistinguishable from a constant. Every rank, ratio and
91
+ comparison survives, so the method is unaffected. GREEN
92
+ spread <= 3 floors -> a real but modest point-dependence. A single calibration constant is
93
+ imperfect; ordering probably survives but should be re-checked. WARN
94
+ spread > 3 floors -> the offset varies more than three times the resolution limit, so no
95
+ single calibration works and the distortion may reorder points. ALARM
96
+ """
97
+ spread = float(np.max(offsets) - np.min(offsets))
98
+ ratio = spread / max(floor, 1e-12)
99
+ verdict = "green" if ratio <= 1.0 else ("warn" if ratio <= 3.0 else "alarm")
100
+ return verdict, {
101
+ "mean_offset": round(float(np.mean(offsets)), 6),
102
+ "spread": round(spread, 6),
103
+ "floor": round(float(floor), 6),
104
+ "spread_in_floors": round(ratio, 3),
105
+ "constant_offset_hypothesis": (
106
+ "indistinguishable from constant" if ratio <= 1.0 else "rejected: offset is point-dependent"
107
+ ),
108
+ }
109
+
110
+
111
+ def ordering_agreement(ai: np.ndarray, real: np.ndarray, floor: float) -> dict:
112
+ """Pairwise sign agreement, ignoring pairs the AI itself cannot resolve.
113
+
114
+ A pair whose AI difference is below the resolution floor carries no ordering claim, so
115
+ counting it as agreement or disagreement would both be wrong. Those pairs are reported
116
+ separately as unresolvable.
117
+ """
118
+ agree = disagree = unresolvable = 0
119
+ for i, j in itertools.combinations(range(len(ai)), 2):
120
+ d_ai = ai[i] - ai[j]
121
+ if abs(d_ai) < floor:
122
+ unresolvable += 1
123
+ continue
124
+ if np.sign(d_ai) == np.sign(real[i] - real[j]):
125
+ agree += 1
126
+ else:
127
+ disagree += 1
128
+ total = agree + disagree
129
+ return {
130
+ "pairs_agree": agree,
131
+ "pairs_disagree": disagree,
132
+ "pairs_unresolvable_by_ai": unresolvable,
133
+ "agreement": round(agree / total, 3) if total else None,
134
+ }
135
+
136
+
137
+ def main() -> int:
138
+ ap = argparse.ArgumentParser(description="G6 fake-real feedback + self-diagnosis")
139
+ ap.add_argument(
140
+ "config",
141
+ nargs="?",
142
+ help="campaign config; defaults to configs/simple-plate.yaml while that is unambiguous",
143
+ )
144
+ args = ap.parse_args()
145
+
146
+ campaign, info = build(resolve_config(args.config))
147
+ try:
148
+ stage_dir = info["stage_dir"]
149
+ g3_path = stage_dir / "g3_refinement.json"
150
+ if not g3_path.exists():
151
+ print(f" need {g3_path.relative_to(ROOT)} — run `python main.py refine` first")
152
+ return 1
153
+ g3 = json.loads(g3_path.read_text())
154
+ floors = json.loads((stage_dir / "noise_floor.json").read_text())["per_kpi"]
155
+ anchors = anchors_from_g3(g3, info["box"])
156
+
157
+ # A different grade for the material swap. Chosen as another PP so the deviation is
158
+ # plausible-but-invisible rather than absurd; a wildly different family would be a
159
+ # strawman the classifier could not fail to catch.
160
+ alt = None
161
+ for q in ("PP", "PP Generic"):
162
+ for m in campaign.api.search_materials(q, limit=60):
163
+ if m["material_id"] != info["material_id"] and (m.get("family") or "").upper() == "PP":
164
+ alt = m
165
+ break
166
+ if alt:
167
+ break
168
+ # A moved gate: shift to a different real mesh node, ~2 mm away.
169
+ import numpy as _np
170
+
171
+ from cadmould_geometry.mesh import parse_cfex_nodes
172
+
173
+ nodes = parse_cfex_nodes(info["cfex"])
174
+ g0 = _np.array(info["gates_mm"][0], float)
175
+ d = _np.linalg.norm(nodes - g0, axis=1)
176
+ moved = nodes[int(_np.argmin(_np.abs(d - 2.0)))]
177
+
178
+ deviations = [
179
+ ("baseline", {}, "what the DOE side believes it asked for"),
180
+ (
181
+ "melt_shift",
182
+ {"melt_delta_C": MELT_SHIFT_K},
183
+ f"barrel actually {abs(MELT_SHIFT_K):.0f} K colder than the setpoint",
184
+ ),
185
+ (
186
+ "moved_gate",
187
+ {"gates": [list(map(float, moved))]},
188
+ f"gate actually {float(_np.linalg.norm(moved - g0)):.2f} mm from where the model puts it",
189
+ ),
190
+ ]
191
+ if alt:
192
+ deviations.append(
193
+ (
194
+ "material_swap",
195
+ {"material_id": alt["material_id"]},
196
+ f"grade actually '{alt['trade_name']}', not '{info['cfg']['material']['catalogue_name']}'",
197
+ )
198
+ )
199
+ print(f"\n {len(anchors)} anchors x {len(deviations)} variants = {len(anchors) * len(deviations)} runs")
200
+ for name, _, why in deviations:
201
+ print(f" {name:14s} {why}")
202
+
203
+ specs = []
204
+ for a in anchors:
205
+ for name, dev, _ in deviations:
206
+ specs.append(
207
+ RunSpec(
208
+ melt_C=a["melt_C"] + dev.get("melt_delta_C", 0.0),
209
+ wall_C=a["wall_C"],
210
+ flow_cm3_s=round(a["flow_cm3_s"], 4),
211
+ stage=STAGE,
212
+ label=f"{a['id']}|{name}",
213
+ material_id_override=dev.get("material_id"),
214
+ gates_mm_override=tuple(map(tuple, dev["gates"])) if dev.get("gates") else None,
215
+ meta={"anchor": a["id"], "deviation": name},
216
+ )
217
+ )
218
+
219
+ recs = campaign.run_batch(
220
+ specs,
221
+ stage=STAGE,
222
+ max_workers=int(info["cfg"]["solver"]["max_concurrency"]),
223
+ description="Fake-real feedback: hidden input deviations the DOE "
224
+ "side never sees, for anchor calibration and "
225
+ "self-diagnosis.",
226
+ )
227
+ by = {(r.spec["meta"]["anchor"], r.spec["meta"]["deviation"]): r for r in recs if r.ok}
228
+
229
+ report: dict = {
230
+ "stage": STAGE,
231
+ "generated_at": datetime.now(UTC).isoformat(),
232
+ "fake_real_source": "ai_with_hidden_deviation",
233
+ "limitation": (
234
+ "Measures the pipeline's sensitivity to input "
235
+ "misspecification, NOT the AI-to-physics offset. Swap in "
236
+ "numerical runs when available; nothing else changes."
237
+ ),
238
+ "anchors": anchors,
239
+ "deviations": [{"name": n, "detail": w} for n, _, w in deviations],
240
+ "per_deviation": {},
241
+ }
242
+
243
+ overall = []
244
+ for name, _, why in deviations:
245
+ if name == "baseline":
246
+ continue
247
+ block = {"detail": why, "per_kpi": {}}
248
+ print(f"\n === {name}: {why} ===")
249
+ for kpi in CALIB_KPIS:
250
+ floor = float(floors[kpi]["floor"])
251
+ ai, real, ids = [], [], []
252
+ for a in anchors:
253
+ b = by.get((a["id"], "baseline"))
254
+ r = by.get((a["id"], name))
255
+ if not b or not r:
256
+ continue
257
+ va, vr = b.kpis.get(kpi), r.kpis.get(kpi)
258
+ if va is None or vr is None:
259
+ continue
260
+ ai.append(float(va))
261
+ real.append(float(vr))
262
+ ids.append(a["id"])
263
+ if len(ai) < 2:
264
+ block["per_kpi"][kpi] = {"error": "insufficient successful anchors"}
265
+ print(f" {kpi:22s} insufficient data")
266
+ continue
267
+ ai_a, real_a = np.array(ai), np.array(real)
268
+ off = real_a - ai_a
269
+ verdict, stats = classify(off, floor)
270
+ order = ordering_agreement(ai_a, real_a, floor)
271
+ block["per_kpi"][kpi] = {
272
+ "anchors": ids,
273
+ "ai": [round(x, 4) for x in ai],
274
+ "reality": [round(x, 4) for x in real],
275
+ "offsets": [round(x, 4) for x in off.tolist()],
276
+ "verdict": verdict,
277
+ **stats,
278
+ "ordering": order,
279
+ }
280
+ overall.append(verdict)
281
+ print(
282
+ f" {kpi:22s} offset mean {stats['mean_offset']:+9.3f} "
283
+ f"spread {stats['spread']:8.3f} = {stats['spread_in_floors']:5.2f} floors "
284
+ f"-> {verdict.upper():5s} ordering {order['agreement']} "
285
+ f"({order['pairs_unresolvable_by_ai']} unresolvable)"
286
+ )
287
+ report["per_deviation"][name] = block
288
+
289
+ rank = {"green": 0, "warn": 1, "alarm": 2}
290
+ final = max(overall, key=lambda v: rank[v]) if overall else "insufficient"
291
+ report["self_diagnosis"] = {
292
+ "verdict": final,
293
+ "basis": "worst per-KPI verdict across all deviations",
294
+ "thresholds": {
295
+ "green": "offset spread <= 1 x derived floor",
296
+ "warn": "<= 3 x derived floor",
297
+ "alarm": "> 3 x derived floor",
298
+ },
299
+ "threshold_basis": (
300
+ "the UNIT is this part's own epsilon-derived resolution floor; the "
301
+ "multipliers 1 and 3 are a stated judgement, not measured"
302
+ ),
303
+ }
304
+ print(f"\n SELF-DIAGNOSIS: {final.upper()} (worst KPI across all deviations)")
305
+
306
+ (stage_dir / "g6_feedback.json").write_text(json.dumps(report, indent=2))
307
+ print(f" wrote {(stage_dir / 'g6_feedback.json').relative_to(ROOT)}")
308
+
309
+ campaign.note(
310
+ f"G6 feedback loop: {len(anchors)} anchors x {len(deviations)} variants. Reality faked "
311
+ f"by hidden input deviations the DOE side never sees (melt shift, moved gate"
312
+ f"{', material swap' if alt else ''}). Self-diagnosis: {final.upper()}. Thresholds in "
313
+ f"units of this part's derived resolution floor. LIMITATION: measures sensitivity to "
314
+ f"input misspecification, not the AI-to-physics offset."
315
+ )
316
+ finally:
317
+ info["api"].close()
318
+ return 0
319
+
320
+
321
+ if __name__ == "__main__":
322
+ raise SystemExit(main())
@@ -0,0 +1,279 @@
1
+ """G3: adaptive refinement of the feasibility boundary, then the centre point.
2
+
3
+ Places further AI runs where the surrogate is **uncertain** *and* the **boundary is near** —
4
+ dense where the response is steep, sparse where it is flat, which is what the brief asks for. The
5
+ acquisition score is
6
+
7
+ score = posterior_variance ^ w_var * boundary_proximity ^ w_bnd
8
+
9
+ with boundary proximity as a narrow kernel on the signed distance to the gate, measured in units
10
+ of the derived KPI floor: a point is "near the boundary" if the surrogate cannot tell which side
11
+ of the gate it is on. That ties the refinement directly to the measured resolution limit rather
12
+ than to an arbitrary band.
13
+
14
+ Candidates are drawn from the *continuation of the same Sobol sequence*, so the refinement points
15
+ inherit the design's space-filling property instead of clumping, and the whole campaign remains
16
+ one extensible sequence.
17
+
18
+ Run: python main.py refine configs/simple-plate.yaml [--n 384]
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import json
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+
28
+ import numpy as np
29
+
30
+ from . import centre as C, surrogate as S
31
+ from .design import scale_to_box, sobol
32
+ from .harness import RunSpec
33
+ from .run_screening import GATE_KPI, NO_FLOW_END_MIN_K, VARS, load_records
34
+ from .setup_campaign import build, resolve_config
35
+
36
+ ROOT = Path(__file__).resolve().parents[1]
37
+
38
+ STAGE = "G3_refinement"
39
+ SWEEP_STAGE = "G1_wide_sweep"
40
+
41
+
42
+ def acquisition(
43
+ surr, cand_unit: np.ndarray, gate: float, floor: float, *, w_var: float = 0.5, w_bnd: float = 1.0
44
+ ) -> np.ndarray:
45
+ """Uncertainty x boundary-proximity, both normalised to [0,1]."""
46
+ var = surr.variance_unit(cand_unit)
47
+ var = var / max(float(var.max()), 1e-12)
48
+
49
+ pred = surr.predict_unit(cand_unit)
50
+ # Distance to the gate in units of the resolution floor. Within ~1 floor of the gate the
51
+ # surrogate genuinely cannot say which side a point is on — that is where a run buys the most.
52
+ z = np.abs(pred - gate) / max(floor, 1e-9)
53
+ prox = np.exp(-0.5 * (z / 2.0) ** 2)
54
+
55
+ return (var**w_var) * (prox**w_bnd)
56
+
57
+
58
+ def pick(
59
+ surr,
60
+ box: np.ndarray,
61
+ n: int,
62
+ gate: float,
63
+ floor: float,
64
+ *,
65
+ pool: int = 20000,
66
+ start: int = 4096,
67
+ min_sep: float = 0.02,
68
+ ) -> tuple[np.ndarray, dict]:
69
+ """Choose n candidates by acquisition, with a minimum separation so they do not stack up."""
70
+ cand = sobol(pool + start, len(box))[start:] # continuation of the same sequence
71
+ score = acquisition(surr, cand, gate, floor)
72
+ order = np.argsort(score)[::-1]
73
+ chosen: list[int] = []
74
+ for i in order:
75
+ if len(chosen) >= n:
76
+ break
77
+ if all(np.max(np.abs(cand[i] - cand[j])) > min_sep for j in chosen):
78
+ chosen.append(int(i))
79
+ idx = np.array(chosen)
80
+ diag = {
81
+ "pool": pool,
82
+ "sobol_offset": start,
83
+ "min_separation_unit": min_sep,
84
+ "score_range": [round(float(score[idx].min()), 6), round(float(score[idx].max()), 6)],
85
+ "n_selected": len(idx),
86
+ }
87
+ return cand[idx], diag
88
+
89
+
90
+ def main() -> int:
91
+ ap = argparse.ArgumentParser(description="G3 boundary refinement + centre point")
92
+ ap.add_argument(
93
+ "config",
94
+ nargs="?",
95
+ help="campaign config; defaults to configs/simple-plate.yaml while that is unambiguous",
96
+ )
97
+ ap.add_argument("--n", type=int, default=None)
98
+ ap.add_argument("--grid", type=int, default=41)
99
+ ap.add_argument("--no-run", action="store_true", help="fit and solve only; submit nothing")
100
+ args = ap.parse_args()
101
+
102
+ campaign, info = build(resolve_config(args.config))
103
+ try:
104
+ cfg = info["cfg"]
105
+ runs_dir, stage_dir = info["runs_dir"], info["stage_dir"]
106
+ n_ref = int(args.n or cfg["budget"]["refine_points"])
107
+
108
+ floor_all = json.loads((stage_dir / "noise_floor.json").read_text())["per_kpi"]
109
+ recs = load_records(runs_dir, SWEEP_STAGE) + load_records(runs_dir, STAGE)
110
+ if len(recs) < 100:
111
+ print(f" only {len(recs)} scored runs; let G1 progress further first")
112
+ return 1
113
+ print(f" fitting on {len(recs)} runs ({SWEEP_STAGE} + any prior {STAGE})")
114
+
115
+ X = np.array([[r["spec"][v] for v in VARS] for r in recs], float)
116
+ box = np.array([tuple(info["box"][v]) for v in VARS], float)
117
+
118
+ y_gate = np.array([r["kpis"][GATE_KPI] for r in recs], float)
119
+ ok = np.isfinite(y_gate)
120
+ f_gate = floor_all[GATE_KPI]["floor"]
121
+ surr_gate = S.fit(X[ok], y_gate[ok], box, noise_floor=f_gate)
122
+ print(
123
+ f" {GATE_KPI}: LOO nrmse {surr_gate.loo_error()['nrmse_range'] * 100:.2f}%, "
124
+ f"floor {f_gate:.4g} K, observed {y_gate[ok].min():.1f}..{y_gate[ok].max():.1f} K"
125
+ )
126
+
127
+ # ---- submit refinement runs -------------------------------------
128
+ if not args.no_run:
129
+ cand, diag = pick(surr_gate, box, n_ref, NO_FLOW_END_MIN_K, f_gate)
130
+ cols = scale_to_box(cand, {v: tuple(box[i]) for i, v in enumerate(VARS)}, VARS)
131
+ specs = [
132
+ RunSpec(
133
+ melt_C=float(cols["melt_C"][i]),
134
+ wall_C=float(cols["wall_C"][i]),
135
+ flow_cm3_s=round(float(cols["flow_cm3_s"][i]), 4),
136
+ stage=STAGE,
137
+ label=f"refine{i:04d}",
138
+ meta={"acquisition": True},
139
+ )
140
+ for i in range(len(cand))
141
+ ]
142
+ print(
143
+ f" {len(specs)} refinement points selected "
144
+ f"(uncertainty x boundary-proximity within ~2 floors of the {NO_FLOW_END_MIN_K} K gate)"
145
+ )
146
+ campaign.run_batch(
147
+ specs,
148
+ stage=STAGE,
149
+ max_workers=int(cfg["solver"]["max_concurrency"]),
150
+ description=f"G3 adaptive refinement near the {GATE_KPI} = {NO_FLOW_END_MIN_K} K feasibility boundary.",
151
+ )
152
+ recs = load_records(runs_dir, SWEEP_STAGE) + load_records(runs_dir, STAGE)
153
+ X = np.array([[r["spec"][v] for v in VARS] for r in recs], float)
154
+ y_gate = np.array([r["kpis"][GATE_KPI] for r in recs], float)
155
+ ok = np.isfinite(y_gate)
156
+ surr_gate = S.fit(X[ok], y_gate[ok], box, noise_floor=f_gate)
157
+ print(f" refit on {len(recs)} runs: LOO nrmse {surr_gate.loo_error()['nrmse_range'] * 100:.2f}%")
158
+
159
+ # ---- green region + Chebyshev centre ----------------------------
160
+ # Every criterion becomes a Constraint carrying its own threshold and its own derived
161
+ # resolution band, so the solver can tell which one actually binds at the centre and take
162
+ # the margin from that one. Amber quality cuts are percentiles of the observed sample
163
+ # (rank-based, per guardrail 1).
164
+ y_hot = np.array([r["kpis"]["hotspot"] for r in recs], float)
165
+ y_p80 = np.array([r["kpis"]["p80_bar"] for r in recs], float)
166
+ surr_hot = S.fit(X, y_hot, box, noise_floor=floor_all["hotspot"]["floor"])
167
+ surr_p80 = S.fit(X, y_p80, box, noise_floor=floor_all["p80_bar"]["floor"])
168
+ hot_cut = float(np.quantile(y_hot, 0.90))
169
+ p80_cut = float(np.quantile(y_p80, 0.90))
170
+
171
+ cons = [
172
+ C.Constraint(
173
+ GATE_KPI,
174
+ surr_gate,
175
+ NO_FLOW_END_MIN_K,
176
+ "min",
177
+ float(f_gate),
178
+ effect_span=np.array(S.effect_span(surr_gate, len(VARS)), float),
179
+ ),
180
+ C.Constraint(
181
+ "hotspot",
182
+ surr_hot,
183
+ hot_cut,
184
+ "max",
185
+ float(floor_all["hotspot"]["floor"]),
186
+ effect_span=np.array(S.effect_span(surr_hot, len(VARS)), float),
187
+ ),
188
+ C.Constraint(
189
+ "p80_bar",
190
+ surr_p80,
191
+ p80_cut,
192
+ "max",
193
+ float(floor_all["p80_bar"]["floor"]),
194
+ effect_span=np.array(S.effect_span(surr_p80, len(VARS)), float),
195
+ ),
196
+ ]
197
+ for c in cons:
198
+ print(
199
+ f" constraint {c.name:18s} {c.sense} {c.threshold:9.4g} band {c.band:.4g} "
200
+ "span " + ", ".join(f"{v}={c.effect_span[i]:.3g}" for i, v in enumerate(VARS))
201
+ )
202
+
203
+ g = C.solve(constraints=cons, box=box, var_names=VARS, grid_n=args.grid)
204
+
205
+ print(f"\n green region: {100 * g.feasible_fraction:.1f}% of the box (grid {args.grid}^{len(VARS)})")
206
+ print(f" binding constraint at centre: {g.binding_constraint}")
207
+ for note in g.notes:
208
+ print(f" NOTE: {note}")
209
+ if np.all(np.isfinite(g.centre_phys)):
210
+ print(f"\n Chebyshev centre (deepest interior point, inradius {g.inradius_unit:.3f} normalised):")
211
+ for i, v in enumerate(VARS):
212
+ print(f" {v:12s} {g.centre_phys[i]:8.3f} margin +/- {g.margin_phys[i]:.3f}")
213
+ print("\n recommended box after inward shrink by the margin:")
214
+ for i, v in enumerate(VARS):
215
+ print(f" {v:12s} {g.shrunk_box_phys[i, 0]:8.3f} .. {g.shrunk_box_phys[i, 1]:8.3f}")
216
+ print(f" limiting variable: {g.limiting_variable}")
217
+
218
+ out = {
219
+ "stage": STAGE,
220
+ "generated_at": datetime.now(UTC).isoformat(),
221
+ "n_runs_used": len(recs),
222
+ "gate_kpi": GATE_KPI,
223
+ "gate_value_K": NO_FLOW_END_MIN_K,
224
+ "binding_constraint": g.binding_constraint,
225
+ "slack_at_centre_bands": {k: round(v, 3) for k, v in g.slack_at_centre.items()},
226
+ "band_basis": (
227
+ "each constraint carries its own derived resolution floor for this "
228
+ "part; the AI-to-numerical disagreement from G4 must be added once "
229
+ "available"
230
+ ),
231
+ "surrogates": {
232
+ GATE_KPI: {"loo": surr_gate.loo_error(), "floor": f_gate},
233
+ "hotspot": {"loo": surr_hot.loo_error(), "cut": round(hot_cut, 6)},
234
+ "p80_bar": {"loo": surr_p80.loo_error(), "cut_bar": round(p80_cut, 3)},
235
+ },
236
+ "green_fraction": round(g.feasible_fraction, 4),
237
+ "centre": {
238
+ "definition": "chebyshev_centre_shrunk",
239
+ **{v: round(float(g.centre_phys[i]), 4) for i, v in enumerate(VARS)},
240
+ },
241
+ "inradius_unit": round(g.inradius_unit, 5),
242
+ "margin_variable_space": {v: round(float(g.margin_phys[i]), 4) for i, v in enumerate(VARS)},
243
+ "constraints": [
244
+ {
245
+ "name": c.name,
246
+ "sense": c.sense,
247
+ "threshold": round(float(c.threshold), 6),
248
+ "band": round(float(c.band), 6),
249
+ "effect_span": {v: round(float(c.effect_span[i]), 4) for i, v in enumerate(VARS)},
250
+ }
251
+ for c in cons
252
+ ],
253
+ "recommended_box": {
254
+ v: [round(float(g.shrunk_box_phys[i, 0]), 4), round(float(g.shrunk_box_phys[i, 1]), 4)]
255
+ for i, v in enumerate(VARS)
256
+ },
257
+ "limiting_variable": g.limiting_variable,
258
+ "notes": g.notes,
259
+ }
260
+ (stage_dir / "g3_refinement.json").write_text(json.dumps(out, indent=2))
261
+ print(f"\n wrote {(stage_dir / 'g3_refinement.json').relative_to(ROOT)}")
262
+
263
+ if np.all(np.isfinite(g.centre_phys)):
264
+ campaign.note(
265
+ f"G3: green region {100 * g.feasible_fraction:.1f}% of the wide box over "
266
+ f"{len(recs)} AI runs. Chebyshev centre (deepest interior point, NOT the survivor "
267
+ f"mean) at melt {g.centre_phys[0]:.1f} C / wall {g.centre_phys[1]:.1f} C / "
268
+ f"Q {g.centre_phys[2]:.3f} cm3/s. Binding constraint: {g.binding_constraint}. "
269
+ f"Margin in variable space via that constraint's local gradient: "
270
+ f"+/-{g.margin_phys[0]:.2f} C, +/-{g.margin_phys[1]:.2f} C, "
271
+ f"+/-{g.margin_phys[2]:.3f} cm3/s. Limiting variable: {g.limiting_variable}."
272
+ )
273
+ finally:
274
+ info["api"].close()
275
+ return 0
276
+
277
+
278
+ if __name__ == "__main__":
279
+ raise SystemExit(main())