3compare_linsol.py — Linear-solver / preconditioner comparison.
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.
11Group 1 EQUATIONS='discrete' (full per-size, I_bin=V_bin=0)
14 D-KLU klu (sparse direct, full_CD only)
16Group 2 EQUATIONS='bin_moment' (i_discrete=v_discrete=50, I_bin=V_bin=20)
19 B-KLU klu <-- C++ rejects: full_CD only
21After the 6 runs the script writes
22 compare_linsol_results.json
23 compare_linsol_report.md
26from __future__
import annotations
28import sys, os, io, time, json, subprocess, importlib, traceback
29from pathlib
import Path
30from datetime
import datetime
34os.environ.setdefault(
'PYTHONIOENCODING',
'utf-8')
36 sys.stdout.reconfigure(encoding=
'utf-8', errors=
'replace')
37 sys.stderr.reconfigure(encoding=
'utf-8', errors=
'replace')
42MODULE_ROOT = Path(__file__).resolve().parent.parent.parent
43REPO_ROOT = MODULE_ROOT.parent
44HERE = Path(__file__).resolve().parent
45for p
in (str(REPO_ROOT), str(MODULE_ROOT)):
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,
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)
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):
79from radcluster_code.py_utils.simulation
import RadClusterSimulation
88i_mobile, v_mobile = 10, 2
113BIN_MOMENT_GRID = dict(i_discrete=50, v_discrete=50,
115 shape_function=
'linear')
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 ),
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()
137 sim = RadClusterSimulation(
139 solver_mode=
'full_system',
143 he_kinetics=
'quasi_steady_state',
144 i_mobile=i_mobile, v_mobile=v_mobile,
147 ov = dict(PHYSICS_OVERRIDES)
148 if equations ==
'discrete':
153 ov[
'shape_function'] =
'linear'
155 ov.update(BIN_MOMENT_GRID)
158 for key, val
in ov.items():
160 for d
in (inp.production_fission, inp.production_fusion,
161 inp.diffusion, inp.reactions,
162 inp.energetics, inp.dissociation):
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()
175 sys.stdout, sys.stderr = saved_out, saved_err
183 'concentration_threshold': 1e-22,
186 if linsol ==
'gmres' and preconditioner
is not None:
187 method[
'preconditioner'] = preconditioner
190 'n_points': N_POINTS,
194 'solver_method': method,
200 return float(arr[-1])
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)
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)
218 record[
'N_eq'] = int(sim.rate_equations.N_eq)
221 t0 = time.perf_counter()
222 results = sim.run(solver_config=cfg, save_output=
False,
224 wall = time.perf_counter() - t0
225 record[
'wall_s'] = wall
228 record[
'status'] =
'failed'
229 print(f
" -> no results returned ({wall:.1f} s)", flush=
True)
234 record[
'status'] = (
'timeout' if (results.get(
'partial')
235 or wall >= TIMEOUT_S - 1)
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'))
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)
254 except Exception
as exc:
255 record[
'status'] =
'error'
256 record[
'error'] = f
"{type(exc).__name__}: {exc}"
257 traceback.print_exc()
267out_json = HERE /
'compare_linsol_results.json'
268with out_json.open(
'w')
as f:
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,
277 }, f, indent=2, default=str)
278print(f
"\nRaw results -> {out_json}")
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}"
291 if r[
'tag'] == tag:
return r
295 if a
is None or b
is None or b == 0:
return '—'
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
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))
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(
"|-------|--------------|--------|----------|------:|------------|---------:|------:|-------------|------------|--------------|---------|---------|")
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} |"
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']} |")
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.")
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']} |")
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.")
388lines.append(
"## 4. Inter-group comparison\n")
389lines.append(
"Compares the cheapest successful run in each group at matched physics.\n")
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)):
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} |"
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")
408 lines.append(
"_Inter-group comparison skipped — at least one group had no completed run._\n")
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}$.")
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)