Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions docs/visualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,28 @@ SPDX-License-Identifier: MPL-2.0
## Features

- Based on [dash-cytoscape](https://github.com/plotly/dash-cytoscape).
- Visualize small and large (10000+ nodes) networks
- 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
- Visualize attributes over a heatmap

With Coordinates | Hierarchical | Force-Directed
:------------------:|:------------:|:-------------:
<img width="250" alt="Coordinates" src="_static/grid-with-coordinates.png" /> | <img width="250" alt="Hierarchical" src="_static/grid-hierarchical.png" /> | <img width="250" alt="Force-Directed" src="_static/grid-force-directed.png" />

-----
-----

## 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
Expand All @@ -34,8 +39,23 @@ 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

This will start a local web server at <http://localhost:8050>

#### Examples

The visualizer has a minimal design at the moment.
Hence not all possibilities are directly mentioned in the UI. This section provides some handy tips to visualize common situations:

- All elements of a particular component type: Search -> Desired Component -> any attribute -> unassgined value (eg. Search -> `sym_power_sensor` -> `id` -> `!=` -> `-1`)
- All in-edges/out-edges for a particular node: Search -> `branches` -> `from_node` or `to_node` -> desired node.
- Voltage levels: Heatmap -> `node` -> `u_rated`
- Heatmap any result when an extended grid with result attribute is provided can be visualized.
- Heatmap -> `node` -> `u_pu`
- Heatmap -> `line` -> `loading`
- Heatmap -> `node` -> `energized`

#### 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.
60 changes: 52 additions & 8 deletions src/power_grid_model_ds/_core/visualizer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
config,
element_selection,
header,
heatmap,
search_form,
)
from power_grid_model_ds._core.visualizer.layout.cytoscape_html import get_cytoscape_html
from power_grid_model_ds._core.visualizer.layout.cytoscape_styling import DEFAULT_STYLESHEET
from power_grid_model_ds._core.visualizer.layout.graph_layout import LayoutOptions
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._core.visualizer.parsers import parse_element_data
from power_grid_model_ds.arrays import NodeArray

GOOGLE_FONTS = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
Expand Down Expand Up @@ -65,22 +67,64 @@ def _get_columns_store(grid: Grid) -> dcc.Store:
ComponentType.three_winding_transformer: grid.three_winding_transformer.columns,
ComponentType.asym_line: grid.asym_line.columns,
ComponentType.generic_branch: grid.generic_branch.columns,
"branch": grid.branches.columns,
ComponentType.sym_load: grid.sym_load.columns,
ComponentType.sym_gen: grid.sym_gen.columns,
ComponentType.source: grid.source.columns,
ComponentType.sym_power_sensor: grid.sym_power_sensor.columns,
ComponentType.asym_power_sensor: grid.asym_power_sensor.columns,
ComponentType.sym_voltage_sensor: grid.sym_voltage_sensor.columns,
ComponentType.asym_voltage_sensor: grid.asym_voltage_sensor.columns,
ComponentType.sym_current_sensor: grid.sym_current_sensor.columns,
ComponentType.asym_current_sensor: grid.asym_current_sensor.columns,
ComponentType.transformer_tap_regulator: grid.transformer_tap_regulator.columns,
"branches": grid.branches.columns,
},
)


def _get_min_max_store(grid: Grid):
min_max_dict = {}
for comp_name in [
ComponentType.node.value,
ComponentType.line.value,
ComponentType.link.value,
ComponentType.transformer.value,
ComponentType.three_winding_transformer.value,
ComponentType.asym_line.value,
ComponentType.generic_branch.value,
ComponentType.sym_load.value,
ComponentType.sym_gen.value,
ComponentType.source.value,
"branches",
]:
if not hasattr(grid, comp_name):
continue
array = getattr(grid, comp_name)
if not array.data.size or array.data.size == 0:
continue
for column in array.columns:
min_max_dict[f"{comp_name}_{column}_min"] = array[column].min()
min_max_dict[f"{comp_name}_{column}_max"] = array[column].max()
return dcc.Store(id="heatmap-min-max-store", data=min_max_dict)


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)
min_max_store = _get_min_max_store(grid)
layout = _get_graph_layout(grid.node)
viz_elements_dict, viz_to_comp_data = parse_element_data(grid)
elements = list(viz_elements_dict.values())
cytoscape_html = get_cytoscape_html(layout, elements)

