RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
make_rag_figure.py
Go to the documentation of this file.
1#!/usr/bin/env python
2"""make_rag_figure.py — node-link drawing of the EUROFER-97 RAG.
3
4Renders the reaction admissibility graph declared in
5``py_utils/materials/eurofer97`` as a three-lane ladder: one lane per
6population, cluster size along x, one arc per declared edge family.
7
8The drawing is generated from the MATERIALIZED graph
9(``py_utils.core.to_networkx``), not from a hand-transcribed edge list, so
10it cannot drift from the declaration. If a family is added to the host
11declaration it appears here on the next run.
12
13Binary classes (COALESCENCE, ANNIHILATION) are omitted: they are directed
14hyperedges and O(n_max**2) in count -- 6564 of the 7078 arcs at n_max=30 --
15and drawing them turns the figure into a solid block. Their count is
16reported on the figure instead.
17
18ENCODING. Colour carries the EDGE CLASS here, not the population, because
19the edge classes are what must be told apart: several share a vertex pair
20and are drawn as nested arcs millimetres from each other. Population
21identity is carried instead by a tinted lane band plus a direct lane label
22-- a large, low-saturation mark that cannot be confused with a thin
23saturated arc, and which leaves the whole categorical budget for the arcs.
24The lane tints are the suite population colours from
25py_utils/visualization.py, so the association with the physics figures
26survives.
27
28The four arc hues are reference categorical slots 1/2/3/7, validated under
29the ALL-PAIRS gate (worst CVD dE 9.2, worst normal-vision dE 16.3).
30All-pairs is the right gate here because nested arcs sit side by side
31rather than in a legend-ordered stack. Line style is a redundant second
32channel, so the figure survives greyscale printing.
33
34Style otherwise follows py_utils/visualization.py: titles suppressed (the
35document captions the figure) and type set once via rcParams at 22/20/16.
36
37Usage::
38
39 cd radcluster_code
40 python codes/make_rag_figure.py --n-max 12 \
41 --out ../docs/Formulation/reaction_admissibility_graph/figures/eurofer_rag_figure.png
42"""
43from __future__ import annotations
44
45import argparse
46import sys
47from pathlib import Path
48
49import matplotlib
50matplotlib.use("Agg")
51import matplotlib.pyplot as plt
52from matplotlib.lines import Line2D
53from matplotlib.patches import Circle, FancyArrowPatch, Rectangle
54
55sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
56
57from py_utils.input_data import InputData # noqa: E402
58from py_utils.reaction_rates import ReactionRates # noqa: E402
59from py_utils.materials import build_eurofer_rag # noqa: E402
60from py_utils.core.to_networkx import to_networkx # noqa: E402
61
62# ── house style (py_utils/visualization.py) ──────────────────────────────
63_LABEL_FONTSIZE = 22
64_TICK_FONTSIZE = 20
65_PLOT_FONTSIZE = 20
66_NODE_FONTSIZE = 26 # the size label inside a vertex
67_LW = 3.0
68_LW_THIN = 2.4
69_DPI = 150
70
71plt.rcParams.update({
72 "axes.labelsize": _LABEL_FONTSIZE,
73 "axes.titlesize": _LABEL_FONTSIZE,
74 "xtick.labelsize": _TICK_FONTSIZE,
75 "ytick.labelsize": _TICK_FONTSIZE,
76 "legend.fontsize": _PLOT_FONTSIZE,
77 "legend.title_fontsize": _PLOT_FONTSIZE,
78 "figure.dpi": _DPI,
79})
80
81_INK = "#1a1a1a" # text tokens, and the cross-lane transfer arrows
82_NODE_RING = "#3f3f3f"
83_SENTINEL = "#b4b4b4" # sentinel node outline
84_FAN_SIA = "#1a1a1a" # source/sink fans touching an SIA population
85_FAN_VAC = "#1a4f8a" # ... and a vacancy population
86_GREY_TEXT = "#6a6a6a"
87
88# Suite population colours, used here only as a 12%-alpha lane tint.
89_POP = {
90 "bulk-111": ("steelblue", r"$\frac{1}{2}\langle 111\rangle$ SIA loops"),
91 "bulk-100": ("darkviolet", r"$\langle 100\rangle$ SIA loops"),
92 "bulk": ("tomato", "vacancy clusters / cavities"),
93}
94
95# Validated all-pairs categorical set for the four classes that co-locate
96# as nested arcs. inter_population and solute_trapping are NOT given
97# categorical hues: they are geometrically unmistakable (a vertical arrow
98# between lanes; a closed loop under a vertex), so spending scarce
99# validated slots on them would degrade the four that need separating.
100_EDGE = {
101 "growth": ("#2a78d6", "-", _LW),
102 "shrinkage": ("#eb6834", "--", _LW_THIN),
103 "dissociation": ("#1baf7a", ":", _LW_THIN),
104 "recombination": ("#4a3aa7", "-.", _LW_THIN),
105 "inter_population": (_INK, "-", _LW),
106 "solute_trapping": ("#4a4a4a", "-", _LW_THIN),
107 "source": (_FAN_SIA, "-", 1.4),
108 "sink": (_FAN_SIA, (0, (7, 3)), 1.4),
109}
110
111# Opened up on both axes: the previous 1.0/1.0 spacing left a 0.4-unit gap
112# between circles, far too short for a dash pattern to read.
113_X_SCALE = 3.00
114_LANE_Y = {"bulk-111": 6.2, "bulk-100": 3.1, "bulk": 0.0}
115_R = 0.44
116
117
118def _node_key(node: str):
119 """'sia:bulk-111:7' -> ('bulk-111', 7); sentinels -> (None, None)."""
120 if node in ("SOURCE", "SINK"):
121 return None, None
122 _, pop, n = node.split(":")
123 return pop, int(n)
124
125
126# ── worked examples of the two omitted binary classes ────────────────────
127# The lane ladder cannot show COALESCENCE or ANNIHILATION in place (they are
128# O(n_max^2) hyperedges), but one instance of each, drawn full size above the
129# ladder, states the two-tails/one-head convention that the count in the
130# header would otherwise leave abstract.
131_EX_R = 0.64
132_POP_SHORT = {
133 "bulk-111": r"$\frac{1}{2}\langle 111\rangle$",
134 "bulk-100": r"$\langle 100\rangle$",
135 "bulk": "vacancy",
136}
137# Compact coloured marks, matching the companion hyperedge figure's
138# schematic. Only the reaction VERTEX is coloured, never the arrows: a
139# coloured arrow up here would be read as one of the lane arc classes.
140_HYPER = {"coalescence": "#256abf", "annihilation": "#d03b3b"}
141
142
143def find_hyperedge(rag, edge_class, tails, head):
144 """Assert that (tails -> head) is really declared, and name its family.
145
146 The examples are hand-picked sizes, so unlike everything else in this
147 figure they could drift from the declaration. This looks them up in a
148 graph materialized wide enough to contain them and returns the family
149 label, so a stale example fails loudly instead of drawing a reaction
150 the host does not admit.
151 """
152 from collections import defaultdict
153 want_t, want_h = sorted(tails), tuple(head)
154 span = max(max(n for _, n in tails), head[1]) + 1
155 G = to_networkx(rag, n_max=span, include_binary=True)
156 groups = defaultdict(list)
157 for u, v, d in G.edges(data=True):
158 if d.get("hyper_id") is not None:
159 groups[d["hyper_id"]].append((u, v, d))
160 for arcs in groups.values():
161 d = arcs[0][2]
162 if d["edge_class"] != edge_class:
163 continue
164 if sorted(_node_key(a[0]) for a in arcs) == want_t \
165 and _node_key(arcs[0][1]) == want_h:
166 return d["label"]
167 raise LookupError(
168 f"{edge_class} {tails} -> {head} is not declared; the example is "
169 f"stale or the host declaration changed")
170
171
172def _draw_example(ax, x0, y, tails, head, edge_class, label, n_max,
173 note=None):
174 """One hyperedge: two tails -> reaction vertex -> one head."""
175 colour = _HYPER[edge_class]
176 tail_xy = [(x0, y + 1.25), (x0, y - 1.25)]
177 sq, hx = x0 + 3.5, x0 + 7.0
178
179 def vertex(cx, cy, pop, n, side):
180 ax.add_patch(Circle((cx, cy), _EX_R, facecolor=_POP[pop][0],
181 alpha=0.30, edgecolor="none", zorder=5))
182 ax.add_patch(Circle((cx, cy), _EX_R, facecolor="none",
183 edgecolor=_NODE_RING, lw=_LW_THIN, zorder=6))
184 ax.text(cx, cy, str(n), ha="center", va="center", color=_INK,
185 fontweight="bold", fontsize=_NODE_FONTSIZE, zorder=7)
186 dx = -(_EX_R + 0.3) if side == "left" else (_EX_R + 0.3)
187 ax.text(cx + dx, cy, _POP_SHORT[pop], ha=side and
188 ("right" if side == "left" else "left"), va="center",
189 color=_GREY_TEXT, fontsize=_PLOT_FONTSIZE, zorder=7)
190
191 for (tx, ty), (pop, n) in zip(tail_xy, tails):
192 vertex(tx, ty, pop, n, "left")
193 ax.add_patch(FancyArrowPatch((tx, ty), (sq, y), arrowstyle="-|>",
194 mutation_scale=17, lw=_LW_THIN,
195 color=colour, shrinkA=38, shrinkB=14,
196 zorder=4))
197 ax.add_patch(Rectangle((sq - 0.42, y - 0.42), 0.84, 0.84,
198 facecolor=colour, edgecolor="none", zorder=6))
199 ax.add_patch(FancyArrowPatch((sq, y), (hx, y), arrowstyle="-|>",
200 mutation_scale=20, lw=_LW, color=colour,
201 shrinkA=14, shrinkB=38, zorder=4))
202 vertex(hx, y, head[0], head[1], "right")
203
204 ax.text(x0 + 3.5, y + 2.45, f"{edge_class} · {label}", ha="center",
205 va="bottom", color=_INK, fontsize=_PLOT_FONTSIZE, zorder=8,
206 bbox=dict(facecolor="white", edgecolor="none", pad=2.0))
207 if note:
208 ax.text(x0 + 3.5, y - 2.45, note, ha="center", va="top",
209 color=_GREY_TEXT, fontsize=_PLOT_FONTSIZE, zorder=8,
210 bbox=dict(facecolor="white", edgecolor="none", pad=2.0))
211
212 # Leaders tie every example vertex that IS a ladder vertex back to it --
213 # the head as much as the tails, and on WHICHEVER lane it lives. The
214 # annihilation example spans polarities, so restricting leaders to the
215 # <111> lane (as this did originally) left its vacancy tail floating:
216 # the one vertex whose lane the reader most needs pointed out, because
217 # it is the reason the reaction cannot be drawn inside a single lane.
218 # Its leader crosses the other lanes, which is honest -- the reaction
219 # crosses them too.
220 #
221 # The only vertex that still gets none is a head beyond the cut-off (the
222 # coalescence head n+m = 11): there is no rung to point at, and that
223 # absence is the truncation the caption describes.
224 leader_pts = list(zip(tail_xy, tails)) + [((hx, y), tuple(head))]
225 for (cx, cy), (pop, n) in leader_pts:
226 if pop not in _LANE_Y or n > n_max:
227 continue
228 # Strong black dash: this is a callout tying the example to the
229 # ladder, and it has to survive crossing the source/sink fans. The
230 # dash period is deliberately longer than the sink arcs' (7,3) so
231 # the two do not read as the same class.
232 ax.plot([cx, _X_SCALE * n], [cy - _EX_R, _LANE_Y[pop] + _R],
233 ls=(0, (9, 4)), lw=2.2, color=_INK, alpha=0.95, zorder=2)
234
235
236def build_figure(n_max: int, out: Path, examples: bool = True) -> None:
237 inp = InputData(I=200, V=200, physics_option="full_CD_fission")
238 rag, _ = build_eurofer_rag(inp, ReactionRates(inp))
239
240 G = to_networkx(rag, n_max=n_max, include_binary=False)
241 Gfull = to_networkx(rag, n_max=n_max, include_binary=True)
242 n_binary = Gfull.number_of_edges() - G.number_of_edges()
243
244 pos = {}
245 for node in G.nodes:
246 pop, n = _node_key(node)
247 if pop is not None:
248 pos[node] = (_X_SCALE * n, _LANE_Y[pop])
249 x_lo, x_hi = 0.0, _X_SCALE * (n_max + 1)
250 pos["SOURCE"] = (x_lo, _LANE_Y["bulk-111"] + 2.7)
251 pos["SINK"] = (x_hi, _LANE_Y["bulk"] - 2.7)
252
253 _w = 1.95 * n_max + 8.0
254 fig, ax = plt.subplots(figsize=(_w, _w / 1.42 if examples else 12.0))
255
256 # ── lane bands: population identity, large and low-saturation ────────
257 for pop, y in _LANE_Y.items():
258 ax.add_patch(Rectangle((x_lo - 0.6, y - 0.95),
259 x_hi - x_lo + 1.2, 1.9,
260 facecolor=_POP[pop][0], alpha=0.12,
261 edgecolor="none", zorder=0))
262
263 # ── arcs ─────────────────────────────────────────────────────────────
264 # Parallel families between the same vertex pair fan out by increasing
265 # |rad| -- the graph is a MultiDiGraph precisely because several
266 # classes share endpoints (cavity growth and trap mutation both
267 # advance the vacancy lane).
268 seen: dict[tuple, int] = {}
269 for u, v, d in G.edges(data=True):
270 ec = d["edge_class"]
271 if u not in pos or v not in pos:
272 continue
273 upop, un = _node_key(u)
274 vpop, vn = _node_key(v)
275 colour, ls, lw = _EDGE.get(ec, (_INK, "-", _LW_THIN))
276 alpha = 0.95
277 zorder = 3
278 if ec in ("source", "sink"):
279 # Fan colour encodes the POLARITY of the population it touches:
280 # the cascade feeds, and the sinks drain, the two sub-spaces
281 # independently, and that split is the thing worth seeing.
282 # source: head is the population vertex; sink: tail is.
283 fan_pop = vpop if ec == "source" else upop
284 colour = _FAN_VAC if fan_pop == "bulk" else _FAN_SIA
285 alpha, zorder = 0.55, 1
286
287 idx = seen.get((u, v), 0)
288 seen[(u, v)] = idx + 1
289
290 if u == v:
291 # Solute trapping is a self-loop because the composition axis
292 # is NOT expanded here (expanding it would multiply the node
293 # count by |C|). Drawn TANGENT to the vertex and above the
294 # arc layer, on a white halo: the loss arcs bow up to 1.3
295 # units below the lane, so an untangent loop drawn underneath
296 # them either disappears or reads as a second vertex.
297 x, y = pos[u]
298 cx, cy, r = x, y - _R - 0.27, 0.27
299 ax.add_patch(Circle((cx, cy), r, fill=False, ec="white",
300 lw=lw + 3.0, zorder=6))
301 ax.add_patch(Circle((cx, cy), r, fill=False, ec=colour,
302 lw=lw, ls=ls, zorder=7))
303 ax.plot([cx - r], [cy], marker=(3, 0, 90), markersize=8,
304 color=colour, zorder=7)
305 continue
306
307 if un is not None and vn is not None and upop == vpop:
308 # growth bows above the lane, loss below; nested by family
309 base = 0.24 if vn > un else -0.24
310 rad = base * (1.0 + 0.62 * idx)
311 else:
312 rad = 0.10 * (1 + idx)
313
314 ax.add_patch(FancyArrowPatch(
315 pos[u], pos[v], connectionstyle=f"arc3,rad={rad}",
316 arrowstyle="-|>", mutation_scale=19,
317 shrinkA=24, shrinkB=24,
318 lw=lw, ls=ls, color=colour, alpha=alpha, zorder=zorder))
319
320 # ── vertices ─────────────────────────────────────────────────────────
321 # Neutral on purpose: the lane band already says which population this
322 # is, and a saturated node would compete with the arc palette.
323 for node, (x, y) in pos.items():
324 pop, n = _node_key(node)
325 if pop is None:
326 continue # sentinels drawn as label boxes
327 ax.add_patch(Circle((x, y), _R, facecolor="white",
328 edgecolor=_NODE_RING, lw=_LW_THIN, zorder=5))
329 ax.text(x, y, str(n), ha="center", va="center", color=_INK,
330 fontweight="bold", fontsize=_NODE_FONTSIZE, zorder=6)
331
332 for pop, y in _LANE_Y.items():
333 ax.text(x_lo - 0.9, y, _POP[pop][1], ha="right", va="center",
334 color=_INK, fontsize=_LABEL_FONTSIZE)
335
336 _box = dict(boxstyle="round,pad=0.45", fc="white", ec=_SENTINEL,
337 lw=_LW_THIN)
338 ax.text(*pos["SOURCE"], "cascade\nsource", ha="center", va="center",
339 color=_GREY_TEXT, fontsize=_PLOT_FONTSIZE, bbox=_box, zorder=6)
340 ax.text(*pos["SINK"], "sinks\n(dislocations, GBs, precipitates)",
341 ha="center", va="center", color=_GREY_TEXT,
342 fontsize=_PLOT_FONTSIZE, bbox=_box, zorder=6)
343
344 # ── legend ───────────────────────────────────────────────────────────
345 handles = [
346 Line2D([], [], color=c, ls=ls, lw=max(lw, 2.6),
347 label=name.replace("_", " "))
348 for name, (c, ls, lw) in _EDGE.items()
349 if name not in ("source", "sink")
350 ]
351 _dash = (0, (7, 3))
352 handles += [
353 Line2D([], [], color=_FAN_SIA, ls="-", lw=2.0, label="source → SIA"),
354 Line2D([], [], color=_FAN_VAC, ls="-", lw=2.0, label="source → vacancy"),
355 Line2D([], [], color=_FAN_SIA, ls=_dash, lw=2.0, label="sink ← SIA"),
356 Line2D([], [], color=_FAN_VAC, ls=_dash, lw=2.0, label="sink ← vacancy"),
357 ]
358 fig.legend(handles=handles, loc="lower center",
359 bbox_to_anchor=(0.5, 0.008), frameon=False,
360 title="edge class", ncol=4)
361
362 if examples:
363 ey = _LANE_Y["bulk-111"] + 5.6
364 lab_a = find_hyperedge(rag, "annihilation",
365 [("bulk", 2), ("bulk-111", 4)], ("bulk-111", 2))
366 _draw_example(ax, x_lo + 2.0, ey,
367 [("bulk-111", 4), ("bulk", 2)], ("bulk-111", 2),
368 "annihilation", lab_a, n_max,
369 "the larger loop survives, shrunk by\nthe vacancy "
370 "content it absorbed")
371 lab_c = find_hyperedge(rag, "coalescence",
372 [("bulk-111", 3), ("bulk-111", 8)],
373 ("bulk-111", 11))
374 _draw_example(ax, x_lo + 15.6, ey,
375 [("bulk-111", 3), ("bulk-111", 8)], ("bulk-111", 11),
376 "coalescence", lab_c, n_max,
377 f"head $n{{+}}m=11$ lies beyond the cut-off\n"
378 f"$n_{{max}}={n_max}$: not an arc in the ladder")
379 ax.set_xlabel("cluster size $n$")
380 ax.set_xlim(x_lo - 1.4, x_hi + 1.4)
381 top = _LANE_Y["bulk-111"] + (9.6 if examples else 4.3)
382 ax.set_ylim(_LANE_Y["bulk"] - 4.4, top)
383 ax.set_xticks([_X_SCALE * n for n in range(1, n_max + 1)])
384 ax.set_xticklabels([str(n) for n in range(1, n_max + 1)])
385 ax.set_yticks([])
386 for side in ("top", "right", "left"):
387 ax.spines[side].set_visible(False)
388 ax.spines["bottom"].set_color(_SENTINEL)
389 ax.set_aspect("equal")
390
391 # No title: suppressed suite-wide; the document captions the figure.
392 fig.tight_layout(rect=(0.0, 0.15 if examples else 0.23, 1.0, 1.0))
393 out.parent.mkdir(parents=True, exist_ok=True)
394 fig.savefig(out, dpi=_DPI, bbox_inches="tight", facecolor="white")
395 print(f"nodes {G.number_of_nodes()} unary arcs {G.number_of_edges()} "
396 f"binary omitted {n_binary}")
397 print(f"figure -> {out}")
398
399
400if __name__ == "__main__":
401 ap = argparse.ArgumentParser(description=__doc__)
402 ap.add_argument("--n-max", type=int, default=8)
403 ap.add_argument("--no-examples", action="store_true",
404 help="omit the two worked hyperedge examples")
405 ap.add_argument("--out", type=Path,
406 default=Path("../docs/Formulation/reaction_admissibility_graph/figures/eurofer_rag_figure.png"))
407 a = ap.parse_args()
408 build_figure(a.n_max, a.out, examples=not a.no_examples)
find_hyperedge(rag, edge_class, tails, head)
None build_figure(int n_max, Path out, bool examples=True)
_draw_example(ax, x0, y, tails, head, edge_class, label, n_max, note=None)