RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
loop_burgers_fraction.py
Go to the documentation of this file.
1"""
2loop_burgers_fraction.py
3Extracts and plots the fraction of ½⟨111⟩ dislocation loops vs temperature
4and irradiation dose from Ferritics_Irradiated_Microstructure_Data.xlsx.
5
6Two data sources are used:
7 1. The `Dominant Burgers Vector` column of the
8 `N – Dislocation Loops` and `Ion – Dislocation Loops` sheets.
9 Free-text descriptions are parsed into numeric f₁₁₁ ∈ [0, 1].
10 2. The `111 Fraction` sheet, which contains size-binned histograms.
11 For conditions where both ⟨100⟩ and ½⟨111⟩ histograms are present
12 (e.g. 350°C / 16 dpa), an integrated bulk fraction is computed.
13
14Programmatic use (Jupyter):
15 from loop_burgers_fraction import plot_loop_111_fraction
16 figs, df = plot_loop_111_fraction(
17 xlsx="../../input/Ferritics_Irradiated_Microstructure_Data.xlsx",
18 outdir="../../output/microstructure",
19 font_size=16, save=True, show=True,
20 )
21"""
22
23import os
24import re
25import warnings
26import numpy as np
27import pandas as pd
28import matplotlib
29import matplotlib.pyplot as plt
30
31warnings.filterwarnings("ignore")
32
33# ── Burgers-vector text parser ──────────────────────────────────────────────
34# Returns f₁₁₁ ∈ [0, 1] (or None if unparseable). May depend on T and dose
35# when the source string encodes a transition (e.g. "½⟨111⟩→⟨100⟩").
36
37# Rendered titles are suppressed to match `py_utils/visualization.py`: these
38# figures are captioned by the notebook or document that embeds them. Set
39# SHOW_TITLES = True to draw them again.
40SHOW_TITLES = False
41
42
43def _set_title(ax, text, **kw):
44 """Axes (or panel) title, drawn only when SHOW_TITLES is on."""
45 if SHOW_TITLES:
46 ax.set_title(text, **kw)
47
48
49def _has(s, *needles):
50 return any(n in s for n in needles)
51
52def parse_burgers_fraction(bv_str, temp_c=None, dose_dpa=None):
53 if bv_str is None or (isinstance(bv_str, float) and np.isnan(bv_str)):
54 return None
55 s = str(bv_str).strip()
56 s_low = s.lower()
57
58 # ── 1. Explicit numeric percentage hints, e.g. "(~40% ⟨100⟩)"
59 m = re.search(r"~?(\d{1,3})\s*%\s*(?:⟨|<|a)?\s*1?00", s_low)
60 if m:
61 f100 = float(m.group(1)) / 100.0
62 return max(0.0, min(1.0, 1.0 - f100))
63 m = re.search(r"~?(\d{1,3})\s*%\s*(?:⟨|<|a)?\s*111", s_low)
64 if m:
65 return max(0.0, min(1.0, float(m.group(1)) / 100.0))
66
67 # ── 2. Temperature-conditional transitions
68 # e.g. "½⟨111⟩ (≤300°C)→⟨100⟩ (≥350°C)" or "½⟨111⟩→⟨100⟩"
69 if "→" in s and "111" in s and "100" in s:
70 if temp_c is None:
71 return 0.5
72 t_lo_match = re.search(r"≤\s*(\d{2,4})\s*°?c", s_low)
73 t_hi_match = re.search(r"≥\s*(\d{2,4})\s*°?c", s_low)
74 t_lo = float(t_lo_match.group(1)) if t_lo_match else 300.0
75 t_hi = float(t_hi_match.group(1)) if t_hi_match else 350.0
76 if temp_c <= t_lo: return 1.0
77 if temp_c >= t_hi: return 0.0
78 return float((t_hi - temp_c) / max(1.0, (t_hi - t_lo)))
79
80 # ── 3. Dose-conditional transitions
81 # e.g. "a/2⟨111⟩ dom. (<4 dpa); a⟨100⟩ dom. (>4 dpa)"
82 if ("dpa" in s_low) and ("<" in s_low or ">" in s_low) and "111" in s_low and "100" in s_low:
83 if dose_dpa is None:
84 return 0.5
85 d_match = re.search(r"<\s*(\d+(?:\.\d+)?)\s*dpa", s_low)
86 d_thr = float(d_match.group(1)) if d_match else 4.0
87 return 1.0 if dose_dpa <= d_thr else 0.15
88
89 has_111 = "111" in s_low
90 has_100 = "100" in s_low
91
92 # ── 4. Single-population entries
93 if has_111 and not has_100:
94 return 1.0
95 if has_100 and not has_111:
96 return 0.0
97
98 # ── 5. Mixed population — qualify by "dominant" wording
99 if has_111 and has_100:
100 # Find which symbol comes first / which is followed by "dom"
101 # split on common separators; check which fragment contains "dom"
102 m100_dom = bool(re.search(r"100[^\.;]*?\bdom", s_low))
103 m111_dom = bool(re.search(r"111[^\.;]*?\bdom", s_low))
104 if m100_dom and not m111_dom: return 0.15
105 if m111_dom and not m100_dom: return 0.85
106 return 0.5 # mixed, no dominance qualifier
107
108 return None
109
110
111# ── Build a tidy DataFrame of f_111 vs (T, dose) ─────────────────────────────
112def extract_f111_table(xlsx):
113 rows = []
114
115 for sheet, irr_kind in (("N – Dislocation Loops", "Neutron"),
116 ("Ion – Dislocation Loops", "Ion")):
117 df = pd.read_excel(xlsx, sheet_name=sheet, header=[1, 2])
118 df.columns = [str(c2).replace("\n", " ").strip() for (_, c2) in df.columns]
119 for _, r in df.iterrows():
120 mat = r.get("Material")
121 if pd.isna(mat):
122 continue
123 T = pd.to_numeric(str(r.get("Temp. (°C)", "")).split("–")[0].strip(), errors="coerce")
124 D = pd.to_numeric(str(r.get("Dose (dpa)", "")).split("–")[0].strip(), errors="coerce")
125 f = parse_burgers_fraction(r.get("Dominant Burgers Vector"),
126 temp_c=None if pd.isna(T) else float(T),
127 dose_dpa=None if pd.isna(D) else float(D))
128 if f is None or pd.isna(T):
129 continue
130 rows.append(dict(material=str(mat).strip(),
131 irradiation=irr_kind,
132 dose_dpa=float(D) if not pd.isna(D) else np.nan,
133 temperature_C=float(T),
134 f_111=float(f),
135 source=sheet,
136 raw_bv=str(r.get("Dominant Burgers Vector"))))
137
138 # ── 111 Fraction sheet: integrated bulk fraction at conditions where both
139 # ⟨100⟩ and ½⟨111⟩ histograms are present (e.g. 350°C / 16 dpa).
140 try:
141 raw = pd.read_excel(xlsx, sheet_name="111 Fraction", header=None)
142 # Row 0 has block labels; row 1 has 'Diameter'/'Fraction'; data starts row 2.
143 labels = [str(raw.iat[0, j]) if pd.notna(raw.iat[0, j]) else "" for j in range(raw.shape[1])]
144 # Group columns by label header (label appears in the first col of each block)
145 blocks = {}
146 for j, lab in enumerate(labels):
147 if lab and lab.lower() != "nan":
148 blocks[lab] = (j, j + 1) # (Diameter col, Fraction col)
149 # Sum each block's fractions to get total counts (proxy for relative density)
150 block_sum = {}
151 for lab, (jd, jf) in blocks.items():
152 vals = pd.to_numeric(raw.iloc[2:, jf], errors="coerce").dropna().values
153 block_sum[lab] = vals.sum()
154 # If both 350C_16dpa_100 and _111 are present, compute integrated f_111
155 if "350C_16dpa_100" in block_sum and "350C_16dpa_111" in block_sum:
156 s100 = block_sum["350C_16dpa_100"]
157 s111 = block_sum["350C_16dpa_111"]
158 f = s111 / (s100 + s111) if (s100 + s111) > 0 else np.nan
159 rows.append(dict(material="EUROFER97 (111 sheet)",
160 irradiation="Neutron",
161 dose_dpa=16.0,
162 temperature_C=350.0,
163 f_111=float(f),
164 source="111 Fraction (integrated)",
165 raw_bv="histogram-derived"))
166 except Exception:
167 pass
168
169 out = pd.DataFrame(rows)
170 return out
171
172
173# ── Plotting ─────────────────────────────────────────────────────────────────
174def _apply_style(font_size, font_family, font_serif, axes_linewidth, dpi, save_dpi):
175 matplotlib.rcParams.update({
176 "font.family": font_family,
177 "font.serif": list(font_serif),
178 "font.size": font_size,
179 "axes.labelsize": font_size,
180 "axes.titlesize": font_size,
181 "legend.fontsize": font_size,
182 "xtick.labelsize": font_size,
183 "ytick.labelsize": font_size,
184 "axes.linewidth": axes_linewidth,
185 "axes.spines.top": False,
186 "axes.spines.right": False,
187 "figure.dpi": dpi,
188 "savefig.dpi": save_dpi,
189 "savefig.bbox": "tight",
190 })
191
192
193def _r2(x, y):
194 """Coefficient of determination for a least-squares linear fit."""
195 x, y = np.asarray(x, float), np.asarray(y, float)
196 ok = ~(np.isnan(x) | np.isnan(y))
197 x, y = x[ok], y[ok]
198 if x.size < 3 or np.std(x) == 0:
199 return 0.0
200 a, b = np.polyfit(x, y, 1)
201 yhat = a * x + b
202 ss_res = np.sum((y - yhat) ** 2)
203 ss_tot = np.sum((y - np.mean(y)) ** 2)
204 return 0.0 if ss_tot == 0 else 1.0 - ss_res / ss_tot
205
206
208 """|Spearman ρ| — captures monotonic (incl. nonlinear) dependence; better
209 than R² when the underlying relationship is sigmoidal or step-like."""
210 x, y = np.asarray(x, float), np.asarray(y, float)
211 ok = ~(np.isnan(x) | np.isnan(y))
212 x, y = x[ok], y[ok]
213 if x.size < 3:
214 return 0.0
215 rx = pd.Series(x).rank().values
216 ry = pd.Series(y).rank().values
217 if np.std(rx) == 0 or np.std(ry) == 0:
218 return 0.0
219 return float(abs(np.corrcoef(rx, ry)[0, 1]))
220
221
222def _scatter(ax, df, xkey, xlabel, color_by="material", marker_by="irradiation"):
223 materials = sorted(df["material"].unique())
224 cmap = plt.get_cmap("tab10")
225 color_map = {m: cmap(i % 10) for i, m in enumerate(materials)}
226 marker_map = {"Neutron": "o", "Ion": "^"}
227
228 for mat in materials:
229 sub = df[df["material"] == mat]
230 for irr, mk in marker_map.items():
231 ssub = sub[sub["irradiation"] == irr]
232 if ssub.empty:
233 continue
234 ax.scatter(ssub[xkey], 100.0 * ssub["f_111"],
235 color=color_map[mat], marker=mk,
236 s=110, edgecolors="black", linewidths=0.6,
237 alpha=0.85, label=f"{mat} ({irr})")
238 ax.set_xlabel(xlabel)
239 ax.set_ylabel("Fraction of ½⟨111⟩ loops (%)")
240 ax.set_ylim(-5, 105)
241 ax.grid(True, ls=":", alpha=0.4)
242
243
245 xlsx="Ferritics_Irradiated_Microstructure_Data.xlsx",
246 outdir="figures",
247 save=True,
248 show=False,
249 formats=("pdf", "png"),
250 weak_threshold=0.20, # |Spearman ρ| below this is called "weak"
251 force=None, # "T", "dose", "both", or None for auto
252 font_size=16,
253 font_family="serif",
254 font_serif=("Times New Roman", "DejaVu Serif"),
255 axes_linewidth=1.2,
256 dpi=150,
257 save_dpi=300,
258 figsize=(10, 6),
259):
260 """
261 Build figures of f_111 vs (T, dose).
262
263 Strategy:
264 - Compute |Spearman ρ| for f_111 vs T and vs dose (rank-based;
265 captures monotonic non-linear trends — e.g. the sigmoidal
266 ½⟨111⟩→⟨100⟩ transition with T).
267 - If both > weak_threshold, plot both.
268 - If only one is, plot only that one (the weak axis is omitted,
269 per the user's spec).
270 - If neither is, plot both anyway as a diagnostic.
271 - `force` overrides the auto choice.
272
273 Returns (figs_dict, dataframe).
274 """
275 _apply_style(font_size, font_family, font_serif, axes_linewidth, dpi, save_dpi)
276 if save:
277 os.makedirs(outdir, exist_ok=True)
278
279 df = extract_f111_table(xlsx)
280 if df.empty:
281 print("No parseable f_111 data found.")
282 return {}, df
283
284 rho_T = _spearman_abs(df["temperature_C"], df["f_111"])
285 rho_dose = _spearman_abs(df["dose_dpa"], df["f_111"])
286 r2_T = _r2(df["temperature_C"], df["f_111"])
287 r2_dose = _r2(df["dose_dpa"], df["f_111"])
288 print(f"Parsed {len(df)} data points")
289 print(f" |Spearman ρ|(T) = {rho_T:.3f} (linear R² = {r2_T:.3f})")
290 print(f" |Spearman ρ|(dose) = {rho_dose:.3f} (linear R² = {r2_dose:.3f})")
291 print(f" weak threshold = {weak_threshold}")
292
293 if force == "T":
294 which = ("T",)
295 elif force == "dose":
296 which = ("dose",)
297 elif force == "both":
298 which = ("T", "dose")
299 else:
300 T_strong = rho_T >= weak_threshold
301 dose_strong = rho_dose >= weak_threshold
302 if T_strong and dose_strong:
303 which = ("T", "dose")
304 elif T_strong:
305 which = ("T",)
306 elif dose_strong:
307 which = ("dose",)
308 else:
309 print(" → both dependences look weak; plotting both as a diagnostic.")
310 which = ("T", "dose")
311
312 out = {}
313
314 if "T" in which:
315 fig, ax = plt.subplots(figsize=figsize)
316 _scatter(ax, df, "temperature_C", "Irradiation temperature (°C)")
317 _set_title(ax, f"Fraction of ½⟨111⟩ loops vs Temperature "
318 f"(|ρ| = {rho_T:.2f})", fontweight="bold")
319 ax.legend(frameon=False, loc="center left", bbox_to_anchor=(1.01, 0.5),
320 fontsize=max(8, font_size - 4))
321 fig.tight_layout()
322 out["loop_111_fraction_vs_temperature"] = fig
323
324 if "dose" in which:
325 fig, ax = plt.subplots(figsize=figsize)
326 _scatter(ax, df, "dose_dpa", "Dose (dpa)")
327 ax.set_xscale("log")
328 _set_title(ax, f"Fraction of ½⟨111⟩ loops vs Dose "
329 f"(|ρ| = {rho_dose:.2f})", fontweight="bold")
330 ax.legend(frameon=False, loc="center left", bbox_to_anchor=(1.01, 0.5),
331 fontsize=max(8, font_size - 4))
332 fig.tight_layout()
333 out["loop_111_fraction_vs_dose"] = fig
334
335 if save:
336 for stem, fig in out.items():
337 for ext in formats:
338 fig.savefig(os.path.join(outdir, f"{stem}.{ext}"))
339 print(f"Saved {len(out)} figure(s) to {os.path.abspath(outdir)}/")
340
341 if not show:
342 for fig in out.values():
343 plt.close(fig)
344
345 return out, df
346
347
348if __name__ == "__main__":
349 import argparse
350 p = argparse.ArgumentParser()
351 p.add_argument("--xlsx", default="Ferritics_Irradiated_Microstructure_Data.xlsx")
352 p.add_argument("--outdir", default="figures")
353 p.add_argument("--font-size", type=int, default=16)
354 a = p.parse_args()
355 plot_loop_111_fraction(xlsx=a.xlsx, outdir=a.outdir,
356 font_size=a.font_size, save=True, show=False)
plot_loop_111_fraction(xlsx="Ferritics_Irradiated_Microstructure_Data.xlsx", outdir="figures", save=True, show=False, formats=("pdf", "png"), weak_threshold=0.20, force=None, font_size=16, font_family="serif", font_serif=("Times New Roman", "DejaVu Serif"), axes_linewidth=1.2, dpi=150, save_dpi=300, figsize=(10, 6))
_apply_style(font_size, font_family, font_serif, axes_linewidth, dpi, save_dpi)
_scatter(ax, df, xkey, xlabel, color_by="material", marker_by="irradiation")
parse_burgers_fraction(bv_str, temp_c=None, dose_dpa=None)