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 :------------------:|:------------:|:-------------: 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]]