diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c778240b8..a8e7fdc44 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -78,33 +78,33 @@ IMPORTANT NOTES "A complete thought. [PR XYZ]((https://github.com/NatLabRockies/H2Integrate/pull/XYZ)", where `XYZ` should be replaced with the actual number. -## Section 3: Related Issues +## Section 4: Related Issues -## Section 4: Impacted Areas of the Software +## Section 5: Impacted Areas of the Software -### Section 4.1: New Files +### Section 5.1: New Files - `path/to/file.extension` - `method1`: What and why something was changed in one sentence or less. -### Section 4.2: Modified Files +### Section 5.2: Modified Files - `path/to/file.extension` - `method1`: What and why something was changed in one sentence or less. -## Section 5: Additional Supporting Information +## Section 6: Additional Supporting Information -## Section 6: Test Results, if applicable +## Section 7: Test Results, if applicable -## Section 7 (Optional): New Model Checklist +## Section 8 (Optional): New Model Checklist - [ ] **Model Structure**: - [ ] Follows established naming conventions outlined in `docs/developer_guide/coding_guidelines.md` @@ -128,6 +128,7 @@ failing test cases. - [ ] Model added to the main models list in `docs/user_guide/model_overview.md` - [ ] Model documentation page added to the appropriate `docs/` section - [ ] `.md` is added to the `_toc.yml` + - [ ] Run `generate_class_hierarchy.py` to update the class hierarchy diagram in `docs/developer_guide/class_structure.md` diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 779253020..1e89e3f5a 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,7 +5,9 @@ build: tools: python: '3.11' jobs: - pre_build: [jupyter-book config sphinx docs/] + pre_build: + - python docs/generate_class_hierarchy.py + - jupyter-book config sphinx docs/ python: install: - method: pip diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d8a85f8..5ab712688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Removed a few usages of `shape_by_conn` due to issues with OpenMDAO v3.43.0 release on some computers [PR 632](https://github.com/NatLabRockies/H2Integrate/pull/632) - Made generating an XDSM diagram from connections in a model optional and added documentation on model visualization. [PR 629](https://github.com/NatLabRockies/H2Integrate/pull/629) - Added a storage performance baseclass model `StoragePerformanceBase` and updated the other storage performance models to inherit it [PR 624](https://github.com/NatLabRockies/H2Integrate/pull/624) +- Added an automated script to crawl through the codebase and generate a visualization of the class hierarchy in H2Integrate. [PR 643](https://github.com/NatLabRockies/H2Integrate/pull/643) - Modified the calc tilt angle function for pysam solar to support latitudes in the southern hemisphere [PR 646](https://github.com/NatLabRockies/H2Integrate/pull/646) - Added oxygen production metrics and as outputs to `ECOElectrolyzerPerformanceModel` [PR 642](https://github.com/NatLabRockies/H2Integrate/pull/642) - Bugfix to allow for one resource to be connected to multiple technologies [PR 655](https://github.com/NatLabRockies/H2Integrate/pull/655) diff --git a/docs/_config.yml b/docs/_config.yml index a140e3997..06149877f 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -55,6 +55,8 @@ sphinx: # class-doc-from # no-value autodoc_typehints: description + html_static_path: + - _static napoleon_use_admonition_for_notes: true napoleon_use_rtype: false nb_merge_streams: true diff --git a/docs/_static/class_hierarchy.html b/docs/_static/class_hierarchy.html new file mode 100644 index 000000000..acd5c8d55 --- /dev/null +++ b/docs/_static/class_hierarchy.html @@ -0,0 +1,497 @@ + + + + + + + + + +
+

+
+ + + + + + +
+

+
+ + + + + +
+ + +
+
+ + +
+
+
0%
+
+
+
+
+
+ + + + + +
+ Color = Application Group
+ Chemical
Control
Core / General
Hydrogen
Metal
Other
Other Elec. Generators
Renewables +

+ Shape = Model Category
+ Control
Converter
Core
Finance
Resource
Storage +

+ + Border width = inheritance depth
+ (thicker = higher-level parent)

