RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
bin_moment_rates.py
Go to the documentation of this file.
1"""
2bin_moment_rates.py — Size-bin moment reduction for RadCluster_2_1.
3
4REFERENCE ONLY (ODE RHS) — the `ode_system` method of
5BinMomentRateEquations is not used by the solver; the production RHS is the
6C++ implementation. May be stale. Scheduled for replacement by the
7Stage-3 graph-walker. simulation.py integrates exclusively via the C++
8solver and never calls the Python `ode_system`. Note that the
9reconstruction/projection helpers and bin-construction code in this module
10ARE used (e.g. by simulation.py's adaptive domain expansion); only the
11`ode_system` RHS is dead code.
12
13Implements the Chapter 9 state-space reduction for the SIA and vacancy
14cluster populations via logarithmic size bins. Three intra-bin shape
15functions (closures) are supported:
16
17 shape_function = "constant" — piecewise-constant (1 moment/bin: μ₀)
18 shape_function = "linear" — hat-function / dual-basis (2 moments/bin: μ₀, μ₁)
19 shape_function = "lognormal" — log-normal (3 moments/bin: μ₀, μ₁, μ₂)
20
21Physics reference
22-----------------
23Ghoniem, N.M. (2026), Chapter 9 (Rate_Equations.pdf):
24 Eqs. 188-211.
25
26Bin partition (Eq. 188)
27-----------------------
28Logarithmic bin edges:
29 n_k = floor(n_1 · r^k), k = 0, 1, ..., K_bins
30 r > 1 (typically r = 2)
31
32Bin B_k = {n : n_k ≤ n < n_{k+1}} (Eq. 189)
33Bin width |B_k| = n_{k+1} − n_k (Eq. 190)
34
35Bin moments (Eq. 192)
36---------------------
37 μ_k^(0) = Σ_{n ∈ B_k} c_n (zeroth — bin density)
38 μ_k^(1) = Σ_{n ∈ B_k} n · c_n (first — bin content)
39 μ_k^(2) = Σ_{n ∈ B_k} n² · c_n (second — used only by lognormal)
40
41Closures
42--------
43Piecewise-constant (Eq. 198-200):
44 c_n ≈ μ_k^(0) / |B_k| for all n ∈ B_k
45
46Hat-function / linear (Galerkin) (Eq. 201-206):
47 c_n = φ_{k,0}(n) · μ_k^(0) + φ_{k,1}(n) · μ_k^(1)
48
49Log-normal (Eq. 213-216):
50 c_n ∝ (1/n) exp[−(ln n − m_k)² / (2σ_k²)]
51 with m_k, σ_k determined from μ₀, μ₁, μ₂; normalized to preserve μ₀.
52"""
53
54import numpy as np
55from .defect_production import production_rates
56
57
58_kB = 8.617333262e-5
59
60# Valid shape function names and the number of moments each requires per bin
61_SHAPE_FUNCTIONS = {'constant': 1, 'linear': 2, 'lognormal': 3}
62
63# Integer encoding for C++ bridge: constant=0, linear=1, lognormal=2
64_SHAPE_FUNCTION_INT = {'constant': 0, 'linear': 1, 'lognormal': 2}
65
66
67def n_moments_for_shape(shape_function):
68 """Return the number of tracked moments per bin (1, 2, or 3)."""
69 if shape_function not in _SHAPE_FUNCTIONS:
70 raise ValueError(
71 f"Unknown shape_function='{shape_function}'. "
72 f"Must be one of {list(_SHAPE_FUNCTIONS.keys())}.")
73 return _SHAPE_FUNCTIONS[shape_function]
74
75
76def build_bins(N_max, n1=1, r=2.0):
77 """
78 Build logarithmic bin partition.
79
80 Parameters
81 ----------
82 N_max : int — maximum SIA cluster size tracked
83 n1 : int — minimum cluster size (first bin starts here)
84 r : float — bin ratio n_{k+1}/n_k > 1
85
86 Returns
87 -------
88 bins : list of (n_lo, n_hi) tuples
89 Each tuple gives the inclusive lower and exclusive upper bounds of bin k.
90 bins[k] = (n_k, n_{k+1})
91 edges : ndarray of int bin edges
92 """
93 edges = [n1]
94 while edges[-1] < N_max:
95 next_edge = max(int(np.floor(edges[-1] * r)), edges[-1] + 1)
96 edges.append(min(next_edge, N_max + 1))
97 edges = np.array(edges, dtype=int)
98 bins = [(int(edges[k]), int(edges[k + 1])) for k in range(len(edges) - 1)]
99 return bins, edges
100
101
102def moments_from_distribution(c_n, bins, n_mom=2):
103 """
104 Compute bin moments from per-size concentrations.
105
106 Parameters
107 ----------
108 c_n : ndarray [N_max] — concentrations, c_n[n-1] = c_n
109 bins : list of (n_lo, n_hi)
110 n_mom : int — number of moments to compute (1, 2, or 3)
111
112 Returns
113 -------
114 mu0 : ndarray [K_bins] — zeroth moments
115 mu1 : ndarray [K_bins] or None — first moments (if n_mom >= 2)
116 mu2 : ndarray [K_bins] or None — second moments (if n_mom >= 3)
117 """
118 K = len(bins)
119 mu0 = np.zeros(K)
120 mu1 = np.zeros(K) if n_mom >= 2 else None
121 mu2 = np.zeros(K) if n_mom >= 3 else None
122 for k, (nlo, nhi) in enumerate(bins):
123 ns = np.arange(nlo, nhi)
124 idx = ns - 1
125 valid = (idx >= 0) & (idx < len(c_n))
126 c_valid = c_n[idx[valid]]
127 n_valid = ns[valid]
128 mu0[k] = np.sum(c_valid)
129 if mu1 is not None:
130 mu1[k] = np.sum(n_valid * c_valid)
131 if mu2 is not None:
132 mu2[k] = np.sum(n_valid * n_valid * c_valid)
133 return mu0, mu1, mu2
134
135
136def distribution_from_moments_pc(mu0, mu1, bins, N_max):
137 """
138 Reconstruct piecewise-constant per-size concentrations from moments.
139
140 c_n = μ_k^(0) / |B_k| for n ∈ B_k (Eq. 198-200)
141
142 Parameters
143 ----------
144 mu0 : ndarray [K_bins]
145 mu1 : ndarray [K_bins] (unused in piecewise-constant)
146 bins : list of (n_lo, n_hi)
147 N_max: int
148
149 Returns
150 -------
151 c_n : ndarray [N_max]
152 """
153 c_n = np.zeros(N_max)
154 for k, (nlo, nhi) in enumerate(bins):
155 bw = float(nhi - nlo)
156 val = mu0[k] / bw if bw > 0 else 0.0
157 ns = np.arange(nlo, nhi) - 1
158 valid = (ns >= 0) & (ns < N_max)
159 c_n[ns[valid]] = val
160 return c_n
161
162
163def midpoint_distribution_from_moments(mu0, mu1, bins, mu2=None,
164 shape_function='linear'):
165 """
166 Evaluate concentration at bin geometric midpoints using the chosen closure.
167
168 Parameters
169 ----------
170 mu0 : ndarray [K]
171 mu1 : ndarray [K] or None
172 bins : list of (n_lo, n_hi)
173 mu2 : ndarray [K] or None — second moments (used only by lognormal)
174 shape_function : str — "constant", "linear", or "lognormal"
175
176 Returns
177 -------
178 midpoints : ndarray [K] — geometric midpoint of each bin
179 conc : ndarray [K] — concentration at that midpoint
180 """
181 K = len(bins)
182 midpoints = np.zeros(K)
183 conc = np.zeros(K)
184
185 for k, (nlo, nhi) in enumerate(bins):
186 bw = nhi - nlo
187 if bw <= 0:
188 continue
189 midpoints[k] = np.sqrt(float(nlo) * float(max(nhi - 1, nlo)))
190
191 if bw == 1:
192 conc[k] = max(mu0[k], 0.0)
193 continue
194
195 if shape_function == 'constant':
196 conc[k] = max(mu0[k] / float(bw), 0.0)
197
198 elif shape_function == 'lognormal' and mu2 is not None and mu0[k] > 0:
199 n_bar = mu1[k] / max(mu0[k], 1e-300)
200 n2_bar = mu2[k] / max(mu0[k], 1e-300)
201 ratio = n2_bar / max(n_bar * n_bar, 1e-300)
202 if ratio > 1.0 + 1e-12:
203 sig2 = np.log(ratio)
204 m_k = np.log(max(n_bar, 1e-300)) - 0.5 * sig2
205 n_mid = midpoints[k]
206 ln_n = np.log(n_mid)
207 conc[k] = max(mu0[k] / float(bw)
208 * np.exp(-(ln_n - m_k)**2 / (2.0 * sig2)), 0.0)
209 else:
210 # Fallback to linear
211 mu1_k = mu1[k] if mu1 is not None else mu0[k] * (nlo + nhi - 1) / 2.0
212 ns = np.arange(nlo, nhi, dtype=float)
213 S1, S2 = np.sum(ns), np.sum(ns * ns)
214 det = bw * S2 - S1 * S1
215 if abs(det) < 1e-300:
216 conc[k] = max(mu0[k] / bw, 0.0)
217 else:
218 n_mid = midpoints[k]
219 phi0 = (S2 - S1 * n_mid) / det
220 phi1 = (bw * n_mid - S1) / det
221 conc[k] = max(phi0 * mu0[k] + phi1 * mu1_k, 0.0)
222
223 else: # 'linear' (default / hat-function)
224 mu1_k = mu1[k] if mu1 is not None else mu0[k] * (nlo + nhi - 1) / 2.0
225 n_mid = midpoints[k]
226 ns = np.arange(nlo, nhi, dtype=float)
227 S1 = np.sum(ns)
228 S2 = np.sum(ns * ns)
229 det = bw * S2 - S1 * S1
230 if abs(det) < 1e-300:
231 conc[k] = max(mu0[k] / bw, 0.0)
232 else:
233 phi0 = (S2 - S1 * n_mid) / det
234 phi1 = (bw * n_mid - S1) / det
235 conc[k] = max(phi0 * mu0[k] + phi1 * mu1_k, 0.0)
236
237 return midpoints, conc
238
239
240def distribution_from_moments_hat(mu0, mu1, bins, N_max):
241 """
242 Reconstruct per-size concentrations from moments using a linear
243 dual-basis that exactly preserves both μ₀ and μ₁.
244
245 Shape functions (linear dual basis):
246 φ₀(n) = (S₂ − S₁·n) / Δ
247 φ₁(n) = (bw·n − S₁) / Δ
248 where
249 S₁ = Σ n, S₂ = Σ n², Δ = bw·S₂ − S₁²
250
251 This guarantees:
252 Σ φ₀ = 1, Σ φ₁ = 0 → Σ cₙ = μ₀
253 Σ n·φ₀ = 0, Σ n·φ₁ = 1 → Σ n·cₙ = μ₁
254
255 Negative values are clamped to 0 (breaks exact preservation for
256 strongly skewed bins, but keeps concentrations physical).
257
258 Parameters
259 ----------
260 mu0, mu1 : ndarray [K_bins]
261 bins : list of (n_lo, n_hi)
262 N_max : int
263
264 Returns
265 -------
266 c_n : ndarray [N_max]
267 """
268 c_n = np.zeros(N_max)
269 for k, (nlo, nhi) in enumerate(bins):
270 bw = float(nhi - nlo)
271 if bw <= 0:
272 continue
273 if bw == 1:
274 # Single-size bin: c_n = mu0
275 idx = nlo - 1
276 if 0 <= idx < N_max:
277 c_n[idx] = max(mu0[k], 0.0)
278 continue
279 ns = np.arange(nlo, nhi, dtype=float)
280 S1 = np.sum(ns)
281 S2 = np.sum(ns * ns)
282 det = bw * S2 - S1 * S1
283 if abs(det) < 1e-300:
284 # Degenerate bin — fall back to piecewise-constant
285 c_n_val = mu0[k] / bw
286 idx = (ns - 1).astype(int)
287 valid = (idx >= 0) & (idx < N_max)
288 c_n[idx[valid]] = max(c_n_val, 0.0)
289 continue
290 phi0 = (S2 - S1 * ns) / det
291 phi1 = (bw * ns - S1) / det
292 vals = phi0 * mu0[k] + phi1 * mu1[k]
293 idx = (ns - 1).astype(int)
294 valid = (idx >= 0) & (idx < N_max)
295 c_n[idx[valid]] = np.maximum(vals[valid], 0.0)
296 return c_n
297
298
299def distribution_from_moments_lognormal(mu0, mu1, mu2, bins, N_max):
300 """
301 Reconstruct per-size concentrations from moments using a log-normal
302 intra-bin shape function (Eq. 213-216).
303
304 Within each bin B_k, the distribution is assumed to follow:
305 c_n ∝ (1/n) exp[−(ln n − m_k)² / (2σ_k²)]
306
307 The two log-normal parameters are determined from the moments:
308 n_bar = μ₁/μ₀
309 n2_bar = μ₂/μ₀
310 σ_k² = ln(n2_bar / n_bar²) = ln(μ₂·μ₀ / μ₁²)
311 m_k = ln(n_bar) − σ_k²/2
312
313 The amplitude is set so that Σ_{n∈B_k} c_n = μ₀ exactly.
314
315 Falls back to linear (hat-function) closure when σ_k² ≤ 0
316 (monodisperse limit), and to piecewise-constant when the bin
317 has only one size.
318
319 Parameters
320 ----------
321 mu0, mu1, mu2 : ndarray [K_bins]
322 bins : list of (n_lo, n_hi)
323 N_max : int
324
325 Returns
326 -------
327 c_n : ndarray [N_max]
328 """
329 c_n = np.zeros(N_max)
330 for k, (nlo, nhi) in enumerate(bins):
331 bw = nhi - nlo
332 if bw <= 0 or mu0[k] <= 0.0:
333 continue
334 if bw == 1:
335 idx = nlo - 1
336 if 0 <= idx < N_max:
337 c_n[idx] = max(mu0[k], 0.0)
338 continue
339
340 ns = np.arange(nlo, nhi, dtype=float)
341 n_bar = mu1[k] / max(mu0[k], 1e-300)
342 n2_bar = mu2[k] / max(mu0[k], 1e-300)
343
344 # σ² = ln(μ₂·μ₀ / μ₁²); degenerate when ≤ 0
345 ratio = n2_bar / max(n_bar * n_bar, 1e-300)
346 if ratio <= 1.0 + 1e-12:
347 # Monodisperse or invalid — fall back to linear (hat-function)
348 S1 = np.sum(ns)
349 S2 = np.sum(ns * ns)
350 det = float(bw) * S2 - S1 * S1
351 if abs(det) < 1e-300:
352 val = mu0[k] / float(bw)
353 idx = (ns - 1).astype(int)
354 valid = (idx >= 0) & (idx < N_max)
355 c_n[idx[valid]] = max(val, 0.0)
356 else:
357 phi0 = (S2 - S1 * ns) / det
358 phi1 = (float(bw) * ns - S1) / det
359 vals = phi0 * mu0[k] + phi1 * mu1[k]
360 idx = (ns - 1).astype(int)
361 valid = (idx >= 0) & (idx < N_max)
362 c_n[idx[valid]] = np.maximum(vals[valid], 0.0)
363 continue
364
365 sig2 = np.log(ratio)
366 m_k = np.log(max(n_bar, 1e-300)) - 0.5 * sig2
367
368 # Log-normal shape: f(n) = (1/n) exp[−(ln n − m)² / (2σ²)]
369 ln_ns = np.log(ns)
370 log_f = -(ln_ns - m_k)**2 / (2.0 * sig2) - ln_ns
371 # Subtract max for numerical stability
372 log_f -= np.max(log_f)
373 f = np.exp(log_f)
374
375 # Normalize so that Σ f_n = μ₀
376 f_sum = np.sum(f)
377 if f_sum < 1e-300:
378 val = mu0[k] / float(bw)
379 idx = (ns - 1).astype(int)
380 valid = (idx >= 0) & (idx < N_max)
381 c_n[idx[valid]] = max(val, 0.0)
382 continue
383
384 vals = f * (mu0[k] / f_sum)
385 idx = (ns - 1).astype(int)
386 valid = (idx >= 0) & (idx < N_max)
387 c_n[idx[valid]] = np.maximum(vals[valid], 0.0)
388
389 return c_n
390
391
392def reconstruct_distribution(shape_function, mu0, mu1, mu2, bins, N_max,
393 *, smooth_edges=False):
394 """
395 Dispatch to the appropriate reconstruction function.
396
397 Parameters
398 ----------
399 shape_function : str — "constant", "linear", or "lognormal"
400 mu0 : ndarray [K]
401 mu1 : ndarray [K] or None
402 mu2 : ndarray [K] or None
403 bins : list of (n_lo, n_hi)
404 N_max : int
405 smooth_edges : bool, default False
406 If True, post-process the reconstructed distribution by averaging
407 the two values straddling each internal bin boundary (geometric
408 mean in log-space) and rescaling each bin so that
409 Σ_{n ∈ B_k} c_n equals its pre-smoothing sum, preserving μ_k^(0)
410 exactly. Use for visualization only — ODE right-hand sides and
411 conservation diagnostics must pass False.
412
413 Returns
414 -------
415 c_n : ndarray [N_max]
416 """
417 if shape_function == 'constant':
418 c_n = distribution_from_moments_pc(mu0, mu1, bins, N_max)
419 elif shape_function == 'linear':
420 c_n = distribution_from_moments_hat(mu0, mu1, bins, N_max)
421 elif shape_function == 'lognormal':
422 c_n = distribution_from_moments_lognormal(mu0, mu1, mu2, bins, N_max)
423 else:
424 raise ValueError(f"Unknown shape_function='{shape_function}'")
425
426 if smooth_edges:
428 return c_n
429
430
432 """
433 Visualization post-process: kill the staircase jumps at internal bin
434 boundaries while preserving each bin's μ_k^(0) exactly.
435
436 Algorithm:
437 1. Record each bin's pre-smoothing sum (= μ_k^(0) of the closure).
438 2. At each internal boundary, replace both straddling values
439 (last size of bin k, first size of bin k+1) with their
440 geometric mean (or arithmetic mean if either is non-positive).
441 3. Multiplicatively rescale each bin's interior so the sum is
442 restored to step 1.
443
444 Width-1 bins are effectively unchanged (the rescale undoes the mean).
445 First/last bins keep their outer edge untouched.
446 """
447 K = len(bins)
448 if K < 2:
449 return c_n
450 N_max = len(c_n)
451
452 pre_sums = np.empty(K)
453 for k, (nlo, nhi) in enumerate(bins):
454 idx_lo = max(nlo - 1, 0)
455 idx_hi = min(nhi - 1, N_max)
456 pre_sums[k] = np.sum(c_n[idx_lo:idx_hi]) if idx_hi > idx_lo else 0.0
457
458 for k in range(K - 1):
459 n_boundary = bins[k][1] # = bins[k+1][0]
460 i_left = n_boundary - 2 # 0-idx: last size of bin k
461 i_right = n_boundary - 1 # 0-idx: first size of bin k+1
462 if i_left < 0 or i_right >= N_max:
463 continue
464 v_l, v_r = c_n[i_left], c_n[i_right]
465 if v_l > 0.0 and v_r > 0.0:
466 avg = np.sqrt(v_l * v_r)
467 else:
468 avg = 0.5 * (v_l + v_r)
469 c_n[i_left] = avg
470 c_n[i_right] = avg
471
472 for k, (nlo, nhi) in enumerate(bins):
473 idx_lo = max(nlo - 1, 0)
474 idx_hi = min(nhi - 1, N_max)
475 if idx_hi <= idx_lo or pre_sums[k] <= 0.0:
476 continue
477 new_sum = np.sum(c_n[idx_lo:idx_hi])
478 if new_sum > 0.0:
479 c_n[idx_lo:idx_hi] *= pre_sums[k] / new_sum
480 return c_n
481
482
483def distribution_from_moments_continuous(mu0, mu1, bins, N_max):
484 """
485 Reconstruct per-size concentrations with C⁰ continuity across bin edges.
486
487 At each internal bin boundary, the left and right hat-function values
488 are averaged. Within each bin, linear interpolation between the
489 (averaged) edge values replaces the raw hat function. This removes
490 the staircase discontinuities visible in the standard reconstruction
491 while preserving the bin moments to first order.
492
493 Parameters
494 ----------
495 mu0, mu1 : ndarray [K_bins]
496 bins : list of (n_lo, n_hi)
497 N_max : int
498
499 Returns
500 -------
501 c_n : ndarray [N_max]
502 """
503 K = len(bins)
504 c_n = np.zeros(N_max)
505
506 # Step 1: compute raw hat-function value at every bin edge
507 # edge_val_left[k] = value at n_lo of bin k (from bin k)
508 # edge_val_right[k] = value at n_hi-1 of bin k (from bin k, right side)
509 edge_left = np.zeros(K) # c(n_lo) from bin k
510 edge_right = np.zeros(K) # c(n_hi-1) from bin k (last point in bin)
511
512 for k, (nlo, nhi) in enumerate(bins):
513 bw = float(nhi - nlo)
514 if bw <= 0:
515 continue
516 norm = bw * (bw + 1.0) / 2.0
517 # Value at n = nlo (leftmost point)
518 phi0_lo = float(nhi - nlo) / norm
519 phi1_lo = 0.0 / norm # (nlo - nlo) = 0
520 edge_left[k] = phi0_lo * mu0[k] + phi1_lo * mu1[k]
521 # Value at n = nhi - 1 (rightmost point in bin)
522 phi0_hi = 1.0 / norm # (nhi - (nhi-1)) = 1
523 phi1_hi = (nhi - 1.0 - nlo) / norm
524 edge_right[k] = phi0_hi * mu0[k] + phi1_hi * mu1[k]
525
526 # Step 2: average edge values at internal boundaries
527 # At boundary between bin k and k+1:
528 # edge_right[k] is bin k's value at n = n_hi[k] - 1
529 # edge_left[k+1] is bin k+1's value at n = n_lo[k+1] = n_hi[k]
530 # These are adjacent sizes (n_hi-1 and n_hi), so we set:
531 # c(n_hi - 1) = average of edge_right[k] and edge_left[k+1]
532 # and keep the bin's own left edge for the leftmost point.
533 c_left = np.zeros(K) # adjusted value at bin's left edge
534 c_right = np.zeros(K) # adjusted value at bin's right edge
535
536 for k in range(K):
537 c_left[k] = edge_left[k]
538 c_right[k] = edge_right[k]
539
540 # Smooth internal boundaries: average the right-of-k with left-of-k+1
541 for k in range(K - 1):
542 avg = 0.5 * (edge_right[k] + edge_left[k + 1])
543 c_right[k] = avg
544 c_left[k + 1] = avg
545
546 # Step 3: linearly interpolate within each bin between adjusted edges
547 for k, (nlo, nhi) in enumerate(bins):
548 bw = nhi - nlo
549 if bw <= 0:
550 continue
551 ns = np.arange(nlo, nhi, dtype=float)
552 if bw == 1:
553 vals = np.array([c_left[k]])
554 else:
555 # Linear interpolation from c_left at nlo to c_right at nhi-1
556 t = (ns - float(nlo)) / float(bw - 1)
557 vals = c_left[k] * (1.0 - t) + c_right[k] * t
558 idx = (ns - 1).astype(int)
559 valid = (idx >= 0) & (idx < N_max)
560 c_n[idx[valid]] = np.maximum(vals[valid], 0.0)
561
562 return c_n
563
564
566 """
567 Chapter 9 bin-moment ODE system for both SIA and vacancy clusters.
568
569 Both populations are reduced to logarithmic size bins. The intra-bin
570 shape function is controlled by ``shape_function``:
571
572 "constant" — piecewise-constant (1 moment/bin: μ₀)
573 "linear" — hat-function / dual-basis (2 moments/bin: μ₀, μ₁)
574 "lognormal" — log-normal (3 moments/bin: μ₀, μ₁, μ₂)
575
576 State vector layout (example for Case 2, QSS He, P moments/bin):
577 y[0..i_d-1] : discrete SIA
578 y[i_d..i_d+P*I_bin-1] : SIA bin moments
579 y[i_d+P*I_bin..+v_d-1] : discrete VAC
580 y[..+v_d..+v_d+P*V_bin-1] : VAC bin moments
581 y[..] : He state (Q_tot or Q_k)
582
583 Parameters
584 ----------
585 input_data : InputData
586 reaction_rates : ReactionRates
587 """
588
589 def __init__(self, input_data, reaction_rates):
590 self.inp = input_data
591 self.rr = reaction_rates
592
593 I = input_data.I
594 V = input_data.V
595 self.I = I
596 self.V = V
597 # Backward-compat aliases
598 self.N = I
599 self.M = V
600
601 # ── Shape function ────────────────────────────────────────────────
602 sf_raw = str(input_data.reactions.get('shape_function', 'linear')).lower()
603 if sf_raw not in _SHAPE_FUNCTIONS:
604 raise ValueError(
605 f"Unknown shape_function='{sf_raw}'. "
606 f"Must be one of {list(_SHAPE_FUNCTIONS.keys())}.")
607 self.shape_function = sf_raw
608 self.n_mom = _SHAPE_FUNCTIONS[sf_raw] # moments per bin: 1, 2, or 3
609
610 # Discrete and binned sizes
611 self.i_discrete = input_data.i_discrete
612 self.v_discrete = input_data.v_discrete
613
614 # Build SIA bin partition (covers sizes i_discrete+1..I)
615 I_bin_target = input_data.I_bin
616 if I_bin_target > 0 and self.i_discrete < I:
617 # Compute r from target bin count
618 r_i = (float(I) / max(self.i_discrete, 1)) ** (1.0 / I_bin_target)
619 r_i = max(r_i, 1.01) # ensure r > 1
620 self.bins, self.edges = build_bins(
621 I, n1=self.i_discrete + 1, r=r_i)
622 self.I_bin = len(self.bins)
623 else:
624 self.bins, self.edges = [], np.array([I + 1])
625 self.I_bin = 0
626 r_i = 2.0
627 self.r = r_i
628 # Backward-compat aliases
629 self.K = self.I_bin
630 self.K_i = self.I_bin
631 self.n1 = self.i_discrete
632
633 # Build vacancy bin partition (covers sizes v_discrete+1..V)
634 V_bin_target = input_data.V_bin
635 if V_bin_target > 0 and self.v_discrete < V:
636 r_v = (float(V) / max(self.v_discrete, 1)) ** (1.0 / V_bin_target)
637 r_v = max(r_v, 1.01)
639 V, n1=self.v_discrete + 1, r=r_v)
640 self.V_bin = len(self.vac_bins)
641 else:
642 self.vac_bins, self.vac_edges = [], np.array([V + 1])
643 self.V_bin = 0
644 # Backward-compat alias
645 self.K_v = self.V_bin
646
647 # Precompute vacancy bin midpoints for zeroth-moment-only closure
648 self.vac_mid = np.array([(mlo + mhi - 1) / 2.0
649 for mlo, mhi in self.vac_bins])
650
651 # Free He mode: 'dynamic' or 'quasi_steady_state' (mirrors RateEquations)
652 raw = str(input_data.reactions.get('he_kinetics', 'dynamic')).lower()
653 self.he_kinetics = raw
654 self.qss_He = (raw == 'quasi_steady_state')
655
656 # Pre-compute beta_He for QSS (same as RateEquations)
657 kBT_val = input_data.derived['kBT']
658 E_b_hV1 = float(input_data.energetics.get('E_b_hV_1', 2.30))
659 E_m_h = float(input_data.derived['E_m_h'])
660 nu_h = float(input_data.derived['nu_h'])
661 self.beta_He = nu_h * np.exp(-(E_b_hV1 + E_m_h) / kBT_val)
662
663 P = self.n_mom # shorthand for moments per bin
664
665 # ── State-vector index map ────────────────────────────────────────
666 # Layout: [discrete SIA | binned SIA moments | discrete VAC |
667 # binned VAC moments | He state]
668 po = input_data.physics_option
669 n_sia = self.i_discrete + P * self.I_bin # SIA ODEs
670 n_vac = self.v_discrete + P * self.V_bin # VAC ODEs
671
672 self.i_MOM = self.i_discrete # first binned SIA moment index
673 self.i_VAC = n_sia # first vacancy index
674
675 if 'fusion' in po:
676 self.he_mode = 'case1'
677 self.i_Q = n_sia + n_vac # Q_k per vac bin
678 if self.qss_He:
679 n_he = self.V_bin
680 self.i_He = None
681 else:
682 n_he = self.V_bin + 1
683 self.i_He = n_sia + n_vac + self.V_bin
684 else:
685 self.he_mode = 'case2'
686 self.i_Qtot = n_sia + n_vac
687 if self.qss_He:
688 n_he = 1
689 self.i_He = None
690 else:
691 n_he = 2
692 self.i_He = n_sia + n_vac + 1
693
694 # Conservation accounting ODEs (5 cumulative integrals)
695 n_phys = n_sia + n_vac + n_he
696 self.i_J_SIA_fixed = n_phys # cumulative SIA content to fixed sinks
697 self.i_J_SIA_mutual = n_phys + 1 # cumulative SIA to recombination + cavity
698 self.i_J_VAC_fixed = n_phys + 2 # cumulative VAC content to fixed sinks
699 self.i_J_VAC_mutual = n_phys + 3 # cumulative VAC to recombination + cavity
700 self.i_J_He_sink = n_phys + 4 # cumulative He to sinks
701 self.N_eq = n_phys + 5
702
703 # Cascade production
704 # G_He_r sourced from the Excel Reactions sheet (single authoritative
705 # source — see defect_production.production_rates docstring).
706 spectrum = input_data.derived['spectrum']
707 G = input_data.derived['G']
708 G_He_r = input_data.derived['G_He_r']
709 Pr_SIA, Pr_VAC, G_He = production_rates(
710 G, spectrum, I, V, G_He_r,
711 spec_over=(input_data.production_fission if 'fiss' in spectrum.lower()
712 else input_data.production_fusion))
713 self.Pr_SIA = Pr_SIA[1:] # [I], index k → size k+1
714 self.Pr_VAC = Pr_VAC[1:] # [V]
715 self.G_He = G_He
716
717 print(f"BinMomentRateEquations: i_discrete={self.i_discrete}"
718 f" I_bin={self.I_bin} V_bin={self.V_bin} N_eq={self.N_eq}"
719 f" he_mode='{self.he_mode}' r_i={r_i:.3f}"
720 f" shape_function='{self.shape_function}' ({P} mom/bin)")
721 if self.I_bin > 0:
722 print(f" SIA bin edges (first 5): {list(self.edges[:6])}")
723 if self.V_bin > 0:
724 print(f" VAC bin edges (first 5): {list(self.vac_edges[:6])}")
725
726 def compute_c_h_qss(self, c_v, Q_tot=None, Q_m=None):
727 """QSS free He — same formula as RateEquations.compute_c_h_qss."""
728 rr = self.rr
729 sink = np.sum(rr.K_HeV * c_v) + rr.k2_He_scalar
730 if self.he_mode == 'case2':
731 source = self.G_He + self.beta_He * (Q_tot if Q_tot is not None else 0.0)
732 else:
733 He_emit = self.beta_He * np.sum(Q_m) if Q_m is not None else 0.0
734 source = self.G_He + He_emit
735 return source / max(sink, 1e-300)
736
738 """Return y0 — near-zero but consistent moments + concentrations.
739
740 Bin moments are initialized so that μ₁/μ₀ = midpoint and
741 μ₂/μ₀ = midpoint², ensuring the reconstruction places the
742 distribution inside the bin range. Setting all moments to
743 C_floor would give μ₁/μ₀ = 1 (outside bin range), causing
744 garbage reconstruction and immediate CVODE failure.
745
746 The C_floor fallback default (1e-15) must match the value the
747 C++ solver clamps to, so the initial state is consistent with
748 the solver's floor.
749 """
750 C_floor = float(self.inp.reactions.get('C_floor', 1e-15))
751 y0 = np.full(self.N_eq, C_floor)
752 P = self.n_mom
753
754 # SIA bin moments: μ₀ = C_floor, μ₁ = C_floor × midpoint, ...
755 for k, (nlo, nhi) in enumerate(self.bins):
756 mid = 0.5 * (nlo + nhi - 1)
757 idx = self.i_discrete + P * k
758 y0[idx] = C_floor # μ₀
759 if P >= 2:
760 y0[idx + 1] = C_floor * mid # μ₁
761 if P >= 3:
762 y0[idx + 2] = C_floor * mid * mid # μ₂
763
764 # Vacancy bin moments: same consistent initialization
765 iv = self.i_VAC
766 for k, (mlo, mhi) in enumerate(self.vac_bins):
767 mid = 0.5 * (mlo + mhi - 1)
768 idx = iv + self.v_discrete + P * k
769 y0[idx] = C_floor # μ₀
770 if P >= 2:
771 y0[idx + 1] = C_floor * mid # μ₁
772 if P >= 3:
773 y0[idx + 2] = C_floor * mid * mid # μ₂
774
775 return y0
776
777 def _unpack_moments(self, y_block, K_bin):
778 """Extract mu0, mu1, mu2 from a contiguous block of P*K_bin values."""
779 P = self.n_mom
780 mu0 = np.maximum(y_block[0::P][:K_bin], 0.0)
781 mu1 = y_block[1::P][:K_bin] if P >= 2 else None
782 mu2 = y_block[2::P][:K_bin] if P >= 3 else None
783 return mu0, mu1, mu2
784
785 def _project_to_moments(self, dc, bins, N_max, dydt, offset, K_bin):
786 """Project per-size rates dc onto P moments per bin in dydt."""
787 P = self.n_mom
788 for k, (nlo, nhi) in enumerate(bins):
789 ns = np.arange(nlo, nhi)
790 idx = ns - 1
791 valid = (idx >= 0) & (idx < N_max)
792 dc_valid = dc[idx[valid]]
793 n_valid = ns[valid].astype(float)
794 dydt[offset + P * k] = np.sum(dc_valid) # dμ₀/dt
795 if P >= 2:
796 dydt[offset + P * k + 1] = np.sum(n_valid * dc_valid) # dμ₁/dt
797 if P >= 3:
798 dydt[offset + P * k + 2] = np.sum(n_valid * n_valid * dc_valid) # dμ₂/dt
799
800 def ode_system(self, t, y):
801 """ODE RHS using hybrid discrete + bin-moment layout.
802
803 REFERENCE ONLY — not used by the solver; the production RHS is the
804 C++ implementation. May be stale. Scheduled for replacement by the
805 Stage-3 graph-walker.
806 """
807 I_bin = self.I_bin
808 V_bin = self.V_bin
809 V = self.V
810 I = self.I
811 i_d = self.i_discrete
812 v_d = self.v_discrete
813 rr = self.rr
814 inp = self.inp
815 P = self.n_mom
816 sf = self.shape_function
817 dydt = np.zeros(self.N_eq)
818
819 # ── Unpack SIA: discrete + binned → full c_n[0..I-1] ─────────────
820 c_n = np.zeros(I)
821 # Discrete sizes 1..i_discrete
822 c_n[:i_d] = np.maximum(y[:i_d], 0.0)
823 # Binned sizes i_discrete+1..I (from moments)
824 if I_bin > 0:
825 mom_start = i_d
826 sia_mu0, sia_mu1, sia_mu2 = self._unpack_moments(
827 y[mom_start:mom_start + P * I_bin], I_bin)
828 c_binned = reconstruct_distribution(
829 sf, sia_mu0, sia_mu1, sia_mu2, self.bins, I)
830 c_n[i_d:] = c_binned[i_d:]
831
832 # ── Unpack VAC: discrete + binned → full c_v[0..V-1] ─────────────
833 iv = self.i_VAC
834 c_v = np.zeros(V)
835 # Discrete sizes 1..v_discrete
836 c_v[:v_d] = np.maximum(y[iv:iv + v_d], 0.0)
837 # Binned sizes v_discrete+1..V (from tracked moments)
838 if V_bin > 0:
839 vac_start = iv + v_d
840 vac_mu0, vac_mu1, vac_mu2 = self._unpack_moments(
841 y[vac_start:vac_start + P * V_bin], V_bin)
842 c_v_binned = reconstruct_distribution(
843 sf, vac_mu0, vac_mu1, vac_mu2, self.vac_bins, V)
844 c_v[v_d:] = c_v_binned[v_d:]
845
846 # ── He state ──────────────────────────────────────────────────────
847 if self.he_mode == 'case2':
848 Q_tot = max(y[self.i_Qtot], 0.0)
849 Q_k = None
850 if self.qss_He:
851 c_h = self.compute_c_h_qss(c_v, Q_tot=Q_tot)
852 else:
853 c_h = max(y[self.i_He], 0.0)
854 else:
855 Q_k = np.maximum(y[self.i_Q:self.i_Q + V_bin], 0.0)
856 Q_tot = np.sum(Q_k)
857 if self.qss_He:
858 c_h = self.compute_c_h_qss(c_v, Q_m=Q_k)
859 else:
860 c_h = max(y[self.i_He], 0.0)
861
862 ci1 = c_n[0] if len(c_n) > 0 else 0.0
863 cv1 = c_v[0]
864 i_mobile = inp.derived['i_mobile']
865 reflect = inp.derived.get('boundary_flux', 'absorption') == 'reflection'
866 m13 = np.arange(1.0, V + 1) ** (1.0 / 3.0)
867 denom_m = 1.0 + rr.B_rot * rr.L_hat**2 / m13
868
869 # ══════════════════════════════════════════════════════════════════
870 # SIA per-size dc_n/dt (full per-size, then project)
871 # ══════════════════════════════════════════════════════════════════
872 dc_n = np.zeros(I)
873
874 dc_n += self.Pr_SIA
875 dc_n[:-1] += rr.G_SIA[1:] * c_n[1:]
876 dc_n -= rr.G_SIA * c_n
877 dc_n[1:] += rr.K_SIA_grow[:-1] * ci1 * c_n[:-1] # gain at n+1
878 dc_n -= rr.K_SIA_grow * ci1 * c_n # target loss
879 if reflect: # suppress I_1 + I_I → I_{I+1} (reaction blocked at wall)
880 dc_n[-1] += rr.K_SIA_grow[-1] * ci1 * c_n[-1] # undo target loss
881 dc_n[0] += ci1 * rr.K_SIA_grow[-1] * c_n[-1] # undo monomer depletion
882 # Monomer projectile depletion
883 dc_n[0] -= ci1 * np.dot(rr.K_SIA_grow, c_n)
884
885 # V–I annihilation
886 dc_n[0] -= rr.K_iv * cv1 * ci1
887 dc_n[1:] -= rr.K_SIA_shrink[1:] * cv1 * c_n[1:]
888 dc_n[:-1] += rr.K_SIA_shrink[1:] * cv1 * c_n[1:]
889
890 # SIA cluster–cavity absorption
891 sum_K3D_cv_m2 = rr.K_3D_cav_pref * np.dot(m13[1:], c_v[1:])
892 dc_n[0] -= c_n[0] * sum_K3D_cv_m2
893 sum_K3D_cv = rr.K_3D_cav_pref * np.dot(m13, c_v)
894 for n in range(2, min(4, i_mobile + 1)):
895 dc_n[n - 1] -= c_n[n - 1] * sum_K3D_cv
896 for n in range(4, min(I, i_mobile) + 1):
897 k_pref = rr.K_1D_pref[n - 1]
898 if k_pref < 1e-300:
899 continue
900 dc_n[n - 1] -= c_n[n - 1] * k_pref * np.dot(m13 / denom_m, c_v)
901
902 dc_n -= rr.k2_SIA * c_n
903
904 # ── Project dc_n into dydt ───────────────────────────────────────
905 # Discrete sizes: direct copy
906 dydt[:i_d] = dc_n[:i_d]
907 # Binned sizes: project onto P moments per bin
908 self._project_to_moments(dc_n, self.bins, I, dydt, i_d, I_bin)
909
910 # ══════════════════════════════════════════════════════════════════
911 # Vacancy per-size dc_v/dt → project onto vacancy bin moments
912 # ══════════════════════════════════════════════════════════════════
913 C_vac_tot = np.sum(c_v)
914 ell_bar = Q_tot / max(C_vac_tot, 1e-200)
915 G_VAC_eff = rr.G_VAC.copy()
916 if ell_bar > 0.01:
917 for m in range(1, min(V, 20) + 1):
918 G_VAC_eff[m - 1] = rr.alpha_bubble_fn(m, ell_bar * m**(2.0/3.0))
919
920 dc_v = np.zeros(V)
921
922 # Production
923 dc_v += self.Pr_VAC
924
925 # Thermal vacancy emission (gain + loss)
926 # V_m → V_{m-1} + V_1: loss at m, gain at m-1 (residual), gain at 1 (emitted monomer)
927 dc_v -= G_VAC_eff * c_v
928 dc_v[:-1] += G_VAC_eff[1:] * c_v[1:]
929 dc_v[0] += np.sum(G_VAC_eff[1:] * c_v[1:]) # emitted monomers
930
931 # V–V monomer growth (gain + loss)
932 dc_v -= rr.K_VAC_grow * cv1 * c_v # target loss
933 dc_v[1:] += rr.K_VAC_grow[:-1] * cv1 * c_v[:-1] # gain at m+1
934 if reflect: # suppress V_1 + V_V → V_{V+1} (reaction blocked at wall)
935 dc_v[-1] += rr.K_VAC_grow[-1] * cv1 * c_v[-1] # undo target loss
936 dc_v[0] += cv1 * rr.K_VAC_grow[-1] * c_v[-1] # undo monomer depletion
937 # Vacancy monomer projectile depletion
938 dc_v[0] -= cv1 * np.dot(rr.K_VAC_grow, c_v)
939
940 # SIA-induced cavity shrinkage
941 dc_v[0] -= rr.K_iv * ci1 * cv1
942 if V >= 2:
943 dc_v[0] += rr.K_VAC_shrink[1] * ci1 * c_v[1]
944 dc_v[1:] -= rr.K_VAC_shrink[1:] * ci1 * c_v[1:]
945 if V >= 3:
946 dc_v[1:-1] += rr.K_VAC_shrink[2:] * ci1 * c_v[2:]
947 for n in range(2, min(4, i_mobile + 1)):
948 cn = c_n[n - 1]
949 dc_v -= rr.K_VAC_shrink * cn * c_v
950 if n < V:
951 dc_v[:V - n] += rr.K_VAC_shrink[n:] * cn * c_v[n:]
952 m_arr = np.arange(1.0, V + 1)
953 for n in range(4, min(I, i_mobile) + 1):
954 k_pref = rr.K_1D_pref[n - 1]
955 if k_pref < 1e-300:
956 continue
957 cn = c_n[n - 1]
958 k_loss = k_pref * m13 / denom_m
959 dc_v -= k_loss * cn * c_v
960 if n < V:
961 mp = m_arr[:V - n] + float(n)
962 mp13 = mp ** (1.0 / 3.0)
963 denom = 1.0 + rr.B_rot * rr.L_hat**2 / mp13
964 dc_v[:V - n] += (k_pref * mp13 / denom) * cn * c_v[n:]
965
966 # Vacancy monomer consumed by SIA loop shrinkage:
967 # I_n + V_1 → I_{n-1} for n≥2 (n=1 already in K_iv)
968 dc_v[0] -= cv1 * np.dot(rr.K_SIA_shrink[1:], c_n[1:])
969
970 # Fixed sinks — only mobile vacancy clusters (v ≤ v_mobile)
971 v_mobile = self.inp.derived['v_mobile']
972 mask_mobile_v = np.zeros(V)
973 mask_mobile_v[:min(v_mobile, V)] = 1.0
974 dc_v -= rr.k2_vac_scalar * c_v * mask_mobile_v
975
976 # ── Project dc_v into dydt ───────────────────────────────────────
977 # Discrete sizes: direct copy
978 dydt[iv:iv + v_d] = dc_v[:v_d]
979 # Binned sizes: project onto P moments per bin
980 vac_mom_start = iv + v_d
981 self._project_to_moments(dc_v, self.vac_bins, V, dydt,
982 vac_mom_start, V_bin)
983
984 # ══════════════════════════════════════════════════════════════════
985 # He equations
986 # ══════════════════════════════════════════════════════════════════
987 if self.he_mode == 'case2':
988 He_uptake = np.sum(rr.K_HeV * c_h * c_v)
989 He_release = rr.alpha_He * Q_tot if hasattr(rr, 'alpha_He') else 0.0
990 # He lost only from mobile voids reaching fixed sinks
991 ms_arr = np.arange(1.0, V + 1.0)
992 ell_m_arr = ell_bar * ms_arr ** (2.0 / 3.0)
993 He_sink = rr.k2_vac_scalar * np.sum(ell_m_arr[:v_mobile] * c_v[:v_mobile])
994 dydt[self.i_Qtot] = He_uptake - He_release - He_sink
995 if not self.qss_He:
996 dydt[self.i_He] = (self.G_He
997 - He_uptake
998 - rr.k2_He_scalar * c_h
999 + He_release)
1000 else:
1001 # Case 1: Q_k per vacancy bin (one per bin)
1002 dQ = dydt[self.i_Q:self.i_Q + V_bin]
1003 He_cap_total = 0.0
1004 He_emit_total = 0.0
1005 for kv, (mlo, mhi) in enumerate(self.vac_bins):
1006 mu0_kv = max(vac_mu0[kv], 1e-200)
1007 ell_kv = Q_k[kv] / mu0_kv
1008 for m in range(mlo, mhi):
1009 mi = m - 1
1010 if mi < 0 or mi >= V:
1011 continue
1012 cm = c_v[mi]
1013 he_cap = rr.K_HeV[mi] * c_h * cm
1014 alpha_h = rr.alpha_He_emit_fn(m, max(int(round(ell_kv)), 0))
1015 q_m_approx = ell_kv * cm
1016 he_emit = alpha_h * q_m_approx
1017 dQ[kv] += he_cap - he_emit
1018 if m <= v_mobile: # only mobile voids
1019 dQ[kv] -= rr.k2_vac_scalar * q_m_approx
1020 He_cap_total += he_cap
1021 He_emit_total += he_emit
1022 if not self.qss_He:
1023 dydt[self.i_He] = (self.G_He
1024 - He_cap_total
1025 - rr.k2_He_scalar * c_h
1026 + He_emit_total)
1027
1028 # ══════════════════════════════════════════════════════════════════
1029 # Conservation accounting ODEs (cumulative integrals, exact via ODE)
1030 # ══════════════════════════════════════════════════════════════════
1031
1032 # (1) SIA content to fixed sinks: Σ_n n · k2_SIA[n] · c_n
1033 ns_all = np.arange(1.0, I + 1)
1034 dydt[self.i_J_SIA_fixed] = np.dot(ns_all, rr.k2_SIA * c_n)
1035
1036 # (2) SIA content to mutual annihilation (recombination + cavity):
1037 # Channel (a): mobile SIA clusters absorbed by cavities
1038 # Channel (b): mobile vacancies shrinking SIA clusters
1039 mutual = rr.K_iv * ci1 * cv1 # V_1+I_1 recomb (1 SIA)
1040 sum_K3D_cv_m2 = rr.K_3D_cav_pref * np.dot(m13[1:], c_v[1:])
1041 mutual += ci1 * sum_K3D_cv_m2 # I_1 → cavities m≥2 (1 SIA)
1042 mutual += cv1 * np.sum(rr.K_SIA_shrink[1:] * c_n[1:]) # V_1+I_n→I_{n-1} (1 SIA each)
1043 for n in range(2, min(4, i_mobile + 1)):
1044 mutual += n * c_n[n-1] * rr.K_3D_cav_pref * np.dot(m13, c_v)
1045 for n in range(4, min(I, i_mobile) + 1):
1046 k_pref = rr.K_1D_pref[n-1]
1047 if k_pref < 1e-300:
1048 continue
1049 mutual += n * c_n[n-1] * k_pref * np.dot(m13 / denom_m, c_v)
1050 dydt[self.i_J_SIA_mutual] = mutual
1051
1052 # (3) VAC content to fixed sinks: Σ_{m≤v_mobile} m · k2_vac · c_m
1053 vm = min(v_mobile, V)
1054 ms_v = np.arange(1.0, vm + 1)
1055 dydt[self.i_J_VAC_fixed] = rr.k2_vac_scalar * np.dot(ms_v, c_v[:vm])
1056
1057 # (3b) VAC content to mutual annihilation:
1058 # When I_n + V_{m'} react, vacancies destroyed = min(m', n) = SIA destroyed.
1059 # Therefore J_VAC_mutual = J_SIA_mutual (matches C++ implementation).
1060 dydt[self.i_J_VAC_mutual] = mutual # J_VAC_mutual = J_SIA_mutual (physically correct)
1061
1062 # (4) He to sinks: k2_He · c_h + k2_vac · Σ_{m≤v_mobile} ℓ_m · c_m
1063 sum_cv = np.sum(c_v)
1064 he_sink = rr.k2_He_scalar * c_h
1065 if sum_cv > 1e-300 and Q_tot > 0:
1066 ell_bar_loc = Q_tot / sum_cv
1067 ell_m_v = ell_bar_loc * np.arange(1.0, vm + 1) ** (2.0/3.0)
1068 he_sink += rr.k2_vac_scalar * np.dot(ell_m_v, c_v[:vm])
1069 dydt[self.i_J_He_sink] = he_sink
1070
1071 return dydt
1072
1073 def _extract_mu1_content(self, y, offset, K_bin, bins):
1074 """Extract first-moment content from state vector.
1075
1076 For P >= 2, μ₁ is tracked explicitly (stride P, offset 1).
1077 For P == 1 (constant closure), μ₁ is approximated as μ₀ × midpoint.
1078 """
1079 P = self.n_mom
1080 if P >= 2:
1081 return np.sum(y[offset + 1::P][:K_bin])
1082 else:
1083 # Approximate: μ₁ ≈ μ₀ × arithmetic midpoint
1084 content = 0.0
1085 for k, (nlo, nhi) in enumerate(bins):
1086 mu0_k = max(y[offset + k], 0.0)
1087 content += mu0_k * (nlo + nhi - 1) / 2.0
1088 return content
1089
1090 def conservation_diagnostic(self, t, y, G_tot):
1091 """
1092 FP conservation diagnostic for hybrid discrete + bin-moment system.
1093
1094 δ_FP^bin = |SIA_content − VAC_content| / (G·t)
1095 """
1096 i_d = self.i_discrete
1097 v_d = self.v_discrete
1098 iv = self.i_VAC
1099
1100 # SIA content: discrete + binned first moments
1101 ns_disc = np.arange(1, i_d + 1, dtype=float)
1102 SIA_content = np.dot(ns_disc, np.maximum(y[:i_d], 0.0))
1103 if self.I_bin > 0:
1104 SIA_content += self._extract_mu1_content(
1105 y, i_d, self.I_bin, self.bins)
1106
1107 # VAC content: discrete + binned first moments
1108 ms_disc = np.arange(1, v_d + 1, dtype=float)
1109 VAC_content = np.dot(ms_disc, np.maximum(y[iv:iv + v_d], 0.0))
1110 if self.V_bin > 0:
1111 VAC_content += self._extract_mu1_content(
1112 y, iv + v_d, self.V_bin, self.vac_bins)
1113
1114 delta_J = abs(SIA_content - VAC_content)
1115 denom = max(G_tot * max(t, 1e-20), 1e-300)
1116 return delta_J / denom
_project_to_moments(self, dc, bins, N_max, dydt, offset, K_bin)
compute_c_h_qss(self, c_v, Q_tot=None, Q_m=None)
distribution_from_moments_hat(mu0, mu1, bins, N_max)
reconstruct_distribution(shape_function, mu0, mu1, mu2, bins, N_max, *smooth_edges=False)
build_bins(N_max, n1=1, r=2.0)
distribution_from_moments_lognormal(mu0, mu1, mu2, bins, N_max)
midpoint_distribution_from_moments(mu0, mu1, bins, mu2=None, shape_function='linear')
n_moments_for_shape(shape_function)
moments_from_distribution(c_n, bins, n_mom=2)
distribution_from_moments_continuous(mu0, mu1, bins, N_max)
distribution_from_moments_pc(mu0, mu1, bins, N_max)