RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
post_process.py
Go to the documentation of this file.
1"""
2post_process.py — Derived quantities for RadCluster_2_1.
3
4Computes macroscopic observables from the ODE solution, including:
5 - Total SIA / vacancy / He contents
6 - Mean cluster sizes
7 - Void swelling via the swelling identity (Eq. 161)
8 - Frenkel pair conservation diagnostic δ_FP (Eq. 164)
9 - He conservation diagnostic δ_He (Eq. 165)
10
11Physics reference
12-----------------
13Ghoniem, N.M. (2026), Section 7 (Rate_Equations.pdf):
14 Eqs. 161-165.
15"""
16
17import numpy as np
18
19_kB = 8.617333262e-5
20
21
22def _floor_bin_moments(mu0_raw, mu1_raw, nlo, nhi, C_floor, P):
23 """Remove the uniform C_floor IC from a bin's moments, CONSISTENTLY.
24
25 For a bin spanning [nlo, nhi) of width Δ, the uniform C_floor initial
26 condition contributes Δ·C_floor to μ₀ and C_floor·Δ·(nlo+nhi−1)/2 to μ₁.
27 Subtracting them is exact — but clamping each at zero INDEPENDENTLY is not:
28 a near-empty bin can clamp μ₀ to exactly 0 while μ₁ stays positive, i.e. an
29 empty bin that still carries content. Summed over bins that inflates the
30 content while the count collapses, and content/count — the reported mean
31 size — diverges. That is how ``mean_n_100`` reached 2853 on a grid of
32 I = 1000, a value arithmetically impossible for a mean size, and why
33 ``bin_moment`` + ``loop_conversion`` appeared to fail silently.
34
35 The bug is NOT conversion-specific: the same pattern governed ``mean_n_i``
36 and ``mean_n_v``. It stayed latent there only because the ½⟨111⟩ and
37 vacancy bins are normally well populated, whereas the ⟨100⟩ population is
38 near-empty for most of a run (it exists only by conversion) and so sits in
39 the pathological regime continuously. It still bites the others in the
40 early transient and in the large-size tail.
41
42 Two couplings restore consistency, mirroring the guard the C++
43 reconstruction already applies (``rate_kernels.cpp``, "clamp n_bar into the
44 bin"):
45 (a) an empty bin carries no content;
46 (b) a bin's mean size must lie inside the bin, i.e. μ₁/μ₀ ∈ [nlo, nhi−1].
47
48 Accepts scalars or arrays (the ⟨100⟩ path passes whole time series).
49 """
50 width = float(nhi - nlo)
51 sum_n = width * (nlo + nhi - 1) / 2.0
52 mu0_eff = np.maximum(np.asarray(mu0_raw, dtype=float) - width * C_floor, 0.0)
53 if P >= 2:
54 mu1_eff = np.maximum(np.asarray(mu1_raw, dtype=float) - C_floor * sum_n,
55 0.0)
56 else:
57 # P == 1 carries no independent μ₁; the closure puts every cluster at
58 # the bin midpoint, which is inside the bin by construction.
59 return mu0_eff, mu0_eff * (nlo + nhi - 1) / 2.0
60
61 empty = mu0_eff <= 0.0
62 mu1_eff = np.where(empty, 0.0, mu1_eff)
63 lo_f, hi_f = float(nlo), float(nhi - 1)
64 with np.errstate(divide='ignore', invalid='ignore'):
65 n_bar = np.divide(mu1_eff, mu0_eff, out=np.zeros_like(mu1_eff),
66 where=mu0_eff > 0.0)
67 mu1_eff = np.where(~empty & (n_bar < lo_f), mu0_eff * lo_f, mu1_eff)
68 mu1_eff = np.where(~empty & (n_bar > hi_f), mu0_eff * hi_f, mu1_eff)
69 return mu0_eff, mu1_eff
70
71
72def calculate_derived_quantities(t, y, input_data, rate_eq_obj,
73 xmax_history=None, y_sia100=None):
74 """
75 Compute macroscopic quantities from ODE solution.
76
77 Parameters
78 ----------
79 t : ndarray [n_t]
80 y : ndarray [N_eq, n_t]
81 input_data : InputData
82 rate_eq_obj : RateEquations or BinMomentRateEquations
83 xmax_history: ignored (kept for API compatibility)
84
85 Returns
86 -------
87 results : dict
88 """
89 d = input_data.derived
90 Omega = d['Omega']
91 inv_Omega = 1.0 / Omega # [m^-3] — converts at.frac → m^-3
92 r0 = (3.0 * Omega / (4.0 * np.pi)) ** (1.0 / 3.0)
93
94 I = input_data.I
95 V = input_data.V
96 G = d['G']
97 G_He = d['G_He']
98
99 n_t = len(t)
100 ns = np.arange(1, I + 1, dtype=float)
101 ms = np.arange(1, V + 1, dtype=float)
102
103 # Identify state vector layout from rate_eq_obj
104 he_mode = getattr(rate_eq_obj, 'he_mode', 'case2')
105 is_bin = hasattr(rate_eq_obj, 'bins') # BinMomentRateEquations
106 qss_He = getattr(rate_eq_obj, 'qss_He', False)
107 C_floor = float(input_data.reactions.get('C_floor', 1e-15))
108
109 i_SIA = getattr(rate_eq_obj, 'i_SIA', 0)
110 i_VAC = getattr(rate_eq_obj, 'i_VAC', I)
111 i_He = getattr(rate_eq_obj, 'i_He', None) # None when qss_He=True
112 if i_He is None and not qss_He:
113 i_He = y.shape[0] - 1
114
115 # For bin-moment: reconstruct c_n from SIA moments
116 I_bin = getattr(rate_eq_obj, 'I_bin', getattr(rate_eq_obj, 'K', 0))
117 # Vacancy bins (V_bin > 0 means vacancies are also binned)
118 V_bin = getattr(rate_eq_obj, 'V_bin', getattr(rate_eq_obj, 'K_v', 0))
119
120 # Allocate output arrays
121 C_SIA_tot = np.zeros(n_t) # total SIA content Σ n·c_n
122 C_VAC_tot = np.zeros(n_t) # total vacancy content Σ m·c_m
123 C_He_tot = np.zeros(n_t) # total He (free + trapped)
124 mean_n_i = np.zeros(n_t) # mean SIA cluster size
125 mean_n_v = np.zeros(n_t) # mean vacancy cluster size
126 swelling = np.zeros(n_t) # void swelling S(t) [fraction]
127 N_loops = np.zeros(n_t) # number density of SIA loops [a.frac]
128 N_voids = np.zeros(n_t) # number density of voids [a.frac]
129 C_i1 = np.zeros(n_t) # free SIA monomer
130 C_v1 = np.zeros(n_t) # free vacancy monomer
131 C_He_free = np.zeros(n_t) # free He
132
133 delta_FP = np.zeros(n_t) # Frenkel pair conservation (Eq. 164) — max(sia, vac)
134 delta_FP_sia = np.zeros(n_t) # SIA arm of FP conservation
135 delta_FP_vac = np.zeros(n_t) # VAC arm of FP conservation
136 delta_He = np.zeros(n_t) # He conservation (Eq. 165)
137
138 # (Conservation fluxes are now tracked by CVODE as extra state variables)
139
140 # Rate constants for sink terms
141 rr = getattr(rate_eq_obj, 'rr', None)
142 v_mobile = d.get('v_mobile', d.get('m_max_v', 1))
143
144 # Cascade survival fraction η — select on the canonical cascade axis
145 # ('fission' | 'fusion'), not a substring match on the spectrum label.
146 cascade = getattr(input_data, 'cascade', None)
147 if cascade is None:
148 from .input_data import split_physics_option
149 cascade = split_physics_option(input_data.physics_option)[1]
150 eta = input_data.production_fission.get('eta', 0.30) \
151 if cascade == 'fission' \
152 else input_data.production_fusion.get('eta', 0.28)
153
154 # Vacancy bin midpoints (for algebraic mu1 when vacancies are binned)
155 vac_mid = getattr(rate_eq_obj, 'vac_mid', None)
156
157 for j in range(n_t):
158 yj = np.maximum(y[:, j], 0.0)
159
160 if is_bin:
161 from .bin_moment_rates import reconstruct_distribution
162 i_d = getattr(rate_eq_obj, 'i_discrete', 0)
163 v_d = getattr(rate_eq_obj, 'v_discrete', 0)
164 P = getattr(rate_eq_obj, 'n_mom', 2)
165 sf = getattr(rate_eq_obj, 'shape_function', 'linear')
166
167 # ── SIA: discrete + binned ───────────────────────────────
168 # Discrete sizes contribute directly to content
169 ns_disc = np.arange(1, i_d + 1, dtype=float)
170 SIA_content_from_mu1 = np.dot(ns_disc, yj[:i_d])
171 # Binned: use tracked first moments μ₁ directly (or approximate)
172 if I_bin > 0:
173 mom = yj[i_d:i_d + P * I_bin]
174 mu0_j = mom[0::P][:I_bin]
175 if P >= 2:
176 mu1_j = mom[1::P][:I_bin]
177 SIA_content_from_mu1 += np.sum(mu1_j)
178 else:
179 mu1_j = None
180 # Approximate μ₁ as μ₀ × midpoint
181 for kb, (nlo, nhi) in enumerate(rate_eq_obj.bins):
182 SIA_content_from_mu1 += mu0_j[kb] * (nlo + nhi - 1) / 2.0
183 mu2_j = mom[2::P][:I_bin] if P >= 3 else None
184 # Reconstruct c_n for mean-size and number-density calcs
185 c_n = reconstruct_distribution(sf, mu0_j, mu1_j, mu2_j,
186 rate_eq_obj.bins, I)
187 c_n[:i_d] = yj[:i_d] # overwrite discrete region
188 else:
189 c_n = np.zeros(I)
190 c_n[:i_d] = yj[:i_d]
191
192 # ── Vacancy: discrete + binned ───────────────────────────
193 ms_disc = np.arange(1, v_d + 1, dtype=float)
194 c_v = np.zeros(V)
195 c_v[:v_d] = yj[i_VAC:i_VAC + v_d]
196 VAC_content_from_mu1 = np.dot(ms_disc, c_v[:v_d])
197 if V_bin > 0:
198 vac_start = i_VAC + v_d
199 vmom = yj[vac_start:vac_start + P * V_bin]
200 vmu0 = vmom[0::P][:V_bin]
201 if P >= 2:
202 vmu1 = vmom[1::P][:V_bin]
203 VAC_content_from_mu1 += np.sum(vmu1)
204 else:
205 vmu1 = None
206 for kb, (mlo, mhi) in enumerate(rate_eq_obj.vac_bins):
207 VAC_content_from_mu1 += vmu0[kb] * (mlo + mhi - 1) / 2.0
208 vmu2 = vmom[2::P][:V_bin] if P >= 3 else None
209 c_v_binned = reconstruct_distribution(sf, vmu0, vmu1, vmu2,
210 rate_eq_obj.vac_bins, V)
211 c_v[v_d:] = c_v_binned[v_d:]
212 else:
213 c_n = yj[i_SIA:i_VAC] # [N]
214 c_v = yj[i_VAC:i_VAC + V] # [V]
215 SIA_content_from_mu1 = None
216 VAC_content_from_mu1 = None
217
218 # Q (He in voids) — extract based on he_mode
219 if he_mode == 'case1':
220 i_Q = getattr(rate_eq_obj, 'i_Q', i_VAC + V)
221 n_Q = V_bin if V_bin > 0 else V
222 Q_m = yj[i_Q:i_Q + n_Q]
223 Q_tot = np.sum(Q_m)
224 else:
225 i_Qtot = getattr(rate_eq_obj, 'i_Qtot', i_VAC + V)
226 Q_tot = yj[i_Qtot]
227 Q_m = None
228
229 # Free He: read from state or reconstruct from QSS
230 if qss_He:
231 c_h = rate_eq_obj.compute_c_h_qss(c_v, Q_tot=Q_tot, Q_m=Q_m)
232 else:
233 c_h = yj[i_He]
234
235 # SIA and vacancy total content
236 # Prefer tracked moments (exact) over PC reconstruction (approximate)
237 C_SIA_tot[j] = SIA_content_from_mu1 if SIA_content_from_mu1 is not None \
238 else np.dot(ns, c_n)
239 # Tracked mu1 gives exact VAC content for both discrete and binned
240 C_VAC_tot[j] = VAC_content_from_mu1 if VAC_content_from_mu1 is not None \
241 else np.dot(ms, c_v)
242 C_He_tot[j] = c_h + Q_tot
243
244 # Mean cluster sizes and number densities — computed from tracked
245 # moments (μ₀, μ₁) to stay consistent with C_SIA_tot / C_VAC_tot.
246 # Floor subtraction is applied at the moment level (exact, no
247 # reconstruction): for a bin spanning [nlo, nhi) of width Δ, the
248 # uniform C_floor IC contributes Δ·C_floor to μ₀ and
249 # C_floor·Δ·(nlo+nhi-1)/2 to μ₁.
250 if is_bin:
251 # SIA: discrete (n=2..i_d) + binned
252 disc_i_eff = np.maximum(yj[1:i_d] - C_floor, 0.0)
253 count_i = float(np.sum(disc_i_eff))
254 content_i = float(np.dot(ns_disc[1:], disc_i_eff))
255 if I_bin > 0:
256 for kb, (nlo, nhi) in enumerate(rate_eq_obj.bins):
257 mu0_eff, mu1_eff = _floor_bin_moments(
258 mu0_j[kb], mu1_j[kb] if P >= 2 else 0.0,
259 nlo, nhi, C_floor, P)
260 count_i += float(mu0_eff)
261 content_i += float(mu1_eff)
262 N_loops[j] = count_i
263 mean_n_i[j] = content_i / count_i if count_i > 0.0 else 0.0
264
265 # VAC: discrete (m=2..v_d) + binned
266 disc_v_eff = np.maximum(yj[i_VAC + 1:i_VAC + v_d] - C_floor, 0.0)
267 count_v = float(np.sum(disc_v_eff))
268 content_v = float(np.dot(ms_disc[1:], disc_v_eff))
269 if V_bin > 0:
270 for kb, (mlo, mhi) in enumerate(rate_eq_obj.vac_bins):
271 mu0_eff, mu1_eff = _floor_bin_moments(
272 vmu0[kb], vmu1[kb] if P >= 2 else 0.0,
273 mlo, mhi, C_floor, P)
274 count_v += float(mu0_eff)
275 content_v += float(mu1_eff)
276 N_voids[j] = count_v
277 mean_n_v[j] = content_v / count_v if count_v > 0.0 else 0.0
278 else:
279 c_n_eff = np.maximum(c_n[1:] - C_floor, 0.0)
280 sum_cni_eff = np.sum(c_n_eff)
281 mean_n_i[j] = (np.dot(ns[1:], c_n_eff) / sum_cni_eff) \
282 if sum_cni_eff > 0.0 else 0.0
283 c_v_eff = np.maximum(c_v[1:] - C_floor, 0.0)
284 sum_cvi_eff = np.sum(c_v_eff)
285 mean_n_v[j] = (np.dot(ms[1:], c_v_eff) / sum_cvi_eff) \
286 if sum_cvi_eff > 0.0 else 0.0
287 N_loops[j] = np.sum(c_n[1:])
288 N_voids[j] = np.sum(c_v[1:])
289
290 # Void swelling (Eq. 161): S = Σ_m m·c_m
291 # V_m = m·Ω per cluster; c_m is cluster number density in at.frac;
292 # ΔV/V = Σ_m (m·c_m·Ω)/(N_atoms·Ω) = Σ_m m·c_m = C_VAC_tot
293 swelling[j] = C_VAC_tot[j]
294
295 C_i1[j] = c_n[0]
296 C_v1[j] = c_v[0]
297 C_He_free[j] = c_h
298
299
300 # ── Loop-conversion: split SIA loops into ½⟨111⟩ and ⟨100⟩ populations ────
301 # y_sia100 is the appended sessile ⟨100⟩ block as solved (per-size in
302 # discrete mode, discrete-prefix + bin moments in bin-moment mode); the
303 # ½⟨111⟩ block is y[0:I] (discrete when conversion is on). We expose, per
304 # population: number density (N_loops_111/100), mean size (mean_n_111/100),
305 # and the content-weighted ½⟨111⟩ fraction f₁₁₁(t) — directly comparable to
306 # the experimental loop fraction. The combined SIA totals (C_SIA_tot,
307 # N_loops, mean_n_i) are also updated so the conservation diagnostics and
308 # legacy plots see the full SIA content. When conversion is off the ⟨100⟩
309 # arrays are zero and f₁₁₁ ≡ 1 (all SIA loops are ½⟨111⟩).
310 N_loops_111 = N_loops.copy() # ½⟨111⟩ density (loops, n ≥ 2)
311 N_loops_100 = np.zeros(n_t)
312 mean_n_111 = mean_n_i.copy() # ½⟨111⟩ mean size
313 mean_n_100 = np.zeros(n_t)
314 f_111_loop = np.ones(n_t) # ½⟨111⟩ loop fraction (content-weighted)
315 if y_sia100 is not None and np.asarray(y_sia100).size:
316 # y_sia100 is the ⟨100⟩ block AS SOLVED: per-size [I, n_t] in discrete
317 # mode, and the discrete-prefix + bin-moment vector [i_d + P·I_bin, n_t]
318 # in bin-moment mode (the bridge no longer reconstructs it to per-size
319 # before this point). The ½⟨111⟩ content is C_SIA_tot as accumulated
320 # above (BEFORE the ⟨100⟩ contribution is folded in) and its loop density
321 # is N_loops_111.
322 cont111 = C_SIA_tot.copy() # ½⟨111⟩ content
323 cnt111 = N_loops_111 # ½⟨111⟩ density
324 y100 = np.maximum(np.asarray(y_sia100, dtype=float), 0.0)
325 # The C_floor IC must be removed BEFORE forming the size-weighted content
326 # — the same treatment as the main SIA/VAC mean-size calcs above. In
327 # bin-moment mode this MUST be done at the MOMENT level (per bin), not by
328 # reconstructing to per-size and subtracting C_floor afterwards: the
329 # linear closure spreads a near-empty bin's residual over its sizes and
330 # over-floors the bin-representative sizes, so a clamp at the per-size
331 # level leaves a positive, size-weighted tail at large n. During the
332 # early transient (no real ⟨100⟩ population yet — it forms only by
333 # conversion from ½⟨111⟩) that tail dominates the content, giving a
334 # spurious mean_n_100 of ~10²–10³ over a vanishing density. Subtracting
335 # at the moment level zeroes empty bins exactly (μ₀ − Δ·C_floor → 0).
336 if is_bin:
337 i_d = getattr(rate_eq_obj, 'i_discrete', 0)
338 P = getattr(rate_eq_obj, 'n_mom', 2)
339 bins_ = getattr(rate_eq_obj, 'bins', [])
340 ns_disc = np.arange(1, i_d + 1, dtype=float)
341 disc100 = np.maximum(y100[1:i_d, :] - C_floor, 0.0) # [i_d-1, n_t]
342 N_loops_100 = np.sum(disc100, axis=0)
343 cont100 = ns_disc[1:] @ disc100
344 mom100 = y100[i_d:i_d + P * len(bins_), :]
345 mu0_100 = mom100[0::P, :]
346 mu1_100 = mom100[1::P, :] if P >= 2 else None
347 for kb, (nlo, nhi) in enumerate(bins_):
348 mu0_eff, mu1_eff = _floor_bin_moments(
349 mu0_100[kb, :], mu1_100[kb, :] if P >= 2 else 0.0,
350 nlo, nhi, C_floor, P)
351 N_loops_100 = N_loops_100 + mu0_eff
352 cont100 = cont100 + mu1_eff
353 else:
354 c100 = np.maximum(y100 - C_floor, 0.0) # [I, n_t]
355 cont100 = ns @ c100 # ⟨100⟩ content
356 N_loops_100 = np.sum(c100[1:, :], axis=0) # ⟨100⟩ density
357 C_SIA_tot = C_SIA_tot + cont100 # full SIA inventory
358 cnt100 = N_loops_100
359 mean_n_100 = np.divide(cont100, cnt100,
360 out=np.zeros(n_t), where=cnt100 > 0)
361 tot = cont111 + cont100
362 f_111_loop = np.divide(cont111, tot,
363 out=np.ones(n_t), where=tot > 0)
364 cnt_tot = cnt111 + cnt100
365 mean_n_i = np.divide(cont111 + cont100, cnt_tot,
366 out=mean_n_i.copy(), where=cnt_tot > 0)
367 N_loops = N_loops_111 + N_loops_100 # combined density
368
369 # Dose axis [dpa]
370 dose = G * t
371
372 # Network dislocation density ρ_net [m^-2] (loop_network_loss.tex). ρ_net is
373 # held piecewise-constant within a segment by the operator split and refreshed
374 # by run_adaptive between segments, so concatenating these per-segment
375 # constants reproduces the step-function ρ_net(t) history vs. dose. Equals
376 # the static ρ_d when the LOOP_NETWORK_LOSS channel is off.
377 rho_net_val = float(getattr(rr, 'rho_net',
378 input_data.reactions.get('rho_net',
379 input_data.reactions.get('rho_d', 1.0e14))))
380 rho_net = np.full(n_t, rho_net_val)
381
382 # ── Conservation diagnostics — read cumulative sinks from state vector ──
383 # The cumulative sink fluxes are CVODE-integrated extra state variables.
384 # Their row indices are exposed explicitly by the rate-equation object;
385 # fall back to the historical "last 5" offsets only if an attribute is
386 # missing.
387 # i_J_SIA_fixed -> J_SIA_fixed: cumulative SIA content to fixed sinks
388 # i_J_SIA_mutual -> J_SIA_mutual: cumulative SIA to recombination + cavity
389 # i_J_VAC_fixed -> J_VAC_fixed: cumulative VAC content to fixed sinks
390 # i_J_VAC_mutual -> J_VAC_mutual: cumulative VAC to recombination + cavity
391 # i_J_He_sink -> J_He_sink: cumulative He to sinks
392 N_eq = y.shape[0]
393 i_J_SIA_fixed = getattr(rate_eq_obj, 'i_J_SIA_fixed', N_eq - 5)
394 i_J_SIA_mutual = getattr(rate_eq_obj, 'i_J_SIA_mutual', N_eq - 4)
395 i_J_VAC_fixed = getattr(rate_eq_obj, 'i_J_VAC_fixed', N_eq - 3)
396 i_J_VAC_mutual = getattr(rate_eq_obj, 'i_J_VAC_mutual', N_eq - 2)
397 i_J_He_sink = getattr(rate_eq_obj, 'i_J_He_sink', N_eq - 1)
398 J_SIA_fixed = np.maximum(y[i_J_SIA_fixed, :], 0.0)
399 J_SIA_mutual = np.maximum(y[i_J_SIA_mutual, :], 0.0)
400 J_VAC_fixed = np.maximum(y[i_J_VAC_fixed, :], 0.0)
401 J_VAC_mutual = np.maximum(y[i_J_VAC_mutual, :], 0.0)
402 J_He_sink = np.maximum(y[i_J_He_sink, :], 0.0)
403
404 # FP conservation — swelling-identity diagnostic (Eq. 96):
405 # δ_FP = |S - S_I - J_SIA_fixed + J_VAC_fixed|
406 # / (S + S_I + J_SIA_fixed + J_VAC_fixed)
407 # with S = C_VAC_tot (vacancy inventory) and S_I = C_SIA_tot (SIA
408 # inventory). J_SIA_fixed enters with a MINUS sign, J_VAC_fixed with a
409 # PLUS sign. J_*_mutual does NOT appear: mutual annihilation destroys
410 # equal SIA and vacancy atoms, so it cancels exactly in d(S - S_I)/dt.
411 # delta_FP_sia / delta_FP_vac retain the per-species residuals for
412 # diagnostic inspection.
413 for j in range(n_t):
414 num = abs(C_VAC_tot[j] - C_SIA_tot[j]
415 - J_SIA_fixed[j] + J_VAC_fixed[j])
416 den = (C_VAC_tot[j] + C_SIA_tot[j]
417 + J_SIA_fixed[j] + J_VAC_fixed[j])
418 delta_FP[j] = num / den if den > 1e-300 else 0.0
419
420 # Production referenced to the first sample t[0] (see δ_He note below):
421 # defects and the cumulative-flux integrals both start from 0 at t[0],
422 # so the FP production balance must use ∫_{t0}^t = η·G·(t−t0).
423 prod = eta * G * (t[j] - t[0])
424 if prod > 1e-300:
425 sia0 = C_SIA_tot[0] + J_SIA_fixed[0] + J_SIA_mutual[0]
426 vac0 = C_VAC_tot[0] + J_VAC_fixed[0] + J_VAC_mutual[0]
427 delta_FP_sia[j] = abs(
428 prod - (C_SIA_tot[j] + J_SIA_fixed[j] + J_SIA_mutual[j] - sia0)) / prod
429 delta_FP_vac[j] = abs(
430 prod - (C_VAC_tot[j] + J_VAC_fixed[j] + J_VAC_mutual[j] - vac0)) / prod
431 else:
432 delta_FP_sia[j] = delta_FP_vac[j] = 0.0
433
434 # He conservation diagnostic (Eq. 97):
435 # δ_He = |ΔS_He + ΔJ_He_sink - ∫G_He dt'| / (S_He + J_He_sink + ∫G_He dt')
436 # The fixed-sink loss J_He_sink must be tracked explicitly; S_He is NOT
437 # conserved against ∫G_He dt alone.
438 #
439 # The integrated balance depends on the He kinetics mode:
440 # dynamic: c_h is a state variable, so S_He = c_h + Q (free + trapped)
441 # satisfies d(S_He)/dt = G_He - (sink losses) exactly.
442 # QSS: c_h is algebraic (dc_h/dt = 0 substituted), so only the
443 # trapped inventory obeys dQ/dt = G_He - k2_He*c_h - sink,
444 # and the conserved combination is S_He = Q alone. Including
445 # the algebraic c_h would book the entire instantaneous
446 # free-He concentration as an apparent conservation error.
447 #
448 # Production and the accounting integrals are referenced to the FIRST
449 # sample t[0], not absolute t=0: CVODE applies the IC at t[0]=t_span[0]
450 # (e.g. 1e-6 s) and the cumulative-flux state variables start from 0
451 # there. Using ∫G_He = G_He·t[j] (from 0) while the inventory only
452 # accumulates from t[0] leaves a constant G_He·t[0] offset that reads as
453 # a spurious ~1e-2 early-time imbalance decaying as t[0]/t. Differencing
454 # against the t[0] baseline removes it and is exact for a constant G_He.
455 He_bal = (C_He_tot - C_He_free) if qss_He else C_He_tot
456 S_He0 = He_bal[0] + J_He_sink[0] # conserved combination at t[0]
457 for j in range(n_t):
458 he_prod = G_He * (t[j] - t[0]) # ∫_{t0}^t G_He dt' (constant G_He)
459 num = abs(He_bal[j] + J_He_sink[j] - he_prod - S_He0)
460 den = He_bal[j] + J_He_sink[j] + he_prod
461 delta_He[j] = num / den if den > 1e-300 else 0.0
462
463 # Convert concentrations from atomic fraction to m^-3
464 # c [at.frac] / Omega [m^3/atom] = N [m^-3]
465 # swelling, mean sizes, and conservation diagnostics remain dimensionless.
466 C_SIA_tot *= inv_Omega
467 C_VAC_tot *= inv_Omega
468 C_He_tot *= inv_Omega
469 C_He_free *= inv_Omega
470 N_loops *= inv_Omega
471 N_loops_111 *= inv_Omega
472 N_loops_100 *= inv_Omega
473 N_voids *= inv_Omega
474 C_i1 *= inv_Omega
475 C_v1 *= inv_Omega
476
477 # n_active_sia / n_active_vac / n_active are populated by cpp_bridge from
478 # the C++ window-bounds sidecar (<bin>.window.csv). We can't infer them
479 # from y because out-of-window indices remain at C_floor (the initial
480 # value), so they look identical to evolved-but-still-low in-window slots.
481
482 # Concentration names for quality checks
483 conc_names = ([f'Ci_{n}' for n in range(1, I + 1)] +
484 [f'Cv_{m}' for m in range(1, V + 1)] + ['C_He'])
485
486 results = {
487 't': t,
488 'dose': dose,
489 'rho_net': rho_net, # [m^-2] network dislocation density vs. dose
490 'y': y, # raw ODE state, still in at.frac
491 'Omega': Omega, # [m^3] — for downstream unit conversions
492 'C_SIA_tot': C_SIA_tot, # [m^-3]
493 'C_VAC_tot': C_VAC_tot, # [m^-3]
494 'C_He_tot': C_He_tot, # [m^-3]
495 'C_He_free': C_He_free, # [m^-3]
496 'mean_n_i': mean_n_i, # [atoms]
497 'mean_n_v': mean_n_v, # [atoms]
498 'N_loops': N_loops, # [m^-3] (combined SIA loops)
499 'N_loops_111': N_loops_111, # [m^-3] ½⟨111⟩ loops
500 'N_loops_100': N_loops_100, # [m^-3] ⟨100⟩ loops (loop conversion)
501 'mean_n_111': mean_n_111, # [atoms] ½⟨111⟩ mean loop size
502 'mean_n_100': mean_n_100, # [atoms] ⟨100⟩ mean loop size
503 'f_111_loop': f_111_loop, # ½⟨111⟩ loop fraction (content-weighted)
504 'N_voids': N_voids, # [m^-3]
505 'swelling': swelling, # [dimensionless fraction]
506 'C_i1': C_i1, # [m^-3]
507 'C_v1': C_v1, # [m^-3]
508 'delta_FP': delta_FP, # FP conservation: max(sia, vac) relative error
509 'delta_FP_sia': delta_FP_sia, # SIA arm of FP conservation
510 'delta_FP_vac': delta_FP_vac, # VAC arm of FP conservation
511 'delta_He': delta_He, # He conservation (Eq. 97; dynamic + QSS)
512 'J_SIA_fixed': J_SIA_fixed, # cumulative SIA to fixed sinks [at.frac]
513 'J_SIA_mutual': J_SIA_mutual, # cumulative SIA to recomb + cavity [at.frac]
514 'J_VAC_fixed': J_VAC_fixed, # cumulative VAC to fixed sinks [at.frac]
515 'J_VAC_mutual': J_VAC_mutual, # cumulative VAC to recomb + cavity [at.frac]
516 'J_He_sink': J_He_sink, # cumulative He to sinks [at.frac]
517 'eta_G': eta * G, # FP production rate η·G [at.frac/s]
518 'conc_names': conc_names,
519 # Layout indices for cross-segment y merging during domain doubling
520 '_y_i_VAC': i_VAC,
521 '_y_i_He': i_VAC + (getattr(rate_eq_obj, 'v_discrete', 0)
522 + getattr(rate_eq_obj, 'n_mom', 2)
523 * getattr(rate_eq_obj, 'V_bin',
524 getattr(rate_eq_obj, 'K_v', 0))
525 if is_bin else V),
526 }
527 return results
528
529
531 """Return the last finite (non-NaN, non-inf) value of an array.
532
533 Falls back to an empty string when the array holds no finite value,
534 so the CSV never carries a literal 'nan'/'inf'.
535 """
536 a = np.asarray(arr, dtype=float)
537 finite = a[np.isfinite(a)]
538 if finite.size == 0:
539 return ''
540 return finite[-1]
541
542
543def _safe_scalar(value):
544 """Guard a single scalar against NaN/inf for CSV output."""
545 try:
546 v = float(value)
547 except (TypeError, ValueError):
548 return value
549 return v if np.isfinite(v) else ''
550
551
552def summary_csv_row(results, input_data, solver_label=''):
553 """
554 Build a summary dict for a single run (for output/summary.csv).
555
556 Every numeric value is guarded against NaN/inf: array metrics emit the
557 last finite element (or empty string if none), scalar metrics emit an
558 empty string when non-finite. This prevents literal 'nan' in
559 summary.csv.
560 """
561 d = input_data.derived
562 t_end = results['t'][-1]
563 row = {
564 'solver': solver_label,
565 'physics_option':input_data.physics_option,
566 'T_K': _safe_scalar(d['T']),
567 'G_dpa_s': _safe_scalar(d['G']),
568 't_end_s': _safe_scalar(t_end),
569 'dose_dpa': _last_finite(results['dose']),
570 'C_SIA_tot_m3': _last_finite(results['C_SIA_tot']),
571 'C_VAC_tot_m3': _last_finite(results['C_VAC_tot']),
572 'C_He_tot_m3': _last_finite(results['C_He_tot']),
573 'mean_n_i': _last_finite(results['mean_n_i']),
574 'mean_n_v': _last_finite(results['mean_n_v']),
575 'swelling': _last_finite(results['swelling']),
576 'delta_FP': _last_finite(results['delta_FP']),
577 'delta_He': _last_finite(results['delta_He']),
578 }
579 return row
calculate_derived_quantities(t, y, input_data, rate_eq_obj, xmax_history=None, y_sia100=None)
summary_csv_row(results, input_data, solver_label='')
_floor_bin_moments(mu0_raw, mu1_raw, nlo, nhi, C_floor, P)