RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
compare_linsol.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3compare_linsol.py — Linear-solver / preconditioner comparison.
4
5Six configurations are timed back-to-back, all with identical physics
6overrides (taken verbatim from the current cell of
7RadCluster_2_1.ipynb). Each run is capped at TIMEOUT_S wall-clock
8seconds; if the cap is hit, the C++ solver finalises the current
9output step gracefully and the partial trajectory is kept.
10
11Group 1 EQUATIONS='discrete' (full per-size, I_bin=V_bin=0)
12 D-WB gmres + Woodbury
13 D-JC gmres + Jacobi
14 D-KLU klu (sparse direct, full_CD only)
15
16Group 2 EQUATIONS='bin_moment' (i_discrete=v_discrete=50, I_bin=V_bin=20)
17 B-WB gmres + Woodbury
18 B-JC gmres + Jacobi
19 B-KLU klu <-- C++ rejects: full_CD only
20
21After the 6 runs the script writes
22 compare_linsol_results.json
23 compare_linsol_report.md
24in the same directory.
25"""
26from __future__ import annotations
27
28import sys, os, io, time, json, subprocess, importlib, traceback
29from pathlib import Path
30from datetime import datetime
31
32# Force UTF-8 stdout so bridge/solver prints with non-ASCII (any "->" etc.)
33# never crash the parse path on Windows cp1252 consoles.
34os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
35try:
36 sys.stdout.reconfigure(encoding='utf-8', errors='replace')
37 sys.stderr.reconfigure(encoding='utf-8', errors='replace')
38except Exception:
39 pass
40
41# ── Repo paths ────────────────────────────────────────────────────────────────
42MODULE_ROOT = Path(__file__).resolve().parent.parent.parent # radcluster_code/
43REPO_ROOT = MODULE_ROOT.parent
44HERE = Path(__file__).resolve().parent
45for p in (str(REPO_ROOT), str(MODULE_ROOT)):
46 if p not in sys.path:
47 sys.path.insert(0, p)
48
49# ── Build C++ solver (skip if up-to-date) ─────────────────────────────────────
50build_dir = MODULE_ROOT / 'build'
51exe_name = 'solver.exe' if sys.platform == 'win32' else 'solver'
52exe_paths = [build_dir / 'Release' / exe_name,
53 build_dir / 'Debug' / exe_name,
54 build_dir / exe_name]
55if not any(p.exists() for p in exe_paths):
56 print("Building C++ solver ...", flush=True)
57 build_dir.mkdir(exist_ok=True)
58 cmake_src = MODULE_ROOT / 'cpp_utils'
59 for cmd in (['cmake', '-S', str(cmake_src), '-B', str(build_dir),
60 '-DCMAKE_BUILD_TYPE=Release'],
61 ['cmake', '--build', str(build_dir), '--config', 'Release']):
62 res = subprocess.run(cmd, capture_output=True, text=True)
63 if res.returncode != 0:
64 print(res.stderr[-1500:]); sys.exit(1)
65 print("Build OK\n", flush=True)
66
67# ── Reload py_utils so any in-progress edits land ─────────────────────────────
68import radcluster_code.py_utils.defect_production as _dp
69import radcluster_code.py_utils.binding_energies as _be
70import radcluster_code.py_utils.bin_moment_rates as _bmr
71import radcluster_code.py_utils.input_data as _inp
72import radcluster_code.py_utils.reaction_rates as _rr
73import radcluster_code.py_utils.rate_equations as _re
74import radcluster_code.py_utils.cpp_bridge as _cb
75import radcluster_code.py_utils.post_process as _pp
76import radcluster_code.py_utils.simulation as _sim
77for _m in (_dp, _be, _bmr, _inp, _rr, _re, _cb, _pp, _sim):
78 importlib.reload(_m)
79from radcluster_code.py_utils.simulation import RadClusterSimulation
80
81# ══════════════════════════════════════════════════════════════════════════════
82# 1. Parameters — mirror the notebook cell exactly
83# ══════════════════════════════════════════════════════════════════════════════
84TIMEOUT_S = 3000.0 # per-run wall-clock cap
85
86# Domain (kept identical across both groups so physics is comparable)
87I = V = int(1e4)
88i_mobile, v_mobile = 10, 2
89
90# Time horizon and tolerances — copied from the notebook cell
91T_SPAN = (1e-6, 1e4)
92N_POINTS = 200
93RTOL = 1e-6
94ATOL = 1e-20
95C_FLOOR = 1e-25
96
97# Physics overrides — copied verbatim from the notebook cell
98PHYSICS_OVERRIDES = {
99 'T': 673,
100 'eta': 0.3,
101 'f_cl_i': 0.5,
102 'f_cl_v': 0.15,
103 'E_m_1D': 0.4,
104 'L_hat': 5,
105 'c_C': 1e-3,
106 'E_b_C_SIA': 0.65,
107 'rho_d': 1e14,
108 'Z_i': 1.08,
109 'Z_ii': 1.01,
110}
111
112# Bin-moment hybrid grid (user spec: I_discrete=v_discrete=50, I_bin=V_bin=20)
113BIN_MOMENT_GRID = dict(i_discrete=50, v_discrete=50,
114 I_bin=20, V_bin=20,
115 shape_function='linear')
116
117# Six cases. prec_type only consulted when linsol='gmres'.
118CASES = [
119 # tag, equations, linsol, preconditioner
120 ('D-WB', 'discrete', 'gmres', 'Woodbury'),
121 ('D-JC', 'discrete', 'gmres', 'Jacobi' ),
122 ('D-KLU', 'discrete', 'klu', None ),
123 ('B-WB', 'bin_moment', 'gmres', 'Woodbury'),
124 ('B-JC', 'bin_moment', 'gmres', 'Jacobi' ),
125 ('B-KLU', 'bin_moment', 'klu', None ), # expected: rejected by C++
126]
127
128
129# ══════════════════════════════════════════════════════════════════════════════
130# 2. Helpers
131# ══════════════════════════════════════════════════════════════════════════════
132def build_sim(equations: str) -> RadClusterSimulation:
133 """Construct a fresh RadClusterSimulation with the right equation form."""
134 saved_out, saved_err = sys.stdout, sys.stderr
135 sys.stdout = sys.stderr = io.StringIO()
136 try:
137 sim = RadClusterSimulation(
138 I=I, V=V,
139 solver_mode='full_system', # Woodbury requires full_system
140 equations=equations,
141 cascade='fission',
142 C_floor=C_FLOOR,
143 he_kinetics='quasi_steady_state',
144 i_mobile=i_mobile, v_mobile=v_mobile,
145 )
146 # Inject overrides
147 ov = dict(PHYSICS_OVERRIDES)
148 if equations == 'discrete':
149 ov['i_discrete'] = I # full per-size
150 ov['v_discrete'] = V
151 ov['I_bin'] = 0
152 ov['V_bin'] = 0
153 ov['shape_function'] = 'linear'
154 else:
155 ov.update(BIN_MOMENT_GRID)
156
157 inp = sim.input_data
158 for key, val in ov.items():
159 placed = False
160 for d in (inp.production_fission, inp.production_fusion,
161 inp.diffusion, inp.reactions,
162 inp.energetics, inp.dissociation):
163 if key in d:
164 d[key] = val
165 placed = True
166 if not placed:
167 inp.reactions[key] = val
168 for mob_key in ('i_mobile', 'v_mobile'):
169 v_int = int(ov.get(mob_key, inp.diffusion.get(mob_key, 0)))
170 inp.diffusion[mob_key] = v_int
171 inp.reactions[mob_key] = v_int
172 inp._calculate_derived()
173 sim.rebuild_rates()
174 finally:
175 sys.stdout, sys.stderr = saved_out, saved_err
176 return sim
177
178
179def make_solver_config(linsol: str, preconditioner) -> dict:
180 method = {
181 'linsol': linsol,
182 'window_width': 10,
183 'concentration_threshold': 1e-22,
184 'window_pad': 20,
185 }
186 if linsol == 'gmres' and preconditioner is not None:
187 method['preconditioner'] = preconditioner
188 return {
189 't_span': T_SPAN,
190 'n_points': N_POINTS,
191 'log_time': True,
192 'rtol': RTOL,
193 'atol': ATOL,
194 'solver_method': method,
195 }
196
197
198def _last(arr):
199 try:
200 return float(arr[-1])
201 except Exception:
202 return None
203
204
205def run_one(tag, equations, linsol, preconditioner):
206 print(f"\n{'='*72}\n CASE {tag}: equations={equations!r} linsol={linsol!r}"
207 f" prec={preconditioner!r}\n{'='*72}", flush=True)
208
209 record = dict(tag=tag, equations=equations, linsol=linsol,
210 preconditioner=preconditioner,
211 N_eq=None, wall_s=None, status='unknown',
212 t_final=None, dose_final=None, n_pts=None,
213 swelling_pct=None, delta_FP=None, delta_He=None,
214 mean_n_i=None, mean_n_v=None, error=None)
215
216 try:
217 sim = build_sim(equations)
218 record['N_eq'] = int(sim.rate_equations.N_eq)
219 cfg = make_solver_config(linsol, preconditioner)
220
221 t0 = time.perf_counter()
222 results = sim.run(solver_config=cfg, save_output=False,
223 timeout_s=TIMEOUT_S)
224 wall = time.perf_counter() - t0
225 record['wall_s'] = wall
226
227 if results is None:
228 record['status'] = 'failed'
229 print(f" -> no results returned ({wall:.1f} s)", flush=True)
230 return record
231
232 # Authoritative timeout flag set by cpp_bridge when wall cap or Ctrl+C
233 # tripped the graceful shutdown path; fall back to a wall-clock heuristic.
234 record['status'] = ('timeout' if (results.get('partial')
235 or wall >= TIMEOUT_S - 1)
236 else 'completed')
237 t_arr = results.get('t', [])
238 record['n_pts'] = int(len(t_arr))
239 record['t_final'] = _last(t_arr)
240 record['dose_final'] = _last(results.get('dose'))
241 sw = _last(results.get('swelling'))
242 record['swelling_pct'] = sw * 100.0 if sw is not None else None
243 record['delta_FP'] = _last(results.get('delta_FP'))
244 record['delta_He'] = _last(results.get('delta_He'))
245 record['mean_n_i'] = _last(results.get('mean_n_i'))
246 record['mean_n_v'] = _last(results.get('mean_n_v'))
247
248 tf = record['t_final']
249 tf_s = '—' if tf is None else f"{tf:.3e}"
250 print(f" -> status={record['status']} wall={wall:.2f} s "
251 f"n_pts={record['n_pts']} t_final={tf_s}", flush=True)
252 return record
253
254 except Exception as exc:
255 record['status'] = 'error'
256 record['error'] = f"{type(exc).__name__}: {exc}"
257 traceback.print_exc()
258 return record
259
260
261# ══════════════════════════════════════════════════════════════════════════════
262# 3. Run the matrix
263# ══════════════════════════════════════════════════════════════════════════════
264records = [run_one(*c) for c in CASES]
265
266# ── persist raw results ───────────────────────────────────────────────────────
267out_json = HERE / 'compare_linsol_results.json'
268with out_json.open('w') as f:
269 json.dump({
270 'timestamp': datetime.now().isoformat(timespec='seconds'),
271 'I': I, 'V': V, 'i_mobile': i_mobile, 'v_mobile': v_mobile,
272 't_span': T_SPAN, 'n_points': N_POINTS, 'rtol': RTOL, 'atol': ATOL,
273 'physics_overrides': PHYSICS_OVERRIDES,
274 'bin_moment_grid': BIN_MOMENT_GRID,
275 'timeout_s': TIMEOUT_S,
276 'records': records,
277 }, f, indent=2, default=str)
278print(f"\nRaw results -> {out_json}")
279
280
281# ══════════════════════════════════════════════════════════════════════════════
282# 4. Auto-generate the markdown report
283# ══════════════════════════════════════════════════════════════════════════════
284def fmt_t(x): return '—' if x is None else f"{x:.3e}"
285def fmt_w(x): return '—' if x is None else f"{x:8.1f}"
286def fmt_pct(x): return '—' if x is None else f"{x:.4f}"
287def fmt_n(x): return '—' if x is None else f"{int(x):d}"
288
289def find(tag):
290 for r in records:
291 if r['tag'] == tag: return r
292 return None
293
294def ratio(a, b):
295 if a is None or b is None or b == 0: return '—'
296 return f"{a/b:.2f}×"
297
298D_WB, D_JC, D_KLU = find('D-WB'), find('D-JC'), find('D-KLU')
299B_WB, B_JC, B_KLU = find('B-WB'), find('B-JC'), find('B-KLU')
300
301# Pick the fastest *successful* run in each group as the in-group reference
302def fastest(group):
303 ok = [r for r in group if r['status'] == 'completed' and r['wall_s']]
304 return min(ok, key=lambda r: r['wall_s']) if ok else None
305
306ref_d = fastest([D_WB, D_JC, D_KLU])
307ref_b = fastest([B_WB, B_JC, B_KLU])
308
309lines = []
310lines.append("# Linear-solver / preconditioner comparison\n")
311lines.append(f"_Generated {datetime.now().isoformat(timespec='seconds')}_\n")
312lines.append("## 1. Setup\n")
313lines.append(f"- **Domain:** `I = V = {I}`, `i_mobile = {i_mobile}`, `v_mobile = {v_mobile}`")
314lines.append(f"- **Time horizon:** `t_span = {T_SPAN}`, `n_points = {N_POINTS}`, "
315 f"`rtol = {RTOL:.0e}`, `atol = {ATOL:.0e}`")
316lines.append(f"- **Solver mode:** `full_system`, cascade = `fission`, he_kinetics = `quasi_steady_state`")
317lines.append(f"- **Wall-clock cap per run:** `TIMEOUT_S = {TIMEOUT_S:.0f} s` "
318 f"(C++ child finalises gracefully on cap, partial bin file kept)")
319lines.append("- **Group 1 (`discrete`):** full per-size — `i_discrete = I`, `v_discrete = V`, "
320 "`I_bin = V_bin = 0`")
321lines.append(f" - `N_eq` = {D_WB['N_eq'] if D_WB else '—'}")
322lines.append("- **Group 2 (`bin_moment`):** hybrid — "
323 "`i_discrete = v_discrete = 50`, `I_bin = V_bin = 20`, `shape_function = 'linear'` (P=2)")
324lines.append(f" - `N_eq` = {B_WB['N_eq'] if B_WB else '—'}\n")
325lines.append("**Physics overrides (identical for all 6 runs):**\n")
326lines.append("```python")
327lines.append("PHYSICS_OVERRIDES = " + json.dumps(PHYSICS_OVERRIDES, indent=4))
328lines.append("```\n")
329
330# ── Results table ────────────────────────────────────────────────────────────
331lines.append("## 2. Raw results\n")
332lines.append("| Tag | Equations | linsol | precond | N_eq | status | wall (s) | n_pts | t_final (s) | dose (dpa) | swelling (%) | δ_FP | δ_He |")
333lines.append("|-------|--------------|--------|----------|------:|------------|---------:|------:|-------------|------------|--------------|---------|---------|")
334for r in records:
335 lines.append(
336 f"| {r['tag']:<5} | {r['equations']:<12} | {r['linsol']:<6} | "
337 f"{(r['preconditioner'] or '—'):<8} | "
338 f"{r['N_eq'] if r['N_eq'] else '—':>5} | {r['status']:<10} | "
339 f"{fmt_w(r['wall_s']):>8} | {fmt_n(r['n_pts']):>5} | "
340 f"{fmt_t(r['t_final'])} | {fmt_t(r['dose_final'])} | "
341 f"{fmt_pct(r['swelling_pct']):>12} | "
342 f"{fmt_t(r['delta_FP']):>7} | {fmt_t(r['delta_He']):>7} |"
343 )
344lines.append("")
345
346# ── Intra-group analysis ─────────────────────────────────────────────────────
347lines.append("## 3. Intra-group comparison\n")
348lines.append("### 3.1 `discrete` (Group 1)\n")
349lines.append(f"Reference (fastest completed): **{ref_d['tag'] if ref_d else 'none'}** "
350 f"({fmt_w(ref_d['wall_s']) if ref_d else '—'} s).\n")
351lines.append("| Tag | precond | wall (s) | speed-up vs ref | status |")
352lines.append("|-------|----------|---------:|-----------------|------------|")
353for r in (D_WB, D_JC, D_KLU):
354 if r is None: continue
355 rel = ratio(r['wall_s'], ref_d['wall_s']) if (ref_d and r['wall_s']) else '—'
356 lines.append(f"| {r['tag']:<5} | {(r['preconditioner'] or r['linsol']):<8} | "
357 f"{fmt_w(r['wall_s'])} | {rel} | {r['status']} |")
358lines.append("")
359lines.append(
360 "_Notes._ Within Group 1 the C++ KLU path (linsol=`klu`) builds the exact "
361 "sparsity pattern of the per-size Jacobian and uses a colored finite-difference "
362 "Jacobian, giving sparse-direct factorisation. GMRES + Woodbury exploits the "
363 "bordered-banded structure $J = T + UV^T$ with band half-width "
364 f"`{2*max(i_mobile, v_mobile)+1}` and rank `{i_mobile+v_mobile}`. GMRES + Jacobi "
365 "is the legacy diagonal preconditioner — included as a baseline.")
366lines.append("")
367
368lines.append("### 3.2 `bin_moment` (Group 2)\n")
369lines.append(f"Reference (fastest completed): **{ref_b['tag'] if ref_b else 'none'}** "
370 f"({fmt_w(ref_b['wall_s']) if ref_b else '—'} s).\n")
371lines.append("| Tag | precond | wall (s) | speed-up vs ref | status |")
372lines.append("|-------|----------|---------:|-----------------|------------|")
373for r in (B_WB, B_JC, B_KLU):
374 if r is None: continue
375 rel = ratio(r['wall_s'], ref_b['wall_s']) if (ref_b and r['wall_s']) else '—'
376 lines.append(f"| {r['tag']:<5} | {(r['preconditioner'] or r['linsol']):<8} | "
377 f"{fmt_w(r['wall_s'])} | {rel} | {r['status']} |")
378lines.append("")
379lines.append(
380 "_Notes._ KLU is **not wired for `bin_moment`** in the C++ solver — the "
381 "sparsity-pattern builder is `full_CD`-only (`solver.cpp` lines 386-426), so "
382 "B-KLU is expected to fail at startup with the message "
383 "`[KLU] linsol=klu only supports full_CD modes`. The two GMRES variants are "
384 "the practical options for the bin-moment formulation.")
385lines.append("")
386
387# ── Inter-group analysis ─────────────────────────────────────────────────────
388lines.append("## 4. Inter-group comparison\n")
389lines.append("Compares the cheapest successful run in each group at matched physics.\n")
390if ref_d and ref_b:
391 spd = ratio(ref_d['wall_s'], ref_b['wall_s'])
392 lines.append(f"| Group | best tag | wall (s) | N_eq | swelling (%) | δ_FP | δ_He |")
393 lines.append(f"|--------------|----------|---------:|-----:|--------------|---------|---------|")
394 for label, r in (('discrete', ref_d), ('bin_moment', ref_b)):
395 lines.append(
396 f"| {label:<12} | {r['tag']:<8} | {fmt_w(r['wall_s'])} | "
397 f"{r['N_eq']:>4} | {fmt_pct(r['swelling_pct']):>12} | "
398 f"{fmt_t(r['delta_FP']):>7} | {fmt_t(r['delta_He']):>7} |"
399 )
400 lines.append("")
401 lines.append(f"**Speed ratio (discrete / bin_moment):** {spd}.\n")
402 lines.append("**Solution agreement:** swelling and conservation diagnostics should "
403 "match between the two formulations to within the bin-moment closure "
404 "error (`linear` shape, P=2 → truncation error $O((r-1)^3)$). "
405 "A swelling discrepancy above ~1 % (relative) or a δ_FP / δ_He gap "
406 "above $10^{-3}$ is a red flag for the binning grid.\n")
407else:
408 lines.append("_Inter-group comparison skipped — at least one group had no completed run._\n")
409
410# ── Recommendations ──────────────────────────────────────────────────────────
411lines.append("## 5. Recommendations (data-driven, fill in after inspection)\n")
412lines.append("- **Best linsol for `discrete`:** _e.g._ KLU when the Jacobian fits in cache; "
413 "Woodbury when N_eq exceeds a few thousand.")
414lines.append("- **Best linsol for `bin_moment`:** Woodbury or Jacobi GMRES — pick from the "
415 "Group-2 wall-clock column above.")
416lines.append("- **Recommended production setup:** the formulation/linsol pair with the "
417 "lowest wall-clock that also keeps δ_FP, δ_He < $10^{-6}$.")
418lines.append("")
419
420report_path = HERE / 'compare_linsol_report.md'
421report_path.write_text('\n'.join(lines), encoding='utf-8')
422print(f"Report -> {report_path}")
RadClusterSimulation build_sim(str equations)
run_one(tag, equations, linsol, preconditioner)
dict make_solver_config(str linsol, preconditioner)