frequenz-gridpool 0.3.0__tar.gz → 0.3.1__tar.gz

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 (22) hide show
  1. {frequenz_gridpool-0.3.0/src/frequenz_gridpool.egg-info → frequenz_gridpool-0.3.1}/PKG-INFO +54 -6
  2. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/README.md +44 -0
  3. frequenz_gridpool-0.3.1/RELEASE_NOTES.md +19 -0
  4. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/pyproject.toml +12 -6
  5. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz/gridpool/cli/__main__.py +42 -0
  6. frequenz_gridpool-0.3.1/src/frequenz/gridpool/cli/_render_graph.py +295 -0
  7. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1/src/frequenz_gridpool.egg-info}/PKG-INFO +54 -6
  8. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz_gridpool.egg-info/SOURCES.txt +1 -0
  9. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz_gridpool.egg-info/requires.txt +10 -5
  10. frequenz_gridpool-0.3.0/RELEASE_NOTES.md +0 -17
  11. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/LICENSE +0 -0
  12. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/MANIFEST.in +0 -0
  13. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/setup.cfg +0 -0
  14. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz/gridpool/__init__.py +0 -0
  15. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz/gridpool/_graph_generator.py +0 -0
  16. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz/gridpool/_microgrid_config.py +0 -0
  17. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz/gridpool/cli/__init__.py +0 -0
  18. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz/gridpool/conftest.py +0 -0
  19. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz/gridpool/py.typed +0 -0
  20. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz_gridpool.egg-info/dependency_links.txt +0 -0
  21. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz_gridpool.egg-info/entry_points.txt +0 -0
  22. {frequenz_gridpool-0.3.0 → frequenz_gridpool-0.3.1}/src/frequenz_gridpool.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frequenz-gridpool
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: High-level interface to grid pools for the Frequenz platform.
5
5
  Author-email: Frequenz Energy-as-a-Service GmbH <floss@frequenz.com>
6
6
  License: MIT
@@ -25,6 +25,9 @@ Requires-Dist: asyncclick<9,>=8.3.0.4
25
25
  Requires-Dist: typing-extensions<5,>=4.14.1
26
26
  Requires-Dist: frequenz-microgrid-component-graph<0.4,>=0.3.4
27
27
  Requires-Dist: frequenz-client-assets<0.3,>=0.2.0
28
+ Provides-Extra: render-graph
29
+ Requires-Dist: matplotlib<4,>=3.7.0; extra == "render-graph"
30
+ Requires-Dist: networkx<4,>=3.0; extra == "render-graph"
28
31
  Provides-Extra: dev-flake8
29
32
  Requires-Dist: flake8==7.3.0; extra == "dev-flake8"
30
33
  Requires-Dist: flake8-docstrings==1.7.0; extra == "dev-flake8"
@@ -41,12 +44,13 @@ Requires-Dist: mike==2.1.3; extra == "dev-mkdocs"
41
44
  Requires-Dist: mkdocs-gen-files==0.5.0; extra == "dev-mkdocs"
42
45
  Requires-Dist: mkdocs-literate-nav==0.6.2; extra == "dev-mkdocs"
43
46
  Requires-Dist: mkdocs-macros-plugin==1.5.0; extra == "dev-mkdocs"
44
- Requires-Dist: mkdocs-material==9.7.0; extra == "dev-mkdocs"
45
- Requires-Dist: mkdocstrings[python]==1.0.0; extra == "dev-mkdocs"
47
+ Requires-Dist: mkdocs-material==9.7.1; extra == "dev-mkdocs"
48
+ Requires-Dist: mkdocstrings[python]==1.0.1; extra == "dev-mkdocs"
46
49
  Requires-Dist: mkdocstrings-python==1.19.0; extra == "dev-mkdocs"
47
50
  Requires-Dist: frequenz-repo-config[lib]==0.13.7; extra == "dev-mkdocs"
48
51
  Provides-Extra: dev-mypy
49
- Requires-Dist: mypy==1.19.0; extra == "dev-mypy"
52
+ Requires-Dist: mypy==1.19.1; extra == "dev-mypy"
53
+ Requires-Dist: types-networkx>=3.6.1.20251220; extra == "dev-mypy"
50
54
  Requires-Dist: types-Markdown==3.10.0.20251106; extra == "dev-mypy"
