RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
to_networkx.py
Go to the documentation of this file.
1"""
2core/to_networkx.py — explicit materialization of a RAG for diagnostics.
3
4Host-independent. The production path keeps the reaction admissibility
5graph *implicit*: an :class:`Edge` is a size-parameterized family, and the
6graph walker never instantiates a vertex. That is the right choice for the
7inner loop (see the paper, Section "Implementation"), but it means the
8standard structural queries — connectivity, reachability from the monomer
9pools, orphaned vertices, cycle structure — are not available for free.
10
11This module supplies them by exporting the declared graph, truncated at a
12chosen size cut-off, into an explicit ``networkx.MultiDiGraph``. It is a
13DIAGNOSTIC path only: nothing in the solver imports it, and NetworkX is an
14optional dependency.
15
16Typical use::
17
18 from py_utils.materials import build_eurofer_rag
19 from py_utils.core.to_networkx import to_networkx, structural_report
20
21 rag, layout = build_eurofer_rag(inp, rr)
22 G = to_networkx(rag, n_max=40)
23 print(structural_report(rag, n_max=40))
24
25Cite as: NetworkX, Hagberg, Schult & Swart, Proc. SciPy 2008.
26"""
27from __future__ import annotations
28
29from typing import Dict, Iterable, List, Optional, Tuple
30
31from .cluster_identifier import Polarity, Population
32from .edge_classes import EDGE_CLASS_SPEC, EdgeClass
33from .rag import Edge, ReactionAdmissibilityGraph
34
35
36# ─────────────────────────────────────────────────────────────────────────
37# vertex naming
38# ─────────────────────────────────────────────────────────────────────────
39def vertex(pop: Population, n: int) -> str:
40 """Canonical string key for the vertex (polarity, size, population)."""
41 return f"{pop.polarity.label}:{pop.name}:{n}"
42
43
44def _sizes(pop: Population, n_max: int) -> range:
45 return range(pop.n_min, n_max + 1)
46
47
48# ─────────────────────────────────────────────────────────────────────────
49# export
50# ─────────────────────────────────────────────────────────────────────────
51def to_networkx(rag: ReactionAdmissibilityGraph,
52 n_max: int = 40,
53 include_binary: bool = True):
54 """Materialize ``rag`` up to size ``n_max`` as a ``networkx.MultiDiGraph``.
55
56 Parameters
57 ----------
58 rag : ReactionAdmissibilityGraph
59 The host's declared graph.
60 n_max : int
61 Size cut-off. Keep it small (tens): the binary edge classes are
62 O(n_max**2) per family, which is exactly why the production path
63 does not materialize the graph at all.
64 include_binary : bool
65 Expand COALESCENCE and ANNIHILATION families. These are directed
66 *hyperedges* (two precursors); NetworkX has no hyperedge type, so
67 each is represented by its two tail->head arcs, both tagged
68 ``hyperedge=True`` and sharing a ``hyper_id``. Set False for a
69 cleaner drawing.
70
71 Returns
72 -------
73 networkx.MultiDiGraph
74 Node attributes: ``polarity``, ``population``, ``size``.
75 Edge attributes: ``edge_class``, ``label``, ``kernel``,
76 ``hyperedge``, ``hyper_id``.
77 """
78 try:
79 import networkx as nx
80 except ImportError as exc: # pragma: no cover
81 raise ImportError(
82 "to_networkx() needs the optional dependency 'networkx' "
83 "(pip install networkx). It is a diagnostic path only; the "
84 "solver does not require it.") from exc
85
86 G = nx.MultiDiGraph(name=rag.name)
87
88 # ── vertices ────────────────────────────────────────────────────────
89 for pop in rag.populations:
90 for n in _sizes(pop, n_max):
91 G.add_node(vertex(pop, n),
92 polarity=pop.polarity.label,
93 population=pop.name,
94 size=n)
95
96 # Sources and sinks are represented by two sentinel vertices so that
97 # reachability questions have somewhere to start and end.
98 G.add_node("SOURCE", polarity="none", population="cascade", size=0)
99 G.add_node("SINK", polarity="none", population="unresolved", size=0)
100
101 hyper_id = 0
102 for e in rag.edges:
103 ec = e.edge_class
104 pop = e.population
105 attrs = dict(edge_class=ec.value, label=e.label, kernel=e.kernel,
106 hyperedge=False, hyper_id=None)
107
108 if ec is EdgeClass.SOURCE:
109 for n in _sizes(pop, n_max):
110 G.add_edge("SOURCE", vertex(pop, n), **attrs)
111
112 elif ec is EdgeClass.SINK:
113 for n in _sizes(pop, n_max):
114 G.add_edge(vertex(pop, n), "SINK", **attrs)
115
116 elif ec in (EdgeClass.GROWTH,):
117 for n in _sizes(pop, n_max):
118 if n + 1 <= n_max:
119 G.add_edge(vertex(pop, n), vertex(pop, n + 1), **attrs)
120
121 elif ec in (EdgeClass.SHRINKAGE, EdgeClass.DISSOCIATION,
122 EdgeClass.RECOMBINATION):
123 for n in _sizes(pop, n_max):
124 if n - 1 >= pop.n_min:
125 G.add_edge(vertex(pop, n), vertex(pop, n - 1), **attrs)
126
127 elif ec is EdgeClass.INTER_POPULATION:
128 dst = e.product_population
129 for n in _sizes(pop, n_max):
130 if dst.n_min <= n <= n_max:
131 G.add_edge(vertex(pop, n), vertex(dst, n), **attrs)
132
133 elif ec is EdgeClass.SOLUTE_TRAPPING:
134 # Composition axis is not expanded here (it would multiply the
135 # node count by |C|); the trapping edge is drawn as a self-loop
136 # tagged with the gas species index.
137 a = dict(attrs, gas_species=e.gas_species)
138 for n in _sizes(pop, n_max):
139 G.add_edge(vertex(pop, n), vertex(pop, n), **a)
140
141 elif ec is EdgeClass.COALESCENCE and include_binary:
142 dst = e.product_population or pop
143 partner = e.partner_population or pop
144 for n in _sizes(pop, n_max):
145 for m in _sizes(partner, n_max):
146 if n + m > n_max or n + m < dst.n_min:
147 continue
148 a = dict(attrs, hyperedge=True, hyper_id=hyper_id)
149 G.add_edge(vertex(pop, n), vertex(dst, n + m), **a)
150 G.add_edge(vertex(partner, m), vertex(dst, n + m), **a)
151 hyper_id += 1
152
153 elif ec is EdgeClass.ANNIHILATION and include_binary:
154 partner = e.partner_population
155 for n in _sizes(pop, n_max):
156 for m in _sizes(partner, n_max):
157 if n == m:
158 head = "SINK" # mutual annihilation
159 elif n > m:
160 head = vertex(pop, n - m)
161 else:
162 head = vertex(partner, m - n)
163 if head not in G:
164 continue
165 a = dict(attrs, hyperedge=True, hyper_id=hyper_id)
166 G.add_edge(vertex(pop, n), head, **a)
167 G.add_edge(vertex(partner, m), head, **a)
168 hyper_id += 1
169
170 return G
171
172
173# ─────────────────────────────────────────────────────────────────────────
174# serialization
175# ─────────────────────────────────────────────────────────────────────────
176def write_graphml(G, path) -> None:
177 """Write ``G`` to GraphML, dropping attributes GraphML cannot represent.
178
179 ``to_networkx`` tags every arc with ``hyper_id``, which is ``None`` on
180 the unary edge classes because they are not hyperedge members, and
181 ``kernel`` is ``None`` for any edge the host declared without one.
182 The GraphML writer rejects ``None`` outright ("GraphML writer does not
183 support <class 'NoneType'> as data values"), so exporting the graph
184 unmodified always fails. Attributes are dropped rather than coerced:
185 an absent ``hyper_id`` is what "not a hyperedge" means, and writing a
186 sentinel would invite a reader to treat it as an id.
187
188 The input graph is not modified.
189 """
190 import networkx as nx
191
192 H = G.copy()
193 for _, _, d in H.edges(data=True):
194 for k in [k for k, v in d.items() if v is None]:
195 del d[k]
196 for _, d in H.nodes(data=True):
197 for k in [k for k, v in d.items() if v is None]:
198 del d[k]
199 nx.write_graphml(H, path)
200
201
202# ─────────────────────────────────────────────────────────────────────────
203# structural diagnostics
204# ─────────────────────────────────────────────────────────────────────────
205def structural_report(rag: ReactionAdmissibilityGraph,
206 n_max: int = 40) -> str:
207 """Human-readable structural audit of a declared RAG.
208
209 Reports vertex/edge counts, weak connectivity, the set of vertices not
210 reachable from the monomer pools (a declaration bug: population with no
211 production channel), and the set with no outgoing edge (a sink-only
212 trap). Intended to be run once per host declaration and quoted in the
213 paper, not called during integration.
214 """
215 import networkx as nx
216
217 G = to_networkx(rag, n_max=n_max)
218 lines: List[str] = []
219 lines.append(f"RAG {rag.name!r} materialized at n_max={n_max}")
220 lines.append(f" vertices : {G.number_of_nodes()}")
221 lines.append(f" arcs : {G.number_of_edges()}")
222 lines.append(f" edge families declared: {len(rag.edges)}")
223 lines.append(f" weakly connected: {nx.is_weakly_connected(G)}")
224
225 # reachability from the monomer pools
226 roots = []
227 for polarity in (Polarity.SIA, Polarity.VACANCY):
228 try:
229 pop = rag.monomer_population(polarity)
230 except KeyError:
231 continue
232 roots.append(vertex(pop, pop.n_min))
233 reach = set()
234 for r in roots:
235 if r in G:
236 reach |= nx.descendants(G, r) | {r}
237 orphans = [v for v in G if v not in reach and v not in ("SOURCE", "SINK")]
238 lines.append(f" monomer roots: {roots}")
239 lines.append(f" unreachable from monomers: {len(orphans)}")
240 if orphans:
241 lines.append(f" e.g. {orphans[:8]}")
242
243 dead = [v for v in G if G.out_degree(v) == 0 and v != "SINK"]
244 lines.append(f" vertices with no outgoing arc: {len(dead)}")
245 if dead:
246 lines.append(f" e.g. {dead[:8]}")
247
248 by_class: Dict[str, int] = {}
249 for _, _, d in G.edges(data=True):
250 by_class[d["edge_class"]] = by_class.get(d["edge_class"], 0) + 1
251 lines.append(" arcs by edge class:")
252 for k in sorted(by_class, key=lambda k: -by_class[k]):
253 lines.append(f" {k:<18s} {by_class[k]}")
254 return "\n".join(lines)
255
256
257if __name__ == "__main__": # pragma: no cover
258 import argparse
259 import sys
260 from pathlib import Path
261
262 # The relative imports above mean this file cannot be run as a script;
263 # invoke it as a module from the radcluster_code directory:
264 # python -m py_utils.core.to_networkx
265 sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
266 from py_utils.input_data import InputData
267 from py_utils.reaction_rates import ReactionRates
268 from py_utils.materials import build_eurofer_rag
269
270 ap = argparse.ArgumentParser(
271 description="Materialize and audit the EUROFER-97 reaction "
272 "admissibility graph.")
273 ap.add_argument("--n-max", type=int, default=30,
274 help="size cut-off; binary classes are O(n_max**2)")
275 ap.add_argument("--I", type=int, default=200)
276 ap.add_argument("--V", type=int, default=200)
277 ap.add_argument("--equations", default="discrete",
278 choices=["discrete", "bin_moment"])
279 ap.add_argument("--cascade", default="fission",
280 choices=["fission", "fusion"])
281 ap.add_argument("--no-binary", action="store_true",
282 help="omit COALESCENCE/ANNIHILATION; they dominate the "
283 "arc count and make any drawing unreadable")
284 ap.add_argument("--graphml", type=Path, default=None,
285 help="also export the materialized graph to this path")
286 ap.add_argument("--report", type=Path, default=None,
287 help="write the structural report here as well as stdout")
288 a = ap.parse_args()
289
290 inp = InputData(I=a.I, V=a.V,
291 physics_option=f"full_CD_{a.cascade}")
292 rag, _ = build_eurofer_rag(inp, ReactionRates(inp),
293 equations=a.equations, cascade=a.cascade)
294
295 report = structural_report(rag, n_max=a.n_max)
296 print(report)
297 if a.report:
298 a.report.write_text(report + "\n", encoding="utf-8")
299 print(f"\nreport -> {a.report}")
300 if a.graphml:
301 G = to_networkx(rag, n_max=a.n_max,
302 include_binary=not a.no_binary)
303 write_graphml(G, a.graphml)
304 print(f"graphml -> {a.graphml} "
305 f"({G.number_of_nodes()} nodes, {G.number_of_edges()} arcs)")
range _sizes(Population pop, int n_max)