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,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.
|