Skip to content

Commit 82dcb63

Browse files
Fallback components in generated formulas (#1037)
Fallback components are used in generated formulas. If primary components is unavailable, formula will generate metric from fallback components. Fallback formulas are implemented for: - PVPowerFormula - ProducerPowerFormula - BatteryPowerFormula - ConsumerPowerFormula - GridPowerFormula All necessary formulas are implemented in this PR
2 parents 4641bd9 + c70ea97 commit 82dcb63

23 files changed

+1539
-161
lines changed

RELEASE_NOTES.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010

1111
## New Features
1212

13-
<!-- Here goes the main new features and examples or instructions on how to use them -->
13+
- Fallback components are used in generated formulas. If primary components is unavailable, formula will generate metric from fallback components. Fallback formulas are implemented for:
14+
- PVPowerFormula
15+
- ProducerPowerFormula
16+
- BatteryPowerFormula
17+
- ConsumerPowerFormula
18+
- GridPowerFormula
1419

1520
## Bug Fixes
1621

src/frequenz/sdk/timeseries/battery_pool/_battery_pool.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ def power(self) -> FormulaEngine[Power]:
245245
BatteryPowerFormula,
246246
FormulaGeneratorConfig(
247247
component_ids=self._pool_ref_store._batteries,
248+
allow_fallback=True,
248249
),
249250
)
250251
assert isinstance(engine, FormulaEngine)

src/frequenz/sdk/timeseries/formula_engine/_formula_engine.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ConstantValue,
2828
Consumption,
2929
Divider,
30+
FallbackMetricFetcher,
3031
FormulaStep,
3132
Maximizer,
3233
MetricFetcher,
@@ -747,6 +748,7 @@ def push_metric(
747748
data_stream: Receiver[Sample[QuantityT]],
748749
*,
749750
nones_are_zeros: bool,
751+
fallback: FallbackMetricFetcher[QuantityT] | None = None,
750752
) -> None:
751753
"""Push a metric receiver into the engine.
752754
@@ -755,9 +757,18 @@ def push_metric(
755757
data_stream: A receiver to fetch this metric from.
756758
nones_are_zeros: Whether to treat None values from the stream as 0s. If
757759
False, the returned value will be a None.
760+
fallback: Metric fetcher to use if primary one start sending
761+
invalid data (e.g. due to a component stop). If None, the data from
762+
primary metric fetcher will be used.
758763
"""
759764
fetcher = self._metric_fetchers.setdefault(
760-
name, MetricFetcher(name, data_stream, nones_are_zeros=nones_are_zeros)
765+
name,
766+
MetricFetcher(
767+
name,
768+
data_stream,
769+
nones_are_zeros=nones_are_zeros,
770+
fallback=fallback,
771+
),
761772
)
762773
self._steps.append(fetcher)
763774

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

Lines changed: 98 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33

44
"""Formula generator from component graph for Grid Power."""
55

6+
import itertools
67
import logging
78

8-
from frequenz.client.microgrid import 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
14+
from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher
1315
from ._formula_generator import (
1416
NON_EXISTING_COMPONENT_ID,
1517
ComponentNotFound,
1618
FormulaGenerationError,
1719
FormulaGenerator,
20+
FormulaGeneratorConfig,
1821
)
1922

