RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
compare_window_modes.py
Go to the documentation of this file.
1"""
2compare_window_modes.py
3=======================
4Run and compare the two solver modes for the I=V=10000 full_CD system:
5
6 Mode 0 — full_system : reference (20 006 ODEs, no window)
7 Mode 4 — active_window : two independent sliding windows + OpenMP-parallel
8 RHS (auto-serial when threads=1)
9
10Physical parameters are taken verbatim from the notebook cell:
11 T=573 K, G=1e-6 dpa/s, I=V=10000, i_mobile=v_mobile=1,
12 i_discrete=I, v_discrete=V, I_bin=V_bin=0 (full per-size),
13 he_kinetics=quasi_steady_state, fission cascade.
14
15Window design (derived from I=V=1000 reference run):
16 SIA front saturates at n≈62 at t=1e4 s, VAC front reaches m≈1000 at t=1e4 s.
17 Initial width must accommodate both axes → window_width = 500.
18 (expand thresholds / pads tuned to match these observations)
19
20Usage:
21 cd EuroferMicrostructure/radcluster_code/codes
22 python compare_window_modes.py [--ref-dir <path>] [--skip-mode0]
23"""
24
25import sys
26import os
27import io
28import time
29import argparse
30import importlib
31from pathlib import Path
32
33# ── path setup ────────────────────────────────────────────────────────────────
34CODES_DIR = Path(__file__).resolve().parent.parent
35MODULE_ROOT = CODES_DIR.parent
36REPO_ROOT = MODULE_ROOT.parent
37for p in [str(REPO_ROOT), str(MODULE_ROOT)]:
38 if p not in sys.path:
39 sys.path.insert(0, p)
40
41import numpy as np
42import matplotlib
43matplotlib.use('Agg')
44import matplotlib.pyplot as plt
45import matplotlib.gridspec as gridspec
46
47# Reload py_utils in case stale .pyc files exist
48import radcluster_code.py_utils.defect_production as _dp
49import radcluster_code.py_utils.binding_energies as _be
50import radcluster_code.py_utils.bin_moment_rates as _bmr
51import radcluster_code.py_utils.input_data as _inp
52import radcluster_code.py_utils.reaction_rates as _rr
53import radcluster_code.py_utils.rate_equations as _re
54import radcluster_code.py_utils.cpp_bridge as _cb
55import radcluster_code.py_utils.post_process as _pp
56import radcluster_code.py_utils.simulation as _sim
57for _m in [_dp, _be, _bmr, _inp, _rr, _re, _cb, _pp, _sim]:
58 importlib.reload(_m)
59from radcluster_code.py_utils.simulation import RadClusterSimulation
60
61
62# ══════════════════════════════════════════════════════════════════════════════
63# 1. Shared physical parameters (mirror notebook exactly)
64# ══════════════════════════════════════════════════════════════════════════════
65
66I = int(1e4)
67V = int(1e4)
68i_mobile = 1
69v_mobile = 1
70i_discrete = I
71v_discrete = V
72I_bin = 0
73V_bin = 0
74C_FLOOR = 1e-25
75HE_KINETICS = 'quasi_steady_state'
76
77PARAM_OVERRIDES = {
78 'eta': 0.3,
79 'f_cl_i': 0.58,
80 'f_cl_v': 0.45,
81 'E_m_1D': 0.34,
82 'i_mobile': i_mobile,
83 'L_hat': 71.8,
84 'c_C': 1.94e-4,
85 'E_b_C_SIA': 0.45,
86 'rho_d': 1e14,
87 'Z_i': 1.1,
88 'Z_ii': 1.1,
89 'shape_function': 'linear',
90 'i_discrete': i_discrete,
91 'v_discrete': v_discrete,
92 'I_bin': I_bin,
93 'V_bin': V_bin,
94}
95
96BASE_SOLVER_CFG = {
97 't_span': (1e-6, 1e4),
98 'n_points': 200,
99 'log_time': True,
100 'rtol': 1e-6,
101 'atol': 1e-25,
102}
103
104# ── Window parameters (physics-calibrated) ───────────────────────────────────
105# From I=V=1000 analysis:
106# SIA: 99.9% content at n≤24; physical front at n≈62 at t=1e4 s.
107# VAC: void front grows ~linearly; extrapolates to m≈1000 at t=1e4 s for V=10000.
108WINDOW_METHOD = {
109 'linsol': 'gmres',
110 # Shared initial width and expansion threshold (SIA + VAC)
111 'window_width': 500, # initial active window: sizes 1..500
112 'concentration_threshold': 1e-22, # expand when leading concentration > 1e-22
113 # Per-axis expansion pads
114 'window_pad': 50, # grow SIA window by 50 at a time
115 'window_pad_v': 200, # grow VAC window by 200 at a time
116 # Misc
117 'window_check_every': 1,
118}
119
120MODE4_CFG = {**BASE_SOLVER_CFG, 'solver_method': WINDOW_METHOD}
121MODE0_CFG = {**BASE_SOLVER_CFG, 'solver_method': {
122 'linsol': 'gmres',
123}}
124
125
126# ══════════════════════════════════════════════════════════════════════════════
127# 2. Helper: build a fresh sim with the shared parameters
128# ══════════════════════════════════════════════════════════════════════════════
129
130PHYSICS_OPTION = 'full_CD_fission' # overridden by --physics CLI arg
131
132
133def build_sim(solver_mode, physics_option=None):
134 """Construct an RadClusterSimulation with the reference parameters."""
135 if physics_option is None:
136 physics_option = PHYSICS_OPTION
137 saved = sys.stdout, sys.stderr
138 sys.stdout = sys.stderr = io.StringIO()
139 try:
140 sim = RadClusterSimulation(
141 I=I, V=V,
142 solver_mode=solver_mode,
143 physics_option=physics_option,
144 C_floor=C_FLOOR,
145 he_kinetics=HE_KINETICS,
146 i_mobile=i_mobile,
147 v_mobile=v_mobile,
148 )
149 finally:
150 sys.stdout, sys.stderr = saved
151
152 saved2 = sys.stdout, sys.stderr
153 sys.stdout = sys.stderr = io.StringIO()
154 try:
155 inp = sim.input_data
156 for key, val in PARAM_OVERRIDES.items():
157 placed = False
158 for d in [inp.production_fission, inp.production_fusion,
159 inp.diffusion, inp.reactions,
160 inp.energetics, inp.dissociation]:
161 if key in d:
162 d[key] = val
163 placed = True
164 if not placed:
165 inp.reactions[key] = val
166 if 'i_mobile' in PARAM_OVERRIDES:
167 inp.diffusion['i_mobile'] = int(PARAM_OVERRIDES['i_mobile'])
168 inp.reactions['i_mobile'] = int(PARAM_OVERRIDES['i_mobile'])
169 inp._calculate_derived()
170 sim.rebuild_rates()
171 finally:
172 sys.stdout, sys.stderr = saved2
173
174 return sim
175
176
177# ══════════════════════════════════════════════════════════════════════════════
178# 3. Run a mode, return (results_dict, wall_clock_s, output_dir)
179# ══════════════════════════════════════════════════════════════════════════════
180
181def run_mode(label, solver_mode, solver_cfg):
182 print(f'\n{"="*70}')
183 print(f' Running {label} ({solver_mode} / {PHYSICS_OPTION})')
184 print(f'{"="*70}')
185 sim = build_sim(solver_mode)
186 t0 = time.perf_counter()
187 results = sim.run_adaptive(
188 solver_config=solver_cfg,
189 save_output=True,
190 progress_callback=None,
191 boundary_threshold=0.1,
192 max_doublings=0,
193 points_per_segment=10,
194 )
195 wall = time.perf_counter() - t0
196 if results is None:
197 print(f' {label}: FAILED')
198 return None, wall, None
199
200 # Locate the just-written output directory (newest)
201 out_root = MODULE_ROOT / 'output'
202 dirs = sorted(out_root.glob(f'*_{solver_mode}_*'), key=lambda p: p.name)
203 out_dir = dirs[-1] if dirs else None
204
205 t_arr = results['t']
206 print(f' {label}: {len(t_arr)} pts, '
207 f't=[{t_arr[0]:.2e},{t_arr[-1]:.2e}] s, '
208 f'wall={wall:.1f} s')
209 print(f' Swelling(final)={results["swelling"][-1]*100:.5f} % '
210 f'N_loops={results["N_loops"][-1]:.3e} m^-3 '
211 f'N_voids={results["N_voids"][-1]:.3e} m^-3')
212 print(f' delta_FP={results["delta_FP"][-1]:.2e} '
213 f'delta_He={results["delta_He"][-1]:.2e}')
214 return results, wall, out_dir
215
216
217# ══════════════════════════════════════════════════════════════════════════════
218# 4. Locate / load existing runs from saved .npy files
219# ══════════════════════════════════════════════════════════════════════════════
220
221def _load_run_from_dir(d, solver_mode, label):
222 """
223 Load a saved run from directory *d*. Returns (results_dict, dir_name) or
224 (None, None) if the directory doesn't contain valid results.
225 """
226 d = Path(d)
227 t_path = d / 'results_t.npy'
228 y_path = d / 'results_y.npy'
229 if not (t_path.exists() and y_path.exists()):
230 return None, None
231 t = np.load(t_path)
232 y = np.load(y_path, mmap_mode='r')
233 print(f' Found {label}: {d.name} ({y.shape[1]} pts, N_eq={y.shape[0]})')
234 sim = build_sim(solver_mode)
235 re = sim.rate_equations
236 from radcluster_code.py_utils.post_process import calculate_derived_quantities
237 saved = sys.stdout, sys.stderr
238 sys.stdout = sys.stderr = io.StringIO()
239 try:
240 res = calculate_derived_quantities(t, y, sim.input_data, re)
241 finally:
242 sys.stdout, sys.stderr = saved
243 res['y'] = y
244 res['_source_dir'] = d.name
245 return res, d.name
246
247
248def find_reference_run(ref_dir=None):
249 """
250 Return the results dict for mode-0. If ref_dir is given, load from there.
251 Otherwise scan the output directory for the most recent full_system run
252 with N_eq == 20006 (I=V=10000, QSS He) matching PHYSICS_OPTION (or
253 bin_moment fallback since they are mathematically identical with I_bin=0).
254 Legacy `cpp_full` directory names are also matched.
255 """
256 out_root = MODULE_ROOT / 'output'
257
258 # Prefer exact physics match, then accept bin_moment (mathematically identical).
259 # Both new (`full_system`) and legacy (`cpp_full`) directory prefixes are scanned.
260 glob_patterns = [
261 f'*_full_system_{PHYSICS_OPTION}_*',
262 f'*_cpp_full_{PHYSICS_OPTION}_*',
263 '*_full_system_bin_moment_CD_fission_*',
264 '*_cpp_full_bin_moment_CD_fission_*',
265 '*_full_system_full_CD_fission_*',
266 '*_cpp_full_full_CD_fission_*',
267 ]
268 candidates = []
269 for pat in glob_patterns:
270 candidates += sorted(out_root.glob(pat), key=lambda p: p.name, reverse=True)
271 if ref_dir:
272 candidates = [Path(ref_dir)] + candidates
273
274 seen = set()
275 for d in candidates:
276 if d in seen:
277 continue
278 seen.add(d)
279 y_path = d / 'results_y.npy'
280 if not y_path.exists():
281 continue
282 y = np.load(y_path, mmap_mode='r')
283 if y.shape[0] == 20006: # I=V=10000, QSS-He
284 return _load_run_from_dir(d, 'full_system', 'mode-0 reference')
285 return None, None
286
287
288def find_window_run(load_dir=None):
289 """
290 Return the results dict for active_window. If load_dir is given, load
291 from there; otherwise find the most recent matching output directory for
292 PHYSICS_OPTION. Legacy `sliding_OpenMP` directory names are also matched.
293 """
294 out_root = MODULE_ROOT / 'output'
295 label = 'Mode IV'
296
297 if load_dir:
298 return _load_run_from_dir(Path(load_dir), 'active_window', label)
299
300 glob_patterns = [
301 f'*_active_window_{PHYSICS_OPTION}_*',
302 f'*_sliding_OpenMP_{PHYSICS_OPTION}_*',
303 ]
304 candidates = []
305 for pat in glob_patterns:
306 candidates += sorted(out_root.glob(pat), key=lambda p: p.name, reverse=True)
307 for d in candidates:
308 res, src = _load_run_from_dir(d, 'active_window', label)
309 if res is not None:
310 return res, src
311 return None, None
312
313
314# ══════════════════════════════════════════════════════════════════════════════
315# 5. Comparison plots
316# ══════════════════════════════════════════════════════════════════════════════
317
318COLORS = {'mode0': '#1f77b4', 'mode4': '#2ca02c'}
319LABELS = {'mode0': 'Mode 0 — full_system (ref)',
320 'mode4': 'Mode IV — active_window'}
321LS = {'mode0': '-', 'mode4': '--'}
322
323def _relerr(ref, new):
324 """Element-wise relative error |new - ref| / (|ref| + 1e-30)."""
325 return np.abs(new - ref) / (np.abs(ref) + 1e-30)
326
327
328def make_comparison_plots(res_dict, wall_dict, out_path):
329 """
330 res_dict : {'mode0': results, 'mode4': results} (any key may be absent)
331 wall_dict: {'mode0': float, 'mode4': float}
332 out_path : Path to save the figure
333 """
334 modes = [k for k in ['mode0', 'mode4'] if k in res_dict and res_dict[k] is not None]
335
336 fig = plt.figure(figsize=(18, 22))
337 gs = gridspec.GridSpec(4, 3, figure=fig, hspace=0.42, wspace=0.35)
338
339 # ── (0,0) Swelling ──────────────────────────────────────────────────────
340 ax = fig.add_subplot(gs[0, 0])
341 for m in modes:
342 r = res_dict[m]
343 ax.loglog(r['dose'], r['swelling']*100,
344 color=COLORS[m], ls=LS[m], lw=1.8, label=LABELS[m])
345 ax.set_xlabel('Dose (dpa)'); ax.set_ylabel('Swelling (%)')
346 ax.set_title('Void swelling'); ax.legend(fontsize=7)
347
348 # ── (0,1) Number densities ───────────────────────────────────────────────
349 ax = fig.add_subplot(gs[0, 1])
350 for m in modes:
351 r = res_dict[m]
352 ax.loglog(r['dose'], r['N_loops'], color=COLORS[m], ls=LS[m], lw=1.8)
353 ax.loglog(r['dose'], r['N_voids'], color=COLORS[m], ls=LS[m], lw=1.0, alpha=0.5)
354 # phantom lines for legend
355 from matplotlib.lines import Line2D
356 ax.add_line(Line2D([],[], color='k', lw=1.8, label='Loops (solid)'))
357 ax.add_line(Line2D([],[], color='k', lw=1.0, alpha=0.5, label='Voids (faint)'))
358 ax.set_xlabel('Dose (dpa)'); ax.set_ylabel('Number density (m⁻³)')
359 ax.set_title('N_loops & N_voids'); ax.legend(fontsize=7)
360
361 # ── (0,2) Mean cluster sizes ─────────────────────────────────────────────
362 ax = fig.add_subplot(gs[0, 2])
363 for m in modes:
364 r = res_dict[m]
365 ax.loglog(r['dose'], r['mean_n_i'], color=COLORS[m], ls=LS[m], lw=1.8)
366 ax.loglog(r['dose'], r['mean_n_v'], color=COLORS[m], ls=LS[m], lw=1.0, alpha=0.5)
367 ax.set_xlabel('Dose (dpa)'); ax.set_ylabel('Mean cluster size (defects)')
368 ax.set_title('Mean sizes (loops solid, voids faint)')
369
370 # ── (1,0) Point-defect concentrations ───────────────────────────────────
371 ax = fig.add_subplot(gs[1, 0])
372 for m in modes:
373 r = res_dict[m]
374 ax.loglog(r['dose'], r['C_i1'], color=COLORS[m], ls=LS[m], lw=1.8)
375 ax.loglog(r['dose'], r['C_v1'], color=COLORS[m], ls=LS[m], lw=1.0, alpha=0.5)
376 ax.set_xlabel('Dose (dpa)'); ax.set_ylabel('Concentration (m⁻³)')
377 ax.set_title('Monomers C_i1 (solid) & C_v1 (faint)')
378
379 # ── (1,1) SIA and VAC total content ─────────────────────────────────────
380 ax = fig.add_subplot(gs[1, 1])
381 for m in modes:
382 r = res_dict[m]
383 S_I = r['C_SIA_tot'] * r['Omega'] # SIA content (atom fraction)
384 S = r['swelling'] # VAC content / void swelling (atom fraction)
385 ax.loglog(r['dose'], S_I, color=COLORS[m], ls=LS[m], lw=1.8, label=LABELS[m])
386 ax.loglog(r['dose'], S, color=COLORS[m], ls=LS[m], lw=1.0, alpha=0.5)
387 ax.set_xlabel('Dose (dpa)'); ax.set_ylabel('Defect content (atom fraction)')
388 ax.set_title('S_I SIA content (solid) & S VAC content (faint)')
389 ax.legend(fontsize=7)
390
391 # ── (1,2) Conservation diagnostics ──────────────────────────────────────
392 ax = fig.add_subplot(gs[1, 2])
393 for m in modes:
394 r = res_dict[m]
395 ax.loglog(r['dose'], np.maximum(r['delta_FP'], 1e-16),
396 color=COLORS[m], ls=LS[m], lw=1.8, label=r'$\delta_{FP}$ '+LABELS[m])
397 ax.axhline(1e-6, color='gray', ls='--', lw=0.8, label='1e-6 threshold')
398 ax.set_xlabel('Dose (dpa)'); ax.set_ylabel(r'$\delta_{FP}$ (relative)')
399 ax.set_title('Frenkel-pair conservation'); ax.legend(fontsize=6)
400
401 # ── (2,0) Relative errors vs mode 0 ─────────────────────────────────────
402 ref = res_dict.get('mode0')
403 if ref is not None and 'mode4' in res_dict and res_dict['mode4'] is not None:
404 quantities = ['swelling', 'N_loops', 'N_voids', 'mean_n_i', 'mean_n_v', 'delta_FP']
405 q_labels = ['Swelling', 'N_loops', 'N_voids', r'$\bar{n}_i$', r'$\bar{n}_v$', r'$\delta_{FP}$']
406 q_colors = ['C0','C1','C2','C3','C4','C5']
407
408 ax = fig.add_subplot(gs[2, 0])
409 wr = res_dict['mode4']
410 t_ref = ref['dose']
411 t_win = wr['dose']
412 for qn, ql, qc in zip(quantities, q_labels, q_colors):
413 try:
414 ref_q = np.interp(t_win, t_ref, ref[qn])
415 win_q = wr[qn]
416 err = _relerr(ref_q, win_q)
417 ax.loglog(t_win, np.maximum(err, 1e-16),
418 color=qc, lw=1.4, label=ql)
419 except Exception:
420 pass
421 ax.axhline(1e-3, color='gray', ls='--', lw=0.8, label='0.1% error')
422 ax.set_xlabel('Dose (dpa)')
423 ax.set_ylabel('|ε| relative error')
424 ax.set_title(f'Relative error: {LABELS["mode4"]}\nvs Mode 0 reference')
425 ax.legend(fontsize=6, ncol=2)
426
427 # ── (3,0) Wall-clock time bar chart ─────────────────────────────────────
428 ax = fig.add_subplot(gs[3, 0])
429 labels_bar = [LABELS[m] for m in modes if m in wall_dict]
430 times_bar = [wall_dict[m] for m in modes if m in wall_dict]
431 colors_bar = [COLORS[m] for m in modes if m in wall_dict]
432 bars = ax.bar(range(len(labels_bar)), times_bar, color=colors_bar, width=0.5)
433 ax.set_xticks(range(len(labels_bar)))
434 ax.set_xticklabels([l.split('—')[0].strip() for l in labels_bar],
435 rotation=15, ha='right', fontsize=8)
436 ax.set_ylabel('Wall-clock time (s)')
437 ax.set_title('Solver timing comparison')
438 for bar, t in zip(bars, times_bar):
439 ax.text(bar.get_x() + bar.get_width()/2, bar.get_height()*1.02,
440 f'{t:.0f}s', ha='center', va='bottom', fontsize=8)
441 if times_bar:
442 ax.set_ylim(0, max(times_bar)*1.25)
443
444 # ── (3,1) Speedup ratio ──────────────────────────────────────────────────
445 ax = fig.add_subplot(gs[3, 1])
446 if 'mode0' in wall_dict and wall_dict['mode0'] > 0 and 'mode4' in wall_dict:
447 speedup = wall_dict['mode0'] / wall_dict['mode4']
448 ax.bar([0], [speedup], color=COLORS['mode4'], width=0.4)
449 ax.set_xticks([0])
450 ax.set_xticklabels([LABELS['mode4'].split('—')[0].strip()],
451 rotation=15, ha='right', fontsize=8)
452 ax.set_ylabel('Speedup vs Mode 0')
453 ax.set_title('Speedup factor')
454 ax.axhline(1.0, color='gray', ls='--', lw=0.8)
455 ax.text(0, speedup*1.02, f'{speedup:.1f}×',
456 ha='center', va='bottom', fontsize=9)
457 else:
458 ax.text(0.5, 0.5, 'Mode 0 timing\nnot available',
459 ha='center', va='center', transform=ax.transAxes, fontsize=9)
460 ax.set_title('Speedup factor')
461
462 # ── (3,2) Annotation box ─────────────────────────────────────────────────
463 ax = fig.add_subplot(gs[3, 2])
464 ax.axis('off')
465 lines = [
466 'Run parameters',
467 f' I = V = {I:,}',
468 f' i_mobile = v_mobile = {i_mobile}',
469 f' G = 1×10⁻⁶ dpa/s',
470 f' T = 573 K',
471 f' t_span = (1e-6, 1e4) s',
472 f' rtol={BASE_SOLVER_CFG["rtol"]:.0e} atol={BASE_SOLVER_CFG["atol"]:.0e}',
473 '',
474 'Window parameters (Mode IV)',
475 f' width = {WINDOW_METHOD["window_width"]} (shared SIA/VAC)',
476 f' SIA pad={WINDOW_METHOD["window_pad"]} '
477 f'VAC pad={WINDOW_METHOD["window_pad_v"]}',
478 f' conc_threshold = {WINDOW_METHOD["concentration_threshold"]:.0e} (shared)',
479 f' linsol = gmres maxl=50 (hardwired)',
480 f' OMP threads = auto from N_eq (override via OMP_NUM_THREADS)',
481 '',
482 'From I=V=1000 reference:',
483 ' SIA 99.9% content: n≤24',
484 ' VAC front at t=1e4s: m≈1000',
485 ]
486 ax.text(0.05, 0.97, '\n'.join(lines), transform=ax.transAxes,
487 fontsize=7.5, va='top', family='monospace',
488 bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))
489
490 fig.suptitle(
491 'Sliding-window solver comparison\n'
492 f'Mode 0 (full_system) vs Mode IV (active_window)\n'
493 f'I = V = {I:,} physics: {PHYSICS_OPTION} fission cascade',
494 fontsize=11, y=0.995
495 )
496 fig.savefig(out_path, dpi=150, bbox_inches='tight')
497 print(f'\nSaved comparison figure → {out_path}')
498 plt.close(fig)
499
500
501# ══════════════════════════════════════════════════════════════════════════════
502# 6. SIA & VAC size distribution comparison (final time)
503# ══════════════════════════════════════════════════════════════════════════════
504
505def make_distribution_plots(res_dict, out_path):
506 modes = [k for k in ['mode0','mode4'] if k in res_dict and res_dict[k] is not None]
507 fig, axes = plt.subplots(1, 2, figsize=(13, 5))
508
509 for m in modes:
510 y = res_dict[m].get('y')
511 if y is None:
512 continue
513 ci = y[:I, -1]
514 cv = y[I:I+V, -1]
515 n_arr = np.arange(1, I+1)
516 m_arr = np.arange(1, V+1)
517
518 # SIA
519 mask_i = ci > 1e-30
520 axes[0].loglog(n_arr[mask_i], ci[mask_i],
521 color=COLORS[m], ls=LS[m], lw=1.6, label=LABELS[m])
522 # VAC
523 mask_v = cv > 1e-30
524 axes[1].loglog(m_arr[mask_v], cv[mask_v],
525 color=COLORS[m], ls=LS[m], lw=1.6)
526
527 axes[0].set_xlabel('SIA cluster size n')
528 axes[0].set_ylabel('Concentration (atom fraction)')
529 axes[0].set_title('SIA size distribution at t=10⁴ s')
530 axes[0].legend(fontsize=8)
531
532 axes[1].set_xlabel('Vacancy cluster size m')
533 axes[1].set_ylabel('Concentration (atom fraction)')
534 axes[1].set_title('VAC size distribution at t=10⁴ s')
535
536 fig.suptitle(
537 f'Cluster size distributions at final time — I=V={I:,}',
538 fontsize=11)
539 fig.tight_layout()
540 fig.savefig(out_path, dpi=150, bbox_inches='tight')
541 print(f'Saved distribution figure → {out_path}')
542 plt.close(fig)
543
544
545# ══════════════════════════════════════════════════════════════════════════════
546# 7. Text report
547# ══════════════════════════════════════════════════════════════════════════════
548
549def write_report(res_dict, wall_dict, report_path):
550 lines = []
551 sep = '=' * 72
552
553 lines += [sep,
554 'SLIDING-WINDOW SOLVER COMPARISON REPORT',
555 f'RadCluster_2_1 — {PHYSICS_OPTION} — I=V=10,000',
556 sep, '']
557
558 # ── Run parameters ───────────────────────────────────────────────────────
559 lines += ['PHYSICAL PARAMETERS',
560 '-'*40,
561 f' Domain: I = V = {I:,}',
562 f' Mobility: i_mobile = v_mobile = {i_mobile}',
563 f' Dose rate: G = 1×10⁻⁶ dpa/s',
564 f' Temperature: T = 573 K',
565 f' t_span: (1×10⁻⁶, 1×10⁴) s',
566 f' He treatment: quasi_steady_state',
567 f' i_discrete: {i_discrete} (full per-size, no bins)',
568 f' v_discrete: {v_discrete}',
569 f' N_eq (mode 0): 20,006',
570 '',
571 'SOLVER SETTINGS',
572 '-'*40,
573 f' linsol: gmres gmres_maxl=40',
574 f' rtol: {BASE_SOLVER_CFG["rtol"]:.0e}',
575 f' atol: {BASE_SOLVER_CFG["atol"]:.0e}',
576 '',
577 'WINDOW PARAMETERS (Mode IV)',
578 '-'*40,
579 f' width = {WINDOW_METHOD["window_width"]} (shared SIA/VAC)',
580 f' conc_threshold = {WINDOW_METHOD["concentration_threshold"]:.0e} (shared)',
581 f' pad SIA = {WINDOW_METHOD["window_pad"]} '
582 f'pad VAC = {WINDOW_METHOD["window_pad_v"]}',
583 f' OMP threads: auto from N_eq (override via OMP_NUM_THREADS)',
584 '']
585
586 # ── Timing table ─────────────────────────────────────────────────────────
587 lines += ['WALL-CLOCK TIME',
588 '-'*40]
589 w0 = wall_dict.get('mode0', None)
590 for m, lbl in [('mode0','full_system'), ('mode4','active_window')]:
591 if m in wall_dict:
592 w = wall_dict[m]
593 speedup = f' ({w0/w:.1f}× speedup)' if w0 and m != 'mode0' else ''
594 lines.append(f' {lbl:<22} {w:>8.1f} s{speedup}')
595 lines.append('')
596
597 # ── Accuracy table ───────────────────────────────────────────────────────
598 ref = res_dict.get('mode0')
599 lines += ['ACCURACY (relative error vs mode 0 at final time)',
600 '-'*40]
601 if ref is None:
602 lines.append(' [mode 0 reference not available]')
603 elif 'mode4' not in res_dict or res_dict['mode4'] is None:
604 lines.append(' [Mode IV result not available]')
605 else:
606 keys = ['swelling','N_loops','N_voids','mean_n_i','mean_n_v','delta_FP']
607 lines.append(f' {"Quantity":<14} {"Mode IV":>12}')
608 lines.append(' ' + '-'*30)
609 for k in keys:
610 try:
611 r0 = float(ref[k][-1])
612 rw = float(res_dict['mode4'][k][-1])
613 err = abs(rw - r0) / (abs(r0) + 1e-30)
614 lines.append(f' {k:<14} {err:>12.3e}')
615 except Exception:
616 lines.append(f' {k:<14} {"N/A":>12}')
617 lines.append('')
618
619 # ── Conservation ─────────────────────────────────────────────────────────
620 lines += ['CONSERVATION DIAGNOSTICS (at final time)',
621 '-'*40]
622 for m, lbl in [('mode0','full_system'), ('mode4','active_window')]:
623 if m in res_dict and res_dict[m] is not None:
624 r = res_dict[m]
625 lines.append(f' {lbl:<22} '
626 f'delta_FP={r["delta_FP"][-1]:.3e} '
627 f'delta_He={r["delta_He"][-1]:.3e}')
628 lines.append('')
629
630 # ── Summary ──────────────────────────────────────────────────────────────
631 lines += ['SUMMARY',
632 '-'*40,
633 ' Window design is based on I=V=1000 analysis:',
634 ' SIA: 99.9% content always in n≤24; active front at n≈62',
635 ' VAC: void front grows ~linearly; reaches m≈1000 at t=1e4 s',
636 ' For I=V=10000 this gives active window fractions of:',
637 f' SIA: ~{100/I*100:.1f}% of domain at saturation',
638 f' VAC: ~{1200/V*100:.1f}% of domain at saturation',
639 ' Expected GMRES cost reduction: ~15× per linear solve.',
640 '',
641 ' Mode IV should give results within rtol of mode 0.',
642 ' Any residual difference is due to boundary truncation, not',
643 ' algorithmic error; increase window_pad to reduce it.',
644 sep]
645
646 report_path.write_text('\n'.join(lines))
647 print(f'Saved text report → {report_path}')
648
649
650# ══════════════════════════════════════════════════════════════════════════════
651# 8. Main
652# ══════════════════════════════════════════════════════════════════════════════
653
654def main():
655 ap = argparse.ArgumentParser(description=__doc__,
656 formatter_class=argparse.RawDescriptionHelpFormatter)
657 ap.add_argument('--ref-dir', help='Path to existing mode-0 output directory')
658 ap.add_argument('--load-mode4', help='Load Mode IV from this existing output directory')
659 ap.add_argument('--skip-mode0', action='store_true',
660 help='Skip running mode 0 (use existing reference only)')
661 ap.add_argument('--skip-mode4', action='store_true',
662 help='Skip running Mode IV (load existing if available)')
663 ap.add_argument('--wall-mode0', type=float, default=0.0,
664 help='Known wall-clock time for mode 0 (s), for speedup plots')
665 ap.add_argument('--wall-mode4', type=float, default=0.0,
666 help='Known wall-clock time for Mode IV (s)')
667 ap.add_argument('--physics', default='full_CD_fission',
668 choices=['full_CD_fission','full_CD_fusion',
669 'bin_moment_CD_fission','bin_moment_CD_fusion'],
670 help='Physics option for all modes (default: full_CD_fission)')
671 ap.add_argument('--out-dir', default=str(MODULE_ROOT / 'output' / 'window_comparison'),
672 help='Directory for comparison plots and report')
673 args = ap.parse_args()
674
675 # Set global physics option so build_sim / find_* use it
676 global PHYSICS_OPTION
677 PHYSICS_OPTION = args.physics
678
679 out_dir = Path(args.out_dir)
680 out_dir.mkdir(parents=True, exist_ok=True)
681
682 res_dict = {}
683 wall_dict = {}
684
685 # ── Mode 0 (reference) ───────────────────────────────────────────────────
686 if not args.skip_mode0:
687 r0, w0, _ = run_mode('Mode 0 — full_system', 'full_system', MODE0_CFG)
688 res_dict['mode0'] = r0
689 wall_dict['mode0'] = w0
690 else:
691 print('\nSearching for existing mode-0 reference run …')
692 r0, src = find_reference_run(args.ref_dir)
693 if r0 is not None:
694 res_dict['mode0'] = r0
695 wall_dict['mode0'] = args.wall_mode0
696 print(f' Loaded from {src}')
697 else:
698 print(' No I=V=10000 mode-0 run found. Relative-error plots will be skipped.')
699
700 # ── Mode IV ──────────────────────────────────────────────────────────────
701 if not args.skip_mode4:
702 r4, w4, _ = run_mode('Mode IV — active_window', 'active_window', MODE4_CFG)
703 res_dict['mode4'] = r4
704 wall_dict['mode4'] = w4
705 else:
706 print('\nSearching for existing Mode IV run …')
707 r4, src4 = find_window_run(args.load_mode4)
708 if r4 is not None:
709 res_dict['mode4'] = r4
710 wall_dict['mode4'] = args.wall_mode4
711 print(f' Loaded from {src4}')
712 else:
713 print(' No Mode IV run found.')
714
715 # ── Plots and report ─────────────────────────────────────────────────────
716 make_comparison_plots(res_dict, wall_dict, out_dir / 'comparison_overview.png')
717 make_distribution_plots(res_dict, out_dir / 'size_distributions.png')
718 write_report(res_dict, wall_dict, out_dir / 'comparison_report.txt')
719
720 print('\nAll done. Output in:', out_dir)
721
722
723if __name__ == '__main__':
724 main()
make_comparison_plots(res_dict, wall_dict, out_path)
make_distribution_plots(res_dict, out_path)
write_report(res_dict, wall_dict, report_path)
_load_run_from_dir(d, solver_mode, label)
run_mode(label, solver_mode, solver_cfg)
build_sim(solver_mode, physics_option=None)