RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
make_M_report.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""make_M_report.py — campaign M: binning convergence against the exact arm.
3
4 python make_M_report.py # markdown to stdout
5 python make_M_report.py --out FILE
6
7Campaign M asks one question that Table 4 could not answer cleanly: at a FIXED
8domain and a FIXED discrete core (i_discrete = v_discrete = 50), how do the six
9observables converge as the bin count rises? Table 4 varied the discrete core
10and the bin count together, so its rungs moved along two axes at once. Here
11only the binning moves, and the reference is a fully discrete solve of the same
12domain -- so a deviation is closure error and nothing else.
13
14The reference is M1_D10000: I = V = 10000 discrete, one core, no wall-clock
15cap. Until it finishes, the rungs are still reported -- their mutual spread IS
16self-convergence evidence -- but the deviation columns are withheld rather than
17computed against the finest rung. Quoting convergence against the finest
18AVAILABLE approximation is how a ladder appears to converge to the wrong
19answer; plan S3.4.1 exists for the same reason.
20"""
21from __future__ import annotations
22
23import argparse
24import glob
25import json
26import sys
27import types
28from pathlib import Path
29
30HERE = Path(__file__).resolve().parent
31DT = HERE.parent
32MOD = DT.parent
33REPO = MOD.parent
34for p in (str(REPO), str(DT), str(HERE)):
35 if p not in sys.path:
36 sys.path.insert(0, p)
37
38import runs as manifest_mod # noqa: E402
39
40SYNC = HERE / ".sync" / "claims"
41SCORE_DOSE = "20" # every M row shares this grid (plan S3.2)
42
43# The six observables, in the order the paper reports them.
44OBS = [("N_loops_111", "N_111 (m^-3)", "{:.3e}"),
45 ("d_111_nm", "d_111 (nm)", "{:.3f}"),
46 ("N_loops_100", "N_100 (m^-3)", "{:.3e}"),
47 ("d_100_nm", "d_100 (nm)", "{:.3f}"),
48 ("N_voids", "N_cav (m^-3)", "{:.3e}"),
49 ("d_cavity_nm", "d_cav (nm)", "{:.3f}")]
50
51
52def board() -> dict:
53 out = {}
54 for f in sorted(SYNC.glob("*.json")):
55 try:
56 j = json.loads(f.read_text(encoding="utf-8"))
57 out[j.get("run_id", f.stem)] = j
58 except Exception:
59 pass
60 return out
61
62
63def cell(rec):
64 """The scored block at the comparison dose, or None if it may not be used."""
65 if not rec or rec.get("status") != "done":
66 return None
67 v = (rec.get("scored") or {}).get(SCORE_DOSE)
68 if not v or v.get("missing") or v.get("off_grid"):
69 return None
70 return v
71
72
73def ladder(B, md, tbl, prefix, bins, dom, refid):
74 """One binning ladder, with deviations only if its exact arm completed."""
75 ref_rec = B.get(refid) if refid else None
76 ref = cell(ref_rec)
77 md.append(f"## {tbl} — binning convergence at I = V = {dom}\n")
78 md.append("`i_discrete = v_discrete = 50` throughout; only the bin count "
79 "moves, so a deviation below is closure error and nothing else.\n")
80 if ref is None:
81 why = (f"`{refid}` did not complete" if refid else
82 "the discrete solve at this domain diverges")
83 md.append(f"> **No exact arm: {why}.** The rungs are listed so their "
84 "mutual spread can be read, but the deviation columns are "
85 "WITHHELD. Scoring a ladder against its own finest rung is "
86 "how one converges confidently to the wrong answer.\n")
87
88 md.append("| rung | I_bin=V_bin | r | N_eq | "
89 + " | ".join(o[1] for o in OBS) + " |")
90 md.append("|" + "---|" * (4 + len(OBS)))
91 if ref is not None:
92 md.append(f"| **{refid} (exact)** | — | — | {ref_rec.get('N_eq', '—')} | "
93 + " | ".join(f[2].format(ref[f[0]]) for f in OBS) + " |")
94 rows = []
95 for nb in bins:
96 rid = f"{prefix}{nb}"
97 rec = B.get(rid)
98 v = cell(rec)
99 if v is None:
100 r = rec or {}
101 got = r.get("dose_reached")
102 why = (f"reached {got:.4g} dpa" if isinstance(got, (int, float))
103 else r.get("status", "not run"))
104 md.append(f"| {rid} | {nb} | — | — | _{why}_ | "
105 + " | ".join(["—"] * (len(OBS) - 1)) + " |")
106 continue
107 md.append(f"| {rid} | {rec.get('bin_I_bin', nb)} "
108 f"| {rec.get('bin_r', float('nan')):.3f} "
109 f"| {rec.get('N_eq', '—')} | "
110 + " | ".join(f[2].format(v[f[0]]) for f in OBS) + " |")
111 rows.append((nb, rec, v))
112 md.append("")
113
114 if ref is not None and rows:
115 md.append(f"### {tbl}: deviation from the exact arm (%)\n")
116 md.append("| rung | N_eq | r | "
117 + " | ".join(o[1].split(" ")[0] for o in OBS) + " | max |")
118 md.append("|" + "---|" * (4 + len(OBS)))
119 for nb, rec, v in rows:
120 devs = [((v[k] - ref[k]) / ref[k] * 100.0) if ref[k] else float("nan")
121 for k, _, _ in OBS]
122 worst = max(abs(d) for d in devs)
123 md.append(f"| {prefix}{nb} | {rec.get('N_eq', '—')} "
124 f"| {rec.get('bin_r', float('nan')):.2f} | "
125 + " | ".join(f"{d:+.1f}" for d in devs)
126 + f" | **{worst:.1f}** |")
127 md.append("")
128
129
130def main(argv=None):
131 ap = argparse.ArgumentParser()
132 ap.add_argument("--out")
133 a = ap.parse_args(argv)
134 B = board()
135 ent = {e["run_id"]: e for e in manifest_mod.manifest()}
136
137 ref_id = f"M1_D{manifest_mod.M_REF_DOMAIN}"
138 ref_rec = B.get(ref_id)
139 ref = cell(ref_rec)
140
141 md = ["# Campaign M — system size and binning", ""]
142 md.append(f"Production mobility (`i_mobile = v_mobile = 5`), fission "
143 f"cascade, `LOOP_COAL` and `prec_bw` at their defaults in BOTH "
144 f"arms. All rows share one 45-point log grid to "
145 f"{SCORE_DOSE} dpa, so the comparison dose is the same point in "
146 f"every column (plan S3.2).\n")
147
148 # ── M1: the cost curve ──────────────────────────────────────────────────
149 md.append("## M1 — cost against domain (fully discrete)\n")
150 md.append("| rung | I = V | N_eq | dose reached | wall |")
151 md.append("|---|---|---|---|---|")
152 for rid, e in ent.items():
153 if e["table"] not in ("M1", "M1REF"):
154 continue
155 r = B.get(rid)
156 if not r:
157 md.append(f"| {rid} | {e['I']} | — | _not run_ | — |")
158 continue
159 st = r.get("status", "?")
160 w = r.get("wall_s")
161 md.append(f"| {rid} | {e['I']} | {r.get('N_eq', '—')} "
162 f"| {r.get('dose_reached', float('nan')):.4g} / {e['dose']:g} dpa "
163 f"| {(w/60 if w else float('nan')):.1f} min ({st}) |")
164 md.append("")
165
166 # ── The binning ladders ─────────────────────────────────────────────────
167 # Two of them: M2 at I = 10000 (no exact arm -- the discrete solve there
168 # diverges) and M2R at I = 1000 (M1_D1000 completes, so it can be scored).
169 for tbl, prefix, bins, dom, refid in (
170 ("M2R", "M2R_B", manifest_mod.M2R_BINS, manifest_mod.M2R_DOMAIN,
171 manifest_mod.M_REF_RUN),
172 ("M2", "M2_B", manifest_mod.M2_BINS, manifest_mod.M_BIN_DOMAIN,
173 None)):
174 ladder(B, md, tbl, prefix, bins, dom, refid)
175
176 md.append("## What must be stated\n")
177 md.append(f"1. **The domain is truncated.** V = {manifest_mod.M_BIN_DOMAIN} "
178 "sits below the mean cavity size at production dose, so d_cav "
179 "reads the ceiling at the coarse rungs rather than the model. "
180 "Both arms see the identical truncation, so it cancels out of the "
181 "closure error — but nothing here may be compared to experiment.")
182 md.append("2. **`delta_FP` runs ~0.05 throughout**, against the model's own "
183 "1e-2 gate. A separate, disclosed defect: closure error and "
184 "conservation error are different quantities. Do not conflate "
185 "them.")
186 md.append("3. **The reference is a single trajectory, not a converged "
187 "domain.** M1 shows how cost grows with I; it does not show that "
188 "I = V = 10000 is itself converged in domain. A rung agreeing "
189 "with it agrees with the exact solution ON THIS DOMAIN.\n")
190
191 out = "\n".join(md)
192 if a.out:
193 Path(a.out).write_text(out, encoding="utf-8")
194 print(f"wrote {a.out}")
195 else:
196 print(out)
197 return 0
198
199
200if __name__ == "__main__":
201 sys.exit(main())
ladder(B, md, tbl, prefix, bins, dom, refid)
main(argv=None)