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,484 @@
|
|
|
1
|
+
"""Driver for an agentic quote: geometry -> gating study -> costing -> lessons.
|
|
2
|
+
|
|
3
|
+
Four stages, each a subcommand, each resumable:
|
|
4
|
+
|
|
5
|
+
python main.py intake --cavities 4 # uses sample/clip_frame.stl
|
|
6
|
+
python main.py intake --part my.stl --cavities 4
|
|
7
|
+
python main.py study --part my.stl # cloud: the gating decision
|
|
8
|
+
python main.py cost --part my.stl # price every gating option
|
|
9
|
+
python main.py learn --quote <key> # apply the estimator's corrections
|
|
10
|
+
|
|
11
|
+
The geometry is named rather than positional, and the same way in every template: these
|
|
12
|
+
workflows will grow inputs, and a flag keeps that from reordering anyone's command. The
|
|
13
|
+
subcommand stays positional - it is a verb, not an input.
|
|
14
|
+
|
|
15
|
+
**A quote is keyed on the part's contents, not its file name.** Re-export a part and it
|
|
16
|
+
becomes a new quote, because the mesh, the uploaded geometry and the decisions already on
|
|
17
|
+
file describe the previous revision. ``quoting.state.part_key`` is where that is decided.
|
|
18
|
+
The last stage takes ``--quote`` rather than ``--part`` for the same reason: it corrects a
|
|
19
|
+
quote that already exists, and it never opens the geometry.
|
|
20
|
+
|
|
21
|
+
Two folders, both created on demand and both safe to delete:
|
|
22
|
+
|
|
23
|
+
* ``cache/<key>/`` - ours. The mesh, the cloud ids, ``state.json`` and the decision log.
|
|
24
|
+
Deleting it costs a re-mesh and a re-upload, never a wrong answer.
|
|
25
|
+
* ``output/<key>/`` - yours. ``quote.json`` and the simulation results behind it.
|
|
26
|
+
|
|
27
|
+
The quote itself lands in ``output/<key>/quote.json``. The kit writes no document: hand that
|
|
28
|
+
file to your assistant and ask for the quotation in whatever form your customer expects.
|
|
29
|
+
|
|
30
|
+
``intake`` and ``cost`` are fully offline; only ``study`` needs a licence and cloud access. Add
|
|
31
|
+
``--offline`` to ``study`` to substitute rule-of-thumb gating so the whole flow is demonstrable
|
|
32
|
+
without credentials - every artefact it produces is then marked as estimated.
|
|
33
|
+
|
|
34
|
+
The agent drives these; the stages exist so that each decision is written down before the next
|
|
35
|
+
one depends on it. See ``METHOD.md`` for how to run the flow.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import contextlib
|
|
42
|
+
import json
|
|
43
|
+
import sys
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from quoting import costing, geometry, shop, state
|
|
47
|
+
|
|
48
|
+
HERE = Path(__file__).resolve().parent
|
|
49
|
+
# Written by the generator, so this path resolves in your project and not in ours.
|
|
50
|
+
DEFAULT_PART = HERE / "sample" / "clip_frame.stl"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _utf8_stdout() -> None:
|
|
54
|
+
# Windows consoles default to cp1252 and choke on the odd non-ASCII glyph.
|
|
55
|
+
with contextlib.suppress(Exception):
|
|
56
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _part(args: argparse.Namespace, *, resuming: bool = False) -> Path:
|
|
60
|
+
"""The geometry this stage acts on, checked before anything is written.
|
|
61
|
+
|
|
62
|
+
A resuming stage refuses to guess which quote it continues; see
|
|
63
|
+
``quoting.state.part_for_stage``.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
chosen = state.part_for_stage(args.part, DEFAULT_PART) if resuming else Path(args.part)
|
|
67
|
+
except state.AmbiguousQuote as exc:
|
|
68
|
+
raise SystemExit(str(exc)) from None
|
|
69
|
+
part = Path(chosen).resolve()
|
|
70
|
+
if not part.is_file():
|
|
71
|
+
where = "the bundled sample is missing" if part == DEFAULT_PART else f"no such geometry: {part}"
|
|
72
|
+
raise SystemExit(f"{where}\nPass your own STL with --part, or put one back at {DEFAULT_PART}.")
|
|
73
|
+
return part
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _open_quote(part: Path) -> state.QuoteState:
|
|
77
|
+
"""The quote for this part, which must already have been through `intake`."""
|
|
78
|
+
quote = state.QuoteState.for_part(part)
|
|
79
|
+
if not quote.path.exists():
|
|
80
|
+
known = state.quotes_on_file()
|
|
81
|
+
listed = "\n ".join(known) if known else "(none yet)"
|
|
82
|
+
raise SystemExit(
|
|
83
|
+
f"no quote on file for {part.name} as it stands today (key {quote.key}).\n"
|
|
84
|
+
f"Run `intake` first. Quotes that do exist:\n {listed}\n"
|
|
85
|
+
"A quote is keyed on the part's contents, so a re-exported part starts a new one."
|
|
86
|
+
)
|
|
87
|
+
return quote
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _load_geo(part: Path, st: state.QuoteState) -> geometry.PartGeometry:
|
|
91
|
+
"""Re-derive the PartGeometry from the STL, with the draw direction chosen at intake."""
|
|
92
|
+
facts = st.get("geometry")
|
|
93
|
+
if not facts:
|
|
94
|
+
raise SystemExit("no geometry on file - run `intake` first")
|
|
95
|
+
return geometry.analyse(part, draw_axis="xyz".index(facts["draw_axis"]))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _material_note(st: state.QuoteState) -> str:
|
|
99
|
+
"""The catalogue-substitution sentence, if the house grade was not in the catalogue."""
|
|
100
|
+
mat = st.get("material") or {}
|
|
101
|
+
if not mat.get("substituted"):
|
|
102
|
+
return ""
|
|
103
|
+
from quoting.study import MaterialResolution
|
|
104
|
+
|
|
105
|
+
fields = {f: mat[f] for f in MaterialResolution.__dataclass_fields__ if f in mat}
|
|
106
|
+
return MaterialResolution(**fields).note()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _options(st: state.QuoteState):
|
|
110
|
+
from quoting.study import GateOption
|
|
111
|
+
|
|
112
|
+
raw = st.get("gate_options")
|
|
113
|
+
if not raw:
|
|
114
|
+
raise SystemExit("no gating options on file - run `study` first")
|
|
115
|
+
return [GateOption(**o) for o in raw]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ==========================================================================
|
|
119
|
+
# 0. setup - make the shop context this user's shop, not the example one
|
|
120
|
+
# ==========================================================================
|
|
121
|
+
EXAMPLE_MARKERS = [
|
|
122
|
+
("shop-profile.md", "name", lambda ctx: ctx.shop.get("name")),
|
|
123
|
+
("machines.md", "first press", lambda ctx: (ctx.machines.get("machines") or [{}])[0].get("name")),
|
|
124
|
+
("materials.md", "default grade", lambda ctx: ctx.materials.get("default_grade")),
|
|
125
|
+
("commercial.md", "labour rate", lambda ctx: ctx.commercial.get("labour_rate_eur_h")),
|
|
126
|
+
("tooling.md", "cavity base price", lambda ctx: (ctx.tooling.get("cavity") or {}).get("fixed_eur_per_cavity")),
|
|
127
|
+
]
|
|
128
|
+
EXAMPLE_VALUES = {
|
|
129
|
+
"name": shop.EXAMPLE_SHOP_NAME,
|
|
130
|
+
"first press": "Engel e-mac 50",
|
|
131
|
+
"default grade": "PP TV20",
|
|
132
|
+
"labour rate": 38.0,
|
|
133
|
+
"cavity base price": 2500,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_setup(args: argparse.Namespace) -> int:
|
|
138
|
+
"""Report how much of the shop context is still the shipped example, or confirm it is ours."""
|
|
139
|
+
ctx = shop.load()
|
|
140
|
+
still_example = [(f, what) for f, what, get in EXAMPLE_MARKERS if get(ctx) == EXAMPLE_VALUES[what]]
|
|
141
|
+
|
|
142
|
+
if not args.confirm:
|
|
143
|
+
print(f"\nshop context: {ctx.summary()}")
|
|
144
|
+
print(f"provenance: {ctx.shop.get('provenance', 'example')}")
|
|
145
|
+
if still_example:
|
|
146
|
+
print("\nstill at the shipped example values:")
|
|
147
|
+
for f, what in still_example:
|
|
148
|
+
print(f" - {f:18s} {what}")
|
|
149
|
+
else:
|
|
150
|
+
print("\nno obvious example values left.")
|
|
151
|
+
print(
|
|
152
|
+
"\nWork through INTERVIEW.md with the estimator and edit the shop/ files directly.\n"
|
|
153
|
+
"Then record whose shop this is:\n"
|
|
154
|
+
' python main.py setup --confirm "<Your Shop GmbH>"\n'
|
|
155
|
+
"Until then every quote is watermarked as built on example data."
|
|
156
|
+
)
|
|
157
|
+
return 1 if ctx.is_example else 0
|
|
158
|
+
|
|
159
|
+
if still_example and not args.force:
|
|
160
|
+
print("\nrefusing to confirm - these are still the shipped example values:")
|
|
161
|
+
for f, what in still_example:
|
|
162
|
+
print(f" - {f:18s} {what}")
|
|
163
|
+
print(
|
|
164
|
+
"\nThe point of the interview is that these change. Fix them, or pass --force if a "
|
|
165
|
+
"value genuinely matches the example by coincidence."
|
|
166
|
+
)
|
|
167
|
+
return 1
|
|
168
|
+
|
|
169
|
+
profile = ctx.root / "shop-profile.md"
|
|
170
|
+
block = shop.read_block(profile, "shop")
|
|
171
|
+
block["provenance"] = args.confirm
|
|
172
|
+
block["name"] = args.confirm
|
|
173
|
+
shop.write_block(profile, "shop", block)
|
|
174
|
+
print(f"\n shop context is now '{args.confirm}'. Quotes are no longer watermarked as example data.")
|
|
175
|
+
print(" Re-run the interview whenever a press, a rate or the margin changes.")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ==========================================================================
|
|
180
|
+
# 1. intake - everything the geometry alone can decide
|
|
181
|
+
# ==========================================================================
|
|
182
|
+
def cmd_intake(args: argparse.Namespace) -> int:
|
|
183
|
+
ctx = shop.load()
|
|
184
|
+
print(f"\nshop context: {ctx.summary()}")
|
|
185
|
+
if ctx.is_example:
|
|
186
|
+
print("\n" + ctx.example_warning() + "\n")
|
|
187
|
+
stl = _part(args)
|
|
188
|
+
|
|
189
|
+
print(f"\n1. reading the geometry ({stl.name})")
|
|
190
|
+
geo = geometry.analyse(stl, draw_axis=None if args.draw_axis is None else "xyz".index(args.draw_axis))
|
|
191
|
+
print(geo.report())
|
|
192
|
+
|
|
193
|
+
st = state.QuoteState.for_part(stl)
|
|
194
|
+
grade = ctx.find_grade(args.material)
|
|
195
|
+
st.update(
|
|
196
|
+
part=stl.stem,
|
|
197
|
+
cavities=args.cavities,
|
|
198
|
+
geometry=geo.as_dict(),
|
|
199
|
+
grade=grade,
|
|
200
|
+
material_specified=bool(args.material),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
print(f"\n2. material: {grade['name']} ({'specified' if args.material else 'shop default proposal'})")
|
|
204
|
+
if not args.material:
|
|
205
|
+
print(
|
|
206
|
+
" NOTE: no material was specified. Ask the customer before the quote goes out - "
|
|
207
|
+
f"the ask is scripted in shop/materials.md. Quoting {grade['name']} as an assumption."
|
|
208
|
+
)
|
|
209
|
+
st.decide(
|
|
210
|
+
"Material",
|
|
211
|
+
grade["name"],
|
|
212
|
+
(
|
|
213
|
+
"specified by the customer"
|
|
214
|
+
if args.material
|
|
215
|
+
else f"no grade given; house default from shop/materials.md ({grade['family']}, "
|
|
216
|
+
f"{grade['price_eur_kg']} {ctx.currency()}/kg, {grade['stock']})"
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
st.decide(
|
|
220
|
+
"Draw direction and cavity count",
|
|
221
|
+
f"draw along {'xyz'[geo.draw_axis]}, {args.cavities} cavities",
|
|
222
|
+
f"draw axis taken as the largest projected area ({geo.projected_area_cm2:.1f} cm2); "
|
|
223
|
+
"the cavity count is the customer's and is not ours to change",
|
|
224
|
+
)
|
|
225
|
+
print(f"\n state -> {st.path}")
|
|
226
|
+
print(f" next: python main.py study --part {stl}")
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ==========================================================================
|
|
231
|
+
# 2. study - buy the decisions that move the price
|
|
232
|
+
# ==========================================================================
|
|
233
|
+
def cmd_study(args: argparse.Namespace) -> int:
|
|
234
|
+
from quoting import study as S
|
|
235
|
+
|
|
236
|
+
stl = _part(args, resuming=True)
|
|
237
|
+
st = _open_quote(stl)
|
|
238
|
+
geo = _load_geo(stl, st)
|
|
239
|
+
grade = st.get("grade")
|
|
240
|
+
counts = [int(c) for c in args.gate_counts.split(",")]
|
|
241
|
+
|
|
242
|
+
if args.offline:
|
|
243
|
+
print("\n OFFLINE: no cloud runs. Gating and pressure are rule-of-thumb estimates.")
|
|
244
|
+
options = S.estimate_options(
|
|
245
|
+
geo.volume_cm3,
|
|
246
|
+
geo.nominal_wall_mm,
|
|
247
|
+
max(geo.bbox_mm),
|
|
248
|
+
counts,
|
|
249
|
+
grade,
|
|
250
|
+
fill_time_s=max(0.5, geo.nominal_wall_mm),
|
|
251
|
+
)
|
|
252
|
+
thermal = S.FAMILY_THERMAL.get(grade.get("family", ""), S.DEFAULT_THERMAL)
|
|
253
|
+
melt_K, mold_K = 513.15, 313.15
|
|
254
|
+
material = {"house_name": grade["name"], "source": "offline estimate"}
|
|
255
|
+
for o in options:
|
|
256
|
+
print(o.row())
|
|
257
|
+
else:
|
|
258
|
+
cfex = st.dir / f"{stl.stem}.cfex"
|
|
259
|
+
print(f"\n1. meshing {geo.path.name} (cadmould SDK - needs a licence)")
|
|
260
|
+
part = S.mesh_part(geo.path, cfex)
|
|
261
|
+
st.update(mesh={"cfex": str(cfex), "nodes": part.n_nodes, "sdk_fill_time_s": part.sdk_fill_time_s})
|
|
262
|
+
|
|
263
|
+
print("\n2. connecting to the cloud")
|
|
264
|
+
study = S.QuotingStudy(
|
|
265
|
+
f"QUOTE {st.key}",
|
|
266
|
+
part,
|
|
267
|
+
st.output_dir / "results",
|
|
268
|
+
geometry_id=st.get("geometry_id"),
|
|
269
|
+
project_id=st.get("project_id"),
|
|
270
|
+
)
|
|
271
|
+
try:
|
|
272
|
+
mat = study.resolve_material(grade)
|
|
273
|
+
study.upload_geometry()
|
|
274
|
+
st.update(project_id=study.project_id, geometry_id=study.geometry_id, material=mat.as_dict())
|
|
275
|
+
study.note(
|
|
276
|
+
f"Quoting {stl.stem} in {grade['name']} ({mat.catalogue_name}), {st.get('cavities')} cavities. "
|
|
277
|
+
f"Gate-count options simulated: {counts}.",
|
|
278
|
+
title="Quotation study",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
flow = study.nominal_flow_cm3_s(geo.nominal_wall_mm)
|
|
282
|
+
print(f"\n3. gate-count study at {flow:.1f} cm3/s (fill ~{geo.volume_cm3 / flow:.2f} s)")
|
|
283
|
+
options = study.gate_count_batch(counts, flow)
|
|
284
|
+
if not options:
|
|
285
|
+
raise SystemExit("every simulation failed - check the token and the endpoint, then retry")
|
|
286
|
+
|
|
287
|
+
if args.process_sweep:
|
|
288
|
+
best = min(options, key=lambda o: o.max_pressure_bar)
|
|
289
|
+
flows = [flow * f for f in (0.6, 0.8, 1.0, 1.3, 1.7)]
|
|
290
|
+
print("\n4. process window at the lowest-pressure gating")
|
|
291
|
+
options += study.process_batch(best.gates_mm, flows)
|
|
292
|
+
material = mat.as_dict()
|
|
293
|
+
thermal, melt_K, mold_K = mat.thermal, mat.melt_K, mat.wall_K
|
|
294
|
+
finally:
|
|
295
|
+
study.close()
|
|
296
|
+
|
|
297
|
+
st.update(
|
|
298
|
+
gate_options=[o.as_dict() for o in options],
|
|
299
|
+
thermal=thermal,
|
|
300
|
+
melt_K=melt_K,
|
|
301
|
+
mold_K=mold_K,
|
|
302
|
+
material=material,
|
|
303
|
+
study_source="estimated" if args.offline else "cloud",
|
|
304
|
+
)
|
|
305
|
+
print(f"\n {len(options)} option(s) on file -> {st.path}")
|
|
306
|
+
print(f" next: python main.py cost --part {stl}")
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ==========================================================================
|
|
311
|
+
# 3. cost - price every option and pick on money
|
|
312
|
+
# ==========================================================================
|
|
313
|
+
def cmd_cost(args: argparse.Namespace) -> int:
|
|
314
|
+
ctx = shop.load()
|
|
315
|
+
stl = _part(args, resuming=True)
|
|
316
|
+
st = _open_quote(stl)
|
|
317
|
+
geo = _load_geo(stl, st)
|
|
318
|
+
options = _options(st)
|
|
319
|
+
grade = st.get("grade")
|
|
320
|
+
ladder = ctx.volume_ladder()
|
|
321
|
+
reference = args.reference or ladder[len(ladder) // 2]
|
|
322
|
+
|
|
323
|
+
# Only the gate-count options compete for the gating decision; process-sweep runs share a
|
|
324
|
+
# gate count and would otherwise look like duplicate options.
|
|
325
|
+
by_count = {}
|
|
326
|
+
for o in options:
|
|
327
|
+
keep = by_count.get(o.n_gates)
|
|
328
|
+
if keep is None or o.max_pressure_bar < keep.max_pressure_bar:
|
|
329
|
+
by_count[o.n_gates] = o
|
|
330
|
+
|
|
331
|
+
quotes = costing.compare_options(
|
|
332
|
+
ctx,
|
|
333
|
+
geo,
|
|
334
|
+
sorted(by_count.values(), key=lambda o: o.n_gates),
|
|
335
|
+
cavities=int(st.get("cavities")),
|
|
336
|
+
grade=grade,
|
|
337
|
+
thermal=st.get("thermal"),
|
|
338
|
+
melt_K=float(st.get("melt_K")),
|
|
339
|
+
mold_K=float(st.get("mold_K")),
|
|
340
|
+
finish=args.finish,
|
|
341
|
+
material_note=_material_note(st),
|
|
342
|
+
)
|
|
343
|
+
print(f"\n gating options priced (reference volume {reference:,} parts/year):\n")
|
|
344
|
+
print(costing.comparison_table(quotes, reference))
|
|
345
|
+
|
|
346
|
+
chosen = quotes[0]
|
|
347
|
+
print("\n chosen:\n")
|
|
348
|
+
print(chosen.summary())
|
|
349
|
+
|
|
350
|
+
runner_up = quotes[1] if len(quotes) > 1 else None
|
|
351
|
+
st.decide(
|
|
352
|
+
"Gate count",
|
|
353
|
+
f"{chosen.option.n_gates} gate(s) per cavity",
|
|
354
|
+
(
|
|
355
|
+
f"lowest total cost of ownership at {reference:,} parts/year "
|
|
356
|
+
f"({chosen.total_cost_of_ownership(reference):,.0f} {chosen.currency}); "
|
|
357
|
+
f"{chosen.option.max_pressure_bar:.0f} bar peak cavity pressure, {chosen.option.n_welds} weld line(s); "
|
|
358
|
+
f"press {chosen.machine.name}"
|
|
359
|
+
),
|
|
360
|
+
alternatives=(
|
|
361
|
+
f"{runner_up.option.n_gates} gate(s): {runner_up.total_cost_of_ownership(reference):,.0f} "
|
|
362
|
+
f"{runner_up.currency} - "
|
|
363
|
+
+ (
|
|
364
|
+
"cheaper tool, higher pressure"
|
|
365
|
+
if runner_up.option.n_gates < chosen.option.n_gates
|
|
366
|
+
else "lower pressure, more nozzles and welds"
|
|
367
|
+
)
|
|
368
|
+
if runner_up
|
|
369
|
+
else "none - only one gating option was simulated"
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
st.decide(
|
|
373
|
+
"Press and cycle",
|
|
374
|
+
f"{chosen.machine.name}, cycle {chosen.cycle.total_s:.1f} s",
|
|
375
|
+
f"clamp {chosen.machine.required_clamp_kN:.0f} kN required, basis '{chosen.machine.clamp_basis}'; "
|
|
376
|
+
f"cooling {chosen.cycle.cool_s:.1f} s from the {chosen.cycle.governing_wall_mm:.2f} mm governing wall",
|
|
377
|
+
)
|
|
378
|
+
st.update(
|
|
379
|
+
quote=chosen.as_dict(),
|
|
380
|
+
all_quotes=[q.as_dict() for q in quotes],
|
|
381
|
+
reference_parts=reference,
|
|
382
|
+
finish=args.finish,
|
|
383
|
+
)
|
|
384
|
+
# The chosen quote's own fields sit at the top level, and the comparison rides beside
|
|
385
|
+
# them. Since the kit writes no document, this file is the deliverable: whoever writes
|
|
386
|
+
# the quotation must be able to state what was chosen, what it beat and at what volume,
|
|
387
|
+
# without also opening state.json.
|
|
388
|
+
quote_payload = {
|
|
389
|
+
**chosen.as_dict(),
|
|
390
|
+
"reference_parts": reference,
|
|
391
|
+
"finish": args.finish,
|
|
392
|
+
"alternatives": [q.as_dict() for q in quotes if q is not chosen],
|
|
393
|
+
}
|
|
394
|
+
st.output_dir.mkdir(parents=True, exist_ok=True)
|
|
395
|
+
(st.output_dir / "quote.json").write_text(json.dumps(quote_payload, indent=2, default=str), encoding="utf-8")
|
|
396
|
+
print(f"\n quote -> {st.output_dir / 'quote.json'}")
|
|
397
|
+
print(f" next: review quote.json, then: python main.py learn --quote {st.key}")
|
|
398
|
+
return 0
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ==========================================================================
|
|
402
|
+
# 4. learn - the loop that makes the next quote better
|
|
403
|
+
# ==========================================================================
|
|
404
|
+
def cmd_learn(args: argparse.Namespace) -> int:
|
|
405
|
+
try:
|
|
406
|
+
st = state.QuoteState.open_existing(args.quote)
|
|
407
|
+
except state.NoSuchQuote as exc:
|
|
408
|
+
# A traceback here would bury the list of quotes that do exist, which is the
|
|
409
|
+
# only thing the reader needs.
|
|
410
|
+
raise SystemExit(str(exc.args[0])) from None
|
|
411
|
+
path = Path(args.corrections) if args.corrections else st.dir / "corrections.json"
|
|
412
|
+
if not path.exists():
|
|
413
|
+
path.write_text(json.dumps({**state.CORRECTIONS_TEMPLATE, "quote": args.quote}, indent=2), encoding="utf-8")
|
|
414
|
+
print(f"\n wrote a template to {path}")
|
|
415
|
+
print(" Fill it in from the review conversation (the estimator's own words in 'why'), then re-run.")
|
|
416
|
+
return 1
|
|
417
|
+
corrections = json.loads(path.read_text(encoding="utf-8"))
|
|
418
|
+
added = state.apply_corrections(corrections)
|
|
419
|
+
print(f"\n recorded {len(added)} lesson(s) in shop/lessons.md: {', '.join(added)}")
|
|
420
|
+
print(" The calibration block is updated, so the next quote already reflects this.")
|
|
421
|
+
st.update(lessons=added)
|
|
422
|
+
return 0
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ==========================================================================
|
|
426
|
+
def main() -> int:
|
|
427
|
+
_utf8_stdout()
|
|
428
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
429
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
430
|
+
|
|
431
|
+
p = sub.add_parser("setup", help="check/confirm that shop/ is this shop, not the shipped example")
|
|
432
|
+
p.add_argument(
|
|
433
|
+
"--confirm", default=None, metavar="SHOP", help="record whose shop this is, once the values are yours"
|
|
434
|
+
)
|
|
435
|
+
p.add_argument("--force", action="store_true", help="confirm even though example values remain")
|
|
436
|
+
p.set_defaults(func=cmd_setup)
|
|
437
|
+
|
|
438
|
+
# The geometry is `--part` in every template, and every stage that works on a geometry
|
|
439
|
+
# takes it: the quote is keyed on the file's contents, so a stage that never sees the
|
|
440
|
+
# file cannot tell that the part changed underneath it.
|
|
441
|
+
def part_option(parser: argparse.ArgumentParser, *, resuming: bool = False) -> None:
|
|
442
|
+
# A resuming stage defaults to None rather than to the sample, so it can tell
|
|
443
|
+
# "the user said nothing" from "the user asked for the sample" and refuse to
|
|
444
|
+
# guess between quotes instead of silently re-pricing the wrong part.
|
|
445
|
+
parser.add_argument(
|
|
446
|
+
"--part",
|
|
447
|
+
default=None if resuming else str(DEFAULT_PART),
|
|
448
|
+
help="path to the geometry (default: the bundled sample)",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
p = sub.add_parser("intake", help="read the geometry, pick a material, open the quote")
|
|
452
|
+
part_option(p)
|
|
453
|
+
p.add_argument("--cavities", type=int, required=True, help="cavity count (the customer's, not ours)")
|
|
454
|
+
p.add_argument("--material", default=None, help="house grade; omitted = shop default + an assumption")
|
|
455
|
+
p.add_argument("--draw-axis", choices=list("xyz"), default=None, help="force the draw direction")
|
|
456
|
+
p.set_defaults(func=cmd_intake)
|
|
457
|
+
|
|
458
|
+
p = sub.add_parser("study", help="run the gating study on the cloud")
|
|
459
|
+
part_option(p, resuming=True)
|
|
460
|
+
p.add_argument("--gate-counts", default="1,2,3", help="gate counts per cavity to simulate")
|
|
461
|
+
p.add_argument("--process-sweep", action="store_true", help="also sweep the flow rate")
|
|
462
|
+
p.add_argument("--offline", action="store_true", help="rule-of-thumb gating instead of cloud runs")
|
|
463
|
+
p.set_defaults(func=cmd_study)
|
|
464
|
+
|
|
465
|
+
p = sub.add_parser("cost", help="price every gating option and choose")
|
|
466
|
+
part_option(p, resuming=True)
|
|
467
|
+
p.add_argument("--reference", type=int, default=None, help="volume the options are compared at")
|
|
468
|
+
p.add_argument("--finish", default="technical", choices=["technical", "polished", "textured"])
|
|
469
|
+
p.set_defaults(func=cmd_cost)
|
|
470
|
+
|
|
471
|
+
# `--quote`, not `--part`: this stage corrects a quote that already exists and never
|
|
472
|
+
# opens the geometry. Demanding the STL back would also refuse the correction whenever
|
|
473
|
+
# the part had been re-exported since - exactly when a lesson is worth recording.
|
|
474
|
+
p = sub.add_parser("learn", help="apply the estimator's corrections to shop/lessons.md")
|
|
475
|
+
p.add_argument("--quote", required=True, help="the quote key printed by intake and cost")
|
|
476
|
+
p.add_argument("--corrections", default=None, help="path to corrections.json")
|
|
477
|
+
p.set_defaults(func=cmd_learn)
|
|
478
|
+
|
|
479
|
+
args = ap.parse_args()
|
|
480
|
+
return args.func(args)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
if __name__ == "__main__":
|
|
484
|
+
raise SystemExit(main())
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Quoting toolkit: geometry -> gating decision -> cost model -> lessons.
|
|
2
|
+
|
|
3
|
+
The shared ``cadmould_*`` packages are imported by name: in a generated project they sit
|
|
4
|
+
beside this folder, and in the kit's own repository ``PYTHONPATH=packages`` supplies them.
|
|
5
|
+
Nothing here edits ``sys.path``. The offline half (``shop``, ``geometry``, ``costing``,
|
|
6
|
+
``state``) needs neither a cadmould licence nor cloud access.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__all__ = ["costing", "geometry", "shop", "state", "study", "toolkit"]
|