Skip to content

Commit 2eb0c72

Browse files
authored
Merge branch 'main' into feature/add-citations
Signed-off-by: Martijn Govers <martygovers@hotmail.com>
2 parents cc0801a + 832b59c commit 2eb0c72

File tree

14 files changed

+580
-344
lines changed

14 files changed

+580
-344
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,6 @@ PYPI_VERSION
5555
build/
5656
wheelhouse/
5757
_build/
58+
59+
# Jupyter Notebook
60+
.ipynb_checkpoints

CITATION.cff

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,6 @@ references:
7979
email: "Jerry.Jinfeng.Guo@hotmail.com"
8080
affiliation: "Alliander"
8181
orcid: "https://orcid.org/0000-0002-8065-4084"
82-
- family-names: "Figueroa Manrique"
83-
given-names: "Santiago"
84-
email: "Santiago.Figueroa.Manrique@Alliander.com"
85-
affiliation: "Alliander"
8682
- family-names: "Jagutis"
8783
given-names: "Laurynas"
8884
email: "Laurynas.Jagutis@Alliander.com"

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.0
1+
1.1

docs/examples/utils/grid_from_txt_examples.ipynb

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,22 @@
2828
"- A _transformer_ is defined as `<from_node> <to_node> transformer`\n",
2929
" - e.g.: `8 9 transformer`\n",
3030
"- A _grid opening_ is defined by adding `open`\n",
31-
" - e.g.: `4 5 open` for _lines_ or `6 7 transformer,open` for _transformers_\n",
32-
"\n",
31+
" - e.g.: `4 5 open` for _lines_ or `6 7 transformer,open` for _transformers_\n"
32+
]
33+
},
34+
{
35+
"cell_type": "markdown",
36+
"metadata": {},
37+
"source": [
3338
"### Loading a drawn grid into pgm-ds\n",
3439
"\n",
35-
"Once you've created a grid, copy the _Graph Data_ of your grid to a text file (e.g. `my_grid.txt`).\n",
40+
"There are two ways of loading a text grid into a pgm-ds:\n",
41+
"- load grid from a .txt file\n",
42+
"- load grid from a list of strings\n",
3643
"\n",
37-
"For example, your file could contain the following data:\n",
44+
"#### Load a grid from a .txt file\n",
45+
"Copy the _Graph Data_ of your grid to a text file (e.g. `my_grid.txt`).\n",
46+
"For the example above, the file should contain the following data:\n",
3847
"\n",
3948
"```text\n",
4049
"S1 2\n",
@@ -52,7 +61,7 @@
5261
},
5362
{
5463
"cell_type": "code",
55-
"execution_count": 1,
64+
"execution_count": 9,
5665
"metadata": {},
5766
"outputs": [],
5867
"source": [
@@ -68,12 +77,36 @@
6877
"cell_type": "markdown",
6978
"metadata": {},
7079
"source": [
71-
"**You should now have a grid loaded from your drawn graph data!**\n"
80+
"\n",
81+
"#### Load grid from a list of strings\n",
82+
"You can also load a grid directly from a list of strings"
83+
]
84+
},
85+
{
86+
"cell_type": "code",
87+
"execution_count": 7,
88+
"metadata": {},
89+
"outputs": [],
90+
"source": [
91+
"from power_grid_model_ds import Grid\n",
92+
"\n",
93+
"grid = Grid.from_txt(\n",
94+
" [\n",
95+
" \"S1 2\",\n",
96+
" \"S1 3 open\",\n",
97+
" \"2 7\",\n",
98+
" \"3 5\",\n",
99+
" \"3 6 transformer\",\n",
100+
" \"5 7\",\n",
101+
" \"7 8\",\n",
102+
" \"8 9\",\n",
103+
" ]\n",
104+
")"
72105
]
73106
},
74107
{
75108
"cell_type": "code",
76-
"execution_count": 2,
109+
"execution_count": 10,
77110
"metadata": {},
78111
"outputs": [
79112
{
@@ -99,7 +132,7 @@
99132
],
100133
"metadata": {
101134
"kernelspec": {
102-
"display_name": ".venv",
135+
"display_name": "Python 3 (ipykernel)",
103136
"language": "python",
104137
"name": "python3"
105138
},
@@ -113,9 +146,9 @@
113146
"name": "python",
114147
"nbconvert_exporter": "python",
115148
"pygments_lexer": "ipython3",
116-
"version": "3.12.3"
149+
"version": "3.13.1"
117150
}
118151
},
119152
"nbformat": 4,
120-
"nbformat_minor": 2
153+
"nbformat_minor": 4
121154
}

src/power_grid_model_ds/_core/model/graphs/container.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
import dataclasses
88
from dataclasses import dataclass
9-
from pathlib import PosixPath
109
from typing import Generator
1110

1211
import numpy as np
@@ -119,15 +118,6 @@ def make_inactive(self, branch: BranchArray) -> None:
119118
graph.delete_branch(from_ext_node_id=from_node, to_ext_node_id=to_node)
120119
setattr(self, field.name, graph)
121120

122-
def cache(self, cache_dir: PosixPath, compress: bool) -> PosixPath:
123-
"""Cache the container into a folder with .pkl and graph files"""
124-
cache_dir.mkdir(parents=True, exist_ok=True)
125-
126-
for field in self.graph_attributes:
127-
graph = getattr(self, field.name)
128-
graph.cache(cache_dir=cache_dir, graph_name=field.name, compress=compress)
129-
return cache_dir
130-
131121
@classmethod
132122
def from_arrays(cls, arrays: MinimalGridArrays) -> "GraphContainer":
133123
"""Build from arrays"""

src/power_grid_model_ds/_core/model/graphs/models/base.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
# SPDX-License-Identifier: MPL-2.0
44

55
from abc import ABC, abstractmethod
6+
from contextlib import contextmanager
7+
from typing import Generator
68

79
import numpy as np
810
from numpy._typing import NDArray
@@ -34,6 +36,14 @@ def nr_nodes(self):
3436
def nr_branches(self):
3537
"""Returns the number of branches in the graph"""
3638

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+
3747
@abstractmethod
3848
def external_to_internal(self, ext_node_id: int) -> int:
3949
"""Convert external node id to internal node id (internal)
@@ -63,6 +73,14 @@ def has_node(self, node_id: int) -> bool:
6373

6474
return self._has_node(node_id=internal_node_id)
6575

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+
6684
def add_node(self, ext_node_id: int, raise_on_fail: bool = True) -> None:
6785
"""Add a node to the graph."""
6886
if self.has_node(ext_node_id):
@@ -158,12 +176,34 @@ def delete_branch_array(self, branch_array: BranchArray, raise_on_fail: bool = T
158176
if self._branch_is_relevant(branch):
159177
self.delete_branch(branch.from_node.item(), branch.to_node.item(), raise_on_fail=raise_on_fail)
160178

161-
def delete_branch3_array(self, branch_array: Branch3Array, raise_on_fail: bool = True) -> None:
179+
def delete_branch3_array(self, branch3_array: Branch3Array, raise_on_fail: bool = True) -> None:
162180
"""Delete all branch3s in the branch3 array from the graph."""
163-
for branch3 in branch_array:
181+
for branch3 in branch3_array:
164182
branches = _get_branch3_branches(branch3)
165183
self.delete_branch_array(branches, raise_on_fail=raise_on_fail)
166184

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+
167207
def get_shortest_path(self, ext_start_node_id: int, ext_end_node_id: int) -> tuple[list[int], int]:
168208
"""Calculate the shortest path between two nodes
169209
@@ -235,8 +275,49 @@ def get_connected(
235275
nodes_to_ignore=self._externals_to_internals(nodes_to_ignore),
236276
inclusive=inclusive,
237277
)
278+
238279
return self._internals_to_externals(nodes)
239280

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+
240321
def find_fundamental_cycles(self) -> list[list[int]]:
241322
"""Find all fundamental cycles in the graph.
242323
Returns:
@@ -270,9 +351,15 @@ def _branch_is_relevant(self, branch: BranchArray) -> bool:
270351
return branch.is_active.item()
271352
return True
272353

354+
@abstractmethod
355+
def _in_branches(self, int_node_id: int) -> Generator[tuple[int, int], None, None]: ...
356+
273357
@abstractmethod
274358
def _get_connected(self, node_id: int, nodes_to_ignore: list[int], inclusive: bool = False) -> list[int]: ...
275359

360+
@abstractmethod
361+
def _find_first_connected(self, node_id: int, candidate_node_ids: list[int]) -> int: ...
362+
276363
@abstractmethod
277364
def _has_branch(self, from_node_id, to_node_id) -> bool: ...
278365

@@ -307,6 +394,9 @@ def _get_components(self, substation_nodes: list[int]) -> list[list[int]]: ...
307394
@abstractmethod
308395
def _find_fundamental_cycles(self) -> list[list[int]]: ...
309396

397+
@abstractmethod
398+
def _all_branches(self) -> Generator[tuple[int, int], None, None]: ...
399+
310400

311401
def _get_branch3_branches(branch3: Branch3Array) -> BranchArray:
312402
node_1 = branch3.node_1.item()

src/power_grid_model_ds/_core/model/graphs/models/rustworkx.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
# SPDX-License-Identifier: MPL-2.0
44

55
import logging
6+
from typing import Generator
67

78
import rustworkx as rx
89
from rustworkx import NoEdgeBetweenNodes
9-
from rustworkx.visit import BFSVisitor, PruneSearch
10+
from rustworkx.visit import BFSVisitor, PruneSearch, StopSearch
1011

1112
from power_grid_model_ds._core.model.graphs.errors import MissingBranchError, MissingNodeError, NoPathBetweenNodes
1213
from power_grid_model_ds._core.model.graphs.models._rustworkx_search import find_fundamental_cycles_rustworkx
@@ -99,6 +100,16 @@ def _get_connected(self, node_id: int, nodes_to_ignore: list[int], inclusive: bo
99100

100101
return connected_nodes
101102

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+
102113
def _find_fundamental_cycles(self) -> list[list[int]]:
103114
"""Find all fundamental cycles in the graph using Rustworkx.
104115
@@ -107,6 +118,9 @@ def _find_fundamental_cycles(self) -> list[list[int]]:
107118
"""
108119
return find_fundamental_cycles_rustworkx(self._graph)
109120

121+
def _all_branches(self) -> Generator[tuple[int, int], None, None]:
122+
return ((source, target) for source, target in self._graph.edge_list())
123+
110124

111125
class _NodeVisitor(BFSVisitor):
112126
def __init__(self, nodes_to_ignore: list[int]):
@@ -117,3 +131,16 @@ def discover_vertex(self, v):
117131
if v in self.nodes_to_ignore:
118132
raise PruneSearch
119133
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

0 commit comments

Comments
 (0)