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