RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
param_sweep.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3Parameter sweep for RadCluster_2_1
4========================================
5Objective: find input parameter sets that produce SIA loop size distribution
6peak at 2-5 nm diameter by 0.1 dpa.
7
8Diameter formula: d_nm = 2 * sqrt(n * Omega / (pi * b_111)) * 1e9
9 -> d=2 nm => n ~ 66, d=5 nm => n ~ 413, d=3 nm => n ~ 149
10
11Strategy: vary the most influential parameters within physical ranges,
12run the C++ active_window solver, extract the SIA peak location at
13the time step closest to 0.1 dpa.
14"""
15
16import sys, os, io, json, time
17from pathlib import Path
18import numpy as np
19
20# ── Path setup ──────────────────────────────────────────────────────────────
21MODULE_ROOT = Path(__file__).resolve().parent.parent.parent
22REPO_ROOT = MODULE_ROOT.parent
23for p in [str(REPO_ROOT), str(MODULE_ROOT)]:
24 if p not in sys.path:
25 sys.path.insert(0, p)
26
27import importlib
28import radcluster_code.py_utils.defect_production as _dp_mod
29import radcluster_code.py_utils.binding_energies as _be_mod
30import radcluster_code.py_utils.bin_moment_rates as _bmr
31import radcluster_code.py_utils.input_data as _inp_mod
32import radcluster_code.py_utils.reaction_rates as _rr_mod
33import radcluster_code.py_utils.rate_equations as _re_mod
34import radcluster_code.py_utils.cpp_bridge as _cb_mod
35import radcluster_code.py_utils.post_process as _pp_mod
36import radcluster_code.py_utils.simulation as _sim_mod
37for _m in [_dp_mod, _be_mod, _bmr, _inp_mod, _rr_mod, _re_mod,
38 _cb_mod, _pp_mod, _sim_mod]:
39 importlib.reload(_m)
40from radcluster_code.py_utils.simulation import RadClusterSimulation
41from radcluster_code.py_utils.defect_production import FISSION as _FISSION_DICT
42
43# ── Physical constants for diameter conversion ──────────────────────────────
44Omega = 1.18e-29 # m^3 (atomic volume bcc Fe)
45b_111 = 2.482e-10 # m (Burgers vector 1/2<111>)
46
48 """SIA cluster size -> loop diameter in nm."""
49 return 2.0 * np.sqrt(n * Omega / (np.pi * b_111)) * 1e9
50
52 """Loop diameter in nm -> SIA cluster size n."""
53 d_m = d_nm * 1e-9
54 return np.pi * b_111 * (d_m / 2.0)**2 / Omega
55
56
57# ── Solver config (extend to 0.1 dpa: t_end = 1e5 s at G=1e-6 dpa/s) ─────
58# Reduced domain (I=V=3000) and output points for faster sweep
59I_SWEEP = 3000 # max SIA cluster size (d=5nm -> n~413, so 3000 is plenty)
60V_SWEEP = 3000 # max vacancy cluster size
61
62SOLVER_CONFIG = {
63 't_span': (1e-6, 1e5), # reach 0.1 dpa
64 'n_points': 100, # fewer points for speed
65 'log_time': True,
66 'rtol': 1e-5, # slightly relaxed for speed
67 'atol': 1e-20,
68 'solver_method': {
69 'linsol': 'gmres',
70 'window_width': 50,
71 'concentration_threshold': 1e-18,
72 'window_pad': 10,
73 }
74}
75
76# ── Base parameters (notebook defaults) ─────────────────────────────────────
77BASE_PARAMS = {
78 'eta': 0.3,
79 'f_cl_i': 0.3,
80 'f_cl_v': 0.25,
81 'E_m_1D': 0.4,
82 'i_mobile': 10,
83 'L_hat': 71.8,
84 'c_C': 1.94e-4,
85 'E_b_C_SIA': 0.65,
86 'rho_d': 1e13,
87 'Z_i': 1.05,
88 'Z_ii': 1.2,
89 'shape_function': 'linear',
90}
91
92# ── Parameter sets to test ──────────────────────────────────────────────────
93# Physics rationale for each run:
94# - More coalescence (Z_ii, i_mobile) → larger loops
95# - Higher clustering (f_cl_i) → more SIA in clusters → faster growth
96# - Lower dislocation density (rho_d) → less sink loss → more growth
97# - Longer mean free path (L_hat) → less 1D sink capture → more coalescence
98# - Lower 1D migration barrier (E_m_1D) → faster 1D → more encounters
99# - Less C trapping (lower c_C or E_b_C_SIA) → faster effective diffusion
100# - Higher temperature → more thermal emission from small, growth of large
101
102RUNS = {
103 # ── Run 0: Current baseline (L_hat=71.8, rot=13547) ─────────────────
104 'R00_baseline': {},
105
106 # ── Runs 1-5: L_hat scan (key lever for 1D→3D mobility) ─────────────
107 # Lower L_hat → more direction changes → higher effective 3D diffusivity
108 # for glissile clusters n=4..i_mobile → more coalescence
109 'R01_Lhat_30': {'L_hat': 30.0}, # rot=2363
110 'R02_Lhat_15': {'L_hat': 15.0}, # rot=592
111 'R03_Lhat_8': {'L_hat': 8.0}, # rot=169
112 'R04_Lhat_4': {'L_hat': 4.0}, # rot=43
113 'R05_Lhat_2': {'L_hat': 2.0}, # rot=11.5
114
115 # ── Runs 6-8: L_hat + coalescence + production ──────────────────────
116 'R06_Lhat8_Zii2': {
117 'L_hat': 8.0,
118 'Z_ii': 2.0,
119 'f_cl_i': 0.50,
120 },
121
122 'R07_Lhat4_full': {
123 'L_hat': 4.0,
124 'Z_ii': 1.5,
125 'f_cl_i': 0.50,
126 'rho_d': 5e12,
127 },
128
129 'R08_Lhat2_full': {
130 'L_hat': 2.0,
131 'Z_ii': 1.5,
132 'f_cl_i': 0.50,
133 'rho_d': 5e12,
134 },
135}
136
137
138def run_single(name, overrides, I=I_SWEEP, V=V_SWEEP):
139 """Run one simulation and return the SIA peak info at ~0.1 dpa."""
140 params = dict(BASE_PARAMS)
141 params.update(overrides)
142
143 i_mob = int(params.get('i_mobile', 10))
144 v_mob = 5
145
146 # Patch module-level FISSION dict for production parameters
147 # (production_rates() reads from this, not from inp.production_fission)
148 _fission_backup = dict(_FISSION_DICT)
149 for key in ['eta', 'f_cl_i', 'f_cl_v', 's_i', 's_v', 'i_cascade', 'v_cascade']:
150 if key in params:
151 _FISSION_DICT[key] = params[key]
152
153 # Suppress output
154 saved = sys.stdout, sys.stderr
155 sys.stdout = sys.stderr = io.StringIO()
156 try:
157 sim = RadClusterSimulation(
158 I=I, V=V,
159 solver_mode='active_window',
160 physics_option='full_CD_fission',
161 C_floor=1e-25,
162 he_kinetics='quasi_steady_state',
163 i_mobile=i_mob,
164 v_mobile=v_mob,
165 )
166
167 # Apply overrides
168 inp = sim.input_data
169 for key, val in params.items():
170 placed = False
171 for d in [inp.production_fission, inp.production_fusion,
172 inp.diffusion, inp.reactions,
173 inp.energetics, inp.dissociation]:
174 if key in d:
175 d[key] = val
176 placed = True
177 if not placed:
178 inp.reactions[key] = val
179 inp.diffusion['i_mobile'] = i_mob
180 inp.reactions['i_mobile'] = i_mob
181 # Discrete/bin settings (full_CD)
182 inp.reactions['i_discrete'] = i_mob
183 inp.reactions['v_discrete'] = v_mob
184 inp.reactions['I_bin'] = 0
185 inp.reactions['V_bin'] = 0
186 inp._calculate_derived()
187 sim.rebuild_rates()
188
189 results = sim.run_adaptive(
190 solver_config=SOLVER_CONFIG,
191 save_output=False,
192 progress_callback=None,
193 boundary_threshold=0.1,
194 max_doublings=0,
195 points_per_segment=10,
196 )
197 finally:
198 sys.stdout, sys.stderr = saved
199 # Restore FISSION dict for next run
200 _FISSION_DICT.update(_fission_backup)
201
202 if results is None:
203 return None
204
205 # ── Extract SIA peak at closest time to 0.1 dpa ──────────────────────
206 dose = results['dose']
207 t_arr = results['t']
208 G_dpa = inp.derived['G']
209 Omega_val = inp.derived['Omega']
210 s2m = 1.0 / Omega_val
211
212 # Find index closest to 0.1 dpa
213 target_dose = 0.1
214 idx_01 = np.argmin(np.abs(dose - target_dose))
215 actual_dose = dose[idx_01]
216
217 # Get SIA size distribution at that time
218 # results['y'] has shape [N_eq, n_times]; SIA clusters are y[0:I, :]
219 y_full = results['y'] # atom fractions [N_eq, n_t]
220 c_i_at_01 = np.maximum(y_full[0:I, idx_01], 0.0) * s2m # m^-3
221
222 N_sia = c_i_at_01.shape[0]
223 ns = np.arange(1, N_sia + 1)
224 d_nm = n_to_diameter_nm(ns)
225
226 # Find peak: weighted by concentration (exclude n=1 mono-interstitials)
227 c_trimmed = c_i_at_01[1:] # n >= 2
228 ns_trimmed = ns[1:]
229 d_trimmed = d_nm[1:]
230
231 if np.max(c_trimmed) <= 0:
232 return None
233
234 # Peak = argmax of concentration for n >= 2
235 ipeak = np.argmax(c_trimmed)
236 peak_n = ns_trimmed[ipeak]
237 peak_d = d_trimmed[ipeak]
238 peak_c = c_trimmed[ipeak]
239
240 # Also compute number-weighted mean diameter (for clusters n >= 2 with c > threshold)
241 mask = c_trimmed > 1e10 # above noise floor
242 if mask.any():
243 mean_d = np.sum(d_trimmed[mask] * c_trimmed[mask]) / np.sum(c_trimmed[mask])
244 total_N = np.sum(c_trimmed[mask])
245 else:
246 mean_d = 0.0
247 total_N = 0.0
248
249 # Find the diameter range containing 50% of the concentration (FWHM-like)
250 # Sort by concentration to find the dominant sizes
251 sorted_idx = np.argsort(c_trimmed)[::-1]
252 cum_c = np.cumsum(c_trimmed[sorted_idx])
253 half_total = cum_c[-1] * 0.5
254 top_half_mask = cum_c <= half_total
255 if top_half_mask.any():
256 top_half_n = ns_trimmed[sorted_idx[top_half_mask]]
257 d_range = (n_to_diameter_nm(top_half_n.min()), n_to_diameter_nm(top_half_n.max()))
258 else:
259 d_range = (peak_d, peak_d)
260
261 return {
262 'name': name,
263 'dose_actual': actual_dose,
264 'peak_n': int(peak_n),
265 'peak_d_nm': peak_d,
266 'peak_c_m3': peak_c,
267 'mean_d_nm': mean_d,
268 'total_N_m3': total_N,
269 'd_range_nm': d_range,
270 'mean_n_i': results['mean_n_i'][idx_01],
271 'N_loops': results['N_loops'][idx_01],
272 'swelling_pct': results['swelling'][idx_01] * 100,
273 'delta_FP': results['delta_FP'][idx_01],
274 'params': params,
275 }
276
277
278# ═══════════════════════════════════════════════════════════════════════════
279# MAIN
280# ═══════════════════════════════════════════════════════════════════════════
281if __name__ == '__main__':
282 print('=' * 80)
283 print(' PARAMETER SWEEP — SIA Loop Size Peak Optimization')
284 print(' Target: peak diameter 2-5 nm at 0.1 dpa')
285 print(f' (n_target: {diameter_nm_to_n(2.0):.0f}-{diameter_nm_to_n(5.0):.0f})')
286 print('=' * 80)
287
288 all_results = []
289 t0_total = time.time()
290
291 for i, (name, overrides) in enumerate(RUNS.items()):
292 print(f'\n[{i+1}/{len(RUNS)}] Running {name} ...', flush=True)
293 if overrides:
294 for k, v in overrides.items():
295 print(f' {k} = {v}')
296 else:
297 print(' (baseline — no overrides)')
298
299 t0 = time.time()
300 try:
301 res = run_single(name, overrides)
302 except Exception as e:
303 print(f' FAILED: {e}')
304 res = None
305 elapsed = time.time() - t0
306
307 if res is not None:
308 all_results.append(res)
309 in_target = 2.0 <= res['peak_d_nm'] <= 5.0
310 marker = ' *** IN TARGET ***' if in_target else ''
311 print(f' Done in {elapsed:.1f}s | dose={res["dose_actual"]:.4f} dpa')
312 print(f' Peak: n={res["peak_n"]}, d={res["peak_d_nm"]:.2f} nm, '
313 f'C={res["peak_c_m3"]:.2e} m^-3{marker}')
314 print(f' Mean d={res["mean_d_nm"]:.2f} nm, '
315 f'N_loops={res["N_loops"]:.2e} m^-3, '
316 f'swelling={res["swelling_pct"]:.4f}%')
317 else:
318 print(f' FAILED ({elapsed:.1f}s)')
319
320 total_time = time.time() - t0_total
321
322 # ── Summary table ─────────────────────────────────────────────────────
323 print('\n' + '=' * 80)
324 print(' SUMMARY TABLE — sorted by distance to target (3.5 nm)')
325 print('=' * 80)
326 target_d = 3.5 # nm (center of 2-5 nm range)
327 all_results.sort(key=lambda r: abs(r['peak_d_nm'] - target_d))
328
329 print(f'{"Run":<25} {"Peak d (nm)":>11} {"Peak n":>7} {"Mean d (nm)":>11} '
330 f'{"N_loops":>10} {"Swell %":>9} {"In target":>10}')
331 print('-' * 85)
332 for r in all_results:
333 in_t = 'YES' if 2.0 <= r['peak_d_nm'] <= 5.0 else 'no'
334 print(f'{r["name"]:<25} {r["peak_d_nm"]:>11.2f} {r["peak_n"]:>7d} '
335 f'{r["mean_d_nm"]:>11.2f} {r["N_loops"]:>10.2e} '
336 f'{r["swelling_pct"]:>9.4f} {in_t:>10}')
337
338 # ── Best result ───────────────────────────────────────────────────────
339 print('\n' + '=' * 80)
340 if all_results:
341 best = all_results[0]
342 print(f' BEST RUN: {best["name"]}')
343 print(f' Peak diameter: {best["peak_d_nm"]:.2f} nm (target: 2-5 nm)')
344 print(f' Peak cluster size n = {best["peak_n"]}')
345 print(f' Mean diameter: {best["mean_d_nm"]:.2f} nm')
346 print(f' Loop density: {best["N_loops"]:.2e} m^-3')
347 print(f' Swelling: {best["swelling_pct"]:.4f} %')
348 print(f' delta_FP: {best["delta_FP"]:.2e}')
349 print(f'\n Optimal parameter overrides:')
350 # Only print non-base values
351 for k, v in best['params'].items():
352 base_v = BASE_PARAMS.get(k)
353 if base_v != v:
354 print(f' {k:>15} = {v} (base: {base_v})')
355 else:
356 print(f' {k:>15} = {v}')
357 else:
358 print(' No successful runs!')
359
360 print(f'\n Total sweep time: {total_time:.0f}s ({total_time/60:.1f} min)')
361 print('=' * 80)
362
363 # ── Save results to JSON ──────────────────────────────────────────────
364 out_path = Path(__file__).parent / 'param_sweep_results.json'
365 serializable = []
366 for r in all_results:
367 sr = dict(r)
368 sr['d_range_nm'] = list(sr['d_range_nm'])
369 # Convert numpy types
370 for k, v in sr.items():
371 if isinstance(v, (np.integer,)):
372 sr[k] = int(v)
373 elif isinstance(v, (np.floating,)):
374 sr[k] = float(v)
375 elif isinstance(v, dict):
376 sr[k] = {kk: (float(vv) if isinstance(vv, (np.floating,)) else
377 int(vv) if isinstance(vv, (np.integer,)) else vv)
378 for kk, vv in v.items()}
379 serializable.append(sr)
380 with open(out_path, 'w') as f:
381 json.dump(serializable, f, indent=2)
382 print(f'\nResults saved to {out_path}')
n_to_diameter_nm(n)
run_single(name, overrides, I=I_SWEEP, V=V_SWEEP)
diameter_nm_to_n(d_nm)