RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
rag.py
Go to the documentation of this file.
1"""
2core/rag.py — The Reaction Admissibility Graph G = (V, E, nu, k) (Layer 1).
3
4Host-independent. Implements the labelled multidigraph of
5Ghoniem (2026), *A Generalized Graph-Based Cluster Dynamics Framework*,
6Sections 2.2 and 3.2:
7
8 G = (V, E, nu, k)
9
10 V : admissible vertex set — cluster identifiers (chi, n, p, c)
11 E : edge set — every admissible elementary reaction, each carrying a
12 stoichiometric tuple nu and an :class:`EdgeClass`
13 nu: the stoichiometric action (fixed per EdgeClass by EDGE_CLASS_SPEC)
14 k : the rate-kernel library — calibration-specific, supplied by a host
15
16The graph is *host-specific in (V, E)* and *calibration-specific in k*;
17the algorithmic core (the graph walker) is host-independent. This module
18provides only the container and the admissibility logic — it declares no
19populations, no edges and no kernels itself. A host material builds a
20RAG by declaring its populations, adding :class:`Edge` families, and
21registering rate kernels.
22
23Physically forbidden reactions are excluded at construction time: an edge
24is admissible only if its endpoints are admissible and its stoichiometry
25respects the host conservation laws. Once built, the master equation is
26the host-independent graph-walking accumulator applied edge-by-edge.
27"""
28from __future__ import annotations
29
30from dataclasses import dataclass, field
31from typing import Callable, Dict, List, Optional, Tuple
32
33from .cluster_identifier import Polarity, Population
34from .edge_classes import EDGE_CLASS_SPEC, EdgeClass
35
36
37@dataclass
38class Edge:
39 """One admissible reaction-edge *family* in the RAG (paper Section 3.2).
40
41 An ``Edge`` is parameterised over the size axis: it represents the
42 whole family of elementary reactions of one :class:`EdgeClass` acting
43 on one population (or population pair). The graph walker applies the
44 edge's kernel vectorised over n, so a single ``Edge`` stands for
45 O(N) scalar reactions.
46
47 Attributes
48 ----------
49 edge_class : EdgeClass
50 Which of the ten elementary classes this family belongs to.
51 label : str
52 Human-readable identifier, unique within a RAG (diagnostics).
53 population : Population
54 The population the (surviving) cluster belongs to.
55 kernel : str
56 Key into the host rate-kernel library ``k``. The kernel supplies
57 the size-resolved rate; the abstract core never evaluates it.
58 partner_population : Population, optional
59 The second precursor population for binary classes
60 (COALESCENCE: same polarity; ANNIHILATION: opposite polarity).
61 product_population : Population, optional
62 The product population for INTER_POPULATION edges (p' != p).
63 gas_species : int, optional
64 Index into the host gas list, for SOLUTE_TRAPPING edges.
65 meta : dict
66 Free-form host annotations (e.g. emission channel, sink kind).
67 """
68
69 edge_class: EdgeClass
70 label: str
71 population: Population
72 kernel: str
73 partner_population: Optional[Population] = None
74 product_population: Optional[Population] = None
75 gas_species: Optional[int] = None
76 meta: Dict = field(default_factory=dict)
77
78 @property
79 def spec(self):
80 """The host-independent stoichiometric contract for this edge."""
81 return EDGE_CLASS_SPEC[self.edge_class]
82
83 def __post_init__(self) -> None:
84 sp = self.spec
85 if sp.n_precursor_clusters == 2 and self.partner_populationpartner_population is None:
86 # Binary edges default the partner to the primary population.
88 if self.edge_class is EdgeClass.INTER_POPULATION:
89 if self.product_population is None:
90 raise ValueError(
91 f"edge {self.label!r}: INTER_POPULATION requires a "
92 "product_population")
93 if self.product_population.polarity is not self.population.polarity:
94 raise ValueError(
95 f"edge {self.label!r}: inter-population transfer cannot "
96 "cross the polarity layers")
97 if self.edge_class is EdgeClass.ANNIHILATION:
98 if (self.partner_populationpartner_population is not None and
99 self.partner_populationpartner_population.polarity is self.population.polarity):
100 raise ValueError(
101 f"edge {self.label!r}: ANNIHILATION partner must be of "
102 "opposite polarity")
103 if (self.edge_class is EdgeClass.COALESCENCE and
104 self.product_population is not None and
105 self.product_population.polarity is not self.population.polarity):
106 # Optional cross-population product (e.g. <111>+<111> junction ->
107 # <100>): same-polarity binary growth that deposits the product
108 # into a *different* same-polarity population. Polarity must be
109 # preserved so signed-defect content q = chi*n is conserved.
110 raise ValueError(
111 f"edge {self.label!r}: COALESCENCE product_population must "
112 "share the reactants' polarity")
113 if (self.edge_class is EdgeClass.SOLUTE_TRAPPING and
114 self.gas_species is None):
115 raise ValueError(
116 f"edge {self.label!r}: SOLUTE_TRAPPING requires gas_species")
117
118 def __repr__(self) -> str:
119 return f"<Edge {self.edge_class.value}:{self.label} @ {self.population.name}>"
120
121
123 """The host-specific RAG: vertex space, edge set, and kernel library.
124
125 A host material populates this object by declaring populations, adding
126 :class:`Edge` families, and registering rate kernels. The abstract
127 graph walker then consumes it without any further reference to the
128 host parameter set.
129 """
130
131 def __init__(self, name: str, gas_species: Optional[List[str]] = None):
132 self.name = name
133 self.gas_species: List[str] = list(gas_species or [])
134 # Population names are unique only *within* a polarity layer — the
135 # paper's EUROFER instantiation has both a vacancy 'bulk' and an
136 # SIA 'bulk' (Eqs. 42-43) — so the registry is keyed (polarity, name).
137 self._populations: Dict[Tuple[Polarity, str], Population] = {}
138 self._edges: List[Edge] = []
139 self._kernels: Dict[str, Callable] = {}
140 # The population whose size-1 vertex is the point-defect monomer
141 # pool for each polarity (the chi-monomer / (-chi)-monomer source
142 # consumed by GROWTH / SHRINKAGE / RECOMBINATION edges).
143 self._monomer_pop: Dict[Polarity, Population] = {}
144
145 # ── populations ───────────────────────────────────────────────────────
146 @staticmethod
147 def _pop_key(p: Population) -> "Tuple[Polarity, str]":
148 return (p.polarity, p.name)
149
150 def add_population(self, p: Population) -> Population:
151 key = self._pop_key(p)
152 if key in self._populations:
153 raise ValueError(
154 f"duplicate population {p.name!r} in the "
155 f"{p.polarity.label} layer")
156 self._populations[key] = p
157 return p
158
159 @property
160 def populations(self) -> Tuple[Population, ...]:
161 return tuple(self._populations.values())
162
163 def populations_of(self, polarity: Polarity) -> Tuple[Population, ...]:
164 return tuple(p for p in self._populations.values()
165 if p.polarity is polarity)
166
167 def set_monomer_population(self, p: Population) -> None:
168 """Declare ``p`` as the point-defect monomer pool for its polarity."""
169 if self._pop_key(p) not in self._populations:
170 raise ValueError(
171 f"monomer population {p.name!r} must be registered first")
172 self._monomer_pop[p.polarity] = p
173
174 def monomer_population(self, polarity: Polarity) -> Population:
175 """Return the population that holds the ``polarity`` monomer pool."""
176 try:
177 return self._monomer_pop[polarity]
178 except KeyError:
179 raise KeyError(
180 f"RAG {self.name!r}: no monomer population set for "
181 f"{polarity.label}") from None
182
183 # ── edges ─────────────────────────────────────────────────────────────
184 def add_edge(self, edge: Edge) -> Edge:
185 if any(e.label == edge.label for e in self._edges):
186 raise ValueError(f"duplicate edge label {edge.label!r}")
187 for p in (edge.population, edge.partner_population,
188 edge.product_population):
189 if p is not None and self._pop_key(p) not in self._populations:
190 raise ValueError(
191 f"edge {edge.label!r} references unregistered "
192 f"{p.polarity.label} population {p.name!r}")
193 self._edges.append(edge)
194 return edge
195
196 @property
197 def edges(self) -> Tuple[Edge, ...]:
198 return tuple(self._edges)
199
200 def edges_of_class(self, edge_class: EdgeClass) -> Tuple[Edge, ...]:
201 return tuple(e for e in self._edges if e.edge_class is edge_class)
202
203 # ── kernel library k ──────────────────────────────────────────────────
204 def register_kernel(self, name: str, kernel) -> None:
205 """Bind a rate kernel to a name.
206
207 ``kernel`` may be a precomputed array/scalar, or a zero-argument
208 callable that builds and returns the kernel on first use. Lazy
209 callables let large O(N²) kernels (coalescence/annihilation, the
210 junction-branching split) be registered cheaply and materialised only
211 if the Python GraphWalker actually evaluates the edge; the C++ solver
212 path computes them on the fly and never requests them, so production
213 runs never pay the allocation.
214 """
215 self._kernels[name] = kernel
216
217 def kernel(self, name: str):
218 try:
219 k = self._kernels[name]
220 except KeyError:
221 raise KeyError(
222 f"RAG {self.name!r}: no kernel registered for {name!r}") from None
223 # Resolve and memoise a lazy builder on first request. Arrays and
224 # scalars (the common case) are not callable and pass straight through.
225 if callable(k):
226 k = k()
227 self._kernels[name] = k
228 return k
229
230 # ── validation ────────────────────────────────────────────────────────
231 def validate(self) -> None:
232 """Check structural consistency: every edge's kernel is registered.
233
234 Signed-defect conservation is guaranteed structurally by
235 EDGE_CLASS_SPEC (the per-class S_ar sign pattern), so it does not
236 need to be re-verified here; only the host-supplied bindings are
237 checked.
238 """
239 for e in self._edges:
240 if e.kernel not in self._kernels:
241 raise KeyError(
242 f"RAG {self.name!r}: edge {e.label!r} needs unregistered "
243 f"kernel {e.kernel!r}")
244
245 def summary(self) -> str:
246 by_class = {ec: len(self.edges_of_class(ec)) for ec in EdgeClass}
247 active = {k.value: v for k, v in by_class.items() if v}
248 return (f"RAG {self.name!r}: {len(self._populations)} populations, "
249 f"{len(self._edges)} edges, {len(self._kernels)} kernels; "
250 f"edge classes active: {active}")
251
252 def __repr__(self) -> str:
253 return (f"<ReactionAdmissibilityGraph {self.name!r} "
254 f"V_pop={len(self._populations)} E={len(self._edges)}>")
Optional product_population
Definition rag.py:74
Optional partner_population
Definition rag.py:73
None __post_init__(self)
Definition rag.py:83
Population population
Definition rag.py:71
EdgeClass edge_class
Definition rag.py:69
Optional gas_species
Definition rag.py:75
str __repr__(self)
Definition rag.py:118
__init__(self, str name, Optional[List[str]] gas_species=None)
Definition rag.py:131
None register_kernel(self, str name, kernel)
Definition rag.py:204
Tuple[Edge,...] edges_of_class(self, EdgeClass edge_class)
Definition rag.py:200
"Tuple[Polarity, str]" _pop_key(Population p)
Definition rag.py:147
Tuple[Population,...] populations(self)
Definition rag.py:160
None set_monomer_population(self, Population p)
Definition rag.py:167
Tuple[Population,...] populations_of(self, Polarity polarity)
Definition rag.py:163
Population monomer_population(self, Polarity polarity)
Definition rag.py:174
Population add_population(self, Population p)
Definition rag.py:150