Skip to content

Commit c953071

Browse files
authored
Switch to the rust component graph (#1295)
- Also implements the new `Formula`/`Formula3Phase` classes to replace the old formula engine, for supporting the new formula functions - max/min/coalesce. - Drops the old component graph and the old formula engine
2 parents 615288c + 5f644c8 commit c953071

File tree

79 files changed

+3843
-9476
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+3843
-9476
lines changed

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
<!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
1010

11+
- The `FormulaEngine` is now replaced by a newly implemented `Formula` type. This doesn't affect the high level interfaces.
12+
13+
- The `ComponentGraph` has been replaced by the `frequenz-microgrid-component-graph` package, which provides python bindings for the rust implementation.
14+
1115
## New Features
1216

1317
- The power manager algorithm for batteries can now be changed from the default ShiftingMatryoshka, by passing it as an argument to `microgrid.initialize()`
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
# Formula Engine
1+
# Formulas
22

3-
::: frequenz.sdk.timeseries.formula_engine
3+
::: frequenz.sdk.timeseries.formulas
44
options:
55
members: None
66
show_bases: false

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ dependencies = [
3030
# changing the version
3131
# (plugins.mkdocstrings.handlers.python.import)
3232
"frequenz-client-microgrid >= 0.18.0, < 0.19.0",
33+
"frequenz-microgrid-component-graph >= 0.2.0, < 0.3",
3334
"frequenz-client-common >= 0.3.6, < 0.4.0",
3435
"frequenz-channels >= 1.6.1, < 2.0.0",
3536
"frequenz-quantities[marshmallow] >= 1.0.0, < 2.0.0",
36-
"networkx >= 2.8, < 4",
3737
"numpy >= 2.1.0, < 3",
38-
"typing_extensions >= 4.13.0, < 5",
38+
"typing_extensions >= 4.14.1, < 5",
3939
"marshmallow >= 3.19.0, < 5",
4040
"marshmallow_dataclass >= 8.7.1, < 9",
4141
]
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Graph traversal helpers."""
5+
6+
from __future__ import annotations
7+
8+
from collections.abc import Iterable
9+
from typing import Callable
10+
11+
from frequenz.client.common.microgrid.components import ComponentId
12+
from frequenz.client.microgrid.component import (
13+
BatteryInverter,
14+
Chp,
15+
Component,
16+
ComponentConnection,
17+
EvCharger,
18+
GridConnectionPoint,
19+
SolarInverter,
20+
)
21+
from frequenz.microgrid_component_graph import ComponentGraph, InvalidGraphError
22+
23+
24+
def is_pv_inverter(component: Component) -> bool:
25+
"""Check if the component is a PV inverter.
26+
27+
Args:
28+
component: The component to check.
29+
30+
Returns:
31+
`True` if the component is a PV inverter, `False` otherwise.
32+
"""
33+
return isinstance(component, SolarInverter)
34+
35+
36+
def is_battery_inverter(component: Component) -> bool:
37+
"""Check if the component is a battery inverter.
38+
39+
Args:
40+
component: The component to check.
41+
42+
Returns:
43+
`True` if the component is a battery inverter, `False` otherwise.
44+
"""
45+
return isinstance(component, BatteryInverter)
46+
47+
48+
def is_chp(component: Component) -> bool:
49+
"""Check if the component is a CHP.
50+
51+
Args:
52+
component: The component to check.
53+
54+
Returns:
55+
`True` if the component is a CHP, `False` otherwise.
56+
"""
57+
return isinstance(component, Chp)
58+
59+
60+
def is_ev_charger(component: Component) -> bool:
61+
"""Check if the component is an EV charger.
62+
63+
Args:
64+
component: The component to check.
65+
66+
Returns:
67+
`True` if the component is an EV charger, `False` otherwise.
68+
"""
69+
return isinstance(component, EvCharger)
70+
71+
72+
def is_battery_chain(
73+
graph: ComponentGraph[Component, ComponentConnection, ComponentId],
74+
component: Component,
75+
) -> bool:
76+
"""Check if the specified component is part of a battery chain.
77+
78+
A component is part of a battery chain if it is either a battery inverter or a
79+
battery meter.
80+
81+
Args:
82+
graph: The component graph.
83+
component: component to check.
84+
85+
Returns:
86+
Whether the specified component is part of a battery chain.
87+
"""
88+
return is_battery_inverter(component) or graph.is_battery_meter(component.id)
89+
90+
91+
def is_pv_chain(
92+
graph: ComponentGraph[Component, ComponentConnection, ComponentId],
93+
component: Component,
94+
) -> bool:
95+
"""Check if the specified component is part of a PV chain.
96+
97+
A component is part of a PV chain if it is either a PV inverter or a PV
98+
meter.
99+
100+
Args:
101+
graph: The component graph.
102+
component: component to check.
103+
104+
Returns:
105+
Whether the specified component is part of a PV chain.
106+
"""
107+
return is_pv_inverter(component) or graph.is_pv_meter(component.id)
108+
109+
110+
def is_ev_charger_chain(
111+
graph: ComponentGraph[Component, ComponentConnection, ComponentId],
112+
component: Component,
113+
) -> bool:
114+
"""Check if the specified component is part of an EV charger chain.
115+
116+
A component is part of an EV charger chain if it is either an EV charger or an
117+
EV charger meter.
118+
119+
Args:
120+
graph: The component graph.
121+
component: component to check.
122+
123+
Returns:
124+
Whether the specified component is part of an EV charger chain.
125+
"""
126+
return is_ev_charger(component) or graph.is_ev_charger_meter(component.id)
127+
128+
129+
def is_chp_chain(
130+
graph: ComponentGraph[Component, ComponentConnection, ComponentId],
131+
component: Component,
132+
) -> bool:
133+
"""Check if the specified component is part of a CHP chain.
134+
135+
A component is part of a CHP chain if it is either a CHP or a CHP meter.
136+
137+
Args:
138+
graph: The component graph.
139+
component: component to check.
140+
141+
Returns:
142+
Whether the specified component is part of a CHP chain.
143+
"""
144+
return is_chp(component) or graph.is_chp_meter(component.id)
145+
146+
147+
def dfs(
148+
graph: ComponentGraph[Component, ComponentConnection, ComponentId],
149+
current_node: Component,
150+
visited: set[Component],
151+
condition: Callable[[Component], bool],
152+
) -> set[Component]:
153+
"""
154+
Search for components that fulfill the condition in the Graph.
155+
156+
DFS is used for searching the graph. The graph traversal is stopped
157+
once a component fulfills the condition.
158+
159+
Args:
160+
graph: The component graph.
161+
current_node: The current node to search from.
162+
visited: The set of visited nodes.
163+
condition: The condition function to check for.
164+
165+
Returns:
166+
A set of component ids where the corresponding components fulfill
167+
the condition function.
168+
"""
169+
if current_node in visited:
170+
return set()
171+
172+
visited.add(current_node)
173+
174+
if condition(current_node):
175+
return {current_node}
176+
177+
component: set[Component] = set()
178+
179+
for successor in graph.successors(current_node.id):
180+
component.update(dfs(graph, successor, visited, condition))
181+
182+
return component
183+
184+
185+
def find_first_descendant_component(
186+
graph: ComponentGraph[Component, ComponentConnection, ComponentId],
187+
*,
188+
descendants: Iterable[type[Component]],
189+
) -> Component:
190+
"""Find the first descendant component given root and descendant categories.
191+
192+
This method looks for the first descendant component from the GRID
193+
component, considering only the immediate descendants.
194+
195+
The priority of the component to search for is determined by the order
196+
of the descendant categories, with the first category having the
197+
highest priority.
198+
199+
Args:
200+
graph: The component graph to search.
201+
descendants: The descendant classes to search for the first
202+
descendant component in.
203+
204+
Returns:
205+
The first descendant component found in the component graph,
206+
considering the specified `descendants` categories.
207+
208+
Raises:
209+
InvalidGraphError: When no GRID component is found in the graph.
210+
ValueError: When no component is found in the given categories.
211+
"""
212+
# We always sort by component ID to ensure consistent results
213+
214+
def sorted_by_id(components: Iterable[Component]) -> Iterable[Component]:
215+
return sorted(components, key=lambda c: c.id)
216+
217+
root_component = next(
218+
iter(sorted_by_id(graph.components(matching_types={GridConnectionPoint}))),
219+
None,
220+
)
221+
if root_component is None:
222+
raise InvalidGraphError(
223+
"No GridConnectionPoint component found in the component graph!"
224+
)
225+
226+
successors = sorted_by_id(graph.successors(root_component.id))
227+
228+
def find_component(component_class: type[Component]) -> Component | None:
229+
return next(
230+
(comp for comp in successors if isinstance(comp, component_class)),
231+
None,
232+
)
233+
234+
# Find the first component that matches the given descendant categories
235+
# in the order of the categories list.
236+
component = next(filter(None, map(find_component, descendants)), None)
237+
238+
if component is None:
239+
raise ValueError("Component not found in any of the descendant categories.")
240+
241+
return component

src/frequenz/sdk/microgrid/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@
153153
## Streaming component data
154154
155155
All pools have a `power` property, which is a
156-
[`FormulaEngine`][frequenz.sdk.timeseries.formula_engine.FormulaEngine] that can
156+
[`Formula`][frequenz.sdk.timeseries.formulas.Formula] that can
157157
158158
- provide a stream of resampled power values, which correspond to the sum of the
159159
power measured from all the components in the pool together.

0 commit comments

Comments
 (0)