simcon-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simcon_toolkit/__init__.py +40 -0
- simcon_toolkit/__main__.py +7 -0
- simcon_toolkit/_kit/LICENSE +202 -0
- simcon_toolkit/_kit/NOTICE +37 -0
- simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
- simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
- simcon_toolkit/_kit/packages/.ruff.toml +10 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
- simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
- simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
- simcon_toolkit/_kit/pyproject.toml +232 -0
- simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
- simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
- simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
- simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
- simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
- simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
- simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
- simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
- simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
- simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
- simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
- simcon_toolkit/_kit/templates/process-window/README.md +176 -0
- simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
- simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
- simcon_toolkit/_kit/templates/process-window/main.py +82 -0
- simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
- simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
- simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
- simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
- simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
- simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
- simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
- simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
- simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
- simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
- simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
- simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
- simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
- simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
- simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
- simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
- simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
- simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
- simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
- simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
- simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
- simcon_toolkit/_kit/templates/quoting/README.md +158 -0
- simcon_toolkit/_kit/templates/quoting/main.py +484 -0
- simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
- simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
- simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
- simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
- simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
- simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
- simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
- simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
- simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
- simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
- simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
- simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
- simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
- simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
- simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
- simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
- simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
- simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
- simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
- simcon_toolkit/_kit/templates.toml +83 -0
- simcon_toolkit/choices.py +11 -0
- simcon_toolkit/cli.py +381 -0
- simcon_toolkit/generate.py +590 -0
- simcon_toolkit/instructions.py +152 -0
- simcon_toolkit/manifest.py +86 -0
- simcon_toolkit/project.py +356 -0
- simcon_toolkit/wizard.py +160 -0
- simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
- simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
- simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
- simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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)
|