Temporary utility to view a model
This commit is contained in:
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