diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b6d95..23708df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Added `Transformers2WResult` dictionary support [#217](https://github.com/ie3-institute/pypsdm/pull/217) - `PrimaryData` time series are now protected, introduced `add_time_series` method [#218](https://github.com/ie3-institute/pypsdm/pull/218) - Added method to create nodes and lines [#223](https://github.com/ie3-institute/pypsdm/pull/223) +- Added graph traversal functionality [#224](https://github.com/ie3-institute/pypsdm/pull/224) ### Changed diff --git a/pypsdm/graph.py b/pypsdm/graph.py new file mode 100644 index 0000000..2468eeb --- /dev/null +++ b/pypsdm/graph.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import networkx as nx +from networkx import Graph + +if TYPE_CHECKING: + from pypsdm import RawGridContainer + + +def find_branches(G: Graph, start_node): + visited = set() + visited.add(start_node) + branches = [] + + def dfs(node, path): + visited.add(node) + path.append(node) + + for neighbor in G.neighbors(node): + if neighbor not in visited: + dfs(neighbor, path) + + for neighbor in G.neighbors(start_node): + if neighbor not in visited: + path = [start_node] + dfs(neighbor, path) + branches.append(path) + + return branches + + +def find_n_hop_closest_in_slack_direction( + uuid: str, n: int, raw_grid: RawGridContainer, candidates: set[str] | None = None +): + slack_ds = raw_grid.find_slack_downstream() + G = raw_grid.build_networkx_graph() + if candidates is None: + candidates = set(raw_grid.nodes.uuid) + # find djikstra shortest path between uuid and slck_ds + path = nx.shortest_path(G, uuid, slack_ds) + closest = find_n_hop_closest_candidates(n, G, uuid, candidates) + closest = [n for n in closest if n in path] + return closest + + +def find_n_hop_closest_candidates(n: int, G, uuid, candidates): + """ + Find all nodes within candidates that are n candidate hops away from the given node. + """ + closest = set([uuid]) + for _ in range(n): + for node in closest: + closest = closest.union(find_closest_candidates(G, node, candidates)) + return closest + + +def find_closest_candidates(G: nx.Graph, node: str, candidates: set[str]) -> list[str]: + """ + Starting from the node, find the closest nodes in all directions, that are in the + candidates set. + + Args: + G: The graph to search in. + node: The node for which to find the closest candidates. + candidates: The set of nodes to search for. + """ + # NOTE: This might be added to pypsdm + res = [] + visited = set() + + def preorder_dfs(cur: str): + if cur in visited: + return + visited.add(cur) + if cur in candidates and cur != node: + res.append(cur) + return + for n in G.neighbors(cur): + preorder_dfs(n) + + preorder_dfs(node) + return res diff --git a/pypsdm/models/gwr.py b/pypsdm/models/gwr.py index 82ad293..3c31d5d 100644 --- a/pypsdm/models/gwr.py +++ b/pypsdm/models/gwr.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import datetime +from pathlib import Path from typing import Optional, Tuple, Union import numpy as np @@ -274,8 +275,8 @@ def to_csv( @classmethod def from_csv( cls, - grid_path: str, - result_path: str, + grid_path: str | Path, + result_path: str | Path, grid_delimiter: str | None = None, result_delimiter: str | None = None, primary_data_delimiter: Optional[str] = None, diff --git a/pypsdm/models/input/container/grid.py b/pypsdm/models/input/container/grid.py index d2d1834..1ec0e62 100644 --- a/pypsdm/models/input/container/grid.py +++ b/pypsdm/models/input/container/grid.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING, Dict, Optional, Union from pypsdm.models.enums import ( @@ -171,7 +172,7 @@ def plot_grid( def to_csv( self, - path: str, + path: str | Path, include_primary_data: bool = True, mkdirs: bool = False, delimiter: str = ",", @@ -185,7 +186,7 @@ def to_csv( @classmethod def from_csv( cls, - path: str, + path: str | Path, delimiter: str | None = None, primary_data_delimiter: Optional[str] = None, ): diff --git a/pypsdm/models/input/container/mixins.py b/pypsdm/models/input/container/mixins.py index c567341..3453363 100644 --- a/pypsdm/models/input/container/mixins.py +++ b/pypsdm/models/input/container/mixins.py @@ -7,6 +7,7 @@ from dataclasses import replace from datetime import datetime from functools import partial +from pathlib import Path from typing import TYPE_CHECKING, Self, Tuple from loguru import logger @@ -112,7 +113,7 @@ def entity_keys(cls): @classmethod def entities_from_csv( cls, - simulation_data_path: str, + simulation_data_path: str | Path, simulation_end: datetime | None = None, grid_container: GridContainer | None = None, delimiter: str | None = None, diff --git a/pypsdm/models/input/container/participants.py b/pypsdm/models/input/container/participants.py index 12b8e0a..cf6ecaf 100644 --- a/pypsdm/models/input/container/participants.py +++ b/pypsdm/models/input/container/participants.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from pathlib import Path from typing import Union import pandas as pd @@ -151,7 +152,7 @@ def subset(self, uuids): @staticmethod def from_csv( - path: str, delimiter: str | None = None + path: str | Path, delimiter: str | None = None ) -> "SystemParticipantsContainer": loads = Loads.from_csv(path, delimiter, must_exist=False) fixed_feed_ins = FixedFeedIns.from_csv(path, delimiter, must_exist=False) diff --git a/pypsdm/models/input/container/raw_grid.py b/pypsdm/models/input/container/raw_grid.py index 09768cd..34e32d8 100644 --- a/pypsdm/models/input/container/raw_grid.py +++ b/pypsdm/models/input/container/raw_grid.py @@ -1,8 +1,10 @@ from dataclasses import dataclass +from pathlib import Path from typing import Union from networkx import Graph +from pypsdm.graph import find_branches from pypsdm.models.enums import RawGridElementsEnum from pypsdm.models.input.connector.lines import Lines from pypsdm.models.input.connector.switches import Switches @@ -61,7 +63,7 @@ def get_branches(self, as_graphs=False) -> Union[list[list[str]], list[Graph]]: raise ValueError("Did not find a slack node!") slack_connected_node = slack_connected_node.pop() graph = self.build_networkx_graph() - branches = self._find_branches(graph, slack_connected_node) + branches = find_branches(graph, slack_connected_node) branches = [[slack_connected_node] + branch for branch in branches] if as_graphs: return [graph.subgraph(branch).copy() for branch in branches] @@ -110,30 +112,40 @@ def admittance_matrix(self, uuid_to_idx: dict): transformers_admittance = self.transformers_2_w.admittance_matrix(uuid_to_idx) return lines_admittance + transformers_admittance - @staticmethod - def _find_branches(G: Graph, start_node): - visited = set() - visited.add(start_node) - branches = [] - - def dfs(node, path): - visited.add(node) - path.append(node) - - for neighbor in G.neighbors(node): - if neighbor not in visited: - dfs(neighbor, path) - - for neighbor in G.neighbors(start_node): - if neighbor not in visited: - path = [] - dfs(neighbor, path) - branches.append(path) + def find_slack_downstream(self) -> str: + """ + Find the downstream node of the slack node, which is the node on the transformer's + lower voltage side. + """ + from pypsdm import Transformers2W - return branches + slack_node = self.nodes.get_slack_nodes() + if len(slack_node.data) != 1: + raise ValueError("Currently only implemented for singular slack nodes.") + transformers = self.transformers_2_w + slack_transformers = Transformers2W( + transformers.data[ + (transformers.node_a.isin(slack_node.uuid.to_list())) + | (transformers.node_b.isin(slack_node.uuid.to_list())) + ] + ) + slack_connected_node = ( + set(slack_transformers.node_a) + .union(slack_transformers.node_b) + .difference(slack_node.uuid) + ) + if len(slack_connected_node) > 1: + raise ValueError( + "There are multiple nodes connected to the slack node via a transformer." + ) + elif len(slack_connected_node) == 0: + raise ValueError("Did not find a slack node!") + return slack_connected_node.pop() @classmethod - def from_csv(cls, path: str, delimiter: str | None = None) -> "RawGridContainer": + def from_csv( + cls, path: str | Path, delimiter: str | None = None + ) -> "RawGridContainer": nodes = Nodes.from_csv(path, delimiter) lines = Lines.from_csv(path, delimiter, must_exist=False) transformers_2_w = Transformers2W.from_csv(path, delimiter, must_exist=False) diff --git a/pypsdm/models/input/create/grid_elements.py b/pypsdm/models/input/create/grid_elements.py index 2a6197f..6bf3072 100644 --- a/pypsdm/models/input/create/grid_elements.py +++ b/pypsdm/models/input/create/grid_elements.py @@ -1,8 +1,10 @@ -from pypsdm.models.input import Nodes, Lines -from pypsdm.models.input.create.utils import create_data from uuid import uuid4 + import pandas as pd +from pypsdm.models.input import Lines, Nodes +from pypsdm.models.input.create.utils import create_data + def create_nodes(data_dict): return Nodes(create_data(data_dict, create_nodes_data)) diff --git a/pypsdm/models/input/entity.py b/pypsdm/models/input/entity.py index 753c235..357f0de 100644 --- a/pypsdm/models/input/entity.py +++ b/pypsdm/models/input/entity.py @@ -344,7 +344,7 @@ def copy( @classmethod def from_csv( cls: Type[Self], - path: str, + path: str | Path, delimiter: str | None = None, must_exist: bool = True, ) -> Self: @@ -364,7 +364,7 @@ def from_csv( @classmethod def _from_csv( cls: Type[Self], - path: str, + path: str | Path, entity: EntitiesEnum, delimiter: str | None = None, must_exist: bool = True, diff --git a/pypsdm/models/input/utils.py b/pypsdm/models/input/utils.py deleted file mode 100644 index 0260ed3..0000000 --- a/pypsdm/models/input/utils.py +++ /dev/null @@ -1,30 +0,0 @@ -from pypsdm import RawGridContainer, Transformers2W - - -def find_slack_downstream(rg: RawGridContainer) -> str: - """ - Find the downstream node of the slack node, which is the node on the transformer's - lower voltage side. - """ - slack_node = rg.nodes.get_slack_nodes() - if len(slack_node.data) != 1: - raise ValueError("Currently only implemented for singular slack nodes.") - transformers = rg.transformers_2_w - slack_transformers = Transformers2W( - transformers.data[ - (transformers.node_a.isin(slack_node.uuid.to_list())) - | (transformers.node_b.isin(slack_node.uuid.to_list())) - ] - ) - slack_connected_node = ( - set(slack_transformers.node_a) - .union(slack_transformers.node_b) - .difference(slack_node.uuid) - ) - if len(slack_connected_node) > 1: - raise ValueError( - "There are multiple nodes connected to the slack node via a transformer." - ) - elif len(slack_connected_node) == 0: - raise ValueError("Did not find a slack node!") - return slack_connected_node.pop() diff --git a/pypsdm/models/result/container/grid.py b/pypsdm/models/result/container/grid.py index 150cfd8..858baaa 100644 --- a/pypsdm/models/result/container/grid.py +++ b/pypsdm/models/result/container/grid.py @@ -3,6 +3,7 @@ import os from dataclasses import dataclass from datetime import datetime +from pathlib import Path from typing import TYPE_CHECKING, Optional, Union from pypsdm.io.utils import check_filter @@ -133,7 +134,7 @@ def concat(self, other: "GridResultContainer", deep: bool = True, keep="last"): @classmethod def from_csv( cls, - simulation_data_path: str, + simulation_data_path: str | Path, delimiter: str | None = None, simulation_end: Optional[datetime] = None, grid_container: Optional[GridContainer] = None, diff --git a/pypsdm/models/result/container/participants.py b/pypsdm/models/result/container/participants.py index c75e4d6..706553d 100644 --- a/pypsdm/models/result/container/participants.py +++ b/pypsdm/models/result/container/participants.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import datetime +from pathlib import Path from typing import Iterable, Optional, Self, Tuple, Union import pandas as pd @@ -204,7 +205,7 @@ def to_csv(self, path: str, delimiter: str = ",", mkdirs=False): @classmethod def from_csv( cls, - simulation_data_path: str, + simulation_data_path: str | Path, simulation_end: Optional[datetime] = None, grid_container: Optional[GridContainer] = None, delimiter: Optional[str] = None, diff --git a/pypsdm/models/result/container/raw_grid.py b/pypsdm/models/result/container/raw_grid.py index 57ce3f1..574c8be 100644 --- a/pypsdm/models/result/container/raw_grid.py +++ b/pypsdm/models/result/container/raw_grid.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import datetime +from pathlib import Path from typing import Optional, Self, Union from pypsdm.models.enums import RawGridElementsEnum @@ -112,7 +113,7 @@ def entity_keys(cls): @classmethod def from_csv( cls, - simulation_data_path: str, + simulation_data_path: str | Path, simulation_end: Optional[datetime] = None, grid_container: Optional[GridContainer] = None, delimiter: Optional[str] = None, diff --git a/pypsdm/processing/series.py b/pypsdm/processing/series.py index ce628c2..9d665d1 100644 --- a/pypsdm/processing/series.py +++ b/pypsdm/processing/series.py @@ -9,19 +9,19 @@ def duration_weighted_series(series: Series): series.sort_index(inplace=True) values = series[:-1].reset_index(drop=True) - time = ( + duration = ( (series.index[1::] - series.index[:-1]) .to_series() .apply(lambda x: x.total_seconds() / 3600) .reset_index(drop=True) ) - return pd.concat([values.rename("values"), time.rename("time")], axis=1) + return pd.concat([values.rename("values"), duration.rename("duration")], axis=1) def weighted_series_sum(weighted_series: DataFrame) -> float: if len(weighted_series) == 0: return 0.0 - return (weighted_series["values"] * weighted_series["time"]).sum() + return (weighted_series["values"] * weighted_series["duration"]).sum() def duration_weighted_sum(series: Series) -> float: