RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
make_dose_figures.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Dose-evolution figures for a RadCluster_2_1 run, with EUROFER97 data overlaid.
3
4Regenerates `plots/density_vs_dose.png` and `plots/size_vs_dose.png` (plus the
5`_annotated` variants) for ANY run directory under `output/`.
6
7WHY THIS FILE EXISTS. The reference figures for run
8`20260904_212202_..._I80000V20000_im5vm5` were made by a script that was never
9saved next to them -- `output/` is gitignored, so the only surviving relative,
10`output/20260904_153005_f281fcf/make_figures.py`, is a DIFFERENT and earlier
11variant: it plots the campaign row's `at_dose` trajectory out of
12`digital_twin/results/B3_coal.jsonl`, not the run's own solution, and it draws
13the marker-on-curve "annotated" style with no experimental bands. The notebook
14never produced these figures at all. So the headline figures of the reference
15case could not be regenerated from anything in version control. This script
16closes that gap and is committed.
17
18The six series are derived from the run's own `plots/plot_data.pkl`, using the
19same expressions `digital_twin/run_ensemble.py` uses for the campaign
20observables, so a figure and a ledger row for the same vector agree by
21construction:
22
23 d_111 = 2*sqrt(mean_n_111 * Omega / (pi*b_111)) [loop, 1/2<111>]
24 d_100 = 2*sqrt(mean_n_100 * Omega / (pi*b_100)) [loop, <100>]
25 d_cav = 2*(3*mean_n_v*Omega/(4*pi))^(1/3) [spherical cavity]
26
27Verified against the reference run: reproduces its provenance addendum's
28"Model at key doses" table to every printed digit.
29
30ENCODING (revised 2026-09-06). Experimental points are coloured by *literature
31source*, not by defect population, and every source is named in the legend --
32replacing the earlier filled/open "experiment, 330 C" / "experiment, 300/350 C"
33pair, which spent two legend entries on temperature and none on provenance.
34Defect population is carried by marker SHAPE (o = 1/2<111>, s = <100>,
35^ = cavity), and the model legend entries repeat that shape, so a reader can
36still tell which curve each measurement belongs against. The irradiation
37temperature of an individual point is no longer encoded; every point in the
38window is 300-350 C and the model is at 330 C.
39
40Usage:
41 python codes/make_dose_figures.py <run_dir> [--annotated] [--in-place]
42
43 <run_dir> an output/ run directory (must contain plots/plot_data.pkl)
44 --annotated also write the marker/annotated *_annotated.png variants
45 --in-place write into <run_dir>/plots/ (default: a new timestamped dir,
46 so an existing reference run is never overwritten)
47"""
48import argparse, pickle, sys, datetime, subprocess, re
49from pathlib import Path
50
51import numpy as np
52import pandas as pd
53import matplotlib
54matplotlib.use('Agg')
55import matplotlib.pyplot as plt
56from matplotlib.patches import Rectangle
57from matplotlib.legend_handler import HandlerTuple
58from matplotlib.colors import to_rgba
59
60MOD = Path(__file__).resolve().parent.parent
61DB = MOD.parent / 'docs' / 'Database'
62
63# Categorical identity: one fixed hue per defect population, assigned in a fixed
64# order and never cycled. These are the three slots the reference figures used;
65# they are kept verbatim so regenerated figures are comparable to the originals.
66C111, C100, CCAV = '#2a78d6', '#eb6834', '#1baf7a'
67INK, INK2, GRID = '#0b0b0b', '#52514e', '#d8d7d2'
68
69# One fixed hue per literature source. Deliberately drawn from hues the three
70# population colours do NOT occupy (no medium blue, orange or green): a source
71# colour must never read as a population colour, since the two encodings share
72# the same plot. Order follows docs/Database/SOURCES.md.
73SOURCE_COLOR = {
74 'Dethloff 2016': '#111111',
75 'Dethloff 2018': '#8a1c1c',
76 'Klimenkov 2011': '#b8860b',
77 'Klimenkov 2020': '#7b2d8e',
78 'Weiß 2012': '#d81b60',
79 'Chauhan 2021': '#6b4423',
80 'Coppola 2019': '#4a6572',
81}
82
83LW = 4.5 # model curve weight
84MS = 260 # experimental marker area (pt^2); ~16 pt across
85
86# Axis labels and tick labels carry the figure at a glance, so they are the
87# largest text; the legend stays at 16 pt (the project floor) because it carries
88# up to ten entries -- three model series plus one per source.
89LABEL_PT, TICK_PT, LEGEND_PT = 28, 24, 16
90
91plt.rcParams.update({
92 'font.size': TICK_PT, 'axes.labelsize': LABEL_PT, 'axes.titlesize': LABEL_PT,
93 'xtick.labelsize': TICK_PT, 'ytick.labelsize': TICK_PT,
94 'legend.fontsize': LEGEND_PT,
95 'axes.edgecolor': INK2, 'axes.labelcolor': INK, 'text.color': INK,
96 'xtick.color': INK2, 'ytick.color': INK2, 'figure.facecolor': 'white',
97 'axes.facecolor': 'white', 'savefig.facecolor': 'white',
98 'axes.linewidth': 1.2, 'lines.linewidth': LW})
99
100XMIN = 1e-3 # both figures start here
101NMIN = 1e19 # density floor
102DMAX = 18.0 # size figure: drop measurements above this diameter (nm)
103
104
105def load_model(run_dir: Path):
106 """Six observable series + dose axis, from the run's own solution."""
107 pkl = run_dir / 'plots' / 'plot_data.pkl'
108 if not pkl.exists():
109 sys.exit(f'no plots/plot_data.pkl in {run_dir} -- re-run the notebook '
110 f'for this case, or point at a run directory that has one')
111 # The pickle holds live InputData / RateEquations objects, so the package
112 # must be importable before it can be loaded.
113 sys.path[:0] = [str(MOD.parent), str(MOD)]
114 d = pickle.load(open(pkl, 'rb'))
115 R, E = d['results'], d['input_data'].energetics
116 Om = float(R['Omega'])
117 b111, b100 = float(E['b_111']) * 1e-9, float(E['b_100']) * 1e-9
118 n111 = np.maximum(np.asarray(R['mean_n_111'], float), 0.0)
119 n100 = np.maximum(np.asarray(R['mean_n_100'], float), 0.0)
120 nv = np.maximum(np.asarray(R['mean_n_v'], float), 0.0)
121 return np.asarray(R['dose'], float), {
122 'N_loops_111': np.asarray(R['N_loops_111'], float),
123 'N_loops_100': np.asarray(R['N_loops_100'], float),
124 'N_voids': np.asarray(R['N_voids'], float),
125 'd_111_nm': 2 * np.sqrt(n111 * Om / (np.pi * b111)) * 1e9,
126 'd_100_nm': 2 * np.sqrt(n100 * Om / (np.pi * b100)) * 1e9,
127 'd_cavity_nm': 2 * (3 * nv * Om / (4 * np.pi)) ** (1 / 3) * 1e9,
128 }
129
130
131def short_source(title):
132 """'Dethloff et al. - 2016 - Microstructural ...' -> 'Dethloff 2016'."""
133 if not isinstance(title, str) or not title.strip():
134 return None
135 first = title.split(' - ')[0].replace(' et al.', '').strip()
136 year = re.search(r'\b(19|20)\d{2}\b', title)
137 return f'{first} {year.group(0)}' if year else first
138
139
140def attribute(df):
141 """Add a short `Source` per row of InterstitialLoop.xlsx.
142
143 The `Paper` column uses MERGED CELLS: one title heads a block of rows and
144 pandas leaves NaN on every row but the first, so attribution is a forward
145 fill. The fill is fenced by irradiation type: the sheet has one place --
146 Excel row 40 -- where a neutron row sits immediately below an ion block, and
147 an unfenced fill would hand it to Boulanger 2009 (an ion study). A finer
148 fence would be wrong: the Dethloff 2018 merge legitimately spans an HFR ->
149 BOR-60 facility change. Fencing leaves row 40 unattributed, exactly what
150 SOURCES.md caveat 1 records; it is then assigned to Chauhan 2021 by the
151 inference stated there (its 1/2<111> partner, same 6.2 nm, is Chauhan), and
152 the assumption is printed at run time. Fix the merge range to retire this.
153 """
154 src = df['Paper'].map(short_source)
155 fence = df['Type of Irradiation'].astype(str).str.strip().str.lower()
156 df = df.assign(Source=src.groupby((fence != fence.shift()).cumsum()).ffill())
157 orphan = df['Source'].isna()
158 if orphan.any():
159 print(f'note: {int(orphan.sum())} loop row(s) sit outside every merged Paper '
160 f'block (SOURCES.md caveat 1); assigned to Chauhan 2021 by inference')
161 df.loc[orphan, 'Source'] = 'Chauhan 2021'
162 return df
163
164
166 """EUROFER97, neutron, 300-350 C. Provenance: docs/Database/SOURCES.md."""
167 def euro(df):
168 m = (df['Material'].astype(str).str.contains('urofer97', na=False)
169 & df['Type of Irradiation'].astype(str).str.strip().str.lower().eq('neutron')
170 & df['Irradiation Temp [C]'].between(300, 350))
171 return df[m].copy()
172 L = euro(attribute(pd.read_excel(DB / 'InterstitialLoop.xlsx')))
173 V = euro(pd.read_excel(DB / 'Void.xlsx'))
174 V['Source'] = V['Paper'].map(short_source)
175 # A blank Loop Type is a TOTAL loop density; it belongs to neither
176 # character-resolved series and would double-count if left in.
177 lt = L['Loop Type'].astype(str)
178 return ((L[lt.str.contains('111', na=False)], C111, 'o',
179 'N_loops_111', 'd_111_nm', r'$\frac{1}{2}\langle111\rangle$ loops'),
180 (L[lt.str.contains('100', na=False)], C100, 's',
181 'N_loops_100', 'd_100_nm', r'$\langle100\rangle$ loops'),
182 (V, CCAV, '^', 'N_voids', 'd_cavity_nm', 'cavities'))
183
184
185def draw(D, M, SER, which, annotated):
186 ycol = 'N' if which == 'N' else 'd'
187 ylabel = (r'Number density (m$^{-3}$)' if ycol == 'N'
188 else 'Mean diameter (nm)')
189 datacol = 'Density [m^-3]' if ycol == 'N' else 'Diameter [nm]'
190 fig, ax = plt.subplots(figsize=(11, 8))
191 seen = [] # sources actually drawn in this figure
192 shapes = {} # source -> marker shapes it contributes
193
194 for df, c, mk, nk, dk, _ in SER:
195 key = nk if ycol == 'N' else dk
196 # Model: a smooth 2 px line. Markers only in the annotated variant,
197 # where they mark the actual computed doses.
198 if annotated:
199 ax.plot(D, M[key], '-', color=c, marker=mk, ms=7,
200 mec='white', mew=1.0, zorder=4)
201 else:
202 ax.plot(D, M[key], '-', color=c, zorder=4)
203
204 s_all = df.dropna(subset=['Dose [dpa]', datacol])
205 if ycol == 'd':
206 # Diameters above DMAX are a handful of 20-48 nm outliers (the
207 # 350 C Klimenkov rows). On the linear size axis they alone set the
208 # scale and press every model curve into the bottom sixth of the
209 # panel, so the figure is cut at DMAX.
210 s_all = s_all[s_all[datacol] <= DMAX]
211 # Experimental band: the rectangle spanning this population's measured
212 # dose range x its measured value range. Experimental coverage is
213 # narrow and sits at the right-hand edge, so the band is what stops a
214 # reader reading agreement into the unconstrained low-dose decades.
215 # Band and curve share one hue -- the band IS that population's data --
216 # so it carries a saturated edge in the curve colour rather than a wash
217 # that greys out where two bands overlap. The edge is DASHED: a solid
218 # one in the curve's own colour reads as a second model curve.
219 if not annotated and len(s_all) > 1:
220 x0, x1 = s_all['Dose [dpa]'].min(), s_all['Dose [dpa]'].max()
221 y0, y1 = s_all[datacol].min(), s_all[datacol].max()
222 if x1 > x0 and y1 > y0:
223 ax.add_patch(Rectangle((x0, y0), x1 - x0, y1 - y0,
224 facecolor=to_rgba(c, 0.10),
225 edgecolor=to_rgba(c, 0.8), lw=2.0,
226 ls=(0, (6, 4)), zorder=1))
227 # Colour = literature source; shape = defect population.
228 for source, sub in s_all.groupby('Source'):
229 ax.scatter(sub['Dose [dpa]'], sub[datacol], s=MS, marker=mk,
230 facecolors=SOURCE_COLOR.get(source, INK2),
231 edgecolors='white', linewidths=1.6, zorder=5)
232 if source not in seen:
233 seen.append(source)
234 shapes.setdefault(source, [])
235 if mk not in shapes[source]:
236 shapes[source].append(mk)
237
238 ax.set_xscale('log')
239 ax.set_xlabel('Dose (dpa)')
240 ax.set_ylabel(ylabel)
241 ax.set_xlim(left=XMIN)
242 if ycol == 'N':
243 ax.set_yscale('log')
244 # Density floor at 1e19 m^-3. The <100> population is still nucleating
245 # at the left edge and its density there runs orders below the lowest
246 # measurement; letting the axis follow it squashes the 1e20-1e22 region
247 # where the model meets the data into a sliver.
248 ax.set_ylim(bottom=NMIN)
249 else:
250 ax.set_ylim(0, DMAX)
251 ax.grid(True, which='major', color=GRID, lw=0.8, zorder=0)
252 ax.grid(True, which='minor', color=GRID, lw=0.4, alpha=0.55, zorder=0)
253 ax.set_axisbelow(True)
254 # Full box: all four spines, with ticks on all four sides.
255 for sp in ax.spines.values():
256 sp.set_visible(True)
257 ax.tick_params(which='both', top=True, right=True, direction='out')
258
259 # Two legends. The model series carry both their colour and their marker
260 # shape, so the shape key that identifies the experimental points is stated
261 # once and the source legend needs only colour. The source legend sits
262 # BELOW the axes: six or seven entries placed inside would land on the
263 # 10-32 dpa column where every measurement is, and the only clear interior
264 # region is too small to hold them without clipping a curve.
265 mh = [plt.Line2D([], [], color=c, lw=LW, marker=mk, ms=13, mec='white')
266 for _, c, mk, _, _, _ in SER]
267 ax.legend(mh, [s[5] for s in SER], frameon=not annotated,
268 loc='best', labelcolor=INK,
269 handletextpad=0.8, borderaxespad=0.8)
270 order = ([s for s in SOURCE_COLOR if s in seen]
271 + [s for s in seen if s not in SOURCE_COLOR])
272 # A source's swatch shows the actual marker shapes it contributes -- one
273 # marker if it measured a single population, three if it measured all of
274 # them -- so no shape appears in the legend that is not on the plot, and
275 # none on the plot that is missing from the legend.
276 sh = [tuple(plt.Line2D([], [], lw=0, marker=mk, ms=14, mec='white', mew=1.6,
277 mfc=SOURCE_COLOR.get(s, INK2))
278 for mk in shapes[s]) for s in order]
279 nmax = max(len(h) for h in sh)
280 if annotated:
281 ax.annotate('line + small markers = model (markers at computed doses);\n'
282 'large markers = experiment',
283 xy=(0.015, 0.985), xycoords='axes fraction', va='top',
284 fontsize=13, color=INK2)
285 fig.tight_layout()
286 # Placed after tight_layout, in figure coordinates below the axes; the
287 # savefig bbox_inches='tight' grows the canvas to take it in.
288 fig.legend(sh, order, ncol=4, loc='upper center',
289 bbox_to_anchor=(0.5, 0.04), frameon=False, labelcolor=INK,
290 handler_map={tuple: HandlerTuple(ndivide=None, pad=0.35)},
291 handlelength=1.0 * nmax, handletextpad=0.6, columnspacing=1.6)
292 return fig
293
294
295def main():
296 ap = argparse.ArgumentParser(description=__doc__,
297 formatter_class=argparse.RawDescriptionHelpFormatter)
298 ap.add_argument('run_dir', type=Path)
299 ap.add_argument('--annotated', action='store_true',
300 help='also write the marker/annotated *_annotated.png variants')
301 ap.add_argument('--in-place', action='store_true',
302 help='write into <run_dir>/plots/ instead of a new run dir')
303 a = ap.parse_args()
304
305 run = a.run_dir.resolve()
306 D, M = load_model(run)
307 SER = load_experiment()
308 n_pts = {s[5]: len(s[0].dropna(subset=['Dose [dpa]'])) for s in SER}
309 print(f'model: {len(D)} dose points, {D[D > 0].min():.2e} - {D.max():.4g} dpa')
310 print('experiment: ' + ', '.join(f'{v} {k}' for k, v in n_pts.items()))
311
312 if a.in_place:
313 out = run / 'plots'
314 else:
315 sha = subprocess.run(['git', '-C', str(MOD), 'rev-parse', '--short', 'HEAD'],
316 capture_output=True, text=True).stdout.strip()
317 out = MOD / 'output' / f"{datetime.datetime.now():%Y%m%d_%H%M%S}_{sha}_figs" / 'plots'
318 out.mkdir(parents=True, exist_ok=True)
319
320 jobs = [('density_vs_dose.png', 'N', False), ('size_vs_dose.png', 'd', False)]
321 if a.annotated:
322 jobs += [('density_vs_dose_annotated.png', 'N', True),
323 ('size_vs_dose_annotated.png', 'd', True)]
324 for name, which, ann in jobs:
325 fig = draw(D, M, SER, which, ann)
326 fig.savefig(out / name, dpi=200, bbox_inches='tight')
327 plt.close(fig)
328 print('wrote', out / name)
329
330
331if __name__ == '__main__':
332 main()
load_model(Path run_dir)
draw(D, M, SER, which, annotated)