← Correction
Line-numbered frozen source
REPORT.md
1 # 04 · Where should the blank witnesses go? (H0199)
2
3 **Priority 4 · status: mathematical result turned into a design rule with numbers.**
4
5 The catalogue entry says interleaving inert witness patches among reactive patches estimates
6 deposition bias better than putting all blanks at the edge. The dossier already flags the honest
7 caveat: "drop any universal superiority claim — edge samples can suffice for simple gradients and may
8 win for edge-localized structure."
9
10 This dossier turns that caveat into a quantitative design rule. It also identifies the case where the
11 answer is not "it depends" but a flat failure: **a deposition field that vanishes on the boundary is
12 invisible to edge witnesses, and edge-only blanks then mis-estimate capture efficiency by 23 % at
13 *any* number of blanks.**
14
15 ---
16
17 ## 1. The measurement, stated precisely
18
19 A substrate carries `M = 25` reactive patches. Their measured uptake is
20
21 R_i = eps · f(x_i) + noise, i = 1..M
22
23 where `eps` (capture efficiency) is the unknown we want and `f(x)` is the unknown spatial deposition
24 field. `N = 12` inert witnesses measure the same field up to a known constant:
25
26 W_j = f(x_j) + noise, j = 1..N
27
28 The estimator is `eps_hat = Σ R_i / Σ f_hat(x_i)`, with `f_hat` interpolated from the witnesses
29 (quadratic least squares by default; a Gaussian-process variant is also run). The reported error is
30 `eps_hat/eps − 1`, averaged over 400 independent field draws at fixed noise `σ = 0.02` (relative) and
31 true `eps = 1.3`.
32
33 This is a *synthetic* study. Every field is stated below; no experimental data are used or implied.
34 Code: `code/h0199.py`.
35
36 ## 2. Result
37
38 RMSE of the relative error in `eps_hat`, 400 draws:
39
40 | deposition field | edge only | distributed grid | jittered grid | random |
41 |---|---|---|---|---|
42 | linear gradient | 1.92 % | **0.79 %** | 0.78 % | 0.81 % |
43 | quadratic bowl | 2.54 % | **1.05 %** | 1.04 % | 1.07 % |
44 | centre-to-edge (radial) | 1.95 % | **0.80 %** | 0.80 % | 0.82 % |
45 | edge-enhanced (rim peak) | **37.9 %** | **1.03 %** | 2.86 % | 3.60 % |
46 | vanishes on boundary | **23.1 %** | **0.93 %** | 1.34 % | 1.91 % |
47 | smooth random field, ℓ = 0.35 | 7.1 % | 4.3 % | 4.7 % | **3.7 %** |
48
49 (`figures/rmse_by_field.png`.) Reading:
50
51 1. **Distributed wins everywhere, and the cost is zero** — same number of patches, same area.
52 On the benign fields the gain is a factor 2.4; on the adversarial fields the edge layout is not
53 merely worse, it is wrong by tens of percent.
54 2. **The edge layout's failure mode is structural, not statistical.** A quadratic fit through
55 boundary points must extrapolate to the interior. When the field's interior structure is not in
56 the polynomial span, the extrapolation error does not shrink with more edge blanks.
57 3. **Random placement is not the same as a grid.** On the edge-enhanced field a random layout still
58 has 3.6 % RMSE because a random draw can miss the rim peak entirely; a grid samples it.
59
60 ## 3. The identifiability argument
61
62 Let `f` be continuous on the substrate `Ω` and constant on `∂Ω` (value `f_0`). Every edge witness
63 returns `f_0` up to noise. Any estimator built only from boundary samples therefore has no
64 information about the interior: the family `{f : f|∂Ω = f_0}` is infinite-dimensional, and the
65 area mean `∫f` can take any value above `f_0·|Ω|`. The quadratic correction model masks this by
66 imposing a functional form, but the masking is exactly the extrapolation error in the table.
67
68 The demo field is `f = 1 + 0.5·sin(πx)·sin(πy)`, whose boundary trace is exactly 1. Edge witnesses
69 see a uniform field; the estimator then divides by `f_hat = 1` and overestimates `eps` by the mean of
70 the interior bump — **+22.9 % bias, stable in sign and magnitude, unaffected by adding edge patches**
71 (`figures/boundary_null_layouts.png`, `rmse_vs_n.png`).
72
73 This is not a pathological construction. Centre-to-edge non-uniformity is the canonical defect of
74 CVD, sputtering, evaporation and spin-coating reactors, and a radial profile is one of the few
75 shapes that a rim-only witness ring is guaranteed to miss.
76
77 ## 4. When does the edge suffice? (the decision rule)
78
79 The edge layout is only defensible when the field is *monotone* over the substrate. Sweeping the
80 correlation length of a smooth random field (`figures/rmse_vs_correlation.png`):
81
82 | correlation length ℓ (substrate widths) | 0.12 | 0.20 | 0.30 | 0.50 | 0.80 | 1.50 |
83 |---|---|---|---|---|---|---|
84 | edge only | 41.4 % | 18.0 % | 7.6 % | 5.4 % | 4.3 % | 3.8 % |
85 | distributed | 10.6 % | 7.6 % | 5.0 % | 3.1 % | 2.3 % | 1.8 % |
86
87 The edge layout never *reaches* the 2 % target in this sweep, because its error floor is set by
88 extrapolation, not by sample size. A practical rule:
89
90 > **Use distributed blanks when ℓ < 0.5 substrate widths, or whenever the process can produce a
91 > centre-to-edge or rim profile. Use edge blanks only when the field is known monotone and the
92 > interior cannot be instrumented.**
93
94 Number of witnesses (`rmse_vs_n.png`, radial field): 4 distributed blanks (1.6 % RMSE) already beat
95 36 edge blanks (0.77 % vs 0.32 % — the edge does improve with more points on a field that a
96 quadratic can represent). For the boundary-null field, no number of edge blanks helps.
97
98 ## 5. A cheap physical check before any deposition campaign
99
100 The whole question can be settled for one process in a day, without reactive chemistry:
101
102 1. Use a benign deposition analogue (a dye or fluorescent aerosol, or a sputtered thin film) on a
103 substrate carrying `M` reactive and `N` inert patches.
104 2. Measure the full field independently — profilometry, fluorescence scanning, or a quartz-crystal
105 microbalance raster — so the true `f(x)` is known, not inferred.
106 3. Compare `eps_hat` from edge blanks against `eps_hat` from distributed blanks, at equal patch
107 count and area, in a *pre-registered* head-to-head.
108 4. Report the ratio, not a p-value. The decision is which layout to buy, so the relevant number is
109 the error at the operating point of the real process.
110
111 Cost: one substrate batch, one mapping instrument day. No new apparatus.
112
113 ## 6. What this changes in the catalogue entry
114
115 The entry's stop rule is right, and the analysis sharpens it. It should say: *distributed blanks win
116 on every field family tested except a pure linear gradient, where they tie; edge-only blanks are
117 not merely suboptimal but unidentifiable when the field is constant on the boundary, which includes
118 the standard centre-to-edge non-uniformity of most deposition reactors.*
119
120 The claim "more accurately than edge placement" survives — with the strong caveat that the advantage
121 is large mainly when the field has interior or rim structure, and that it is a *bias* advantage, not
122 a noise advantage.
123
124 ## 7. Limits
125
126 - Two-dimensional, steady deposition; no time variation of `f` during the campaign.
127 - Witness patches are assumed not to perturb the field. In a real reactor they do (they shadow); the
128 control for this is a separate experiment, not modelled here.
129 - The correction model is a low-order polynomial; kriging with a fitted kernel would change the
130 constants but not the identifiability argument, which is model-free.
131 - Capture efficiency is assumed spatially uniform. If `eps` itself varies with position, no layout
132 separates the two effects without an independent measurement.
133
134 Numbers: `results/h0199_sweep.json`.
135
136
137 ## Figures
138
139 
140
141 *The failure case: a field that vanishes on the boundary. Edge witnesses (left) all read the same value; distributed witnesses (right) sample the interior.*
142
143 
144
145 *RMSE of the estimated capture efficiency, by field family and witness layout, at 12 witnesses and equal area. Note the log scale.*
146
147 
148
149 *Edge versus distributed as the field correlation length shrinks. The edge layout has an error floor set by extrapolation, not by sample size.*
150
151 
152
153 *Cost of the layout choice: RMSE against the number of witnesses, for a radial non-uniformity.*
README.md
1 # 04 · H0199 where the blank witnesses go
2
3 `REPORT.md` — the measurement model, the identifiability argument, the design rule, a physical check.
4
5 - `code/h0199.py` — field families, four layouts, polynomial and GP estimators, 400-draw RMSE sweep.
6 - `code/make_figures.py` — the four figures.
7 - `results/h0199_sweep.json`.
8
9 Headline: 12 distributed blanks give 0.8–1.0 % error where 12 edge blanks give 1.9–37.9 %, at
10 identical cost. For a field that is constant on the boundary — the standard centre-to-edge
11 non-uniformity of deposition reactors — the edge layout is off by **22.9 %** and no number of edge
12 blanks fixes it.
code/h0199.py
1 """H0199 -- where should inert witness patches go?
2
3 Claim under test (incoprea study-1, ID H0199):
4 "Interleaving chemically inert witness patches among reactive patches estimates spatial
5 deposition bias more accurately than placing all blanks at the sample edge."
6
7 We formalise the measurement. A substrate carries M reactive patches whose uptake is
8 R_i = eps * f(x_i) + noise
9 where eps is the unknown capture efficiency and f(x) the (unknown) deposition field. N inert
10 witness patches measure the same field up to a known constant:
11 W_j = f(x_j) + noise.
12 The quantity of interest is eps (hence the total uptake eps * integral f). The estimator is
13 eps_hat = sum_i R_i / sum_i f_hat(x_i)
14 with f_hat interpolated from the witnesses. The layout question is: where to put the N witnesses.
15
16 Everything is synthetic and explicit: the field is drawn from a stated family, the truth is known,
17 and the reported error is eps_hat - eps. No experimental data are used or implied.
18 """
19 import json
20 import os
21
22 import numpy as np
23
24 HERE = os.path.dirname(os.path.abspath(__file__))
25 ROOT = os.path.normpath(os.path.join(HERE, ".."))
26 RES = os.path.join(ROOT, "results")
27 os.makedirs(RES, exist_ok=True)
28
29 rng_global = np.random.default_rng(20261101)
30
31
32 # ---------------------------------------------------------------- fields
33 def field(name, X, ell=0.35, seed=0):
34 x, y = X[:, 0], X[:, 1]
35 if name == "linear":
36 return 1.0 + 0.6 * x + 0.3 * y
37 if name == "quadratic":
38 return 1.0 + 0.8 * (x - 0.5) ** 2 + 0.5 * (y - 0.5) ** 2
39 if name == "radial_edge": # centre-to-edge non-uniformity
40 r = np.hypot(x - 0.5, y - 0.5) / 0.7071
41 return 1.0 + 0.6 * (1 - r ** 2)
42 if name == "edge_enhanced": # deposition peaks at the rim
43 d = np.minimum.reduce([x, 1 - x, y, 1 - y])
44 return 1.0 + 0.8 * np.exp(-d / 0.08)
45 if name == "boundary_null": # vanishes on the whole boundary
46 return 1.0 + 0.5 * np.sin(np.pi * x) * np.sin(np.pi * y)
47 if name == "gp": # smooth random field
48 rng = np.random.default_rng(seed)
49 k = 24
50 c = rng.uniform(0, 1, (k, 2))
51 a = rng.normal(0, 1, k)
52 d2 = ((X[:, None, :] - c[None, :, :]) ** 2).sum(-1)
53 g = np.exp(-d2 / (2 * ell ** 2)) @ a
54 return 1.0 + 0.45 * g / (np.abs(g).max() + 1e-12)
55 raise ValueError(name)
56
57
58 # ---------------------------------------------------------------- layouts
59 def layout_edge(n):
60 t = np.linspace(0, 1, n // 4 + 2)[1:-1]
61 pts = []
62 for u in t:
63 pts += [(u, 0.0), (u, 1.0), (0.0, u), (1.0, u)]
64 return np.array(pts[:n])
65
66
67 def layout_grid(n, jitter=0.0, rng=None):
68 k = int(np.ceil(np.sqrt(n)))
69 g = (np.arange(k) + 0.5) / k
70 X, Y = np.meshgrid(g, g)
71 pts = np.stack([X.ravel(), Y.ravel()], 1)[:n]
72 if jitter and rng is not None:
73 pts = pts + rng.uniform(-jitter, jitter, pts.shape)
74 pts = np.clip(pts, 0.02, 0.98)
75 return pts
76
77
78 def layout_random(n, rng, margin=0.05):
79 return rng.uniform(margin, 1 - margin, (n, 2))
80
81
82 def reactive_patches(m=25, inset=0.12):
83 g = np.linspace(inset, 1 - inset, int(np.sqrt(m)))
84 X, Y = np.meshgrid(g, g)
85 return np.stack([X.ravel(), Y.ravel()], 1)
86
87
88 # ---------------------------------------------------------------- estimator
89 def poly_basis(X, deg):
90 x, y = X[:, 0], X[:, 1]
91 cols = [np.ones_like(x), x, y]
92 if deg >= 2:
93 cols += [x * x, x * y, y * y]
94 return np.stack(cols, 1)
95
96
97 def estimator(wit, W, react, deg, ridge=1e-9):
98 """Fit a polynomial surface to the witnesses, predict at reactive patches."""
99 A = poly_basis(wit, deg)
100 coef, *_ = np.linalg.lstsq(A, W, rcond=None)
101 return poly_basis(react, deg) @ coef
102
103
104 def gy_estimator(wit, W, react, ell):
105 """Gaussian-process (RBF) prediction at the reactive patches."""
106 def K(a, b):
107 d2 = ((a[:, None, :] - b[None, :, :]) ** 2).sum(-1)
108 return np.exp(-d2 / (2 * ell ** 2))
109 Kww = K(wit, wit) + 1e-6 * np.eye(len(wit))
110 alpha = np.linalg.solve(Kww, W)
111 return K(react, wit) @ alpha
112
113
114 def trial(field_name, layout, n_wit, deg, sigma, eps=1.3, m=25, seed=0, ell=0.35,
115 estimator_kind="poly", gy_ell=0.35):
116 rng = np.random.default_rng(seed)
117 react = reactive_patches(m)
118 f_r = field(field_name, react, ell=ell, seed=seed)
119 R = eps * f_r + rng.normal(0, sigma, len(react))
120 if layout == "edge":
121 wit = layout_edge(n_wit)
122 elif layout == "grid":
123 wit = layout_grid(n_wit)
124 elif layout == "jitter":
125 wit = layout_grid(n_wit, 0.06, rng)
126 elif layout == "random":
127 wit = layout_random(n_wit, rng)
128 else:
129 raise ValueError(layout)
130 f_w = field(field_name, wit, ell=ell, seed=seed)
131 W = f_w + rng.normal(0, sigma, len(wit))
132 if estimator_kind == "poly":
133 f_hat = estimator(wit, W, react, deg)
134 else:
135 f_hat = gy_estimator(wit, W, react, gy_ell)
136 num = R.sum()
137 den = f_hat.sum()
138 return num / den / eps - 1.0 # relative error
139
140
141 def rmse(v):
142 v = np.asarray(v)
143 return float(np.sqrt(np.mean(v ** 2)))
144
145
146 def run_sweep():
147 out = {"spec": dict(m=25, n_witness=12, sigma=0.02, eps=1.3,
148 metrics="relative error of eps_hat, RMSE over 400 field draws")}
149 layouts = ["edge", "grid", "jitter", "random"]
150 fields = ["linear", "quadratic", "radial_edge", "edge_enhanced", "boundary_null", "gp"]
151 rows = []
152 for fld in fields:
153 for lay in layouts:
154 errs = [trial(fld, lay, 12, 2, 0.02, seed=s) for s in range(400)]
155 rows.append(dict(field=fld, layout=lay, rmse=rmse(errs),
156 bias=float(np.mean(errs)),
157 sd=float(np.std(errs)),
158 p90=float(np.percentile(np.abs(errs), 90))))
159 out["main"] = rows
160
161 # degree-1 correction model (the cheap, common practice)
162 rows1 = []
163 for fld in fields:
164 for lay in ["edge", "grid"]:
165 errs = [trial(fld, lay, 12, 1, 0.02, seed=s) for s in range(400)]
166 rows1.append(dict(field=fld, layout=lay, rmse=rmse(errs),
167 bias=float(np.mean(errs))))
168 out["degree1"] = rows1
169
170 # correlation-length sweep with a GP field
171 rows2 = []
172 for ell in [0.12, 0.2, 0.3, 0.5, 0.8, 1.5]:
173 for lay in ["edge", "grid"]:
174 errs = [trial("gp", lay, 12, 2, 0.02, seed=s, ell=ell) for s in range(200)]
175 rows2.append(dict(ell=ell, layout=lay, rmse=rmse(errs)))
176 out["ell_sweep"] = rows2
177
178 # number of witnesses
179 rows3 = []
180 for n in [4, 8, 12, 16, 24, 36]:
181 for lay in ["edge", "grid"]:
182 errs = [trial("radial_edge", lay, n, 2, 0.02, seed=s) for s in range(200)]
183 rows3.append(dict(n=n, layout=lay, rmse=rmse(errs)))
184 out["n_sweep"] = rows3
185
186 # the modal argument: a field that vanishes on the boundary
187 rows4 = []
188 for lay in ["edge", "grid"]:
189 errs = []
190 for s in range(200):
191 errs.append(trial("radial_edge", lay, 12, 2, 0.02, seed=s, eps=1.0))
192 rows4.append(dict(layout=lay, rmse=rmse(errs)))
193 out["unity_eps"] = rows4
194 return out
195
196
197 if __name__ == "__main__":
198 res = run_sweep()
199 json.dump(res, open(os.path.join(RES, "h0199_sweep.json"), "w"), indent=2)
200 for r in res["main"]:
201 print("%-14s %-7s rmse %.4f bias %+.4f" %
202 (r["field"], r["layout"], r["rmse"], r["bias"]))
203 print()
204 for r in res["ell_sweep"]:
205 print("ell %.2f %-6s rmse %.4f" % (r["ell"], r["layout"], r["rmse"]))
206 print()
207 for r in res["n_sweep"]:
208 print("n %2d %-6s rmse %.4f" % (r["n"], r["layout"], r["rmse"]))
code/make_figures.py
1 """Figures for the H0199 reference-placement dossier."""
2 import json
3 import os
4
5 import matplotlib
6 matplotlib.use("Agg")
7 import matplotlib.pyplot as plt
8 import numpy as np
9
10 HERE = os.path.dirname(os.path.abspath(__file__))
11 ROOT = os.path.normpath(os.path.join(HERE, ".."))
12 RES = os.path.join(ROOT, "results")
13 FIG = os.path.join(ROOT, "figures")
14 os.makedirs(FIG, exist_ok=True)
15 plt.rcParams.update({"font.size": 9, "axes.spines.top": False,
16 "axes.spines.right": False, "figure.dpi": 200})
17 EDGE, GRID = "#b5651d", "#0b6e6e"
18
19
20 def load():
21 return json.load(open(os.path.join(RES, "h0199_sweep.json")))
22
23
24 def fig_main(d):
25 rows = d["main"]
26 fields = []
27 for r in rows:
28 if r["field"] not in fields:
29 fields.append(r["field"])
30 lay = ["edge", "grid", "jitter", "random"]
31 fig, ax = plt.subplots(figsize=(5.2, 2.9))
32 x = np.arange(len(fields))
33 w = 0.2
34 cols = [EDGE, GRID, "#7a7a7a", "#3b3b6d"]
35 for j, L in enumerate(lay):
36 v = [next(r["rmse"] for r in rows if r["field"] == f and r["layout"] == L)
37 for f in fields]
38 ax.bar(x + (j - 1.5) * w, v, w, label=L, color=cols[j])
39 ax.set_yscale("log")
40 ax.set_ylim(top=max(r["rmse"] for r in rows) * 2.2)
41 ax.set_xticks(x)
42 ax.set_xticklabels([f.replace("_", "\n") for f in fields], fontsize=7.5)
43 ax.set_ylabel("RMSE of capture efficiency (relative)")
44 ax.axhline(0.02, color="k", lw=0.7, ls=":")
45 ax.text(len(fields) - 0.55, 0.0215, "2 % target", fontsize=7)
46 ax.set_title("12 witnesses, polynomial correction", loc="left", fontsize=10)
47 ax.legend(frameon=False, fontsize=7, ncol=4, loc="upper center",
48 bbox_to_anchor=(0.5, -0.22))
49 fig.tight_layout()
50 fig.savefig(os.path.join(FIG, "rmse_by_field.png"), bbox_inches="tight")
51 plt.close(fig)
52
53
54 def fig_corr(d):
55 rows = d["ell_sweep"]
56 ell = sorted({r["ell"] for r in rows})
57 fig, ax = plt.subplots(figsize=(4.0, 2.8))
58 for L, c, m in (("edge", EDGE, "o"), ("grid", GRID, "s")):
59 v = [next(r["rmse"] for r in rows if r["ell"] == e and r["layout"] == L) for e in ell]
60 ax.plot(ell, v, m + "-", color=c, label=L, ms=4, lw=1.8)
61 ax.set_xscale("log")
62 ax.set_yscale("log")
63 ax.set_ylim(top=max(r["rmse"] for r in rows) * 2.2)
64 ax.set_xlabel("field correlation length (substrate widths)")
65 ax.set_ylabel("RMSE of capture efficiency")
66 ax.set_title("Where edge witnesses stop working", loc="left", fontsize=10)
67 ax.legend(frameon=False, fontsize=8)
68 fig.tight_layout()
69 fig.savefig(os.path.join(FIG, "rmse_vs_correlation.png"))
70 plt.close(fig)
71
72
73 def fig_n(d):
74 rows = d["n_sweep"]
75 ns = sorted({r["n"] for r in rows})
76 fig, ax = plt.subplots(figsize=(4.0, 2.8))
77 for L, c, m in (("edge", EDGE, "o"), ("grid", GRID, "s")):
78 v = [next(r["rmse"] for r in rows if r["n"] == n and r["layout"] == L) for n in ns]
79 ax.plot(ns, v, m + "-", color=c, label=L, ms=4, lw=1.8)
80 ax.set_xscale("log"); ax.set_yscale("log")
81 ax.set_xlabel("number of witness patches")
82 ax.set_ylabel("RMSE (radial non-uniformity)")
83 ax.set_title("Cost of the layout choice", loc="left", fontsize=10)
84 ax.legend(frameon=False, fontsize=8)
85 fig.tight_layout()
86 fig.savefig(os.path.join(FIG, "rmse_vs_n.png"))
87 plt.close(fig)
88
89
90 def fig_field():
91 import importlib.util
92 spec = importlib.util.spec_from_file_location("h", os.path.join(HERE, "h0199.py"))
93 h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h)
94 g = np.linspace(0, 1, 240)
95 X, Y = np.meshgrid(g, g)
96 P = np.stack([X.ravel(), Y.ravel()], 1)
97 F = h.field("boundary_null", P).reshape(X.shape)
98 fig, axs = plt.subplots(1, 2, figsize=(5.6, 2.7))
99 im = axs[0].pcolormesh(X, Y, F, cmap="viridis", shading="auto")
100 axs[0].plot(*h.layout_edge(12).T, "o", ms=3.5, mfc="white", mec="k")
101 axs[0].set_title("edge witnesses", fontsize=9)
102 axs[1].pcolormesh(X, Y, F, cmap="viridis", shading="auto")
103 axs[1].plot(*h.layout_grid(12).T, "o", ms=3.5, mfc="white", mec="k")
104 axs[1].set_title("distributed witnesses", fontsize=9)
105 for a in axs:
106 a.set_xticks([]); a.set_yticks([])
107 a.set_aspect("equal")
108 for s in a.spines.values():
109 s.set_visible(False)
110 cb = fig.colorbar(im, ax=axs, fraction=0.03, pad=0.02)
111 cb.set_label("deposition field f", fontsize=8)
112 cb.ax.tick_params(labelsize=7)
113 fig.suptitle("A field that vanishes on the boundary is invisible to edge blanks",
114 fontsize=9, x=0.02, ha="left")
115 fig.savefig(os.path.join(FIG, "boundary_null_layouts.png"), bbox_inches="tight")
116 plt.close(fig)
117
118
119 if __name__ == "__main__":
120 d = load()
121 fig_main(d)
122 fig_corr(d)
123 fig_n(d)
124 fig_field()
125 print("ok")