RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
calibrate_loop_conversion.py
Go to the documentation of this file.
1"""
2calibrate_loop_conversion.py — Phase-6 calibration of the ½⟨111⟩→⟨100⟩
3conversion against the experimental ½⟨111⟩-loop fraction f₁₁₁(T).
4
5Strategy
6--------
7The crossover temperature T* (where ⟨100⟩ overtakes ½⟨111⟩) is the dominant
8knob; the other five (E_a0, γ_a, φ_max, σ_s, n_j_min) are anchored by Marian /
9Dudarev and held at their physics defaults. We:
10
11 1. load the experimental f₁₁₁(T) for EUROFER-97 (the target material) from
12 the microstructure database via loop_burgers_fraction;
13 2. run the C++ solver (conversion ON) over a temperature sweep for each
14 candidate T*, extracting the modeled f₁₁₁ = S_I^{111} / (S_I^{111}+S_I^{100})
15 at a fixed dose;
16 3. pick the T* minimising the squared error to the experimental points;
17 4. write a model-vs-experiment plot and report the calibrated T*.
18
19This is intentionally a 1-D calibration of the dominant knob: the data are
20scattered across materials/ion-vs-neutron, so fitting all six knobs would
21overfit. Run cost is a few dozen short solver runs.
22
23Usage:
24 python codes/Python_Testing/calibrate_loop_conversion.py [--quick]
25"""
26import io
27import os
28import sys
29from pathlib import Path
30
31import numpy as np
32
33ROOT = Path(__file__).resolve().parents[2]
34sys.path.insert(0, str(ROOT))
35sys.path.insert(0, str(ROOT / "py_utils"))
36
37from py_utils.simulation import RadClusterSimulation
38from loop_burgers_fraction import extract_f111_table
39
40DB = ROOT / "input" / "Ferritics_Irradiated_Microstructure_Data.xlsx"
41G_DPA = 1.0e-6 # displacement rate [dpa/s] (default)
42
43
44# ── experimental target ───────────────────────────────────────────────────────
45def f111_experimental(material_filter="EUROFER"):
46 """Return (T_C, f111) experimental points for the target material."""
47 df = extract_f111_table(str(DB))
48 sub = df[df["material"].str.contains(material_filter, case=False, na=False)]
49 sub = sub.dropna(subset=["temperature_C", "f_111"])
50 return sub["temperature_C"].to_numpy(), sub["f_111"].to_numpy()
51
52
53# ── model evaluation ──────────────────────────────────────────────────────────
54def f111_model(T_C, t_end_s, overrides=None, I=60):
55 """Run the solver at temperature T_C to t_end_s; return modeled f₁₁₁."""
56 _s = sys.stdout, sys.stderr
57 sys.stdout = sys.stderr = io.StringIO()
58 try:
60 I=I, V=I, solver_mode="full_system",
61 physics_option="full_CD_fission", C_floor=1e-25,
62 he_kinetics="quasi_steady_state", i_mobile=5, v_mobile=2)
63 inp = sim.input_data
64 inp.reactions["T"] = T_C + 273.15
65 for k, v in (overrides or {}).items():
66 inp.reactions[k] = v
67 inp._calculate_derived()
68 sim.rebuild_rates()
69 cfg = {"t_span": (1e-6, t_end_s), "n_points": 6, "log_time": True,
70 "rtol": 1e-6, "atol": 1e-26,
71 "solver_method": {"linsol": "dense"}, "loop_conversion": 1}
72 r = sim.run_adaptive(solver_config=cfg, save_output=False)
73 finally:
74 sys.stdout, sys.stderr = _s
75 y = r["y"]; y100 = r["y_sia100"]; sz = np.arange(1, I + 1)
76 S111 = float(np.dot(sz, np.maximum(y[:I, -1], 0.0)))
77 S100 = float(np.dot(sz, np.maximum(y100[:, -1], 0.0)))
78 return S111 / (S111 + S100) if (S111 + S100) > 0 else 1.0
79
80
81# ── calibration sweep ─────────────────────────────────────────────────────────
82def main():
83 quick = "--quick" in sys.argv
84 Tgrid = np.array([250, 300, 350, 400, 450] if quick
85 else [250, 300, 330, 360, 400, 450, 500], dtype=float)
86 Tstar_candidates = [400.0] if quick else [300.0, 350.0, 400.0, 450.0, 500.0]
87 dose = 0.3 # dpa target for the sweep (conversion saturates early)
88 t_end = dose / G_DPA # [s]
89
90 Texp, fexp = f111_experimental("EUROFER")
91 print(f"Experimental EUROFER points: {len(Texp)} "
92 f"(T {Texp.min():.0f}-{Texp.max():.0f} C)")
93
94 # interpolate experiment onto the model grid for the loss
95 order = np.argsort(Texp)
96 fexp_on_grid = np.interp(Tgrid, Texp[order], fexp[order])
97
98 results = {} # Tstar -> modeled f111(Tgrid)
99 best = (None, np.inf, None)
100 for Tstar in Tstar_candidates:
101 curve = np.array([
102 f111_model(T, t_end, overrides={"T_star_conv_C": Tstar})
103 for T in Tgrid])
104 loss = float(np.mean((curve - fexp_on_grid) ** 2))
105 results[Tstar] = curve
106 print(f" T*={Tstar:5.0f} C -> f111(T)="
107 f"[{', '.join('%.2f' % c for c in curve)}] loss={loss:.4f}")
108 if loss < best[1]:
109 best = (Tstar, loss, curve)
110
111 print(f"\nBEST T* = {best[0]:.0f} C (loss={best[1]:.4f})")
112
113 # ── plot ──────────────────────────────────────────────────────────────────
114 try:
115 import matplotlib
116 matplotlib.use("Agg")
117 import matplotlib.pyplot as plt
118 fig, ax = plt.subplots(figsize=(8, 5))
119 ax.scatter(Texp, 100 * fexp, s=80, c="k", marker="o",
120 edgecolors="white", zorder=5, label="EUROFER-97 (exp.)")
121 for Tstar, curve in results.items():
122 lw = 2.5 if Tstar == best[0] else 1.2
123 ax.plot(Tgrid, 100 * curve, "-o", lw=lw,
124 label=f"model T*={Tstar:.0f} C"
125 + (" (best)" if Tstar == best[0] else ""))
126 ax.set_xlabel("Irradiation temperature (C)")
127 ax.set_ylabel("1/2<111> loop fraction (%)")
128 ax.set_title(f"Loop-conversion calibration ({dose:.0f} dpa)")
129 ax.set_ylim(-5, 105); ax.grid(True, ls=":", alpha=0.4)
130 ax.legend(fontsize=9, frameon=False)
131 out = ROOT / "output" / "loop_conversion_calibration.png"
132 out.parent.mkdir(parents=True, exist_ok=True)
133 fig.tight_layout(); fig.savefig(out, dpi=150)
134 print(f"Saved plot: {out}")
135 except Exception as e:
136 print(f"(plot skipped: {e})")
137
138 return best[0]
139
140
141if __name__ == "__main__":
142 main()
f111_model(T_C, t_end_s, overrides=None, I=60)
f111_experimental(material_filter="EUROFER")