RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
rate_equations.py
Go to the documentation of this file.
1"""
2rate_equations.py — ODE right-hand side for RadCluster_2_1.
3
4REFERENCE ONLY — not used by the solver; the production RHS is the C++
5implementation. May be stale. Scheduled for replacement by the Stage-3
6graph-walker. The `ode_system` / `_rhs_case1` / `_rhs_case2` methods below
7are dead code: simulation.py integrates exclusively via the C++ solver and
8never calls these Python RHS methods. They have drifted from the C++
9(Python RHS is mono-defect only) and are kept solely as a physics reference
10until the Stage-3 graph-walker replaces them.
11
12Implements the full cluster dynamics master equations (Eqs. 152, 155, 157)
13and dispatches to the appropriate He-reduction mode based on physics_option.
14
15Physics reference
16-----------------
17Ghoniem, N.M. (2026), Sections 5-8 (Rate_Equations.pdf):
18 Eqs. 152, 155, 157 (master equations)
19 Eq. 174 (He Case 1 — fusion/fast_eq)
20 Eq. 175 (He Case 2 — fission/decoupled)
21
22He kinetics (he_kinetics)
23-------------------------
24'dynamic' — free He c_h integrated as a full ODE variable (Eq. 157)
25'quasi_steady_state' — c_h eliminated; computed algebraically from dc_h/dt = 0
26 at each RHS call (valid because E_m_h = 0.06 eV is small)
27
28State vector y for full_CD modes
29---------------------------------
30Case 2 — fission (decoupled), dynamic:
31 y[0..I-1] : SIA clusters c_n, n=1..I
32 y[I..I+V-1] : void/bubble marginal c_m = Σ_ℓ c_{m,ℓ}, m=1..V
33 y[I+V] : total He in bubbles Q_tot = Σ_{m,ℓ} ℓ·c_{m,ℓ}
34 y[I+V+1] : free He c_h
35 N_eq = I + V + 2
36
37Case 2 — fission (decoupled), quasi_steady_state:
38 y[0..I-1], y[I..I+V-1], y[I+V] as above
39 c_h from QSS: c_h = (G_He + β·Q_tot) / (Σ K_HeV·c_v + k2_He)
40 N_eq = I + V + 1
41
42Case 1 — fusion (mean-field), dynamic:
43 y[0..I-1] : SIA clusters c_n
44 y[I..I+V-1] : marginal c_m^tot
45 y[I+V..I+2V-1] : He content per class Q_m = Σ_ℓ ℓ·c_{m,ℓ}
46 y[I+2V] : free He c_h
47 N_eq = I + 2V + 1
48
49Case 1 — fusion (mean-field), quasi_steady_state:
50 As above without c_h in state; c_h from QSS.
51 N_eq = I + 2V
52
53Concentration floor (C_floor)
54------------------------------
55Any state variable below C_floor is clamped to C_floor before evaluating
56rate terms. C_floor is set by inp.reactions['C_floor'] (default 1e-15).
57"""
58
59import numpy as np
60from .defect_production import production_rates
61from .binding_energies import ell_max
62
63_kB = 8.617333262e-5
64
65_VALID_HE_KINETICS = ('dynamic', 'quasi_steady_state')
66
67
69 """
70 ODE system for RadCluster_2_1.
71
72 Dispatches to the appropriate He-reduction mode:
73 'full_CD_fission' → Case 2 decoupled (Eq. 175)
74 'full_CD_fusion' → Case 1 mean-field (Eq. 174)
75
76 The bin_moment_CD modes are handled by BinMomentRateEquations (bin_moment_rates.py).
77
78 Parameters
79 ----------
80 input_data : InputData
81 reaction_rates : ReactionRates
82 """
83
84 def __init__(self, input_data, reaction_rates):
85 self.inp = input_data
86 self.rr = reaction_rates
87
88 I = input_data.I
89 V = input_data.V
90 self.I = I
91 self.V = V
92
93 po = input_data.physics_option
95
96 # Concentration floor — clamp before rate evaluation; gate negative dydt
97 self.C_floor = float(input_data.reactions.get('C_floor', 1e-15))
98
99 # Free He mode: 'dynamic' or 'quasi_steady_state'
100 raw = str(input_data.reactions.get('he_kinetics', 'dynamic')).lower()
101 if raw not in _VALID_HE_KINETICS:
102 import warnings
103 warnings.warn(f"Unknown he_kinetics='{raw}'. Using 'dynamic'.")
104 raw = 'dynamic'
105 self.he_kinetics = raw
106 self.qss_He = (raw == 'quasi_steady_state')
107
108 # Pre-compute beta_He (He de-trapping attempt rate) for QSS and Q_tot RHS
109 kBT_val = input_data.derived['kBT']
110 E_b_hV1 = float(input_data.energetics.get('E_b_hV_1', 2.30))
111 E_m_h = float(input_data.derived['E_m_h'])
112 nu_h = float(input_data.derived['nu_h'])
113 self.beta_He = nu_h * np.exp(-(E_b_hV1 + E_m_h) / kBT_val)
114
115 # Decide He reduction mode and build state-vector index map
116 if 'fusion' in po:
117 self.he_mode = 'case1' # Eq. 174 — mean-field ℓ̄_m
118 self.i_SIA = 0
119 self.i_VAC = I
120 self.i_Q = I + V # per-class He content Q_m
121 if self.qss_He:
122 n_phys = I + 2 * V
123 self.i_He = None
124 else:
125 n_phys = I + 2 * V + 1
126 self.i_He = I + 2 * V
127 else:
128 self.he_mode = 'case2' # Eq. 175 — decoupled scalar ℓ_tot
129 self.i_SIA = 0
130 self.i_VAC = I
131 self.i_Qtot = I + V # scalar total He in bubbles
132 if self.qss_He:
133 n_phys = I + V + 1
134 self.i_He = None
135 else:
136 n_phys = I + V + 2
137 self.i_He = I + V + 1
138
139 # Conservation accounting ODEs (5 cumulative integrals)
140 self.i_J_SIA_fixed = n_phys
141 self.i_J_SIA_mutual = n_phys + 1
142 self.i_J_VAC_fixed = n_phys + 2
143 self.i_J_VAC_mutual = n_phys + 3
144 self.i_J_He_sink = n_phys + 4
145 self.N_eq = n_phys + 5
146
147 # Cascade production
148 # G_He_r sourced from the Excel Reactions sheet (single authoritative
149 # source — see defect_production.production_rates docstring).
150 spectrum = input_data.derived['spectrum']
151 G = input_data.derived['G']
152 G_He_r = input_data.derived['G_He_r']
153 Pr_SIA, Pr_VAC, G_He = production_rates(
154 G, spectrum, I, V, G_He_r,
155 spec_over=(input_data.production_fission if 'fiss' in spectrum.lower()
156 else input_data.production_fusion))
157
158 self.Pr_SIA = Pr_SIA[1:] # [I] 0-indexed: index k → size k+1
159 self.Pr_VAC = Pr_VAC[1:] # [V]
160 self.G_He = G_He
161
162 self.alpha_He = input_data.alpha_He
163
164 # Pre-compute geometry arrays for cavity-absorption inner loops
165 self._m_arr = np.arange(1.0, V + 1)
166 self._m13 = self._m_arr ** (1.0 / 3.0)
167 self._denom_m = 1.0 + reaction_rates.B_rot * reaction_rates.L_hat**2 / self._m13
168
169 print(f"RateEquations: he_mode='{self.he_mode}' he_kinetics='{self.he_kinetics}'"
170 f" N_eq={self.N_eq} I={I} V={V}"
171 f" G_He={G_He:.3e} at.frac/s"
172 f" C_floor={self.C_floor:.1e} beta_He={self.beta_He:.3e}")
173
175 """Return y0 — all concentrations set to C_floor."""
176 return np.full(self.N_eq, self.C_floor)
177
178 # ── QSS He algebraic formula ──────────────────────────────────────────────
179
180 def compute_c_h_qss(self, c_v, Q_tot=None, Q_m=None):
181 """
182 Quasi-steady-state free He (Eq. 157 with dc_h/dt = 0):
183
184 Case 2: c_h = (G_He + β·Q_tot) / (Σ K_HeV·c_v + k2_He)
185 Case 1: c_h = (G_He + Σ β·Q_m) / (Σ K_HeV·c_v + k2_He)
186
187 Parameters
188 ----------
189 c_v : ndarray [V] — void concentration array (clamped)
190 Q_tot : float — scalar total He in voids (case2)
191 Q_m : ndarray [V] — per-class He content (case1)
192
193 Returns
194 -------
195 c_h : float
196 """
197 rr = self.rr
198 sink = np.sum(rr.K_HeV * c_v) + rr.k2_He_scalar
199 if self.he_mode == 'case2':
200 source = self.G_He + self.beta_He * (Q_tot if Q_tot is not None else 0.0)
201 else:
202 # Case 1: use beta_He * Q_m (same scalar approximation as RHS)
203 He_emit = self.beta_He * np.sum(Q_m) if Q_m is not None else 0.0
204 source = self.G_He + He_emit
205 return source / max(sink, 1e-300)
206
207 # ── ODE RHS dispatch ─────────────────────────────────────────────────────
208
209 def ode_system(self, t, y):
210 """ODE right-hand side. Dispatches by he_mode.
211
212 REFERENCE ONLY — not used by the solver; the production RHS is the
213 C++ implementation. May be stale. Scheduled for replacement by the
214 Stage-3 graph-walker.
215 """
216 if self.he_mode == 'case1':
217 return self._rhs_case1(t, y)
218 else:
219 return self._rhs_case2(t, y)
220
221 # ── Case 2 — fission (decoupled), Eq. 175 ────────────────────────────────
222
223 def _rhs_case2(self, t, y):
224 """
225 Decoupled He-reduction (Case 2, fission, Eq. 175).
226
227 he_kinetics='dynamic':
228 State: [c_1..c_I | c_{-1}..c_{-V} | Q_tot | c_h]
229 he_kinetics='quasi_steady_state':
230 State: [c_1..c_I | c_{-1}..c_{-V} | Q_tot]
231 c_h = (G_He + β·Q_tot) / (Σ K_HeV·c_v + k2_He)
232 """
233 I = self.I
234 V = self.V
235 rr = self.rr
236 C_floor = self.C_floor
237 dydt = np.zeros(self.N_eq)
238
239 # Raw state segments (for derivative clamping guard)
240 y_SIA = y[self.i_SIA:self.i_VAC]
241 y_VAC = y[self.i_VAC:self.i_Qtot]
242 y_Qtot = y[self.i_Qtot]
243
244 # Clamp to C_floor for rate evaluation
245 c_i = np.maximum(y_SIA, C_floor)
246 c_v = np.maximum(y_VAC, C_floor)
247 Q_tot = max(float(y_Qtot), C_floor)
248
249 # Free He
250 if self.qss_He:
251 c_h = self.compute_c_h_qss(c_v, Q_tot=Q_tot)
252 else:
253 y_He = y[self.i_He]
254 c_h = max(float(y_He), C_floor)
255
256 ci1 = c_i[0]
257 cv1 = c_v[0]
258
259 # Mean He loading per void (scalar, Eq. 175)
260 C_vac_tot = np.sum(c_v)
261 ell_bar = Q_tot / max(C_vac_tot, 1e-200)
262
263 # Effective thermal void emission corrected for He pressure
264 G_VAC_eff = rr.G_VAC.copy()
265 if ell_bar > 0.01:
266 for m in range(1, min(V, 20) + 1):
267 G_VAC_eff[m - 1] = rr.alpha_bubble_fn(m, ell_bar * m**(2.0/3.0))
268
269 # ── SIA cluster equations (Eq. ME_SIA) ─────────────────────────────
270 dci = dydt[self.i_SIA:self.i_VAC]
271 i_mobile = self.inp.derived['i_mobile']
272 reflect = self.inp.derived.get('boundary_flux', 'absorption') == 'reflection'
273 m13 = self._m13
274 denom_m = self._denom_m
275
276 # Production
277 dci += self.Pr_SIA
278
279 # Thermal SIA emission (gain + loss)
280 dci[:-1] += rr.G_SIA[1:] * c_i[1:]
281 dci -= rr.G_SIA * c_i
282
283 # i–i monomer growth (gain + loss)
284 dci -= rr.K_SIA_grow * ci1 * c_i # target loss
285 dci[1:] += rr.K_SIA_grow[:-1] * ci1 * c_i[:-1] # gain at n+1
286 if reflect: # suppress I_1 + I_I → I_{I+1} (reaction blocked at wall)
287 dci[-1] += rr.K_SIA_grow[-1] * ci1 * c_i[-1] # undo target loss
288 dci[0] += ci1 * rr.K_SIA_grow[-1] * c_i[-1] # undo monomer depletion
289 # Monomer projectile depletion: every I_n + I_1 → I_{n+1} consumes
290 # one monomer as projectile. At n=1 this gives the Becker-Döring
291 # factor of 2 for self-reaction; at n≥2 it is the projectile loss.
292 dci[0] -= ci1 * np.dot(rr.K_SIA_grow, c_i)
293
294 # V–I annihilation (gain + loss)
295 # n=1: P1 recombination uses K_iv (Eq. monodef_iv)
296 dci[0] -= rr.K_iv * cv1 * ci1
297 # n≥2: loop shrinkage by mono-vacancy (P3, gain + loss)
298 dci[1:] -= rr.K_SIA_shrink[1:] * cv1 * c_i[1:]
299 dci[:-1] += rr.K_SIA_shrink[1:] * cv1 * c_i[1:]
300
301 # SIA cluster–cavity absorption: all mobile n = 1..i_mobile
302 # n=1 already handled by K_iv above for m=1;
303 # for m≥2 cavities absorbing the monomer SIA:
304 sum_K3D_cv_m2 = rr.K_3D_cav_pref * np.dot(m13[1:], c_v[1:])
305 dci[0] -= ci1 * sum_K3D_cv_m2
306
307 # n=2,3 (3D): absorb into all cavities m=1..V
308 sum_K3D_cv = rr.K_3D_cav_pref * np.dot(m13, c_v)
309 for n in range(2, min(4, i_mobile + 1)):
310 dci[n - 1] -= c_i[n - 1] * sum_K3D_cv
311
312 # n=4..i_mobile (1D/3D mixed): K = K_1D_pref · m^{1/3} / denom
313 for n in range(4, min(I, i_mobile) + 1):
314 k_pref = rr.K_1D_pref[n - 1]
315 if k_pref < 1e-300:
316 continue
317 dci[n - 1] -= c_i[n - 1] * k_pref * np.dot(m13 / denom_m, c_v)
318
319 # Fixed sinks
320 dci -= rr.k2_SIA * c_i
321
322 # ── Vacancy cluster equations (Eq. ME_vac, Case 2) ──────────────────
323 dcv = dydt[self.i_VAC:self.i_Qtot]
324
325 # Production
326 dcv += self.Pr_VAC
327
328 # Thermal vacancy emission (gain + loss)
329 # V_m → V_{m-1} + V_1: loss at m, gain at m-1 (residual), gain at 1 (emitted monomer)
330 dcv -= G_VAC_eff * c_v
331 dcv[:-1] += G_VAC_eff[1:] * c_v[1:]
332 dcv[0] += np.sum(G_VAC_eff[1:] * c_v[1:]) # emitted monomers
333
334 # V–V monomer growth (gain + loss)
335 dcv -= rr.K_VAC_grow * cv1 * c_v # target loss
336 dcv[1:] += rr.K_VAC_grow[:-1] * cv1 * c_v[:-1] # gain at m+1
337 if reflect: # suppress V_1 + V_V → V_{V+1} (reaction blocked at wall)
338 dcv[-1] += rr.K_VAC_grow[-1] * cv1 * c_v[-1] # undo target loss
339 dcv[0] += cv1 * rr.K_VAC_grow[-1] * c_v[-1] # undo monomer depletion
340 # Vacancy monomer projectile depletion (same logic as SIA)
341 dcv[0] -= cv1 * np.dot(rr.K_VAC_grow, c_v)
342
343 # SIA-induced cavity shrinkage: all mobile n = 1..i_mobile
344 #
345 # m=1, n=1: P1 recombination (same K_iv as SIA side)
346 dcv[0] -= rr.K_iv * ci1 * cv1
347 # m=1, gain from m=2 shrunk by mono-SIA:
348 if V >= 2:
349 dcv[0] += rr.K_VAC_shrink[1] * ci1 * c_v[1]
350
351 # m≥2: mono-SIA absorption (P2, gain + loss)
352 dcv[1:] -= rr.K_VAC_shrink[1:] * ci1 * c_v[1:]
353 if V >= 3:
354 dcv[1:-1] += rr.K_VAC_shrink[2:] * ci1 * c_v[2:]
355
356 # Vacancy monomer consumed when SIA clusters absorb it:
357 # I_n + V_1 → I_{n-1} for n≥2 (n=1 already in K_iv above)
358 dcv[0] -= cv1 * np.dot(rr.K_SIA_shrink[1:], c_i[1:])
359
360 # n=2,3 (3D mobile SIA clusters): absorb into cavities
361 for n in range(2, min(4, i_mobile + 1)):
362 cn = c_i[n - 1]
363 dcv -= rr.K_VAC_shrink * cn * c_v
364 if n < V:
365 dcv[:V - n] += rr.K_VAC_shrink[n:] * cn * c_v[n:]
366
367 # n=4..i_mobile (1D/3D mixed): gain + loss
368 for n in range(4, min(I, i_mobile) + 1):
369 k_pref = rr.K_1D_pref[n - 1]
370 if k_pref < 1e-300:
371 continue
372 cn = c_i[n - 1]
373 k_loss = k_pref * m13 / denom_m
374 dcv -= k_loss * cn * c_v
375 if n < V:
376 mp = self._m_arr[:V - n] + float(n)
377 mp13 = mp ** (1.0 / 3.0)
378 denom = 1.0 + rr.B_rot * rr.L_hat**2 / mp13
379 dcv[:V - n] += (k_pref * mp13 / denom) * cn * c_v[n:]
380
381 # Fixed sinks — only mobile vacancy clusters (m ≤ v_mobile) diffuse to sinks
382 v_mobile = self.inp.derived['v_mobile']
383 mask_mobile_v = np.zeros(V)
384 mask_mobile_v[:min(v_mobile, V)] = 1.0
385 dcv -= rr.k2_vac_scalar * c_v * mask_mobile_v
386
387 # ── Q_tot equation (total He in voids) ───────────────────────────────
388 He_uptake = np.sum(rr.K_HeV * c_h * c_v)
389 He_release = self.beta_He * Q_tot
390 # He lost only from mobile voids reaching fixed sinks
391 ms_arr = np.arange(1.0, V + 1.0)
392 ell_m_arr = ell_bar * ms_arr ** (2.0 / 3.0)
393 He_sink = rr.k2_vac_scalar * np.sum(ell_m_arr[:v_mobile] * c_v[:v_mobile])
394 dydt[self.i_Qtot] = He_uptake - He_release - He_sink
395
396 # ── Free He equation (Eq. 157) — dynamic mode only ───────────────────
397 if not self.qss_He:
398 dydt[self.i_He] = (self.G_He
399 - He_uptake
400 - rr.k2_He_scalar * c_h
401 + He_release)
402
403 # ── Conservation accounting ODEs (cumulative integrals, exact via ODE) ─
404 ns_all = np.arange(1.0, I + 1)
405
406 # (1) SIA content to fixed sinks: Σ_n n · k2_SIA[n] · c_n
407 dydt[self.i_J_SIA_fixed] = np.dot(ns_all, rr.k2_SIA * c_i)
408
409 # (2) SIA content to mutual annihilation (recomb + cavity absorption)
410 mutual = rr.K_iv * ci1 * cv1 # V_1+I_1 recomb (1 SIA)
411 mutual += ci1 * sum_K3D_cv_m2 # I_1 → cavities m≥2 (1 SIA)
412 mutual += cv1 * np.sum(rr.K_SIA_shrink[1:] * c_i[1:]) # V_1+I_n→I_{n-1} (1 SIA each)
413 for n in range(2, min(4, i_mobile + 1)):
414 mutual += n * c_i[n - 1] * sum_K3D_cv # I_n → all cavities (n SIA)
415 for n in range(4, min(I, i_mobile) + 1):
416 k_pref = rr.K_1D_pref[n - 1]
417 if k_pref < 1e-300:
418 continue
419 mutual += n * c_i[n - 1] * k_pref * np.dot(m13 / denom_m, c_v)
420 dydt[self.i_J_SIA_mutual] = mutual
421
422 # (3) VAC content to fixed sinks: Σ_{m≤v_mobile} m · k2_vac · c_m
423 vm = min(v_mobile, V)
424 ms_mob = np.arange(1.0, vm + 1)
425 dydt[self.i_J_VAC_fixed] = rr.k2_vac_scalar * np.dot(ms_mob, c_v[:vm])
426
427 # (3b) VAC content to mutual annihilation:
428 # When I_n + V_{m'} react, the reaction is:
429 # m' > n: V_{m'} → V_{m'-n} (only n vacancies removed)
430 # m' = n: complete annihilation (n vacancies removed)
431 # m' < n: I_{n-m'} produced (m' vacancies removed)
432 # In all cases, vacancies destroyed = min(m', n) = SIA destroyed.
433 # Therefore J_VAC_mutual = J_SIA_mutual (matches C++ implementation).
434 dydt[self.i_J_VAC_mutual] = mutual # J_VAC_mutual = J_SIA_mutual (physically correct)
435
436 # (4) He to sinks: k2_He · c_h + k2_vac · Σ_{m≤v_mobile} ℓ_m · c_m
437 he_sink_acc = rr.k2_He_scalar * c_h
438 if C_vac_tot > 1e-300 and Q_tot > 0:
439 ell_m_mob = ell_bar * ms_mob ** (2.0 / 3.0)
440 he_sink_acc += rr.k2_vac_scalar * np.dot(ell_m_mob, c_v[:vm])
441 dydt[self.i_J_He_sink] = he_sink_acc
442
443 return dydt
444
445 # ── Case 1 — fusion (mean-field), Eq. 174 ────────────────────────────────
446
447 def _rhs_case1(self, t, y):
448 """
449 Mean-field He-reduction (Case 1, fusion, Eq. 174).
450
451 he_kinetics='dynamic':
452 State: [c_1..c_I | c_{-1}..c_{-V} | Q_1..Q_V | c_h]
453 he_kinetics='quasi_steady_state':
454 State: [c_1..c_I | c_{-1}..c_{-V} | Q_1..Q_V]
455 c_h = (G_He + β·Σ Q_m) / (Σ K_HeV·c_v + k2_He)
456 """
457 I = self.I
458 V = self.V
459 rr = self.rr
460 C_floor = self.C_floor
461 dydt = np.zeros(self.N_eq)
462
463 y_SIA = y[self.i_SIA:self.i_VAC]
464 y_VAC = y[self.i_VAC:self.i_Q]
465 y_Q = y[self.i_Q:self.i_Q + V]
466
467 c_i = np.maximum(y_SIA, C_floor)
468 c_v = np.maximum(y_VAC, C_floor)
469 Q_m = np.maximum(y_Q, C_floor)
470
471 ell_bar_m = Q_m / np.maximum(c_v, 1e-200) # [V]
472
473 # Free He: QSS or ODE
474 if self.qss_He:
475 c_h = self.compute_c_h_qss(c_v, Q_m=Q_m)
476 else:
477 y_He = y[self.i_He]
478 c_h = max(float(y_He), C_floor)
479
480 ci1 = c_i[0]
481 cv1 = c_v[0]
482
483 i_mobile = self.inp.derived['i_mobile']
484 L_hat = rr.L_hat
485 B_rot = rr.B_rot
486
487 G_VAC_eff = np.array([
488 rr.alpha_bubble_fn(m + 1, ell_bar_m[m]) for m in range(V)
489 ])
490
491 # ── SIA clusters (same structure as Case 2) ────────────────────────
492 dci = dydt[self.i_SIA:self.i_VAC]
493 reflect = self.inp.derived.get('boundary_flux', 'absorption') == 'reflection'
494 m13 = self._m13
495 denom_m = self._denom_m
496
497 dci += self.Pr_SIA
498 dci[:-1] += rr.G_SIA[1:] * c_i[1:]
499 dci -= rr.G_SIA * c_i
500 dci -= rr.K_SIA_grow * ci1 * c_i # target loss
501 dci[1:] += rr.K_SIA_grow[:-1] * ci1 * c_i[:-1] # gain
502 if reflect: # suppress I_1 + I_I → I_{I+1} (reaction blocked at wall)
503 dci[-1] += rr.K_SIA_grow[-1] * ci1 * c_i[-1] # undo target loss
504 dci[0] += ci1 * rr.K_SIA_grow[-1] * c_i[-1] # undo monomer depletion
505 # Monomer projectile depletion (same as Case 2)
506 dci[0] -= ci1 * np.dot(rr.K_SIA_grow, c_i)
507
508 # V–I annihilation (gain + loss)
509 # n=1: P1 recombination uses K_iv (Eq. monodef_iv)
510 dci[0] -= rr.K_iv * cv1 * ci1
511 # n≥2: loop shrinkage by mono-vacancy (P3, gain + loss)
512 dci[1:] -= rr.K_SIA_shrink[1:] * cv1 * c_i[1:]
513 dci[:-1] += rr.K_SIA_shrink[1:] * cv1 * c_i[1:]
514
515 # SIA cluster–cavity absorption (all mobile n)
516 # n=1: only m≥2 cavities (m=1 already handled by K_iv)
517 sum_K3D_cv_m2 = rr.K_3D_cav_pref * np.dot(m13[1:], c_v[1:])
518 dci[0] -= ci1 * sum_K3D_cv_m2
519 # n=2,3: all cavities m=1..V
520 sum_K3D_cv = rr.K_3D_cav_pref * np.dot(m13, c_v)
521 for n in range(2, min(4, i_mobile + 1)):
522 dci[n - 1] -= c_i[n - 1] * sum_K3D_cv
523 for n in range(4, min(I, i_mobile) + 1):
524 k_pref = rr.K_1D_pref[n - 1]
525 if k_pref < 1e-300:
526 continue
527 dci[n - 1] -= c_i[n - 1] * k_pref * np.dot(m13 / denom_m, c_v)
528
529 dci -= rr.k2_SIA * c_i
530
531 # ── Vacancy clusters — He capture does NOT change size class m ────────
532 dcv = dydt[self.i_VAC:self.i_Q]
533 dcv += self.Pr_VAC
534 dcv -= G_VAC_eff * c_v
535 dcv[:-1] += G_VAC_eff[1:] * c_v[1:]
536 dcv[0] += np.sum(G_VAC_eff[1:] * c_v[1:]) # emitted monomers
537 dcv -= rr.K_VAC_grow * cv1 * c_v # target loss
538 dcv[1:] += rr.K_VAC_grow[:-1] * cv1 * c_v[:-1] # gain
539 if reflect: # suppress V_1 + V_V → V_{V+1} (reaction blocked at wall)
540 dcv[-1] += rr.K_VAC_grow[-1] * cv1 * c_v[-1] # undo target loss
541 dcv[0] += cv1 * rr.K_VAC_grow[-1] * c_v[-1] # undo monomer depletion
542 # Vacancy monomer projectile depletion
543 dcv[0] -= cv1 * np.dot(rr.K_VAC_grow, c_v)
544
545 # SIA-induced cavity shrinkage (all mobile n, gain + loss)
546 # m=1, n=1: P1 recombination
547 dcv[0] -= rr.K_iv * ci1 * cv1
548 if V >= 2:
549 dcv[0] += rr.K_VAC_shrink[1] * ci1 * c_v[1]
550 # m≥2: mono-SIA absorption (P2, gain + loss)
551 dcv[1:] -= rr.K_VAC_shrink[1:] * ci1 * c_v[1:]
552 if V >= 3:
553 dcv[1:-1] += rr.K_VAC_shrink[2:] * ci1 * c_v[2:]
554 # n=2,3 (3D mobile)
555 for n in range(2, min(4, i_mobile + 1)):
556 cn = c_i[n - 1]
557 dcv -= rr.K_VAC_shrink * cn * c_v
558 if n < V:
559 dcv[:V - n] += rr.K_VAC_shrink[n:] * cn * c_v[n:]
560 for n in range(4, min(I, i_mobile) + 1):
561 k_pref = rr.K_1D_pref[n - 1]
562 if k_pref < 1e-300:
563 continue
564 cn = c_i[n - 1]
565 k_loss = k_pref * m13 / denom_m
566 dcv -= k_loss * cn * c_v
567 if n < V:
568 mp = self._m_arr[:V - n] + float(n)
569 mp13 = mp ** (1.0 / 3.0)
570 denom = 1.0 + rr.B_rot * rr.L_hat**2 / mp13
571 dcv[:V - n] += (k_pref * mp13 / denom) * cn * c_v[n:]
572
573 # Vacancy monomer consumed by SIA loop shrinkage:
574 # I_n + V_1 → I_{n-1} for n≥2 (n=1 already in K_iv)
575 dcv[0] -= cv1 * np.dot(rr.K_SIA_shrink[1:], c_i[1:])
576
577 # Fixed sinks — only mobile vacancy clusters (m ≤ v_mobile)
578 v_mobile = self.inp.derived['v_mobile']
579 mask_mobile_v = np.zeros(V)
580 mask_mobile_v[:min(v_mobile, V)] = 1.0
581 dcv -= rr.k2_vac_scalar * c_v * mask_mobile_v
582
583 # ── Q_m equations (He content per void class, Eq. 174) ───────────────
584 dQ = dydt[self.i_Q:self.i_Q + V]
585
586 He_cap_total = 0.0
587 He_emit_total = 0.0
588 for m in range(1, V + 1):
589 k = m - 1
590 cm = c_v[k]
591 ell = ell_bar_m[k]
592
593 he_cap_m = rr.K_HeV[k] * c_h * cm
594 dQ[k] += he_cap_m
595 He_cap_total += he_cap_m
596
597 alpha_h = rr.alpha_He_emit_fn(m, max(int(round(ell)), 0))
598 he_emit_m = alpha_h * Q_m[k]
599 dQ[k] -= he_emit_m
600 He_emit_total += he_emit_m
601
602 # He lost only when mobile voids are absorbed at sinks
603 if m <= v_mobile:
604 dQ[k] -= rr.k2_vac_scalar * Q_m[k]
605
606 # ── Free He (Eq. 157) — dynamic mode only ────────────────────────────
607 if not self.qss_He:
608 dydt[self.i_He] = (self.G_He
609 - He_cap_total
610 - rr.k2_He_scalar * c_h
611 + He_emit_total)
612
613 # ── Conservation accounting ODEs (cumulative integrals, exact via ODE) ─
614 ns_all = np.arange(1.0, I + 1)
615
616 # (1) SIA content to fixed sinks
617 dydt[self.i_J_SIA_fixed] = np.dot(ns_all, rr.k2_SIA * c_i)
618
619 # (2) SIA content to mutual annihilation
620 mutual = rr.K_iv * ci1 * cv1
621 mutual += ci1 * sum_K3D_cv_m2
622 mutual += cv1 * np.sum(rr.K_SIA_shrink[1:] * c_i[1:])
623 for n in range(2, min(4, i_mobile + 1)):
624 mutual += n * c_i[n - 1] * sum_K3D_cv
625 for n in range(4, min(I, i_mobile) + 1):
626 k_pref = rr.K_1D_pref[n - 1]
627 if k_pref < 1e-300:
628 continue
629 mutual += n * c_i[n - 1] * k_pref * np.dot(m13 / denom_m, c_v)
630 dydt[self.i_J_SIA_mutual] = mutual
631
632 # (3) VAC content to fixed sinks
633 vm = min(v_mobile, V)
634 ms_mob = np.arange(1.0, vm + 1)
635 dydt[self.i_J_VAC_fixed] = rr.k2_vac_scalar * np.dot(ms_mob, c_v[:vm])
636
637 # (3b) VAC content to mutual annihilation:
638 # Same as J_SIA_mutual for channel (a), but channel (b) removes m'
639 # per vacancy cluster consumed (not min(m',n) as for SIA side).
640 vac_mutual = mutual # start with SIA mutual (channels a all equal)
641 for mp in range(2, vm + 1):
642 c_mp = c_v[mp - 1]
643 if c_mp < 1e-300:
644 continue
645 for n in range(1, min(mp, I + 1)):
646 cn = c_i[n - 1]
647 if cn < 1e-300:
648 continue
649 if n <= 3:
650 K_s = rr.K_3D_cav_pref * float(mp) ** (1.0 / 3.0)
651 else:
652 k_pref = rr.K_1D_pref[n - 1]
653 if k_pref < 1e-300:
654 continue
655 K_s = k_pref * float(mp) ** (1.0 / 3.0) / (
656 1.0 + rr.B_rot * rr.L_hat**2 / float(mp) ** (1.0 / 3.0))
657 vac_mutual += (mp - n) * K_s * c_mp * cn
658 dydt[self.i_J_VAC_mutual] = vac_mutual
659
660 # (4) He to sinks — must mirror the EXACT state losses applied to dQ
661 # above (−k2·Q_m for mobile classes), not the Case-2 ℓ̄·m^{2/3}
662 # allocation, so that c_h + ΣQ_m + J_He_sink − ∫G_He is conserved.
663 he_sink_acc = rr.k2_He_scalar * c_h
664 he_sink_acc += rr.k2_vac_scalar * np.sum(np.maximum(Q_m[:vm], 0.0))
665 dydt[self.i_J_He_sink] = he_sink_acc
666
667 return dydt
compute_c_h_qss(self, c_v, Q_tot=None, Q_m=None)
__init__(self, input_data, reaction_rates)