Temporary utility to view a model
This commit is contained in:
30
.vscode/tasks.json
vendored
Normal file
30
.vscode/tasks.json
vendored
Normal 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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
src/bedit_util/__init__.py
Normal file
5
src/bedit_util/__init__.py
Normal 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"]
|
||||||
5
src/bedit_util/__main__.py
Normal file
5
src/bedit_util/__main__.py
Normal 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
185
src/bedit_util/graphviz.py
Normal 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())
|
||||||
Reference in New Issue
Block a user