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,184 @@
1
+ """G0 geometry inspection. Free, local, and it predicts the answer before any simulation.
2
+
3
+ The previous gating study's playbook makes this step 1 for a reason: bbox, volume, wall
4
+ thickness and flow-length ratio already tell you whether a part is thermally marginal, and
5
+ therefore whether a feasibility sweep will discriminate at all. On a thick-walled part it
6
+ predicts an all-green sweep before you spend a single cloud run.
7
+
8
+ Run: python main.py inspect [--part a.stl b.stl ...]
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import numpy as np
18
+
19
+ import cadmould
20
+ from cadmould import mesh, tools
21
+ from cadmould_geometry import stl
22
+
23
+ ROOT = Path(__file__).resolve().parents[1]
24
+
25
+
26
+ def cavity_count(pts: np.ndarray, faces: np.ndarray) -> int:
27
+ """Number of separate bodies in the STL — i.e. how many cavities are actually modelled.
28
+
29
+ Reported because a briefing document said "4 of 32 cavities" while the geometry was a single
30
+ body, and that claim propagated into every caveat before anyone checked it. It costs
31
+ milliseconds, so there is no reason not to state it up front.
32
+ """
33
+ return stl.body_count(pts, faces)
34
+
35
+
36
+ def inspect(stl_path: Path, *, session_open: bool = False) -> dict:
37
+ pts, faces = stl.read_stl(stl_path)
38
+ dims = pts.max(0) - pts.min(0)
39
+ volume_cm3 = stl.volume_mm3(pts, faces) / 1000.0
40
+ long_axis = int(np.argmax(dims))
41
+ n_bodies = cavity_count(pts, faces)
42
+
43
+ def _mesh_it():
44
+ meshed = (
45
+ mesh.Mesher(mesh.Mesh(pts.ravel(), faces.ravel()))
46
+ .with_surface_triangulation()
47
+ .with_thickness_and_w2w()
48
+ .build()
49
+ )
50
+ gates = [[float(p.x), float(p.y), float(p.z)] for p in tools.suggest_gates(meshed).points]
51
+ return meshed, gates
52
+
53
+ if session_open:
54
+ meshed, gates = _mesh_it()
55
+ else:
56
+ with cadmould.Session.user_based():
57
+ meshed, gates = _mesh_it()
58
+
59
+ # Flow length from the suggested gate to the furthest mesh node, along the surface's
60
+ # bounding geometry. A straight-line distance understates a wrapped path but is the right
61
+ # order of magnitude and needs no geodesic solver.
62
+ g = np.array(gates[0]) if gates else pts.mean(0)
63
+ flow_len = float(np.linalg.norm(pts - g, axis=1).max())
64
+
65
+ # Wall thickness: the mesher measures it, but the wheel exposes no getter, so estimate it
66
+ # from the closed-surface identity volume ~= area/2 * thickness for a thin shell.
67
+ area_mm2 = stl.surface_area_mm2(pts, faces)
68
+ open_edges = stl.open_edge_count(pts, faces)
69
+ t_est = 2.0 * volume_cm3 * 1000.0 / area_mm2 if area_mm2 else float("nan")
70
+
71
+ return {
72
+ "name": stl_path.stem,
73
+ "path": str(stl_path),
74
+ "bodies_in_stl": n_bodies,
75
+ "nodes": meshed.node_count(),
76
+ "elements": meshed.element_count(),
77
+ "volume_cm3": volume_cm3,
78
+ "area_mm2": area_mm2,
79
+ "bbox_mm": [round(float(d), 1) for d in dims],
80
+ "long_axis": "xyz"[long_axis],
81
+ "long_mm": float(dims[long_axis]),
82
+ "wall_est_mm": t_est,
83
+ "flow_length_mm": flow_len,
84
+ "L_over_t": flow_len / t_est if t_est else float("nan"),
85
+ "watertight": open_edges == 0,
86
+ "open_edges": open_edges,
87
+ "suggested_gate_mm": gates[0] if gates else None,
88
+ "n_suggested_gates": len(gates),
89
+ }
90
+
91
+
92
+ def verdict(d: dict) -> list[str]:
93
+ """What the geometry alone says about whether a feasibility sweep will discriminate."""
94
+ out = []
95
+ lt = d["L_over_t"]
96
+ # PP fills to roughly L/t ~ 200-250 before pressure becomes the limit; well under that and
97
+ # a single gate is comfortable, which also means freeze-off is unlikely to bite.
98
+ if np.isfinite(lt):
99
+ if lt < 80:
100
+ out.append(
101
+ f"L/t = {lt:.0f}: very easy to fill (PP tolerates ~200+). A fill-phase "
102
+ "feasibility sweep will likely find the whole process window feasible."
103
+ )
104
+ elif lt < 150:
105
+ out.append(f"L/t = {lt:.0f}: moderate. Some of the process window should be marginal.")
106
+ else:
107
+ out.append(f"L/t = {lt:.0f}: demanding for PP. Expect a real infeasible region.")
108
+ if d["wall_est_mm"] < 1.0:
109
+ out.append(
110
+ f"mean wall ~{d['wall_est_mm']:.2f} mm: thin. Thin walls freeze fast, so "
111
+ "freeze-off is a live constraint and the no-flow gate should bite."
112
+ )
113
+ elif d["wall_est_mm"] > 2.5:
114
+ out.append(
115
+ f"mean wall ~{d['wall_est_mm']:.2f} mm: thick. Slow to freeze; the fill phase "
116
+ "will be forgiving and packing/cooling will dominate instead."
117
+ )
118
+ if not d["watertight"]:
119
+ out.append(
120
+ f"NOT watertight ({d['open_edges']} open edges) — the volume and wall estimate "
121
+ "are unreliable; check the STL before trusting derived flow rates."
122
+ )
123
+ return out
124
+
125
+
126
+ DEFAULT_PART = ROOT / "sample" / "simple_plate.stl"
127
+
128
+
129
+ def main() -> int:
130
+ ap = argparse.ArgumentParser(description="G0 geometry inspection")
131
+ # Named, like every other template's geometry. It takes more than one because
132
+ # comparing candidate parts is the point of inspecting before a campaign; a single
133
+ # --part behaves exactly as it does elsewhere in the kit.
134
+ ap.add_argument(
135
+ "--part",
136
+ nargs="+",
137
+ type=Path,
138
+ default=[DEFAULT_PART],
139
+ help=f"STL geometry to inspect; pass several to compare them (default: {DEFAULT_PART.name} under sample/)",
140
+ )
141
+ args = ap.parse_args()
142
+
143
+ missing = [p for p in args.part if not p.is_file()]
144
+ if missing:
145
+ for p in missing:
146
+ print(f"geometry not found: {p}", file=sys.stderr)
147
+ print("Pass your own STL with --part, or put one back at sample/simple_plate.stl.", file=sys.stderr)
148
+ return 2
149
+
150
+ rows = []
151
+ with cadmould.Session.user_based():
152
+ for s in args.part:
153
+ rows.append(inspect(Path(s), session_open=True))
154
+
155
+ for d in rows:
156
+ print(f"\n=== {d['name']} ===")
157
+ print(
158
+ f" bodies in STL {d['bodies_in_stl']}"
159
+ + (" <- cavities actually modelled; state this, do not inherit it" if d["bodies_in_stl"] >= 1 else "")
160
+ )
161
+ print(f" mesh {d['nodes']} nodes / {d['elements']} elements")
162
+ print(f" bbox {d['bbox_mm']} mm (long axis {d['long_axis']}, {d['long_mm']:.0f} mm)")
163
+ print(f" volume {d['volume_cm3']:.2f} cm3 surface {d['area_mm2']:.0f} mm2")
164
+ print(f" mean wall (est) {d['wall_est_mm']:.2f} mm")
165
+ print(f" flow length {d['flow_length_mm']:.0f} mm from suggested gate")
166
+ print(f" L/t {d['L_over_t']:.0f}")
167
+ print(f" watertight {d['watertight']}" + ("" if d["watertight"] else f" ({d['open_edges']} open edges)"))
168
+ print(f" suggested gate {np.round(d['suggested_gate_mm'], 2).tolist()} ({d['n_suggested_gates']} gate(s))")
169
+ for line in verdict(d):
170
+ print(f" -> {line}")
171
+
172
+ if len(rows) > 1:
173
+ print("\n=== comparison ===")
174
+ print(f" {'part':32s} {'vol cm3':>9s} {'long mm':>8s} {'wall mm':>8s} {'L/t':>6s}")
175
+ for d in rows:
176
+ print(
177
+ f" {d['name'][:32]:32s} {d['volume_cm3']:9.2f} {d['long_mm']:8.0f} "
178
+ f"{d['wall_est_mm']:8.2f} {d['L_over_t']:6.0f}"
179
+ )
180
+ return 0
181
+
182
+
183
+ if __name__ == "__main__":
184
+ raise SystemExit(main())
@@ -0,0 +1,355 @@
1
+ """KPI extraction from one AI-solver filling result.
2
+
3
+ Implements exactly the KPI set in the brief, and nothing else. Two design rules run
4
+ through all of it:
5
+
6
+ **Dimensionless by construction where possible.** Guardrail 1 forbids absolute thresholds,
7
+ because the AI solver under-predicts absolute cavity pressure on multi-gate parts (~0.3x on
8
+ 8 gates) and we model 1 of 32 cavities with no runner. Rather than police that downstream, the
9
+ are formed as *ratios* — normalised by a temperature difference the process itself defines
10
+ (melt minus wall, or melt minus no-flow). A ratio has no scale to be wrong about, so it
11
+ transfers across cavity counts in a way a temperature in Kelvin does not. `p80` is the one
12
+ KPI that stays in physical units, and it is used for **ordering only**.
13
+
14
+ **The feasibility gate is freeze-off, not fill completeness.** A short shot is a
15
+ *consequence* of the melt reaching its no-flow temperature before the cavity fills. The
16
+ no-flow margin is therefore the causal quantity, it is continuous (so boundary refinement has
17
+ a gradient to follow), and it is immune to the known incomplete-fill bug in the AI model.
18
+ Fill completeness is still measured on every run and reported — as a diagnostic.
19
+
20
+ Weld detection is reused from the sibling gate-study template (`metrics`) rather than
21
+ reimplemented: three plausible detectors were tried there and failed, and the working one
22
+ (convergence of the flow-direction field) is calibrated and documented.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import sys
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+
31
+ import h5py
32
+ import numpy as np
33
+
34
+ # Reused from templates/gate-study rather than vendored, so there is one source of truth for
35
+ # the weld detector and its calibration. It is imported directly from the shared package.
36
+ from cadmould_scoring import metrics as M
37
+
38
+ from .units import UnitError, pressure_field_to_bar
39
+
40
+ DOF_THRESH = 0.5 # cloud results: sharp 0->1 front; arrival = first crossing
41
+
42
+ # Bump whenever a KPI definition changes. It is part of every run id, so a redefinition
43
+ # invalidates existing records instead of silently reusing scores computed under the old
44
+ # definition — a record is (inputs -> KPIs), so changing the KPIs changes what it means.
45
+ # v1: initial set
46
+ # v2: added the end-of-fill channel-closure reading of the no-flow margin (F16)
47
+ KPI_VERSION = "v2"
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # low-level readers
52
+ # ---------------------------------------------------------------------------
53
+ def _steps(f: h5py.File) -> list[str]:
54
+ return sorted(k for k in f if k.startswith("TimeStep_"))
55
+
56
+
57
+ def _phi_axis(f: h5py.File, steps: list[str], reached_frac: np.ndarray) -> tuple[np.ndarray, str]:
58
+ """Injected-volume fraction phi per time step, and where it came from.
59
+
60
+ **Do not trust the solver's ``filling_level`` attribute.** On cloud AI results it is
61
+ written as a constant ``1.0`` at every step, which silently collapses the whole phi axis
62
+ onto a single point: `argmin(|phi - 0.8|)` then returns step 0, so `p80` becomes the
63
+ pressure before anything has filled and every switchover read-out is identical. Verified
64
+ on a 50-step production result. It is checked for variation here and rejected if flat.
65
+
66
+ The correct axis for a **constant-Q** run is ``t / t_end``: injected volume is Q*t, so
67
+ phi = Q*t/V = t/t_fill exactly. This is the same reparameterisation the filling-profile
68
+ POC is built on, and it inherits the same incompressible-melt caveat — injected volume is
69
+ equated with filled cavity volume, which is sub-percent during fill and worst as phi -> 1.
70
+ The AI solver only ever runs constant-Q, so the assumption always holds here.
71
+
72
+ Node-reached fraction is the last resort: it is a *geometric* proxy, not a volume
73
+ fraction, and on a part with uneven wall thickness the two differ materially.
74
+ """
75
+ lv = [f[s].attrs.get("filling_level") for s in steps]
76
+ if all(v is not None for v in lv):
77
+ arr = np.asarray([float(v) for v in lv], dtype=np.float64)
78
+ if float(np.ptp(arr)) > 0.01:
79
+ return arr, "filling_level"
80
+
81
+ times = np.asarray([float(f[s].attrs["Time"]) for s in steps], dtype=np.float64)
82
+ t_end = float(times[-1])
83
+ if t_end > 0:
84
+ return times / t_end, "time_over_t_end_constant_Q"
85
+ return reached_frac, "reached_fraction_proxy"
86
+
87
+
88
+ def _core_temp_series(f: h5py.File, steps: list[str]) -> np.ndarray:
89
+ """(T, N) melt temperature at the wall's mid-plane (core layer), in K.
90
+
91
+ The core is the right layer for both thermal KPIs. Skin layers are quenched by the
92
+ mould by design, so they say more about the wall temperature than about the melt; the
93
+ core is what is still flowing, and it is the core freezing that stops the fill.
94
+ """
95
+ ds0 = f[steps[0]]["Temperature"]
96
+ core = ds0.shape[1] // 2
97
+ return np.stack([np.asarray(f[s]["Temperature"][:, core], dtype=np.float32) for s in steps])
98
+
99
+
100
+ def _fill_state(f: h5py.File) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
101
+ """``(t_fill, arrival_step, reached, reached_frac_per_step)``.
102
+
103
+ Arrival is the first step at which ``Degree_of_filling`` crosses 0.5. Keying off
104
+ ``isfinite`` instead — as the old hand-authored files invite — makes every node appear
105
+ to arrive at t=0 and silently destroys every time-resolved KPI.
106
+ """
107
+ steps = _steps(f)
108
+ n = f[steps[0]]["Degree_of_filling"].shape[0]
109
+ t_fill = np.full(n, np.inf, np.float64)
110
+ arr_step = np.full(n, -1, np.int32)
111
+ reached = np.zeros(n, bool)
112
+ frac = np.zeros(len(steps), np.float64)
113
+ for i, s in enumerate(steps):
114
+ dof = np.asarray(f[s]["Degree_of_filling"][()], dtype=np.float32)
115
+ now = np.isfinite(dof) & (dof > DOF_THRESH)
116
+ newly = now & ~reached
117
+ t_fill[newly] = float(f[s].attrs["Time"])
118
+ arr_step[newly] = i
119
+ reached |= now
120
+ frac[i] = reached.mean()
121
+ return t_fill, arr_step, reached, frac
122
+
123
+
124
+ def _max_pressure_bar_at(f: h5py.File, step_name: str) -> float:
125
+ ds = f[step_name]["Pressure"]
126
+ unit = ds.attrs.get("unit")
127
+ if unit is None:
128
+ raise UnitError(f"{step_name}/Pressure has no 'unit' attribute; refusing to infer Pa vs bar.")
129
+ pr = pressure_field_to_bar(np.asarray(ds[()], dtype=np.float64), unit)
130
+ fin = pr[np.isfinite(pr)]
131
+ return float(fin.max()) if fin.size else float("nan")
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # result
136
+ # ---------------------------------------------------------------------------
137
+ @dataclass
138
+ class Kpis:
139
+ # --- primary continuous KPI: ORDERING ONLY, never a threshold in bar ---
140
+ p80_bar: float
141
+ p80_phi_actual: float # the phi actually available nearest 0.80
142
+
143
+ # --- feasibility: two readings of "distance to no-flow" ---
144
+ # at ARRIVAL: is the melt still molten when it gets there? Weak, because fountain flow keeps
145
+ # the front hot (F15) — retained for continuity and because it is the literal reading.
146
+ no_flow_margin: float # (T_core,min_at_front - T_noflow)/(T_melt - T_noflow)
147
+ no_flow_margin_K: float # same in K, for the engineer's intuition
148
+ # at END OF FILL: did the flow channel stay open? This is the reading that discriminates
149
+ # (F16). A robust 5th percentile, not the minimum: the minimum is dominated by the mesher's
150
+ # degenerate sub-0.05 mm slivers and swings 90 K on a handful of nodes.
151
+ no_flow_margin_end_K: float # p5(T_core at end of fill) - T_noflow
152
+ frozen_fraction_end: float # share of filled nodes with core below no-flow (DIAGNOSTIC)
153
+
154
+ # --- fill-phase quality proxy ---
155
+ weld_front_temp_K: float
156
+ weld_front_temp_ratio: float # (T_weld - T_wall)/(T_melt - T_wall)
157
+ n_weld_clusters: int
158
+
159
+ # --- warpage RISK flag (dimensionless) ---
160
+ hotspot: float # in-plane core-temperature spread / (T_melt - T_wall)
161
+
162
+ # --- diagnostics, NOT gates ---
163
+ fill_residual: float # unfilled node fraction at end of fill (known bug)
164
+ race_anomaly_tail: float # ANOMALY DETECTOR ONLY - must never screen
165
+
166
+ # --- switchover read-out (free: state AT switchover, not effect OF switching) ---
167
+ switchover: dict = field(default_factory=dict)
168
+
169
+ # --- provenance / honesty flags ---
170
+ phi_axis_source: str = "filling_level"
171
+ flags: list = field(default_factory=list)
172
+
173
+ def as_dict(self) -> dict:
174
+ d = dict(self.__dict__)
175
+ for k, v in d.items():
176
+ if isinstance(v, float):
177
+ d[k] = None if not np.isfinite(v) else round(v, 6)
178
+ return d
179
+
180
+ def summary(self) -> str:
181
+ return (
182
+ f"p80={self.p80_bar:7.1f}bar(phi{self.p80_phi_actual:.2f}) "
183
+ f"nfEnd={self.no_flow_margin_end_K:+7.1f}K(froz={self.frozen_fraction_end:.3f}) "
184
+ f"noflow={self.no_flow_margin:+.3f}({self.no_flow_margin_K:+.1f}K) "
185
+ f"weldT={self.weld_front_temp_K:6.1f}K(r={self.weld_front_temp_ratio:.3f},"
186
+ f"n={self.n_weld_clusters}) hotspot={self.hotspot:.4f} "
187
+ f"resid={self.fill_residual:.3f}"
188
+ )
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ def extract(
193
+ path: str | Path,
194
+ *,
195
+ melt_K: float,
196
+ wall_K: float,
197
+ no_flow_K: float | None = None,
198
+ switchover_phi: tuple[float, ...] = (0.90, 0.95, 0.98, 0.99),
199
+ long_axis: int = 1,
200
+ knn_k: int = 10,
201
+ ) -> Kpis:
202
+ """Compute the full KPI set from one result file.
203
+
204
+ ``melt_K`` / ``wall_K`` are the *requested* process values (the result file omits the
205
+ ``/input`` group), and are what the ratio KPIs normalise against — so the
206
+ normalisation reflects the process point we asked for, not one inferred from the field.
207
+ """
208
+ path = Path(path)
209
+ flags: list[str] = []
210
+
211
+ with h5py.File(path, "r") as f:
212
+ steps = _steps(f)
213
+ if not steps:
214
+ raise ValueError(f"{path.name}: no TimeStep_* groups")
215
+ coords = M.read_coords(f)
216
+ t_fill, arr_step, reached, reached_frac = _fill_state(f)
217
+ phi, phi_src = _phi_axis(f, steps, reached_frac)
218
+ if phi_src == "reached_fraction_proxy":
219
+ flags.append("phi_axis_is_geometric_proxy")
220
+ if phi_src == "time_over_t_end_constant_Q":
221
+ flags.append("filling_level_attr_flat_ignored")
222
+ core_T = _core_temp_series(f, steps) # (T, N) K
223
+
224
+ # --- p80: primary KPI, at the step nearest 80 % injected volume -------
225
+ i80 = int(np.argmin(np.abs(phi - 0.80)))
226
+ p80 = _max_pressure_bar_at(f, steps[i80])
227
+ p80_phi = float(phi[i80])
228
+ if abs(p80_phi - 0.80) > 0.05:
229
+ flags.append(f"p80_phi_off_by_{abs(p80_phi - 0.80):.3f}")
230
+
231
+ # --- switchover read-out ---------------------------------------------
232
+ switch: dict = {}
233
+ for ps in switchover_phi:
234
+ i = int(np.argmin(np.abs(phi - ps)))
235
+ filled_now = (arr_step >= 0) & (arr_step <= i)
236
+ # Two DIFFERENT temperatures, kept apart on purpose. Reporting only the minimum
237
+ # over all filled nodes is actively misleading: it is dominated by the
238
+ # earliest-filled material near the gate, which has been cooling the longest and
239
+ # is legitimately below the no-flow temperature while the part is still filling
240
+ # perfectly well. Placed next to the no-flow margin it reads as infeasibility
241
+ # when nothing is wrong.
242
+ # front_* = melt that has just arrived -> can the rest of the cavity still be
243
+ # filled? This is the freeze-off-relevant number at switchover.
244
+ # coldest_*= coldest point anywhere already filled -> gate/skin freeze proxy.
245
+ front = arr_step == i
246
+ switch[f"phi_{ps:.2f}"] = {
247
+ "phi_actual": float(phi[i]),
248
+ "p_max_bar": _max_pressure_bar_at(f, steps[i]),
249
+ "unfilled_fraction": float(1.0 - filled_now.mean()),
250
+ "front_core_T_min_K": (float(np.nanmin(core_T[i][front])) if front.any() else None),
251
+ "coldest_filled_core_T_K": (float(np.nanmin(core_T[i][filled_now])) if filled_now.any() else None),
252
+ }
253
+
254
+ # --- weld detection (reused, calibrated prior art) --------------------
255
+ knn = M.knn_indices(coords, k=knn_k)
256
+ score, _angle, clusters = M.detect_welds(
257
+ coords, t_fill, knn, np.asarray([float(f[s].attrs["Time"]) for s in steps]), long_axis=long_axis
258
+ )
259
+ weld_mask = M.cluster_member_mask(coords, score, knn)
260
+
261
+ # --- weld-front temperature: core temp at the moment the fronts met ------
262
+ if weld_mask.any():
263
+ idx = np.where(weld_mask & (arr_step >= 0))[0]
264
+ weld_T = float(np.nanmean(core_T[arr_step[idx], idx])) if idx.size else float("nan")
265
+ else:
266
+ weld_T = float("nan")
267
+ flags.append("no_weld_clusters_detected")
268
+
269
+ dT = melt_K - wall_K
270
+ if dT <= 0:
271
+ raise UnitError(f"melt ({melt_K} K) must exceed wall ({wall_K} K)")
272
+ weld_ratio = (weld_T - wall_K) / dT if np.isfinite(weld_T) else float("nan")
273
+
274
+ # --- no-flow margin: coldest core melt on the front, when it arrived -----
275
+ filled = arr_step >= 0
276
+ T_at_arrival = core_T[arr_step[filled], np.where(filled)[0]]
277
+ T_at_arrival = T_at_arrival[np.isfinite(T_at_arrival)]
278
+
279
+ # Sanity check on the temperature field's scale. The core cannot sit far above the
280
+ # melt the solver was asked for: shear heating adds some, but tens of K in the *mean*
281
+ # is not shear heating, it is a scale or offset problem — and a temperature field that
282
+ # is wrong by a constant makes the weld-front and no-flow KPIs quietly meaningless
283
+ # while still producing plausible-looking numbers. Flagged loudly, not swallowed.
284
+ if T_at_arrival.size:
285
+ excess = float(np.median(T_at_arrival)) - melt_K
286
+ if excess > 30.0:
287
+ flags.append(f"core_T_median_{excess:+.0f}K_vs_requested_melt__VERIFY_FIELD_SCALE")
288
+
289
+ # End-of-fill core temperature over filled nodes: the channel-closure reading.
290
+ end_core = core_T[-1][filled]
291
+ end_core = end_core[np.isfinite(end_core)]
292
+
293
+ if no_flow_K is None:
294
+ nf_ratio = nf_K = nf_end_K = frozen_frac = float("nan")
295
+ flags.append("no_flow_temp_unavailable")
296
+ else:
297
+ T_min = float(np.nanmin(T_at_arrival)) if T_at_arrival.size else float("nan")
298
+ nf_K = T_min - no_flow_K
299
+ span = melt_K - no_flow_K
300
+ nf_ratio = nf_K / span if span > 0 else float("nan")
301
+ if end_core.size:
302
+ nf_end_K = float(np.percentile(end_core, 5)) - no_flow_K
303
+ frozen_frac = float((end_core < no_flow_K).mean())
304
+ else:
305
+ nf_end_K = frozen_frac = float("nan")
306
+
307
+ # --- hotspot / thermal heterogeneity at end of fill ----------------------
308
+ # In-plane spread of core temperature at the start of packing is what drives
309
+ # DIFFERENTIAL shrinkage, and differential shrinkage is what warps a part. Uniformly
310
+ # hot is not a warpage risk; unevenly hot is. Normalised by (melt - wall) so it is a
311
+ # ratio: no absolute scale to be wrong about, and it transfers across cavity counts.
312
+ # This is the one KPI the brief left open. Robust percentile spread (p95-p05) rather
313
+ # than max-min, because the field is float16 and a single quantisation outlier at the
314
+ # gate singularity would otherwise set the metric.
315
+ endT = core_T[-1][filled]
316
+ endT = endT[np.isfinite(endT)]
317
+ hotspot = float(np.percentile(endT, 95) - np.percentile(endT, 5)) / dT if endT.size else float("nan")
318
+
319
+ # --- diagnostics ---------------------------------------------------------
320
+ residual = float(1.0 - reached.mean())
321
+ bal = M.fill_balance(t_fill, reached)
322
+ race = float(bal["tail_fraction"])
323
+
324
+ return Kpis(
325
+ p80_bar=p80,
326
+ p80_phi_actual=p80_phi,
327
+ no_flow_margin=nf_ratio,
328
+ no_flow_margin_K=nf_K,
329
+ no_flow_margin_end_K=nf_end_K,
330
+ frozen_fraction_end=frozen_frac,
331
+ weld_front_temp_K=weld_T,
332
+ weld_front_temp_ratio=weld_ratio,
333
+ n_weld_clusters=len(clusters),
334
+ hotspot=hotspot,
335
+ fill_residual=residual,
336
+ race_anomaly_tail=race,
337
+ switchover=switch,
338
+ phi_axis_source=phi_src,
339
+ flags=flags,
340
+ )
341
+
342
+
343
+ if __name__ == "__main__":
344
+ import json
345
+
346
+ # A diagnostic, not a campaign stage: score one result file from the shell.
347
+ if len(sys.argv) < 2:
348
+ raise SystemExit("usage: python -m process_window.kpis <result.h5> [melt_K] [wall_K] [no_flow_K]")
349
+ p = sys.argv[1]
350
+ melt = float(sys.argv[2]) if len(sys.argv) > 2 else 513.15
351
+ wall = float(sys.argv[3]) if len(sys.argv) > 3 else 313.15
352
+ nf = float(sys.argv[4]) if len(sys.argv) > 4 else None
353
+ k = extract(p, melt_K=melt, wall_K=wall, no_flow_K=nf)
354
+ print(k.summary())
355
+ print(json.dumps(k.as_dict(), indent=2, default=str))