51
55
  Requires-Dist: frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-mypy"
52
56
  Provides-Extra: dev-noxfile
@@ -55,14 +59,14 @@ Requires-Dist: frequenz-repo-config[lib]==0.13.7; extra == "dev-noxfile"
55
59
  Provides-Extra: dev-pylint
56
60
  Requires-Dist: frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-pylint"
57
61
  Provides-Extra: dev-pytest
58
- Requires-Dist: pytest==8.4.1; extra == "dev-pytest"
62
+ Requires-Dist: pytest==9.0.2; extra == "dev-pytest"
59
63
  Requires-Dist: pylint==4.0.4; extra == "dev-pytest"
60
64
  Requires-Dist: frequenz-repo-config[extra-lint-examples]==0.13.7; extra == "dev-pytest"
61
65
  Requires-Dist: pytest-mock==3.15.1; extra == "dev-pytest"
62
66
  Requires-Dist: pytest-asyncio==1.3.0; extra == "dev-pytest"
63
67
  Requires-Dist: async-solipsism==0.9; extra == "dev-pytest"
64
68
  Provides-Extra: dev
65
- Requires-Dist: frequenz-gridpool[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]; extra == "dev"
69
+ Requires-Dist: frequenz-gridpool[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest,render-graph]; extra == "dev"
66
70
  Dynamic: license-file
67
71
 
68
72
  # Frequenz Gridpool Library
@@ -85,6 +89,50 @@ The following platforms are officially supported (tested):
85
89
  - **Operating System:** Ubuntu Linux 20.04
86
90
  - **Architectures:** amd64, arm64
87
91
 
92
+ ## CLI
93
+
94
+ This package ships the `gridpool-cli` command with two subcommands.
95
+
96
+ ### Setup
97
+
98
+ Set the Assets API credentials before running the CLI:
99
+
100
+ ```bash
101
+ export ASSETS_API_URL="grpc://..."
102
+ export ASSETS_API_AUTH_KEY="..."
103
+ export ASSETS_API_SIGN_SECRET="..."
104
+ ```
105
+
106
+ ### Print component formulas
107
+
108
+ ```bash
109
+ gridpool-cli print-formulas <microgrid_id>
110
+ ```
111
+
112
+ Optional prefix formatting:
113
+
114
+ ```bash
115
+ gridpool-cli print-formulas <microgrid_id> --prefix "{microgrid_id}.{component}"
116
+ ```
117
+
118
+ ### Render component graph
119
+
120
+ Rendering requires optional dependencies. Install with:
121
+
122
+ ```bash
123
+ pip install frequenz-gridpool[render-graph]
124
+ ```
125
+
126
+ ```bash
127
+ gridpool-cli render-graph <microgrid_id>
128
+ ```
129
+
130
+ To save without opening a window:
131
+
132
+ ```bash
133
+ gridpool-cli render-graph <microgrid_id> --no-show --output component_graph.png
134
+ ```
135
+
88
136
  ## Contributing
89
137
 
90
138
  If you want to know how to build this project and contribute to it, please
@@ -18,6 +18,50 @@ The following platforms are officially supported (tested):
18
18
  - **Operating System:** Ubuntu Linux 20.04
19
19
  - **Architectures:** amd64, arm64
20
20
 
21
+ ## CLI
22
+
23
+ This package ships the `gridpool-cli` command with two subcommands.
24
+
25
+ ### Setup
26
+
27
+ Set the Assets API credentials before running the CLI:
28
+
29
+ ```bash
30
+ export ASSETS_API_URL="grpc://..."
31
+ export ASSETS_API_AUTH_KEY="..."
32
+ export ASSETS_API_SIGN_SECRET="..."
33
+ ```
34
+
35
+ ### Print component formulas
36
+
37
+ ```bash
38
+ gridpool-cli print-formulas <microgrid_id>
39
+ ```
40
+
41
+ Optional prefix formatting:
42
+
43
+ ```bash
44
+ gridpool-cli print-formulas <microgrid_id> --prefix "{microgrid_id}.{component}"
45
+ ```
46
+
47
+ ### Render component graph
48
+
49
+ Rendering requires optional dependencies. Install with:
50
+
51
+ ```bash
52
+ pip install frequenz-gridpool[render-graph]
53
+ ```
54
+
55
+ ```bash
56
+ gridpool-cli render-graph <microgrid_id>
57
+ ```
58
+
59
+ To save without opening a window:
60
+
61
+ ```bash
62
+ gridpool-cli render-graph <microgrid_id> --no-show --output component_graph.png
63
+ ```
64
+
21
65
  ## Contributing
22
66
 
23
67
  If you want to know how to build this project and contribute to it, please
@@ -0,0 +1,19 @@
1
+ # Frequenz Gridpool Library Release Notes
2
+
3
+ ## Summary
4
+
5
+ <!-- Here goes a general summary of what this release is about -->
6
+
7
+ ## Upgrading
8
+
9
+ * The minimum required version of `frequenz-microgrid-component-graph` is now `v0.3.4`.
10
+
11
+ ## New Features
12
+
13
+ * Added `gridpool-cli render-graph` to visualize microgrid component graphs using the
14
+ Assets API credentials (`ASSETS_API_URL`, `ASSETS_API_AUTH_KEY`, and
15
+ `ASSETS_API_SIGN_SECRET`).
16
+
17
+ ## Bug Fixes
18
+
19
+ - Fixed component graph rendering so children follow the vertical order of their parents, keeping upper-level branches above lower ones in the layered layout.
@@ -3,7 +3,7 @@
3
3
 
4
4
  [build-system]
5
5
  requires = [
6
- "setuptools == 80.9.0",
6
+ "setuptools == 80.10.1",
7
7
  "setuptools_scm[toml] == 9.2.2",
8
8
  "frequenz-repo-config[lib] == 0.13.7",
9
9
  ]
@@ -42,6 +42,11 @@ name = "Frequenz Energy-as-a-Service GmbH"
42
42
  email = "floss@frequenz.com"
43
43
 
44
44
  [project.optional-dependencies]
