diff --git a/src/power_grid_model_ds/_core/visualizer/app.py b/src/power_grid_model_ds/_core/visualizer/app.py index 1483602..1d98059 100644 --- a/src/power_grid_model_ds/_core/visualizer/app.py +++ b/src/power_grid_model_ds/_core/visualizer/app.py @@ -48,11 +48,6 @@ def get_app_layout(grid: Grid) -> html.Div: initial_elements = filter_out_appliances(viz_elements_dict.values()) - # Remove associated_ids from initial element data. - # They will be added and used after migrating to using grid object for callbacks - for element in initial_elements: - element["data"].pop("associated_ids", None) - cytoscape_html = get_cytoscape_html(layout, initial_elements, grid.source.size != 0) return html.Div( 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 01c2f2e..3152fcb 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 @@ -4,14 +4,14 @@ from typing import Any +from venv import logger -from dash import Input, Output, callback, dash_table +from dash import Input, Output, callback, dash_table, html from power_grid_model_ds._core.model.grids.base import Grid from power_grid_model_ds._core.visualizer.layout.selection_output import ( SELECTION_OUTPUT_HTML, ) -from power_grid_model_ds._core.visualizer.parsing_utils import viz_id_to_pgm_id from power_grid_model_ds._core.visualizer.server_state import get_grid from power_grid_model_ds.arrays import IdArray @@ -23,33 +23,51 @@ ) def display_selected_element(node_data: list[dict[str, Any]], edge_data: list[dict[str, Any]]): """Display the tapped edge data.""" - # 0th element means data for only a single selection is shown - if node_data: - selected_data = node_data.pop() - elif edge_data: - selected_data = edge_data.pop() - else: + if not node_data and not edge_data: return SELECTION_OUTPUT_HTML.children - group = selected_data["group"] - pgm_id = viz_id_to_pgm_id(selected_data["id"]) + # Extract the associated pgm_ids for all selected nodes and edges, grouped by component type + items_to_visualize: dict[str, list[int]] = {} + for node_edge_data in [node_data, edge_data]: + if node_edge_data is None: + continue + for elm in node_edge_data: + for group, pgm_ids in elm["associated_ids"].items(): + if group not in items_to_visualize: + items_to_visualize[group] = [] + items_to_visualize[group].extend(pgm_ids) grid: Grid = get_grid() - array_data = getattr(grid, group).get(id=pgm_id) - return _to_data_table(array_data) + tables: list[html.H5 | html.Div] = [] + for comp_type, ids in items_to_visualize.items(): + # Select unique as multiple selected elements can be connected to the same component + # (e.g. appliance and node selected together, branch3 edges selected together) + unique_ids = list(set(ids)) + data = getattr(grid, comp_type).filter(id=unique_ids) + tables.append(html.H5(comp_type, style={"marginTop": "15px", "textAlign": "left"})) + tables.append(_to_data_table(data)) + + return html.Div(children=tables, style={"overflowX": "scroll", "margin": "10px"}).children def _to_data_table(array_data: IdArray): - array_data_dict = {} - for column in array_data.columns: - array_data_dict[column] = array_data[column].item() + list_array_data = [] + for entry in array_data: + record_dict = {} + for col in array_data.columns: + if entry[col].ndim == 1: + record_dict[col] = entry[col].item() + else: + logger.warning(f"Column '{col}' in group '{array_data.name}' is not 1-dimensional. Skipping search.") + + list_array_data.append(record_dict) # ignore[attr-defined] added for https://github.com/plotly/dash/issues/3226 data_table = dash_table.DataTable( # type: ignore[attr-defined] - data=[array_data_dict], - columns=[{"name": key, "id": key} for key in array_data_dict], + data=list_array_data, + columns=[{"name": key, "id": key} for key in list_array_data[0]], editable=False, fill_width=False, ) - return data_table + return html.Div(data_table) 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 2125119..5a6582e 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 @@ -25,6 +25,11 @@ def get_cytoscape_html(layout: LayoutOptions, elements: list[dict[str, Any]], so style=_CYTO_INNER_STYLE, elements=elements, stylesheet=DEFAULT_STYLESHEET, + zoom=1.0, # Default zoom level + minZoom=0.05, + maxZoom=3.0, + boxSelectionEnabled=True, + wheelSensitivity=0.2, # Smooth zooming ), style=_CYTO_OUTER_STYLE, ) diff --git a/src/power_grid_model_ds/_core/visualizer/parsers.py b/src/power_grid_model_ds/_core/visualizer/parsers.py index bf0041d..cfcc493 100644 --- a/src/power_grid_model_ds/_core/visualizer/parsers.py +++ b/src/power_grid_model_ds/_core/visualizer/parsers.py @@ -9,6 +9,7 @@ from power_grid_model_ds._core.model.grids.base import Grid from power_grid_model_ds._core.visualizer.parsing_utils import ( append_component_list_parsed_elements, + map_appliance_to_nodes, ) from power_grid_model_ds._core.visualizer.styling_classification import ( StyleClass, @@ -19,13 +20,23 @@ from power_grid_model_ds._core.visualizer.typing import ( ComponentTypeAppliance, ComponentTypeBranch, + ComponentTypeFlowSensor, VizToComponentElements, ) from power_grid_model_ds.arrays import ( ApplianceArray, + AsymCurrentSensorArray, + AsymPowerSensorArray, + AsymVoltageSensorArray, Branch3Array, BranchArray, + FaultArray, NodeArray, + SymCurrentSensorArray, + SymPowerSensorArray, + SymVoltageSensorArray, + TransformerTapRegulatorArray, + VoltageRegulatorArray, ) _NODE_BRANCH_TERMINAL_TYPE = [ @@ -78,6 +89,20 @@ def parse_element_data(grid: Grid) -> VizToComponentElements: _parse_appliances(elements, grid.asym_gen, ComponentType.asym_gen) _parse_appliances(elements, grid.shunt, ComponentType.shunt) + # Parse sensors + appliance_to_node = map_appliance_to_nodes(grid) + _parse_flow_sensors(elements, grid.sym_power_sensor, ComponentType.sym_power_sensor, appliance_to_node) + _parse_flow_sensors(elements, grid.asym_power_sensor, ComponentType.asym_power_sensor, appliance_to_node) + _parse_flow_sensors(elements, grid.sym_current_sensor, ComponentType.sym_current_sensor, appliance_to_node) + _parse_flow_sensors(elements, grid.asym_current_sensor, ComponentType.asym_current_sensor, appliance_to_node) + _parse_voltage_sensors(elements, grid.sym_voltage_sensor, ComponentType.sym_voltage_sensor) + _parse_voltage_sensors(elements, grid.asym_voltage_sensor, ComponentType.asym_voltage_sensor) + _parse_voltage_regulators(elements, grid.voltage_regulator) + _parse_faults(elements, grid.fault) + + # Parse regulators + _parse_transformer_tap_regulators(elements, grid.transformer_tap_regulator) + return elements @@ -174,7 +199,6 @@ def _parse_appliances(elements: VizToComponentElements, array: ApplianceArray, g "group": group.value, "associated_ids": {group.value: [appliance.id.item()]}, }, - "selectable": False, "classes": get_appliance_edge_classification(appliance, group), } @@ -185,3 +209,74 @@ def _parse_appliances(elements: VizToComponentElements, array: ApplianceArray, g } # invert y-axis for visualization append_component_list_parsed_elements(elements, appliance.id.item(), node_id_str, group.value) + + +def _parse_flow_sensors( + elements: VizToComponentElements, + array: SymPowerSensorArray | SymCurrentSensorArray | AsymPowerSensorArray | AsymCurrentSensorArray, + group: ComponentTypeFlowSensor, + appliance_to_node: dict[str, str], +): + """Parse power sensors and return appliance-to-power-sensor mapping.""" + for power_sensor in array: + measured_object_id_str = str(power_sensor.measured_object.item()) + measured_terminal_type = power_sensor.measured_terminal_type.item() + + if measured_terminal_type in _NODE_BRANCH_TERMINAL_TYPE: + mapping_id_strs = [measured_object_id_str] + elif measured_terminal_type in _BRANCH3_TERMINAL_TYPE: + mapping_id_strs = [f"{measured_object_id_str}_{count}" for count in range(3)] + elif measured_terminal_type in _APPLIANCE_TERMINAL_TYPE: + mapping_id_strs = [measured_object_id_str] + # Map appliance to both appliance and its node as both can be unvisualized + if measured_object_id_str in appliance_to_node: + mapping_id_strs.append(appliance_to_node[measured_object_id_str]) + else: + raise ValueError(f"Unknown measured_terminal_type: {measured_terminal_type}") + + for id_str in mapping_id_strs: + append_component_list_parsed_elements(elements, power_sensor.id.item(), id_str, group.value) + + +def _parse_voltage_sensors( + elements: VizToComponentElements, + array: SymVoltageSensorArray | AsymVoltageSensorArray, + group: Literal[ComponentType.sym_voltage_sensor, ComponentType.asym_voltage_sensor], +): + """Parse voltage sensors and associate them with nodes.""" + for voltage_sensor in array: + node_id_str = str(voltage_sensor.measured_object.item()) + append_component_list_parsed_elements(elements, voltage_sensor.id.item(), node_id_str, group.value) + + +def _parse_transformer_tap_regulators(elements: VizToComponentElements, array: TransformerTapRegulatorArray): + """Parse transformer tap regulators and associate them with transformers.""" + for tap_regulator in array: + regulated_object_str = str(tap_regulator.regulated_object.item()) + if regulated_object_str in elements: + mapping_id_strs = [regulated_object_str] + else: + mapping_id_strs = [ + f"{regulated_object_str}_{count}" for count in range(3) if f"{regulated_object_str}_{count}" in elements + ] + + for id_str in mapping_id_strs: + append_component_list_parsed_elements( + elements, tap_regulator.id.item(), id_str, ComponentType.transformer_tap_regulator.value + ) + + +def _parse_voltage_regulators(elements: VizToComponentElements, array: VoltageRegulatorArray): + """Parse voltage regulators and associate them with nodes.""" + for voltage_regulator in array: + regulated_object_str = str(voltage_regulator.regulated_object.item()) + append_component_list_parsed_elements( + elements, voltage_regulator.id.item(), regulated_object_str, ComponentType.voltage_regulator.value + ) + + +def _parse_faults(elements: VizToComponentElements, array: FaultArray): + """Parse faults and associate them with nodes.""" + for fault in array: + fault_object_str = str(fault.fault_object.item()) + append_component_list_parsed_elements(elements, fault.id.item(), fault_object_str, ComponentType.fault.value) diff --git a/tests/unit/visualizer/test_callbacks.py b/tests/unit/visualizer/test_callbacks.py index e56d7e9..99e94a4 100644 --- a/tests/unit/visualizer/test_callbacks.py +++ b/tests/unit/visualizer/test_callbacks.py @@ -79,7 +79,7 @@ def test_element_selection_callback(): server_state.set_grid(grid) - node_data = [{"id": "1", "u_rated": 100.0, "group": "node"}] + node_data = [{"id": "1", "group": "node", "associated_ids": {"node": [1]}}] edge_data = [] result = display_selected_element(node_data, edge_data) @@ -96,8 +96,9 @@ def test_element_selection_callback(): ], editable=False, ) - assert result.data == expected.data - assert result.columns == expected.columns + assert result[0].children == "node" + assert result[1].children.data == expected.data + assert result[1].children.columns == expected.columns def test_display_selected_element_none(): diff --git a/tests/unit/visualizer/test_parsers.py b/tests/unit/visualizer/test_parsers.py index fb8f30c..cc485cb 100644 --- a/tests/unit/visualizer/test_parsers.py +++ b/tests/unit/visualizer/test_parsers.py @@ -5,10 +5,15 @@ import numpy as np import pytest from numpy.typing import NDArray -from power_grid_model import ComponentType +from power_grid_model import ComponentType, MeasuredTerminalType from power_grid_model_ds._core.visualizer.parsers import ( _parse_appliances, + _parse_faults, + _parse_flow_sensors, + _parse_transformer_tap_regulators, + _parse_voltage_regulators, + _parse_voltage_sensors, parse_branch3_array, parse_branch_array, parse_node_array, @@ -21,6 +26,7 @@ LineArray, NodeArray, SymLoadArray, + SymPowerSensorArray, SymVoltageSensorArray, ThreeWindingTransformerArray, TransformerTapRegulatorArray, @@ -192,7 +198,6 @@ def appliance_array() -> tuple[SymLoadArray, VizToComponentElements, VizToCompon ComponentType.sym_load.value: [100], }, }, - "selectable": False, "classes": f"{StyleClass.LOADING_APPLIANCE.value}", }, "100_ghost_node": { @@ -216,7 +221,6 @@ def appliance_array() -> tuple[SymLoadArray, VizToComponentElements, VizToCompon ComponentType.sym_load.value: [101], }, }, - "selectable": False, "classes": f"{StyleClass.LOADING_APPLIANCE.value}", }, "101_ghost_node": { @@ -389,6 +393,10 @@ def test_parsing(func, request, test_data, kwargs): "func, test_data, kwargs", [ (_parse_appliances, "appliance_array", {"group": ComponentType.sym_load}), + (_parse_transformer_tap_regulators, "transformer_tap_regulator_array", {}), + (_parse_voltage_regulators, "voltage_regulator_array", {}), + (_parse_voltage_sensors, "voltage_sensor_array", {"group": ComponentType.sym_voltage_sensor}), + (_parse_faults, "fault_array", {}), ], ) def test_parsing_with_starting_elements(func, request, test_data, kwargs): @@ -399,3 +407,113 @@ def test_parsing_with_starting_elements(func, request, test_data, kwargs): assert len(elements) == len(expected_elements) assert elements == expected_elements + + +def test_parse_flow_sensors() -> None: + power_sensors = SymPowerSensorArray.zeros(3) + power_sensors[:] = 99 + power_sensors["id"] = [1000, 1001, 1002] + power_sensors["measured_object"] = [100, 101, 102] + power_sensors["measured_terminal_type"] = [ + MeasuredTerminalType.branch_from, + MeasuredTerminalType.load, + MeasuredTerminalType.branch3_1, + ] + + appliance_to_node = {"101": "1"} + starting_elements: VizToComponentElements = { + "1": { + "data": { + "id": "1", + "group": "node", + "associated_ids": {ComponentType.node.value: [100]}, + }, + "classes": f"{StyleClass.NODE.value}", + }, + "100": { + "data": { + "id": "100", + "group": "line", + "associated_ids": {ComponentType.line.value: [100]}, + }, + "classes": f"{StyleClass.LINE.value}", + }, + "101": { + "data": { + "id": "101", + "group": "sym_load", + "source": "1", + "target": "101_ghost_node", + "associated_ids": { + ComponentType.sym_load.value: [101], + }, + }, + "classes": f"{StyleClass.LOADING_APPLIANCE.value}", + }, + "102_0": { + "data": { + "id": "102_0", + "group": "three_winding_transformer", + "source": "99", + "target": "99", + "associated_ids": { + ComponentType.three_winding_transformer.value: [102], + }, + }, + "classes": f"{StyleClass.BRANCH.value} {StyleClass.TRANSFORMER.value}", + }, + "102_1": { + "data": { + "id": "102_1", + "group": "three_winding_transformer", + "source": "99", + "target": "99", + "associated_ids": { + ComponentType.three_winding_transformer.value: [102], + }, + }, + "classes": f"{StyleClass.BRANCH.value} {StyleClass.TRANSFORMER.value}", + }, + "102_2": { + "data": { + "id": "102_2", + "group": "three_winding_transformer", + "source": "99", + "target": "99", + "associated_ids": { + ComponentType.three_winding_transformer.value: [102], + }, + }, + "classes": f"{StyleClass.BRANCH.value} {StyleClass.TRANSFORMER.value}", + }, + } + + elements = starting_elements.copy() + _parse_flow_sensors( + elements=elements, + array=power_sensors, + group=ComponentType.sym_power_sensor, + appliance_to_node=appliance_to_node, + ) + + expected_elements = starting_elements.copy() + expected_elements["100"]["data"]["associated_ids"].update({ComponentType.sym_power_sensor.value: [1000]}) + expected_elements["1"]["data"]["associated_ids"].update({ComponentType.sym_power_sensor.value: [1001]}) + expected_elements["102_0"]["data"]["associated_ids"].update({ComponentType.sym_power_sensor.value: [1002]}) + expected_elements["102_1"]["data"]["associated_ids"].update({ComponentType.sym_power_sensor.value: [1002]}) + expected_elements["102_2"]["data"]["associated_ids"].update({ComponentType.sym_power_sensor.value: [1002]}) + + assert len(elements) == len(expected_elements) + assert elements == expected_elements + + +def test_parse_flow_sensors_invalid_measured_terminal_type() -> None: + power_sensors = SymPowerSensorArray.zeros(1) + power_sensors["id"] = [1000] + power_sensors[:] = 99 + power_sensors["measured_terminal_type"] = [123] + + with pytest.raises(ValueError, match="Unknown measured_terminal_type"): + _parse_flow_sensors( + elements={}, array=power_sensors, group=ComponentType.sym_power_sensor, appliance_to_node={} + )