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,137 @@
1
+ # Template · Gate study
2
+
3
+ A **ready-to-run** early-stage gating study: drive the Simcon cloud AI solver through the
4
+ typical decisions for an injection-moulded part — **how many gates, where, and what process** —
5
+ scoring each run for filling pressure, fill evenness and **weld lines**, organised into tidy
6
+ cloud groups. It ships with the original prompt, the playbook, and all the code, pointed at a
7
+ neutral sample part (`simple_plate.stl`), so you start from something that already runs instead
8
+ of a blank page.
9
+
10
+ ## Why a custom REST client (not `cadmould.cloud`)?
11
+
12
+ The `cadmould.cloud` SDK wraps the **per-simulation** surface — upload geometry, look up materials,
13
+ run a filling simulation, download the result. It does **not** wrap the **management** surface an
14
+ organised study needs: **projects, groups, and the decision log** (project notes/events). This
15
+ study groups runs by phase (gate count → placement → process → validation) and records *why* each
16
+ choice was made, so it drives the cloud over the **REST API** directly via the `cadmould_cloud`
17
+ package that ships beside this file (`client.py::PlatformAPI` + Auth0 PKCE login). For a
18
+ simple one-off run you don't need any of this — the **run-one-simulation** template shows the SDK
19
+ path; reach for REST only when you need the management/decision-log surface (or just prefer your
20
+ own client). The API itself is documented at <https://api.simcon.ai/scalar>.
21
+
22
+ ## Prerequisites
23
+
24
+ <!-- simcon-toolkit:setup -->
25
+ **Python 3.12 or newer**, and a virtual environment created *with that interpreter* — a venv
26
+ isolates packages, not the interpreter, so naming it is what makes the version real.
27
+
28
+ **Three sign-ins, each in your browser, and each does a different job.**
29
+
30
+ 1. **The package index**, once, in the steps below: `cadmould-sdk-auth` lets pip install the
31
+ licensed `cadmould` wheel from SIMCON's private index. Your company's email domain has to be
32
+ enabled for it: if you can sign in but the browser tab then says `Access denied: your email
33
+ domain is not authorized for this API`, ask SIMCON support.
34
+ 2. **Your licence**, the first time the code opens a licence session. It first checks the
35
+ Thales Sentinel run-time on your machine, and prints `License init failed: HASP driver
36
+ version too old` or `... runtime version too old` when that needs installing or updating.
37
+ 3. **The cloud**, the first time the code calls it.
38
+
39
+ ⚠️ A failed licence sign-in does not stop the script. It prints `License init failed: ...` or
40
+ `License acquisition failed.` to the terminal and carries on, so the error you meet later is `Cadmould API licence (LICENCE_API)
41
+ required`. When you see that one, scroll up to the first.
42
+
43
+ **macOS / Linux (bash):**
44
+ ```bash
45
+ python3.12 -m venv .venv
46
+ source .venv/bin/activate
47
+ pip install --upgrade pip cadmould-sdk-auth
48
+ cadmould-sdk-auth # opens a browser; sign in with your Simcon account
49
+ pip install . # installs cadmould and everything this study needs
50
+ ```
51
+
52
+ **Windows (PowerShell):**
53
+ ```powershell
54
+ py -3.12 -m venv .venv
55
+ .\.venv\Scripts\python.exe -m pip install --upgrade pip cadmould-sdk-auth
56
+ $env:VIRTUAL_ENV = "$PWD\.venv" # tell the login CLI which venv to configure
57
+ .\.venv\Scripts\cadmould-sdk-auth.exe # opens a browser; sign in with your Simcon account
58
+ .\.venv\Scripts\python.exe -m pip install .
59
+ ```
60
+
61
+ The Windows route never activates the environment. `Activate.ps1` is blocked by the default
62
+ execution policy on many machines; naming the interpreter outright avoids it.
63
+ <!-- /simcon-toolkit:setup -->
64
+
65
+ The first cloud run opens the third sign-in, for the cloud — no manual token needed.
66
+
67
+ ## Run it
68
+
69
+ All commands assume you are **in this folder**.
70
+
71
+ **macOS / Linux (bash):**
72
+ ```bash
73
+ # each phase is its own cloud group; the FIRST run opens the cloud login
74
+ python main.py group1 # gate count: 1 vs 2 vs 3 vs 4
75
+ python main.py group2 # single-gate placement sweep
76
+ python main.py group3 # process / flow-rate sweep
77
+ python main.py group4 # validation
78
+ ```
79
+
80
+ **Windows (PowerShell):**
81
+ ```powershell
82
+ python main.py group1
83
+ python main.py group2
84
+ python main.py group3
85
+ python main.py group4
86
+ ```
87
+
88
+ Study your own part instead of the sample one — no code edit needed:
89
+ ```bash
90
+ python main.py group1 --part path/to/your-part.stl
91
+ ```
92
+
93
+ The part is meshed and uploaded **once**; `cache/<part>.study_state.json` holds the
94
+ geometry id and the decisions so later phases reuse them — one file per part, so studying a
95
+ second part never inherits the first one's geometry. Each group prints a scored table and
96
+ writes results + a `manifest.json` under `output/<group>/`.
97
+
98
+ The kit stops at the scored numbers. Turning them into a deck or a write-up is your assistant's
99
+ job, working from the `manifest.json` and the result files — that way the document is in your
100
+ format rather than ours.
101
+
102
+ ## Layout
103
+
104
+ ```
105
+ gate-study/
106
+ ├── main.py # the driver: group1..4 (run this)
107
+ ├── pipeline.py # cloud project/group/upload/batch-run + scoring
108
+ ├── INITIAL_PROMPT.md # the prompt that kicks off a study
109
+ ├── GATING_STUDY_PLAYBOOK.md # HOW TO run a gate study + every gotcha (read this)
110
+ ├── sample/ # your copy of the sample part (written when the project
111
+ │ # is generated; not present in this repository)
112
+ ├── output/ # yours: scored results, one folder per group
113
+ └── cache/ # ours: the meshed .cfex and study_state.json
114
+ ```
115
+ `output/` and `cache/` are both safe to delete — clearing the cache costs a re-mesh and a
116
+ re-upload, never a different answer. The cloud project is found again by its name.
117
+
118
+ Running it **inside this repository** needs the shared packages on the path, because the
119
+ generator has not copied them in yet:
120
+ ```bash
121
+ PYTHONPATH=packages python templates/gate-study/main.py group1 --part assets/parts/simple_plate.stl
122
+ ```
123
+
124
+ ## What each script does
125
+
126
+ - **`main.py`** — `group1`…`group4`; builds gate configs, submits them concurrently (low
127
+ concurrency + retry, because the endpoint sheds load), downloads + scores each.
128
+ - **`cadmould_scoring.metrics`** (in `packages/`) — peak pressure (bar), fill evenness (`tail`), and weld lines via
129
+ convergence of the flow-direction field. Pure numpy, h5py and scipy: reading and scoring a
130
+ result needs no graphics stack.
131
+
132
+ ## Read this before adapting to a new part
133
+
134
+ [`GATING_STUDY_PLAYBOOK.md`](GATING_STUDY_PLAYBOOK.md) — the method, the
135
+ metrics, **§7 how to repoint at a new part**, and §5 the non-obvious cloud/SDK/data details
136
+ (weld-detector calibration, the fill-front format, re-centred geometry, endpoint concurrency
137
+ limits, the numerical-solver path).
@@ -0,0 +1,344 @@
1
+ """Driver for the gating study (live Simcon cloud AI solver).
2
+
3
+ Each phase is a subcommand and a logical cloud *group* under one *project*:
4
+
5
+ python main.py group1 # gate-count: 1 vs 2 vs 3 vs 4 gates
6
+ python main.py group2 # gate placement for the chosen count
7
+ python main.py group3 # process / pressure tuning
8
+ python main.py group4 # validation of the final design
9
+
10
+ The part is meshed and the geometry uploaded **once**; the cached state under
11
+ ``cache/`` remembers the project + geometry ids (and decisions) so later phases
12
+ reuse them. Scored results land under ``output/``.
13
+
14
+ The sample part is a default, not a fixture: pass ``--part path/to/your.stl``
15
+ to study your own geometry.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import hashlib
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import pipeline
27
+
28
+ from cadmould_geometry.mesh import Geometry, gate_line, parse_cfex_nodes, prepare_geometry
29
+
30
+ HERE = Path(__file__).resolve().parent
31
+ # Written by the generator, so this path resolves in your project and not in ours.
32
+ DEFAULT_PART = HERE / "sample" / "simple_plate.stl"
33
+ # Yours: scored results, one folder per group. Safe to delete.
34
+ OUTPUT = HERE / "output"
35
+ # Ours: the meshed geometry and the study's own bookkeeping, both per part. Deleting
36
+ # it costs a re-mesh and a re-upload, never a wrong answer — the cloud project is
37
+ # found again by name.
38
+ CACHE = HERE / "cache"
39
+
40
+ NOMINAL_FLOW = 45.0 # cm^3/s -> ~2.3 s fill for the 103.6 cm^3 sample part
41
+
42
+
43
+ # --------------------------------------------------------------------------
44
+ def part_key(part: Path) -> str:
45
+ """Cache identity for a part: its name, plus enough of its content to be honest.
46
+
47
+ The name alone is not an identity. Two different parts can both be bracket.stl,
48
+ and re-exporting the same part changes its bytes but not its path — either way a
49
+ name-keyed cache hands the study a mesh and an uploaded geometry from the wrong
50
+ version of the part, with nothing to show that it happened.
51
+ """
52
+ digest = hashlib.sha256(part.read_bytes()).hexdigest()[:12]
53
+ return f"{part.stem}-{digest}"
54
+
55
+
56
+ def state_path(part: Path) -> Path:
57
+ """One state file per part: ids and decisions describe a geometry, not a folder."""
58
+ return CACHE / f"{part_key(part)}.study_state.json"
59
+
60
+
61
+ def studies_on_file() -> list[str]:
62
+ """Every part a study has actually been started for."""
63
+ if not CACHE.is_dir():
64
+ return []
65
+ return sorted(p.name.removesuffix(".study_state.json") for p in CACHE.glob("*.study_state.json"))
66
+
67
+
68
+ def resolve_part(explicit: Path | None) -> Path:
69
+ """The geometry this phase studies, refusing to guess when the flag was left off.
70
+
71
+ A later phase reads state a previous one wrote, so falling back to the bundled sample
72
+ is not a harmless default: run ``group1 --part mine.stl`` and then a bare ``group2``,
73
+ and the second phase studies the SAMPLE - scoring it, writing its results, and saying
74
+ nothing. The default therefore stands only while it is unambiguous, which is the demo
75
+ case of the sample being the only study on file.
76
+ """
77
+ if explicit is not None:
78
+ return explicit
79
+ started = studies_on_file()
80
+ mine = part_key(DEFAULT_PART) if DEFAULT_PART.is_file() else None
81
+ if [k for k in started if k != mine]:
82
+ listed = "\n ".join(started)
83
+ raise SystemExit(
84
+ "this phase continues a study, so it needs to know which one.\n"
85
+ "Pass --part pointing at the geometry you ran group1 on.\n"
86
+ f"Studies on file:\n {listed}"
87
+ )
88
+ return DEFAULT_PART
89
+
90
+
91
+ def load_state(state: Path) -> dict:
92
+ return json.loads(state.read_text()) if state.exists() else {}
93
+
94
+
95
+ def save_state(state: Path, **kw) -> None:
96
+ st = load_state(state)
97
+ st.update(kw)
98
+ CACHE.mkdir(parents=True, exist_ok=True)
99
+ state.write_text(json.dumps(st, indent=2))
100
+
101
+
102
+ def get_geometry(part: Path, state: Path) -> Geometry:
103
+ """Reuse the meshed .cfex if we already have it, else mesh now."""
104
+ cfex = CACHE / f"{part_key(part)}.cfex"
105
+ st = load_state(state)
106
+ if cfex.exists() and st.get("suggested_gate"):
107
+ nodes = parse_cfex_nodes(cfex)
108
+ print(f" reusing meshed {cfex.name} ({len(nodes)} nodes)")
109
+ return Geometry(cfex, nodes, st["suggested_gate"], st["volume_cm3"], st["long_axis"])
110
+ CACHE.mkdir(parents=True, exist_ok=True)
111
+ geo = prepare_geometry(part, cfex)
112
+ # A fresh mesh invalidates the upload that went with the old one: keeping the id would
113
+ # skip the upload and run the new gates against the geometry already in the cloud.
114
+ save_state(
115
+ state,
116
+ geometry_id=None,
117
+ suggested_gate=geo.suggested_gate,
118
+ volume_cm3=geo.volume_cm3,
119
+ long_axis=geo.long_axis,
120
+ )
121
+ return geo
122
+
123
+
124
+ def make_study(geo: Geometry, part: Path, state: Path) -> pipeline.Study:
125
+ """One cloud project per part, named after it, reused across the four phases."""
126
+ st = load_state(state)
127
+ study = pipeline.Study(part.stem, geo, part.name, OUTPUT, geometry_id=st.get("geometry_id"))
128
+ study.upload_geometry()
129
+ save_state(state, geometry_id=study.geometry_id)
130
+ return study
131
+
132
+
133
+ def table(results: list[pipeline.RunResult]) -> None:
134
+ print(
135
+ f"\n {'config':20s} {'g':>2} {'flow':>5} {'maxP(bar)':>9} {'shear(1/s)':>10} "
136
+ f"{'fill%':>6} {'t_fill':>7} {'tail':>5} {'welds':>6} weld axis-pos/angle"
137
+ )
138
+ print(" " + "-" * 116)
139
+ for r in results:
140
+ a = r.analysis
141
+ if a is None:
142
+ print(f" {r.config.name:20s} FAILED: {r.error}")
143
+ continue
144
+ wl = ", ".join(f"{c.axis_pos:.2f}/{c.mean_angle_deg:.0f}" for c in a.weld_clusters[:5]) or "-"
145
+ print(
146
+ f" {r.config.name:20s} {len(r.config.gates_mm):>2} {r.config.flow_rate_cm3_s:>5.0f} "
147
+ f"{a.max_pressure_bar:>9.1f} {a.max_shear_rate_1s:>10.0f} "
148
+ f"{a.fill_pct:>6.1f} {a.total_fill_time_s:>7.2f} {a.tail_fraction:>5.2f} "
149
+ f"{a.n_welds:>6} {wl}"
150
+ )
151
+
152
+
153
+ # --------------------------------------------------------------------------
154
+ def group1(study: pipeline.Study, geo: Geometry, state: Path) -> list[pipeline.RunResult]:
155
+ """Gate-count study at a fixed nominal process (1 vs 2 vs 3 vs 4 gates)."""
156
+ cfgs = [
157
+ study.make_config(
158
+ "1gate_center",
159
+ gate_line(geo, [0.5]),
160
+ flow_rate_cm3_s=NOMINAL_FLOW,
161
+ notes="single central gate (= cadmould suggestion)",
162
+ ),
163
+ study.make_config(
164
+ "2gate", gate_line(geo, [0.25, 0.75]), flow_rate_cm3_s=NOMINAL_FLOW, notes="2 gates at 1/4, 3/4 length"
165
+ ),
166
+ study.make_config(
167
+ "3gate",
168
+ gate_line(geo, [1 / 6, 0.5, 5 / 6]),
169
+ flow_rate_cm3_s=NOMINAL_FLOW,
170
+ notes="3 gates at 1/6, 1/2, 5/6",
171
+ ),
172
+ study.make_config(
173
+ "4gate",
174
+ gate_line(geo, [0.125, 0.375, 0.625, 0.875]),
175
+ flow_rate_cm3_s=NOMINAL_FLOW,
176
+ notes="4 gates evenly",
177
+ ),
178
+ ]
179
+ res = study.run_group(
180
+ "G1 - gate count (nominal process)",
181
+ cfgs,
182
+ description=(
183
+ f"Gate-count study on {study.stl_name} at fixed {NOMINAL_FLOW:.0f} cm3/s, "
184
+ f"melt {study.melt_K - 273.15:.0f}C, wall {study.wall_K - 273.15:.0f}C. "
185
+ "Trade flow length / pressure against number of weld lines."
186
+ ),
187
+ )
188
+ return res
189
+
190
+
191
+ def group2(study: pipeline.Study, geo: Geometry, state: Path) -> list[pipeline.RunResult]:
192
+ """Single-gate placement: sweep position along the length for balanced fill."""
193
+ fracs = [0.40, 0.45, 0.50, 0.55, 0.60]
194
+ cfgs = [
195
+ study.make_config(
196
+ f"pos_{int(f * 100):02d}",
197
+ gate_line(geo, [f]),
198
+ flow_rate_cm3_s=NOMINAL_FLOW,
199
+ notes=f"single gate at {f:.2f} of length",
200
+ )
201
+ for f in fracs
202
+ ]
203
+ res = study.run_group(
204
+ "G2 - single-gate placement",
205
+ cfgs,
206
+ description=(
207
+ "Single central gate swept along the part length at fixed "
208
+ f"{NOMINAL_FLOW:.0f} cm3/s. Find the flow-balanced position: "
209
+ "most even fill (lowest tail), lowest peak pressure, welds at the "
210
+ "hidden ends. The part tapers, so the optimum may be off geometric centre."
211
+ ),
212
+ )
213
+ # pick: most even fill (low tail) then lowest pressure, among full fills
214
+ ok = [r for r in res if r.analysis and not r.analysis.short_shot]
215
+ if ok:
216
+ best = min(ok, key=lambda r: (round(r.analysis.tail_fraction, 2), r.analysis.max_pressure_bar))
217
+ save_state(
218
+ state,
219
+ best_gate_frac=float(best.config.name.split("_")[1]) / 100.0,
220
+ best_gate_mm=best.config.gates_mm,
221
+ best_gate_flow=best.config.flow_rate_cm3_s,
222
+ )
223
+ print(
224
+ f"\n >> best single-gate placement: {best.config.name} "
225
+ f"(P={best.analysis.max_pressure_bar:.0f}bar, tail={best.analysis.tail_fraction:.2f}) "
226
+ f"-> saved to state"
227
+ )
228
+ return res
229
+
230
+
231
+ def chosen_gate(geo: Geometry, state: Path) -> tuple[list[list[float]], str]:
232
+ """The gate later phases run at, and an honest name for it.
233
+
234
+ Each phase can be run on its own, so group2 may not have happened. Falling back to a
235
+ central gate is the right behaviour — it is a legitimate baseline — but the description
236
+ travels to the cloud as this study's record, so it has to say which of the two it got.
237
+ """
238
+ gate = load_state(state).get("best_gate_mm")
239
+ if gate:
240
+ return gate, "the gate placement group2 selected"
241
+ return gate_line(geo, [0.5]), "a central gate (group2 has not run, so nothing was selected)"
242
+
243
+
244
+ def group3(study: pipeline.Study, geo: Geometry, state: Path) -> list[pipeline.RunResult]:
245
+ """Process tuning: flow-rate sweep at the gate group2 chose, or at a central baseline."""
246
+ gate, placement = chosen_gate(geo, state)
247
+ print(f" process sweep at {placement}")
248
+ flows = [25.0, 35.0, 45.0, 60.0, 80.0] # -> ~4.1, 3.0, 2.3, 1.7, 1.3 s fill
249
+ cfgs = [
250
+ study.make_config(
251
+ f"flow_{int(fl):02d}",
252
+ gate,
253
+ flow_rate_cm3_s=fl,
254
+ notes=f"{fl:.0f} cm3/s -> ~{geo.volume_cm3 / fl:.1f} s fill",
255
+ )
256
+ for fl in flows
257
+ ]
258
+ res = study.run_group(
259
+ "G3 - process tuning (flow sweep)",
260
+ cfgs,
261
+ description=(
262
+ f"Flow-rate sweep at {placement} to map the process window: "
263
+ "peak pressure and shear vs fill time. Pick the fastest fill that "
264
+ "keeps pressure and shear comfortable."
265
+ ),
266
+ )
267
+ return res
268
+
269
+
270
+ def group4(study: pipeline.Study, geo: Geometry, state: Path) -> list[pipeline.RunResult]:
271
+ """Validate the final design: timestep-resolution stability + numerical cross-check."""
272
+ gate, placement = chosen_gate(geo, state)
273
+ print(f" validating at {placement}")
274
+ flow = 45.0
275
+ cfgs = [
276
+ study.make_config(
277
+ "final_ai_50steps",
278
+ gate,
279
+ flow_rate_cm3_s=flow,
280
+ notes=f"final design: {placement}, {flow:.0f} cm3/s, 50 steps",
281
+ ),
282
+ study.make_config(
283
+ "final_ai_100steps",
284
+ gate,
285
+ flow_rate_cm3_s=flow,
286
+ num_timesteps=100,
287
+ notes="resolution check: same design at 100 timesteps",
288
+ ),
289
+ study.make_config(
290
+ "final_numerical",
291
+ gate,
292
+ flow_rate_cm3_s=flow,
293
+ solver_type="numerical",
294
+ notes="numerical-solver cross-check (best-effort; may be async/unavailable)",
295
+ ),
296
+ ]
297
+ return study.run_group(
298
+ "G4 - validation (final design)",
299
+ cfgs,
300
+ max_workers=1,
301
+ description=(
302
+ f"Validation of the recommended design ({placement}, at "
303
+ f"{flow:.0f} cm3/s). Confirms metrics are stable under finer time resolution "
304
+ "and cross-checks the AI surrogate against the numerical solver."
305
+ ),
306
+ )
307
+
308
+
309
+ GROUPS = {"group1": group1, "group2": group2, "group3": group3, "group4": group4}
310
+
311
+
312
+ def main() -> int:
313
+ ap = argparse.ArgumentParser(description="gating study driver")
314
+ ap.add_argument("group", choices=sorted(GROUPS), help="which study phase to run")
315
+ # Defaults to None rather than to the sample, so a phase can tell "the user said
316
+ # nothing" from "the user asked for the sample" and refuse to guess between studies.
317
+ ap.add_argument(
318
+ "--part",
319
+ type=Path,
320
+ default=None,
321
+ help=f"STL to study (default: {DEFAULT_PART.name} under sample/)",
322
+ )
323
+ args = ap.parse_args()
324
+ args.part = resolve_part(args.part)
325
+
326
+ if not args.part.is_file():
327
+ print(f"geometry not found: {args.part}", file=sys.stderr)
328
+ print("Pass your own STL with --part, or put one back at sample/simple_plate.stl.", file=sys.stderr)
329
+ return 2
330
+
331
+ state = state_path(args.part)
332
+ geo = get_geometry(args.part, state)
333
+ study = make_study(geo, args.part, state)
334
+ try:
335
+ results = GROUPS[args.group](study, geo, state)
336
+ finally:
337
+ study.close()
338
+
339
+ table(results)
340
+ return 0
341
+
342
+
343
+ if __name__ == "__main__":
344
+ raise SystemExit(main())