45
+ render-graph = [
46
+ "matplotlib >= 3.7.0, < 4",
47
+ "networkx >= 3.0, < 4",
48
+ ]
49
+
45
50
  dev-flake8 = [
46
51
  "flake8 == 7.3.0",
47
52
  "flake8-docstrings == 1.7.0",
@@ -57,13 +62,14 @@ dev-mkdocs = [
57
62
  "mkdocs-gen-files == 0.5.0",
58
63
  "mkdocs-literate-nav == 0.6.2",
59
64
  "mkdocs-macros-plugin == 1.5.0",
60
- "mkdocs-material == 9.7.0",
61
- "mkdocstrings[python] == 1.0.0",
65
+ "mkdocs-material == 9.7.1",
66
+ "mkdocstrings[python] == 1.0.1",
62
67
  "mkdocstrings-python == 1.19.0",
63
68
  "frequenz-repo-config[lib] == 0.13.7",
64
69
  ]
65
70
  dev-mypy = [
66
- "mypy == 1.19.0",
71
+ "mypy == 1.19.1",
72
+ "types-networkx>=3.6.1.20251220",
67
73
  "types-Markdown == 3.10.0.20251106",
68
74
  # For checking the noxfile, docs/ script, and tests
69
75
  "frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]",
@@ -78,7 +84,7 @@ dev-pylint = [
78
84
  "frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]",
79
85
  ]
80
86
  dev-pytest = [
81
- "pytest == 8.4.1",
87
+ "pytest == 9.0.2",
82
88
  "pylint == 4.0.4", # We need this to check for the examples
83
89
  "frequenz-repo-config[extra-lint-examples] == 0.13.7",
84
90
  "pytest-mock == 3.15.1",
@@ -86,7 +92,7 @@ dev-pytest = [
86
92
  "async-solipsism == 0.9",
87
93
  ]
88
94
  dev = [
89
- "frequenz-gridpool[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]",
95
+ "frequenz-gridpool[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest,render-graph]",
90
96
  ]
91
97
 
92
98
  [project.urls]
@@ -10,6 +10,7 @@ from frequenz.client.assets import AssetsApiClient
10
10
  from frequenz.client.common.microgrid import MicrogridId
11
11
 
12
12
  from frequenz.gridpool import ComponentGraphGenerator
13
+ from frequenz.gridpool.cli._render_graph import ComponentGraphRenderer, RenderOptions
13
14
 
14
15
 
15
16
  @click.group()
@@ -63,6 +64,47 @@ async def print_formulas(
63
64
  )
64
65
 
65
66
 
67
+ @cli.command("render-graph")
68
+ @click.argument("microgrid_id", type=int)
69
+ @click.option(
70
+ "--output",
71
+ type=click.Path(dir_okay=False, writable=True),
72
+ default="component_graph.png",
73
+ show_default=True,
74
+ help="Output image path.",
75
+ )
76
+ @click.option(
77
+ "--show/--no-show",
78
+ default=True,
79
+ show_default=True,
80
+ help="Display the graph interactively.",
81
+ )
82
+ async def render_graph(microgrid_id: int, output: str, show: bool) -> None:
83
+ """Render and save a component graph visualization for a microgrid."""
84
+ url = os.environ.get("ASSETS_API_URL")
85
+ key = os.environ.get("ASSETS_API_AUTH_KEY")
86
+ secret = os.environ.get("ASSETS_API_SIGN_SECRET")
87
+ if not url or not key or not secret:
88
+ raise click.ClickException(
89
+ "ASSETS_API_URL, ASSETS_API_AUTH_KEY, ASSETS_API_SIGN_SECRET must be set."
90
+ )
91
+
92
+ try:
93
+ async with AssetsApiClient(
94
+ url,
95
+ auth_key=key,
96
+ sign_secret=secret,
97
+ ) as client:
98
+ renderer = ComponentGraphRenderer(client)
99
+ graph = await renderer.build_graph(MicrogridId(microgrid_id))
100
+ if not graph.nodes:
101
+ raise click.ClickException("No components found for this microgrid.")
102
+ pos = renderer.compute_layout(graph)
103
+ renderer.render(graph, pos, RenderOptions(output=output, show=show))
104
+ except RuntimeError as exc:
105
+ raise click.ClickException(str(exc)) from exc
106
+
107
+
66
108
  def main() -> None:
67
109
  """Run the CLI tool."""
68
110
  cli(_anyio_backend="asyncio")
@@ -0,0 +1,295 @@
1
+ # License: MIT
2
+ # Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3
+ # pylint: disable=import-error
4
+
5
+ """Render component graphs via the Assets API."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from frequenz.client.assets import AssetsApiClient
13
+ from frequenz.client.common.microgrid import MicrogridId
14
+
15
+
16
+ def _format_category(category: Any) -> str:
17
+ """Normalize a component category into a short display label."""
18
+ if category is None:
19
+ return "UNKNOWN"
20
+ if hasattr(category, "name"):
21
+ return str(category.name).replace("COMPONENT_CATEGORY_", "")
22
+ return str(category).replace("COMPONENT_CATEGORY_", "")
23
+
24
+
25
+ def _require_networkx() -> Any:
26
+ try:
27
+ # pylint: disable=import-outside-toplevel
28
+ import networkx as nx
29
+ except ModuleNotFoundError as exc:
30
+ raise RuntimeError(
31
+ "Rendering requires optional dependencies. Install "
32
+ "`frequenz-gridpool[render-graph]`."
33
+ ) from exc
34
+ return nx
35
+
36
+
37
+ def _require_matplotlib() -> Any:
38
+ try:
39
+ # pylint: disable=import-outside-toplevel
40
+ import matplotlib.pyplot as plt
41
+ except ModuleNotFoundError as exc:
42
+ raise RuntimeError(
43
+ "Rendering requires optional dependencies. Install "
44
+ "`frequenz-gridpool[render-graph]`."
45
+ ) from exc
46
+ return plt
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RenderOptions:
51
+ """Rendering options for component graphs."""
52
+
53
+ output: str
54
+ show: bool = True
55
+ figsize: tuple[int, int] = (15, 10)
56
+ title: str = "Microgrid Component Graph"
57
+
58
+
59
+ class ComponentGraphRenderer:
60
+ """Build and render component graphs for a microgrid."""
61
+
62
+ def __init__(self, client: AssetsApiClient) -> None:
63
+ """Initialize the renderer.
64
+
65
+ Args:
66
+ client: API client used to fetch microgrid components and their connections.
67
+ """
68
+ self._client = client
69
+
70
+ async def build_graph(self, microgrid_id: MicrogridId) -> Any:
71
+ """Build a directed graph representing the microgrid electrical topology.
72
+
73
+ Fetches electrical components and their connections for the given microgrid and
74
+ constructs a directed graph where nodes represent components and edges represent
75
+ connections from source to destination.
76
+
77
+ Args:
78
+ microgrid_id: Identifier of the microgrid to fetch and model.
79
+
80
+ Returns:
81
+ A directed graph containing component nodes and connection edges.
82
+ """
83
+ components = await self._client.list_microgrid_electrical_components(
84
+ microgrid_id
85
+ )
86
+ connections = (
87
+ await self._client.list_microgrid_electrical_component_connections(
88
+ microgrid_id
89
+ )
90
+ )
91
+
92
+ nx = _require_networkx()
93
+ graph = nx.DiGraph()
94
+
95
+ for component in components:
96
+ graph.add_node(
97
+ component.id,
98
+ name=component.name or str(component.id),
99
+ category=_format_category(component.category),
100
+ orig_id=component.id,
101
+ )
102
+
103
+ for connection in connections:
104
+ if connection is None:
105
+ continue
106
+ graph.add_edge(connection.source, connection.destination)
107
+
108
+ return graph
109
+
110
+ def compute_layout(self, graph: Any) -> dict[Any, tuple[float, float]]:
111
+ """Compute a layered node layout with the root on the left.
112
+
113
+ Selects a root node, groups nodes by shortest-path distance from the root, and
114
+ assigns (x, y) coordinates such that layers are spaced along the x-axis.
115
+
116
+ Args:
117
+ graph: A graph-like object containing nodes and edges.
118
+
119
+ Returns:
120
+ A mapping from node identifiers to (x, y) coordinates.
121
+ """
122
+ if not graph.nodes:
123
+ return {}
124
+
125
+ root_node = self._select_root(graph)
126
+ layered_nodes = self._group_by_level(graph, root_node)
127
+ ordered_layers = self._order_layers(graph, layered_nodes)
128
+ return self._build_positions(ordered_layers)
129
+
130
+ def _select_root(self, graph: Any) -> Any:
131
+ """Select a root node for layout.
132
+
133
+ Prefers a node with no incoming edges and at least one outgoing edge. If no such
134
+ node exists, falls back to an arbitrary node.
135
+
136
+ Args:
137
+ graph: A graph-like object containing nodes and edges.
138
+
139
+ Returns:
140
+ The selected root node identifier.
141
+ """
142
+ roots = [
143
+ node
144
+ for node in graph.nodes
145
+ if graph.in_degree(node) == 0 and graph.out_degree(node) > 0
146
+ ]
147
+ return roots[0] if roots else list(graph.nodes)[0]
148
+
149
+ def _group_by_level(self, graph: Any, root_node: Any) -> dict[int, list[Any]]:
150
+ """Group nodes into layers by shortest-path distance from a root node.
151
+
152
+ Nodes reachable from the root are assigned to layers based on their shortest-path
153
+ distance. Nodes not reachable from the root are placed into a final "orphan"
154
+ layer after the deepest reachable layer.
155
+
156
+ Args:
157
+ graph: A graph-like object containing nodes and edges.
158
+ root_node: The node to treat as the root for distance computation.
159
+
160
+ Returns:
161
+ A mapping from layer index to the list of nodes in that layer.
162
+ """
163
+ nx = _require_networkx()
164
+ levels = nx.single_source_shortest_path_length(graph, root_node)
165
+ layered_nodes: dict[int, list[Any]] = {}
166
+ for node, dist in levels.items():
167
+ layered_nodes.setdefault(dist, []).append(node)
168
+
169
+ orphans = [node for node in graph.nodes if node not in levels]
170
+ if orphans:
171
+ orphan_layer = max(layered_nodes.keys()) + 1 if layered_nodes else 0
172
+ layered_nodes[orphan_layer] = orphans
173
+ return layered_nodes
174
+
175
+ def _order_layers(
176
+ self, graph: Any, layered_nodes: dict[int, list[Any]]
177
+ ) -> dict[int, list[Any]]:
178
+ """Order nodes within each layer based on their parent positions.
179
+
180
+ Reorders the nodes in each layer to improve visual readability when rendering a
181
+ layered graph layout. The first layer is ordered deterministically (by string
182
+ representation). For subsequent layers, nodes are ordered primarily by the
183
+ lowest index of any already-ordered parent (predecessor) from the previous
184
+ layers, with a deterministic string-based tie-breaker.
185
+
186
+ Args:
187
+ graph:
188
+ Graph-like object providing predecessor relationships via
189
+ ``graph.predecessors(node)``.
190
+ layered_nodes:
191
+ Mapping from layer index to the list of nodes assigned to that layer.
192
+
193
+ Returns:
194
+ A new mapping with the same layer keys as ``layered_nodes``, where each
195
+ layer's node list is ordered to align children beneath their parents.
196
+ """
197
+ ordered_layers: dict[int, list[Any]] = {}
198
+ previous_order: dict[Any, int] = {}
199
+
200
+ for level in sorted(layered_nodes):
201
+ nodes = layered_nodes[level]
202
+ if level == 0:
203
+ ordered_nodes = sorted(nodes, key=str)
204
+ else:
205
+
206
+ def sort_key(node: Any) -> tuple[int, str]:
207
+ parents = [
208
+ parent
209
+ for parent in graph.predecessors(node)
210
+ if parent in previous_order
211
+ ]
212
+ if parents:
213
+ parent_index = min(previous_order[parent] for parent in parents)
214
+ else:
215
+ parent_index = len(previous_order)
216
+ return (parent_index, str(node))
217
+
218
+ ordered_nodes = sorted(nodes, key=sort_key)
219
+
220
+ ordered_layers[level] = ordered_nodes
221
+ previous_order = {node: idx for idx, node in enumerate(ordered_nodes)}
222
+
223
+ return ordered_layers
224
+
225
+ def _build_positions(
226
+ self, layered_nodes: dict[int, list[Any]]
227
+ ) -> dict[Any, tuple[float, float]]:
228
+ """Compute node positions from pre-grouped layers.
229
+
230
+ Assigns x-coordinates based on layer index and y-coordinates by distributing
231
+ nodes evenly within each layer.
232
+
233
+ Args:
234
+ layered_nodes: Mapping from layer index to the list of nodes in that layer.
235
+
236
+ Returns:
237
+ A mapping from node identifiers to (x, y) coordinates.
238
+ """
239
+ x_spacing, y_spacing = 2.5, 1.2
240
+ pos: dict[Any, tuple[float, float]] = {}
241
+ for level in sorted(layered_nodes):
242
+ nodes = layered_nodes[level]
243
+ x_pos = level * x_spacing
244
+ y_start = (len(nodes) - 1) * y_spacing / 2
245
+ for i, node in enumerate(nodes):
246
+ pos[node] = (x_pos, y_start - (i * y_spacing))
247
+ return pos
248
+
249
+ def render(
250
+ self, graph: Any, pos: dict[Any, tuple[float, float]], options: RenderOptions
251
+ ) -> None:
252
+ """Render a component graph to an image file and optionally display it.
253
+
254
+ This method uses NetworkX and Matplotlib to draw the given graph using the
255
+ provided node positions, applies basic styling (labels, colors, arrows),
256
+ saves the resulting figure to the path specified in ``options.output``,
257
+ and, if configured, opens an interactive window to show the plot.
258
+
259
+ Args:
260
+ graph: A NetworkX graph-like object (typically a ``DiGraph``) whose
261
+ nodes and edges represent the microgrid components and their
262
+ electrical connections.
263
+ pos: A mapping from node identifiers to ``(x, y)`` coordinates used
264
+ to place each node in the rendered figure, usually obtained from
265
+ :meth:`compute_layout`.
266
+ options: Rendering configuration, including output file path, whether
267
+ to show the figure interactively, figure size, and plot title.
268
+ """
269
+ nx = _require_networkx()
270
+ plt = _require_matplotlib()
271
+
272
+ node_labels = {
273
+ node: (
274
+ f'{graph.nodes[node].get("name", str(node))}\n'
275
+ f'ID: {graph.nodes[node].get("orig_id", node)}'
276
+ )
277
+ for node in graph.nodes
278
+ }
279
+
280
+ plt.figure(figsize=options.figsize)
281
+ nx.draw(
282
+ graph,
283
+ pos,
284
+ with_labels=True,
285
+ labels=node_labels,
286
+ node_color="lightblue",
287
+ edge_color="gray",
288
+ node_size=800,
289
+ font_size=8,
290
+ )
291
+ plt.title(options.title)
292
+ plt.tight_layout()
293
+ plt.savefig(options.output, dpi=300)
294
+ if options.show:
295
+ plt.show()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frequenz-gridpool
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: High-level interface to grid pools for the Frequenz platform.
5
5
  Author-email: Frequenz Energy-as-a-Service GmbH <floss@frequenz.com>
6
6
  License: MIT
@@ -25,6 +25,9 @@ Requires-Dist: asyncclick<9,>=8.3.0.4
25
25
  Requires-Dist: typing-extensions<5,>=4.14.1
26
26
  Requires-Dist: frequenz-microgrid-component-graph<0.4,>=0.3.4
27
27
  Requires-Dist: frequenz-client-assets<0.3,>=0.2.0
28
+ Provides-Extra: render-graph
29
+ Requires-Dist: matplotlib<4,>=3.7.0; extra == "render-graph"
30
+ Requires-Dist: networkx<4,>=3.0; extra == "render-graph"
28
31
  Provides-Extra: dev-flake8
29
32
  Requires-Dist: flake8==7.3.0; extra == "dev-flake8"
30
33
  Requires-Dist: flake8-docstrings==1.7.0; extra == "dev-flake8"
@@ -41,12 +44,13 @@ Requires-Dist: mike==2.1.3; extra == "dev-mkdocs"
41
44
  Requires-Dist: mkdocs-gen-files==0.5.0; extra == "dev-mkdocs"
42
45
  Requires-Dist: mkdocs-literate-nav==0.6.2; extra == "dev-mkdocs"
43
46
  Requires-Dist: mkdocs-macros-plugin==1.5.0; extra == "dev-mkdocs"
44
- Requires-Dist: mkdocs-material==9.7.0; extra == "dev-mkdocs"
45
- Requires-Dist: mkdocstrings[python]==1.0.0; extra == "dev-mkdocs"
47
+ Requires-Dist: mkdocs-material==9.7.1; extra == "dev-mkdocs"
48
+ Requires-Dist: mkdocstrings[python]==1.0.1; extra == "dev-mkdocs"
46
49
  Requires-Dist: mkdocstrings-python==1.19.0; extra == "dev-mkdocs"
47
50
  Requires-Dist: frequenz-repo-config[lib]==0.13.7; extra == "dev-mkdocs"
48
51
  Provides-Extra: dev-mypy
49
- Requires-Dist: mypy==1.19.0; extra == "dev-mypy"
52
+ Requires-Dist: mypy==1.19.1; extra == "dev-mypy"
53
+ Requires-Dist: types-networkx>=3.6.1.20251220; extra == "dev-mypy"
50
54
  Requires-Dist: types-Markdown==3.10.0.20251106; extra == "dev-mypy"
51
55
  Requires-Dist: frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-mypy"
52
56
  Provides-Extra: dev-noxfile
@@ -55,14 +59,14 @@ Requires-Dist: frequenz-repo-config[lib]==0.13.7; extra == "dev-noxfile"
55
59
  Provides-Extra: dev-pylint
56
60
  Requires-Dist: frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-pylint"
57
61
  Provides-Extra: dev-pytest
58
- Requires-Dist: pytest==8.4.1; extra == "dev-pytest"
62
+ Requires-Dist: pytest==9.0.2; extra == "dev-pytest"
59
63
  Requires-Dist: pylint==4.0.4; extra == "dev-pytest"
60
64
  Requires-Dist: frequenz-repo-config[extra-lint-examples]==0.13.7; extra == "dev-pytest"
61
65
  Requires-Dist: pytest-mock==3.15.1; extra == "dev-pytest"
62
66
  Requires-Dist: pytest-asyncio==1.3.0; extra == "dev-pytest"
63
67
  Requires-Dist: async-solipsism==0.9; extra == "dev-pytest"
64
68
  Provides-Extra: dev
65
- Requires-Dist: frequenz-gridpool[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]; extra == "dev"
69
+ Requires-Dist: frequenz-gridpool[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest,render-graph]; extra == "dev"
66
70
  Dynamic: license-file
67
71
 
68
72
  # Frequenz Gridpool Library
@@ -85,6 +89,50 @@ The following platforms are officially supported (tested):
85
89
  - **Operating System:** Ubuntu Linux 20.04
86
90
  - **Architectures:** amd64, arm64
87
91
 
92
+ ## CLI
93
+
94
+ This package ships the `gridpool-cli` command with two subcommands.
95
+
96
+ ### Setup
97
+
98
+ Set the Assets API credentials before running the CLI:
99
+
100
+ ```bash
101
+ export ASSETS_API_URL="grpc://..."
102
+ export ASSETS_API_AUTH_KEY="..."
103
+ export ASSETS_API_SIGN_SECRET="..."
104
+ ```
105
+
106
+ ### Print component formulas
107
+
108
+ ```bash
109
+ gridpool-cli print-formulas <microgrid_id>
110
+ ```
111
+
112
+ Optional prefix formatting:
113
+
114
+ ```bash
115
+ gridpool-cli print-formulas <microgrid_id> --prefix "{microgrid_id}.{component}"
116
+ ```
117
+
118
+ ### Render component graph
119
+
120
+ Rendering requires optional dependencies. Install with:
121
+
122
+ ```bash
123
+ pip install frequenz-gridpool[render-graph]
124
+ ```
125
+
126
+ ```bash
127
+ gridpool-cli render-graph <microgrid_id>
128
+ ```
129
+
130
+ To save without opening a window:
131
+
132
+ ```bash
133
+ gridpool-cli render-graph <microgrid_id> --no-show --output component_graph.png
134
+ ```
135
+
88
136
  ## Contributing
89
137
 
90
138
  If you want to know how to build this project and contribute to it, please
@@ -10,6 +10,7 @@ src/frequenz/gridpool/conftest.py
10
10
  src/frequenz/gridpool/py.typed
11
11
  src/frequenz/gridpool/cli/__init__.py
12
12
  src/frequenz/gridpool/cli/__main__.py
13
+ src/frequenz/gridpool/cli/_render_graph.py
13
14
  src/frequenz_gridpool.egg-info/PKG-INFO
14
15
  src/frequenz_gridpool.egg-info/SOURCES.txt
15
16
  src/frequenz_gridpool.egg-info/dependency_links.txt
@@ -5,7 +5,7 @@ frequenz-microgrid-component-graph<0.4,>=0.3.4
5
5
  frequenz-client-assets<0.3,>=0.2.0
6
6
 
7
7
  [dev]
8
- frequenz-gridpool[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]
8
+ frequenz-gridpool[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest,render-graph]
9
9
 
10
10
  [dev-flake8]
11
11
  flake8==7.3.0
@@ -25,13 +25,14 @@ mike==2.1.3
25
25
  mkdocs-gen-files==0.5.0
26
26
  mkdocs-literate-nav==0.6.2
27
27
  mkdocs-macros-plugin==1.5.0
28
- mkdocs-material==9.7.0
29
- mkdocstrings[python]==1.0.0
28
+ mkdocs-material==9.7.1
29
+ mkdocstrings[python]==1.0.1
30
30
  mkdocstrings-python==1.19.0
31
31
  frequenz-repo-config[lib]==0.13.7
32
32
 
33
33
  [dev-mypy]
34
- mypy==1.19.0
34
+ mypy==1.19.1
35
+ types-networkx>=3.6.1.20251220
35
36
  types-Markdown==3.10.0.20251106
36
37
  frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]
37
38
 
@@ -43,9 +44,13 @@ frequenz-repo-config[lib]==0.13.7
43
44
  frequenz-gridpool[dev-mkdocs,dev-noxfile,dev-pytest]
44
45
 
45
46
  [dev-pytest]
46
- pytest==8.4.1
47
+ pytest==9.0.2
47
48
  pylint==4.0.4
48
49
  frequenz-repo-config[extra-lint-examples]==0.13.7
49
50
  pytest-mock==3.15.1
50
51
  pytest-asyncio==1.3.0
51
52
  async-solipsism==0.9
53
+
54
+ [render-graph]
55
+ matplotlib<4,>=3.7.0
56
+ networkx<4,>=3.0
@@ -1,17 +0,0 @@
1
- # Frequenz Gridpool Library Release Notes
2
-
3
- ## Summary
4
-
5
- <!-- Here goes a general summary of what this release is about -->
6
-
7
- ## Upgrading
8
-
9
- * The minimum required version of `frequenz-microgrid-component-graph` is now `v0.3.4`.
10
-
11
- ## New Features
12
-
13
- <!-- Here goes the main new features and examples or instructions on how to use them -->
14
-
15
- ## Bug Fixes
16
-
17
- <!-- Here goes notable bug fixes that are worth a special mention or explanation -->