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,113 @@
1
+ """Checks on the Sobol generator, before spending 1 024 cloud runs on its output.
2
+
3
+ Verifies the properties the campaign actually relies on rather than reimplementing a
4
+ reference: extensibility (the reason Sobol was chosen over LHS), prefix balance, and
5
+ projection uniformity. Run: python -m process_window.test_design
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from .design import epsilon_design, sobol
13
+
14
+ FAILED: list[str] = []
15
+
16
+
17
+ def check(name: str, cond: bool, detail: str = "") -> None:
18
+ print(f" {'PASS' if cond else 'FAIL'} {name}{' ' + detail if detail else ''}")
19
+ if not cond:
20
+ FAILED.append(name)
21
+
22
+
23
+ def main() -> int:
24
+ print("Sobol generator")
25
+
26
+ # The property that makes the sweep stoppable and extensible: a longer draw must begin
27
+ # with the shorter one. Without this, "continue the sweep" would mean "start over".
28
+ a, b = sobol(256, 3), sobol(1024, 3)
29
+ check(
30
+ "extensible: sobol(1024)[:256] == sobol(256)",
31
+ np.allclose(a, b[:256]),
32
+ f"max diff {np.abs(a - b[:256]).max():.2e}",
33
+ )
34
+
35
+ pts = sobol(1024, 3)
36
+ check("shape", pts.shape == (1024, 3), str(pts.shape))
37
+ check("inside [0,1)", bool(pts.min() >= 0.0 and pts.max() < 1.0), f"[{pts.min():.4f}, {pts.max():.4f}]")
38
+
39
+ # Balance: for a 2^k prefix, each of the 2^k equal bins of every 1-D projection holds
40
+ # exactly one point. This is the defining Sobol property and a strong correctness test —
41
+ # a wrong direction-number table fails it immediately.
42
+ ok = True
43
+ for k in (4, 6, 8, 10):
44
+ n = 2**k
45
+ p = sobol(n, 3)
46
+ for d in range(3):
47
+ counts = np.bincount((p[:, d] * n).astype(int), minlength=n)
48
+ ok &= bool((counts == 1).all())
49
+ check("every 2^k prefix is 1-per-bin in each projection", ok)
50
+
51
+ # And confirm the documented cost of dropping index 0, so the choice of skip=0 stays a
52
+ # measured decision rather than a preference.
53
+ p = sobol(64, 3, skip=1)
54
+ c = np.bincount((p[:, 0] * 64).astype(int), minlength=64)
55
+ check(
56
+ "skip=1 costs exactly one empty and one doubled bin",
57
+ int((c == 0).sum()) == 1 and int(c.max()) == 2,
58
+ f"empty {(c == 0).sum()}, max {c.max()}",
59
+ )
60
+
61
+ # 2-D projections should be far more even than random. Compare worst-cell occupancy of a
62
+ # 16x16 grid against uniform random draws.
63
+ g = 16
64
+ worst_sobol = 0
65
+ for d0, d1 in ((0, 1), (0, 2), (1, 2)):
66
+ h, _, _ = np.histogram2d(pts[:, d0], pts[:, d1], bins=g, range=[[0, 1], [0, 1]])
67
+ worst_sobol = max(worst_sobol, int(h.max()))
68
+ rng = np.random.default_rng(0)
69
+ worst_rand = max(
70
+ int(np.histogram2d(*rng.random((2, 1024)), bins=g, range=[[0, 1], [0, 1]])[0].max()) for _ in range(5)
71
+ )
72
+ expected = 1024 / (g * g)
73
+ check(
74
+ "2-D projections more even than random",
75
+ worst_sobol < worst_rand,
76
+ f"sobol {worst_sobol} vs random {worst_rand} (mean {expected:.0f}/cell)",
77
+ )
78
+
79
+ check("no duplicate points", len(np.unique(pts, axis=0)) == 1024)
80
+ check("dimension guard raises", _raises(lambda: sobol(8, 99)))
81
+
82
+ print("\nEpsilon design")
83
+ centre = {"melt_C": 240.0, "wall_C": 40.0, "flow_cm3_s": 8.93}
84
+ eps = epsilon_design(centre)
85
+ check("count", len(eps) == 17, f"{len(eps)} points")
86
+ check("exactly one nominal", sum(p["kind"] == "nominal" for p in eps) == 1)
87
+ # Every perturbation must be physically meaningless: two orders of magnitude below what a
88
+ # machine can hold. If one were not, the "floor" would include a real process effect.
89
+ check("melt perturbations <= 0.1 K", max(abs(p["melt_C"] - centre["melt_C"]) for p in eps) <= 0.1 + 1e-9)
90
+ check("wall perturbations <= 0.1 K", max(abs(p["wall_C"] - centre["wall_C"]) for p in eps) <= 0.1 + 1e-9)
91
+ check(
92
+ "flow perturbations <= 0.1 %",
93
+ max(abs(p["flow_cm3_s"] / centre["flow_cm3_s"] - 1.0) for p in eps) <= 1e-3 + 1e-12,
94
+ )
95
+ # Distinct inputs, or the deterministic solver would return identical results and the
96
+ # measured spread would be a meaningless zero.
97
+ keys = {(round(p["melt_C"], 6), round(p["wall_C"], 6), round(p["flow_cm3_s"], 6)) for p in eps}
98
+ check("all epsilon points are distinct inputs", len(keys) == len(eps))
99
+
100
+ print(f"\n{'ALL PASS' if not FAILED else 'FAILURES: ' + ', '.join(FAILED)}")
101
+ return 1 if FAILED else 0
102
+
103
+
104
+ def _raises(fn) -> bool:
105
+ try:
106
+ fn()
107
+ except Exception:
108
+ return True
109
+ return False
110
+
111
+
112
+ if __name__ == "__main__":
113
+ raise SystemExit(main())
@@ -0,0 +1,157 @@
1
+ """The guardrails must actually fire. A lint that never rejects anything is decoration.
2
+
3
+ Run: python -m process_window.test_guardrails
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .guardrails import (
9
+ GuardrailViolation,
10
+ check_absolute_claim_has_caveats,
11
+ check_criteria,
12
+ check_criterion,
13
+ check_forbidden_kpi,
14
+ check_noise_floor_derived,
15
+ lint_spec,
16
+ )
17
+
18
+ FAILED: list[str] = []
19
+
20
+
21
+ def check(name: str, cond: bool, detail: str = "") -> None:
22
+ print(f" {'PASS' if cond else 'FAIL'} {name}{' ' + detail if detail else ''}")
23
+ if not cond:
24
+ FAILED.append(name)
25
+
26
+
27
+ def rejects(fn) -> bool:
28
+ try:
29
+ fn()
30
+ except GuardrailViolation:
31
+ return True
32
+ except Exception:
33
+ return False
34
+ return False
35
+
36
+
37
+ def main() -> int:
38
+ print("Guardrail 1 — absolute pressure must never screen")
39
+ for expr in (
40
+ "p80 < 250 bar",
41
+ "p80_bar <= 300",
42
+ "max_pressure > 1.5e7 Pa",
43
+ "peak_pressure >= 200bar",
44
+ "cavity_pressure < 180",
45
+ "p_max > 250 mbar",
46
+ ):
47
+ check(f"rejects {expr!r}", rejects(lambda e=expr: check_criterion("t", e)))
48
+
49
+ print("\n relative forms must be allowed through")
50
+ for expr in (
51
+ "p80_rank <= 0.70",
52
+ "p80_ratio < 1.2",
53
+ "p80_percentile <= 0.8",
54
+ "p80 / p80_centre < 1.15",
55
+ "hotspot_rank <= 0.9",
56
+ "(T_front - no_flow) / (melt - no_flow) >= 0.15",
57
+ ):
58
+ ok = True
59
+ try:
60
+ check_criterion("t", expr)
61
+ except GuardrailViolation:
62
+ ok = False
63
+ check(f"allows {expr!r}", ok)
64
+
65
+ print("\nKind and absolute flags")
66
+ check(
67
+ "rejects kind='threshold'",
68
+ rejects(lambda: check_criteria([{"name": "x", "expression": "a_rank<0.5", "kind": "threshold"}])),
69
+ )
70
+ check(
71
+ "rejects absolute:true on a criterion",
72
+ rejects(lambda: check_criteria([{"name": "x", "expression": "a_rank<0.5", "kind": "rank", "absolute": True}])),
73
+ )
74
+ check(
75
+ "accepts a clean rank criterion",
76
+ not rejects(lambda: check_criteria([{"name": "x", "expression": "p80_rank<=0.7", "kind": "rank"}])),
77
+ )
78
+
79
+ print("\nForbidden KPI — fill race")
80
+ check(
81
+ "rejects a fill-race criterion",
82
+ rejects(
83
+ lambda: check_forbidden_kpi([{"name": "race_gate", "kpi": "race_anomaly_tail", "expression": "race < 0.2"}])
84
+ ),
85
+ )
86
+ check(
87
+ "rejects fill_time_diff",
88
+ rejects(lambda: check_forbidden_kpi([{"name": "x", "expression": "fill_time_diff<0.1"}])),
89
+ )
90
+
91
+ print("\nGuardrail 2 — noise floor derived, not imported")
92
+ check(
93
+ "rejects an imported floor",
94
+ rejects(lambda: check_noise_floor_derived({"p80": {"basis": "imported", "n_runs": 16}})),
95
+ )
96
+ check(
97
+ "rejects a floor with no run count", rejects(lambda: check_noise_floor_derived({"p80": {"basis": "derived"}}))
98
+ )
99
+ check("rejects an empty floor", rejects(lambda: check_noise_floor_derived({})))
100
+ check(
101
+ "accepts a derived floor",
102
+ not rejects(lambda: check_noise_floor_derived({"p80": {"basis": "derived", "n_runs": 17, "floor": 1.8}})),
103
+ )
104
+
105
+ print("\nGuardrail 3 — absolute claims carry caveats")
106
+ check(
107
+ "rejects an uncaveated machine limit",
108
+ rejects(
109
+ lambda: check_absolute_claim_has_caveats(
110
+ "machine_limits", {"max_injection_pressure_bar": 1500, "absolute": True}
111
+ )
112
+ ),
113
+ )
114
+ check(
115
+ "accepts a caveated machine limit",
116
+ not rejects(
117
+ lambda: check_absolute_claim_has_caveats(
118
+ "machine_limits",
119
+ {"max_injection_pressure_bar": 1500, "absolute": True, "caveats": ["cavities_1_of_32"]},
120
+ )
121
+ ),
122
+ )
123
+
124
+ print("\nFull spec lint")
125
+ good = {
126
+ "acceptance_criteria": [
127
+ {"name": "pressure_ordering", "expression": "p80_rank <= 0.70", "kind": "rank", "kpi": "p80"},
128
+ {
129
+ "name": "no_flow_guard",
130
+ "expression": "(T_front - no_flow) / (melt - no_flow) >= 0.15",
131
+ "kind": "ratio",
132
+ "kpi": "no_flow_margin",
133
+ },
134
+ ],
135
+ "noise_floor": {"per_kpi": {"p80": {"basis": "derived", "n_runs": 17, "floor": 1.8}}},
136
+ "provenance": {
137
+ "machine_limits": {"max_injection_pressure_bar": 1500.0, "absolute": True, "caveats": ["cavities_1_of_32"]}
138
+ },
139
+ }
140
+ try:
141
+ passed = lint_spec(good)
142
+ check("clean spec passes", True, f"{len(passed)} checks")
143
+ except GuardrailViolation as exc:
144
+ check("clean spec passes", False, str(exc))
145
+
146
+ bad = dict(good)
147
+ bad["acceptance_criteria"] = good["acceptance_criteria"] + [
148
+ {"name": "sneaky", "expression": "p80 < 250 bar", "kind": "comparison"}
149
+ ]
150
+ check("spec with an absolute threshold fails", rejects(lambda: lint_spec(bad)))
151
+
152
+ print(f"\n{'ALL PASS' if not FAILED else 'FAILURES: ' + ', '.join(FAILED)}")
153
+ return 1 if FAILED else 0
154
+
155
+
156
+ if __name__ == "__main__":
157
+ raise SystemExit(main())
@@ -0,0 +1,127 @@
1
+ """Validate the surrogate and the effect-share estimator against known answers.
2
+
3
+ A surrogate that is wrong invalidates the screening, the boundary and the centre point, so it is
4
+ checked against analytic functions whose Sobol indices are known in closed form before it is
5
+ trusted with 1 024 cloud runs. Run: python -m process_window.test_surrogate
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from .surrogate import effect_shares, effect_span, fit, sobol_indices
13
+
14
+ FAILED: list[str] = []
15
+
16
+
17
+ def check(name: str, cond: bool, detail: str = "") -> None:
18
+ print(f" {'PASS' if cond else 'FAIL'} {name}{' ' + detail if detail else ''}")
19
+ if not cond:
20
+ FAILED.append(name)
21
+
22
+
23
+ def main() -> int:
24
+ rng = np.random.default_rng(0)
25
+ box = np.array([[0.0, 1.0]] * 3)
26
+
27
+ print("Interpolation accuracy (smooth 3-D function)")
28
+
29
+ def f(X):
30
+ return np.sin(2 * X[:, 0]) + 0.5 * X[:, 1] ** 2 + 0.3 * X[:, 0] * X[:, 2]
31
+
32
+ Xtr = rng.random((300, 3))
33
+ s = fit(Xtr, f(Xtr), box)
34
+ Xte = rng.random((200, 3))
35
+ err = np.abs(s.predict(Xte) - f(Xte))
36
+ rng_y = f(Xtr).max() - f(Xtr).min()
37
+ check(
38
+ "test NRMSE < 2 %",
39
+ float(np.sqrt((err**2).mean()) / rng_y) < 0.02,
40
+ f"{np.sqrt((err**2).mean()) / rng_y * 100:.3f} %",
41
+ )
42
+ check(
43
+ "reproduces training data",
44
+ float(np.abs(s.predict(Xtr) - f(Xtr)).max()) < 0.02 * rng_y,
45
+ f"max {np.abs(s.predict(Xtr) - f(Xtr)).max():.4f}",
46
+ )
47
+ loo = s.loo_error()
48
+ check("LOO NRMSE < 3 %", loo["nrmse_range"] < 0.03, f"{loo['nrmse_range'] * 100:.3f} %")
49
+
50
+ print("\nPosterior variance behaves (drives G3 adaptive sampling)")
51
+ v_at = s.variance_unit(s.Xn[:20])
52
+ Xsparse = rng.random((12, 3)) * 0.2 # data only in a corner
53
+ s2 = fit(Xsparse, f(Xsparse), box)
54
+ v_near = float(s2.variance_unit(np.array([[0.1, 0.1, 0.1]]))[0])
55
+ v_away = float(s2.variance_unit(np.array([[0.9, 0.9, 0.9]]))[0])
56
+ check("variance ~0 at training points", float(v_at.max()) < 0.05, f"max {v_at.max():.2e}")
57
+ check("variance grows away from data", v_away > 10 * max(v_near, 1e-12), f"near {v_near:.2e} vs away {v_away:.2e}")
58
+
59
+ print("\nSobol indices vs closed form — Ishigami (the standard benchmark)")
60
+ # Ishigami on [-pi,pi]^3 with a=7,b=0.1 has analytic first-order/total indices.
61
+ a, b = 7.0, 0.1
62
+
63
+ def ishigami_unit(U):
64
+ X = -np.pi + 2 * np.pi * np.asarray(U)
65
+ return np.sin(X[:, 0]) + a * np.sin(X[:, 1]) ** 2 + b * X[:, 2] ** 4 * np.sin(X[:, 0])
66
+
67
+ V1 = 0.5 * (1 + b * np.pi**4 / 5) ** 2
68
+ V2 = a**2 / 8
69
+ V13 = b**2 * np.pi**8 * (1 / 18 - 1 / 50)
70
+ V = V1 + V2 + V13
71
+ exp_S1 = np.array([V1 / V, V2 / V, 0.0])
72
+ exp_ST = np.array([(V1 + V13) / V, V2 / V, V13 / V])
73
+ got = sobol_indices(ishigami_unit, 3, n=200_000, seed=1)
74
+ s1, st = np.array(got["S1"]), np.array(got["ST"])
75
+ print(f" S1 exact {np.round(exp_S1, 4)} got {np.round(s1, 4)}")
76
+ print(f" ST exact {np.round(exp_ST, 4)} got {np.round(st, 4)}")
77
+ check(
78
+ "first-order indices within 0.02",
79
+ float(np.abs(s1 - exp_S1).max()) < 0.02,
80
+ f"max err {np.abs(s1 - exp_S1).max():.4f}",
81
+ )
82
+ check(
83
+ "total indices within 0.02", float(np.abs(st - exp_ST).max()) < 0.02, f"max err {np.abs(st - exp_ST).max():.4f}"
84
+ )
85
+ check(
86
+ "detects x3 has zero first-order but nonzero total",
87
+ s1[2] < 0.02 and st[2] > 0.1,
88
+ f"S1={s1[2]:.4f} ST={st[2]:.4f}",
89
+ )
90
+
91
+ print("\nInert-variable detection (the guardrail-2 mechanism)")
92
+
93
+ # x1 matters, x2 matters less, x3 is pure noise-level -> must show a tiny span.
94
+ def g(X):
95
+ return 10.0 * X[:, 0] + 2.0 * X[:, 1] + 0.001 * X[:, 2]
96
+
97
+ Xtr = rng.random((400, 3))
98
+ sg = fit(Xtr, g(Xtr), box, noise_floor=0.05)
99
+ span = effect_span(sg, 3)
100
+ print(f" spans (y units): {np.round(span, 4)} true: [10, 2, 0.001]")
101
+ check("ranks x1 > x2 > x3 by span", span[0] > span[1] > span[2])
102
+ check("x3 span below a 0.05 floor", span[2] < 0.05, f"{span[2]:.5f}")
103
+ check("x1 span above the floor", span[0] > 0.05, f"{span[0]:.3f}")
104
+ sh = effect_shares(sg, n=8192)
105
+ check("share ordering matches", sh["share"][0] > sh["share"][1] > sh["share"][2], f"{np.round(sh['share'], 4)}")
106
+
107
+ print("\nQuantised data (what the real KPIs look like — F8)")
108
+ # Emulate the measured lattice: quantise to a relative step, then confirm the nugget keeps
109
+ # the fit from chasing lattice steps as signal.
110
+ step_rel = 1.2e-3
111
+ y_true = 50.0 + 8.0 * Xtr[:, 0] + 1.5 * Xtr[:, 1]
112
+ y_q = np.round(y_true / (y_true * step_rel)) * (y_true * step_rel)
113
+ floor = float(np.abs(y_q - y_true).max() * 2)
114
+ sq = fit(Xtr, y_q, box, noise_floor=floor)
115
+ resid = np.abs(sq.predict(Xtr) - y_true)
116
+ check(
117
+ "quantised fit recovers the smooth truth within the floor",
118
+ float(resid.max()) < max(floor * 3, 0.1),
119
+ f"max resid {resid.max():.4f} (floor {floor:.4f})",
120
+ )
121
+
122
+ print(f"\n{'ALL PASS' if not FAILED else 'FAILURES: ' + ', '.join(FAILED)}")
123
+ return 1 if FAILED else 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(main())
@@ -0,0 +1,152 @@
1
+ """Unit-checked wrappers for every value that crosses an API boundary.
2
+
3
+ The cloud AI endpoint takes **Kelvin**; the local SDK rm1 builder takes **Celsius**.
4
+ That is this project's most dangerous boundary — a silent confusion between them
5
+ shifts every temperature by 273.15 K and quietly invalidates every conclusion while
6
+ still producing plausible-looking results.
7
+
8
+ So: nothing here accepts a bare number and guesses. A conversion states its input unit
9
+ explicitly, and the result is range-checked against what is physically possible for
10
+ thermoplastic injection moulding. A melt temperature of 245 (K) is not a warning, it is
11
+ an error — 245 K is -28 C, which is not a melt.
12
+
13
+ Design rules:
14
+ * fail loudly, never coerce and never clamp;
15
+ * plausibility bands are deliberately wide (they catch unit errors, not process errors
16
+ — narrowing them to the material card's window is the material card's job);
17
+ * `Process` is the only object handed to a solver call, and it validates on construction.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+
24
+ ZERO_C_IN_K = 273.15
25
+
26
+ # Plausibility bands. Wide on purpose: these exist to catch a 273.15 shift or a
27
+ # Pa/bar mix-up, not to enforce processability.
28
+ MELT_C = (100.0, 430.0) # thermoplastics: PE ~160 C .. PEEK ~400 C
29
+ WALL_C = (-20.0, 250.0) # chilled tooling .. hot tooling for high-temp resins
30
+ FLOW_CM3_S = (0.01, 5000.0)
31
+ PRESSURE_BAR = (0.0, 100_000.0)
32
+
33
+
34
+ class UnitError(ValueError):
35
+ """A value failed its unit or plausibility check. Never caught to coerce."""
36
+
37
+
38
+ def _check(value: float, band: tuple[float, float], what: str, unit: str) -> float:
39
+ try:
40
+ v = float(value)
41
+ except (TypeError, ValueError) as exc:
42
+ raise UnitError(f"{what}: {value!r} is not a number") from exc
43
+ if v != v: # NaN
44
+ raise UnitError(f"{what}: NaN")
45
+ lo, hi = band
46
+ if not (lo <= v <= hi):
47
+ raise UnitError(
48
+ f"{what}: {v:g} {unit} is outside the plausible band {lo:g}..{hi:g} {unit}. "
49
+ f"This is almost always a unit error — check K vs degC and Pa vs bar."
50
+ )
51
+ return v
52
+
53
+
54
+ # --- temperature ---------------------------------------------------------------
55
+ def degC_to_K(value_C: float, *, what: str = "temperature") -> float:
56
+ """Celsius -> Kelvin. The input is validated as Celsius before converting."""
57
+ band = MELT_C if "melt" in what.lower() else WALL_C
58
+ return _check(value_C, band, what, "degC") + ZERO_C_IN_K
59
+
60
+
61
+ def K_to_degC(value_K: float, *, what: str = "temperature") -> float:
62
+ """Kelvin -> Celsius. The *result* is validated as Celsius."""
63
+ try:
64
+ v = float(value_K)
65
+ except (TypeError, ValueError) as exc:
66
+ raise UnitError(f"{what}: {value_K!r} is not a number") from exc
67
+ if v < 0.0:
68
+ raise UnitError(f"{what}: {v:g} K is below absolute zero — a Celsius value was passed as Kelvin")
69
+ out = v - ZERO_C_IN_K
70
+ band = MELT_C if "melt" in what.lower() else WALL_C
71
+ _check(out, band, f"{what} (converted from {v:g} K)", "degC")
72
+ return out
73
+
74
+
75
+ # --- pressure ------------------------------------------------------------------
76
+ def Pa_to_bar(value_Pa: float, *, what: str = "pressure") -> float:
77
+ return _check(float(value_Pa) / 1.0e5, PRESSURE_BAR, what, "bar")
78
+
79
+
80
+ def bar_to_Pa(value_bar: float, *, what: str = "pressure") -> float:
81
+ return _check(value_bar, PRESSURE_BAR, what, "bar") * 1.0e5
82
+
83
+
84
+ def pressure_field_to_bar(values, unit_attr: str, *, what: str = "Pressure field"):
85
+ """Convert a result-file pressure array to bar using its own ``unit`` attribute.
86
+
87
+ Cloud results store Pa; older files store bar. Guessing from magnitude is exactly
88
+ the mistake this module exists to prevent, so the attribute is required.
89
+ """
90
+ u = (unit_attr.decode() if isinstance(unit_attr, bytes) else str(unit_attr)).strip().lower()
91
+ if u in ("pa", "pascal"):
92
+ return values / 1.0e5
93
+ if u == "bar":
94
+ return values
95
+ raise UnitError(
96
+ f"{what}: unit attribute is {unit_attr!r}, expected 'Pa' or 'bar'. Refusing to infer the unit from the values."
97
+ )
98
+
99
+
100
+ # --- the object handed to a solver call ----------------------------------------
101
+ @dataclass(frozen=True)
102
+ class Process:
103
+ """One AI-solver process point, validated on construction.
104
+
105
+ Stored in Celsius because that is what an engineer reads; the ``*_K`` properties
106
+ are what the API is given. Both are always available, so no caller ever converts
107
+ by hand.
108
+
109
+ ``fill_time_s`` is **derived** from volume / flow, never supplied independently:
110
+ an inconsistent (flow, fill_time) pair is a self-contradictory process state that
111
+ the solver treats as a scaling hint and silently absorbs.
112
+ """
113
+
114
+ melt_C: float
115
+ wall_C: float
116
+ flow_cm3_s: float
117
+ volume_cm3: float
118
+
119
+ def __post_init__(self) -> None:
120
+ _check(self.melt_C, MELT_C, "melt temperature", "degC")
121
+ _check(self.wall_C, WALL_C, "mould wall temperature", "degC")
122
+ _check(self.flow_cm3_s, FLOW_CM3_S, "flow rate", "cm3/s")
123
+ _check(self.volume_cm3, (1e-6, 1e7), "cavity volume", "cm3")
124
+ if self.wall_C >= self.melt_C:
125
+ raise UnitError(
126
+ f"mould wall ({self.wall_C:g} C) is not below melt ({self.melt_C:g} C) — "
127
+ "check that the two were not swapped"
128
+ )
129
+
130
+ @property
131
+ def melt_K(self) -> float:
132
+ return degC_to_K(self.melt_C, what="melt temperature")
133
+
134
+ @property
135
+ def wall_K(self) -> float:
136
+ return degC_to_K(self.wall_C, what="wall temperature")
137
+
138
+ @property
139
+ def fill_time_s(self) -> float:
140
+ return round(self.volume_cm3 / self.flow_cm3_s, 4)
141
+
142
+ def api_kwargs(self) -> dict:
143
+ """Exactly the process arguments the AI filling endpoint takes, in its units."""
144
+ return {
145
+ "melt_temperature_K": self.melt_K,
146
+ "wall_temperature_K": self.wall_K,
147
+ "flow_rate_cm3_s": self.flow_cm3_s,
148
+ "estimated_filling_time_s": self.fill_time_s,
149
+ }
150
+
151
+ def label(self) -> str:
152
+ return f"melt{self.melt_C:.0f}C wall{self.wall_C:.0f}C Q{self.flow_cm3_s:.2f}"
@@ -0,0 +1,24 @@
1
+ # What this template writes when it runs. A generated project receives THIS file, not the
2
+ # starter kit's root one, so the rules have to live here too or a customer's first commit
3
+ # carries their own quotes and a meshed geometry.
4
+ #
5
+ # cache/ is ours: the mesh, the cloud ids, the resumable state and the decision log. Safe
6
+ # to delete - it costs a re-mesh and a re-upload, never a wrong answer.
7
+ # output/ is yours: quote.json and the simulation results behind it.
8
+ cache/
9
+ output/
10
+
11
+ # parts/ is where you drop your own geometry. The folder is documented in PROMPT.md so it
12
+ # is kept, but what lands in it is yours and is not ours to commit. sample/ is deliberately
13
+ # NOT ignored: it is the geometry this project ships with.
14
+ parts/*
15
+ !parts/.gitkeep
16
+
17
+ .venv/
18
+ __pycache__/
19
+ *.pyc
20
+ .pytest_cache/
21
+
22
+ # never commit credentials
23
+ *token*.json
24
+ .env
@@ -0,0 +1,58 @@
1
+ # Quoting — how to drive this template
2
+
3
+ You have been asked to quote an injection-moulded part. This file is the map; the method is
4
+ in [`METHOD.md`](METHOD.md), and you should read that before running anything.
5
+
6
+ ## Read these first, in this order
7
+
8
+ 1. **[`METHOD.md`](METHOD.md)** — the procedure, stage by stage. What each stage decides, what
9
+ it needs from you, and what it writes down.
10
+ 2. **[`shop/`](shop/)** — the shop you are quoting as. Its presses, its material bias, how it
11
+ estimates a mould, what it declines. Quote as *that* shop, not in general.
12
+ 3. **[`QUOTING_PLAYBOOK.md`](QUOTING_PLAYBOOK.md)** — the cost model and its formulas. Read it
13
+ when a number needs justifying or the method has to be adapted.
14
+
15
+ ## The stages
16
+
17
+ ```
18
+ python main.py setup # is shop/ this shop, or still the example?
19
+ python main.py intake --part <file> --cavities N # what the geometry alone decides
20
+ python main.py study --part <file> # cloud: the gating decision
21
+ python main.py cost --part <file> # price every option, choose on money
22
+ python main.py learn --quote <key> # fold the estimator's corrections back in
23
+ ```
24
+
25
+ `--part` defaults to the bundled sample, so every stage runs without it. **`intake` still needs
26
+ `--cavities N`**, which has no default on purpose: the cavity count is the customer's commercial
27
+ decision and inventing one puts a made-up number into a quotation. Ask for it.
28
+
29
+ Only `study` needs a licence and cloud access; add `--offline` to it and the whole flow is
30
+ demonstrable without credentials, with every artefact marked as estimated.
31
+
32
+ ## Four things to get right
33
+
34
+ **Quote as this shop.** The numbers in `shop/` are the whole point. A quote that ignores them
35
+ is a generic calculation the customer could have done themselves.
36
+
37
+ **Never invent a cavity count.** It is the customer's commercial decision. Ask.
38
+
39
+ **A quote is keyed on the part's contents.** `intake` prints a key like
40
+ `clip_frame-655b895fd2b5`. Re-export the part and it becomes a *different* quote, deliberately:
41
+ the mesh, the uploaded geometry and the decisions on file describe the previous revision. If a
42
+ stage says there is no quote on file for a part you have already run, the part changed.
43
+
44
+ **The shipped `shop/` is fictional.** Until someone works through [`INTERVIEW.md`](INTERVIEW.md)
45
+ and confirms it, every quote is watermarked as example data. Say so rather than presenting a
46
+ watermarked quote as a price.
47
+
48
+ ## Where things land
49
+
50
+ | Path | Whose | What |
51
+ |---|---|---|
52
+ | `cache/<key>/` | ours | `state.json`, `decisions.md`, the mesh. Deleting costs a re-mesh. |
53
+ | `output/<key>/` | the customer's | `quote.json` and the simulation results behind it. |
54
+ | `parts/` | the customer's | Where they drop their own geometry. |
55
+ | `sample/` | shipped | The default geometry, written when the project is generated. |
56
+
57
+ The kit writes no document. `quote.json` is the deliverable — turn it into the quotation in
58
+ whatever form the customer expects.