RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
sweep_loop100_and_network.py
Go to the documentation of this file.
1"""
2sweep_loop100_and_network.py — exercise the two newest RadCluster_2_1 features
3against experiment:
4
5 (1) ½⟨111⟩ → ⟨100⟩ loop conversion — content-weighted f₁₁₁(T) crossover.
6 Sweeps T ∈ {450, 500, 550} °C with the design-note-calibrated crossover
7 T_star_conv_C = 340 °C, so the Dudarev unary driving force turns on and
8 the large loops convert as T rises (⟨100⟩ becomes dominant at high T —
9 the experimental high-T regime).
10
11 (2) Loop → network-dislocation loss — dynamic ρ_net(dose).
12 Enables LOOP_NETWORK_LOSS and drives ρ_net through run_adaptive's
13 operator-split so it rises ~1 order of magnitude and saturates near the
14 experimental EUROFER network density (~5×10¹⁴ m⁻²).
15
16Run (from module root, needs a built solver.exe):
17 python codes/Python_Testing/sweep_loop100_and_network.py [--quick] [--T 500]
18"""
19import argparse
20import io
21import sys
22from pathlib import Path
23
24import numpy as np
25
26ROOT = Path(__file__).resolve().parents[2] # radcluster_code/
27sys.path.insert(0, str(ROOT))
28try:
29 sys.stdout.reconfigure(encoding="utf-8")
30except Exception:
31 pass
32
33from py_utils.simulation import RadClusterSimulation
34
35# ── ρ_net channel calibration (target: rise from rho_d0 → ~5e14, saturate) ────
36# The geometric capture switch P_ℓd turns on only when the elastic interaction
37# range χ·d_loop reaches the network spacing L_ℓd = ρ_net^(-1/2). For the few-nm
38# loops here that needs χ ≳ 30 (at the realistic as-tempered ρ_net≈5e13–1e14,
39# spacing ~100 nm); above ~30 it saturates. With the switch on, w_c=b_111 (the
40# physical capture width) and K_rec=0 give a monotonic rise that saturates at the
41# rho_max ceiling — the classic irradiation network-saturation signature.
42# Conversion crossover: T_star=250C makes the unary dF>0 cover the loop content
43# (peaks ~n=200) by 500C → f_100≈1, while staying partial at 400C (cutoff ~n=98)
44# → "significant by 400C, near-100% by 500C" (the requested experimental target).
45T_STAR_CONV = 250.0
46# E_a0 is THE efficiency lever: it is size-INDEPENDENT, so it speeds conversion at
47# the small/mid sizes (n~10-50) where the loops actually live and where the
48# growth-vs-conversion race is decided. 1.8 -> 1.6 lifts f_100(500C) 0.32 -> 0.95
49# while leaving 350C low (0.16). (gamma_a scales with P(n) and only moved large-n
50# rates where no loops exist; T_star only moves the dF cutoff, which loops never
51# reach — both were dead ends. See docs/design_notes/loop_conversion_calibration.md.)
52E_A0_CONV = 1.6 # unary direct-rotation barrier [eV]
53GAMMA_A_CONV = 0.02 # unary barrier size-slope [eV/segment]: lowered from the
54 # 0.03 default so n~200 loops convert by ~2 dpa at 500C
55 # (f_100->1) while staying partial at 400C (dF cutoff ~98).
56DH2_CONV = None # Marian two-step gate dH2 [eV]; None = workbook (1.0 = OFF)
57PHI_MAX_JUNC = None # junction peak yield; None = workbook default (0.5)
58RHO_D0 = 5.0e13 # initial network density [m^-2] (~as-tempered EUROFER)
59RHO_MAX = 5.0e14 # ceiling = experimental saturation plateau [m^-2]
60# Capture width: the PHYSICAL value is w_c≈b_111≈2.5e-10 m, but the net climb
61# velocity v_net is small (SIA/vacancy fluxes nearly cancel), so the physical
62# channel moves ρ_net only ~0.1 %/dpa (saturates at ~160 dpa). w_c is the
63# designated amplification knob (loop_network_loss.tex; check_loop_network_loss
64# amplifies it likewise). 50×b_111 makes ρ_net rise and saturate at the ceiling
65# by ~3–6 dpa — a clear demonstration; precise w_c/K_rec are an offline calibration.
66LOOP_NET_WC = 3.72e-8 # = 150×b_111 (amplified demo; reaches the 5e14 ceiling
67 # by ~6 dpa so ρ_net rises then saturates within 10 dpa)
68LOOP_NET_KREC = 0.0 # no recovery → monotonic rise to the ceiling
69LOOP_NET_CHI = 50.0 # elastic interaction range (loop-diameters); ≥30 = on
70LOOP_NET_NINC = 10 # incorporation onset size (sessile loops dominate)
71
72
73def run_one(T_C, t_end, n_points, I=1000, V=1000, i_mobile=20, v_mobile=5,
74 rtol=1e-5, network_loss=True, conversion=True):
75 """One temperature: conversion ON + (optionally) network loss, via
76 run_adaptive so ρ_net advances between segments. Returns the results dict.
77
78 Uses the efficient full_system + bin_moment + Woodbury path (the original
79 16-min config) so loops reach n≈200 — the size the network-loss channel
80 needs to activate (the capture switch needs χ·d_loop ≳ network spacing)."""
81 _s = sys.stdout, sys.stderr
82 sys.stdout = sys.stderr = io.StringIO()
83 try:
85 I=I, V=V, solver_mode="full_system",
86 equations="bin_moment", cascade="fission", C_floor=1e-25,
87 he_kinetics="quasi_steady_state",
88 i_mobile=i_mobile, v_mobile=v_mobile)
89 re = sim.input_data.reactions
90 re["T"] = float(T_C) + 273.15
91 re["rho_d"] = RHO_D0
92 re["T_star_conv_C"] = T_STAR_CONV
93 re["E_a0_conv"] = E_A0_CONV
94 re["gamma_a_conv"] = GAMMA_A_CONV
95 re["n_loop_min"] = 4
96 # bin-moment discrete/bin split (matches the original I=1000 run)
97 re["i_discrete"] = 50; re["v_discrete"] = 20
98 re["I_bin"] = 20; re["V_bin"] = 20
99 re["shape_function"] = "linear"
100 if network_loss:
101 re["LOOP_NETWORK_LOSS"] = 1
102 re["loop_net_chi"] = LOOP_NET_CHI
103 re["loop_net_w_c"] = LOOP_NET_WC
104 re["loop_net_K_rec"] = LOOP_NET_KREC
105 re["loop_net_n_inc"] = LOOP_NET_NINC
106 re["loop_net_rho_max"] = RHO_MAX
107 sim.input_data._calculate_derived()
108 sim.rebuild_rates()
109 cfg = {"t_span": (1e-6, t_end), "n_points": n_points, "log_time": True,
110 "rtol": rtol, "atol": 1e-20, "timeout_s": 2400,
111 "solver_method": {"linsol": "gmres", "preconditioner": "Woodbury",
112 "window_width": 10, "window_pad": 20,
113 "concentration_threshold": 1e-22},
114 "loop_conversion": int(conversion)}
115 r = sim.run_adaptive(solver_config=cfg, save_output=False,
116 points_per_segment=max(4, n_points // 10),
117 max_doublings=2, boundary_threshold=0.05)
118 finally:
119 sys.stdout, sys.stderr = _s
120 return r
121
122
123def run_conv(T_C, t_end=3e6, n_points=60, I=200, i_mobile=10, v_mobile=3,
124 rtol=3e-3, timeout_s=4500, progress_path=None):
125 """f_111(T) CROSSOVER point: loop conversion ON, network loss OFF, on a
126 reduced active_window/discrete grid so the (stiff) conversion runs complete.
127 f_100 is a growth-vs-conversion competition gated by the dF cutoff (T_star):
128 large loops convert fast at high T (gamma_a tuned) -> f_100->1, but stay
129 partial at low T where the dF cutoff caps the convertible size.
130
131 If ``progress_path`` is given, per-SEGMENT f_100 / loop concentrations are
132 appended live (via a wrapper on sim._merge_results) so an in-flight run's
133 progress is visible without waiting for the point to finish."""
134 _s = sys.stdout, sys.stderr
135 sys.stdout = sys.stderr = io.StringIO()
136 try:
138 I=I, V=I, solver_mode="active_window",
139 physics_option="full_CD_fission", C_floor=1e-25,
140 he_kinetics="quasi_steady_state",
141 i_mobile=i_mobile, v_mobile=v_mobile)
142 re = sim.input_data.reactions
143 re["T"] = float(T_C) + 273.15
144 re["T_star_conv_C"] = T_STAR_CONV
145 re["E_a0_conv"] = E_A0_CONV
146 re["gamma_a_conv"] = GAMMA_A_CONV
147 re["n_loop_min"] = 4
148 # Marian kinetic channels (junction + absorption). Their T-gate is
149 # P_success = 1/(1+exp((dH2-dH_rev)/kT)); at the shipped dH2=1.0 it is
150 # ~1e-5, i.e. the channels are OFF and the unary does all the work
151 # (which is why f_100 peaks then decays once loops outgrow the unary
152 # window). Lower dH2 to engage them on the LARGE loops.
153 if DH2_CONV is not None:
154 re["dH2_conv"] = DH2_CONV
155 if PHI_MAX_JUNC is not None:
156 re["phi_max_junc"] = PHI_MAX_JUNC
157 sim.input_data._calculate_derived(); sim.rebuild_rates()
158
159 # Live per-segment progress: wrap _merge_results (called once per
160 # completed segment with post-processed results) to append a log line.
161 if progress_path is not None:
162 _orig_merge = sim._merge_results
163 def _logged_merge(acc, res, _o=_orig_merge, _T=T_C, _p=progress_path):
164 try:
165 def last(k, d=0.0):
166 a = res.get(k)
167 return float(np.asarray(a)[-1]) if a is not None and len(a) else d
168 with open(_p, "a", encoding="utf-8") as fh:
169 fh.write(f" T={_T:3.0f}C dose={last('dose'):6.3f}dpa "
170 f"f_100={1-last('f_111_loop',1.0):.4f} "
171 f"N_111={last('N_loops_111'):.3e} "
172 f"N_100={last('N_loops_100'):.3e} "
173 f"mean_n111={last('mean_n_111'):.1f} "
174 f"mean_n100={last('mean_n_100'):.1f}\n")
175 except Exception:
176 pass
177 return _o(acc, res)
178 sim._merge_results = _logged_merge
179
180 cfg = {"t_span": (1e-6, t_end), "n_points": n_points, "log_time": True,
181 "rtol": rtol, "atol": 1e-20, "timeout_s": timeout_s,
182 "solver_method": {"linsol": "dense"},
183 "loop_conversion": 1}
184 r = sim.run_adaptive(solver_config=cfg, save_output=False,
185 points_per_segment=5,
186 max_doublings=2, boundary_threshold=0.05)
187 finally:
188 sys.stdout, sys.stderr = _s
189 return r
190
191
192def summarize(T_C, r):
193 f111 = float(np.asarray(r["f_111_loop"])[-1])
194 rho0 = float(np.asarray(r["rho_net"])[0])
195 rho1 = float(np.asarray(r["rho_net"])[-1])
196 dose = float(np.asarray(r["dose"])[-1])
197 print(f" T={T_C:3.0f}C dose={dose:5.2f}dpa | "
198 f"f_111={f111:.4f} f_100={1-f111:.4f} | "
199 f"rho_net {rho0:.2e} -> {rho1:.2e} (x{rho1/rho0:.1f}) "
200 f"mean_n_100={float(np.asarray(r['mean_n_100'])[-1]):.1f}")
201 return dict(T_C=T_C, dose=dose, f111=f111, rho0=rho0, rho1=rho1)
202
203
204if __name__ == "__main__":
205 ap = argparse.ArgumentParser()
206 ap.add_argument("--quick", action="store_true",
207 help="fast probe: low dose, smaller domain")
208 ap.add_argument("--T", type=float, default=None,
209 help="single temperature (°C) instead of the 3-point sweep")
210 ap.add_argument("--no-conv", action="store_true",
211 help="disable loop conversion (isolate/verify the ρ_net channel)")
212 ap.add_argument("--crossover", action="store_true",
213 help="f_111(T) crossover sweep: conversion ON, network OFF, "
214 "reduced grid (350/400/450/500/550 C)")
215 ap.add_argument("--progress", type=str, default=None,
216 help="path to a live per-segment progress log file")
217 ap.add_argument("--gamma-a", type=float, default=None,
218 help="override GAMMA_A_CONV (unary barrier size-slope)")
219 ap.add_argument("--e-a0", type=float, default=None,
220 help="override E_A0_CONV (unary barrier offset)")
221 ap.add_argument("--t-end", type=float, default=None,
222 help="override crossover t_end [s] (dose = t_end*1e-6 dpa)")
223 ap.add_argument("--t-star", type=float, default=None,
224 help="override T_STAR_CONV (conversion crossover T, C)")
225 ap.add_argument("--timeout", type=float, default=None,
226 help="override per-point solver timeout [s]")
227 ap.add_argument("--dh2", type=float, default=None,
228 help="override dH2_conv [eV] — the Marian two-step gate. "
229 "Lower => P_success up => junction+absorption engage.")
230 ap.add_argument("--phi-max", type=float, default=None,
231 help="override phi_max_junc (junction peak yield)")
232 args = ap.parse_args()
233 if args.gamma_a is not None:
234 GAMMA_A_CONV = args.gamma_a
235 if args.e_a0 is not None:
236 E_A0_CONV = args.e_a0
237 if args.t_star is not None:
238 T_STAR_CONV = args.t_star
239 if args.dh2 is not None:
240 DH2_CONV = args.dh2
241 if args.phi_max is not None:
242 PHI_MAX_JUNC = args.phi_max
243
244 if args.crossover:
245 temps = ([args.T] if args.T is not None
246 else [350.0, 400.0, 450.0, 500.0, 550.0])
247 print(f"f_111(T) crossover sweep: conversion ON, T_star={T_STAR_CONV}C, "
248 f"E_a0={E_A0_CONV}eV, gamma_a={GAMMA_A_CONV}, network OFF, T={temps} C",
249 flush=True)
250 rows = []
251 import time
252 for T_C in temps:
253 if args.progress:
254 with open(args.progress, "a", encoding="utf-8") as fh:
255 fh.write(f"\n=== T={T_C:.0f}C starting ===\n")
256 t0 = time.perf_counter()
257 _kw = {}
258 if args.t_end:
259 _kw["t_end"] = args.t_end
260 if args.timeout:
261 _kw["timeout_s"] = args.timeout
262 r = run_conv(T_C, progress_path=args.progress, **_kw)
263 dt = time.perf_counter() - t0
264 if r is None:
265 print(f" T={T_C:3.0f}C FAILED", flush=True); continue
266 f111 = float(np.asarray(r["f_111_loop"])[-1])
267 dose = float(np.asarray(r["dose"])[-1])
268 print(f" T={T_C:3.0f}C dose={dose:5.2f}dpa | f_100={1-f111:.4f} | "
269 f"mean_n_100={float(np.asarray(r['mean_n_100'])[-1]):.1f} "
270 f"mean_n_111={float(np.asarray(r['mean_n_111'])[-1]):.1f} (wall {dt:.0f}s)",
271 flush=True)
272 rows.append((T_C, 1 - f111))
273 print("\nf_111(T) crossover T[C] f_100")
274 for T_C, f100 in rows:
275 print(f" {T_C:5.0f} {f100:.4f}")
276 sys.exit(0)
277
278 # Efficient full_system + bin_moment + Woodbury config (loops reach n≈200).
279 if args.quick:
280 t_end, n_points, I = 3e6, 60, 1000 # ~3 dpa verification
281 else:
282 t_end, n_points, I = 1e7, 80, 1000 # ~10 dpa
283
284 temps = [args.T] if args.T is not None else [450.0, 500.0, 550.0]
285 print(f"Sweep T={temps} °C (t_end={t_end:.0e}s ~ {t_end*1e-6:.0f} dpa, "
286 f"I={I} bin_moment/full_system, T_star=340C, LOOP_NETWORK_LOSS on)")
287 rows = []
288 import time
289 for T_C in temps:
290 t0 = time.perf_counter()
291 r = run_one(T_C, t_end, n_points, I=I, V=I, conversion=not args.no_conv)
292 dt = time.perf_counter() - t0
293 if r is None:
294 print(f" T={T_C:3.0f}C FAILED (no results)")
295 continue
296 row = summarize(T_C, r)
297 row["wall_s"] = dt
298 print(f" (wall {dt:.0f}s)")
299 rows.append(row)
300
301 print("\nSummary T[C] f_100 rho_net_final")
302 for x in rows:
303 print(f" {x['T_C']:5.0f} {1-x['f111']:.4f} {x['rho1']:.2e}")
run_one(T_C, t_end, n_points, I=1000, V=1000, i_mobile=20, v_mobile=5, rtol=1e-5, network_loss=True, conversion=True)
run_conv(T_C, t_end=3e6, n_points=60, I=200, i_mobile=10, v_mobile=3, rtol=3e-3, timeout_s=4500, progress_path=None)