RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
make_calib_design.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Generate a SPACE-FILLING calibration design for the T8 experiment match.
3
4WHY NOT SALTELLI. T2/T3 used a Saltelli A/B/AB_j design because the question
5was the RELATIVE RANKING of parameters (Sobol indices), which needs that exact
6pairing and costs N*(p+2) rows to buy it. The question now is different: find
7parameter vectors whose loop character and mean size match the measurement.
8That is estimation, not attribution -- the pairing buys nothing, and a Latin
9hypercube covers the box far better per row. At p = 7 a Saltelli design would
10need 9 rows per base sample; this needs 1.
11
12WHY THESE SEVEN. Screened on the T3 campaign (1008 rows) against the two
13observables the campaign never screened -- f_100_tem_1 and the loop diameters --
14because <100> was withheld under bin_moment:
15
16 parameter rho(d_100) rho(d_111) rho(pile_100) rho(f_100)
17 L_hat -0.540 +0.011 -0.699 +0.004
18 E_a0_conv +0.650 +0.268 +0.119 -0.583
19 phi_max_junc +0.322 +0.267 -0.019 -0.556
20 Z_i +0.420 +0.270 +0.015 -0.590
21 f_cl_v -0.337 -0.251 +0.136 +0.605
22 f_cl_i +0.343 -0.031 +0.284 +0.013
23 dH2_abs_conv +0.323 +0.113 +0.430 -0.183
24
25L_hat is the one lever that is strong on the <100> axis and ORTHOGONAL to
26d_111 (rho = +0.011), so it de-saturates the grid without disturbing the
271/2<111> size that sets the character. Every other parameter trades the two
28against each other. It is sampled HIGH by default: all four grid-clean
29full-dose rows in T3 had L_hat >= 1016, and the L_hat < 100 half of the campaign
30has median pile_100 = 1.000, i.e. totally saturated.
31
32THE TARGET is the 85 grid-clean rows (pile_100 < 0.05, 8.4 % of T3), where the
33model already reproduces the experiment qualitatively: median f_100_tem_1 =
340.331 against a "< 0.5" datum, median d_111 = 2.31 nm against 3.4 nm, and
35d_100 down to 1.61 nm. This design searches that region rather than the full
36prior box.
37
38Every non-sampled parameter is pinned to a BASE ROW rather than the workbook, so
39the design sits at a point already known to be grid-clean, conserving and cheap
40(T3 row 38: pile_100 = 1e-17, delta_FP = 4.6e-3, 1852 s to full dose).
41
42Usage:
43 python make_calib_design.py --n 64 --condition C_330_G6 --out design/T8_calib.csv
44"""
45from __future__ import annotations
46
47import argparse
48import csv
49import hashlib
50import json
51import math
52import random
53from pathlib import Path
54
55HERE = Path(__file__).resolve().parent
56
57# key -> (lo, hi, scale). "log" samples uniformly in log10.
58#
59# BOX WIDENED 2026-08-13, author-directed, after the T7 corners showed d_111
60# pinned at 0.79-0.85 nm at EVERY (T, G) -- and the whole T3 campaign sat at a
61# median of 0.88 nm across 19 parameters. That invariance is the signature of a
62# box that does not contain the answer, not of a corner not yet sampled: the
63# 1/2<111> population is held below the 1.0 nm TEM cutoff, so it is invisible and
64# the numerous small <100> loops take the entire visible count. That single fact
65# produces BOTH open misfits (inverted character, 158x excess density), so the
66# widened ranges all target it, from four independent directions:
67#
68# (a) let 1/2<111> SURVIVE long enough to grow -- slow the conversion:
69# E_a0_conv up (unary barrier; the crossover-temperature knob)
70# phi_max_junc down (junction branching)
71# dH2_abs_conv up (absorption gate; 0.70 is the documented usable top,
72# vs 1.00 for the junction)
73# (b) NUCLEATE FEWER loops so each grows bigger AND the density falls:
74# f_cl_i down (cascade cluster fraction)
75# (c) make 1/2<111> STABLE against thermal emission:
76# E_b_i2 up (dimer binding; A_111 is derived from it)
77# B_111 down (binding falls off more slowly with n)
78# (d) keep the <100> axis unsaturated so the size readout is not a ceiling:
79# L_hat unchanged -- already the orthogonal lever (rho = +0.011
80# on d_111, -0.699 on pile_100)
81#
82# Ranges that LEAVE their prior: E_a0_conv 2.4 -> 3.5, phi_max_junc 0.1 -> 0.01,
83# dH2_abs_conv 0.45 -> 0.70, f_cl_i 0.05 -> 0.01, E_b_i2 1.2 -> 1.6,
84# B_111 0.3 -> 0.2. Excursion factors recorded here so a fit that lands outside
85# the original priors is reported as such rather than quietly adopted.
86SAMPLED = {
87 "L_hat": (300.0, 3500.0, "log"),
88 "E_a0_conv": (1.4, 3.5, "lin"), # was (1.6, 2.4)
89 "phi_max_junc": (0.01, 1.0, "log"), # was (0.1, 1.0) lin
90 "Z_i": (1.02, 1.15, "lin"),
91 "f_cl_v": (0.2, 0.7, "lin"),
92 "f_cl_i": (0.01, 0.25, "log"), # was (0.05, 0.25) lin
93 "dH2_abs_conv": (0.26, 0.70, "lin"), # was (0.26, 0.45)
94 "E_b_i2": (0.6, 1.6, "lin"), # NEW -- was pinned
95 "B_111": (0.2, 0.7, "lin"), # NEW -- was pinned
96}
97
98# Original prior bounds, for reporting how far a fitted value sits outside the
99# box the priors were elicited over. None = the range was not widened.
100ORIGINAL_PRIOR = {
101 "E_a0_conv": (1.6, 2.4),
102 "phi_max_junc": (0.1, 1.0),
103 "f_cl_i": (0.05, 0.25),
104 "dH2_abs_conv": (0.26, 0.45),
105 "E_b_i2": (0.6, 1.2),
106 "B_111": (0.3, 0.7),
107}
108
109
110def latin_hypercube(n: int, d: int, rng: random.Random) -> list[list[float]]:
111 """Stratified sample in [0,1)^d: one point per stratum per dimension.
112
113 Plain uniform sampling leaves gaps and clumps that matter at n = 64 over 7
114 dimensions; the LHS guarantees each parameter's marginal is covered evenly,
115 which is what makes a small design usable for estimation.
116 """
117 cols = []
118 for _ in range(d):
119 strata = [(i + rng.random()) / n for i in range(n)]
120 rng.shuffle(strata)
121 cols.append(strata)
122 return [[cols[j][i] for j in range(d)] for i in range(n)]
123
124
125def main(argv=None) -> int:
126 ap = argparse.ArgumentParser()
127 ap.add_argument("--n", type=int, default=64)
128 ap.add_argument("--condition", required=True,
129 help="comma-separated condition keys; each sampled theta is "
130 "emitted once PER CONDITION so the same parameter "
131 "vector is evaluated at every one. That pairing is the "
132 "point when the target is a crossover: character is only "
133 "constrained by 300 C AND 350 C together, never by one.")
134 ap.add_argument("--seed", type=int, default=20260813)
135 ap.add_argument("--base-design", type=Path, default=HERE / "design/T3_rev6.csv")
136 ap.add_argument("--base-row", default="38")
137 ap.add_argument("--base-condition", default="N2")
138 ap.add_argument("--out", type=Path, required=True)
139 a = ap.parse_args(argv)
140
141 base_rows = [r for r in csv.DictReader(a.base_design.open())
142 if r["row_id"] == a.base_row and r["condition"] == a.base_condition]
143 if not base_rows:
144 raise SystemExit(f"base row {a.base_row}/{a.base_condition} not found in "
145 f"{a.base_design}")
146 base = base_rows[0]
147 params = json.loads((HERE / "design/T3_rev6.meta.json").read_text())["parameters"]
148 missing = [k for k in SAMPLED if k not in params]
149 if missing:
150 raise SystemExit(f"sampled keys absent from the parameter list: {missing}")
151
152 rng = random.Random(a.seed)
153 keys = list(SAMPLED)
154 pts = latin_hypercube(a.n, len(keys), rng)
155
156 conditions = [c.strip() for c in a.condition.split(",") if c.strip()]
157 cols = ["row_id", "condition", "cond_row_id", "matrix", "base_idx",
158 "param_j", "theta_id"] + params
159 out = []
160 rid = 800
161 for i, pt in enumerate(pts):
162 theta = {}
163 for p in params:
164 theta[p] = base[p] # pinned unless sampled below
165 for u, k in zip(pt, keys):
166 lo, hi, scale = SAMPLED[k]
167 if scale == "log":
168 theta[k] = repr(10.0 ** (math.log10(lo) + u * (math.log10(hi)
169 - math.log10(lo))))
170 else:
171 theta[k] = repr(lo + u * (hi - lo))
172 # SAME theta at every condition, tagged by theta_id so the crossover can
173 # be evaluated per parameter vector rather than per row.
174 for cond in conditions:
175 row = {"row_id": rid, "condition": cond, "cond_row_id": rid,
176 "matrix": "L", "base_idx": i, "param_j": -1, "theta_id": i}
177 row.update(theta)
178 out.append(row)
179 rid += 1
180
181 a.out.parent.mkdir(parents=True, exist_ok=True)
182 with a.out.open("w", newline="") as f:
183 w = csv.DictWriter(f, fieldnames=cols)
184 w.writeheader()
185 w.writerows(out)
186
187 sha = hashlib.sha256(a.out.read_bytes()).hexdigest()
188 meta = {
189 "tier": "T8_calibration",
190 "kind": "latin_hypercube",
191 "n_theta": a.n,
192 "n_rows": len(out),
193 "seed": a.seed,
194 "conditions": conditions,
195 "sampled": {k: {"lo": v[0], "hi": v[1], "scale": v[2],
196 "original_prior": ORIGINAL_PRIOR.get(k),
197 "widened": k in ORIGINAL_PRIOR}
198 for k, v in SAMPLED.items()},
199 "base_row": f"{a.base_design.name}:{a.base_row}/{a.base_condition}",
200 "parameters": params,
201 "design_sha256": sha,
202 "note": ("Space-filling, NOT Saltelli: this design is for ESTIMATION "
203 "(match the measurement), not attribution (Sobol indices), so "
204 "it carries no A/B/AB_j pairing and merge_and_sobol must not be "
205 "pointed at it."),
206 }
207 a.out.with_suffix(".meta.json").write_text(json.dumps(meta, indent=1))
208 print(f"wrote {a.out} ({a.n} rows, {len(keys)} sampled, condition={a.condition})")
209 print(f" sha256 {sha[:16]}")
210 for k, (lo, hi, sc) in SAMPLED.items():
211 vals = [float(r[k]) for r in out]
212 print(f" {k:14s} [{lo:g}, {hi:g}] {sc:3s} -> "
213 f"min={min(vals):.4g} max={max(vals):.4g}")
214 return 0
215
216
217if __name__ == "__main__":
218 raise SystemExit(main())
list[list[float]] latin_hypercube(int n, int d, random.Random rng)