RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
make_tables.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""make_tables - the analysis tables for a campaign, joined into one place.
3
4 python make_tables.py --design design/T2_design_v1.csv --results results/
5
6Writes a single named artifact set into report/, default stem
7`provisional_twin_map` (override with --name):
8
9 provisional_twin_map.md the report: what was run, model against
10 experiment, the sigma_model floor, and an
11 explicit statement of what is NOT included.
12
13 provisional_twin_map_runs.csv ONE ROW PER MODEL EVALUATION: identity,
14 every theta component, every observable,
15 the admissibility block, and provenance
16 hashes. The master table -- everything
17 else is a view of it. The .jsonl stores
18 only theta_hash, so this join against the
19 design file is what puts theta beside the
20 result it produced.
21
22 ..._runs_admissible.csv the same, usable rows only.
23
24 ..._observables.csv per observable per condition: model
25 p05/median/p95 over admissible rows
26 against the experimental band, with the
27 in-band fraction.
28
29 ..._targets.csv the experimental table, standalone.
30
31"PROVISIONAL" is meant literally: see the three exclusions below. This is a
32map of where the model sits relative to the data under the current prior box,
33not a calibrated twin.
34
35WHAT THESE TABLES DO NOT CONTAIN, AND WHY
36-----------------------------------------
371. RESULTS EXTRAPOLATED TO EXPERIMENTAL CONDITIONS. Deliberately absent. The
38 Tier-2 ensemble runs at d_LF = 0.1 dpa; the database is at 15-30 dpa. There
39 is no defensible closed-form extrapolation across that gap -- <100> loops in
40 this model have no size cap (loop->network is off and its P_ld is ~0 at these
41 loop sizes anyway), so a run that matches at 0.1 dpa keeps growing and any
42 naive scaling would be confidently wrong. Bridging LF to experimental
43 conditions is precisely the job of the multi-fidelity emulator (plan Tier 4,
44 surrogate.py + calibrate.py), which is not built. Emitting a column labelled
45 "extrapolated" before that exists would be inventing data.
46
472. POSTERIOR UNCERTAINTY. Also absent. Tier 2 produces SENSITIVITY (Sobol
48 S_i, S_i^T -- which parameters matter), not UNCERTAINTY (posterior spread on
49 theta given the data). What these tables can honestly show is the ENSEMBLE
50 SPREAD -- p05/median/p95 of each observable over the prior box -- which is a
51 prior-predictive spread, not a posterior. They are different quantities and
52 conflating them would overstate what the campaign knows.
53
543. sigma_model. The plan expects model error to dominate sigma_exp for f_100.
55 The convention spread IS computable here (f_100 is emitted at four TEM
56 cutoffs) and is reported as a floor on sigma_model, but the full model-error
57 term belongs to calibration.
58"""
59from __future__ import annotations
60
61import argparse
62import json
63from pathlib import Path
64
65import numpy as np
66
67HERE = Path(__file__).resolve().parent
68
69OBSERVABLES = ["N_loops_100", "d_100_nm", "d_100_content_nm", "mean_n_100",
70 "N_loops_111", "d_111_nm", "mean_n_111",
71 "f_100_number", "f_100_content",
72 "f_100_tem_0p8", "f_100_tem_1", "f_100_tem_1p25", "f_100_tem_1p5",
73 "N_100_visible", "N_111_visible", "N_voids", "mean_n_v",
74 "d_cavity_nm", "S_inventory"]
75ADMIN = ["admissible", "grid_limited", "starved", "dose_reached",
76 "occ_100", "occ_111", "pile_100", "pile_111",
77 "d_over_ceiling_100", "d_ceiling_100_nm", "delta_FP", "delta_He",
78 "solver_rc", "wall_s", "error"]
79PROV = ["machine_id", "git_sha", "solver_sha256", "workbook_sha256",
80 "design_sha256", "run_cfg_sha", "theta_hash"]
81TEM_COLS = ["f_100_tem_0p8", "f_100_tem_1", "f_100_tem_1p25", "f_100_tem_1p5"]
82
83
84def read_design(design: Path):
85 meta = json.loads(design.with_suffix(".meta.json").read_text(encoding="utf-8"))
86 lines = design.read_text(encoding="utf-8").strip().splitlines()
87 cols = lines[0].split(",")
88 rows = {}
89 for ln in lines[1:]:
90 v = dict(zip(cols, ln.split(",")))
91 rows[int(v["row_id"])] = v
92 return rows, meta, cols
93
94
95def load_results(results_dir: Path):
96 recs = {}
97 for f in sorted(Path(results_dir).glob("*.jsonl")):
98 for ln in f.read_text(encoding="utf-8").splitlines():
99 if not ln.strip():
100 continue
101 try:
102 r = json.loads(ln)
103 except json.JSONDecodeError:
104 continue
105 recs[r["row_id"]] = r
106 return recs
107
108
109def _s(v):
110 if v is None:
111 return ""
112 if isinstance(v, bool):
113 return "1" if v else "0"
114 if isinstance(v, float):
115 return "" if not np.isfinite(v) else f"{v:.10g}"
116 return str(v).replace(",", ";").replace("\n", " ")
117
118
119def main(argv=None):
120 ap = argparse.ArgumentParser(description=__doc__,
121 formatter_class=argparse.RawDescriptionHelpFormatter)
122 ap.add_argument("--design", type=Path, required=True)
123 ap.add_argument("--results", type=Path, default=HERE / "results")
124 ap.add_argument("--targets", type=Path, default=HERE / "targets.json")
125 ap.add_argument("--out", type=Path, default=HERE / "report")
126 ap.add_argument("--name", default="provisional_twin_map",
127 help="stem for the artifact set (default provisional_twin_map)")
128 a = ap.parse_args(argv)
129
130 design, meta, dcols = read_design(a.design)
131 recs = load_results(a.results, meta.get('design_sha256'))
132 targets = json.loads(a.targets.read_text(encoding="utf-8"))
133 theta_keys = meta["parameters"]
134 a.out.mkdir(parents=True, exist_ok=True)
135
136 # ── 1. master table: theta beside the result it produced ────────────────
137 head = (["row_id", "condition", "matrix", "base_idx", "param_j"]
138 + [f"theta.{k}" for k in theta_keys]
139 + [f"obs.{k}" for k in OBSERVABLES]
140 + [f"adm.{k}" for k in ADMIN]
141 + [f"prov.{k}" for k in PROV])
142 full, adm_rows = [], []
143 for rid in sorted(design):
144 d = design[rid]
145 r = recs.get(rid)
146 line = [rid, d["condition"], d["matrix"], d["base_idx"], d["param_j"]]
147 line += [d.get(k, "") for k in theta_keys]
148 line += [(r or {}).get(k) for k in OBSERVABLES]
149 line += [(r or {}).get(k) for k in ADMIN]
150 line += [(r or {}).get(k) for k in PROV]
151 full.append(line)
152 if r and not r.get("solver_rc") and r.get("admissible"):
153 adm_rows.append(line)
154
155 def write(path, rows):
156 with path.open("w", encoding="utf-8", newline="") as fh:
157 fh.write(",".join(head) + "\n")
158 for ln in rows:
159 fh.write(",".join(_s(v) for v in ln) + "\n")
160
161 stem = a.name
162 write(a.out / f"{stem}_runs.csv", full)
163 write(a.out / f"{stem}_runs_admissible.csv", adm_rows)
164
165 # ── 2. observables vs the experimental table, per condition ─────────────
166 obs_rows = []
167 conds = meta["conditions"]
168 for cond in conds + ["ALL"]:
169 sel = [r for rid, r in recs.items()
170 if not r.get("solver_rc") and r.get("admissible")
171 and (cond == "ALL" or design[rid]["condition"] == cond)]
172 for key, t in targets.get("observables", {}).items():
173 vals = np.array([r[key] for r in sel
174 if r.get(key) is not None
175 and np.isfinite(r.get(key, np.nan))], dtype=float)
176 lo, hi, tgt = t.get("lo"), t.get("hi"), t.get("target")
177 row = {"condition": cond, "observable": key, "units": t.get("units", ""),
178 "n_admissible": int(vals.size),
179 "model_p05": None, "model_median": None, "model_p95": None,
180 "exp_target": tgt, "exp_lo": lo, "exp_hi": hi,
181 "frac_in_band": None, "median_over_target": None}
182 if vals.size:
183 row["model_p05"] = float(np.percentile(vals, 5))
184 row["model_median"] = float(np.median(vals))
185 row["model_p95"] = float(np.percentile(vals, 95))
186 if lo is not None and hi is not None:
187 row["frac_in_band"] = float(np.mean((vals >= lo) & (vals <= hi)))
188 if tgt:
189 row["median_over_target"] = float(np.median(vals) / tgt)
190 obs_rows.append(row)
191 ohead = list(obs_rows[0].keys()) if obs_rows else []
192 with (a.out / f"{stem}_observables.csv").open("w", encoding="utf-8",
193 newline="") as fh:
194 fh.write(",".join(ohead) + "\n")
195 for r in obs_rows:
196 fh.write(",".join(_s(r[k]) for k in ohead) + "\n")
197
198 # ── 3. the experimental table, standalone ───────────────────────────────
199 with (a.out / f"{stem}_targets.csv").open("w", encoding="utf-8",
200 newline="") as fh:
201 fh.write("observable,target,lo,hi,units,note\n")
202 for k, t in targets.get("observables", {}).items():
203 fh.write(",".join([k, _s(t.get("target")), _s(t.get("lo")),
204 _s(t.get("hi")), _s(t.get("units")),
205 _s(t.get("note", ""))]) + "\n")
206
207 # ── 4. sigma_model floor from the TEM-convention spread ─────────────────
208 adm = [r for r in recs.values()
209 if not r.get("solver_rc") and r.get("admissible")]
210 spread = None
211 if adm:
212 per_run = []
213 for r in adm:
214 v = [r[c] for c in TEM_COLS
215 if r.get(c) is not None and np.isfinite(r.get(c, np.nan))]
216 if len(v) >= 2:
217 per_run.append(max(v) - min(v))
218 if per_run:
219 spread = {"n": len(per_run), "median": float(np.median(per_run)),
220 "p95": float(np.percentile(per_run, 95))}
221
222 # ── 5. human-readable summary ───────────────────────────────────────────
223 n_tot = len(design)
224 n_done = len(recs)
225 md = [f"# Provisional twin map — {a.design.name}", "",
226 "*Provisional*: this maps where the model sits relative to the data "
227 "under the current prior box. It is not a calibrated twin — see "
228 "\"What is deliberately NOT here\" at the end.", "",
229 f"- design `{meta.get('design_sha256','')[:16]}`, tier {meta.get('tier')}, "
230 f"N={meta.get('N')}, p={meta.get('p')}, conditions {', '.join(conds)}",
231 f"- rows {n_done}/{n_tot} evaluated, {len(adm)} admissible", ""]
232 if meta.get("revision_pending"):
233 md += [f"> **Design carries REVISION_PENDING parameters:** "
234 f"{', '.join(meta['revision_pending'])}. Their ranges predate the "
235 f"2026-08-01 absorption-gate work.", ""]
236 md += ["## Files", "",
237 "| file | contents |",
238 "|---|---|",
239 f"| `{stem}_runs.csv` | one row per evaluation: θ, observables, admissibility, provenance |",
240 f"| `{stem}_runs_admissible.csv` | the same, usable rows only |",
241 f"| `{stem}_observables.csv` | model p05/median/p95 vs the experimental band, per condition |",
242 f"| `{stem}_targets.csv` | the experimental table |",
243 "| `T2_sobol_indices.csv` | S_i, S_i^T (from `merge_and_sobol.py`) |", ""]
244 md += ["## Model vs experiment (all conditions, admissible rows)", "",
245 "| observable | n | p05 | median | p95 | target | in band |",
246 "|---|---:|---:|---:|---:|---:|---:|"]
247 for r in [x for x in obs_rows if x["condition"] == "ALL"]:
248 if not r["n_admissible"]:
249 continue
250 fb = "n/a" if r["frac_in_band"] is None else f"{100*r['frac_in_band']:.0f}%"
251 tg = "n/a" if r["exp_target"] is None else f"{r['exp_target']:.3g}"
252 md.append(f"| `{r['observable']}` | {r['n_admissible']} | "
253 f"{r['model_p05']:.3g} | {r['model_median']:.3g} | "
254 f"{r['model_p95']:.3g} | {tg} | {fb} |")
255 md += ["", "p05/median/p95 are the **prior-predictive** spread of the "
256 "ensemble over the prior box. They are *not* a posterior — see below.", ""]
257 if spread:
258 md += ["## σ_model floor from the TEM-cutoff convention", "",
259 f"Per-run spread of `f_100_tem` across d_min ∈ {{0.8, 1.0, 1.25, "
260 f"1.5}} nm, over {spread['n']} admissible runs:", "",
261 f"- median **{spread['median']:.3f}**, p95 **{spread['p95']:.3f}**",
262 "", "This is a *floor* on σ_model for the loop-fraction "
263 "observable: it is the variation produced by the reporting "
264 "convention alone, with θ held fixed. If a calibration returns a "
265 "posterior on the conversion parameters tighter than this, σ_model "
266 "was set too small.", ""]
267 md += ["## What is deliberately NOT here", "",
268 "**Results extrapolated to experimental conditions.** The Tier-2 "
269 "ensemble runs at 0.1 dpa; the database is at 15–30 dpa. There is no "
270 "defensible closed-form extrapolation across that gap — ⟨100⟩ loops "
271 "have no size cap in the present model, so a run matching at 0.1 dpa "
272 "keeps growing and any naive scaling would be confidently wrong. "
273 "Bridging LF to experimental conditions is the multi-fidelity "
274 "emulator's job (plan Tier 4, `surrogate.py` + `calibrate.py`), which "
275 "is not built. A column labelled *extrapolated* before that exists "
276 "would be invented data.", "",
277 "**Posterior uncertainty.** Tier 2 produces *sensitivity* (which "
278 "parameters matter), not *uncertainty* (posterior spread on θ given "
279 "the data). The spread reported above is prior-predictive. Posterior "
280 "UQ requires Tier 4.", ""]
281 (a.out / f"{stem}.md").write_text("\n".join(md), encoding="utf-8")
282
283 print(f" wrote {a.out}/")
284 for f in (f"{stem}.md", f"{stem}_runs.csv", f"{stem}_runs_admissible.csv",
285 f"{stem}_observables.csv", f"{stem}_targets.csv"):
286 p = a.out / f
287 print(f" {f:32s} {p.stat().st_size:>9,d} bytes")
288 print(f"\n {n_done}/{n_tot} rows evaluated, {len(adm)} admissible")
289 print(" NOTE: no extrapolation to experimental conditions and no posterior")
290 print(f" UQ - both require Tier 4. See {stem}.md.")
291 return 0
292
293
294if __name__ == "__main__":
295 raise SystemExit(main())
read_design(Path design)
main(argv=None)
load_results(Path results_dir)