51def to_networkx(rag: ReactionAdmissibilityGraph,
53 include_binary: bool =
True):
54 """Materialize ``rag`` up to size ``n_max`` as a ``networkx.MultiDiGraph``.
58 rag : ReactionAdmissibilityGraph
59 The host's declared graph.
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.
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
74 Node attributes: ``polarity``, ``population``, ``size``.
75 Edge attributes: ``edge_class``, ``label``, ``kernel``,
76 ``hyperedge``, ``hyper_id``.
80 except ImportError
as exc:
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
86 G = nx.MultiDiGraph(name=rag.name)
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,
98 G.add_node(
"SOURCE", polarity=
"none", population=
"cascade", size=0)
99 G.add_node(
"SINK", polarity=
"none", population=
"unresolved", size=0)
105 attrs = dict(edge_class=ec.value, label=e.label, kernel=e.kernel,
106 hyperedge=
False, hyper_id=
None)
108 if ec
is EdgeClass.SOURCE:
109 for n
in _sizes(pop, n_max):
110 G.add_edge(
"SOURCE", vertex(pop, n), **attrs)
112 elif ec
is EdgeClass.SINK:
113 for n
in _sizes(pop, n_max):
114 G.add_edge(vertex(pop, n),
"SINK", **attrs)
116 elif ec
in (EdgeClass.GROWTH,):
117 for n
in _sizes(pop, n_max):
119 G.add_edge(vertex(pop, n), vertex(pop, n + 1), **attrs)
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)
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)
133 elif ec
is EdgeClass.SOLUTE_TRAPPING:
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)
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:
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)
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):
160 head = vertex(pop, n - m)
162 head = vertex(partner, m - n)
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)
205def structural_report(rag: ReactionAdmissibilityGraph,
206 n_max: int = 40) -> str:
207 """Human-readable structural audit of a declared RAG.
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.
215 import networkx
as nx
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)}")
227 for polarity
in (Polarity.SIA, Polarity.VACANCY):
229 pop = rag.monomer_population(polarity)
232 roots.append(vertex(pop, pop.n_min))
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)}")
241 lines.append(f
" e.g. {orphans[:8]}")
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)}")
246 lines.append(f
" e.g. {dead[:8]}")
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)