2visualization.py — Plotting routines for RadCluster_2_1.
4Generates standardized figures from the ODE post-processing results.
5All concentration quantities are in m^-3 (converted in post_process.py).
11 import matplotlib.pyplot
as plt
16_CONC_LABEL =
r'Concentration (m$^{-3}$)'
21_INTERIOR_LEGEND_FONTSIZE = 12
24_LW, _LW_THIN = 3.0, 2.0
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,
52 'concentration': {
'xlim': (
None,
None),
'ylim': (
None,
None),
53 'xscale':
None,
'yscale':
None},
56 'scalar': {
'xlim': (
None,
None),
'ylim': (
None,
None),
57 'xscale':
None,
'yscale':
None},
60 'size_dist': {
'xlim': (
None,
None),
'ylim': (
None,
None),
61 'xscale':
None,
'yscale':
None},
62 'legend_fontsize': _PLOT_FONTSIZE,
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):
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)
89 """Apply the named group's scale and limits to a single axis."""
90 cfg = _PLOT_CONFIG.get(group)
93 xs, ys = cfg.get(
'xscale'), cfg.get(
'yscale')
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])
112 return _PLOT_CONFIG.get(
'legend_fontsize', 5)
117 Place a single shared legend underneath the plot area.
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).
127 if not isinstance(axes, (list, tuple, np.ndarray)):
130 leg = ax.get_legend()
133 handles, labels = axes[0].get_legend_handles_labels()
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)
144 raise ImportError(
"matplotlib is required for visualization.")
149 Return the left x-limit for a dose axis.
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.
155 return float(pos.min())
if len(pos) > 0
else 1e-20
160 Return (y, dose) with matching time dimensions.
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
168 dose = results[
'dose']
176 """Free SIA (Ci1) and vacancy (Cv1) monomers vs. dose."""
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)
185 ax.legend(loc=
'best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
186 ax.grid(
True, which=
'both', alpha=0.3)
190 ax.set_xlim(left=1e-11
if float(np.max(dose)) > 1e-11
else _dose_xlim(dose))
191 ax.set_ylim(bottom=1e16)
195 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
200 """Total SIA, vacancy, and He contents vs. dose."""
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')
208 ax.set_ylim(bottom=1e11)
209 ax.set_xlabel(
'Dose (dpa)')
210 ax.set_ylabel(_CONC_LABEL)
212 ax.legend(loc=
'best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
213 ax.grid(
True, which=
'both', alpha=0.3)
217 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
222 """Void swelling S(t) vs. dose."""
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)
234 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
239 """Network dislocation density ρ_net vs. dose (loop_network_loss.tex).
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.
248 fig, ax = plt.subplots(figsize=(9, 5.5))
249 dose = np.asarray(results[
'dose'], dtype=float)
250 rho_net = results.get(
'rho_net')
253 rho_net = np.asarray(rho_net, dtype=float)
254 if rho_net.shape[0] != dose.shape[0]:
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)
266 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
270_LBL_111 =
r'$\frac{1}{2}\langle 111\rangle$ loops'
271_LBL_100 =
r'$\langle 100\rangle$ loops'
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)
283 """Mean SIA and vacancy cluster sizes vs. dose.
285 With loop conversion, the SIA loops are split into ½⟨111⟩ and ⟨100⟩.
288 fig, ax = plt.subplots(figsize=(9, 5.5))
289 dose = results[
'dose']
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)
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)')
302 ax.legend(loc=
'best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
303 ax.grid(
True, alpha=0.3)
305 ax.set_xlim(left=1e-3
if float(np.max(dose)) > 1e-3
else _dose_xlim(dose))
306 ax.set_ylim(bottom=0)
310 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
315 """Free and total He vs. dose."""
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)
324 ax.legend(loc=
'best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
325 ax.grid(
True, which=
'both', alpha=0.3)
332 ax.set_xlim(left=1e-3
if float(np.max(dose)) > 1e-3
else _dose_xlim(dose))
333 ax.set_ylim(bottom=1e12)
337 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
342 """SIA and vacancy cluster size distributions at a given time index."""
346 Omega = results.get(
'Omega', input_data.derived[
'Omega'])
347 inv_Omega = 1.0 / Omega
349 y = results[
'y'][:, t_idx]
350 if y.shape[0] < N + M:
354 c_n = np.maximum(y[:N], 0.0) * inv_Omega
355 c_v = np.maximum(y[N:N + M], 0.0) * inv_Omega
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)
361 ns = np.arange(1, N + 1)
362 ms = np.arange(1, M + 1)
365 r0 = results.get(
'r0', input_data.derived[
'r0'])
366 b_111 = results.get(
'b_111', input_data.derived[
'b_111'])
367 d_loop = 2.0 * np.sqrt(ns * Omega / (np.pi * b_111)) * 1e9
368 d_void = 2.0 * r0 * ms ** (1.0 / 3.0) * 1e9
370 time_label = f
't={results["t"][t_idx]:.2e} s'
372 fig, axes = plt.subplots(2, 2, figsize=(12, 8))
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]
387 ax1.set_ylim(bottom=c_n_pos.min() * 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]
397 ax2.set_ylim(bottom=c_v_pos.min() * 0.1)
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)
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)
421 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
427 Return (c_n, c_v, dose) arrays [size x n_t] in m^-3.
429 Works only for full_CD modes (y has per-cluster rows).
430 Returns None for bin-moment runs where y rows are moments.
435 Omega = results.get(
'Omega', input_data.derived[
'Omega'])
439 if y.shape[0] < N + M:
440 return None,
None,
None
442 c_n = np.maximum(y[:N, :], 0.0) * inv_O
443 c_v = np.maximum(y[N:N + M, :], 0.0) * inv_O
444 return c_n, c_v, dose
448 """SIA cluster concentrations vs. dose, split into small / mid / large."""
452 print(
"plot_sia_clusters: skipped (bin-moment mode).")
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'),
462 for gtitle, rng, fname
in groups:
463 idx = [n - 1
for n
in rng
if n - 1 < N]
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']
475 ax.grid(
True, which=
'both', alpha=0.3)
476 ax.set_xlim(left=
_dose_xlim(full_dose), right=full_dose[-1])
479 ncol = min(8, max(2, len(idx)))
483 from pathlib
import Path
as _P
485 fig.savefig(str(p.parent / f
'{fname}.png'), dpi=150,
492 """Vacancy cluster concentrations vs. dose, split into small / mid / large."""
496 print(
"plot_vac_clusters: skipped (bin-moment mode).")
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'),
506 for gtitle, rng, fname
in groups:
507 idx = [m - 1
for m
in rng
if m - 1 < M]
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)
519 ax.grid(
True, which=
'both', alpha=0.3)
520 ax.set_xlim(left=
_dose_xlim(full_dose), right=full_dose[-1])
523 ncol = min(8, max(2, len(idx)))
526 from pathlib
import Path
as _P
528 fig.savefig(str(p.parent / f
'{fname}.png'), dpi=150,
535 """Return n_times indices log-spaced across the dose array."""
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]
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)))
555_DIST_N_MAX, _DIST_D_MAX_NM = 5000, 10.0
559 """Shared SIA-loop size-distribution-evolution figures.
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>').
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.
570 ns = np.arange(1, N + 1)
571 d_loop = 2.0 * np.sqrt(ns * Omega / (np.pi * b)) * 1e9
573 cmap = plt.cm.viridis(np.linspace(0.05, 0.70, len(indices)))
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')
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))
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)
596 for f
in (fig1, fig2):
603 """Write the (size, diameter) figure pair as <stem>_size / _diameter PNGs."""
606 from pathlib
import Path
608 for fig, suffix
in zip(figs, (
'_size',
'_diameter')):
609 fig.savefig(p.with_name(p.stem + suffix + p.suffix), dpi=150,
615 out_path=None, title=''):
617 ½⟨111⟩ SIA cluster size distribution c_n(n) at n_times log-spaced snapshots.
619 Top panel: log-scale concentration vs cluster size n.
620 Bottom panel: linear-scale concentration vs loop diameter (nm).
624 Omega = results.get(
'Omega', input_data.derived[
'Omega'])
625 b_111 = results.get(
'b_111', input_data.derived[
'b_111'])
628 print(
"plot_sia_distribution_evolution: skipped (bin-moment mode).")
637 out_path=None, title=''):
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.
644 y100 = results.get(
'y_sia100')
645 if y100
is None or not np.asarray(y100).size
or not _has_conversion(results):
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)
650 dose = np.asarray(results[
'dose'])[:c100.shape[1]]
653 Omega, dose, n_times,
'<100>', title),
658 out_path=None, title=''):
660 Void/vacancy cluster size distribution c_m(m) at n_times log-spaced dose snapshots.
662 Top panel: log-scale concentration vs vacancy cluster size m.
663 Bottom panel: linear-scale concentration vs void diameter (nm).
668 Omega = results.get(
'Omega', input_data.derived[
'Omega'])
669 r0 = results.get(
'r0', input_data.derived[
'r0'])
672 if y.shape[0] < N + M:
673 print(
"plot_void_distribution_evolution: skipped (bin-moment mode).")
677 ms = np.arange(1, M + 1)
678 d_void = 2.0 * r0 * ms ** (1.0 / 3.0) * 1e9
680 cmap = plt.cm.plasma(np.linspace(0.05, 0.72, len(indices)))
682 fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(8, 9))
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')
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)
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)
706 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
711 """Loop and void number densities (clusters n,m ≥ 2) vs. dose."""
713 fig, ax = plt.subplots(figsize=(9, 5.5))
714 dose = results[
'dose']
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)
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)
733 ax.set_xlim(left=1e-3
if float(np.max(dose)) > 1e-3
else _dose_xlim(dose))
734 ax.set_ylim(bottom=1e14)
738 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
743 """½⟨111⟩ loop fraction f₁₁₁ vs. dose (loop conversion).
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.
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}')
763 ax.legend(loc=
'best', fontsize=_INTERIOR_LEGEND_FONTSIZE)
764 ax.grid(
True, alpha=0.3)
768 ax.set_xlim(left=1e-2
if float(np.max(dose)) > 1e-2
else _dose_xlim(dose))
772 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
786 """Return mask for TEM-visible clusters (size index >= n_min - 1)."""
788 return np.arange(1, N + 1) >= n_min
792 out_path=None, title=''):
794 Loop and void number densities vs dose — TEM-visible only (n,m ≥ 10).
795 Plotted alongside the total (all sizes) for comparison.
800 results, input_data, rate_eq_obj)
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
809 N_loops_all = np.sum(c_n_all[1:, :], axis=0)
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)
814 fig, ax = plt.subplots(figsize=(8, 5))
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}$)')
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)
846 ax.set_xlim(left=1e-3
if float(np.max(full_dose)) > 1e-3
else _dose_xlim(full_dose),
848 ax.set_ylim(bottom=1e14)
852 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
857 out_path=None, title=''):
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).
865 results, input_data, rate_eq_obj)
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
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
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)
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
895 fig, ax = plt.subplots(figsize=(8, 6))
896 full_dose = results[
'dose']
912 b_100 =
_b_100(results, input_data, b_111)
914 c100 = np.maximum(np.asarray(results[
'y_sia100'], dtype=float), 0.0) * inv_O
915 if c100.shape[1] > n_t:
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)
923 c_eff = c100[mk100, j]
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}$)')
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)
947 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
953 Build the visualization bin list covering sizes 1..max_size.
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').
961 is_bin = hasattr(rate_eq_obj,
'bins')
963 return [(n, n + 1)
for n
in range(1, max_size + 1)]
965 d_disc = getattr(rate_eq_obj,
'i_discrete', 0)
966 grouped = list(rate_eq_obj.bins)
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)]
977 Sum per-size concentrations over each visualization bin.
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).
983 n_t = c_per_size.shape[1]
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)
992 xform, n_min, xlabel, ylabel, plot_title,
993 cmap_name, use_logx, out_path, ymin=None):
995 TEM-visible per-bin density plot.
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
1007 keep = [k
for k, (nlo, _nhi)
in enumerate(bins_full)
if nlo >= n_min]
1012 [xform(bins_full[keep[0]][0])]
1013 + [xform(bins_full[k][1])
for k
in keep],
1015 widths = np.diff(edges_x)
1016 widths = np.where(widths > 0.0, widths, 1.0)
1019 cmap = getattr(plt.cm, cmap_name)(np.linspace(0.05, 0.70, len(indices)))
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')
1029 ax.set_xscale(
'log')
1030 ax.set_yscale(
'log')
1031 ax.set_xlabel(xlabel)
1032 ax.set_ylabel(ylabel)
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)
1042 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
1046_DENSITY_LABEL_SIZE =
r'$dc/dn$ (m$^{-3}$)'
1047_DENSITY_LABEL_DIAM =
r'$dc/dD$ (m$^{-3}$ nm$^{-1}$)'
1051 n_times=8, out_path=None, title=''):
1052 """SIA distribution density vs cluster size (TEM-visible, n ≥ N_MIN_TEM)."""
1055 results, input_data, rate_eq_obj)
1058 N = c_n_all.shape[0]
1062 bins_full, mu0_arr, dose,
1064 xform=
lambda n: float(n),
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,
1076 n_times=8, out_path=None, title=''):
1077 """SIA distribution density vs loop diameter (TEM-visible, n ≥ N_MIN_TEM)."""
1080 results, input_data, rate_eq_obj)
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
1090 bins_full, mu0_arr, dose,
1092 xform=
lambda n: pref * np.sqrt(float(n)),
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,
1104 n_times=8, out_path=None, title=''):
1105 """Cavity distribution density vs cluster size (TEM-visible, m ≥ N_MIN_TEM)."""
1108 results, input_data, rate_eq_obj)
1111 M = c_v_all.shape[0]
1115 bins_full, mu0_arr, dose,
1117 xform=
lambda m: float(m),
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,
1129 n_times=8, out_path=None, title=''):
1130 """Cavity distribution density vs void diameter (TEM-visible, m ≥ N_MIN_TEM)."""
1133 results, input_data, rate_eq_obj)
1136 M = c_v_all.shape[0]
1137 r0 = results.get(
'r0', input_data.derived[
'r0'])
1138 pref = 2.0 * r0 * 1e9
1142 bins_full, mu0_arr, dose,
1144 xform=
lambda m: pref * float(m) ** (1.0 / 3.0),
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,
1156_SMOOTH_DOSES = (1.0, 5.0, 10.0, 20.0, 40.0)
1160 """Interpolate every bin's mu0 onto `targets`, linearly in log-log.
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.
1170 d = np.asarray(dose, dtype=float)
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)))
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.
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.
1195 keep = [k
for k, (nlo, _nhi)
in enumerate(bins_full)
if nlo >= n_min]
1200 [xform(bins_full[keep[0]][0])]
1201 + [xform(bins_full[k][1])
for k
in keep],
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:])
1208 cmap = getattr(plt.cm, cmap_name)(np.linspace(0.05, 0.70, len(doses)))
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')
1216 ax.set_yscale(
'log')
1217 ax.set_xlabel(xlabel)
1218 ax.set_ylabel(ylabel)
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)
1225 ax.set_xlim(0.0, xmax
if xmax
is not None else edges_x[-1])
1230 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
1235 out_path=None, title=''):
1236 """Cavity diameter distribution as smooth curves at fixed doses."""
1239 results, input_data, rate_eq_obj)
1242 M = c_v_all.shape[0]
1243 r0 = results.get(
'r0', input_data.derived[
'r0'])
1244 pref = 2.0 * r0 * 1e9
1248 bins_full, mu0_arr, dose,
1249 xform=
lambda m: pref * float(m) ** (1.0 / 3.0),
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,
1261 out_path=None, title=''):
1262 """SIA loop diameter distribution as smooth curves at fixed doses."""
1265 results, input_data, rate_eq_obj)
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
1275 bins_full, mu0_arr, dose,
1276 xform=
lambda n: pref * np.sqrt(float(n)),
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,
1284 ymin=1e18, xmax=30.0)
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.
1292 Returns (c_n, c_v, dose) or (None, None, None) on failure.
1294 from .bin_moment_rates
import (reconstruct_distribution,
1295 distribution_from_moments_continuous)
1300 Omega = results.get(
'Omega', input_data.derived[
'Omega'])
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')
1313 c_n_all = np.zeros((N, n_t))
1314 c_v_all = np.zeros((M, n_t))
1316 def _bridge_discrete_to_bin(c_arr, j_idx, i_disc, bins_list):
1320 if i_disc < 1
or not bins_list:
1322 nlo, nhi = bins_list[0]
1323 lo, hi = nlo - 1, nhi - 1
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:
1330 pre_sum = c_arr[lo:hi, j_idx].sum()
1333 c_arr[lo, j_idx] = np.sqrt(v_disc * v_bin)
1334 new_sum = c_arr[lo:hi, j_idx].sum()
1336 c_arr[lo:hi, j_idx] *= pre_sum / new_sum
1338 for j
in range(n_t):
1339 yj = np.maximum(y[:, j], 0.0)
1342 c_n_all[:i_d, j] = yj[:i_d] * inv_O
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)
1355 c_n_all[:, j] = yj[:N] * inv_O
1359 c_v_all[:v_d, j] = yj[iv:iv + v_d] * inv_O
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
1372 c_v_binned = distribution_from_moments_continuous(
1373 vmu0, vmu1, rate_eq_obj.vac_bins, M) * inv_O
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)
1381 c_v_all[:, j] = yj[iv:iv + M] * inv_O
1383 return c_n_all, c_v_all, dose
1389 out_path=None, title=''):
1391 (1) SIA concentrations vs. dose — discrete sizes + bin mu0.
1393 Discrete sizes plotted individually; each bin as one curve.
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'])
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)))
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],
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}')
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):
1430 ax.loglog(dose, np.maximum(y[n-1, :] * inv_O, 1e-10),
1431 color=cmap[i], label=f
'n={n}')
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']
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)
1450 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
1455 *, weight, ylabel, plot_title, out_path,
1458 Cavity moment vs. dose — discrete sizes + bin mu0, weighted by `weight`.
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).
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'])
1473 use_content = (weight ==
'content')
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)))
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],
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}')
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):
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}')
1507 full_dose = results[
'dose']
1508 ax.set_xlabel(
'Dose (dpa)')
1509 ax.set_ylabel(ylabel)
1511 ax.grid(
True, which=
'both', alpha=0.3)
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)
1526 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
1531 out_path=None, title=''):
1532 """Cavity zeroth moment μ_k^(0) (number density per bin) vs. dose."""
1534 results, input_data, rate_eq_obj,
1537 plot_title=f
'Cavity Cluster Concentrations {title}',
1538 out_path=out_path, ymin=1e17)
1542 out_path=None, title=''):
1543 """Cavity content per bin (μ_k^(0) × m̄_k) vs. dose."""
1545 results, input_data, rate_eq_obj,
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)
1553 """Return 4 time indices at ~25%, 50%, 75%, 100% of the log-dose range."""
1558 """Return *n_snap* time indices log-spaced over the dose range."""
1559 d = np.asarray(dose)
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]
1571 SIA content fraction breakdown vs. dose.
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)
1579 dose = results[
'dose']
1580 eta_G = results.get(
'eta_G', 0)
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))
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
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)')
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)
1616 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
1622 Vacancy content fraction breakdown vs. dose.
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)
1630 dose = results[
'dose']
1631 eta_G = results.get(
'eta_G', 0)
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))
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
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)')
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)
1666 fig.savefig(out_path, dpi=150, bbox_inches=
'tight')
1670PLOT_DATA_FILENAME =
'plot_data.pkl'
1674 """Pickle to a sibling .tmp file, fsync, then atomic-rename to out_path.
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.
1682 out_path = str(out_path)
1683 tmp_path = out_path +
'.tmp'
1685 with open(tmp_path,
'wb')
as f:
1686 pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL)
1688 os.fsync(f.fileno())
1689 os.replace(tmp_path, out_path)
1690 except BaseException:
1700 Pickle everything needed to regenerate the plots later.
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.
1706 Writes are atomic (temp file + os.replace) so an interrupt mid-dump
1707 never leaves a truncated pickle at `out_path`.
1711 'input_data': input_data,
1712 'rate_eq_obj': rate_eq_obj,
1719 """Inverse of save_plot_data — returns the dict that was pickled."""
1721 with open(path,
'rb')
as f:
1722 return pickle.load(f)
1726 """Run a save call, catching/printing any failure with a traceback.
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.
1735 except KeyboardInterrupt:
1737 except Exception
as exc:
1739 print(f
" ✗ {artifact}: {type(exc).__name__}: {exc}")
1740 traceback.print_exc()
1746 """Save all standard plots to out_dir/.
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.
1752 from pathlib
import Path
1753 Path(out_dir).mkdir(parents=
True, exist_ok=
True)
1757 save_plot_data, results, input_data,
1758 f
"{out_dir}/{PLOT_DATA_FILENAME}",
1759 rate_eq_obj=rate_eq_obj, label=label)
1761 opts = dict(title=label)
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,)),
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)),
1777 (
'loop100_dist_evolution.png', plot_100_distribution_evolution, (results, input_data)),
1778 (
'void_dist_evolution.png', plot_void_distribution_evolution, (results, input_data)),
1780 (
'sia_small.png', plot_sia_clusters, (results, input_data)),
1781 (
'vac_small.png', plot_vac_clusters, (results, input_data)),
1783 for name, fn, args
in common:
1784 _safe_save(name, fn, *args, out_path=f
"{out_dir}/{name}", **opts)
1787 if rate_eq_obj
is not None:
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),
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),
1800 (
'sia_dist_diameter_smooth.png', plot_sia_distribution_diameter_smooth),
1801 (
'vac_dist_diameter_smooth.png', plot_vac_distribution_diameter_smooth),
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)
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)
_align_dose_to_y(results)
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)