diff --git a/docs/visualizer.md b/docs/visualizer.md
index 604df513..8203f89b 100644
--- a/docs/visualizer.md
+++ b/docs/visualizer.md
@@ -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
:------------------:|:------------:|:-------------:
|
|
------
+-----
+
## 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
@@ -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
+
+#### 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.
diff --git a/src/power_grid_model_ds/_core/visualizer/app.py b/src/power_grid_model_ds/_core/visualizer/app.py
index c6775113..d103d45d 100644
--- a/src/power_grid_model_ds/_core/visualizer/app.py
+++ b/src/power_grid_model_ds/_core/visualizer/app.py
@@ -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"
@@ -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,
@@ -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
diff --git a/src/power_grid_model_ds/_core/visualizer/callbacks/config.py b/src/power_grid_model_ds/_core/visualizer/callbacks/config.py
index afdbfde1..c731aa19 100644
--- a/src/power_grid_model_ds/_core/visualizer/callbacks/config.py
+++ b/src/power_grid_model_ds/_core/visualizer/callbacks/config.py
@@ -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
@@ -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(
@@ -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
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
index 81449983..6b4f8208 100644
--- a/src/power_grid_model_ds/_core/visualizer/callbacks/element_selection.py
+++ b/src/power_grid_model_ds/_core/visualizer/callbacks/element_selection.py
@@ -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
diff --git a/src/power_grid_model_ds/_core/visualizer/callbacks/header.py b/src/power_grid_model_ds/_core/visualizer/callbacks/header.py
index b8902d65..1458aeea 100644
--- a/src/power_grid_model_ds/_core/visualizer/callbacks/header.py
+++ b/src/power_grid_model_ds/_core/visualizer/callbacks/header.py
@@ -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(
@@ -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:
@@ -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")
diff --git a/src/power_grid_model_ds/_core/visualizer/callbacks/heatmap.py b/src/power_grid_model_ds/_core/visualizer/callbacks/heatmap.py
new file mode 100644
index 00000000..4a182881
--- /dev/null
+++ b/src/power_grid_model_ds/_core/visualizer/callbacks/heatmap.py
@@ -0,0 +1,88 @@
+# SPDX-FileCopyrightText: Contributors to the Power Grid Model project
+#
+# SPDX-License-Identifier: MPL-2.0
+
+
+from dash import Input, Output, State, callback
+from dash.exceptions import PreventUpdate
+
+from power_grid_model_ds._core.visualizer.layout.colors import CYTO_COLORS
+from power_grid_model_ds._core.visualizer.typing import STYLESHEET
+
+
+@callback(
+ Output("heatmap-column-input", "options"),
+ Output("heatmap-column-input", "value"),
+ Input("heatmap-group-input", "value"),
+ Input("columns-store", "data"),
+)
+def update_heatmap_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
+
+
+@callback(
+ Output("cytoscape-graph", "stylesheet", allow_duplicate=True),
+ Input("heatmap-group-input", "value"),
+ Input("heatmap-column-input", "value"),
+ State("heatmap-min-max-store", "data"),
+ State("stylesheet-store", "data"),
+ prevent_initial_call=True,
+)
+def apply_heatmap_selection(
+ group: str,
+ column: str,
+ heatmap_min_max: dict,
+ stylesheet: STYLESHEET,
+) -> STYLESHEET:
+ """Apply heatmap coloring to elements based on the selected column and range."""
+ if not group or not column:
+ raise PreventUpdate
+
+ if group == "branches":
+ selector = "edge[group = 'line'], edge[group = 'link'], edge[group = 'transformer']"
+ else:
+ selector = f"[group = '{group}']"
+
+ new_style = {
+ "selector": selector,
+ "style": _get_heatmap_style(group, column, heatmap_min_max),
+ }
+ updated_stylesheet = stylesheet + [new_style]
+ return updated_stylesheet
+
+
+def _get_heatmap_style(
+ group: str,
+ column: str,
+ heatmap_min_max: dict,
+) -> dict:
+ """Get the style dictionary for heatmap coloring."""
+ min_key = f"{group}_{column}_min"
+ max_key = f"{group}_{column}_max"
+
+ if not heatmap_min_max or min_key not in heatmap_min_max or max_key not in heatmap_min_max:
+ return {}
+
+ min_value = heatmap_min_max[min_key]
+ max_value = heatmap_min_max[max_key]
+
+ # id column was renamed to pgm_id because of cytoscape reserved word
+ search_column = column if column != "id" else "pgm_id"
+ mapping_expression = (
+ f"mapData({search_column}, {min_value}, {max_value}, "
+ f"{CYTO_COLORS['heatmap_min']}, {CYTO_COLORS['heatmap_max']})"
+ )
+ return {
+ "background-color": mapping_expression,
+ "text-background-color": mapping_expression,
+ "line-color": mapping_expression,
+ "target-arrow-color": mapping_expression,
+ }
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
index 7a04268f..2a68229d 100644
--- a/src/power_grid_model_ds/_core/visualizer/callbacks/search_form.py
+++ b/src/power_grid_model_ds/_core/visualizer/callbacks/search_form.py
@@ -2,43 +2,88 @@
#
# SPDX-License-Identifier: MPL-2.0
+
from dash import Input, Output, State, callback
from dash.exceptions import PreventUpdate
+from power_grid_model import ComponentType
from power_grid_model_ds._core.visualizer.layout.colors import CYTO_COLORS
-from power_grid_model_ds._core.visualizer.typing import STYLESHEET
+from power_grid_model_ds._core.visualizer.typing import STYLESHEET, ListArrayData, VizToComponentData
+
+HIGHLIGHT_STYLE = {
+ "background-color": CYTO_COLORS["highlighted"],
+ "text-background-color": CYTO_COLORS["highlighted"],
+ "line-color": CYTO_COLORS["highlighted"],
+ "target-arrow-color": CYTO_COLORS["highlighted"],
+}
+
+NON_VISIBLE_ELMS = [
+ ComponentType.sym_power_sensor.value,
+ ComponentType.sym_voltage_sensor.value,
+ ComponentType.asym_voltage_sensor.value,
+ ComponentType.transformer_tap_regulator.value,
+]
+
+BRANCHES_COMPONENTS = [
+ ComponentType.line.value,
+ ComponentType.link.value,
+ ComponentType.generic_branch.value,
+ ComponentType.transformer.value,
+ ComponentType.asym_line.value,
+]
+
+NON_VISIBLE_ELMS_INCL_APPLIANCES = [ComponentType.sym_load.value, ComponentType.sym_gen.value] + NON_VISIBLE_ELMS
@callback(
- Output("cytoscape-graph", "stylesheet"),
+ Output("cytoscape-graph", "stylesheet", allow_duplicate=True),
Input("search-form-group-input", "value"),
Input("search-form-column-input", "value"),
Input("search-form-operator-input", "value"),
Input("search-form-value-input", "value"),
+ State("viz-to-comp-store", "data"),
State("stylesheet-store", "data"),
+ State("show-appliances-store", "data"),
+ prevent_initial_call=True,
)
-def search_element(group: str, column: str, operator: str, value: str, stylesheet: STYLESHEET) -> STYLESHEET:
+def search_element( # pylint: disable=too-many-arguments, disable=too-many-positional-arguments
+ group: str,
+ column: str,
+ operator: str,
+ value: str,
+ viz_to_comp: VizToComponentData,
+ stylesheet: STYLESHEET,
+ show_appliances: bool,
+) -> STYLESHEET:
"""Color the specified element red based on the input values."""
if not group or not column or not value:
raise PreventUpdate
- # 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"]}
+ # id column was renamed to pgm_id because of cytoscape reserved word
+ search_column = column if column != "id" else "pgm_id"
+ search_query = f"{search_column} {operator} {value}"
- if column == "id":
- selector = f'[{column} {operator} "{value}"]'
+ non_visible_elms = NON_VISIBLE_ELMS if show_appliances else NON_VISIBLE_ELMS_INCL_APPLIANCES
+ if group == "branches":
+ branches_selector = ", ".join([f"edge[group = '{comp}']" for comp in BRANCHES_COMPONENTS])
+ selector = f"edge[{search_query}]_[{branches_selector}]"
+ elif group in non_visible_elms:
+ found = _search_components(
+ viz_to_comp=viz_to_comp,
+ component_type=ComponentType(group),
+ column=search_column,
+ operator=operator,
+ value=value,
+ )
+ if not found:
+ return stylesheet
+ selector = ", ".join([f'[id = "{node_id}"]' for node_id in found])
else:
- selector = f"[{column} {operator} {value}]"
+ selector = f"[{search_query}][group = '{group}']"
new_style = {
"selector": selector,
- "style": style,
+ "style": HIGHLIGHT_STYLE,
}
updated_stylesheet = stylesheet + [new_style]
return updated_stylesheet
@@ -60,3 +105,50 @@ def update_column_options(selected_group, store_data):
default_value = columns[0] if columns else "id"
return columns, default_value
+
+
+def _search_components(
+ viz_to_comp: VizToComponentData, component_type: ComponentType, column: str, operator: str, value: str
+) -> list[str]:
+ """Find node or edge IDs that have components matching the search criteria."""
+ try:
+ numeric_value = float(value)
+ except ValueError:
+ return []
+
+ matching_nodes = []
+ for node_edge_id, node_edge_data in viz_to_comp.items():
+ if _find_components_node_edge(node_edge_data, component_type, column, numeric_value, operator):
+ matching_nodes.append(node_edge_id)
+
+ return matching_nodes
+
+
+def _find_components_node_edge(
+ node_edge_data: dict[ComponentType, ListArrayData],
+ component_type: ComponentType,
+ column: str,
+ numeric_value: float,
+ operator: str,
+) -> bool:
+ if component_type in node_edge_data:
+ for component in node_edge_data[component_type]:
+ if column in component:
+ if _compare_values(numeric_value, component[column], operator):
+ return True
+ return False
+
+
+def _compare_values(numeric_value: float, component_value: float, operator: str) -> bool:
+ """Compare component value with the numeric value based on the operator."""
+ match operator:
+ case "=":
+ return component_value == numeric_value
+ case ">":
+ return component_value > numeric_value
+ case "<":
+ return component_value < numeric_value
+ case "!=":
+ return component_value != numeric_value
+ case _:
+ return False
diff --git a/src/power_grid_model_ds/_core/visualizer/layout/colors.py b/src/power_grid_model_ds/_core/visualizer/layout/colors.py
index 32df9853..11f9d864 100644
--- a/src/power_grid_model_ds/_core/visualizer/layout/colors.py
+++ b/src/power_grid_model_ds/_core/visualizer/layout/colors.py
@@ -19,5 +19,7 @@
"substation_node": "purple",
"open_branch": "#c9c9c9",
"highlighted": "#a10000",
+ "heatmap_min": "#0000ff",
+ "heatmap_max": "#ff0000",
}
BACKGROUND_COLOR = "#555555"
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
index 5a02def8..36d49d6f 100644
--- a/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_html.py
+++ b/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_html.py
@@ -9,17 +9,18 @@
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
+from power_grid_model_ds._core.visualizer.layout.graph_layout import LayoutOptions
_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:
+def get_cytoscape_html(layout: LayoutOptions, elements: list[dict[str, Any]]) -> html.Div:
"""Get the Cytoscape HTML element"""
return html.Div(
cyto.Cytoscape(
id="cytoscape-graph",
- layout={"name": layout},
+ layout=layout.layout_with_config(),
style=_CYTO_INNER_STYLE,
elements=elements,
stylesheet=DEFAULT_STYLESHEET,
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
index 851a2de4..d1d346a7 100644
--- a/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_styling.py
+++ b/src/power_grid_model_ds/_core/visualizer/layout/cytoscape_styling.py
@@ -7,12 +7,13 @@
from power_grid_model import ComponentType
from power_grid_model_ds._core.visualizer.layout.colors import CYTO_COLORS
+from power_grid_model_ds._core.visualizer.styling_classification import StyleClass
-NODE_SIZE = 100
+NODE_SIZE = 75
BRANCH_WIDTH = 10
_BRANCH_STYLE = {
- "selector": "edge",
+ "selector": f".{StyleClass.BRANCH.value}",
"style": {
"line-color": CYTO_COLORS[ComponentType.line],
"target-arrow-color": CYTO_COLORS[ComponentType.line],
@@ -22,7 +23,7 @@
},
}
_NODE_STYLE = {
- "selector": "node",
+ "selector": f".{StyleClass.NODE.value}",
"style": {
"label": "data(id)",
"border-width": 5,
@@ -34,12 +35,12 @@
"text-background-color": CYTO_COLORS["node"],
"text-background-opacity": 1,
"text-background-shape": "round-rectangle",
- "width": 75,
- "height": 75,
+ "width": NODE_SIZE,
+ "height": NODE_SIZE,
},
}
_NODE_LARGE_ID_STYLE = {
- "selector": "node[id > 10000000]",
+ "selector": f".{StyleClass.LARGE_ID_NODE.value}",
"style": {"font-size": 15},
}
_SELECTED_NODE_STYLE = {
@@ -47,14 +48,67 @@
"style": {"border-width": 5, "border-color": CYTO_COLORS["selected"]},
}
+_APPLIANCE_GHOST_NODE_STYLE = {
+ "selector": f".{StyleClass.APPLIANCE_GHOST_NODE.value}",
+ "style": {
+ "label": "",
+ "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": NODE_SIZE * 0.25,
+ "height": NODE_SIZE * 0.25,
+ },
+}
+
+
+_GENERATING_EDGE_STYLE = {
+ "selector": f".{StyleClass.GENERATING_APPLIANCE.value}",
+ "style": {
+ "line-color": CYTO_COLORS["line"],
+ "curve-style": "bezier",
+ "source-arrow-color": CYTO_COLORS["line"],
+ "source-arrow-shape": "vee",
+ "arrow-scale": 3.0,
+ "width": BRANCH_WIDTH * 0.5,
+ },
+}
+_SOURCE_EDGE_STYLE = {
+ "selector": f".{StyleClass.SOURCE.value}",
+ "style": {
+ "line-color": CYTO_COLORS["substation_node"],
+ "source-arrow-color": CYTO_COLORS["substation_node"],
+ },
+}
+_LOADING_EDGE_STYLE = {
+ "selector": f".{StyleClass.LOADING_APPLIANCE.value}",
+ "style": {
+ "line-color": CYTO_COLORS["line"],
+ "curve-style": "bezier",
+ "target-arrow-color": CYTO_COLORS["line"],
+ "target-arrow-shape": "vee",
+ "arrow-scale": 3.0,
+ "width": BRANCH_WIDTH * 0.5,
+ },
+}
+
_SELECTED_BRANCH_STYLE = {
"selector": "edge:selected, edge:active",
- "style": {"line-color": CYTO_COLORS["selected"], "target-arrow-color": CYTO_COLORS["selected"], "width": 10},
+ "style": {
+ "line-color": CYTO_COLORS["selected"],
+ "target-arrow-color": CYTO_COLORS["selected"],
+ "width": 10,
+ },
}
_SUBSTATION_NODE_STYLE = {
- "selector": "node[node_type = 1]",
+ "selector": f".{StyleClass.SUBSTATION_NODE.value}",
"style": {
"label": "data(id)",
"shape": "diamond",
@@ -66,11 +120,16 @@
},
}
_TRANSFORMER_STYLE = {
- "selector": "edge[group = 'transformer']",
+ "selector": f".{StyleClass.TRANSFORMER.value}",
"style": {"line-color": CYTO_COLORS["transformer"], "target-arrow-color": CYTO_COLORS["transformer"]},
}
_SELECTED_TRANSFORMER_STYLE = {
- "selector": "edge[group = 'transformer']:selected, edge[group = 'transformer']:active",
+ "selector": (
+ f".{StyleClass.TRANSFORMER.value}:selected, "
+ f".{StyleClass.TRANSFORMER.value}:active, "
+ f".{StyleClass.TRANSFORMER.value}:selected, "
+ f".{StyleClass.TRANSFORMER.value}:active"
+ ),
"style": {
"line-color": CYTO_COLORS["selected_transformer"],
"target-arrow-color": CYTO_COLORS["selected_transformer"],
@@ -78,21 +137,21 @@
}
_LINK_STYLE = {
- "selector": "edge[group = 'link']",
+ "selector": f".{StyleClass.LINK.value}",
"style": {"line-color": CYTO_COLORS["link"], "target-arrow-color": CYTO_COLORS["link"]},
}
_SELECTED_LINK_STYLE = {
- "selector": "edge[group = 'link']:selected, edge[group = 'link']:active",
+ "selector": f".{StyleClass.LINK.value}:selected, .{StyleClass.LINK.value}:active",
"style": {"line-color": CYTO_COLORS["selected_link"], "target-arrow-color": CYTO_COLORS["selected_link"]},
}
_GENERIC_BRANCH_STYLE = {
- "selector": "edge[group = 'generic_branch']",
+ "selector": f".{StyleClass.GENERIC_BRANCH.value}",
"style": {"line-color": CYTO_COLORS["generic_branch"], "target-arrow-color": CYTO_COLORS["generic_branch"]},
}
_SELECTED_GENERIC_BRANCH_STYLE = {
- "selector": "edge[group = 'generic_branch']:selected, edge[group = 'generic_branch']:active",
+ "selector": f".{StyleClass.GENERIC_BRANCH.value}:selected, .{StyleClass.GENERIC_BRANCH.value}:active",
"style": {
"line-color": CYTO_COLORS["selected_generic_branch"],
"target-arrow-color": CYTO_COLORS["selected_generic_branch"],
@@ -100,19 +159,19 @@
}
_ASYM_LINE_STYLE = {
- "selector": "edge[group = 'asym_line']",
+ "selector": f".{StyleClass.ASYM_LINE.value}",
"style": {"line-color": CYTO_COLORS["asym_line"], "target-arrow-color": CYTO_COLORS["asym_line"]},
}
_SELECTED_ASYM_LINE_STYLE = {
- "selector": "edge[group = 'asym_line']:selected, edge[group = 'asym_line']:active",
+ "selector": f".{StyleClass.ASYM_LINE.value}:selected, .{StyleClass.ASYM_LINE.value}:active",
"style": {
"line-color": CYTO_COLORS["selected_asym_line"],
"target-arrow-color": CYTO_COLORS["selected_asym_line"],
},
}
_OPEN_BRANCH_STYLE = {
- "selector": "edge[from_status = 0], edge[to_status = 0]",
+ "selector": (f".{StyleClass.OPEN_BRANCH.value}"),
"style": {
"line-style": "dashed",
"target-arrow-color": CYTO_COLORS["open_branch"],
@@ -120,20 +179,37 @@
},
}
_OPEN_FROM_SIDE_BRANCH_STYLE = {
- "selector": "edge[from_status = 0]",
+ "selector": f".{StyleClass.OPEN_BRANCH_FROM.value}",
"style": {
"source-arrow-shape": "diamond",
"source-arrow-fill": "hollow",
},
}
_OPEN_TO_SIDE_BRANCH_STYLE = {
- "selector": "edge[to_status = 0]",
+ "selector": f".{StyleClass.OPEN_BRANCH_TO.value}",
"style": {
"target-arrow-shape": "diamond",
"target-arrow-fill": "hollow",
},
}
-
+_OPEN_LOADING_EDGE_STYLE = {
+ "selector": f".{StyleClass.OPEN_LOADING_APPLIANCE.value}",
+ "style": {
+ "line-style": "dashed",
+ "line-color": CYTO_COLORS["open_branch"],
+ "target-arrow-color": CYTO_COLORS["open_branch"],
+ "target-arrow-fill": "hollow",
+ },
+}
+_OPEN_GENERATING_EDGE_STYLE = {
+ "selector": f".{StyleClass.OPEN_GENERATING_APPLIANCE.value}",
+ "style": {
+ "line-style": "dashed",
+ "line-color": CYTO_COLORS["open_branch"],
+ "source-arrow-color": CYTO_COLORS["open_branch"],
+ "source-arrow-fill": "hollow",
+ },
+}
DEFAULT_STYLESHEET = [
_NODE_STYLE,
@@ -150,8 +226,14 @@
_SELECTED_GENERIC_BRANCH_STYLE,
_ASYM_LINE_STYLE,
_SELECTED_ASYM_LINE_STYLE,
+ _APPLIANCE_GHOST_NODE_STYLE,
+ _GENERATING_EDGE_STYLE,
+ _LOADING_EDGE_STYLE,
+ _SOURCE_EDGE_STYLE,
# Note: Keep the OPEN BRANCH styles last in list, otherwise they potentially get overridden.
_OPEN_BRANCH_STYLE,
_OPEN_FROM_SIDE_BRANCH_STYLE,
_OPEN_TO_SIDE_BRANCH_STYLE,
+ _OPEN_LOADING_EDGE_STYLE,
+ _OPEN_GENERATING_EDGE_STYLE,
]
diff --git a/src/power_grid_model_ds/_core/visualizer/layout/graph_layout.py b/src/power_grid_model_ds/_core/visualizer/layout/graph_layout.py
new file mode 100644
index 00000000..4db3dc9d
--- /dev/null
+++ b/src/power_grid_model_ds/_core/visualizer/layout/graph_layout.py
@@ -0,0 +1,24 @@
+from enum import Enum
+
+
+class LayoutOptions(Enum):
+ """Cytoscape layout options."""
+
+ RANDOM = "random"
+ CIRCLE = "circle"
+ CONCENTRIC = "concentric"
+ GRID = "grid"
+ COSE = "cose"
+ BREADTHFIRST = "breadthfirst"
+ PRESET = "preset"
+
+ def layout_with_config(self) -> dict:
+ """Get the layout options for the selected layout."""
+ if self == LayoutOptions.BREADTHFIRST:
+ return {"name": self.value, "roots": 'node[group = "source_ghost_node"]'}
+ return {"name": self.value}
+
+ @staticmethod
+ def dropdown_layouts() -> list[str]:
+ """Get a list of available layout options for the dropdown."""
+ return [option.value for option in LayoutOptions if option != LayoutOptions.PRESET]
diff --git a/src/power_grid_model_ds/_core/visualizer/layout/header.py b/src/power_grid_model_ds/_core/visualizer/layout/header.py
index 76029b36..9b46674b 100644
--- a/src/power_grid_model_ds/_core/visualizer/layout/header.py
+++ b/src/power_grid_model_ds/_core/visualizer/layout/header.py
@@ -6,6 +6,7 @@
from dash import html
from power_grid_model_ds._core.visualizer.layout.header_config import CONFIG_ELEMENTS
+from power_grid_model_ds._core.visualizer.layout.header_heatmap import HEATMAP_ELEMENTS
from power_grid_model_ds._core.visualizer.layout.header_legenda import LEGENDA_ELEMENTS, LEGENDA_STYLE
from power_grid_model_ds._core.visualizer.layout.header_search import SEARCH_ELEMENTS
@@ -16,6 +17,7 @@
dbc.Button("Legend", id="btn-legend", className=_MENU_BUTTON_STYLE_CLASS),
dbc.Button("Search", id="btn-search", className=_MENU_BUTTON_STYLE_CLASS),
dbc.Button("Config", id="btn-config", className=_MENU_BUTTON_STYLE_CLASS),
+ dbc.Button("Heatmap", id="btn-heatmap", className=_MENU_BUTTON_STYLE_CLASS),
],
id="header-left-col",
width=5,
@@ -37,10 +39,11 @@
CONFIG_DIV = html.Div(CONFIG_ELEMENTS, style=_RIGHT_COLUMN_STYLE | {"justify-content": "space-between"})
SEARCH_DIV = html.Div(SEARCH_ELEMENTS, style=_RIGHT_COLUMN_STYLE | {"justify-content": "center"})
+HEATMAP_DIV = html.Div(HEATMAP_ELEMENTS, style=_RIGHT_COLUMN_STYLE | {"justify-content": "center"})
LEGENDA_DIV = html.Div(LEGENDA_ELEMENTS, style=_RIGHT_COLUMN_STYLE | LEGENDA_STYLE)
_RIGHT_COLUMN_HTML = dbc.Col(
- [LEGENDA_DIV, SEARCH_DIV, CONFIG_DIV],
+ [LEGENDA_DIV, SEARCH_DIV, CONFIG_DIV, HEATMAP_DIV],
id="header-right-col",
width=7,
)
diff --git a/src/power_grid_model_ds/_core/visualizer/layout/header_config.py b/src/power_grid_model_ds/_core/visualizer/layout/header_config.py
index e94911fd..4e74bacb 100644
--- a/src/power_grid_model_ds/_core/visualizer/layout/header_config.py
+++ b/src/power_grid_model_ds/_core/visualizer/layout/header_config.py
@@ -1,12 +1,12 @@
# SPDX-FileCopyrightText: Contributors to the Power Grid Model project
#
# SPDX-License-Identifier: MPL-2.0
-from enum import Enum
import dash_bootstrap_components as dbc
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.graph_layout import LayoutOptions
NODE_SCALE_HTML = [
html.I(className="fas fa-circle", style={"color": CYTO_COLORS["node"], "margin-right": "10px"}),
@@ -36,24 +36,19 @@
_SCALING_DIV = html.Div(NODE_SCALE_HTML + EDGE_SCALE_HTML, style={"margin": "0 20px 0 10px"})
-class LayoutOptions(Enum):
- """Cytoscape layout options."""
-
- RANDOM = "random"
- CIRCLE = "circle"
- CONCENTRIC = "concentric"
- GRID = "grid"
- COSE = "cose"
- BREADTHFIRST = "breadthfirst"
-
-
_LAYOUT_DROPDOWN = html.Div(
dcc.Dropdown(
id="dropdown-update-layout",
placeholder="Select layout",
value=LayoutOptions.BREADTHFIRST.value,
clearable=False,
- options=[{"label": option.value, "value": option.value} for option in LayoutOptions], # type: ignore[arg-type]
+ options=[
+ {
+ "label": option,
+ "value": option,
+ }
+ for option in LayoutOptions.dropdown_layouts()
+ ], # type: ignore[arg-type]
style={"width": "200px"},
),
style={"margin": "0 20px 0 10px", "color": "black"},
@@ -68,4 +63,12 @@ class LayoutOptions(Enum):
style={"margin-top": "10px"},
)
-CONFIG_ELEMENTS = [_LAYOUT_DROPDOWN, _ARROWS_CHECKBOX, _SCALING_DIV]
+_SHOW_APPLIANCES_CHECKBOX = dbc.Checkbox(
+ id="show-appliances",
+ label="Show appliances",
+ value=True,
+ label_style={"color": "white"},
+ style={"margin-top": "10px"},
+)
+
+CONFIG_ELEMENTS = [_LAYOUT_DROPDOWN, _ARROWS_CHECKBOX, _SHOW_APPLIANCES_CHECKBOX, _SCALING_DIV]
diff --git a/src/power_grid_model_ds/_core/visualizer/layout/header_heatmap.py b/src/power_grid_model_ds/_core/visualizer/layout/header_heatmap.py
new file mode 100644
index 00000000..dcc97119
--- /dev/null
+++ b/src/power_grid_model_ds/_core/visualizer/layout/header_heatmap.py
@@ -0,0 +1,47 @@
+# 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="heatmap-group-input",
+ options=[
+ {"label": "node", "value": "node"},
+ {"label": "line", "value": "line"},
+ {"label": "link", "value": "link"},
+ {"label": "transformer", "value": "transformer"},
+ {"label": "three_winding_transformer", "value": "three_winding_transformer"},
+ {"label": "asym_line", "value": "asym_line"},
+ {"label": "generic_branch", "value": "generic_branch"},
+ {"label": "sym_load", "value": "sym_load"},
+ {"label": "sym_gen", "value": "sym_gen"},
+ {"label": "source", "value": "source"},
+ {"label": "branches", "value": "branches"},
+ ],
+ value="node", # Default value
+ style=_INPUT_STYLE,
+)
+
+COLUMN_INPUT = dbc.Select(
+ id="heatmap-column-input",
+ options=[{"label": "id", "value": "id"}],
+ value="id", # Default value
+ style=_INPUT_STYLE,
+)
+
+# Arrange as a sentence
+HEATMAP_ELEMENTS = [
+ html.Div(
+ [
+ html.Span("Apply heatmap ", style=SPAN_TEXT_STYLE),
+ GROUP_INPUT,
+ html.Span(" with ", style=SPAN_TEXT_STYLE),
+ COLUMN_INPUT,
+ ]
+ )
+]
diff --git a/src/power_grid_model_ds/_core/visualizer/layout/header_legenda.py b/src/power_grid_model_ds/_core/visualizer/layout/header_legenda.py
index 68865d75..734cba55 100644
--- a/src/power_grid_model_ds/_core/visualizer/layout/header_legenda.py
+++ b/src/power_grid_model_ds/_core/visualizer/layout/header_legenda.py
@@ -22,6 +22,14 @@
_GENERIC_BRANCH_ICON_STYLE = {"font-size": _FONT_SIZE, "margin": _MARGIN, "color": CYTO_COLORS["generic_branch"]}
_ASYM_LINE_ICON_STYLE = {"font-size": _FONT_SIZE, "margin": _MARGIN, "color": CYTO_COLORS["asym_line"]}
_OPEN_BRANCH_ICON_STYLE = {"font-size": _FONT_SIZE, "margin": _MARGIN, "color": CYTO_COLORS["open_branch"]}
+_HEATMAP_STYLE = {
+ "width": "60px",
+ "height": "30px",
+ "margin": _MARGIN,
+ "background": f"linear-gradient(to right, {CYTO_COLORS['heatmap_min']}, {CYTO_COLORS['heatmap_max']})",
+ "border": "2px solid white",
+ "border-radius": "4px",
+}
LEGENDA_ELEMENTS = [
html.I(className="fas fa-circle", id="node-icon", style=NODE_ICON_STYLE),
dbc.Tooltip("Node", target="node-icon", placement="bottom"),
@@ -39,4 +47,6 @@
dbc.Tooltip("Asymmetrical Line", target="asym-line-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"),
+ html.Div(id="heatmap-icon", style=_HEATMAP_STYLE),
+ dbc.Tooltip("Heatmap", target="heatmap-icon", placement="bottom"),
]
diff --git a/src/power_grid_model_ds/_core/visualizer/layout/header_search.py b/src/power_grid_model_ds/_core/visualizer/layout/header_search.py
index f7e4b263..0c9efbd1 100644
--- a/src/power_grid_model_ds/_core/visualizer/layout/header_search.py
+++ b/src/power_grid_model_ds/_core/visualizer/layout/header_search.py
@@ -15,7 +15,20 @@
{"label": "line", "value": "line"},
{"label": "link", "value": "link"},
{"label": "transformer", "value": "transformer"},
- {"label": "branch", "value": "branch"},
+ {"label": "three_winding_transformer", "value": "three_winding_transformer"},
+ {"label": "asym_line", "value": "asym_line"},
+ {"label": "generic_branch", "value": "generic_branch"},
+ {"label": "sym_load", "value": "sym_load"},
+ {"label": "sym_gen", "value": "sym_gen"},
+ {"label": "source", "value": "source"},
+ {"label": "sym_power_sensor", "value": "sym_power_sensor"},
+ {"label": "asym_power_sensor", "value": "asym_power_sensor"},
+ {"label": "sym_voltage_sensor", "value": "sym_voltage_sensor"},
+ {"label": "asym_voltage_sensor", "value": "asym_voltage_sensor"},
+ {"label": "sym_current_sensor", "value": "sym_current_sensor"},
+ {"label": "asym_current_sensor", "value": "asym_current_sensor"},
+ {"label": "transformer_tap_regulator", "value": "transformer_tap_regulator"},
+ {"label": "branches", "value": "branches"},
],
value="node", # Default value
style=_INPUT_STYLE,
diff --git a/src/power_grid_model_ds/_core/visualizer/parsers.py b/src/power_grid_model_ds/_core/visualizer/parsers.py
index 9ed131a8..b97f8a19 100644
--- a/src/power_grid_model_ds/_core/visualizer/parsers.py
+++ b/src/power_grid_model_ds/_core/visualizer/parsers.py
@@ -2,84 +2,291 @@
#
# SPDX-License-Identifier: MPL-2.0
-from typing import Any, Literal
+from typing import Literal
-from power_grid_model_ds._core.model.arrays.base.array import FancyArray
+from power_grid_model import ComponentType, MeasuredTerminalType
+
+from power_grid_model_ds._core.model.arrays.pgm_arrays import (
+ AsymCurrentSensorArray,
+ AsymPowerSensorArray,
+ AsymVoltageSensorArray,
+ Branch3Array,
+ BranchArray,
+ NodeArray,
+ SourceArray,
+ SymCurrentSensorArray,
+ SymGenArray,
+ SymLoadArray,
+ SymPowerSensorArray,
+ SymVoltageSensorArray,
+ TransformerTapRegulatorArray,
+)
from power_grid_model_ds._core.model.grids.base import Grid
-from power_grid_model_ds.arrays import Branch3Array, BranchArray, NodeArray
+from power_grid_model_ds._core.visualizer.parsing_utils import (
+ append_component_list,
+ array_to_dict,
+ map_appliance_to_nodes,
+ merge_viz_to_comp,
+)
+from power_grid_model_ds._core.visualizer.styling_classification import (
+ StyleClass,
+ get_appliance_edge_classification,
+ get_branch_classification,
+ get_node_classification,
+)
+from power_grid_model_ds._core.visualizer.typing import VizToComponentData, VizToComponentElements
+
+_NODE_BRANCH_TERMINAL_TYPE = [
+ MeasuredTerminalType.branch_from,
+ MeasuredTerminalType.branch_to,
+ MeasuredTerminalType.node,
+]
+_APPLIANCE_TERMINAL_TYPE = [
+ MeasuredTerminalType.load,
+ MeasuredTerminalType.generator,
+ MeasuredTerminalType.source,
+]
+_BRANCH3_TERMINAL_TYPE = [
+ MeasuredTerminalType.branch3_1,
+ MeasuredTerminalType.branch3_2,
+ MeasuredTerminalType.branch3_3,
+]
+
+
+def parse_element_data(grid: Grid) -> tuple[VizToComponentElements, VizToComponentData]:
+ """
+ Parse grid element data and organize by node ID as string.
+
+ Args:
+ grid (Grid): The power grid model.
+ Returns:
+ tuple[VizToComponentElements, VizToComponentElements]: A tuple containing
+ a dict of elements for visualization
+ A mapping from node or edge IDs used in visualization to their associated component data.
+ """
+ elements: VizToComponentElements = {}
+ viz_to_comp: VizToComponentData = {}
+
+ elements.update(parse_node_array(grid.node))
+
+ # Parse branches
+ elements.update(parse_branch_array(grid.asym_line, ComponentType.asym_line))
+ elements.update(parse_branch_array(grid.line, ComponentType.line))
+ elements.update(parse_branch_array(grid.generic_branch, ComponentType.generic_branch))
+ elements.update(parse_branch_array(grid.link, ComponentType.link))
+ elements.update(parse_branch_array(grid.transformer, ComponentType.transformer))
+
+ # Parse branch3
+ elements.update(parse_branch3_array(grid.three_winding_transformer, ComponentType.three_winding_transformer))
+
+ # Parse appliances
+ for appliance_name in (ComponentType.sym_load, ComponentType.sym_gen, ComponentType.source):
+ parsed, viz_to_comp_appliance = _parse_appliances(
+ getattr(grid, appliance_name),
+ appliance_name,
+ )
+ elements.update(parsed)
+ merge_viz_to_comp(viz_to_comp, viz_to_comp_appliance)
-def parse_node_array(nodes: NodeArray) -> list[dict[str, Any]]:
- """Parse the nodes."""
- parsed_nodes = []
+ appliance_to_node = map_appliance_to_nodes(grid)
+
+ # Parse sensors
+ merge_viz_to_comp(
+ viz_to_comp, _parse_flow_sensors(grid.sym_power_sensor, ComponentType.sym_power_sensor, appliance_to_node)
+ )
+ merge_viz_to_comp(
+ viz_to_comp, _parse_flow_sensors(grid.asym_power_sensor, ComponentType.asym_power_sensor, appliance_to_node)
+ )
+ merge_viz_to_comp(
+ viz_to_comp, _parse_flow_sensors(grid.sym_current_sensor, ComponentType.sym_current_sensor, appliance_to_node)
+ )
+ merge_viz_to_comp(
+ viz_to_comp, _parse_flow_sensors(grid.asym_current_sensor, ComponentType.asym_current_sensor, appliance_to_node)
+ )
+ merge_viz_to_comp(viz_to_comp, _parse_voltage_sensors(grid.sym_voltage_sensor, ComponentType.sym_voltage_sensor))
+ merge_viz_to_comp(viz_to_comp, _parse_voltage_sensors(grid.asym_voltage_sensor, ComponentType.asym_voltage_sensor))
+
+ # Parse regulators
+ merge_viz_to_comp(viz_to_comp, _parse_transformer_tap_regulators(grid.transformer_tap_regulator, elements))
+
+ return elements, viz_to_comp
+
+
+def parse_node_array(nodes: NodeArray) -> VizToComponentElements:
+ """Parse the nodes. Fills node data to viz_to_comp."""
+ parsed_nodes: VizToComponentElements = {}
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
+ node_id_str = str(node.id.item())
+ parsed_nodes[node_id_str] = {
+ "data": {"id": node_id_str, "group": "node"},
+ "classes": get_node_classification(node),
+ }
+ parsed_nodes[node_id_str]["data"].update(array_to_dict(node, nodes.columns))
-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"))
- parsed_branches.extend(parse_branch_array(grid.generic_branch, "generic_branch"))
- parsed_branches.extend(parse_branch_array(grid.asym_line, "asym_line"))
- parsed_branches.extend(parse_branch3_array(grid.three_winding_transformer, "transformer"))
-
- return parsed_branches
+ if with_coords:
+ parsed_nodes[node_id_str]["position"] = {
+ "x": node.x.item(),
+ "y": -node.y.item(),
+ } # invert y-axis for visualization
+ return parsed_nodes
-def parse_branch3_array(branches: Branch3Array, group: Literal["transformer"]) -> list[dict[str, Any]]:
- """Parse the three-winding transformer array."""
- parsed_branches = []
- columns = branches.columns
+def parse_branch3_array(
+ branches: Branch3Array, group: Literal[ComponentType.three_winding_transformer]
+) -> VizToComponentElements:
+ """Parse the three-winding transformer array. Fills branch3 data to viz_to_comp."""
+ parsed_branches: VizToComponentElements = {}
for branch3 in branches:
- for branch1 in branch3.as_branches():
- cyto_elements = {"data": _array_to_dict(branch1, columns)}
- cyto_elements["data"].update(
- {
- # IDs need to be unique, so we combine the branch ID with the from and to nodes
- "id": str(branch3.id.item()) + f"_{branch1.from_node.item()}_{branch1.to_node.item()}",
- "source": str(branch1.from_node.item()),
- "target": str(branch1.to_node.item()),
- "group": group,
- }
- )
- parsed_branches.append(cyto_elements)
+ branch3_component_data = array_to_dict(branch3, branches.columns) # Same for all three branches
+ for count, branch in enumerate(branch3.as_branches()):
+ branch_id_str = f"{branch3.id.item()}_{count}"
+ parsed_branches[branch_id_str] = {
+ "data": {
+ # IDs need to be unique, so we combine the branch ID with 0,1,2
+ "id": branch_id_str,
+ "source": str(branch.from_node.item()),
+ "target": str(branch.to_node.item()),
+ "group": group.value,
+ },
+ "classes": get_branch_classification(branch, group),
+ }
+ parsed_branches[branch_id_str]["data"].update(branch3_component_data)
return parsed_branches
def parse_branch_array(
branches: BranchArray,
- group: Literal["line", "link", "transformer", "generic_branch", "asym_line"],
-) -> list[dict[str, Any]]:
- """Parse the branch array."""
- parsed_branches = []
- columns = branches.columns
+ group: Literal[
+ ComponentType.line,
+ ComponentType.asym_line,
+ ComponentType.generic_branch,
+ ComponentType.link,
+ ComponentType.transformer,
+ ],
+) -> VizToComponentElements:
+ """Parse the branch array. Fills branch data to viz_to_comp."""
+ parsed_branches: VizToComponentElements = {}
for branch in branches:
- cyto_elements = {"data": _array_to_dict(branch, columns)}
- cyto_elements["data"].update(
- {
- "id": str(branch.id.item()),
+ branch_id_str = str(branch.id.item())
+ parsed_branches[branch_id_str] = {
+ "data": {
+ "id": branch_id_str,
"source": str(branch.from_node.item()),
"target": str(branch.to_node.item()),
- "group": group,
- }
- )
- parsed_branches.append(cyto_elements)
+ "group": group.value,
+ },
+ "classes": get_branch_classification(branch, group),
+ }
+ parsed_branches[branch_id_str]["data"].update(array_to_dict(branch, branches.columns))
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()))
+def _parse_appliances(
+ appliances: SymLoadArray | SymGenArray | SourceArray,
+ group: Literal[ComponentType.sym_load, ComponentType.sym_gen, ComponentType.source],
+) -> tuple[VizToComponentElements, VizToComponentData]:
+ """Parse appliances and associate them with nodes."""
+ parsed_appliances: VizToComponentElements = {}
+ viz_to_comp_appliance: VizToComponentData = {}
+
+ for appliance in appliances:
+ appliance_id_str = str(appliance.id.item())
+ appliance_ghost_id_str = f"{appliance_id_str}_ghost_node"
+ node_id_str = str(appliance.node.item())
+
+ # Add appliance to node
+ parsed_appliances[appliance_ghost_id_str] = {
+ "data": {
+ "id": appliance_ghost_id_str,
+ "group": f"{group.value}_ghost_node",
+ },
+ "selectable": False,
+ "classes": StyleClass.APPLIANCE_GHOST_NODE.value,
+ }
+
+ parsed_appliances[appliance_id_str] = {
+ "data": {
+ "id": appliance_id_str,
+ "source": node_id_str,
+ "target": appliance_ghost_id_str,
+ "group": group.value,
+ "status": appliance.status.item(),
+ },
+ "classes": get_appliance_edge_classification(appliance, group),
+ }
+ parsed_appliances[appliance_id_str]["data"].update(array_to_dict(appliance, appliances.columns))
+ append_component_list(viz_to_comp_appliance, array_to_dict(appliance, appliances.columns), node_id_str, group)
+ return parsed_appliances, viz_to_comp_appliance
+
+
+def _parse_flow_sensors(
+ sensors: SymPowerSensorArray | SymCurrentSensorArray | AsymPowerSensorArray | AsymCurrentSensorArray,
+ group: Literal[
+ ComponentType.sym_power_sensor,
+ ComponentType.sym_current_sensor,
+ ComponentType.asym_power_sensor,
+ ComponentType.asym_current_sensor,
+ ],
+ appliance_to_node: dict[str, str],
+) -> VizToComponentData:
+ """Parse power sensors and return appliance-to-power-sensor mapping."""
+ viz_to_comp: VizToComponentData = {}
+ for power_sensor in sensors:
+ measured_object_id_str = str(power_sensor.measured_object.item())
+ measured_terminal_type = power_sensor.measured_terminal_type.item()
+ sensor_dict = array_to_dict(power_sensor, sensors.columns)
+
+ if measured_terminal_type in _NODE_BRANCH_TERMINAL_TYPE:
+ append_component_list(viz_to_comp, sensor_dict, measured_object_id_str, group)
+ elif measured_terminal_type in _BRANCH3_TERMINAL_TYPE:
+ for count in range(3):
+ branch1_id = f"{measured_object_id_str}_{count}"
+ append_component_list(viz_to_comp, sensor_dict, branch1_id, group)
+ elif measured_terminal_type in _APPLIANCE_TERMINAL_TYPE:
+ append_component_list(viz_to_comp, sensor_dict, measured_object_id_str, group)
+ # Map appliance to both appliance and its node as both can be unvisualized
+ append_component_list(viz_to_comp, sensor_dict, appliance_to_node[measured_object_id_str], group)
+ else:
+ raise ValueError(f"Unknown measured_terminal_type: {measured_terminal_type}")
+
+ return viz_to_comp
+
+
+def _parse_voltage_sensors(
+ voltage_sensors: SymVoltageSensorArray | AsymVoltageSensorArray,
+ sensor_type: Literal[ComponentType.sym_voltage_sensor, ComponentType.asym_voltage_sensor],
+) -> VizToComponentData:
+ """Parse voltage sensors and associate them with nodes."""
+ viz_to_comp: VizToComponentData = {}
+ for voltage_sensor in voltage_sensors:
+ node_id_str = str(voltage_sensor.measured_object.item())
+ sym_voltage_sensor_data = array_to_dict(voltage_sensor, voltage_sensors.columns)
+ append_component_list(viz_to_comp, sym_voltage_sensor_data, node_id_str, sensor_type)
+ return viz_to_comp
+
+
+def _parse_transformer_tap_regulators(
+ transformer_tap_regulators: TransformerTapRegulatorArray, elements: VizToComponentElements
+) -> VizToComponentData:
+ """Parse transformer tap regulators and associate them with transformers."""
+ viz_to_comp: VizToComponentData = {}
+ for tap_regulator in transformer_tap_regulators:
+ regulated_object_str = str(tap_regulator.regulated_object.item())
+ tap_regulator_data = array_to_dict(tap_regulator, transformer_tap_regulators.columns)
+ if regulated_object_str in elements:
+ append_component_list(
+ viz_to_comp, tap_regulator_data, regulated_object_str, ComponentType.transformer_tap_regulator
+ )
+ else:
+ for count in range(3):
+ branch3_id_str = f"{regulated_object_str}_{count}"
+ if branch3_id_str in elements:
+ append_component_list(
+ viz_to_comp, tap_regulator_data, branch3_id_str, ComponentType.transformer_tap_regulator
+ )
+ return viz_to_comp
diff --git a/src/power_grid_model_ds/_core/visualizer/parsing_utils.py b/src/power_grid_model_ds/_core/visualizer/parsing_utils.py
new file mode 100644
index 00000000..29d45de1
--- /dev/null
+++ b/src/power_grid_model_ds/_core/visualizer/parsing_utils.py
@@ -0,0 +1,52 @@
+# SPDX-FileCopyrightText: Contributors to the Power Grid Model project
+#
+# SPDX-License-Identifier: MPL-2.0
+
+from typing import Any
+
+from power_grid_model import ComponentType
+
+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._core.visualizer.typing import VizToComponentData
+
+
+def array_to_dict(array_record: FancyArray, columns: list[str]) -> dict[str, Any]:
+ """Stringify the record (required by Dash)."""
+ return {
+ ("pgm_id" if column == "id" else column): value for column, value in zip(columns, array_record.tolist().pop())
+ }
+
+
+def append_component_list(
+ viz_to_comp: VizToComponentData, to_append: dict[str, Any], id_str: str, component_type: ComponentType
+) -> None:
+ """Append a component to the VizToComponentData structure."""
+ if id_str not in viz_to_comp:
+ viz_to_comp[id_str] = {}
+ if component_type not in viz_to_comp[id_str]:
+ viz_to_comp[id_str][component_type] = []
+ viz_to_comp[id_str][component_type].append(to_append)
+
+
+def merge_viz_to_comp(viz_to_comp: VizToComponentData, to_merge: VizToComponentData) -> VizToComponentData:
+ """Merge two nested dictionaries of VizToComponentData type."""
+ for id_str, component_data in to_merge.items():
+ if id_str not in viz_to_comp:
+ viz_to_comp[id_str] = component_data
+ continue
+ for comp_type in component_data:
+ if comp_type not in viz_to_comp[id_str]:
+ viz_to_comp[id_str][comp_type] = component_data[comp_type]
+ continue
+ viz_to_comp[id_str][comp_type].extend(component_data[comp_type])
+ return viz_to_comp
+
+
+def map_appliance_to_nodes(grid: Grid) -> dict[str, str]:
+ """Map appliance IDs to their associated node IDs."""
+ appliance_to_node: dict[str, str] = {}
+ for appliance_name in [ComponentType.sym_load, ComponentType.sym_gen, ComponentType.source]:
+ appliance_array = getattr(grid, appliance_name)
+ appliance_to_node.update(dict(zip(map(str, appliance_array.id), map(str, appliance_array.node))))
+ return appliance_to_node
diff --git a/src/power_grid_model_ds/_core/visualizer/styling_classification.py b/src/power_grid_model_ds/_core/visualizer/styling_classification.py
new file mode 100644
index 00000000..aaaae549
--- /dev/null
+++ b/src/power_grid_model_ds/_core/visualizer/styling_classification.py
@@ -0,0 +1,91 @@
+from enum import StrEnum
+from typing import Literal
+
+from power_grid_model import ComponentType
+
+from power_grid_model_ds._core.model.arrays.pgm_arrays import BranchArray, NodeArray
+
+
+class StyleClass(StrEnum):
+ """Styling classes used in the visualizer."""
+
+ NODE = "node"
+ SUBSTATION_NODE = "substation_node"
+ LARGE_ID_NODE = "large_id_node"
+ BRANCH = "branch"
+ OPEN_BRANCH = "open_branch"
+ OPEN_BRANCH_FROM = "open_branch_from"
+ OPEN_BRANCH_TO = "open_branch_to"
+ LINE = "line"
+ TRANSFORMER = "transformer"
+ LINK = "link"
+ GENERIC_BRANCH = "generic_branch"
+ ASYM_LINE = "asym_line"
+ SOURCE = "source"
+ GENERATING_APPLIANCE = "generating_appliance"
+ LOADING_APPLIANCE = "loading_appliance"
+ OPEN_GENERATING_APPLIANCE = "open_generating_appliance"
+ OPEN_LOADING_APPLIANCE = "open_loading_appliance"
+ APPLIANCE_GHOST_NODE = "appliance_ghost_node"
+
+
+def get_node_classification(node_arr: NodeArray) -> str:
+ """Get the space separated string of styling classes for a node."""
+ classes = [StyleClass.NODE]
+ if node_arr.id > 10000000:
+ classes.append(StyleClass.LARGE_ID_NODE)
+ if node_arr.node_type == 1:
+ classes.append(StyleClass.SUBSTATION_NODE)
+ return " ".join((entry.value for entry in classes))
+
+
+def get_branch_classification(
+ branch_arr: BranchArray,
+ component_type: Literal[
+ ComponentType.transformer,
+ ComponentType.three_winding_transformer,
+ ComponentType.link,
+ ComponentType.generic_branch,
+ ComponentType.line,
+ ComponentType.asym_line,
+ ],
+) -> str:
+ """Get the space separated string of styling classes for a branch."""
+ classes = [StyleClass.BRANCH]
+
+ type_to_vizclass = {
+ ComponentType.transformer: StyleClass.TRANSFORMER,
+ ComponentType.three_winding_transformer: StyleClass.TRANSFORMER,
+ ComponentType.link: StyleClass.LINK,
+ ComponentType.generic_branch: StyleClass.GENERIC_BRANCH,
+ ComponentType.line: StyleClass.LINE,
+ ComponentType.asym_line: StyleClass.ASYM_LINE,
+ }
+ classes.append(type_to_vizclass[component_type])
+
+ if branch_arr.from_status == 0:
+ classes.extend([StyleClass.OPEN_BRANCH, StyleClass.OPEN_BRANCH_FROM])
+ if branch_arr.to_status == 0:
+ classes.extend([StyleClass.OPEN_BRANCH, StyleClass.OPEN_BRANCH_TO])
+
+ return " ".join((entry.value for entry in classes))
+
+
+def get_appliance_edge_classification(
+ appliance_arr, component_type: Literal[ComponentType.sym_load, ComponentType.sym_gen, ComponentType.source]
+) -> str:
+ """Get the space separated string of styling classes for an appliance edge."""
+ type_to_vizclass = {
+ ComponentType.sym_load: StyleClass.LOADING_APPLIANCE,
+ ComponentType.sym_gen: StyleClass.GENERATING_APPLIANCE,
+ ComponentType.source: StyleClass.GENERATING_APPLIANCE,
+ }
+
+ classes = [type_to_vizclass[component_type]]
+ if appliance_arr.status == 0:
+ if type_to_vizclass[component_type] == StyleClass.LOADING_APPLIANCE:
+ classes.append(StyleClass.OPEN_LOADING_APPLIANCE)
+ else:
+ classes.append(StyleClass.OPEN_GENERATING_APPLIANCE)
+
+ return " ".join((entry.value for entry in classes))
diff --git a/src/power_grid_model_ds/_core/visualizer/typing.py b/src/power_grid_model_ds/_core/visualizer/typing.py
index 083f7080..2447fcf1 100644
--- a/src/power_grid_model_ds/_core/visualizer/typing.py
+++ b/src/power_grid_model_ds/_core/visualizer/typing.py
@@ -4,4 +4,42 @@
from typing import Any
+from power_grid_model import ComponentType
+
STYLESHEET = list[dict[str, Any]]
+ListArrayData = list[dict[str, Any]]
+
+VizToComponentElements = dict[str, Any | dict[str, Any]]
+
+"""
+Mapping from visualization element ID to component type to list of array data.
+Purpose is to link unvisualized elements data to visualized elements id they are connected to.
+
+For example:
+ {
+ "node_id_1": {
+ "node": [ {"id": 0, "u_rated": 100}, {...} ],
+ "sym_voltage_sensor": [ {..sensor data..}, {...}],
+ ...
+ },
+ "edge_id_1": {
+ "ComponentType.line": [ {..line data..}, {...} ],
+ "ComponentType.sym_power_sensor": [ {..sensor data..}, {...}],
+ ...
+ },
+ "branch3_id_0": {
+ "ComponentType.three_winding_transformer": [ {..three_winding_transformer data..}, {...} ],
+ ...
+ },
+ "branch3_id_1": {
+ "ComponentType.three_winding_transformer": [ {..same three_winding_transformer data..}, {...} ],
+ ...
+ },
+ {
+ "source_id_str": {
+ "ComponentType.sym_power_sensor": [ {..sensor data..}, {...}],
+ }
+ ...
+ }
+"""
+VizToComponentData = dict[str, dict[ComponentType, ListArrayData]]