simcon-toolkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. simcon_toolkit/__init__.py +40 -0
  2. simcon_toolkit/__main__.py +7 -0
  3. simcon_toolkit/_kit/LICENSE +202 -0
  4. simcon_toolkit/_kit/NOTICE +37 -0
  5. simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
  6. simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
  7. simcon_toolkit/_kit/packages/.ruff.toml +10 -0
  8. simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
  9. simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
  10. simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
  11. simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
  12. simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
  13. simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
  14. simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
  15. simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
  16. simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
  17. simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
  18. simcon_toolkit/_kit/pyproject.toml +232 -0
  19. simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
  20. simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
  21. simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
  22. simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
  23. simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
  24. simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
  25. simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
  26. simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
  27. simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
  28. simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
  29. simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
  30. simcon_toolkit/_kit/templates/process-window/README.md +176 -0
  31. simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
  32. simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
  33. simcon_toolkit/_kit/templates/process-window/main.py +82 -0
  34. simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
  35. simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
  36. simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
  37. simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
  38. simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
  39. simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
  40. simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
  41. simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
  42. simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
  43. simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
  44. simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
  45. simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
  46. simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
  47. simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
  48. simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
  49. simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
  50. simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
  51. simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
  52. simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
  53. simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
  54. simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
  55. simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
  56. simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
  57. simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
  58. simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
  59. simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
  60. simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
  61. simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
  62. simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
  63. simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
  64. simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
  65. simcon_toolkit/_kit/templates/quoting/README.md +158 -0
  66. simcon_toolkit/_kit/templates/quoting/main.py +484 -0
  67. simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
  68. simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
  69. simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
  70. simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
  71. simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
  72. simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
  73. simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
  74. simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
  75. simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
  76. simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
  77. simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
  78. simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
  79. simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
  80. simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
  81. simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
  82. simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
  83. simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
  84. simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
  85. simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
  86. simcon_toolkit/_kit/templates.toml +83 -0
  87. simcon_toolkit/choices.py +11 -0
  88. simcon_toolkit/cli.py +381 -0
  89. simcon_toolkit/generate.py +590 -0
  90. simcon_toolkit/instructions.py +152 -0
  91. simcon_toolkit/manifest.py +86 -0
  92. simcon_toolkit/project.py +356 -0
  93. simcon_toolkit/wizard.py +160 -0
  94. simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
  95. simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
  96. simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
  97. simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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"]