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