Skip to content

Commit c9d613e

Browse files
Add fallback formula feature to ConsumerPowerFormula
Signed-off-by: Elzbieta Kotulska <[email protected]>
1 parent 226d628 commit c9d613e

File tree

4 files changed

+316
-23
lines changed

4 files changed

+316
-23
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- PVPowerFormula
1515
- ProducerPowerFormula
1616
- BatteryPowerFormula
17+
- ConsumerPowerFormula
1718

1819
## Bug Fixes
1920

src/frequenz/sdk/timeseries/formula_engine/_formula_generators/_consumer_power_formula.py

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
from ..._quantities import Power
1212
from .._formula_engine import FormulaEngine
1313
from .._resampled_formula_builder import ResampledFormulaBuilder
14+
from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher
1415
from ._formula_generator import (
1516
NON_EXISTING_COMPONENT_ID,
1617
ComponentNotFound,
1718
FormulaGenerator,
19+
FormulaGeneratorConfig,
1820
)
21+
from ._simple_power_formula import SimplePowerFormula
1922

2023
_logger = logging.getLogger(__name__)
2124

@@ -27,6 +30,25 @@ class ConsumerPowerFormula(FormulaGenerator[Power]):
2730
are not part of a battery, CHP, PV or EV charger chain.
2831
"""
2932

33+
def _are_grid_meters(self, grid_successors: set[Component]) -> bool:
34+
"""Check if the grid successors are grid meters.
35+
36+
Args:
37+
grid_successors: The successors of the grid component.
38+
39+
Returns:
40+
True if the provided components are grid meters, False otherwise.
41+
"""
42+
component_graph = connection_manager.get().component_graph
43+
return all(
44+
successor.category == ComponentCategory.METER
45+
and not component_graph.is_battery_chain(successor)
46+
and not component_graph.is_chp_chain(successor)
47+
and not component_graph.is_pv_chain(successor)
48+
and not component_graph.is_ev_charger_chain(successor)
49+
for successor in grid_successors
50+
)
51+
3052
def generate(self) -> FormulaEngine[Power]:
3153
"""Generate formula for calculating consumer power from the component graph.
3254
@@ -48,15 +70,7 @@ def generate(self) -> FormulaEngine[Power]:
4870
if not grid_successors:
4971
raise ComponentNotFound("No components found in the component graph.")
5072

51-
component_graph = connection_manager.get().component_graph
52-
if all(
53-
successor.category == ComponentCategory.METER
54-
and not component_graph.is_battery_chain(successor)
55-
and not component_graph.is_chp_chain(successor)
56-
and not component_graph.is_pv_chain(successor)
57-
and not component_graph.is_ev_charger_chain(successor)
58-
for successor in grid_successors
59-
):
73+
if self._are_grid_meters(grid_successors):
6074
return self._gen_with_grid_meter(builder, grid_successors)
6175

6276
return self._gen_without_grid_meter(builder, self._get_grid_component())
@@ -112,13 +126,30 @@ def non_consumer_component(component: Component) -> bool:
112126
grid_meter.component_id, nones_are_zeros=False
113127
)
114128

115-
# push all non consumer components and subtract them from the grid meters
116-
for component in non_consumer_components:
117-
builder.push_oper("-")
118-
builder.push_component_metric(
119-
component.component_id,
120-
nones_are_zeros=component.category != ComponentCategory.METER,
121-
)
129+
if self._config.allow_fallback:
130+
fallbacks = self._get_fallback_formulas(non_consumer_components)
131+
132+
for idx, (primary_component, fallback_formula) in enumerate(
133+
fallbacks.items()
134+
):
135+
builder.push_oper("-")
136+
137+
# should only be the case if the component is not a meter
138+
builder.push_component_metric(
139+
primary_component.component_id,
140+
nones_are_zeros=(
141+
primary_component.category != ComponentCategory.METER
142+
),
143+
fallback=fallback_formula,
144+
)
145+
else:
146+
# push all non consumer components and subtract them from the grid meters
147+
for component in non_consumer_components:
148+
builder.push_oper("-")
149+
builder.push_component_metric(
150+
component.component_id,
151+
nones_are_zeros=component.category != ComponentCategory.METER,
152+
)
122153

