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,403 @@
|
|
|
1
|
+
"""G4: build and validate the numerical confirmation set.
|
|
2
|
+
|
|
3
|
+
Selects 12-16 points from the G3 result — the centre, the face points of the recommended box, and
|
|
4
|
+
deliberate probes just *outside* the green edge — and writes a complete, valid Cadmould `.rm1`
|
|
5
|
+
deck for each, on the **same mesh** as the AI runs so the comparison is apples to apples.
|
|
6
|
+
|
|
7
|
+
This is where the packing and cooling variables enter, and where **switchover becomes a real swept
|
|
8
|
+
input** rather than the read-out it was on the AI side. Switchover is encoded the way the solver
|
|
9
|
+
expresses it: the fill profile runs to level φ_s and the packing profile takes over
|
|
10
|
+
from there, so φ_s *is* the velocity-to-pressure handover point.
|
|
11
|
+
|
|
12
|
+
Scope for this session (user directive): the decks and submit specs are the deliverable; executing
|
|
13
|
+
them is not required and `.car` parsing is out of scope. "Validated" therefore means something
|
|
14
|
+
real and checkable without the solver — every deck is written, **read back with `Rm1Reader`, and
|
|
15
|
+
its process parameters compared field by field against what was intended**. A deck that does not
|
|
16
|
+
round-trip is a failure, not a stub.
|
|
17
|
+
|
|
18
|
+
Run: python main.py confirm configs/simple-plate.yaml
|
|
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
|
+
import cadmould
|
|
31
|
+
from cadmould import domain, io as cio, material as cmat, mesh as cmesh, tools
|
|
32
|
+
|
|
33
|
+
from .run_screening import VARS
|
|
34
|
+
from .setup_campaign import build, resolve_config
|
|
35
|
+
from .units import Process
|
|
36
|
+
|
|
37
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
38
|
+
|
|
39
|
+
STAGE = "G4_numerical_confirmation"
|
|
40
|
+
N_FILL_STEPS = 12
|
|
41
|
+
SWITCHOVER_VARIANTS = (0.92, 0.96, 0.99)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fill_profile_to(phi_s: float, rate_cm3_s: float, n: int = N_FILL_STEPS):
|
|
45
|
+
"""Constant-rate fill profile running from 0 to level ``phi_s``.
|
|
46
|
+
|
|
47
|
+
Switchover is the level at which velocity control stops, so a profile that ends at phi_s and
|
|
48
|
+
hands over to the pack profile *is* the switchover setting. Constant rate matches what the AI
|
|
49
|
+
solver could see, which keeps the comparison honest: the numerical deck is not quietly given a
|
|
50
|
+
better velocity profile than the AI runs that recommended it.
|
|
51
|
+
"""
|
|
52
|
+
levels = np.linspace(phi_s / n, phi_s, n)
|
|
53
|
+
steps = [domain.FillStep(float(level), float(rate_cm3_s)) for level in levels]
|
|
54
|
+
return domain.FillProfile(steps), levels
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def pack_profile(hold_bar: float, hold_s: float, n: int = 4):
|
|
58
|
+
"""Flat hold then a ramp down — the conventional shape.
|
|
59
|
+
|
|
60
|
+
``PackStep.time_s`` is a **cumulative schedule time, not a per-step duration**: the solver
|
|
61
|
+
rejects the profile with "step times must be strictly increasing" otherwise. That mirrors
|
|
62
|
+
``FillStep.level``, which is likewise an absolute position on an increasing axis rather than an
|
|
63
|
+
increment — both profiles are schedules, not lists of durations. Learned by having it rejected.
|
|
64
|
+
"""
|
|
65
|
+
hold_flat = 0.6 * hold_s # full pressure until gate seal
|
|
66
|
+
times = [hold_flat] + list(np.linspace(hold_flat, hold_s, n)[1:])
|
|
67
|
+
press = [hold_bar] + [hold_bar * (1.0 - i / (n - 1)) for i in range(1, n - 1)] + [0.0]
|
|
68
|
+
steps = [domain.PackStep(float(p), float(t)) for p, t in zip(press, times, strict=True)]
|
|
69
|
+
# Strictly increasing times, defensively — a silently accepted bad profile is worse than a raise.
|
|
70
|
+
for a, b in zip(steps, steps[1:], strict=False): # deliberately offset by one
|
|
71
|
+
if not float(b.time_s) > float(a.time_s):
|
|
72
|
+
raise ValueError(f"pack profile times not strictly increasing: {[float(s.time_s) for s in steps]}")
|
|
73
|
+
return domain.PackProfile(steps)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_deck(
|
|
77
|
+
*,
|
|
78
|
+
out: Path,
|
|
79
|
+
parts,
|
|
80
|
+
material,
|
|
81
|
+
gate_mm,
|
|
82
|
+
melt_C: float,
|
|
83
|
+
wall_C: float,
|
|
84
|
+
rate_cm3_s: float,
|
|
85
|
+
phi_s: float,
|
|
86
|
+
hold_bar: float,
|
|
87
|
+
hold_s: float,
|
|
88
|
+
cool_s: float,
|
|
89
|
+
) -> dict:
|
|
90
|
+
"""Write one .rm1 and report what was requested. Falls back if packing is unlicensed."""
|
|
91
|
+
fp, levels = fill_profile_to(phi_s, rate_cm3_s)
|
|
92
|
+
gates = domain.GatePoints.at(float(gate_mm[0]), float(gate_mm[1]), float(gate_mm[2]))
|
|
93
|
+
warn = []
|
|
94
|
+
|
|
95
|
+
def _mk(with_pack: bool):
|
|
96
|
+
b = (
|
|
97
|
+
domain.SimulationConfig.Builder(parts)
|
|
98
|
+
.with_material(material, melt_temp=float(melt_C)) # degC
|
|
99
|
+
.with_injection(gates)
|
|
100
|
+
.with_wall_temperature(domain.ConstantWallTemp(temp=float(wall_C))) # degC
|
|
101
|
+
.with_filling(fp)
|
|
102
|
+
.with_cooling(float(cool_s))
|
|
103
|
+
)
|
|
104
|
+
if with_pack:
|
|
105
|
+
b = b.with_packing(pack_profile(hold_bar, hold_s))
|
|
106
|
+
return b.build()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
cfg = _mk(True)
|
|
110
|
+
packed = True
|
|
111
|
+
except Exception as exc:
|
|
112
|
+
# with_packing requires LICENCE_PACK. Report it rather than silently shipping a
|
|
113
|
+
# fill-only deck labelled as a packing study.
|
|
114
|
+
warn.append(
|
|
115
|
+
f"packing not applied ({type(exc).__name__}: {exc}); deck is FILL-ONLY and "
|
|
116
|
+
"the holding-pressure variables are recorded in the spec but not in the rm1"
|
|
117
|
+
)
|
|
118
|
+
cfg = _mk(False)
|
|
119
|
+
packed = False
|
|
120
|
+
|
|
121
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
cio.Rm1Writer().save(cfg, str(out))
|
|
123
|
+
return {
|
|
124
|
+
"packing_in_deck": packed,
|
|
125
|
+
"warnings": warn,
|
|
126
|
+
"fill_levels": [round(float(x), 4) for x in levels],
|
|
127
|
+
"bytes": out.stat().st_size,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def validate_deck(path: Path, intended: dict) -> dict:
|
|
132
|
+
"""Read the deck back and compare its process parameters with what was intended."""
|
|
133
|
+
checks: dict = {}
|
|
134
|
+
try:
|
|
135
|
+
cfg = cio.Rm1Reader().load(str(path))
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
return {"round_trip": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
138
|
+
checks["round_trip"] = True
|
|
139
|
+
try:
|
|
140
|
+
pp = cfg.process_params
|
|
141
|
+
got_melt = float(pp.melt_temperature)
|
|
142
|
+
got_wall = float(pp.wall_temperature)
|
|
143
|
+
# The reader may return either unit; accept whichever matches and record which, rather
|
|
144
|
+
# than assuming — this is exactly the K/degC boundary that breaks silently.
|
|
145
|
+
for name, got, want in (("melt", got_melt, intended["melt_C"]), ("wall", got_wall, intended["wall_C"])):
|
|
146
|
+
if abs(got - want) < 0.51:
|
|
147
|
+
checks[f"{name}_ok"] = True
|
|
148
|
+
checks[f"{name}_unit_read"] = "degC"
|
|
149
|
+
elif abs(got - (want + 273.15)) < 0.51:
|
|
150
|
+
checks[f"{name}_ok"] = True
|
|
151
|
+
checks[f"{name}_unit_read"] = "K"
|
|
152
|
+
else:
|
|
153
|
+
checks[f"{name}_ok"] = False
|
|
154
|
+
checks[f"{name}_read"] = round(got, 3)
|
|
155
|
+
checks[f"{name}_wanted_degC"] = round(want, 3)
|
|
156
|
+
fp = pp.fill_profile
|
|
157
|
+
steps = list(fp.steps) if fp is not None else []
|
|
158
|
+
checks["n_fill_steps"] = len(steps)
|
|
159
|
+
if steps:
|
|
160
|
+
last = float(steps[-1].level)
|
|
161
|
+
checks["switchover_level_read"] = round(last, 4)
|
|
162
|
+
checks["switchover_ok"] = abs(last - intended["phi_s"]) < 1e-3
|
|
163
|
+
rates = {round(float(s.rate), 4) for s in steps}
|
|
164
|
+
checks["constant_rate"] = len(rates) == 1
|
|
165
|
+
checks["rate_read"] = round(float(steps[-1].rate), 4)
|
|
166
|
+
checks["rate_ok"] = abs(float(steps[-1].rate) - intended["rate_cm3_s"]) < 1e-3
|
|
167
|
+
checks["n_parts"] = len(cfg.parts) if cfg.parts is not None else 0
|
|
168
|
+
checks["has_material"] = cfg.material is not None
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
checks["inspect_error"] = f"{type(exc).__name__}: {exc}"
|
|
171
|
+
checks["all_ok"] = all(v for k, v in checks.items() if k.endswith("_ok") or k == "round_trip")
|
|
172
|
+
return checks
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main() -> int:
|
|
176
|
+
ap = argparse.ArgumentParser(description="G4 numerical confirmation decks")
|
|
177
|
+
ap.add_argument(
|
|
178
|
+
"config",
|
|
179
|
+
nargs="?",
|
|
180
|
+
help="campaign config; defaults to configs/simple-plate.yaml while that is unambiguous",
|
|
181
|
+
)
|
|
182
|
+
args = ap.parse_args()
|
|
183
|
+
|
|
184
|
+
campaign, info = build(resolve_config(args.config))
|
|
185
|
+
try:
|
|
186
|
+
stage_dir, runs_dir = info["stage_dir"], info["runs_dir"]
|
|
187
|
+
g3_path = stage_dir / "g3_refinement.json"
|
|
188
|
+
if not g3_path.exists():
|
|
189
|
+
print(f" need {g3_path.relative_to(ROOT)} first — run `python main.py refine`")
|
|
190
|
+
return 1
|
|
191
|
+
g3 = json.loads(g3_path.read_text())
|
|
192
|
+
centre = {v: float(g3["centre"][v]) for v in VARS}
|
|
193
|
+
rbox = {v: [float(x) for x in g3["recommended_box"][v]] for v in VARS}
|
|
194
|
+
wide = {v: [float(x) for x in np.array(info["box"][v], float)] for v in VARS}
|
|
195
|
+
vol = info["volume_cm3"]
|
|
196
|
+
cool_s = info["sdk_cooling_time_s"]
|
|
197
|
+
|
|
198
|
+
# Packing nominals from the SDK, not invented.
|
|
199
|
+
with cadmould.Session.user_based():
|
|
200
|
+
raw_mat = cmat.io.PlbReader().load(
|
|
201
|
+
str(
|
|
202
|
+
Path(info["cfg"]["material"]["plb"])
|
|
203
|
+
if Path(info["cfg"]["material"]["plb"]).is_absolute()
|
|
204
|
+
else ROOT / info["cfg"]["material"]["plb"]
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
parts = cmesh.io.CfexReader().load(str(info["cfex"]))
|
|
208
|
+
pk = tools.suggest_pack_profile(parts[0], raw_mat)
|
|
209
|
+
pk_steps = [(float(s.pressure_bar), float(s.time_s)) for s in pk.steps]
|
|
210
|
+
hold_bar = max(p for p, _ in pk_steps)
|
|
211
|
+
hold_s = sum(t for _, t in pk_steps)
|
|
212
|
+
print(
|
|
213
|
+
f" SDK pack suggestion: {len(pk_steps)} steps, peak {hold_bar:.0f} bar, "
|
|
214
|
+
f"total {hold_s:.2f} s; cooling {cool_s:.2f} s"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# ---- the point set -------------------------------------------
|
|
218
|
+
pts: list[dict] = []
|
|
219
|
+
|
|
220
|
+
def add(pid, role, melt, wall, flow, phi_s=0.96, hb=None, hs=None, note=""):
|
|
221
|
+
pts.append(
|
|
222
|
+
{
|
|
223
|
+
"id": pid,
|
|
224
|
+
"role": role,
|
|
225
|
+
"melt_C": round(float(melt), 3),
|
|
226
|
+
"wall_C": round(float(wall), 3),
|
|
227
|
+
"flow_cm3_s": round(float(flow), 4),
|
|
228
|
+
"phi_s": float(phi_s),
|
|
229
|
+
"hold_bar": float(hb if hb is not None else hold_bar),
|
|
230
|
+
"hold_s": float(hs if hs is not None else hold_s),
|
|
231
|
+
"cool_s": float(cool_s),
|
|
232
|
+
"note": note,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
add(
|
|
237
|
+
"C0",
|
|
238
|
+
"centre",
|
|
239
|
+
centre["melt_C"],
|
|
240
|
+
centre["wall_C"],
|
|
241
|
+
centre["flow_cm3_s"],
|
|
242
|
+
note="Chebyshev centre of the green region, shrunk by the margin",
|
|
243
|
+
)
|
|
244
|
+
for v in VARS:
|
|
245
|
+
for side, i in (("lo", 0), ("hi", 1)):
|
|
246
|
+
p = dict(centre)
|
|
247
|
+
p[v] = rbox[v][i]
|
|
248
|
+
add(
|
|
249
|
+
f"F_{v}_{side}",
|
|
250
|
+
"face",
|
|
251
|
+
p["melt_C"],
|
|
252
|
+
p["wall_C"],
|
|
253
|
+
p["flow_cm3_s"],
|
|
254
|
+
note=f"face point of the recommended box: {v} at its {side} bound",
|
|
255
|
+
)
|
|
256
|
+
# Probes deliberately OUTSIDE the green edge, to test whether the gate is too
|
|
257
|
+
# conservative. If these fill cleanly, the bound moves outward next campaign — but
|
|
258
|
+
# per the brief, disagreement only ever steps the bounds INWARD, never outward,
|
|
259
|
+
# so a clean probe is recorded as evidence, not acted on unilaterally.
|
|
260
|
+
add(
|
|
261
|
+
"P1",
|
|
262
|
+
"probe_outside",
|
|
263
|
+
wide["melt_C"][0],
|
|
264
|
+
centre["wall_C"],
|
|
265
|
+
wide["flow_cm3_s"][0],
|
|
266
|
+
note="coldest melt + slowest flow: the freeze-off corner, outside the green edge",
|
|
267
|
+
)
|
|
268
|
+
add(
|
|
269
|
+
"P2",
|
|
270
|
+
"probe_outside",
|
|
271
|
+
wide["melt_C"][0],
|
|
272
|
+
wide["wall_C"][0],
|
|
273
|
+
wide["flow_cm3_s"][0],
|
|
274
|
+
note="coldest everything: worst case in the wide box",
|
|
275
|
+
)
|
|
276
|
+
add(
|
|
277
|
+
"P3",
|
|
278
|
+
"probe_outside",
|
|
279
|
+
centre["melt_C"],
|
|
280
|
+
centre["wall_C"],
|
|
281
|
+
wide["flow_cm3_s"][1],
|
|
282
|
+
note="fastest flow: shear/pressure edge rather than the thermal edge",
|
|
283
|
+
)
|
|
284
|
+
# Switchover is a genuinely swept variable here (D2).
|
|
285
|
+
for phi in SWITCHOVER_VARIANTS:
|
|
286
|
+
add(
|
|
287
|
+
f"S_{int(phi * 100)}",
|
|
288
|
+
"switchover",
|
|
289
|
+
centre["melt_C"],
|
|
290
|
+
centre["wall_C"],
|
|
291
|
+
centre["flow_cm3_s"],
|
|
292
|
+
phi_s=phi,
|
|
293
|
+
note=f"centre with velocity-to-pressure switchover at phi={phi}",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
print(
|
|
297
|
+
f"\n {len(pts)} confirmation points "
|
|
298
|
+
f"({sum(1 for p in pts if p['role'] == 'face')} face, "
|
|
299
|
+
f"{sum(1 for p in pts if p['role'] == 'probe_outside')} outside-probes, "
|
|
300
|
+
f"{sum(1 for p in pts if p['role'] == 'switchover')} switchover)"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# ---- build + validate every deck -----------------------------
|
|
304
|
+
deck_dir = runs_dir / STAGE
|
|
305
|
+
results = []
|
|
306
|
+
for p in pts:
|
|
307
|
+
# Unit-checked: the AI side is Kelvin, the rm1 builder is Celsius.
|
|
308
|
+
proc = Process(melt_C=p["melt_C"], wall_C=p["wall_C"], flow_cm3_s=p["flow_cm3_s"], volume_cm3=vol)
|
|
309
|
+
rm1 = deck_dir / f"{p['id']}.rm1"
|
|
310
|
+
bi = build_deck(
|
|
311
|
+
out=rm1,
|
|
312
|
+
parts=parts,
|
|
313
|
+
material=raw_mat,
|
|
314
|
+
gate_mm=info["gates_mm"][0],
|
|
315
|
+
melt_C=proc.melt_C,
|
|
316
|
+
wall_C=proc.wall_C,
|
|
317
|
+
rate_cm3_s=proc.flow_cm3_s,
|
|
318
|
+
phi_s=p["phi_s"],
|
|
319
|
+
hold_bar=p["hold_bar"],
|
|
320
|
+
hold_s=p["hold_s"],
|
|
321
|
+
cool_s=p["cool_s"],
|
|
322
|
+
)
|
|
323
|
+
val = validate_deck(
|
|
324
|
+
rm1,
|
|
325
|
+
{"melt_C": proc.melt_C, "wall_C": proc.wall_C, "phi_s": p["phi_s"], "rate_cm3_s": proc.flow_cm3_s},
|
|
326
|
+
)
|
|
327
|
+
results.append(
|
|
328
|
+
{
|
|
329
|
+
**p,
|
|
330
|
+
"fill_time_s": proc.fill_time_s,
|
|
331
|
+
"rm1": str(rm1.relative_to(runs_dir)),
|
|
332
|
+
"build": bi,
|
|
333
|
+
"validation": val,
|
|
334
|
+
"submit": {
|
|
335
|
+
"endpoint": "cadmould.cloud.simulation.create_simulation",
|
|
336
|
+
"rest": "POST /api/v1/simulations then .../submit",
|
|
337
|
+
"tier": 1,
|
|
338
|
+
"poll_interval_s": 15,
|
|
339
|
+
"timeout_s": 7200,
|
|
340
|
+
"status": "prepared",
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
)
|
|
344
|
+
ok = "OK " if val.get("all_ok") else "FAIL"
|
|
345
|
+
print(
|
|
346
|
+
f" {ok} {p['id']:16s} melt {proc.melt_C:6.1f}C wall {proc.wall_C:5.1f}C "
|
|
347
|
+
f"Q {proc.flow_cm3_s:6.3f} phi_s {p['phi_s']:.2f} "
|
|
348
|
+
f"{bi['bytes'] / 1000:6.1f} kB" + ("" if val.get("all_ok") else f" {val}")
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
n_ok = sum(1 for r in results if r["validation"].get("all_ok"))
|
|
352
|
+
n_pack = sum(1 for r in results if r["build"]["packing_in_deck"])
|
|
353
|
+
print(f"\n {n_ok}/{len(results)} decks validated by Rm1Writer -> Rm1Reader round trip")
|
|
354
|
+
print(f" {n_pack}/{len(results)} decks carry the packing profile")
|
|
355
|
+
if n_pack == 0:
|
|
356
|
+
print(
|
|
357
|
+
" NOTE: packing is unlicensed here (LICENCE_PACK) — decks are fill-only and the "
|
|
358
|
+
"holding-pressure/cooling variables are recorded in the specs only."
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
out = {
|
|
362
|
+
"stage": STAGE,
|
|
363
|
+
"generated_at": datetime.now(UTC).isoformat(),
|
|
364
|
+
"mesh": "same_as_ai_runs",
|
|
365
|
+
"cfex": str(info["cfex"].name),
|
|
366
|
+
"geometry_id_ai": info["geometry_id"],
|
|
367
|
+
"material": {
|
|
368
|
+
"plb": info["cfg"]["material"]["plb"],
|
|
369
|
+
"is_placeholder": bool(info["cfg"]["material"].get("is_placeholder")),
|
|
370
|
+
},
|
|
371
|
+
"pack_nominal": {
|
|
372
|
+
"source": "tools.suggest_pack_profile",
|
|
373
|
+
"steps_bar_s": pk_steps,
|
|
374
|
+
"peak_bar": hold_bar,
|
|
375
|
+
"total_s": hold_s,
|
|
376
|
+
},
|
|
377
|
+
"cooling_nominal_s": {"value": cool_s, "source": "tools.suggest_cooling_time"},
|
|
378
|
+
"switchover_variants": list(SWITCHOVER_VARIANTS),
|
|
379
|
+
"inward_only_rule": (
|
|
380
|
+
"Where a numerical result disagrees with the AI ordering, step the bounds INWARD, never outward."
|
|
381
|
+
),
|
|
382
|
+
"validation_summary": {"decks": len(results), "round_trip_ok": n_ok, "with_packing": n_pack},
|
|
383
|
+
"points": results,
|
|
384
|
+
}
|
|
385
|
+
(stage_dir / "g4_confirmation.json").write_text(json.dumps(out, indent=2))
|
|
386
|
+
print(f" wrote {(stage_dir / 'g4_confirmation.json').relative_to(ROOT)}")
|
|
387
|
+
|
|
388
|
+
campaign.note(
|
|
389
|
+
f"G4: {len(results)} numerical confirmation decks built on the SAME mesh as the AI runs "
|
|
390
|
+
f"({n_ok}/{len(results)} validated by rm1 write->read round trip). Centre + "
|
|
391
|
+
f"{sum(1 for p in pts if p['role'] == 'face')} face points + "
|
|
392
|
+
f"{sum(1 for p in pts if p['role'] == 'probe_outside')} deliberate probes outside the "
|
|
393
|
+
f"green edge + {len(SWITCHOVER_VARIANTS)} switchover settings. Packing "
|
|
394
|
+
f"{'included' if n_pack else 'NOT licensed here, recorded in specs only'}; cooling "
|
|
395
|
+
f"{cool_s:.2f} s from tools.suggest_cooling_time. Decks are prepared, not submitted."
|
|
396
|
+
)
|
|
397
|
+
finally:
|
|
398
|
+
info["api"].close()
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
if __name__ == "__main__":
|
|
403
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Derive THIS part's resolution floor per KPI, empirically.
|
|
2
|
+
|
|
3
|
+
Guardrail 2 forbids importing the reference campaign's ±1.2 bar / 2 bar figures — they are
|
|
4
|
+
part-specific. So: perturb the inputs by amounts no machine could resolve (0.02–0.10 K on
|
|
5
|
+
temperatures, 0.02–0.1 % on flow — two orders of magnitude below what a production machine
|
|
6
|
+
holds) and measure how far each KPI moves. That spread is the floor below which an apparent
|
|
7
|
+
effect is not an effect.
|
|
8
|
+
|
|
9
|
+
What this is not. The solver is deterministic, so there is no run-to-run scatter and a zero
|
|
10
|
+
perturbation would return a bit-identical result. The spread measured here is **float16 storage
|
|
11
|
+
quantization of the result fields plus the model's own conditioning** — a genuine resolution
|
|
12
|
+
floor, but not something more runs could average away. The dossier must say so; calling it
|
|
13
|
+
"noise" invites exactly the wrong inference.
|
|
14
|
+
|
|
15
|
+
Run: python main.py epsilon-floor [configs/simple-plate.yaml]
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
from .design import epsilon_design
|
|
28
|
+
from .harness import RunSpec
|
|
29
|
+
from .setup_campaign import build, resolve_config
|
|
30
|
+
|
|
31
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
32
|
+
|
|
33
|
+
STAGE = "G1a_epsilon_floor"
|
|
34
|
+
|
|
35
|
+
# KPIs the floor is derived for, with the unit each is reported in. p80 is the only one in
|
|
36
|
+
# physical units; the rest are dimensionless by construction (guardrail 1).
|
|
37
|
+
KPI_UNITS = {
|
|
38
|
+
"p80_bar": "bar",
|
|
39
|
+
"no_flow_margin": "-",
|
|
40
|
+
"no_flow_margin_K": "K",
|
|
41
|
+
"no_flow_margin_end_K": "K",
|
|
42
|
+
"frozen_fraction_end": "-",
|
|
43
|
+
"weld_front_temp_K": "K",
|
|
44
|
+
"weld_front_temp_ratio": "-",
|
|
45
|
+
"hotspot": "-",
|
|
46
|
+
"fill_residual": "-",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def summarise(records) -> dict:
|
|
51
|
+
"""Per-KPI spread across the epsilon set, plus the input span that produced it."""
|
|
52
|
+
vals: dict[str, list[float]] = {k: [] for k in KPI_UNITS}
|
|
53
|
+
for r in records:
|
|
54
|
+
if not r.ok:
|
|
55
|
+
continue
|
|
56
|
+
for k in KPI_UNITS:
|
|
57
|
+
v = r.kpis.get(k)
|
|
58
|
+
if v is not None and np.isfinite(v):
|
|
59
|
+
vals[k].append(float(v))
|
|
60
|
+
|
|
61
|
+
out: dict[str, dict] = {}
|
|
62
|
+
for k, unit in KPI_UNITS.items():
|
|
63
|
+
a = np.asarray(vals[k], dtype=float)
|
|
64
|
+
if a.size < 3:
|
|
65
|
+
out[k] = {
|
|
66
|
+
"basis": "derived",
|
|
67
|
+
"n_runs": int(a.size),
|
|
68
|
+
"floor": None,
|
|
69
|
+
"unit": unit,
|
|
70
|
+
"note": "too few successful runs",
|
|
71
|
+
}
|
|
72
|
+
continue
|
|
73
|
+
rng = float(a.max() - a.min())
|
|
74
|
+
out[k] = {
|
|
75
|
+
"basis": "derived",
|
|
76
|
+
"n_runs": int(a.size),
|
|
77
|
+
"unit": unit,
|
|
78
|
+
# The floor is the full observed range, not a standard deviation: with a
|
|
79
|
+
# deterministic solver this is a bound on how far the output can move for an input
|
|
80
|
+
# change that means nothing, and a bound is what an inert-variable test needs.
|
|
81
|
+
"floor": round(rng, 6),
|
|
82
|
+
"range": [round(float(a.min()), 6), round(float(a.max()), 6)],
|
|
83
|
+
"median": round(float(np.median(a)), 6),
|
|
84
|
+
"std": round(float(a.std(ddof=1)), 6),
|
|
85
|
+
"rel_floor_pct": (round(100.0 * rng / abs(float(np.median(a))), 4) if np.median(a) else None),
|
|
86
|
+
}
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> int:
|
|
91
|
+
ap = argparse.ArgumentParser(description="G1a resolution floor")
|
|
92
|
+
ap.add_argument(
|
|
93
|
+
"config",
|
|
94
|
+
nargs="?",
|
|
95
|
+
help="campaign config; defaults to configs/simple-plate.yaml while that is unambiguous",
|
|
96
|
+
)
|
|
97
|
+
args = ap.parse_args()
|
|
98
|
+
cfg_path = resolve_config(args.config)
|
|
99
|
+
campaign, info = build(cfg_path)
|
|
100
|
+
try:
|
|
101
|
+
centre = {
|
|
102
|
+
"melt_C": info["card"].melt.rec_C,
|
|
103
|
+
"wall_C": info["card"].mould.rec_C,
|
|
104
|
+
"flow_cm3_s": info["nominal_flow_cm3_s"],
|
|
105
|
+
}
|
|
106
|
+
print(
|
|
107
|
+
f"\n centre: melt {centre['melt_C']:.1f} C, wall {centre['wall_C']:.1f} C, "
|
|
108
|
+
f"Q {centre['flow_cm3_s']:.2f} cm3/s"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
pts = epsilon_design(centre)
|
|
112
|
+
specs = [
|
|
113
|
+
RunSpec(
|
|
114
|
+
melt_C=p["melt_C"],
|
|
115
|
+
wall_C=p["wall_C"],
|
|
116
|
+
flow_cm3_s=p["flow_cm3_s"],
|
|
117
|
+
stage=STAGE,
|
|
118
|
+
label=p["label"],
|
|
119
|
+
keep_h5=(p["kind"] == "nominal"), # keep the nominal as an anchor
|
|
120
|
+
meta={"kind": p["kind"]},
|
|
121
|
+
)
|
|
122
|
+
for p in pts
|
|
123
|
+
]
|
|
124
|
+
print(
|
|
125
|
+
f" {len(specs)} epsilon points (perturbations <= 0.1 K and <= 0.1 % — "
|
|
126
|
+
"two orders of magnitude below machine control)"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
records = campaign.run_batch(
|
|
130
|
+
specs,
|
|
131
|
+
stage=STAGE,
|
|
132
|
+
max_workers=int(info["cfg"]["solver"]["max_concurrency"]),
|
|
133
|
+
description="Epsilon-input micro experiment: physically meaningless input deltas, "
|
|
134
|
+
"to derive this part's per-KPI resolution floor (guardrail 2).",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
floor = summarise(records)
|
|
138
|
+
print("\n derived floor per KPI:")
|
|
139
|
+
for k, d in floor.items():
|
|
140
|
+
if d.get("floor") is None:
|
|
141
|
+
print(f" {k:22s} n={d['n_runs']:2d} (insufficient)")
|
|
142
|
+
continue
|
|
143
|
+
rel = f"{d['rel_floor_pct']:.3f}%" if d["rel_floor_pct"] is not None else "n/a"
|
|
144
|
+
print(
|
|
145
|
+
f" {k:22s} floor={d['floor']:<12.6g} {d['unit']:4s} "
|
|
146
|
+
f"(median {d['median']:.4g}, rel {rel}, n={d['n_runs']})"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
out = {
|
|
150
|
+
"stage": STAGE,
|
|
151
|
+
"generated_at": datetime.now(UTC).isoformat(),
|
|
152
|
+
"method": "epsilon_input_micro_experiment",
|
|
153
|
+
"mechanism": (
|
|
154
|
+
"Solver is deterministic; this is float16 storage quantization of the "
|
|
155
|
+
"result fields plus model conditioning, NOT run-to-run scatter. "
|
|
156
|
+
"Replicates carry zero information."
|
|
157
|
+
),
|
|
158
|
+
"perturbations": {"melt_K": [0.02, 0.05, 0.10], "wall_K": [0.02, 0.05, 0.10], "flow_rel": [2e-4, 1e-3]},
|
|
159
|
+
"justification": (
|
|
160
|
+
"A production machine holds melt to ~+/-1-2 K and mould to ~+/-1 K; "
|
|
161
|
+
"these deltas are two orders of magnitude smaller, so any output "
|
|
162
|
+
"movement they cause cannot be a real process effect."
|
|
163
|
+
),
|
|
164
|
+
"centre": centre,
|
|
165
|
+
"per_kpi": floor,
|
|
166
|
+
"runs": [
|
|
167
|
+
{
|
|
168
|
+
"run_id": r.run_id,
|
|
169
|
+
"label": r.spec.get("label"),
|
|
170
|
+
"kind": (r.spec.get("meta") or {}).get("kind"),
|
|
171
|
+
"simulation_id": r.simulation_id,
|
|
172
|
+
"ok": r.ok,
|
|
173
|
+
"error": r.error or None,
|
|
174
|
+
"kpis": {k: (r.kpis or {}).get(k) for k in KPI_UNITS} if r.ok else None,
|
|
175
|
+
}
|
|
176
|
+
for r in records
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
dest = info["stage_dir"] / "noise_floor.json"
|
|
180
|
+
dest.write_text(json.dumps(out, indent=2))
|
|
181
|
+
print(f"\n wrote {dest.relative_to(ROOT)}")
|
|
182
|
+
|
|
183
|
+
p80 = floor.get("p80_bar", {})
|
|
184
|
+
if p80.get("floor") is not None:
|
|
185
|
+
campaign.note(
|
|
186
|
+
f"Noise floor DERIVED for this part from {p80['n_runs']} epsilon-input runs "
|
|
187
|
+
f"(perturbations <=0.1 K / <=0.1 %): p80 floor = {p80['floor']:.3g} bar "
|
|
188
|
+
f"({p80['rel_floor_pct']:.3g} % of median). Mechanism is float16 quantization + "
|
|
189
|
+
f"model conditioning, not scatter — the solver is deterministic. Reference "
|
|
190
|
+
f"campaign's +/-1.2 bar / 2 bar figures deliberately NOT imported (guardrail 2)."
|
|
191
|
+
)
|
|
192
|
+
finally:
|
|
193
|
+
info["api"].close()
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
raise SystemExit(main())
|