RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
check_bm_vs_discrete.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""
3check_bm_vs_discrete.py — the discrete path silently ignores LOOP_COAL.
4
5CLAUDE.md S1 says that at I_bin = 0 and i_discrete = I the bin_moment system IS
6the discrete system. It is not, and the reason is NOT a bin_moment defect:
7
8 P.loop_coal -- 1/2<111> loop-loop coarsening, rate_kernels.cpp:1944 -- is
9 implemented ONLY in rhs_bin_moment. rhs_full_CD contains zero references
10 to it. The workbook ships LOOP_COAL = 1, so the discrete path accepts the
11 flag, reports it in provenance, and does nothing with it.
12
13So the discrete arm is not the exact solution of the same equations: it is the
14same equations MINUS the loop-coarsening channel. The bin_moment answer is the
15physically intended one.
16
17rate_kernels.cpp:1930 predicts exactly what the discrete arm then shows, and
18calls it "the missing edge": without coarsening "the 1/2<111> mean size pins AT
19the cutoff" and "the density runs 20-60x over the EUROFER97 data". Measured
20here: discrete d_111 frozen at 1.759 / 1.763 / 1.760 nm across a 30x dose range,
21and N_111 3.5x the bin_moment value.
22
23Runs in ~20 s. Exits non-zero when the two paths disagree, so it can serve as a
24regression test once the cause is fixed.
25"""
26import io, sys
27from pathlib import Path
28import numpy as np
29
30MOD = Path(__file__).resolve().parents[2] # radcluster_code/
31for p in (str(MOD.parent), str(MOD / "digital_twin")):
32 if p not in sys.path:
33 sys.path.insert(0, p)
34
35from radcluster_code.py_utils.simulation import RadClusterSimulation # noqa: E402
36import run_ensemble as re_mod # noqa: E402
37
38# Grid: the TD_BM_FULL / TD_DISC_REF pair would have checked this at I = V =
39# 4000 and 0.2 dpa, and was stopped after ~13 h at 31% with steps at 148 min and
40# lengthening -- roughly two more days to confirm what this file settles in
41# seconds. Raising the grid here buys the same domain and dose coverage at a
42# cost that can run on every change. --big goes further still.
43I = V = 1000
44DOSE = 1e-2 # dpa; well past nucleation onset, unlike the old 1e-3
45# The two paths do identical arithmetic in DIFFERENT ORDERS, so CVODE's adaptive
46# stepping cannot make them agree better than its own tolerance. The runs use
47# rtol = 1e-5; 1e-4 leaves an order of magnitude of headroom while still failing
48# instantly on a missing channel (which showed as 0.43, not 1e-5).
49TOL = 1e-4
50
51
52def run(equations):
53 buf, (so, se) = io.StringIO(), (sys.stdout, sys.stderr)
54 try:
55 sys.stdout = sys.stderr = buf
56 sim = RadClusterSimulation(I=I, V=V, solver_mode="full_system",
57 equations=equations, cascade="fission",
58 he_kinetics="quasi_steady_state",
59 i_mobile=5, v_mobile=5)
60 if equations == "bin_moment":
61 # i_discrete = I and I_bin = 0 -> no bins, hence no closure at all.
62 re_mod.apply_bin_config(sim, {"equations": "bin_moment",
63 "i_discrete": I, "v_discrete": V,
64 "I_bin": 0, "V_bin": 0,
65 "shape_function": "linear"})
66 sim.input_data._calculate_derived()
67 sim.rebuild_rates()
68 G = float(sim.input_data.reactions["G"])
69 cfg = {"t_span": (1e-6, DOSE / G), "n_points": 16, "log_time": True,
70 "rtol": 1e-5, "atol": 1e-20, "timeout_s": 900,
71 "solver_method": {"linsol": "gmres", "preconditioner": "woodbury",
72 "concentration_threshold": 1e-22},
73 "loop_conversion": 1}
74 return sim.run(solver_config=cfg, save_output=False), sim
75 finally:
76 sys.stdout, sys.stderr = so, se
77
78
79def main():
80 global I, V, DOSE
81 if "--big" in sys.argv: # heavier soak: minutes, not seconds
82 I = V = 4000
83 DOSE = 2e-2
84
85 rb, sb = run("bin_moment")
86 rd, _ = run("discrete")
87 yb, yd = np.asarray(rb["y"]), np.asarray(rd["y"])
88 print(f"state length: bin_moment {yb.shape[0]}, discrete {yd.shape[0]}"
89 f" (N_eq {getattr(sb.rate_equations, 'N_eq', '?')})")
90
91 worst = 0.0
92 print(f"\nper-size 1/2<111> at the final time:\n{'n':>5} {'bin_moment':>13}"
93 f" {'discrete':>13} {'ratio':>10}")
94 for n in (2, 5, 10, 20, 40, 80, 150, 300, 600, I - 1):
95 a, b = yb[n - 1, -1], yd[n - 1, -1]
96 r = a / b if b else float("inf")
97 worst = max(worst, abs(r - 1.0))
98 print(f"{n:5d} {a:13.5e} {b:13.5e} {r:10.4f}")
99
100 print("\nBoth paths must carry loop-loop coarsening (P.loop_coal) and pile a")
101 print("product past the grid edge AT the edge. When rhs_case2 lacked the")
102 print("channel the per-size ratio ran 0.49 at n=40 and 906 at n=150; when it")
103 print("had the channel but dropped the overflow instead of piling it, the")
104 print("last size sat at 2.24x while everything below agreed to 0.04%.")
105
106 for k in ("mean_n_111", "N_loops_111", "mean_n_v"):
107 a = float(np.asarray(rb[k])[-1])
108 b = float(np.asarray(rd[k])[-1])
109 rel = abs(a - b) / max(abs(b), 1e-300)
110 flag = " <-- DISAGREES" if rel > TOL else " ok"
111 print(f" {k:14s} bm={a:12.5e} disc={b:12.5e} rel={rel:9.2e}{flag}")
112
113 ok = all(abs(float(np.asarray(rb[k])[-1]) - float(np.asarray(rd[k])[-1]))
114 / max(abs(float(np.asarray(rd[k])[-1])), 1e-300) <= TOL
115 for k in ("mean_n_111", "N_loops_111"))
116 print("\nRESULT:", "paths agree" if ok else "PATHS DISAGREE")
117 return 0 if ok else 1
118
119
120if __name__ == "__main__":
121 sys.exit(main())