123154
return builder.build()
124155

@@ -175,13 +206,76 @@ def consumer_component(component: Component) -> bool:
175206
)
176207
return builder.build()
177208

178-
for idx, component in enumerate(consumer_components):
179-
if idx > 0:
180-
builder.push_oper("+")
209+
if self._config.allow_fallback:
210+
fallbacks = self._get_fallback_formulas(consumer_components)
211+
212+
for idx, (primary_component, fallback_formula) in enumerate(
213+
fallbacks.items()
214+
):
215+
if idx > 0:
216+
builder.push_oper("+")
217+
218+
# should only be the case if the component is not a meter
219+
builder.push_component_metric(
220+
primary_component.component_id,
221+
nones_are_zeros=(
222+
primary_component.category != ComponentCategory.METER
223+
),
224+
fallback=fallback_formula,
225+
)
226+
else:
227+
for idx, component in enumerate(consumer_components):
228+
if idx > 0:
229+
builder.push_oper("+")
230+
231+
builder.push_component_metric(
232+
component.component_id,
233+
nones_are_zeros=component.category != ComponentCategory.METER,
234+
)
181235

182-
builder.push_component_metric(
183-
component.component_id,
184-
nones_are_zeros=component.category != ComponentCategory.METER,
236+
return builder.build()
237+
238+
def _get_fallback_formulas(
239+
self, components: set[Component]
240+
) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]:
241+
"""Find primary and fallback components and create fallback formulas.
242+
243+
The primary component is the one that will be used to calculate the consumer power.
244+
However, if it is not available, the fallback formula will be used instead.
245+
Fallback formulas calculate the consumer power using the fallback components.
246+
Fallback formulas are wrapped in `FallbackFormulaMetricFetcher` to allow
247+
for lazy initialization.
248+
249+
Args:
250+
components: The producer components.
251+
252+
Returns:
253+
A dictionary mapping primary components to their FallbackFormulaMetricFetcher.
254+
"""
255+
fallbacks = self._get_metric_fallback_components(components)
256+
257+
fallback_formulas: dict[
258+
Component, FallbackFormulaMetricFetcher[Power] | None
259+
] = {}
260+
261+
for primary_component, fallback_components in fallbacks.items():
262+
if len(fallback_components) == 0:
263+
fallback_formulas[primary_component] = None
264+
continue
265+
266+
fallback_ids = [c.component_id for c in fallback_components]
267+
generator = SimplePowerFormula(
268+
f"{self._namespace}_fallback_{fallback_ids}",
269+
self._channel_registry,
270+
self._resampler_subscription_sender,
271+
FormulaGeneratorConfig(
272+
component_ids=set(fallback_ids),
273+
allow_fallback=False,
274+
),
185275
)
186276

