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,298 @@
1
+ """The green region, the Chebyshev centre, and the safety margin in variable space.
2
+
3
+ Three things the brief is specific about, and each has a trap:
4
+
5
+ **Chebyshev centre, not the survivor mean.** The mean of the survivors is pulled toward wherever
6
+ the design happened to put points, and on a non-convex or disconnected green region it can land
7
+ *outside* it entirely (demonstrated in `test_centre.py`). The Chebyshev centre is the deepest
8
+ interior point — the centre of the largest inscribed ball — so it maximises the distance to the
9
+ nearest failure in every direction at once. With three variables this is a distance transform on a
10
+ grid, no optimiser needed.
11
+
12
+ **The margin must be in variable space, and against the constraint that actually binds.**
13
+ A KPI uncertainty band is in bar or K; the engineer needs K, cm³/s and %. The conversion is
14
+ `band / |dKPI/dx_i|` — the distance you must move x_i before a KPI change becomes distinguishable
15
+ from the band. But it must be computed for the constraint whose surface is *nearest* at the
16
+ centre. Deriving it from a constraint that never activates inside the box produces a margin that
17
+ is both meaningless and, because a shallow gradient makes `band/gradient` enormous, actively
18
+ harmful: it collapses the window of the variable the engineer is freest to move. So every
19
+ constraint is carried with its own threshold and band, the binding one is identified by normalised
20
+ slack, and the margin comes from that one.
21
+
22
+ **Distances must be normalised before they can be compared.** 1 K and 1 cm³/s are not comparable
23
+ magnitudes, so the distance transform runs in the unit box. Skipping that silently weights
24
+ whichever variable happens to have the largest numeric range.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+
31
+ import numpy as np
32
+
33
+
34
+ @dataclass
35
+ class Constraint:
36
+ """One acceptance criterion, in a form the geometry code can reason about.
37
+
38
+ ``sense="min"`` means feasible where value >= threshold (e.g. a no-flow margin);
39
+ ``sense="max"`` means feasible where value <= threshold (e.g. a hotspot rank cut).
40
+ ``band`` is the KPI's derived resolution floor — the unit in which "close to the threshold"
41
+ is measured, so proximity is judged against what the solver can actually resolve.
42
+ """
43
+
44
+ name: str
45
+ surr: object
46
+ threshold: float
47
+ sense: str
48
+ band: float
49
+ effect_span: np.ndarray | None = None
50
+
51
+ def value(self, P: np.ndarray) -> np.ndarray:
52
+ return self.surr.predict_unit(np.atleast_2d(P))
53
+
54
+ def feasible(self, P: np.ndarray) -> np.ndarray:
55
+ v = self.value(P)
56
+ return v >= self.threshold if self.sense == "min" else v <= self.threshold
57
+
58
+ def slack_bands(self, P: np.ndarray) -> np.ndarray:
59
+ """How many bands of headroom this constraint has. Negative = violated."""
60
+ v = self.value(P)
61
+ raw = (v - self.threshold) if self.sense == "min" else (self.threshold - v)
62
+ return raw / max(self.band, 1e-12)
63
+
64
+
65
+ @dataclass
66
+ class GreenRegion:
67
+ grid_shape: tuple[int, ...]
68
+ box: np.ndarray
69
+ feasible: np.ndarray
70
+ centre_unit: np.ndarray
71
+ centre_phys: np.ndarray
72
+ inradius_unit: float
73
+ margin_phys: np.ndarray
74
+ shrunk_box_phys: np.ndarray
75
+ feasible_fraction: float
76
+ limiting_variable: str | None
77
+ binding_constraint: str | None
78
+ slack_at_centre: dict = field(default_factory=dict)
79
+ notes: list = field(default_factory=list)
80
+
81
+
82
+ def _grid(box: np.ndarray, n: int) -> np.ndarray:
83
+ axes = [np.linspace(0.0, 1.0, n) for _ in range(len(box))]
84
+ mesh = np.meshgrid(*axes, indexing="ij")
85
+ return np.stack([m.ravel() for m in mesh], axis=1)
86
+
87
+
88
+ def _erode_cube(m: np.ndarray) -> np.ndarray:
89
+ """One erosion by a full (3,)*d cube — i.e. one step of L-infinity.
90
+
91
+ Cube erosion is separable, but the 1-D erosions must be **chained**: each axis erodes the
92
+ *result* of the previous axis. Rolling the same input for every axis instead erodes by a cross,
93
+ which gives the L1 (Manhattan) transform — a different metric, and the wrong one here.
94
+ """
95
+ out = m
96
+ for ax in range(m.ndim):
97
+ a, b = np.roll(out, 1, axis=ax), np.roll(out, -1, axis=ax)
98
+ lo = [slice(None)] * m.ndim
99
+ hi = [slice(None)] * m.ndim
100
+ lo[ax], hi[ax] = 0, -1
101
+ a[tuple(lo)] = False # roll wraps; the wrapped face must not count as safe
102
+ b[tuple(hi)] = False
103
+ out = out & a & b
104
+ return out
105
+
106
+
107
+ def _chebyshev(feasible: np.ndarray) -> tuple[np.ndarray, float]:
108
+ """Deepest interior cell and its depth in cells — exact L-infinity distance transform.
109
+
110
+ L-infinity is the right metric for a process window: the depth is then the half-width of the
111
+ largest inscribed **box**, which is exactly "how far can each variable move independently
112
+ before leaving the window". A Euclidean or L1 inradius would not answer that question.
113
+
114
+ Outside the grid counts as infeasible, so the box edges bound the inscribed cube — a centre
115
+ cannot claim depth it only has because the grid stopped.
116
+ """
117
+ depth = feasible.astype(np.int32)
118
+ cur = feasible.copy()
119
+ while cur.any():
120
+ cur = _erode_cube(cur)
121
+ depth += cur.astype(np.int32)
122
+ return np.array(np.unravel_index(int(np.argmax(depth)), feasible.shape)), float(depth.max())
123
+
124
+
125
+ def gradient_unit(surr, at_unit: np.ndarray, *, h: float = 0.02) -> np.ndarray:
126
+ """d(KPI)/d(x_i) per unit of normalised x_i, central difference."""
127
+ d = len(at_unit)
128
+ g = np.zeros(d)
129
+ for i in range(d):
130
+ lo, hi = at_unit.copy(), at_unit.copy()
131
+ lo[i] = max(0.0, at_unit[i] - h)
132
+ hi[i] = min(1.0, at_unit[i] + h)
133
+ span = hi[i] - lo[i]
134
+ if span > 0:
135
+ g[i] = float(surr.predict_unit(hi[None, :])[0] - surr.predict_unit(lo[None, :])[0]) / span
136
+ return g
137
+
138
+
139
+ def solve(
140
+ *,
141
+ constraints: list[Constraint],
142
+ box: np.ndarray,
143
+ var_names: tuple[str, ...],
144
+ grid_n: int = 41,
145
+ active_within_bands: float = 6.0,
146
+ floor_frac: float = 0.02,
147
+ cap_frac: float = 0.5,
148
+ ) -> GreenRegion:
149
+ """Green region, Chebyshev centre, and the inward-shrunk recommended box."""
150
+ notes: list[str] = []
151
+ d = len(box)
152
+ spans = box[:, 1] - box[:, 0]
153
+ pts = _grid(box, grid_n)
154
+
155
+ feas_flat = np.ones(len(pts), bool)
156
+ for c in constraints:
157
+ feas_flat &= c.feasible(pts)
158
+ feasible = feas_flat.reshape((grid_n,) * d)
159
+ frac = float(feasible.mean())
160
+
161
+ if not feasible.any():
162
+ # A grid can only see a region thicker than one cell; probe densely before declaring the
163
+ # box rejected, or we would report a confident wrong answer.
164
+ rng = np.random.default_rng(0)
165
+ probe = rng.random((200_000, d))
166
+ hits = np.ones(len(probe), bool)
167
+ for c in constraints:
168
+ hits &= c.feasible(probe)
169
+ if hits.any():
170
+ notes.append(
171
+ f"GRID TOO COARSE: no feasible cell at grid_n={grid_n}, but {int(hits.sum())} of "
172
+ f"200000 random probes are feasible (~{100 * hits.mean():.3f} % of the box). The "
173
+ "green region is thinner than one grid cell — refine the grid rather than "
174
+ "reporting the box as rejected."
175
+ )
176
+ notes.append(
177
+ f"suggested grid_n >= "
178
+ f"{int(np.clip(np.ceil(3.0 / max(hits.mean() ** (1.0 / d), 1e-6)), grid_n * 2, 401))}"
179
+ )
180
+ else:
181
+ notes.append("no feasible cell on the grid and none in 200000 random probes — the box really is rejected")
182
+ return GreenRegion(
183
+ (grid_n,) * d,
184
+ box,
185
+ feasible,
186
+ np.full(d, np.nan),
187
+ np.full(d, np.nan),
188
+ 0.0,
189
+ np.full(d, np.nan),
190
+ box.copy(),
191
+ frac,
192
+ None,
193
+ None,
194
+ {},
195
+ notes,
196
+ )
197
+
198
+ idx, depth_cells = _chebyshev(feasible)
199
+ centre_unit = idx / (grid_n - 1)
200
+ cell = 1.0 / (grid_n - 1)
201
+ inradius_unit = depth_cells * cell
202
+ centre_phys = box[:, 0] + centre_unit * spans
203
+
204
+ if depth_cells <= 1:
205
+ notes.append(
206
+ f"Chebyshev depth is only {depth_cells:.0f} grid cell(s): the green region is "
207
+ "barely thicker than the grid resolution, so the centre is poorly determined. "
208
+ "Refine the grid before trusting it."
209
+ )
210
+
211
+ # --- which constraint actually binds at the centre? ----------------------
212
+ slack = {c.name: float(c.slack_bands(centre_unit[None, :])[0]) for c in constraints}
213
+ binding = min(slack, key=slack.get)
214
+ binding_c = next(c for c in constraints if c.name == binding)
215
+ notes.append(
216
+ "slack at centre, in units of each KPI's own resolution band: "
217
+ + ", ".join(f"{k}={v:+.1f}" for k, v in sorted(slack.items(), key=lambda kv: kv[1]))
218
+ )
219
+
220
+ if slack[binding] > active_within_bands:
221
+ notes.append(
222
+ f"NO CONSTRAINT IS ACTIVE at the centre: the nearest ({binding}) still has "
223
+ f"{slack[binding]:.1f} bands of headroom. The recommended box is therefore limited by "
224
+ "the extent of the green region and by the wide box itself, not by any KPI boundary — "
225
+ "so a KPI-derived safety margin is not meaningful here and is set to zero. Report the "
226
+ "box as 'no active constraint found inside the wide box' rather than implying a "
227
+ "margin was earned."
228
+ )
229
+ margin_phys = np.zeros(d)
230
+ else:
231
+ # Margin from the BINDING constraint's local gradient. Axes along which that constraint
232
+ # cannot move by even one band across the whole box do not define its boundary, so they
233
+ # are charged nothing — otherwise a shallow gradient would collapse the window of the
234
+ # variable the engineer is freest to move.
235
+ g_unit = np.abs(gradient_unit(binding_c.surr, centre_unit))
236
+ margin_phys = np.zeros(d)
237
+ for i in range(d):
238
+ es = None if binding_c.effect_span is None else float(binding_c.effect_span[i])
239
+ if es is not None and es < binding_c.band:
240
+ notes.append(
241
+ f"{var_names[i]}: cannot move {binding} by one band anywhere in the "
242
+ f"box (span {es:.4g} < band {binding_c.band:.4g}); it does not define "
243
+ "this boundary, so no margin is charged on this axis."
244
+ )
245
+ continue
246
+ if g_unit[i] <= 1e-12:
247
+ margin_phys[i] = cap_frac * spans[i]
248
+ else:
249
+ margin_phys[i] = float(np.clip(binding_c.band / g_unit[i], floor_frac, cap_frac) * spans[i])
250
+
251
+ # --- largest axis-aligned box inside the green region, then shrink -------
252
+ lo_u, hi_u = np.zeros(d), np.zeros(d)
253
+ for i in range(d):
254
+ for direction in (-1, +1):
255
+ k = 0
256
+ while True:
257
+ probe = idx.copy()
258
+ probe[i] = idx[i] + direction * (k + 1)
259
+ if probe[i] < 0 or probe[i] >= grid_n or not feasible[tuple(probe)]:
260
+ break
261
+ k += 1
262
+ if direction < 0:
263
+ lo_u[i] = k * cell
264
+ else:
265
+ hi_u[i] = k * cell
266
+
267
+ margin_unit = margin_phys / np.maximum(spans, 1e-12)
268
+ shrunk = np.empty_like(box)
269
+ limiting, worst = None, np.inf
270
+ for i in range(d):
271
+ half_lo = max(lo_u[i] - margin_unit[i], 0.0)
272
+ half_hi = max(hi_u[i] - margin_unit[i], 0.0)
273
+ shrunk[i, 0] = box[i, 0] + (centre_unit[i] - half_lo) * spans[i]
274
+ shrunk[i, 1] = box[i, 0] + (centre_unit[i] + half_hi) * spans[i]
275
+ width = half_lo + half_hi
276
+ if width < worst:
277
+ worst, limiting = width, var_names[i]
278
+ if width <= 0:
279
+ notes.append(
280
+ f"{var_names[i]}: the margin ({margin_phys[i]:.4g}) exceeds the room "
281
+ "inside the green region — no usable window; fix it at the centre value."
282
+ )
283
+
284
+ return GreenRegion(
285
+ (grid_n,) * d,
286
+ box,
287
+ feasible,
288
+ centre_unit,
289
+ centre_phys,
290
+ inradius_unit,
291
+ margin_phys,
292
+ shrunk,
293
+ frac,
294
+ limiting,
295
+ binding,
296
+ slack,
297
+ notes,
298
+ )
@@ -0,0 +1,144 @@
1
+ """Space-filling designs, on numpy alone (no scipy in this venv).
2
+
3
+ **Sobol, not LHS**, and the reason matters: the brief chose Sobol because it is *incremental* —
4
+ the first 2^k points of the sequence are themselves a balanced design, so the sweep can stop
5
+ early or be extended without recommitting, and the design does not have to be fixed before we
6
+ know which variables matter. An LHS loses its stratification the moment you add a point, which
7
+ would forfeit exactly that property. So this is a real Sobol sequence (direction numbers below),
8
+ not an LHS standing in for one.
9
+
10
+ `scipy.stats.qmc` is unavailable here, so the generator is implemented directly. It is verified
11
+ against the properties that actually matter for this campaign (balance of every 2^k prefix,
12
+ one-dimensional projection uniformity) in `test_design.py`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import numpy as np
18
+
19
+ # Primitive polynomials (as bit-encoded coefficients) and initial direction numbers for the
20
+ # first 8 dimensions, from the standard Joe-Kuo tables. Dimension 1 is the plain van der
21
+ # Corput sequence in base 2 (degree 0, no recurrence).
22
+ #
23
+ # These numbers are third-party data under their own BSD licence, which covers the
24
+ # upstream program and these numbers alike; of the two, this project carries only the
25
+ # numbers, and the code below is the project's own. Their copyright notice and
26
+ # disclaimer are in NOTICE at the root of this project, and must travel with any copy.
27
+ _POLY = [1, 3, 7, 11, 13, 19, 25, 37]
28
+ _MDEG = [0, 1, 2, 3, 3, 4, 4, 5]
29
+ _MINIT: list[list[int]] = [
30
+ [],
31
+ [1],
32
+ [1, 3],
33
+ [1, 3, 1],
34
+ [1, 1, 1],
35
+ [1, 1, 3, 3],
36
+ [1, 3, 5, 13],
37
+ [1, 1, 5, 5, 17],
38
+ ]
39
+ _BITS = 30
40
+
41
+
42
+ def _direction_numbers(dim: int) -> np.ndarray:
43
+ """(dim, _BITS) table of direction integers V[j][i] for the Gray-code recurrence."""
44
+ if dim > len(_POLY):
45
+ raise ValueError(f"Sobol here supports up to {len(_POLY)} dimensions, asked for {dim}")
46
+ V = np.zeros((dim, _BITS + 1), dtype=np.uint64)
47
+ # dimension 0: V[i] = 1 << (BITS - i) -> van der Corput
48
+ for i in range(1, _BITS + 1):
49
+ V[0, i] = np.uint64(1) << np.uint64(_BITS - i)
50
+ for j in range(1, dim):
51
+ s, a, m = _MDEG[j], _POLY[j], _MINIT[j]
52
+ for i in range(1, s + 1):
53
+ V[j, i] = np.uint64(m[i - 1]) << np.uint64(_BITS - i)
54
+ for i in range(s + 1, _BITS + 1):
55
+ v = V[j, i - s] ^ (V[j, i - s] >> np.uint64(s))
56
+ for k in range(1, s):
57
+ if (a >> (s - 1 - k)) & 1:
58
+ v ^= V[j, i - k]
59
+ V[j, i] = v
60
+ return V
61
+
62
+
63
+ def sobol(n: int, dim: int, *, skip: int = 0) -> np.ndarray:
64
+ """First ``n`` Sobol points in [0,1)^dim, as an (n, dim) array.
65
+
66
+ Generated by the Gray-code recurrence, so ``sobol(2n, d)[:n] == sobol(n, d)`` — that
67
+ identity is what makes the sweep extensible, and it is asserted in the tests.
68
+
69
+ ``skip=0`` keeps index 0, the all-zeros point. QMC code often drops it because it is not a
70
+ "random-looking" point, but here it maps to a real and interesting process point — the box
71
+ corner at coldest melt, coldest mould, slowest fill — which this study wants evaluated
72
+ anyway. Keeping it also preserves exact balance: every 2^k prefix then puts exactly one
73
+ point in each of the 2^k bins of every one-dimensional projection, whereas dropping a point
74
+ from the block leaves one bin empty and one doubled (measured, and asserted in the tests).
75
+ """
76
+ if n <= 0:
77
+ return np.zeros((0, dim))
78
+ V = _direction_numbers(dim)
79
+ total = n + skip
80
+ out = np.zeros((total, dim), dtype=np.float64)
81
+ x = np.zeros(dim, dtype=np.uint64)
82
+ for i in range(1, total):
83
+ c = 1
84
+ v = i - 1
85
+ while v & 1:
86
+ v >>= 1
87
+ c += 1
88
+ x ^= V[:, c]
89
+ out[i] = x.astype(np.float64) / float(1 << _BITS)
90
+ return out[skip:]
91
+
92
+
93
+ def scale_to_box(
94
+ unit: np.ndarray, box: dict[str, tuple[float, float]], order: tuple[str, ...]
95
+ ) -> dict[str, np.ndarray]:
96
+ """Map unit-hypercube points onto the named variable box."""
97
+ if unit.shape[1] != len(order):
98
+ raise ValueError(f"design has {unit.shape[1]} dims, box order names {len(order)}")
99
+ return {name: box[name][0] + unit[:, i] * (box[name][1] - box[name][0]) for i, name in enumerate(order)}
100
+
101
+
102
+ def epsilon_design(
103
+ centre: dict[str, float],
104
+ *,
105
+ melt_deltas_K=(0.02, 0.05, 0.10),
106
+ wall_deltas_K=(0.02, 0.05, 0.10),
107
+ flow_rel=(2e-4, 1e-3),
108
+ ) -> list[dict]:
109
+ """Physically meaningless input perturbations, for the noise-floor experiment.
110
+
111
+ The point is to move the *inputs* by an amount no real machine could resolve, and see how
112
+ far the *outputs* move. Whatever spread comes back is the floor below which an apparent
113
+ effect is not an effect.
114
+
115
+ Scale justification: a production machine holds melt temperature to roughly ±1–2 K and
116
+ mould temperature to about ±1 K, so 0.02–0.10 K is two orders of magnitude below anything
117
+ controllable — physically meaningless by a wide margin, while still changing the submitted
118
+ float. Flow is perturbed *relatively* (it is a ratio scale) by 0.02–0.1 %, likewise far
119
+ below any screw's metering repeatability.
120
+
121
+ Note what this does **not** measure. The solver is deterministic, so there is no run-to-run
122
+ scatter to find; a zero perturbation would return a bit-identical result. What is measured
123
+ is the response to a negligible input change, dominated by float16 storage quantization of
124
+ the result fields plus the model's own conditioning. That is a genuine resolution floor —
125
+ but calling it "noise" would wrongly imply more runs could average it away.
126
+ """
127
+ pts = [dict(centre, label="eps nominal", kind="nominal")]
128
+ for d in melt_deltas_K:
129
+ for s in (+1, -1):
130
+ pts.append(dict(centre, melt_C=centre["melt_C"] + s * d, label=f"eps melt {s * d:+.3f}K", kind="melt"))
131
+ for d in wall_deltas_K:
132
+ for s in (+1, -1):
133
+ pts.append(dict(centre, wall_C=centre["wall_C"] + s * d, label=f"eps wall {s * d:+.3f}K", kind="wall"))
134
+ for r in flow_rel:
135
+ for s in (+1, -1):
136
+ pts.append(
137
+ dict(
138
+ centre,
139
+ flow_cm3_s=centre["flow_cm3_s"] * (1.0 + s * r),
140
+ label=f"eps flow {s * r * 100:+.3f}%",
141
+ kind="flow",
142
+ )
143
+ )
144
+ return pts