RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
reaction_rates.py
Go to the documentation of this file.
1"""
2reaction_rates.py — Pre-computed rate constant arrays for RadCluster_2_1.
3
4Implements all capture, emission, trap-mutation, and re-solution rates for
5the full per-size cluster dynamics system in bcc Fe / EUROFER97.
6
7Physics reference
8-----------------
9Ghoniem, N.M. (2026), Sections 5-6 (Rate_Equations.pdf):
10 Eqs. 109-143, Tables 25, 26, 28, 30.
11
12Key rate formulas
13-----------------
14Geometric prefactors (Eq. 128):
15 A_sph = (48π²)^{1/3} ≈ 7.818
16 A_loop = 8√(π/√3) ≈ 10.78
17 A_1D = 9/(8π^{2/3}) ≈ 2.632
18 B_rot = (4/π)(8π/3)^{1/3} ≈ 2.627
19
20Spherical 3D capture (Eq. 109, 131):
21 K_sph(α, m) = A_sph · m^{1/3} · ω_α^eff [m^3/s per unit Ω]
22
23Loop capture of SIA by dislocation loops (Eq. 113, 132):
24 K_loop(i, n) = A_loop · n^{1/2} · Z_i^loop · ω_i^eff
25
26V–SIA recombination (Eq. P1, monodef_iv):
27 K_iv = 4√3·π · (ω_i^eff + ω_v^eff) [s^-1 per (at.frac)^2]
28
29Mixed 1D/3D effective rate for glissile SIA clusters (Eq. 121, 141):
30 K_n,m^eff = A_sph · m^{1/3} · ω_n^{1D} / (1 + B_rot · L̂² · m^{-1/3})
31
32Thermal emission (Eq. 122, 138-140):
33 α_α(m) = A_sph · (m-1)^{1/3} · ω_α^eff · exp(−E_b(m) / k_B T)
34
35Fixed sinks (Eq. 134-137):
36 D_α^d = Z_α · ρ_d · ω_α^eff · a²
37 D_α^gb = π² · D_α^eff / d_g²
38 D_α^p = 4π · Z_p · ρ_p · r_p · D_α^eff (Eq. P4p; Z_p is a pure bias)
39
40State vector convention
41-----------------------
42All rate constants are dimensionless per-atom-fraction quantities (unit = s^-1)
43obtained by dividing the volumetric rate [m^3/s] by Ω [m^3].
44
45This ensures: dC [at.frac/s] = K [s^-1 per at.frac] · C_A · C_B.
46"""
47
48import numpy as np
49from .binding_energies import (
50 E_b_void, E_b_loop_i, E_b_loop_100, E_b_loop_v, E_b_bubble, ell_max,
51 Gamma_TM, Gamma_res, atomic_radius, A_111_from_E_b_i2
52)
53from .loop_energetics import LoopEnergetics
54
55_kB = 8.617333262e-5 # eV K^-1
56_J_eV = 6.241509074e18 # J → eV
57
58
60 """Workbook cell → positive float, or None.
61
62 Returns None for an absent key, a blank cell (pandas NaN), a
63 non-numeric entry, or a non-positive value. Used for *optional override*
64 keys, where "not supplied" must be distinguishable from "supplied as 0"
65 and must fall back to the legacy code path rather than raise.
66 """
67 if v is None:
68 return None
69 try:
70 f = float(v)
71 except (TypeError, ValueError):
72 return None
73 if not np.isfinite(f) or f <= 0.0:
74 return None
75 return f
76
77
78_Z_WOLFER_MAX = 5.0 # clamp: bias is unphysical below r_L ~ r_c/8
79
80
81def _num(v, default):
82 """Workbook cell → float, falling back to ``default`` for a blank/absent
83 cell. Unlike :func:`_opt_pos_float` this accepts zero and negatives — it is
84 for keys whose legitimate nominal value may be 0 (``loop_net_xi``,
85 ``loop_net_K_rec``).
86 """
87 if v is None:
88 return float(default)
89 try:
90 f = float(v)
91 except (TypeError, ValueError):
92 return float(default)
93 return f if np.isfinite(f) else float(default)
94
95
96def effective_n_j_min(n_j_min_junc, i_mobile, frac=0.6):
97 """Junction minimum size, made consistent with the mobility cutoff.
98
99 The Marian junction fires only when BOTH partners satisfy
100 ``min(n, n') >= n_j_min_junc`` (``reaction_rates.phi_junc``,
101 ``rate_kernels.cpp:conv_phi_junc``). But coalescence partners are mobile
102 clusters, so no partner can exceed ``i_mobile``. With the shipped
103 ``n_j_min_junc = 30`` and ``i_mobile < 30`` the condition is unsatisfiable
104 and the junction channel is **identically zero** — measured 2026-07-31:
105 ``phi_max_junc`` 0.1 and 1.0 gave bit-identical ``f_100`` at
106 ``i_mobile = 10``.
107
108 That matters because ``i_mobile`` is sampled over 5-50 in the twin, so the
109 channel would switch on discontinuously at ``i_mobile = 30`` and
110 ``phi_max_junc`` would screen as inert over most of the prior for a purely
111 structural reason.
112
113 Rule: ``n_j_min_eff = min(n_j_min_junc, ceil(frac * i_mobile))``, floored
114 at 2 (a junction needs at least two di-interstitials). With the default
115 ``frac = 0.6`` this returns exactly ``n_j_min_junc`` at ``i_mobile = 50``
116 (0.6*50 = 30), so the production configuration is **unchanged** and every
117 existing result at ``i_mobile >= 50`` is bit-identical; the threshold only
118 relaxes where the mobility cutoff would otherwise make it unreachable.
119 """
120 n_j = float(_num(n_j_min_junc, 30.0))
121 i_m = float(_num(i_mobile, 50.0))
122 f = float(_num(frac, 0.6))
123 scaled = float(np.ceil(f * i_m)) if np.isfinite(f) and f > 0 else n_j
124 return float(max(2.0, min(n_j, scaled)))
125
126
128 """
129 Pre-computed rate constant arrays for the RadCluster_2_1 ODE system.
130
131 Arrays are 0-indexed: index k corresponds to cluster size k+1.
132
133 Parameters
134 ----------
135 input_data : InputData
136 """
137
138 def __init__(self, input_data):
139 self.inp = input_data
140 self._precompute()
141
142 def _precompute(self):
143 inp = self.inp
144 d = inp.derived
145 re = inp.reactions
146 ener = inp.energetics
147 diff = inp.diffusion
148
149 T = d['T']
150 kBT = d['kBT']
151 a_m = d['a_m']
152 Omega = d['Omega']
153 r0 = d['r0']
154 b_111 = d['b_111']
155
156 omega_i = d['omega_i_eff'] # effective jump frequency, Eq. 42
157 omega_v = d['omega_v_eff'] # Eq. 48
158 omega_h = d['omega_h_eff']
159
160 Di_eff = d['Di_eff']
161 Dv_eff = d['Dv_eff']
162 Dh_eff = d['Dh_eff']
163 D1D = d['D1D'] # callable D1D(n)
164 s_1D = d['s_1D']
165 s_3D = float(diff.get('s_3D', 0.0)) # 3D cluster mobility exponent
166 # TODO(Stage3): s_3D has no Excel row; document/expose it or remove.
167 i_mobile = d['i_mobile']
168 v_mobile = d['v_mobile']
169 L_hat = d['L_hat']
170 B_rot = d['B_rot']
171
172 E_f_v = d['E_f_v']
173 gamma_s = d['gamma_s']
174 E_s_He = d['E_s_He']
175
176 I = inp.I
177 V = inp.V
178
179 # Geometric prefactors (Eq. 128)
180 A_sph = d['A_sph'] # (48π²)^{1/3} ≈ 7.818
181 A_loop = d['A_loop'] # 8√(π/√3) ≈ 10.78
182 A_iv = 4.0 * np.sqrt(3.0) * np.pi # ≈ 21.77 for K_iv (Eq. 130)
183 # Spherical-precipitate sink prefactor. Eq. P4p is
184 # D_a^p = (8*pi*r_p/a) * c_p^N * omega_a^eff
185 # and with c_p^N = rho_p*Omega, omega = D/a^2, Omega ~ a^3/2 this reduces
186 # to the standard spherical sink strength k^2 = 4*pi*r_p*rho_p. Until
187 # 2026-08-16 the code implemented Z_p*rho_p*r_p*D -- i.e. WITHOUT the
188 # 4*pi -- so every precipitate sink was 12.57x too weak and Z_p was not
189 # a pure bias factor (a "neutral" precipitate needed Z_p = 12.57).
190 # Restored here so Z_p_i / Z_p_v mean what their names say.
191 A_PREC = 4.0 * np.pi
192
193 # Dislocation sink parameters (Table 26)
194 rho_d = float(re.get('rho_d', 1.0e14))
195 # ── Loop → network-dislocation loss (loop_network_loss.tex) ──────────
196 # When the LOOP_NETWORK_LOSS channel is active, the static network
197 # density rho_d is replaced by the *dynamic* network density rho_net
198 # (operator-split: piecewise-constant within a segment, refreshed by
199 # simulation.run_adaptive between segments via rebuild_rates). This is
200 # the saturation feedback — a growing network raises the P4 sink
201 # strength. With the flag OFF, no 'rho_net' key is present and the
202 # legacy static rho_d is used verbatim (bit-identical).
203 self._loop_net_on = int(_num(re.get('LOOP_NETWORK_LOSS', 0), 0)) != 0
204 if self._loop_net_on:
205 rho_d = float(re.get('rho_net', rho_d)) # dynamic network feeds P4 sink
206 Z_i = float(re.get('Z_i', 1.10))
207 Z_v = float(re.get('Z_v', 1.00))
208 Z_He = float(re.get('Z_He', 1.00))
209 # Loop SIA bias Z_i^loop (Eq. P3_i, Table 26) — INDEPENDENT of the
210 # network bias Z_i. The pair's *ratio* sets how the SIA flux partitions
211 # between loops and the dislocation network, so they must be separately
212 # settable; collapsing them to one number removes that degree of freedom
213 # and forces the production terms to absorb the difference.
214 # Falls back to Z_i when the key is absent, so a workbook without the
215 # row reproduces the old aliased behaviour bit-for-bit.
216 Z_i_loop = _num(re.get('Z_i_loop', Z_i), Z_i)
217
218 # Grain / lath boundary sink (Eq. 135)
219 d_g = float(re.get('d_g', 5.0e-6))
220 # Boundary bias factors. Until 2026-08-16 the boundary sink was
221 # IDENTICAL for SIAs and vacancies, so it could carry no net bias at
222 # all. That was harmless while d_g = 1 mm made k2_gb = 9.9e6 m^-2
223 # (seven orders below the network), but with d_g at the EUROFER97 lath
224 # width the boundary carries 6.2e13 m^-2 and its bias is a first-order
225 # term in the I/V balance. Default 1.0 on both -> unbiased, i.e. the
226 # legacy behaviour is reproduced bit-for-bit by a workbook without
227 # these rows. He is deliberately left unbiased: the bias is an elastic
228 # drift of the Frenkel-pair species, and He is not part of that balance.
229 Z_gb_i = float(re.get('Z_gb_i', 1.0))
230 Z_gb_v = float(re.get('Z_gb_v', 1.0))
231
232 # Precipitate sink (Eq. 136)
233 rho_p = float(re.get('rho_p', 1.0e21))
234 r_p = float(re.get('r_p', 5.0e-9))
235 Z_p_i = float(re.get('Z_p_i', 1.0))
236 Z_p_v = float(re.get('Z_p_v', 1.0))
237
238 # Binding energy parameters
239 A_111 = float(inp.dissociation.get('A_111', 0.7501))
240 B_111 = float(inp.dissociation.get('B_111', 0.3873))
241 # A_100 / B_100: <100> SIA-loop binding parameters. Currently unused in
242 # E_b_loop_i (only the <111> branch is active), but RESERVED for the
243 # upcoming <111>/<100> SIA population split — do NOT delete.
244 A_100 = float(inp.dissociation.get('A_100', 0.7160))
245 B_100 = float(inp.dissociation.get('B_100', 0.3581))
246 n_tr = float(inp.dissociation.get('n_tr', 25.0))
247 sigma_tr = float(inp.dissociation.get('sigma_tr', 5.0))
248 gamma_sf = float(inp.dissociation.get('gamma_sf', 0.6))
249
250 # ── Di-interstitial binding energy override (dissociation!E_b_i2) ────
251 # The small-n loop branch is E_b^fit(n) = A_111·n^{+B_111}, so its value
252 # at n=2 IS the di-interstitial binding energy. Setting that
253 # measurable quantity is preferable to setting the bare amplitude
254 # A_111, which has no independent experimental determination. When
255 # 'E_b_i2' is present and positive, it OVERRIDES the workbook A_111:
256 #
257 # A_111 <- E_b_i2 · 2^{-B_111} (Eq. Eb_smalln_fit)
258 #
259 # A_100 is rescaled by the SAME factor so the <100>/<111> amplitude
260 # ratio — which sets the relative stability of the two loop characters,
261 # and hence f_100 — is preserved and does not become a free parameter
262 # riding along with this override.
263 #
264 # Absent, blank or non-positive => legacy path, A_111/A_100 used
265 # verbatim (bit-identical to the pre-override behaviour).
266 E_b_i2 = _opt_pos_float(inp.dissociation.get('E_b_i2', None))
267 self.E_b_i2 = E_b_i2
268 if E_b_i2 is not None:
269 A_111_new = A_111_from_E_b_i2(E_b_i2, B_111)
270 if A_111 > 0.0:
271 A_100 = A_100 * (A_111_new / A_111)
272 A_111 = A_111_new
273
274 # ── Void-binding blend parameters (dissociation!lambda, !A_void_0) ───
275 # Read from the workbook rather than taken from the module constants in
276 # binding_energies.py. Both keys have always been present in the
277 # workbook but were previously hard-coded and silently ignored — a
278 # varied-but-unread parameter is indistinguishable from an inert one.
279 lambda_void = float(inp.dissociation.get('lambda', 0.5756))
280 A_void_0 = float(inp.dissociation.get('A_void_0', 1.2353))
281 alpha_He = inp.alpha_He
282 nu0_TM = float(inp.dissociation.get('nu0_TM', 1.0e12))
283
284 # SIA-loop continuum-elasticity parameters for E_b_loop_i (Eqs. 106-108).
285 # Sourced explicitly from Excel (energetics) so the function does NOT
286 # fall back to its hard-coded defaults (which carried a stale
287 # E_f_i=3.77 eV; the Table-3 / Excel value is 3.64 eV).
288 E_f_i = float(ener.get('E_f_i', 3.64)) # SIA formation energy [eV]
289 mu_Pa = float(ener.get('mu', 82.0)) * 1.0e9 # shear modulus GPa → Pa
290 nu_pois = float(ener.get('nu', 0.29)) # Poisson ratio
291
292 # Re-solution parameter
293 spec = d['spectrum']
294 b0_key = 'b0_fission' if 'fiss' in spec else 'b0_fusion'
295 b0_res = float(re.get(b0_key, 0.01 if 'fiss' in spec else 0.10))
296 G = d['G']
297
298 # Ω^{-2/3}: factor for converting volumetric rate k [m^3/s] to K [s^-1]
299 # K = k/Ω = (A_sph·m^{1/3}·D) / Ω = A_sph·m^{1/3}·D·Ω^{-2/3} / Ω^{1/3}
300 # Equivalently: K = A_sph·m^{1/3}·D / Ω^{2/3} [s^-1] (Eq. 131)
301 inv_Omega23 = Omega**(-2.0 / 3.0) # [m^-2]
302
303 # ── Notation helpers ─────────────────────────────────────────────────
304 # K_sph_3D = A_sph · m^{1/3} · D / Ω^{2/3} [s^-1 per at.frac] (Eq. 131)
305 # D [m^2/s] is the effective diffusivity of the mobile species.
306 def K_sph(D, m):
307 return A_sph * float(m)**(1.0/3.0) * D * inv_Omega23
308
309 # K_loop = A_loop · n^{1/2} · Z_i^loop · D_i / Ω^{2/3} (Eq. 132)
310 def K_loop(n):
311 return A_loop * float(n)**(1.0/2.0) * Z_i_loop * Di_eff * inv_Omega23
312
313 # K_iv recombination = A_iv · (D_i + D_v) / Ω^{2/3} (Eq. P1, monodef_iv)
314 # Uses mutual diffusivity: both species mobile in 3D
315 K_iv_scalar = A_iv * (Di_eff + Dv_eff) * inv_Omega23
316
317 # Mixed 1D/3D effective rate for SIA cluster(n) + vacancy cluster(m)
318 # Eq. 141: K_{n,m}^eff = A_sph·m^{1/3}·D_n^{1D} / (Ω^{2/3}·(1+B_rot·L̂²·m^{-1/3}))
319 def K_1D_eff(n, m):
320 denom = 1.0 + B_rot * L_hat**2 * float(m)**(-1.0/3.0)
321 return A_sph * float(m)**(1.0/3.0) * D1D(n) * inv_Omega23 / denom
322
323 # Thermal SIA emission from loop of size n (Eq. 138)
324 def alpha_loop(n):
325 if n <= 1:
326 return 0.0
327 Eb = E_b_loop_i(n, A_111, B_111, A_100, B_100, n_tr, sigma_tr,
328 E_f_i=E_f_i, G_shear=mu_Pa, b_111=b_111,
329 nu=nu_pois, gamma_sf=gamma_sf, Omega=Omega)
330 return A_sph * max(n - 1.0, 0.0)**(1.0/3.0) * Di_eff * np.exp(-Eb / kBT) * inv_Omega23
331
332 # Thermal vacancy emission from void of size m (Eq. 139)
333 def alpha_void(m):
334 if m <= 1:
335 return 0.0
336 Eb = E_b_void(m, E_f_v, gamma_s, Omega,
337 lambda_void=lambda_void, A_void_0=A_void_0)
338 return A_sph * max(m - 1.0, 0.0)**(1.0/3.0) * Dv_eff * np.exp(-Eb / kBT) * inv_Omega23
339
340 # Thermal vacancy emission from bubble (m, ell) (Eq. 139 modified)
341 def alpha_bubble(m, ell):
342 if m <= 1:
343 return 0.0
344 Eb = E_b_bubble(m, ell, E_f_v, gamma_s, Omega, T)
345 Eb = max(Eb, 0.01) # floor to prevent negative barriers
346 return A_sph * max(m - 1.0, 0.0)**(1.0/3.0) * Dv_eff * np.exp(-Eb / kBT) * inv_Omega23
347
348 # Thermal He emission from bubble (m, ell) (Eq. 140)
349 def alpha_He_emit(m, ell):
350 if ell <= 0:
351 return 0.0
352 from .binding_energies import E_b_He
353 Eb = E_b_He(m, ell, E_s_He, Omega, T)
354 Eb = max(Eb, 0.01)
355 return A_sph * max(m - 1.0, 0.0)**(1.0/3.0) * Dh_eff * np.exp(-Eb / kBT) * inv_Omega23
356
357 # ── Build arrays for SIA clusters n=1..I ────────────────────────────
358 ns = np.arange(1, I + 1, dtype=float)
359
360 # 3D cluster diffusivity: D_n^{3D} = Di_eff / n^{s_3D}
361 # s_3D = 0 → all small clusters (n<4) diffuse at Di_eff (original)
362 # s_3D > 0 → di- and tri-SIA diffuse slower than monomers
363 def Di_cluster_3D(n):
364 return Di_eff / float(n)**s_3D
365
366 # Rotational-correlation factor for 1D/3D mixed transport (Eq. 121)
367 # Used in K_SIA_grow, K_SIA_loop, K_SIA_shrink, and k2_SIA below.
368 rot_factor = 1.0 + B_rot * L_hat**2 # ≈ 6568 for B_rot=2.627, L_hat=50
369
370 # ══ AUTHOR-DIRECTED MODEL OPTIONS (2026-08-31) ═══════════════════════
371 # Both default OFF, so every prior run reproduces bit-for-bit.
372 #
373 # (1) sia_D_flat -- SIZE-INDEPENDENT CLUSTER DIFFUSIVITY.
374 # Legacy: n<4 diffuse at Di_eff/n^s_3D, 4<=n<=i_mobile at
375 # D1D(n)/rot_factor (a factor 2.7e6 suppression at L_hat=1016), and
376 # n>i_mobile exactly 0. Measured consequence: D falls 1.8e6x between
377 # n=3 and n=4, so the coalescence sum over mobile projectiles --
378 # which the kernel DOES carry, over np=1..i_mobile -- collapses to
379 # the monomer term (n=4,5 contribute ~5e-7 of it).
380 # The 1-D-glide isotropic-equivalent D1D/rot_factor is the rate a
381 # randomly placed SPHERICAL sink sees from a rotating 1-D glider;
382 # Heinisch (docs/Literature, "The effects of one-dimensional glide on
383 # the reaction kinetics of interstitial clusters") shows 1-D migration
384 # gives kinetics no scalar isotropic D reproduces, and Osetsky
385 # ("Stability and mobility of defect clusters and dislocation loops in
386 # metals") and Trinkaus, Singh & Foreman (1993) establish that
387 # 1/2<111> loops in bcc Fe stay glissile at ALL sizes with migration
388 # barriers of a few tens of meV, essentially size-independent.
389 # With sia_D_flat=1 every mobile cluster n <= i_mobile diffuses at the
390 # monomer value Di_eff. Raising i_mobile then genuinely raises the
391 # net interstitial flux instead of adding near-immobile species.
392 sia_D_flat = int(_num(re.get('sia_D_flat', 0), 0))
393
394 # (2) Z_loop_model -- SIZE-DEPENDENT LOOP BIAS (Wolfer).
395 # Legacy: Z_i^loop is a CONSTANT and Z_v^loop is hard-wired to 1.0,
396 # so the drift bracket [Z_i^loop D_i c_1 - D_v c_v] carries no n and
397 # v(n) = C n^(1/2) has no zero crossing -- no critical radius at any
398 # size. That is why <111> mean size was a stiff invariant at n~20
399 # across six independent levers (report S17).
400 #
401 # The bias originates in the elastic interaction between a point
402 # defect's relaxation volume and the loop strain field; an SIA has a
403 # much larger relaxation volume than a vacancy, hence Z_i > Z_v. The
404 # interaction defines a DRIFT CAPTURE RADIUS r_c^j (the distance at
405 # which |E_int| = kT), and because that is a length, the capture
406 # efficiency of a loop of radius r_L necessarily depends on r_L.
407 #
408 # Form used (toroidal sink, log capture efficiency):
409 # Z_j^loop(r_L) = ln(8 r_L / r_0) / ln(8 r_L / r_c^j)
410 # -> 1 as r_c^j -> r_0 (neutral sink), and -> 1 as r_L -> infinity
411 # (large loops approach the unbiased geometric limit). r_0 is the
412 # core radius, taken as b.
413 #
414 # SOURCE. Wolfer & Ashkin, "Diffusion of vacancies and interstitials
415 # to edge dislocations", J. Appl. Phys. 47 (1976) 791; Wolfer, "The
416 # Dislocation Bias", J. Computer-Aided Mater. Des. 14 (2007) 403-417;
417 # loop/toroidal capture efficiency after Rauh & Simon, phys. stat.
418 # sol. (a) 46 (1978) 499, in the form used by Golubov, Barashev &
419 # Stoller, "Radiation Damage Theory", Comprehensive Nuclear Materials
420 # Vol. 1 Ch. 1.13 (2012).
421 #
422 # CAVEAT -- READ BEFORE TRUSTING THE NUMBERS. None of these papers is
423 # in docs/Literature. The FUNCTIONAL FORM above is standard and I am
424 # confident in it; the capture radii r_c^i / r_c^v are exposed as
425 # parameters (in units of b) rather than hard-coded, with defaults
426 # 3.0 b and 1.0 b. Those defaults are order-of-magnitude values, NOT
427 # taken from a specific table, and should be replaced with values read
428 # from Wolfer (2007) or CNM Ch. 1.13 before any published result.
429 # r_c^v = 1.0 b reproduces the legacy Z_v^loop = 1.0 exactly.
430 Z_loop_model = int(_num(re.get('Z_loop_model', 0), 0))
431 r_cap_i_b = float(_num(re.get('r_cap_i_b', 3.0), 3.0))
432 r_cap_v_b = float(_num(re.get('r_cap_v_b', 1.0), 1.0))
433
434 def _r_loop(n):
435 """Radius of a prismatic loop of n SIAs: pi r^2 = n*Omega/b."""
436 return np.sqrt(max(n, 1.0) * Omega / (np.pi * b_111))
437
438 def _Z_wolfer(n, r_cap_b):
439 """Z_j^loop(r_L) = ln(8 r_L / r_0) / ln(8 r_L / r_c^j), r_0 = b."""
440 r_L = _r_loop(n)
441 r0 = b_111
442 rc = r_cap_b * b_111
443 x0, xj = 8.0 * r_L / r0, 8.0 * r_L / rc
444 # Guard the singular/inverted branch at r_L <~ r_c/8.
445 if xj <= 1.05 or x0 <= 1.0:
446 return _Z_WOLFER_MAX
447 return float(min(np.log(x0) / np.log(xj), _Z_WOLFER_MAX))
448
449 # SIA growth (absorbs mono-SIA): LOOP geometry for n ≥ 4 (Eq. P3_i)
450 # SIA clusters of size n ≥ 4 form prismatic dislocation loops whose
451 # capture cross-section scales as the circumference (∝ n^{1/2}), not
452 # the surface area of an equivalent sphere (∝ n^{1/3}).
453 #
454 # For n < 4 (3D mobile point-defect clusters / dumbbells):
455 # Spherical geometry: K = A_sph · n^{1/3} · D_i / Ω^{2/3}
456 # For n ≥ 4 (dislocation loops — both mobile and sessile):
457 # Loop geometry: K = A_loop · n^{1/2} · Z_i^loop · D_i / Ω^{2/3}
458 # The Z_i^loop bias factor reflects preferential SIA capture by the
459 # stress field of the prismatic loop (Eq. P3_i, Table 26).
460 K_SIA_grow_arr = np.zeros(I)
461 for ni in range(1, I + 1):
462 Zi_n = (_Z_wolfer(ni, r_cap_i_b) if Z_loop_model else Z_i_loop)
463 if ni < 4 and not sia_D_flat:
464 K_SIA_grow_arr[ni - 1] = K_sph(Di_eff, ni)
465 elif ni <= i_mobile:
466 # sia_D_flat: every mobile cluster carries the MONOMER diffusivity,
467 # so the projectile term is Di_eff rather than D1D/rot_factor.
468 D_n_3D = Di_eff if sia_D_flat else D1D(ni) / rot_factor
469 K_SIA_grow_arr[ni - 1] = (A_loop * float(ni)**0.5
470 * Zi_n * (Di_eff + D_n_3D) * inv_Omega23)
471 else:
472 K_SIA_grow_arr[ni - 1] = (A_loop * float(ni)**0.5
473 * Zi_n * Di_eff * inv_Omega23)
474 self.K_SIA_grow = K_SIA_grow_arr
475 self.Z_i_loop_arr = np.array(
476 [(_Z_wolfer(ni, r_cap_i_b) if Z_loop_model else Z_i_loop)
477 for ni in range(1, I + 1)])
478
479 # SIA loop-capture rate K_loop(n) (Eq. 132) — same mobility logic
480 K_SIA_loop_arr = np.zeros(I)
481 for ni in range(1, I + 1):
482 if ni < 4:
483 K_SIA_loop_arr[ni - 1] = K_loop(ni)
484 elif ni <= i_mobile:
485 D_n_3D = D1D(ni) / rot_factor
486 K_SIA_loop_arr[ni - 1] = (A_loop * float(ni)**0.5
487 * Z_i_loop * D_n_3D * inv_Omega23)
488 else:
489 K_SIA_loop_arr[ni - 1] = K_loop(ni)
490 self.K_SIA_loop = K_SIA_loop_arr
491
492 # SIA cluster shrinks by absorbing a vacancy (Eq. P3_v)
493 # Same loop geometry for n ≥ 4 but NO bias factor (Z_v^loop = 1.0):
494 # vacancy capture by the loop is purely geometric (no elastic preference).
495 K_SIA_shrink_arr = np.zeros(I)
496 for ni in range(1, I + 1):
497 # Legacy Z_v^loop is hard-wired to 1.0. Under Z_loop_model the
498 # vacancy side gets its OWN capture radius, so the drift bracket
499 # [Z_i D_i c_1 - Z_v D_v c_v] finally carries n on BOTH sides.
500 Zv_n = (_Z_wolfer(ni, r_cap_v_b) if Z_loop_model else 1.0)
501 if ni < 4 and not sia_D_flat:
502 K_SIA_shrink_arr[ni - 1] = K_sph(Dv_eff, ni)
503 elif ni <= i_mobile:
504 D_n_3D = Di_eff if sia_D_flat else D1D(ni) / rot_factor
505 K_SIA_shrink_arr[ni - 1] = (A_loop * float(ni)**0.5
506 * Zv_n * (Dv_eff + D_n_3D) * inv_Omega23)
507 else:
508 K_SIA_shrink_arr[ni - 1] = (A_loop * float(ni)**0.5
509 * Zv_n * Dv_eff * inv_Omega23)
510 self.K_SIA_shrink = K_SIA_shrink_arr
511
512 # Thermal SIA emission from loop (Eq. 138)
513 self.G_SIA = np.array([alpha_loop(n) for n in ns])
514
515 # Dislocation sink for SIA clusters (Eq. 134)
516 # For 3D-mobile n < 4: use ω_i^eff; for 1D n ≥ 4: use D1D(n)/a²
517 # Effective 3D diffusivity for fixed-sink capture (Eq. 134-137).
518 # For 1D-gliding clusters (n >= 4, n <= i_mobile): D1D is a 1D transport
519 # coefficient; plugging it directly into a 3D spherical-capture formula
520 # overestimates dislocation/GB absorption by the rotational-correlation
521 # factor (1 + B_rot * L_hat^2). rot_factor is defined above.
522 k2_SIA = np.zeros(I)
523 for n in range(1, I + 1):
524 if n < 4:
525 om = omega_i / float(n)**s_3D # 3D mobile: ω_i^eff / n^{s_3D}
526 elif n <= i_mobile:
527 # 1D glider: effective 3D diffusivity reduced by rotational factor
528 om = D1D(n) / (a_m**2 * rot_factor)
529 else:
530 om = 0.0 # immobile large loops
531 k2_d = Z_i * rho_d * om * a_m**2 # disloc sink
532 k2_gb = Z_gb_i * np.pi**2 * (om * a_m**2) / d_g**2 # lath GB sink
533 k2_p = A_PREC * Z_p_i * rho_p * r_p * (om * a_m**2) # precip sink
534 k2_SIA[n - 1] = (k2_d + k2_gb + k2_p) # [s^-1]
535 self.k2_SIA = k2_SIA
536
537 # Mixed 1D/3D cross-term coefficients for SIA cluster(n) + void(m)
538 # Stored as K_1D_eff_n[n-1] — called at runtime with m argument
539 # For efficiency: precompute K_1D_pref[n-1] = A_sph · D_n^{1D} / Ω^{2/3}
540 K_1D_pref = np.zeros(I)
541 for n in range(1, I + 1):
542 if n <= i_mobile and n >= 4:
543 K_1D_pref[n - 1] = A_sph * D1D(n) * inv_Omega23
544 self.K_1D_pref = K_1D_pref # multiply by m^{1/3}/(1+B_rot·L̂²·m^{-1/3})
545
546 # ── Build arrays for vacancy clusters m=1..V ─────────────────────────
547 ms = np.arange(1, V + 1, dtype=float)
548
549 # Vacancy captured by void K_sph(D_v, m) (Eq. 131)
550 self.K_VAC_grow = np.array([K_sph(Dv_eff, m) for m in ms])
551
552 # SIA captured by void (annihilation) K_sph(D_i, m) (Eq. 131)
553 self.K_VAC_shrink = np.array([K_sph(Di_eff, m) for m in ms])
554
555 # He captured by void K_sph(D_h, m) (Eq. 131)
556 self.K_HeV = np.array([K_sph(Dh_eff, m) for m in ms])
557
558 # Thermal vacancy emission from pure void (Eq. 139)
559 self.G_VAC = np.array([alpha_void(m) for m in ms])
560
561 # Fixed vacancy sink (Eq. 134-137)
562 k2_d_v = Z_v * rho_d * Dv_eff # disloc
563 k2_gb_v = Z_gb_v * np.pi**2 * Dv_eff / d_g**2 # lath GB
564 k2_p_v = A_PREC * Z_p_v * rho_p * r_p * Dv_eff # precip
565 self.k2_vac_scalar = k2_d_v + k2_gb_v + k2_p_v # [s^-1]
566
567 # Fixed He sink (Eq. 134-137)
568 k2_d_h = Z_He * rho_d * Dh_eff
569 k2_gb_h = np.pi**2 * Dh_eff / d_g**2
570 self.k2_He_scalar = k2_d_h + k2_gb_h
571
572 # Fixed SIA (monomer) sink
573 k2_d_i = Z_i * rho_d * Di_eff
574 k2_gb_i = Z_gb_i * np.pi**2 * Di_eff / d_g**2
575 k2_p_i = A_PREC * Z_p_i * rho_p * r_p * Di_eff
576 self.k2_SIA_scalar = k2_d_i + k2_gb_i + k2_p_i
577
578 # V–SIA recombination scalar (Eq. 130)
579 self.K_iv = K_iv_scalar
580
581 # Store callables for He-vacancy reactions (used in full 2D grid mode).
582 # These are nested functions — excluded from pickling via __getstate__.
583 self.alpha_bubble_fn = alpha_bubble
584 self.alpha_He_emit_fn = alpha_He_emit
585 self.K_1D_eff_fn = K_1D_eff
586
587 # 3D cavity absorption prefactor: A_sph · Di_eff / Ω^{2/3}
588 # Used for mobile SIA clusters n=1..3 hitting cavities (Eq. K_cav, 3D branch)
589 self.K_3D_cav_pref = A_sph * Di_eff * inv_Omega23
590
591 # ── Effective 3D diffusivities for mobile clusters (for coalescence) ──
592 # D_SIA_eff[n-1] = effective 3D diffusivity of SIA cluster of size n.
593 # Used by the general i–i coalescence and mobile-SIA–cavity terms.
594 # ONLY mobile clusters (n ≤ i_mobile) have non-zero diffusivity.
595 # For n < 4 AND n ≤ i_mobile (3D mobile): D_n = Di_eff / n^{s_3D}
596 # For 4 ≤ n ≤ i_mobile (1D gliders): D_n = D1D(n) / rot_factor
597 # For n > i_mobile: D_n = 0 (sessile — no coalescence as projectile)
598 D_SIA_eff = np.zeros(I)
599 for n in range(1, I + 1):
600 if n > i_mobile:
601 pass # sessile: D = 0
602 elif sia_D_flat:
603 D_SIA_eff[n - 1] = Di_eff # size-independent (author, 2026-08-31)
604 elif n < 4:
605 D_SIA_eff[n - 1] = Di_cluster_3D(n)
606 else:
607 D_SIA_eff[n - 1] = D1D(n) / rot_factor
608 self.D_SIA_eff = D_SIA_eff
609
610 # ── ½⟨111⟩ LOOP COARSENING (LOOP_COAL, default off) ───────────────────
611 # D_SIA_eff above is hard-truncated to zero at i_mobile, so a loop that
612 # grows past the cutoff can never take part in a coalescence again.
613 # Two mobile loops of n̄ ≈ 33 merge to 66 > i_mobile = 40, and the
614 # product is frozen. That is why the ½⟨111⟩ mean size is pinned AT the
615 # cutoff: measured mean_n_111/i_mobile = 1.32 ± 0.75 at i_mobile = 40 and
616 # 0.84 ± 0.25 at i_mobile = 100 across 16 runs (2026-08-16), i.e. the
617 # mean tracks the cutoff rather than the physics. Real ½⟨111⟩ loops in
618 # bcc Fe stay glissile to large size and coarsen by 1-D glide.
619 #
620 # D_loop_coal continues the SAME 1-D glide law past the cutoff instead
621 # of truncating it. It is a SEPARATE array, used ONLY by the loop–loop
622 # coalescence edge: reusing D_SIA_eff would also make large loops visible
623 # to the fixed-sink and cavity-absorption channels, which is a different
624 # physics change and would confound the test.
625 self.loop_coal = int(_num(re.get('LOOP_COAL', 0), 0))
626 self.loop_coal_pref = float(_num(re.get('loop_coal_pref', 1.0), 1.0))
627 D_loop_coal = np.zeros(I)
628 if self.loop_coal:
629 for n in range(1, I + 1):
630 if n < 4:
631 D_loop_coal[n - 1] = Di_cluster_3D(n)
632 else:
633 D_loop_coal[n - 1] = D1D(n) / rot_factor
634 D_loop_coal *= self.loop_coal_pref
635 self.D_loop_coal = D_loop_coal
636
637 # D_VAC_eff[m-1] = effective 3D diffusivity of vacancy cluster of size m.
638 # D_m = D_v / m^{s_vc} for m ≤ v_mobile; 0 for immobile clusters.
639 s_vc = float(diff.get('s_vc', 1.0))
640 D_VAC_eff = np.zeros(V)
641 for m in range(1, V + 1):
642 if m <= v_mobile:
643 D_VAC_eff[m - 1] = Dv_eff / float(m) ** s_vc
644 # else: 0 (sessile)
645 self.D_VAC_eff = D_VAC_eff
646
647 # Geometric prefactor for coalescence: A_sph / Ω^{2/3}
648 self.A_sph_inv_O23 = A_sph * inv_Omega23
649 self.A_loop_inv_O23 = A_loop * inv_Omega23
650
651 # ── Phase-3: ½⟨111⟩ → ⟨100⟩ loop-conversion kernels ──────────────────
652 # See docs/design_notes/loop_111_to_100_conversion.md. These are the
653 # 1-D ingredients owned by ReactionRates; the 2-D junction / absorption
654 # coalescence kernels are assembled from D_SIA_eff + phi_junc in the
655 # EUROFER declaration layer (build_eurofer_rag).
656 le = LoopEnergetics(a=a_m * 1.0e10, Omega=Omega * 1.0e30,
657 T_star_C=float(re.get('T_star_conv_C', 450.0)),
658 n_ref=float(re.get('n_ref_conv', 30.0)))
660
661 # (1) Unary (Dudarev) conversion rate Γ_uni(n) at the operating T:
662 # Γ_uni(n) = ν₀·exp(−E_a(n)/kT)·max(0, 1 − exp(−ΔF(n,T)/kT)),
663 # E_a(n) = E_a0 + γ_a·P_111(n)/b_111 (size-dependent barrier).
664 # Nonzero only where ΔF(n,T) > 0 (small-loop biased — see note).
665 n_loop_min = int(re.get('n_loop_min', 4)) # ⟨100⟩ loop-onset floor
666 # Unary = DIRECT single-loop ½⟨111⟩→⟨100⟩ rotation (no junction partner to
667 # enable the easy two-step path), so its barrier is Marian's direct-
668 # rotation value (>2 eV) — NOT the two-step ΔH₂. Calibrated to place the
669 # f₁₁₁(T) crossover at ~350 °C at reactor dose rates (Phase 6).
670 E_a0 = float(re.get('E_a0_conv', 1.8)) # eV (direct-rotation barrier)
671 gamma_a = float(re.get('gamma_a_conv', 0.03)) # eV per perimeter-segment
672 nu0_conv = float(re.get('nu0_conv', 1.0e13)) # s⁻¹ attempt frequency
673 dF = le.driving_force_array(T, I) # eV over n = 1..I
674 P111 = np.asarray(le.perimeter(ns, 111), dtype=float) # Å
675 E_a = E_a0 + gamma_a * P111 / le.b_111 # eV
676 # Driving-force gate, evaluated only on the ΔF>0 support to avoid exp
677 # overflow on the large-loop (ΔF≪0) tail that np.where would discard.
678 gate = np.zeros(I)
679 pos = dF > 0.0
680 gate[pos] = 1.0 - np.exp(-dF[pos] / kBT)
681 Gamma_uni = nu0_conv * np.exp(-E_a / kBT) * gate
682 # No ⟨100⟩ loop exists below the loop-onset floor → no unary conversion.
683 Gamma_uni[:max(n_loop_min - 1, 0)] = 0.0
684 self.Gamma_uni = Gamma_uni
685 self.conversion_dF = dF # diagnostic
686
687 # (2) Marian junction branching φ_junc[n−1, n′−1] (size comparability):
688 # φ = φ_max·exp(−(ln(n/n′))²/2σ_s²)·Θ(min(n,n′) ≥ n_j_min).
689 # The dense [I, I] matrix is NOT built here: it is an O(I²) object that
690 # only the Python GraphWalker consumes (the C++ solver computes φ on the
691 # fly per (n,n') pair). At production sizes (I ~ 1e5) materialising it
692 # would need ~80 GB. We store the scalar parameters now and expose the
693 # matrix through the cached ``phi_junc`` property, so it is built only if
694 # the Python reference RHS actually asks for it (small-I runs/tests).
695 phi_max = float(re.get('phi_max_junc', 0.5))
696 sigma_s = float(re.get('sigma_s_junc', 0.35))
697 # Tied to the mobility cutoff: a junction partner must be mobile, so a
698 # threshold above i_mobile is unsatisfiable and silently kills the
699 # channel. Unchanged at i_mobile >= 50 (see effective_n_j_min).
700 n_j_min = effective_n_j_min(re.get('n_j_min_junc', 30.0), i_mobile,
701 re.get('n_j_min_frac', 0.6))
702 # Marian two-step success probability (½⟨111⟩ → ½⟨110⟩ → ⟨100⟩, Fig. 3):
703 # from the metastable ½⟨110⟩ intermediate the segment either rotates
704 # FORWARD to ⟨100⟩ (barrier ΔH₂) or REVERTS to ½⟨111⟩ (reverse barrier
705 # ΔH_rev = peak₁ − E_⟨110⟩). The branching is the probability that a
706 # junction / absorption event yields a *stable* ⟨100⟩ rather than
707 # reverting; ΔH₂ ≫ ΔH_rev ⇒ small at low T, rising with T. This is the
708 # temperature dependence the kinetic Marian channels were missing — it
709 # multiplies BOTH the junction yield and the absorption rate.
710 dH2_conv = float(re.get('dH2_conv', 1.0)) # ½⟨110⟩→⟨100⟩ barrier [eV]
711 dH_rev_conv = float(re.get('dH_rev_conv', 0.30)) # ½⟨110⟩→½⟨111⟩ barrier [eV]
712 ef = np.exp(-dH2_conv / kBT)
713 eb = np.exp(-dH_rev_conv / kBT)
714 self.conv_psuccess = float(ef / (ef + eb)) # P_success(T) ∈ (0, 0.5)
715 # ── Size-asymmetric success gate for ABSORPTION (2026-08-01) ─────────
716 # The single P_success above gates two physically different events:
717 # (a) a JUNCTION between two loops of comparable size, where the
718 # ½⟨110⟩ intermediate must nucleate a new ⟨100⟩ character and
719 # reversion is genuinely competitive; and
720 # (b) ABSORPTION of a mobile ≤ i_mobile cluster by a ⟨100⟩ loop of
721 # hundreds of atoms, where the incorporated segment is negligible
722 # against the host and inherits — is templated by — the host's
723 # existing ⟨100⟩ character. Reversion should be far less likely.
724 # Charging (b) the same 0.70 eV as (a) throttles it by 1/P_success =
725 # 4.6e5. That is the whole ⟨100⟩ size deficit: the ½⟨111⟩ and ⟨100⟩
726 # MONOMER kernels are identical (K_SIA_grow vs K_100_grow), so the only
727 # asymmetry between the characters is that a sessile ½⟨111⟩ loop eats
728 # mobile clusters at full rate (K_SIA_loop) while a ⟨100⟩ loop does so
729 # at 2.18e-6 of it. Hence a separate, lower forward barrier for (b).
730 # P_success^abs(T) = A_abs · [1 + exp((ΔH₂^abs − ΔH_rev)/k_BT)]^{-1}
731 # Defaults reproduce legacy behaviour exactly (ΔH₂^abs = ΔH₂, A = 1).
732 # Expressing it as an energy rather than a bare rate multiplier keeps
733 # the Arrhenius T-dependence the digital-twin campaign needs across
734 # 300–400 °C, which a constant boost factor would not supply.
735 # `_num`, not float(): 'dH2_abs_conv' ships BLANK in the workbook (its
736 # default is dynamic — it tracks dH2_conv), and a blank cell arrives as
737 # pandas NaN rather than as an absent key, so re.get(k, default) returns
738 # NaN and the default never fires. float(NaN) would then poison the
739 # gate silently. Same trap documented for loop_net_n_inc/loop_net_w_c.
740 dH2_abs = float(_num(re.get('dH2_abs_conv', dH2_conv), dH2_conv))
741 A_abs = float(_num(re.get('psucc_abs_pref', 1.0), 1.0))
742 ef_abs = np.exp(-dH2_abs / kBT)
743 self.conv_psuccess_abs = float(A_abs * ef_abs / (ef_abs + eb))
744 self.dH2_abs_conv = dH2_abs
745 self.psucc_abs_pref = A_abs
746 # Parameters for the lazy φ_junc property (no dense allocation here).
747 self._phi_junc_params = (phi_max, sigma_s, n_j_min)
748 self._phi_junc_n = int(ns.size)
749 self._phi_junc_cache = None
750
751 # (3) Sessile ⟨100⟩ point-defect kernels — loop geometry, immobile loop;
752 # the migrating monomer's diffusivity sets the capture rate. ⟨100⟩
753 # loops do not migrate, so there is no fixed-sink loss (k2_100 = 0).
754 K100_grow = np.zeros(I)
755 K100_shrink = np.zeros(I)
756 G100 = np.zeros(I)
757 # ⟨100⟩-ONLY monomer-capture scale. `Z_i_loop` cannot serve this role:
758 # it multiplies the ½⟨111⟩ loop-capture kernels too (see the K_loop
759 # branches above), so raising it grows both characters equally and
760 # leaves the ⟨100⟩/½⟨111⟩ ratio unchanged — which is why a 1.05→1.30
761 # sweep produced no differential effect (2026-08-01). Default 1.0 ⇒
762 # bit-identical. Reaches the C++ solver automatically because
763 # cpp_bridge exports K_100_grow as an explicit per-size array.
764 grow_boost_100 = float(re.get('grow_boost_100', 1.0) or 1.0)
765 for n in range(1, I + 1):
766 if n >= n_loop_min:
767 rt = float(n) ** 0.5
768 K100_grow[n - 1] = (A_loop * rt * Z_i_loop * Di_eff
769 * inv_Omega23 * grow_boost_100)
770 K100_shrink[n - 1] = A_loop * rt * Dv_eff * inv_Omega23
771 if n >= 2:
772 Eb100 = E_b_loop_100(n, A_100=A_100, B_100=B_100,
773 n_tr=n_tr, sigma_tr=sigma_tr,
774 E_f_i=E_f_i, G_shear=mu_Pa, b_100=a_m,
775 nu=nu_pois, gamma_sf=gamma_sf, Omega=Omega)
776 G100[n - 1] = (A_loop * max(n - 1.0, 0.0) ** 0.5 * Di_eff
777 * np.exp(-Eb100 / kBT) * inv_Omega23)
778 self.K_100_grow = K100_grow
779 self.K_100_shrink = K100_shrink
780 self.G_100 = G100
781 self.k2_100 = np.zeros(I) # sessile: no fixed-sink loss
782 self.n_loop_min = n_loop_min
783
784 # ── Loop → network-dislocation loss channel (loop_network_loss.tex) ──
785 # Transfer of SIA loops to the pre-existing network as a *network-only*,
786 # diffusivity-independent diagonal sink Λ_n^net (active even for sessile
787 # loops, which dominate incorporation). Both characters are treated;
788 # the channel is folded additively into the P4 diagonal sinks
789 # (k2_SIA for ½⟨111⟩, k2_100 for ⟨100⟩) per the additive-sink-strength
790 # rule, so the existing GraphWalker SINK edges and the C++ k2_SIA path
791 # carry it with no new edge/term, and the SIA-content ledger
792 # J_SIA_fixed (which already sums k2_SIA) stays exactly conservative.
793 # Λ_n^net = ν_net · P_ℓd(n), ν_net = v_net · ρ_net · w_c
794 # See docs/Formulation/dislocation_loops/loop_network_loss.tex Eqs. (loop_diameter_from_n,
795 # loop_network_spacing, elastic_interaction_zone, P_loop_dislocation,
796 # Lambda_network, vnet).
797 ns_f = np.arange(1.0, I + 1.0)
798 b_100_val = a_m # ⟨100⟩ Burgers magnitude ≈ a (cf. E_b_loop_100, b_100=a_m)
799 # Per-character loop diameter d_n = 2·sqrt(n·Ω/(π·b_c)) (Eq. loop_diameter_from_n)
800 self.d_loop_111 = 2.0 * np.sqrt(ns_f * Omega / (np.pi * b_111))
801 self.d_loop_100 = 2.0 * np.sqrt(ns_f * Omega / (np.pi * b_100_val))
802 self.Lambda_net_111 = np.zeros(I)
803 self.Lambda_net_100 = np.zeros(I)
805 self.rho_net = rho_d # = static rho_d when channel off
806 self.K_rec = 0.0
807 if self._loop_net_on:
808 # Blank workbook cells fall back to the code default: the keys with
809 # a *static* default carry an explicit number in the sheet, while
810 # 'loop_net_n_inc' and 'loop_net_w_c' have *dynamic* defaults
811 # (i_mobile, and the per-character Burgers vector) and are shipped
812 # blank. Reading them with float()/int() directly would turn a
813 # blank cell into NaN and propagate it into Λ_n^net.
814 chi = _num(re.get('loop_net_chi', 1.0), 1.0) # geometric range (Eq. 4)
815 xi = _num(re.get('loop_net_xi', 0.0), 0.0) # small-n floor (default off)
816 n_inc = int(_num(re.get('loop_net_n_inc', i_mobile), i_mobile)) # onset
817 # Recovery prefactor K_rec [m·s^-1] sets the steady-state ρ_net; it
818 # is a primary CALIBRATION parameter (Phase 5) and defaults to 0
819 # (no recovery → ρ_net grows monotonically) so an uncalibrated run
820 # does not crash ρ_net to the floor. See loop_network_loss.tex.
821 self.K_rec = _num(re.get('loop_net_K_rec', 0.0), 0.0)
822 rho_net = rho_d
823 # Segment-frozen point-defect monomers set the network climb velocity
824 # v_net (Eq. vnet); held constant within a segment (operator split),
825 # so Λ_n^net is a pure size array — no Jacobian coupling to c_1.
826 #
827 # Climb velocity (Bullough–Newman): the number of defects absorbed
828 # per unit dislocation line length per time is Z_α a² ω_α c_α / Ω,
829 # each adding volume Ω and climbing the line by Ω/b, so
830 # v_net = (a²/b)·(Z_i ω_i^eff c_i − Z_v ω_v^eff c_v) [m/s].
831 # (The note's Eq. vnet writes Ω/b, which is dimensionally m²/s; the
832 # correct prefactor is a²/b — Ω≈a³.) |v_net| is used: the network
833 # sweeps past stationary loops whichever way it climbs.
834 ci1 = float(re.get('ci1_seg', 0.0))
835 cv1 = float(re.get('cv1_seg', 0.0))
836 v_net = (a_m ** 2 / b_111) * (Z_i * omega_i * ci1 - Z_v * omega_v * cv1)
837 v_net = abs(v_net)
838 L_ld = rho_net ** -0.5 if rho_net > 0.0 else np.inf
839
840 def _lambda_net(d_arr, b_c, w_c):
841 R_int = chi * d_arr
842 if xi != 0.0:
843 R_int = R_int + xi * b_c * np.sqrt(
844 mu_Pa * Omega * _J_eV * ns_f / max(kBT, 1e-30))
845 s_ld = np.maximum(b_c, L_ld - 0.5 * d_arr)
846 P_ld = 0.5 * (1.0 + np.tanh((R_int - s_ld) / b_c))
847 Lam = (v_net * rho_net * w_c) * P_ld
848 if n_inc > 1:
849 Lam[:min(n_inc - 1, I)] = 0.0 # loops below n_inc excluded
850 return Lam
851
852 # Capture width w_c = O(b_c) per character (fixed-but-tunable). An
853 # explicit 'loop_net_w_c' overrides BOTH characters (used to amplify
854 # the channel in tests); otherwise each uses its own Burgers vector.
855 w_c_over = _opt_pos_float(re.get('loop_net_w_c', None))
856 w_c_111 = w_c_over if w_c_over is not None else b_111
857 w_c_100 = w_c_over if w_c_over is not None else b_100_val
858 self.Lambda_net_111 = _lambda_net(self.d_loop_111, b_111, w_c_111)
859 self.Lambda_net_100 = _lambda_net(self.d_loop_100, b_100_val, w_c_100)
860 # Additive-sink-strength rule: total loss diagonal = P4 + Λ_n^net.
861 self.k2_SIA = self.k2_SIA + self.Lambda_net_111
862 self.k2_100 = self.k2_100 + self.Lambda_net_100
863 self.rho_net = rho_net
864
865 # ── Cavity → network-dislocation SWEEPING (VOID_NETWORK_LOSS) ────────
866 # A climbing/gliding network dislocation that intersects a cavity
867 # absorbs it: the cavity's m vacancies are delivered to the line and the
868 # cavity is destroyed. Same geometry as the loop channel above
869 # Λ_m^void = |v_net| · ρ_net · w_c · P_ld(m)
870 # but applied to the VACANCY block, which makes it the only cavity sink
871 # in the model that does not act through c_v. That matters: c_v sets the
872 # loop drive Z_i^loop ω_i c_i − ω_v c_v, which sits at −17% of its
873 # vacancy term, so every lever that moves c_v enough to shrink cavities
874 # flips the loops first (measured: Z_v 1.0→1.15 gives S_I ×65). This
875 # edge removes cavity content while leaving c_v, and hence the loops,
876 # untouched.
877 #
878 # It is also the only channel that escapes the swelling-identity floor.
879 # With loops and the bias flux as the only routes, S = S_I + ΔJ^d pins
880 # the cavity content to the loop content and bounds d_cavity below by
881 # 4.70 nm. Sweeping routes cavity vacancies straight to the line, so it
882 # enters ΔJ^d and S may fall below S_I.
883 #
884 # Λ ∝ d_cav ∝ m^(1/3) removes LARGE cavities preferentially, which
885 # truncates the upper tail — the size-dependent negative feedback the
886 # model otherwise lacks (growth is dm/dt ∝ m^(1/3)·drive with a drive
887 # that never decays, giving unbounded m ∝ t^1.5).
888 #
889 # Gated OFF by default: with the key absent Λ_m^void ≡ 0 and every
890 # downstream expression is unchanged, so the legacy path is
891 # bit-identical.
892 self.void_network_loss = int(_num(re.get('VOID_NETWORK_LOSS', 0), 0)) != 0
893 self.Lambda_net_void = np.zeros(V)
894 if self.void_network_loss:
895 v_chi = _num(re.get('void_net_chi', 1.0), 1.0) # elastic range / d
896 # Onset: mobile vacancy clusters DIFFUSE to the network and are
897 # already counted in k2_disl_v. Sweeping them too would double-count
898 # that loss, so the channel starts above v_mobile.
899 m_inc = int(_num(re.get('void_net_m_inc', v_mobile), v_mobile))
900 rho_n = self.rho_net
901 # Same Bullough–Newman climb velocity as the loop channel; recomputed
902 # here so the cavity edge does not require LOOP_NETWORK_LOSS to be on.
903 ci1_v = float(re.get('ci1_seg', 0.0))
904 cv1_v = float(re.get('cv1_seg', 0.0))
905 v_net_v = abs((a_m ** 2 / b_111)
906 * (Z_i * omega_i * ci1_v - Z_v * omega_v * cv1_v))
907 self.L_ld_void = rho_n ** -0.5 if rho_n > 0.0 else np.inf # diagnostic
908 # Spherical cavity diameter d_m = 2(3mΩ/4π)^(1/3) — the same
909 # relation post_process/run_ensemble use for d_cavity_nm.
910 d_cav = 2.0 * (3.0 * ms * Omega / (4.0 * np.pi)) ** (1.0 / 3.0)
911 # Capture width: blank ⇒ the cavity's own diameter (bare geometric
912 # cross-section). An explicit void_net_w_c overrides it.
913 w_c_v = _opt_pos_float(re.get('void_net_w_c', None))
914 w_arr = d_cav if w_c_v is None else np.full(V, w_c_v)
915 # NO geometric gate here, unlike the loop channel. P_ld there asks
916 # whether a loop lies INSIDE the instantaneous elastic zone of a
917 # line, which is right for incorporation of a static loop. A
918 # SWEEPING dislocation traverses the volume, so v·ρ·w is already the
919 # encounter rate and a gate would double-count it — with the loop
920 # gate applied, spacing L_ld = 44.7 nm against a 5.5 nm cavity gives
921 # P_ld = 0 and the channel vanishes at χ = 1, which is wrong.
922 # χ instead widens the CAPTURE WIDTH (elastic attraction draws
923 # cavities onto the passing line): w_c = χ·d_cav.
924 Lam_v = (v_net_v * rho_n) * (v_chi * w_arr)
925 # Zero sizes 1..m_inc INCLUSIVE: with m_inc = v_mobile those clusters
926 # diffuse to the network and are already charged to k2_disl_v, so
927 # sweeping them as well would count the same loss twice.
928 if m_inc >= 1:
929 Lam_v[:min(m_inc, V)] = 0.0
930 self.Lambda_net_void = Lam_v
931
932 # Scalar physics
933 self.B_rot = B_rot
934 self.L_hat = L_hat
935 self.alpha_He = alpha_He
936
937 @property
938 def phi_junc(self):
939 """Marian junction-branching matrix φ_junc[n−1, n′−1], built lazily.
940
941 φ = P_success · φ_max · exp(−(ln(n/n'))² / 2σ_s²) · Θ(min(n,n') ≥ n_j_min).
942
943 This is a dense [I, I] array consumed only by the Python GraphWalker
944 (the reference RHS) and the loop-conversion unit tests. The production
945 C++ solver computes the same branching on the fly per (n,n') pair, so
946 the matrix is built only on first access and never on a C++ run —
947 avoiding an ~O(I²) allocation that is infeasible at production sizes.
948 """
949 if self._phi_junc_cache is not None:
950 return self._phi_junc_cache
951 phi_max, sigma_s, n_j_min = self._phi_junc_params
952 n = np.arange(1, self._phi_junc_n + 1, dtype=float)
953 ni = n[:, None]
954 nj = n[None, :]
955 with np.errstate(divide='ignore', invalid='ignore'):
956 log_ratio = np.log(ni / nj)
957 phi = phi_max * np.exp(-(log_ratio ** 2) / (2.0 * sigma_s ** 2))
958 phi = phi * (np.minimum(ni, nj) >= n_j_min)
959 self._phi_junc_cache = phi * self.conv_psuccess
960 return self._phi_junc_cache
961
962 def __getstate__(self):
963 # Nested-function attributes from _precompute can't be pickled.
964 # They are reconstructable but unused after unpickling (replot path
965 # only reads data arrays), so drop them. The lazily-built φ_junc
966 # cache is also dropped: it is large (O(I²)) and reconstructable.
967 state = self.__dict__.copy()
968 for k in ('alpha_bubble_fn', 'alpha_He_emit_fn', 'K_1D_eff_fn',
969 '_phi_junc_cache'):
970 state.pop(k, None)
971 return state
972
973 def __setstate__(self, state):
974 self.__dict__.update(state)
975 # Restore the dropped lazy cache so the property can rebuild on demand.
976 if not hasattr(self, '_phi_junc_cache'):
977 self._phi_junc_cache = None
978
979 def loop_network_drho_dt(self, c_111, c_100=None):
980 """Operator-split network-density rate dρ_net/dt (loop_network_loss.tex,
981 Eq. rho_net_balance):
982
983 dρ_net/dt = (π/Ω) Σ_c Σ_n d_n^(c) Λ_n^(c) c_n^(c) − K_rec ρ_net^{3/2}
984
985 ``c_111`` / ``c_100`` are atom-fraction loop distributions (size arrays);
986 ``c_100`` may be None when the ⟨100⟩ population is absent. Returns 0.0
987 when the channel is off. Called by simulation.run_adaptive between
988 segments to advance ρ_net explicitly while it is held frozen inside the
989 stiff cluster solve.
990 """
991 if not getattr(self, 'loop_network_loss', False):
992 return 0.0
993 Omega = float(self.inp.derived['Omega'])
994 c_111 = np.asarray(c_111, dtype=float)
995 gain = np.dot(self.d_loop_111[:c_111.size] * self.Lambda_net_111[:c_111.size],
996 np.maximum(c_111, 0.0))
997 if c_100 is not None:
998 c_100 = np.asarray(c_100, dtype=float)
999 gain += np.dot(self.d_loop_100[:c_100.size] * self.Lambda_net_100[:c_100.size],
1000 np.maximum(c_100, 0.0))
1001 gain *= np.pi / Omega
1002 recovery = self.K_rec * max(self.rho_net, 0.0) ** 1.5
1003 return float(gain - recovery)
1004
1005 def format_diagnostic(self, mean_n_i=None):
1006 """Return key rate constants as a formatted string.
1007
1008 Parameters
1009 ----------
1010 mean_n_i : float, optional
1011 Mean SIA cluster size <n> at the current output step.
1012 """
1013 mean_str = f" mean_n_i={mean_n_i:.2f}" if mean_n_i is not None else ""
1014 lines = []
1015 lines.append(
1016 f"ReactionRates: K_SIA_grow[0]={self.K_SIA_grow[0]:.3e}"
1017 f" K_VAC_grow[0]={self.K_VAC_grow[0]:.3e}"
1018 f" G_VAC[0]={self.G_VAC[0]:.3e}"
1019 f" K_iv={self.K_iv:.3e}"
1020 f" K_3D_cav={self.K_3D_cav_pref:.3e}"
1021 f"{mean_str}")
1022 lines.append(
1023 f" k2_SIA[0]={self.k2_SIA[0]:.3e}"
1024 f" k2_vac={self.k2_vac_scalar:.3e}"
1025 f" k2_He={self.k2_He_scalar:.3e}")
1026 lines.append(
1027 f" D_SIA_eff: n=1: {self.D_SIA_eff[0]:.3e}"
1028 f" n=2: {self.D_SIA_eff[1]:.3e}"
1029 f" n=3: {self.D_SIA_eff[2]:.3e}"
1030 + (f" n=4: {self.D_SIA_eff[3]:.3e}" if len(self.D_SIA_eff) > 3 else ""))
1031 if len(self.K_SIA_grow) >= 5:
1032 lines.append(
1033 f" C_i5: K_grow={self.K_SIA_grow[4]:.3e}"
1034 f" K_shrink={self.K_SIA_shrink[4]:.3e}"
1035 f" K_loop={self.K_SIA_loop[4]:.3e}"
1036 f" k2={self.k2_SIA[4]:.3e}"
1037 f" G_emit={self.G_SIA[4]:.3e}")
1038 return '\n'.join(lines)
1039
1040 def print_diagnostic(self, mean_n_i=None):
1041 """Print key rate constants — called at output time steps."""
1042 print(self.format_diagnostic(mean_n_i))
loop_network_drho_dt(self, c_111, c_100=None)
effective_n_j_min(n_j_min_junc, i_mobile, frac=0.6)
static double K_1D_eff(const Parameters &P, int n_idx, int m_idx)