power-grid-model-ds 0.0.1a11709467271__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.
Files changed (64) hide show
  1. power_grid_model_ds/__init__.py +9 -0
  2. power_grid_model_ds/_core/__init__.py +0 -0
  3. power_grid_model_ds/_core/data_source/__init__.py +0 -0
  4. power_grid_model_ds/_core/data_source/generator/__init__.py +0 -0
  5. power_grid_model_ds/_core/data_source/generator/arrays/__init__.py +0 -0
  6. power_grid_model_ds/_core/data_source/generator/arrays/base.py +25 -0
  7. power_grid_model_ds/_core/data_source/generator/arrays/line.py +133 -0
  8. power_grid_model_ds/_core/data_source/generator/arrays/node.py +37 -0
  9. power_grid_model_ds/_core/data_source/generator/arrays/source.py +30 -0
  10. power_grid_model_ds/_core/data_source/generator/arrays/transformer.py +37 -0
  11. power_grid_model_ds/_core/data_source/generator/grid_generators.py +78 -0
  12. power_grid_model_ds/_core/fancypy.py +66 -0
  13. power_grid_model_ds/_core/load_flow.py +140 -0
  14. power_grid_model_ds/_core/model/__init__.py +0 -0
  15. power_grid_model_ds/_core/model/arrays/__init__.py +43 -0
  16. power_grid_model_ds/_core/model/arrays/base/__init__.py +0 -0
  17. power_grid_model_ds/_core/model/arrays/base/_build.py +166 -0
  18. power_grid_model_ds/_core/model/arrays/base/_filters.py +115 -0
  19. power_grid_model_ds/_core/model/arrays/base/_modify.py +64 -0
  20. power_grid_model_ds/_core/model/arrays/base/_optional.py +11 -0
  21. power_grid_model_ds/_core/model/arrays/base/_string.py +94 -0
  22. power_grid_model_ds/_core/model/arrays/base/array.py +325 -0
  23. power_grid_model_ds/_core/model/arrays/base/errors.py +17 -0
  24. power_grid_model_ds/_core/model/arrays/pgm_arrays.py +122 -0
  25. power_grid_model_ds/_core/model/constants.py +27 -0
  26. power_grid_model_ds/_core/model/containers/__init__.py +0 -0
  27. power_grid_model_ds/_core/model/containers/base.py +244 -0
  28. power_grid_model_ds/_core/model/containers/grid_protocol.py +22 -0
  29. power_grid_model_ds/_core/model/dtypes/__init__.py +0 -0
  30. power_grid_model_ds/_core/model/dtypes/appliances.py +39 -0
  31. power_grid_model_ds/_core/model/dtypes/branches.py +117 -0
  32. power_grid_model_ds/_core/model/dtypes/id.py +19 -0
  33. power_grid_model_ds/_core/model/dtypes/nodes.py +27 -0
  34. power_grid_model_ds/_core/model/dtypes/regulators.py +30 -0
  35. power_grid_model_ds/_core/model/dtypes/sensors.py +63 -0
  36. power_grid_model_ds/_core/model/enums/__init__.py +0 -0
  37. power_grid_model_ds/_core/model/enums/nodes.py +16 -0
  38. power_grid_model_ds/_core/model/graphs/__init__.py +0 -0
  39. power_grid_model_ds/_core/model/graphs/container.py +158 -0
  40. power_grid_model_ds/_core/model/graphs/errors.py +19 -0
  41. power_grid_model_ds/_core/model/graphs/models/__init__.py +7 -0
  42. power_grid_model_ds/_core/model/graphs/models/_rustworkx_search.py +63 -0
  43. power_grid_model_ds/_core/model/graphs/models/base.py +326 -0
  44. power_grid_model_ds/_core/model/graphs/models/rustworkx.py +119 -0
  45. power_grid_model_ds/_core/model/grids/__init__.py +0 -0
  46. power_grid_model_ds/_core/model/grids/_text_sources.py +119 -0
  47. power_grid_model_ds/_core/model/grids/base.py +434 -0
  48. power_grid_model_ds/_core/model/grids/helpers.py +122 -0
  49. power_grid_model_ds/_core/utils/__init__.py +0 -0
  50. power_grid_model_ds/_core/utils/misc.py +41 -0
  51. power_grid_model_ds/_core/utils/pickle.py +47 -0
  52. power_grid_model_ds/_core/utils/zip.py +72 -0
  53. power_grid_model_ds/arrays.py +39 -0
  54. power_grid_model_ds/constants.py +7 -0
  55. power_grid_model_ds/enums.py +7 -0
  56. power_grid_model_ds/errors.py +27 -0
  57. power_grid_model_ds/fancypy.py +9 -0
  58. power_grid_model_ds/generators.py +11 -0
  59. power_grid_model_ds/graph_models.py +8 -0
  60. power_grid_model_ds-0.0.1a11709467271.dist-info/LICENSE +292 -0
  61. power_grid_model_ds-0.0.1a11709467271.dist-info/METADATA +80 -0
  62. power_grid_model_ds-0.0.1a11709467271.dist-info/RECORD +64 -0
  63. power_grid_model_ds-0.0.1a11709467271.dist-info/WHEEL +5 -0
  64. power_grid_model_ds-0.0.1a11709467271.dist-info/top_level.txt +1 -0
