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