power-grid-model-ds 1.0.13__py3-none-any.whl → 1.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- power_grid_model_ds/_core/model/graphs/models/base.py +90 -0
- power_grid_model_ds/_core/model/graphs/models/rustworkx.py +28 -1
- power_grid_model_ds/_core/model/grids/_text_sources.py +9 -11
- power_grid_model_ds/_core/model/grids/base.py +24 -8
- {power_grid_model_ds-1.0.13.dist-info → power_grid_model_ds-1.1.1.dist-info}/METADATA +1 -1
- {power_grid_model_ds-1.0.13.dist-info → power_grid_model_ds-1.1.1.dist-info}/RECORD +9 -9
- {power_grid_model_ds-1.0.13.dist-info → power_grid_model_ds-1.1.1.dist-info}/LICENSE +0 -0
- {power_grid_model_ds-1.0.13.dist-info → power_grid_model_ds-1.1.1.dist-info}/WHEEL +0 -0
- {power_grid_model_ds-1.0.13.dist-info → power_grid_model_ds-1.1.1.dist-info}/top_level.txt +0 -0
@@ -3,6 +3,8 @@
|
|
3
3
|
# SPDX-License-Identifier: MPL-2.0
|
4
4
|
|
5
5
|
from abc import ABC, abstractmethod
|
6
|
+
from contextlib import contextmanager
|
7
|
+
from typing import Generator
|
6
8
|
|
7
9
|
import numpy as np
|
8
10
|
from numpy._typing import NDArray
|
@@ -34,6 +36,14 @@ class BaseGraphModel(ABC):
|
|
34
36
|
def nr_branches(self):
|
35
37
|
"""Returns the number of branches in the graph"""
|
36
38
|
|
39
|
+
@property
|
40
|
+
def all_branches(self) -> Generator[tuple[int, int], None, None]:
|
41
|
+
"""Returns all branches in the graph."""
|
42
|
+
return (
|
43
|
+
(self.internal_to_external(source), self.internal_to_external(target))
|
44
|
+
for source, target in self._all_branches()
|
45
|
+
)
|
46
|
+
|
37
47
|
@abstractmethod
|
38
48
|
def external_to_internal(self, ext_node_id: int) -> int:
|
39
49
|
"""Convert external node id to internal node id (internal)
|
@@ -63,6 +73,14 @@ class BaseGraphModel(ABC):
|
|
63
73
|
|
64
74
|
return self._has_node(node_id=internal_node_id)
|
65
75
|
|
76
|
+
def in_branches(self, node_id: int) -> Generator[tuple[int, int], None, None]:
|
77
|
+
"""Return all branches that have the node as an endpoint."""
|
78
|
+
int_node_id = self.external_to_internal(node_id)
|
79
|
+
internal_edges = self._in_branches(int_node_id=int_node_id)
|
80
|
+
return (
|
81
|
+
(self.internal_to_external(source), self.internal_to_external(target)) for source, target in internal_edges
|
82
|
+
)
|
83
|
+
|
66
84
|
def add_node(self, ext_node_id: int, raise_on_fail: bool = True) -> None:
|
67
85
|
"""Add a node to the graph."""
|
68
86
|
if self.has_node(ext_node_id):
|
@@ -164,6 +182,28 @@ class BaseGraphModel(ABC):
|
|
164
182
|
branches = _get_branch3_branches(branch3)
|
165
183
|
self.delete_branch_array(branches, raise_on_fail=raise_on_fail)
|
166
184
|
|
185
|
+
@contextmanager
|
186
|
+
def tmp_remove_nodes(self, nodes: list[int]) -> Generator:
|
187
|
+
"""Context manager that temporarily removes nodes and their branches from the graph.
|
188
|
+
Example:
|
189
|
+
>>> with graph.tmp_remove_nodes([1, 2, 3]):
|
190
|
+
>>> assert not graph.has_node(1)
|
191
|
+
>>> assert graph.has_node(1)
|
192
|
+
In practice, this is useful when you want to e.g. calculate the shortest path between two nodes without
|
193
|
+
considering certain nodes.
|
194
|
+
"""
|
195
|
+
edge_list = []
|
196
|
+
for node in nodes:
|
197
|
+
edge_list += list(self.in_branches(node))
|
198
|
+
self.delete_node(node)
|
199
|
+
|
200
|
+
yield
|
201
|
+
|
202
|
+
for node in nodes:
|
203
|
+
self.add_node(node)
|
204
|
+
for source, target in edge_list:
|
205
|
+
self.add_branch(source, target)
|
206
|
+
|
167
207
|
def get_shortest_path(self, ext_start_node_id: int, ext_end_node_id: int) -> tuple[list[int], int]:
|
168
208
|
"""Calculate the shortest path between two nodes
|
169
209
|
|
@@ -235,8 +275,49 @@ class BaseGraphModel(ABC):
|
|
235
275
|
nodes_to_ignore=self._externals_to_internals(nodes_to_ignore),
|
236
276
|
inclusive=inclusive,
|
237
277
|
)
|
278
|
+
|
238
279
|
return self._internals_to_externals(nodes)
|
239
280
|
|
281
|
+
def find_first_connected(self, node_id: int, candidate_node_ids: list[int]) -> int:
|
282
|
+
"""Find the first connected node to the node_id from the candidate_node_ids
|
283
|
+
|
284
|
+
Note:
|
285
|
+
If multiple candidate nodes are connected to the node, the first one found is returned.
|
286
|
+
There is no guarantee that the same candidate node will be returned each time.
|
287
|
+
|
288
|
+
Raises:
|
289
|
+
MissingNodeError: if no connected node is found
|
290
|
+
ValueError: if the node_id is in candidate_node_ids
|
291
|
+
"""
|
292
|
+
internal_node_id = self.external_to_internal(node_id)
|
293
|
+
internal_candidates = self._externals_to_internals(candidate_node_ids)
|
294
|
+
if internal_node_id in internal_candidates:
|
295
|
+
raise ValueError("node_id cannot be in candidate_node_ids")
|
296
|
+
return self.internal_to_external(self._find_first_connected(internal_node_id, internal_candidates))
|
297
|
+
|
298
|
+
def get_downstream_nodes(self, node_id: int, start_node_ids: list[int], inclusive: bool = False) -> list[int]:
|
299
|
+
"""Find all nodes downstream of the node_id with respect to the start_node_ids
|
300
|
+
|
301
|
+
Example:
|
302
|
+
given this graph: [1] - [2] - [3] - [4]
|
303
|
+
>>> graph.get_downstream_nodes(2, [1]) == [3, 4]
|
304
|
+
>>> graph.get_downstream_nodes(2, [1], inclusive=True) == [2, 3, 4]
|
305
|
+
|
306
|
+
args:
|
307
|
+
node_id: node id to start the search from
|
308
|
+
start_node_ids: list of node ids considered 'above' the node_id
|
309
|
+
inclusive: whether to include the given node id in the result
|
310
|
+
returns:
|
311
|
+
list of node ids sorted by distance, downstream of to the node id
|
312
|
+
"""
|
313
|
+
connected_node = self.find_first_connected(node_id, start_node_ids)
|
314
|
+
path, _ = self.get_shortest_path(node_id, connected_node)
|
315
|
+
_, upstream_node, *_ = (
|
316
|
+
path # path is at least 2 elements long or find_first_connected would have raised an error
|
317
|
+
)
|
318
|
+
|
319
|
+
return self.get_connected(node_id, [upstream_node], inclusive)
|
320
|
+
|
240
321
|
def find_fundamental_cycles(self) -> list[list[int]]:
|
241
322
|
"""Find all fundamental cycles in the graph.
|
242
323
|
Returns:
|
@@ -270,9 +351,15 @@ class BaseGraphModel(ABC):
|
|
270
351
|
return branch.is_active.item()
|
271
352
|
return True
|
272
353
|
|
354
|
+
@abstractmethod
|
355
|
+
def _in_branches(self, int_node_id: int) -> Generator[tuple[int, int], None, None]: ...
|
356
|
+
|
273
357
|
@abstractmethod
|
274
358
|
def _get_connected(self, node_id: int, nodes_to_ignore: list[int], inclusive: bool = False) -> list[int]: ...
|
275
359
|
|
360
|
+
@abstractmethod
|
361
|
+
def _find_first_connected(self, node_id: int, candidate_node_ids: list[int]) -> int: ...
|
362
|
+
|
276
363
|
@abstractmethod
|
277
364
|
def _has_branch(self, from_node_id, to_node_id) -> bool: ...
|
278
365
|
|
@@ -307,6 +394,9 @@ class BaseGraphModel(ABC):
|
|
307
394
|
@abstractmethod
|
308
395
|
def _find_fundamental_cycles(self) -> list[list[int]]: ...
|
309
396
|
|
397
|
+
@abstractmethod
|
398
|
+
def _all_branches(self) -> Generator[tuple[int, int], None, None]: ...
|
399
|
+
|
310
400
|
|
311
401
|
def _get_branch3_branches(branch3: Branch3Array) -> BranchArray:
|
312
402
|
node_1 = branch3.node_1.item()
|
@@ -3,10 +3,11 @@
|
|
3
3
|
# SPDX-License-Identifier: MPL-2.0
|
4
4
|
|
5
5
|
import logging
|
6
|
+
from typing import Generator
|
6
7
|
|
7
8
|
import rustworkx as rx
|
8
9
|
from rustworkx import NoEdgeBetweenNodes
|
9
|
-
from rustworkx.visit import BFSVisitor, PruneSearch
|
10
|
+
from rustworkx.visit import BFSVisitor, PruneSearch, StopSearch
|
10
11
|
|
11
12
|
from power_grid_model_ds._core.model.graphs.errors import MissingBranchError, MissingNodeError, NoPathBetweenNodes
|
12
13
|
from power_grid_model_ds._core.model.graphs.models._rustworkx_search import find_fundamental_cycles_rustworkx
|
@@ -99,6 +100,16 @@ class RustworkxGraphModel(BaseGraphModel):
|
|
99
100
|
|
100
101
|
return connected_nodes
|
101
102
|
|
103
|
+
def _in_branches(self, int_node_id: int) -> Generator[tuple[int, int], None, None]:
|
104
|
+
return ((source, target) for source, target, _ in self._graph.in_edges(int_node_id))
|
105
|
+
|
106
|
+
def _find_first_connected(self, node_id: int, candidate_node_ids: list[int]) -> int:
|
107
|
+
visitor = _NodeFinder(candidate_nodes=candidate_node_ids)
|
108
|
+
rx.bfs_search(self._graph, [node_id], visitor)
|
109
|
+
if visitor.found_node is None:
|
110
|
+
raise MissingNodeError(f"node {node_id} is not connected to any of the candidate nodes")
|
111
|
+
return visitor.found_node
|
112
|
+
|
102
113
|
def _find_fundamental_cycles(self) -> list[list[int]]:
|
103
114
|
"""Find all fundamental cycles in the graph using Rustworkx.
|
104
115
|
|
@@ -107,6 +118,9 @@ class RustworkxGraphModel(BaseGraphModel):
|
|
107
118
|
"""
|
108
119
|
return find_fundamental_cycles_rustworkx(self._graph)
|
109
120
|
|
121
|
+
def _all_branches(self) -> Generator[tuple[int, int], None, None]:
|
122
|
+
return ((source, target) for source, target in self._graph.edge_list())
|
123
|
+
|
110
124
|
|
111
125
|
class _NodeVisitor(BFSVisitor):
|
112
126
|
def __init__(self, nodes_to_ignore: list[int]):
|
@@ -117,3 +131,16 @@ class _NodeVisitor(BFSVisitor):
|
|
117
131
|
if v in self.nodes_to_ignore:
|
118
132
|
raise PruneSearch
|
119
133
|
self.nodes.append(v)
|
134
|
+
|
135
|
+
|
136
|
+
class _NodeFinder(BFSVisitor):
|
137
|
+
"""Visitor that stops the search when a candidate node is found"""
|
138
|
+
|
139
|
+
def __init__(self, candidate_nodes: list[int]):
|
140
|
+
self.candidate_nodes = candidate_nodes
|
141
|
+
self.found_node: int | None = None
|
142
|
+
|
143
|
+
def discover_vertex(self, v):
|
144
|
+
if v in self.candidate_nodes:
|
145
|
+
self.found_node = v
|
146
|
+
raise StopSearch
|
@@ -5,7 +5,6 @@
|
|
5
5
|
"""Create a grid from text a text file"""
|
6
6
|
|
7
7
|
import logging
|
8
|
-
from pathlib import Path
|
9
8
|
from typing import TYPE_CHECKING
|
10
9
|
|
11
10
|
from power_grid_model_ds._core.model.enums.nodes import NodeType
|
@@ -35,29 +34,28 @@ class TextSource:
|
|
35
34
|
def __init__(self, grid_class: type["Grid"]):
|
36
35
|
self.grid = grid_class.empty()
|
37
36
|
|
38
|
-
def
|
39
|
-
"""Load
|
40
|
-
they are ready to be appended to the grid"""
|
37
|
+
def load_from_txt(self, *args: str) -> "Grid":
|
38
|
+
"""Load a grid from text"""
|
41
39
|
|
42
|
-
|
40
|
+
text_lines = [line for arg in args for line in arg.strip().split("\n")]
|
41
|
+
|
42
|
+
txt_nodes, txt_branches = self.read_txt(text_lines)
|
43
43
|
self.add_nodes(txt_nodes)
|
44
44
|
self.add_branches(txt_branches)
|
45
45
|
self.grid.set_feeder_ids()
|
46
46
|
return self.grid
|
47
47
|
|
48
48
|
@staticmethod
|
49
|
-
def read_txt(
|
49
|
+
def read_txt(txt_lines: list[str]) -> tuple[set, dict]:
|
50
50
|
"""Extract assets from text"""
|
51
|
-
with open(path, "r", encoding="utf-8") as f:
|
52
|
-
txt_rows = f.readlines()
|
53
51
|
|
54
52
|
txt_nodes = set()
|
55
53
|
txt_branches = {}
|
56
|
-
for text_line in
|
54
|
+
for text_line in txt_lines:
|
57
55
|
if not text_line.strip() or text_line.startswith("#"):
|
58
56
|
continue # skip empty lines and comments
|
59
57
|
try:
|
60
|
-
from_node_str, to_node_str, *comments = text_line.strip().split(
|
58
|
+
from_node_str, to_node_str, *comments = text_line.strip().split()
|
61
59
|
except ValueError as err:
|
62
60
|
raise ValueError(f"Text line '{text_line}' is invalid. Skipping...") from err
|
63
61
|
comments = comments[0].split(",") if comments else []
|
@@ -116,4 +114,4 @@ class TextSource:
|
|
116
114
|
new_branch.to_status = 0
|
117
115
|
else:
|
118
116
|
new_branch.to_status = 1
|
119
|
-
self.grid.append(new_branch)
|
117
|
+
self.grid.append(new_branch, check_max_id=False)
|
@@ -331,6 +331,7 @@ class Grid(FancyArrayContainer):
|
|
331
331
|
|
332
332
|
def get_downstream_nodes(self, node_id: int, inclusive: bool = False):
|
333
333
|
"""Get the downstream nodes from a node.
|
334
|
+
Assuming each node has a single feeding substation and the grid is radial
|
334
335
|
|
335
336
|
Example:
|
336
337
|
given this graph: [1] - [2] - [3] - [4], with 1 being a substation node
|
@@ -349,15 +350,14 @@ class Grid(FancyArrayContainer):
|
|
349
350
|
Returns:
|
350
351
|
list[int]: The downstream nodes.
|
351
352
|
"""
|
352
|
-
|
353
|
+
substation_nodes = self.node.filter(node_type=NodeType.SUBSTATION_NODE.value)
|
353
354
|
|
354
|
-
if node_id
|
355
|
+
if node_id in substation_nodes.id:
|
355
356
|
raise NotImplementedError("get_downstream_nodes is not implemented for substation nodes!")
|
356
357
|
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
return self.graphs.active_graph.get_connected(node_id, nodes_to_ignore=[upstream_node], inclusive=inclusive)
|
358
|
+
return self.graphs.active_graph.get_downstream_nodes(
|
359
|
+
node_id=node_id, start_node_ids=list(substation_nodes.id), inclusive=inclusive
|
360
|
+
)
|
361
361
|
|
362
362
|
def cache(self, cache_dir: Path, cache_name: str, compress: bool = True):
|
363
363
|
"""Cache Grid to a folder
|
@@ -408,6 +408,21 @@ class Grid(FancyArrayContainer):
|
|
408
408
|
raise TypeError(f"{pickle_path.name} is not a valid {cls.__name__} cache.")
|
409
409
|
return grid
|
410
410
|
|
411
|
+
@classmethod
|
412
|
+
def from_txt(cls, *args: str):
|
413
|
+
"""Build a grid from a list of strings
|
414
|
+
|
415
|
+
See the documentation for the expected format of the txt_lines
|
416
|
+
|
417
|
+
Args:
|
418
|
+
*args (str): The lines of the grid
|
419
|
+
|
420
|
+
Examples:
|
421
|
+
>>> Grid.from_txt("1 2", "2 3", "3 4 transformer", "4 5", "S1 6")
|
422
|
+
alternative: Grid.from_txt("1 2\n2 3\n3 4 transformer\n4 5\nS1 6")
|
423
|
+
"""
|
424
|
+
return TextSource(grid_class=cls).load_from_txt(*args)
|
425
|
+
|
411
426
|
@classmethod
|
412
427
|
# pylint: disable=arguments-differ
|
413
428
|
def from_txt_file(cls, txt_file_path: Path):
|
@@ -416,8 +431,9 @@ class Grid(FancyArrayContainer):
|
|
416
431
|
Args:
|
417
432
|
txt_file_path (Path): The path to the txt file
|
418
433
|
"""
|
419
|
-
|
420
|
-
|
434
|
+
with open(txt_file_path, "r", encoding="utf-8") as f:
|
435
|
+
txt_lines = f.readlines()
|
436
|
+
return TextSource(grid_class=cls).load_from_txt(*txt_lines)
|
421
437
|
|
422
438
|
def set_feeder_ids(self):
|
423
439
|
"""Sets feeder and substation id properties in the grids arrays"""
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.2
|
2
2
|
Name: power-grid-model-ds
|
3
|
-
Version: 1.
|
3
|
+
Version: 1.1.1
|
4
4
|
Summary: Power Grid Model extension which provides a grid data structure for simulation and analysis
|
5
5
|
Author-email: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
|
6
6
|
License: MPL-2.0
|
@@ -47,18 +47,18 @@ power_grid_model_ds/_core/model/graphs/container.py,sha256=w3nwfR5sM-EqVPmZWSmhI
|
|
47
47
|
power_grid_model_ds/_core/model/graphs/errors.py,sha256=-9fAsCqpKM0-lCM-tO2fVot9hqZBJa8WCxxqWEH_z6k,479
|
48
48
|
power_grid_model_ds/_core/model/graphs/models/__init__.py,sha256=xwhMajQKIAfL1R3cq2sMNXMWtqfI5WGBi6-P4clyTes,262
|
49
49
|
power_grid_model_ds/_core/model/graphs/models/_rustworkx_search.py,sha256=TqRR22TPx8XjRLukQxK8R8FjZvJ60CttDFctbISSbOM,2979
|
50
|
-
power_grid_model_ds/_core/model/graphs/models/base.py,sha256=
|
51
|
-
power_grid_model_ds/_core/model/graphs/models/rustworkx.py,sha256=
|
50
|
+
power_grid_model_ds/_core/model/graphs/models/base.py,sha256=cHwwQFgPW78P5JDyfGE1fXEfwG7cv9-aG05UKPd_vGo,16710
|
51
|
+
power_grid_model_ds/_core/model/graphs/models/rustworkx.py,sha256=a7PrDgge2KzMbeTQhQTE-KRPdKNj5TJvfYkzYNT8FtM,5730
|
52
52
|
power_grid_model_ds/_core/model/grids/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
53
|
-
power_grid_model_ds/_core/model/grids/_text_sources.py,sha256=
|
54
|
-
power_grid_model_ds/_core/model/grids/base.py,sha256=
|
53
|
+
power_grid_model_ds/_core/model/grids/_text_sources.py,sha256=3CWn0zjaUkFhrnSEIqAas52xeW0EX3EWXIXyo8XSQgw,4229
|
54
|
+
power_grid_model_ds/_core/model/grids/base.py,sha256=knOYFl5snBFjccpbDPgWMrgCaFYN6JS74L9O15_snAI,16599
|
55
55
|
power_grid_model_ds/_core/model/grids/helpers.py,sha256=VmIQ5a8oBMWW-VT5HOJk4iRRfEyO_OKc9LqKG0eVpSs,4174
|
56
56
|
power_grid_model_ds/_core/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
57
57
|
power_grid_model_ds/_core/utils/misc.py,sha256=aAdlXjyKN6_T4b_CQZ879TbkbDZW4TwBUsxTpkwvymU,1228
|
58
58
|
power_grid_model_ds/_core/utils/pickle.py,sha256=LGeTc7nu9RY1noOzLJzYaSHSWIgqzHy2xhmueKGuipc,1445
|
59
59
|
power_grid_model_ds/_core/utils/zip.py,sha256=9RtJYhjlgNZOtxr4iI-CpsjT1axw5kCCqprfTjaIsiI,2197
|
60
|
-
power_grid_model_ds-1.
|
61
|
-
power_grid_model_ds-1.
|
62
|
-
power_grid_model_ds-1.
|
63
|
-
power_grid_model_ds-1.
|
64
|
-
power_grid_model_ds-1.
|
60
|
+
power_grid_model_ds-1.1.1.dist-info/LICENSE,sha256=GpbnG1pNl-ORtSP6dmHeiZvwy36UFli6MEZy1XlmBqo,14906
|
61
|
+
power_grid_model_ds-1.1.1.dist-info/METADATA,sha256=cby_jnH_ZVpRHkUu56M1NdbHpF7i5GyE2DQK0hj2p1k,4269
|
62
|
+
power_grid_model_ds-1.1.1.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
63
|
+
power_grid_model_ds-1.1.1.dist-info/top_level.txt,sha256=nJa103Eqvm5TESYEKPFVImfLg_ugGOBznikrM-rZQZg,20
|
64
|
+
power_grid_model_ds-1.1.1.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|