+ Arrows: parent → child
+ Scroll to zoom | Drag to pan +
+
+ + +
+ H2Integrate Class Hierarchy
+ + Interactive visualization — zoom, pan, hover for details + +
+ + + + + + diff --git a/docs/build_book.sh b/docs/build_book.sh index 5817e636c..8e9d6f837 100644 --- a/docs/build_book.sh +++ b/docs/build_book.sh @@ -1,2 +1,6 @@ rm -rf _build + +# Generate the interactive class hierarchy diagram +python generate_class_hierarchy.py + jupyter-book build --keep-going . diff --git a/docs/developer_guide/class_structure.md b/docs/developer_guide/class_structure.md index 49631eb14..0b99823ca 100644 --- a/docs/developer_guide/class_structure.md +++ b/docs/developer_guide/class_structure.md @@ -18,6 +18,29 @@ Let us take a PEM electrolyzer model as an example. Each electrolyzer model has shared methods and attributes that would be present in any valid model. These methods are defined at the `ElectrolyzerBaseClass` level, which inherits from `ConverterBaseClass`. Any implemented electrolyzer model should inherit from `ElectrolyzerBaseClass` to make use of its already built out structure and methods. -This is shown below. -![Class structure](fig_of_class_structure.png) +## Interactive class hierarchy + +The diagram below shows **every model class** in H2Integrate and how they inherit from one another. +The visual encoding uses three dimensions: + +- **Color** represents the application group (electricity, chemical, metal, etc.) +- **Shape** represents the model category (converter, storage, transporter, etc.) +- **Border thickness** indicates inheritance depth (thicker borders = higher-level parent classes) + +Arrows point from parent to child. +You can **zoom**, **pan**, **hover** for details, and **drag** nodes to rearrange the layout. + +To regenerate this visualization after code changes, run: + +```bash +python docs/generate_class_hierarchy.py +``` + +```{raw} html +
+ +
+``` diff --git a/docs/generate_class_hierarchy.py b/docs/generate_class_hierarchy.py new file mode 100644 index 000000000..47918b890 --- /dev/null +++ b/docs/generate_class_hierarchy.py @@ -0,0 +1,673 @@ +""" +Generate an interactive class hierarchy visualization for H2Integrate. + +This script scans the h2integrate package to discover all classes and their +inheritance relationships, then produces an interactive HTML visualization +(zoomable/scrollable) suitable for embedding in the Jupyter Book docs. + +Visual encoding: + - **Shape** → model category (converter, storage, transporter, etc.) + - **Color** → product / application group (electricity, chemical, metal, etc.) + - **Border width** → inheritance depth (thicker = higher-level parent) + +Usage: + python docs/generate_class_hierarchy.py + +Outputs: + docs/_static/class_hierarchy.html — interactive graph +""" + +import os +import re +import ast +import math +from pathlib import Path + +import networkx as nx +from pyvis.network import Network + +from h2integrate import ROOT_DIR + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +REPO_ROOT = ROOT_DIR.parent +OUTPUT_HTML = REPO_ROOT / "docs" / "_static" / "class_hierarchy.html" + +# Directories / path fragments that indicate test code (case-insensitive check) +TEST_INDICATORS = {"test", "tests", "conftest", "test_"} + +# We only show H2I-native classes; external bases (OpenMDAO, attrs, etc.) +# are excluded from the visualization entirely. +EXTERNAL_BASES_TO_KEEP: set[str] = set() + +# --------------------------------------------------------------------------- +# Category detection rules: (directory substring -> (model_category, product)) +# Order matters -- first match wins. +# model_category → controls node SHAPE +# product → controls node COLOR (via color group mapping below) +# --------------------------------------------------------------------------- +CATEGORY_RULES = [ + ("core", ("Core", "General")), + # --- Electricity-producing converters --- + ("converters/wind", ("Converter", "Wind")), + ("converters/solar", ("Converter", "Solar")), + ("converters/nuclear", ("Converter", "Nuclear")), + ("converters/grid", ("Converter", "Grid")), + ("converters/water_power", ("Converter", "Water Power")), + ("converters/natural_gas", ("Converter", "Natural Gas")), + ("converters/hopp", ("Converter", "HOPP")), + # --- Chemical converters --- + ("converters/hydrogen", ("Converter", "Hydrogen")), + ("converters/ammonia", ("Converter", "Ammonia")), + ("converters/methanol", ("Converter", "Methanol")), + ("converters/co2", ("Converter", "CO2")), + ("converters/nitrogen", ("Converter", "Nitrogen")), + ("converters/water", ("Converter", "Water")), + # --- Metal converters --- + ("converters/iron", ("Converter", "Iron")), + ("converters/steel", ("Converter", "Steel")), + # --- Catch-all converter --- + ("converters", ("Converter", "Other")), + # --- Non-converter categories --- + ("storage", ("Storage", "General")), + ("resource", ("Resource", "General")), + ("finances", ("Finance", "General")), + ("transporters", ("Transporter", "General")), + ("control", ("Control", "General")), + ("simulation", ("Simulation", "General")), + ("tools", ("Tools", "General")), + ("postprocess", ("Post-processing", "General")), + ("preprocess", ("Pre-processing", "General")), +] + +# --------------------------------------------------------------------------- +# Shape by model category +# --------------------------------------------------------------------------- +CATEGORY_SHAPES_PYVIS = { + "Core": "ellipse", + "Converter": "dot", + "Storage": "diamond", + "Resource": "triangle", + "Finance": "star", + "Transporter": "square", + "Control": "hexagon", + "Simulation": "triangleDown", + "Tools": "box", + "Post-processing": "box", + "Pre-processing": "box", +} + +# --------------------------------------------------------------------------- +# Color by broad application group +# Products are mapped to a broad group, each group gets one color. +# --------------------------------------------------------------------------- +PRODUCT_TO_GROUP = { + "General": "Core / General", + "Wind": "Renewables", + "Solar": "Renewables", + "Water Power": "Renewables", + "HOPP": "Renewables", + "Nuclear": "Other Elec. Generators", + "Grid": "Other Elec. Generators", + "Natural Gas": "Other Elec. Generators", + "Hydrogen": "Hydrogen", + "Ammonia": "Chemical", + "Methanol": "Chemical", + "CO2": "Chemical", + "Nitrogen": "Chemical", + "Water": "Chemical", + "Iron": "Metal", + "Steel": "Metal", + "Other": "Other", +} + +GROUP_COLORS = { + "Core / General": "#555555", + "Renewables": "#4A90D9", + "Other Elec. Generators": "#1B3A5C", + "Hydrogen": "#2E7D32", + "Chemical": "#66BB6A", + "Metal": "#D84315", + "Control": "#00ACC1", + "Other": "#F5C542", +} + +# Patterns used to detect control-related classes by name. +# If a class name matches any of these, its color group is overridden to "Control". +CONTROL_NAME_PATTERNS = re.compile(r"control|pyomo|openloop|open_loop", re.IGNORECASE) + +# Border width range for inheritance depth +MAX_BORDER_WIDTH = 5 +MIN_BORDER_WIDTH = 1 + + +# --------------------------------------------------------------------------- +# AST helpers +# --------------------------------------------------------------------------- + + +def _is_test_path(filepath: Path) -> bool: + """Return True if the file path looks like it belongs to test code.""" + parts = filepath.parts + for part in parts: + lower = part.lower() + if lower in TEST_INDICATORS or lower.startswith("test_"): + return True + if filepath.stem.lower().startswith("test_") or filepath.stem.lower() == "conftest": + return True + return False + + +def _rel_mod_path(filepath: Path) -> str: + """Return the filepath relative to the repo root using forward slashes.""" + try: + return str(filepath.relative_to(ROOT_DIR)).replace("\\", "/") + except ValueError: + return str(filepath).replace("\\", "/") + + +def _classify(filepath: Path) -> tuple[str, str]: + """Determine the (model_category, product) for a class based on its file path.""" + rel = _rel_mod_path(filepath) + for pattern, cat_tuple in CATEGORY_RULES: + if pattern in rel: + return cat_tuple + return ("Other", "Other") + + +def _resolve_base_name(base_node: ast.expr) -> str | None: + """Extract a human-readable base-class name from an AST node.""" + if isinstance(base_node, ast.Name): + return base_node.id + if isinstance(base_node, ast.Attribute): + # e.g. om.ExplicitComponent -> ExplicitComponent + return base_node.attr + if isinstance(base_node, ast.Subscript): + # e.g. Generic[T] -> Generic + return _resolve_base_name(base_node.value) + return None + + +# --------------------------------------------------------------------------- +# Scanning +# --------------------------------------------------------------------------- + + +def scan_classes(package_root: Path): + """Walk the package tree and return class info. + + Returns + ------- + classes : dict + {class_name: {"bases": [str], "file": Path, "category": str}} + If multiple classes share a name, the module path is prepended to + disambiguate. + """ + raw: list[dict] = [] + + for dirpath, _dirnames, filenames in os.walk(package_root): + dirpath = Path(dirpath) + for fname in filenames: + if not fname.endswith(".py"): + continue + filepath = dirpath / fname + if _is_test_path(filepath): + continue + try: + source = filepath.read_text(encoding="utf-8", errors="replace") + tree = ast.parse(source, filename=str(filepath)) + except SyntaxError: + continue + + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + bases = [] + for b in node.bases: + bname = _resolve_base_name(b) + if bname: + bases.append(bname) + category, subcategory = _classify(filepath) + raw.append( + { + "name": node.name, + "bases": bases, + "file": filepath, + "model_category": category, + "product": subcategory, + } + ) + + # Detect name collisions and disambiguate + from collections import Counter + + name_counts = Counter(r["name"] for r in raw) + classes: dict[str, dict] = {} + for r in raw: + name = r["name"] + if name_counts[name] > 1: + # Prefix with the relative module path to disambiguate + module = _rel_mod_path(r["file"]).replace("/", ".").removesuffix(".py") + key = f"{module}.{name}" + else: + key = name + classes[key] = { + "bases": r["bases"], + "file": r["file"], + "model_category": r["model_category"], + "product": r["product"], + } + + return classes + + +# --------------------------------------------------------------------------- +# Graph construction +# --------------------------------------------------------------------------- + +# Classes to exclude from the visualization entirely. +# We filter out all config/dataclass definitions — only performance, cost, +# and other "model" classes are shown. +CONFIG_PATTERN = re.compile(r"Config$", re.IGNORECASE) +EXCLUDE_CLASSES = { + "BaseConfig", +} + + +def build_graph(classes: dict) -> nx.DiGraph: + """Build a directed graph of class inheritance (edges point from parent to child).""" + G = nx.DiGraph() + + # Build a set of all known H2I class names for quick lookup + known_names = set(classes.keys()) + # Also build short-name -> full-key mapping (for resolving base names) + short_to_full: dict[str, list[str]] = {} + for key in classes: + short = key.rsplit(".", 1)[-1] if "." in key else key + short_to_full.setdefault(short, []).append(key) + + def resolve(base_name: str) -> str | None: + """Resolve a base class name to a node key, or None to skip.""" + if base_name in known_names: + return base_name + candidates = short_to_full.get(base_name, []) + if len(candidates) == 1: + return candidates[0] + if len(candidates) > 1: + # Ambiguous — pick the one from core if available + for c in candidates: + if "core" in c.lower(): + return c + return candidates[0] + # External base — keep only the interesting ones + if base_name in EXTERNAL_BASES_TO_KEEP: + return base_name + return None + + def _is_excluded(name: str) -> bool: + """Return True if the class should be excluded (configs, etc.).""" + if name in EXCLUDE_CLASSES: + return True + if CONFIG_PATTERN.search(name): + return True + return False + + # Add all H2I class nodes (skip configs) + for key, info in classes.items(): + short_name = key.rsplit(".", 1)[-1] if "." in key else key + if _is_excluded(short_name): + continue + model_cat = info.get("model_category", "Other") + product = info.get("product", "General") + G.add_node( + key, + label=short_name, + model_category=model_cat, + product=product, + title=f"{short_name}\n{_rel_mod_path(info['file'])}\n[{model_cat} / {product}]", + ) + + # Add edges (parent -> child) — only between H2I classes already in the graph + for key, info in classes.items(): + short_name = key.rsplit(".", 1)[-1] if "." in key else key + if _is_excluded(short_name): + continue + if key not in G: + continue + for base_name in info["bases"]: + parent = resolve(base_name) + if parent is None: + continue + # Only add edges to parents that are already in the graph (H2I classes). + # Do NOT add external nodes. + if parent not in G: + continue + G.add_edge(parent, key) + + return G + + +# --------------------------------------------------------------------------- +# Inheritance depth computation +# --------------------------------------------------------------------------- + + +def _compute_depths(G: nx.DiGraph) -> dict[str, int]: + """Compute inheritance depth for each node (0 = root, increases for children).""" + depths: dict[str, int] = {} + roots = [n for n in G if G.in_degree(n) == 0] + for root in roots: + queue = [(root, 0)] + visited = {root} + while queue: + node, d = queue.pop(0) + depths[node] = max(depths.get(node, 0), d) + for child in G.successors(node): + if child not in visited: + visited.add(child) + queue.append((child, d + 1)) + for n in G.nodes: + if n not in depths: + depths[n] = 0 + return depths + + +def _border_width(depth: int, max_depth: int) -> float: + """Compute border width: thicker for roots, thinner for deeply inherited.""" + if max_depth == 0: + return MAX_BORDER_WIDTH + return max( + MIN_BORDER_WIDTH, + MAX_BORDER_WIDTH - depth * (MAX_BORDER_WIDTH - MIN_BORDER_WIDTH) / max_depth, + ) + + +# --------------------------------------------------------------------------- +# Visualization — interactive HTML +# --------------------------------------------------------------------------- + + +def build_interactive_html(G: nx.DiGraph, output_path: Path): + """Create a pyvis interactive HTML visualization of the graph.""" + net = Network( + height="900px", + width="100%", + directed=True, + notebook=False, + cdn_resources="in_line", + bgcolor="#FFFFFF", + font_color="#333333", + select_menu=False, + filter_menu=False, + ) + + net.set_options(""" + { + "physics": { + "enabled": true, + "forceAtlas2Based": { + "gravitationalConstant": -80, + "centralGravity": 0.008, + "springLength": 120, + "springConstant": 0.06, + "damping": 0.4, + "avoidOverlap": 0.6 + }, + "solver": "forceAtlas2Based", + "stabilization": { + "enabled": true, + "iterations": 1500, + "updateInterval": 25 + }, + "maxVelocity": 50, + "minVelocity": 0.01 + }, + "layout": { + "improvedLayout": true, + "randomSeed": 42 + }, + "edges": { + "arrows": { "to": { "enabled": true, "scaleFactor": 0.5 } }, + "color": { "color": "#888888", "opacity": 0.5 }, + "smooth": { "type": "continuous" }, + "width": 1.2 + }, + "interaction": { + "hover": true, + "tooltipDelay": 100, + "zoomView": true, + "dragView": true, + "navigationButtons": true, + "keyboard": { "enabled": true } + } + } + """) + + depths = _compute_depths(G) + max_depth = max(depths.values(), default=0) + + # Cluster seeds by model_category + used_categories = sorted({G.nodes[n].get("model_category", "Other") for n in G.nodes}) + cat_to_index = {cat: i for i, cat in enumerate(used_categories)} + n_cats = len(used_categories) + CLUSTER_RADIUS = 600 + INTRA_SCATTER = 180 + _cat_counter: dict[str, int] = {c: 0 for c in used_categories} + + # Determine node sizes based on out-degree + max_degree = max((G.out_degree(n) for n in G.nodes), default=1) or 1 + + for node_id in G.nodes: + data = G.nodes[node_id] + label = data.get("label", node_id) + model_cat = data.get("model_category", "Other") + product = data.get("product", "General") + tooltip = data.get("title", label) + + # Override color group to "Control" for control-related classes + if CONTROL_NAME_PATTERNS.search(label): + color_group = "Control" + else: + color_group = PRODUCT_TO_GROUP.get(product, "Other") + color = GROUP_COLORS.get(color_group, "#BDBDBD") + shape = CATEGORY_SHAPES_PYVIS.get(model_cat, "dot") + bw = _border_width(depths.get(node_id, 0), max_depth) + out_deg = G.out_degree(node_id) + size = 18 + 27 * (out_deg / max_degree) + + # Compute initial position: cluster centre + spiral offset + idx = cat_to_index.get(model_cat, 0) + angle_base = 2 * math.pi * idx / max(n_cats, 1) + cx = CLUSTER_RADIUS * math.cos(angle_base) + cy = CLUSTER_RADIUS * math.sin(angle_base) + seq = _cat_counter[model_cat] + _cat_counter[model_cat] += 1 + spiral_angle = seq * 0.8 + spiral_r = INTRA_SCATTER * (0.3 + 0.7 * (seq / max(1, _cat_counter[model_cat]))) + x = cx + spiral_r * math.cos(spiral_angle) + y = cy + spiral_r * math.sin(spiral_angle) + + net.add_node( + node_id, + label=label, + title=tooltip, + color={ + "background": color, + "border": "#555555", + "highlight": {"background": "#FF6B6B", "border": "#FF0000"}, + "hover": {"background": "#FFD700", "border": "#FF8C00"}, + }, + size=size, + borderWidth=bw, + shape=shape, + font={"size": 14, "face": "Arial"}, + x=x, + y=y, + ) + + for u, v in G.edges: + net.add_edge(u, v) + + # --- Build legend HTML --- + # Color groups: collect the actual group used for each node + used_groups = set() + for n in G.nodes: + label_n = G.nodes[n].get("label", n) + product_n = G.nodes[n].get("product", "General") + if CONTROL_NAME_PATTERNS.search(label_n): + used_groups.add("Control") + else: + used_groups.add(PRODUCT_TO_GROUP.get(product_n, "Other")) + used_groups = sorted(used_groups) + color_items = [] + for group in used_groups: + c = GROUP_COLORS.get(group, "#BDBDBD") + color_items.append( + f'{group}' + ) + color_legend = "
".join(color_items) + + # Model category shapes + shape_labels = { + "ellipse": "◯", # circle-like for Core + "dot": "●", # filled circle for Converter + "diamond": "◆", # diamond for Storage + "triangle": "▲", # triangle for Resource + "star": "★", # star for Finance + "square": "■", # square for Transporter + "hexagon": "⬢", # hexagon for Control + "triangleDown": "▼", # down-triangle for Simulation + "box": "■", # square for Tools + } + used_cats = sorted({G.nodes[n].get("model_category", "Other") for n in G.nodes}) + shape_items = [] + for cat in used_cats: + shape = CATEGORY_SHAPES_PYVIS.get(cat, "dot") + symbol = shape_labels.get(shape, "●") + shape_items.append( + f'' + f"{symbol}{cat}" + ) + shape_legend = "
".join(shape_items) + + # Save and inject legend + net.generate_html() + raw_html = net.html + output_path.write_text(raw_html, encoding="utf-8") + html = output_path.read_text(encoding="utf-8") + + legend_div = f""" +
+ Color = Application Group
+ {color_legend} +

+ Shape = Model Category
+ {shape_legend} +

+ + Border width = inheritance depth
+ (thicker = higher-level parent)

+ Arrows: parent → child
+ Scroll to zoom | Drag to pan +
+
+ """ + + title_div = """ +
+ H2Integrate Class Hierarchy
+ + Interactive visualization — zoom, pan, hover for details + +
+ """ + + # Inject a script to disable physics after stabilization so the + # diagram is still unless the user drags a node. + stabilize_script = """ + + """ + + html = html.replace("", f"{legend_div}\n{title_div}\n{stabilize_script}\n") + output_path.write_text(html, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main(): + print(f"Scanning classes in {ROOT_DIR} ...") + classes = scan_classes(ROOT_DIR) + print(f" Found {len(classes)} classes (excluding test files)") + + print("Building inheritance graph ...") + G = build_graph(classes) + print(f" Graph has {G.number_of_nodes()} nodes and {G.number_of_edges()} edges") + + # Remove isolated nodes (no inheritance relationships) to reduce clutter + isolates = list(nx.isolates(G)) + G.remove_nodes_from(isolates) + print( + f" After removing {len(isolates)} isolated classes: " + f"{G.number_of_nodes()} nodes, {G.number_of_edges()} edges" + ) + + print(f"Generating interactive HTML → {OUTPUT_HTML} ...") + OUTPUT_HTML.parent.mkdir(parents=True, exist_ok=True) + build_interactive_html(G, OUTPUT_HTML) + + print("Done!") + + # Print summary by category + cats: dict[str, int] = {} + for n in G.nodes: + cat = G.nodes[n].get("model_category", "Other") + cats[cat] = cats.get(cat, 0) + 1 + print("\nClasses by model category (in graph):") + for cat in sorted(cats, key=cats.get, reverse=True): + print(f" {cat}: {cats[cat]}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 907ca8144..0a67eb712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,9 @@ changelog = "https://github.com/NatLabRockies/H2Integrate/blob/main/CHANGELOG.md develop = [ "isort", "jupyter-book<2", + "networkx", "pre-commit", + "pyvis", "pytest>=9", "pytest-cov", "ruff",