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,312 @@
1
+ """Build a ready-to-run Campaign from a config file.
2
+
3
+ Everything that happens once per campaign: read the config and material card, mesh the part,
4
+ pick the gate, authenticate, resolve the material, create the cloud project, upload the
5
+ geometry. Cached in `cache/runs/<part-key>/campaign_state.json` so re-running any stage does not re-mesh or
6
+ re-upload — the geometry_id is part of every run id, so a fresh upload would invalidate every
7
+ existing result and silently force a full re-run.
8
+
9
+ One licence session wraps meshing and the material card. Cloud calls here go over REST (the
10
+ project/group/notes surface the SDK does not wrap), so they need no session.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import yaml
21
+
22
+ import cadmould
23
+ from cadmould import (
24
+ material as _cm_material,
25
+ mesh,
26
+ tools,
27
+ )
28
+ from cadmould_cloud import auth as cloud_auth
29
+ from cadmould_cloud.client import PlatformAPI
30
+ from cadmould_geometry import stl
31
+
32
+ from . import material_card
33
+ from .harness import Campaign
34
+ from .identity import find_existing_campaign, foreign_campaigns, part_key, slug
35
+
36
+ ROOT = Path(__file__).resolve().parents[1]
37
+
38
+
39
+ def _resolve(_unused: Path, rel: str) -> Path:
40
+ """Resolve a config path relative to the PROJECT ROOT, not the config file's folder.
41
+
42
+ Config paths read as if written from the project root, which is
43
+ how anyone editing the yaml naturally thinks about them; resolving against `configs/`
44
+ instead silently pointed at a path inside `configs/`.
45
+ """
46
+ p = Path(rel)
47
+ return p if p.is_absolute() else (ROOT / rel).resolve()
48
+
49
+
50
+ def load_config(path: str | Path) -> dict:
51
+ path = Path(path)
52
+ cfg = yaml.safe_load(path.read_text())
53
+ cfg["_dir"] = path.parent
54
+ return cfg
55
+
56
+
57
+ def campaign_dir_name(cfg: dict) -> str:
58
+ """The folder this campaign lives in: the part's identity, scale included.
59
+
60
+ Falls back to a saved campaign of the same name when the geometry is gone, so a
61
+ dossier can still be re-emitted from a kept `cache/` on a machine that no longer
62
+ holds the part. Ambiguity is refused rather than guessed.
63
+ """
64
+ part = _resolve(cfg["_dir"], cfg["part"]["stl"])
65
+ scale = float(cfg["part"].get("scale", 1.0))
66
+ if not part.exists():
67
+ base = Path(cfg["storage"]["runs_dir"])
68
+ runs_root = base if base.is_absolute() else ROOT / base
69
+ found = find_existing_campaign(runs_root, part)
70
+ if found is not None:
71
+ return found.name
72
+ return part_key(part, scale)
73
+
74
+
75
+ def resolve_config(arg: str | None) -> Path:
76
+ """The config a stage runs against, refusing a default that could mean two things.
77
+
78
+ The fallback to the bundled sample stays — it is what makes a first run one command.
79
+ It is withdrawn only once another campaign exists, because from then on a bare stage
80
+ could plausibly mean either, and choosing wrong is silent: it reads and writes under
81
+ the sample's key while looking like a continuation of the engineer's own work.
82
+ """
83
+ default = ROOT / "configs" / "simple-plate.yaml"
84
+ if arg:
85
+ return Path(arg)
86
+
87
+ cfg = load_config(default)
88
+ base = Path(cfg["storage"]["runs_dir"])
89
+ runs_root = base if base.is_absolute() else ROOT / base
90
+ try:
91
+ default_key: str | None = campaign_dir_name(cfg)
92
+ except SystemExit:
93
+ default_key = None
94
+
95
+ others = foreign_campaigns(runs_root, default_key)
96
+ if others:
97
+ listed = "\n ".join(others)
98
+ raise SystemExit(
99
+ "no config given, and more than one campaign is on disk:\n "
100
+ f"{listed}\n"
101
+ f"Defaulting to {default.name} here would read and write the bundled sample's "
102
+ "campaign while looking like it continued yours. Name the config you mean, "
103
+ "for example: python main.py <step> configs/your-part.yaml"
104
+ )
105
+ return default
106
+
107
+
108
+ def runs_dir_for(cfg: dict) -> Path:
109
+ """Per-part runs directory, so two campaigns cannot clobber each other's state.
110
+
111
+ The geometry_id is baked into every run id, so a shared state file would let one part's
112
+ upload silently invalidate the other's entire run history.
113
+ """
114
+ base = cfg["storage"]["runs_dir"]
115
+ p = Path(base)
116
+ p = p if p.is_absolute() else ROOT / base
117
+ return p / campaign_dir_name(cfg)
118
+
119
+
120
+ def stage_dir_for(cfg: dict) -> Path:
121
+ """Where each stage records what the next one reads.
122
+
123
+ Under cache/, never under output/: these are recorded decisions, and a customer
124
+ clearing a folder called output would take them with it. The campaign would then
125
+ re-derive a floor or a boundary rather than reuse the one it agreed.
126
+ """
127
+ d = runs_dir_for(cfg) / "stages"
128
+ d.mkdir(parents=True, exist_ok=True)
129
+ return d
130
+
131
+
132
+ def dossier_dir_for(cfg: dict) -> Path:
133
+ """Where the answer lands: doe_spec.yaml and dossier.md, and nothing else.
134
+
135
+ Under output/, because it is the customer's to keep, move or delete.
136
+ """
137
+ d = ROOT / "output" / campaign_dir_name(cfg)
138
+ d.mkdir(parents=True, exist_ok=True)
139
+ return d
140
+
141
+
142
+ def build(config_path: str | Path, *, logger=print) -> tuple[Campaign, dict]:
143
+ cfg = load_config(config_path)
144
+ cfg_dir = cfg["_dir"]
145
+ runs_dir = runs_dir_for(cfg)
146
+ runs_dir.mkdir(parents=True, exist_ok=True)
147
+ state_path = runs_dir / "campaign_state.json"
148
+ state = json.loads(state_path.read_text()) if state_path.exists() else {}
149
+
150
+ stl_path = _resolve(cfg_dir, cfg["part"]["stl"])
151
+ plb = _resolve(cfg_dir, cfg["material"]["plb"])
152
+
153
+ # ---- local: material card + mesh + gate (one session, not nested) --------
154
+ with cadmould.Session.user_based():
155
+ card = material_card.load(plb, session_open=True)
156
+ logger(card.describe())
157
+
158
+ scale = float(cfg["part"].get("scale", 1.0))
159
+ cfex = runs_dir / "_mesh" / f"{part_key(stl_path, scale)}.cfex"
160
+ raw_pts, faces = stl.read_stl(stl_path)
161
+ pts = raw_pts * scale
162
+ volume_cm3 = stl.volume_mm3(raw_pts, faces) / 1000.0 * scale**3
163
+ if not cfex.exists():
164
+ meshed = (
165
+ mesh.Mesher(mesh.Mesh(pts.ravel(), faces.ravel()))
166
+ .with_surface_triangulation()
167
+ .with_thickness_and_w2w()
168
+ .build()
169
+ )
170
+ cfex.parent.mkdir(parents=True, exist_ok=True)
171
+ mesh.io.CfexWriter().save(meshed, str(cfex))
172
+ # The mesh was rebuilt, so whatever the cloud holds is not this file. The
173
+ # content key makes that unlikely, but a state file can outlive its mesh and
174
+ # a stale id is invisible: every run would score the wrong geometry.
175
+ #
176
+ # Written to disk here rather than with the rest of the state at the end: if
177
+ # the upload below fails, the old id must not survive to be reused on the
178
+ # next run, when the mesh will exist and nothing will re-invalidate it.
179
+ state["geometry_id"] = None
180
+ state_path.parent.mkdir(parents=True, exist_ok=True)
181
+ state_path.write_text(json.dumps(state, indent=2))
182
+ else:
183
+ meshed = mesh.io.CfexReader().load(str(cfex))[0]
184
+
185
+ suggested = tools.suggest_gates(meshed)
186
+ if cfg["gates"].get("positions_mm"):
187
+ gates = [[float(v) for v in g] for g in cfg["gates"]["positions_mm"]]
188
+ else:
189
+ gates = [[float(p.x), float(p.y), float(p.z)] for p in suggested.points]
190
+
191
+ # Nominal flow from the SDK's own physics, not a guessed fill time. suggest_fill_profile
192
+ # accounts for wall thickness and the material's rheology/thermal data, so the box lands
193
+ # where the process actually is. An arbitrary "fill in 1 s" default was 3.5x too slow on
194
+ # a 0.71 mm-wall part whose wall freezes in ~0.13 s — it would have put the entire box in
195
+ # freeze-off. It is also the better answer for minimal user input: nothing to set.
196
+ raw_material = _cm_material.io.PlbReader().load(str(plb))
197
+ fill_profile = tools.suggest_fill_profile(meshed, raw_material, suggested)
198
+ sdk_fill_time_s = float(tools.estimate_fill_time(meshed, fill_profile))
199
+ sdk_cooling_time_s = float(tools.suggest_cooling_time(meshed, raw_material))
200
+
201
+ dims = pts.max(0) - pts.min(0)
202
+ long_axis = int(np.argmax(dims))
203
+ logger(
204
+ f" mesh {meshed.node_count()} nodes / {meshed.element_count()} elems; "
205
+ f"volume {volume_cm3:.2f} cm3; long axis {'xyz'[long_axis]}"
206
+ )
207
+ logger(f" gate(s) {np.round(gates, 2).tolist()}")
208
+
209
+ # ---- cloud: auth, material, project, geometry ---------------------------
210
+ cc = cfg["cloud"]
211
+ api = PlatformAPI(cc["rest_base_url"], cloud_auth.get_access_token(), logger=lambda *_: None)
212
+ mat = api.find_material(cfg["material"]["catalogue_name"])
213
+ material_id = mat["material_id"]
214
+ ver = api.get_material_version(mat["latest_version"]["version_id"])
215
+ d_melt = abs(float(ver["suggested_mass_temp_K"]) - card.melt.rec_K)
216
+ logger(
217
+ f" material {mat['trade_name']} id={material_id}; catalogue vs card melt "
218
+ f"{'AGREES' if d_melt < 1.0 else f'DIFFERS by {d_melt:.2f} K'}"
219
+ )
220
+
221
+ project = api.get_or_create_project(cc["project"], notes="Frontloaded DOE POC (frontloaded DOE, the trade show)")
222
+ project_id = project["id"]
223
+
224
+ geometry_id = state.get("geometry_id")
225
+ if not geometry_id or state.get("cfex_name") != cfex.name:
226
+ up = api.request_geometry_upload(cfex.name, description=f"DOE POC: {stl_path.name}")
227
+ api.put_cfex(up["presigned_put_url"], cfex)
228
+ geometry_id = up["geometry_id"]
229
+ logger(f" uploaded geometry -> {geometry_id}")
230
+ else:
231
+ logger(f" reusing geometry {geometry_id} (cached; a new upload would invalidate every existing run id)")
232
+
233
+ state.update(
234
+ {
235
+ "project_id": project_id,
236
+ "geometry_id": geometry_id,
237
+ "material_id": material_id,
238
+ "cfex_name": cfex.name,
239
+ "volume_cm3": volume_cm3,
240
+ "gates_mm": gates,
241
+ "long_axis": long_axis,
242
+ }
243
+ )
244
+ state_path.write_text(json.dumps(state, indent=2))
245
+
246
+ sv = cfg["solver"]
247
+ campaign = Campaign(
248
+ api=api,
249
+ project_id=project_id,
250
+ geometry_id=geometry_id,
251
+ material_id=material_id,
252
+ volume_cm3=volume_cm3,
253
+ card=card,
254
+ long_axis=long_axis,
255
+ gates_mm=gates,
256
+ runs_dir=runs_dir,
257
+ model_version=cc["model_version"],
258
+ num_timesteps=int(sv["num_timesteps"]),
259
+ htc_W_m2K=float(sv["htc_W_m2K"]),
260
+ switchover_phi=tuple(cfg["variables"]["switchover_readout_phi"]),
261
+ logger=logger,
262
+ )
263
+
264
+ fv = cfg["variables"]["flow"]
265
+ nominal_flow = fv.get("nominal_cm3_s")
266
+ if nominal_flow is None:
267
+ if fv.get("from") == "sdk_suggestion" or fv.get("target_fill_time_s") is None:
268
+ nominal_flow = round(volume_cm3 / sdk_fill_time_s, 4)
269
+ nominal_basis = f"sdk_suggest_fill_profile (fill {sdk_fill_time_s:.4f} s)"
270
+ else:
271
+ nominal_flow = round(volume_cm3 / float(fv["target_fill_time_s"]), 4)
272
+ nominal_basis = f"target_fill_time_s={fv['target_fill_time_s']}"
273
+ else:
274
+ nominal_basis = "pinned in config"
275
+ logger(f" SDK suggests fill {sdk_fill_time_s:.4f} s, cooling {sdk_cooling_time_s:.2f} s")
276
+ logger(f" nominal flow {nominal_flow:.4f} cm3/s ({nominal_basis})")
277
+ box = material_card.wide_box(
278
+ card, nominal_flow_cm3_s=float(nominal_flow), flow_rel_span=float(cfg["variables"]["flow"]["rel_span"])
279
+ )
280
+
281
+ info = {
282
+ "cfg": cfg,
283
+ "card": card,
284
+ "box": box,
285
+ "nominal_flow_cm3_s": float(nominal_flow),
286
+ "nominal_flow_basis": nominal_basis,
287
+ "sdk_fill_time_s": sdk_fill_time_s,
288
+ "sdk_cooling_time_s": sdk_cooling_time_s,
289
+ "volume_cm3": volume_cm3,
290
+ "gates_mm": gates,
291
+ "long_axis": long_axis,
292
+ "cfex": cfex,
293
+ "runs_dir": runs_dir,
294
+ "project_id": project_id,
295
+ "geometry_id": geometry_id,
296
+ "material_id": material_id,
297
+ "api": api,
298
+ "part_name": cfg["part"]["name"],
299
+ "part_slug": slug(cfg["part"]["name"]),
300
+ "stage_dir": stage_dir_for(cfg),
301
+ }
302
+ return campaign, info
303
+
304
+
305
+ if __name__ == "__main__":
306
+ cp = sys.argv[1] if len(sys.argv) > 1 else str(ROOT / "configs" / "simple-plate.yaml")
307
+ c, info = build(cp)
308
+ print("\nwide box:")
309
+ for k in ("melt_C", "wall_C", "flow_cm3_s"):
310
+ lo, hi = info["box"][k]
311
+ print(f" {k:12s} {lo:8.2f} .. {hi:8.2f} basis={info['box']['basis'][k]}")
312
+ info["api"].close()
@@ -0,0 +1,201 @@
1
+ """Cheap surrogate + variable ranking, on numpy alone.
2
+
3
+ No scipy/sklearn in this venv, so both pieces are implemented directly. Neither is exotic:
4
+
5
+ **Surrogate — Gaussian-process / RBF interpolation** with a Gaussian kernel on inputs normalised
6
+ to the unit box. Chosen over a quadratic response surface because the feasibility boundary is
7
+ expected to be a curved surface in a 3-D box, and a quadratic cannot bend twice; and over a
8
+ neural surrogate because with ~1 000 points and 3 inputs a kernel method is both better
9
+ conditioned and honest about where it has no data — which G3 needs, since it places new points
10
+ where the surrogate is *uncertain*.
11
+
12
+ **Ranking — variance-based effect shares (Sobol indices)** estimated by Monte Carlo *on the
13
+ surrogate*, which is free. First-order index S_i is the share of output variance explained by
14
+ moving variable i alone; total index S_i^T adds everything variable i participates in through
15
+ interactions. Reported as normalised shares, which is what the brief asks for.
16
+
17
+ The nugget is not a nuisance parameter here — it is set from the **derived noise floor**, because
18
+ we know the outputs are quantised on a floating-point lattice (F8). Fitting through quantisation
19
+ noise is what makes an interpolator ring.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+
26
+ import numpy as np
27
+
28
+
29
+ def normalise(X: np.ndarray, box: np.ndarray) -> np.ndarray:
30
+ """Map (n, d) physical points into the unit box, so one length scale is meaningful."""
31
+ lo, hi = box[:, 0], box[:, 1]
32
+ return (X - lo) / np.maximum(hi - lo, 1e-12)
33
+
34
+
35
+ @dataclass
36
+ class Surrogate:
37
+ """Gaussian RBF interpolator with a noise nugget and a leave-one-out error estimate."""
38
+
39
+ Xn: np.ndarray # (n, d) normalised inputs
40
+ y: np.ndarray # (n,)
41
+ box: np.ndarray # (d, 2) physical bounds
42
+ length: float
43
+ nugget: float
44
+ _w: np.ndarray
45
+ _K: np.ndarray
46
+ _Kinv: np.ndarray
47
+ y_mean: float
48
+ y_scale: float
49
+
50
+ # -- prediction ---------------------------------------------------------
51
+ def _kern(self, A: np.ndarray, B: np.ndarray) -> np.ndarray:
52
+ d2 = ((A[:, None, :] - B[None, :, :]) ** 2).sum(-1)
53
+ return np.exp(-0.5 * d2 / self.length**2)
54
+
55
+ def predict(self, X: np.ndarray) -> np.ndarray:
56
+ Xn = normalise(np.atleast_2d(X), self.box)
57
+ return self._kern(Xn, self.Xn) @ self._w * self.y_scale + self.y_mean
58
+
59
+ def predict_unit(self, Xn: np.ndarray) -> np.ndarray:
60
+ """Predict from already-normalised points (avoids re-normalising in hot loops)."""
61
+ return self._kern(np.atleast_2d(Xn), self.Xn) @ self._w * self.y_scale + self.y_mean
62
+
63
+ def variance_unit(self, Xn: np.ndarray) -> np.ndarray:
64
+ """Posterior variance, in normalised output units. Zero at the data, large away from it.
65
+
66
+ This is what G3's adaptive sampling steers on: place runs where the surrogate does not
67
+ know the answer *and* the boundary is near.
68
+ """
69
+ Xn = np.atleast_2d(Xn)
70
+ Ks = self._kern(Xn, self.Xn)
71
+ v = 1.0 + self.nugget - np.einsum("ij,jk,ik->i", Ks, self._Kinv, Ks)
72
+ return np.maximum(v, 0.0)
73
+
74
+ # -- diagnostics --------------------------------------------------------
75
+ def loo_error(self) -> dict:
76
+ """Leave-one-out error without refitting, via the inverse-matrix identity.
77
+
78
+ e_i = (K^-1 y)_i / (K^-1)_ii — exact for a fixed kernel, so it costs nothing and
79
+ cannot be fudged. The filling-profile POC used the same check.
80
+ """
81
+ a = self._Kinv @ ((self.y - self.y_mean) / self.y_scale)
82
+ d = np.diag(self._Kinv)
83
+ e = (a / d) * self.y_scale
84
+ rng = float(self.y.max() - self.y.min()) or 1.0
85
+ return {
86
+ "mae": float(np.abs(e).mean()),
87
+ "rmse": float(np.sqrt((e**2).mean())),
88
+ "max": float(np.abs(e).max()),
89
+ "nrmse_range": float(np.sqrt((e**2).mean()) / rng),
90
+ }
91
+
92
+
93
+ def fit(
94
+ X: np.ndarray, y: np.ndarray, box: np.ndarray, *, noise_floor: float | None = None, length: float | None = None
95
+ ) -> Surrogate:
96
+ """Fit the surrogate. ``noise_floor`` is the DERIVED per-KPI floor, in y units."""
97
+ X = np.atleast_2d(np.asarray(X, float))
98
+ y = np.asarray(y, float).ravel()
99
+ box = np.asarray(box, float)
100
+ Xn = normalise(X, box)
101
+
102
+ y_mean = float(y.mean())
103
+ y_scale = float(y.std()) or 1.0
104
+ yz = (y - y_mean) / y_scale
105
+
106
+ if length is None:
107
+ # A length scale that spans a few nearest-neighbour distances: long enough to smooth
108
+ # the quantisation lattice, short enough to resolve a curved boundary.
109
+ n, d = Xn.shape
110
+ length = float(np.clip(2.0 * n ** (-1.0 / d), 0.06, 0.5))
111
+
112
+ # The nugget encodes the quantisation floor. Without it the interpolator would chase a
113
+ # lattice step as if it were signal (F8: p80 moves in 0.06 bar increments).
114
+ nug = ((noise_floor / y_scale) ** 2 if noise_floor else 1e-8) + 1e-10
115
+
116
+ s = Surrogate(
117
+ Xn=Xn,
118
+ y=y,
119
+ box=box,
120
+ length=length,
121
+ nugget=float(nug),
122
+ _w=np.zeros(len(y)),
123
+ _K=np.zeros((len(y), len(y))),
124
+ _Kinv=np.zeros((len(y), len(y))),
125
+ y_mean=y_mean,
126
+ y_scale=y_scale,
127
+ )
128
+ K = s._kern(Xn, Xn) + np.eye(len(y)) * s.nugget
129
+ # Cholesky with escalating jitter: a Gaussian kernel on ~1 000 points is often numerically
130
+ # singular, and failing loudly here beats returning a silently garbage fit.
131
+ jitter = 0.0
132
+ for _ in range(8):
133
+ try:
134
+ np.linalg.cholesky(K + np.eye(len(y)) * jitter)
135
+ break
136
+ except np.linalg.LinAlgError:
137
+ jitter = max(jitter * 10, 1e-10)
138
+ else:
139
+ raise np.linalg.LinAlgError("surrogate kernel not positive definite even with jitter")
140
+ Kj = K + np.eye(len(y)) * jitter
141
+ w = np.linalg.solve(Kj, yz)
142
+ object.__setattr__(s, "_w", w)
143
+ object.__setattr__(s, "_K", Kj)
144
+ object.__setattr__(s, "_Kinv", np.linalg.inv(Kj))
145
+ return s
146
+
147
+
148
+ def sobol_indices(model, dim: int, *, n: int = 4096, seed: int = 0) -> dict:
149
+ """First-order and total effect shares by Saltelli's estimator, on the surrogate.
150
+
151
+ S_i : variance explained by variable i alone.
152
+ S_i^T: variance i is involved in at all, including interactions.
153
+ A large gap between them means the variable matters mainly *in combination* — which is
154
+ exactly the case where ranking it low and fixing it would be wrong.
155
+ """
156
+ rng = np.random.default_rng(seed)
157
+ A = rng.random((n, dim))
158
+ B = rng.random((n, dim))
159
+ yA = model(A)
160
+ yB = model(B)
161
+ varY = float(np.var(np.concatenate([yA, yB])))
162
+ if varY <= 0:
163
+ return {"var": 0.0, "S1": [0.0] * dim, "ST": [0.0] * dim}
164
+
165
+ S1, ST = [], []
166
+ for i in range(dim):
167
+ AB = A.copy()
168
+ AB[:, i] = B[:, i] # i from B, rest from A
169
+ yAB = model(AB)
170
+ # Saltelli 2010 estimators, which behave far better at modest n than the raw forms.
171
+ S1.append(float(np.mean(yB * (yAB - yA)) / varY))
172
+ ST.append(float(np.mean((yA - yAB) ** 2) / (2.0 * varY)))
173
+ return {"var": varY, "S1": S1, "ST": ST}
174
+
175
+
176
+ def effect_shares(surr: Surrogate, *, n: int = 4096, seed: int = 0) -> dict:
177
+ """Normalised effect shares plus the raw indices, ready for the dossier."""
178
+ dim = surr.Xn.shape[1]
179
+ idx = sobol_indices(surr.predict_unit, dim, n=n, seed=seed)
180
+ st = np.array(idx["ST"], float)
181
+ st_pos = np.maximum(st, 0.0)
182
+ share = st_pos / st_pos.sum() if st_pos.sum() > 0 else np.zeros(dim)
183
+ return {"S1": idx["S1"], "ST": idx["ST"], "share": share.tolist(), "var": idx["var"]}
184
+
185
+
186
+ def effect_span(surr: Surrogate, dim: int, *, grid: int = 24) -> list[float]:
187
+ """Peak-to-peak swing of the KPI attributable to each variable, in y units.
188
+
189
+ This is what the inert test needs — the noise floor is a bound on the output, so it must be
190
+ compared against an output *span*, not a variance share. A variable can hold a large share
191
+ of a tiny total variance and still be inert.
192
+ """
193
+ out = []
194
+ lin = np.linspace(0.0, 1.0, grid)
195
+ base = np.full((grid, dim), 0.5)
196
+ for i in range(dim):
197
+ P = base.copy()
198
+ P[:, i] = lin
199
+ y = surr.predict_unit(P)
200
+ out.append(float(y.max() - y.min()))
201
+ return out
@@ -0,0 +1,169 @@
1
+ """Check the Chebyshev centre, binding-constraint detection, and margin conversion.
2
+
3
+ The centre point and bounds are the headline deliverable, so they are tested against regions and
4
+ constraints whose right answers can be worked out by hand — including the two cases that motivated
5
+ the design: the survivor mean landing outside a disconnected region, and a margin derived from a
6
+ constraint that never activates. Run: python -m process_window.test_centre
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ from .centre import Constraint, solve
14
+
15
+ FAILED: list[str] = []
16
+
17
+
18
+ def check(name: str, cond: bool, detail: str = "") -> None:
19
+ print(f" {'PASS' if cond else 'FAIL'} {name}{' ' + detail if detail else ''}")
20
+ if not cond:
21
+ FAILED.append(name)
22
+
23
+
24
+ class Lin:
25
+ """KPI = c0 + sum(coef * x) in unit coords, with a matching effect span per axis."""
26
+
27
+ def __init__(self, c0, coef):
28
+ self.c0 = float(c0)
29
+ self.coef = np.asarray(coef, float)
30
+
31
+ def predict_unit(self, P):
32
+ return self.c0 + np.atleast_2d(P) @ self.coef
33
+
34
+ @property
35
+ def span(self):
36
+ return np.abs(self.coef)
37
+
38
+
39
+ class Box:
40
+ """Feasible inside a centred cube of half-width h, expressed as a KPI on distance."""
41
+
42
+ def __init__(self, h):
43
+ self.h = h
44
+
45
+ def predict_unit(self, P):
46
+ return np.max(np.abs(np.atleast_2d(P) - 0.5), axis=1)
47
+
48
+
49
+ def main() -> int:
50
+ box = np.array([[0.0, 100.0]] * 3)
51
+ names = ("a", "b", "c")
52
+
53
+ print("Centred cube -> centre in the middle")
54
+ r = solve(constraints=[Constraint("cube", Box(0.25), 0.25, "max", 0.01)], box=box, var_names=names, grid_n=41)
55
+ check("centre ~ (50,50,50)", np.allclose(r.centre_phys, 50, atol=3), str(np.round(r.centre_phys, 1)))
56
+ check("inradius ~0.25", abs(r.inradius_unit - 0.25) < 0.04, f"{r.inradius_unit:.3f}")
57
+
58
+ print("\nMetric check: L-infinity, NOT L1 — a cube region and a diamond region")
59
+
60
+ # A box's inradius is the same under both metrics, so a cube cannot detect the difference.
61
+ # An L1 ball (diamond) of radius r has L-inf inradius r/d but L1 inradius r, so in 3-D the two
62
+ # differ threefold. This is the test that catches eroding by a cross instead of a cube.
63
+ class Diamond:
64
+ def predict_unit(self, P):
65
+ return np.sum(np.abs(np.atleast_2d(P) - 0.5), axis=1)
66
+
67
+ r = 0.30
68
+ g = solve(constraints=[Constraint("diamond", Diamond(), r, "max", 0.005)], box=box, var_names=names, grid_n=61)
69
+ expect_linf, expect_l1 = r / 3.0, r
70
+ print(f" inradius {g.inradius_unit:.4f} L-inf expects {expect_linf:.4f}, L1 would give {expect_l1:.4f}")
71
+ check(
72
+ "diamond inradius matches L-infinity",
73
+ abs(g.inradius_unit - expect_linf) < 0.03,
74
+ f"{g.inradius_unit:.4f} vs {expect_linf:.4f}",
75
+ )
76
+ check("and is NOT the L1 value", abs(g.inradius_unit - expect_l1) > 0.10)
77
+
78
+ print("\nDisconnected region -> the case that breaks the survivor mean")
79
+
80
+ class TwoBlobs:
81
+ def predict_unit(self, P):
82
+ P = np.atleast_2d(P)
83
+ d1 = np.max(np.abs(P - np.array([0.18, 0.18, 0.5])), axis=1)
84
+ d2 = np.max(np.abs(P - np.array([0.82, 0.82, 0.5])), axis=1)
85
+ return np.minimum(d1, d2)
86
+
87
+ con = Constraint("blobs", TwoBlobs(), 0.12, "max", 0.005)
88
+ r = solve(constraints=[con], box=box, var_names=names, grid_n=41)
89
+ inside = bool(con.feasible(r.centre_unit[None, :])[0])
90
+ M = np.stack([m.ravel() for m in np.meshgrid(*[np.linspace(0, 1, 41)] * 3, indexing="ij")], 1)
91
+ mean_unit = M[con.feasible(M)].mean(0)
92
+ mean_inside = bool(con.feasible(mean_unit[None, :])[0])
93
+ print(f" chebyshev {np.round(r.centre_unit, 3)} inside={inside}")
94
+ print(f" survivor mean {np.round(mean_unit, 3)} inside={mean_inside}")
95
+ check("Chebyshev centre is inside", inside)
96
+ check("survivor mean is outside -> Chebyshev is necessary", inside and not mean_inside)
97
+
98
+ print("\nBinding constraint is identified by normalised slack")
99
+ # 'tight' has 2 bands of headroom at the centre; 'loose' has 40. tight must bind.
100
+ tight = Constraint(
101
+ "tight", Lin(10.0, [5.0, 0.0, 0.0]), 10.0, "min", 1.25, effect_span=Lin(10.0, [5.0, 0.0, 0.0]).span
102
+ )
103
+ loose = Constraint(
104
+ "loose", Lin(100.0, [5.0, 0.0, 0.0]), 60.0, "min", 1.0, effect_span=Lin(100.0, [5.0, 0.0, 0.0]).span
105
+ )
106
+ r = solve(constraints=[tight, loose], box=box, var_names=names, grid_n=41)
107
+ print(f" slack {r.slack_at_centre} binding={r.binding_constraint}")
108
+ check("binding constraint is 'tight'", r.binding_constraint == "tight")
109
+
110
+ print("\nNo active constraint -> margin is zero, and it says so")
111
+ far1 = Constraint("far1", Lin(100.0, [1.0, 0.0, 0.0]), 10.0, "min", 1.0, effect_span=np.array([1.0, 0.0, 0.0]))
112
+ far2 = Constraint("far2", Lin(5.0, [1.0, 0.0, 0.0]), 500.0, "max", 1.0, effect_span=np.array([1.0, 0.0, 0.0]))
113
+ r = solve(constraints=[far1, far2], box=box, var_names=names, grid_n=41)
114
+ check("margin is zero when nothing binds", np.allclose(r.margin_phys, 0.0), str(np.round(r.margin_phys, 3)))
115
+ check("and it is reported explicitly", any("NO CONSTRAINT IS ACTIVE" in n for n in r.notes))
116
+ check(
117
+ "box is not collapsed by a phantom margin",
118
+ bool(np.all(r.shrunk_box_phys[:, 1] - r.shrunk_box_phys[:, 0] > 50.0)),
119
+ str(np.round(r.shrunk_box_phys[:, 1] - r.shrunk_box_phys[:, 0], 1)),
120
+ )
121
+
122
+ print("\nActive constraint -> margin = band/gradient, per axis")
123
+ # KPI = 40 + 10a + 1b + 0c, threshold 40 'min', band 0.5 -> at centre value 45.5, slack 11
124
+ # bands, so make the threshold closer: 45 -> slack 1 band, active.
125
+ surr = Lin(40.0, [10.0, 1.0, 0.0])
126
+ act = Constraint("active", surr, 45.0, "min", 0.5, effect_span=surr.span)
127
+ r = solve(constraints=[act], box=box, var_names=names, grid_n=41)
128
+ print(f" binding={r.binding_constraint} slack={r.slack_at_centre}")
129
+ print(f" margins {np.round(r.margin_phys, 3)}")
130
+ check(
131
+ "steep axis gets the smaller margin",
132
+ r.margin_phys[0] < r.margin_phys[1],
133
+ f"a={r.margin_phys[0]:.2f} b={r.margin_phys[1]:.2f}",
134
+ )
135
+ check(
136
+ "steep axis margin ~ band/grad = 0.5/10 = 5 units", abs(r.margin_phys[0] - 5.0) < 1.5, f"{r.margin_phys[0]:.2f}"
137
+ )
138
+ check(
139
+ "axis that cannot move the KPI one band is charged nothing",
140
+ r.margin_phys[2] == 0.0 and any("does not define this boundary" in n for n in r.notes),
141
+ f"c={r.margin_phys[2]:.2f}",
142
+ )
143
+
144
+ print("\nSub-grid region -> not reported as 'box rejected'")
145
+
146
+ # Centred BETWEEN grid points (grid_n=21 -> spacing 0.05, points at 0.50 and 0.55), so no
147
+ # grid node can fall inside it. This is the case that must not be reported as "box rejected".
148
+ class Sliver:
149
+ def predict_unit(self, P):
150
+ return np.abs(np.atleast_2d(P)[:, 0] - 0.525)
151
+
152
+ r = solve(constraints=[Constraint("sliver", Sliver(), 0.0015, "max", 1e-4)], box=box, var_names=names, grid_n=21)
153
+ check("detects a too-coarse grid", any("GRID TOO COARSE" in n for n in r.notes), "; ".join(r.notes)[:80])
154
+
155
+ print("\nGenuinely empty -> confirmed by dense probe")
156
+ r = solve(
157
+ constraints=[Constraint("impossible", Lin(0.0, [1.0, 0, 0]), 99.0, "min", 1.0)],
158
+ box=box,
159
+ var_names=names,
160
+ grid_n=21,
161
+ )
162
+ check("confirms empty", any("really is rejected" in n for n in r.notes))
163
+
164
+ print(f"\n{'ALL PASS' if not FAILED else 'FAILURES: ' + ', '.join(FAILED)}")
165
+ return 1 if FAILED else 0
166
+
167
+
168
+ if __name__ == "__main__":
169
+ raise SystemExit(main())