simcon-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simcon_toolkit/__init__.py +40 -0
- simcon_toolkit/__main__.py +7 -0
- simcon_toolkit/_kit/LICENSE +202 -0
- simcon_toolkit/_kit/NOTICE +37 -0
- simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
- simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
- simcon_toolkit/_kit/packages/.ruff.toml +10 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
- simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
- simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
- simcon_toolkit/_kit/pyproject.toml +232 -0
- simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
- simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
- simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
- simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
- simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
- simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
- simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
- simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
- simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
- simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
- simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
- simcon_toolkit/_kit/templates/process-window/README.md +176 -0
- simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
- simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
- simcon_toolkit/_kit/templates/process-window/main.py +82 -0
- simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
- simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
- simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
- simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
- simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
- simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
- simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
- simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
- simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
- simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
- simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
- simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
- simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
- simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
- simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
- simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
- simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
- simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
- simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
- simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
- simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
- simcon_toolkit/_kit/templates/quoting/README.md +158 -0
- simcon_toolkit/_kit/templates/quoting/main.py +484 -0
- simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
- simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
- simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
- simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
- simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
- simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
- simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
- simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
- simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
- simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
- simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
- simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
- simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
- simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
- simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
- simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
- simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
- simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
- simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
- simcon_toolkit/_kit/templates.toml +83 -0
- simcon_toolkit/choices.py +11 -0
- simcon_toolkit/cli.py +381 -0
- simcon_toolkit/generate.py +590 -0
- simcon_toolkit/instructions.py +152 -0
- simcon_toolkit/manifest.py +86 -0
- simcon_toolkit/project.py +356 -0
- simcon_toolkit/wizard.py +160 -0
- simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
- simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
- simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
- simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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
|