RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
make_size_effect_figures.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3make_size_effect_figures.py — dose dependence of each observable across the
4Table 4 system-size ladder, with the EUROFER97 measurements overlaid.
5
6One figure per observable, curves ordered D, C4, C3, C2, C1, C0 (increasing
7number of equations after the exact arm). d_111 is excluded by request.
8C4/P=1 is excluded: it is a closure-ORDER column, not a system-size rung, and at
9+345% it would set the scale of every panel.
10
11DOSE COVERAGE. These runs stop at 2 dpa; the EUROFER97 neutron data sit at
1214.6-32 dpa (loops) and 2.7-32 dpa (cavities). The overlay therefore shows
13almost no overlap, and that is the honest picture rather than a defect of the
14figure: Table 4 is a VERIFICATION at low dose, and nothing in it may be read as
15agreement with experiment. The band is drawn where the data actually are so a
16reader cannot mistake the unconstrained region for a validated one.
17
18Output: docs/Formulation/verification_campaign/figs/size_effect_<observable>.pdf
19"""
20from __future__ import annotations
21
22import glob
23import sys
24from pathlib import Path
25
26import numpy as np
27import pandas as pd
28import matplotlib
29matplotlib.use("Agg")
30import matplotlib.pyplot as plt
31from matplotlib.patches import Rectangle
32from matplotlib.colors import to_rgba
33
34MOD = Path(__file__).resolve().parent.parent
35REPO = MOD.parent
36sys.path.insert(0, str(REPO))
37DB = REPO / "docs" / "Database"
38OUT = REPO / "docs" / "Formulation" / "verification_campaign" / "figs"
39
40LABEL_PT, TICK_PT, LEGEND_PT = 22, 20, 15
41plt.rcParams.update({
42 "axes.labelsize": LABEL_PT, "axes.titlesize": LABEL_PT,
43 "xtick.labelsize": TICK_PT, "ytick.labelsize": TICK_PT,
44 "legend.fontsize": LEGEND_PT, "lines.linewidth": 2.6,
45})
46
47# The ladder, in the order requested: exact first, then coarsest -> finest.
48#
49# The counts are the TOTAL integrated system -- main block plus the appended
50# <100> loop block, which cpp_bridge splits off after the solve so the solver
51# only ever reports the main one. Labelling D with the total (12006) while the
52# C rungs carried their main block (830, ...) made the same run read as 8006 in
53# Table 4 and 12006 in the legend. One quantity everywhere; Table 4 now prints
54# both columns.
55LADDER = [
56 ("D (exact, 12006 eq) - to 2 dpa", "20260907_093333_T4_D_4k_*", "#111111", "-"),
57 # C2-C4 are the 40 dpa reruns: at 2 dpa no model curve reached the dose
58 # where the EUROFER97 data sit (14.6-32 dpa for loops), so the overlay had
59 # no shared x-range at all. Same grids as their 2 dpa twins.
60 ("C4 1/800 (129 eq)", "*_T4_C4_40_*", "#d62728", "-"),
61 ("C3 1/160 (161 eq)", "*_T4_C3_40_*", "#ff7f0e", "-"),
62 ("C2 1/40 (364 eq)", "*_T4_C2_40_*", "#2ca02c", "-"),
63 ("C1 1/10 (1242 eq) - to 2 dpa", "*_T4_C1_nc_*", "#1f77b4", "-"),
64 ("C0 1/2 (4826 eq)", "*_T4_C0_nc_*", "#9467bd", "-"),
65]
66
67# observable key -> (axis label, log?, experiment source, experiment column)
68OBSERVABLES = [
69 ("N_loops_111", r"$\frac{1}{2}\langle111\rangle$ density (m$^{-3}$)", True, "loop111", "Density [m^-3]"),
70 ("N_loops_100", r"$\langle100\rangle$ density (m$^{-3}$)", True, "loop100", "Density [m^-3]"),
71 ("d_100_nm", r"$\langle100\rangle$ mean diameter (nm)", False, "loop100", "Diameter [nm]"),
72 ("N_voids", r"Cavity density (m$^{-3}$)", True, "cavity", "Density [m^-3]"),
73 ("d_cavity_nm", r"Cavity mean diameter (nm)", False, "cavity", "Diameter [nm]"),
74]
75EXP_COLOR = {"loop111": "#1f77b4", "loop100": "#7d3c98", "cavity": "#e8663c"}
76EXP_MARK = {"loop111": "o", "loop100": "s", "cavity": "^"}
77
78# Lower bound of the ordinate, per observable. On the log panels the solution
79# rises out of the C_floor initial condition through ten empty decades that
80# carry no information and compress every curve into the top of the frame;
81# these floors put the axis where the populations actually are.
82YMIN = {
83 "N_loops_111": 1e19,
84 "N_loops_100": 1e12,
85 "N_voids": 1e19,
86}
87
88# One EUROFER97 <100> measurement reports a mean loop diameter of 48 nm at
89# 17.4 dpa, 350 C, against 2.8-8.0 nm for the other nine rows of the same
90# population. Carried into the diameter panel it sets the ordinate on its own
91# and compresses the model curves and the remaining data into the bottom tenth
92# of the frame, so it is excluded from the diameter comparison. Its density is
93# unremarkable (3.7e21 m^-3, interior to the other rows) and is retained, so
94# the exclusion is by quantity rather than a discarded measurement.
95EXP_EXCLUDE = {
96 "d_100_nm": lambda df: df["Diameter [nm]"] < 40.0,
97}
98
99# --- provenance ---------------------------------------------------------
100# The "Paper" column of both workbooks is merged-cell style: the citation is
101# written once at the head of a block and left blank on the rows beneath it, so
102# it has to be forward-filled before a row can be attributed.
103#
104# Forward-filling the loop sheet puts one NEUTRON row (BOR-60, 15 dpa, <100>,
105# 6.2 nm) under Boulanger & Serruys, which is an ION-irradiation study and
106# therefore cannot be its source. That row pairs with the 1/2<111> row directly
107# below it -- same 6.2 nm, same facility, same dose -- which carries the Chauhan
108# label, so the block boundary is off by one and the row belongs to Chauhan.
109# Corrected explicitly rather than silently: an ion paper cited as the source of
110# a neutron measurement would be a real error in the report.
111PAPER_TO_KEY = {
112 "Dethloff et al. - 2016": "dethloff2016",
113 "Dethloff et al. - 2018": "dethloff2018",
114 "Klimenkov et al. - 2011": "klimenkov2011",
115 "Klimenkov et al. - 2020": "klimenkov2020",
116 "Weiß et al. - 2012": "weiss2012",
117 "Chauhan et al. - 2021": "chauhan2021",
118 "Coppola et al. - 2019": "coppola2019",
119 "Boulanger and Serruys - 2009": "chauhan2021", # see note above
120}
121# Order in which keys are listed in a citation, so captions read consistently.
122KEY_ORDER = ["dethloff2016", "dethloff2018", "klimenkov2011", "klimenkov2020",
123 "weiss2012", "chauhan2021", "coppola2019"]
124SOURCES_TEX = OUT / "sources.tex"
125
126# Plotted dose range. The solution is featureless below 1e-6 dpa (clusters are
127# still nucleating out of the C_floor initial condition); the upper edge keeps
128# the measured band in view.
129XLIM = (1e-6, 60.0)
130
131# Per-observable overrides. The <100> diameter spends its first three decades
132# in the nucleation transient, which on a linear ordinate costs most of the
133# frame and says nothing about the closure; the panel starts where the
134# populations are established instead.
135XLIM_OVERRIDE = {
136 "d_100_nm": (1e-3, 60.0),
137}
138
139
140def euro(df):
141 m = (df["Material"].astype(str).str.contains("urofer97", na=False)
142 & df["Type of Irradiation"].astype(str).str.strip().str.lower().eq("neutron")
143 & df["Irradiation Temp [C]"].between(300, 350))
144 return df[m].copy()
145
146
147def _read(name):
148 df = pd.read_excel(DB / name)
149 df["Paper"] = df["Paper"].ffill()
150 return euro(df)
151
152
154 L = _read("InterstitialLoop.xlsx")
155 V = _read("Void.xlsx")
156 lt = L["Loop Type"].astype(str)
157 return {"loop111": L[lt.str.contains("111", na=False)],
158 "loop100": L[lt.str.contains("100", na=False)],
159 "cavity": V}
160
161
163 """Add d_100_nm / d_cavity_nm as TIME SERIES.
164
165 They are not in `results`: post_process stores mean_n_100 and mean_n_v, and
166 the diameters are formed only inside the at_dose ladder. Plotting the raw
167 key therefore silently produced panels with the experimental band and no
168 model curves at all -- caught by looking at the figure, not by the run
169 succeeding. Same expressions the ladder uses, so figure and table agree.
170 """
171 r = d["results"]
172 idata = d["input_data"]
173 Om = float(r.get("Omega", idata.derived["Omega"]))
174 b111 = float(r.get("b_111", idata.derived["b_111"]))
175 b100 = float(r.get("b_100", idata.energetics.get("b_100", 0.2867) * 1e-9))
176 out = dict(r)
177 n100 = np.maximum(np.asarray(r.get("mean_n_100", []), float), 0.0)
178 nv = np.maximum(np.asarray(r.get("mean_n_v", []), float), 0.0)
179 n111 = np.maximum(np.asarray(r.get("mean_n_111", []), float), 0.0)
180 out["d_100_nm"] = 2 * np.sqrt(n100 * Om / (np.pi * b100)) * 1e9
181 out["d_111_nm"] = 2 * np.sqrt(n111 * Om / (np.pi * b111)) * 1e9
182 out["d_cavity_nm"] = 2 * (3 * nv * Om / (4 * np.pi)) ** (1.0 / 3.0) * 1e9
183 return out
184
185
187 from radcluster_code.py_utils import visualization as viz
188 import run_ensemble as re_mod # noqa: F401 (import parity)
189 out = []
190 for label, pat, colour, ls in LADDER:
191 f = sorted(glob.glob(str(MOD / "output" / pat / "plots" / "plot_data.pkl")))
192 if not f:
193 print(f" MISSING trajectory for {label} ({pat})")
194 continue
195 d = viz.load_plot_data(f[-1])
196 r = with_diameters(d)
197 out.append((label, colour, ls, np.asarray(r["dose"], float), r))
198 return out
199
200
201def _linear_extent(key, traj, df, expcol):
202 """Range of the model curves and measurements *inside the plotted window*.
203
204 Restricted to the panel's own x-limits: the transient below the left edge
205 carries diameters well above anything on screen, and including it set the
206 ordinate from data the reader never sees.
207 """
208 lo_x, hi_x = XLIM_OVERRIDE.get(key, XLIM)
209 vals = []
210 for _, _, _, dose, r in traj:
211 y = np.asarray(r.get(key, []), float)
212 if y.size == dose.size:
213 y = y[(dose >= lo_x) & (dose <= hi_x) & np.isfinite(y)]
214 if y.size:
215 vals.append((y.min(), y.max()))
216 if len(df):
217 vals.append((float(df[expcol].min()), float(df[expcol].max())))
218 if not vals:
219 return None, None
220 return min(v[0] for v in vals), max(v[1] for v in vals)
221
222
223def one_figure(key, ylabel, logy, expkey, expcol, traj, EXP):
224 fig, ax = plt.subplots(figsize=(11, 7.5))
225 for label, colour, ls, dose, r in traj:
226 y = np.asarray(r.get(key, []), float)
227 if y.size != dose.size:
228 continue
229 m = dose > 0
230 lw = 3.4 if label.startswith("D ") else 2.4
231 z = 5 if label.startswith("D ") else 3
232 ax.plot(dose[m], y[m], ls, color=colour, lw=lw, label=label, zorder=z)
233
234 df = EXP[expkey].dropna(subset=["Dose [dpa]", expcol])
235 keep = EXP_EXCLUDE.get(key)
236 if keep is not None and len(df):
237 df = df[keep(df)]
238 if len(df):
239 c = EXP_COLOR[expkey]
240 x0, x1 = df["Dose [dpa]"].min(), df["Dose [dpa]"].max()
241 y0, y1 = df[expcol].min(), df[expcol].max()
242 if x1 > x0 and y1 > y0:
243 ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0,
244 facecolor=to_rgba(c, 0.12),
245 edgecolor=to_rgba(c, 0.85), lw=2.0,
246 ls=(0, (6, 4)), zorder=1,
247 label="EUROFER97 measured range"))
248 ax.scatter(df["Dose [dpa]"], df[expcol], s=150, marker=EXP_MARK[expkey],
249 facecolors=c, edgecolors="white", linewidths=1.6, zorder=6,
250 label="EUROFER97 neutron, 300-350 $^\\circ$C")
251
252 ax.set_xscale("log")
253 ax.set_xlim(*XLIM_OVERRIDE.get(key, XLIM))
254 if logy:
255 ax.set_yscale("log")
256 if key in YMIN:
257 ax.set_ylim(bottom=YMIN[key])
258 else:
259 # Linear panels: frame the model curves and the retained measurements
260 # together, with a 5 percent margin, rather than letting matplotlib
261 # pad around whatever the widest single series happens to be.
262 lo, hi = _linear_extent(key, traj, df, expcol)
263 if lo is not None:
264 pad = 0.05 * (hi - lo) if hi > lo else 0.05 * max(hi, 1.0)
265 ax.set_ylim(max(0.0, lo - pad), hi + pad)
266 ax.set_xlabel("Dose (dpa)")
267 ax.set_ylabel(ylabel)
268 ax.grid(True, which="both", alpha=0.25)
269 ax.legend(loc="best", frameon=True, framealpha=0.92)
270 fig.tight_layout()
271 OUT.mkdir(parents=True, exist_ok=True)
272 p = OUT / f"size_effect_{key}.pdf"
273 fig.savefig(p, bbox_inches="tight")
274 plt.close(fig)
275 return p
276
277
278def _keys(df):
279 """Citation keys for the rows actually plotted, in KEY_ORDER."""
280 seen = set()
281 for paper in df["Paper"].dropna().astype(str):
282 head = " - ".join(paper.split(" - ")[:2])
283 key = PAPER_TO_KEY.get(head)
284 if key is None:
285 print(f" UNMAPPED source: {head}")
286 else:
287 seen.add(key)
288 return [k for k in KEY_ORDER if k in seen]
289
290
291# LaTeX control sequences may contain letters only, so the digits in the
292# observable keys are spelled out. Left as digits, \newcommand{\srcNLoops111}
293# defines \srcNLoops, drops "111" into the preamble, and then typesets the
294# \cite -- which is how this first went wrong.
295_DIGIT = {"0": "Zero", "1": "One", "2": "Two", "3": "Three", "4": "Four",
296 "5": "Five", "6": "Six", "7": "Seven", "8": "Eight", "9": "Nine"}
297
298
299def _macro_name(key):
300 camel = "".join(w.capitalize() for w in key.split("_"))
301 return "".join(_DIGIT.get(c, c) for c in camel)
302
303
304def write_sources(panel_keys):
305 """Emit \\srcNloops111 etc. so the captions cite what was actually drawn.
306
307 Hand-copied citations drift the moment a row is added to the database; this
308 keeps the caption and the plotted points derived from one source of truth.
309 """
310 lines = ["% Generated by codes/make_size_effect_figures.py -- do not edit.",
311 "% One macro per panel, listing the sources of the points drawn on it."]
312 for key, keys in panel_keys.items():
313 macro = "src" + _macro_name(key)
314 cites = ",".join(keys) if keys else ""
315 lines.append(f"\\newcommand{{\\{macro}}}{{\\cite{{{cites}}}}}")
316 SOURCES_TEX.write_text("\n".join(lines) + "\n", encoding="utf-8")
317 return SOURCES_TEX
318
319
320def main():
321 EXP = experiment()
322 traj = trajectories()
323 print(f"{len(traj)} trajectories loaded")
324 for label, _, _, dose, _ in traj:
325 print(f" {label:26s} {len(dose):3d} points, to {dose.max():.3g} dpa")
326 panel_keys = {}
327 for key, ylabel, logy, ek, ec, in [(o[0], o[1], o[2], o[3], o[4]) for o in OBSERVABLES]:
328 p = one_figure(key, ylabel, logy, ek, ec, traj, EXP)
329 df = EXP[ek].dropna(subset=["Dose [dpa]", ec])
330 keep = EXP_EXCLUDE.get(key)
331 if keep is not None and len(df):
332 df = df[keep(df)]
333 panel_keys[key] = _keys(df)
334 print(f" wrote {p.relative_to(REPO)} sources: {', '.join(panel_keys[key])}")
335 sp = write_sources(panel_keys)
336 print(f" wrote {sp.relative_to(REPO)}")
337 return 0
338
339
340if __name__ == "__main__":
341 sys.exit(main())
one_figure(key, ylabel, logy, expkey, expcol, traj, EXP)
_linear_extent(key, traj, df, expcol)