RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
visualization.py
Go to the documentation of this file.
1"""
2visualization.py — Plotting routines for RadCluster_2_1.
3
4Generates standardized figures from the ODE post-processing results.
5All concentration quantities are in m^-3 (converted in post_process.py).
6"""
7
8import numpy as np
9
10try:
11 import matplotlib.pyplot as plt
12 _HAS_MPL = True
13except ImportError:
14 _HAS_MPL = False
15
16_CONC_LABEL = r'Concentration (m$^{-3}$)'
17
18_PLOT_FONTSIZE = 16 # legend text; deliberately left where it was
19_LABEL_FONTSIZE = 22 # axis labels and titles
20_TICK_FONTSIZE = 20 # tick labels
21_INTERIOR_LEGEND_FONTSIZE = 12
22# Suite-wide line weights. _LW is the default for every curve; _LW_THIN is for
23# the few reference/overlay curves that must sit visibly behind the data.
24_LW, _LW_THIN = 3.0, 2.0
25if _HAS_MPL:
26 plt.rcParams.update({
27 'lines.linewidth': _LW,
28 'axes.titlesize': _LABEL_FONTSIZE,
29 'axes.labelsize': _LABEL_FONTSIZE,
30 'xtick.labelsize': _TICK_FONTSIZE,
31 'ytick.labelsize': _TICK_FONTSIZE,
32 'legend.fontsize': _PLOT_FONTSIZE,
33 'legend.title_fontsize': _PLOT_FONTSIZE,
34 'figure.titlesize': _LABEL_FONTSIZE,
35 })
36
37
38# ── User-configurable axis controls ──────────────────────────────────────────
39# Plots are split into three groups so a small set of knobs covers the whole
40# suite. Each group entry accepts:
41# xlim / ylim : (min, max) tuple — None for auto on either bound.
42# xscale / yscale : 'log' | 'linear' | None (None = keep the plot's default).
43# A global `legend_fontsize` controls the size of the figure-level legends
44# placed underneath cluster-concentration plots.
45#
46# Override from the notebook with:
47# viz.set_plot_config({'concentration': {'ylim': (1e16, 1e22)},
48# 'legend_fontsize': 5})
49_PLOT_CONFIG = {
50 # Concentration vs dose: point defects, totals, He content, number
51 # densities (incl. TEM), SIA/cavity cluster concentrations.
52 'concentration': {'xlim': (None, None), 'ylim': (None, None),
53 'xscale': None, 'yscale': None},
54 # Scalar metrics vs dose: swelling, mean sizes, fraction balances,
55 # conservation diagnostics.
56 'scalar': {'xlim': (None, None), 'ylim': (None, None),
57 'xscale': None, 'yscale': None},
58 # Size-distribution snapshots: concentration vs cluster size n/m or
59 # vs loop/void diameter (nm).
60 'size_dist': {'xlim': (None, None), 'ylim': (None, None),
61 'xscale': None, 'yscale': None},
62 'legend_fontsize': _PLOT_FONTSIZE,
63 # Rendered titles are suppressed suite-wide: these figures are placed in
64 # documents and notebooks that caption them, and a baked-in title then
65 # duplicates the caption. The `title=` kwargs are still plumbed through, so
66 # turning this back on -- set_plot_config({'show_titles': True}) -- restores
67 # every title, including the per-panel labels on the multi-panel figures.
68 'show_titles': False,
69}
70
71
73 """Update the module-level plot config; partial overrides are merged."""
74 for k, v in cfg.items():
75 cur = _PLOT_CONFIG.get(k)
76 if isinstance(cur, dict) and isinstance(v, dict):
77 cur.update(v)
78 else:
79 _PLOT_CONFIG[k] = v
80
81
82def _set_title(ax, text, **kw):
83 """Axes (or panel) title, drawn only when `show_titles` is on."""
84 if _PLOT_CONFIG.get('show_titles'):
85 ax.set_title(text, **kw)
86
87
88def _apply_axis_config(ax, group):
89 """Apply the named group's scale and limits to a single axis."""
90 cfg = _PLOT_CONFIG.get(group)
91 if not cfg:
92 return
93 xs, ys = cfg.get('xscale'), cfg.get('yscale')
94 if xs:
95 ax.set_xscale(xs)
96 if ys:
97 ax.set_yscale(ys)
98 xlim = cfg.get('xlim') or (None, None)
99 ylim = cfg.get('ylim') or (None, None)
100 if xlim[0] is not None or xlim[1] is not None:
101 ax.set_xlim(left=xlim[0], right=xlim[1])
102 if ylim[0] is not None or ylim[1] is not None:
103 ax.set_ylim(bottom=ylim[0], top=ylim[1])
104
105
107 for ax in fig.axes:
108 _apply_axis_config(ax, group)
109
110
112 return _PLOT_CONFIG.get('legend_fontsize', 5)
113
114
115def _legend_below(fig, axes, ncol=8, fontsize=None, bottom=0.28):
116 """
117 Place a single shared legend underneath the plot area.
118
119 Removes any existing per-axes legends, then adds a figure-level legend
120 using the handles from the first axis (cluster-concentration plots
121 duplicate the same labels across panels).
122 """
123 if not _HAS_MPL:
124 return
125 if fontsize is None:
126 fontsize = _legend_fs()
127 if not isinstance(axes, (list, tuple, np.ndarray)):
128 axes = [axes]
129 for ax in axes:
130 leg = ax.get_legend()
131 if leg is not None:
132 leg.remove()
133 handles, labels = axes[0].get_legend_handles_labels()
134 if not handles:
135 return
136 fig.legend(handles, labels, loc='lower center',
137 bbox_to_anchor=(0.5, 0.0),
138 ncol=ncol, fontsize=fontsize, frameon=True)
139 fig.subplots_adjust(bottom=bottom)
140
141
143 if not _HAS_MPL:
144 raise ImportError("matplotlib is required for visualization.")
145
146
147def _dose_xlim(dose):
148 """
149 Return the left x-limit for a dose axis.
150
151 Uses the minimum positive dose value in the data so the plot is never
152 reversed when the run ends at a dose below any hard-coded threshold.
153 """
154 pos = dose[dose > 0]
155 return float(pos.min()) if len(pos) > 0 else 1e-20
156
157
158def _align_dose_to_y(results):
159 """
160 Return (y, dose) with matching time dimensions.
161
162 After adaptive domain-doubling, ``results['y']`` may contain only the
163 last segment (its row count changed), while ``results['dose']`` spans
164 the full run. When the two lengths differ, take the *tail* of dose
165 to match ``y``.
166 """
167 y = results['y']
168 dose = results['dose']
169 n_y = y.shape[1]
170 if len(dose) != n_y:
171 dose = dose[-n_y:]
172 return y, dose
173
174
175def plot_point_defects(results, out_path=None, title=''):
176 """Free SIA (Ci1) and vacancy (Cv1) monomers vs. dose."""
177 _check_mpl()
178 fig, ax = plt.subplots(figsize=(9, 5.5))
179 dose = results['dose']
180 ax.loglog(dose, results['C_i1'], label='C_i1 (free SIA)', color='royalblue')
181 ax.loglog(dose, results['C_v1'], label='C_v1 (free vac)', color='firebrick')
182 ax.set_xlabel('Dose (dpa)')
183 ax.set_ylabel(_CONC_LABEL)
184 _set_title(ax, f'Point Defects {title}')
185 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
186 ax.grid(True, which='both', alpha=0.3)
187 # Fixed left/bottom bounds. The transient below 1e-11 dpa is the initial
188 # build-up from zero, not steady-state point-defect physics. Both curves
189 # stay above 1e16 over the plotted range, so nothing is clipped.
190 ax.set_xlim(left=1e-11 if float(np.max(dose)) > 1e-11 else _dose_xlim(dose))
191 ax.set_ylim(bottom=1e16)
192 _apply_axis_config(ax, 'concentration')
193 fig.tight_layout()
194 if out_path:
195 fig.savefig(out_path, dpi=150, bbox_inches='tight')
196 return fig
197
198
199def plot_totals(results, out_path=None, title=''):
200 """Total SIA, vacancy, and He contents vs. dose."""
201 _check_mpl()
202 fig, ax = plt.subplots(figsize=(9, 5.5))
203 dose = results['dose']
204 ax.loglog(dose, results['C_SIA_tot'], label=r'SIA total ($\Sigma n\cdot c_n$)', color='steelblue')
205 ax.loglog(dose, results['C_VAC_tot'], label=r'Vac total ($\Sigma m\cdot c_m$)', color='tomato')
206 ax.loglog(dose, results['C_He_tot'], label='He total', color='green')
207 ax.set_xlim(left=_dose_xlim(dose))
208 ax.set_ylim(bottom=1e11)
209 ax.set_xlabel('Dose (dpa)')
210 ax.set_ylabel(_CONC_LABEL)
211 _set_title(ax, f'Defect Contents {title}')
212 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
213 ax.grid(True, which='both', alpha=0.3)
214 _apply_axis_config(ax, 'concentration')
215 fig.tight_layout()
216 if out_path:
217 fig.savefig(out_path, dpi=150, bbox_inches='tight')
218 return fig
219
220
221def plot_swelling(results, out_path=None, title=''):
222 """Void swelling S(t) vs. dose."""
223 _check_mpl()
224 fig, ax = plt.subplots(figsize=(9, 5.5))
225 dose = results['dose']
226 ax.semilogx(dose, results['swelling'] * 100, color='darkorange', lw=_LW)
227 ax.set_xlabel('Dose (dpa)')
228 ax.set_ylabel('Swelling S(t) (%)')
229 _set_title(ax, f'Void Swelling (Eq. 161) {title}')
230 ax.grid(True, alpha=0.3)
231 _apply_axis_config(ax, 'scalar')
232 fig.tight_layout()
233 if out_path:
234 fig.savefig(out_path, dpi=150, bbox_inches='tight')
235 return fig
236
237
238def plot_network_density(results, out_path=None, title=''):
239 """Network dislocation density ρ_net vs. dose (loop_network_loss.tex).
240
241 With the LOOP_NETWORK_LOSS channel active, sessile loops are swept into the
242 pre-existing network, so ρ_net rises with dose and (with a calibrated
243 recovery sink) saturates — the feedback that bounds the loop density. When
244 the channel is off the curve is flat at the static ρ_d, which is still a
245 valid reading of the network density.
246 """
247 _check_mpl()
248 fig, ax = plt.subplots(figsize=(9, 5.5))
249 dose = np.asarray(results['dose'], dtype=float)
250 rho_net = results.get('rho_net')
251 if rho_net is None:
252 return None # nothing to plot (older run without the rho_net history)
253 rho_net = np.asarray(rho_net, dtype=float)
254 if rho_net.shape[0] != dose.shape[0]: # align tail (post-doubling)
255 n = min(rho_net.shape[0], dose.shape[0])
256 rho_net, dose = rho_net[-n:], dose[-n:]
257 ax.semilogx(dose, rho_net, color='purple', lw=_LW)
258 ax.set_xlabel('Dose (dpa)')
259 ax.set_ylabel(r'Network density $\rho_{\rm net}$ (m$^{-2}$)')
260 _set_title(ax, f'Network Dislocation Density {title}')
261 ax.grid(True, alpha=0.3)
262 ax.set_xlim(left=_dose_xlim(dose))
263 _apply_axis_config(ax, 'scalar')
264 fig.tight_layout()
265 if out_path:
266 fig.savefig(out_path, dpi=150, bbox_inches='tight')
267 return fig
268
269
270_LBL_111 = r'$\frac{1}{2}\langle 111\rangle$ loops'
271_LBL_100 = r'$\langle 100\rangle$ loops'
272_C_111 = 'steelblue'
273_C_100 = 'darkviolet'
274
275
276def _has_conversion(results):
277 """True if the run carries a populated ⟨100⟩ loop population."""
278 n100 = results.get('N_loops_100')
279 return n100 is not None and np.any(np.asarray(n100, dtype=float) > 0.0)
280
281
282def plot_mean_sizes(results, out_path=None, title=''):
283 """Mean SIA and vacancy cluster sizes vs. dose.
284
285 With loop conversion, the SIA loops are split into ½⟨111⟩ and ⟨100⟩.
286 """
287 _check_mpl()
288 fig, ax = plt.subplots(figsize=(9, 5.5))
289 dose = results['dose']
290 if _has_conversion(results):
291 ax.semilogx(dose, results['mean_n_111'],
292 label=r'$\langle n\rangle$ ' + _LBL_111, color=_C_111)
293 ax.semilogx(dose, results['mean_n_100'],
294 label=r'$\langle n\rangle$ ' + _LBL_100, color=_C_100)
295 else:
296 ax.semilogx(dose, results['mean_n_i'],
297 label=r'$\langle n \rangle$ SIA loops', color=_C_111)
298 ax.semilogx(dose, results['mean_n_v'], label=r'$\langle m \rangle$ voids', color='tomato')
299 ax.set_xlabel('Dose (dpa)')
300 ax.set_ylabel('Mean cluster size (defects)')
301 _set_title(ax, f'Mean Cluster Sizes {title}')
302 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
303 ax.grid(True, alpha=0.3)
304 # Fixed left bound; falls back to the data range for shorter runs.
305 ax.set_xlim(left=1e-3 if float(np.max(dose)) > 1e-3 else _dose_xlim(dose))
306 ax.set_ylim(bottom=0)
307 _apply_axis_config(ax, 'scalar')
308 fig.tight_layout()
309 if out_path:
310 fig.savefig(out_path, dpi=150, bbox_inches='tight')
311 return fig
312
313
314def plot_he_content(results, out_path=None, title=''):
315 """Free and total He vs. dose."""
316 _check_mpl()
317 fig, ax = plt.subplots(figsize=(9, 5.5))
318 dose = results['dose']
319 ax.loglog(dose, results['C_He_tot'], label='He total', color='darkgreen')
320 ax.loglog(dose, results['C_He_free'], label=r'He free ($c_h$)', color='teal', ls='--')
321 ax.set_xlabel('Dose (dpa)')
322 ax.set_ylabel(_CONC_LABEL)
323 _set_title(ax, f'Helium Content {title}')
324 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
325 ax.grid(True, which='both', alpha=0.3)
326 # Fixed left/bottom bounds rather than the data range. Below 1e-3 dpa the
327 # He inventory is still filling from zero and spans ten featureless decades;
328 # the 1e12 m^-3 floor keeps the panel on the accumulated total. NOTE: free
329 # He sits far below that floor (order 1e8 m^-3 here), so the c_h curve is
330 # clipped out of view -- raise the floor deliberately, not by accident.
331 # Falls back to the data range for runs that never reach 1e-3 dpa.
332 ax.set_xlim(left=1e-3 if float(np.max(dose)) > 1e-3 else _dose_xlim(dose))
333 ax.set_ylim(bottom=1e12)
334 _apply_axis_config(ax, 'concentration')
335 fig.tight_layout()
336 if out_path:
337 fig.savefig(out_path, dpi=150, bbox_inches='tight')
338 return fig
339
340
341def plot_size_distribution(results, input_data, t_idx=-1, out_path=None, title=''):
342 """SIA and vacancy cluster size distributions at a given time index."""
343 _check_mpl()
344 N = input_data.I
345 M = input_data.V
346 Omega = results.get('Omega', input_data.derived['Omega'])
347 inv_Omega = 1.0 / Omega
348
349 y = results['y'][:, t_idx]
350 if y.shape[0] < N + M:
351 return None
352
353 # Raw ODE state is in at.frac; convert to m^-3 for display
354 c_n = np.maximum(y[:N], 0.0) * inv_Omega
355 c_v = np.maximum(y[N:N + M], 0.0) * inv_Omega
356 # ⟨100⟩ loop distribution (loop conversion), if present
357 y100 = results.get('y_sia100')
358 c_n100 = (np.maximum(np.asarray(y100)[:, t_idx], 0.0) * inv_Omega
359 if y100 is not None and np.asarray(y100).size else None)
360
361 ns = np.arange(1, N + 1)
362 ms = np.arange(1, M + 1)
363
364 # Diameter conversions
365 r0 = results.get('r0', input_data.derived['r0']) # Wigner-Seitz radius [m]
366 b_111 = results.get('b_111', input_data.derived['b_111']) # Burgers vector [m]
367 d_loop = 2.0 * np.sqrt(ns * Omega / (np.pi * b_111)) * 1e9 # SIA loop diameter [nm]
368 d_void = 2.0 * r0 * ms ** (1.0 / 3.0) * 1e9 # void diameter [nm]
369
370 time_label = f't={results["t"][t_idx]:.2e} s'
371
372 fig, axes = plt.subplots(2, 2, figsize=(12, 8))
373
374 # --- Row 1: log-scale vs cluster size ---
375 ax1 = axes[0, 0]
376 ax1.semilogy(ns, c_n + 1e-10, color=_C_111,
377 label=_LBL_111 if c_n100 is not None else None)
378 if c_n100 is not None:
379 ax1.semilogy(ns, c_n100 + 1e-10, color=_C_100, label=_LBL_100)
380 ax1.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
381 ax1.set_xlabel('SIA cluster size n')
382 ax1.set_ylabel(_CONC_LABEL)
383 _set_title(ax1, f'SIA Distribution {time_label} {title}')
384 ax1.grid(True, alpha=0.3)
385 c_n_pos = c_n[c_n > 0]
386 if c_n_pos.size > 0:
387 ax1.set_ylim(bottom=c_n_pos.min() * 0.1)
388
389 ax2 = axes[0, 1]
390 ax2.semilogy(ms, c_v + 1e-10, color='tomato')
391 ax2.set_xlabel('Vacancy cluster size m')
392 ax2.set_ylabel(_CONC_LABEL)
393 _set_title(ax2, f'Void Distribution {time_label} {title}')
394 ax2.grid(True, alpha=0.3)
395 c_v_pos = c_v[c_v > 0]
396 if c_v_pos.size > 0:
397 ax2.set_ylim(bottom=c_v_pos.min() * 0.1)
398
399 # --- Row 2: linear-scale vs diameter (nm) ---
400 ax3 = axes[1, 0]
401 ax3.plot(d_loop, c_n, color=_C_111,
402 label=_LBL_111 if c_n100 is not None else None)
403 if c_n100 is not None:
404 ax3.plot(d_loop, c_n100, color=_C_100, label=_LBL_100)
405 ax3.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
406 ax3.set_xlabel('SIA loop diameter (nm)')
407 ax3.set_ylabel(_CONC_LABEL)
408 _set_title(ax3, f'SIA Loop Size {time_label} {title}')
409 ax3.grid(True, alpha=0.3)
410
411 ax4 = axes[1, 1]
412 ax4.plot(d_void, c_v, color='tomato')
413 ax4.set_xlabel('Void diameter (nm)')
414 ax4.set_ylabel(_CONC_LABEL)
415 _set_title(ax4, f'Void Size {time_label} {title}')
416 ax4.grid(True, alpha=0.3)
417
418 _apply_axis_config_to_fig(fig, 'size_dist')
419 fig.tight_layout()
420 if out_path:
421 fig.savefig(out_path, dpi=150, bbox_inches='tight')
422 return fig
423
424
425def _extract_cluster_arrays(results, input_data):
426 """
427 Return (c_n, c_v, dose) arrays [size x n_t] in m^-3.
428
429 Works only for full_CD modes (y has per-cluster rows).
430 Returns None for bin-moment runs where y rows are moments.
431 """
432 N = input_data.I
433 M = input_data.V
434 y, dose = _align_dose_to_y(results)
435 Omega = results.get('Omega', input_data.derived['Omega'])
436 inv_O = 1.0 / Omega
437
438 # Sanity check: full_CD has at least N+M rows
439 if y.shape[0] < N + M:
440 return None, None, None
441
442 c_n = np.maximum(y[:N, :], 0.0) * inv_O # [N, n_t] m^-3
443 c_v = np.maximum(y[N:N + M, :], 0.0) * inv_O # [M, n_t] m^-3
444 return c_n, c_v, dose
445
446
447def plot_sia_clusters(results, input_data, out_path=None, title=''):
448 """SIA cluster concentrations vs. dose, split into small / mid / large."""
449 _check_mpl()
450 c_n, c_v, dose = _extract_cluster_arrays(results, input_data)
451 if c_n is None:
452 print("plot_sia_clusters: skipped (bin-moment mode).")
453 return None
454
455 N = input_data.I
456 groups = [
457 ('SIA clusters: n = 1–5', range(1, min(6, N + 1)), 'sia_small'),
458 ('SIA clusters: n = 6–20', range(6, min(21, N + 1)), 'sia_mid'),
459 ('SIA clusters: n = 20–100', range(20, min(101, N + 1)), 'sia_large'),
460 ]
461 figs = []
462 for gtitle, rng, fname in groups:
463 idx = [n - 1 for n in rng if n - 1 < N]
464 if not idx:
465 continue
466 fig, ax = plt.subplots(figsize=(7, 5))
467 cmap = plt.cm.viridis(np.linspace(0.05, 0.70, len(idx)))
468 for k, color in zip(idx, cmap):
469 ax.loglog(dose, np.maximum(c_n[k, :], 1e-10),
470 color=color, label=f'n={k + 1}')
471 ax.set_xlabel('Dose (dpa)')
472 ax.set_ylabel(_CONC_LABEL)
473 full_dose = results['dose']
474 _set_title(ax, f'{gtitle} {title}')
475 ax.grid(True, which='both', alpha=0.3)
476 ax.set_xlim(left=_dose_xlim(full_dose), right=full_dose[-1])
477 _apply_axis_config(ax, 'concentration')
478 fig.tight_layout()
479 ncol = min(8, max(2, len(idx)))
480 _legend_below(fig, ax, ncol=ncol, bottom=0.25)
481 if out_path:
482 # Replace file stem with group-specific name
483 from pathlib import Path as _P
484 p = _P(out_path)
485 fig.savefig(str(p.parent / f'{fname}.png'), dpi=150,
486 bbox_inches='tight')
487 figs.append(fig)
488 return figs
489
490
491def plot_vac_clusters(results, input_data, out_path=None, title=''):
492 """Vacancy cluster concentrations vs. dose, split into small / mid / large."""
493 _check_mpl()
494 c_n, c_v, dose = _extract_cluster_arrays(results, input_data)
495 if c_v is None:
496 print("plot_vac_clusters: skipped (bin-moment mode).")
497 return None
498
499 M = input_data.V
500 groups = [
501 ('Vacancy clusters: m = 1–5', range(1, min(6, M + 1)), 'vac_small'),
502 ('Vacancy clusters: m = 6–20', range(6, min(21, M + 1)), 'vac_mid'),
503 ('Vacancy clusters: m = 20–100', range(20, min(101, M + 1)), 'vac_large'),
504 ]
505 figs = []
506 for gtitle, rng, fname in groups:
507 idx = [m - 1 for m in rng if m - 1 < M]
508 if not idx:
509 continue
510 fig, ax = plt.subplots(figsize=(7, 5))
511 cmap = plt.cm.plasma(np.linspace(0.05, 0.72, len(idx)))
512 for k, color in zip(idx, cmap):
513 ax.loglog(dose, np.maximum(c_v[k, :], 1e-10),
514 color=color, label=f'm={k + 1}')
515 full_dose = results['dose']
516 ax.set_xlabel('Dose (dpa)')
517 ax.set_ylabel(_CONC_LABEL)
518 _set_title(ax, f'{gtitle} {title}')
519 ax.grid(True, which='both', alpha=0.3)
520 ax.set_xlim(left=_dose_xlim(full_dose), right=full_dose[-1])
521 _apply_axis_config(ax, 'concentration')
522 fig.tight_layout()
523 ncol = min(8, max(2, len(idx)))
524 _legend_below(fig, ax, ncol=ncol, bottom=0.25)
525 if out_path:
526 from pathlib import Path as _P
527 p = _P(out_path)
528 fig.savefig(str(p.parent / f'{fname}.png'), dpi=150,
529 bbox_inches='tight')
530 figs.append(fig)
531 return figs
532
533
534def _log_snapshot_indices(dose, n_times=10):
535 """Return n_times indices log-spaced across the dose array."""
536 d = np.asarray(dose)
537 d_pos = d[d > 0]
538 if len(d_pos) < 2:
539 return np.linspace(0, len(d) - 1, min(n_times, len(d)), dtype=int)
540 log_min = np.log10(d_pos[0])
541 log_max = np.log10(d[-1])
542 targets = np.logspace(log_min, log_max, n_times)
543 return [int(np.argmin(np.abs(d - t))) for t in targets]
544
545
546def _b_100(results, input_data, b_111):
547 """⟨100⟩ Burgers vector (= lattice parameter a) [m]."""
548 return results.get('b_100',
549 input_data.derived.get('a_m', b_111 * 2.0 / np.sqrt(3.0)))
550
551
552# Size-distribution evolution: the plotted range of each axis. The loop
553# populations occupy the first few hundred sizes out of I = 80000, so an axis
554# that follows the grid is a spike against an empty plain.
555_DIST_N_MAX, _DIST_D_MAX_NM = 5000, 10.0
556
557
558def _loop_dist_evolution(c_src, b, Omega, dose, n_times, char, title):
559 """Shared SIA-loop size-distribution-evolution figures.
560
561 c_src : [I, n_t] loop densities in at.frac; b : Burgers vector [m];
562 char : population label for titles/axes (e.g. '1/2<111>', '<100>').
563
564 Returns TWO figures -- (vs cluster size n, vs loop diameter nm). They were
565 one stacked 8x9 canvas until 2026-09-06, which left both panels squeezed and
566 their titles colliding; separate figures let each carry a real axis range.
567 """
568 inv_O = 1.0 / Omega
569 N = c_src.shape[0]
570 ns = np.arange(1, N + 1)
571 d_loop = 2.0 * np.sqrt(ns * Omega / (np.pi * b)) * 1e9 # nm
572 indices = _log_snapshot_indices(dose, n_times)
573 cmap = plt.cm.viridis(np.linspace(0.05, 0.70, len(indices)))
574
575 fig1, ax1 = plt.subplots(figsize=(8, 5))
576 fig2, ax2 = plt.subplots(figsize=(8, 5))
577 for idx, color in zip(indices, cmap):
578 c_n = np.maximum(c_src[:, idx], 0.0) * inv_O
579 ax1.semilogy(ns, c_n + 1e-10, color=color, label=f'{dose[idx]:.2e} dpa')
580 ax2.plot(d_loop, c_n, color=color, label=f'{dose[idx]:.2e} dpa')
581
582 ax1.set_xlabel(f'{char} cluster size n')
583 ax1.set_ylabel(_CONC_LABEL)
584 _set_title(ax1, f'{char} Size Distribution {title}')
585 ax1.legend(ncol=2, title='Dose', loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
586 ax1.grid(True, which='both', alpha=0.3)
587 ax1.set_xlim(0, min(_DIST_N_MAX, N))
588
589 ax2.set_xlabel(f'{char} loop diameter (nm)')
590 ax2.set_ylabel(_CONC_LABEL)
591 _set_title(ax2, f'{char} Loop Size Distribution {title}')
592 ax2.legend(ncol=2, title='Dose', loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
593 ax2.grid(True, alpha=0.3)
594 ax2.set_xlim(0, _DIST_D_MAX_NM)
595
596 for f in (fig1, fig2):
597 _apply_axis_config_to_fig(f, 'size_dist')
598 f.tight_layout()
599 return fig1, fig2
600
601
602def _save_pair(figs, out_path):
603 """Write the (size, diameter) figure pair as <stem>_size / _diameter PNGs."""
604 if not out_path:
605 return figs
606 from pathlib import Path
607 p = Path(out_path)
608 for fig, suffix in zip(figs, ('_size', '_diameter')):
609 fig.savefig(p.with_name(p.stem + suffix + p.suffix), dpi=150,
610 bbox_inches='tight')
611 return figs
612
613
614def plot_sia_distribution_evolution(results, input_data, n_times=10,
615 out_path=None, title=''):
616 """
617 ½⟨111⟩ SIA cluster size distribution c_n(n) at n_times log-spaced snapshots.
618
619 Top panel: log-scale concentration vs cluster size n.
620 Bottom panel: linear-scale concentration vs loop diameter (nm).
621 """
622 _check_mpl()
623 N = input_data.I
624 Omega = results.get('Omega', input_data.derived['Omega'])
625 b_111 = results.get('b_111', input_data.derived['b_111'])
626 y, dose = _align_dose_to_y(results)
627 if y.shape[0] < N:
628 print("plot_sia_distribution_evolution: skipped (bin-moment mode).")
629 return None
630 char = '1/2<111>' if _has_conversion(results) else 'SIA'
631 return _save_pair(
632 _loop_dist_evolution(y[:N, :], b_111, Omega, dose, n_times, char, title),
633 out_path)
634
635
636def plot_100_distribution_evolution(results, input_data, n_times=10,
637 out_path=None, title=''):
638 """
639 ⟨100⟩ loop size distribution c_n(n) at n_times log-spaced snapshots
640 (loop conversion). Mirrors plot_sia_distribution_evolution for the sessile
641 ⟨100⟩ population; returns None when conversion is off.
642 """
643 _check_mpl()
644 y100 = results.get('y_sia100')
645 if y100 is None or not np.asarray(y100).size or not _has_conversion(results):
646 return None
647 Omega = results.get('Omega', input_data.derived['Omega'])
648 b_111 = results.get('b_111', input_data.derived['b_111'])
649 c100 = np.asarray(y100, dtype=float) # [I, n_t], same grid as results
650 dose = np.asarray(results['dose'])[:c100.shape[1]]
651 return _save_pair(
652 _loop_dist_evolution(c100, _b_100(results, input_data, b_111),
653 Omega, dose, n_times, '<100>', title),
654 out_path)
655
656
657def plot_void_distribution_evolution(results, input_data, n_times=10,
658 out_path=None, title=''):
659 """
660 Void/vacancy cluster size distribution c_m(m) at n_times log-spaced dose snapshots.
661
662 Top panel: log-scale concentration vs vacancy cluster size m.
663 Bottom panel: linear-scale concentration vs void diameter (nm).
664 """
665 _check_mpl()
666 N = input_data.I
667 M = input_data.V
668 Omega = results.get('Omega', input_data.derived['Omega'])
669 r0 = results.get('r0', input_data.derived['r0'])
670 y, dose = _align_dose_to_y(results)
671
672 if y.shape[0] < N + M:
673 print("plot_void_distribution_evolution: skipped (bin-moment mode).")
674 return None
675
676 inv_O = 1.0 / Omega
677 ms = np.arange(1, M + 1)
678 d_void = 2.0 * r0 * ms ** (1.0 / 3.0) * 1e9 # nm
679 indices = _log_snapshot_indices(dose, n_times)
680 cmap = plt.cm.plasma(np.linspace(0.05, 0.72, len(indices)))
681
682 fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 9))
683
684 for idx, color in zip(indices, cmap):
685 c_v = np.maximum(y[N:N + M, idx], 0.0) * inv_O
686 ax1.semilogy(ms, c_v + 1e-10, color=color,
687 label=f'{dose[idx]:.2e} dpa')
688 ax2.plot(d_void, c_v, color=color,
689 label=f'{dose[idx]:.2e} dpa')
690
691 ax1.set_xlabel('Vacancy cluster size m')
692 ax1.set_ylabel(_CONC_LABEL)
693 _set_title(ax1, f'Void Size Distribution {title}')
694 ax1.legend(ncol=2, title='Dose', loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
695 ax1.grid(True, which='both', alpha=0.3)
696
697 ax2.set_xlabel('Void diameter (nm)')
698 ax2.set_ylabel(_CONC_LABEL)
699 _set_title(ax2, f'Void Diameter Distribution {title}')
700 ax2.legend(ncol=2, title='Dose', loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
701 ax2.grid(True, alpha=0.3)
702
703 _apply_axis_config_to_fig(fig, 'size_dist')
704 fig.tight_layout()
705 if out_path:
706 fig.savefig(out_path, dpi=150, bbox_inches='tight')
707 return fig
708
709
710def plot_number_densities(results, out_path=None, title=''):
711 """Loop and void number densities (clusters n,m ≥ 2) vs. dose."""
712 _check_mpl()
713 fig, ax = plt.subplots(figsize=(9, 5.5))
714 dose = results['dose']
715 if _has_conversion(results):
716 ax.loglog(dose, np.maximum(results['N_loops_111'], 1e-10),
717 label=_LBL_111 + r' (n≥2)', color=_C_111)
718 ax.loglog(dose, np.maximum(results['N_loops_100'], 1e-10),
719 label=_LBL_100 + r' (n≥2)', color=_C_100)
720 ax.loglog(dose, np.maximum(results['N_loops'], 1e-10),
721 label='SIA loops (total)', color='dimgray', ls=':', alpha=0.9)
722 else:
723 ax.loglog(dose, np.maximum(results['N_loops'], 1e-10),
724 label='Loop density (SIA, n≥2)', color=_C_111)
725 ax.loglog(dose, np.maximum(results['N_voids'], 1e-10),
726 label='Void density (vac, m≥2)', color='tomato', ls='--')
727 ax.set_xlabel('Dose (dpa)')
728 ax.set_ylabel(r'Number density (m$^{-3}$)')
729 _set_title(ax, f'Loop and Void Number Densities {title}')
730 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
731 ax.grid(True, which='both', alpha=0.3)
732 # Fixed left/bottom bounds, matching plot_number_densities_tem.
733 ax.set_xlim(left=1e-3 if float(np.max(dose)) > 1e-3 else _dose_xlim(dose))
734 ax.set_ylim(bottom=1e14)
735 _apply_axis_config(ax, 'concentration')
736 fig.tight_layout()
737 if out_path:
738 fig.savefig(out_path, dpi=150, bbox_inches='tight')
739 return fig
740
741
742def plot_loop_fraction(results, out_path=None, title=''):
743 """½⟨111⟩ loop fraction f₁₁₁ vs. dose (loop conversion).
744
745 f₁₁₁ = Σ n c_n^{111} / Σ n (c_n^{111}+c_n^{100}) — the content-weighted
746 fraction of SIA-loop atoms still in ½⟨111⟩ loops, directly comparable to the
747 experimental Burgers-vector fraction. Returns None if conversion is off.
748 """
749 _check_mpl()
750 if not _has_conversion(results):
751 return None
752 fig, ax = plt.subplots(figsize=(9, 5.5))
753 dose = results['dose']
754 f111 = np.asarray(results['f_111_loop'], dtype=float)
755 ax.semilogx(dose, 100.0 * f111, color=_C_111, lw=_LW,
756 label=r'$\frac{1}{2}\langle 111\rangle$')
757 ax.semilogx(dose, 100.0 * (1.0 - f111), color=_C_100, lw=_LW, ls='--',
758 label=r'$\langle 100\rangle$')
759 ax.set_xlabel('Dose (dpa)')
760 ax.set_ylabel('Loop fraction (%)')
761 _set_title(ax, f'½⟨111⟩ / ⟨100⟩ Loop Fraction {title}')
762 ax.set_ylim(-5, 105)
763 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
764 ax.grid(True, alpha=0.3)
765 # Fixed left bound: ⟨100⟩ loops have not nucleated below ~1e-2 dpa, so the
766 # decades to the left are a flat 100 % / 0 % pair carrying no information.
767 # Falls back to the data range for runs that never reach 1e-2 dpa.
768 ax.set_xlim(left=1e-2 if float(np.max(dose)) > 1e-2 else _dose_xlim(dose))
769 _apply_axis_config(ax, 'scalar')
770 fig.tight_layout()
771 if out_path:
772 fig.savefig(out_path, dpi=150, bbox_inches='tight')
773 return fig
774
775
776# ══════════════════════════════════════════════════════════════════════════════
777# TEM-visible cluster plots (n,m ≥ N_MIN_TEM)
778# ══════════════════════════════════════════════════════════════════════════════
779
780_N_MIN_TEM = 10 # atoms — minimum TEM-visible cluster size (~1 nm)
781_OMEGA = 1.18e-29 # m^3 — atomic volume (bcc Fe)
782_B_111 = 2.482e-10 # m — Burgers vector 1/2<111>
783
784
785def _tem_filter(c_arr, n_min=_N_MIN_TEM):
786 """Return mask for TEM-visible clusters (size index >= n_min - 1)."""
787 N = len(c_arr)
788 return np.arange(1, N + 1) >= n_min
789
790
791def plot_number_densities_tem(results, input_data, rate_eq_obj,
792 out_path=None, title=''):
793 """
794 Loop and void number densities vs dose — TEM-visible only (n,m ≥ 10).
795 Plotted alongside the total (all sizes) for comparison.
796 """
797 _check_mpl()
798
799 c_n_all, c_v_all, dose = _reconstruct_distributions(
800 results, input_data, rate_eq_obj)
801 if c_n_all is None:
802 return None
803
804 N, M = c_n_all.shape[0], c_v_all.shape[0]
805 n_t = c_n_all.shape[1]
806 mask_i = np.arange(1, N + 1) >= _N_MIN_TEM
807 mask_v = np.arange(1, M + 1) >= _N_MIN_TEM
808
809 N_loops_all = np.sum(c_n_all[1:, :], axis=0) # n ≥ 2 (½⟨111⟩ when conv.)
810 N_loops_tem = np.sum(c_n_all[mask_i, :], axis=0)
811 N_voids_all = np.sum(c_v_all[1:, :], axis=0)
812 N_voids_tem = np.sum(c_v_all[mask_v, :], axis=0)
813
814 fig, ax = plt.subplots(figsize=(8, 5))
815 if _has_conversion(results):
816 # ½⟨111⟩ = c_n_all (the SIA block); ⟨100⟩ from y_sia100 [I, n_t] at.frac.
817 inv_O = 1.0 / results.get('Omega', input_data.derived['Omega'])
818 c100 = np.maximum(np.asarray(results['y_sia100'], dtype=float), 0.0) * inv_O
819 m100 = np.arange(1, c100.shape[0] + 1) >= _N_MIN_TEM
820 N100_all = np.sum(c100[1:, :], axis=0)
821 N100_tem = np.sum(c100[m100, :], axis=0)
822 ax.loglog(dose, np.maximum(N_loops_all, 1), color=_C_111,
823 alpha=0.3, ls=':', label=r'$\frac{1}{2}\langle111\rangle$ (all $n\geq2$)')
824 ax.loglog(dose, np.maximum(N_loops_tem, 1), color=_C_111, lw=_LW,
825 label=rf'$\frac{{1}}{{2}}\langle111\rangle$ (TEM, $n\geq{_N_MIN_TEM}$)')
826 ax.loglog(dose, np.maximum(N100_all, 1), color=_C_100,
827 alpha=0.3, ls=':', label=r'$\langle100\rangle$ (all $n\geq2$)')
828 ax.loglog(dose, np.maximum(N100_tem, 1), color=_C_100, lw=_LW,
829 label=rf'$\langle100\rangle$ (TEM, $n\geq{_N_MIN_TEM}$)')
830 else:
831 ax.loglog(dose, np.maximum(N_loops_all, 1), color=_C_111,
832 alpha=0.3, ls=':', label=r'SIA loops (all $n\geq2$)')
833 ax.loglog(dose, np.maximum(N_loops_tem, 1), color=_C_111,
834 lw=_LW, label=rf'SIA loops (TEM, $n\geq{_N_MIN_TEM}$)')
835 ax.loglog(dose, np.maximum(N_voids_all, 1), color='tomato',
836 alpha=0.3, ls=':', label=r'Voids (all $m\geq2$)')
837 ax.loglog(dose, np.maximum(N_voids_tem, 1), color='tomato',
838 lw=_LW, ls='--', label=rf'Voids (TEM, $m\geq{_N_MIN_TEM}$)')
839 full_dose = results['dose']
840 ax.set_xlabel('Dose (dpa)')
841 ax.set_ylabel(r'Number density (m$^{-3}$)')
842 _set_title(ax, f'TEM-Visible Number Densities {title}')
843 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
844 ax.grid(True, which='both', alpha=0.3)
845 # Fixed left/bottom bounds; the x fallback covers runs shorter than 1e-3 dpa.
846 ax.set_xlim(left=1e-3 if float(np.max(full_dose)) > 1e-3 else _dose_xlim(full_dose),
847 right=full_dose[-1])
848 ax.set_ylim(bottom=1e14)
849 _apply_axis_config(ax, 'concentration')
850 fig.tight_layout()
851 if out_path:
852 fig.savefig(out_path, dpi=150, bbox_inches='tight')
853 return fig
854
855
856def plot_mean_sizes_tem(results, input_data, rate_eq_obj,
857 out_path=None, title=''):
858 """
859 Mean cluster sizes vs dose — TEM-visible only (n,m ≥ 10).
860 Shows both atoms (left axis) and physical diameter in nm (right axis).
861 """
862 _check_mpl()
863
864 c_n_all, c_v_all, dose = _reconstruct_distributions(
865 results, input_data, rate_eq_obj)
866 if c_n_all is None:
867 return None
868
869 N, M = c_n_all.shape[0], c_v_all.shape[0]
870 n_t = c_n_all.shape[1]
871 ns = np.arange(1, N + 1, dtype=float)
872 ms = np.arange(1, M + 1, dtype=float)
873 mask_i = ns >= _N_MIN_TEM
874 mask_v = ms >= _N_MIN_TEM
875
876 Omega = input_data.derived.get('Omega', _OMEGA)
877 b_111 = input_data.derived.get('b_111', _B_111)
878 C_floor = float(input_data.reactions.get('C_floor', 1e-15)) / Omega
879
880 mean_n_i_tem = np.zeros(n_t)
881 mean_n_v_tem = np.zeros(n_t)
882 d_i_nm = np.zeros(n_t)
883 d_v_nm = np.zeros(n_t)
884
885 for j in range(n_t):
886 ci_eff = np.maximum(c_n_all[mask_i, j] - C_floor, 0.0)
887 cv_eff = np.maximum(c_v_all[mask_v, j] - C_floor, 0.0)
888 cnt_i = np.sum(ci_eff)
889 cnt_v = np.sum(cv_eff)
890 mean_n_i_tem[j] = np.dot(ns[mask_i], ci_eff) / cnt_i if cnt_i > 0 else 0.0
891 mean_n_v_tem[j] = np.dot(ms[mask_v], cv_eff) / cnt_v if cnt_v > 0 else 0.0
892 d_i_nm[j] = 2 * np.sqrt(mean_n_i_tem[j] * Omega / (np.pi * b_111)) * 1e9
893 d_v_nm[j] = 2 * (3 * mean_n_v_tem[j] * Omega / (4 * np.pi))**(1./3.) * 1e9
894
895 fig, ax = plt.subplots(figsize=(8, 6))
896 full_dose = results['dose']
897
898 if _has_conversion(results):
899 # ⟨100⟩ TEM mean diameter (uses b_100); ½⟨111⟩ is d_i_nm above.
900 # results['y_sia100'] is the per-size ⟨100⟩ block in ATOM FRACTION and
901 # must be scaled by inv_O before it can be compared with C_floor, which
902 # is in m^-3 -- the same conversion plot_number_densities_tem applies.
903 # Without it every entry sat ~28 decades below the floor, the clamp
904 # zeroed the whole population, and the ⟨100⟩ curve was identically 0 nm
905 # while mean_sizes.png (fed by post_process.mean_n_100, which works in
906 # solver units) showed ⟨100⟩ comparable to ½⟨111⟩.
907 #
908 # The floor is then subtracted only in discrete mode: in bin-moment mode
909 # cpp_bridge already removed the C_floor IC at the MOMENT level before
910 # reconstructing to per-size, so a second per-size clamp would
911 # double-floor the reconstruction.
912 b_100 = _b_100(results, input_data, b_111)
913 inv_O = 1.0 / Omega
914 c100 = np.maximum(np.asarray(results['y_sia100'], dtype=float), 0.0) * inv_O
915 if c100.shape[1] > n_t: # y is a tail after adaptive domain doubling
916 c100 = c100[:, -n_t:]
917 if not hasattr(rate_eq_obj, 'bins'):
918 c100 = np.maximum(c100 - C_floor, 0.0)
919 m100 = np.arange(1, c100.shape[0] + 1, dtype=float)
920 mk100 = m100 >= _N_MIN_TEM
921 d_100_nm = np.zeros(n_t)
922 for j in range(n_t):
923 c_eff = c100[mk100, j]
924 cnt = np.sum(c_eff)
925 mean100 = np.dot(m100[mk100], c_eff) / cnt if cnt > 0 else 0.0
926 d_100_nm[j] = 2 * np.sqrt(mean100 * Omega / (np.pi * b_100)) * 1e9
927 ax.plot(dose, d_i_nm, color=_C_111, lw=_LW,
928 label=rf'$\frac{{1}}{{2}}\langle111\rangle$ ($n\geq{_N_MIN_TEM}$)')
929 ax.plot(dose, d_100_nm, color=_C_100, lw=_LW,
930 label=rf'$\langle100\rangle$ ($n\geq{_N_MIN_TEM}$)')
931 else:
932 ax.plot(dose, d_i_nm, color=_C_111, lw=_LW,
933 label=rf'SIA loops ($n\geq{_N_MIN_TEM}$)')
934 ax.plot(dose, d_v_nm, color='tomato', lw=_LW,
935 label=rf'Voids ($m\geq{_N_MIN_TEM}$)')
936 ax.set_xlabel('Dose (dpa)')
937 ax.set_ylabel('Mean diameter (nm)')
938 _set_title(ax, f'TEM-Visible Mean Cluster Sizes {title}')
939 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
940 ax.grid(True, alpha=0.3)
941 ax.set_xlim(left=0, right=full_dose[-1])
942 ax.set_ylim(bottom=0)
943
944 _apply_axis_config_to_fig(fig, 'scalar')
945 fig.tight_layout()
946 if out_path:
947 fig.savefig(out_path, dpi=150, bbox_inches='tight')
948 return fig
949
950
951def _visualization_bins(rate_eq_obj, max_size, kind):
952 """
953 Build the visualization bin list covering sizes 1..max_size.
954
955 Each entry is (n_lo, n_hi) with n_hi exclusive (matching the
956 bin_moment_rates convention). For full_CD modes every integer is
957 its own width-1 bin. For bin_moment modes the discrete portion is
958 width-1 integers and the grouped portion is taken from
959 rate_eq_obj.bins (kind='sia') or rate_eq_obj.vac_bins (kind='vac').
960 """
961 is_bin = hasattr(rate_eq_obj, 'bins')
962 if not is_bin:
963 return [(n, n + 1) for n in range(1, max_size + 1)]
964 if kind == 'sia':
965 d_disc = getattr(rate_eq_obj, 'i_discrete', 0)
966 grouped = list(rate_eq_obj.bins)
967 else:
968 d_disc = getattr(rate_eq_obj, 'v_discrete', 0)
969 grouped = list(rate_eq_obj.vac_bins)
970 out = [(n, n + 1) for n in range(1, d_disc + 1)]
971 out.extend(grouped)
972 return out
973
974
975def _aggregate_to_bins(c_per_size, bins_full):
976 """
977 Sum per-size concentrations over each visualization bin.
978
979 Returns mu0 of shape [K, n_t] where K = len(bins_full). For width-1
980 bins this is just c_n; for grouped bins it is the bin's zeroth
981 moment (the lognormal/linear closure preserves this exactly).
982 """
983 n_t = c_per_size.shape[1]
984 K = len(bins_full)
985 mu0 = np.zeros((K, n_t))
986 for k, (nlo, nhi) in enumerate(bins_full):
987 mu0[k] = c_per_size[nlo - 1:nhi - 1].sum(axis=0)
988 return mu0
989
990
991def _plot_tem_density_panel(bins_full, mu0_arr, dose, *, n_times,
992 xform, n_min, xlabel, ylabel, plot_title,
993 cmap_name, use_logx, out_path, ymin=None):
994 """
995 TEM-visible per-bin density plot.
996
997 Each visualization bin is rendered as a stair segment of height
998 rho_k = mu_0(k) / (x_hi - x_lo) where the x-edges come from
999 `xform` (identity for size axis, diameter formula for diameter
1000 axes). This is the standard microstructure-distribution
1001 representation: integral under the stairs equals total
1002 concentration in the displayed range. Per-bin density is well
1003 defined regardless of the intra-bin shape function, so the comb
1004 artefact of integer-resolved lognormal reconstruction is avoided
1005 by construction.
1006 """
1007 keep = [k for k, (nlo, _nhi) in enumerate(bins_full) if nlo >= n_min]
1008 if not keep:
1009 return None
1010
1011 edges_x = np.array(
1012 [xform(bins_full[keep[0]][0])]
1013 + [xform(bins_full[k][1]) for k in keep],
1014 dtype=float)
1015 widths = np.diff(edges_x)
1016 widths = np.where(widths > 0.0, widths, 1.0)
1017
1018 indices = _log_snapshot_indices(dose, n_times)
1019 cmap = getattr(plt.cm, cmap_name)(np.linspace(0.05, 0.70, len(indices)))
1020
1021 fig, ax = plt.subplots(figsize=(8, 6))
1022 for idx, color in zip(indices, cmap):
1023 rho = mu0_arr[keep, idx] / widths
1024 rho = np.maximum(rho, 1e-30)
1025 ax.stairs(rho, edges_x, color=color, lw=_LW, baseline=None,
1026 label=f'{dose[idx]:.2e} dpa')
1027
1028 if use_logx:
1029 ax.set_xscale('log')
1030 ax.set_yscale('log')
1031 ax.set_xlabel(xlabel)
1032 ax.set_ylabel(ylabel)
1033 _set_title(ax, plot_title)
1034 ax.legend(ncol=2, title='Dose', loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
1035 ax.grid(True, which='both', alpha=0.3)
1036 if ymin is not None:
1037 ax.set_ylim(bottom=ymin)
1038
1039 _apply_axis_config_to_fig(fig, 'size_dist')
1040 fig.tight_layout()
1041 if out_path:
1042 fig.savefig(out_path, dpi=150, bbox_inches='tight')
1043 return fig
1044
1045
1046_DENSITY_LABEL_SIZE = r'$dc/dn$ (m$^{-3}$)'
1047_DENSITY_LABEL_DIAM = r'$dc/dD$ (m$^{-3}$ nm$^{-1}$)'
1048
1049
1050def plot_sia_distribution_tem_size(results, input_data, rate_eq_obj,
1051 n_times=8, out_path=None, title=''):
1052 """SIA distribution density vs cluster size (TEM-visible, n ≥ N_MIN_TEM)."""
1053 _check_mpl()
1054 c_n_all, _, dose = _reconstruct_distributions(
1055 results, input_data, rate_eq_obj)
1056 if c_n_all is None:
1057 return None
1058 N = c_n_all.shape[0]
1059 bins_full = _visualization_bins(rate_eq_obj, N, kind='sia')
1060 mu0_arr = _aggregate_to_bins(c_n_all, bins_full)
1062 bins_full, mu0_arr, dose,
1063 n_times=n_times,
1064 xform=lambda n: float(n),
1065 n_min=_N_MIN_TEM,
1066 xlabel='SIA cluster size n',
1067 ylabel=_DENSITY_LABEL_SIZE,
1068 plot_title=rf'TEM-Visible SIA Distribution ($n\geq{_N_MIN_TEM}$) {title}',
1069 cmap_name='viridis', use_logx=True, out_path=out_path,
1070 # Same reason as the diameter panel: the near-zero tail of the earliest
1071 # snapshots otherwise stretches the axis over eighteen decades.
1072 ymin=1e16)
1073
1074
1075def plot_sia_distribution_tem_diameter(results, input_data, rate_eq_obj,
1076 n_times=8, out_path=None, title=''):
1077 """SIA distribution density vs loop diameter (TEM-visible, n ≥ N_MIN_TEM)."""
1078 _check_mpl()
1079 c_n_all, _, dose = _reconstruct_distributions(
1080 results, input_data, rate_eq_obj)
1081 if c_n_all is None:
1082 return None
1083 N = c_n_all.shape[0]
1084 Omega = input_data.derived.get('Omega', _OMEGA)
1085 b_111 = input_data.derived.get('b_111', _B_111)
1086 pref = 2.0 * np.sqrt(Omega / (np.pi * b_111)) * 1e9
1087 bins_full = _visualization_bins(rate_eq_obj, N, kind='sia')
1088 mu0_arr = _aggregate_to_bins(c_n_all, bins_full)
1090 bins_full, mu0_arr, dose,
1091 n_times=n_times,
1092 xform=lambda n: pref * np.sqrt(float(n)),
1093 n_min=_N_MIN_TEM,
1094 xlabel='SIA loop diameter (nm)',
1095 ylabel=_DENSITY_LABEL_DIAM,
1096 plot_title=rf'TEM-Visible SIA Loop Diameters ($n\geq{_N_MIN_TEM}$) {title}',
1097 cmap_name='viridis', use_logx=False, out_path=out_path,
1098 # Without a floor the axis follows the near-zero tail of the earliest
1099 # snapshots down through eighteen decades.
1100 ymin=1e16)
1101
1102
1103def plot_vac_distribution_tem_size(results, input_data, rate_eq_obj,
1104 n_times=8, out_path=None, title=''):
1105 """Cavity distribution density vs cluster size (TEM-visible, m ≥ N_MIN_TEM)."""
1106 _check_mpl()
1107 _, c_v_all, dose = _reconstruct_distributions(
1108 results, input_data, rate_eq_obj)
1109 if c_v_all is None:
1110 return None
1111 M = c_v_all.shape[0]
1112 bins_full = _visualization_bins(rate_eq_obj, M, kind='vac')
1113 mu0_arr = _aggregate_to_bins(c_v_all, bins_full)
1115 bins_full, mu0_arr, dose,
1116 n_times=n_times,
1117 xform=lambda m: float(m),
1118 n_min=_N_MIN_TEM,
1119 xlabel='Cavity cluster size m',
1120 ylabel=_DENSITY_LABEL_SIZE,
1121 plot_title=rf'TEM-Visible Cavity Distribution ($m\geq{_N_MIN_TEM}$) {title}',
1122 cmap_name='plasma', use_logx=True, out_path=out_path,
1123 # Same reason as the other TEM panels: the near-zero tail of the
1124 # earliest snapshots otherwise stretches the axis over empty decades.
1125 ymin=1e14)
1126
1127
1128def plot_vac_distribution_tem_diameter(results, input_data, rate_eq_obj,
1129 n_times=8, out_path=None, title=''):
1130 """Cavity distribution density vs void diameter (TEM-visible, m ≥ N_MIN_TEM)."""
1131 _check_mpl()
1132 _, c_v_all, dose = _reconstruct_distributions(
1133 results, input_data, rate_eq_obj)
1134 if c_v_all is None:
1135 return None
1136 M = c_v_all.shape[0]
1137 r0 = results.get('r0', input_data.derived['r0'])
1138 pref = 2.0 * r0 * 1e9
1139 bins_full = _visualization_bins(rate_eq_obj, M, kind='vac')
1140 mu0_arr = _aggregate_to_bins(c_v_all, bins_full)
1142 bins_full, mu0_arr, dose,
1143 n_times=n_times,
1144 xform=lambda m: pref * float(m) ** (1.0 / 3.0),
1145 n_min=_N_MIN_TEM,
1146 xlabel='Void diameter (nm)',
1147 ylabel=_DENSITY_LABEL_DIAM,
1148 plot_title=rf'TEM-Visible Cavity Diameters ($m\geq{_N_MIN_TEM}$) {title}',
1149 cmap_name='plasma', use_logx=False, out_path=out_path,
1150 # Same reason as the SIA panels: the near-zero tail of the earliest
1151 # snapshots otherwise stretches the axis over many empty decades.
1152 ymin=1e17)
1153
1154
1155# Doses at which the smooth diameter distributions are drawn.
1156_SMOOTH_DOSES = (1.0, 5.0, 10.0, 20.0, 40.0)
1157
1158
1159def _interp_bins_at_doses(mu0_arr, dose, targets):
1160 """Interpolate every bin's mu0 onto `targets`, linearly in log-log.
1161
1162 The solution is written out on a coarse dose grid -- in the reference run
1163 only 37 points, with a single stored point covering 6.2-15.7 dpa -- so
1164 snapping to the nearest stored dose would hand two different requested
1165 doses the same curve. Interpolating in log(mu0) vs log(dose) is exact at
1166 the stored points and smooth between them. THESE CURVES ARE THEREFORE A
1167 DISPLAY INTERPOLATION, not solver output at those doses; write the run out
1168 on a finer dose grid if the intermediate curves need to be exact.
1169 """
1170 d = np.asarray(dose, dtype=float)
1171 ok = d > 0
1172 ld = np.log10(d[ok])
1173 lt = np.log10(np.clip(np.asarray(targets, dtype=float),
1174 10.0 ** ld.min(), 10.0 ** ld.max()))
1175 out = np.empty((mu0_arr.shape[0], len(lt)))
1176 for k in range(mu0_arr.shape[0]):
1177 out[k] = 10.0 ** np.interp(
1178 lt, ld, np.log10(np.maximum(mu0_arr[k, ok], 1e-300)))
1179 return out
1180
1181
1182def _plot_smooth_diameter_panel(bins_full, mu0_arr, dose, *, xform, n_min,
1183 xlabel, ylabel, plot_title, cmap_name,
1184 out_path, ymin, xmax=None,
1185 doses=_SMOOTH_DOSES):
1186 """Per-bin density vs diameter as smooth curves through the bin midpoints.
1187
1188 The stair rendering of `_plot_tem_density_panel` is faithful to the
1189 coarse-grained representation but hard to read when several doses overlap.
1190 Here each visualization bin contributes one point -- the midpoint of its
1191 diameter interval, at height rho_k = mu0_k / (d_hi - d_lo) -- and the points
1192 are joined. Same quantity, same normalisation: the area under the curve
1193 still approximates the concentration in the displayed range.
1194 """
1195 keep = [k for k, (nlo, _nhi) in enumerate(bins_full) if nlo >= n_min]
1196 if not keep:
1197 return None
1198
1199 edges_x = np.array(
1200 [xform(bins_full[keep[0]][0])]
1201 + [xform(bins_full[k][1]) for k in keep],
1202 dtype=float)
1203 widths = np.diff(edges_x)
1204 widths = np.where(widths > 0.0, widths, 1.0)
1205 mid_x = 0.5 * (edges_x[:-1] + edges_x[1:])
1206
1207 vals = _interp_bins_at_doses(mu0_arr, dose, doses)
1208 cmap = getattr(plt.cm, cmap_name)(np.linspace(0.05, 0.70, len(doses)))
1209
1210 fig, ax = plt.subplots(figsize=(8, 6))
1211 for j, (dpa, color) in enumerate(zip(doses, cmap)):
1212 rho = np.maximum(vals[keep, j] / widths, 1e-30)
1213 ax.plot(mid_x, rho, '-o', color=color, ms=5, mec='white', mew=0.8,
1214 label=f'{dpa:g} dpa')
1215
1216 ax.set_yscale('log')
1217 ax.set_xlabel(xlabel)
1218 ax.set_ylabel(ylabel)
1219 _set_title(ax, plot_title)
1220 ax.legend(title='Dose', loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
1221 ax.grid(True, which='both', alpha=0.3)
1222 ax.set_ylim(bottom=ymin)
1223 # The x axis otherwise follows the bin grid, which runs well past the
1224 # largest populated bin and leaves the right of the panel empty.
1225 ax.set_xlim(0.0, xmax if xmax is not None else edges_x[-1])
1226
1227 _apply_axis_config_to_fig(fig, 'size_dist')
1228 fig.tight_layout()
1229 if out_path:
1230 fig.savefig(out_path, dpi=150, bbox_inches='tight')
1231 return fig
1232
1233
1234def plot_vac_distribution_diameter_smooth(results, input_data, rate_eq_obj,
1235 out_path=None, title=''):
1236 """Cavity diameter distribution as smooth curves at fixed doses."""
1237 _check_mpl()
1238 _, c_v_all, dose = _reconstruct_distributions(
1239 results, input_data, rate_eq_obj)
1240 if c_v_all is None:
1241 return None
1242 M = c_v_all.shape[0]
1243 r0 = results.get('r0', input_data.derived['r0'])
1244 pref = 2.0 * r0 * 1e9
1245 bins_full = _visualization_bins(rate_eq_obj, M, kind='vac')
1246 mu0_arr = _aggregate_to_bins(c_v_all, bins_full)
1248 bins_full, mu0_arr, dose,
1249 xform=lambda m: pref * float(m) ** (1.0 / 3.0),
1250 n_min=_N_MIN_TEM,
1251 xlabel='Void diameter (nm)',
1252 ylabel=_DENSITY_LABEL_DIAM,
1253 plot_title=rf'Cavity Diameter Distribution ($m\geq{_N_MIN_TEM}$) {title}',
1254 cmap_name='plasma', out_path=out_path,
1255 # Peak density is ~7e23 and the lowest populated bin ~2e20, so 1e20
1256 # frames the whole distribution with no empty band beneath it.
1257 ymin=1e20)
1258
1259
1260def plot_sia_distribution_diameter_smooth(results, input_data, rate_eq_obj,
1261 out_path=None, title=''):
1262 """SIA loop diameter distribution as smooth curves at fixed doses."""
1263 _check_mpl()
1264 c_n_all, _, dose = _reconstruct_distributions(
1265 results, input_data, rate_eq_obj)
1266 if c_n_all is None:
1267 return None
1268 N = c_n_all.shape[0]
1269 Omega = input_data.derived.get('Omega', _OMEGA)
1270 b_111 = input_data.derived.get('b_111', _B_111)
1271 pref = 2.0 * np.sqrt(Omega / (np.pi * b_111)) * 1e9
1272 bins_full = _visualization_bins(rate_eq_obj, N, kind='sia')
1273 mu0_arr = _aggregate_to_bins(c_n_all, bins_full)
1275 bins_full, mu0_arr, dose,
1276 xform=lambda n: pref * np.sqrt(float(n)),
1277 n_min=_N_MIN_TEM,
1278 xlabel='SIA loop diameter (nm)',
1279 ylabel=_DENSITY_LABEL_DIAM,
1280 plot_title=rf'SIA Loop Diameter Distribution ($n\geq{_N_MIN_TEM}$) {title}',
1281 cmap_name='viridis', out_path=out_path,
1282 # Peak density is ~5e21; 1e18 frames the region where the five doses
1283 # actually separate, and drops the long low tail below it.
1284 ymin=1e18, xmax=30.0)
1285
1286
1287def _reconstruct_distributions(results, input_data, rate_eq_obj):
1288 """
1289 Reconstruct per-size c_n [N, n_t] and c_v [M, n_t] in m^-3 from
1290 ODE state, handling both full_CD and bin-moment modes.
1291
1292 Returns (c_n, c_v, dose) or (None, None, None) on failure.
1293 """
1294 from .bin_moment_rates import (reconstruct_distribution,
1295 distribution_from_moments_continuous)
1296
1297 N = input_data.I
1298 M = input_data.V
1299 y, dose = _align_dose_to_y(results)
1300 Omega = results.get('Omega', input_data.derived['Omega'])
1301 inv_O = 1.0 / Omega
1302 n_t = y.shape[1]
1303
1304 is_bin = hasattr(rate_eq_obj, 'bins')
1305 I_bin = getattr(rate_eq_obj, 'I_bin', getattr(rate_eq_obj, 'K_i', 0))
1306 V_bin = getattr(rate_eq_obj, 'V_bin', getattr(rate_eq_obj, 'K_v', 0))
1307 i_d = getattr(rate_eq_obj, 'i_discrete', 0)
1308 v_d = getattr(rate_eq_obj, 'v_discrete', 0)
1309 iv = getattr(rate_eq_obj, 'i_VAC', N)
1310 P = getattr(rate_eq_obj, 'n_mom', 2)
1311 sf = getattr(rate_eq_obj, 'shape_function', 'linear')
1312
1313 c_n_all = np.zeros((N, n_t))
1314 c_v_all = np.zeros((M, n_t))
1315
1316 def _bridge_discrete_to_bin(c_arr, j_idx, i_disc, bins_list):
1317 # Visualization-only smoothing across the discrete↔bin transition.
1318 # Adjusts only the leftmost-bin's first value (closure side) and
1319 # rescales that bin so its zeroth moment is preserved.
1320 if i_disc < 1 or not bins_list:
1321 return
1322 nlo, nhi = bins_list[0]
1323 lo, hi = nlo - 1, nhi - 1 # 0-indexed bin extent (hi exclusive)
1324 if hi <= lo:
1325 return
1326 v_disc = c_arr[i_disc - 1, j_idx]
1327 v_bin = c_arr[lo, j_idx]
1328 if v_disc <= 0.0 or v_bin <= 0.0:
1329 return
1330 pre_sum = c_arr[lo:hi, j_idx].sum()
1331 if pre_sum <= 0.0:
1332 return
1333 c_arr[lo, j_idx] = np.sqrt(v_disc * v_bin)
1334 new_sum = c_arr[lo:hi, j_idx].sum()
1335 if new_sum > 0.0:
1336 c_arr[lo:hi, j_idx] *= pre_sum / new_sum
1337
1338 for j in range(n_t):
1339 yj = np.maximum(y[:, j], 0.0)
1340 if is_bin:
1341 # Discrete SIA sizes
1342 c_n_all[:i_d, j] = yj[:i_d] * inv_O
1343 # Binned SIA sizes (reconstruct from moments)
1344 if I_bin > 0:
1345 mom = yj[i_d:i_d + P * I_bin]
1346 mu0 = mom[0::P][:I_bin]
1347 mu1 = mom[1::P][:I_bin] if P >= 2 else None
1348 mu2 = mom[2::P][:I_bin] if P >= 3 else None
1349 c_binned = reconstruct_distribution(
1350 sf, mu0, mu1, mu2, rate_eq_obj.bins, N,
1351 smooth_edges=True) * inv_O
1352 c_n_all[i_d:, j] = c_binned[i_d:]
1353 _bridge_discrete_to_bin(c_n_all, j, i_d, rate_eq_obj.bins)
1354 else:
1355 c_n_all[:, j] = yj[:N] * inv_O
1356
1357 if is_bin:
1358 # Discrete vacancy sizes
1359 c_v_all[:v_d, j] = yj[iv:iv + v_d] * inv_O
1360 # Binned vacancy sizes
1361 if V_bin > 0:
1362 vac_start = iv + v_d
1363 vmom = yj[vac_start:vac_start + P * V_bin]
1364 vmu0 = vmom[0::P][:V_bin]
1365 vmu1 = vmom[1::P][:V_bin] if P >= 2 else None
1366 vmu2 = vmom[2::P][:V_bin] if P >= 3 else None
1367 # Vacancy distribution decays steeply, so the linear hat
1368 # function clamps at bin edges and produces sawtooth cliffs.
1369 # For viz only, use the C⁰-continuous reconstruction when
1370 # the linear closure is active.
1371 if sf == 'linear':
1372 c_v_binned = distribution_from_moments_continuous(
1373 vmu0, vmu1, rate_eq_obj.vac_bins, M) * inv_O
1374 else:
1375 c_v_binned = reconstruct_distribution(
1376 sf, vmu0, vmu1, vmu2,
1377 rate_eq_obj.vac_bins, M, smooth_edges=True) * inv_O
1378 c_v_all[v_d:, j] = c_v_binned[v_d:]
1379 _bridge_discrete_to_bin(c_v_all, j, v_d, rate_eq_obj.vac_bins)
1380 else:
1381 c_v_all[:, j] = yj[iv:iv + M] * inv_O
1382
1383 return c_n_all, c_v_all, dose
1384
1385
1386# ── Bin-moment-aware plots ────────────────────────────────────────────────────
1387
1388def plot_sia_conc_vs_dose(results, input_data, rate_eq_obj,
1389 out_path=None, title=''):
1390 """
1391 (1) SIA concentrations vs. dose — discrete sizes + bin mu0.
1392
1393 Discrete sizes plotted individually; each bin as one curve.
1394 """
1395 _check_mpl()
1396 is_bin = hasattr(rate_eq_obj, 'bins')
1397 I_bin = getattr(rate_eq_obj, 'I_bin', getattr(rate_eq_obj, 'K_i', 0))
1398 i_d = getattr(rate_eq_obj, 'i_discrete', 0)
1399 Omega = results.get('Omega', input_data.derived['Omega'])
1400 inv_O = 1.0 / Omega
1401 y, dose = _align_dose_to_y(results)
1402
1403 fig, ax = plt.subplots(figsize=(8, 6))
1404 n_curves = i_d + I_bin if is_bin else min(input_data.I, 20)
1405 cmap = plt.cm.viridis(np.linspace(0.05, 0.70, max(n_curves, 1)))
1406
1407 if is_bin:
1408 ci = 0
1409 # Discrete SIA sizes (1..i_discrete)
1410 for n in range(1, i_d + 1):
1411 cn = np.maximum(y[n - 1, :], 0.0) * inv_O
1412 ax.loglog(dose, np.maximum(cn, 1e-10), color=cmap[ci],
1413 label=f'n={n}')
1414 ci += 1
1415 # Binned SIA (mu0 for each bin)
1416 P = getattr(rate_eq_obj, 'n_mom', 2)
1417 for k in range(I_bin):
1418 nlo, nhi = rate_eq_obj.bins[k]
1419 mu0 = np.maximum(y[i_d + P*k, :], 0.0) * inv_O
1420 ax.loglog(dose, np.maximum(mu0, 1e-10), color=cmap[ci],
1421 label=f'n={nlo}-{nhi-1}')
1422 ci += 1
1423 else:
1424 I = input_data.I
1425 ns_show = [1, 2, 5, 10, 20, 50, 100]
1426 cmap = plt.cm.viridis(np.linspace(0.05, 0.70, len(ns_show)))
1427 for i, n in enumerate(ns_show):
1428 if n > I:
1429 break
1430 ax.loglog(dose, np.maximum(y[n-1, :] * inv_O, 1e-10),
1431 color=cmap[i], label=f'n={n}')
1432
1433 ax.set_xlabel('Dose (dpa)')
1434 ax.set_ylabel(_CONC_LABEL)
1435 _set_title(ax, f'SIA Cluster Concentrations {title}')
1436 ax.grid(True, which='both', alpha=0.3)
1437 full_dose = results['dose']
1438 # Fixed left/bottom bounds; the x fallback covers runs shorter than 1e-9 dpa.
1439 ax.set_xlim(left=1e-9 if float(np.max(full_dose)) > 1e-9 else _dose_xlim(full_dose),
1440 right=full_dose[-1])
1441 ax.set_ylim(bottom=1e16)
1442 _apply_axis_config(ax, 'concentration')
1443 fig.tight_layout()
1444 # No legend. These figures carry one curve per discrete size plus one per
1445 # bin -- over a hundred entries here -- and the legend that named them all
1446 # was several times the size of the axes and drawn across them. Curve
1447 # identity is carried by the colormap, which runs monotonically from the
1448 # smallest size to the largest.
1449 if out_path:
1450 fig.savefig(out_path, dpi=150, bbox_inches='tight')
1451 return fig
1452
1453
1454def _plot_vac_moment_vs_dose(results, input_data, rate_eq_obj,
1455 *, weight, ylabel, plot_title, out_path,
1456 ymin=None):
1457 """
1458 Cavity moment vs. dose — discrete sizes + bin mu0, weighted by `weight`.
1459
1460 weight='mu0' → plot mu0 (number density per bin).
1461 weight='content'→ plot mu0 * (m or midpoint) (vacancy content per bin).
1462 ymin → fixed y floor; None leaves it to the data (the two
1463 weightings do not share a scale).
1464 """
1465 _check_mpl()
1466 is_bin = hasattr(rate_eq_obj, 'bins')
1467 V_bin = getattr(rate_eq_obj, 'V_bin', getattr(rate_eq_obj, 'K_v', 0))
1468 v_d = getattr(rate_eq_obj, 'v_discrete', 0)
1469 iv = getattr(rate_eq_obj, 'i_VAC', input_data.I)
1470 Omega = results.get('Omega', input_data.derived['Omega'])
1471 inv_O = 1.0 / Omega
1472 y, dose = _align_dose_to_y(results)
1473 use_content = (weight == 'content')
1474
1475 fig, ax = plt.subplots(figsize=(8, 6))
1476 n_curves = v_d + V_bin if is_bin else min(input_data.V, 20)
1477 cmap = plt.cm.plasma(np.linspace(0.05, 0.72, max(n_curves, 1)))
1478
1479 if is_bin:
1480 ci = 0
1481 for m in range(1, v_d + 1):
1482 cv = np.maximum(y[iv + m - 1, :], 0.0) * inv_O
1483 vals = cv * m if use_content else cv
1484 ax.loglog(dose, np.maximum(vals, 1e-10), color=cmap[ci],
1485 label=f'm={m}')
1486 ci += 1
1487 if V_bin > 0:
1488 vac_mid = rate_eq_obj.vac_mid
1489 for k in range(V_bin):
1490 mlo, mhi = rate_eq_obj.vac_bins[k]
1491 mu0 = np.maximum(y[iv + v_d + k, :], 0.0) * inv_O
1492 vals = mu0 * vac_mid[k] if use_content else mu0
1493 ax.loglog(dose, np.maximum(vals, 1e-10), color=cmap[ci],
1494 label=f'm={mlo}-{mhi-1}')
1495 ci += 1
1496 else:
1497 V = input_data.V
1498 ms_show = [1, 2, 5, 10, 20, 50, 100]
1499 cmap = plt.cm.plasma(np.linspace(0.05, 0.72, len(ms_show)))
1500 for i, m in enumerate(ms_show):
1501 if m > V:
1502 break
1503 cv = np.maximum(y[iv + m - 1, :] * inv_O, 1e-10)
1504 vals = cv * m if use_content else cv
1505 ax.loglog(dose, vals, color=cmap[i], label=f'm={m}')
1506
1507 full_dose = results['dose']
1508 ax.set_xlabel('Dose (dpa)')
1509 ax.set_ylabel(ylabel)
1510 _set_title(ax, plot_title)
1511 ax.grid(True, which='both', alpha=0.3)
1512 # Fixed left bound; the fallback covers runs shorter than 1e-9 dpa.
1513 ax.set_xlim(left=1e-9 if float(np.max(full_dose)) > 1e-9 else _dose_xlim(full_dose),
1514 right=full_dose[-1])
1515 if ymin is not None:
1516 ax.set_ylim(bottom=ymin)
1517
1518 _apply_axis_config(ax, 'concentration')
1519 fig.tight_layout()
1520 # No legend. These figures carry one curve per discrete size plus one per
1521 # bin -- over a hundred entries here -- and the legend that named them all
1522 # was several times the size of the axes and drawn across them. Curve
1523 # identity is carried by the colormap, which runs monotonically from the
1524 # smallest size to the largest.
1525 if out_path:
1526 fig.savefig(out_path, dpi=150, bbox_inches='tight')
1527 return fig
1528
1529
1530def plot_vac_conc_vs_dose(results, input_data, rate_eq_obj,
1531 out_path=None, title=''):
1532 """Cavity zeroth moment μ_k^(0) (number density per bin) vs. dose."""
1534 results, input_data, rate_eq_obj,
1535 weight='mu0',
1536 ylabel=_CONC_LABEL,
1537 plot_title=f'Cavity Cluster Concentrations {title}',
1538 out_path=out_path, ymin=1e17)
1539
1540
1541def plot_vac_content_vs_dose(results, input_data, rate_eq_obj,
1542 out_path=None, title=''):
1543 """Cavity content per bin (μ_k^(0) × m̄_k) vs. dose."""
1545 results, input_data, rate_eq_obj,
1546 weight='content',
1547 ylabel=r'Content $\mu_k^{(0)} \times \bar{m}_k$ (m$^{-3}$)',
1548 plot_title=r'Cavity content per bin ' + title,
1549 out_path=out_path, ymin=1e17)
1550
1551
1553 """Return 4 time indices at ~25%, 50%, 75%, 100% of the log-dose range."""
1554 return _logspaced_indices(dose, 4)
1555
1556
1557def _logspaced_indices(dose, n_snap=8):
1558 """Return *n_snap* time indices log-spaced over the dose range."""
1559 d = np.asarray(dose)
1560 pos = d[d > 0]
1561 if len(pos) < n_snap:
1562 return list(range(min(n_snap, len(d))))
1563 log_min, log_max = np.log10(pos[0]), np.log10(d[-1])
1564 fracs = np.linspace(1.0 / n_snap, 1.0, n_snap)
1565 targets = [10**(log_min + f * (log_max - log_min)) for f in fracs]
1566 return [int(np.argmin(np.abs(d - t))) for t in targets]
1567
1568
1569def plot_sia_fraction_breakdown(results, out_path=None, title=''):
1570 """
1571 SIA content fraction breakdown vs. dose.
1572
1573 Three curves that should sum to 1.0 at every dose:
1574 - In clusters (content in system / total produced)
1575 - At fixed sinks (cumulative fixed-sink loss / total produced)
1576 - Recombined + cavity (mutual annihilation / total produced)
1577 """
1578 _check_mpl()
1579 dose = results['dose']
1580 eta_G = results.get('eta_G', 0)
1581 t = results['t']
1582 prod = eta_G * t
1583
1584 # All in at.frac — convert C_SIA_tot back from m^-3
1585 Omega = results.get('Omega', 1.18e-29)
1586 C_SIA = results['C_SIA_tot'] * Omega
1587 J_fixed = results.get('J_SIA_fixed', np.zeros_like(t))
1588 J_mutual = results.get('J_SIA_mutual', np.zeros_like(t))
1589
1590 fig, ax = plt.subplots(figsize=(8, 5))
1591 mask = prod > 1e-300
1592 f_clusters = np.where(mask, C_SIA / prod, 0.0)
1593 f_fixed = np.where(mask, J_fixed / prod, 0.0)
1594 f_mutual = np.where(mask, J_mutual / prod, 0.0)
1595 f_total = f_clusters + f_fixed + f_mutual
1596
1597 ax.semilogx(dose[mask], f_clusters[mask], color='steelblue', lw=_LW,
1598 label='In clusters')
1599 ax.semilogx(dose[mask], f_fixed[mask], color='firebrick', lw=_LW,
1600 label='Fixed sinks')
1601 ax.semilogx(dose[mask], f_mutual[mask], color='forestgreen', lw=_LW,
1602 label='Recombination + cavity')
1603 ax.semilogx(dose[mask], f_total[mask], color='black', lw=_LW_THIN, ls='--',
1604 label='Sum (should = 1)')
1605
1606 ax.set_xlabel('Dose (dpa)')
1607 ax.set_ylabel('Fraction of total SIA produced')
1608 _set_title(ax, f'SIA Content Balance {title}')
1609 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
1610 ax.grid(True, alpha=0.3)
1611 ax.set_ylim(-0.05, 1.15)
1612 ax.set_xlim(left=_dose_xlim(dose))
1613 _apply_axis_config(ax, 'scalar')
1614 fig.tight_layout()
1615 if out_path:
1616 fig.savefig(out_path, dpi=150, bbox_inches='tight')
1617 return fig
1618
1619
1620def plot_vac_fraction_breakdown(results, out_path=None, title=''):
1621 """
1622 Vacancy content fraction breakdown vs. dose.
1623
1624 Three curves that should sum to 1.0:
1625 - In clusters (swelling content / total produced)
1626 - At fixed sinks (cumulative fixed-sink loss / total produced)
1627 - Annihilated by SIA (SIA-cavity + recombination / total produced)
1628 """
1629 _check_mpl()
1630 dose = results['dose']
1631 eta_G = results.get('eta_G', 0)
1632 t = results['t']
1633 prod = eta_G * t
1634
1635 Omega = results.get('Omega', 1.18e-29)
1636 C_VAC = results['C_VAC_tot'] * Omega
1637 J_fixed = results.get('J_VAC_fixed', np.zeros_like(t))
1638 J_mutual = results.get('J_VAC_mutual', np.zeros_like(t)) # VAC-specific mutual
1639
1640 fig, ax = plt.subplots(figsize=(8, 5))
1641 mask = prod > 1e-300
1642 f_clusters = np.where(mask, C_VAC / prod, 0.0)
1643 f_fixed = np.where(mask, J_fixed / prod, 0.0)
1644 f_mutual = np.where(mask, J_mutual / prod, 0.0)
1645 f_total = f_clusters + f_fixed + f_mutual
1646
1647 ax.semilogx(dose[mask], f_clusters[mask], color='tomato', lw=_LW,
1648 label='In clusters (swelling)')
1649 ax.semilogx(dose[mask], f_fixed[mask], color='firebrick', lw=_LW,
1650 label='Fixed sinks')
1651 ax.semilogx(dose[mask], f_mutual[mask], color='forestgreen', lw=_LW,
1652 label='Annihilated by SIA')
1653 ax.semilogx(dose[mask], f_total[mask], color='black', lw=_LW_THIN, ls='--',
1654 label='Sum (should = 1)')
1655
1656 ax.set_xlabel('Dose (dpa)')
1657 ax.set_ylabel('Fraction of total VAC produced')
1658 _set_title(ax, f'Vacancy Content Balance {title}')
1659 ax.legend(loc='best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
1660 ax.grid(True, alpha=0.3)
1661 ax.set_ylim(-0.05, 1.15)
1662 ax.set_xlim(left=_dose_xlim(dose))
1663 _apply_axis_config(ax, 'scalar')
1664 fig.tight_layout()
1665 if out_path:
1666 fig.savefig(out_path, dpi=150, bbox_inches='tight')
1667 return fig
1668
1669
1670PLOT_DATA_FILENAME = 'plot_data.pkl'
1671
1672
1673def _atomic_pickle_dump(payload, out_path):
1674 """Pickle to a sibling .tmp file, fsync, then atomic-rename to out_path.
1675
1676 A crash, Ctrl+C, or OOM during the dump leaves the .tmp behind (cleaned
1677 up here on its way out) and never touches an existing good file at
1678 out_path. Callers can therefore re-load the previous run's pickle even
1679 if the latest save was interrupted.
1680 """
1681 import os, pickle
1682 out_path = str(out_path)
1683 tmp_path = out_path + '.tmp'
1684 try:
1685 with open(tmp_path, 'wb') as f:
1686 pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL)
1687 f.flush()
1688 os.fsync(f.fileno())
1689 os.replace(tmp_path, out_path)
1690 except BaseException:
1691 try:
1692 os.unlink(tmp_path)
1693 except OSError:
1694 pass
1695 raise
1696
1697
1698def save_plot_data(results, input_data, out_path, *, rate_eq_obj=None, label=''):
1699 """
1700 Pickle everything needed to regenerate the plots later.
1701
1702 The dump contains the full `results` dict, the `InputData` object, the
1703 rate-equation object (if provided), and the title `label` so a notebook
1704 can call any plot function later without re-running the simulation.
1705
1706 Writes are atomic (temp file + os.replace) so an interrupt mid-dump
1707 never leaves a truncated pickle at `out_path`.
1708 """
1709 payload = {
1710 'results': results,
1711 'input_data': input_data,
1712 'rate_eq_obj': rate_eq_obj,
1713 'label': label,
1714 }
1715 _atomic_pickle_dump(payload, out_path)
1716
1717
1719 """Inverse of save_plot_data — returns the dict that was pickled."""
1720 import pickle
1721 with open(path, 'rb') as f:
1722 return pickle.load(f)
1723
1724
1725def _safe_save(artifact, fn, *args, **kwargs):
1726 """Run a save call, catching/printing any failure with a traceback.
1727
1728 KeyboardInterrupt is NOT caught here — it propagates so the outer
1729 interrupt shield (see simulation._InterruptDeferred) can decide whether
1730 to defer it through the rest of the save or escalate.
1731 """
1732 try:
1733 fn(*args, **kwargs)
1734 return True
1735 except KeyboardInterrupt:
1736 raise
1737 except Exception as exc:
1738 import traceback
1739 print(f" ✗ {artifact}: {type(exc).__name__}: {exc}")
1740 traceback.print_exc()
1741 return False
1742
1743
1744def save_all_plots(results, input_data, out_dir, label='',
1745 rate_eq_obj=None):
1746 """Save all standard plots to out_dir/.
1747
1748 Each artifact (the pickle and every PNG) is wrapped in its own
1749 try/except so a single failure cannot abort the rest. The pickle uses
1750 atomic writes; if it fails, any pre-existing good pickle is preserved.
1751 """
1752 from pathlib import Path
1753 Path(out_dir).mkdir(parents=True, exist_ok=True)
1754
1755 # Persist plotting inputs (atomic; previous good file is preserved on failure).
1756 _safe_save(PLOT_DATA_FILENAME,
1757 save_plot_data, results, input_data,
1758 f"{out_dir}/{PLOT_DATA_FILENAME}",
1759 rate_eq_obj=rate_eq_obj, label=label)
1760
1761 opts = dict(title=label)
1762 common = [
1763 ('sia_balance.png', plot_sia_fraction_breakdown, (results,)),
1764 ('vac_balance.png', plot_vac_fraction_breakdown, (results,)),
1765 ('point_defects.png', plot_point_defects, (results,)),
1766 ('totals.png', plot_totals, (results,)),
1767 ('swelling.png', plot_swelling, (results,)),
1768 ('network_density.png', plot_network_density, (results,)),
1769 ('mean_sizes.png', plot_mean_sizes, (results,)),
1770 ('he_content.png', plot_he_content, (results,)),
1771 ('number_densities.png', plot_number_densities, (results,)),
1772 # ½⟨111⟩/⟨100⟩ loop fraction (loop conversion; auto-skips if off)
1773 ('loop_fraction.png', plot_loop_fraction, (results,)),
1774 ('size_distributions.png', plot_size_distribution, (results, input_data)),
1775 ('sia_dist_evolution.png', plot_sia_distribution_evolution, (results, input_data)),
1776 # ⟨100⟩ loop distribution evolution (loop conversion; auto-skips if off)
1777 ('loop100_dist_evolution.png', plot_100_distribution_evolution, (results, input_data)),
1778 ('void_dist_evolution.png', plot_void_distribution_evolution, (results, input_data)),
1779 # Per-cluster time-series (full_CD modes only; skipped for bin_moment)
1780 ('sia_small.png', plot_sia_clusters, (results, input_data)),
1781 ('vac_small.png', plot_vac_clusters, (results, input_data)),
1782 ]
1783 for name, fn, args in common:
1784 _safe_save(name, fn, *args, out_path=f"{out_dir}/{name}", **opts)
1785
1786 # (1)-(4) Bin-moment-aware plots (work for both full_CD and bin_moment)
1787 if rate_eq_obj is not None:
1788 bin_aware = [
1789 ('sia_bin_conc.png', plot_sia_conc_vs_dose),
1790 ('vac_bin_conc.png', plot_vac_conc_vs_dose),
1791 ('vac_bin_content.png', plot_vac_content_vs_dose),
1792 # TEM-visible plots (n,m ≥ 10)
1793 ('number_densities_tem.png', plot_number_densities_tem),
1794 ('mean_sizes_tem.png', plot_mean_sizes_tem),
1795 ('sia_dist_tem_size.png', plot_sia_distribution_tem_size),
1796 ('sia_dist_tem_diameter.png', plot_sia_distribution_tem_diameter),
1797 ('vac_dist_tem_size.png', plot_vac_distribution_tem_size),
1798 ('vac_dist_tem_diameter.png', plot_vac_distribution_tem_diameter),
1799 # Smooth (midpoint) diameter distributions at fixed doses
1800 ('sia_dist_diameter_smooth.png', plot_sia_distribution_diameter_smooth),
1801 ('vac_dist_diameter_smooth.png', plot_vac_distribution_diameter_smooth),
1802 ]
1803 for name, fn in bin_aware:
1804 _safe_save(name, fn, results, input_data, rate_eq_obj,
1805 out_path=f"{out_dir}/{name}", **opts)
1806
1807 print(f"Saved plots to {out_dir}/")
_b_100(results, input_data, b_111)
plot_mean_sizes(results, out_path=None, title='')
_plot_vac_moment_vs_dose(results, input_data, rate_eq_obj, *weight, ylabel, plot_title, out_path, ymin=None)
plot_sia_distribution_diameter_smooth(results, input_data, rate_eq_obj, out_path=None, title='')
plot_mean_sizes_tem(results, input_data, rate_eq_obj, out_path=None, title='')
_plot_tem_density_panel(bins_full, mu0_arr, dose, *n_times, xform, n_min, xlabel, ylabel, plot_title, cmap_name, use_logx, out_path, ymin=None)
_reconstruct_distributions(results, input_data, rate_eq_obj)
plot_vac_conc_vs_dose(results, input_data, rate_eq_obj, out_path=None, title='')
_logspaced_indices(dose, n_snap=8)
plot_vac_fraction_breakdown(results, out_path=None, title='')
plot_sia_clusters(results, input_data, out_path=None, title='')
_aggregate_to_bins(c_per_size, bins_full)
plot_he_content(results, out_path=None, title='')
_interp_bins_at_doses(mu0_arr, dose, targets)
plot_totals(results, out_path=None, title='')
_save_pair(figs, out_path)
plot_network_density(results, out_path=None, title='')
plot_vac_clusters(results, input_data, out_path=None, title='')
_apply_axis_config(ax, group)
plot_void_distribution_evolution(results, input_data, n_times=10, out_path=None, title='')
_extract_cluster_arrays(results, input_data)
plot_vac_distribution_diameter_smooth(results, input_data, rate_eq_obj, out_path=None, title='')
plot_sia_distribution_tem_diameter(results, input_data, rate_eq_obj, n_times=8, out_path=None, title='')
plot_number_densities(results, out_path=None, title='')
plot_size_distribution(results, input_data, t_idx=-1, out_path=None, title='')
_log_snapshot_indices(dose, n_times=10)
plot_vac_distribution_tem_diameter(results, input_data, rate_eq_obj, n_times=8, out_path=None, title='')
plot_100_distribution_evolution(results, input_data, n_times=10, out_path=None, title='')
plot_swelling(results, out_path=None, title='')
save_plot_data(results, input_data, out_path, *rate_eq_obj=None, label='')
plot_loop_fraction(results, out_path=None, title='')
save_all_plots(results, input_data, out_dir, label='', rate_eq_obj=None)
_atomic_pickle_dump(payload, out_path)
plot_number_densities_tem(results, input_data, rate_eq_obj, out_path=None, title='')
_tem_filter(c_arr, n_min=_N_MIN_TEM)
plot_sia_fraction_breakdown(results, out_path=None, title='')
_plot_smooth_diameter_panel(bins_full, mu0_arr, dose, *xform, n_min, xlabel, ylabel, plot_title, cmap_name, out_path, ymin, xmax=None, doses=_SMOOTH_DOSES)
_loop_dist_evolution(c_src, b, Omega, dose, n_times, char, title)
plot_sia_distribution_evolution(results, input_data, n_times=10, out_path=None, title='')
plot_point_defects(results, out_path=None, title='')
_visualization_bins(rate_eq_obj, max_size, kind)
plot_vac_distribution_tem_size(results, input_data, rate_eq_obj, n_times=8, out_path=None, title='')
_set_title(ax, text, **kw)
_legend_below(fig, axes, ncol=8, fontsize=None, bottom=0.28)
plot_sia_distribution_tem_size(results, input_data, rate_eq_obj, n_times=8, out_path=None, title='')
plot_sia_conc_vs_dose(results, input_data, rate_eq_obj, out_path=None, title='')
_apply_axis_config_to_fig(fig, group)
plot_vac_content_vs_dose(results, input_data, rate_eq_obj, out_path=None, title='')
_safe_save(artifact, fn, *args, **kwargs)