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.
@@ -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 load_grid_from_path(self, path: Path):
39
- """Load assets from text file & sort them by id so that
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
- txt_nodes, txt_branches = self.read_txt(path)
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(path: Path) -> tuple[set, dict]:
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 txt_rows:
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
- substation_node_id = self.get_nearest_substation_node(node_id).id.item()
353
+ substation_nodes = self.node.filter(node_type=NodeType.SUBSTATION_NODE.value)
353
354
 
354
- if node_id == substation_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
- path_to_substation, _ = self.graphs.active_graph.get_shortest_path(node_id, substation_node_id)
358
- upstream_node = path_to_substation[1]
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
- text_source = TextSource(grid_class=cls)
420
- return text_source.load_grid_from_path(txt_file_path)
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.0.13
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=5IzmRGyacpoFNI09GEDGV383pBhtMIQfklTuRw1Utpc,12792
51
- power_grid_model_ds/_core/model/graphs/models/rustworkx.py,sha256=jD0Bn2tXYdnTKEUPc-eh-sie6o7ME5ukf62VwNSeUUc,4589
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=Ywt2c1YzBv88w778pr33MVJeCYQX51TjwCgL70M3MPw,4306
54
- power_grid_model_ds/_core/model/grids/base.py,sha256=FnUR0112xHvmKwY9u3_yTMARlO8btXGPj9dmNUZMJUM,16074
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.0.13.dist-info/LICENSE,sha256=GpbnG1pNl-ORtSP6dmHeiZvwy36UFli6MEZy1XlmBqo,14906
61
- power_grid_model_ds-1.0.13.dist-info/METADATA,sha256=G_AdWIdAk5jp2QL4pfinDGReAda1MeU-Y2gYosL8HeY,4270
62
- power_grid_model_ds-1.0.13.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
63
- power_grid_model_ds-1.0.13.dist-info/top_level.txt,sha256=nJa103Eqvm5TESYEKPFVImfLg_ugGOBznikrM-rZQZg,20
64
- power_grid_model_ds-1.0.13.dist-info/RECORD,,
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,,