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
+:------------------:|:------------:|:-------------:
+
|
|
+
+-----
+## 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"