Temporary utility to view a model

This commit is contained in:
2026-07-23 16:03:06 +02:00
parent d050d61701
commit 820dc8e77f
4 changed files with 225 additions and 0 deletions

30
.vscode/tasks.json vendored Normal file
View File

@@ -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": []
}
]
}

View File

@@ -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"]

View File

@@ -0,0 +1,5 @@
"""Run the Graphviz utility with ``python -m bedit_util``."""
from .graphviz import main
raise SystemExit(main())

185
src/bedit_util/graphviz.py Normal file
View File

@@ -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())