Source code for sierra.core.graphs.network

# Copyright 2018 John Harwell, All rights reserved.
#
#  SPDX-License-Identifier: MIT
#
"""
Heatmap graph generation classes for stage{4,5}.
"""

# Core packages
import textwrap
import typing as tp
import logging
import pathlib

# 3rd party packages
import matplotlib.pyplot as plt
import networkx as nx
import holoviews as hv
import bokeh
import numpy as np
import matplotlib as mpl

# Project packages
from sierra.core import utils, config
from . import pathset as _pathset

_logger = logging.getLogger(__name__)


def _ofile_ext(backend: str) -> tp.Optional[str]:
    if backend == "matplotlib":
        return str(config.GRAPHS["static_type"])

    if backend == "bokeh":
        return str(config.GRAPHS["interactive_type"])

    return None


[docs] def generate( # noqa: PLR0913 pathset: _pathset.PathSet, input_stem: str, output_stem: str, medium: str, title: str, backend: str, layout: str, node_color_attr: tp.Optional[str] = None, node_size_attr: tp.Optional[str] = None, edge_color_attr: tp.Optional[str] = None, edge_weight_attr: tp.Optional[str] = None, edge_label_attr: tp.Optional[str] = None, large_text: bool = False, ) -> bool: """ Generate a network (graph) plot from a ``.graphml`` file, using networkx. """ hv.extension(backend, inline=False, logo=False) ofile_ext = _ofile_ext(backend) input_fpath = pathset.input_root / (input_stem + ".graphml") output_fpath = pathset.output_root / f"N-{output_stem}.{ofile_ext}" if not utils.path_exists(input_fpath): _logger.debug( "Not generating <batchroot>/%s: <batchroot>/%s does not exist", output_fpath.relative_to(pathset.batchroot.resolve()), input_fpath.relative_to(pathset.batchroot.resolve()), ) return False title = "\n".join(textwrap.wrap(title, 40)) text_size = ( config.GRAPHS["text_size_large"] if large_text else config.GRAPHS["text_size_small"] ) # Read GraphML G = nx.read_graphml(input_fpath) # 2025-11-24 [JRH]: Sizing nodes according to their degree seems to give # good results/highlight interesting areas of graphs, and is a good default # when no size attribute is provided. The min/max are # empirically determined. if not node_size_attr: degrees = [G.degree(i) for i in G.nodes()] min_size, max_size = 10, 25 min_degree, max_degree = min(degrees), max(degrees) for node in G.nodes(): G.nodes[node]["size"] = min_size + (G.degree(node) - min_degree) / ( max(max_degree - min_degree, 1) ) * (max_size - min_size) node_size_attr = "size" # Build plot and configure plot, _ = _build_plot(G, layout) plot.opts( node_size=node_size_attr, node_color=node_color_attr if node_color_attr else "gray", edge_color=edge_color_attr if edge_color_attr else "black", edge_linewidth=edge_weight_attr if edge_weight_attr else 2, xaxis=None, yaxis=None, ) if backend == "bokeh" and edge_label_attr is not None: plot.opts(edge_label=edge_label_attr) elif backend == "matplotlib": plot.opts( fontsize={ "title": text_size["title"], "labels": text_size["xyz_label"], "ticks": text_size["tick_label"], } ) plot.opts(title=title) try: _save(plot, output_fpath, backend) except Exception as e: _logger.warning("Failed to output plot: %s", e) _logger.debug( "Graph written to <batchroot>/%s", output_fpath.relative_to(pathset.batchroot), ) return True
def _build_plot(G: nx.Graph, layout: str) -> tuple: # Create graph if layout == "spring": nxlayout = nx.spring_layout(G, k=3.0, iterations=100, seed=42, scale=5.0) elif layout == "spectral": nxlayout = nx.spectral_layout(G, scale=5.0) elif layout == "planar": nxlayout = nx.planar_layout(G, scale=5.0) elif layout == "spiral": nxlayout = nx.spiral_layout(G, scale=5.0) elif layout == "graphviz_dot": root = _find_root_node(G) nxlayout = nx.nx_agraph.graphviz_layout(G, prog="dot", root=root) elif layout == "graphviz_neato": root = _find_root_node(G) nxlayout = nx.nx_agraph.graphviz_layout(G, prog="neato", root=root) elif layout == "bfs": root = _find_root_node(G) nxlayout = nx.bfs_layout(G, root, scale=5.0) else: raise RuntimeError(f"Unknown layout '{layout}'. See docs for valid values.") return hv.Graph.from_networkx(G, nxlayout), nxlayout def _find_root_node(G: nx.Graph): """ Find the root node in a tree (both directed/undirected graphs). For directed graphs, root is the node with in-degree = 0. For undirected graphs, root is the center node (minimum eccentricity) """ # Check if it's a tree is_directed = G.is_directed() if is_directed: # Check if it's a directed tree (arborescence) if not nx.is_tree(G): _logger.error("Not a valid tree structure") return None # Find node with in-degree = 0 (no incoming edges) root_candidates = [node for node in G.nodes() if G.in_degree(node) == 0] if len(root_candidates) == 0: _logger.warning("No root found (no node with in-degree 0)") return None if len(root_candidates) > 1: _logger.warning("Multiple potential roots found: %s", root_candidates) return root_candidates[0] return root_candidates[0] # Undirected graph - find center if not nx.is_tree(G): _logger.warning("Not a valid tree structure") return None # Find center node(s) - node with minimum eccentricity center_nodes = nx.center(G) return center_nodes[0] # Return first center node def _save(plot: hv.Overlay, output_fpath: pathlib.Path, backend: str) -> None: if backend == "matplotlib": hv.save( plot.opts(fig_inches=config.GRAPHS["base_size"]), output_fpath, fig=config.GRAPHS["static_type"], dpi=config.GRAPHS["dpi"], ) plt.close("all") elif backend == "bokeh": fig = hv.render(plot) # 2025-12-02 [JRH]: We don't set dimensions, because that makes the # interactive plots fixed size, which makes them unsuitable for # embedding into webpages. fig.sizing_mode = "scale_width" html = bokeh.embed.file_html(fig, resources=bokeh.resources.INLINE) with output_fpath.open("w") as f: f.write(html) __all__ = ["generate"]