← 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 ![The failure case: a field that vanishes on the boundary. Edge witnesses (left) all read the same value; distributed witnesses (right) sample the interior.](figures/boundary_null_layouts.png)
 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 ![RMSE of the estimated capture efficiency, by field family and witness layout, at 12 witnesses and equal area. Note the log scale.](figures/rmse_by_field.png)
 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 ![Edge versus distributed as the field correlation length shrinks. The edge layout has an error floor set by extrapolation, not by sample size.](figures/rmse_vs_correlation.png)
 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 ![Cost of the layout choice: RMSE against the number of witnesses, for a radial non-uniformity.](figures/rmse_vs_n.png)
 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")