Skip to content

Feat/add visualizer #47

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 47 commits into from
Apr 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
8523654
Add visualizer dependencies
Thijss Mar 20, 2025
5426cc8
Add visualizer code
Thijss Mar 20, 2025
11b422a
Add visualize method to public api
Thijss Mar 20, 2025
735e2e4
Fix all imports
Thijss Mar 20, 2025
e58a463
Add integration test
Thijss Mar 20, 2025
f3041a7
some cleanup (not done)
Thijss Mar 20, 2025
8c460b3
rename test file
Thijss Mar 20, 2025
32502fc
decrease to 80vh
Thijss Mar 20, 2025
0520977
add highlighted color
Thijss Mar 20, 2025
fb3aee2
search form on one sentence
Thijss Mar 20, 2025
78fd55f
Improve search form
Thijss Mar 20, 2025
b19ffa8
Lots of styling
Thijss Mar 21, 2025
ff38bb2
Add scaling, legenda and frontend layout
Thijss Mar 21, 2025
dae8686
Adjust scaling steps
Thijss Mar 25, 2025
fb7ff04
adjust view height
Thijss Mar 30, 2025
403bd30
cleanup
Thijss Mar 30, 2025
d47df1e
remove uv.lock
Thijss Mar 30, 2025
5e8475d
add REUSE Compliance
Thijss Mar 30, 2025
32a00d8
make visualize import optional and
Thijss Mar 30, 2025
e5cb443
move visualizer import
Thijss Mar 30, 2025
55389f3
cleanup code
Thijss Mar 31, 2025
40e9761
install visualizer deps in CI
Thijss Mar 31, 2025
10ba51c
Add parser tests
Thijss Mar 31, 2025
c498ffc
Add REUSE compliance
Thijss Mar 31, 2025
678a9ee
Install visualizer in CI
Thijss Mar 31, 2025
92597c0
cleanup
Thijss Mar 31, 2025
2cbcd3a
add visualizer dependencies to 'dev' group too
Thijss Mar 31, 2025
422d76f
move CoordinatedNodeArray
Thijss Mar 31, 2025
c15a384
Add documentation
Thijss Mar 31, 2025
f33bd47
Add port parameter
Thijss Mar 31, 2025
63069de
move vis-deps to correct group
Thijss Mar 31, 2025
1f1f0b0
Add reuse
Thijss Mar 31, 2025
c4c509a
rename main.py to app.py
Thijss Mar 31, 2025
bf563e4
add layout test
Thijss Mar 31, 2025
bff8c2b
exclude visualizer from coverage
Thijss Apr 18, 2025
3bf3f1b
Update visualizer docs with disclaimer
Thijss Apr 18, 2025
f26873e
re-enable visualizer tests
Thijss Apr 18, 2025
a4e6585
Update docs
Thijss Apr 18, 2025
985da8d
Update pyproject.toml
Thijss Apr 18, 2025
cf74704
Add basic test for get_app_layout
Thijss Apr 18, 2025
5d583b2
Merge remote-tracking branch 'origin/feat/add-visualizer' into feat/a…
Thijss Apr 18, 2025
e896064
clenaup
Thijss Apr 18, 2025
02c3ba1
add type hints
Thijss Apr 18, 2025
be09046
add callback tests
Thijss Apr 18, 2025
e254271
use constants
Thijss Apr 18, 2025
f815151
bump minor version
Thijss Apr 18, 2025
5318f28
Merge branch 'main' into feat/add-visualizer
Thijss Apr 18, 2025
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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.3
1.3
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ demos/index
model_structure
model_interface
examples/index
visualizer
advanced_documentation/index
```

Expand Down
41 changes: 41 additions & 0 deletions docs/visualizer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!--
SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>

SPDX-License-Identifier: MPL-2.0
-->

# 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
:------------------:|:------------:|:-------------:
<img width="250" alt="Coordinates" src="https://github.com/user-attachments/assets/6f991cb1-08b4-4c4b-8adc-eed36f58db40" /> | <img width="250" alt="Hierarchical" src="https://github.com/user-attachments/assets/0cf5684d-fb7c-4920-92b8-1e49bc827a92" /> | <img width="250" alt="Force-Directed" src="https://github.com/user-attachments/assets/f0167ded-ceb4-4a31-a91e-e029dd6d7f13" />

-----
## 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.
23 changes: 22 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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",
Expand All @@ -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"
Expand Down Expand Up @@ -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:',
]
2 changes: 1 addition & 1 deletion src/power_grid_model_ds/_core/model/graphs/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion src/power_grid_model_ds/_core/model/graphs/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Empty file.
90 changes: 90 additions & 0 deletions src/power_grid_model_ds/_core/visualizer/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
#
# 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"
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
#
# 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
#
# 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
#
# 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}
60 changes: 60 additions & 0 deletions src/power_grid_model_ds/_core/visualizer/callbacks/search_form.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
#
# 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
Empty file.
17 changes: 17 additions & 0 deletions src/power_grid_model_ds/_core/visualizer/layout/colors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
#
# 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"
Loading