RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
declaration.py
Go to the documentation of this file.
1"""
2materials/eurofer97/declaration.py — EUROFER-97 RAG declaration (Layer 2).
3
4Builds the host-specific reaction admissibility graph G_Eur and its
5contiguous ODE-index :class:`StateLayout` for the reduced-activation
6ferritic-martensitic steel EUROFER-97, following Ghoniem (2026),
7*A Generalized Graph-Based Cluster Dynamics Framework for Irradiated
8Materials*, Section 4.
9
10This module is a *declaration*, not an integrator. It
11
12 * declares the EUROFER-97 populations (Eqs. 42-46): one bulk SIA
13 population and one bulk vacancy population, plus helium as the single
14 resolved gas (Eq. 41);
15 * adds one :class:`Edge` family per EUROFER process class P1-P8
16 (Section 4.3), each mapped to one of the ten abstract
17 :class:`EdgeClass` values;
18 * registers, for every edge, the size-resolved rate kernel as the
19 *precomputed numpy array* already built by :class:`ReactionRates`
20 (the abstract core never re-derives a rate); and
21 * builds the :class:`StateLayout` that packs the SIA ladder, the
22 vacancy ladder, the helium block, and a small conservation-accounting
23 aux block into one flat ODE state vector.
24
25The production solver (C++/CVODE) remains the engine; this layer is the
26"subclass" in the paper's abstract-base-class analogy — it says *what*
27EUROFER-97 is, never *how* to integrate it.
28"""
29from __future__ import annotations
30
31import numpy as np
32
33from ...core import (
34 Polarity,
35 Population,
36 EdgeClass,
37 Edge,
38 ReactionAdmissibilityGraph,
39 StateLayout,
40 BinMomentReduction,
41 HeReductionMode,
42)
43from ...defect_production import production_rates
44from ...binding_energies import Gamma_TM, Gamma_res, ell_max
45
46
47# ─────────────────────────────────────────────────────────────────────────────
48# Rate-kernel construction helpers
49#
50# ReactionRates precomputes 1-D size-resolved arrays (0-indexed by size-1) and
51# a handful of scalars. The abstract GROWTH / SHRINKAGE / DISSOCIATION / SINK
52# / SOURCE edge classes consume a per-size array (or a broadcastable scalar)
53# directly. COALESCENCE and ANNIHILATION need a 2-D kernel K[n-1, n'-1]; we
54# assemble those here from the precomputed effective diffusivities so that no
55# rate physics is re-derived — only the precomputed pieces are recombined into
56# the shape the graph walker expects (paper Eqs. 79-81).
57# ─────────────────────────────────────────────────────────────────────────────
58
59def _xi(n: np.ndarray) -> np.ndarray:
60 """Capture radius in lattice units, xi_n = (3 n / 8 pi)^(1/3) (paper Eq. 79)."""
61 return (3.0 * n / (8.0 * np.pi)) ** (1.0 / 3.0)
62
63
64def _coalescence_kernel(D_eff: np.ndarray, A_pref: float) -> np.ndarray:
65 """Same-polarity binary-growth kernel K[n-1, n'-1] (paper Eqs. 79-80).
66
67 K_{n,n'} = 8 pi (xi_n + xi_n') (D_n + D_n') / Omega^(2/3)
68
69 ``D_eff`` is the precomputed effective 3-D diffusivity array (mobile
70 sizes non-zero, sessile sizes zero) from :class:`ReactionRates`;
71 ``A_pref`` is ``8 pi / Omega^(2/3)`` assembled from the precomputed
72 ``A_sph_inv_O23`` geometric prefactor. Sessile-sessile pairs vanish
73 automatically because their diffusivities are zero.
74 """
75 n = np.arange(1, D_eff.size + 1, dtype=float)
76 xi = _xi(n)
77 xi_sum = xi[:, None] + xi[None, :]
78 D_sum = D_eff[:, None] + D_eff[None, :]
79 return A_pref * xi_sum * D_sum
80
81
82def _absorb_kernel(D_partner: np.ndarray, A_pref: float) -> np.ndarray:
83 """Cross-character absorption kernel K[m-1, n-1] (Marian growth, P3-like).
84
85 For ``<100>_m + <111>_n -> <100>_{m+n}`` (paper Eqs. 79/81 form):
86
87 K_{m,n} = 8 pi (xi_m + xi_n) (D^{100}_m + D^{111}_n) / Omega^(2/3)
88
89 The sessile ⟨100⟩ primary contributes zero diffusivity, so only the mobile
90 ½⟨111⟩ partner ``D_partner = D_SIA_eff`` drives the capture. Primary axis
91 m = ⟨100⟩ size, partner axis n = ½⟨111⟩ size (both span 1..I).
92 """
93 n = np.arange(1, D_partner.size + 1, dtype=float)
94 xi = _xi(n)
95 xi_sum = xi[:, None] + xi[None, :]
96 return A_pref * xi_sum * D_partner[None, :]
97
98
99def _annihilation_kernel(D_v: np.ndarray, D_i: np.ndarray,
100 A_pref: float) -> np.ndarray:
101 """Cross-polarity V-I annihilation kernel K[m-1, n-1] (paper Eq. 81).
102
103 K_{m,n} = 8 pi (xi_m + xi_n) (D_m^v + D_n^i) / Omega^(2/3)
104
105 The primary axis is the vacancy population (size m), the partner axis
106 is the SIA population (size n), matching the EUROFER ANNIHILATION edge
107 declared below (vacancy population as ``population``, SIA population as
108 ``partner_population``).
109 """
110 m = np.arange(1, D_v.size + 1, dtype=float)
111 n = np.arange(1, D_i.size + 1, dtype=float)
112 xi_sum = _xi(m)[:, None] + _xi(n)[None, :]
113 D_sum = D_v[:, None] + D_i[None, :]
114 return A_pref * xi_sum * D_sum
115
116
117def _source_array(P_indexed: np.ndarray) -> np.ndarray:
118 """Convert a 1-indexed production array P[size] to a 0-indexed size array.
119
120 :func:`production_rates` returns arrays of length ``n_max + 1`` whose
121 index 0 is unused and index k holds the size-k production rate. The
122 SOURCE edge class consumes a 0-indexed size array (size n -> [n-1]).
123 """
124 return np.asarray(P_indexed[1:], dtype=float)
125
126
127def _trap_mutation_kernel(V: int, T: float) -> np.ndarray:
128 """Size-resolved trap-mutation rate kernel Gamma_TM[m-1] (paper Eq. 75 / P7).
129
130 Trap mutation V_m(He) -> V_{m+1} + I_1 is gas-pressure-driven; its rate
131 ``Gamma_TM(m, ell)`` (binding_energies, paper Eq. 75) depends on both the
132 vacancy size ``m`` and the helium loading ``ell``. This declaration layer
133 carries one bulk vacancy population without an explicit (m, ell)
134 occupancy axis, so the loading-resolved barrier table cannot be collapsed
135 to a 1-D size array without picking a representative ``ell`` per ``m``.
136
137 We register the *spontaneous-mutation envelope*: for each vacancy size m
138 we evaluate ``Gamma_TM`` at the maximum admissible helium loading
139 ``ell = ell_max(m)`` (the trap-mutation limit, paper Table 27). Where
140 that (m, ell) pair is absent from the barrier table (``E_TM`` table only
141 populates the small over-pressurized bubbles He5-7V1 / He4-6), the rate
142 is exactly zero — so the returned array is non-zero only on the handful
143 of sizes for which an atomistic trap-mutation barrier is known and zero
144 elsewhere. This is a faithful placeholder: the C++ solver does not yet
145 evaluate P7 (Stage-2 review), so no run consumes a non-zero value here;
146 the array documents *where* trap mutation is admissible.
147 """
148 m = np.arange(1, V + 1, dtype=int)
149 return np.array([Gamma_TM(int(mi), ell_max(int(mi)), T) for mi in m],
150 dtype=float)
151
152
153def _resolution_kernel(V: int, phi_dot: float, b0: float) -> np.ndarray:
154 """Size-resolved radiation re-solution rate kernel Gamma_res[m-1] (P8).
155
156 Radiation re-solution He_ell V_m -> He_{ell-1} V_m + He detraps one
157 helium atom per displacement event; its rate ``Gamma_res(ell) = b0 *
158 ell * phi_dot`` (binding_energies, paper Eq. 75 / Section 5.3) is
159 proportional to the helium loading ``ell``, not the vacancy size ``m``.
160
161 Without an explicit (m, ell) occupancy axis the per-``ell`` factor
162 cannot be resolved here; we register the *per-He-atom* re-solution
163 coefficient ``b0 * phi_dot`` broadcast over the vacancy size axis (so
164 the C++ kernel arithmetic multiplies it by the local loading ell when
165 P8 is implemented). If the displacement rate or b0 is unavailable the
166 array degrades to all-zero, which cleanly disables P8.
167 """
168 coeff = float(b0) * float(phi_dot)
169 return np.full(V, coeff, dtype=float)
170
171
172# ─────────────────────────────────────────────────────────────────────────────
173# The EUROFER-97 RAG declaration
174# ─────────────────────────────────────────────────────────────────────────────
175
176def build_eurofer_rag(input_data, reaction_rates, *,
177 equations: str = "discrete",
178 cascade: str = "fission"):
179 """Build the EUROFER-97 reaction admissibility graph and state layout.
180
181 Parameters
182 ----------
183 input_data : InputData
184 The loaded EUROFER-97 parameter set (sizes ``I``/``V``, mobility
185 cutoffs ``i_mobile``/``v_mobile``, derived physics).
186 reaction_rates : ReactionRates
187 The precomputed rate-constant arrays built from ``input_data``.
188 Its attributes are registered verbatim as the RAG kernel library.
189 equations : {'discrete', 'bin_moment'}
190 Selects the SIA / vacancy state-space representation: one ODE per
191 size (``discrete``) or the logarithmic bin-moment reduction
192 (``bin_moment``, paper Section 5.4).
193 cascade : {'fission', 'fusion'}
194 Selects the cascade production spectrum and, through
195 :meth:`HeReductionMode.from_cascade`, the helium reduction
196 (fission -> Case 2 decoupled; fusion -> Case 1 mean-field).
197
198 Returns
199 -------
200 (ReactionAdmissibilityGraph, StateLayout)
201 The host-specific graph G_Eur with all kernels registered, and the
202 contiguous ODE-index layout that packs it.
203 """
204 equations = str(equations).lower()
205 if equations not in ("discrete", "bin_moment"):
206 raise ValueError(
207 f"equations must be 'discrete' or 'bin_moment', got {equations!r}")
208
209 I = int(input_data.I) # max SIA cluster size
210 V = int(input_data.V) # max vacancy cluster size
211 i_mobile = int(input_data.i_mobile) # max mobile SIA size
212 v_mobile = int(input_data.v_mobile) # max mobile vacancy size
213
214 # ── 1. The graph and its populations (paper Eqs. 41-46) ──────────────────
215 rag = ReactionAdmissibilityGraph("EUROFER-97", gas_species=["He"])
216
217 # Two SIA populations + one vacancy population (paper Eqs. 42-43, Eq. 12).
218 #
219 # bulk-111 : glissile ½⟨111⟩ loops + 3D-mobile small clusters (n < 4);
220 # holds the SIA monomer pool; receives ALL cascade SIA
221 # production; self-coalesces (split by phi_junc).
222 # bulk-100 : sessile ⟨100⟩ loops (mobile_max = 0); no glide, no
223 # self-coalescence, no cascade source. n_min is the
224 # loop-onset floor (no ⟨100⟩ loop exists below it).
225 #
226 # The two are coupled by the ½⟨111⟩ → ⟨100⟩ conversion edges declared in
227 # Section 3: a unary INTER_POPULATION transformation (Dudarev), a binary
228 # junction COALESCENCE (Marian), and a ½⟨111⟩-absorption COALESCENCE
229 # (Marian growth). See docs/design_notes/loop_111_to_100_conversion.md.
230 n_loop_min = int(getattr(reaction_rates, "n_loop_min", 4))
231 sia_111 = rag.add_population(
232 Population("bulk-111", Polarity.SIA, n_min=1, mobile_max=i_mobile))
233 sia_100 = rag.add_population(
234 Population("bulk-100", Polarity.SIA, n_min=n_loop_min, mobile_max=0))
235 vac_bulk = rag.add_population(
236 Population("bulk", Polarity.VACANCY, n_min=1, mobile_max=v_mobile))
237
238 # The size-1 vertex of bulk-111 is the SIA point-defect monomer pool; the
239 # sessile bulk-100 loops draw/emit monomers from it automatically (the
240 # walker resolves the monomer by polarity). Vacancy monomer pool is the
241 # vacancy bulk. GROWTH / SHRINKAGE / RECOMBINATION / DISSOCIATION consume.
242 rag.set_monomer_population(sia_111)
243 rag.set_monomer_population(vac_bulk)
244
245 # ── 2. Rate-kernel library k (precomputed by ReactionRates) ──────────────
246 # Every kernel below is an array/scalar already built by ReactionRates;
247 # the abstract core never evaluates a rate formula.
248 rr = reaction_rates
249
250 # 8 pi / Omega^(2/3): coalescence/annihilation geometric prefactor.
251 # ReactionRates exposes A_sph / Omega^(2/3) as A_sph_inv_O23, with
252 # A_sph = (48 pi^2)^(1/3); hence 8 pi / Omega^(2/3) = A_sph_inv_O23 *
253 # 8 pi / A_sph.
254 A_sph = float(input_data.derived["A_sph"])
255 A_8pi = rr.A_sph_inv_O23 * (8.0 * np.pi / A_sph)
256
257 # P1 — V-SIA recombination kernel (scalar K_iv, paper Eqs. 52, 78).
258 rag.register_kernel("K_iv", float(rr.K_iv))
259
260 # P2/P3 — monomer absorption (growth / shrinkage ladders).
261 # SIA loop grows by absorbing an SIA monomer -> K_SIA_grow
262 # SIA loop shrinks by absorbing a vacancy monomer -> K_SIA_shrink
263 # void grows by absorbing a vacancy monomer -> K_VAC_grow
264 # void shrinks by absorbing an SIA monomer -> K_VAC_shrink
265 rag.register_kernel("K_SIA_grow", np.asarray(rr.K_SIA_grow, dtype=float))
266 rag.register_kernel("K_SIA_shrink", np.asarray(rr.K_SIA_shrink, dtype=float))
267 rag.register_kernel("K_VAC_grow", np.asarray(rr.K_VAC_grow, dtype=float))
268 rag.register_kernel("K_VAC_shrink", np.asarray(rr.K_VAC_shrink, dtype=float))
269
270 # P4 — fixed-sink loss D_alpha (dislocations + GB + precipitates).
271 # SIA: size-resolved array k2_SIA (mobile-only; 0 for large loops).
272 # Vacancy: scalar k2_vac_scalar broadcast over the size axis.
273 rag.register_kernel("D_SIA_sink", np.asarray(rr.k2_SIA, dtype=float))
274 rag.register_kernel("D_VAC_sink", float(rr.k2_vac_scalar))
275
276 # P5 — thermal monomer emission (dissociation ladders).
277 # SIA loop emits an SIA monomer -> G_SIA (alpha_i(n))
278 # void emits a vacancy monomer -> G_VAC (alpha_v(m))
279 rag.register_kernel("eps_SIA_emit", np.asarray(rr.G_SIA, dtype=float))
280 rag.register_kernel("eps_VAC_emit", np.asarray(rr.G_VAC, dtype=float))
281
282 # SIA-SIA and V-V coalescence — 2-D same-polarity binary kernels
283 # assembled from the precomputed effective diffusivities (Eqs. 79-80).
284 #
285 # The ½⟨111⟩-½⟨111⟩ collision kernel K_ii is *split* by the Marian
286 # size-comparability branching phi_junc[n,n'] (built by ReactionRates):
287 # K_111_self = (1 - phi) * K_ii -> product stays in bulk-111
288 # K_111_junction = phi * K_ii -> product is a ⟨100⟩ loop
289 # so the total ½⟨111⟩ collision rate is conserved (no double counting).
290 # These 2-D same-/cross-polarity kernels are O(I²). They are consumed
291 # ONLY by the Python GraphWalker (reference RHS); the production C++ solver
292 # computes K_ii, K_vv, K_vi, and φ_junc on the fly per (n,n') pair and never
293 # reads them. Register them as zero-argument lazy builders so the matrices
294 # are materialised only if the GraphWalker actually evaluates the edge —
295 # never on a C++ run, where materialising [I,I] at I~1e5 would need ~80 GB.
296 D_SIA_eff = np.asarray(rr.D_SIA_eff, dtype=float)
297 D_VAC_eff = np.asarray(rr.D_VAC_eff, dtype=float)
298 rag.register_kernel(
299 "K_111_self",
300 lambda: (1.0 - np.asarray(rr.phi_junc, dtype=float))
301 * _coalescence_kernel(D_SIA_eff, A_8pi))
302 rag.register_kernel(
303 "K_111_junction",
304 lambda: np.asarray(rr.phi_junc, dtype=float)
305 * _coalescence_kernel(D_SIA_eff, A_8pi))
306 rag.register_kernel("K_vv_coal",
307 lambda: _coalescence_kernel(D_VAC_eff, A_8pi))
308
309 # ── ½⟨111⟩ → ⟨100⟩ conversion + sessile-⟨100⟩ kernels (loop-conversion) ──
310 # Unary (Dudarev) transformation rate Γ_uni(n) (1-D, INTER_POPULATION edge).
311 rag.register_kernel("Gamma_uni", np.asarray(rr.Gamma_uni, dtype=float))
312 # Marian absorption ⟨100⟩_m + ½⟨111⟩_n -> ⟨100⟩_{m+n} (cross-character),
313 # gated by the two-step success probability P_success(T) (Marian Fig. 3) —
314 # the absorbed ½⟨111⟩ must rotate through the metastable ½⟨110⟩ to ⟨100⟩.
315 # `absorb_boost_100` scales THIS kernel only. It exists because
316 # `conv_psuccess` multiplies both the absorption kernel and the junction
317 # yield, so varying ΔH₂ cannot separate "⟨100⟩ grows faster" from "⟨100⟩
318 # nucleates more often" — the 2026-07-31 ΔH₂ scan raised both at once and
319 # N₁₀₀ doubled while d₁₀₀ moved 9 %.
320 #
321 # Physically it represents 1-D-glide-enhanced capture of ½⟨111⟩ by a
322 # sessile ⟨100⟩ loop. `D_SIA_eff` carries the isotropic-equivalent
323 # diffusivity `D1D(n)/rot_factor` with `rot_factor = 1 + B_rot·L̂² ≈ 6568`
324 # (reaction_rates.py), i.e. the rate a randomly-placed spherical sink sees
325 # from a rotating 1-D glider. A ⟨100⟩ loop is neither: it is an extended
326 # planar trap, and a 1-D glider sweeps a tube, so the tube-disc
327 # intersection rate can exceed the isotropic estimate by up to ~rot_factor.
328 # Default 1.0 ⇒ bit-identical to the previous behaviour.
329 absorb_boost = float(input_data.reactions.get('absorb_boost_100', 1.0) or 1.0)
330 # As of 2026-08-01 this kernel uses its OWN success gate `conv_psuccess_abs`
331 # (barrier ΔH₂^abs, prefactor A_abs — see reaction_rates.py), decoupled from
332 # the junction's `conv_psuccess`. Rationale: absorption of a ≤ i_mobile
333 # cluster by a several-hundred-atom ⟨100⟩ loop is templated by the host's
334 # existing character, so it should not carry the junction's 0.70 eV
335 # reversion penalty. Defaults (ΔH₂^abs = ΔH₂, A_abs = 1) reproduce the old
336 # behaviour bit-for-bit, so `absorb_boost_100` remains a pure test knob.
337 rag.register_kernel(
338 "K_100_absorb",
339 lambda: (float(rr.conv_psuccess_abs) * absorb_boost
340 * _absorb_kernel(D_SIA_eff, A_8pi)))
341 # Sessile ⟨100⟩ point-defect ladders (loop geometry; monomer-driven).
342 rag.register_kernel("K_100_grow", np.asarray(rr.K_100_grow, dtype=float))
343 rag.register_kernel("K_100_shrink", np.asarray(rr.K_100_shrink, dtype=float))
344 rag.register_kernel("eps_100_emit", np.asarray(rr.G_100, dtype=float))
345 rag.register_kernel("D_100_sink", np.asarray(rr.k2_100, dtype=float))
346 # Vacancy-cluster annihilation of sessile ⟨100⟩ loops (D^{100} = 0).
347 I_size = int(input_data.I)
348 rag.register_kernel(
349 "K_vi_annih_100",
350 lambda: _annihilation_kernel(D_VAC_eff, np.zeros(I_size), A_8pi))
351
352 # V-I cluster annihilation — cross-polarity 2-D kernel K[m-1, n-1]
353 # (paper Eq. 81). Vacancy axis primary, SIA axis partner.
354 rag.register_kernel(
355 "K_vi_annih",
356 lambda: _annihilation_kernel(D_VAC_eff, D_SIA_eff, A_8pi))
357
358 # Cascade production sources (paper Eqs. 11-13). production_rates
359 # returns 1-indexed arrays; convert to 0-indexed size arrays.
360 G = float(input_data.derived["G"])
361 G_He_r = float(input_data.derived["G_He_r"])
362 Pr_SIA, Pr_VAC, _G_He = production_rates(G, cascade, I, V, G_He_r)
363 rag.register_kernel("G_SIA_cascade", _source_array(Pr_SIA))
364 rag.register_kernel("G_VAC_cascade", _source_array(Pr_VAC))
365
366 # P7 — trap-mutation kernel Gamma_TM (paper Eq. 75). Built here from
367 # binding_energies.Gamma_TM because ReactionRates does not expose a
368 # size/loading-resolved trap-mutation array. The kernel is non-zero
369 # only on the sizes for which an atomistic E_TM barrier is tabulated
370 # (Table 27) and zero elsewhere — see _trap_mutation_kernel.
371 T = float(input_data.derived["T"])
372 rag.register_kernel("Gamma_TM", _trap_mutation_kernel(V, T))
373
374 # P8 — radiation re-solution kernel Gamma_res / b0 (paper Eq. 75,
375 # Section 5.3). Gamma_res(ell) = b0 * ell * phi_dot is per-He-atom;
376 # we register the per-He-atom coefficient b0 * phi_dot broadcast over
377 # the vacancy size axis (the loading factor ell is applied by the
378 # C++ kernel arithmetic when P8 is implemented).
379 spec = str(input_data.derived.get("spectrum", cascade)).lower()
380 b0_key = "b0_fission" if "fiss" in spec else "b0_fusion"
381 b0_res = float(input_data.reactions.get(
382 b0_key, 0.01 if "fiss" in spec else 0.10))
383 rag.register_kernel("Gamma_res", _resolution_kernel(V, G, b0_res))
384
385 # ── 3. Edge families (one per EUROFER process class P1-P8) ───────────────
386 # Each Edge is a *family*: the graph walker applies its kernel vectorised
387 # over the whole size axis, so one Edge stands for O(N) scalar reactions.
388
389 # P1 — V-SIA recombination (cross-polarity, RECOMBINATION).
390 # Walked on the SIA ladder: I_n + V_1 -> I_{n-1}, consuming a vacancy
391 # monomer. The n=1 step (I_1 + V_1 -> empty) is pure recombination.
392 rag.add_edge(Edge(
393 EdgeClass.RECOMBINATION, "P1_recombination", sia_111,
394 kernel="K_iv",
395 meta={"process": "P1", "note": "V-SIA recombination, paper Eq. 52"}))
396
397 # P2/P3 — monomer absorption: cavity & loop GROWTH and SHRINKAGE.
398 # Vacancy side: void absorbs a vacancy monomer (grow, P2v) or an SIA
399 # monomer (shrink, P2i/P6).
400 rag.add_edge(Edge(
401 EdgeClass.GROWTH, "P2v_cavity_growth", vac_bulk,
402 kernel="K_VAC_grow",
403 meta={"process": "P2", "note": "void + V_1 -> void_{m+1}"}))
404 rag.add_edge(Edge(
405 EdgeClass.SHRINKAGE, "P2i_cavity_shrink", vac_bulk,
406 kernel="K_VAC_shrink",
407 meta={"process": "P2/P6", "note": "void + I_1 -> void_{m-1}"}))
408 # SIA side: loop absorbs an SIA monomer (grow, P3) or a vacancy
409 # monomer (shrink, P3 vacancy side).
410 rag.add_edge(Edge(
411 EdgeClass.GROWTH, "P3_loop_growth", sia_111,
412 kernel="K_SIA_grow",
413 meta={"process": "P3", "note": "loop + I_1 -> loop_{n+1}"}))
414 rag.add_edge(Edge(
415 EdgeClass.SHRINKAGE, "P3_loop_shrink", sia_111,
416 kernel="K_SIA_shrink",
417 meta={"process": "P3", "note": "loop + V_1 -> loop_{n-1}"}))
418
419 # P4 — absorption at fixed unresolved sinks (SINK): dislocation
420 # network, grain boundaries, MX/M23C6 precipitates.
421 rag.add_edge(Edge(
422 EdgeClass.SINK, "P4_SIA_sink", sia_111,
423 kernel="D_SIA_sink",
424 meta={"process": "P4", "note": "SIA fixed-sink loss D_i"}))
425 rag.add_edge(Edge(
426 EdgeClass.SINK, "P4_VAC_sink", vac_bulk,
427 kernel="D_VAC_sink",
428 meta={"process": "P4", "note": "vacancy fixed-sink loss D_v"}))
429
430 # P5 — thermal monomer emission (DISSOCIATION): loop emits an SIA,
431 # void emits a vacancy, into the respective monomer pool.
432 rag.add_edge(Edge(
433 EdgeClass.DISSOCIATION, "P5i_SIA_emission", sia_111,
434 kernel="eps_SIA_emit",
435 meta={"process": "P5", "note": "loop_n -> loop_{n-1} + I_1"}))
436 rag.add_edge(Edge(
437 EdgeClass.DISSOCIATION, "P5v_VAC_emission", vac_bulk,
438 kernel="eps_VAC_emit",
439 meta={"process": "P5", "note": "void_m -> void_{m-1} + V_1"}))
440
441 # SIA-SIA and V-V coalescence (COALESCENCE, same-polarity binary).
442 # ½⟨111⟩ self-coalescence keeps the (1 - phi_junc) branch in bulk-111.
443 rag.add_edge(Edge(
444 EdgeClass.COALESCENCE, "SIA111_self_coalescence", sia_111,
445 kernel="K_111_self",
446 meta={"note": "I_n + I_n' -> I_{n+n'} (½<111>), (1-phi) branch, Eq. 79"}))
447 rag.add_edge(Edge(
448 EdgeClass.COALESCENCE, "VAC_VAC_coalescence", vac_bulk,
449 kernel="K_vv_coal",
450 meta={"note": "V_m + V_m' -> V_{m+m'}, paper Eq. 80"}))
451
452 # ── ½⟨111⟩ → ⟨100⟩ conversion edges (loop-conversion work) ───────────────
453 # Marian junction (binary): ½⟨111⟩_n + ½⟨111⟩_n' -> ⟨100⟩_{n+n'}. Cross-
454 # population COALESCENCE: reactants in bulk-111, product deposited into
455 # bulk-100 (the phi_junc branch of the ½⟨111⟩ collision rate).
456 rag.add_edge(Edge(
457 EdgeClass.COALESCENCE, "SIA111_junction", sia_111,
458 kernel="K_111_junction", product_population=sia_100,
459 meta={"mechanism": "Marian junction",
460 "note": "½<111>_n + ½<111>_n' -> <100>_{n+n'}"}))
461 # Dudarev unary transformation: ½⟨111⟩_n -> ⟨100⟩_n (size-fixed, one-way),
462 # INTER_POPULATION gated by the thermodynamic driving force ΔF(n,T).
463 rag.add_edge(Edge(
464 EdgeClass.INTER_POPULATION, "loop_111to100_unary", sia_111,
465 kernel="Gamma_uni", product_population=sia_100,
466 meta={"mechanism": "Dudarev unary transformation",
467 "note": "½<111>_n -> <100>_n, one-way, gated by ΔF(n,T)>0"}))
468
469 # V-I cluster annihilation (ANNIHILATION, cross-polarity binary).
470 # Primary = vacancy population, partner = SIA population.
471 rag.add_edge(Edge(
472 EdgeClass.ANNIHILATION, "VI_annihilation", vac_bulk,
473 kernel="K_vi_annih", partner_population=sia_111,
474 meta={"note": "V_m + I_n -> survivor of size |m-n|, paper Eq. 81"}))
475
476 # Cascade production (SOURCE, no precursor vertex): displacement
477 # cascades inject SIA and vacancy clusters across the size spectrum.
478 rag.add_edge(Edge(
479 EdgeClass.SOURCE, "cascade_SIA_source", sia_111,
480 kernel="G_SIA_cascade",
481 meta={"process": "cascade", "note": "SIA cascade injection G_n"}))
482 rag.add_edge(Edge(
483 EdgeClass.SOURCE, "cascade_VAC_source", vac_bulk,
484 kernel="G_VAC_cascade",
485 meta={"process": "cascade", "note": "vacancy cascade injection G_m"}))
486
487 # ── Sessile ⟨100⟩ population edge families (loop-conversion work) ─────────
488 # ⟨100⟩ loops grow to TEM-visible sizes mainly by ABSORBING mobile ½⟨111⟩
489 # clusters (Marian growth): ⟨100⟩_m + ½⟨111⟩_n -> ⟨100⟩_{m+n}. Cross-
490 # population COALESCENCE with the ½⟨111⟩ partner; product = bulk-100 = the
491 # primary population, so no product_population redirect is needed.
492 rag.add_edge(Edge(
493 EdgeClass.COALESCENCE, "SIA100_absorb", sia_100,
494 kernel="K_100_absorb", partner_population=sia_111,
495 meta={"mechanism": "Marian absorption growth",
496 "note": "<100>_m + ½<111>_n -> <100>_{m+n}"}))
497 # ⟨100⟩ point-defect ladders (sessile: monomer-driven capture/emission).
498 rag.add_edge(Edge(
499 EdgeClass.GROWTH, "P3_100_growth", sia_100,
500 kernel="K_100_grow",
501 meta={"process": "P3", "note": "<100>_n + I_1 -> <100>_{n+1}"}))
502 rag.add_edge(Edge(
503 EdgeClass.SHRINKAGE, "P3_100_shrink", sia_100,
504 kernel="K_100_shrink",
505 meta={"process": "P3", "note": "<100>_n + V_1 -> <100>_{n-1}"}))
506 rag.add_edge(Edge(
507 EdgeClass.DISSOCIATION, "P5_100_emission", sia_100,
508 kernel="eps_100_emit",
509 meta={"process": "P5", "note": "<100>_n -> <100>_{n-1} + I_1"}))
510 rag.add_edge(Edge(
511 EdgeClass.SINK, "P4_100_sink", sia_100,
512 kernel="D_100_sink",
513 meta={"process": "P4", "note": "sessile <100>: D=0 (no fixed-sink loss)"}))
514 # Vacancy-cluster annihilation of sessile ⟨100⟩ loops (primary = vacancy).
515 rag.add_edge(Edge(
516 EdgeClass.ANNIHILATION, "VI_annihilation_100", vac_bulk,
517 kernel="K_vi_annih_100", partner_population=sia_100,
518 meta={"note": "V_m + <100>_n -> survivor of size |m-n|"}))
519
520 # ── P7 / P8 — gas-pressure-driven and solute-detrapping edges ────────────
521 # These two edges complete the EUROFER-97 RAG *structurally* per the
522 # paper's master equations 83-86 (Table 1, rows P7 and P8). They are
523 # declared with their kernels registered; the C++ kernel arithmetic that
524 # evaluates P7/P8 is the remaining physics-implementation item — the
525 # current C++ solver does NOT yet evaluate P7 or P8 (confirmed in the
526 # Stage-2 review). Declaring the edges now makes the RAG the complete,
527 # paper-faithful structural description of the EUROFER-97 network; the
528 # graph walker / C++ bridge will pick up P7/P8 once their arithmetic
529 # lands, with no further change to this declaration.
530
531 # P7 — trap mutation: V_m(He) -> V_{m+1} + I_1 (paper Eq. 75, Table 1).
532 # An over-pressurized gas-bearing cavity emits an SIA monomer while
533 # GROWING its vacancy content by one (m -> m+1). The reaction conserves
534 # signed lattice-defect content because -m = -(m+1) + 1 (paper Eq. 36).
535 #
536 # EdgeClass choice — GROWTH. P7 is a *unary, gas-pressure-driven* size
537 # increment of the vacancy cluster (size_shift = +1), so structurally it
538 # is the vacancy ladder's GROWTH step: the paper's Table 1 lists P7 as a
539 # "TM ladder in m" for c_{m,ell} (gain at m+1, loss at m), exactly the
540 # GROWTH stoichiometry. It is NOT DISSOCIATION (that is size_shift = -1)
541 # and NOT SOLUTE_TRAPPING (that fixes size and only shifts the He
542 # composition vector). The dual character of P7 — it ALSO sources an
543 # SIA monomer at n = 1 — has no single-class abstraction in the ten-class
544 # catalogue; it is recorded in meta['sia_source'] so the graph walker /
545 # C++ kernel can add the n = 1 SIA source alongside the vacancy-ladder
546 # growth. (A future refinement could split P7 into a GROWTH edge on the
547 # vacancy ladder plus a SOURCE edge on the SIA monomer, both sharing the
548 # Gamma_TM kernel; one GROWTH edge with the annotation is kept here so
549 # the edge count matches the eight paper process classes P1-P8.)
550 rag.add_edge(Edge(
551 EdgeClass.GROWTH, "P7_trap_mutation", vac_bulk,
552 kernel="Gamma_TM",
553 meta={"process": "P7",
554 "note": "trap mutation V_m(He) -> V_{m+1} + I_1, paper Eq. 75",
555 "sia_source": "emits one I_1 monomer per mutation (n=1 SIA "
556 "source); gas-pressure-driven, kernel Gamma_TM",
557 "cpp_status": "kernel registered; C++ arithmetic not yet "
558 "implemented (Stage-2 review)"}))
559
560 # P8 — radiation re-solution: He_ell V_m -> He_{ell-1} V_m + He
561 # (paper Eq. 75, Section 5.3, Table 1). A displacement event athermally
562 # ejects one trapped helium atom from a cavity back into the free-He
563 # reservoir; the vacancy size m is unchanged and only the helium
564 # composition vector decreases by one (ell -> ell-1).
565 #
566 # EdgeClass choice — SOLUTE_TRAPPING in the detrap direction. This is
567 # exactly the SOLUTE_TRAPPING contract: size fixed, changes_composition
568 # True, the unary (detrap) branch of the trap/detrap pair. gas_species
569 # is the helium index (0) in the RAG's single-entry gas list.
570 rag.add_edge(Edge(
571 EdgeClass.SOLUTE_TRAPPING, "P8_radiation_resolution", vac_bulk,
572 kernel="Gamma_res", gas_species=rag.gas_species.index("He"),
573 meta={"process": "P8", "direction": "detrap",
574 "note": "radiation re-solution He_ell V_m -> He_{ell-1} V_m "
575 "+ He, paper Eq. 75 / Section 5.3",
576 "cpp_status": "kernel registered; C++ arithmetic not yet "
577 "implemented (Stage-2 review)"}))
578
579 # ── 4. State layout (contiguous ODE-index map) ───────────────────────────
580 layout = StateLayout()
581
582 if equations == "discrete":
583 # One ODE per size: SIA ladder 1..I, vacancy ladder 1..V. Each
584 # discrete block carries meta['population'] so the GraphWalker can
585 # map (polarity, name) -> block.
586 layout.add_discrete("SIA", I, population=sia_111)
587 layout.add_discrete("SIA100", I, population=sia_100)
588 layout.add_discrete("VAC", V, population=vac_bulk)
589 n_vac_classes = V
590 else:
591 # Bin-moment reduction: discrete prefix + logarithmic bins. The
592 # BinMomentReduction sizes each block; meta['population'] and the
593 # reduction object are carried so the reduction wrapper can
594 # reconstruct / project around a GraphWalker.
595 i_discrete = int(input_data.i_discrete)
596 v_discrete = int(input_data.v_discrete)
597 I_bin = int(input_data.I_bin)
598 V_bin = int(input_data.V_bin)
599 shape = str(input_data.shape_function)
600
601 sia_red = BinMomentReduction(I, i_discrete, I_bin, shape)
602 sia100_red = BinMomentReduction(I, i_discrete, I_bin, shape)
603 vac_red = BinMomentReduction(V, v_discrete, V_bin, shape)
604 layout.add_bin_moment(
605 "SIA", sia_red.n_discrete, sia_red.n_bins,
606 sia_red.moments_per_bin,
607 population=sia_111, reduction=sia_red, n_max=I)
608 # Sessile ⟨100⟩ loops share the SIA size grid/reduction. The
609 # conversion edges couple the two SIA bin-moment systems; the
610 # reduction wrapper / C++ handle the reconstruct→transfer→project
611 # (the discrete reference walker uses the discrete layout above).
612 layout.add_bin_moment(
613 "SIA100", sia100_red.n_discrete, sia100_red.n_bins,
614 sia100_red.moments_per_bin,
615 population=sia_100, reduction=sia100_red, n_max=I)
616 layout.add_bin_moment(
617 "VAC", vac_red.n_discrete, vac_red.n_bins,
618 vac_red.moments_per_bin,
619 population=vac_bulk, reduction=vac_red, n_max=V)
620 # The helium block tracks one Q per vacancy class (Case 1) or one
621 # scalar (Case 2); the relevant class count is the number of
622 # vacancy size classes carrying helium = the bin-moment length.
623 n_vac_classes = vac_red.length
624
625 # Helium block — sized by the cascade-selected He reduction
626 # (fission -> Case 2 decoupled, length 1; fusion -> Case 1 mean-field,
627 # length n_vac_classes). free_he_tracked=True: free helium c_h is an
628 # explicit ODE entry appended by the reduction.
629 he_mode = HeReductionMode.from_cascade(cascade)
630 he_len = he_mode.he_block_length(n_vac_classes, free_he_tracked=True)
631 layout.add_aux("He", he_len,
632 reduction=he_mode.value, free_he_tracked=True,
633 n_vac_classes=n_vac_classes)
634
635 # Conservation-accounting aux block — five cumulative integrals used by
636 # the post-processing conservation diagnostics (cumulative bias flux
637 # Delta J^d, integrated SIA/vacancy/He production, integrated sink
638 # losses; paper Section 4.5 / Eqs. delta_FP, delta_He).
639 layout.add_aux("conservation", 5,
640 note="cumulative accounting integrals for delta_FP/delta_He")
641
642 layout.freeze()
643 return rag, layout
np.ndarray _absorb_kernel(np.ndarray D_partner, float A_pref)
np.ndarray _source_array(np.ndarray P_indexed)
np.ndarray _annihilation_kernel(np.ndarray D_v, np.ndarray D_i, float A_pref)
np.ndarray _trap_mutation_kernel(int V, float T)
np.ndarray _coalescence_kernel(np.ndarray D_eff, float A_pref)
np.ndarray _resolution_kernel(int V, float phi_dot, float b0)