return html.Div(
[
columns_store,
dcc.Store(id="parsed-elements-store", data=elements),
dcc.Store(id="viz-to-comp-store", data=viz_to_comp_data),
dcc.Store(id="stylesheet-store", data=DEFAULT_STYLESHEET),
dcc.Store(id="show-appliances-store", data=True, storage_type="session"),
min_max_store,
HEADER_HTML,
html.Hr(style={"border-color": "white", "margin": "0"}),
cytoscape_html,
Expand All @@ -89,8 +133,8 @@ def get_app_layout(grid: Grid) -> html.Div:
)


def _get_graph_layout(nodes: NodeArray) -> str:
def _get_graph_layout(nodes: NodeArray) -> LayoutOptions:
"""Determine the graph layout"""
if "x" in nodes.columns and "y" in nodes.columns:
return "preset"
return "breadthfirst"
return LayoutOptions.PRESET
return LayoutOptions.BREADTHFIRST
62 changes: 41 additions & 21 deletions src/power_grid_model_ds/_core/visualizer/callbacks/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
from dash import Input, Output, State, callback
from dash.exceptions import PreventUpdate

from power_grid_model_ds._core.visualizer.layout.cytoscape_styling import BRANCH_WIDTH, NODE_SIZE
from power_grid_model_ds._core.visualizer.layout.cytoscape_styling import (
BRANCH_WIDTH,
NODE_SIZE,
)
from power_grid_model_ds._core.visualizer.layout.graph_layout import LayoutOptions
from power_grid_model_ds._core.visualizer.styling_classification import StyleClass
from power_grid_model_ds._core.visualizer.typing import STYLESHEET


Expand All @@ -22,32 +27,29 @@ def scale_elements(node_scale: float, edge_scale: float, stylesheet: STYLESHEET)
"""Callback to scale the elements of the graph."""
if stylesheet is None:
raise PreventUpdate
if node_scale == 1 and edge_scale == 1:
raise PreventUpdate
new_stylesheet = stylesheet.copy()
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)

for selector, new_style in [
(f".{StyleClass.BRANCH.value}", {"width": BRANCH_WIDTH * edge_scale}),
(f".{StyleClass.NODE.value}", {"height": NODE_SIZE * node_scale, "width": NODE_SIZE * node_scale}),
(
f".{StyleClass.APPLIANCE_GHOST_NODE.value}",
{"height": NODE_SIZE * 0.25 * node_scale, "width": NODE_SIZE * 0.25 * node_scale},
),
(f".{StyleClass.GENERATING_APPLIANCE.value}", {"width": BRANCH_WIDTH * 0.5 * edge_scale}),
(f".{StyleClass.LOADING_APPLIANCE.value}", {"width": BRANCH_WIDTH * 0.5 * edge_scale}),
]:
new_stylesheet.append({"selector": selector, "style": new_style})

return new_stylesheet, new_stylesheet


@callback(Output("cytoscape-graph", "layout"), Input("dropdown-update-layout", "value"), prevent_initial_call=True)
def update_layout(layout):
def update_layout(layout_config):
"""Callback to update the layout of the graph."""
return {"name": layout, "animate": True}
layout_config = LayoutOptions(layout_config).layout_with_config()
layout_config.update({"animate": True})
return layout_config


