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,577 @@
1
+ """The cloud side of a quote: mesh once, then buy the few decisions that move the price.
2
+
3
+ A quoting study is **not** a design study. It runs the smallest number of simulations that
4
+ settle the questions the estimator cannot answer from the drawing:
5
+
6
+ 1. **How many gates?** -> hot-runner nozzles x cavities, i.e. real money on the tool.
7
+ 2. **What cavity pressure?** -> clamp force -> which press -> the hourly rate.
8
+ 3. **How fast can it be filled?** -> the fill part of the cycle, and the shear ceiling.
9
+
10
+ Everything else (weld position, fill balance) is scored because it is free once the run exists,
11
+ and because a weld line on a functional face is a quality risk that belongs on the quote's
12
+ assumptions slide rather than in a surprise at first article.
13
+
14
+ Units mirror the API: temperatures K, pressure Pa, spatial mm, flow cm3/s.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import concurrent.futures as cf
20
+ import os
21
+ import re
22
+ import time
23
+ from dataclasses import asdict, dataclass, field
24
+ from pathlib import Path
25
+
26
+ import httpx
27
+ import numpy as np
28
+
29
+ from cadmould_geometry import stl as stl_reader
30
+
31
+ from . import toolkit as T
32
+
33
+ MODEL_VERSION = "v0.1.0"
34
+ NUM_TIMESTEPS = 50
35
+ HTC_W_M2K = 1000.0
36
+
37
+ # Thermal data by material family, used only when the platform's material-version payload does
38
+ # not carry it (it usually does - lambda_W_mK / cp_J_kgK / melt_density_kg_m3 /
39
+ # no_flow_temperature_K / ejection_temperature_K). Textbook values, good enough for a cycle-time
40
+ # estimate, not good enough for a warranty.
41
+ FAMILY_THERMAL: dict[str, dict] = {
42
+ "PP": {"lambda_W_mK": 0.19, "cp_J_kgK": 2500, "rho_kg_m3": 750, "no_flow_K": 408.15, "eject_K": 353.15},
43
+ "PP-GF": {"lambda_W_mK": 0.25, "cp_J_kgK": 1800, "rho_kg_m3": 900, "no_flow_K": 418.15, "eject_K": 373.15},
44
+ "PA-GF": {"lambda_W_mK": 0.28, "cp_J_kgK": 1900, "rho_kg_m3": 1150, "no_flow_K": 483.15, "eject_K": 403.15},
45
+ "POM": {"lambda_W_mK": 0.23, "cp_J_kgK": 2100, "rho_kg_m3": 1200, "no_flow_K": 423.15, "eject_K": 373.15},
46
+ "ABS": {"lambda_W_mK": 0.18, "cp_J_kgK": 1900, "rho_kg_m3": 950, "no_flow_K": 393.15, "eject_K": 353.15},
47
+ }
48
+ DEFAULT_THERMAL = FAMILY_THERMAL["PP"]
49
+
50
+
51
+ # ==========================================================================
52
+ # weld consolidation
53
+ # ==========================================================================
54
+ # Two filters and a merge, all validated against real runs on the sample plate (1 / 2 / 3 gates).
55
+ # Raw, the detector reported 7 / 10 / 6 weld clusters on a flat plate - a number that is worse
56
+ # than useless on a quote, because it reads as fact. Inspecting the clusters showed what they
57
+ # were: real weld lines at the two ends (forming at t ~ 1.43 s of a 1.46 s fill) plus clusters
58
+ # sitting ON the gate at t ~ 0.17 s. A weld cannot form at the gate before anything has met; that
59
+ # is the flow-direction field diverging from a point source. After filtering and merging, the
60
+ # same runs give exactly what the physics predicts: 1 gate -> welds at both ends only; 2 gates ->
61
+ # one interior weld at the midpoint between them; 3 gates -> two interior welds at the two
62
+ # midpoints. The gate-study playbook says to re-validate the detector on every new part; this is
63
+ # that re-validation, kept in code so it is not re-argued.
64
+ EARLY_FILL_FRACTION = 0.15 # a "weld" before this share of the fill has passed is the gate
65
+ GATE_EXCLUSION_AXIS = 0.06 # ... or one sitting this close to a gate, in normalised axis units
66
+ MERGE_AXIS_TOLERANCE = 0.05 # clusters this close along the axis are one weld line
67
+ END_ZONE_AXIS = 0.08 # welds within this of either end are "at the end" - normally hidden
68
+
69
+
70
+ @dataclass
71
+ class WeldLine:
72
+ """One consolidated weld line, located along the part's long axis (0..1)."""
73
+
74
+ axis_pos: float
75
+ n_nodes: int
76
+ fill_time_s: float
77
+ at_end: bool
78
+
79
+ def as_dict(self) -> dict:
80
+ return asdict(self)
81
+
82
+
83
+ def consolidate_welds(analysis, gate_axis_pos: list[float], total_fill_time_s: float) -> list[WeldLine]:
84
+ """Drop gate artefacts, merge co-located clusters, and mark which welds sit at the ends."""
85
+ kept = []
86
+ for c in analysis.weld_clusters:
87
+ if total_fill_time_s > 0 and c.fill_time_s < EARLY_FILL_FRACTION * total_fill_time_s:
88
+ continue # nothing has met yet - this is the front leaving the gate
89
+ if any(abs(c.axis_pos - g) < GATE_EXCLUSION_AXIS for g in gate_axis_pos):
90
+ continue # a "weld" at the gate is the gate
91
+ kept.append(c)
92
+
93
+ kept.sort(key=lambda c: c.axis_pos)
94
+ lines: list[WeldLine] = []
95
+ for c in kept:
96
+ if lines and abs(c.axis_pos - lines[-1].axis_pos) < MERGE_AXIS_TOLERANCE:
97
+ prev = lines[-1]
98
+ total = prev.n_nodes + c.n_nodes
99
+ prev.axis_pos = (prev.axis_pos * prev.n_nodes + c.axis_pos * c.n_nodes) / total
100
+ prev.fill_time_s = max(prev.fill_time_s, c.fill_time_s)
101
+ prev.n_nodes = total
102
+ prev.at_end = prev.axis_pos < END_ZONE_AXIS or prev.axis_pos > 1.0 - END_ZONE_AXIS
103
+ continue
104
+ lines.append(
105
+ WeldLine(
106
+ axis_pos=float(c.axis_pos),
107
+ n_nodes=int(c.n_nodes),
108
+ fill_time_s=float(c.fill_time_s),
109
+ at_end=bool(c.axis_pos < END_ZONE_AXIS or c.axis_pos > 1.0 - END_ZONE_AXIS),
110
+ )
111
+ )
112
+ return lines
113
+
114
+
115
+ # ==========================================================================
116
+ # results of the study
117
+ # ==========================================================================
118
+ @dataclass
119
+ class GateOption:
120
+ """One gating candidate, scored. The unit the costing model prices."""
121
+
122
+ n_gates: int
123
+ gates_mm: list[list[float]]
124
+ flow_rate_cm3_s: float
125
+ fill_time_s: float
126
+ max_pressure_bar: float
127
+ max_shear_1s: float
128
+ fill_pct: float
129
+ tail_fraction: float
130
+ n_welds: int # consolidated weld lines (gate artefacts removed)
131
+ weld_axis_pos: list[float] = field(default_factory=list)
132
+ n_interior_welds: int = 0 # the ones that do NOT sit at an end - i.e. across the part
133
+ n_weld_clusters_raw: int = 0 # what the detector reported before consolidation
134
+ short_shot: bool = False
135
+ simulation_id: str = ""
136
+ h5_path: str = ""
137
+ source: str = "cloud" # "cloud" | "estimated"
138
+ label: str = ""
139
+
140
+ def as_dict(self) -> dict:
141
+ return asdict(self)
142
+
143
+ def row(self) -> str:
144
+ return (
145
+ f" {self.label or (str(self.n_gates) + ' gate'):18s} "
146
+ f"P={self.max_pressure_bar:7.1f} bar shear={self.max_shear_1s:8.0f} 1/s "
147
+ f"fill={self.fill_pct:5.1f}% t={self.fill_time_s:5.2f} s "
148
+ f"tail={self.tail_fraction:4.2f} welds={self.n_welds} "
149
+ f"({self.n_interior_welds} interior, from {self.n_weld_clusters_raw} raw clusters)"
150
+ )
151
+
152
+
153
+ @dataclass
154
+ class MaterialResolution:
155
+ """A house grade resolved against the platform catalogue."""
156
+
157
+ house_name: str
158
+ catalogue_name: str
159
+ material_id: str
160
+ version_id: str
161
+ melt_K: float
162
+ wall_K: float
163
+ thermal: dict
164
+ source: str = "catalogue" # thermal data: "catalogue" | "family-default"
165
+ matched_on: str = "" # the query that actually hit
166
+ substituted: bool = False # the house grade itself is not in the catalogue
167
+
168
+ def as_dict(self) -> dict:
169
+ return asdict(self)
170
+
171
+ def note(self) -> str:
172
+ """One sentence for the quote's assumptions slide."""
173
+ if not self.substituted:
174
+ return f"Simulated with catalogue grade '{self.catalogue_name}'."
175
+ return (
176
+ f"'{self.house_name}' is not in the platform's material catalogue, so the filling "
177
+ f"simulation used '{self.catalogue_name}' as the nearest stand-in (matched on "
178
+ f"'{self.matched_on}'). Flow behaviour transfers well within a family; absolute "
179
+ "pressure does not, which is why the clamp force is not sized from it."
180
+ )
181
+
182
+
183
+ # ==========================================================================
184
+ # local meshing (the only step that needs the cadmould licence)
185
+ # ==========================================================================
186
+ @dataclass
187
+ class MeshedPart:
188
+ cfex_path: Path
189
+ node_coords: np.ndarray
190
+ suggested_gate: list[float]
191
+ volume_cm3: float
192
+ long_axis: int
193
+ n_nodes: int
194
+ sdk_fill_time_s: float | None = None
195
+
196
+ def harness_geometry(self) -> T.HarnessGeometry:
197
+ """The shape the reused gate-placement helpers expect."""
198
+ return T.HarnessGeometry(self.cfex_path, self.node_coords, self.suggested_gate, self.volume_cm3, self.long_axis)
199
+
200
+
201
+ def mesh_part(stl_path: str | Path, cfex_out: str | Path, *, logger=print) -> MeshedPart:
202
+ """Mesh the STL with the cadmould SDK and read the real node coordinates back.
203
+
204
+ The AI solver needs every gate to sit on an actual mesh node, and the SDK's ``Mesh`` exposes
205
+ no coordinate getters - so the node table is read back out of the written ``.cfex``. One
206
+ licence session covers the whole function; do not nest another inside it.
207
+ """
208
+ import cadmould
209
+
210
+ stl_path, cfex_out = Path(stl_path), Path(cfex_out)
211
+ # The kit's one STL reader: a gate is stored as an index into this vertex array, so
212
+ # every template must fold and order the vertices the same way.
213
+ pts, faces = stl_reader.read_stl(stl_path)
214
+ pts = np.asarray(pts, np.float64)
215
+ faces = np.asarray(faces, np.int32)
216
+ volume_cm3 = abs(stl_reader.volume_mm3(pts, faces)) / 1000.0
217
+
218
+ mesh_mod = getattr(cadmould, "mesh", None)
219
+ io_mod = getattr(mesh_mod, "io", None) if mesh_mod else None
220
+ Mesh = getattr(mesh_mod, "Mesh", None) or cadmould.domain.Mesh
221
+ Mesher = getattr(mesh_mod, "Mesher", None) or cadmould.tools.Mesher
222
+ CfexWriter = getattr(io_mod, "CfexWriter", None) or cadmould.io.CfexWriter
223
+
224
+ sdk_fill_time = None
225
+ with cadmould.Session.user_based():
226
+ logger(" cadmould licence session opened (user_based); a failed sign-in prints above")
227
+ meshed = Mesher(Mesh(pts.ravel(), faces.ravel())).with_surface_triangulation().with_thickness_and_w2w().build()
228
+ gate = cadmould.tools.suggest_gates(meshed).points[0]
229
+ suggested = [float(gate.x), float(gate.y), float(gate.z)]
230
+ cfex_out.parent.mkdir(parents=True, exist_ok=True)
231
+ CfexWriter().save(meshed, str(cfex_out))
232
+ n_nodes = int(meshed.node_count())
233
+ # The SDK's own fill-time estimate is the one to use where it is reachable: it accounts
234
+ # for wall thickness and the grade's rheology, and a hand-picked "fill it in 1 s" has been
235
+ # measured 3.5x wrong on a thin part. But the chain is
236
+ # estimate_fill_time(mesh, FillProfile) <- suggest_fill_profile(mesh, Material, gates)
237
+ # and `Material` can only be built from a local `.plb` - the cloud catalogue exposes
238
+ # materials by id, not as a downloadable card. So this runs only when a .plb is supplied
239
+ # via CADMOULD_MATERIAL_PLB; otherwise the caller falls back to the wall-thickness rule
240
+ # and says so. Not a bug to fix here - a missing input, named.
241
+ plb = os.environ.get("CADMOULD_MATERIAL_PLB")
242
+ if plb and Path(plb).exists():
243
+ try:
244
+ material = cadmould.material.io.PlbReader().load(plb)
245
+ gates_obj = cadmould.tools.suggest_gates(meshed)
246
+ profile = cadmould.tools.suggest_fill_profile(meshed, material, gates_obj)
247
+ sdk_fill_time = float(cadmould.tools.estimate_fill_time(meshed, profile))
248
+ logger(f" SDK fill-time estimate: {sdk_fill_time:.2f} s (from {Path(plb).name})")
249
+ # Deliberately broad: the SDK's exception types vary across wheel versions.
250
+ except Exception as exc:
251
+ logger(f" (SDK fill-time estimate failed: {type(exc).__name__}: {exc})")
252
+ else:
253
+ logger(" (no CADMOULD_MATERIAL_PLB set - the SDK fill-time estimate needs a local material card)")
254
+
255
+ node_coords = T.parse_cfex_nodes(cfex_out)
256
+ dims = np.ptp(node_coords, axis=0)
257
+ long_axis = int(np.argmax(dims))
258
+ logger(
259
+ f" meshed {n_nodes} nodes; cfex {len(node_coords)} nodes; volume {volume_cm3:.2f} cm3; "
260
+ f"long axis {'xyz'[long_axis]} ({dims[long_axis]:.0f} mm)"
261
+ )
262
+ return MeshedPart(cfex_out, node_coords, suggested, volume_cm3, long_axis, n_nodes, sdk_fill_time)
263
+
264
+
265
+ # ==========================================================================
266
+ # the study
267
+ # ==========================================================================
268
+ class QuotingStudy:
269
+ """One cloud project per quote; meshes once, uploads once, runs small scored batches."""
270
+
271
+ def __init__(
272
+ self,
273
+ project_name: str,
274
+ part: MeshedPart,
275
+ results_dir: str | Path,
276
+ *,
277
+ token: str | None = None,
278
+ geometry_id: str | None = None,
279
+ project_id: str | None = None,
280
+ logger=print,
281
+ ):
282
+ self.part = part
283
+ self.results_dir = Path(results_dir)
284
+ self.logger = logger
285
+ self.api = T.PlatformAPI(T.BASE_URL, token or T.access_token(), logger=lambda *_: None)
286
+ if project_id:
287
+ self.project_id = project_id
288
+ logger(f" reusing cloud project id={project_id}")
289
+ else:
290
+ proj = self.api.get_or_create_project(
291
+ project_name, notes="Quotation study - gating, pressure and process window for a costed offer"
292
+ )
293
+ self.project_id = proj["id"]
294
+ logger(f" cloud project '{project_name}' id={self.project_id}")
295
+ self.geometry_id = geometry_id
296
+ self.material: MaterialResolution | None = None
297
+
298
+ def close(self) -> None:
299
+ self.api.close()
300
+
301
+ def note(self, message: str, *, title: str | None = None) -> None:
302
+ """Write a decision into the cloud project log and echo it locally."""
303
+ self.logger(f" note: {message}")
304
+ self.api.add_note(self.project_id, message, title=title)
305
+
306
+ # -- material ----------------------------------------------------------
307
+ def resolve_material(self, grade: dict) -> MaterialResolution:
308
+ """Find the house grade in the platform catalogue and read its process + thermal data.
309
+
310
+ The catalogue is the authority for melt/mould temperature *and* for the thermal data the
311
+ cooling-time model needs, so a resolved material removes two guesses at once. Record
312
+ which catalogue entry was used - house names and catalogue names are not the same thing.
313
+
314
+ **A house grade that is not in the catalogue must not abort a quote.** Shops run compounds
315
+ the catalogue has never heard of, and refusing to quote because a trade name does not
316
+ match would make this unusable. So the search falls back to the shop's own declared
317
+ stand-in and then to the family's generic grade, and the substitution is carried through
318
+ to the quote's assumptions rather than being hidden.
319
+ """
320
+ primary = grade.get("catalogue_query") or grade["name"]
321
+ candidates = [primary]
322
+ for extra in (grade.get("catalogue_fallback"), f"{grade.get('family', '')} Generic"):
323
+ if extra and extra not in candidates:
324
+ candidates.append(extra)
325
+
326
+ mat, matched = None, ""
327
+ for query in candidates:
328
+ try:
329
+ mat = self.api.find_material(query)
330
+ matched = query
331
+ break
332
+ except RuntimeError:
333
+ self.logger(f" material '{query}' not in the catalogue; trying the next stand-in")
334
+ if mat is None:
335
+ raise RuntimeError(
336
+ f"none of {candidates} is in the material catalogue. Add a reachable "
337
+ f"'catalogue_fallback' for '{grade['name']}' in shop/materials.md."
338
+ )
339
+ substituted = matched != primary
340
+ ver = self.api.get_material_version(mat["latest_version"]["version_id"])
341
+ thermal = dict(FAMILY_THERMAL.get(grade.get("family", ""), DEFAULT_THERMAL))
342
+ got = []
343
+ for key, src in (
344
+ ("lambda_W_mK", "lambda_W_mK"),
345
+ ("cp_J_kgK", "cp_J_kgK"),
346
+ ("rho_kg_m3", "melt_density_kg_m3"),
347
+ ("no_flow_K", "no_flow_temperature_K"),
348
+ ("eject_K", "ejection_temperature_K"),
349
+ ):
350
+ if ver.get(src) is not None:
351
+ thermal[key] = float(ver[src])
352
+ got.append(key)
353
+ self.material = MaterialResolution(
354
+ house_name=grade["name"],
355
+ catalogue_name=mat.get("trade_name", matched),
356
+ material_id=mat["material_id"],
357
+ version_id=mat["latest_version"]["version_id"],
358
+ melt_K=float(ver["suggested_mass_temp_K"]),
359
+ wall_K=float(ver["suggested_wall_temp_K"]),
360
+ thermal=thermal,
361
+ source="catalogue" if len(got) >= 3 else "family-default",
362
+ matched_on=matched,
363
+ substituted=substituted,
364
+ )
365
+ self.logger(
366
+ f" material '{grade['name']}' -> catalogue '{self.material.catalogue_name}'"
367
+ f"{' (SUBSTITUTE)' if substituted else ''} "
368
+ f"melt {self.material.melt_K - 273.15:.0f}C wall {self.material.wall_K - 273.15:.0f}C "
369
+ f"(thermal data: {'catalogue' if got else 'family default'})"
370
+ )
371
+ return self.material
372
+
373
+ # -- geometry ----------------------------------------------------------
374
+ def upload_geometry(self) -> str:
375
+ if self.geometry_id:
376
+ return self.geometry_id
377
+ up = self.api.request_geometry_upload(self.part.cfex_path.name, description="quotation study")
378
+ self.geometry_id = up["geometry_id"]
379
+ self.api.put_cfex(up["presigned_put_url"], self.part.cfex_path)
380
+ self.logger(f" geometry_id={self.geometry_id}")
381
+ return self.geometry_id
382
+
383
+ def gate_axis_positions(self, gates_mm: list[list[float]]) -> list[float]:
384
+ """Each gate's position along the part's long axis, normalised to 0..1.
385
+
386
+ The weld consolidation works in this space because that is the space the result's weld
387
+ clusters are reported in - and because the result mesh is re-centred on the origin while
388
+ the submitted gate coordinates are not, so raw distances between the two are meaningless.
389
+ """
390
+ axis = self.part.long_axis
391
+ coords = self.part.node_coords[:, axis]
392
+ lo, hi = float(coords.min()), float(coords.max())
393
+ span = max(hi - lo, 1e-9)
394
+ return [float((g[axis] - lo) / span) for g in gates_mm]
395
+
396
+ # -- running -----------------------------------------------------------
397
+ def _filling_with_retry(self, cfg: dict, group_id: str, *, retries: int = 4, backoff: float = 2.0) -> dict:
398
+ """The AI filling call, with the backoff the endpoint needs under any concurrency."""
399
+ last: Exception | None = None
400
+ for attempt in range(retries + 1):
401
+ try:
402
+ return self.api.run_filling(
403
+ geometry_id=self.geometry_id,
404
+ material_id=self.material.material_id,
405
+ gate_positions_mm=cfg["gates_mm"],
406
+ melt_temperature_K=self.material.melt_K,
407
+ wall_temperature_K=self.material.wall_K,
408
+ flow_rate_cm3_s=cfg["flow_rate_cm3_s"],
409
+ estimated_filling_time_s=cfg["fill_time_s"],
410
+ model_version=MODEL_VERSION,
411
+ num_timesteps=NUM_TIMESTEPS,
412
+ htc_W_m2K=HTC_W_M2K,
413
+ label=cfg["label"],
414
+ project_id=self.project_id,
415
+ group_id=group_id,
416
+ solver_type="ai",
417
+ )
418
+ except httpx.HTTPStatusError as exc:
419
+ last = exc
420
+ if exc.response.status_code not in (429, 500, 502, 503, 504):
421
+ raise
422
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
423
+ last = exc
424
+ if attempt < retries:
425
+ time.sleep(backoff * (attempt + 1))
426
+ raise last # type: ignore[misc]
427
+
428
+ def run_batch(self, group_name: str, configs: list[dict], *, description: str = "") -> list[GateOption]:
429
+ """Create a cloud group and run its configs (low concurrency - the endpoint sheds load)."""
430
+ self.upload_geometry()
431
+ grp = self.api.create_group(self.project_id, group_name, description=description or f"{len(configs)} runs")
432
+ out_dir = self.results_dir / re.sub(r"[^A-Za-z0-9_-]+", "_", group_name)
433
+ out_dir.mkdir(parents=True, exist_ok=True)
434
+ self.logger(f"\n group '{group_name}' -> {len(configs)} simulations")
435
+
436
+ def run_one(cfg: dict) -> GateOption:
437
+ res = self._filling_with_retry(cfg, grp["id"])
438
+ sim_id = res["simulation_id"]
439
+ h5 = out_dir / f"{cfg['label']}__{sim_id[:8]}.h5"
440
+ self.api.download_to(res["result"]["download"]["url"], h5)
441
+ a = T.metrics.analyze(h5, cfg["gates_mm"], long_axis=self.part.long_axis)
442
+ welds = consolidate_welds(a, self.gate_axis_positions(cfg["gates_mm"]), float(a.total_fill_time_s))
443
+ return GateOption(
444
+ n_gates=len(cfg["gates_mm"]),
445
+ gates_mm=cfg["gates_mm"],
446
+ flow_rate_cm3_s=cfg["flow_rate_cm3_s"],
447
+ fill_time_s=float(a.total_fill_time_s),
448
+ max_pressure_bar=float(a.max_pressure_bar),
449
+ max_shear_1s=float(a.max_shear_rate_1s),
450
+ fill_pct=float(a.fill_pct),
451
+ tail_fraction=float(a.tail_fraction),
452
+ n_welds=len(welds),
453
+ weld_axis_pos=[round(w.axis_pos, 3) for w in welds],
454
+ n_interior_welds=sum(1 for w in welds if not w.at_end),
455
+ n_weld_clusters_raw=int(a.n_welds),
456
+ short_shot=bool(a.short_shot),
457
+ simulation_id=sim_id,
458
+ h5_path=str(h5),
459
+ label=cfg["label"],
460
+ )
461
+
462
+ results: list[GateOption] = []
463
+ # max_workers=2: the synchronous filling endpoint sheds load (5xx) above that.
464
+ with cf.ThreadPoolExecutor(max_workers=min(2, len(configs))) as pool:
465
+ futures = {pool.submit(run_one, c): c for c in configs}
466
+ for fut in cf.as_completed(futures):
467
+ cfg = futures[fut]
468
+ try:
469
+ opt = fut.result()
470
+ # Deliberately broad: one dead run must not kill the batch.
471
+ except Exception as exc:
472
+ self.logger(f" {cfg['label']:18s} FAILED: {type(exc).__name__}: {exc}")
473
+ continue
474
+ results.append(opt)
475
+ self.logger(opt.row())
476
+ results.sort(key=lambda o: (o.n_gates, o.flow_rate_cm3_s))
477
+ return results
478
+
479
+ # -- the two batches a quote actually needs ----------------------------
480
+ def _config(self, gates: list[list[float]], flow: float, label: str) -> dict:
481
+ return {
482
+ "gates_mm": gates,
483
+ "flow_rate_cm3_s": float(flow),
484
+ "fill_time_s": round(self.part.volume_cm3 / max(flow, 1e-6), 3),
485
+ "label": label,
486
+ }
487
+
488
+ def nominal_flow_cm3_s(self, nominal_wall_mm: float | None = None) -> float:
489
+ """Volume / target fill time, with the target from the SDK wherever the wheel provides it.
490
+
491
+ The SDK's ``estimate_fill_time`` accounts for wall thickness and the grade's rheology, so
492
+ it is always preferred. The fallback scales with wall thickness rather than being a
493
+ constant, because a fixed "fill it in 1 s" has been measured 3.5x wrong on a thin part -
494
+ and since the flow rate sets both the fill time and the pressure, that error propagates
495
+ into the cycle, the press and the price.
496
+ """
497
+ target = self.part.sdk_fill_time_s
498
+ if not target or target <= 0:
499
+ target = max(0.3, 0.9 * float(nominal_wall_mm or 1.0))
500
+ self.logger(f" (no SDK fill-time estimate; falling back to {target:.2f} s from wall thickness)")
501
+ return float(self.part.volume_cm3 / target)
502
+
503
+ def gate_count_batch(self, counts: list[int], flow: float) -> list[GateOption]:
504
+ """The decision that costs hot-runner nozzles: how many gates per cavity."""
505
+ geo = self.part.harness_geometry()
506
+ configs = []
507
+ for n in counts:
508
+ fracs = [(i + 0.5) / n for i in range(n)]
509
+ configs.append(self._config(T.gate_line(geo, fracs), flow, f"{n}gate"))
510
+ return self.run_batch(
511
+ "Q1 - gate count",
512
+ configs,
513
+ description=(
514
+ "Gate count at a fixed nominal process. Each extra gate lowers filling pressure "
515
+ "(possibly a smaller press) and adds a hot-runner nozzle per cavity plus a weld "
516
+ "line. The quote takes the cheapest option that still fills."
517
+ ),
518
+ )
519
+
520
+ def process_batch(self, gates: list[list[float]], flows: list[float]) -> list[GateOption]:
521
+ """The flow-rate sweep that sets the fill part of the cycle and the shear ceiling."""
522
+ configs = [self._config(gates, f, f"flow_{f:.0f}") for f in flows]
523
+ return self.run_batch(
524
+ "Q2 - process window",
525
+ configs,
526
+ description=(
527
+ "Flow-rate sweep at the chosen gating. Fixes the fill time used in the cycle-time "
528
+ "estimate and shows where shear rate, not pressure, starts to limit the process."
529
+ ),
530
+ )
531
+
532
+
533
+ # ==========================================================================
534
+ # offline fallback - so the flow is demonstrable without cloud access
535
+ # ==========================================================================
536
+ def estimate_options(
537
+ volume_cm3: float,
538
+ nominal_wall_mm: float,
539
+ flow_length_mm: float,
540
+ counts: list[int],
541
+ grade: dict,
542
+ *,
543
+ fill_time_s: float = 1.0,
544
+ ) -> list[GateOption]:
545
+ """Rule-of-thumb stand-in for the gate-count batch when there is no cloud access.
546
+
547
+ Pressure scales super-linearly with flow length, and flow length halves roughly with each
548
+ doubling of gate count. This reproduces the *shape* of the trade-off well enough to exercise
549
+ the costing - it is **not** a substitute for the simulation, and every quote built on it is
550
+ marked ``source="estimated"`` all the way through to ``quote.json``.
551
+ """
552
+ p_ref = float(grade.get("clamp_pressure_bar", 400))
553
+ out = []
554
+ for n in counts:
555
+ length = flow_length_mm / max(n, 1)
556
+ # p ~ (L/t)^1.3, normalised so that L/t = 100 at one gate lands on the family reference.
557
+ ratio = (length / max(nominal_wall_mm, 1e-6)) / 100.0
558
+ pressure = p_ref * (max(ratio, 1e-3) ** 1.3)
559
+ out.append(
560
+ GateOption(
561
+ n_gates=n,
562
+ gates_mm=[],
563
+ flow_rate_cm3_s=volume_cm3 / max(fill_time_s, 1e-6),
564
+ fill_time_s=fill_time_s,
565
+ max_pressure_bar=pressure,
566
+ max_shear_1s=float("nan"),
567
+ fill_pct=100.0,
568
+ tail_fraction=0.10,
569
+ # A single gate welds only where the front wraps back on itself: the two ends.
570
+ # Every additional gate adds one interior weld at the midpoint of the pair.
571
+ n_welds=(n - 1) + 2,
572
+ n_interior_welds=n - 1,
573
+ source="estimated",
574
+ label=f"{n}gate (est.)",
575
+ )
576
+ )
577
+ return out
@@ -0,0 +1,50 @@
1
+ """The cloud client and result metrics a quote needs, from the kit's shared packages.
2
+
3
+ The kit ships a worked REST client (``cadmould_cloud``: Auth0 PKCE login plus
4
+ ``PlatformAPI``) and a validated result analyser (``cadmould_scoring.metrics``: pressure,
5
+ fill evenness, weld-line detection). A quote needs exactly those, so this module re-exports
6
+ them rather than forking 400 lines that would then drift.
7
+
8
+ ``PlatformAPI.add_note`` is the project decision log: six months on, the estimator can see
9
+ *why* the tool was quoted with two gates. The local log (``cache/<key>/decisions.md``)
10
+ stays the primary record and the project note is the shared copy.
11
+
12
+ ``prepare_geometry`` and the gate helpers come from ``cadmould_geometry.mesh``, which
13
+ imports the licensed ``cadmould`` wheel only inside the functions that mesh - so the
14
+ offline half of a quote (geometry, costing) runs on a machine with no licence.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from cadmould_cloud import auth as cloud_auth
22
+ from cadmould_cloud.client import PlatformAPI
23
+ from cadmould_geometry.mesh import (
24
+ Geometry as HarnessGeometry,
25
+ gate_line,
26
+ parse_cfex_nodes,
27
+ snap,
28
+ )
29
+ from cadmould_scoring import metrics
30
+
31
+ BASE_URL = os.environ.get("CLOUD_BASE_URL", "https://api.simcon.ai/api/v1")
32
+
33
+
34
+ def access_token() -> str:
35
+ """Bearer token for the REST API - env var first, then the PKCE browser login."""
36
+ token = os.environ.get("CLOUD_SOLVER_TOKEN")
37
+ return token if token else cloud_auth.get_access_token()
38
+
39
+
40
+ __all__ = [
41
+ "BASE_URL",
42
+ "HarnessGeometry",
43
+ "PlatformAPI",
44
+ "access_token",
45
+ "cloud_auth",
46
+ "gate_line",
47
+ "metrics",
48
+ "parse_cfex_nodes",
49
+ "snap",
50
+ ]
@@ -0,0 +1,43 @@
1
+ # `shop/` — the agent's own context
2
+
3
+ Everything the quoting agent knows about **this shop**: what we do, what we run it on, what we
4
+ buy, what we charge, and what we got wrong last time. The agent reads **all** of it before it
5
+ quotes anything, and it **writes back** to [`lessons.md`](lessons.md) after every review.
6
+
7
+ Replace the contents with your own shop. The example content is a fictional German
8
+ moldmaker + molder (`Feldmann Formenbau & Spritzguss GmbH`) — realistic in shape, invented in
9
+ numbers.
10
+
11
+ | File | Holds | Who edits it |
12
+ |---|---|---|
13
+ | [`shop-profile.md`](shop-profile.md) | what we sell, what we do in-house, how we quote | you |
14
+ | [`machines.md`](machines.md) | the press list — clamp, shot, tie-bars, hourly rate | you |
15
+ | [`materials.md`](materials.md) | house grades, prices, and the **material bias** | you |
16
+ | [`tooling.md`](tooling.md) | the mold cost model — bases, cavities, slides, hot runner | you |
17
+ | [`commercial.md`](commercial.md) | labour, overhead, scrap, packaging, margin, terms | you |
18
+ | [`lessons.md`](lessons.md) | **corrections from real quotes + the calibration numbers** | **the agent** |
19
+
20
+ ## The two layers in each file
21
+
22
+ Each file is **prose for the agent** plus one fenced block of **JSON for the code**:
23
+
24
+ ````markdown
25
+ ```json machines
26
+ { "machines": [ … ] }
27
+ ```
28
+ ````
29
+
30
+ The prose is where judgement lives ("we don't take Class-A cosmetic work"); the JSON block is
31
+ what [`quoting/shop.py`](../quoting/shop.py) parses so the cost model computes with the same
32
+ numbers you read. Keep the two consistent — if you change a rate in the prose, change it in the
33
+ block. The block name is the word after `json` on the fence; nothing else in the file is parsed.
34
+
35
+ ## How this stays true over time
36
+
37
+ The last stage of every quote is a review: the agent shows what it decided, the user corrects
38
+ it, and the agent writes each correction into `lessons.md` — the **reason** as prose, the
39
+ **number** into the `calibration` block. `quoting/costing.py` reads that block on the next
40
+ quote, so a correction changes the next answer instead of being forgotten. That is the whole
41
+ self-improving loop; there is nothing else to it.
42
+
43
+ Expect the first handful of quotes to be mostly learning. That is the intended use.