RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
make_rag_hyperedge_figure.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""make_rag_hyperedge_figure.py — the binary half of the EUROFER-97 RAG.
3
4Companion to ``make_rag_figure.py``, which draws the unary skeleton and
5omits COALESCENCE and ANNIHILATION because they are directed *hyperedges*
6(two tails, one head) and O(n_max**2) in count -- 360 arcs at n_max=8,
76564 at n_max=30. A node-link drawing of those is a solid block.
8
9A hyperedge family is a *reaction table*, so it is drawn as one: the
10(n, m) plane, with the cell carrying the head the pair maps to. Every
11cell IS one hyperedge, so nothing is sampled or elided.
12
13The pairs are reconstructed from the materialized graph by grouping arcs
14on ``hyper_id`` -- each id carries exactly the two tail->head arcs of one
15hyperedge -- so this figure, like its companion, is generated from the
16declaration rather than from a transcription of the arithmetic.
17
18ENCODING. Coalescence carries a magnitude (the product size n+m), so it
19gets the sequential blue ramp. Annihilation carries a polarity -- which
20sub-space survives the encounter -- so it gets the diverging blue<->red
21pair with a neutral gray midpoint at n == m, where the two annihilate
22exactly and the content leaves through SINK. Blue means vacancy in both
23panels and in the companion figure.
24
25Usage::
26
27 cd radcluster_code
28 python codes/make_rag_hyperedge_figure.py --n-max 8 \
29 --out ../docs/Formulation/reaction_admissibility_graph/figures/eurofer_rag_hyperedges.png
30"""
31from __future__ import annotations
32
33import argparse
34import sys
35import textwrap
36from collections import defaultdict
37from pathlib import Path
38
39import matplotlib
40matplotlib.use("Agg")
41import matplotlib.pyplot as plt
42import numpy as np
43from matplotlib.colors import LinearSegmentedColormap, Normalize, TwoSlopeNorm
44from matplotlib.patches import Circle, FancyArrowPatch, Rectangle
45
46sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
47
48from py_utils.input_data import InputData # noqa: E402
49from py_utils.reaction_rates import ReactionRates # noqa: E402
50from py_utils.materials import build_eurofer_rag # noqa: E402
51from py_utils.core.to_networkx import to_networkx # noqa: E402
52
53# ── house style (py_utils/visualization.py) ──────────────────────────────
54_LABEL_FONTSIZE = 22
55_TICK_FONTSIZE = 20
56_PLOT_FONTSIZE = 20
57_LW = 3.0
58_LW_THIN = 2.4
59_DPI = 150
60
61plt.rcParams.update({
62 "axes.labelsize": _LABEL_FONTSIZE,
63 "axes.titlesize": _LABEL_FONTSIZE,
64 "xtick.labelsize": _TICK_FONTSIZE,
65 "ytick.labelsize": _TICK_FONTSIZE,
66 "legend.fontsize": _PLOT_FONTSIZE,
67 "legend.title_fontsize": _PLOT_FONTSIZE,
68 "figure.dpi": _DPI,
69})
70
71_INK = "#1a1a1a"
72_GREY_TEXT = "#6a6a6a"
73_OUTSIDE = "#e8e7e3" # pair declared but head falls outside the domain
74
75# Documented blue ramp, 100 -> 700 (sequential: magnitude).
76_SEQ = LinearSegmentedColormap.from_list("radcluster_seq", [
77 "#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b",
78])
79# Documented diverging pair: blue <-> red, neutral gray midpoint.
80_DIV = LinearSegmentedColormap.from_list("radcluster_div", [
81 "#8f2020", "#d03b3b", "#e06a6a", "#eda3a3", "#f0efec",
82 "#9ec5f4", "#3987e5", "#184f95", "#0d366b",
83])
84
85_POP_LABEL = {
86 "bulk-111": r"$\frac{1}{2}\langle 111\rangle$",
87 "bulk-100": r"$\langle 100\rangle$",
88 "bulk": "vacancy",
89}
90
91
92def _key(node: str):
93 if node in ("SOURCE", "SINK"):
94 return node, None
95 _, pop, n = node.split(":")
96 return pop, int(n)
97
98
100 """Group materialized arcs by ``hyper_id`` -> (tails, head, label).
101
102 ``to_networkx`` emits each binary reaction as its two tail->head arcs
103 sharing one ``hyper_id``, which is exactly enough to recover the
104 (n, m) pair and its product without re-deriving the arithmetic here.
105 """
106 groups = defaultdict(list)
107 for u, v, d in G.edges(data=True):
108 if d.get("hyperedge") and d.get("hyper_id") is not None:
109 groups[d["hyper_id"]].append((u, v, d))
110 out = []
111 for hid, arcs in groups.items():
112 head = arcs[0][1]
113 tails = [a[0] for a in arcs]
114 out.append((tails, head, arcs[0][2]["edge_class"], arcs[0][2]["label"]))
115 return out
116
117
118def _cell_text_colour(rgba) -> str:
119 r, g, b = rgba[:3]
120 lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
121 return "white" if lum < 0.55 else _INK
122
123
124def _draw_matrix(ax, grid, mask_outside, n_max, cmap, norm, fmt):
125 for i in range(n_max):
126 for j in range(n_max):
127 n, m = j + 1, i + 1
128 if mask_outside[i, j]:
129 ax.add_patch(Rectangle((n - 0.5, m - 0.5), 1, 1,
130 facecolor=_OUTSIDE, edgecolor="white",
131 lw=1.5, hatch="///", zorder=1))
132 continue
133 if np.isnan(grid[i, j]):
134 ax.add_patch(Rectangle((n - 0.5, m - 0.5), 1, 1,
135 facecolor="white", edgecolor="white",
136 lw=1.5, zorder=1))
137 continue
138 rgba = cmap(norm(grid[i, j]))
139 ax.add_patch(Rectangle((n - 0.5, m - 0.5), 1, 1, facecolor=rgba,
140 edgecolor="white", lw=1.5, zorder=1))
141 ax.text(n, m, fmt(grid[i, j]), ha="center", va="center",
142 fontsize=_PLOT_FONTSIZE, color=_cell_text_colour(rgba),
143 zorder=2)
144 ax.set_xlim(0.5, n_max + 0.5)
145 ax.set_ylim(0.5, n_max + 0.5)
146 ax.set_xticks(range(1, n_max + 1))
147 ax.set_yticks(range(1, n_max + 1))
148 ax.set_aspect("equal")
149 for side in ("top", "right"):
150 ax.spines[side].set_visible(False)
151 for side in ("left", "bottom"):
152 ax.spines[side].set_color("#b4b4b4")
153
154
156 """The two-tails/one-head convention, drawn once."""
157 ax.set_xlim(0, 10)
158 ax.set_ylim(0, 10)
159 ax.set_aspect("equal")
160 ax.axis("off")
161
162 def reaction(y, t1, t2, head, colour, caption):
163 for (cx, cy), lab in (((1.5, y + 1.1), t1), ((1.5, y - 1.1), t2)):
164 ax.add_patch(Circle((cx, cy), 0.72, facecolor="white",
165 edgecolor="#3f3f3f", lw=_LW_THIN, zorder=3))
166 ax.text(cx, cy, lab, ha="center", va="center", fontsize=_PLOT_FONTSIZE,
167 color=_INK, zorder=4)
168 ax.add_patch(Rectangle((4.55, y - 0.45), 0.9, 0.9, facecolor=colour,
169 edgecolor="none", zorder=3))
170 for cy in (y + 1.1, y - 1.1):
171 ax.add_patch(FancyArrowPatch((2.25, cy), (4.5, y),
172 arrowstyle="-|>", mutation_scale=18,
173 lw=_LW_THIN, color=colour, zorder=2))
174 ax.add_patch(FancyArrowPatch((5.5, y), (7.55, y), arrowstyle="-|>",
175 mutation_scale=18, lw=_LW, color=colour,
176 zorder=2))
177 ax.add_patch(Circle((8.3, y), 0.72, facecolor="white",
178 edgecolor="#3f3f3f", lw=_LW_THIN, zorder=3))
179 ax.text(8.3, y, head, ha="center", va="center", fontsize=_PLOT_FONTSIZE,
180 color=_INK, zorder=4)
181 ax.text(5.0, y - 1.85, caption, ha="center", va="top",
182 fontsize=_PLOT_FONTSIZE, color=_GREY_TEXT)
183
184 reaction(7.0, "$n$", "$m$", "$n{+}m$", "#256abf", "coalescence")
185 reaction(2.4, "$n$", "$m$", "$n{-}m$", "#d03b3b", "annihilation")
186
187
188def build_figure(n_max: int, out: Path) -> None:
189 inp = InputData(I=200, V=200, physics_option="full_CD_fission")
190 rag, _ = build_eurofer_rag(inp, ReactionRates(inp))
191 G = to_networkx(rag, n_max=n_max, include_binary=True)
192 hyper = collect_hyperedges(G)
193
194 coal = [h for h in hyper if h[2] == "coalescence"]
195 annih = [h for h in hyper if h[2] == "annihilation"]
196
197 # ── coalescence: head size n+m, NaN where the pair is not declared ───
198 c_grid = np.full((n_max, n_max), np.nan)
199 for tails, head, _, _ in coal:
200 sizes = sorted(_key(t)[1] for t in tails)
201 n, m = sizes[0], sizes[-1]
202 hp, hn = _key(head)
203 if n <= n_max and m <= n_max:
204 c_grid[m - 1, n - 1] = hn
205 c_grid[n - 1, m - 1] = hn
206 # a pair whose product leaves the domain is skipped by the exporter
207 c_outside = np.zeros((n_max, n_max), bool)
208 for i in range(n_max):
209 for j in range(n_max):
210 if np.isnan(c_grid[i, j]) and (i + 1) + (j + 1) > n_max:
211 c_outside[i, j] = True
212
213 # ── annihilation: signed survivor, + vacancy / - SIA / 0 mutual ─────
214 a_grid = np.full((n_max, n_max), np.nan)
215 for tails, head, _, _ in annih:
216 vac = [t for t in tails if _key(t)[0] == "bulk"]
217 sia = [t for t in tails if _key(t)[0] in ("bulk-111", "bulk-100")]
218 if not vac or not sia:
219 continue
220 nv, ns = _key(vac[0])[1], _key(sia[0])[1]
221 if nv <= n_max and ns <= n_max:
222 a_grid[ns - 1, nv - 1] = nv - ns
223 a_outside = np.zeros((n_max, n_max), bool)
224
225 fig = plt.figure(figsize=(26.0, 10.5))
226 gs = fig.add_gridspec(1, 3, width_ratios=[0.85, 1.0, 1.0], wspace=0.28)
227 ax0, ax1, ax2 = (fig.add_subplot(gs[0, k]) for k in range(3))
228
229 _schematic(ax0)
230
231 lo, hi = np.nanmin(c_grid), np.nanmax(c_grid)
232 n_seq = Normalize(vmin=lo, vmax=hi)
233 _draw_matrix(ax1, c_grid, c_outside, n_max, _SEQ, n_seq,
234 lambda v: f"{int(v)}")
235 ax1.set_xlabel("$n$")
236 ax1.set_ylabel("$m$")
237 fig.colorbar(plt.cm.ScalarMappable(norm=n_seq, cmap=_SEQ), ax=ax1,
238 fraction=0.046, pad=0.03,
239 label="product size $n{+}m$")
240
241 amax = np.nanmax(np.abs(a_grid))
242 n_div = TwoSlopeNorm(vmin=-amax, vcenter=0.0, vmax=amax)
243 _draw_matrix(ax2, a_grid, a_outside, n_max, _DIV, n_div,
244 lambda v: f"{int(abs(v))}" if v else "0")
245 ax2.set_xlabel("vacancy cluster size $n$")
246 ax2.set_ylabel("SIA cluster size $m$")
247 cb = fig.colorbar(plt.cm.ScalarMappable(norm=n_div, cmap=_DIV), ax=ax2,
248 fraction=0.046, pad=0.03,
249 label="surviving cluster")
250 cb.set_ticks([-amax, 0, amax])
251 cb.set_ticklabels(["SIA", "mutual\n(SINK)", "vacancy"])
252
253 fams_c = sorted({h[3] for h in coal})
254 fams_a = sorted({h[3] for h in annih})
255 _wrap = lambda names: "\n".join(textwrap.wrap(", ".join(names), 46))
256 ax1.annotate("union over " + str(len(fams_c))
257 + " families, all with head $n{+}m$:\n"
258 + _wrap(fams_c)
259 + "\n\nhatched: pair declared, product outside the domain",
260 xy=(0.5, -0.15), xycoords="axes fraction", ha="center",
261 va="top", fontsize=_PLOT_FONTSIZE, color=_GREY_TEXT,
262 linespacing=1.5)
263 ax2.annotate("union over " + str(len(fams_a)) + " families:\n"
264 + _wrap(fams_a),
265 xy=(0.5, -0.15), xycoords="axes fraction", ha="center",
266 va="top", fontsize=_PLOT_FONTSIZE, color=_GREY_TEXT,
267 linespacing=1.5)
268
269 fig.tight_layout()
270 out.parent.mkdir(parents=True, exist_ok=True)
271 fig.savefig(out, dpi=_DPI, bbox_inches="tight", facecolor="white")
272 print(f"coalescence hyperedges {len(coal)} "
273 f"annihilation hyperedges {len(annih)} total arcs "
274 f"{G.number_of_edges()}")
275 print(f"figure -> {out}")
276
277
278if __name__ == "__main__":
279 ap = argparse.ArgumentParser(description=__doc__)
280 ap.add_argument("--n-max", type=int, default=8)
281 ap.add_argument("--out", type=Path,
282 default=Path("../docs/Formulation/reaction_admissibility_graph/figures/eurofer_rag_hyperedges.png"))
283 a = ap.parse_args()
284 build_figure(a.n_max, a.out)
None build_figure(int n_max, Path out)
_draw_matrix(ax, grid, mask_outside, n_max, cmap, norm, fmt)