Skip to content

Commit 404359c

Browse files
Add new method to stream grid reactive power
Implement new formula GridReactivePowerFormula to calculate `reactive_power`. Add new method microgrid.grid().reactive_power to stream reactive power at the grid connection point. Signed-off-by: Elzbieta Kotulska <[email protected]>
1 parent da640cb commit 404359c

File tree

7 files changed

+208
-8
lines changed

7 files changed

+208
-8
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.

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",
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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 ComponentMetricId
8+
from frequenz.quantities import Power
9+
10+
from .._formula_engine import FormulaEngine
11+
from ._grid_power_formula_base import GridPowerFormulaBase
12+
13+
14+
class GridReactivePowerFormula(GridPowerFormulaBase):
15+
"""Creates a formula engine from the component graph for calculating grid reactive power."""
16+
17+
def generate( # noqa: DOC502
18+
# * ComponentNotFound is raised indirectly by _get_grid_component_successors
19+
self,
20+
) -> FormulaEngine[Power]:
21+
"""Generate a formula for calculating grid reactive power from the component graph.
22+
23+
Returns:
24+
A formula engine that will calculate grid reactive power values.
25+
26+
Raises:
27+
ComponentNotFound: when the component graph doesn't have a `GRID` component.
28+
"""
29+
builder = self._get_builder(
30+
f"grid-{ComponentMetricId.REACTIVE_POWER.value}",
31+
ComponentMetricId.REACTIVE_POWER,
32+
Power.from_watts,
33+
)
34+
return self._generate(builder)

src/frequenz/sdk/timeseries/grid.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from dataclasses import dataclass
1414

1515
from frequenz.channels import Sender
16-
from frequenz.client.microgrid._component import ComponentCategory
16+
from frequenz.client.microgrid._component import ComponentCategory, ComponentMetricId
1717
from frequenz.quantities import Current, Power
1818

1919
from .._internal._channels import ChannelRegistry
@@ -26,6 +26,7 @@
2626
GridCurrentFormula,
2727
GridPower3PhaseFormula,
2828
GridPowerFormula,
29+
GridReactivePowerFormula,
2930
)
3031

3132
_logger = logging.getLogger(__name__)
@@ -95,6 +96,28 @@ def power(self) -> FormulaEngine[Power]:
9596
assert isinstance(engine, FormulaEngine)
9697
return engine
9798

