RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
rate_kernels.cpp
Go to the documentation of this file.
1
60#include "rate_equations.h"
61#include <nvector/nvector_serial.h>
62#include <cmath>
63#include <algorithm>
64
65#ifdef CD_HAVE_OPENMP
66#include <omp.h>
67#endif
68
69// ── Helpers ───────────────────────────────────────────────────────────────────
70
71// Size-only table accessors. Each returns exactly what the corresponding
72// std:: call returned before the tables existed — build_kernel_tables() fills
73// the entries with that same call — so substituting them anywhere in the
74// kernels leaves the arithmetic bit-identical. The bounds test falls back to
75// the direct call when the tables are unbuilt or the size is off the end.
76static inline double sz_cbrt(const Parameters& P, int s) {
77 return (s >= 1 && s <= P.size_tab_max) ? P.cbrt_tab[s - 1]
78 : std::cbrt(static_cast<double>(s));
79}
80static inline double sz_sqrt(const Parameters& P, int s) {
81 return (s >= 1 && s <= P.size_tab_max) ? P.sqrt_tab[s - 1]
82 : std::sqrt(static_cast<double>(s));
83}
84static inline double sz_pow23(const Parameters& P, int s) {
85 return (s >= 1 && s <= P.size_tab_max) ? P.pow23_tab[s - 1]
86 : std::pow(static_cast<double>(s), 2.0 / 3.0);
87}
88
89static inline double K_1D_eff(const Parameters& P, int n_idx, int m_idx) {
90 // Mixed 1D/3D effective rate (Eq. 141)
91 // K_n,m^eff = K_1D_pref[n] · m^{1/3} / (1 + B_rot · L̂² · m^{-1/3})
92 int n = n_idx + 1;
93 int m = m_idx + 1;
94 if (n < 4 || n > P.i_mobile) return 0.0;
95 double k_pref = P.K_1D_pref[n_idx];
96 if (k_pref < 1e-300) return 0.0;
97 double m13 = sz_cbrt(P, m);
98 double denom = 1.0 + P.B_rot * P.L_hat * P.L_hat / m13;
99 return k_pref * m13 / denom;
100}
101
110static inline double c_h_qss_case2(const Parameters& P,
111 const double* c_v, double Q_tot) {
112 double sink = P.k2_disl_He;
113 for (int m = 0; m < P.V; ++m)
114 sink += P.KHeV[m] * std::max(c_v[m], 0.0);
115 double source = P.G_He + P.beta_He * std::max(Q_tot, 0.0);
116 return source / (sink > 1e-300 ? sink : 1e-300);
117}
118
131static inline double c_h_qss_case1(const Parameters& P, const double* c_v,
132 const double* Q, int n_Q) {
133 double sink = P.k2_disl_He;
134 for (int m = 0; m < P.V; ++m)
135 sink += P.KHeV[m] * std::max(c_v[m], 0.0);
136 double He_emit = 0.0;
137 for (int k = 0; k < n_Q; ++k)
138 He_emit += P.beta_He * std::max(Q[k], 0.0);
139 double source = P.G_He + He_emit;
140 return source / (sink > 1e-300 ? sink : 1e-300);
141}
142
143// ── Coalescence rate constants ───────────────────────────────────────────────
144//
145// K_ii(n, n') = A_sph/Ω^{2/3} · (n^{1/3} + n'^{1/3}) · D_{n'}^{eff}
146// Rate for mobile SIA cluster n' encountering SIA cluster n.
147// Only the mobile partner's diffusivity enters; target can be sessile.
148
149static inline double K_ii_coal(const Parameters& P, int n, int np) {
150 // n, np are 1-indexed sizes
151 // Z_ii bias factor accounts for elastic interaction between SIA loops.
152 // Prismatic loops have strong stress fields; mutual attraction can
153 // significantly enhance the encounter rate beyond geometric capture.
154 //
155 // Geometry: SIA clusters n ≥ 4 form dislocation loops with capture
156 // cross-section ∝ circumference (n^{1/2}), not surface area (n^{1/3}).
157 // Small clusters n < 4 (dumbbells) remain spherical.
158 // The Z_i_loop bias applies only to the loop target absorbing SIA.
159 double D_np = P.D_SIA_eff[np - 1];
160 if (D_np < 1e-300) return 0.0;
161
162 // Target (n) geometry: loop for n ≥ 4, spherical for n < 4
163 double target_factor, target_pref, target_bias;
164 if (n >= 4) {
165 target_factor = sz_sqrt(P, n);
166 target_pref = P.A_loop_inv_O23;
167 // Size-dependent Wolfer bias when the bridge shipped the array; the
168 // scalar otherwise (bit-identical to every pre-2026-08-31 run).
169 target_bias = (!P.Z_i_loop_arr.empty() && n >= 1 && n <= P.I)
170 ? P.Z_i_loop_arr[n - 1] : P.Z_i_loop;
171 } else {
172 target_factor = sz_cbrt(P, n);
173 target_pref = P.A_sph_inv_O23;
174 target_bias = 1.0;
175 }
176 // Projectile (np) geometry: loop for np ≥ 4, spherical for np < 4
177 double proj_factor;
178 if (np >= 4) {
179 proj_factor = sz_sqrt(P, np);
180 } else {
181 proj_factor = sz_cbrt(P, np);
182 }
183 // Combined size factor uses average of target and projectile prefactors
184 // weighted by their respective geometry. For simplicity and consistency
185 // with the additive cross-section convention, use target geometry for
186 // the full rate (target is the absorber; projectile diffuses to it).
187 return P.Z_ii * target_bias * target_pref * (target_factor + proj_factor) * D_np;
188}
189
190// K_vv(m, m') = A_sph/Ω^{2/3} · (m^{1/3} + m'^{1/3}) · D_{m'}^{eff}
191// Rate for mobile vacancy cluster m' encountering vacancy cluster m.
192
193static inline double K_vv_coal(const Parameters& P, int m, int mp) {
194 double D_mp = P.D_VAC_eff[mp - 1];
195 if (D_mp < 1e-300) return 0.0;
196 double size_factor = sz_cbrt(P, m) + sz_cbrt(P, mp);
197 return P.A_sph_inv_O23 * size_factor * D_mp;
198}
199
200// K_vi(n, m') = A_sph/Ω^{2/3} · n^{1/3} · D_{m'}^{eff}
201// Rate for mobile vacancy cluster m' shrinking SIA cluster n.
202
203static inline double K_vi_coal(const Parameters& P, int n, int mp) {
204 double D_mp = P.D_VAC_eff[mp - 1];
205 if (D_mp < 1e-300) return 0.0;
206 return P.A_sph_inv_O23 * sz_cbrt(P, n) * D_mp;
207}
208
209// ── Case 2 — fission/decoupled (Eq. 175) ─────────────────────────────────────
210//
211// State (dynamic): [c_i(I) | c_v(V) | Q_tot | c_h] N_eq = I+V+2
212// State (quasi_steady_state):[c_i(I) | c_v(V) | Q_tot] N_eq = I+V+1
213//
214// RHS design notes:
215// • All state variables clamped to max(y, 0) at access time.
216// This prevents negative amplification without introducing kinks above 0
217// (which would break CVODE's Jacobian estimation).
218// • C_floor is enforced post-step in solver.cpp, NOT inside the RHS.
219// • KHeV term removed from dcv: He capture preserves marginal Σ_ℓ c_{m,ℓ}.
220// • Fixed-sink loss limited to mobile voids (m+1 ≤ m_max_v).
221
222// x_hi_i_win / x_hi_v_win: inclusive upper bounds for active SIA / VAC state
223// indices (0-based). Full solver passes I-1 / V-1. Sliding-window modes pass
224// the current window frontier.
225// OpenMP is enabled at compile time via CD_HAVE_OPENMP; thread count is taken
226// from the OpenMP runtime (OMP_NUM_THREADS env var if set, otherwise the
227// machine maximum). Falls back to serial when CD_HAVE_OPENMP is not defined.
228// ── ½⟨111⟩ → ⟨100⟩ loop-conversion helpers (only used when P.loop_conversion) ─
229// Marian size-comparability junction branching φ(a,b): peaks at a=b, zero below
230// n_j_min (symmetric in a,b; a,b are 1-indexed sizes).
231static inline double conv_phi_junc(const Parameters& P, int a, int b) {
232 if (std::min(a, b) < P.conv_n_j_min) return 0.0;
233 const double lr = std::log(static_cast<double>(a) / static_cast<double>(b));
234 return P.conv_phi_max * P.conv_psuccess // Marian two-step success gate
235 * std::exp(-(lr * lr) / (2.0 * P.conv_sigma_s * P.conv_sigma_s));
236}
237// Marian absorption kernel for ⟨100⟩_m + ½⟨111⟩_n → ⟨100⟩_{m+n}:
238// 8π(ξ_m + ξ_n)·D^{111}_n / Ω^{2/3} (sessile ⟨100⟩ contributes D=0, so only
239// the mobile ½⟨111⟩ partner drives the capture). m, n are 1-indexed.
240static inline double xi_of(const Parameters& P, int s) {
241 static const double PI = 3.14159265358979323846;
242 return (s >= 1 && s <= P.size_tab_max)
243 ? P.xi_tab[s - 1]
244 : std::cbrt(3.0 * static_cast<double>(s) / (8.0 * PI));
245}
246
247static inline double K_100_absorb(const Parameters& P, int m, int n) {
248 static const double PI = 3.14159265358979323846;
249 const double A_8pi = P.A_sph_inv_O23 * (8.0 * PI / P.A_sph);
250 const double xi_m = xi_of(P, m);
251 const double xi_n = xi_of(P, n);
252 // Uses the ABSORPTION-only gate: a mobile ≤ i_mobile cluster joining a
253 // several-hundred-atom ⟨100⟩ loop is templated by the host's existing
254 // character, so it does not pay the junction's reversion penalty.
255 return A_8pi * (xi_m + xi_n) * P.D_SIA_eff[n - 1] * P.conv_psuccess_abs
256 * P.conv_absorb_boost;
257}
258
259// ── Size-only kernel tables ──────────────────────────────────────────────────
260//
261// Builds every y-independent quantity the RHS kernels used to recompute per
262// call. See the comment block on Parameters::size_tab_max for why this
263// matters: at I = V = 1e5 the bin-moment RHS was issuing O(I+V) cbrt/sqrt/
264// pow/log/exp calls per evaluation, and the Woodbury preconditioner makes
265// (2·prec_bw+1) + prec_rank evaluations per Jacobian rebuild.
266//
267// Bit-exactness: each entry is filled by the identical std:: call the kernel
268// made inline, and no kernel expression is re-associated — only the leaf
269// transcendental is replaced by its precomputed value.
271 static const double PI = 3.14159265358979323846;
272
273 // ── Leaf size tables ─────────────────────────────────────────────────────
274 // Sized to cover every index the kernels can reach: SIA sizes run to I,
275 // vacancy sizes to V, and the coalescence product size (sn + np) can
276 // exceed both by i_mobile before the overflow guard rejects it.
277 const int S = std::max(P.I, P.V) + std::max(P.i_mobile, P.v_mobile) + 1;
278 if (S < 1) return;
279
280 P.cbrt_tab.resize(S);
281 P.sqrt_tab.resize(S);
282 P.pow23_tab.resize(S);
283 P.xi_tab.resize(S);
284 for (int s = 1; s <= S; ++s) {
285 const double sf = static_cast<double>(s);
286 P.cbrt_tab [s - 1] = std::cbrt(sf);
287 P.sqrt_tab [s - 1] = std::sqrt(sf);
288 P.pow23_tab[s - 1] = std::pow(sf, 2.0 / 3.0);
289 P.xi_tab [s - 1] = std::cbrt(3.0 * sf / (8.0 * PI));
290 }
291 // Publish only once every leaf table is complete — the accessors read
292 // size_tab_max to decide whether the tables are usable, and K_ii_coal
293 // below goes through them.
294 P.size_tab_max = S;
295
296 // ── Per-bin Σn and Σn² (linear-reconstruction normal equations) ──────────
297 // Recomputed on every RHS call at O(I) / O(V) total cost, for values fixed
298 // by the bin edges. Accumulation order is preserved (ascending n), so the
299 // sums are bit-identical to the in-loop version.
300 if (!P.sia_bin_lo.empty() && !P.sia_bin_hi.empty()) {
301 P.bin_S1.assign(P.I_bin, 0.0);
302 P.bin_S2.assign(P.I_bin, 0.0);
303 for (int k = 0; k < P.I_bin; ++k) {
304 double S1 = 0.0, S2 = 0.0;
305 for (int n = P.sia_bin_lo[k]; n < P.sia_bin_hi[k]; ++n) {
306 const double nf = static_cast<double>(n);
307 S1 += nf; S2 += nf * nf;
308 }
309 P.bin_S1[k] = S1; P.bin_S2[k] = S2;
310 }
311 }
312 if (!P.vac_bin_lo.empty() && !P.vac_bin_hi.empty()) {
313 P.vbin_S1.assign(P.V_bin, 0.0);
314 P.vbin_S2.assign(P.V_bin, 0.0);
315 for (int k = 0; k < P.V_bin; ++k) {
316 double S1 = 0.0, S2 = 0.0;
317 for (int m = P.vac_bin_lo[k]; m < P.vac_bin_hi[k]; ++m) {
318 const double mf = static_cast<double>(m);
319 S1 += mf; S2 += mf * mf;
320 }
321 P.vbin_S1[k] = S1; P.vbin_S2[k] = S2;
322 }
323 }
324
325 // ── Fused Marian junction yield × SIA coalescence kernel ─────────────────
326 // The junction loop evaluates conv_phi_junc(np, npp)·K_ii_coal(npp, np)
327 // for npp = sn - np across sn = 2..I, i.e. O(i_mobile · I) log/exp/cbrt per
328 // RHS call — all of it a function of the two integer sizes alone. The
329 // product is tabulated (not the factors) because the RHS multiplies them
330 // together first: fusing them here reproduces that association exactly.
331 if (P.loop_conversion && P.i_mobile >= 1 && P.I >= 1) {
332 const long long need = static_cast<long long>(P.i_mobile) * P.I;
333 P.phiK_junc_tab.assign(static_cast<size_t>(need), 0.0);
334 P.phiK_junc_stride = P.I;
335 for (int np = 1; np <= P.i_mobile; ++np) {
336 for (int npp = 1; npp <= P.I; ++npp) {
337 const double ph = conv_phi_junc(P, np, npp);
338 P.phiK_junc_tab[static_cast<size_t>(np - 1) * P.I + (npp - 1)] =
339 (ph <= 0.0) ? 0.0 : ph * K_ii_coal(P, npp, np);
340 }
341 }
342 }
343}
344
345static int rhs_case2(sunrealtype /*t*/, N_Vector yv, N_Vector ydotv,
346 const Parameters& P,
347 int x_hi_i_win, int x_hi_v_win) {
348 const double* y = N_VGetArrayPointer_Serial(yv);
349 double* dydt = N_VGetArrayPointer_Serial(ydotv);
350 const int I = P.I;
351 const int V = P.V;
352 const bool qss = (P.he_kinetics == 1);
353
354 // Unpack and clamp to 0 (CVODE can probe transiently negative values)
355 const double* c_i = y; // [I] — use max(y[n], 0) at access time
356 const double* c_v = y + I; // [V]
357 const double Q_tot = std::max(y[I + V], 0.0);
358
359 // Free He: QSS algebraic or ODE state
360 const double c_h = qss ? c_h_qss_case2(P, c_v, Q_tot)
361 : std::max(y[I + V + 1], 0.0);
362
363 const double ci1 = std::max(c_i[0], 0.0);
364 const double cv1 = std::max(c_v[0], 0.0);
365
366 for (int k = 0; k < P.N_eq; ++k) dydt[k] = 0.0;
367
368 double* dci = dydt; // [I]
369 double* dcv = dydt + I; // [V]
370
371 // Mean He per void (scalar, Eq. 175) — for He-pressure correction of GVV
372 double C_vac_tot = 0.0;
373 for (int m = 0; m < V; ++m) C_vac_tot += std::max(c_v[m], 0.0);
374 const double ell_bar = (C_vac_tot > 1e-300) ? Q_tot / C_vac_tot : 0.0;
375
376 // GVV corrected for He pressure (simplified expression)
377 auto GVV_eff = [&](int m_idx) -> double {
378 const int m = m_idx + 1;
379 const double ell_m = ell_bar * std::pow(static_cast<double>(m), 2.0/3.0);
380 if (ell_m < 1e-6) return P.GVV[m_idx];
381 const double ratio = ell_m / static_cast<double>(m);
382 if (ratio < 1e-10) return P.GVV[m_idx];
383 const double dE = P.delta_He * P.beta_He_exp / static_cast<double>(m)
384 * std::pow(ratio, P.beta_He_exp - 1.0);
385 return P.GVV[m_idx] * std::exp(std::min(-ell_m * dE / P.kBT, 0.0));
386 };
387
388 // ── SIA cluster equations (Eq. ME_SIA) ─────────────────────────────────
389 // Loop is restricted to [0, x_hi_i_win] — clusters beyond the window
390 // keep dydt[n]=0 (initialised above), preventing CVODE from evolving them.
391 // Each iteration writes only to dci[n], so the loop is race-free under OMP.
392#ifdef CD_HAVE_OPENMP
393#pragma omp parallel for schedule(static, 64) if(x_hi_i_win + x_hi_v_win > 500)
394#endif
395 for (int n = 0; n <= x_hi_i_win; ++n) {
396 const int sn = n + 1; // 1-indexed size
397 const double cn = std::max(c_i[n], 0.0);
398
399 // Production
400 dci[n] += P.Pr_SIA[n];
401
402 // Thermal SIA emission (gain + loss)
403 if (n + 1 < I) dci[n] += P.GII[n + 1] * std::max(c_i[n + 1], 0.0);
404 dci[n] -= P.GII[n] * cn;
405
406 // i–i coalescence with ALL SIA clusters
407 // Gain: pairs (np, sn-np) where np is mobile (projectile).
408 // With single-D rate constant K(target, projectile) = geo·sf·D_proj,
409 // both orderings fire for mobile-mobile pairs, summing D_a + D_b = K_full.
410 // No symmetry factor needed (each ordering contributes its own D).
411 for (int np = 1; np <= std::min(sn - 1, P.i_mobile); ++np) {
412 const int npp = sn - np; // partner size (1-indexed)
413 if (npp < 1 || npp > I) continue;
414 const double c_np = std::max(c_i[np - 1], 0.0);
415 const double c_npp = std::max(c_i[npp - 1], 0.0);
416 dci[n] += K_ii_coal(P, npp, np) * c_np * c_npp;
417 }
418 // Loss (D_np contribution): any mobile np hits this cluster
419 for (int np = 1; np <= P.i_mobile; ++np) {
420 const double c_np = std::max(c_i[np - 1], 0.0);
421 dci[n] -= K_ii_coal(P, sn, np) * cn * c_np;
422 }
423 // Loss (D_sn contribution): this mobile cluster hits ALL targets
424 if (sn <= P.i_mobile) {
425 for (int np = 1; np <= I; ++np) {
426 const double c_np = std::max(c_i[np - 1], 0.0);
427 dci[n] -= K_ii_coal(P, np, sn) * cn * c_np;
428 }
429 }
430
431 // V–I annihilation: all mobile vacancy clusters m' = 1..v_mobile
432 if (n == 0) {
433 // n=1, m'=1: P1 recombination (V_1 + I_1 → nothing)
434 dci[0] -= P.K_iv * cv1 * ci1;
435 // m'=1 gain: V_1 + I_2 → I_1 (was missing — Bug fix)
436 if (1 < I)
437 dci[0] += P.KIV[1] * cv1 * std::max(c_i[1], 0.0);
438 // m'=2..v_mobile: mobile vac cluster shrinks I_{1+m'} → I_1
439 for (int mp = 2; mp <= P.v_mobile; ++mp) {
440 const double c_mp = std::max(c_v[mp - 1], 0.0);
441 if (sn + mp - 1 < I) {
442 // c_i index: target I_{sn+mp} has 0-index (sn+mp-1)
443 const double c_target = std::max(c_i[sn + mp - 1], 0.0);
444 dci[0] += K_vi_coal(P, sn + mp, mp) * c_mp * c_target;
445 }
446 dci[0] -= K_vi_coal(P, sn, mp) * c_mp * ci1;
447 }
448 } else {
449 // n>=2: all mobile vacancy clusters m' = 1..v_mobile
450 for (int mp = 1; mp <= P.v_mobile; ++mp) {
451 const double c_mp = std::max(c_v[mp - 1], 0.0);
452 double K_shrink;
453 if (mp == 1) {
454 K_shrink = P.KIV[n]; // existing rate for mono-vacancy
455 } else {
456 K_shrink = K_vi_coal(P, sn, mp);
457 }
458 // gain from larger cluster I_{n+m'} shrunk by V_{m'}
459 if (n + mp < I) {
460 double K_gain = (mp == 1) ? P.KIV[n + mp] :
461 K_vi_coal(P, sn + mp, mp);
462 dci[n] += K_gain * c_mp * std::max(c_i[n + mp], 0.0);
463 }
464 dci[n] -= K_shrink * c_mp * cn;
465 }
466 }
467
468 // SIA cluster–cavity absorption: all mobile n = 1..i_mobile
469 if (n == 0) {
470 for (int m = 1; m < V; ++m)
471 dci[0] -= P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
472 } else if (n < 3 && n < P.i_mobile) {
473 for (int m = 0; m < V; ++m)
474 dci[n] -= P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
475 } else if (n >= 3 && n < P.i_mobile) {
476 for (int m = 0; m < V; ++m)
477 dci[n] -= K_1D_eff(P, n, m) * cn * std::max(c_v[m], 0.0);
478 }
479 // Partial SIA survival (channel a): when I_{n+1} > V_{m+1} (n > m),
480 // the SIA is only partially absorbed: I_{n+1} + V_{m+1} → I_{n-m}.
481 if (n >= 1 && n < P.i_mobile) {
482 for (int m = 0; m < std::min(n, V); ++m) {
483 const double cvm = std::max(c_v[m], 0.0);
484 const double K_cav = (n < 3) ? P.K_3D_cav_pref * P.m13[m]
485 : K_1D_eff(P, n, m);
486 dci[n - m - 1] += K_cav * cn * cvm;
487 }
488 }
489
490 // Fixed sinks
491 dci[n] -= P.k2_SIA[n] * cn;
492 }
493
494
495 // SIA thermal emission I_{n+1} -> I_n + I_1 moves the cluster down one size above; the emitted monomer
496 // is returned to I_1 here, as emit_mono does for vacancies. Before v2.1.0 it was omitted, which lost one
497 // SIA per emission event and was the whole of the Frenkel-pair residual delta_FP (1.2e-2 on im5vm2;
498 // 2.9e-7 with the monomer returned). sia_emission_monomer = 0 restores the old arithmetic bit for bit.
499 if (P.sia_emission_monomer) {
500 double emit_mono_sia = 0.0;
501 for (int n = 1; n <= x_hi_i_win; ++n)
502 emit_mono_sia += P.GII[n] * std::max(c_i[n], 0.0);
503 dci[0] += emit_mono_sia;
504 }
505
506 // Window boundary: suppress SIA coalescence reactions whose product
507 // exceeds the current window frontier but stays within I. When
508 // I_{k} + I_{np} → I_{k+np} with k+np > wlim (1-indexed window
509 // limit), the gain at the product is never computed because the
510 // outer loop stops at x_hi_i_win. Undo the corresponding target
511 // and projectile loss so SIA content is conserved across the window.
512 if (x_hi_i_win < I - 1) {
513 const int wlim = x_hi_i_win + 1; // 1-indexed window size limit
514 for (int np = 1; np <= P.i_mobile; ++np) {
515 const double c_np = std::max(c_i[np - 1], 0.0);
516 if (c_np < 1e-300) continue;
517 // Target sizes k (1-indexed) in window where product k+np
518 // overflows the window (k+np > wlim) but not the domain
519 // (k+np ≤ I; domain overflow handled by boundary_flux).
520 const int k_lo = std::max(wlim - np + 1, 1);
521 const int k_hi = std::min(wlim, I - np);
522 for (int k = k_lo; k <= k_hi; ++k) {
523 const double ck = std::max(c_i[k - 1], 0.0);
524 const double rate = K_ii_coal(P, k, np) * c_np * ck;
525 dci[k - 1] += rate; // undo target loss
526 dci[np - 1] += rate; // undo projectile depletion
527 }
528 }
529 }
530
531 // Reflection boundary: suppress reactions whose product exceeds I
532 // Undo target loss and monomer/projectile depletion for overflow reactions.
533 if (P.boundary_flux == 1) {
534 for (int np = 1; np <= P.i_mobile; ++np) {
535 const double c_np = std::max(c_i[np - 1], 0.0);
536 for (int k = std::max(I - np + 1, 1); k <= I; ++k) {
537 const double rate = K_ii_coal(P, k, np) * c_np * std::max(c_i[k - 1], 0.0);
538 dci[k - 1] += rate; // undo target loss
539 dci[np - 1] += rate; // undo projectile depletion
540 }
541 }
542 }
543
544 // ── Vacancy cluster equations (Eq. ME_vac, Case 2) ──────────────────────
545 // He capture does NOT change void size class m — handled by Q_tot below.
546 // Pre-accumulate emitted monomers from thermal vacancy emission within
547 // the active VAC window [1, x_hi_v_win]. Out-of-window clusters have
548 // near-zero concentration so their contribution is negligible.
549 {
550 double emit_mono = 0.0;
551 for (int m = 1; m <= x_hi_v_win; ++m)
552 emit_mono += GVV_eff(m) * std::max(c_v[m], 0.0);
553 dcv[0] += emit_mono;
554 }
555 // Loop restricted to [0, x_hi_v_win]; each iteration writes only to dcv[m].
556#ifdef CD_HAVE_OPENMP
557#pragma omp parallel for schedule(static, 64) if(x_hi_i_win + x_hi_v_win > 500)
558#endif
559 for (int m = 0; m <= x_hi_v_win; ++m) {
560 const double cm = std::max(c_v[m], 0.0);
561 const double gvv_m = GVV_eff(m);
562
563 // Production
564 dcv[m] += P.Pr_VAC[m];
565
566 // Thermal vacancy emission (gain + loss)
567 if (m + 1 < V) dcv[m] += GVV_eff(m + 1) * std::max(c_v[m + 1], 0.0);
568 dcv[m] -= gvv_m * cm;
569
570 // V–V coalescence with ALL vacancy clusters
571 const int sm = m + 1; // 1-indexed size
572 // Gain: pairs (mp, sm-mp) where mp is mobile (projectile)
573 for (int mp = 1; mp <= std::min(sm - 1, P.v_mobile); ++mp) {
574 const int mpp = sm - mp;
575 if (mpp < 1 || mpp > V) continue;
576 const double c_mp = std::max(c_v[mp - 1], 0.0);
577 const double c_mpp = std::max(c_v[mpp - 1], 0.0);
578 dcv[m] += K_vv_coal(P, mpp, mp) * c_mp * c_mpp;
579 }
580 // Loss (D_mp contribution): any mobile mp hits this cluster
581 for (int mp = 1; mp <= P.v_mobile; ++mp) {
582 const double c_mp = std::max(c_v[mp - 1], 0.0);
583 dcv[m] -= K_vv_coal(P, sm, mp) * cm * c_mp;
584 }
585 // Loss (D_sm contribution): this mobile cluster hits ALL targets
586 if (sm <= P.v_mobile) {
587 for (int mp = 1; mp <= V; ++mp) {
588 const double c_mp = std::max(c_v[mp - 1], 0.0);
589 dcv[m] -= K_vv_coal(P, mp, sm) * cm * c_mp;
590 }
591 }
592
593 // SIA-induced cavity shrinkage: all mobile SIA n = 1..i_mobile
594 // n=1 (monomer):
595 if (m == 0) {
596 dcv[0] -= P.K_iv * ci1 * cv1;
597 if (V >= 2) dcv[0] += P.KVI[1] * ci1 * std::max(c_v[1], 0.0);
598 // Vacancy monomer consumed by SIA loop shrinkage:
599 // V_1 + I_n → I_{n-1} for n>=2 (n=1 already in K_iv above)
600 {
601 double sia_shrink_sink = 0.0;
602 for (int np = 1; np < I; ++np)
603 sia_shrink_sink += P.KIV[np] * std::max(c_i[np], 0.0);
604 dcv[0] -= cv1 * sia_shrink_sink;
605 }
606 } else {
607 dcv[m] -= P.KVI[m] * ci1 * cm;
608 if (m + 1 < V) dcv[m] += P.KVI[m + 1] * ci1 * std::max(c_v[m + 1], 0.0);
609 }
610
611 // n=2,3 (3D mobile SIA clusters): absorb into all cavities
612 for (int n = 1; n < std::min(3, P.i_mobile); ++n) {
613 const double cn = std::max(c_i[n], 0.0);
614 dcv[m] -= P.KVI[m] * cn * cm;
615 if (m + n + 1 < V)
616 dcv[m] += P.KVI[m + n + 1] * cn * std::max(c_v[m + n + 1], 0.0);
617 }
618
619 // n=4..i_mobile (1D/3D mixed): gain + loss
620 for (int n = 3; n < std::min(I, P.i_mobile); ++n) {
621 const double cn = std::max(c_i[n], 0.0);
622 dcv[m] -= K_1D_eff(P, n, m) * cn * cm;
623 if (m + n + 1 < V) {
624 const int mp = m + n + 1;
625 const double k_gain = K_1D_eff(P, n, mp);
626 dcv[m] += k_gain * cn * std::max(c_v[mp], 0.0);
627 }
628 }
629
630 // V–I annihilation channel (b): mobile V_{sm} diffuses to SIA.
631 // V_1 loss handled above (lines 386–392); add loss for sm = 2..v_mobile
632 // and gain at all m from V_{sm+sn} + I_{sn} → V_{sm} (source mobile).
633 if (sm >= 2 && sm <= P.v_mobile) {
634 for (int sn = 1; sn <= I; ++sn) {
635 const double c_sn = std::max(c_i[sn - 1], 0.0);
636 dcv[m] -= K_vi_coal(P, sn, sm) * cm * c_sn;
637 }
638 }
639 for (int sn = 1; sm + sn <= P.v_mobile && sn <= I; ++sn) {
640 if (sm + sn > V) break;
641 dcv[m] += K_vi_coal(P, sn, sm + sn)
642 * std::max(c_v[sm + sn - 1], 0.0)
643 * std::max(c_i[sn - 1], 0.0);
644 }
645
646 // Fixed sinks — only mobile vacancy clusters diffuse to sinks
647 if (m + 1 <= P.v_mobile)
648 dcv[m] -= P.k2_disl_v * cm;
649 // Cavity → network sweeping (VOID_NETWORK_LOSS): a climbing/gliding
650 // line intersects the cavity and absorbs it whole. Diagonal
651 // first-order loss; the m vacancies it carries are charged to
652 // J_VAC_fixed below, so δ_FP is preserved. Λ_m^void is zero for
653 // m ≤ v_mobile (those already pay k2_disl_v) and zero everywhere when
654 // the channel is off, making the OFF path byte-identical.
655 if (!P.Lambda_net_void.empty())
656 dcv[m] -= P.Lambda_net_void[m] * cm;
657 }
658
659 // Window boundary: suppress VAC coalescence reactions whose product
660 // exceeds the current VAC window frontier but stays within V.
661 if (x_hi_v_win < V - 1) {
662 const int wlim_v = x_hi_v_win + 1;
663 for (int mp = 1; mp <= P.v_mobile; ++mp) {
664 const double c_mp = std::max(c_v[mp - 1], 0.0);
665 if (c_mp < 1e-300) continue;
666 const int k_lo = std::max(wlim_v - mp + 1, 1);
667 const int k_hi = std::min(wlim_v, V - mp);
668 for (int k = k_lo; k <= k_hi; ++k) {
669 const double ck = std::max(c_v[k - 1], 0.0);
670 const double rate = K_vv_coal(P, k, mp) * c_mp * ck;
671 dcv[k - 1] += rate; // undo target loss
672 dcv[mp - 1] += rate; // undo projectile depletion
673 }
674 }
675 }
676
677 // Reflection boundary: suppress reactions whose product exceeds V
678 if (P.boundary_flux == 1) {
679 for (int mp = 1; mp <= P.v_mobile; ++mp) {
680 const double c_mp = std::max(c_v[mp - 1], 0.0);
681 for (int k = std::max(V - mp + 1, 1); k <= V; ++k) {
682 const double rate = K_vv_coal(P, k, mp) * c_mp * std::max(c_v[k - 1], 0.0);
683 dcv[k - 1] += rate; // undo target loss
684 dcv[mp - 1] += rate; // undo projectile depletion
685 }
686 }
687 }
688
689 // ── ½⟨111⟩ → ⟨100⟩ loop conversion (optional; full_system validated) ─────
690 // Appended c_i100 block. All terms conserve signed-defect content q=χn:
691 // unary/junction relabel ½⟨111⟩→⟨100⟩ at fixed size (or product size); the
692 // sessile ⟨100⟩ ladders exchange monomers with the ½⟨111⟩ pool (growth/
693 // emission) or annihilate a vacancy monomer (shrink, a Frenkel pair).
694 if (P.loop_conversion) {
695 const double* c_i100 = y + P.sia100_off;
696 double* dci100 = dydt + P.sia100_off;
697 const int nlm = P.conv_n_loop_min;
698 const int nhi = std::min(I, x_hi_i_win + 1); // ½⟨111⟩ size frontier
699
700 // (1) Unary transformation ½⟨111⟩_n → ⟨100⟩_n (size-fixed, one-way).
701 for (int n = nlm; n <= nhi; ++n) {
702 const double rate = P.Gamma_uni[n - 1] * std::max(c_i[n - 1], 0.0);
703 dci[n - 1] -= rate;
704 dci100[n - 1] += rate;
705 }
706
707 // (2) Marian junction: redirect a fraction φ of the ½⟨111⟩ coalescence
708 // GAIN into ⟨100⟩ (reactant losses stay on ½⟨111⟩; single-D convention).
709 for (int sn = 2; sn <= nhi; ++sn) { // product size
710 double moved = 0.0;
711 for (int np = 1; np <= std::min(sn - 1, P.i_mobile); ++np) {
712 const int npp = sn - np;
713 if (npp < 1 || npp > I) continue;
714 const double ph = conv_phi_junc(P, np, npp);
715 if (ph <= 0.0) continue;
716 moved += ph * K_ii_coal(P, npp, np)
717 * std::max(c_i[np - 1], 0.0)
718 * std::max(c_i[npp - 1], 0.0);
719 }
720 dci[sn - 1] -= moved;
721 dci100[sn - 1] += moved;
722 }
723
724 // (3) Marian absorption growth: ⟨100⟩_m + ½⟨111⟩_n → ⟨100⟩_{m+n}.
725 for (int m = nlm; m <= I; ++m) {
726 const double cm100 = std::max(c_i100[m - 1], 0.0);
727 if (cm100 < 1e-300) continue;
728 for (int n = 1; n <= P.i_mobile && m + n <= I; ++n) {
729 const double rate = K_100_absorb(P, m, n)
730 * cm100 * std::max(c_i[n - 1], 0.0);
731 dci100[m - 1] -= rate; // ⟨100⟩_m consumed
732 dci[n - 1] -= rate; // ½⟨111⟩_n absorbed
733 dci100[m + n - 1] += rate; // ⟨100⟩_{m+n}
734 }
735 }
736
737 // (4) Sessile ⟨100⟩ point-defect ladders (monomer-coupled to ½⟨111⟩).
738 for (int n = nlm; n <= I; ++n) {
739 const double cn100 = std::max(c_i100[n - 1], 0.0);
740 if (cn100 < 1e-300) continue;
741 // Growth ⟨100⟩_n + I_1 → ⟨100⟩_{n+1} (SIA monomer from ½⟨111⟩ pool)
742 if (n < I) {
743 const double g = P.K_100_grow[n - 1] * cn100 * ci1;
744 dci100[n - 1] -= g;
745 dci100[n] += g;
746 dci[0] -= g;
747 }
748 // Shrink ⟨100⟩_n + V_1 → ⟨100⟩_{n-1} (vacancy monomer annihilated)
749 {
750 const double s = P.K_100_shrink[n - 1] * cn100 * cv1;
751 dci100[n - 1] -= s;
752 if (n - 1 >= nlm) dci100[n - 2] += s; // stays ⟨100⟩
753 else if (n >= 2) dci[n - 2] += s; // dissolves to ½⟨111⟩
754 dcv[0] -= s;
755 }
756 // Emission ⟨100⟩_n → ⟨100⟩_{n-1} + I_1 (SIA monomer to ½⟨111⟩ pool)
757 {
758 const double e = P.G_100[n - 1] * cn100;
759 dci100[n - 1] -= e;
760 if (n - 1 >= nlm) dci100[n - 2] += e;
761 else if (n >= 2) dci[n - 2] += e;
762 dci[0] += e;
763 }
764 // Loop → network-dislocation loss: sessile ⟨100⟩_n swept into the
765 // network (loop_network_loss.tex). Diagonal first-order sink; its
766 // SIA content is routed into J_SIA_fixed below to keep δ_FP exact.
767 dci100[n - 1] -= P.Lambda_net_100[n - 1] * cn100;
768 }
769 }
770
771 // ── ½⟨111⟩ LOOP–LOOP COARSENING (P.loop_coal) — discrete mirror ──────────
772 // The per-size counterpart of the class-pair sum in rhs_bin_moment (see the
773 // long note there). It was implemented ONLY in the bin_moment path, while
774 // the workbook ships LOOP_COAL = 1, so rhs_case2 accepted the flag,
775 // recorded it in provenance and silently ignored it. A fully discrete run
776 // was therefore NOT the same equations as its bin_moment counterpart, and
777 // the 17.6 h Table 4 "exact" arm measured that difference rather than a
778 // closure error: d_111 frozen at 1.76 nm over a 30x dose range and N_111
779 // 3.5x the binned value -- precisely the "mean size pins AT the cutoff /
780 // density runs 20-60x over the data" failure the channel exists to remove.
781 //
782 // On a discrete grid there is no reconstruction, so the O(bw^2) blow-up
783 // that forced the moment formulation upstairs does not arise: each sessile
784 // SIZE is its own monodisperse class. Only sizes above i_mobile
785 // participate; mobile ones already coalesce through K_ii_coal. Classes at
786 // or below C_floor are skipped, which is what keeps the pair sum tractable
787 // (the populated size range is a small fraction of I).
788 if (P.loop_coal) {
789 // PARTICIPATION THRESHOLD. The pair sum is O(NC^2), and NC here counts
790 // SIZES, not bins: the moment version upstairs has ~20 classes, this has
791 // up to I. Gating only on C_floor is not enough -- sizes initialised AT
792 // the floor drift a hair above it and join, trending toward NC = I and
793 // 8e6 pairs per RHS at I = 4000. Measured: the rebuilt exact arm spent
794 // 2.6 h on the step at 0.014 dpa where the pre-fix arm covered 0.067 dpa
795 // in its first 170 min.
796 //
797 // The rate is proportional to c_a * c_b, so a class at a fraction eps of
798 // the peak contributes at most ~eps of the peak pair rate. Dropping
799 // classes below 1e-8 of the peak is therefore a relative error bounded
800 // by ~1e-8 on this channel -- four orders below the solver's own
801 // rtol = 1e-5, and check_bm_vs_discrete.py re-measures the agreement
802 // against the moment path on every run. The C_floor gate is retained as
803 // an absolute lower bound for the case where the peak itself is tiny.
804 // The gate that matters is ABSOLUTE, a small multiple of C_floor. A
805 // purely relative one does not fire: measured on the killed I = 4000
806 // arm, peak = 1.131e-09, so 1e-8 x peak = 1.13e-17 sits BELOW C_floor
807 // and NC stayed at 3904 (7.6e6 pairs) -- which is why the first attempt
808 // bought no speedup at all. 3795 of those 3904 classes were dust
809 // hovering between 1.0 and 1.13 x C_floor, i.e. sizes that never left
810 // their initial value.
811 //
812 // gate NC pairs largest excluded / peak
813 // 1 x Cf 3904 7,622,560 8.8e-07
814 // 2 x Cf 96 4,656 1.7e-06
815 // 10 x Cf 83 3,486 7.7e-06
816 //
817 // 2 x C_floor takes essentially all the benefit at the smallest error.
818 // The rate is proportional to c_a * c_b, so a dropped class bounds its
819 // own contribution by (c_excluded / peak) ~ 1.7e-06 -- below the
820 // solver's rtol = 1e-5 -- and a dropped PAIR by the square of it. The
821 // relative term is kept as a guard for a peak so large that C_floor-
822 // scale classes are irrelevant on their own.
823 constexpr double LOOP_COAL_ABS = 2.0; // x C_floor
824 constexpr double LOOP_COAL_REL = 1e-6; // x peak
825 double cmax = 0.0;
826 for (int sn = P.i_mobile + 1; sn <= I; ++sn)
827 cmax = std::max(cmax, std::max(c_i[sn - 1], 0.0));
828 const double c_min = std::max(LOOP_COAL_ABS * P.C_floor,
829 LOOP_COAL_REL * cmax);
830 std::vector<int> idx;
831 idx.reserve(64);
832 for (int sn = P.i_mobile + 1; sn <= I; ++sn)
833 if (std::max(c_i[sn - 1], 0.0) > c_min) idx.push_back(sn);
834 const int NC = static_cast<int>(idx.size());
835 for (int a = 0; a < NC; ++a) {
836 const int na = idx[a];
837 const double ca = std::max(c_i[na - 1], 0.0);
838 const double Da = P.D_loop_coal[na - 1];
839 const double Zt = (!P.Z_i_loop_arr.empty()) ? P.Z_i_loop_arr[na - 1]
840 : P.Z_i_loop;
841 for (int b = a; b < NC; ++b) {
842 const int nb = idx[b];
843 const double cb = std::max(c_i[nb - 1], 0.0);
844 const double Db = P.D_loop_coal[nb - 1];
845 // Both partners glide: the encounter rate carries D_a + D_b,
846 // unlike K_ii_coal's projectile-only convention.
847 const double Dsum = Da + Db;
848 if (Dsum < 1e-300) continue;
849 const double sf = std::sqrt(static_cast<double>(na))
850 + std::sqrt(static_cast<double>(nb));
851 const double K = P.Z_ii * Zt * P.A_loop_inv_O23 * sf * Dsum;
852 // a == b: 1/2 K c^2, and each event consumes TWO of the class.
853 const double r = (a == b) ? 0.5 * K * ca * ca : K * ca * cb;
854 if (r < 1e-300) continue;
855 if (a == b) dci[na - 1] -= 2.0 * r;
856 else { dci[na - 1] -= r; dci[nb - 1] -= r; }
857 // Product beyond the grid PILES AT THE EDGE, matching
858 // rhs_bin_moment's deposit() ("if (si > I) si = I"). Dropping
859 // the overflow instead leaves the two paths solving different
860 // equations at the top of the domain -- measured as a 2.24x
861 // ratio at the last size while everything below agreed to
862 // 0.04%. Whether edge-piling or loss is the better convention
863 // is a separate question; they must agree on it.
864 const int prod = std::min(na + nb, I);
865 dci[prod - 1] += r;
866 }
867 }
868 }
869
870 // ── Q_tot equation (total He in voids) ───────────────────────────────────
871 double He_uptake = 0.0;
872 for (int m = 0; m < V; ++m)
873 He_uptake += P.KHeV[m] * c_h * std::max(c_v[m], 0.0);
874 const double He_emit = P.beta_He * Q_tot;
875
876 // He lost when mobile voids (m <= v_mobile) are absorbed at fixed sinks.
877 // Immobile voids do not diffuse to sinks, so their He is retained.
878 double He_sink = 0.0;
879 for (int m = 0; m < std::min(V, P.v_mobile); ++m) {
880 const double ell_m = ell_bar * std::pow(static_cast<double>(m + 1), 2.0/3.0);
881 He_sink += P.k2_disl_v * ell_m * std::max(c_v[m], 0.0);
882 }
883 dydt[I + V] = He_uptake - He_emit - He_sink;
884
885 // ── Free He (Eq. 157) — dynamic mode only ────────────────────────────────
886 if (!qss)
887 dydt[I + V + 1] = P.G_He - He_uptake - P.k2_disl_He * c_h + He_emit;
888
889 // ── Conservation accounting ODEs ─────────────────────────────────────────
890 // J_SIA_fixed: SIA content lost to fixed sinks
891 {
892 double sia_fixed = 0.0;
893 for (int n = 0; n < I; ++n)
894 sia_fixed += static_cast<double>(n + 1) * P.k2_SIA[n] * std::max(c_i[n], 0.0);
895 dydt[P.cons_off + 0] = sia_fixed;
896 }
897
898 // J_SIA_mutual: ALL SIA content lost to SIA-vacancy annihilation.
899 // Two channels: (a) mobile SIA hitting voids, (b) mobile vacancies hitting SIA.
900 // Weight = min(sn, m+1): when I_n hits V_m with n > m, only m defects
901 // are annihilated; the remainder forms a smaller SIA cluster.
902 {
903 double mutual = 0.0;
904 for (int n = 0; n < I; ++n) {
905 const int sn = n + 1;
906 const double cn = std::max(c_i[n], 0.0);
907 if (cn < 1e-300) continue;
908 // (a) Mobile SIA cluster → cavity absorption
909 if (n == 0) {
910 for (int m = 1; m < V; ++m)
911 mutual += P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
912 } else if (n < 3 && n < P.i_mobile) {
913 for (int m = 0; m < V; ++m)
914 mutual += std::min(sn, m + 1) * P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
915 } else if (n >= 3 && n < P.i_mobile) {
916 for (int m = 0; m < V; ++m)
917 mutual += std::min(sn, m + 1) * K_1D_eff(P, n, m) * cn * std::max(c_v[m], 0.0);
918 }
919 // (b) Mobile vacancy hitting this SIA cluster
920 for (int mp = 1; mp <= P.v_mobile; ++mp) {
921 const double c_mp = std::max(c_v[mp - 1], 0.0);
922 if (c_mp < 1e-300) continue;
923 double K_s;
924 if (mp == 1 && n > 0) K_s = P.KIV[n];
925 else if (mp == 1 && n == 0) K_s = P.K_iv;
926 else K_s = K_vi_coal(P, sn, mp);
927 mutual += std::min(mp, sn) * K_s * c_mp * cn;
928 }
929 }
930 dydt[P.cons_off + 1] = mutual;
931 }
932
933 // J_VAC_fixed: VAC content lost to fixed sinks
934 {
935 double vac_fixed = 0.0;
936 for (int m = 0; m < std::min(P.v_mobile, P.V); ++m)
937 vac_fixed += static_cast<double>(m + 1) * P.k2_disl_v * std::max(c_v[m], 0.0);
938 // Swept cavities deliver their FULL vacancy content m to the line.
939 // Without this the vacancies removed by Λ_m^void leave the ledger and
940 // δ_FP reports the channel as a conservation violation.
941 if (!P.Lambda_net_void.empty())
942 for (int m = 0; m < P.V; ++m)
943 vac_fixed += static_cast<double>(m + 1) * P.Lambda_net_void[m]
944 * std::max(c_v[m], 0.0);
945 dydt[P.cons_off + 2] = vac_fixed;
946 }
947
948 // J_VAC_mutual: VAC content lost to mutual annihilation.
949 // For both channels (a) and (b), the vacancy content destroyed per
950 // reaction equals min(m', n) — the same as the SIA content destroyed.
951 // When V_{m'} hits I_n with m'>n, the vacancy cluster shrinks to
952 // V_{m'-n}, losing only n vacancies (not m').
953 dydt[P.cons_off + 3] = dydt[P.cons_off + 1]; // J_VAC_mutual = J_SIA_mutual
954
955 // Loop conversion: each ⟨100⟩ shrink (⟨100⟩_n + V_1 → ⟨100⟩_{n-1}) annihilates
956 // one SIA and one vacancy, so it belongs in the mutual-annihilation flux for
957 // both species (keeps δ_FP_sia / δ_FP_vac exact with conversion on).
958 if (P.loop_conversion) {
959 const double* c_i100 = y + P.sia100_off;
960 double s100_mut = 0.0, s100_fixed = 0.0;
961 for (int n = P.conv_n_loop_min; n <= I; ++n) {
962 s100_mut += P.K_100_shrink[n - 1] * std::max(c_i100[n - 1], 0.0) * cv1;
963 // ⟨100⟩ loops swept into the network leave SIA content at a fixed
964 // sink (loop_network_loss.tex, Eq. ledger) — keeps δ_FP exact.
965 s100_fixed += static_cast<double>(n) * P.Lambda_net_100[n - 1]
966 * std::max(c_i100[n - 1], 0.0);
967 }
968 dydt[P.cons_off + 1] += s100_mut; // J_SIA_mutual
969 dydt[P.cons_off + 3] += s100_mut; // J_VAC_mutual
970 dydt[P.cons_off + 0] += s100_fixed; // J_SIA_fixed (network sweep)
971 }
972
973 // J_He_sink: He lost to sinks
974 {
975 double he_sink = P.k2_disl_He * c_h;
976 if (C_vac_tot > 1e-300 && Q_tot > 0.0) {
977 double ell_bar_loc = Q_tot / C_vac_tot;
978 for (int m = 0; m < std::min(P.v_mobile, P.V); ++m) {
979 double ell_m = ell_bar_loc * std::pow(static_cast<double>(m + 1), 2.0/3.0);
980 he_sink += P.k2_disl_v * ell_m * std::max(c_v[m], 0.0);
981 }
982 }
983 dydt[P.cons_off + 4] = he_sink;
984 }
985
986 return 0;
987}
988
989// ── Case 1 — fusion/mean-field (Eq. 174) ─────────────────────────────────────
990//
991// State (dynamic): [c_i(I) | c_v(V) | Q_m(V) | c_h] N_eq = I+2V+1
992// State (quasi_steady_state): [c_i(I) | c_v(V) | Q_m(V)] N_eq = I+2V
993
994static int rhs_case1(sunrealtype /*t*/, N_Vector yv, N_Vector ydotv,
995 const Parameters& P,
996 int x_hi_i_win, int x_hi_v_win) {
997 const double* y = N_VGetArrayPointer_Serial(yv);
998 double* dydt = N_VGetArrayPointer_Serial(ydotv);
999 const int I = P.I;
1000 const int V = P.V;
1001 const bool qss = (P.he_kinetics == 1);
1002
1003 const double* c_i = y;
1004 const double* c_v = y + I;
1005 const double* Q_m = y + I + V;
1006
1007 const double c_h = qss ? c_h_qss_case1(P, c_v, Q_m, V)
1008 : std::max(y[I + 2*V], 0.0);
1009
1010 const double ci1 = std::max(c_i[0], 0.0);
1011 const double cv1 = std::max(c_v[0], 0.0);
1012
1013 for (int k = 0; k < P.N_eq; ++k) dydt[k] = 0.0;
1014
1015 double* dci = dydt;
1016 double* dcv = dydt + I;
1017 double* dQ = dydt + I + V;
1018
1019 // Per-class He loading ell_bar_m = Q_m / c_m (Eq. 174)
1020 auto ell_bar_m = [&](int m_idx) -> double {
1021 const double cm = std::max(c_v[m_idx], 1e-300);
1022 return std::max(Q_m[m_idx], 0.0) / cm;
1023 };
1024
1025 auto GVV_eff_m = [&](int m_idx) -> double {
1026 const int m = m_idx + 1;
1027 const double ell = ell_bar_m(m_idx);
1028 if (ell < 1e-6) return P.GVV[m_idx];
1029 const double ratio = ell / static_cast<double>(m);
1030 if (ratio < 1e-10) return P.GVV[m_idx];
1031 const double dE = P.delta_He * P.beta_He_exp / static_cast<double>(m)
1032 * std::pow(ratio, P.beta_He_exp - 1.0);
1033 return P.GVV[m_idx] * std::exp(std::min(-ell * dE / P.kBT, 0.0));
1034 };
1035
1036 // ── SIA clusters (general coalescence, same structure as Case 2) ─────────
1037 // Loop restricted to [0, x_hi_i_win]; each iteration writes only to dci[n].
1038#ifdef CD_HAVE_OPENMP
1039#pragma omp parallel for schedule(static, 64) if(x_hi_i_win + x_hi_v_win > 500)
1040#endif
1041 for (int n = 0; n <= x_hi_i_win; ++n) {
1042 const int sn = n + 1;
1043 const double cn = std::max(c_i[n], 0.0);
1044 dci[n] += P.Pr_SIA[n];
1045 if (n + 1 < I) dci[n] += P.GII[n+1] * std::max(c_i[n+1], 0.0);
1046 dci[n] -= P.GII[n] * cn;
1047
1048 // i–i coalescence with ALL SIA clusters
1049 for (int np = 1; np <= std::min(sn - 1, P.i_mobile); ++np) {
1050 const int npp = sn - np;
1051 if (npp < 1 || npp > I) continue;
1052 const double c_np = std::max(c_i[np - 1], 0.0);
1053 const double c_npp = std::max(c_i[npp - 1], 0.0);
1054 dci[n] += K_ii_coal(P, npp, np) * c_np * c_npp;
1055 }
1056 // Loss (D_np contribution): any mobile np hits this cluster
1057 for (int np = 1; np <= P.i_mobile; ++np) {
1058 dci[n] -= K_ii_coal(P, sn, np) * cn * std::max(c_i[np - 1], 0.0);
1059 }
1060 // Loss (D_sn contribution): this mobile cluster hits ALL targets
1061 if (sn <= P.i_mobile) {
1062 for (int np = 1; np <= I; ++np) {
1063 dci[n] -= K_ii_coal(P, np, sn) * cn * std::max(c_i[np - 1], 0.0);
1064 }
1065 }
1066
1067 // V–I annihilation: all mobile vacancy clusters m' = 1..v_mobile
1068 if (n == 0) {
1069 // P1 recombination: V_1 + I_1 → nothing
1070 dci[0] -= P.K_iv * cv1 * ci1;
1071 // m'=1 gain: V_1 + I_2 → I_1 (Bug fix — was missing)
1072 if (1 < I)
1073 dci[0] += P.KIV[1] * cv1 * std::max(c_i[1], 0.0);
1074 // m'=2..v_mobile: V_{m'} + I_{1+m'} → I_1
1075 for (int mp = 2; mp <= P.v_mobile; ++mp) {
1076 const double c_mp = std::max(c_v[mp - 1], 0.0);
1077 if (sn + mp - 1 < I) {
1078 // target I_{sn+mp} at 0-index (sn+mp-1) — fixed off-by-one
1079 dci[0] += K_vi_coal(P, sn + mp, mp) * c_mp * std::max(c_i[sn + mp - 1], 0.0);
1080 }
1081 dci[0] -= K_vi_coal(P, sn, mp) * c_mp * ci1;
1082 }
1083 } else {
1084 for (int mp = 1; mp <= P.v_mobile; ++mp) {
1085 const double c_mp = std::max(c_v[mp - 1], 0.0);
1086 double K_s = (mp == 1) ? P.KIV[n] : K_vi_coal(P, sn, mp);
1087 if (n + mp < I) {
1088 double K_g = (mp == 1) ? P.KIV[n + mp] : K_vi_coal(P, sn + mp, mp);
1089 dci[n] += K_g * c_mp * std::max(c_i[n + mp], 0.0);
1090 }
1091 dci[n] -= K_s * c_mp * cn;
1092 }
1093 }
1094
1095 // Cavity absorption
1096 if (n == 0) {
1097 for (int m = 1; m < V; ++m)
1098 dci[0] -= P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
1099 } else if (n < 3 && n < P.i_mobile) {
1100 for (int m = 0; m < V; ++m)
1101 dci[n] -= P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
1102 } else if (n >= 3 && n < P.i_mobile) {
1103 for (int m = 0; m < V; ++m)
1104 dci[n] -= K_1D_eff(P, n, m) * cn * std::max(c_v[m], 0.0);
1105 }
1106 // Partial SIA survival (channel a): I_{n+1} + V_{m+1} → I_{n-m}
1107 if (n >= 1 && n < P.i_mobile) {
1108 for (int m = 0; m < std::min(n, V); ++m) {
1109 const double cvm = std::max(c_v[m], 0.0);
1110 const double K_cav = (n < 3) ? P.K_3D_cav_pref * P.m13[m]
1111 : K_1D_eff(P, n, m);
1112 dci[n - m - 1] += K_cav * cn * cvm;
1113 }
1114 }
1115
1116 dci[n] -= P.k2_SIA[n] * cn;
1117 }
1118
1119
1120 // SIA thermal emission I_{n+1} -> I_n + I_1 moves the cluster down one size above; the emitted monomer
1121 // is returned to I_1 here, as emit_mono does for vacancies. Before v2.1.0 it was omitted, which lost one
1122 // SIA per emission event and was the whole of the Frenkel-pair residual delta_FP (1.2e-2 on im5vm2;
1123 // 2.9e-7 with the monomer returned). sia_emission_monomer = 0 restores the old arithmetic bit for bit.
1124 if (P.sia_emission_monomer) {
1125 double emit_mono_sia = 0.0;
1126 for (int n = 1; n <= x_hi_i_win; ++n)
1127 emit_mono_sia += P.GII[n] * std::max(c_i[n], 0.0);
1128 dci[0] += emit_mono_sia;
1129 }
1130
1131 // Window boundary: suppress SIA coalescence beyond window frontier.
1132 if (x_hi_i_win < I - 1) {
1133 const int wlim = x_hi_i_win + 1;
1134 for (int np = 1; np <= P.i_mobile; ++np) {
1135 const double c_np = std::max(c_i[np - 1], 0.0);
1136 if (c_np < 1e-300) continue;
1137 const int k_lo = std::max(wlim - np + 1, 1);
1138 const int k_hi = std::min(wlim, I - np);
1139 for (int k = k_lo; k <= k_hi; ++k) {
1140 const double ck = std::max(c_i[k - 1], 0.0);
1141 const double rate = K_ii_coal(P, k, np) * c_np * ck;
1142 dci[k - 1] += rate;
1143 dci[np - 1] += rate;
1144 }
1145 }
1146 }
1147
1148 // Reflection boundary: suppress reactions whose product exceeds I
1149 if (P.boundary_flux == 1) {
1150 for (int np = 1; np <= P.i_mobile; ++np) {
1151 const double c_np = std::max(c_i[np - 1], 0.0);
1152 for (int k = std::max(I - np + 1, 1); k <= I; ++k) {
1153 const double rate = K_ii_coal(P, k, np) * c_np * std::max(c_i[k - 1], 0.0);
1154 dci[k - 1] += rate; // undo target loss
1155 dci[np - 1] += rate; // undo projectile depletion
1156 }
1157 }
1158 }
1159
1160 // ── Vacancy clusters (general coalescence) ───────────────────────────────
1161 // Pre-accumulate emitted monomers within active VAC window.
1162 {
1163 double emit_mono = 0.0;
1164 for (int m = 1; m <= x_hi_v_win; ++m)
1165 emit_mono += GVV_eff_m(m) * std::max(c_v[m], 0.0);
1166 dcv[0] += emit_mono;
1167 }
1168 // Loop restricted to [0, x_hi_v_win]; each iteration writes only to dcv[m].
1169#ifdef CD_HAVE_OPENMP
1170#pragma omp parallel for schedule(static, 64) if(x_hi_i_win + x_hi_v_win > 500)
1171#endif
1172 for (int m = 0; m <= x_hi_v_win; ++m) {
1173 const int sm = m + 1;
1174 const double cm = std::max(c_v[m], 0.0);
1175 const double gvv = GVV_eff_m(m);
1176 dcv[m] += P.Pr_VAC[m];
1177 if (m + 1 < V) dcv[m] += GVV_eff_m(m+1) * std::max(c_v[m+1], 0.0);
1178 dcv[m] -= gvv * cm;
1179
1180 // V–V coalescence with ALL vacancy clusters
1181 for (int mp = 1; mp <= std::min(sm - 1, P.v_mobile); ++mp) {
1182 const int mpp = sm - mp;
1183 if (mpp < 1 || mpp > V) continue;
1184 const double c_mp = std::max(c_v[mp - 1], 0.0);
1185 const double c_mpp = std::max(c_v[mpp - 1], 0.0);
1186 dcv[m] += K_vv_coal(P, mpp, mp) * c_mp * c_mpp;
1187 }
1188 // Loss (D_mp contribution): any mobile mp hits this cluster
1189 for (int mp = 1; mp <= P.v_mobile; ++mp) {
1190 dcv[m] -= K_vv_coal(P, sm, mp) * cm * std::max(c_v[mp - 1], 0.0);
1191 }
1192 // Loss (D_sm contribution): this mobile cluster hits ALL targets
1193 if (sm <= P.v_mobile) {
1194 for (int mp = 1; mp <= V; ++mp) {
1195 dcv[m] -= K_vv_coal(P, mp, sm) * cm * std::max(c_v[mp - 1], 0.0);
1196 }
1197 }
1198
1199 // SIA-induced cavity shrinkage
1200 if (m == 0) {
1201 dcv[0] -= P.K_iv * ci1 * cv1;
1202 if (V >= 2) dcv[0] += P.KVI[1] * ci1 * std::max(c_v[1], 0.0);
1203 // Vacancy monomer consumed by SIA loop shrinkage:
1204 // V_1 + I_n → I_{n-1} for n>=2 (n=1 already in K_iv above)
1205 {
1206 double sia_shrink_sink = 0.0;
1207 for (int np = 1; np < I; ++np)
1208 sia_shrink_sink += P.KIV[np] * std::max(c_i[np], 0.0);
1209 dcv[0] -= cv1 * sia_shrink_sink;
1210 }
1211 } else {
1212 dcv[m] -= P.KVI[m] * ci1 * cm;
1213 if (m + 1 < V) dcv[m] += P.KVI[m+1] * ci1 * std::max(c_v[m+1], 0.0);
1214 }
1215 for (int n = 1; n < std::min(3, P.i_mobile); ++n) {
1216 const double cn = std::max(c_i[n], 0.0);
1217 dcv[m] -= P.KVI[m] * cn * cm;
1218 if (m + n + 1 < V)
1219 dcv[m] += P.KVI[m + n + 1] * cn * std::max(c_v[m + n + 1], 0.0);
1220 }
1221 for (int n = 3; n < std::min(I, P.i_mobile); ++n) {
1222 const double cn = std::max(c_i[n], 0.0);
1223 dcv[m] -= K_1D_eff(P, n, m) * cn * cm;
1224 if (m + n + 1 < V) {
1225 const int mp = m + n + 1;
1226 dcv[m] += K_1D_eff(P, n, mp) * cn * std::max(c_v[mp], 0.0);
1227 }
1228 }
1229
1230 // V–I annihilation channel (b): mobile V_{sm} diffuses to SIA.
1231 if (sm >= 2 && sm <= P.v_mobile) {
1232 for (int sn = 1; sn <= I; ++sn) {
1233 const double c_sn = std::max(c_i[sn - 1], 0.0);
1234 dcv[m] -= K_vi_coal(P, sn, sm) * cm * c_sn;
1235 }
1236 }
1237 for (int sn = 1; sm + sn <= P.v_mobile && sn <= I; ++sn) {
1238 if (sm + sn > V) break;
1239 dcv[m] += K_vi_coal(P, sn, sm + sn)
1240 * std::max(c_v[sm + sn - 1], 0.0)
1241 * std::max(c_i[sn - 1], 0.0);
1242 }
1243
1244 // Fixed sinks — only mobile vacancy clusters diffuse to sinks
1245 if (m + 1 <= P.v_mobile)
1246 dcv[m] -= P.k2_disl_v * cm;
1247 // Cavity → network sweeping (VOID_NETWORK_LOSS): a climbing/gliding
1248 // line intersects the cavity and absorbs it whole. Diagonal
1249 // first-order loss; the m vacancies it carries are charged to
1250 // J_VAC_fixed below, so δ_FP is preserved. Λ_m^void is zero for
1251 // m ≤ v_mobile (those already pay k2_disl_v) and zero everywhere when
1252 // the channel is off, making the OFF path byte-identical.
1253 if (!P.Lambda_net_void.empty())
1254 dcv[m] -= P.Lambda_net_void[m] * cm;
1255 }
1256
1257 // Window boundary: suppress VAC coalescence beyond window frontier.
1258 if (x_hi_v_win < V - 1) {
1259 const int wlim_v = x_hi_v_win + 1;
1260 for (int mp = 1; mp <= P.v_mobile; ++mp) {
1261 const double c_mp = std::max(c_v[mp - 1], 0.0);
1262 if (c_mp < 1e-300) continue;
1263 const int k_lo = std::max(wlim_v - mp + 1, 1);
1264 const int k_hi = std::min(wlim_v, V - mp);
1265 for (int k = k_lo; k <= k_hi; ++k) {
1266 const double ck = std::max(c_v[k - 1], 0.0);
1267 const double rate = K_vv_coal(P, k, mp) * c_mp * ck;
1268 dcv[k - 1] += rate;
1269 dcv[mp - 1] += rate;
1270 }
1271 }
1272 }
1273
1274 // Reflection boundary: suppress reactions whose product exceeds V
1275 if (P.boundary_flux == 1) {
1276 for (int mp = 1; mp <= P.v_mobile; ++mp) {
1277 const double c_mp = std::max(c_v[mp - 1], 0.0);
1278 for (int k = std::max(V - mp + 1, 1); k <= V; ++k) {
1279 const double rate = K_vv_coal(P, k, mp) * c_mp * std::max(c_v[k - 1], 0.0);
1280 dcv[k - 1] += rate; // undo target loss
1281 dcv[mp - 1] += rate; // undo projectile depletion
1282 }
1283 }
1284 }
1285
1286 // Same channel, fusion/Case-1 path. rhs_case1 was missing it for the
1287 // same reason rhs_case2 was; Table 3 scores two fusion columns, so a
1288 // discrete fusion run would have carried the identical defect.
1289 // ── ½⟨111⟩ LOOP–LOOP COARSENING (P.loop_coal) — discrete mirror ──────────
1290 // The per-size counterpart of the class-pair sum in rhs_bin_moment (see the
1291 // long note there). It was implemented ONLY in the bin_moment path, while
1292 // the workbook ships LOOP_COAL = 1, so rhs_case2 accepted the flag,
1293 // recorded it in provenance and silently ignored it. A fully discrete run
1294 // was therefore NOT the same equations as its bin_moment counterpart, and
1295 // the 17.6 h Table 4 "exact" arm measured that difference rather than a
1296 // closure error: d_111 frozen at 1.76 nm over a 30x dose range and N_111
1297 // 3.5x the binned value -- precisely the "mean size pins AT the cutoff /
1298 // density runs 20-60x over the data" failure the channel exists to remove.
1299 //
1300 // On a discrete grid there is no reconstruction, so the O(bw^2) blow-up
1301 // that forced the moment formulation upstairs does not arise: each sessile
1302 // SIZE is its own monodisperse class. Only sizes above i_mobile
1303 // participate; mobile ones already coalesce through K_ii_coal. Classes at
1304 // or below C_floor are skipped, which is what keeps the pair sum tractable
1305 // (the populated size range is a small fraction of I).
1306 if (P.loop_coal) {
1307 // PARTICIPATION THRESHOLD. The pair sum is O(NC^2), and NC here counts
1308 // SIZES, not bins: the moment version upstairs has ~20 classes, this has
1309 // up to I. Gating only on C_floor is not enough -- sizes initialised AT
1310 // the floor drift a hair above it and join, trending toward NC = I and
1311 // 8e6 pairs per RHS at I = 4000. Measured: the rebuilt exact arm spent
1312 // 2.6 h on the step at 0.014 dpa where the pre-fix arm covered 0.067 dpa
1313 // in its first 170 min.
1314 //
1315 // The rate is proportional to c_a * c_b, so a class at a fraction eps of
1316 // the peak contributes at most ~eps of the peak pair rate. Dropping
1317 // classes below 1e-8 of the peak is therefore a relative error bounded
1318 // by ~1e-8 on this channel -- four orders below the solver's own
1319 // rtol = 1e-5, and check_bm_vs_discrete.py re-measures the agreement
1320 // against the moment path on every run. The C_floor gate is retained as
1321 // an absolute lower bound for the case where the peak itself is tiny.
1322 // The gate that matters is ABSOLUTE, a small multiple of C_floor. A
1323 // purely relative one does not fire: measured on the killed I = 4000
1324 // arm, peak = 1.131e-09, so 1e-8 x peak = 1.13e-17 sits BELOW C_floor
1325 // and NC stayed at 3904 (7.6e6 pairs) -- which is why the first attempt
1326 // bought no speedup at all. 3795 of those 3904 classes were dust
1327 // hovering between 1.0 and 1.13 x C_floor, i.e. sizes that never left
1328 // their initial value.
1329 //
1330 // gate NC pairs largest excluded / peak
1331 // 1 x Cf 3904 7,622,560 8.8e-07
1332 // 2 x Cf 96 4,656 1.7e-06
1333 // 10 x Cf 83 3,486 7.7e-06
1334 //
1335 // 2 x C_floor takes essentially all the benefit at the smallest error.
1336 // The rate is proportional to c_a * c_b, so a dropped class bounds its
1337 // own contribution by (c_excluded / peak) ~ 1.7e-06 -- below the
1338 // solver's rtol = 1e-5 -- and a dropped PAIR by the square of it. The
1339 // relative term is kept as a guard for a peak so large that C_floor-
1340 // scale classes are irrelevant on their own.
1341 constexpr double LOOP_COAL_ABS = 2.0; // x C_floor
1342 constexpr double LOOP_COAL_REL = 1e-6; // x peak
1343 double cmax = 0.0;
1344 for (int sn = P.i_mobile + 1; sn <= I; ++sn)
1345 cmax = std::max(cmax, std::max(c_i[sn - 1], 0.0));
1346 const double c_min = std::max(LOOP_COAL_ABS * P.C_floor,
1347 LOOP_COAL_REL * cmax);
1348 std::vector<int> idx;
1349 idx.reserve(64);
1350 for (int sn = P.i_mobile + 1; sn <= I; ++sn)
1351 if (std::max(c_i[sn - 1], 0.0) > c_min) idx.push_back(sn);
1352 const int NC = static_cast<int>(idx.size());
1353 for (int a = 0; a < NC; ++a) {
1354 const int na = idx[a];
1355 const double ca = std::max(c_i[na - 1], 0.0);
1356 const double Da = P.D_loop_coal[na - 1];
1357 const double Zt = (!P.Z_i_loop_arr.empty()) ? P.Z_i_loop_arr[na - 1]
1358 : P.Z_i_loop;
1359 for (int b = a; b < NC; ++b) {
1360 const int nb = idx[b];
1361 const double cb = std::max(c_i[nb - 1], 0.0);
1362 const double Db = P.D_loop_coal[nb - 1];
1363 // Both partners glide: the encounter rate carries D_a + D_b,
1364 // unlike K_ii_coal's projectile-only convention.
1365 const double Dsum = Da + Db;
1366 if (Dsum < 1e-300) continue;
1367 const double sf = std::sqrt(static_cast<double>(na))
1368 + std::sqrt(static_cast<double>(nb));
1369 const double K = P.Z_ii * Zt * P.A_loop_inv_O23 * sf * Dsum;
1370 // a == b: 1/2 K c^2, and each event consumes TWO of the class.
1371 const double r = (a == b) ? 0.5 * K * ca * ca : K * ca * cb;
1372 if (r < 1e-300) continue;
1373 if (a == b) dci[na - 1] -= 2.0 * r;
1374 else { dci[na - 1] -= r; dci[nb - 1] -= r; }
1375 // Product beyond the grid PILES AT THE EDGE, matching
1376 // rhs_bin_moment's deposit() ("if (si > I) si = I"). Dropping
1377 // the overflow instead leaves the two paths solving different
1378 // equations at the top of the domain -- measured as a 2.24x
1379 // ratio at the last size while everything below agreed to
1380 // 0.04%. Whether edge-piling or loss is the better convention
1381 // is a separate question; they must agree on it.
1382 const int prod = std::min(na + nb, I);
1383 dci[prod - 1] += r;
1384 }
1385 }
1386 }
1387
1388 // ── Q_m equations (He content per void class, Eq. 174) ───────────────────
1389 // Also restricted to the VAC window; Q_m for out-of-window voids stays zero.
1390 double He_cap_total = 0.0;
1391 double He_emit_total = 0.0;
1392 for (int m = 0; m <= x_hi_v_win; ++m) {
1393 const double cm = std::max(c_v[m], 0.0);
1394 const double qm = std::max(Q_m[m], 0.0);
1395 const double he_cap_m = P.KHeV[m] * c_h * cm;
1396 const double he_emit_m = P.beta_He * qm;
1397 dQ[m] += he_cap_m;
1398 dQ[m] -= he_emit_m;
1399 // He lost when mobile voids are absorbed at fixed sinks
1400 if (m + 1 <= P.v_mobile)
1401 dQ[m] -= P.k2_disl_v * qm;
1402 He_cap_total += he_cap_m;
1403 He_emit_total += he_emit_m;
1404 }
1405
1406 // ── Free He (Eq. 157) — dynamic mode only ────────────────────────────────
1407 if (!qss)
1408 dydt[I + 2*V] = P.G_He - He_cap_total - P.k2_disl_He * c_h + He_emit_total;
1409
1410 // ── Conservation accounting ODEs ─────────────────────────────────────────
1411 // J_SIA_fixed: SIA content lost to fixed sinks
1412 {
1413 double sia_fixed = 0.0;
1414 for (int n = 0; n < I; ++n)
1415 sia_fixed += static_cast<double>(n + 1) * P.k2_SIA[n] * std::max(c_i[n], 0.0);
1416 dydt[P.cons_off + 0] = sia_fixed;
1417 }
1418
1419 // J_SIA_mutual: ALL SIA content lost to SIA-vacancy annihilation.
1420 // Two channels: (a) mobile SIA hitting voids, (b) mobile vacancies hitting SIA.
1421 // Weight = min(sn, m+1): when I_n hits V_m with n > m, only m defects
1422 // are annihilated; the remainder forms a smaller SIA cluster.
1423 {
1424 double mutual = 0.0;
1425 for (int n = 0; n < I; ++n) {
1426 const int sn = n + 1;
1427 const double cn = std::max(c_i[n], 0.0);
1428 if (cn < 1e-300) continue;
1429 // (a) Mobile SIA cluster → cavity absorption
1430 if (n == 0) {
1431 for (int m = 1; m < V; ++m)
1432 mutual += P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
1433 } else if (n < 3 && n < P.i_mobile) {
1434 for (int m = 0; m < V; ++m)
1435 mutual += std::min(sn, m + 1) * P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
1436 } else if (n >= 3 && n < P.i_mobile) {
1437 for (int m = 0; m < V; ++m)
1438 mutual += std::min(sn, m + 1) * K_1D_eff(P, n, m) * cn * std::max(c_v[m], 0.0);
1439 }
1440 // (b) Mobile vacancy hitting this SIA cluster
1441 for (int mp = 1; mp <= P.v_mobile; ++mp) {
1442 const double c_mp = std::max(c_v[mp - 1], 0.0);
1443 if (c_mp < 1e-300) continue;
1444 double K_s;
1445 if (mp == 1 && n > 0) K_s = P.KIV[n];
1446 else if (mp == 1 && n == 0) K_s = P.K_iv;
1447 else K_s = K_vi_coal(P, sn, mp);
1448 mutual += std::min(mp, sn) * K_s * c_mp * cn;
1449 }
1450 }
1451 dydt[P.cons_off + 1] = mutual;
1452 }
1453
1454 // J_VAC_fixed: VAC content lost to fixed sinks
1455 {
1456 double vac_fixed = 0.0;
1457 for (int m = 0; m < std::min(P.v_mobile, P.V); ++m)
1458 vac_fixed += static_cast<double>(m + 1) * P.k2_disl_v * std::max(c_v[m], 0.0);
1459 // Swept cavities deliver their FULL vacancy content m to the line.
1460 // Without this the vacancies removed by Λ_m^void leave the ledger and
1461 // δ_FP reports the channel as a conservation violation.
1462 if (!P.Lambda_net_void.empty())
1463 for (int m = 0; m < P.V; ++m)
1464 vac_fixed += static_cast<double>(m + 1) * P.Lambda_net_void[m]
1465 * std::max(c_v[m], 0.0);
1466 dydt[P.cons_off + 2] = vac_fixed;
1467 }
1468
1469 // J_VAC_mutual: VAC content lost to mutual annihilation.
1470 // Vacancy content destroyed per reaction = min(m', n) = SIA content destroyed.
1471 dydt[P.cons_off + 3] = dydt[P.cons_off + 1]; // J_VAC_mutual = J_SIA_mutual
1472
1473 // J_He_sink: He lost to sinks. Must mirror the EXACT state losses so
1474 // the conservation identity c_h + ΣQ_m + J_He_sink = ∫G_He holds to
1475 // solver tolerance: free He at fixed sinks (k2_disl_He·c_h) plus the
1476 // −k2_disl_v·Q_m terms applied to mobile classes in dQ above. (The
1477 // previous ℓ̄·m^{2/3} allocation formula was the Case-2 expression and
1478 // does not equal the per-class Q_m actually removed from the state.)
1479 {
1480 double he_sink = P.k2_disl_He * c_h;
1481 for (int m = 0; m < std::min(P.v_mobile, P.V); ++m)
1482 he_sink += P.k2_disl_v * std::max(Q_m[m], 0.0);
1483 dydt[P.cons_off + 4] = he_sink;
1484 }
1485
1486 return 0;
1487}
1488
1489// ── Dispatch ─────────────────────────────────────────────────────────────────
1490
1491int rhs_full_CD(sunrealtype t, N_Vector y, N_Vector ydot, void* user_data) {
1492 const UserData* ud = static_cast<const UserData*>(user_data);
1493 const Parameters& P = *ud->P;
1494
1495 // Resolve window bounds:
1496 // window_mode == 0 (full_system): entire SIA / VAC domains are active.
1497 // window_mode == 4 (active_window): two independent windows + OpenMP.
1498 // Thread count is auto-picked by N_eq in solver.cpp; if OpenMP is
1499 // unavailable or only 1 thread is selected, the same code path runs
1500 // serial transparently.
1501 // OpenMP is safe for all modes: each loop iteration writes only to its
1502 // own dci[n] or dcv[m], so there are no data races.
1503 const int x_hi_i = ud->window_active ? ud->x_hi_i : P.I - 1;
1504 const int x_hi_v = ud->window_active ? ud->x_hi_v : P.V - 1;
1505
1506 if (P.he_mode == 1)
1507 return rhs_case1(t, y, ydot, P, x_hi_i, x_hi_v);
1508 else
1509 return rhs_case2(t, y, ydot, P, x_hi_i, x_hi_v);
1510}
1511
1512// ── Size-bin moment RHS (Chapter 9, Eqs. 193-208) ────────────────────────────
1513
1514int rhs_bin_moment(sunrealtype t, N_Vector yv, N_Vector ydotv, void* user_data) {
1515 const UserData* ud = static_cast<const UserData*>(user_data);
1516 const Parameters& P = *ud->P;
1517 // Non-const view of the SAME Parameters, used only to reach the persistent
1518 // bm_* scratch buffers (UserData::P is already non-const — the
1519 // preconditioner writes its own storage through it). The physics reads
1520 // stay on the const reference P.
1521 Parameters& Pm = *ud->P;
1522
1523 const double* y = N_VGetArrayPointer_Serial(yv);
1524 double* dydt = N_VGetArrayPointer_Serial(ydotv);
1525
1526 const int Ib = P.I_bin;
1527 const int V = P.V;
1528 const int I = P.I;
1529 const bool qss = (P.he_kinetics == 1);
1530
1531 for (int k = 0; k < P.N_eq; ++k) dydt[k] = 0.0;
1532
1533 // ── Shape function and moment stride ────────────────────────────────────
1534 const int PM = P.n_mom; // moments per bin: 1 (constant), 2 (linear), 3 (lognormal)
1535
1536 // ── Hybrid discrete + binned SIA reconstruction ────────────────────────
1537 const int i_d = P.i_discrete;
1538 const int v_d = P.v_discrete;
1539 const int i_VAC = i_d + PM * Ib; // first vacancy index in state vector
1540
1541 // SIA bin edges (bins cover sizes i_discrete+1 .. I). Consumed directly
1542 // from the explicit edge arrays transmitted by Python — NOT re-derived
1543 // from r_ratio (numpy.floor vs std::floor can diverge over many bins).
1544 // Read straight out of Parameters: the previous per-call copy into local
1545 // vectors allocated on every RHS evaluation for no benefit.
1546 const int* const n_lo = P.sia_bin_lo.data();
1547 const int* const n_hi = P.sia_bin_hi.data();
1548
1549 // Smooth positive-projection for reconstructed concentrations.
1550 // Hard max(x, 0) creates a Jacobian kink at x=0 that stalls BDF.
1551 // softplus(x) = x for x >> eps, ≈ eps·exp(x/eps) for x << -eps.
1552 auto softplus = [](double x) -> double {
1553 constexpr double eps = 1e-30; // transition width
1554 if (x > 20.0 * eps) return x; // fast path: x is safely positive
1555 if (x < -20.0 * eps) return eps * std::exp(x / eps); // exponentially small
1556 return eps * std::log1p(std::exp(x / eps)); // smooth transition
1557 };
1558
1559 // Reconstruct full c_n[0..I-1] from discrete + binned.
1560 // Persistent scratch (Parameters::bm_c_n) rather than a fresh heap vector
1561 // per call — at I = 1e5 the old std::vector<double> c_n(I, 0.0) cost an
1562 // 800 kB malloc/zero/free on every one of the ~29 RHS evaluations the
1563 // Woodbury preconditioner makes per Jacobian rebuild.
1564 Pm.bm_c_n.assign(I, 0.0);
1565 double* const c_n = Pm.bm_c_n.data();
1566 // Discrete sizes: y[0..i_d-1] = c_1..c_{i_discrete}
1567 for (int n = 0; n < i_d; ++n)
1568 c_n[n] = std::max(y[n], 0.0);
1569 // Binned sizes: closure from moments (shape_function selects method)
1570 for (int k = 0; k < Ib; ++k) {
1571 const double mu0_k = std::max(y[i_d + PM*k], 0.0);
1572 double mu1_k = (PM >= 2) ? y[i_d + PM*k + 1] : 0.0;
1573 double mu2_k = (PM >= 3) ? y[i_d + PM*k + 2] : 0.0;
1574 const double bw = static_cast<double>(n_hi[k] - n_lo[k]);
1575 if (bw <= 0 || mu0_k <= 0.0) continue;
1576 if (bw == 1.0) {
1577 int ni = n_lo[k] - 1;
1578 if (ni >= 0 && ni < I) c_n[ni] = mu0_k;
1579 continue;
1580 }
1581
1582 // ── Moment consistency guard ────────────────────────────────────
1583 // Ensure n_bar = mu1/mu0 lies within [n_lo, n_hi). When the
1584 // distribution front enters a bin, mu0 grows from the floor while
1585 // mu1 hasn't caught up, giving n_bar outside the bin range.
1586 // The linear reconstruction then goes negative, creating a
1587 // Jacobian kink that stalls BDF. Clamping n_bar to the bin
1588 // range prevents this while preserving smoothness.
1589 {
1590 const double n_lo_f = static_cast<double>(n_lo[k]);
1591 const double n_hi_f = static_cast<double>(n_hi[k] - 1);
1592 const double n_mid = 0.5 * (n_lo_f + n_hi_f);
1593 double n_bar = mu1_k / std::max(mu0_k, 1e-300);
1594 if (n_bar < n_lo_f) mu1_k = mu0_k * n_lo_f;
1595 else if (n_bar > n_hi_f) mu1_k = mu0_k * n_hi_f;
1596 // Also guard mu2 for lognormal: ensure ratio > 1
1597 if (PM >= 3) {
1598 double n2_min = mu1_k * mu1_k / std::max(mu0_k, 1e-300) * 1.01;
1599 if (mu2_k < n2_min) mu2_k = n2_min;
1600 }
1601 }
1602
1603 // Select reconstruction method
1604 bool use_lognormal = false;
1605 if (P.shape_function == 2 && PM >= 3) {
1606 const double n_bar = mu1_k / std::max(mu0_k, 1e-300);
1607 const double n2_bar = mu2_k / std::max(mu0_k, 1e-300);
1608 const double ratio = n2_bar / std::max(n_bar * n_bar, 1e-300);
1609 if (ratio > 1.5) {
1610 use_lognormal = true;
1611 const double sig2 = std::log(ratio);
1612 const double m_k = std::log(std::max(n_bar, 1e-300)) - 0.5 * sig2;
1613 double f_sum = 0.0;
1614 double max_log_f = -1e300;
1615 std::vector<double> log_f_arr(static_cast<int>(bw));
1616 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
1617 double nf = static_cast<double>(n);
1618 double ln_n = std::log(nf);
1619 double lf = std::max(-(ln_n - m_k) * (ln_n - m_k) / (2.0 * sig2), -500.0) - ln_n;
1620 log_f_arr[n - n_lo[k]] = lf;
1621 if (lf > max_log_f) max_log_f = lf;
1622 }
1623 for (int j = 0; j < static_cast<int>(bw); ++j) {
1624 log_f_arr[j] -= max_log_f;
1625 f_sum += std::exp(log_f_arr[j]);
1626 }
1627 if (f_sum < 1e-300) {
1628 use_lognormal = false; // degenerate — fall back
1629 } else {
1630 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
1631 if (n - 1 >= 0 && n - 1 < I)
1632 c_n[n - 1] = std::max(
1633 mu0_k * std::exp(log_f_arr[n - n_lo[k]]) / f_sum, 0.0);
1634 }
1635 }
1636 }
1637 }
1638
1639 if (!use_lognormal) {
1640 if (P.shape_function == 0) {
1641 // Piecewise-constant
1642 const double val = mu0_k / bw;
1643 for (int n = n_lo[k]; n < n_hi[k]; ++n)
1644 if (n - 1 >= 0 && n - 1 < I) c_n[n - 1] = val;
1645 } else {
1646 // Linear (hat-function / dual-basis) — default and fallback.
1647 // Σn and Σn² depend only on the bin edges: taken from the
1648 // startup table (same ascending accumulation order, so the
1649 // sums are identical) instead of an O(bw) sweep per call.
1650 double S1, S2;
1651 if (!P.bin_S1.empty()) {
1652 S1 = P.bin_S1[k]; S2 = P.bin_S2[k];
1653 } else {
1654 S1 = 0.0; S2 = 0.0;
1655 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
1656 double nf = static_cast<double>(n);
1657 S1 += nf; S2 += nf * nf;
1658 }
1659 }
1660 double det = bw * S2 - S1 * S1;
1661 if (std::abs(det) < 1e-30) {
1662 double val = mu0_k / bw;
1663 for (int n = n_lo[k]; n < n_hi[k]; ++n)
1664 if (n - 1 >= 0 && n - 1 < I) c_n[n - 1] = val;
1665 } else {
1666 // Pure scatter-free write of c_n[n-1]; no cross-iteration
1667 // dependence and no reduction, so the parallel result is
1668 // bit-identical to the serial one.
1669#ifdef CD_HAVE_OPENMP
1670#pragma omp parallel for schedule(static, 256) if(n_hi[k] - n_lo[k] > 2048)
1671#endif
1672 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
1673 if (n - 1 < 0 || n - 1 >= I) continue;
1674 double nf = static_cast<double>(n);
1675 double phi0 = (S2 - S1 * nf) / det;
1676 double phi1 = (bw * nf - S1) / det;
1677 c_n[n - 1] = softplus(phi0 * mu0_k + phi1 * mu1_k);
1678 }
1679 }
1680 }
1681 }
1682 }
1683
1684 // ── Hybrid discrete + binned vacancy reconstruction ──────────────────
1685 const int Kv = P.V_bin;
1686
1687 // Vacancy bin edges (bins cover sizes v_discrete+1 .. V). Consumed
1688 // directly from the explicit edge arrays transmitted by Python.
1689 const int* const m_lo = P.vac_bin_lo.data();
1690 const int* const m_hi = P.vac_bin_hi.data();
1691
1692 // Reconstruct full c_v[0..V-1] from discrete + binned (persistent scratch)
1693 Pm.bm_c_v.assign(V, 0.0);
1694 double* const c_v_vec = Pm.bm_c_v.data();
1695 // Discrete sizes: y[i_VAC..i_VAC+v_d-1] = c_1..c_{v_discrete}
1696 for (int m = 0; m < v_d; ++m)
1697 c_v_vec[m] = std::max(y[i_VAC + m], 0.0);
1698 // Binned sizes: closure from (mu0, mu1[, mu2]) using shape_function
1699 if (Kv > 0) {
1700 const int vac_mom_start = i_VAC + v_d;
1701 for (int k = 0; k < Kv; ++k) {
1702 const double mu0_k = std::max(y[vac_mom_start + PM*k], 0.0);
1703 double mu1_k = (PM >= 2) ? y[vac_mom_start + PM*k + 1] : 0.0;
1704 double mu2_k = (PM >= 3) ? y[vac_mom_start + PM*k + 2] : 0.0;
1705 const double bw = static_cast<double>(m_hi[k] - m_lo[k]);
1706 if (bw <= 0 || mu0_k <= 0.0) continue;
1707 if (bw == 1.0) {
1708 int mi = m_lo[k] - 1;
1709 if (mi >= 0 && mi < V) c_v_vec[mi] = mu0_k;
1710 continue;
1711 }
1712
1713 // ── Moment consistency guard (same as SIA) ──────────────────
1714 {
1715 const double m_lo_f = static_cast<double>(m_lo[k]);
1716 const double m_hi_f = static_cast<double>(m_hi[k] - 1);
1717 double n_bar = mu1_k / std::max(mu0_k, 1e-300);
1718 if (n_bar < m_lo_f) mu1_k = mu0_k * m_lo_f;
1719 else if (n_bar > m_hi_f) mu1_k = mu0_k * m_hi_f;
1720 if (PM >= 3) {
1721 double n2_min = mu1_k * mu1_k / std::max(mu0_k, 1e-300) * 1.01;
1722 if (mu2_k < n2_min) mu2_k = n2_min;
1723 }
1724 }
1725
1726 // Select reconstruction method
1727 bool use_lognormal_v = false;
1728 if (P.shape_function == 2 && PM >= 3) {
1729 const double n_bar = mu1_k / std::max(mu0_k, 1e-300);
1730 const double n2_bar = mu2_k / std::max(mu0_k, 1e-300);
1731 const double ratio = n2_bar / std::max(n_bar * n_bar, 1e-300);
1732 if (ratio > 1.5) {
1733 use_lognormal_v = true;
1734 const double sig2 = std::log(ratio);
1735 const double m_k = std::log(std::max(n_bar, 1e-300)) - 0.5 * sig2;
1736 double f_sum = 0.0;
1737 double max_log_f = -1e300;
1738 std::vector<double> log_f_arr(static_cast<int>(bw));
1739 for (int m = m_lo[k]; m < m_hi[k]; ++m) {
1740 double mf = static_cast<double>(m);
1741 double ln_m = std::log(mf);
1742 double lf = std::max(-(ln_m - m_k) * (ln_m - m_k) / (2.0 * sig2), -500.0) - ln_m;
1743 log_f_arr[m - m_lo[k]] = lf;
1744 if (lf > max_log_f) max_log_f = lf;
1745 }
1746 for (int j = 0; j < static_cast<int>(bw); ++j) {
1747 log_f_arr[j] -= max_log_f;
1748 f_sum += std::exp(log_f_arr[j]);
1749 }
1750 if (f_sum < 1e-300) {
1751 use_lognormal_v = false;
1752 } else {
1753 for (int m = m_lo[k]; m < m_hi[k]; ++m) {
1754 if (m - 1 >= 0 && m - 1 < V)
1755 c_v_vec[m - 1] = std::max(
1756 mu0_k * std::exp(log_f_arr[m - m_lo[k]]) / f_sum, 0.0);
1757 }
1758 }
1759 }
1760 }
1761
1762 if (!use_lognormal_v) {
1763 if (P.shape_function == 0) {
1764 // Piecewise-constant
1765 double val = mu0_k / bw;
1766 for (int m = m_lo[k]; m < m_hi[k]; ++m)
1767 if (m - 1 >= 0 && m - 1 < V) c_v_vec[m - 1] = std::max(val, 0.0);
1768 } else {
1769 // Linear (hat-function) — default and fallback.
1770 // Σm / Σm² from the startup table (see the SIA side).
1771 double S1, S2;
1772 if (!P.vbin_S1.empty()) {
1773 S1 = P.vbin_S1[k]; S2 = P.vbin_S2[k];
1774 } else {
1775 S1 = 0.0; S2 = 0.0;
1776 for (int m = m_lo[k]; m < m_hi[k]; ++m) {
1777 double mf = static_cast<double>(m);
1778 S1 += mf; S2 += mf * mf;
1779 }
1780 }
1781 double det = bw * S2 - S1 * S1;
1782 if (std::abs(det) < 1e-30) {
1783 double val = mu0_k / bw;
1784 for (int m = m_lo[k]; m < m_hi[k]; ++m)
1785 if (m - 1 >= 0 && m - 1 < V) c_v_vec[m - 1] = std::max(val, 0.0);
1786 } else {
1787 // Scatter-free; parallel result is bit-identical.
1788#ifdef CD_HAVE_OPENMP
1789#pragma omp parallel for schedule(static, 256) if(m_hi[k] - m_lo[k] > 2048)
1790#endif
1791 for (int m = m_lo[k]; m < m_hi[k]; ++m) {
1792 if (m - 1 < 0 || m - 1 >= V) continue;
1793 double mf = static_cast<double>(m);
1794 double phi0 = (S2 - S1 * mf) / det;
1795 double phi1 = (bw * mf - S1) / det;
1796 c_v_vec[m - 1] = softplus(phi0 * mu0_k + phi1 * mu1_k);
1797 }
1798 }
1799 }
1800 }
1801 }
1802 }
1803 const double* const c_v = c_v_vec;
1804
1805 // He state
1806 int i_He_idx = -1;
1807 double Q_tot = 0.0;
1808 const int i_Q_base = i_VAC + v_d + PM * Kv; // after discrete VAC + binned VAC moments
1809
1810 // Q length: per-bin (Kv) when vacancies are binned, else per-size (V).
1811 const int n_Q_case1 = (Kv > 0) ? Kv : V;
1812 if (P.he_mode == 1) {
1813 for (int k = 0; k < n_Q_case1; ++k) Q_tot += std::max(y[i_Q_base + k], 0.0);
1814 i_He_idx = qss ? -1 : i_Q_base + n_Q_case1;
1815 } else {
1816 Q_tot = std::max(y[i_Q_base], 0.0);
1817 i_He_idx = qss ? -1 : i_Q_base + 1;
1818 }
1819
1820 // QSS free He. For bin-moment Case 1 the capture sink must be restricted
1821 // to the BINNED void classes only: He is captured solely by binned voids
1822 // (discrete small voids have no per-bin He slot), so summing KHeV over all
1823 // sizes — as the generic helper does — would compute c_h against more
1824 // capture than the Q update actually performs, breaking He conservation.
1825 // (Case 2 and the Kv==0 per-size Case 1 both capture over all sizes, so
1826 // the generic full-range helper is correct there.)
1827 double c_h;
1828 if (!qss) {
1829 c_h = std::max(y[i_He_idx], 0.0);
1830 } else if (P.he_mode == 1 && Kv > 0) {
1831 double cap_sink = 0.0;
1832 for (int k = 0; k < Kv; ++k)
1833 for (int m = m_lo[k]; m < m_hi[k]; ++m) {
1834 const int mi = m - 1;
1835 if (mi >= 0 && mi < V)
1836 cap_sink += P.KHeV[mi] * std::max(c_v[mi], 0.0);
1837 }
1838 const double sink = P.k2_disl_He + cap_sink;
1839 const double source = P.G_He + P.beta_He * Q_tot;
1840 c_h = source / (sink > 1e-300 ? sink : 1e-300);
1841 } else if (P.he_mode == 1) {
1842 c_h = c_h_qss_case1(P, c_v, y + i_Q_base, n_Q_case1);
1843 } else {
1844 c_h = c_h_qss_case2(P, c_v, Q_tot);
1845 }
1846
1847 const double ci1 = (I <= 0) ? 0.0 : std::max(c_n[0], 0.0);
1848 const double cv1 = std::max(c_v[0], 0.0);
1849
1850 // dc_n/dt per-size
1851 // IMPORTANT: For bin-moment mode, general SIA-SIA coalescence is
1852 // restricted to DISCRETE sizes only (n, n' <= i_discrete). Binned
1853 // sizes use piecewise-constant reconstruction, which amplifies
1854 // coalescence rates by O(bw^2) in wide bins. Monomer-driven growth
1855 // (monomer absorbed by any cluster) is still computed for all sizes.
1856 Pm.bm_dc_n.assign(I, 0.0);
1857 double* const dc_n = Pm.bm_dc_n.data();
1858
1859 // The sweep is split in two at n_head so the O(I) bulk can go parallel.
1860 //
1861 // HEAD n < n_head — the full original body. Every branch that reads or
1862 // writes an index OTHER than n is confined here: the discrete–
1863 // discrete coalescence block (gated sn <= i_discrete), the n == 0
1864 // monomer-depletion and recombination special cases, the cavity
1865 // absorption branches (n < i_mobile), and the partial-survival
1866 // channel that scatters into dc_n[n-m-1].
1867 // TAIL n >= n_head — only the size-independent channels survive, and
1868 // they touch dc_n[n] alone. No scatter, no reduction, so running
1869 // it across threads gives bit-identical results.
1870 //
1871 // n_head therefore has to clear BOTH gates (i_discrete for the coalescence
1872 // block, i_mobile for the cavity/survival branches). The tail body below
1873 // repeats the surviving terms in their original order — keep the two in
1874 // step if this loop is ever edited.
1875 // Floor of 1 so the tail's unconditional c_n[n-1] read stays in bounds
1876 // even in the degenerate i_discrete = i_mobile = 0 configuration.
1877 const int n_head = std::min(std::max(std::max(i_d, P.i_mobile), 1), I);
1878
1879 for (int n = 0; n < n_head; ++n) {
1880 const int sn = n + 1;
1881 const double cn = std::max(c_n[n], 0.0);
1882 dc_n[n] += P.Pr_SIA[n];
1883 if (n + 1 < I) dc_n[n] += P.GII[n+1] * std::max(c_n[n+1], 0.0);
1884 dc_n[n] -= P.GII[n] * cn;
1885
1886 // i–i coalescence: discrete–discrete (n, n' <= i_discrete, np >= 2)
1887 // Monomer growth (np=1) is handled separately below for ALL sizes.
1888 // Discrete–binned coalescence handled in a separate block after this loop.
1889 if (sn <= i_d) {
1890 for (int np = 2; np <= std::min(sn - 1, i_d); ++np) {
1891 const int npp = sn - np;
1892 if (npp < 1 || npp > i_d) continue;
1893 const double c_np = std::max(c_n[np - 1], 0.0);
1894 const double c_npp = std::max(c_n[npp - 1], 0.0);
1895 dc_n[n] += K_ii_coal(P, npp, np) * c_np * c_npp;
1896 }
1897 // Loss (D_np contribution): mobile discrete np>=2 hits this cluster
1898 for (int np = 2; np <= std::min(i_d, P.i_mobile); ++np) {
1899 dc_n[n] -= K_ii_coal(P, sn, np) * cn * std::max(c_n[np - 1], 0.0);
1900 }
1901 // Loss (D_sn contribution): this mobile cluster hits ALL discrete targets
1902 // Completes the full rate K_full = K(sn,np;D_np) + K(np,sn;D_sn)
1903 // for discrete–discrete pairs. Binned targets handled separately below.
1904 if (sn >= 2 && sn <= P.i_mobile) {
1905 for (int np = 1; np <= i_d; ++np) {
1906 dc_n[n] -= K_ii_coal(P, np, sn) * cn * std::max(c_n[np - 1], 0.0);
1907 }
1908 }
1909 }
1910
1911 // Monomer growth: I_1 + I_n → I_{n+1} (all sizes, including binned)
1912 // Gain from I_1 + I_{n-1} → I_n
1913 if (n > 0)
1914 dc_n[n] += K_ii_coal(P, n, 1) * ci1 * std::max(c_n[n - 1], 0.0);
1915 // Loss: I_n + I_1 → I_{n+1}
1916 dc_n[n] -= K_ii_coal(P, sn, 1) * cn * ci1;
1917 // Monomer projectile depletion (n=0 only): I_1 consumed by all clusters
1918 if (n == 0) {
1919 double mono_sink = 0.0;
1920 for (int np = 1; np <= I; ++np)
1921 mono_sink += K_ii_coal(P, np, 1) * std::max(c_n[np - 1], 0.0);
1922 dc_n[0] -= ci1 * mono_sink;
1923 }
1924
1925 // V–I annihilation: all mobile vacancy clusters m' = 1..v_mobile
1926 if (n == 0) {
1927 // P1 recombination: V_1 + I_1 → nothing
1928 dc_n[0] -= P.K_iv * cv1 * ci1;
1929 // m'=1 gain: V_1 + I_2 → I_1 (Bug fix — was missing)
1930 if (1 < I)
1931 dc_n[0] += P.KIV[1] * cv1 * std::max(c_n[1], 0.0);
1932 // m'=2..v_mobile: V_{m'} + I_{1+m'} → I_1
1933 for (int mp = 2; mp <= P.v_mobile; ++mp) {
1934 const double c_mp = std::max(c_v[mp - 1], 0.0);
1935 if (sn + mp - 1 < I) {
1936 // target I_{sn+mp} at 0-index (sn+mp-1) — fixed off-by-one
1937 dc_n[0] += K_vi_coal(P, sn + mp, mp) * c_mp * std::max(c_n[sn + mp - 1], 0.0);
1938 }
1939 dc_n[0] -= K_vi_coal(P, sn, mp) * c_mp * ci1;
1940 }
1941 } else {
1942 for (int mp = 1; mp <= P.v_mobile; ++mp) {
1943 const double c_mp = std::max(c_v[mp - 1], 0.0);
1944 double K_s = (mp == 1) ? P.KIV[n] : K_vi_coal(P, sn, mp);
1945 if (n + mp < I) {
1946 double K_g = (mp == 1) ? P.KIV[n + mp] : K_vi_coal(P, sn + mp, mp);
1947 dc_n[n] += K_g * c_mp * std::max(c_n[n + mp], 0.0);
1948 }
1949 dc_n[n] -= K_s * c_mp * cn;
1950 }
1951 }
1952
1953 if (n == 0) {
1954 for (int m = 1; m < V; ++m)
1955 dc_n[0] -= P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
1956 } else if (n < 3 && n < P.i_mobile) {
1957 for (int m = 0; m < V; ++m)
1958 dc_n[n] -= P.K_3D_cav_pref * P.m13[m] * cn * std::max(c_v[m], 0.0);
1959 } else if (n >= 3 && n < P.i_mobile) {
1960 for (int m = 0; m < V; ++m)
1961 dc_n[n] -= K_1D_eff(P, n, m) * cn * std::max(c_v[m], 0.0);
1962 }
1963 // Partial SIA survival (channel a): I_{n+1} + V_{m+1} → I_{n-m}
1964 if (n >= 1 && n < P.i_mobile) {
1965 for (int m = 0; m < std::min(n, V); ++m) {
1966 const double cvm = std::max(c_v[m], 0.0);
1967 const double K_cav = (n < 3) ? P.K_3D_cav_pref * P.m13[m]
1968 : K_1D_eff(P, n, m);
1969 dc_n[n - m - 1] += K_cav * cn * cvm;
1970 }
1971 }
1972
1973 dc_n[n] -= P.k2_SIA[n] * cn;
1974 }
1975
1976 // ── TAIL: n >= n_head (the O(I) bulk) ─────────────────────────────────
1977 // Same terms, same order, as the head body with every n-gated branch
1978 // removed by inspection:
1979 // sn <= i_discrete false (n >= i_d) → coalescence block out
1980 // n == 0 false → monomer sink,
1981 // recombination out
1982 // n < i_mobile false (n >= i_mobile) → cavity absorption
1983 // and partial
1984 // survival out
1985 // What remains writes dc_n[n] and nothing else.
1986#ifdef CD_HAVE_OPENMP
1987#pragma omp parallel for schedule(static, 256) if(I - n_head > 2048)
1988#endif
1989 for (int n = n_head; n < I; ++n) {
1990 const int sn = n + 1;
1991 const double cn = std::max(c_n[n], 0.0);
1992 dc_n[n] += P.Pr_SIA[n];
1993 if (n + 1 < I) dc_n[n] += P.GII[n+1] * std::max(c_n[n+1], 0.0);
1994 dc_n[n] -= P.GII[n] * cn;
1995
1996 // Monomer growth: I_1 + I_n → I_{n+1} (all sizes, including binned)
1997 dc_n[n] += K_ii_coal(P, n, 1) * ci1 * std::max(c_n[n - 1], 0.0);
1998 dc_n[n] -= K_ii_coal(P, sn, 1) * cn * ci1;
1999
2000 // V–I annihilation: all mobile vacancy clusters m' = 1..v_mobile
2001 for (int mp = 1; mp <= P.v_mobile; ++mp) {
2002 const double c_mp = std::max(c_v[mp - 1], 0.0);
2003 double K_s = (mp == 1) ? P.KIV[n] : K_vi_coal(P, sn, mp);
2004 if (n + mp < I) {
2005 double K_g = (mp == 1) ? P.KIV[n + mp] : K_vi_coal(P, sn + mp, mp);
2006 dc_n[n] += K_g * c_mp * std::max(c_n[n + mp], 0.0);
2007 }
2008 dc_n[n] -= K_s * c_mp * cn;
2009 }
2010
2011 dc_n[n] -= P.k2_SIA[n] * cn;
2012 }
2013
2014
2015 // SIA thermal emission I_{n+1} -> I_n + I_1 moves the cluster down one size above; the emitted monomer
2016 // is returned to I_1 here, as emit_mono does for vacancies. Before v2.1.0 it was omitted, which lost one
2017 // SIA per emission event and was the whole of the Frenkel-pair residual delta_FP (1.2e-2 on im5vm2;
2018 // 2.9e-7 with the monomer returned). sia_emission_monomer = 0 restores the old arithmetic bit for bit.
2019 if (P.sia_emission_monomer) {
2020 double emit_mono_sia = 0.0;
2021 for (int n = 1; n <= I - 1; ++n)
2022 emit_mono_sia += P.GII[n] * std::max(c_n[n], 0.0);
2023 dc_n[0] += emit_mono_sia;
2024 }
2025
2026 // Reflection boundary: suppress overflow reactions whose product exceeds I
2027 if (P.boundary_flux == 1) {
2028 // Monomer overflow: I_1 + I_I → I_{I+1}
2029 const double rate_i = P.KII[I - 1] * ci1 * std::max(c_n[I - 1], 0.0);
2030 dc_n[I - 1] += rate_i; // undo target loss
2031 dc_n[0] += rate_i; // undo monomer depletion
2032 // Mobile cluster overflow: I_sn + I_np → I_{sn+np} where sn+np > I
2033 // Undo both D_np and D_sn loss contributions for discrete–discrete pairs
2034 for (int sn = 2; sn <= std::min(i_d, P.i_mobile); ++sn) {
2035 const double c_sn = std::max(c_n[sn - 1], 0.0);
2036 if (c_sn < 1e-300) continue;
2037 for (int np = std::max(I - sn + 1, 1); np <= i_d; ++np) {
2038 const double c_np = std::max(c_n[np - 1], 0.0);
2039 if (c_np < 1e-300) continue;
2040 // Undo D_np loss (np hits sn): applied in D_np loop
2041 if (np >= 2 && np <= P.i_mobile) {
2042 const double rate_np = K_ii_coal(P, sn, np) * c_sn * c_np;
2043 dc_n[sn - 1] += rate_np;
2044 }
2045 // Undo D_sn loss (sn hits np): applied in new D_sn loop
2046 const double rate_sn = K_ii_coal(P, np, sn) * c_sn * c_np;
2047 dc_n[sn - 1] += rate_sn;
2048 }
2049 }
2050 }
2051
2052 // ── Mobile discrete SIA (sn=2..i_mobile): D_sn coalescence ────────────
2053 // Adds the "this cluster diffuses to ALL targets" loss channel and
2054 // the compensating moment corrections for target/product bins.
2055 // (sn=1 monomer is fully handled by monomer growth + monomer depletion.)
2056 //
2057 // Corrections are accumulated at the MOMENT level (per bin), NOT at the
2058 // per-size level, to avoid dense cross-bin Jacobian coupling that would
2059 // collapse CVODE step size. This is exact for piecewise-constant bins
2060 // and O(bw) accurate for linear/lognormal.
2061 //
2062 // For reaction: mobile sn + immobile np → product(sn+np)
2063 // rate = K(np, sn) * c_sn * c_np = Z_ii * A * (np^{1/3}+sn^{1/3}) * D_sn * c_sn * c_np
2064 //
2065 // dc_n[n] -= rate (projectile loss — into per-size dc_n)
2066 // dmu0[k_tgt] -= rate (target bin: one cluster removed)
2067 // dmu1[k_tgt] -= np*rate (target bin: np atoms removed)
2068 // dmu0[k_prd] += rate (product bin: one cluster added)
2069 // dmu1[k_prd] += (sn+np)*rate (product bin: sn+np atoms added)
2070 //
2071 // Net inventory change: -sn*rate (projectile) + sn*rate (product - target) = 0. ✓
2072
2073 // Accumulate moment corrections per SIA bin [indexed 0..Ib-1]
2074 Pm.bm_coal_dmu0.assign(Ib, 0.0);
2075 Pm.bm_coal_dmu1.assign(Ib, 0.0);
2076 Pm.bm_coal_dmu2.assign(Ib, 0.0);
2077 double* const coal_dmu0 = Pm.bm_coal_dmu0.data();
2078 double* const coal_dmu1 = Pm.bm_coal_dmu1.data();
2079 double* const coal_dmu2 = Pm.bm_coal_dmu2.data();
2080
2081 for (int n = 1; n < std::min(i_d, P.i_mobile); ++n) {
2082 const int sn = n + 1;
2083 const double cn = std::max(c_n[n], 0.0);
2084 if (cn < 1e-300) continue;
2085 const double D_sn = P.D_SIA_eff[sn - 1];
2086 if (D_sn < 1e-300) continue;
2087 const double pref = P.Z_ii * P.A_sph_inv_O23 * D_sn * cn;
2088 const double sn_cbrt = sz_cbrt(P, sn);
2089
2090 // Single pass over all SIA bins: target loss + product gain + projectile sink
2091 int kp = 0; // product bin index (monotonic scan)
2092 for (int k = 0; k < Ib; ++k) {
2093 for (int np = n_lo[k]; np < n_hi[k]; ++np) {
2094 if (np - 1 < 0 || np - 1 >= I) continue;
2095 const double c_np = std::max(c_n[np - 1], 0.0);
2096 if (c_np < P.C_floor) continue; // skip negligible sizes
2097 const double nf = static_cast<double>(np);
2098 const double sf = sz_cbrt(P, np) + sn_cbrt;
2099 const double rate = pref * sf * c_np;
2100
2101 // Projectile loss (per-size dc_n)
2102 dc_n[n] -= rate;
2103
2104 // Target bin: one cluster and np atoms removed
2105 coal_dmu0[k] -= rate;
2106 coal_dmu1[k] -= nf * rate;
2107 if (PM >= 3) coal_dmu2[k] -= nf * nf * rate;
2108
2109 // Product bin: one cluster and (sn+np) atoms added
2110 const int prod = sn + np;
2111 if (prod <= I) {
2112 if (prod <= i_d) {
2113 dc_n[prod - 1] += rate;
2114 } else {
2115 while (kp < Ib && prod >= n_hi[kp]) ++kp;
2116 if (kp < Ib && prod >= n_lo[kp]) {
2117 const double pf = static_cast<double>(prod);
2118 coal_dmu0[kp] += rate;
2119 coal_dmu1[kp] += pf * rate;
2120 if (PM >= 3) coal_dmu2[kp] += pf * pf * rate;
2121 }
2122 }
2123 } else if (P.boundary_flux == 1) {
2124 // Reflection: suppress the overflow reaction entirely
2125 dc_n[n] += rate; // undo projectile loss
2126 coal_dmu0[k] += rate; // undo target bin mu0 loss
2127 coal_dmu1[k] += nf * rate; // undo target bin mu1 loss
2128 if (PM >= 3) coal_dmu2[k] += nf * nf * rate;
2129 }
2130 }
2131 }
2132 }
2133
2134 // ── Discrete-discrete SIA coalescence with BINNED product ─────────────
2135 // The discrete-discrete losses (D_np at L1310, D_sn at L1316) fire for
2136 // any pair (sn, np) ≤ i_d when at least one is mobile. The matching
2137 // GAIN was missing for pairs whose product sn+np > i_d: the in-discrete
2138 // gain block is gated by sn ≤ i_d, and the discrete-binned loop above
2139 // only handles binned targets. Add the missing gain at the binned
2140 // product here, mirroring the D_sn ordering of the loss loop — when np
2141 // is the mobile projectile, its contribution is added in the iteration
2142 // where sn_proj=np in this loop.
2143 for (int n = 1; n < std::min(i_d, P.i_mobile); ++n) {
2144 const int sn = n + 1;
2145 const double cn = std::max(c_n[n], 0.0);
2146 if (cn < 1e-300) continue;
2147 if (P.D_SIA_eff[sn - 1] < 1e-300) continue;
2148 int kp = 0;
2149 for (int np = 1; np <= i_d; ++np) {
2150 const int prod = sn + np;
2151 if (prod <= i_d) continue; // discrete product handled at L1301-L1308
2152 if (prod > I) continue; // overflow handled by reflection block
2153 const double c_np = std::max(c_n[np - 1], 0.0);
2154 if (c_np < 1e-300) continue;
2155 const double rate = K_ii_coal(P, np, sn) * cn * c_np;
2156 while (kp < Ib && prod >= n_hi[kp]) ++kp;
2157 if (kp < Ib && prod >= n_lo[kp]) {
2158 const double pf = static_cast<double>(prod);
2159 coal_dmu0[kp] += rate;
2160 if (PM >= 2) coal_dmu1[kp] += pf * rate;
2161 if (PM >= 3) coal_dmu2[kp] += pf * pf * rate;
2162 }
2163 }
2164 }
2165
2166 // ── ½⟨111⟩ LOOP–LOOP COARSENING (P.loop_coal) ─────────────────────────
2167 // The missing edge. D_SIA_eff is truncated to zero above i_mobile, so two
2168 // mobile loops of n̄ ≈ 33 merge to 66 > i_mobile = 40 and the product is
2169 // frozen forever: the ½⟨111⟩ mean size pins AT the cutoff (measured
2170 // mean_n_111/i_mobile = 1.32 ± 0.75 at i_mobile = 40, 0.84 ± 0.25 at 100,
2171 // 16 runs, 2026-08-16) and the density runs 20–60x over the EUROFER97 data.
2172 // Real ½⟨111⟩ loops stay glissile and coarsen by 1-D glide.
2173 //
2174 // Done on MOMENTS, not per-size. The general coalescence above is confined
2175 // to discrete sizes precisely because a per-size reconstruction amplifies
2176 // the rate by O(bw^2) in wide bins (see the note at the head of dc_n).
2177 // Treating each sessile class as monodisperse at its own mean avoids the
2178 // reconstruction entirely: the pair sum is O((i_d - i_mobile + Ib)^2), and
2179 // it conserves SIA inventory exactly because the product carries precisely
2180 // the two reactants' sizes.
2181 if (P.loop_coal) {
2182 struct LoopCls { double n_bar, cnt; int kind, idx; }; // kind 0=discrete,1=bin
2183 std::vector<LoopCls> cls;
2184 cls.reserve(std::max(0, i_d - P.i_mobile) + Ib);
2185 // sessile discrete sizes: mobile ones already coalesce in the block above
2186 for (int sn = P.i_mobile + 1; sn <= i_d; ++sn) {
2187 const double c = std::max(c_n[sn - 1], 0.0);
2188 if (c > P.C_floor)
2189 cls.push_back({static_cast<double>(sn), c, 0, sn - 1});
2190 }
2191 for (int k = 0; k < Ib; ++k) {
2192 const double m0 = std::max(y[i_d + PM*k], 0.0);
2193 if (m0 <= P.C_floor) continue;
2194 double nb = (PM >= 2) ? y[i_d + PM*k + 1] / std::max(m0, 1e-300)
2195 : 0.5 * (n_lo[k] + n_hi[k] - 1);
2196 const double lo_f = static_cast<double>(n_lo[k]);
2197 const double hi_f = static_cast<double>(n_hi[k] - 1);
2198 if (nb < lo_f) nb = lo_f;
2199 else if (nb > hi_f) nb = hi_f;
2200 cls.push_back({nb, m0, 1, k});
2201 }
2202 const int NC = static_cast<int>(cls.size());
2203 // deposit helper: put (cnt, content) at product size s
2204 auto deposit = [&](double s, double r) {
2205 if (s <= 0.0) return;
2206 int si = static_cast<int>(s + 0.5);
2207 if (si > I) si = I; // pile at the grid edge
2208 if (si <= i_d) { // lands on a discrete size
2209 dc_n[si - 1] += r;
2210 return;
2211 }
2212 for (int k = 0; k < Ib; ++k) { // lands in a bin
2213 if (si >= n_lo[k] && si < n_hi[k]) {
2214 coal_dmu0[k] += r;
2215 if (PM >= 2) coal_dmu1[k] += s * r;
2216 if (PM >= 3) coal_dmu2[k] += s * s * r;
2217 return;
2218 }
2219 }
2220 coal_dmu0[Ib - 1] += r; // beyond the last bin
2221 if (PM >= 2) coal_dmu1[Ib - 1] += s * r;
2222 if (PM >= 3) coal_dmu2[Ib - 1] += s * s * r;
2223 };
2224 auto remove = [&](const LoopCls& a, double r) {
2225 if (a.kind == 0) {
2226 dc_n[a.idx] -= r;
2227 } else {
2228 coal_dmu0[a.idx] -= r;
2229 if (PM >= 2) coal_dmu1[a.idx] -= a.n_bar * r;
2230 if (PM >= 3) coal_dmu2[a.idx] -= a.n_bar * a.n_bar * r;
2231 }
2232 };
2233 for (int a = 0; a < NC; ++a) {
2234 const double na = cls[a].n_bar;
2235 const double Da = P.D_loop_coal[std::min(I, std::max(1,
2236 static_cast<int>(na + 0.5))) - 1];
2237 for (int b = a; b < NC; ++b) {
2238 const double nb = cls[b].n_bar;
2239 const double Db = P.D_loop_coal[std::min(I, std::max(1,
2240 static_cast<int>(nb + 0.5))) - 1];
2241 // Both partners glide, so the encounter rate carries D_a + D_b
2242 // (relative diffusivity), unlike the projectile-only convention
2243 // of K_ii_coal where one partner is a fixed target.
2244 const double Dsum = Da + Db;
2245 if (Dsum < 1e-300) continue;
2246 const double sf = std::sqrt(na) + std::sqrt(nb);
2247 const int ia = std::min(I, std::max(1, static_cast<int>(na + 0.5)));
2248 const double Zt = (!P.Z_i_loop_arr.empty())
2249 ? P.Z_i_loop_arr[ia - 1] : P.Z_i_loop;
2250 const double K = P.Z_ii * Zt * P.A_loop_inv_O23 * sf * Dsum;
2251 // a == b: 1/2 K c^2 (each event consumes TWO of the same class)
2252 const double r = (a == b) ? 0.5 * K * cls[a].cnt * cls[a].cnt
2253 : K * cls[a].cnt * cls[b].cnt;
2254 if (r < 1e-300) continue;
2255 if (a == b) {
2256 remove(cls[a], 2.0 * r);
2257 } else {
2258 remove(cls[a], r);
2259 remove(cls[b], r);
2260 }
2261 deposit(na + nb, r);
2262 }
2263 }
2264 }
2265
2266 // ── Project dc_n into dydt ────────────────────────────────────────────
2267 // Discrete sizes: direct copy
2268 for (int n = 0; n < i_d; ++n)
2269 dydt[n] = dc_n[n];
2270 // Binned sizes: project onto PM moments per bin + add coalescence corrections.
2271 // Parallel over BINS: each k accumulates its own dmu* in ascending n (order
2272 // preserved) and writes only its own dydt slots. Bit-identical.
2273 // Dynamic scheduling — the bins are logarithmic, so the last one holds
2274 // orders of magnitude more sizes than the first.
2275#ifdef CD_HAVE_OPENMP
2276#pragma omp parallel for schedule(dynamic, 1) if(I > 4096)
2277#endif
2278 for (int k = 0; k < Ib; ++k) {
2279 double dmu0 = 0.0, dmu1 = 0.0, dmu2 = 0.0;
2280 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
2281 if (n - 1 >= 0 && n - 1 < I) {
2282 const double nf = static_cast<double>(n);
2283 dmu0 += dc_n[n - 1];
2284 if (PM >= 2) dmu1 += nf * dc_n[n - 1];
2285 if (PM >= 3) dmu2 += nf * nf * dc_n[n - 1];
2286 }
2287 }
2288 dydt[i_d + PM*k] = dmu0 + coal_dmu0[k];
2289 if (PM >= 2) dydt[i_d + PM*k + 1] = dmu1 + coal_dmu1[k];
2290 if (PM >= 3) dydt[i_d + PM*k + 2] = dmu2 + coal_dmu2[k];
2291 }
2292
2293 // Note: no inter-bin flux needed — the per-size dc_n projection
2294 // already accounts for growth/emission across bin boundaries exactly.
2295
2296 // ── Vacancy per-size dc_v/dt → project onto vacancy bin moments ─────────
2297 double C_vac_tot = 0.0;
2298 for (int m = 0; m < V; ++m) C_vac_tot += std::max(c_v[m], 0.0);
2299 const double ell_bar = (C_vac_tot > 1e-300) ? Q_tot / C_vac_tot : 0.0;
2300
2301 // Precompute He-modified vacancy emission rates for all sizes
2302 Pm.bm_GVV_eff.resize(V);
2303 double* const GVV_eff = Pm.bm_GVV_eff.data();
2304 // Elementwise in m — no reduction, no scatter — so the parallel result is
2305 // bit-identical. m^{2/3} comes from the startup table (same std::pow
2306 // call, hoisted out of the per-RHS sweep).
2307#ifdef CD_HAVE_OPENMP
2308#pragma omp parallel for schedule(static, 256) if(V > 2048)
2309#endif
2310 for (int m = 0; m < V; ++m) {
2311 const double ell_m = ell_bar * sz_pow23(P, m + 1);
2312 const double ratio = (ell_m > 1e-6) ? ell_m / (m+1) : 0.0;
2313 const double dE = (ratio > 1e-10) ? P.delta_He * P.beta_He_exp / (m+1)
2314 * std::pow(ratio, P.beta_He_exp - 1.0) : 0.0;
2315 GVV_eff[m] = P.GVV[m] * std::exp(std::min(-ell_m * dE / P.kBT, 0.0));
2316 }
2317
2318 Pm.bm_dc_v.assign(V, 0.0);
2319 double* const dc_v = Pm.bm_dc_v.data();
2320 // Pre-accumulate emitted monomers from thermal vacancy emission:
2321 // V_m → V_{m-1} + V_1 — the emitted V_1 monomer must be added to dc_v[0].
2322 {
2323 double emit_mono = 0.0;
2324 for (int m = 1; m < V; ++m)
2325 emit_mono += GVV_eff[m] * std::max(c_v[m], 0.0);
2326 dc_v[0] += emit_mono;
2327 }
2328 // Every branch below accumulates into dc_v[m] and nothing else — the
2329 // m == 0 special cases write dc_v[0], which IS their own index, and the
2330 // inner sums (sia_shrink_sink, the mobile-projectile sweep) are local to
2331 // one iteration, so their accumulation order is untouched. No scatter,
2332 // no cross-iteration reduction ⇒ bit-identical across threads.
2333 // Dynamic scheduling: m == 0 carries O(I + V) inner work while every other
2334 // iteration is O(1), so static chunks would leave one thread straggling.
2335#ifdef CD_HAVE_OPENMP
2336#pragma omp parallel for schedule(dynamic, 256) if(V > 2048)
2337#endif
2338 for (int m = 0; m < V; ++m) {
2339 const double cm = std::max(c_v[m], 0.0);
2340 const int sm = m + 1;
2341 dc_v[m] += P.Pr_VAC[m];
2342 // Thermal emission: gain from m+1 and loss from m — same He-modified rate
2343 if (m + 1 < V) dc_v[m] += GVV_eff[m+1] * std::max(c_v[m+1], 0.0);
2344 dc_v[m] -= GVV_eff[m] * cm;
2345
2346 // V–V coalescence with ALL vacancy clusters
2347 for (int mp = 1; mp <= std::min(sm - 1, P.v_mobile); ++mp) {
2348 const int mpp = sm - mp;
2349 if (mpp < 1 || mpp > V) continue;
2350 const double c_mp = std::max(c_v[mp - 1], 0.0);
2351 const double c_mpp = std::max(c_v[mpp - 1], 0.0);
2352 dc_v[m] += K_vv_coal(P, mpp, mp) * c_mp * c_mpp;
2353 }
2354 // Loss (D_mp contribution): any mobile mp hits this cluster
2355 for (int mp = 1; mp <= P.v_mobile; ++mp)
2356 dc_v[m] -= K_vv_coal(P, sm, mp) * cm * std::max(c_v[mp - 1], 0.0);
2357 // Loss (D_sm contribution): this mobile cluster hits ALL targets
2358 if (sm <= P.v_mobile) {
2359 for (int mp = 1; mp <= V; ++mp)
2360 dc_v[m] -= K_vv_coal(P, mp, sm) * cm * std::max(c_v[mp - 1], 0.0);
2361 }
2362
2363 // SIA-induced cavity shrinkage
2364 if (m == 0) {
2365 dc_v[0] -= P.K_iv * ci1 * cv1;
2366 if (V >= 2) dc_v[0] += P.KVI[1] * ci1 * std::max(c_v[1], 0.0);
2367 // Vacancy monomer consumed by SIA loop shrinkage:
2368 // I_n + V_1 -> I_{n-1} for n>=2 (n=1 already in K_iv above)
2369 double sia_shrink_sink = 0.0;
2370 for (int np = 1; np < I; ++np)
2371 sia_shrink_sink += P.KIV[np] * std::max(c_n[np], 0.0);
2372 dc_v[0] -= cv1 * sia_shrink_sink;
2373 } else {
2374 dc_v[m] -= P.KVI[m] * ci1 * cm;
2375 if (m + 1 < V) dc_v[m] += P.KVI[m+1] * ci1 * std::max(c_v[m+1], 0.0);
2376 }
2377
2378 // n=2,3 (3D mobile SIA clusters): absorb into all cavities
2379 for (int n = 1; n < std::min(3, P.i_mobile); ++n) {
2380 const double cn = std::max(c_n[n], 0.0);
2381 dc_v[m] -= P.KVI[m] * cn * cm;
2382 if (m + n + 1 < V)
2383 dc_v[m] += P.KVI[m + n + 1] * cn * std::max(c_v[m + n + 1], 0.0);
2384 }
2385
2386 // n=4..i_mobile (1D/3D mixed): gain + loss
2387 for (int n = 3; n < std::min(I, P.i_mobile); ++n) {
2388 const double cn = std::max(c_n[n], 0.0);
2389 dc_v[m] -= K_1D_eff(P, n, m) * cn * cm;
2390 if (m + n + 1 < V) {
2391 const int mp = m + n + 1;
2392 const double k_gain = K_1D_eff(P, n, mp);
2393 dc_v[m] += k_gain * cn * std::max(c_v[mp], 0.0);
2394 }
2395 }
2396
2397 // V–I annihilation channel (b): mobile V_{sm} diffuses to SIA.
2398 if (sm >= 2 && sm <= P.v_mobile) {
2399 for (int sn = 1; sn <= I; ++sn) {
2400 const double c_sn = std::max(c_n[sn - 1], 0.0);
2401 dc_v[m] -= K_vi_coal(P, sn, sm) * cm * c_sn;
2402 }
2403 }
2404 for (int sn = 1; sm + sn <= P.v_mobile && sn <= I; ++sn) {
2405 if (sm + sn > V) break;
2406 dc_v[m] += K_vi_coal(P, sn, sm + sn)
2407 * std::max(c_v[sm + sn - 1], 0.0)
2408 * std::max(c_n[sn - 1], 0.0);
2409 }
2410
2411 // Fixed sinks — only mobile vacancy clusters diffuse to sinks
2412 if (m + 1 <= P.v_mobile)
2413 dc_v[m] -= P.k2_disl_v * cm;
2414 // Cavity → network sweeping (VOID_NETWORK_LOSS): a climbing/gliding
2415 // line intersects the cavity and absorbs it whole. Diagonal
2416 // first-order loss; the m vacancies it carries are charged to
2417 // J_VAC_fixed below, so δ_FP is preserved. Λ_m^void is zero for
2418 // m ≤ v_mobile (those already pay k2_disl_v) and zero everywhere when
2419 // the channel is off, making the OFF path byte-identical.
2420 if (!P.Lambda_net_void.empty())
2421 dc_v[m] -= P.Lambda_net_void[m] * cm;
2422 }
2423
2424 // Reflection boundary: suppress reactions whose product exceeds V
2425 if (P.boundary_flux == 1) {
2426 for (int mp = 1; mp <= P.v_mobile; ++mp) {
2427 const double c_mp = std::max(c_v[mp - 1], 0.0);
2428 for (int k = std::max(V - mp + 1, 1); k <= V; ++k) {
2429 const double rate = K_vv_coal(P, k, mp) * c_mp * std::max(c_v[k - 1], 0.0);
2430 dc_v[k - 1] += rate; // undo target loss
2431 dc_v[mp - 1] += rate; // undo projectile depletion
2432 }
2433 }
2434 }
2435
2436 // ── Project dc_v into dydt ────────────────────────────────────────────
2437 // Discrete sizes: direct copy
2438 for (int m = 0; m < v_d; ++m)
2439 dydt[i_VAC + m] = dc_v[m];
2440 // Binned sizes: project onto PM moments per bin
2441 const int vac_mom_start = i_VAC + v_d;
2442 if (Kv > 0) {
2443 // Parallel over bins — see the SIA projection above.
2444#ifdef CD_HAVE_OPENMP
2445#pragma omp parallel for schedule(dynamic, 1) if(V > 4096)
2446#endif
2447 for (int k = 0; k < Kv; ++k) {
2448 double dmu0 = 0.0, dmu1 = 0.0, dmu2 = 0.0;
2449 for (int m = m_lo[k]; m < m_hi[k]; ++m) {
2450 if (m - 1 >= 0 && m - 1 < V) {
2451 const double mf = static_cast<double>(m);
2452 dmu0 += dc_v[m - 1];
2453 if (PM >= 2) dmu1 += mf * dc_v[m - 1];
2454 if (PM >= 3) dmu2 += mf * mf * dc_v[m - 1];
2455 }
2456 }
2457 dydt[vac_mom_start + PM*k] = dmu0;
2458 if (PM >= 2) dydt[vac_mom_start + PM*k + 1] = dmu1;
2459 if (PM >= 3) dydt[vac_mom_start + PM*k + 2] = dmu2;
2460 }
2461 }
2462
2463 // ── ½⟨111⟩ → ⟨100⟩ loop conversion (bin-moment) ──────────────────────────
2464 // The appended ⟨100⟩ block at P.sia100_off carries the SAME discrete-prefix
2465 // + logarithmic-bin reduction as the ½⟨111⟩ block, so BOTH SIA loop
2466 // characters are bin-moment-reduced. Procedure (identical philosophy to the
2467 // ½⟨111⟩/vacancy reductions): reconstruct ⟨100⟩ per-size, evaluate the EXACT
2468 // per-size conversion rates (same arithmetic as rhs_case2 — unary relabel,
2469 // Marian junction & absorption, sessile point-defect ladders), then project
2470 // the per-size deltas back onto the tracked moments. Because the moment
2471 // projection is linear, the ½⟨111⟩ losses are ADDED onto the already-set
2472 // SIA moment derivatives and the vacancy-monomer derivative; the ⟨100⟩ block
2473 // (which evolves ONLY through conversion) is set directly.
2474 // Reconstructed ⟨100⟩ per-size, in persistent scratch. Sized to 0 when
2475 // conversion is off so the `c_n100_on` flag below mirrors the old
2476 // `c_n100.empty()` test exactly.
2477 double* c_n100 = nullptr;
2478 const bool c_n100_on = (P.loop_conversion != 0);
2479 if (P.loop_conversion) {
2480 Pm.bm_c_n100.assign(I, 0.0);
2481 c_n100 = Pm.bm_c_n100.data();
2482 const double* src = y + P.sia100_off;
2483 // Reconstruct ⟨100⟩ per-size from its moment block (closure mirrors c_n).
2484 for (int n = 0; n < i_d; ++n)
2485 c_n100[n] = std::max(src[n], 0.0);
2486 for (int k = 0; k < Ib; ++k) {
2487 const double mu0_k = std::max(src[i_d + PM*k], 0.0);
2488 double mu1_k = (PM >= 2) ? src[i_d + PM*k + 1] : 0.0;
2489 double mu2_k = (PM >= 3) ? src[i_d + PM*k + 2] : 0.0;
2490 const double bw = static_cast<double>(n_hi[k] - n_lo[k]);
2491 if (bw <= 0 || mu0_k <= 0.0) continue;
2492 if (bw == 1.0) {
2493 int ni = n_lo[k] - 1;
2494 if (ni >= 0 && ni < I) c_n100[ni] = mu0_k;
2495 continue;
2496 }
2497 { // moment-consistency guard (clamp n_bar into the bin)
2498 const double n_lo_f = static_cast<double>(n_lo[k]);
2499 const double n_hi_f = static_cast<double>(n_hi[k] - 1);
2500 double n_bar = mu1_k / std::max(mu0_k, 1e-300);
2501 if (n_bar < n_lo_f) mu1_k = mu0_k * n_lo_f;
2502 else if (n_bar > n_hi_f) mu1_k = mu0_k * n_hi_f;
2503 if (PM >= 3) {
2504 double n2_min = mu1_k * mu1_k / std::max(mu0_k, 1e-300) * 1.01;
2505 if (mu2_k < n2_min) mu2_k = n2_min;
2506 }
2507 }
2508 bool use_lognormal = false;
2509 if (P.shape_function == 2 && PM >= 3) {
2510 const double n_bar = mu1_k / std::max(mu0_k, 1e-300);
2511 const double n2_bar = mu2_k / std::max(mu0_k, 1e-300);
2512 const double ratio = n2_bar / std::max(n_bar * n_bar, 1e-300);
2513 if (ratio > 1.5) {
2514 use_lognormal = true;
2515 const double sig2 = std::log(ratio);
2516 const double m_k = std::log(std::max(n_bar, 1e-300)) - 0.5 * sig2;
2517 double f_sum = 0.0, max_log_f = -1e300;
2518 std::vector<double> log_f_arr(static_cast<int>(bw));
2519 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
2520 double ln_n = std::log(static_cast<double>(n));
2521 double lf = std::max(-(ln_n - m_k) * (ln_n - m_k) / (2.0 * sig2), -500.0) - ln_n;
2522 log_f_arr[n - n_lo[k]] = lf;
2523 if (lf > max_log_f) max_log_f = lf;
2524 }
2525 for (int j = 0; j < static_cast<int>(bw); ++j) {
2526 log_f_arr[j] -= max_log_f;
2527 f_sum += std::exp(log_f_arr[j]);
2528 }
2529 if (f_sum < 1e-300) {
2530 use_lognormal = false;
2531 } else {
2532 for (int n = n_lo[k]; n < n_hi[k]; ++n)
2533 if (n - 1 >= 0 && n - 1 < I)
2534 c_n100[n - 1] = std::max(
2535 mu0_k * std::exp(log_f_arr[n - n_lo[k]]) / f_sum, 0.0);
2536 }
2537 }
2538 }
2539 if (!use_lognormal) {
2540 if (P.shape_function == 0) {
2541 const double val = mu0_k / bw;
2542 for (int n = n_lo[k]; n < n_hi[k]; ++n)
2543 if (n - 1 >= 0 && n - 1 < I) c_n100[n - 1] = val;
2544 } else {
2545 // Σn / Σn² from the startup table (see the ½⟨111⟩ side).
2546 double S1, S2;
2547 if (!P.bin_S1.empty()) {
2548 S1 = P.bin_S1[k]; S2 = P.bin_S2[k];
2549 } else {
2550 S1 = 0.0; S2 = 0.0;
2551 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
2552 double nf = static_cast<double>(n);
2553 S1 += nf; S2 += nf * nf;
2554 }
2555 }
2556 double det = bw * S2 - S1 * S1;
2557 if (std::abs(det) < 1e-30) {
2558 double val = mu0_k / bw;
2559 for (int n = n_lo[k]; n < n_hi[k]; ++n)
2560 if (n - 1 >= 0 && n - 1 < I) c_n100[n - 1] = val;
2561 } else {
2562 // Scatter-free; parallel result is bit-identical.
2563#ifdef CD_HAVE_OPENMP
2564#pragma omp parallel for schedule(static, 256) if(n_hi[k] - n_lo[k] > 2048)
2565#endif
2566 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
2567 if (n - 1 < 0 || n - 1 >= I) continue;
2568 double nf = static_cast<double>(n);
2569 double phi0 = (S2 - S1 * nf) / det;
2570 double phi1 = (bw * nf - S1) / det;
2571 c_n100[n - 1] = softplus(phi0 * mu0_k + phi1 * mu1_k);
2572 }
2573 }
2574 }
2575 }
2576 }
2577
2578 // Per-size conversion deltas (mirror rhs_case2 arithmetic exactly).
2579 Pm.bm_d111.assign(I, 0.0);
2580 Pm.bm_d100.assign(I, 0.0);
2581 double* const d111 = Pm.bm_d111.data();
2582 double* const d100 = Pm.bm_d100.data();
2583 double dv1 = 0.0;
2584 const int nlm = P.conv_n_loop_min;
2585
2586 // (1) Unary ½⟨111⟩_n → ⟨100⟩_n (size-fixed relabel).
2587 // Touches only index n-1 of each array — no scatter, no reduction.
2588#ifdef CD_HAVE_OPENMP
2589#pragma omp parallel for schedule(static, 256) if(I - nlm > 2048)
2590#endif
2591 for (int n = nlm; n <= I; ++n) {
2592 const double rate = P.Gamma_uni[n - 1] * std::max(c_n[n - 1], 0.0);
2593 d111[n - 1] -= rate;
2594 d100[n - 1] += rate;
2595 }
2596 // (2) Marian junction: fraction φ of ½⟨111⟩ coalescence GAIN → ⟨100⟩.
2597 // φ(np,npp)·K_ii(npp,np) is a function of the two integer sizes
2598 // alone, so it comes from the startup table. The product is stored
2599 // FUSED because the original expression multiplied the two together
2600 // first — `ph * K * c1 * c2` associates as `((ph*K)*c1)*c2` — so the
2601 // tabulated form reproduces the same rounding. Falls back to the
2602 // inline evaluation when the table was not built.
2603 const double* const phiK = P.phiK_junc_tab.empty()
2604 ? nullptr : P.phiK_junc_tab.data();
2605 // `moved` is accumulated and consumed inside a single sn iteration, so
2606 // its summation order is unchanged by threading; the two writes land
2607 // on index sn-1 only. Bit-identical.
2608#ifdef CD_HAVE_OPENMP
2609#pragma omp parallel for schedule(static, 256) if(I > 2048)
2610#endif
2611 for (int sn = 2; sn <= I; ++sn) {
2612 double moved = 0.0;
2613 for (int np = 1; np <= std::min(sn - 1, P.i_mobile); ++np) {
2614 const int npp = sn - np;
2615 if (npp < 1 || npp > I) continue;
2616 double phK;
2617 if (phiK) {
2618 phK = phiK[static_cast<size_t>(np - 1) * P.phiK_junc_stride
2619 + (npp - 1)];
2620 } else {
2621 const double ph = conv_phi_junc(P, np, npp);
2622 phK = (ph <= 0.0) ? 0.0 : ph * K_ii_coal(P, npp, np);
2623 }
2624 if (phK == 0.0) continue; // mirrors the old `ph <= 0` skip
2625 moved += phK
2626 * std::max(c_n[np - 1], 0.0)
2627 * std::max(c_n[npp - 1], 0.0);
2628 }
2629 d111[sn - 1] -= moved;
2630 d100[sn - 1] += moved;
2631 }
2632 // (3) Marian absorption growth: ⟨100⟩_m + ½⟨111⟩_n → ⟨100⟩_{m+n}.
2633 for (int m = nlm; m <= I; ++m) {
2634 const double cm100 = std::max(c_n100[m - 1], 0.0);
2635 if (cm100 < 1e-300) continue;
2636 for (int n = 1; n <= P.i_mobile && m + n <= I; ++n) {
2637 const double rate = K_100_absorb(P, m, n)
2638 * cm100 * std::max(c_n[n - 1], 0.0);
2639 d100[m - 1] -= rate;
2640 d111[n - 1] -= rate;
2641 d100[m + n - 1] += rate;
2642 }
2643 }
2644 // (4) Sessile ⟨100⟩ point-defect ladders (monomer-coupled to ½⟨111⟩).
2645 for (int n = nlm; n <= I; ++n) {
2646 const double cn100 = std::max(c_n100[n - 1], 0.0);
2647 if (cn100 < 1e-300) continue;
2648 if (n < I) { // growth + I_1
2649 const double g = P.K_100_grow[n - 1] * cn100 * ci1;
2650 d100[n - 1] -= g;
2651 d100[n] += g;
2652 d111[0] -= g;
2653 }
2654 { // shrink + V_1
2655 const double s = P.K_100_shrink[n - 1] * cn100 * cv1;
2656 d100[n - 1] -= s;
2657 if (n - 1 >= nlm) d100[n - 2] += s;
2658 else if (n >= 2) d111[n - 2] += s;
2659 dv1 -= s;
2660 }
2661 { // emission → I_1
2662 const double e = P.G_100[n - 1] * cn100;
2663 d100[n - 1] -= e;
2664 if (n - 1 >= nlm) d100[n - 2] += e;
2665 else if (n >= 2) d111[n - 2] += e;
2666 d111[0] += e;
2667 }
2668 // Loop → network-dislocation loss on sessile ⟨100⟩
2669 // (loop_network_loss.tex); projected onto the ⟨100⟩ moments below.
2670 d100[n - 1] -= P.Lambda_net_100[n - 1] * cn100;
2671 }
2672
2673 // Project d111 onto the ½⟨111⟩ moments and ADD (projection is linear).
2674 for (int n = 0; n < i_d; ++n) dydt[n] += d111[n];
2675#ifdef CD_HAVE_OPENMP
2676#pragma omp parallel for schedule(dynamic, 1) if(I > 4096)
2677#endif
2678 for (int k = 0; k < Ib; ++k) {
2679 double dmu0 = 0.0, dmu1 = 0.0, dmu2 = 0.0;
2680 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
2681 if (n - 1 >= 0 && n - 1 < I) {
2682 const double nf = static_cast<double>(n);
2683 dmu0 += d111[n - 1];
2684 if (PM >= 2) dmu1 += nf * d111[n - 1];
2685 if (PM >= 3) dmu2 += nf * nf * d111[n - 1];
2686 }
2687 }
2688 dydt[i_d + PM*k] += dmu0;
2689 if (PM >= 2) dydt[i_d + PM*k + 1] += dmu1;
2690 if (PM >= 3) dydt[i_d + PM*k + 2] += dmu2;
2691 }
2692 // Vacancy monomer (m=1 is always in the discrete prefix at i_VAC).
2693 dydt[i_VAC + 0] += dv1;
2694
2695 // Project d100 onto the ⟨100⟩ moments and SET (this block evolves only
2696 // through conversion; the whole dydt was zeroed at function entry).
2697 const int s100 = P.sia100_off;
2698 for (int n = 0; n < i_d; ++n) dydt[s100 + n] = d100[n];
2699#ifdef CD_HAVE_OPENMP
2700#pragma omp parallel for schedule(dynamic, 1) if(I > 4096)
2701#endif
2702 for (int k = 0; k < Ib; ++k) {
2703 double dmu0 = 0.0, dmu1 = 0.0, dmu2 = 0.0;
2704 for (int n = n_lo[k]; n < n_hi[k]; ++n) {
2705 if (n - 1 >= 0 && n - 1 < I) {
2706 const double nf = static_cast<double>(n);
2707 dmu0 += d100[n - 1];
2708 if (PM >= 2) dmu1 += nf * d100[n - 1];
2709 if (PM >= 3) dmu2 += nf * nf * d100[n - 1];
2710 }
2711 }
2712 dydt[s100 + i_d + PM*k] = dmu0;
2713 if (PM >= 2) dydt[s100 + i_d + PM*k + 1] = dmu1;
2714 if (PM >= 3) dydt[s100 + i_d + PM*k + 2] = dmu2;
2715 }
2716 }
2717
2718 // ── He equations ─────────────────────────────────────────────────────────
2719 // Accumulates the EXACT He sink losses applied to the Q state below, so
2720 // the J_He_sink ledger mirrors the state and the conservation identity
2721 // c_h + Q + J_He_sink = ∫G_He holds to solver tolerance.
2722 double He_sink_state = 0.0;
2723 if (P.he_mode != 1) {
2724 // Case 2: scalar Q_tot
2725 double He_up = 0.0;
2726 for (int m = 0; m < V; ++m)
2727 He_up += P.KHeV[m] * c_h * std::max(c_v[m], 0.0);
2728 const double He_emit = P.beta_He * Q_tot;
2729 // He lost only from mobile voids reaching fixed sinks
2730 double He_sink_2 = 0.0;
2731 for (int m = 0; m < std::min(V, P.v_mobile); ++m) {
2732 const double ell_m = ell_bar * std::pow(static_cast<double>(m + 1), 2.0/3.0);
2733 He_sink_2 += P.k2_disl_v * ell_m * std::max(c_v[m], 0.0);
2734 }
2735 He_sink_state = He_sink_2;
2736 dydt[i_Q_base] = He_up - He_emit - He_sink_2;
2737 if (!qss)
2738 dydt[i_He_idx] = P.G_He - He_up - P.k2_disl_He * c_h + He_emit;
2739 } else {
2740 // Case 1: Q per bin (Kv>0) or Q per size (Kv==0)
2741 int n_Q = Kv;
2742 double He_cap_total = 0.0, He_emit_total = 0.0;
2743 if (Kv > 0) {
2744 for (int k = 0; k < Kv; ++k) {
2745 const double mu0_k = std::max(y[vac_mom_start + PM*k], 0.0);
2746 const double qk = std::max(y[i_Q_base + k], 0.0);
2747 const double ell_k = (mu0_k > 1e-200) ? qk / mu0_k : 0.0;
2748 double dqk = 0.0;
2749 for (int m = m_lo[k]; m < m_hi[k]; ++m) {
2750 int mi = m - 1;
2751 if (mi < 0 || mi >= V) continue;
2752 const double cm = std::max(c_v[mi], 0.0);
2753 const double he_cap = P.KHeV[mi] * c_h * cm;
2754 const double q_approx = ell_k * cm;
2755 const double he_emit = P.beta_He * q_approx;
2756 dqk += he_cap - he_emit;
2757 if (m <= P.v_mobile) { // only mobile voids
2758 dqk -= P.k2_disl_v * q_approx;
2759 He_sink_state += P.k2_disl_v * q_approx;
2760 }
2761 He_cap_total += he_cap;
2762 He_emit_total += he_emit;
2763 }
2764 dydt[i_Q_base + k] = dqk;
2765 }
2766 } else {
2767 for (int m = 0; m < V; ++m) {
2768 const double cm = std::max(c_v[m], 0.0);
2769 const double qm = std::max(y[i_Q_base + m], 0.0);
2770 const double he_cap_m = P.KHeV[m] * c_h * cm;
2771 const double he_emit_m = P.beta_He * qm;
2772 dydt[i_Q_base + m] += he_cap_m - he_emit_m;
2773 if (m + 1 <= P.v_mobile) { // only mobile voids
2774 dydt[i_Q_base + m] -= P.k2_disl_v * qm;
2775 He_sink_state += P.k2_disl_v * qm;
2776 }
2777 He_cap_total += he_cap_m;
2778 He_emit_total += he_emit_m;
2779 }
2780 }
2781 if (!qss)
2782 dydt[i_He_idx] = P.G_He - He_cap_total - P.k2_disl_He * c_h + He_emit_total;
2783 }
2784
2785 // ── Conservation accounting ODEs ─────────────────────────────────────────
2786 // J_SIA_fixed: SIA content lost to fixed sinks
2787 {
2788 double sia_fixed = 0.0;
2789 for (int n = 0; n < I; ++n)
2790 sia_fixed += static_cast<double>(n + 1) * P.k2_SIA[n] * std::max(c_n[n], 0.0);
2791 dydt[P.cons_off + 0] = sia_fixed;
2792 }
2793
2794 // J_SIA_mutual: ALL SIA content lost to SIA-vacancy annihilation (bin_moment).
2795 // Weight = min(sn, m+1): when I_n hits V_m with n > m, only m defects
2796 // are annihilated; the remainder forms a smaller SIA cluster.
2797 {
2798 double mutual = 0.0;
2799 for (int n = 0; n < I; ++n) {
2800 const int sn = n + 1;
2801 const double cn_val = std::max(c_n[n], 0.0);
2802 if (cn_val < 1e-300) continue;
2803 // (a) Mobile SIA cluster → cavity absorption
2804 if (n == 0) {
2805 for (int m = 1; m < V; ++m)
2806 mutual += P.K_3D_cav_pref * P.m13[m] * cn_val * std::max(c_v[m], 0.0);
2807 } else if (n < 3 && n < P.i_mobile) {
2808 for (int m = 0; m < V; ++m)
2809 mutual += std::min(sn, m + 1) * P.K_3D_cav_pref * P.m13[m] * cn_val * std::max(c_v[m], 0.0);
2810 } else if (n >= 3 && n < P.i_mobile) {
2811 for (int m = 0; m < V; ++m)
2812 mutual += std::min(sn, m + 1) * K_1D_eff(P, n, m) * cn_val * std::max(c_v[m], 0.0);
2813 }
2814 // (b) Mobile vacancy hitting this SIA cluster
2815 for (int mp = 1; mp <= P.v_mobile; ++mp) {
2816 const double c_mp = std::max(c_v[mp - 1], 0.0);
2817 if (c_mp < 1e-300) continue;
2818 double K_s;
2819 if (mp == 1 && n > 0) K_s = P.KIV[n];
2820 else if (mp == 1 && n == 0) K_s = P.K_iv;
2821 else K_s = K_vi_coal(P, sn, mp);
2822 mutual += std::min(mp, sn) * K_s * c_mp * cn_val;
2823 }
2824 }
2825 dydt[P.cons_off + 1] = mutual;
2826 }
2827
2828 // J_VAC_fixed: VAC content lost to fixed sinks
2829 {
2830 double vac_fixed = 0.0;
2831 for (int m = 0; m < std::min(P.v_mobile, P.V); ++m)
2832 vac_fixed += static_cast<double>(m + 1) * P.k2_disl_v * std::max(c_v[m], 0.0);
2833 // Swept cavities deliver their FULL vacancy content m to the line.
2834 // Without this the vacancies removed by Λ_m^void leave the ledger and
2835 // δ_FP reports the channel as a conservation violation.
2836 if (!P.Lambda_net_void.empty())
2837 for (int m = 0; m < P.V; ++m)
2838 vac_fixed += static_cast<double>(m + 1) * P.Lambda_net_void[m]
2839 * std::max(c_v[m], 0.0);
2840 dydt[P.cons_off + 2] = vac_fixed;
2841 }
2842
2843 // J_VAC_mutual: VAC content lost to mutual annihilation.
2844 // For both channels (a) and (b), the vacancy content destroyed per
2845 // reaction equals min(m', n) — the same as the SIA content destroyed.
2846 // When V_{m'} hits I_n with m'>n, the vacancy cluster shrinks to
2847 // V_{m'-n}, losing only n vacancies (not m').
2848 dydt[P.cons_off + 3] = dydt[P.cons_off + 1]; // J_VAC_mutual = J_SIA_mutual
2849
2850 // J_He_sink: He lost to sinks. He_sink_state is the EXACT trapped-He
2851 // loss applied to the Q state above (Case-2 allocation formula or the
2852 // per-class/per-bin Q sink terms for Case 1), so the ledger mirrors
2853 // the state by construction.
2854 dydt[P.cons_off + 4] = P.k2_disl_He * c_h + He_sink_state;
2855
2856 // Loop conversion: each ⟨100⟩ shrink (⟨100⟩_n + V_1 → ⟨100⟩_{n-1}) annihilates
2857 // one SIA and one vacancy, so it enters the mutual-annihilation flux for both
2858 // species (keeps δ_FP_sia / δ_FP_vac exact with conversion on) — mirrors the
2859 // discrete rhs_case2 accounting.
2860 if (P.loop_conversion && c_n100_on) {
2861 double s100_mut = 0.0, s100_fixed = 0.0;
2862 for (int n = P.conv_n_loop_min; n <= I; ++n) {
2863 s100_mut += P.K_100_shrink[n - 1] * std::max(c_n100[n - 1], 0.0) * cv1;
2864 s100_fixed += static_cast<double>(n) * P.Lambda_net_100[n - 1]
2865 * std::max(c_n100[n - 1], 0.0);
2866 }
2867 dydt[P.cons_off + 1] += s100_mut; // J_SIA_mutual
2868 dydt[P.cons_off + 3] += s100_mut; // J_VAC_mutual
2869 dydt[P.cons_off + 0] += s100_fixed; // J_SIA_fixed (network sweep)
2870 }
2871
2872 // ── Sliding-window masking for bin_moment mode ───────────────────────────
2873 // For the bin_moment RHS the outer cluster loops are not restructured here
2874 // (they are governed by discrete index + bin index, not a simple 0..N range).
2875 // Instead, any derivative for a state index beyond the current window
2876 // frontier is zeroed post-hoc. Out-of-window concentrations are near zero
2877 // so their contributions to in-window derivatives are negligible.
2878 if (ud->window_active) {
2879 const int n_sia = i_d + PM * Ib;
2880 const int n_vac = v_d + PM * P.V_bin;
2881
2882 // Zero SIA state derivatives beyond the SIA window
2883 const int hi_i = std::min(ud->x_hi_i, n_sia - 1);
2884 for (int k = hi_i + 1; k < n_sia; ++k) dydt[k] = 0.0;
2885
2886 // Zero VAC state derivatives beyond the VAC window
2887 const int hi_v = std::min(ud->x_hi_v, n_vac - 1);
2888 for (int k = hi_v + 1; k < n_vac; ++k) dydt[i_VAC + k] = 0.0;
2889
2890 // Zero ⟨100⟩ block derivatives beyond the SIA window (same reduction
2891 // length as ½⟨111⟩, so the SIA frontier hi_i applies).
2892 if (P.loop_conversion)
2893 for (int k = hi_i + 1; k < P.sia100_len; ++k)
2894 dydt[P.sia100_off + k] = 0.0;
2895 }
2896
2897 return 0;
2898}
int rhs_bin_moment(sunrealtype t, N_Vector yv, N_Vector ydotv, void *user_data)
static double K_1D_eff(const Parameters &P, int n_idx, int m_idx)
static double c_h_qss_case2(const Parameters &P, const double *c_v, double Q_tot)
static double K_vv_coal(const Parameters &P, int m, int mp)
static double xi_of(const Parameters &P, int s)
static double K_100_absorb(const Parameters &P, int m, int n)
int rhs_full_CD(sunrealtype t, N_Vector y, N_Vector ydot, void *user_data)
static double K_ii_coal(const Parameters &P, int n, int np)
static int rhs_case1(sunrealtype, N_Vector yv, N_Vector ydotv, const Parameters &P, int x_hi_i_win, int x_hi_v_win)
static double conv_phi_junc(const Parameters &P, int a, int b)
static double c_h_qss_case1(const Parameters &P, const double *c_v, const double *Q, int n_Q)
static double sz_cbrt(const Parameters &P, int s)
void build_kernel_tables(Parameters &P)
static double sz_sqrt(const Parameters &P, int s)
static int rhs_case2(sunrealtype, N_Vector yv, N_Vector ydotv, const Parameters &P, int x_hi_i_win, int x_hi_v_win)
static double K_vi_coal(const Parameters &P, int n, int mp)
static double sz_pow23(const Parameters &P, int s)
std::vector< double > bm_d111
Definition parameters.h:339
std::vector< double > bm_dc_n
Definition parameters.h:338
std::vector< double > bm_coal_dmu0
Definition parameters.h:340
std::vector< double > bm_GVV_eff
Definition parameters.h:338
std::vector< double > bm_dc_v
Definition parameters.h:338
std::vector< double > bm_c_v
Definition parameters.h:338
std::vector< double > bm_c_n
Definition parameters.h:338
std::vector< double > bm_c_n100
Definition parameters.h:339
std::vector< double > bm_d100
Definition parameters.h:339
std::vector< double > bm_coal_dmu2
Definition parameters.h:340
std::vector< double > bm_coal_dmu1
Definition parameters.h:340
bool window_active
Parameters * P