diff --git a/VERSION b/VERSION index 7e32cd5..a58941b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3 +1.3 \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index a5cd291..418a03c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,6 +35,7 @@ demos/index model_structure model_interface examples/index +visualizer advanced_documentation/index ``` diff --git a/docs/visualizer.md b/docs/visualizer.md new file mode 100644 index 0000000..35e7143 --- /dev/null +++ b/docs/visualizer.md @@ -0,0 +1,41 @@ + + +# Visualizer + +## Features + +- Based on [dash-cytoscape](https://github.com/plotly/dash-cytoscape). +- Visualize small and large (10000+ nodes) networks +- Explore attributes of nodes and branches +- Highlight specific nodes and branches +- Visualize various layouts, including hierarchical, force-directed and coordinate-based layouts + +With Coordinates | Hierarchical | Force-Directed +:------------------:|:------------:|:-------------: +Coordinates | Hierarchical | Force-Directed + +----- +## Quickstart +#### Installation +```bash +pip install 'power-grid-model-ds[visualizer]' # quotes added for zsh compatibility +``` + +#### Usage +```python +from power_grid_model_ds import Grid +from power_grid_model_ds.visualizer import visualize +from power_grid_model_ds.generators import RadialGridGenerator + +grid = RadialGridGenerator(Grid).run() +visualize(grid) +``` +This will start a local web server at http://localhost:8050 + +#### Disclaimer +Please note that the visualizer is still a work in progress and may not be fully functional or contain bugs. +We welcome any feedback or suggestions for improvement. diff --git a/pyproject.toml b/pyproject.toml index 7288de2..dfa7c1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,11 @@ classifiers = [ "Topic :: Scientific/Engineering :: Physics", ] requires-python = ">=3.11,<3.14" -dependencies = ["power-grid-model>=1.7", "rustworkx>= 0.15.1", "numpy>=1.21"] +dependencies = [ + "power-grid-model>=1.7", + "rustworkx>= 0.15.1", + "numpy>=1.21", +] dynamic = ["version"] [project.optional-dependencies] @@ -40,8 +44,19 @@ dev = [ "isort>=5.13.2", "mypy>=1.9.0", "pre-commit>=4", + # Visualization (make sure these stay equalivalent to the 'visualizer' group) + "dash>=3.0.0", + "dash-bootstrap-components>=2.0.0", + "dash-cytoscape>=1.0.2", +] +visualizer = [ + # Visualization (make sure these are also updated in the 'dev' group) + "dash>=3.0.0", + "dash-bootstrap-components>=2.0.0", + "dash-cytoscape>=1.0.2", ] + doc = [ "sphinx", "myst-nb", @@ -51,6 +66,7 @@ doc = [ "numpydoc", ] + [project.urls] Home-page = "https://lfenergy.org/projects/power-grid-model/" GitHub = "https://github.com/PowerGridModel/power-grid-model-ds" @@ -108,3 +124,8 @@ disable_error_code = ["assignment", "import-untyped"] [tool.coverage.run] relative_files = true branch = true + +[tool.coverage.report] +exclude_also = [ + 'if TYPE_CHECKING:', +] diff --git a/src/power_grid_model_ds/_core/model/graphs/container.py b/src/power_grid_model_ds/_core/model/graphs/container.py index 80232a8..946c2fc 100644 --- a/src/power_grid_model_ds/_core/model/graphs/container.py +++ b/src/power_grid_model_ds/_core/model/graphs/container.py @@ -17,7 +17,7 @@ from power_grid_model_ds._core.model.graphs.models import RustworkxGraphModel from power_grid_model_ds._core.model.graphs.models.base import BaseGraphModel -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from power_grid_model_ds._core.model.grids.base import Grid diff --git a/src/power_grid_model_ds/_core/model/graphs/models/base.py b/src/power_grid_model_ds/_core/model/graphs/models/base.py index 282d815..4d06fcf 100644 --- a/src/power_grid_model_ds/_core/model/graphs/models/base.py +++ b/src/power_grid_model_ds/_core/model/graphs/models/base.py @@ -17,7 +17,7 @@ NoPathBetweenNodes, ) -if TYPE_CHECKING: # pragma: no cover +if TYPE_CHECKING: from power_grid_model_ds._core.model.grids.base import Grid diff --git a/src/power_grid_model_ds/_core/visualizer/__init__.py b/src/power_grid_model_ds/_core/visualizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/power_grid_model_ds/_core/visualizer/app.py b/src/power_grid_model_ds/_core/visualizer/app.py new file mode 100644 index 0000000..2fbb982 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/app.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +import dash_bootstrap_components as dbc +from dash import Dash, dcc, html +from dash_bootstrap_components.icons import FONT_AWESOME + +from power_grid_model_ds._core.model.grids.base import Grid +from power_grid_model_ds._core.visualizer.callbacks import ( # noqa: F401 # pylint: disable=unused-import + element_scaling, + element_selection, + layout_dropdown, + search_form, +) +from power_grid_model_ds._core.visualizer.layout.cytoscape_html import get_cytoscape_html +from power_grid_model_ds._core.visualizer.layout.header import HEADER_HTML +from power_grid_model_ds._core.visualizer.layout.selection_output import SELECTION_OUTPUT_HTML +from power_grid_model_ds._core.visualizer.parsers import parse_branches, parse_node_array +from power_grid_model_ds.arrays import NodeArray + +GOOGLE_FONTS = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" +MDBOOTSTRAP = "https://cdnjs.cloudflare.com/ajax/libs/mdb-ui-kit/8.2.0/mdb.min.css" + + +def visualize(grid: Grid, debug: bool = False, port: int = 8050) -> None: + """Visualize the Grid. + + grid: Grid + The grid to visualize. + + layout: str + The layout to use. + + If 'layout' is not provided (""): + And grid.node contains "x" and "y" columns: + The layout will be set to "preset" which uses the x and y coordinates to place the nodes. + Otherwise: + The layout will be set to "breadthfirst", which is a hierarchical breadth-first-search (BFS) layout. + Other options: + - "random": A layout that places the nodes randomly. + - "circle": A layout that places the nodes in a circle. + - "concentric": A layout that places the nodes in concentric circles. + - "grid": A layout that places the nodes in a grid matrix. + - "cose": A layout that uses the CompoundSpring Embedder algorithm (force-directed layout) + """ + + app = Dash( + external_stylesheets=[dbc.themes.BOOTSTRAP, dbc.icons.BOOTSTRAP, MDBOOTSTRAP, FONT_AWESOME, GOOGLE_FONTS] + ) + app.layout = get_app_layout(grid) + app.run(debug=debug, port=port) + + +def _get_columns_store(grid: Grid) -> dcc.Store: + return dcc.Store( + id="columns-store", + data={ + "node": grid.node.columns, + "line": grid.line.columns, + "link": grid.link.columns, + "transformer": grid.transformer.columns, + "branch": grid.branches.columns, + }, + ) + + +def get_app_layout(grid: Grid) -> html.Div: + """Get the app layout.""" + columns_store = _get_columns_store(grid) + graph_layout = _get_graph_layout(grid.node) + elements = parse_node_array(grid.node) + parse_branches(grid) + cytoscape_html = get_cytoscape_html(graph_layout, elements) + + return html.Div( + [ + columns_store, + HEADER_HTML, + html.Hr(style={"border-color": "white", "margin": "0"}), + cytoscape_html, + SELECTION_OUTPUT_HTML, + ], + ) + + +def _get_graph_layout(nodes: NodeArray) -> str: + """Determine the graph layout""" + if "x" in nodes.columns and "y" in nodes.columns: + return "preset" + return "breadthfirst" diff --git a/src/power_grid_model_ds/_core/visualizer/callbacks/__init__.py b/src/power_grid_model_ds/_core/visualizer/callbacks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/power_grid_model_ds/_core/visualizer/callbacks/element_scaling.py b/src/power_grid_model_ds/_core/visualizer/callbacks/element_scaling.py new file mode 100644 index 0000000..5d47530 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/callbacks/element_scaling.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from copy import deepcopy +from typing import Any + +from dash import Input, Output, callback + +from power_grid_model_ds._core.visualizer.layout.cytoscape_styling import BRANCH_WIDTH, DEFAULT_STYLESHEET, NODE_SIZE + + +@callback( + Output("cytoscape-graph", "stylesheet", allow_duplicate=True), + Input("node-scale-input", "value"), + Input("edge-scale-input", "value"), + prevent_initial_call=True, +) +def scale_elements(node_scale: float, edge_scale: float) -> list[dict[str, Any]]: + """Callback to scale the elements of the graph.""" + new_stylesheet = deepcopy(DEFAULT_STYLESHEET) + edge_style = { + "selector": "edge", + "style": { + "width": BRANCH_WIDTH * edge_scale, + }, + } + new_stylesheet.append(edge_style) + node_style = { + "selector": "node", + "style": { + "height": NODE_SIZE * node_scale, + "width": NODE_SIZE * node_scale, + }, + } + new_stylesheet.append(node_style) + return new_stylesheet diff --git a/src/power_grid_model_ds/_core/visualizer/callbacks/element_selection.py b/src/power_grid_model_ds/_core/visualizer/callbacks/element_selection.py new file mode 100644 index 0000000..ac03df3 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/callbacks/element_selection.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from typing import Any + +from dash import Input, Output, callback, dash_table + +from power_grid_model_ds._core.visualizer.layout.selection_output import ( + SELECTION_OUTPUT_HTML, +) + + +@callback( + Output("selection-output", "children"), + Input("cytoscape-graph", "selectedNodeData"), + Input("cytoscape-graph", "selectedEdgeData"), +) +def display_selected_element(node_data, edge_data): + """Display the tapped edge data.""" + if node_data: + return _to_data_table(node_data.pop()) + if edge_data: + return _to_data_table(edge_data.pop()) + return SELECTION_OUTPUT_HTML + + +def _to_data_table(data: dict[str, Any]): + columns = data.keys() + data_table = dash_table.DataTable( + data=[data], columns=[{"name": key, "id": key} for key in columns], editable=False + ) + return data_table diff --git a/src/power_grid_model_ds/_core/visualizer/callbacks/layout_dropdown.py b/src/power_grid_model_ds/_core/visualizer/callbacks/layout_dropdown.py new file mode 100644 index 0000000..5eab1f5 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/callbacks/layout_dropdown.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from dash import Input, Output, callback + + +@callback(Output("cytoscape-graph", "layout"), Input("dropdown-update-layout", "value"), prevent_initial_call=True) +def update_layout(layout): + """Callback to update the layout of the graph.""" + return {"name": layout, "animate": True} diff --git a/src/power_grid_model_ds/_core/visualizer/callbacks/search_form.py b/src/power_grid_model_ds/_core/visualizer/callbacks/search_form.py new file mode 100644 index 0000000..bbd55ec --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/callbacks/search_form.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 +from typing import Any + +from dash import Input, Output, callback + +from power_grid_model_ds._core.visualizer.layout.colors import CYTO_COLORS +from power_grid_model_ds._core.visualizer.layout.cytoscape_styling import DEFAULT_STYLESHEET + + +@callback( + Output("cytoscape-graph", "stylesheet"), + Input("search-form-group-input", "value"), + Input("search-form-column-input", "value"), + Input("search-form-operator-input", "value"), + Input("search-form-value-input", "value"), +) +def search_element(group: str, column: str, operator: str, value: str) -> list[dict[str, Any]]: + """Color the specified element red based on the input values.""" + if not group or not column or not value: + return DEFAULT_STYLESHEET + + # Determine if we're working with a node or an edge type + if group == "node": + style = { + "background-color": CYTO_COLORS["highlighted"], + "text-background-color": CYTO_COLORS["highlighted"], + } + else: + style = {"line-color": CYTO_COLORS["highlighted"], "target-arrow-color": CYTO_COLORS["highlighted"]} + + if column == "id": + selector = f'[{column} {operator} "{value}"]' + else: + selector = f"[{column} {operator} {value}]" + + new_style = { + "selector": selector, + "style": style, + } + return DEFAULT_STYLESHEET + [new_style] + + +@callback( + Output("search-form-column-input", "options"), + Output("search-form-column-input", "value"), + Input("search-form-group-input", "value"), + Input("columns-store", "data"), +) +def update_column_options(selected_group, store_data): + """Update the column dropdown options based on the selected group.""" + if not selected_group or not store_data: + return [], None + + # Get columns for the selected group (node, line, link, or transformer) + columns = store_data.get(selected_group, []) + default_value = columns[0] if columns else "id" + + return columns, default_value diff --git a/src/power_grid_model_ds/_core/visualizer/layout/__init__.py b/src/power_grid_model_ds/_core/visualizer/layout/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/power_grid_model_ds/_core/visualizer/layout/colors.py b/src/power_grid_model_ds/_core/visualizer/layout/colors.py new file mode 100644 index 0000000..2848a0a --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/layout/colors.py @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +YELLOW = "#facc37" +CYTO_COLORS = { + "line": YELLOW, + "link": "green", + "transformer": "#4290f5", + "node": YELLOW, + "selected": "#e28743", + "selected_transformer": "#0349a3", + "substation_node": "purple", + "open_branch": "#c9c9c9", + "highlighted": "#a10000", +} +BACKGROUND_COLOR = "#555555" diff --git a/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_config.py b/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_config.py new file mode 100644 index 0000000..40a8288 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_config.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from dash import dcc, html + +from power_grid_model_ds._core.visualizer.layout.colors import CYTO_COLORS +from power_grid_model_ds._core.visualizer.layout.cytoscape_html import LAYOUT_OPTIONS + +NODE_SCALE_HTML = [ + html.I(className="fas fa-circle", style={"color": CYTO_COLORS["node"], "margin-right": "10px"}), + dcc.Input( + id="node-scale-input", + type="number", + value=1, + min=0.1, + step=0.1, + style={"width": "75px"}, + ), + html.Span(style={"margin-right": "10px"}), +] + +EDGE_SCALE_HTML = [ + html.I(className="fas fa-arrow-right-long", style={"color": CYTO_COLORS["line"], "margin-right": "10px"}), + dcc.Input( + id="edge-scale-input", + type="number", + value=1, + min=0.1, + step=0.1, + style={"width": "75px"}, + ), +] + +SCALE_INPUTS = [ + html.Div( + NODE_SCALE_HTML + EDGE_SCALE_HTML, + style={"margin": "0 20px 0 10px"}, + ), +] + +LAYOUT_DROPDOWN_HTML = [ + html.Div( + dcc.Dropdown( + id="dropdown-update-layout", + placeholder="Select layout", + value="", + clearable=False, + options=[{"label": name.capitalize(), "value": name} for name in LAYOUT_OPTIONS], + style={"width": "200px"}, + ), + style={"margin": "0 20px 0 10px"}, + ) +] diff --git a/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_html.py b/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_html.py new file mode 100644 index 0000000..5b6dd51 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_html.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from typing import Any + +import dash_cytoscape as cyto +from dash import html + +from power_grid_model_ds._core.visualizer.layout.colors import BACKGROUND_COLOR +from power_grid_model_ds._core.visualizer.layout.cytoscape_styling import DEFAULT_STYLESHEET + +LAYOUT_OPTIONS = ["random", "circle", "concentric", "grid", "cose", "breadthfirst"] + +_CYTO_INNER_STYLE = {"width": "100%", "height": "100%", "background-color": BACKGROUND_COLOR} +_CYTO_OUTER_STYLE = {"height": "80vh"} + + +def get_cytoscape_html(layout: str, elements: list[dict[str, Any]]) -> html.Div: + """Get the Cytoscape HTML element""" + return html.Div( + cyto.Cytoscape( + id="cytoscape-graph", + layout={"name": layout}, + style=_CYTO_INNER_STYLE, + elements=elements, + stylesheet=DEFAULT_STYLESHEET, + ), + style=_CYTO_OUTER_STYLE, + ) diff --git a/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_styling.py b/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_styling.py new file mode 100644 index 0000000..98f6af6 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_styling.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +"""Contains selectors for the Cytoscape stylesheet.""" + +from power_grid_model_ds._core.visualizer.layout.colors import CYTO_COLORS + +NODE_SIZE = 100 +BRANCH_WIDTH = 10 + +_BRANCH_STYLE = { + "selector": "edge", + "style": { + "line-color": CYTO_COLORS["line"], + "target-arrow-color": CYTO_COLORS["line"], + "curve-style": "bezier", + "target-arrow-shape": "triangle", + "width": BRANCH_WIDTH, + }, +} +_NODE_STYLE = { + "selector": "node", + "style": { + "label": "data(id)", + "border-width": 5, + "border-color": "black", + "font-size": 25, + "text-halign": "center", + "text-valign": "center", + "background-color": CYTO_COLORS["node"], + "text-background-color": CYTO_COLORS["node"], + "text-background-opacity": 1, + "text-background-shape": "round-rectangle", + "width": 75, + "height": 75, + }, +} +_NODE_LARGE_ID_STYLE = { + "selector": "node[id > 10000000]", + "style": {"font-size": 15}, +} +_SELECTED_NODE_STYLE = { + "selector": "node:selected, node:active", + "style": {"border-width": 5, "border-color": CYTO_COLORS["selected"]}, +} + +_SELECTED_BRANCH_STYLE = { + "selector": "edge:selected, edge:active", + "style": {"line-color": CYTO_COLORS["selected"], "target-arrow-color": CYTO_COLORS["selected"], "width": 10}, +} + + +_SUBSTATION_NODE_STYLE = { + "selector": "node[node_type = 1]", + "style": { + "label": "data(id)", + "shape": "diamond", + "background-color": CYTO_COLORS["substation_node"], + "text-background-color": CYTO_COLORS["substation_node"], + "width": NODE_SIZE * 1.2, + "height": NODE_SIZE * 1.2, + "color": "white", + }, +} +_TRANSFORMER_STYLE = { + "selector": "edge[group = 'transformer']", + "style": {"line-color": CYTO_COLORS["transformer"], "target-arrow-color": CYTO_COLORS["transformer"]}, +} +_SELECTED_TRANSFORMER_STYLE = { + "selector": "edge[group = 'transformer']:selected, edge[group = 'transformer']:active", + "style": { + "line-color": CYTO_COLORS["selected_transformer"], + "target-arrow-color": CYTO_COLORS["selected_transformer"], + }, +} + +_OPEN_BRANCH_STYLE = { + "selector": "edge[from_status = 0], edge[to_status = 0]", + "style": { + "line-style": "dashed", + "line-color": CYTO_COLORS["open_branch"], + "target-arrow-color": CYTO_COLORS["open_branch"], + "source-arrow-color": CYTO_COLORS["open_branch"], + }, +} +_OPEN_FROM_SIDE_BRANCH_STYLE = { + "selector": "edge[from_status = 0]", + "style": { + "source-arrow-shape": "diamond", + "source-arrow-fill": "hollow", + }, +} +_OPEN_TO_SIDE_BRANCH_STYLE = { + "selector": "edge[to_status = 0]", + "style": { + "target-arrow-shape": "diamond", + "target-arrow-fill": "hollow", + }, +} + + +DEFAULT_STYLESHEET = [ + _NODE_STYLE, + _NODE_LARGE_ID_STYLE, + _SUBSTATION_NODE_STYLE, + _BRANCH_STYLE, + _TRANSFORMER_STYLE, + _SELECTED_NODE_STYLE, + _SELECTED_BRANCH_STYLE, + _SELECTED_TRANSFORMER_STYLE, + _OPEN_BRANCH_STYLE, + _OPEN_FROM_SIDE_BRANCH_STYLE, + _OPEN_TO_SIDE_BRANCH_STYLE, +] diff --git a/src/power_grid_model_ds/_core/visualizer/layout/header.py b/src/power_grid_model_ds/_core/visualizer/layout/header.py new file mode 100644 index 0000000..b039437 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/layout/header.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +import dash_bootstrap_components as dbc + +from power_grid_model_ds._core.visualizer.layout.colors import BACKGROUND_COLOR +from power_grid_model_ds._core.visualizer.layout.cytoscape_config import LAYOUT_DROPDOWN_HTML, SCALE_INPUTS +from power_grid_model_ds._core.visualizer.layout.legenda import LEGENDA_HTML +from power_grid_model_ds._core.visualizer.layout.search_form import SEARCH_FORM_HTML + +_SEARCH_FORM_CARD_STYLE = { + "background-color": "#555555", + "color": "white", + "border-left": "1px solid white", + "border-right": "1px solid white", + "border-radius": 0, +} + + +HEADER_HTML = dbc.Row( + [ + dbc.Col(LEGENDA_HTML, className="d-flex align-items-center"), + dbc.Col( + dbc.Card(SEARCH_FORM_HTML, style=_SEARCH_FORM_CARD_STYLE), + className="d-flex justify-content-center align-items-center", + ), + dbc.Col(SCALE_INPUTS + LAYOUT_DROPDOWN_HTML, className="d-flex justify-content-end align-items-center"), + ], + style={"background-color": BACKGROUND_COLOR}, +) diff --git a/src/power_grid_model_ds/_core/visualizer/layout/legenda.py b/src/power_grid_model_ds/_core/visualizer/layout/legenda.py new file mode 100644 index 0000000..ab0fd65 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/layout/legenda.py @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +import dash_bootstrap_components as dbc +from dash import html + +from power_grid_model_ds._core.visualizer.layout.colors import CYTO_COLORS + +_MARGIN = "0 10px" +_FONT_SIZE = "2.5em" + +NODE_ICON_STYLE = {"font-size": _FONT_SIZE, "margin": _MARGIN, "color": CYTO_COLORS["node"]} +_SUBSTATION_ICON_STYLE = {"font-size": _FONT_SIZE, "margin": _MARGIN, "color": CYTO_COLORS["substation_node"]} +_LINE_ICON_STYLE = {"font-size": _FONT_SIZE, "margin": _MARGIN, "color": CYTO_COLORS["line"]} +_LINK_ICON_STYLE = {"font-size": _FONT_SIZE, "margin": _MARGIN, "color": CYTO_COLORS["link"]} +_TRANSFORMER_ICON_STYLE = {"font-size": _FONT_SIZE, "margin": _MARGIN, "color": CYTO_COLORS["transformer"]} +_OPEN_BRANCH_ICON_STYLE = {"font-size": _FONT_SIZE, "margin": _MARGIN, "color": CYTO_COLORS["open_branch"]} +LEGENDA_HTML = html.Div( + [ + html.I(className="fas fa-circle", id="node-icon", style=NODE_ICON_STYLE), + dbc.Tooltip("Node", target="node-icon", placement="bottom"), + html.I(className="fas fa-diamond", id="substation-icon", style=_SUBSTATION_ICON_STYLE), + dbc.Tooltip("Substation", target="substation-icon", placement="bottom"), + html.I(className="fas fa-arrow-right-long", id="line-icon", style=_LINE_ICON_STYLE), + dbc.Tooltip("Line", target="line-icon", placement="bottom"), + html.I(className="fas fa-arrow-right-long", id="transformer-icon", style=_TRANSFORMER_ICON_STYLE), + dbc.Tooltip("Transformer", target="transformer-icon", placement="bottom"), + html.I(className="fas fa-arrow-right-long", id="link-icon", style=_LINK_ICON_STYLE), + dbc.Tooltip("Link", target="link-icon", placement="bottom"), + html.I(className="fas fa-ellipsis", id="open-branch-icon", style=_OPEN_BRANCH_ICON_STYLE), + dbc.Tooltip("Open Branch", target="open-branch-icon", placement="bottom"), + ], + style={ + "display": "flex", + "align-items": "center", + "margin": _MARGIN, + "width": "100%", + "text-shadow": "0 0 5px #000", + }, +) diff --git a/src/power_grid_model_ds/_core/visualizer/layout/search_form.py b/src/power_grid_model_ds/_core/visualizer/layout/search_form.py new file mode 100644 index 0000000..bb655ff --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/layout/search_form.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +import dash_bootstrap_components as dbc +from dash import html + +SPAN_TEXT_STYLE = {"color": "white", "margin-right": "8px", "font-weight": "bold", "text-shadow": "0 0 5px #000"} +_INPUT_STYLE = {"width": "150px", "display": "inline-block"} +# Create your form components +GROUP_INPUT = dbc.Select( + id="search-form-group-input", + options=[ + {"label": "node", "value": "node"}, + {"label": "line", "value": "line"}, + {"label": "link", "value": "link"}, + {"label": "transformer", "value": "transformer"}, + {"label": "branch", "value": "branch"}, + ], + value="node", # Default value + style=_INPUT_STYLE, +) + +COLUMN_INPUT = dbc.Select( + id="search-form-column-input", + options=[{"label": "id", "value": "id"}], + value="id", # Default value + style=_INPUT_STYLE, +) + +VALUE_INPUT = dbc.Input(id="search-form-value-input", placeholder="Enter value", type="text", style=_INPUT_STYLE) + +OPERATOR_INPUT = dbc.Select( + id="search-form-operator-input", + options=[ + {"label": "=", "value": "="}, + {"label": "<", "value": "<"}, + {"label": ">", "value": ">"}, + {"label": "!=", "value": "!="}, + ], + value="=", # Default value + style={"width": "60px", "display": "inline-block", "margin": "0 8px"}, +) + + +# Arrange as a sentence +SEARCH_FORM_HTML = html.Div( + [ + html.Span("Search ", style=SPAN_TEXT_STYLE), + GROUP_INPUT, + html.Span(" with ", className="mx-2", style=SPAN_TEXT_STYLE), + COLUMN_INPUT, + OPERATOR_INPUT, + VALUE_INPUT, + ], + style={ + "display": "flex", + "align-items": "center", + "justify-content": "center", # Centers items horizontally + "padding": "10px", + "margin": "0 auto", # Centers the container itself + "width": "100%", # Ensures the container takes full width + }, +) diff --git a/src/power_grid_model_ds/_core/visualizer/layout/selection_output.py b/src/power_grid_model_ds/_core/visualizer/layout/selection_output.py new file mode 100644 index 0000000..5075f33 --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/layout/selection_output.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from dash import dcc, html + +SELECTION_OUTPUT_HEADER_STYLE = {"margin": "20px 0 10px 0"} +_SELECTION_OUTPUT_STYLE = {"overflowX": "scroll", "textAlign": "center", "margin": "10px"} + +SELECTION_OUTPUT_HTML = html.Div( + dcc.Markdown("Click on a **node** or **edge** to display its attributes.", style=SELECTION_OUTPUT_HEADER_STYLE), + id="selection-output", + style=_SELECTION_OUTPUT_STYLE, +) diff --git a/src/power_grid_model_ds/_core/visualizer/parsers.py b/src/power_grid_model_ds/_core/visualizer/parsers.py new file mode 100644 index 0000000..94b46cc --- /dev/null +++ b/src/power_grid_model_ds/_core/visualizer/parsers.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from typing import Any, Literal + +from power_grid_model_ds._core.model.arrays.base.array import FancyArray +from power_grid_model_ds._core.model.grids.base import Grid +from power_grid_model_ds.arrays import BranchArray, NodeArray + + +def parse_node_array(nodes: NodeArray) -> list[dict[str, Any]]: + """Parse the nodes.""" + parsed_nodes = [] + + with_coords = "x" in nodes.columns and "y" in nodes.columns + + columns = nodes.columns + for node in nodes: + cyto_elements = {"data": _array_to_dict(node, columns)} + cyto_elements["data"]["id"] = str(node.id.item()) + cyto_elements["data"]["group"] = "node" + if with_coords: + cyto_elements["position"] = {"x": node.x.item(), "y": -node.y.item()} # invert y-axis for visualization + parsed_nodes.append(cyto_elements) + return parsed_nodes + + +def parse_branches(grid: Grid) -> list[dict[str, Any]]: + """Parse the branches.""" + parsed_branches = [] + parsed_branches.extend(parse_branch_array(grid.line, "line")) + parsed_branches.extend(parse_branch_array(grid.link, "link")) + parsed_branches.extend(parse_branch_array(grid.transformer, "transformer")) + return parsed_branches + + +def parse_branch_array(branches: BranchArray, group: Literal["line", "link", "transformer"]) -> list[dict[str, Any]]: + """Parse the branch array.""" + parsed_branches = [] + columns = branches.columns + for branch in branches: + cyto_elements = {"data": _array_to_dict(branch, columns)} + cyto_elements["data"].update( + { + "id": str(branch.id.item()), + "source": str(branch.from_node.item()), + "target": str(branch.to_node.item()), + "group": group, + } + ) + parsed_branches.append(cyto_elements) + return parsed_branches + + +def _array_to_dict(array_record: FancyArray, columns: list[str]) -> dict[str, Any]: + """Stringify the record (required by Dash).""" + return dict(zip(columns, array_record.tolist().pop())) diff --git a/src/power_grid_model_ds/visualizer.py b/src/power_grid_model_ds/visualizer.py new file mode 100644 index 0000000..41b9a6f --- /dev/null +++ b/src/power_grid_model_ds/visualizer.py @@ -0,0 +1,12 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +try: + from power_grid_model_ds._core.visualizer.app import visualize +except ImportError as error: + raise ImportError( + "Missing dependencies for visualizer: install with 'pip install power-grid-model-ds[visualizer]'" + ) from error + +__all__ = ["visualize"] diff --git a/tests/integration/visualizer_tests.py b/tests/integration/visualizer_tests.py new file mode 100644 index 0000000..006023f --- /dev/null +++ b/tests/integration/visualizer_tests.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from dataclasses import dataclass + +from power_grid_model_ds import Grid +from power_grid_model_ds._core.visualizer.app import visualize +from power_grid_model_ds.generators import RadialGridGenerator +from tests.unit.visualizer.test_parsers import CoordinatedNodeArray + + +@dataclass +class CoordinatedGrid(Grid): + node: CoordinatedNodeArray + + +def get_radial_grid() -> Grid: + return RadialGridGenerator(Grid).run() + + +def get_coordinated_grid() -> CoordinatedGrid: + scale = 500 + grid = CoordinatedGrid.from_txt("S1 2 open", "2 3", "3 4", "S1 500000000", "500000000 6", "6 7 transformer") + grid.node.x = [3, 2.5, 2, 1.5, 3.5, 4, 4.5] + grid.node.x *= scale + grid.node.y = [3, 4, 3, 4, 3, 4, 3] + grid.node.y *= scale + return grid + + +def visualize_grid(): + visualize(grid=get_radial_grid(), debug=True) + + +def visualize_coordinated_grid(): + visualize( + grid=get_coordinated_grid(), + debug=True, + ) + + +if __name__ == "__main__": + visualize_grid() + # visualize_coordinated_grid() diff --git a/tests/unit/visualizer/__init__.py b/tests/unit/visualizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/visualizer/test_callbacks.py b/tests/unit/visualizer/test_callbacks.py new file mode 100644 index 0000000..fd3a5b2 --- /dev/null +++ b/tests/unit/visualizer/test_callbacks.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2025 Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +from power_grid_model_ds._core.visualizer.callbacks.element_scaling import scale_elements +from power_grid_model_ds._core.visualizer.callbacks.search_form import search_element +from power_grid_model_ds._core.visualizer.layout.cytoscape_styling import DEFAULT_STYLESHEET + + +def test_scale_elements(): + assert scale_elements(1, 1) + + +def test_search_element_no_input(): + assert search_element(group="", column="", operator="", value="") == DEFAULT_STYLESHEET + + +def test_search_element_with_input(): + group = "node" + column = "id" + operator = "=" + value = "1" + + expected_selector = f'[{column} {operator} "{value}"]' + + result = search_element(group, column, operator, value) + assert result[-1]["selector"] == expected_selector diff --git a/tests/unit/visualizer/test_layout.py b/tests/unit/visualizer/test_layout.py new file mode 100644 index 0000000..075205d --- /dev/null +++ b/tests/unit/visualizer/test_layout.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 +from power_grid_model_ds._core.data_source.generator.grid_generators import RadialGridGenerator +from power_grid_model_ds._core.model.grids.base import Grid +from power_grid_model_ds._core.visualizer.app import get_app_layout +from power_grid_model_ds._core.visualizer.layout.cytoscape_html import get_cytoscape_html + + +def test_get_cytoscape_html(): + elements = [{"data": {"id": "1", "group": "node"}}] + cyto_html = get_cytoscape_html("preset", elements) + assert cyto_html.children.elements == elements + + +def test_get_app_layout(): + grid = RadialGridGenerator(Grid).run() + assert get_app_layout(grid) diff --git a/tests/unit/visualizer/test_parsers.py b/tests/unit/visualizer/test_parsers.py new file mode 100644 index 0000000..1d3b5b8 --- /dev/null +++ b/tests/unit/visualizer/test_parsers.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Contributors to the Power Grid Model project +# +# SPDX-License-Identifier: MPL-2.0 + +import numpy as np +from numpy.typing import NDArray + +from power_grid_model_ds._core.model.arrays import LineArray, NodeArray +from power_grid_model_ds._core.visualizer.parsers import parse_branch_array, parse_node_array + + +class CoordinatedNodeArray(NodeArray): + x: NDArray[np.float64] + y: NDArray[np.float64] + + +class TestParseNodeArray: + def test_parse_node_array(self): + nodes = NodeArray.zeros(3) + nodes["id"] = [1, 2, 3] + nodes["u_rated"] = [10, 20.4, 30.99] + + parsed = parse_node_array(nodes) + assert len(parsed) == 3 + + node_1_data = parsed[0]["data"] + node_2_data = parsed[1]["data"] + node_3_data = parsed[2]["data"] + + assert node_1_data["group"] == "node" + assert parsed[0].get("position") is None # no coordinates + + assert node_1_data["id"] == "1" # ids are converted to strings + assert node_2_data["id"] == "2" + assert node_3_data["id"] == "3" + + assert node_1_data["u_rated"] == 10 + assert node_2_data["u_rated"] == 20.4 + assert node_3_data["u_rated"] == 30.99 + + def test_parse_coordinated_node_array(self): + nodes = CoordinatedNodeArray.zeros(3) + nodes["id"] = [1, 2, 3] + nodes["x"] = [10, 20, 30] + nodes["y"] = [99, 88, 77] + + parsed = parse_node_array(nodes) + position = parsed[0].get("position") + assert position is not None + assert position["x"] == 10 + assert position["y"] == -99 # coordinates are inverted on y-axis + + +class TestParseBranches: + def test_parse_line_array(self): + lines = LineArray.zeros(3) + lines["id"] = [100, 101, 102] + lines["from_node"] = [1, 2, 3] + lines["to_node"] = [4, 5, 6] + parsed = parse_branch_array(lines, "line") + + assert len(parsed) == 3 + assert parsed[0]["data"]["id"] == "100" + assert parsed[0]["data"]["source"] == "1" + assert parsed[0]["data"]["target"] == "4" + assert parsed[0]["data"]["group"] == "line"