Skip to content

Commit 1d4ccce

Browse files
Fetch and stream 3-phase power (#847)
The 3-phase active power is temporary needed to calculate the power factor of the microgrid. The power factor is not available through [the microgrid API in the current version used in the SDK](https://github.com/frequenz-floss/frequenz-api-common/blob/v0.4.0/proto/frequenz/api/common/v1/metrics/electrical.proto#L139-L199). So the 3-phase active power is temporary exposed through the microgrid API until the migration to the latest version is completed. Then the 3-phase power factor can be fetched and streamed through the microgrid API. Temporary workaround for #841.
2 parents 91821a3 + ca22472 commit 1d4ccce

File tree

13 files changed

+310
-6
lines changed

13 files changed

+310
-6
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,4 @@
1414

1515
## Bug Fixes
1616

17-
<!-- Here goes notable bug fixes that are worth a special mention or explanation -->
17+
- Fix grid current formula generator to add the operator `+` to the engine only when the component category is handled.

src/frequenz/sdk/actor/_data_sourcing/microgrid_api_source.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828

2929
_MeterDataMethods: dict[ComponentMetricId, Callable[[MeterData], float]] = {
3030
ComponentMetricId.ACTIVE_POWER: lambda msg: msg.active_power,
31+
ComponentMetricId.ACTIVE_POWER_PHASE_1: lambda msg: msg.active_power_per_phase[0],
32+
ComponentMetricId.ACTIVE_POWER_PHASE_2: lambda msg: msg.active_power_per_phase[1],
33+
ComponentMetricId.ACTIVE_POWER_PHASE_3: lambda msg: msg.active_power_per_phase[2],
3134
ComponentMetricId.CURRENT_PHASE_1: lambda msg: msg.current_per_phase[0],
3235
ComponentMetricId.CURRENT_PHASE_2: lambda msg: msg.current_per_phase[1],
3336
ComponentMetricId.CURRENT_PHASE_3: lambda msg: msg.current_per_phase[2],
@@ -59,6 +62,9 @@
5962

6063
_InverterDataMethods: dict[ComponentMetricId, Callable[[InverterData], float]] = {
6164
ComponentMetricId.ACTIVE_POWER: lambda msg: msg.active_power,
65+
ComponentMetricId.ACTIVE_POWER_PHASE_1: lambda msg: msg.active_power_per_phase[0],
66+
ComponentMetricId.ACTIVE_POWER_PHASE_2: lambda msg: msg.active_power_per_phase[1],
67+
ComponentMetricId.ACTIVE_POWER_PHASE_3: lambda msg: msg.active_power_per_phase[2],
6268
ComponentMetricId.ACTIVE_POWER_INCLUSION_LOWER_BOUND: lambda msg: (
6369
msg.active_power_inclusion_lower_bound
6470
),
@@ -82,6 +88,9 @@
8288

8389
_EVChargerDataMethods: dict[ComponentMetricId, Callable[[EVChargerData], float]] = {
8490
ComponentMetricId.ACTIVE_POWER: lambda msg: msg.active_power,
91+
ComponentMetricId.ACTIVE_POWER_PHASE_1: lambda msg: msg.active_power_per_phase[0],
92+
ComponentMetricId.ACTIVE_POWER_PHASE_2: lambda msg: msg.active_power_per_phase[1],
93+
ComponentMetricId.ACTIVE_POWER_PHASE_3: lambda msg: msg.active_power_per_phase[2],
8594
ComponentMetricId.CURRENT_PHASE_1: lambda msg: msg.current_per_phase[0],
8695
ComponentMetricId.CURRENT_PHASE_2: lambda msg: msg.current_per_phase[1],
8796
ComponentMetricId.CURRENT_PHASE_3: lambda msg: msg.current_per_phase[2],

src/frequenz/sdk/microgrid/component/_component.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,13 @@ class ComponentMetricId(Enum):
195195
ACTIVE_POWER = "active_power"
196196
"""Active power."""
197197

198+
ACTIVE_POWER_PHASE_1 = "active_power_phase_1"
199+
"""Active power in phase 1."""
200+
ACTIVE_POWER_PHASE_2 = "active_power_phase_2"
201+
"""Active power in phase 2."""
202+
ACTIVE_POWER_PHASE_3 = "active_power_phase_3"
203+
"""Active power in phase 3."""
204+
198205
CURRENT_PHASE_1 = "current_phase_1"
199206
"""Current in phase 1."""
200207
CURRENT_PHASE_2 = "current_phase_2"

src/frequenz/sdk/microgrid/component/_component_data.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ class MeterData(ComponentData):
6868
-ve current means supply into the grid.
6969
"""
7070

71+
active_power_per_phase: tuple[float, float, float]
72+
"""The AC active power for phase/line 1,2 and 3 respectively."""
73+
7174
current_per_phase: tuple[float, float, float]
7275
"""AC current in Amperes (A) for phase/line 1,2 and 3 respectively.
7376
+ve current means consumption, away from the grid.
@@ -96,6 +99,11 @@ def from_proto(cls, raw: microgrid_pb.ComponentData) -> MeterData:
9699
component_id=raw.id,
97100
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
98101
active_power=raw.meter.data.ac.power_active.value,
102+
active_power_per_phase=(
103+
raw.meter.data.ac.phase_1.power_active.value,
104+
raw.meter.data.ac.phase_2.power_active.value,
105+
raw.meter.data.ac.phase_3.power_active.value,
106+
),
99107
current_per_phase=(
100108
raw.meter.data.ac.phase_1.current.value,
101109
raw.meter.data.ac.phase_2.current.value,
@@ -231,6 +239,9 @@ class InverterData(ComponentData):
231239
-ve current means supply into the grid.
232240
"""
233241

242+
active_power_per_phase: tuple[float, float, float]
243+
"""The AC active power for phase/line 1, 2 and 3 respectively."""
244+
234245
current_per_phase: tuple[float, float, float]
235246
"""AC current in Amperes (A) for phase/line 1, 2 and 3 respectively.
236247
+ve current means consumption, away from the grid.
@@ -312,6 +323,11 @@ def from_proto(cls, raw: microgrid_pb.ComponentData) -> InverterData:
312323
component_id=raw.id,
313324
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
314325
active_power=raw.inverter.data.ac.power_active.value,
326+
active_power_per_phase=(
327+
raw.inverter.data.ac.phase_1.power_active.value,
328+
raw.inverter.data.ac.phase_2.power_active.value,
329+
raw.inverter.data.ac.phase_3.power_active.value,
330+
),
315331
current_per_phase=(
316332
raw.inverter.data.ac.phase_1.current.value,
317333
raw.inverter.data.ac.phase_2.current.value,
@@ -345,6 +361,9 @@ class EVChargerData(ComponentData):
345361
-ve current means supply into the grid.
346362
"""
347363

364+
active_power_per_phase: tuple[float, float, float]
365+
"""The AC active power for phase/line 1,2 and 3 respectively."""
366+
348367
current_per_phase: tuple[float, float, float]
349368
"""AC current in Amperes (A) for phase/line 1,2 and 3 respectively.
350369
+ve current means consumption, away from the grid.
@@ -424,6 +443,11 @@ def from_proto(cls, raw: microgrid_pb.ComponentData) -> EVChargerData:
424443
component_id=raw.id,
425444
timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc),
426445
active_power=raw_power.value,
446+
active_power_per_phase=(
447+
raw.ev_charger.data.ac.phase_1.power_active.value,
448+
raw.ev_charger.data.ac.phase_2.power_active.value,
449+
raw.ev_charger.data.ac.phase_3.power_active.value,
450+
),
427451
current_per_phase=(
428452
raw.ev_charger.data.ac.phase_1.current.value,
429453
raw.ev_charger.data.ac.phase_2.current.value,

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def __init__(
5151
)
5252
self._string_engines: dict[str, FormulaEngine[Quantity]] = {}
5353
self._power_engines: dict[str, FormulaEngine[Power]] = {}
54+
self._power_3_phase_engines: dict[str, FormulaEngine3Phase[Power]] = {}
5455
self._current_engines: dict[str, FormulaEngine3Phase[Current]] = {}
5556

5657
def from_string(
@@ -123,6 +124,40 @@ def from_power_formula_generator(
123124
self._power_engines[channel_key] = engine
124125
return engine
125126

127+
def from_power_3_phase_formula_generator(
128+
self,
129+
channel_key: str,
130+
generator: type[FormulaGenerator[Power]],
131+
config: FormulaGeneratorConfig = FormulaGeneratorConfig(),
132+
) -> FormulaEngine3Phase[Power]:
133+
"""Get a formula engine that streams 3-phase power values.
134+
135+
Args:
136+
channel_key: The string to uniquely identify the formula.
137+
generator: The formula generator.
138+
config: The config to initialize the formula generator with.
139+
140+
Returns:
141+
A formula engine that streams [3-phase][frequenz.sdk.timeseries.Sample3Phase]
142+
power values.
143+
"""
144+
from ._formula_engine import ( # pylint: disable=import-outside-toplevel
145+
FormulaEngine3Phase,
146+
)
147+
148+
if channel_key in self._power_3_phase_engines:
149+
return self._power_3_phase_engines[channel_key]
150+
151+
engine = generator(
152+
self._namespace,
153+
self._channel_registry,
154+
self._resampler_subscription_sender,
155+
config,
156+
).generate()
157+
assert isinstance(engine, FormulaEngine3Phase)
158+
self._power_3_phase_engines[channel_key] = engine
159+
return engine
160+
126161
def from_3_phase_current_formula_generator(
127162
self,
128163
channel_key: str,
@@ -163,5 +198,7 @@ async def stop(self) -> None:
163198
await string_engine._stop() # pylint: disable=protected-access
164199
for power_engine in self._power_engines.values():
165200
await power_engine._stop() # pylint: disable=protected-access
201+
for power_3_phase_engine in self._power_3_phase_engines.values():
202+
await power_3_phase_engine._stop() # pylint: disable=protected-access
166203
for current_engine in self._current_engines.values():
167204
await current_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
@@ -15,6 +15,7 @@
1515
FormulaGeneratorConfig,
1616
)
1717
from ._grid_current_formula import GridCurrentFormula
18+
from ._grid_power_3_phase_formula import GridPower3PhaseFormula
1819
from ._grid_power_formula import GridPowerFormula
1920
from ._producer_power_formula import ProducerPowerFormula
2021
from ._pv_power_formula import PVPowerFormula
@@ -30,6 +31,7 @@
3031
#
3132
"CHPPowerFormula",
3233
"ConsumerPowerFormula",
34+
"GridPower3PhaseFormula",
3335
"GridPowerFormula",
3436
"BatteryPowerFormula",
3537
"EVChargerPowerFormula",

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,6 @@ def _gen_phase_formula(
5252
# generate a formula that just adds values from all components that are
5353
# directly connected to the grid.
5454
for idx, comp in enumerate(grid_successors):
55-
if idx > 0:
56-
builder.push_oper("+")
57-
5855
# When inverters or ev chargers produce `None` samples, those
5956
# inverters are excluded from the calculation by treating their
6057
# `None` values as `0`s.
@@ -71,6 +68,9 @@ def _gen_phase_formula(
7168
else:
7269
continue
7370

71+
if idx > 0:
72+
builder.push_oper("+")
73+
7474
builder.push_component_metric(
7575
comp.component_id, nones_are_zeros=nones_are_zeros
7676
)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Formula generator from component graph for 3-phase Grid Power."""
5+
6+
from ....microgrid.component import Component, ComponentCategory, ComponentMetricId
7+
from ..._quantities import Power
8+
from .._formula_engine import FormulaEngine, FormulaEngine3Phase
9+
from ._formula_generator import FormulaGenerator
10+
11+
12+
class GridPower3PhaseFormula(FormulaGenerator[Power]):
13+
"""Create a formula engine for calculating the grid 3-phase power."""
14+
15+
def generate( # noqa: DOC502
16+
# ComponentNotFound is raised indirectly by _get_grid_component_successors
17+
self,
18+
) -> FormulaEngine3Phase[Power]:
19+
"""Generate a formula for calculating grid 3-phase power.
20+
21+
Raises:
22+
ComponentNotFound: when the component graph doesn't have a `GRID` component.
23+
24+
Returns:
25+
A formula engine that will calculate grid 3-phase power values.
26+
"""
27+
grid_successors = self._get_grid_component_successors()
28+
29+
return FormulaEngine3Phase(
30+
"grid-power-3-phase",
31+
Power.from_watts,
32+
(
33+
self._gen_phase_formula(
34+
grid_successors, ComponentMetricId.ACTIVE_POWER_PHASE_1
35+
),
36+
self._gen_phase_formula(
37+
grid_successors, ComponentMetricId.ACTIVE_POWER_PHASE_2
38+
),
39+
self._gen_phase_formula(
40+
grid_successors, ComponentMetricId.ACTIVE_POWER_PHASE_3
41+
),
42+
),
43+
)
44+
45+
def _gen_phase_formula(
46+
self,
47+
grid_successors: set[Component],
48+
metric_id: ComponentMetricId,
49+
) -> FormulaEngine[Power]:
50+
"""Generate a formula for calculating grid 3-phase power from the component graph.
51+
52+
Generate a formula that adds values from all components that are directly
53+
connected to the grid.
54+
55+
Args:
56+
grid_successors: The set of components that are directly connected to the grid.
57+
metric_id: The metric to use for the formula.
58+
59+
Returns:
60+
A formula engine that will calculate grid 3-phase power values.
61+
"""
62+
formula_builder = self._get_builder(
63+
"grid-power-3-phase", metric_id, Power.from_watts
64+
)
65+
66+
for idx, comp in enumerate(grid_successors):
67+
# When inverters or EV chargers produce `None` samples, they are
68+
# excluded from the calculation by treating their `None` values
69+
# as `0`s.
70+
#
71+
# This is not possible for Meters, so when they produce `None`
72+
# values, those values get propagated as the output.
73+
if comp.category in (
74+
ComponentCategory.INVERTER,
75+
ComponentCategory.EV_CHARGER,
76+
):
77+
nones_are_zeros = True
78+
elif comp.category == ComponentCategory.METER:
79+
nones_are_zeros = False
80+
else:
81+
continue
82+
83+
if idx > 0:
84+
formula_builder.push_oper("+")
85+
86+
formula_builder.push_component_metric(
87+
comp.component_id, nones_are_zeros=nones_are_zeros
88+
)
89+
90+
return formula_builder.build()

src/frequenz/sdk/timeseries/grid.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
from ._quantities import Current, Power
2222
from .formula_engine import FormulaEngine, FormulaEngine3Phase
2323
from .formula_engine._formula_engine_pool import FormulaEnginePool
24-
from .formula_engine._formula_generators import GridCurrentFormula, GridPowerFormula
24+
from .formula_engine._formula_generators import (
25+
GridCurrentFormula,
26+
GridPower3PhaseFormula,
27+
GridPowerFormula,
28+
)
2529

2630
if TYPE_CHECKING:
2731
# Break circular import
@@ -95,6 +99,24 @@ def power(self) -> FormulaEngine[Power]:
9599
assert isinstance(engine, FormulaEngine)
96100
return engine
97101

102+
@property
103+
def _power_3_phase(self) -> FormulaEngine3Phase[Power]:
104+
"""Fetch the grid 3-phase power for the microgrid.
105+
106+
This formula produces values that are in the Passive Sign Convention (PSC).
107+
108+
A receiver from the formula engine can be created using the
109+
`new_receiver`method.
110+
111+
Returns:
112+
A FormulaEngine that will calculate and stream grid 3-phase power.
113+
"""
114+
engine = self._formula_pool.from_power_3_phase_formula_generator(
115+
"grid_power_3_phase", GridPower3PhaseFormula
116+
)
117+
assert isinstance(engine, FormulaEngine3Phase)
118+
return engine
119+
98120
@property
99121
def current(self) -> FormulaEngine3Phase[Current]:
100122
"""Fetch the grid current for the microgrid.

tests/microgrid/test_component_data.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,17 @@ def test_inverter_data() -> None:
5353
phase_1=electrical_pb2.AC.ACPhase(
5454
current=metrics_pb2.Metric(value=12.3),
5555
voltage=metrics_pb2.Metric(value=229.8),
56+
power_active=metrics_pb2.Metric(value=33.1),
5657
),
5758
phase_2=electrical_pb2.AC.ACPhase(
5859
current=metrics_pb2.Metric(value=23.4),
5960
voltage=metrics_pb2.Metric(value=230.0),
61+
power_active=metrics_pb2.Metric(value=33.3),
6062
),
6163
phase_3=electrical_pb2.AC.ACPhase(
6264
current=metrics_pb2.Metric(value=34.5),
6365
voltage=metrics_pb2.Metric(value=230.2),
66+
power_active=metrics_pb2.Metric(value=33.8),
6467
),
6568
),
6669
),
@@ -78,6 +81,7 @@ def test_inverter_data() -> None:
7881
]
7982
assert inv_data.frequency == pytest.approx(50.1)
8083
assert inv_data.active_power == pytest.approx(100.2)
84+
assert inv_data.active_power_per_phase == pytest.approx((33.1, 33.3, 33.8))
8185
assert inv_data.current_per_phase == pytest.approx((12.3, 23.4, 34.5))
8286
assert inv_data.voltage_per_phase == pytest.approx((229.8, 230.0, 230.2))
8387
assert inv_data.active_power_inclusion_lower_bound == pytest.approx(-51_000.0)

0 commit comments

Comments
 (0)