2023
_logger = logging.getLogger(__name__)
@@ -48,8 +51,8 @@ def generate(
4851
builder = self._get_builder(
4952
"battery-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts
5053
)
51-
component_ids = self._config.component_ids
52-
if not component_ids:
54+
55+
if not self._config.component_ids:
5356
_logger.warning(
5457
"No Battery component IDs specified. "
5558
"Subscribing to the resampling actor with a non-existing "
@@ -63,43 +66,109 @@ def generate(
6366
)
6467
return builder.build()
6568

69+
component_ids = set(self._config.component_ids)
6670
component_graph = connection_manager.get().component_graph
71+
inv_bat_mapping: dict[Component, set[Component]] = {}
6772

68-
battery_inverters = frozenset(
69-
frozenset(
73+
for bat_id in component_ids:
74+
inverters = set(
7075
filter(
7176
component_graph.is_battery_inverter,
7277
component_graph.predecessors(bat_id),
7378
)
7479
)
75-
for bat_id in component_ids
76-
)
77-
78-
if not all(battery_inverters):
79-
raise ComponentNotFound(
80-
"All batteries must have at least one inverter as a predecessor."
81-
)
80+
if len(inverters) == 0:
81+
raise ComponentNotFound(
82+
"All batteries must have at least one inverter as a predecessor."
83+
f"Battery ID {bat_id} has no inverter as a predecessor.",
84+
)
8285

83-
all_connected_batteries = set()
84-
for inverters in battery_inverters:
8586
for inverter in inverters:
86-
all_connected_batteries.update(
87-
component_graph.successors(inverter.component_id)
87+
all_connected_batteries = component_graph.successors(
88+
inverter.component_id
8889
)
90+
battery_ids = set(
91+
map(lambda battery: battery.component_id, all_connected_batteries)
92+
)
93+
if not battery_ids.issubset(component_ids):
94+
raise FormulaGenerationError(
95+
f"Not all batteries behind inverter {inverter.component_id} "
96+
f"are requested. Missing: {battery_ids - component_ids}"
97+
)
98+
99+
inv_bat_mapping[inverter] = all_connected_batteries
100+
101+
if self._config.allow_fallback:
102+
fallbacks = self._get_fallback_formulas(inv_bat_mapping)
103+
104+
for idx, (primary_component, fallback_formula) in enumerate(
105+
fallbacks.items()
106+
):
107+
if idx > 0:
108+
builder.push_oper("+")
109+
110+
builder.push_component_metric(
111+
primary_component.component_id,
112+
nones_are_zeros=(
113+
primary_component.category != ComponentCategory.METER
114+
),
115+
fallback=fallback_formula,
116+
)
117+
else:
118+
for idx, comp in enumerate(inv_bat_mapping.keys()):
119+
if idx > 0:
120+
builder.push_oper("+")
121+
builder.push_component_metric(comp.component_id, nones_are_zeros=True)
122+
123+
return builder.build()
124+
125+
def _get_fallback_formulas(
126+
self, inv_bat_mapping: dict[Component, set[Component]]
127+
) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]:
128+
"""Find primary and fallback components and create fallback formulas.
89129
90-
if len(all_connected_batteries) != len(component_ids):
91-
raise FormulaGenerationError(
92-
"All batteries behind a set of inverters must be requested."
130+
The primary component is the one that will be used to calculate the battery power.
131+
If it is not available, the fallback formula will be used instead.
132+
Fallback formulas calculate the battery power using the fallback components.
133+
Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`.
134+
135+
Args:
136+
inv_bat_mapping: A mapping from inverter to connected batteries.
137+
138+
Returns:
139+
A dictionary mapping primary components to their FallbackFormulaMetricFetcher.
140+
"""
141+
fallbacks = self._get_metric_fallback_components(set(inv_bat_mapping.keys()))
142+
143+
fallback_formulas: dict[
144+
Component, FallbackFormulaMetricFetcher[Power] | None
145+
] = {}
146+
for primary_component, fallback_components in fallbacks.items():
147+
if len(fallback_components) == 0:
148+
fallback_formulas[primary_component] = None
149+
continue
150+
151+
battery_ids = set(
152+
map(
153+
lambda battery: battery.component_id,
154+
itertools.chain.from_iterable(
155+
inv_bat_mapping[inv] for inv in fallback_components
156+
),
157+
)
93158
)
94159

95-
builder.push_oper("(")
96-
builder.push_oper("(")
97-
# Iterate over the flattened list of inverters
98-
for idx, comp in enumerate(
99-
inverter for inverters in battery_inverters for inverter in inverters
100-
):
101-
if idx > 0:
102-
builder.push_oper("+")
103-
builder.push_component_metric(comp.component_id, nones_are_zeros=True)
160+
generator = BatteryPowerFormula(
161+
f"{self._namespace}_fallback_{battery_ids}",
162+
self._channel_registry,
163+
self._resampler_subscription_sender,
164+
FormulaGeneratorConfig(
165+
component_ids=battery_ids,
166+
allow_fallback=False,
167+
),
168+
)
104169

105-
return builder.build()
170+
fallback_formulas[primary_component] = FallbackFormulaMetricFetcher(
171+
generator
172+
)
173+
174+
return fallback_formulas

0 commit comments

Comments
 (0)