@callback(
Expand All @@ -59,8 +61,26 @@ def update_layout(layout):
def update_arrows(show_arrows, current_stylesheet):
"""Callback to update the arrow style of edges in the graph."""
selectors = [rule["selector"] for rule in current_stylesheet]
index = selectors.index("edge")
index = selectors.index(f".{StyleClass.BRANCH.value}")
edge_style = current_stylesheet[index]["style"]

edge_style["target-arrow-shape"] = "triangle" if show_arrows else "none"
return current_stylesheet


@callback(
Output("cytoscape-graph", "elements"),
Output("show-appliances-store", "data"),
Input("show-appliances", "value"),
State("parsed-elements-store", "data"),
prevent_initial_call=True,
)
def update_appliances(show_appliances, parsed_elements):
"""Callback to add or remove appliances in the graph."""
if show_appliances:
return parsed_elements, True
return [
element
for element in parsed_elements
if element["data"]["group"] not in ["sym_load", "sym_gen", "sym_load_ghost_node", "sym_gen_ghost_node"]
], False
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,66 @@
#
# SPDX-License-Identifier: MPL-2.0

from typing import Any

from dash import Input, Output, callback, dash_table
from dash import Input, Output, State, callback, dash_table, html

from power_grid_model_ds._core.visualizer.layout.selection_output import (
SELECTION_OUTPUT_HTML,
)
from power_grid_model_ds._core.visualizer.typing import ListArrayData, VizToComponentData

# Keys used in the visualization elements that are not part of the component data
VISUALIZATION_KEYS = ["id", "label", "group", "position", "parent", "source", "target"]


@callback(
Output("selection-output", "children"),
Input("cytoscape-graph", "selectedNodeData"),
Input("cytoscape-graph", "selectedEdgeData"),
State("viz-to-comp-store", "data"),
State("columns-store", "data"),
)
def display_selected_element(node_data: list[dict[str, Any]], edge_data: list[dict[str, Any]]):
def display_selected_element(
node_data: ListArrayData,
edge_data: ListArrayData,
viz_to_comp: VizToComponentData,
columns_data: dict[str, list[str]],
):
"""Display the tapped edge data."""
# 0th element means data for only a single selection is shown
if node_data:
return _to_data_table(node_data.pop())
if edge_data:
edge_data_dict = edge_data.pop()
del edge_data_dict["source"] # duplicated by from_node
del edge_data_dict["target"] # duplicated by to_node
del edge_data_dict["group"] # unnecessary information
return _to_data_table(edge_data_dict)
return SELECTION_OUTPUT_HTML


def _to_data_table(data: dict[str, Any]):
columns = data.keys()
selected_data = node_data.pop()
elif edge_data:
selected_data = edge_data.pop()
else:
return SELECTION_OUTPUT_HTML.children

group = selected_data["group"]

elm_data = {k: v for k, v in selected_data.items() if k not in VISUALIZATION_KEYS}

tables: list[html.H5 | html.Div] = []
tables.append(html.H5(group, style={"marginTop": "15px", "textAlign": "left"}))
tables.append(_to_multiple_entries_data_tables(list_array_data=[elm_data], columns=columns_data[group]))

elm_id_str = selected_data["id"]
if elm_id_str in viz_to_comp:
for comp_type, list_array_data in viz_to_comp[elm_id_str].items():
tables.append(html.H5(comp_type, style={"marginTop": "15px", "textAlign": "left"}))
tables.append(
_to_multiple_entries_data_tables(list_array_data=list_array_data, columns=columns_data[comp_type])
)
return html.Div(children=tables, style={"overflowX": "scroll", "margin": "10px"}).children


def _to_multiple_entries_data_tables(list_array_data: ListArrayData, columns: list[str]) -> html.Div:
"""Convert list array data to a Dash DataTable."""

# id column was renamed to pgm_id because of cytoscape reserved word
for elm in list_array_data:
elm["id"] = elm["pgm_id"]
del elm["pgm_id"]
data_table = dash_table.DataTable( # type: ignore[attr-defined]
data=[data], columns=[{"name": key, "id": key} for key in columns], editable=False
data=list_array_data, columns=[{"name": key, "id": key} for key in columns], editable=False, fill_width=False
)
return data_table
6 changes: 4 additions & 2 deletions src/power_grid_model_ds/_core/visualizer/callbacks/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import dash
from dash import Input, Output, callback

from power_grid_model_ds._core.visualizer.layout.header import CONFIG_DIV, LEGENDA_DIV, SEARCH_DIV
from power_grid_model_ds._core.visualizer.layout.header import CONFIG_DIV, HEATMAP_DIV, LEGENDA_DIV, SEARCH_DIV


@callback(
Expand All @@ -14,9 +14,10 @@
Input("btn-legend", "n_clicks"),
Input("btn-search", "n_clicks"),
Input("btn-config", "n_clicks"),
Input("btn-heatmap", "n_clicks"),
],
)
def update_right_col(_btn1, _btn2, _btn3):
def update_right_col(_btn1, _btn2, _btn3, _btn4):
"""Update the right column content based on the button clicked."""
ctx = dash.callback_context
if not ctx.triggered:
Expand All @@ -26,5 +27,6 @@ def update_right_col(_btn1, _btn2, _btn3):
"btn-legend": LEGENDA_DIV,
"btn-search": SEARCH_DIV,
"btn-config": CONFIG_DIV,
"btn-heatmap": HEATMAP_DIV,
}
return button_map.get(button_id, "Right Column")
Loading
Loading