187-
return builder.build()
277+
fallback_formulas[primary_component] = FallbackFormulaMetricFetcher(
278+
generator
279+
)
280+
281+
return fallback_formulas
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Formula generator from component graph."""
5+
6+
from frequenz.client.microgrid import ComponentCategory, ComponentMetricId
7+
8+
from ....microgrid import connection_manager
9+
from ..._quantities import Power
10+
from .._formula_engine import FormulaEngine
11+
from ._formula_generator import FormulaGenerator
12+
13+
14+
class SimplePowerFormula(FormulaGenerator[Power]):
15+
"""Formula generator from component graph for calculating sum of Power.
16+
17+
Raises:
18+
RuntimeError: If no components are defined in the config or if any
19+
component is not found in the component graph.
20+
"""
21+
22+
def generate( # noqa: DOC502
23+
# * ComponentNotFound is raised indirectly by _get_grid_component()
24+
# * RuntimeError is raised indirectly by connection_manager.get()
25+
self,
26+
) -> FormulaEngine[Power]:
27+
"""Generate formula for calculating producer power from the component graph.
28+
29+
Returns:
30+
A formula engine that will calculate the producer power.
31+
32+
Raises:
33+
ComponentNotFound: If the component graph does not contain a producer power
34+
component.
35+
RuntimeError: If the grid component has a single successor that is not a
36+
meter.
37+
"""
38+
builder = self._get_builder(
39+
"simple_power_formula", ComponentMetricId.ACTIVE_POWER, Power.from_watts
40+
)
41+
42+
component_graph = connection_manager.get().component_graph
43+
if self._config.component_ids is None:
44+
raise RuntimeError("Power formula without component ids is not supported.")
45+
46+
components = component_graph.components(
47+
component_ids=set(self._config.component_ids)
48+
)
49+
50+
not_found_components = self._config.component_ids - {
51+
c.component_id for c in components
52+
}
53+
if not_found_components:
54+
raise RuntimeError(
55+
f"Unable to find {not_found_components} components in the component graph. ",
56+
)
57+
58+
for idx, component in enumerate(components):
59+
if idx > 0:
60+
builder.push_oper("+")
61+
62+
builder.push_component_metric(
63+
component.component_id,
64+
nones_are_zeros=component.category != ComponentCategory.METER,
65+
)
66+
67+
return builder.build()

tests/timeseries/test_consumer.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,134 @@ async def test_consumer_power_no_grid_meter_no_consumer_meter(
6666
assert (await consumer_power_receiver.receive()).value == Power.from_watts(
6767
0.0
6868
)
69+
70+
async def test_consumer_power_fallback_formula_with_grid_meter(
71+
self, mocker: MockerFixture
72+
) -> None:
73+
"""Test the consumer power formula with a grid meter."""
74+
mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker)
75+
mockgrid.add_batteries(1)
76+
mockgrid.add_solar_inverters(1)
77+
mockgrid.add_solar_inverters(1, no_meter=True)
78+
79+
# formula is grid_meter - battery - pv1 - pv2
80+
81+
async with mockgrid, AsyncExitStack() as stack:
82+
consumer = microgrid.consumer()
83+
stack.push_async_callback(consumer.stop)
84+
consumer_power_formula = consumer.power
85+
print(consumer_power_formula)
86+
consumer_power_receiver = consumer_power_formula.new_receiver()
87+
88+
# Note: ConsumerPowerFormula has a "nones-are-zero" rule, that says:
89+
# * if the meter value is None, it should be treated as None.
90+
# * for other components None is treated as 0.
91+
92+
# fmt: off
93+
expected_input_output: list[
94+
tuple[list[float | None], list[float | None], list[float | None], Power | None]
95+
] = [
96+
# ([grid_meter, bat_meter, pv1_meter], [bat_inv], [pv1_inv, pv2_inv], expected_power) # noqa: E501
97+
([100, 100, -50], [100], [-200, -300], Power.from_watts(350)),
98+
([500, -200, -100], [100], [-200, -100], Power.from_watts(900)),
99+
# Case 2: The meter is unavailable (None).
100+
# Subscribe to the fallback inverter, but return None as the result,
101+
# according to the "nones-are-zero" rule
102+
([500, None, -100], [100], [-200, -100], None),
103+
([500, None, -100], [100], [-200, -100], Power.from_watts(600)),
104+
# Case 3: Second meter is unavailable (None).
105+
([500, None, None], [100], [-200, -100], None),
106+
([500, None, None], [100], [-200, -100], Power.from_watts(700)),
107+
# Case 3: pv2_inv is unavailable (None).
108+
# It has no fallback, so return 0 as its value according to
109+
# the "nones-are-zero" rule.
110+
([500, None, None], [100], [-200, None], Power.from_watts(600)),
111+
# Case 4: Grid meter is unavailable (None).
112+
# It has no fallback, so return None according to the "nones-are-zero" rule.
113+
([None, 100, -50], [100], [-200, -300], None),
114+
([None, 200, -50], [100], [-200, -300], None),
115+
([100, 100, -50], [100], [-200, -300], Power.from_watts(350)),
116+
# Case 5: Only grid meter is working
117+
([100, None, None], [None], [None, None], Power.from_watts(100)),
118+
([-500, None, None], [None], [None, None], Power.from_watts(-500)),
119+
# Case 6: Nothing is working
120+
([None, None, None], [None], [None, None], None),
121+
]
122+
# fmt: on
123+
124+
for idx, (
125+
meter_power,
126+
bat_inv_power,
127+
pv_inv_power,
128+
expected_power,
129+
) in enumerate(expected_input_output):
130+
await mockgrid.mock_resampler.send_meter_power(meter_power)
131+
await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power)
132+
await mockgrid.mock_resampler.send_pv_inverter_power(pv_inv_power)
133+
mockgrid.mock_resampler.next_ts()
134+
135+
result = await consumer_power_receiver.receive()
136+
assert result.value == expected_power, (
137+
f"Test case {idx} failed:"
138+
+ f" meter_power: {meter_power}"
139+
+ f" bat_inverter_power {bat_inv_power}"
140+
+ f" pv_inverter_power {pv_inv_power}"
141+
+ f" expected_power: {expected_power}"
142+
+ f" actual_power: {result.value}"
143+
)
144+
145+
async def test_consumer_power_fallback_formula_without_grid_meter(
146+
self, mocker: MockerFixture
147+
) -> None:
148+
"""Test the consumer power formula with a grid meter."""
149+
mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker)
150+
mockgrid.add_consumer_meters(2)
151+
mockgrid.add_batteries(1)
152+
mockgrid.add_solar_inverters(1, no_meter=True)
153+
154+
# formula is sum of consumer meters
155+
156+
async with mockgrid, AsyncExitStack() as stack:
157+
consumer = microgrid.consumer()
158+
stack.push_async_callback(consumer.stop)
159+
consumer_power_receiver = consumer.power.new_receiver()
160+
161+
# Note: ConsumerPowerFormula has a "nones-are-zero" rule, that says:
162+
# * if the meter value is None, it should be treated as None.
163+
# * for other components None is treated as 0.
164+
165+
# fmt: off
166+
expected_input_output: list[
167+
tuple[list[float | None], list[float | None], list[float | None], Power | None]
168+
] = [
169+
# ([consumer_meter1, consumer_meter2, bat_meter], [bat_inv], [pv_inv], expected_power) # noqa: E501
170+
([100, 100, -50], [100], [-200,], Power.from_watts(200)),
171+
([500, 100, -50], [100], [-200,], Power.from_watts(600)),
172+
# One of the meters is invalid - should return None according to none-are-zero rule
173+
([None, 100, -50], [100], [-200,], None),
174+
([None, None, -50], [100], [-200,], None),
175+
([500, None, -50], [100], [-200,], None),
176+
([2000, 1000, None], [None], [None], Power.from_watts(3000)),
177+
]
178+
# fmt: on
179+
180+
for idx, (
181+
meter_power,
182+
bat_inv_power,
183+
pv_inv_power,
184+
expected_power,
185+
) in enumerate(expected_input_output):
186+
await mockgrid.mock_resampler.send_meter_power(meter_power)
187+
await mockgrid.mock_resampler.send_bat_inverter_power(bat_inv_power)
188+
await mockgrid.mock_resampler.send_pv_inverter_power(pv_inv_power)
189+
mockgrid.mock_resampler.next_ts()
190+
191+
result = await consumer_power_receiver.receive()
192+
assert result.value == expected_power, (
193+
f"Test case {idx} failed:"
194+
+ f" meter_power: {meter_power}"
195+
+ f" bat_inverter_power {bat_inv_power}"
196+
+ f" pv_inverter_power {pv_inv_power}"
197+
+ f" expected_power: {expected_power}"
198+
+ f" actual_power: {result.value}"
199+
)

0 commit comments

Comments
 (0)