From 820dc8e77f3b7475aa4fd8fee5eb7bcb00d11884 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Thu, 23 Jul 2026 16:03:06 +0200 Subject: [PATCH] Temporary utility to view a model --- .vscode/tasks.json | 30 ++++++ src/bedit_util/__init__.py | 5 + src/bedit_util/__main__.py | 5 + src/bedit_util/graphviz.py | 185 +++++++++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 .vscode/tasks.json create mode 100644 src/bedit_util/__init__.py create mode 100644 src/bedit_util/__main__.py create mode 100644 src/bedit_util/graphviz.py diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..5ab06bd --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,30 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Tests: Run pytest", + "type": "shell", + "command": "${command:python.interpreterPath}", + "args": [ + "-m", + "pytest" + ], + "options": { + "cwd": "${workspaceFolder}", + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + } + }, + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "clear": true, + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": [] + } + ] +} diff --git a/src/bedit_util/__init__.py b/src/bedit_util/__init__.py new file mode 100644 index 0000000..1faaf9c --- /dev/null +++ b/src/bedit_util/__init__.py @@ -0,0 +1,5 @@ +"""Small command-line utilities for bedit models.""" + +from .graphviz import document_to_dot, render_document + +__all__ = ["document_to_dot", "render_document"] diff --git a/src/bedit_util/__main__.py b/src/bedit_util/__main__.py new file mode 100644 index 0000000..1b5b7e1 --- /dev/null +++ b/src/bedit_util/__main__.py @@ -0,0 +1,5 @@ +"""Run the Graphviz utility with ``python -m bedit_util``.""" + +from .graphviz import main + +raise SystemExit(main()) diff --git a/src/bedit_util/graphviz.py b/src/bedit_util/graphviz.py new file mode 100644 index 0000000..48680b6 --- /dev/null +++ b/src/bedit_util/graphviz.py @@ -0,0 +1,185 @@ +"""Create Graphviz representations of bedit bond-graph models.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from pathlib import Path + +from bedit_core.bondgraph import BondGraphNetwork, flatten_bondgraph +from bedit_core.models import ( + BondCausality, + Component, + ComponentID, + Document, + GraphImplementation, +) +from bedit_core.serialization import load + + +def document_to_dot(document: Document, component_path: str) -> str: + """Return DOT for the hierarchy starting at ``component_path``.""" + lines = [ + "digraph bedit {", + " graph [rankdir=LR, bgcolor=\"white\"];", + " node [shape=box, style=\"rounded\", fontname=\"sans-serif\"];", + " edge [fontname=\"sans-serif\", arrowsize=0.8];", + ] + + root_id, component, path = _find_component(document, component_path) + network = flatten_bondgraph(component) + paths = _component_paths(component, path[:-1]) + node_ids = { + id(item): f"{root_id}:{'/'.join(item_path)}" + for item, item_path in paths + } + lines.extend(_nodes(network, node_ids, paths)) + lines.extend(_bonds(network, node_ids)) + + lines.append("}") + return "\n".join(lines) + "\n" + + +def render_document( + document: Document, + component_path: str, + output: str | Path, + dot_executable: str = "dot", +) -> Path: + """Render one component hierarchy to PNG using Graphviz ``dot``.""" + output_path = Path(output) + try: + subprocess.run( + [dot_executable, "-Tpng", "-o", str(output_path)], + input=document_to_dot(document, component_path), + text=True, + check=True, + ) + except FileNotFoundError: + raise RuntimeError( + f"Graphviz executable {dot_executable!r} was not found" + ) from None + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"Graphviz failed with exit code {exc.returncode}") from exc + return output_path + + +def main(argv: list[str] | None = None) -> int: + """Load a bedit model and render its bond graph as a PNG.""" + parser = argparse.ArgumentParser( + prog="bedit-graphviz", + description="Render a .json or .beb bedit model as a Graphviz PNG.", + ) + parser.add_argument("model", type=Path, help="input .json or .beb model") + parser.add_argument( + "component_path", + help="dotted component path, for example Root.Subsystem.Component", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + help="output PNG path (default: input name with .png suffix)", + ) + args = parser.parse_args(argv) + + output = args.output or args.model.with_suffix(".png") + render_document(load(args.model), args.component_path, output) + print(output) + return 0 + + +def _component_paths( + root: Component, + prefix: tuple[str, ...] = (), +) -> list[tuple[Component, tuple[str, ...]]]: + """Return components paired with dotted-name path parts.""" + result: list[tuple[Component, tuple[str, ...]]] = [] + + def visit(component: Component, path: tuple[str, ...]) -> None: + current_path = path + (component.name,) + result.append((component, current_path)) + if isinstance(component.implementation, GraphImplementation): + for child in component.implementation.graph.components.values(): + visit(child, current_path) + + visit(root, prefix) + return result + + +def _find_component( + document: Document, + requested_path: str, +) -> tuple[ComponentID, Component, tuple[str, ...]]: + """Find a component by its dotted name path.""" + matches: list[tuple[ComponentID, Component, tuple[str, ...]]] = [] + for root_id, root in document.root.items(): + for component, path in _component_paths(root): + if ".".join(path) == requested_path: + matches.append((root_id, component, path)) + + if len(matches) == 1: + return matches[0] + if not matches: + raise ValueError(f"component path {requested_path!r} was not found") + raise ValueError(f"component path {requested_path!r} is ambiguous") + + +def _nodes( + network: BondGraphNetwork, + node_ids: dict[int, str], + paths: list[tuple[Component, tuple[str, ...]]], +) -> list[str]: + path_by_component = { + id(component): ".".join(path) + for component, path in paths + } + return [ + " " + + _quote(node_ids[id(component)]) + + " [label=" + + _quote(component.name) + + ", xlabel=" + + _quote(path_by_component[id(component)]) + + "];" + for component in network.components + ] + + +def _bonds( + network: BondGraphNetwork, + node_ids: dict[int, str], +) -> list[str]: + lines = [] + for bond in network.bonds: + attributes = { + "arrowhead": "halfopen", + "taillabel": bond.source.port.name, + "headlabel": bond.target.port.name, + "labeldistance": "1.5", + } + if bond.connection.causality is BondCausality.EFFORT_OUT: + attributes["arrowhead"] = "teehalfopen" + elif bond.connection.causality is BondCausality.FLOW_OUT: + attributes.update(dir="both", arrowtail="tee") + if bond.connection.undesired: + attributes.update(color="red", style="dashed") + + source = _quote(node_ids[id(bond.source.component)]) + target = _quote(node_ids[id(bond.target.component)]) + rendered = ", ".join( + f"{name}={_quote(value)}" + for name, value in attributes.items() + ) + lines.append(f" {source} -> {target} [{rendered}];") + return lines + + +def _quote(value: object) -> str: + """Quote a value as a DOT string.""" + return json.dumps(str(value), ensure_ascii=False) + + +if __name__ == "__main__": + raise SystemExit(main())