RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
calibrate.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3calibrate.py — Autonomous parameter calibration for RadCluster_2_1.
4
5Explores the parameter space using Latin Hypercube Sampling, runs bin-moment
6cluster dynamics simulations via the C++ solver, and identifies parameter
7combinations that match experimental TEM data at 10 dpa for EUROFER97:
8
9 SIA loop density ~ 10^22 m^-3 diameter ~ 10 nm
10 Void density ~ 3-5 x 10^21 m^-3 diameter ~ 2-4 nm
11
12Usage
13-----
14 cd radcluster_code
15 python codes/calibrate.py # default 20 + 10 runs
16 python codes/calibrate.py --phase1 30 # more exploration
17 python codes/calibrate.py --timeout 120 # 2 min per run
18"""
19
20import sys, os, csv, time, argparse
21from pathlib import Path
22import numpy as np
23
24# ── Setup path ────────────────────────────────────────────────────────────────
25SCRIPT_DIR = Path(__file__).resolve().parent
26BASE_DIR = SCRIPT_DIR.parent.parent
27sys.path.insert(0, str(BASE_DIR.parent)) # repo root
28
29from radcluster_code.py_utils.simulation import RadClusterSimulation
30
31# ── Physical constants for size conversion ────────────────────────────────────
32OMEGA = 1.18e-29 # atomic volume [m^3]
33B_111 = 2.482e-10 # Burgers vector 1/2<111> [m]
34
35
36# ══════════════════════════════════════════════════════════════════════════════
37# Parameter space
38# ══════════════════════════════════════════════════════════════════════════════
39
40PARAM_SPEC = [
41 # (name, sheet, lo, hi, scale)
42 # -- Production --
43 ('eta', 'production_fission', 0.15, 0.35, 'linear'),
44 ('f_cl_i', 'production_fission', 0.10, 0.55, 'linear'),
45 ('f_cl_v', 'production_fission', 0.02, 0.15, 'linear'),
46 # -- Diffusion: 1D glide & mobility cutoff --
47 ('E_m_1D', 'diffusion', 0.01, 0.20, 'linear'),
48 ('i_mobile', 'diffusion', 3, 100, 'int'),
49 ('L_hat', 'diffusion', 20, 100, 'linear'),
50 # -- Diffusion: solute trapping (dominant effect on SIA mobility) --
51 ('c_C', 'diffusion', 1e-5, 5e-4, 'log'),
52 ('E_b_C_SIA', 'diffusion', 0.20, 0.50, 'linear'),
53 # -- Reactions: sinks and coalescence bias --
54 ('rho_d', 'reactions', 1e12, 1e14, 'log'),
55 ('Z_i', 'reactions', 1.02, 1.20, 'linear'),
56 ('Z_ii', 'reactions', 1.0, 10.0, 'log'),
57]
58
59PARAM_NAMES = [p[0] for p in PARAM_SPEC]
60
61
62def latin_hypercube(n_samples, n_dims, rng):
63 """Simple LHS: stratified random in [0,1]^d."""
64 result = np.zeros((n_samples, n_dims))
65 for j in range(n_dims):
66 perm = rng.permutation(n_samples)
67 for i in range(n_samples):
68 result[perm[i], j] = (i + rng.random()) / n_samples
69 return result
70
71
72def unit_to_physical(u, spec):
73 """Map u in [0,1] to physical value according to spec."""
74 name, sheet, lo, hi, scale = spec
75 if scale == 'log':
76 return 10 ** (np.log10(lo) + u * (np.log10(hi) - np.log10(lo)))
77 elif scale == 'int':
78 return int(round(lo + u * (hi - lo)))
79 else:
80 return lo + u * (hi - lo)
81
82
83def physical_to_unit(val, spec):
84 """Map physical value back to [0,1]."""
85 name, sheet, lo, hi, scale = spec
86 if scale == 'log':
87 return (np.log10(val) - np.log10(lo)) / (np.log10(hi) - np.log10(lo))
88 elif scale == 'int':
89 return (val - lo) / (hi - lo)
90 else:
91 return (val - lo) / (hi - lo)
92
93
94def sample_to_params(unit_vec):
95 """Convert a [0,1]^d vector to a dict of physical parameters."""
96 return {spec[0]: unit_to_physical(u, spec)
97 for u, spec in zip(unit_vec, PARAM_SPEC)}
98
99
100# ══════════════════════════════════════════════════════════════════════════════
101# Objective function
102# ══════════════════════════════════════════════════════════════════════════════
103
104# Experimental targets at 10 dpa
105TARGET_DOSE = 10.0 # dpa
106TARGET_N_LOOPS = 1.0e22 # m^-3 (TEM-visible SIA loop density)
107TARGET_D_I_NM = 10.0 # nm (TEM-visible SIA loop diameter)
108TARGET_N_VOIDS = 4.0e21 # m^-3 (TEM-visible void density)
109TARGET_D_V_NM = 3.0 # nm (TEM-visible void diameter)
110
111# TEM visibility threshold: clusters below this size are invisible
112N_MIN_TEM = 10 # atoms (~1 nm diameter)
113
114
116 """Convert mean SIA cluster size (atoms) to loop diameter (nm)."""
117 if mean_n_i <= 0:
118 return 0.0
119 r = np.sqrt(mean_n_i * OMEGA / (np.pi * B_111))
120 return 2 * r * 1e9 # m -> nm
121
122
124 """Convert mean vacancy cluster size (atoms) to void diameter (nm)."""
125 if mean_n_v <= 0:
126 return 0.0
127 r = (3 * mean_n_v * OMEGA / (4 * np.pi)) ** (1.0 / 3.0)
128 return 2 * r * 1e9 # m -> nm
129
130
131def evaluate_cost(results, sim):
132 """
133 Compute scalar cost measuring deviation from experimental targets at
134 TARGET_DOSE dpa. Uses TEM-visible clusters only (n,m >= N_MIN_TEM).
135 Returns (cost, metrics_dict) or (inf, None) on failure.
136 """
137 if results is None:
138 return float('inf'), None
139
140 dose = results['dose']
141 if dose[-1] < TARGET_DOSE * 0.9:
142 return float('inf'), None
143
144 idx = np.argmin(np.abs(dose - TARGET_DOSE))
145 inv_Omega = 1.0 / OMEGA
146
147 # Reconstruct per-size distributions at the target dose
148 re_obj = sim.rate_equations
149 N = sim.input_data.I
150 M = sim.input_data.V
151 yj = np.maximum(results['y'][:, idx], 0.0)
152
153 is_bin = hasattr(re_obj, 'bins')
154 if is_bin:
155 from radcluster_code.py_utils.bin_moment_rates import \
156 distribution_from_moments_hat
157 i_d = getattr(re_obj, 'i_discrete', 0)
158 v_d = getattr(re_obj, 'v_discrete', 0)
159 K_bins = re_obj.I_bin if hasattr(re_obj, 'I_bin') else len(re_obj.bins)
160 c_n = np.zeros(N)
161 c_n[:i_d] = yj[:i_d]
162 if K_bins > 0:
163 mom = yj[i_d:]
164 mu0 = mom[0::2][:K_bins]
165 mu1 = mom[1::2][:K_bins]
166 c_binned = distribution_from_moments_hat(mu0, mu1, re_obj.bins, N)
167 c_n[i_d:] = c_binned[i_d:]
168 i_VAC = getattr(re_obj, 'i_VAC', i_d + 2 * K_bins)
169 K_v = getattr(re_obj, 'V_bin', getattr(re_obj, 'K_v', 0))
170 c_v = np.zeros(M)
171 c_v[:v_d] = yj[i_VAC:i_VAC + v_d]
172 if K_v > 0:
173 vac_start = i_VAC + v_d
174 vmu0 = yj[vac_start::2][:K_v]
175 vmu1 = yj[vac_start + 1::2][:K_v]
176 c_v_binned = distribution_from_moments_hat(
177 vmu0, vmu1, re_obj.vac_bins, M)
178 c_v[v_d:] = c_v_binned[v_d:]
179 elif v_d == 0:
180 c_v = yj[i_VAC:i_VAC + M]
181 else:
182 i_SIA = getattr(re_obj, 'i_SIA', 0)
183 i_VAC = getattr(re_obj, 'i_VAC', N)
184 c_n = yj[i_SIA:i_VAC]
185 c_v = yj[i_VAC:i_VAC + M]
186
187 # TEM-visible quantities: only clusters with n,m >= N_MIN_TEM
188 ns = np.arange(1, N + 1, dtype=float)
189 ms = np.arange(1, M + 1, dtype=float)
190 tem_i = ns >= N_MIN_TEM
191 tem_v = ms >= N_MIN_TEM
192
193 # SIA loops (TEM-visible)
194 N_loops_vis = np.sum(c_n[tem_i]) * inv_Omega # [m^-3]
195 content_vis = np.dot(ns[tem_i], c_n[tem_i])
196 count_vis = np.sum(c_n[tem_i])
197 mean_n_i_vis = content_vis / max(count_vis, 1e-300)
198 d_i_nm = atoms_to_loop_diameter_nm(mean_n_i_vis)
199
200 # Voids (TEM-visible)
201 N_voids_vis = np.sum(c_v[tem_v]) * inv_Omega # [m^-3]
202 vcontent_vis = np.dot(ms[tem_v], c_v[tem_v])
203 vcount_vis = np.sum(c_v[tem_v])
204 mean_n_v_vis = vcontent_vis / max(vcount_vis, 1e-300)
205 d_v_nm = atoms_to_void_diameter_nm(mean_n_v_vis)
206
207 # Guard against zeros
208 if N_loops_vis < 1e10 or N_voids_vis < 1e10:
209 return float('inf'), None
210 if d_i_nm < 0.01 or d_v_nm < 0.01:
211 return float('inf'), None
212
213 # Log-ratio penalty for densities, relative penalty for sizes
214 cost = (
215 (np.log10(N_loops_vis) - np.log10(TARGET_N_LOOPS)) ** 2
216 + ((d_i_nm - TARGET_D_I_NM) / TARGET_D_I_NM) ** 2
217 + (np.log10(N_voids_vis) - np.log10(TARGET_N_VOIDS)) ** 2
218 + ((d_v_nm - TARGET_D_V_NM) / TARGET_D_V_NM) ** 2
219 )
220
221 metrics = {
222 'dose_dpa': dose[idx],
223 'N_loops': N_loops_vis,
224 'N_voids': N_voids_vis,
225 'mean_n_i': mean_n_i_vis,
226 'mean_n_v': mean_n_v_vis,
227 'd_i_nm': d_i_nm,
228 'd_v_nm': d_v_nm,
229 'delta_FP': results['delta_FP'][idx],
230 'delta_He': results['delta_He'][idx],
231 }
232 return cost, metrics
233
234
235# ══════════════════════════════════════════════════════════════════════════════
236# Simulation runner
237# ══════════════════════════════════════════════════════════════════════════════
238
239def run_single(params, timeout_s=180, run_id=0):
240 """
241 Run one simulation with the given parameter dict.
242 Returns (cost, metrics, wall_time) or (inf, None, wall_time).
243 """
244 t0 = time.time()
245
246 try:
247 sim = RadClusterSimulation(
248 I=5000, V=5000,
249 solver_mode='full_system',
250 physics_option='bin_moment_CD_fission',
251 he_kinetics='quasi_steady_state',
252 )
253
254 # ── Apply parameter overrides ────────────────────────────────────────
255 inp = sim.input_data
256 for pname, sheet, *_ in PARAM_SPEC:
257 val = params[pname]
258 if sheet == 'production_fission':
259 inp.production_fission[pname] = val
260 elif sheet == 'diffusion':
261 inp.diffusion[pname] = val
262 elif sheet == 'reactions':
263 inp.reactions[pname] = val
264
265 # Propagate i_mobile and v_mobile to reactions dict too (used by solver)
266 if 'i_mobile' in params:
267 inp.reactions['i_mobile'] = int(params['i_mobile'])
268 inp.diffusion['i_mobile'] = int(params['i_mobile'])
269
270 # Recompute derived quantities and rebuild rate constants
271 inp._calculate_derived()
272 sim.rebuild_rates()
273
274 # ── Solver config (fast) ─────────────────────────────────────────────
275 G = float(inp.reactions.get('G', 1e-6))
276 t_end = TARGET_DOSE / G # simulate exactly to target dose
277
278 solver_config = {
279 't_span': (1e-3, t_end),
280 'n_points': 50,
281 'log_time': True,
282 'rtol': 1e-5,
283 'atol': 0.0,
284 }
285
286 results = sim.run(
287 solver_config=solver_config,
288 save_output=False,
289 timeout_s=timeout_s,
290 )
291
292 cost, metrics = evaluate_cost(results, sim)
293
294 except Exception as exc:
295 print(f" [run {run_id}] EXCEPTION: {exc}")
296 cost, metrics = float('inf'), None
297
298 wall = time.time() - t0
299 return cost, metrics, wall
300
301
302# ══════════════════════════════════════════════════════════════════════════════
303# Logging
304# ══════════════════════════════════════════════════════════════════════════════
305
306LOG_FIELDS = (
307 ['run_id', 'phase', 'cost', 'wall_s']
308 + PARAM_NAMES
309 + ['N_loops', 'N_voids', 'mean_n_i', 'mean_n_v',
310 'd_i_nm', 'd_v_nm', 'delta_FP', 'delta_He']
311)
312
313
314def append_log(log_path, row_dict):
315 """Append one row to the CSV log, creating headers if needed."""
316 write_header = not log_path.exists()
317 with open(log_path, 'a', newline='') as f:
318 w = csv.DictWriter(f, fieldnames=LOG_FIELDS, extrasaction='ignore')
319 if write_header:
320 w.writeheader()
321 w.writerow(row_dict)
322
323
324def build_row(run_id, phase, params, cost, metrics, wall):
325 """Assemble a log row dict."""
326 row = {'run_id': run_id, 'phase': phase, 'cost': f'{cost:.6f}',
327 'wall_s': f'{wall:.1f}'}
328 for p in PARAM_NAMES:
329 row[p] = f'{params[p]:.6g}'
330 if metrics:
331 for k, v in metrics.items():
332 row[k] = f'{v:.6g}'
333 return row
334
335
336# ══════════════════════════════════════════════════════════════════════════════
337# Main
338# ══════════════════════════════════════════════════════════════════════════════
339
340def main():
341 parser = argparse.ArgumentParser(description='Calibrate RadCluster_2_1')
342 parser.add_argument('--phase1', type=int, default=20,
343 help='Number of Phase 1 (exploration) samples')
344 parser.add_argument('--phase2', type=int, default=10,
345 help='Number of Phase 2 (refinement) samples')
346 parser.add_argument('--timeout', type=int, default=180,
347 help='Max seconds per run')
348 parser.add_argument('--seed', type=int, default=42,
349 help='Random seed')
350 args = parser.parse_args()
351
352 rng = np.random.default_rng(args.seed)
353 log_path = BASE_DIR / 'output' / 'calibration_log.csv'
354 os.makedirs(log_path.parent, exist_ok=True)
355
356 # Remove old log
357 if log_path.exists():
358 log_path.unlink()
359
360 all_results = [] # (cost, params, metrics)
361 run_counter = 0
362
363 # ── Phase 1: Latin Hypercube exploration ──────────────────────────────────
364 n1 = args.phase1
365 n_dims = len(PARAM_SPEC)
366 lhs = latin_hypercube(n1, n_dims, rng)
367
368 print(f"\n{'='*70}")
369 print(f" Phase 1: Latin Hypercube Sampling — {n1} runs")
370 print(f" Timeout: {args.timeout}s per run")
371 print(f" Log: {log_path}")
372 print(f"{'='*70}\n")
373
374 for i in range(n1):
375 params = sample_to_params(lhs[i])
376 param_str = ' '.join(f'{k}={params[k]:.4g}' for k in PARAM_NAMES)
377 print(f"[{i+1}/{n1}] {param_str}")
378
379 cost, metrics, wall = run_single(params, timeout_s=args.timeout,
380 run_id=run_counter)
381 all_results.append((cost, params, metrics))
382 row = build_row(run_counter, 'phase1', params, cost, metrics, wall)
383 append_log(log_path, row)
384
385 status = f"cost={cost:.4f}" if cost < 1e10 else "FAILED"
386 summary = ""
387 if metrics:
388 summary = (f" N_loops={metrics['N_loops']:.2e}"
389 f" d_i={metrics['d_i_nm']:.1f}nm"
390 f" N_voids={metrics['N_voids']:.2e}"
391 f" d_v={metrics['d_v_nm']:.1f}nm")
392 print(f" -> {status} ({wall:.0f}s){summary}\n")
393 run_counter += 1
394
395 # ── Phase 2: Refinement around best candidates ────────────────────────────
396 n2 = args.phase2
397 if n2 > 0:
398 # Sort by cost, take top 3
399 valid = [(c, p, m) for c, p, m in all_results if c < 1e10]
400 valid.sort(key=lambda x: x[0])
401
402 if not valid:
403 print("\nNo valid Phase 1 runs — skipping Phase 2.")
404 else:
405 n_best = min(3, len(valid))
406 print(f"\n{'='*70}")
407 print(f" Phase 2: Refinement — {n2} runs around top {n_best}")
408 print(f"{'='*70}\n")
409
410 # Generate refinement samples: perturb top candidates by +-20%
411 refine_samples = []
412 for rank in range(n_best):
413 _, best_p, _ = valid[rank]
414 n_per = max(1, n2 // n_best + (1 if rank < n2 % n_best else 0))
415 for _ in range(n_per):
416 perturbed = {}
417 for spec in PARAM_SPEC:
418 pname = spec[0]
419 base_val = best_p[pname]
420 u_base = physical_to_unit(base_val, spec)
421 # Perturb in unit space by +-20%
422 delta = 0.20 * (rng.random() * 2 - 1)
423 u_new = np.clip(u_base + delta, 0.0, 1.0)
424 perturbed[pname] = unit_to_physical(u_new, spec)
425 refine_samples.append(perturbed)
426
427 for i, params in enumerate(refine_samples):
428 param_str = ' '.join(f'{k}={params[k]:.4g}'
429 for k in PARAM_NAMES)
430 print(f"[R{i+1}/{len(refine_samples)}] {param_str}")
431
432 cost, metrics, wall = run_single(
433 params, timeout_s=args.timeout, run_id=run_counter)
434 all_results.append((cost, params, metrics))
435 row = build_row(run_counter, 'phase2', params, cost,
436 metrics, wall)
437 append_log(log_path, row)
438
439 status = f"cost={cost:.4f}" if cost < 1e10 else "FAILED"
440 summary = ""
441 if metrics:
442 summary = (f" N_loops={metrics['N_loops']:.2e}"
443 f" d_i={metrics['d_i_nm']:.1f}nm"
444 f" N_voids={metrics['N_voids']:.2e}"
445 f" d_v={metrics['d_v_nm']:.1f}nm")
446 print(f" -> {status} ({wall:.0f}s){summary}\n")
447 run_counter += 1
448
449 # ── Final report ──────────────────────────────────────────────────────────
450 valid = [(c, p, m) for c, p, m in all_results if c < 1e10]
451 valid.sort(key=lambda x: x[0])
452
453 print(f"\n{'='*70}")
454 print(f" CALIBRATION COMPLETE — {run_counter} runs, {len(valid)} valid")
455 print(f"{'='*70}")
456
457 if not valid:
458 print("\n No valid runs found. Try relaxing bounds or increasing timeout.")
459 return
460
461 print(f"\n Experimental targets at {TARGET_DOSE} dpa:")
462 print(f" SIA loop density: {TARGET_N_LOOPS:.1e} m^-3")
463 print(f" SIA loop diameter: {TARGET_D_I_NM} nm")
464 print(f" Void density: {TARGET_N_VOIDS:.1e} m^-3")
465 print(f" Void diameter: {TARGET_D_V_NM} nm")
466
467 print(f"\n Top {min(5, len(valid))} results:\n")
468 print(f" {'Rank':>4} {'Cost':>8} {'N_loops':>10} {'d_i(nm)':>8}"
469 f" {'N_voids':>10} {'d_v(nm)':>8}")
470 print(f" {'-'*60}")
471
472 for rank, (cost, params, metrics) in enumerate(valid[:5]):
473 print(f" {rank+1:>4} {cost:8.4f}"
474 f" {metrics['N_loops']:10.2e} {metrics['d_i_nm']:8.1f}"
475 f" {metrics['N_voids']:10.2e} {metrics['d_v_nm']:8.1f}")
476
477 # Print best parameter set
478 best_cost, best_params, best_metrics = valid[0]
479 print(f"\n Best parameter set (cost={best_cost:.4f}):\n")
480 print(f" {'Parameter':>12} {'Value':>12} {'Sheet'}")
481 print(f" {'-'*45}")
482 for spec in PARAM_SPEC:
483 pname, sheet = spec[0], spec[1]
484 val = best_params[pname]
485 if isinstance(val, int):
486 print(f" {pname:>12} {val:>12} {sheet}")
487 else:
488 print(f" {pname:>12} {val:>12.4g} {sheet}")
489
490 print(f"\n Full log: {log_path}\n")
491
492
493if __name__ == '__main__':
494 main()
append_log(log_path, row_dict)
Definition calibrate.py:314
evaluate_cost(results, sim)
Definition calibrate.py:131
atoms_to_loop_diameter_nm(mean_n_i)
Definition calibrate.py:115
latin_hypercube(n_samples, n_dims, rng)
Definition calibrate.py:62
unit_to_physical(u, spec)
Definition calibrate.py:72
build_row(run_id, phase, params, cost, metrics, wall)
Definition calibrate.py:324
sample_to_params(unit_vec)
Definition calibrate.py:94
physical_to_unit(val, spec)
Definition calibrate.py:83
run_single(params, timeout_s=180, run_id=0)
Definition calibrate.py:239
atoms_to_void_diameter_nm(mean_n_v)
Definition calibrate.py:123