-
Notifications
You must be signed in to change notification settings - Fork 0
[Feature]: Flat Converter from ewoks execution graph to ELK layout graph #17 #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,3 +18,8 @@ __pycache__/ | |
| .eggs/ | ||
| /doc/_generated | ||
| .svg | ||
| # pixi environments | ||
| .pixi/* | ||
| !.pixi/config.toml | ||
| pixi.toml | ||
| pixi.lock | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| from pprint import pprint | ||
|
|
||
| from ewokscore import load_graph | ||
| from ewokscore.tests.examples.graphs import get_graph | ||
| from ewoksdraw import build_svg_task_group | ||
| from ewoksdraw.layout.elk_converter import ElkGraph | ||
| from ewoksdraw.layout.elk_converter import convert_ewoks_to_elk_graph | ||
| from ewoksdraw.svg.svg_task_group import SvgTaskGroup | ||
| from ewoksdraw.svg.svg_task_group import TaskSizes | ||
|
|
||
| graph_description, _ = get_graph("acyclic1") | ||
| ewoks_graph = load_graph(graph_description) | ||
|
|
||
| svg_task_group: SvgTaskGroup = build_svg_task_group(ewoks_graph) | ||
| task_sizes: TaskSizes = svg_task_group.extract_task_sizes() | ||
| elk_graph: ElkGraph = convert_ewoks_to_elk_graph(ewoks_graph, task_sizes) | ||
|
|
||
| pprint(dict(ewoks_graph.graph.nodes(data=True))) | ||
| pprint(list(ewoks_graph.graph.edges(data=True))) | ||
| pprint(task_sizes) | ||
| pprint(elk_graph) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| from .elk_converter import convert_ewoks_to_elk_graph # noqa: F401 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nah. |
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,68 @@ | ||||||
| from typing import Any | ||||||
|
|
||||||
| from ewokscore.graph import TaskGraph | ||||||
|
|
||||||
| from ..config.constants import ELK_ALGORITHM | ||||||
| from ..config.constants import ELK_DIRECTION | ||||||
| from ..config.constants import ELK_ROUTING_MODE | ||||||
| from ..config.constants import ELK_SPACING_LINK | ||||||
| from ..config.constants import ELK_SPACING_TASK_LAYERS | ||||||
| from ..config.constants import ELK_SPACING_TASKS | ||||||
| from ..svg import TaskSizes | ||||||
|
|
||||||
| ElkGraph = dict[str, Any] | ||||||
|
|
||||||
| LAYOUT_OPTIONS = { | ||||||
| "org.eclipse.elk.algorithm": ELK_ALGORITHM, | ||||||
| "org.eclipse.elk.direction": ELK_DIRECTION, | ||||||
| "org.eclipse.elk.spacing.nodeNode": ELK_SPACING_TASKS, | ||||||
| "org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": ELK_SPACING_TASK_LAYERS, | ||||||
| "org.eclipse.elk.spacing.edgeEdge": ELK_SPACING_LINK, | ||||||
| "org.eclipse.elk.edgeRouting": "SPLINES", | ||||||
| "elk.layered.edgeRouting.splines.mode": ELK_ROUTING_MODE, | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| def convert_ewoks_to_elk_graph( | ||||||
| ewoks_graph: TaskGraph, task_sizes: TaskSizes | ||||||
| ) -> ElkGraph: | ||||||
| """Convert an Ewoks task graph into an ELK layout graph. | ||||||
|
|
||||||
| :param ewoks_graph: the task graph to convert, e.g. from ``ewokscore.load_graph``. | ||||||
| :param task_sizes: ``(width, height)`` per task in ``ewoks_graph``, and no | ||||||
| other task id, e.g. ``{"task1": (39.56, 55.0), ...}``. | ||||||
| :returns: an ELK graph, i.e. | ||||||
| ``{"id": str, "layoutOptions": dict, | ||||||
| "children": [{"id", "width", "height"}, ...], | ||||||
| "edges": [{"id", "sources": [str], "targets": [str]}, ...]}``. | ||||||
|
Comment on lines
+34
to
+37
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This would be way better if this was encoded in the type |
||||||
| """ | ||||||
| node_ids = set(ewoks_graph.graph.nodes) | ||||||
| if node_ids != task_sizes.keys(): | ||||||
| raise ValueError( | ||||||
| f"task_sizes {sorted(task_sizes)} do not match ewoks_graph task ids " | ||||||
| f"{sorted(node_ids)}" | ||||||
| ) | ||||||
|
|
||||||
| children = [] | ||||||
| for task_id in ewoks_graph.graph.nodes: | ||||||
| width, height = task_sizes[task_id] | ||||||
| children.append({"id": task_id, "width": width, "height": height}) | ||||||
|
|
||||||
| edges = [] | ||||||
| for source, target, link_attrs in ewoks_graph.graph.edges(data=True): | ||||||
| n_mappings = len(link_attrs.get("data_mapping") or []) or 1 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| for index in range(n_mappings): | ||||||
| edges.append( | ||||||
| { | ||||||
| "id": f"edge_{source}_{target}_{index}", | ||||||
| "sources": [source], | ||||||
| "targets": [target], | ||||||
| } | ||||||
| ) | ||||||
|
|
||||||
| return { | ||||||
| "id": "root", | ||||||
| "layoutOptions": LAYOUT_OPTIONS, | ||||||
| "children": children, | ||||||
| "edges": edges, | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| from .svg_group import SvgGroup | ||
| from .svg_task import SvgTask | ||
|
|
||
| TaskSizes = dict[str, tuple[float, float]] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be best to have a |
||
|
|
||
|
|
||
| class SvgTaskGroup(SvgGroup): | ||
| """ | ||
| Represents a positioned collection of SvgTask elements. | ||
| """ | ||
|
|
||
| def __init__(self, svg_tasks: dict[str, SvgTask]): | ||
| super().__init__() | ||
| self._svg_tasks = svg_tasks | ||
| self._width = 0.0 | ||
| self.add_elements(svg_tasks.values()) | ||
|
|
||
| def arrange_horizontally(self, gap: float) -> None: | ||
| """ | ||
| Lays out the tasks left to right, each separated by `gap`. | ||
|
|
||
| :param gap: The spacing before, between, and after the tasks. | ||
| """ | ||
| x = gap | ||
| for svg_task in self._svg_tasks.values(): | ||
| svg_task.translate(x=x, y=gap) | ||
| x += svg_task.width + gap | ||
| self._width = x | ||
|
|
||
| @property | ||
| def width(self) -> float: | ||
| return self._width | ||
|
|
||
| @property | ||
| def height(self) -> float: | ||
| if not self._svg_tasks: | ||
| return 0.0 | ||
| return max(svg_task.height for svg_task in self._svg_tasks.values()) | ||
|
|
||
| def extract_task_sizes(self) -> TaskSizes: | ||
| return { | ||
| task_id: (svg_task.width, svg_task.height) | ||
| for task_id, svg_task in self._svg_tasks.items() | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| import pytest | ||
| from ewokscore import load_graph | ||
| from ewokscore.tests.examples.graphs import get_graph | ||
| from ewokscore.tests.examples.graphs import graph_names | ||
| from pyelk.graph import validate_graph | ||
|
|
||
| from ewoksdraw.layout.elk_converter import LAYOUT_OPTIONS | ||
| from ewoksdraw.layout.elk_converter import convert_ewoks_to_elk_graph | ||
|
|
||
| _TASK_TYPE = "ewokscore.tests.examples.tasks.sumtask.SumTask" | ||
|
|
||
|
|
||
| def _node(node_id: str) -> dict: | ||
| return {"id": node_id, "task_type": "class", "task_identifier": _TASK_TYPE} | ||
|
|
||
|
|
||
| def _task_sizes(graph) -> dict[str, tuple[float, float]]: | ||
| return { | ||
| node_id: (10.0 * i, 20.0 * i) | ||
| for i, node_id in enumerate(graph.graph.nodes, start=1) | ||
| } | ||
|
|
||
|
|
||
| def test_top_level_structure(): | ||
| graph_description, _ = get_graph("acyclic1") | ||
| graph = load_graph(graph_description) | ||
|
|
||
| elk_graph = convert_ewoks_to_elk_graph(graph, _task_sizes(graph)) | ||
|
|
||
| assert elk_graph["id"] == "root" | ||
| assert elk_graph["layoutOptions"] == LAYOUT_OPTIONS | ||
| assert "children" in elk_graph | ||
| assert "edges" in elk_graph | ||
|
|
||
|
|
||
| def test_children_match_task_sizes(): | ||
| graph_description, _ = get_graph("acyclic1") | ||
| graph = load_graph(graph_description) | ||
| task_sizes = _task_sizes(graph) | ||
|
|
||
| elk_graph = convert_ewoks_to_elk_graph(graph, task_sizes) | ||
|
|
||
| assert len(elk_graph["children"]) == len(task_sizes) | ||
| for child in elk_graph["children"]: | ||
| width, height = task_sizes[child["id"]] | ||
| assert child["width"] == width | ||
| assert child["height"] == height | ||
|
|
||
|
|
||
| def test_link_without_data_mapping_produces_one_elk_edge(): | ||
| graph_description = { | ||
| "graph": {"id": "g", "label": "g", "schema_version": "1.1"}, | ||
| "nodes": [_node("a"), _node("b")], | ||
| "links": [{"source": "a", "target": "b"}], | ||
| } | ||
| graph = load_graph(graph_description) | ||
|
|
||
| elk_graph = convert_ewoks_to_elk_graph(graph, {"a": (1.0, 1.0), "b": (1.0, 1.0)}) | ||
|
|
||
| assert elk_graph["edges"] == [ | ||
| {"id": "edge_a_b_0", "sources": ["a"], "targets": ["b"]} | ||
| ] | ||
|
|
||
|
|
||
| def test_link_with_multiple_data_mappings_produces_one_elk_edge_per_mapping(): | ||
| graph_description = { | ||
| "graph": {"id": "g", "label": "g", "schema_version": "1.1"}, | ||
| "nodes": [_node("a"), _node("b")], | ||
| "links": [ | ||
| { | ||
| "source": "a", | ||
| "target": "b", | ||
| "data_mapping": [ | ||
| {"source_output": "result", "target_input": "a"}, | ||
| {"source_output": "result", "target_input": "b"}, | ||
| ], | ||
| } | ||
| ], | ||
| } | ||
| graph = load_graph(graph_description) | ||
|
|
||
| elk_graph = convert_ewoks_to_elk_graph(graph, {"a": (1.0, 1.0), "b": (1.0, 1.0)}) | ||
|
|
||
| assert elk_graph["edges"] == [ | ||
| {"id": "edge_a_b_0", "sources": ["a"], "targets": ["b"]}, | ||
| {"id": "edge_a_b_1", "sources": ["a"], "targets": ["b"]}, | ||
| ] | ||
|
|
||
|
|
||
| def test_graph_without_link(): | ||
| graph_description, _ = get_graph("empty") | ||
| graph = load_graph(graph_description) | ||
|
|
||
| elk_graph = convert_ewoks_to_elk_graph(graph, {}) | ||
|
|
||
| assert elk_graph["children"] == [] | ||
| assert elk_graph["edges"] == [] | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("graph_name", graph_names()) | ||
| def test_children_and_edges_count_across_example_graphs(graph_name): | ||
| graph_description, _ = get_graph(graph_name) | ||
| graph = load_graph(graph_description) | ||
| task_sizes = _task_sizes(graph) | ||
|
|
||
| elk_graph = convert_ewoks_to_elk_graph(graph, task_sizes) | ||
|
|
||
| assert len(elk_graph["children"]) == graph.graph.number_of_nodes() | ||
|
|
||
| expected_edge_count = sum( | ||
| len(link_attrs.get("data_mapping") or []) or 1 | ||
| for _, _, link_attrs in graph.graph.edges(data=True) | ||
| ) | ||
| assert len(elk_graph["edges"]) == expected_edge_count | ||
|
|
||
|
|
||
| def test_task_sizes_missing_a_node_raises(): | ||
| graph_description, _ = get_graph("acyclic1") | ||
| graph = load_graph(graph_description) | ||
|
|
||
| with pytest.raises(ValueError): | ||
| convert_ewoks_to_elk_graph(graph, {"task1": (10.0, 20.0)}) | ||
|
|
||
|
|
||
| def test_task_sizes_with_extra_task_id_raises(): | ||
| graph_description, _ = get_graph("acyclic1") | ||
| graph = load_graph(graph_description) | ||
| task_sizes = _task_sizes(graph) | ||
| task_sizes["not_a_node"] = (1.0, 1.0) | ||
|
|
||
| with pytest.raises(ValueError): | ||
| convert_ewoks_to_elk_graph(graph, task_sizes) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("graph_name", graph_names()) | ||
| def test_output_is_a_valid_pyelk_graph(graph_name): | ||
| """Check graph validation from pyelk""" | ||
|
|
||
| graph_description, _ = get_graph(graph_name) | ||
| graph = load_graph(graph_description) | ||
| task_sizes = _task_sizes(graph) | ||
|
|
||
| elk_graph = convert_ewoks_to_elk_graph(graph, task_sizes) | ||
|
|
||
| validate_graph(elk_graph) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a usecase to access each constant individually?
If not, we could only have
ELK_LAYOUT_OPTIONSas a constant.