Skip to content

Commit d86115a

Browse files
Implement fallback feature for ProducerPowerFormula
Signed-off-by: Elzbieta Kotulska <[email protected]>
1 parent 012bd82 commit d86115a

File tree

3 files changed

+186
-29
lines changed

3 files changed

+186
-29
lines changed

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

Lines changed: 98 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44
"""Formula generator from component graph for Producer Power."""
55

66
import logging
7+
from typing import Callable
78

8-
from frequenz.client.microgrid import ComponentCategory, ComponentMetricId
9+
from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId
910

1011
from ....microgrid import connection_manager
1112
from ..._quantities import Power
1213
from .._formula_engine import FormulaEngine
13-
from ._formula_generator import NON_EXISTING_COMPONENT_ID, FormulaGenerator
14+
from ._formula_generator import (
15+
NON_EXISTING_COMPONENT_ID,
16+
ComponentNotFound,
17+
FallbackFormulaMetricFetcher,
18+
FormulaGenerator,
19+
FormulaGeneratorConfig,
20+
)
1421

1522
_logger = logging.getLogger(__name__)
1623

@@ -43,35 +50,98 @@ def generate( # noqa: DOC502
4350
)
4451

4552
component_graph = connection_manager.get().component_graph
46-
# if in the future we support additional producers, we need to add them to the lambda
47-
producer_components = component_graph.dfs(
48-
self._get_grid_component(),
49-
set(),
50-
lambda component: component_graph.is_pv_chain(component)
51-
or component_graph.is_chp_chain(component),
52-
)
53-
54-
if not producer_components:
55-
_logger.warning(
56-
"Unable to find any producer components in the component graph. "
57-
"Subscribing to the resampling actor with a non-existing "
58-
"component id, so that `0` values are sent from the formula."
53+
if self._config.component_ids is None:
54+
# if in the future we support additional producers, we need to add them to the lambda
55+
producer_components = component_graph.dfs(
56+
self._get_grid_component(),
57+
set(),
58+
lambda component: component_graph.is_pv_chain(component)
59+
or component_graph.is_chp_chain(component),
5960
)
60-
# If there are no producer components, we have to send 0 values at the same
61-
# frequency as the other streams. So we subscribe with a non-existing
62-
# component id, just to get a `None` message at the resampling interval.
63-
builder.push_component_metric(
64-
NON_EXISTING_COMPONENT_ID, nones_are_zeros=True
61+
62+
if not producer_components:
63+
_logger.warning(
64+
"Unable to find any producer components in the component graph. "
65+
"Subscribing to the resampling actor with a non-existing "
66+
"component id, so that `0` values are sent from the formula."
67+
)
68+
# If there are no producer components, we have to send 0 values at the same
69+
# frequency as the other streams. So we subscribe with a non-existing
70+
# component id, just to get a `None` message at the resampling interval.
71+
builder.push_component_metric(
72+
NON_EXISTING_COMPONENT_ID, nones_are_zeros=True
73+
)
74+
return builder.build()
75+
76+
else:
77+
producer_components = component_graph.components(
78+
component_ids=set(self._config.component_ids)
6579
)
66-
return builder.build()
80+
if len(producer_components) != len(self._config.component_ids):
81+
raise ComponentNotFound(
82+
"Unable to find all requested producer components."
83+
f"Requested {self._config.component_ids}, "
84+
f" found {producer_components}."
85+
)
86+
87+
is_not_meter: Callable[[Component], bool] = (
88+
lambda component: component.category != ComponentCategory.METER
89+
)
6790

68-
for idx, component in enumerate(producer_components):
69-
if idx > 0:
70-
builder.push_oper("+")
91+
if self._config.allow_fallback:
92+
fallbacks = self._get_fallback_formulas(producer_components)
93+
94+
for idx, (primary_component, fallback_formula) in enumerate(
95+
fallbacks.items()
96+
):
97+
if idx > 0:
98+
builder.push_oper("+")
99+
100+
# should only be the case if the component is not a meter
101+
builder.push_component_metric(
102+
primary_component.component_id,
103+
nones_are_zeros=is_not_meter(primary_component),
104+
fallback_metric_fetcher=fallback_formula,
105+
)
106+
else:
107+
for idx, component in enumerate(producer_components):
108+
if idx > 0:
109+
builder.push_oper("+")
110+
111+
builder.push_component_metric(
112+
component.component_id,
113+
nones_are_zeros=is_not_meter(component),
114+
)
71115

72-
builder.push_component_metric(
73-
component.component_id,
74-
nones_are_zeros=component.category != ComponentCategory.METER,
116+
return builder.build()
117+
118+
def _get_fallback_formulas(
119+
self, components: set[Component]
120+
) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]:
121+
fallbacks = self._get_metric_fallback_components(components)
122+
123+
fallback_formulas: dict[
124+
Component, FallbackFormulaMetricFetcher[Power] | None
125+
] = {}
126+
127+
for primary_component, fallback_components in fallbacks.items():
128+
if len(fallback_components) == 0:
129+
fallback_formulas[primary_component] = None
130+
continue
131+
132+
fallback_ids = [c.component_id for c in fallback_components]
133+
generator = ProducerPowerFormula(
134+
f"{self._namespace}_fallback_{fallback_ids}",
135+
self._channel_registry,
136+
self._resampler_subscription_sender,
137+
FormulaGeneratorConfig(
138+
component_ids=set(fallback_ids),
139+
allow_fallback=False,
140+
),
75141
)
76142

77-
return builder.build()
143+
fallback_formulas[primary_component] = FallbackFormulaMetricFetcher(
144+
generator
145+
)
146+
147+
return fallback_formulas

src/frequenz/sdk/timeseries/producer.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
from ._quantities import Power
1313
from .formula_engine import FormulaEngine
1414
from .formula_engine._formula_engine_pool import FormulaEnginePool
15-
from .formula_engine._formula_generators import ProducerPowerFormula
15+
from .formula_engine._formula_generators import (
16+
FormulaGeneratorConfig,
17+
ProducerPowerFormula,
18+
)
1619

1720

1821
class Producer:
@@ -91,6 +94,9 @@ def power(self) -> FormulaEngine[Power]:
9194
engine = self._formula_pool.from_power_formula_generator(
9295
"producer_power",
9396
ProducerPowerFormula,
97+
FormulaGeneratorConfig(
98+
allow_fallback=True,
99+
),
94100
)
95101
assert isinstance(engine, FormulaEngine)
96102
return engine

tests/timeseries/test_producer.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ async def test_producer_power_no_pv_no_consumer_meter(
6060
producer_power_receiver = producer.power.new_receiver()
6161

6262
await mockgrid.mock_resampler.send_chp_power([2.0])
63+
6364
assert (await producer_power_receiver.receive()).value == Power.from_watts(
6465
2.0
6566
)
@@ -94,3 +95,83 @@ async def test_no_producer_power(self, mocker: MockerFixture) -> None:
9495
assert (await producer_power_receiver.receive()).value == Power.from_watts(
9596
0.0
9697
)
98+
99+
async def test_producer_fallback_formula(self, mocker: MockerFixture) -> None:
100+
"""Test the producer power formula with fallback formulas."""
101+
mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker)
102+
mockgrid.add_solar_inverters(2)
103+
# CHP has no meter, so no fallback component
104+
mockgrid.add_chps(1, no_meters=True)
105+
106+
async with mockgrid, AsyncExitStack() as stack:
107+
producer = microgrid.producer()
108+
stack.push_async_callback(producer.stop)
109+
producer_power_receiver = producer.power.new_receiver()
110+
111+
# Note: ProducerPowerFormula has a "nones-are-zero" rule, that says:
112+
# * if the meter value is None, it should be treated as None.
113+
# * for other components None is treated as 0.
114+
115+
# fmt: off
116+
expected_input_output: list[
117+
tuple[list[float | None], list[float | None], list[float | None], Power | None]
118+
] = [
119+
# ([pv_meter_power], [pv_inverter_power], [chp_power], expected_power)
120+
# Add power from meters and chp
121+
([-1.0, -2.0], [None, -200.0], [300], Power.from_watts(297.0)),
122+
([-1.0, -10], [-100.0, -200.0], [400], Power.from_watts(389.0)),
123+
# Case 2: The first meter is unavailable (None).
124+
# Subscribe to the fallback inverter, but return None as the result,
125+
# according to the "nones-are-zero" rule
126+
([None, -2.0], [-100, -200.0], [400], None),
127+
# Case 3: First meter is unavailable (None). Fallback inverter provides
128+
# a value.
129+
# Add second meter, first inverter and chp power
130+
([None, -2.0], [-100, -200.0], [400], Power.from_watts(298.0)),
131+
([None, -2.0], [-50, -200.0], [300], Power.from_watts(248.0)),
132+
# Case 4: Both first meter and its fallback inverter are unavailable
133+
# (None). Return 0 from failing component according to the
134+
# "nones-are-zero" rule.
135+
([None, -2.0], [None, -200.0], [300], Power.from_watts(298.0)),
136+
([None, -10.0], [-20.0, -200.0], [300], Power.from_watts(270.0)),
137+
# Case 5: CHP is unavailable. Return 0 from failing component
138+
# according to the "nones-are-zero" rule.
139+
([None, -10.0], [-20.0, -200.0], [None], Power.from_watts(-30.0)),
140+
# Case 6: Both meters are unavailable (None). Subscribe for fallback inverter
141+
([None, None], [-20.0, -200.0], [None], None),
142+
([None, None], [-20.0, -200.0], [None], Power.from_watts(-220.0)),
143+
([None, None], [None, -200.0], [None], Power.from_watts(-200.0)),
144+
# Case 7: All components are unavailable (None). Return 0 according to the
145+
# "nones-are-zero" rule.
146+
([None, None], [None, None], [None], Power.from_watts(0)),
147+
([None, None], [None, None], [None], Power.from_watts(0)),
148+
([None, None], [None, None], [300.0], Power.from_watts(300.0)),
149+
([-200.0, None], [None, -100.0], [50.0], Power.from_watts(-250.0)),
150+
([-200.0, -200.0], [-10.0, -20.0], [50.0], Power.from_watts(-350.0)),
151+
# Case 8: Meter is unavailable but we already subscribed for inverter
152+
# So don't return None in this case. Just proper formula result.
153+
([None, -200.0], [-10.0, -100.0], [50.0], Power.from_watts(-160.0)),
154+
155+
]
156+
# fmt: on
157+
158+
for idx, (
159+
meter_power,
160+
pv_inverter_power,
161+
chp_power,
162+
expected_power,
163+
) in enumerate(expected_input_output):
164+
await mockgrid.mock_resampler.send_chp_power(chp_power)
165+
await mockgrid.mock_resampler.send_meter_power(meter_power)
166+
await mockgrid.mock_resampler.send_pv_inverter_power(pv_inverter_power)
167+
mockgrid.mock_resampler.next_ts()
168+
169+
result = await producer_power_receiver.receive()
170+
assert result.value == expected_power, (
171+
f"Test case {idx} failed:"
172+
+ f" meter_power: {meter_power}"
173+
+ f" pv_inverter_power {pv_inverter_power}"
174+
+ f" chp_power {chp_power}"
175+
+ f" expected_power: {expected_power}"
176+
+ f" actual_power: {result.value}"
177+
)

0 commit comments

Comments
 (0)