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,725 @@
1
+ """From geometry + a gating decision to a tool price and a piece price.
2
+
3
+ Every number here is traceable to two places and nowhere else: the **shop files** (rates, presses,
4
+ grades, the mold cost model) and the **simulation** (cavity pressure, fill time). Nothing is
5
+ hard-coded that an estimator would want to argue with - if you disagree with a figure, it is in
6
+ ``shop/``, and if the estimate came out wrong, the correction goes in ``shop/lessons.md`` and
7
+ changes the next quote.
8
+
9
+ Three model choices worth knowing before you trust the output:
10
+
11
+ * **Cooling time is analytic**, not simulated - the AI filling solver has no cooling phase. The
12
+ one-term slab solution below is the textbook estimate, cross-checkable on paper, and it is
13
+ multiplied by a calibration factor precisely because it is known to run optimistic on ribbed
14
+ parts (see ``shop/lessons.md`` L-001).
15
+ * **Clamp force is never sized from the simulation alone.** The AI model is one cavity with no
16
+ runner system and under-reads absolute pressure on multi-gate parts, so the material's
17
+ rule-of-thumb cavity pressure is taken as a floor. Which one governed is reported.
18
+ * **The gate count is decided on money**, not on pressure: more gates means lower pressure and
19
+ possibly a smaller press, against one more hot-runner nozzle per cavity and one more weld line.
20
+ :func:`compare_options` prices both sides and picks.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ from dataclasses import asdict, dataclass, field
27
+
28
+ from .geometry import PartGeometry
29
+ from .shop import ShopContext
30
+ from .study import GateOption
31
+
32
+
33
+ # ==========================================================================
34
+ # physics-ish helpers
35
+ # ==========================================================================
36
+ def cooling_time_s(wall_mm: float, thermal: dict, melt_K: float, mold_K: float, *, factor: float = 1.0) -> float:
37
+ """One-term slab solution for cooling the part centre to ejection temperature.
38
+
39
+ t = h^2 / (pi^2 * alpha) * ln( 4/pi * (T_melt - T_mold) / (T_eject - T_mold) )
40
+
41
+ with ``h`` the half wall thickness and ``alpha = lambda / (rho * cp)``. This is the estimate a
42
+ process engineer can redo on paper from a datasheet, which is exactly why it is used here: an
43
+ estimate the customer can check beats a black box. ``factor`` carries the shop's measured
44
+ correction (ribs and bosses cool slower than a slab).
45
+ """
46
+ alpha = float(thermal["lambda_W_mK"]) / (float(thermal["rho_kg_m3"]) * float(thermal["cp_J_kgK"]))
47
+ h = (wall_mm / 2.0) / 1000.0
48
+ eject_K, dT = float(thermal["eject_K"]), float(melt_K - mold_K)
49
+ ratio = (4.0 / math.pi) * dT / max(eject_K - mold_K, 1.0)
50
+ if ratio <= 1.0: # mould already above ejection temperature - not a runnable process
51
+ return float("nan")
52
+ return factor * (h * h) / (math.pi**2 * alpha) * math.log(ratio)
53
+
54
+
55
+ def clamp_force_kN(mean_cavity_pressure_bar: float, projected_area_cm2: float) -> float:
56
+ """F[kN] = p[bar] * A[cm2] / 100. (1 bar = 0.1 N/mm2.)"""
57
+ return mean_cavity_pressure_bar * projected_area_cm2 / 100.0
58
+
59
+
60
+ def cavity_layout(cavities: int, tooling: dict) -> tuple[int, int]:
61
+ """Rows x columns for the cavity grid - the shop's preferred grid, else near-square."""
62
+ preferred = tooling.get("layout", {}).get("preferred_grids", {})
63
+ if str(cavities) in preferred:
64
+ cols, rows = preferred[str(cavities)]
65
+ return int(cols), int(rows)
66
+ cols = int(math.ceil(math.sqrt(cavities)))
67
+ rows = int(math.ceil(cavities / cols))
68
+ return cols, rows
69
+
70
+
71
+ # ==========================================================================
72
+ # results
73
+ # ==========================================================================
74
+ @dataclass
75
+ class CycleTime:
76
+ fill_s: float
77
+ pack_s: float
78
+ cool_s: float
79
+ handling_s: float
80
+ total_s: float
81
+ governing_wall_mm: float
82
+ basis: str = ""
83
+
84
+ def as_dict(self) -> dict:
85
+ return asdict(self)
86
+
87
+
88
+ @dataclass
89
+ class MachineChoice:
90
+ name: str
91
+ clamp_kN: float
92
+ rate_eur_h: float
93
+ shot_cm3: float
94
+ required_clamp_kN: float
95
+ clamp_basis: str # "simulation" | "material rule of thumb"
96
+ shot_volume_cm3: float
97
+ shot_utilisation: float
98
+ clamp_utilisation: float
99
+ plate_mm: tuple[float, float]
100
+ fits: bool
101
+ notes: list[str] = field(default_factory=list)
102
+
103
+ def as_dict(self) -> dict:
104
+ return asdict(self)
105
+
106
+
107
+ @dataclass
108
+ class ToolCost:
109
+ lines: list[tuple[str, float]]
110
+ subtotal_eur: float
111
+ risk_eur: float
112
+ margin_eur: float
113
+ total_eur: float
114
+ lead_time_weeks: float
115
+ plate_mm: tuple[float, float]
116
+ cavity_grid: tuple[int, int]
117
+ complexity: float
118
+ hot_runner: bool
119
+ n_nozzles: int
120
+ side_actions: int
121
+ notes: list[str] = field(default_factory=list)
122
+
123
+ def as_dict(self) -> dict:
124
+ return asdict(self)
125
+
126
+
127
+ @dataclass
128
+ class PiecePrice:
129
+ parts_per_year: int
130
+ material_eur: float
131
+ machine_eur: float
132
+ labour_eur: float
133
+ secondary_eur: float
134
+ setup_eur: float
135
+ overhead_eur: float
136
+ margin_eur: float
137
+ total_eur: float
138
+ tool_amortisation_eur: float
139
+
140
+ def as_dict(self) -> dict:
141
+ return asdict(self)
142
+
143
+
144
+ @dataclass
145
+ class Quote:
146
+ part_name: str
147
+ cavities: int
148
+ grade: dict
149
+ geo: PartGeometry
150
+ option: GateOption
151
+ cycle: CycleTime
152
+ machine: MachineChoice
153
+ tool: ToolCost
154
+ ladder: list[PiecePrice]
155
+ shot_mass_g: float
156
+ part_mass_g: float
157
+ currency: str
158
+ assumptions: list[str] = field(default_factory=list)
159
+ flags: list[str] = field(default_factory=list)
160
+
161
+ def reference_price(self) -> PiecePrice:
162
+ """The middle rung of the ladder - what "the price" means when someone asks for one."""
163
+ return self.ladder[len(self.ladder) // 2]
164
+
165
+ def total_cost_of_ownership(self, parts: int) -> float:
166
+ price = min(self.ladder, key=lambda p: abs(p.parts_per_year - parts))
167
+ return self.tool.total_eur + price.total_eur * parts
168
+
169
+ def as_dict(self) -> dict:
170
+ return {
171
+ "part_name": self.part_name,
172
+ "cavities": self.cavities,
173
+ "grade": self.grade,
174
+ "geometry": self.geo.as_dict(),
175
+ "gating": self.option.as_dict(),
176
+ "cycle": self.cycle.as_dict(),
177
+ "machine": self.machine.as_dict(),
178
+ "tool": self.tool.as_dict(),
179
+ "ladder": [p.as_dict() for p in self.ladder],
180
+ "shot_mass_g": round(self.shot_mass_g, 2),
181
+ "part_mass_g": round(self.part_mass_g, 2),
182
+ "currency": self.currency,
183
+ "assumptions": self.assumptions,
184
+ "flags": self.flags,
185
+ }
186
+
187
+ def summary(self) -> str:
188
+ ref = self.reference_price()
189
+ feed = f"{self.tool.n_nozzles} hot-runner nozzle(s)" if self.tool.hot_runner else "cold runner"
190
+ lines = [
191
+ f" gating {self.option.n_gates} gate(s), {self.option.max_pressure_bar:.0f} bar "
192
+ f"({self.option.source}), {self.option.n_welds} weld line(s) of which {self.option.n_interior_welds} interior",
193
+ f" machine {self.machine.name} ({self.machine.required_clamp_kN:.0f} kN required, "
194
+ f"basis: {self.machine.clamp_basis})",
195
+ f" cycle {self.cycle.total_s:.1f} s (fill {self.cycle.fill_s:.2f} + pack "
196
+ f"{self.cycle.pack_s:.1f} + cool {self.cycle.cool_s:.1f} + handling {self.cycle.handling_s:.1f})",
197
+ f" shot {self.shot_mass_g:.1f} g over {self.cavities} cavities "
198
+ f"({self.machine.shot_utilisation * 100:.0f}% of barrel)",
199
+ f" tooling {self.tool.total_eur:,.0f} {self.currency} lead {self.tool.lead_time_weeks:.0f} weeks"
200
+ f" ({feed}, {self.tool.side_actions} side action(s))",
201
+ " piece price " + " ".join(f"{p.parts_per_year // 1000}k: {p.total_eur:.3f}" for p in self.ladder),
202
+ f" reference {ref.total_eur:.3f} {self.currency}/part at {ref.parts_per_year:,} pcs/year",
203
+ ]
204
+ lines += [f" ! {f}" for f in self.flags]
205
+ return "\n".join(lines)
206
+
207
+
208
+ # ==========================================================================
209
+ # the model
210
+ # ==========================================================================
211
+ def _plate_size_mm(geo: PartGeometry, cavities: int, tooling: dict) -> tuple[float, float, tuple[int, int]]:
212
+ """Mold plate footprint from the cavity grid, part size, spacing and edge margin."""
213
+ layout = tooling["layout"]
214
+ in_plane = [i for i in range(3) if i != geo.draw_axis]
215
+ part_a, part_b = geo.bbox_mm[in_plane[0]], geo.bbox_mm[in_plane[1]]
216
+ cols, rows = cavity_layout(cavities, tooling)
217
+ spacing, margin = layout["cavity_spacing_mm"], layout["edge_margin_mm"]
218
+ a = cols * part_a + (cols - 1) * spacing + 2 * margin
219
+ b = rows * part_b + (rows - 1) * spacing + 2 * margin
220
+ a = max(a, layout["min_plate_mm"][0])
221
+ b = max(b, layout["min_plate_mm"][1])
222
+ return a, b, (cols, rows)
223
+
224
+
225
+ def _complexity(geo: PartGeometry, side_actions: int, ctx: ShopContext) -> float:
226
+ """How much harder than a plain cavity this one is to cut.
227
+
228
+ Deliberately **not** built on surface-to-volume: that number is dominated by wall thinness,
229
+ so a trivial 1.5 mm plate scores 15 on it and would price like a ribbed housing. The shape
230
+ factor (surface vs. bounding-box surface) is what actually tracks feature count.
231
+ """
232
+ f = 1.0
233
+ if geo.nominal_wall_mm < 1.2:
234
+ f += 0.15
235
+ if geo.draw_depth_mm > 60:
236
+ f += 0.15
237
+ f += 0.25 * max(0.0, geo.shape_factor - 1.0)
238
+ f += 0.05 * side_actions
239
+ f += 0.02 * geo.through_holes # every hole is a core pin to make, fit and vent, per cavity
240
+ f += float(ctx.cal("complexity_bonus"))
241
+ return min(f, float(ctx.tooling["cavity"]["complexity_cap"]))
242
+
243
+
244
+ def select_machine(
245
+ ctx: ShopContext,
246
+ *,
247
+ required_clamp_kN: float,
248
+ shot_volume_cm3: float,
249
+ plate_mm: tuple[float, float],
250
+ clamp_basis: str,
251
+ ) -> MachineChoice:
252
+ """The smallest press that clears clamp, shot window and tie-bar opening.
253
+
254
+ The three constraints are not equally hard, and pretending they are produces the useless
255
+ answer "nothing fits" on perfectly ordinary parts. **Clamp and tie-bar are hard** - too little
256
+ tonnage means flash, and a mold that does not physically go in does not go in. The **shot
257
+ window is soft**: a shot below 20 % of the barrel is a melt-residence problem to be managed
258
+ (smaller screw, shorter dwell), not a reason to refuse the job. So the search relaxes in that
259
+ order and says which constraint it had to bend.
260
+ """
261
+ cfg = ctx.machines
262
+ max_util = float(cfg.get("max_clamp_utilisation", 0.8))
263
+ lo, hi = cfg.get("shot_utilisation_window", [0.2, 0.8])
264
+ plate = (max(plate_mm), min(plate_mm))
265
+ ordered = sorted(cfg["machines"], key=lambda x: (x["clamp_kN"], x["rate_eur_h"]))
266
+
267
+ def clears_hard(m: dict) -> bool:
268
+ tb = (max(m["tiebar_mm"]), min(m["tiebar_mm"]))
269
+ return required_clamp_kN <= m["clamp_kN"] * max_util and plate[0] <= tb[0] and plate[1] <= tb[1]
270
+
271
+ def build(m: dict, *, fits: bool, notes: list[str]) -> MachineChoice:
272
+ return MachineChoice(
273
+ name=m["name"] + ("" if fits else " (no exact fit)"),
274
+ clamp_kN=float(m["clamp_kN"]),
275
+ rate_eur_h=float(m["rate_eur_h"]),
276
+ shot_cm3=float(m["shot_cm3"]),
277
+ required_clamp_kN=required_clamp_kN,
278
+ clamp_basis=clamp_basis,
279
+ shot_volume_cm3=shot_volume_cm3,
280
+ shot_utilisation=shot_volume_cm3 / float(m["shot_cm3"]),
281
+ clamp_utilisation=required_clamp_kN / float(m["clamp_kN"]),
282
+ plate_mm=plate_mm,
283
+ fits=fits,
284
+ notes=notes,
285
+ )
286
+
287
+ for m in ordered:
288
+ if clears_hard(m) and m["shot_cm3"] * lo <= shot_volume_cm3 <= m["shot_cm3"] * hi:
289
+ return build(m, fits=True, notes=[])
290
+
291
+ # Relax the shot window, keeping clamp and tie-bar.
292
+ for m in ordered:
293
+ if clears_hard(m):
294
+ util = shot_volume_cm3 / float(m["shot_cm3"])
295
+ note = f"shot is {util * 100:.0f}% of the barrel (window {lo * 100:.0f}-{hi * 100:.0f}%): " + (
296
+ "long melt residence - specify a smaller screw or accept the degradation risk"
297
+ if util < lo
298
+ else "no cushion - the next press up is the safe answer"
299
+ )
300
+ return build(m, fits=True, notes=[note])
301
+
302
+ # Genuinely nothing: name the binding constraint so the estimator can act on it.
303
+ biggest = max(cfg["machines"], key=lambda x: x["clamp_kN"])
304
+ widest = max(cfg["machines"], key=lambda x: max(x["tiebar_mm"]))
305
+ notes = []
306
+ if required_clamp_kN > biggest["clamp_kN"] * max_util:
307
+ notes.append(
308
+ f"needs {required_clamp_kN:.0f} kN clamp - above {biggest['name']} "
309
+ f"({biggest['clamp_kN']} kN) at {max_util * 100:.0f}% utilisation"
310
+ )
311
+ tb = (max(widest["tiebar_mm"]), min(widest["tiebar_mm"]))
312
+ if plate[0] > tb[0] or plate[1] > tb[1]:
313
+ notes.append(f"mold plate {plate_mm[0]:.0f}x{plate_mm[1]:.0f} mm clears no press ({widest['name']} is widest)")
314
+ if not notes:
315
+ notes.append("no press satisfies clamp and tie-bar together")
316
+ return build(biggest, fits=False, notes=notes)
317
+
318
+
319
+ def tool_cost(
320
+ ctx: ShopContext,
321
+ geo: PartGeometry,
322
+ *,
323
+ cavities: int,
324
+ gates_per_cavity: int,
325
+ hardened: bool,
326
+ finish: str = "technical",
327
+ ) -> ToolCost:
328
+ """The mold, line by line. The gate count enters as hot-runner nozzles."""
329
+ t = ctx.tooling
330
+ plate_a, plate_b, grid = _plate_size_mm(geo, cavities, t)
331
+ plate_area_cm2 = plate_a * plate_b / 100.0
332
+ complexity = _complexity(geo, geo.side_actions, ctx)
333
+ notes: list[str] = []
334
+
335
+ lines: list[tuple[str, float]] = []
336
+ lines.append(("Mold base + plates", t["base"]["fixed_eur"] + t["base"]["eur_per_cm2_plate"] * plate_area_cm2))
337
+ lines.append(("Guided ejector package", float(t["base"]["guided_ejector_eur"])))
338
+
339
+ cav = t["cavity"]
340
+ cavity_eur = (cav["fixed_eur_per_cavity"] + cav["eur_per_cm2_part_surface"] * geo.surface_area_cm2) * complexity
341
+ lines.append((f"Cavities + cores ({cavities}x, complexity {complexity:.2f})", cavity_eur * cavities))
342
+ if hardened:
343
+ lines.append(("Hardening / coating (bought in)", cav["hardened_surcharge_eur_per_cavity"] * cavities))
344
+
345
+ sa = t["side_actions"]
346
+ if geo.side_actions:
347
+ lines.append((f"Side actions ({geo.side_actions}x slide)", sa["slide_eur"] * geo.side_actions))
348
+
349
+ feed = t["feed_system"]
350
+ hot = cavities >= int(feed["hot_runner_from_cavities"]) or gates_per_cavity > 1
351
+ n_nozzles = cavities * gates_per_cavity if hot else 0
352
+ if hot:
353
+ hr = feed["hot_runner_controller_eur"] + feed["hot_runner_nozzle_eur"] * n_nozzles
354
+ if n_nozzles > 1:
355
+ hr += feed["hot_runner_manifold_eur"]
356
+ lines.append((f"Hot runner ({n_nozzles} nozzle(s) + manifold + controller)", hr))
357
+ else:
358
+ lines.append((f"Cold runner ({gates_per_cavity} drop(s)/cavity)", float(feed["cold_runner_eur"])))
359
+
360
+ cool = t["cooling"]
361
+ circuits = max(int(cool["min_circuits"]), int(math.ceil(plate_area_cm2 / cool["cm2_plate_per_circuit"])))
362
+ lines.append((f"Cooling ({circuits} circuits)", cool["circuit_eur"] * circuits))
363
+
364
+ ej = t["ejection"]
365
+ pins = max(
366
+ int(ej["min_pins_per_cavity"]), int(math.ceil(ej["pins_per_cavity_per_cm2_projected"] * geo.projected_area_cm2))
367
+ )
368
+ lines.append((f"Ejection ({pins} pins/cavity)", ej["fixed_eur"] + ej["pin_eur"] * pins * cavities))
369
+
370
+ fin = t["finish"]
371
+ finish_rate = fin.get(f"{finish}_eur_per_cm2", fin["technical_eur_per_cm2"])
372
+ lines.append((f"Surface finish ({finish})", finish_rate * geo.surface_area_cm2 * cavities))
373
+
374
+ eng = t["engineering"]
375
+ hours = (
376
+ eng["design_hours_base"]
377
+ + eng["design_hours_per_cavity"] * cavities
378
+ + eng["design_hours_per_side_action"] * geo.side_actions
379
+ )
380
+ lines.append((f"Design + engineering ({hours:.0f} h)", eng["design_rate_eur_h"] * hours))
381
+
382
+ smp = t["sampling"]
383
+ lines.append((f"Sampling ({smp['shots_included']} shots incl.)", smp["eur_per_shot"] * smp["shots_included"]))
384
+
385
+ direct = sum(v for _, v in lines)
386
+ pm = direct * eng["project_mgmt_pct"] / 100.0
387
+ lines.append((f"Project management ({eng['project_mgmt_pct']:.0f}%)", pm))
388
+ subtotal = direct + pm
389
+ risk = subtotal * t["risk_contingency_pct"] / 100.0
390
+ margin = (subtotal + risk) * t["margin_pct"] / 100.0
391
+ total = (subtotal + risk + margin) * float(ctx.cal("tooling_price_factor"))
392
+
393
+ lt = t["lead_time"]
394
+ weeks = (
395
+ lt["base_weeks"]
396
+ + lt["weeks_per_extra_cavity"] * max(0, cavities - 1)
397
+ + lt["weeks_per_side_action"] * geo.side_actions
398
+ + (lt["hot_runner_weeks"] if hot else 0)
399
+ + lt["sampling_weeks"]
400
+ + (cav["hardened_lead_weeks"] if hardened else 0)
401
+ + (fin["texturing_lead_weeks"] if finish == "textured" else 0)
402
+ )
403
+
404
+ limit = t["layout"]["max_plate_mm"]
405
+ if plate_a > limit[0] or plate_b > limit[1]:
406
+ notes.append(f"plate {plate_a:.0f}x{plate_b:.0f} mm exceeds the largest mold we build ({limit[0]}x{limit[1]})")
407
+ if complexity >= t["cavity"]["complexity_cap"]:
408
+ notes.append("complexity is at the cap - this is a guess, not an estimate; quote after a design review")
409
+
410
+ return ToolCost(
411
+ lines=lines,
412
+ subtotal_eur=subtotal,
413
+ risk_eur=risk,
414
+ margin_eur=margin,
415
+ total_eur=total,
416
+ lead_time_weeks=weeks,
417
+ plate_mm=(plate_a, plate_b),
418
+ cavity_grid=grid,
419
+ complexity=complexity,
420
+ hot_runner=hot,
421
+ n_nozzles=n_nozzles,
422
+ side_actions=geo.side_actions,
423
+ notes=notes,
424
+ )
425
+
426
+
427
+ def cycle_time(
428
+ ctx: ShopContext,
429
+ geo: PartGeometry,
430
+ option: GateOption,
431
+ thermal: dict,
432
+ melt_K: float,
433
+ mold_K: float,
434
+ *,
435
+ clamp_kN: float,
436
+ ) -> CycleTime:
437
+ """fill + pack + cool + handling, with cooling analytic and everything else from the shop."""
438
+ c = ctx.cycle
439
+ fill = float(option.fill_time_s)
440
+ # Holding time is set by gate freeze, which follows the wall the melt flows through - the
441
+ # nominal wall, not the thickest section. Cooling below is the opposite: it is set by the
442
+ # slowest-cooling section, because the whole part has to be ejectable.
443
+ pack = float(ctx.cal("pack_time_s_per_mm_wall")) * geo.nominal_wall_mm
444
+ cool = cooling_time_s(geo.governing_wall_mm, thermal, melt_K, mold_K, factor=float(ctx.cal("cooling_time_factor")))
445
+ removal = c["part_removal_s"]["robot" if clamp_kN >= c["robot_from_clamp_kN"] else "freefall"]
446
+ handling = c["mold_open_close_s"] + c["ejection_s"] + removal
447
+ # Pack overlaps the start of cooling in reality; the convention here is that the *cooling*
448
+ # clock starts at the end of fill, so the cycle is fill + max(pack, cool) + handling.
449
+ total = (fill + max(pack, cool) + handling) * float(ctx.cal("cycle_time_factor"))
450
+ return CycleTime(
451
+ fill_s=fill,
452
+ pack_s=pack,
453
+ cool_s=cool,
454
+ handling_s=handling,
455
+ total_s=total,
456
+ governing_wall_mm=geo.governing_wall_mm,
457
+ basis=(
458
+ f"cooling from the one-term slab solution on the p95 wall ({geo.governing_wall_mm:.2f} mm), "
459
+ f"factor {ctx.cal('cooling_time_factor')}"
460
+ ),
461
+ )
462
+
463
+
464
+ def piece_price(
465
+ ctx: ShopContext,
466
+ *,
467
+ parts_per_year: int,
468
+ part_mass_g: float,
469
+ runner_mass_per_part_g: float,
470
+ grade: dict,
471
+ machine: MachineChoice,
472
+ cycle_s: float,
473
+ cavities: int,
474
+ tool_total_eur: float,
475
+ ) -> PiecePrice:
476
+ """One rung of the volume ladder."""
477
+ com = ctx.commercial
478
+ mats = ctx.materials
479
+ oee = float(ctx.cycle.get("oee", 1.0))
480
+
481
+ price_kg = float(grade["price_eur_kg"])
482
+ if grade.get("stock") == "per order":
483
+ price_kg *= 1.0 + float(mats.get("procurement_surcharge_pct", 0)) / 100.0
484
+ if grade.get("dry"):
485
+ price_kg += float(mats.get("drying_eur_per_kg", 0.0))
486
+
487
+ scrap = float(com["scrap_pct_established"]) / 100.0
488
+ regrind = float(grade.get("regrind_pct", 0)) / 100.0
489
+ net_runner_g = runner_mass_per_part_g * (1.0 - regrind)
490
+ kg_per_part = (part_mass_g + net_runner_g) / 1000.0 * (1.0 + scrap)
491
+ # Annual tonnage buys a price break - one of only two things that move along the ladder.
492
+ tonnage = kg_per_part * parts_per_year
493
+ breaks = sorted(com.get("material_price_breaks", []), key=lambda b: b["min_kg_per_year"])
494
+ discount = max((b["discount_pct"] for b in breaks if tonnage >= b["min_kg_per_year"]), default=0.0)
495
+ material = kg_per_part * price_kg * (1.0 - discount / 100.0)
496
+
497
+ machine_h = cycle_s / 3600.0 / max(cavities, 1) / max(oee, 1e-6)
498
+ machine_eur = machine.rate_eur_h * machine_h
499
+ labour_eur = (float(com["labour_rate_eur_h"]) / float(com["machines_per_operator"])) * machine_h
500
+ secondary = float(com["packaging_eur_per_part"]) + float(com["qc_eur_per_part"])
501
+
502
+ # Orders per year is capped by the minimum economic batch: at 10k/year nobody sets the tool
503
+ # up twelve times for 800-part runs, and pretending otherwise buries the low-volume rung of
504
+ # the ladder under a setup cost that would never be incurred.
505
+ min_batch = float(com.get("min_order_parts", 1))
506
+ orders = max(1.0, min(float(com["orders_per_year"]), parts_per_year / max(min_batch, 1.0)))
507
+ setup_per_order = float(com["setup_hours_per_order"]) * machine.rate_eur_h
508
+ setup = setup_per_order * orders / max(parts_per_year, 1)
509
+
510
+ overhead = (material + labour_eur) * float(com["overhead_pct_on_material_and_labour"]) / 100.0
511
+ manufacturing = material + machine_eur + labour_eur + secondary + setup
512
+ margin = (manufacturing + overhead) * float(com["margin_pct"]) / 100.0
513
+ total = (manufacturing + overhead + margin) * float(ctx.cal("piece_price_factor"))
514
+
515
+ return PiecePrice(
516
+ parts_per_year=int(parts_per_year),
517
+ material_eur=material,
518
+ machine_eur=machine_eur,
519
+ labour_eur=labour_eur,
520
+ secondary_eur=secondary,
521
+ setup_eur=setup,
522
+ overhead_eur=overhead,
523
+ margin_eur=margin,
524
+ total_eur=total,
525
+ tool_amortisation_eur=tool_total_eur / max(int(com["amortisation_default_parts"]), 1),
526
+ )
527
+
528
+
529
+ def build_quote(
530
+ ctx: ShopContext,
531
+ geo: PartGeometry,
532
+ option: GateOption,
533
+ *,
534
+ cavities: int,
535
+ grade: dict,
536
+ thermal: dict,
537
+ melt_K: float,
538
+ mold_K: float,
539
+ finish: str = "technical",
540
+ material_note: str = "",
541
+ ) -> Quote:
542
+ """Price one gating option end to end."""
543
+ assumptions: list[str] = []
544
+ if ctx.is_example:
545
+ assumptions.append(
546
+ "BUILT ON EXAMPLE SHOP DATA: the presses, rates, material prices and mould cost model "
547
+ "in shop/ are the shipped fictional example and have not been replaced with this "
548
+ "shop's own figures. This document shows the format of a quotation, not a price."
549
+ )
550
+ if material_note:
551
+ assumptions.append(material_note)
552
+ flags: list[str] = []
553
+ t = ctx.tooling
554
+
555
+ # -- clamp force: simulation as a lower bound, material rule of thumb as the floor --------
556
+ rule_bar = float(grade.get("clamp_pressure_bar", 400))
557
+ sim_bar = float(option.max_pressure_bar) * float(ctx.cal("sim_peak_to_mean_pressure"))
558
+ use_sim = bool(ctx.cal("use_simulated_clamp_pressure")) and option.source == "cloud" and math.isfinite(sim_bar)
559
+ mean_bar = max(sim_bar, rule_bar) if use_sim else rule_bar
560
+ basis = "simulation" if (use_sim and sim_bar >= rule_bar) else "material rule of thumb"
561
+
562
+ # The runner's own projected area adds to the clamp on a cold-runner tool; on a hot runner it
563
+ # does not (the melt channel is inside the manifold, not in the parting line).
564
+ hot = cavities >= int(t["feed_system"]["hot_runner_from_cavities"]) or option.n_gates > 1
565
+ runner_proj_cm2 = 0.0 if hot else 0.15 * geo.projected_area_cm2 * cavities
566
+ projected_total = geo.projected_area_cm2 * cavities + runner_proj_cm2
567
+ required = clamp_force_kN(mean_bar, projected_total) * float(ctx.cal("clamp_safety_factor"))
568
+
569
+ # -- shot ---------------------------------------------------------------------------------
570
+ density = float(grade["density_g_cm3"])
571
+ runner_cm3 = 0.0 if hot else float(t["feed_system"]["cold_runner_volume_cm3_per_drop"]) * cavities * option.n_gates
572
+ shot_cm3 = geo.volume_cm3 * cavities + runner_cm3
573
+ part_mass_g = geo.volume_cm3 * density
574
+ shot_mass_g = shot_cm3 * density
575
+ runner_per_part_g = (runner_cm3 * density) / max(cavities, 1)
576
+
577
+ plate_a, plate_b, _ = _plate_size_mm(geo, cavities, t)
578
+ machine = select_machine(
579
+ ctx,
580
+ required_clamp_kN=required,
581
+ shot_volume_cm3=shot_cm3,
582
+ plate_mm=(plate_a, plate_b),
583
+ clamp_basis=basis,
584
+ )
585
+
586
+ cycle = cycle_time(ctx, geo, option, thermal, melt_K, mold_K, clamp_kN=machine.clamp_kN)
587
+
588
+ # Hardened steel above 100k parts/year or for any glass-filled grade (abrasive).
589
+ ladder = ctx.volume_ladder()
590
+ hardened = max(ladder) > 100_000 or "GF" in str(grade.get("family", ""))
591
+ tool = tool_cost(ctx, geo, cavities=cavities, gates_per_cavity=option.n_gates, hardened=hardened, finish=finish)
592
+
593
+ prices = [
594
+ piece_price(
595
+ ctx,
596
+ parts_per_year=v,
597
+ part_mass_g=part_mass_g,
598
+ runner_mass_per_part_g=runner_per_part_g,
599
+ grade=grade,
600
+ machine=machine,
601
+ cycle_s=cycle.total_s,
602
+ cavities=cavities,
603
+ tool_total_eur=tool.total_eur,
604
+ )
605
+ for v in ladder
606
+ ]
607
+
608
+ # -- what the estimator must know ---------------------------------------------------------
609
+ assumptions.append(
610
+ f"Piece prices are quoted at {', '.join(f'{v // 1000}k' for v in ladder)} parts/year; no annual "
611
+ "volume was stated by the customer."
612
+ )
613
+ assumptions.append(
614
+ f"Cavity pressure for clamp sizing: {mean_bar:.0f} bar ({basis}). "
615
+ f"Simulated peak {option.max_pressure_bar:.0f} bar x {ctx.cal('sim_peak_to_mean_pressure')} = "
616
+ f"{sim_bar:.0f} bar mean; material rule of thumb {rule_bar:.0f} bar."
617
+ )
618
+ assumptions.append(
619
+ f"Cooling time {cycle.cool_s:.1f} s is analytic (one-term slab on the {geo.governing_wall_mm:.2f} mm "
620
+ f"p95 wall, factor {ctx.cal('cooling_time_factor')}); the filling solver models no cooling phase."
621
+ )
622
+ assumptions.append(f"{'Hardened' if hardened else 'Pre-hardened'} steel; surface finish '{finish}'.")
623
+ assumptions.append(f"{geo.side_actions} side action(s) taken from the geometry - verify against the drawing.")
624
+ if option.source != "cloud":
625
+ assumptions.append(
626
+ "No simulation was run: gating and pressure are rule-of-thumb estimates. Treat the machine "
627
+ "selection and the cycle as provisional."
628
+ )
629
+
630
+ if machine.fits:
631
+ flags += machine.notes
632
+ else:
633
+ flags.append("no press in the shop fits: " + "; ".join(machine.notes))
634
+ if option.short_shot:
635
+ flags.append("the simulation did not fill the part at this gating")
636
+ if option.n_interior_welds:
637
+ flags.append(
638
+ f"{option.n_interior_welds} weld line(s) predicted across the part (plus "
639
+ f"{option.n_welds - option.n_interior_welds} at the ends) - confirm none falls on a "
640
+ "functional or visible face"
641
+ )
642
+ elif option.n_welds:
643
+ flags.append(
644
+ f"{option.n_welds} weld line(s) predicted, all at the part ends - normally hidden, "
645
+ "but confirm against the drawing"
646
+ )
647
+ if not geo.watertight:
648
+ flags.append("STL is not watertight; volume and wall thickness are estimates")
649
+ flags += tool.notes
650
+ flags += geo.notes
651
+
652
+ return Quote(
653
+ part_name=geo.path.stem,
654
+ cavities=cavities,
655
+ grade=grade,
656
+ geo=geo,
657
+ option=option,
658
+ cycle=cycle,
659
+ machine=machine,
660
+ tool=tool,
661
+ ladder=prices,
662
+ shot_mass_g=shot_mass_g,
663
+ part_mass_g=part_mass_g,
664
+ currency=ctx.currency(),
665
+ assumptions=assumptions,
666
+ flags=flags,
667
+ )
668
+
669
+
670
+ def compare_options(
671
+ ctx: ShopContext,
672
+ geo: PartGeometry,
673
+ options: list[GateOption],
674
+ *,
675
+ cavities: int,
676
+ grade: dict,
677
+ thermal: dict,
678
+ melt_K: float,
679
+ mold_K: float,
680
+ reference_parts: int | None = None,
681
+ finish: str = "technical",
682
+ material_note: str = "",
683
+ ) -> list[Quote]:
684
+ """Price every gating option and rank them by total cost of ownership.
685
+
686
+ This is the decision the study exists to inform, and it is a **commercial** one: a second
687
+ gate lowers pressure (maybe a press class cheaper, maybe a shorter fill) and costs one more
688
+ hot-runner nozzle per cavity plus a weld line. Neither side wins in general - which is why
689
+ both are priced instead of argued.
690
+ """
691
+ ladder = ctx.volume_ladder()
692
+ ref = reference_parts or ladder[len(ladder) // 2]
693
+ quotes = [
694
+ build_quote(
695
+ ctx,
696
+ geo,
697
+ o,
698
+ cavities=cavities,
699
+ grade=grade,
700
+ thermal=thermal,
701
+ melt_K=melt_K,
702
+ mold_K=mold_K,
703
+ finish=finish,
704
+ material_note=material_note,
705
+ )
706
+ for o in options
707
+ ]
708
+ quotes.sort(key=lambda q: (not q.machine.fits, q.option.short_shot, q.total_cost_of_ownership(ref)))
709
+ return quotes
710
+
711
+
712
+ def comparison_table(quotes: list[Quote], reference_parts: int) -> str:
713
+ head = (
714
+ f" {'gating':>8} {'press':<24} {'cycle':>7} {'tool':>10} {'piece':>8} "
715
+ f"{'TCO @ ' + str(reference_parts // 1000) + 'k':>12} interior welds"
716
+ )
717
+ rows = [head, " " + "-" * (len(head) - 2)]
718
+ for q in quotes:
719
+ price = min(q.ladder, key=lambda p: abs(p.parts_per_year - reference_parts))
720
+ rows.append(
721
+ f" {q.option.n_gates:>6} g {q.machine.name:<24} {q.cycle.total_s:>6.1f}s "
722
+ f"{q.tool.total_eur:>10,.0f} {price.total_eur:>8.3f} "
723
+ f"{q.total_cost_of_ownership(reference_parts):>12,.0f} {q.option.n_interior_welds}"
724
+ )
725
+ return "\n".join(rows)