@@ -0,0 +1,119 @@
1
+ # SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
2
+ #
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ import logging
6
+
7
+ import rustworkx as rx
8
+ from rustworkx import NoEdgeBetweenNodes
9
+ from rustworkx.visit import BFSVisitor, PruneSearch
10
+
11
+ from power_grid_model_ds._core.model.graphs.errors import MissingBranchError, MissingNodeError, NoPathBetweenNodes
12
+ from power_grid_model_ds._core.model.graphs.models._rustworkx_search import find_fundamental_cycles_rustworkx
13
+ from power_grid_model_ds._core.model.graphs.models.base import BaseGraphModel
14
+
15
+ _logger = logging.getLogger(__name__)
16
+
17
+
18
+ class RustworkxGraphModel(BaseGraphModel):
19
+ """A wrapper around the graph from the 'rustworkx' package"""
20
+
21
+ def __init__(self, active_only=False) -> None:
22
+ super().__init__(active_only=active_only)
23
+ self._graph: rx.PyGraph = rx.PyGraph()
24
+ self._internal_to_external: dict[int, int] = {}
25
+ self._external_to_internal: dict[int, int] = {}
26
+
27
+ @property
28
+ def nr_nodes(self):
29
+ return self._graph.num_nodes()
30
+
31
+ @property
32
+ def nr_branches(self):
33
+ return self._graph.num_edges()
34
+
35
+ @property
36
+ def external_ids(self) -> list[int]:
37
+ return list(self._external_to_internal.keys())
38
+
39
+ # pylint: disable=duplicate-code
40
+ def external_to_internal(self, ext_node_id: int):
41
+ try:
42
+ return self._external_to_internal[ext_node_id]
43
+ except KeyError as error:
44
+ raise MissingNodeError(f"External node id '{ext_node_id}' does NOT exist!") from error
45
+
46
+ def internal_to_external(self, int_node_id: int):
47
+ return self._internal_to_external[int_node_id]
48
+
49
+ def _add_node(self, ext_node_id: int):
50
+ graph_node_id = self._graph.add_node(ext_node_id)
51
+ self._external_to_internal[ext_node_id] = graph_node_id
52
+ self._internal_to_external[graph_node_id] = ext_node_id
53
+
54
+ def _delete_node(self, node_id: int):
55
+ self._graph.remove_node(node_id)
56
+ external_node_id = self._internal_to_external.pop(node_id)
57
+ self._external_to_internal.pop(external_node_id)
58
+
59
+ def _has_branch(self, from_node_id: int, to_node_id: int) -> bool:
60
+ return self._graph.has_edge(from_node_id, to_node_id)
61
+
62
+ def _has_node(self, node_id: int) -> bool:
63
+ return self._graph.has_node(node_id)
64
+
65
+ def _add_branch(self, from_node_id: int, to_node_id: int):
66
+ self._graph.add_edge(from_node_id, to_node_id, None)
67
+
68
+ def _delete_branch(self, from_node_id: int, to_node_id: int) -> None:
69
+ try:
70
+ self._graph.remove_edge(from_node_id, to_node_id)
71
+ except NoEdgeBetweenNodes as error:
72
+ raise MissingBranchError(f"No edge between (internal) nodes {from_node_id} and {to_node_id}") from error
73
+
74
+ def _get_shortest_path(self, source: int, target: int) -> tuple[list[int], int]:
75
+ path_mapping = rx.dijkstra_shortest_paths(self._graph, source, target)
76
+
77
+ if target not in path_mapping:
78
+ raise NoPathBetweenNodes(f"No path between internal nodes {source} and {target}")
79
+
80
+ path_nodes = list(path_mapping[target])
81
+ return path_nodes, len(path_nodes) - 1
82
+
83
+ def _get_all_paths(self, source: int, target: int) -> list[list[int]]:
84
+ return list(rx.all_simple_paths(self._graph, source, target))
85
+
86
+ def _get_components(self, substation_nodes: list[int]) -> list[list[int]]:
87
+ no_os_graph = self._graph.copy()
88
+ for os_node in substation_nodes:
89
+ no_os_graph.remove_node(os_node)
90
+ components = rx.connected_components(no_os_graph)
91
+ return [list(component) for component in components]
92
+
93
+ def _get_connected(self, node_id: int, nodes_to_ignore: list[int], inclusive: bool = False) -> list[int]:
94
+ visitor = _NodeVisitor(nodes_to_ignore)
95
+ rx.bfs_search(self._graph, [node_id], visitor)
96
+ connected_nodes = visitor.nodes
97
+ if not inclusive:
98
+ connected_nodes.remove(node_id)
99
+
100
+ return connected_nodes
101
+
102
+ def _find_fundamental_cycles(self) -> list[list[int]]:
103
+ """Find all fundamental cycles in the graph using Rustworkx.
104
+
105
+ Returns:
106
+ list[list[int]]: A list of cycles, each cycle is a list of node IDs.
107
+ """
108
+ return find_fundamental_cycles_rustworkx(self._graph)
109
+
110
+
111
+ class _NodeVisitor(BFSVisitor):
112
+ def __init__(self, nodes_to_ignore: list[int]):
113
+ self.nodes_to_ignore = nodes_to_ignore
114
+ self.nodes: list[int] = []
115
+
116
+ def discover_vertex(self, v):
117
+ if v in self.nodes_to_ignore:
118
+ raise PruneSearch
119
+ self.nodes.append(v)
File without changes
@@ -0,0 +1,119 @@
1
+ # SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
2
+ #
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """Create a grid from text a text file"""
6
+
7
+ import logging
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from power_grid_model_ds._core.model.enums.nodes import NodeType
12
+
13
+ if TYPE_CHECKING:
14
+ from power_grid_model_ds._core.model.grids.base import Grid
15
+
16
+ _logger = logging.getLogger(__name__)
17
+
18
+
19
+ class TextSource:
20
+ """Class for handling text sources.
21
+
22
+ Text sources are only intended for test purposes so that a grid can quickly be designed from a text file.
23
+ Moreover, these text sources are compatible with the grid editor at https://csacademy.com/app/graph_editor/
24
+
25
+ Example of a text file:
26
+ S1 2
27
+ 2 3
28
+ 3 4 transformer
29
+ 4 5
30
+ S1 7
31
+
32
+ See docs/examples/3_drawing_a_grid.md for more information.
33
+ """
34
+
35
+ def __init__(self, grid_class: type["Grid"]):
36
+ self.grid = grid_class.empty()
37
+
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"""
41
+
42
+ txt_nodes, txt_branches = self.read_txt(path)
43
+ self.add_nodes(txt_nodes)
44
+ self.add_branches(txt_branches)
45
+ self.grid.set_feeder_ids()
46
+ return self.grid
47
+
48
+ @staticmethod
49
+ def read_txt(path: Path) -> tuple[set, dict]:
50
+ """Extract assets from text"""
51
+ with open(path, "r", encoding="utf-8") as f:
52
+ txt_rows = f.readlines()
53
+
54
+ txt_nodes = set()
55
+ txt_branches = {}
56
+ for text_line in txt_rows:
57
+ if not text_line.strip() or text_line.startswith("#"):
58
+ continue # skip empty lines and comments
59
+ try:
60
+ from_node_str, to_node_str, *comments = text_line.strip().split(" ")
61
+ except ValueError as err:
62
+ raise ValueError(f"Text line '{text_line}' is invalid. Skipping...") from err
63
+ comments = comments[0].split(",") if comments else []
64
+
65
+ txt_nodes |= {from_node_str, to_node_str}
66
+ txt_branches[(from_node_str, to_node_str)] = comments
67
+ return txt_nodes, txt_branches
68
+
69
+ def add_nodes(self, nodes: set[str]):
70
+ """Add nodes to the grid"""
71
+ source_nodes = {int(node[1:]) for node in nodes if node.startswith("S")}
72
+ regular_nodes = {int(node) for node in nodes if not node.startswith("S")}
73
+
74
+ if source_nodes.intersection(regular_nodes):
75
+ raise ValueError("Source nodes and regular nodes have overlapping ids")
76
+
77
+ for node_id in source_nodes:
78
+ new_node = self.grid.node.empty(1)
79
+ new_node.id = node_id
80
+ new_node.node_type = NodeType.SUBSTATION_NODE
81
+ self.grid.append(new_node, check_max_id=False)
82
+
83
+ for node_id in regular_nodes:
84
+ new_node = self.grid.node.empty(1)
85
+ new_node.id = node_id
86
+ self.grid.append(new_node, check_max_id=False)
87
+
88
+ def add_branches(self, branches: dict[tuple[str, str], list[str]]):
89
+ """Add branches to the grid"""
90
+ for branch, comments in branches.items():
91
+ self.add_branch(branch, comments)
92
+
93
+ def add_branch(self, branch: tuple[str, str], comments: list[str]):
94
+ """Add a branch to the grid"""
95
+ from_node_str, to_node_str = branch
96
+ from_node = int(from_node_str.replace("S", ""))
97
+ to_node = int(to_node_str.replace("S", ""))
98
+
99
+ if "transformer" in comments:
100
+ new_branch = self.grid.transformer.empty(1)
101
+ elif "link" in comments:
102
+ new_branch = self.grid.link.empty(1)
103
+ else: # assume it is a line
104
+ new_branch = self.grid.line.empty(1)
105
+
106
+ branch_ids = [branch_id for branch_id in comments if branch_id.isdigit()]
107
+ if branch_ids:
108
+ if len(branch_ids) > 1:
109
+ raise ValueError(f"Multiple branch ids found in row {branch} {','.join(comments)}")
110
+ new_branch.id = int(branch_ids[0])
111
+
112
+ new_branch.from_node = from_node
113
+ new_branch.to_node = to_node
114
+ new_branch.from_status = 1
115
+ if "open" in comments:
116
+ new_branch.to_status = 0
117
+ else:
118
+ new_branch.to_status = 1
119
+ self.grid.append(new_branch)
@@ -0,0 +1,434 @@
1
+ # SPDX-FileCopyrightText: Contributors to the Power Grid Model project <powergridmodel@lfenergy.org>
2
+ #
3
+ # SPDX-License-Identifier: MPL-2.0
4
+
5
+ """Base grid classes"""
6
+
7
+ import dataclasses
8
+ import itertools
9
+ import logging
10
+ from copy import copy
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Type, TypeVar
14
+
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+
18
+ from power_grid_model_ds._core import fancypy as fp
19
+ from power_grid_model_ds._core.model.arrays import (
20
+ AsymVoltageSensorArray,
21
+ Branch3Array,
22
+ BranchArray,
23
+ LineArray,
24
+ LinkArray,
25
+ NodeArray,
26
+ SourceArray,
27
+ SymGenArray,
28
+ SymLoadArray,
29
+ SymPowerSensorArray,
30
+ SymVoltageSensorArray,
31
+ ThreeWindingTransformerArray,
32
+ TransformerArray,
33
+ TransformerTapRegulatorArray,
34
+ )
35
+ from power_grid_model_ds._core.model.arrays.base.array import FancyArray
36
+ from power_grid_model_ds._core.model.arrays.base.errors import RecordDoesNotExist
37
+ from power_grid_model_ds._core.model.containers.base import FancyArrayContainer
38
+ from power_grid_model_ds._core.model.enums.nodes import NodeType
39
+ from power_grid_model_ds._core.model.graphs.container import GraphContainer
40
+ from power_grid_model_ds._core.model.graphs.models import RustworkxGraphModel
41
+ from power_grid_model_ds._core.model.graphs.models.base import BaseGraphModel
42
+ from power_grid_model_ds._core.model.grids._text_sources import TextSource
43
+ from power_grid_model_ds._core.model.grids.helpers import set_feeder_ids, set_is_feeder
44
+ from power_grid_model_ds._core.utils.pickle import get_pickle_path, load_from_pickle, save_to_pickle
45
+ from power_grid_model_ds._core.utils.zip import file2gzip
46
+
47
+ Self = TypeVar("Self", bound="Grid")
48
+
49
+ # pylint: disable=too-many-instance-attributes
50
+ # pylint: disable=too-many-public-methods
51
+
52
+
53
+ @dataclass
54
+ class Grid(FancyArrayContainer):
55
+ """Grid object containing the entire network and interface to interact with it.
56
+
57
+ Examples:
58
+
59
+ >>> from power_grid_model_ds import Grid
60
+ >>> grid = Grid.empty()
61
+ >>> grid
62
+ """
63
+
64
+ graphs: GraphContainer
65
+ """The graph representations of the grid."""
66
+
67
+ # nodes
68
+ node: NodeArray
69
+
70
+ # branches
71
+ transformer: TransformerArray
72
+ three_winding_transformer: ThreeWindingTransformerArray
73
+ line: LineArray
74
+ link: LinkArray
75
+
76
+ source: SourceArray
77
+ sym_load: SymLoadArray
78
+ sym_gen: SymGenArray
79
+
80
+ # regulators
81
+ transformer_tap_regulator: TransformerTapRegulatorArray
82
+
83
+ # sensors
84
+ sym_power_sensor: SymPowerSensorArray
85
+ sym_voltage_sensor: SymVoltageSensorArray
86
+ asym_voltage_sensor: AsymVoltageSensorArray
87
+
88
+ def __str__(self) -> str:
89
+ """String representation of the grid.
90
+
91
+ Compatible with https://csacademy.com/app/graph_editor/
92
+ """
93
+ grid_str = ""
94
+
95
+ for transformer3 in self.three_winding_transformer:
96
+ nodes = [transformer3.node_1.item(), transformer3.node_2.item(), transformer3.node_3.item()]
97
+ for combo in itertools.combinations(nodes, 2):
98
+ grid_str += f"S{combo[0]} S{combo[1]} {transformer3.id.item()},3-transformer\n"
99
+
100
+ for branch in self.branches:
101
+ from_node = self.node.get(id=branch.from_node).record
102
+ to_node = self.node.get(id=branch.to_node).record
103
+
104
+ from_node_str = f"S{from_node.id}" if from_node.node_type == NodeType.SUBSTATION_NODE else str(from_node.id)
105
+ to_node_str = f"S{to_node.id}" if to_node.node_type == NodeType.SUBSTATION_NODE else str(to_node.id)
106
+
107
+ suffix_str = str(branch.id.item())
108
+ if branch.from_status.item() == 0 or branch.to_status.item() == 0:
109
+ suffix_str = f"{suffix_str},open"
110
+
111
+ if branch.id in self.transformer.id:
112
+ suffix_str = f"{suffix_str},transformer"
113
+ elif branch.id in self.link.id:
114
+ suffix_str = f"{suffix_str},link"
115
+ elif branch.id in self.line.id:
116
+ pass # no suffix needed
117
+ else:
118
+ raise ValueError(f"Branch {branch.id} is not a transformer, link or line")
119
+
120
+ grid_str += f"{from_node_str} {to_node_str} {suffix_str}\n"
121
+ return grid_str
122
+
123
+ @property
124
+ def branches(self) -> BranchArray:
125
+ """Converts all branch arrays into a single BranchArray."""
126
+ branch_dtype = BranchArray.get_dtype()
127
+ branches = BranchArray()
128
+ for array in self.branch_arrays:
129
+ new_branch = BranchArray(data=array.data[list(branch_dtype.names)])
130
+ branches = fp.concatenate(branches, new_branch)
131
+ return branches
132
+
133
+ @property
134
+ def branch_arrays(self) -> list[BranchArray]:
135
+ """Returns all branch arrays"""
136
+ branch_arrays = []
137
+ for field in dataclasses.fields(self):
138
+ array = getattr(self, field.name)
139
+ if isinstance(array, BranchArray):
140
+ branch_arrays.append(array)
141
+ return branch_arrays
142
+
143
+ def get_typed_branches(self, branch_ids: list[int] | npt.NDArray[np.int32]) -> BranchArray:
144
+ """Find a matching LineArray, LinkArray or TransformerArray for the given branch_ids
145
+
146
+ Raises:
147
+ ValueError:
148
+ - If no branch_ids are provided.
149
+ - If not all branch_ids are of the same type.
150
+ """
151
+ if not np.any(branch_ids):
152
+ raise ValueError("No branch_ids provided.")
153
+ for branch_array in self.branch_arrays:
154
+ array = branch_array.filter(branch_ids)
155
+ if 0 < array.size != len(branch_ids):
156
+ raise ValueError("Branches are not of the same type.")
157
+ if array.size:
158
+ return array
159
+ raise RecordDoesNotExist(f"Branches {branch_ids} not found in grid.")
160
+
161
+ def reverse_branches(self, branches: BranchArray):
162
+ """Reverse the direction of the branches."""
163
+ if not branches.size:
164
+ return
165
+ if not isinstance(branches, (LineArray, LinkArray, TransformerArray)):
166
+ try:
167
+ branches = self.get_typed_branches(branches.id)
168
+ except ValueError:
169
+ # If the branches are not of the same type, reverse them per type (though this is slower)
170
+ for array in self.branch_arrays:
171
+ self.reverse_branches(array.filter(branches.id))
172
+ return
173
+
174
+ from_nodes = branches.from_node
175
+ to_nodes = branches.to_node
176
+
177
+ array_field = self.find_array_field(branches.__class__)
178
+ array = getattr(self, array_field.name)
179
+ array.update_by_id(branches.id, from_node=to_nodes, to_node=from_nodes)
180
+
181
+ @classmethod
182
+ def empty(cls: Type[Self], graph_model: type[BaseGraphModel] = RustworkxGraphModel) -> Self:
183
+ """Create an empty grid
184
+
185
+ Args:
186
+ graph_model (type[BaseGraphModel], optional): The graph model to use. Defaults to RustworkxGraphModel.
187
+
188
+ Returns:
189
+ Grid: An empty grid
190
+ """
191
+ empty_fields = cls._get_empty_fields()
192
+ empty_fields["graphs"] = GraphContainer.empty(graph_model=graph_model)
193
+ return cls(**empty_fields)
194
+
195
+ def append(self, array: FancyArray, check_max_id: bool = True):
196
+ """Append an array to the grid. Both 'grid arrays' and 'grid.graphs' will be updated.
197
+
198
+ Args:
199
+ array (FancyArray): The array to append.
200
+ check_max_id (bool, optional): Whether to check if the array id is the maximum id. Defaults to True.
201
+ """
202
+ self._append(array, check_max_id=check_max_id) # noqa
203
+ for row in array:
204
+ # pylint: disable=protected-access
205
+ self.graphs._append(row)
206
+
207
+ def add_branch(self, branch: BranchArray) -> None:
208
+ """Add a branch to the grid
209
+
210
+ Args:
211
+ branch (BranchArray): The branch to add
212
+ """
213
+ self._append(array=branch)
214
+ self.graphs.add_branch(branch=branch)
215
+
216
+ logging.debug(f"added branch {branch.id} from {branch.from_node} to {branch.to_node}")
217
+
218
+ def delete_branch(self, branch: BranchArray) -> None:
219
+ """Remove a branch from the grid
220
+
221
+ Args:
222
+ branch (BranchArray): The branch to remove
223
+ """
224
+ _add_branch_array(branch=branch, grid=self)
225
+ self.graphs.delete_branch(branch=branch)
226
+ logging.debug(
227
+ f"""deleted branch {branch.id.item()} from {branch.from_node.item()} to {branch.to_node.item()}"""
228
+ )
229
+
230
+ def delete_branch3(self, branch: Branch3Array) -> None:
231
+ """Remove a branch3 from the grid
232
+
233
+ Args:
234
+ branch (Branch3Array): The branch3 to remove
235
+ """
236
+ _add_branch_array(branch=branch, grid=self)
237
+ self.graphs.delete_branch3(branch=branch)
238
+
239
+ def add_node(self, node: NodeArray) -> None:
240
+ """Add a new node to the grid
241
+
242
+ Args:
243
+ node (NodeArray): The node to add
244
+ """
245
+ self._append(array=node)
246
+ self.graphs.add_node(node=node)
247
+ logging.debug(f"added rail {node.id}")
248
+
249
+ def delete_node(self, node: NodeArray) -> None:
250
+ """Remove a node from the grid
251
+
252
+ Args:
253
+ node (NodeArray): The node to remove
254
+ """
255
+ self.node = self.node.exclude(id=node.id)
256
+ self.sym_load = self.sym_load.exclude(node=node.id)
257
+ self.source = self.source.exclude(node=node.id)
258
+
259
+ for branch_array in self.branch_arrays:
260
+ matching_branches = branch_array.filter(from_node=node.id, to_node=node.id, mode_="OR")
261
+ for branch in matching_branches:
262
+ self.delete_branch(branch)
263
+
264
+ self.graphs.delete_node(node=node)
265
+ logging.debug(f"deleted rail {node.id}")
266
+
267
+ def make_active(self, branch: BranchArray) -> None:
268
+ """Make a branch active
269
+
270
+ Args:
271
+ branch (BranchArray): The branch to make active
272
+ """
273
+ array_field = self.find_array_field(branch.__class__)
274
+ array_attr = getattr(self, array_field.name)
275
+ branch_mask = array_attr.id == branch.id
276
+ array_attr.from_status[branch_mask] = 1
277
+ array_attr.to_status[branch_mask] = 1
278
+ setattr(self, array_field.name, array_attr)
279
+
280
+ self.graphs.make_active(branch=branch)
281
+ logging.debug(f"activated branch {branch.id}")
282
+
283
+ def make_inactive(self, branch: BranchArray, at_to_side: bool = True) -> None:
284
+ """Make a branch inactive. This is done by setting from or to status to 0.
285
+
286
+ Args:
287
+ branch (BranchArray): The branch to make inactive
288
+ at_to_side (bool, optional): Whether to deactivate the to_status instead of the from_status.
289
+ Defaults to True.
290
+ """
291
+ array_field = self.find_array_field(branch.__class__)
292
+ array_attr = getattr(self, array_field.name)
293
+ branch_mask = array_attr.id == branch.id
294
+ status_side = "to_status" if at_to_side else "from_status"
295
+ array_attr[status_side][branch_mask] = 0
296
+ setattr(self, array_field.name, array_attr)
297
+
298
+ self.graphs.make_inactive(branch=branch)
299
+ logging.debug(f"deactivated branch {branch.id}")
300
+
301
+ def get_branches_in_path(self, nodes_in_path: list[int]) -> BranchArray:
302
+ """Returns all branches within a path of nodes
303
+
304
+ Args:
305
+ nodes_in_path (list[int]): The nodes in the path
306
+
307
+ Returns:
308
+ BranchArray: The branches in the path
309
+ """
310
+ return self.branches.filter(from_node=nodes_in_path, to_node=nodes_in_path, from_status=1, to_status=1)
311
+
312
+ def get_nearest_substation_node(self, node_id: int):
313
+ """Find the nearest substation node.
314
+
315
+ Args:
316
+ node_id(int): The id of the node to find the nearest substation node for.
317
+
318
+ Returns:
319
+ NodeArray: The nearest substation node.
320
+
321
+ Raises:
322
+ RecordDoesNotExist: If no substation node is connected to the input node.
323
+ """
324
+ connected_nodes = self.graphs.active_graph.get_connected(node_id=node_id, inclusive=True)
325
+ substation_nodes = self.node.filter(node_type=NodeType.SUBSTATION_NODE.value)
326
+
327
+ for node in connected_nodes:
328
+ if node in substation_nodes.id:
329
+ return substation_nodes.get(node)
330
+ raise RecordDoesNotExist(f"No {NodeType.SUBSTATION_NODE.name} connected to node {node_id}")
331
+
332
+ def get_downstream_nodes(self, node_id: int, inclusive: bool = False):
333
+ """Get the downstream nodes from a node.
334
+
335
+ Example:
336
+ given this graph: [1] - [2] - [3] - [4], with 1 being a substation node
337
+
338
+ >>> graph.get_downstream_nodes(2) == [3, 4]
339
+ >>> graph.get_downstream_nodes(3) == [4]
340
+ >>> graph.get_downstream_nodes(3, inclusive=True) == [3, 4]
341
+
342
+ Args:
343
+ node_id(int): The id of the node to get the downstream nodes from.
344
+ inclusive(bool): Whether to include the input node in the result.
345
+
346
+ Raises:
347
+ NotImplementedError: If the input node is a substation node.
348
+
349
+ Returns:
350
+ list[int]: The downstream nodes.
351
+ """
352
+ substation_node_id = self.get_nearest_substation_node(node_id).id.item()
353
+
354
+ if node_id == substation_node_id:
355
+ raise NotImplementedError("get_downstream_nodes is not implemented for substation nodes!")
356
+
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)
361
+
362
+ def cache(self, cache_dir: Path, cache_name: str, compress: bool = True):
363
+ """Cache Grid to a folder
364
+
365
+ Args:
366
+ cache_dir (Path): The directory to save the cache to.
367
+ cache_name (str): The name of the cache.
368
+ compress (bool, optional): Whether to compress the cache. Defaults to True.
369
+ """
370
+ tmp_graphs = copy(self.graphs)
371
+ self.graphs = None # noqa
372
+ cache_dir.mkdir(parents=True, exist_ok=True)
373
+
374
+ pickle_path = cache_dir / f"{cache_name}.pickle"
375
+ save_to_pickle(path=pickle_path, python_object=self)
376
+
377
+ if compress:
378
+ gzip_path = file2gzip(pickle_path)
379
+ pickle_path.unlink()
380
+ return gzip_path
381
+
382
+ self.graphs = tmp_graphs
383
+ return pickle_path
384
+
385
+ @classmethod
386
+ # pylint: disable=arguments-differ
387
+ def from_cache(cls: Type[Self], cache_path: Path, load_graphs: bool = True) -> Self:
388
+ """Read from cache and build .graphs from arrays
389
+
390
+ Args:
391
+ cache_path (Path): The path to the cache
392
+ load_graphs (bool, optional): Whether to load the graphs. Defaults to True.
393
+
394
+ Returns:
395
+ Self: The grid loaded from cache
396
+ """
397
+ pickle_path = get_pickle_path(cache_path)
398
+
399
+ grid = cls._from_pickle(pickle_path=pickle_path)
400
+ if load_graphs:
401
+ grid.graphs = GraphContainer.from_arrays(grid)
402
+ return grid
403
+
404
+ @classmethod
405
+ def _from_pickle(cls, pickle_path: Path):
406
+ grid = load_from_pickle(path=pickle_path)
407
+ if not isinstance(grid, Grid):
408
+ raise TypeError(f"{pickle_path.name} is not a valid {cls.__name__} cache.")
409
+ return grid
410
+
411
+ @classmethod
412
+ # pylint: disable=arguments-differ
413
+ def from_txt_file(cls, txt_file_path: Path):
414
+ """Load grid from txt file
415
+
416
+ Args:
417
+ txt_file_path (Path): The path to the txt file
418
+ """
419
+ text_source = TextSource(grid_class=cls)
420
+ return text_source.load_grid_from_path(txt_file_path)
421
+
422
+ def set_feeder_ids(self):
423
+ """Sets feeder and substation id properties in the grids arrays"""
424
+ set_is_feeder(grid=self)
425
+ set_feeder_ids(grid=self)
426
+
427
+
428
+ def _add_branch_array(branch: BranchArray | Branch3Array, grid: Grid):
429
+ """Add a branch array to the grid"""
430
+ array_field = grid.find_array_field(branch.__class__)
431
+ array_attr = getattr(grid, array_field.name)
432
+ setattr(grid, array_field.name, array_attr.exclude(id=branch.id))
433
+
434
+ grid.transformer_tap_regulator = grid.transformer_tap_regulator.exclude(regulated_object=branch.id)