RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
mobility_sweep.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3mobility_sweep.py — diagnostic sweep of SIA / vacancy mobility cutoffs.
4
5Hypothesis under test
6---------------------
7With the current input (n_max_i=50, m_max_v=5, L_hat=50), mobile SIA
8clusters of size 4–50 glide in 1D and are rapidly drained at fixed sinks
9(dislocations, GBs, precipitates) and at voids — even when the 3D
10coalescence diffusivity D_SIA_eff[n>=2] is zero. Vacancy clusters lose
11mobility at m=6 and become permanent sinks for the residual flux. The
12asymmetry should disappear (and may invert) when i_mobile is reduced
13toward 1, when v_mobile is raised, or when L_hat is shortened.
14
15Cases
16-----
17 A baseline i_mobile=50, v_mobile=5, L_hat=50
18 B di-SIA only i_mobile=2, v_mobile=5, L_hat=50
19 C monomer-only SIA i_mobile=1, v_mobile=5, L_hat=50
20 D symmetric mobility i_mobile=50, v_mobile=20, L_hat=50
21 E mono-defect (reference) i_mobile=1, v_mobile=1, L_hat=50
22 F short MFP (1D suppressed) i_mobile=50, v_mobile=5, L_hat=5
23
24Output
25------
26 output/mobility_sweep_<timestamp>/
27 summary.csv — per-case scalars at 0.1 dpa
28 mean_sizes_vs_dose.png — mean_n_i, mean_n_v(t) for all cases
29 swelling_vs_dose.png — swelling(t) for all cases
30 conservation_vs_dose.png — delta_FP vs dose for all cases
31 size_dist_at_0.1dpa.png — c_n, c_m at 0.1 dpa, all cases
32"""
33
34import sys, os, io, csv, time
35from pathlib import Path
36from datetime import datetime
37import numpy as np
38import matplotlib
39matplotlib.use('Agg')
40import matplotlib.pyplot as plt
41
42MODULE_ROOT = Path(__file__).resolve().parent.parent.parent
43REPO_ROOT = MODULE_ROOT.parent
44for p in [str(REPO_ROOT), str(MODULE_ROOT)]:
45 if p not in sys.path:
46 sys.path.insert(0, p)
47
48import importlib
49import radcluster_code.py_utils.defect_production as _dp_mod
50import radcluster_code.py_utils.binding_energies as _be_mod
51import radcluster_code.py_utils.bin_moment_rates as _bmr
52import radcluster_code.py_utils.input_data as _inp_mod
53import radcluster_code.py_utils.reaction_rates as _rr_mod
54import radcluster_code.py_utils.rate_equations as _re_mod
55import radcluster_code.py_utils.cpp_bridge as _cb_mod
56import radcluster_code.py_utils.post_process as _pp_mod
57import radcluster_code.py_utils.simulation as _sim_mod
58for _m in [_dp_mod, _be_mod, _bmr, _inp_mod, _rr_mod, _re_mod,
59 _cb_mod, _pp_mod, _sim_mod]:
60 importlib.reload(_m)
61from radcluster_code.py_utils.simulation import RadClusterSimulation
62
63
64# ── Solver / domain configuration ──────────────────────────────────────────
65# I,V = 1000 each → N_eq = I + V + 6 ≈ 2006 (full_system + Woodbury).
66# This matches the original reference run. Vacancy clusters in case A may
67# bump the V=1000 boundary at 0.01 dpa; delta_FP will quantify any leakage.
68I_SIM, V_SIM = 1000, 1000
69
70# Reference dose for the snapshot column in summary.csv / size_dist plot.
71# 0.01 dpa is past the nucleation transient (~1e-9 dpa) and well into
72# steady-state cluster growth — the SIA/vac size asymmetry is fully developed.
73DOSE_SNAPSHOT = 0.01
74
75SOLVER_CONFIG = {
76 't_span': (1e-6, 1e4), # 1e4 s @ G=1e-6 dpa/s = 0.01 dpa
77 'n_points': 120,
78 'log_time': True,
79 'rtol': 1e-6,
80 'atol': 1e-22,
81 'solver_method': {
82 'linsol': 'gmres', # required for Woodbury auto-engage
83 # window_mode=0 (full_system) so Woodbury preconditioner kicks in
84 # whenever i_mobile>=2 or v_mobile>=2 (parameters.h:494-501)
85 },
86}
87
88CASES = [
89 ('A_baseline', {'i_mobile': 50, 'v_mobile': 5, 'L_hat': 50.0}),
90 ('B_di_SIA', {'i_mobile': 2, 'v_mobile': 5, 'L_hat': 50.0}),
91 ('C_mono_SIA', {'i_mobile': 1, 'v_mobile': 5, 'L_hat': 50.0}),
92 ('D_symmetric', {'i_mobile': 50, 'v_mobile': 20, 'L_hat': 50.0}),
93 ('E_mono_defect', {'i_mobile': 1, 'v_mobile': 1, 'L_hat': 50.0}),
94 ('F_short_MFP', {'i_mobile': 50, 'v_mobile': 5, 'L_hat': 5.0}),
95]
96
97
98def run_case(name, overrides, quiet=True, heartbeat_every=1.5):
99 """Run one case via active_window and return time-resolved results.
100
101 Per-row progress is forwarded to the *real* terminal even while the
102 inner solver's stdout is captured. A line is printed at most once
103 every `heartbeat_every` seconds (wall-clock) plus always at the
104 final point, to avoid flooding while still confirming progress.
105 """
106 i_mob = int(overrides['i_mobile'])
107 v_mob = int(overrides['v_mobile'])
108 L_hat = float(overrides['L_hat'])
109
110 real_stdout = sys.stdout
111 real_stderr = sys.stderr
112 state = {'last_print': 0.0, 'rows': 0, 'G_dpa': None, 't0': time.time()}
113
114 def _hb(row):
115 # called by C++ bridge once per output time step
116 state['rows'] += 1
117 now = time.time()
118 if (now - state['last_print']) < heartbeat_every:
119 return
120 state['last_print'] = now
121 t = row.get('t', 0.0)
122 G = state['G_dpa'] or 1e-6
123 dose = t * G
124 ci1 = row.get('c_i1', 0.0)
125 cv1 = row.get('c_v1', 0.0)
126 elapsed = now - state['t0']
127 real_stdout.write(
128 f" [{name}] step {state['rows']:>4d} "
129 f"t={t:.2e}s dose={dose:.3e} dpa "
130 f"c_i1={ci1:.2e} c_v1={cv1:.2e} "
131 f"({elapsed:5.1f}s elapsed)\n"
132 )
133 real_stdout.flush()
134
135 saved_stdout, saved_stderr = sys.stdout, sys.stderr
136 if quiet:
137 sys.stdout = sys.stderr = io.StringIO()
138 try:
139 sim = RadClusterSimulation(
140 I=I_SIM, V=V_SIM,
141 solver_mode='full_system',
142 physics_option='full_CD_fission',
143 C_floor=1e-25,
144 he_kinetics='quasi_steady_state',
145 i_mobile=i_mob,
146 v_mobile=v_mob,
147 )
148 inp = sim.input_data
149 inp.diffusion['L_hat'] = L_hat
150 inp.diffusion['i_mobile'] = i_mob
151 inp.diffusion['v_mobile'] = v_mob
152 inp.reactions['i_mobile'] = i_mob
153 inp.reactions['v_mobile'] = v_mob
154 inp.reactions['i_discrete'] = i_mob
155 inp.reactions['v_discrete'] = max(v_mob, 5)
156 inp.reactions['I_bin'] = 0
157 inp.reactions['V_bin'] = 0
158 inp._calculate_derived()
159 sim.rebuild_rates()
160 state['G_dpa'] = sim.input_data.derived['G']
161
162 results = sim.run_adaptive(
163 solver_config=SOLVER_CONFIG, save_output=False,
164 progress_callback=_hb, boundary_threshold=0.1,
165 max_doublings=1, points_per_segment=10,
166 )
167 finally:
168 if quiet:
169 sys.stdout, sys.stderr = saved_stdout, saved_stderr
170
171 if results is None:
172 return None
173
174 dose = np.asarray(results['dose'])
175 j01 = int(np.argmin(np.abs(dose - DOSE_SNAPSHOT)))
176
177 # Reconstruct per-size distributions at the snapshot dose
178 y_all = np.asarray(results['y'])
179 s2m = 1.0 / sim.input_data.derived['Omega']
180 I_run = sim.input_data.I
181 V_run = sim.input_data.V
182 c_n_01 = np.maximum(y_all[0:I_run, j01], 0.0) * s2m # m^-3
183 c_v_01 = np.maximum(y_all[I_run:I_run + V_run, j01], 0.0) * s2m
184
185 return {
186 'name': name,
187 'overrides': overrides,
188 't': np.asarray(results['t']),
189 'dose': dose,
190 'mean_n_i': np.asarray(results['mean_n_i']),
191 'mean_n_v': np.asarray(results['mean_n_v']),
192 'swelling': np.asarray(results['swelling']),
193 'N_loops': np.asarray(results['N_loops']),
194 'N_voids': np.asarray(results['N_voids']),
195 'delta_FP': np.asarray(results['delta_FP']),
196 'idx_01': j01,
197 'c_n_01': c_n_01,
198 'c_v_01': c_v_01,
199 'I_run': I_run,
200 'V_run': V_run,
201 }
202
203
204def write_summary_csv(cases, out_dir):
205 path = out_dir / 'summary.csv'
206 tag = f"{DOSE_SNAPSHOT:g}"
207 with open(path, 'w', newline='') as f:
208 w = csv.writer(f)
209 w.writerow(['case', 'i_mobile', 'v_mobile', 'L_hat',
210 'dose_dpa@end', f'mean_n_i@{tag}', f'mean_n_v@{tag}',
211 f'swelling@{tag}[%]', f'N_loops@{tag}[m^-3]',
212 f'N_voids@{tag}[m^-3]', f'delta_FP@{tag}'])
213 for c in cases:
214 if c is None:
215 continue
216 j = c['idx_01']
217 ov = c['overrides']
218 w.writerow([
219 c['name'],
220 ov['i_mobile'], ov['v_mobile'], ov['L_hat'],
221 f"{c['dose'][-1]:.4g}",
222 f"{c['mean_n_i'][j]:.3f}",
223 f"{c['mean_n_v'][j]:.3f}",
224 f"{c['swelling'][j] * 100:.4f}",
225 f"{c['N_loops'][j]:.3e}",
226 f"{c['N_voids'][j]:.3e}",
227 f"{c['delta_FP'][j]:.3e}",
228 ])
229 return path
230
231
232def plot_time_series(cases, out_dir):
233 fig, axes = plt.subplots(1, 2, figsize=(11, 4.2), sharex=True)
234 for c in cases:
235 if c is None:
236 continue
237 axes[0].plot(c['dose'], c['mean_n_i'], label=c['name'])
238 axes[1].plot(c['dose'], c['mean_n_v'], label=c['name'])
239 for ax, ttl in zip(axes, ['mean_n_i (SIA)', 'mean_n_v (vacancy)']):
240 ax.set_xscale('log'); ax.set_yscale('log')
241 ax.set_xlabel('dose (dpa)'); ax.set_ylabel(ttl)
242 ax.grid(True, which='both', alpha=0.3); ax.legend(fontsize=8)
243 fig.suptitle('Mean cluster size vs dose — mobility-cutoff sweep')
244 fig.tight_layout()
245 fig.savefig(out_dir / 'mean_sizes_vs_dose.png', dpi=150)
246 plt.close(fig)
247
248 fig, ax = plt.subplots(figsize=(6.5, 4.2))
249 for c in cases:
250 if c is None:
251 continue
252 ax.plot(c['dose'], np.asarray(c['swelling']) * 100, label=c['name'])
253 ax.set_xscale('log'); ax.set_yscale('log')
254 ax.set_xlabel('dose (dpa)'); ax.set_ylabel('swelling (%)')
255 ax.grid(True, which='both', alpha=0.3); ax.legend(fontsize=8)
256 ax.set_title('Swelling vs dose')
257 fig.tight_layout()
258 fig.savefig(out_dir / 'swelling_vs_dose.png', dpi=150)
259 plt.close(fig)
260
261 fig, ax = plt.subplots(figsize=(6.5, 4.2))
262 for c in cases:
263 if c is None:
264 continue
265 ax.plot(c['dose'], c['delta_FP'], label=c['name'])
266 ax.set_xscale('log'); ax.set_yscale('log')
267 ax.set_xlabel('dose (dpa)'); ax.set_ylabel('delta_FP')
268 ax.axhline(1e-3, ls='--', c='k', alpha=0.5, label='1e-3 (target)')
269 ax.grid(True, which='both', alpha=0.3); ax.legend(fontsize=8)
270 ax.set_title('Frenkel-pair conservation vs dose')
271 fig.tight_layout()
272 fig.savefig(out_dir / 'conservation_vs_dose.png', dpi=150)
273 plt.close(fig)
274
275
276def plot_size_distributions(cases, out_dir):
277 fig, axes = plt.subplots(1, 2, figsize=(12, 4.5), sharey=True)
278 for c in cases:
279 if c is None:
280 continue
281 ns = np.arange(1, c['I_run'] + 1)
282 ms = np.arange(1, c['V_run'] + 1)
283 axes[0].plot(ns, c['c_n_01'], label=c['name'], lw=1.0)
284 axes[1].plot(ms, c['c_v_01'], label=c['name'], lw=1.0)
285 for ax, ttl in zip(axes, [f'SIA c_n at {DOSE_SNAPSHOT} dpa',
286 f'Vacancy c_m at {DOSE_SNAPSHOT} dpa']):
287 ax.set_xscale('log'); ax.set_yscale('log')
288 ax.set_xlabel('cluster size')
289 ax.set_ylabel('concentration (m$^{-3}$)')
290 ax.grid(True, which='both', alpha=0.3); ax.legend(fontsize=8)
291 ax.set_title(ttl)
292 ax.set_ylim(bottom=1e10)
293 fig.tight_layout()
294 fig.savefig(out_dir / f'size_dist_at_{DOSE_SNAPSHOT:g}dpa.png', dpi=150)
295 plt.close(fig)
296
297
298def main():
299 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
300 out_dir = MODULE_ROOT / 'output' / f'mobility_sweep_{timestamp}'
301 out_dir.mkdir(parents=True, exist_ok=True)
302 print(f"Output -> {out_dir}", flush=True)
303
304 results = []
305 sweep_t0 = time.time()
306 for k, (name, ov) in enumerate(CASES, start=1):
307 clock = datetime.now().strftime('%H:%M:%S')
308 print(f"\n=== [{k}/{len(CASES)}] {name} "
309 f"(i_mobile={ov['i_mobile']}, v_mobile={ov['v_mobile']}, "
310 f"L_hat={ov['L_hat']}) start {clock} ===", flush=True)
311 case_t0 = time.time()
312 try:
313 r = run_case(name, ov, quiet=True)
314 except Exception as e:
315 print(f" FAILED: {type(e).__name__}: {e}", flush=True)
316 r = None
317 case_dt = time.time() - case_t0
318 if r is None:
319 print(f" (no result; {case_dt:.1f}s)", flush=True)
320 else:
321 j = r['idx_01']
322 print(f" -> mean_n_i = {r['mean_n_i'][j]:.2f} "
323 f"mean_n_v = {r['mean_n_v'][j]:.2f} "
324 f"swelling = {r['swelling'][j]*100:.4f}% "
325 f"delta_FP = {r['delta_FP'][j]:.2e} "
326 f"({case_dt:.1f}s)", flush=True)
327 results.append(r)
328 # Incremental write so partial results survive a kill / crash
329 try:
330 write_summary_csv(results, out_dir)
331 except Exception:
332 pass
333
334 total_dt = time.time() - sweep_t0
335 csv_path = write_summary_csv(results, out_dir)
336 print(f"\nSweep finished in {total_dt:.1f}s. Wrote {csv_path}", flush=True)
337 plot_time_series(results, out_dir)
338 plot_size_distributions(results, out_dir)
339 print(f"Plots written to {out_dir}", flush=True)
340
341
342if __name__ == '__main__':
343 main()
write_summary_csv(cases, out_dir)
run_case(name, overrides, quiet=True, heartbeat_every=1.5)
plot_time_series(cases, out_dir)
plot_size_distributions(cases, out_dir)