99+
@property
100+
def reactive_power(self) -> FormulaEngine[Power]:
101+
"""Fetch the grid reactive power for the microgrid.
102+
103+
This formula produces values that are in the Passive Sign Convention (PSC).
104+
105+
If a formula engine to calculate grid power is not already running, it will be
106+
started.
107+
108+
A receiver from the formula engine can be created using the `new_receiver`
109+
method.
110+
111+
Returns:
112+
A FormulaEngine that will calculate and stream grid reactive power.
113+
"""
114+
engine = self._formula_pool.from_power_formula_generator(
115+
f"grid-{ComponentMetricId.REACTIVE_POWER.value}",
116+
GridReactivePowerFormula,
117+
)
118+
assert isinstance(engine, FormulaEngine)
119+
return engine
120+
98121
@property
99122
def _power_per_phase(self) -> FormulaEngine3Phase[Power]:
100123
"""Fetch the per-phase grid power for the microgrid.

tests/microgrid/test_grid.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,102 @@ async def test_grid_power_2(mocker: MockerFixture) -> None:
192192
assert equal_float_lists(results, meter_sums)
193193

194194

195+
async def test_grid_reactive_power_1(mocker: MockerFixture) -> None:
196+
"""Test the grid power formula with a grid side meter."""
197+
mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker)
198+
mockgrid.add_batteries(2)
199+
mockgrid.add_solar_inverters(1)
200+
201+
results = []
202+
grid_meter_data = []
203+
async with mockgrid, AsyncExitStack() as stack:
204+
grid = microgrid.grid()
205+
assert grid, "Grid is not initialized"
206+
stack.push_async_callback(grid.stop)
207+
208+
grid_power_recv = grid.reactive_power.new_receiver()
209+
210+
grid_meter_recv = get_resampled_stream(
211+
grid._formula_pool._namespace, # pylint: disable=protected-access
212+
mockgrid.meter_ids[0],
213+
client.ComponentMetricId.REACTIVE_POWER,
214+
Power.from_watts,
215+
)
216+
217+
for count in range(10):
218+
await mockgrid.mock_resampler.send_meter_reactive_power(
219+
[20.0 + count, 12.0, -13.0, -5.0]
220+
)
221+
val = await grid_meter_recv.receive()
222+
assert (
223+
val is not None
224+
and val.value is not None
225+
and val.value.as_watts() != 0.0
226+
)
227+
grid_meter_data.append(val.value)
228+
229+
val = await grid_power_recv.receive()
230+
assert val is not None and val.value is not None
231+
results.append(val.value)
232+
233+
assert equal_float_lists(results, grid_meter_data)
234+
235+
236+
async def test_grid_reactive_power_2(mocker: MockerFixture) -> None:
237+
"""Test the grid power formula without a grid side meter."""
238+
mockgrid = MockMicrogrid(grid_meter=False, mocker=mocker)
239+
mockgrid.add_consumer_meters(1)
240+
mockgrid.add_batteries(1, no_meter=False)
241+
mockgrid.add_batteries(1, no_meter=True)
242+
mockgrid.add_solar_inverters(1)
243+
244+
results: list[Quantity] = []
245+
meter_sums: list[Quantity] = []
246+
async with mockgrid, AsyncExitStack() as stack:
247+
grid = microgrid.grid()
248+
assert grid, "Grid is not initialized"
249+
stack.push_async_callback(grid.stop)
250+
251+
grid_power_recv = grid.reactive_power.new_receiver()
252+
253+
component_receivers = [
254+
get_resampled_stream(
255+
grid._formula_pool._namespace, # pylint: disable=protected-access
256+
component_id,
257+
client.ComponentMetricId.REACTIVE_POWER,
258+
Power.from_watts,
259+
)
260+
for component_id in [
261+
*mockgrid.meter_ids,
262+
# The last battery has no meter, so we get the power from the inverter
263+
mockgrid.battery_inverter_ids[-1],
264+
]
265+
]
266+
267+
for count in range(10):
268+
await mockgrid.mock_resampler.send_meter_reactive_power(
269+
[20.0 + count, 12.0, -13.0]
270+
)
271+
await mockgrid.mock_resampler.send_bat_inverter_reactive_power([0.0, -5.0])
272+
meter_sum = 0.0
273+
for recv in component_receivers:
274+
val = await recv.receive()
275+
assert (
276+
val is not None
277+
and val.value is not None
278+
and val.value.as_watts() != 0.0
279+
)
280+
meter_sum += val.value.as_watts()
281+
282+
val = await grid_power_recv.receive()
283+
assert val is not None and val.value is not None
284+
results.append(val.value)
285+
meter_sums.append(Quantity(meter_sum))
286+
287+
assert len(results) == 10
288+
assert equal_float_lists(results, meter_sums)
289+
290+
195291
async def test_grid_power_3_phase_side_meter(mocker: MockerFixture) -> None:
196292
"""Test the grid 3-phase power with a grid side meter."""
197293
mockgrid = MockMicrogrid(grid_meter=True, mocker=mocker)

tests/timeseries/mock_microgrid.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ def _start_meter_streaming(self, meter_id: int) -> None:
275275
lambda value, ts: MeterDataWrapper(
276276
component_id=meter_id,
277277
timestamp=ts,
278+
reactive_power=2 * value,
278279
active_power=value,
279280
current_per_phase=(value + 100.0, value + 101.0, value + 102.0),
280281
voltage_per_phase=(value + 200.0, value + 199.8, value + 200.2),
@@ -307,7 +308,10 @@ def _start_inverter_streaming(self, inv_id: int) -> None:
307308
self._comp_data_send_task(
308309
inv_id,
309310
lambda value, ts: InverterDataWrapper(
310-
component_id=inv_id, timestamp=ts, active_power=value
311+
component_id=inv_id,
312+
timestamp=ts,
313+
active_power=value,
314+
reactive_power=2 * value,
311315
),
312316
),
313317
)
@@ -325,6 +329,7 @@ def _start_ev_charger_streaming(self, evc_id: int) -> None:
325329
component_id=evc_id,
326330
timestamp=ts,
327331
active_power=value,
332+
reactive_power=2 * value,
328333
current_per_phase=(value + 10.0, value + 11.0, value + 12.0),
329334
component_state=self.evc_component_states[evc_id],
330335
cable_state=self.evc_cable_states[evc_id],

tests/timeseries/mock_resampler.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,29 +67,44 @@ def metric_senders(
6767
]
6868
return senders
6969

70+
# Active power senders
7071
self._bat_inverter_power_senders = metric_senders(
7172
bat_inverter_ids, ComponentMetricId.ACTIVE_POWER
7273
)
73-
self._bat_inverter_frequency_senders = metric_senders(
74-
bat_inverter_ids, ComponentMetricId.FREQUENCY
75-
)
7674
self._pv_inverter_power_senders = metric_senders(
7775
pv_inverter_ids, ComponentMetricId.ACTIVE_POWER
7876
)
7977
self._ev_power_senders = metric_senders(evc_ids, ComponentMetricId.ACTIVE_POWER)
78+
8079
self._chp_power_senders = metric_senders(
8180
chp_ids, ComponentMetricId.ACTIVE_POWER
8281
)
8382
self._meter_power_senders = metric_senders(
8483
meter_ids, ComponentMetricId.ACTIVE_POWER
8584
)
86-
self._meter_frequency_senders = metric_senders(
87-
meter_ids, ComponentMetricId.FREQUENCY
88-
)
8985
self._non_existing_component_sender = metric_senders(
9086
[NON_EXISTING_COMPONENT_ID], ComponentMetricId.ACTIVE_POWER
9187
)[0]
9288

89+
# Frequency senders
90+
self._bat_inverter_frequency_senders = metric_senders(
91+
bat_inverter_ids, ComponentMetricId.FREQUENCY
92+
)
93+
self._meter_frequency_senders = metric_senders(
94+
meter_ids, ComponentMetricId.FREQUENCY
95+
)
96+
97+
# Reactive power senders
98+
self._meter_reactive_power_senders = metric_senders(
99+
meter_ids, ComponentMetricId.REACTIVE_POWER
100+
)
101+
self._bat_inverter_reactive_power_senders = metric_senders(
102+
bat_inverter_ids, ComponentMetricId.REACTIVE_POWER
103+
)
104+
self._ev_reactive_power_senders = metric_senders(
105+
evc_ids, ComponentMetricId.REACTIVE_POWER
106+
)
107+
93108
def multi_phase_senders(
94109
ids: list[int],
95110
metrics: tuple[ComponentMetricId, ComponentMetricId, ComponentMetricId],
@@ -301,6 +316,29 @@ async def send_bat_inverter_power(self, values: list[float | None]) -> None:
301316
sample = self.make_sample(value)
302317
await chan.send(sample)
303318

319+
async def send_meter_reactive_power(self, values: list[float | None]) -> None:
320+
"""Send the given values as resampler output for meter reactive power."""
321+
assert len(values) == len(self._meter_reactive_power_senders)
322+
for chan, value in zip(self._meter_reactive_power_senders, values):
323+
sample = self.make_sample(value)
324+
await chan.send(sample)
325+
326+
async def send_bat_inverter_reactive_power(
327+
self, values: list[float | None]
328+
) -> None:
329+
"""Send the given values as resampler output for battery inverter reactive power."""
330+
assert len(values) == len(self._bat_inverter_reactive_power_senders)
331+
for chan, value in zip(self._bat_inverter_reactive_power_senders, values):
332+
sample = self.make_sample(value)
333+
await chan.send(sample)
334+
335+
async def send_evc_reactive_power(self, values: list[float | None]) -> None:
336+
"""Send the given values as resampler output for EV Charger reactive power."""
337+
assert len(values) == len(self._ev_reactive_power_senders)
338+
for chan, value in zip(self._ev_reactive_power_senders, values):
339+
sample = self.make_sample(value)
340+
await chan.send(sample)
341+
304342
async def send_non_existing_component_value(self) -> None:
305343
"""Send a value for a non existing component."""
306344
sample = self.make_sample(None)

0 commit comments

Comments
 (0)