Skip to content

Commit 0ef9ad1

Browse files
Implement GridReactivePowerFormula (frequenz-floss#1086)
2 parents 8d9e735 + 77e10dd commit 0ef9ad1

File tree

15 files changed

+541
-169
lines changed

15 files changed

+541
-169
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ and adapt your imports if you are using these types.
1818
- `force_polling`: Whether to force file polling to check for changes. Default is `True`.
1919
- `polling_interval`: The interval to check for changes. Only relevant if polling is enabled. Default is 1 second.
2020

21+
- Add a new method `microgrid.grid().reactive_power` to stream reactive power at the grid connection point.
22+
2123
## Bug Fixes
2224

2325
- Many long running async tasks including metric streamers in the BatteryPool now have automatic recovery in case of exceptions.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ dependencies = [
3131
# (plugins.mkdocstrings.handlers.python.import)
3232
"frequenz-client-microgrid >= 0.5.1, < 0.6.0",
3333
"frequenz-channels >= 1.2.0, < 2.0.0",
34-
"frequenz-quantities == 1.0.0rc1",
34+
"frequenz-quantities == 1.0.0rc2",
3535
"networkx >= 2.8, < 4",
3636
"numpy >= 1.26.4, < 2",
3737
"typing_extensions >= 4.6.1, < 5",

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from frequenz.channels import Sender
1111
from frequenz.client.microgrid import ComponentMetricId
12-
from frequenz.quantities import Current, Power, Quantity
12+
from frequenz.quantities import Current, Power, Quantity, ReactivePower
1313

1414
from ..._internal._channels import ChannelRegistry
1515
from ...microgrid._data_sourcing import ComponentMetricRequest
@@ -54,6 +54,7 @@ def __init__(
5454
self._power_engines: dict[str, FormulaEngine[Power]] = {}
5555
self._power_3_phase_engines: dict[str, FormulaEngine3Phase[Power]] = {}
5656
self._current_engines: dict[str, FormulaEngine3Phase[Current]] = {}
57+
self._reactive_power_engines: dict[str, FormulaEngine[ReactivePower]] = {}
5758

5859
def from_string(
5960
self,
@@ -91,6 +92,40 @@ def from_string(
9192

9293
return formula_engine
9394

95+
def from_reactive_power_formula_generator(
96+
self,
97+
channel_key: str,
98+
generator: type[FormulaGenerator[ReactivePower]],
99+
config: FormulaGeneratorConfig = FormulaGeneratorConfig(),
100+
) -> FormulaEngine[ReactivePower]:
101+
"""Get a receiver for a formula from a generator.
102+
103+
Args:
104+
channel_key: A string to uniquely identify the formula.
105+
generator: A formula generator.
106+
config: config to initialize the formula generator with.
107+
108+
Returns:
109+
A FormulaReceiver or a FormulaReceiver3Phase instance based on what the
110+
FormulaGenerator returns.
111+
"""
112+
from ._formula_engine import ( # pylint: disable=import-outside-toplevel
113+
FormulaEngine,
114+
)
115+
116+
if channel_key in self._reactive_power_engines:
117+
return self._reactive_power_engines[channel_key]
118+
119+
engine = generator(
120+
self._namespace,
121+
self._channel_registry,
122+
self._resampler_subscription_sender,
123+
config,
124+
).generate()
125+
assert isinstance(engine, FormulaEngine)
126+
self._reactive_power_engines[channel_key] = engine
127+
return engine
128+
94129
def from_power_formula_generator(
95130
self,
96131
channel_key: str,
@@ -203,3 +238,5 @@ async def stop(self) -> None:
203238
await power_3_phase_engine._stop() # pylint: disable=protected-access
204239
for current_engine in self._current_engines.values():
205240
await current_engine._stop() # pylint: disable=protected-access
241+
for reactive_power_engine in self._reactive_power_engines.values():
242+
await reactive_power_engine._stop() # pylint: disable=protected-access

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ._grid_current_formula import GridCurrentFormula
1818
from ._grid_power_3_phase_formula import GridPower3PhaseFormula
1919
from ._grid_power_formula import GridPowerFormula
20+
from ._grid_reactive_power_formula import GridReactivePowerFormula
2021
from ._producer_power_formula import ProducerPowerFormula
2122
from ._pv_power_formula import PVPowerFormula
2223

@@ -33,6 +34,7 @@
3334
"ConsumerPowerFormula",
3435
"GridPower3PhaseFormula",
3536
"GridPowerFormula",
37+
"GridReactivePowerFormula",
3638
"BatteryPowerFormula",
3739
"EVChargerPowerFormula",
3840
"PVPowerFormula",

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
FormulaGenerator,
1919
FormulaGeneratorConfig,
2020
)
21-
from ._simple_power_formula import SimplePowerFormula
21+
from ._simple_formula import SimplePowerFormula
2222

2323
_logger = logging.getLogger(__name__)
2424

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

Lines changed: 11 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,18 @@
33

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

6-
from frequenz.client.microgrid import Component, ComponentCategory, ComponentMetricId
6+
7+
from frequenz.client.microgrid import Component, ComponentMetricId
78
from frequenz.quantities import Power
89

910
from .._formula_engine import FormulaEngine
1011
from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher
11-
from ._formula_generator import (
12-
ComponentNotFound,
13-
FormulaGenerator,
14-
FormulaGeneratorConfig,
15-
)
16-
from ._simple_power_formula import SimplePowerFormula
12+
from ._formula_generator import FormulaGeneratorConfig
13+
from ._grid_power_formula_base import GridPowerFormulaBase
14+
from ._simple_formula import SimplePowerFormula
1715

1816

19-
class GridPowerFormula(FormulaGenerator[Power]):
17+
class GridPowerFormula(GridPowerFormulaBase[Power]):
2018
"""Creates a formula engine from the component graph for calculating grid power."""
2119

2220
def generate( # noqa: DOC502
@@ -32,70 +30,18 @@ def generate( # noqa: DOC502
3230
ComponentNotFound: when the component graph doesn't have a `GRID` component.
3331
"""
3432
builder = self._get_builder(
35-
"grid-power", ComponentMetricId.ACTIVE_POWER, Power.from_watts
33+
"grid-power",
34+
ComponentMetricId.ACTIVE_POWER,
35+
Power.from_watts,
3636
)
37-
grid_successors = self._get_grid_component_successors()
38-
39-
components = {
40-
c
41-
for c in grid_successors
42-
if c.category
43-
in {
44-
ComponentCategory.INVERTER,
45-
ComponentCategory.EV_CHARGER,
46-
ComponentCategory.METER,
47-
}
48-
}
49-
50-
if not components:
51-
raise ComponentNotFound("No grid successors found")
52-
53-
# generate a formula that just adds values from all components that are
54-
# directly connected to the grid. If the requested formula type is
55-
# `PASSIVE_SIGN_CONVENTION`, there is nothing more to do. If the requested
56-
# formula type is `PRODUCTION`, the formula output is negated, then clipped to
57-
# 0. If the requested formula type is `CONSUMPTION`, the formula output is
58-
# already positive, so it is just clipped to 0.
59-
#
60-
# So the formulas would look like:
61-
# - `PASSIVE_SIGN_CONVENTION`: `(grid-successor-1 + grid-successor-2 + ...)`
62-
# - `PRODUCTION`: `max(0, -(grid-successor-1 + grid-successor-2 + ...))`
63-
# - `CONSUMPTION`: `max(0, (grid-successor-1 + grid-successor-2 + ...))`
64-
if self._config.allow_fallback:
65-
fallbacks = self._get_fallback_formulas(components)
66-
67-
for idx, (primary_component, fallback_formula) in enumerate(
68-
fallbacks.items()
69-
):
70-
if idx > 0:
71-
builder.push_oper("+")
72-
73-
# should only be the case if the component is not a meter
74-
builder.push_component_metric(
75-
primary_component.component_id,
76-
nones_are_zeros=(
77-
primary_component.category != ComponentCategory.METER
78-
),
79-
fallback=fallback_formula,
80-
)
81-
else:
82-
for idx, comp in enumerate(components):
83-
if idx > 0:
84-
builder.push_oper("+")
85-
86-
builder.push_component_metric(
87-
comp.component_id,
88-
nones_are_zeros=(comp.category != ComponentCategory.METER),
89-
)
90-
91-
return builder.build()
37+
return self._generate(builder)
9238

9339
def _get_fallback_formulas(
9440
self, components: set[Component]
9541
) -> dict[Component, FallbackFormulaMetricFetcher[Power] | None]:
9642
"""Find primary and fallback components and create fallback formulas.
9743
98-
The primary component is the one that will be used to calculate the producer power.
44+
The primary component is the one that will be used to calculate the grid power.
9945
If it is not available, the fallback formula will be used instead.
10046
Fallback formulas calculate the grid power using the fallback components.
10147
Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Base formula generator from component graph for Grid Power."""
5+
6+
from abc import ABC, abstractmethod
7+
8+
from frequenz.client.microgrid import Component, ComponentCategory
9+
10+
from ..._base_types import QuantityT
11+
from .._formula_engine import FormulaEngine
12+
from .._resampled_formula_builder import ResampledFormulaBuilder
13+
from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher
14+
from ._formula_generator import ComponentNotFound, FormulaGenerator
15+
16+
17+
class GridPowerFormulaBase(FormulaGenerator[QuantityT], ABC):
18+
"""Base class for grid power formula generators."""
19+
20+
def _generate(
21+
self, builder: ResampledFormulaBuilder[QuantityT]
22+
) -> FormulaEngine[QuantityT]:
23+
"""Generate a formula for calculating grid power from the component graph.
24+
25+
Args:
26+
builder: The builder to use to create the formula.
27+
28+
Returns:
29+
A formula engine that will calculate grid power values.
30+
31+
Raises:
32+
ComponentNotFound: when the component graph doesn't have a `GRID` component.
33+
"""
34+
grid_successors = self._get_grid_component_successors()
35+
36+
components = {
37+
c
38+
for c in grid_successors
39+
if c.category
40+
in {
41+
ComponentCategory.INVERTER,
42+
ComponentCategory.EV_CHARGER,
43+
ComponentCategory.METER,
44+
}
45+
}
46+
47+
if not components:
48+
raise ComponentNotFound("No grid successors found")
49+
50+
# generate a formula that just adds values from all components that are
51+
# directly connected to the grid. If the requested formula type is
52+
# `PASSIVE_SIGN_CONVENTION`, there is nothing more to do. If the requested
53+
# formula type is `PRODUCTION`, the formula output is negated, then clipped to
54+
# 0. If the requested formula type is `CONSUMPTION`, the formula output is
55+
# already positive, so it is just clipped to 0.
56+
#
57+
# So the formulas would look like:
58+
# - `PASSIVE_SIGN_CONVENTION`: `(grid-successor-1 + grid-successor-2 + ...)`
59+
# - `PRODUCTION`: `max(0, -(grid-successor-1 + grid-successor-2 + ...))`
60+
# - `CONSUMPTION`: `max(0, (grid-successor-1 + grid-successor-2 + ...))`
61+
if self._config.allow_fallback:
62+
fallbacks = self._get_fallback_formulas(components)
63+
64+
for idx, (primary_component, fallback_formula) in enumerate(
65+
fallbacks.items()
66+
):
67+
if idx > 0:
68+
builder.push_oper("+")
69+
70+
# should only be the case if the component is not a meter
71+
builder.push_component_metric(
72+
primary_component.component_id,
73+
nones_are_zeros=(
74+
primary_component.category != ComponentCategory.METER
75+
),
76+
fallback=fallback_formula,
77+
)
78+
else:
79+
for idx, comp in enumerate(components):
80+
if idx > 0:
81+
builder.push_oper("+")
82+
83+
builder.push_component_metric(
84+
comp.component_id,
85+
nones_are_zeros=(comp.category != ComponentCategory.METER),
86+
)
87+
88+
return builder.build()
89+
90+
@abstractmethod
91+
def _get_fallback_formulas(
92+
self, components: set[Component]
93+
) -> dict[Component, FallbackFormulaMetricFetcher[QuantityT] | None]:
94+
"""Find primary and fallback components and create fallback formulas.
95+
96+
The primary component is the one that will be used to calculate the producer power.
97+
If it is not available, the fallback formula will be used instead.
98+
Fallback formulas calculate the grid power using the fallback components.
99+
Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`.
100+
101+
Args:
102+
components: The producer components.
103+
104+
Returns:
105+
A dictionary mapping primary components to their FallbackFormulaMetricFetcher.
106+
"""
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Formula generator from component graph for Grid Reactive Power."""
5+
6+
7+
from frequenz.client.microgrid import Component, ComponentMetricId
8+
from frequenz.quantities import ReactivePower
9+
10+
from .._formula_engine import FormulaEngine
11+
from ._fallback_formula_metric_fetcher import FallbackFormulaMetricFetcher
12+
from ._formula_generator import FormulaGeneratorConfig
13+
from ._grid_power_formula_base import GridPowerFormulaBase
14+
from ._simple_formula import SimpleReactivePowerFormula
15+
16+
17+
class GridReactivePowerFormula(GridPowerFormulaBase[ReactivePower]):
18+
"""Creates a formula engine from the component graph for calculating grid reactive power."""
19+
20+
def generate( # noqa: DOC502
21+
# * ComponentNotFound is raised indirectly by _get_grid_component_successors
22+
self,
23+
) -> FormulaEngine[ReactivePower]:
24+
"""Generate a formula for calculating grid reactive power from the component graph.
25+
26+
Returns:
27+
A formula engine that will calculate grid reactive power values.
28+
29+
Raises:
30+
ComponentNotFound: when the component graph doesn't have a `GRID` component.
31+
"""
32+
builder = self._get_builder(
33+
"grid_reactive_power_formula",
34+
ComponentMetricId.REACTIVE_POWER,
35+
ReactivePower.from_volt_amperes_reactive,
36+
)
37+
return self._generate(builder)
38+
39+
def _get_fallback_formulas(
40+
self, components: set[Component]
41+
) -> dict[Component, FallbackFormulaMetricFetcher[ReactivePower] | None]:
42+
"""Find primary and fallback components and create fallback formulas.
43+
44+
The primary component is the one that will be used to calculate the grid reactive power.
45+
If it is not available, the fallback formula will be used instead.
46+
Fallback formulas calculate the grid power using the fallback components.
47+
Fallback formulas are wrapped in `FallbackFormulaMetricFetcher`.
48+
49+
Args:
50+
components: The producer components.
51+
52+
Returns:
53+
A dictionary mapping primary components to their FallbackFormulaMetricFetcher.
54+
"""
55+
fallbacks = self._get_metric_fallback_components(components)
56+
57+
fallback_formulas: dict[
58+
Component, FallbackFormulaMetricFetcher[ReactivePower] | None
59+
] = {}
60+
61+
for primary_component, fallback_components in fallbacks.items():
62+
if len(fallback_components) == 0:
63+
fallback_formulas[primary_component] = None
64+
continue
65+
66+
fallback_ids = [c.component_id for c in fallback_components]
67+
generator = SimpleReactivePowerFormula(
68+
f"{self._namespace}_fallback_{fallback_ids}",
69+
self._channel_registry,
70+
self._resampler_subscription_sender,
71+
FormulaGeneratorConfig(
72+
component_ids=set(fallback_ids),
73+
allow_fallback=False,
74+
),
75+
)
76+
77+
fallback_formulas[primary_component] = FallbackFormulaMetricFetcher(
78+
generator
79+
)
80+
81+
return fallback_formulas

0 commit comments

Comments
 (0)