RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
graph_walker.py
Go to the documentation of this file.
1"""
2core/graph_walker.py — The master equation as a graph-walking accumulator.
3
4Host-independent. Implements the "graph walker" of Ghoniem (2026),
5*A Generalized Graph-Based Cluster Dynamics Framework*, Section 3.3:
6
7 dc_a/dt = G_a + sum_r S_ar J_r(c) - D_a c_a
8
9The walker visits every admissible edge of a :class:`ReactionAdmissibilityGraph`,
10evaluates the reaction flux J_r from the edge's rate kernel, and deposits
11the signed stoichiometric contribution S_ar J_r into the affected vertices
12of the flat ODE state vector defined by a :class:`StateLayout`.
13
14This is a *reference* discrete (one-ODE-per-size) accumulator: it carries
15no host parameters and no EUROFER assumptions — it dispatches purely on
16:class:`EdgeClass`. The size-resolved rate kernels are precomputed
17arrays registered on the RAG by a host material (Layer 2). The
18production solver remains the C++ core; this Python walker is the
19executable specification the C++ must agree with, and the substrate for
20the bin-moment "reconstruct -> walk -> project" reduction.
21
22Sign convention. Every per-class routine below realises exactly the
23stoichiometric contract fixed in ``EDGE_CLASS_SPEC``; because those signs
24are structural, summed signed-defect content q = chi*n is conserved by
25construction for every intra-host reaction (sources/sinks aside).
26"""
27from __future__ import annotations
28
29from typing import Dict, Tuple
30
31import numpy as np
32
33from .cluster_identifier import Polarity, Population
34from .edge_classes import EdgeClass
35from .rag import Edge, ReactionAdmissibilityGraph
36from .state_layout import StateLayout
37
38
40 """Assembles the master-equation RHS by walking a RAG edge-by-edge.
41
42 Parameters
43 ----------
44 rag : ReactionAdmissibilityGraph
45 The host's reaction graph, with rate kernels registered as
46 precomputed numpy arrays (indexed 0-based by size-1) or scalars.
47 layout : StateLayout
48 The ODE-index map. Every discrete size axis must be a block
49 whose ``meta['population']`` is the corresponding
50 :class:`Population`.
51 boundary : {'reflection', 'absorption'}
52 Treatment of growth/coalescence at the top of a size axis.
53 ``reflection`` (default) suppresses the over-the-top reaction;
54 ``absorption`` lets it fire and drops the product.
55 """
56
57 def __init__(self, rag: ReactionAdmissibilityGraph, layout: StateLayout,
58 boundary: str = "reflection"):
59 if boundary not in ("reflection", "absorption"):
60 raise ValueError(f"unknown boundary mode {boundary!r}")
61 self.rag = rag
62 self.layout = layout
63 self.boundary = boundary
64
65 # (polarity, population name) -> discrete block name
66 self._pop_block: Dict[Tuple[Polarity, str], str] = {}
67 for b in layout.blocks:
68 if b.kind != "discrete":
69 continue
70 p = b.meta.get("population")
71 if isinstance(p, Population):
72 self._pop_block[(p.polarity, p.name)] = b.name
73 if not self._pop_block:
74 raise ValueError(
75 "StateLayout has no discrete blocks tagged with a Population")
76
77 self._dispatch = {
78 EdgeClass.GROWTH: self._c_growth_c_growth,
79 EdgeClass.SHRINKAGE: self._c_ladder_down_c_ladder_down,
80 EdgeClass.RECOMBINATION: self._c_ladder_down_c_ladder_down,
81 EdgeClass.DISSOCIATION: self._c_dissociation_c_dissociation,
82 EdgeClass.SOURCE: self._c_source_c_source,
83 EdgeClass.SINK: self._c_sink_c_sink,
84 EdgeClass.INTER_POPULATION: self._c_inter_population_c_inter_population,
85 EdgeClass.COALESCENCE: self._c_coalescence_c_coalescence,
86 EdgeClass.ANNIHILATION: self._c_annihilation_c_annihilation,
87 EdgeClass.SOLUTE_TRAPPING: self._c_solute_trapping_c_solute_trapping,
88 }
89
90 # ── public API ────────────────────────────────────────────────────────
91 def assemble(self, t: float, y: np.ndarray) -> np.ndarray:
92 """Return dy/dt for state ``y`` at time ``t`` (the graph walk)."""
93 y = np.asarray(y, dtype=float)
94 if y.shape != (self.layout.N_eq,):
95 raise ValueError(
96 f"state vector has shape {y.shape}, expected ({self.layout.N_eq},)")
97 dydt = np.zeros(self.layout.N_eq)
98
99 # Concentration (read) and rate (accumulate) views per population.
100 conc: Dict[Tuple[Polarity, str], np.ndarray] = {}
101 dconc: Dict[Tuple[Polarity, str], np.ndarray] = {}
102 for key, bname in self._pop_block.items():
103 sl = self.layout.slice(bname)
104 conc[key] = np.maximum(y[sl], 0.0) # clamp: concentrations >= 0
105 dconc[key] = dydt[sl] # a view -> accumulates in place
106
107 ctx = _WalkContext(conc, dconc, dydt, self.layout)
108 for edge in self.rag.edges:
109 self._dispatch[edge.edge_class](edge, ctx)
110 return dydt
111
112 # ── helpers ───────────────────────────────────────────────────────────
113 def _key(self, p: Population) -> Tuple[Polarity, str]:
114 k = (p.polarity, p.name)
115 if k not in self._pop_block:
116 raise KeyError(
117 f"population {p.name!r} ({p.polarity.label}) has no discrete "
118 "block in the StateLayout")
119 return k
120
121 def _kernel(self, edge: Edge) -> np.ndarray:
122 """Return the edge's rate kernel as an array (broadcast if scalar)."""
123 return np.asarray(self.rag.kernel(edge.kernel), dtype=float)
124
125 def _monomer(self, ctx: "_WalkContext", polarity: Polarity) -> float:
126 """Current concentration of the ``polarity`` point-defect monomer."""
127 mp = self.rag.monomer_population(polarity)
128 return float(ctx.conc[(polarity, mp.name)][0])
129
130 def _monomer_dydt(self, ctx: "_WalkContext", polarity: Polarity) -> np.ndarray:
131 mp = self.rag.monomer_population(polarity)
132 return ctx.dconc[(polarity, mp.name)]
133
134 # ── per-class contributions ───────────────────────────────────────────
135 def _c_growth(self, edge: Edge, ctx: "_WalkContext") -> None:
136 """(chi,n) + chi-monomer -> (chi,n+1). Flux K[n] c_n c_1."""
137 k = self._key(edge.population)
138 c, dc = ctx.conc[k], ctx.dconc[k]
139 chi = edge.population.polarity
140 K = _as_size_array(self._kernel(edge), c.size)
141 c1 = self._monomer(ctx, chi)
142 flux = K * c * c1
143 if self.boundary == "reflection":
144 flux[-1] = 0.0 # no I_{Nmax}+I_1 -> I_{Nmax+1}
145 dc -= flux # loss at n
146 dc[1:] += flux[:-1] # gain at n+1
147 self._monomer_dydt(ctx, chi)[0] -= flux.sum() # one monomer per event
148
149 def _c_ladder_down(self, edge: Edge, ctx: "_WalkContext") -> None:
150 """(chi,n) + (-chi)-monomer -> (chi,n-1). SHRINKAGE / RECOMBINATION.
151
152 The opposite-polarity monomer is consumed (annihilated). At n=1
153 the product is the empty cluster (pure recombination).
154 """
155 k = self._key(edge.population)
156 c, dc = ctx.conc[k], ctx.dconc[k]
157 chi = edge.population.polarity
158 K = _as_size_array(self._kernel(edge), c.size)
159 c1_opp = self._monomer(ctx, chi.opposite)
160 flux = K * c * c1_opp
161 dc -= flux # loss at n
162 dc[:-1] += flux[1:] # gain at n-1 (n>=2)
163 self._monomer_dydt(ctx, chi.opposite)[0] -= flux.sum() # -chi annihilated
164
165 def _c_dissociation(self, edge: Edge, ctx: "_WalkContext") -> None:
166 """(chi,n) -> (chi,n-1) + chi-monomer. First-order thermal emission."""
167 k = self._key(edge.population)
168 c, dc = ctx.conc[k], ctx.dconc[k]
169 chi = edge.population.polarity
170 lam = _as_size_array(self._kernel(edge), c.size)
171 flux = lam * c
172 flux[0] = 0.0 # a monomer cannot dissociate
173 dc -= flux # loss at n
174 dc[:-1] += flux[1:] # residual cluster at n-1
175 self._monomer_dydt(ctx, chi)[0] += flux.sum() # emitted monomer pooled
176
177 def _c_source(self, edge: Edge, ctx: "_WalkContext") -> None:
178 """(empty) -> (chi,n). Cascade / transmutation injection G_n."""
179 k = self._key(edge.population)
180 ctx.dconc[k] += _as_size_array(self._kernel(edge), ctx.conc[k].size)
181
182 def _c_sink(self, edge: Edge, ctx: "_WalkContext") -> None:
183 """(chi,n) -> (sink). First-order loss D_n c_n at a fixed sink."""
184 k = self._key(edge.population)
185 D = _as_size_array(self._kernel(edge), ctx.conc[k].size)
186 ctx.dconc[k] -= D * ctx.conc[k]
187
188 def _c_inter_population(self, edge: Edge, ctx: "_WalkContext") -> None:
189 """(chi,n,p) -> (chi,n,p'). Population transfer, size fixed."""
190 ks, kd = self._key(edge.population), self._key(edge.product_population)
191 kap = _as_size_array(self._kernel(edge), ctx.conc[ks].size)
192 flux = kap * ctx.conc[ks]
193 ctx.dconc[ks] -= flux
194 ctx.dconc[kd][:flux.size] += flux
195
196 def _c_coalescence(self, edge: Edge, ctx: "_WalkContext") -> None:
197 """(chi,n) + (chi,n') -> (chi,n+n'). Same-polarity binary growth.
198
199 Reference double sum; the kernel is a 2-D array K[n-1, n'-1].
200
201 Cross-population product. If the edge carries a ``product_population``
202 (e.g. an SIA <111>+<111> junction whose product is a sessile <100>
203 loop), the gain is deposited into that block while the reactant losses
204 stay on the source and partner blocks. The product keeps the same
205 polarity, so signed-defect content q = chi*n is conserved either way.
206
207 Pair weighting. When the two reactants are the same population
208 (``same``) the ordered double loop visits each unordered pair twice
209 (and the diagonal once), so the per-event rate carries the 1/2
210 unordered-pair weight. The gain is deposited once per visit at that
211 same weighted rate: summed over the two visits of an off-diagonal pair
212 this yields exactly one product, and over the single visit of the
213 diagonal it yields the 1/2-weighted self-pair product — so the loss
214 (also weighted) and the gain balance and total content is conserved.
215 """
216 ka = self._key(edge.population)
217 kb = self._key(edge.partner_population)
218 ca, cb = ctx.conc[ka], ctx.conc[kb]
219 da, db = ctx.dconc[ka], ctx.dconc[kb]
220 dprod = (ctx.dconc[self._key(edge.product_population)]
221 if edge.product_population is not None else da)
222 K = np.atleast_2d(self._kernel(edge))
223 same = (ka == kb)
224 for i in range(ca.size):
225 if ca[i] <= 0.0:
226 continue
227 for j in range(cb.size):
228 ij = i + j + 1 # product size index (n+n'-1)
229 if ij >= dprod.size:
230 if self.boundary == "reflection":
231 continue
232 ij = -1 # absorption: drop product
233 rate = K[i, j] * ca[i] * cb[j]
234 if same:
235 rate *= 0.5 # unordered-pair weight
236 da[i] -= rate
237 db[j] -= rate
238 if ij >= 0:
239 dprod[ij] += rate
240
241 def _c_annihilation(self, edge: Edge, ctx: "_WalkContext") -> None:
242 """(chi,n) + (-chi,n') -> survivor of size |n-n'|. Reference sum."""
243 ka = self._key(edge.population) # chi side
244 kb = self._key(edge.partner_population) # -chi side
245 ca, cb = ctx.conc[ka], ctx.conc[kb]
246 da, db = ctx.dconc[ka], ctx.dconc[kb]
247 K = np.atleast_2d(self._kernel(edge))
248 for i in range(ca.size):
249 if ca[i] <= 0.0:
250 continue
251 for j in range(cb.size):
252 rate = K[i, j] * ca[i] * cb[j]
253 if rate <= 0.0:
254 continue
255 da[i] -= rate
256 db[j] -= rate
257 d = (i + 1) - (j + 1) # signed size difference
258 if d > 0 and d - 1 < da.size:
259 da[d - 1] += rate # chi survivor
260 elif d < 0 and (-d) - 1 < db.size:
261 db[-d - 1] += rate # -chi survivor
262 # d == 0: mutual annihilation, no survivor
263
264 def _c_solute_trapping(self, edge: Edge, ctx: "_WalkContext") -> None:
265 """(chi,n,c) <-> (chi,n,c +/- e_s).
266
267 The composition axis is not materialised by the discrete reference
268 walker; in the EUROFER instantiation helium is carried by the
269 mean-loading reduction (Case 1/2), which owns this edge class.
270 Reached only if a host wires an explicit composition axis.
271 """
272 raise NotImplementedError(
273 "SOLUTE_TRAPPING requires an explicit composition axis; in the "
274 "EUROFER instantiation it is handled by the He mean-loading "
275 "reduction (core/reductions). Edge: " + edge.label)
276
277
279 """Mutable scratch passed to the per-edge contribution routines."""
280
281 __slots__ = ("conc", "dconc", "dydt", "layout")
282
283 def __init__(self, conc, dconc, dydt, layout):
284 self.conc = conc
285 self.dconc = dconc
286 self.dydt = dydt
287 self.layout = layout
288
289
290def _as_size_array(kernel, n: int) -> np.ndarray:
291 """Broadcast a scalar kernel to length ``n``; pass an array through."""
292 arr = np.asarray(kernel, dtype=float)
293 if arr.ndim == 0:
294 return np.full(n, float(arr))
295 if arr.shape[0] != n:
296 raise ValueError(
297 f"kernel length {arr.shape[0]} does not match size axis {n}")
298 return arr
__init__(self, conc, dconc, dydt, layout)
None _c_solute_trapping(self, Edge edge, "_WalkContext" ctx)
None _c_inter_population(self, Edge edge, "_WalkContext" ctx)
np.ndarray _kernel(self, Edge edge)
None _c_sink(self, Edge edge, "_WalkContext" ctx)
Tuple[Polarity, str] _key(self, Population p)
None _c_dissociation(self, Edge edge, "_WalkContext" ctx)
np.ndarray assemble(self, float t, np.ndarray y)
float _monomer(self, "_WalkContext" ctx, Polarity polarity)
None _c_ladder_down(self, Edge edge, "_WalkContext" ctx)
None _c_annihilation(self, Edge edge, "_WalkContext" ctx)
np.ndarray _monomer_dydt(self, "_WalkContext" ctx, Polarity polarity)
None _c_growth(self, Edge edge, "_WalkContext" ctx)
None _c_source(self, Edge edge, "_WalkContext" ctx)
None _c_coalescence(self, Edge edge, "_WalkContext" ctx)
__init__(self, ReactionAdmissibilityGraph rag, StateLayout layout, str boundary="reflection")
np.ndarray _as_size_array(kernel, int n)