Skip to content

Commit 5f694aa

Browse files
committed
Add support for grid frequency
Signed-off-by: Mathias L. Baumann <[email protected]>
1 parent 318b643 commit 5f694aa

File tree

6 files changed

+383
-2
lines changed

6 files changed

+383
-2
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
Note that a microgrid is allowed to have zero or one grid connection point. Microgrids configured as islands will have zero grid connection points, and microgrids configured as grid-connected will have one grid connection point.
1818

19+
- A new method `microgrid.frequeny()` was added to allow easy access to the current frequency of the grid.
20+
1921
- A new class `Fuse` has been added to represent fuses. This class has a member variable `max_current` which represents the maximum current that can course through the fuse. If the current flowing through a fuse is greater than this limit, then the fuse will break the circuit.
2022

2123

src/frequenz/sdk/microgrid/__init__.py

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

1010
from ..actor import ResamplerConfig
1111
from . import _data_pipeline, client, component, connection_manager, fuse, grid
12-
from ._data_pipeline import battery_pool, ev_charger_pool, logical_meter
12+
from ._data_pipeline import battery_pool, ev_charger_pool, frequency, logical_meter
1313
from ._graph import ComponentGraph
1414

1515

@@ -39,5 +39,6 @@ async def initialize(host: str, port: int, resampler_config: ResamplerConfig) ->
3939
"ev_charger_pool",
4040
"fuse",
4141
"grid",
42+
"frequency",
4243
"logical_meter",
4344
]

src/frequenz/sdk/microgrid/_data_pipeline.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
from frequenz.channels import Broadcast, Sender
1919

20+
from ..microgrid.component import Component
21+
from ..timeseries._grid_frequency import GridFrequency
2022
from . import connection_manager
2123
from .component import ComponentCategory
2224

@@ -101,6 +103,29 @@ def __init__(
101103
self._logical_meter: "LogicalMeter" | None = None
102104
self._ev_charger_pools: dict[frozenset[int], "EVChargerPool"] = {}
103105
self._battery_pools: dict[frozenset[int], "BatteryPool"] = {}
106+
self._frequency_pool: dict[int, GridFrequency] = {}
107+
108+
def frequency(self, component: Component | None = None) -> GridFrequency:
109+
"""Fetch the grid frequency for the microgrid.
110+
111+
Args:
112+
component: The component to use when fetching the grid frequency. If None,
113+
the component will be fetched from the registry.
114+
115+
Returns:
116+
A GridFrequency instance.
117+
"""
118+
if component is None:
119+
component = GridFrequency.find_frequency_component()
120+
121+
if component.component_id in self._frequency_pool:
122+
return self._frequency_pool[component.component_id]
123+
124+
grid_frequency = GridFrequency(
125+
self._data_sourcing_request_sender(), self._channel_registry, component
126+
)
127+
self._frequency_pool[component.component_id] = grid_frequency
128+
return grid_frequency
104129

105130
def logical_meter(self) -> LogicalMeter:
106131
"""Return the logical meter instance.
@@ -296,6 +321,19 @@ async def initialize(resampler_config: ResamplerConfig) -> None:
296321
_DATA_PIPELINE = _DataPipeline(resampler_config)
297322

298323

324+
def frequency(component: Component | None = None) -> GridFrequency:
325+
"""Return the grid frequency.
326+
327+
Args:
328+
component: Optional component to get the frequency for. If not specified,
329+
the frequency of the grid is returned.
330+
331+
Returns:
332+
The grid frequency.
333+
"""
334+
return _get().frequency(component)
335+
336+
299337
def logical_meter() -> LogicalMeter:
300338
"""Return the logical meter instance.
301339
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Fetches the Grid Frequency."""
5+
6+
from __future__ import annotations
7+
8+
import asyncio
9+
import logging
10+
from typing import TYPE_CHECKING
11+
12+
from frequenz.channels import Receiver, Sender
13+
14+
from ..actor import ChannelRegistry
15+
from ..microgrid import connection_manager
16+
from ..microgrid.component import Component, ComponentCategory, ComponentMetricId
17+
from ..timeseries._base_types import Sample
18+
from ..timeseries._quantities import Frequency
19+
20+
if TYPE_CHECKING:
21+
# Imported here to avoid a circular import.
22+
from ..actor import ComponentMetricRequest
23+
24+
25+
def create_request(component_id: int) -> ComponentMetricRequest:
26+
"""Create a request for grid frequency.
27+
28+
Args:
29+
component_id: The component id to use for the request.
30+
31+
Returns:
32+
A component metric request for grid frequency.
33+
"""
34+
# Imported here to avoid a circular import.
35+
# pylint: disable=import-outside-toplevel
36+
from ..actor import ComponentMetricRequest
37+
38+
return ComponentMetricRequest(
39+
"grid-frequency", component_id, ComponentMetricId.FREQUENCY, None
40+
)
41+
42+
43+
class GridFrequency:
44+
"""Grid Frequency."""
45+
46+
def __init__(
47+
self,
48+
data_sourcing_request_sender: Sender[ComponentMetricRequest],
49+
channel_registry: ChannelRegistry,
50+
component: Component,
51+
):
52+
"""Initialize the grid frequency formula generator.
53+
54+
Args:
55+
data_sourcing_request_sender: The sender to use for requests.
56+
channel_registry: The channel registry to use for the grid frequency.
57+
component: The component to use for the grid frequency receiver. If not
58+
provided, the first component that is either a meter, inverter or EV
59+
charger will be used.
60+
"""
61+
self._request_sender = data_sourcing_request_sender
62+
self._channel_registry = channel_registry
63+
self._component = component
64+
self._component_metric_request = create_request(component.component_id)
65+
66+
self._task: None | asyncio.Task[None] = None
67+
68+
@property
69+
def component(self) -> Component:
70+
"""The component that is used for grid frequency.
71+
72+
Returns:
73+
The component that is used for grid frequency.
74+
"""
75+
return self._component
76+
77+
def new_receiver(self) -> Receiver[Sample[Frequency]]:
78+
"""Create a receiver for grid frequency.
79+
80+
Returns:
81+
A receiver that will receive grid frequency samples.
82+
"""
83+
receiver = self._channel_registry.new_receiver(
84+
self._component_metric_request.get_channel_name()
85+
)
86+
87+
if not self._task:
88+
self._task = asyncio.create_task(self._send_request())
89+
else:
90+
logging.info("Grid frequency request already sent: %s", self._component)
91+
92+
return receiver
93+
94+
async def _send_request(self) -> None:
95+
"""Send the request for grid frequency."""
96+
logging.info("Sending request for grid frequency: %s", self._component)
97+
await self._request_sender.send(self._component_metric_request)
98+
logging.info("Sent request for grid frequency: %s", self._component)
99+
100+
@staticmethod
101+
def find_frequency_component() -> Component:
102+
"""Find the component that will be used for grid frequency.
103+
104+
Uses the first meter it can find to gather the frequency. If no meter is
105+
available, it will use the first inverter, then EV charger.
106+
107+
Returns:
108+
The component that will be used for grid frequency.
109+
110+
Raises:
111+
ValueError: when the component graph doesn't have a `GRID` component.
112+
"""
113+
component_graph = connection_manager.get().component_graph
114+
grid_component = next(
115+
(
116+
comp
117+
for comp in component_graph.components()
118+
if comp.category == ComponentCategory.GRID
119+
),
120+
None,
121+
)
122+
123+
if grid_component is None:
124+
raise ValueError(
125+
"Unable to find a GRID component from the component graph."
126+
)
127+
128+
# Sort by component id to ensure consistent results
129+
grid_successors = sorted(
130+
component_graph.successors(grid_component.component_id),
131+
key=lambda comp: comp.component_id,
132+
)
133+
134+
def find_component(component_category: ComponentCategory) -> Component | None:
135+
return next(
136+
(
137+
comp
138+
for comp in grid_successors
139+
if comp.category == component_category
140+
),
141+
None,
142+
)
143+
144+
# Find the first component that is either a meter, inverter or EV charger
145+
# with category priority in that order.
146+
component = next(
147+
filter(
148+
None,
149+
map(
150+
find_component,
151+
[
152+
ComponentCategory.METER,
153+
ComponentCategory.INVERTER,
154+
ComponentCategory.EV_CHARGER,
155+
],
156+
),
157+
),
158+
None,
159+
)
160+
161+
if component is None:
162+
raise ValueError(
163+
"Unable to find a METER, INVERTER or EV_CHARGER component from the component graph."
164+
)
165+
166+
return component

tests/timeseries/mock_resampler.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,25 @@ def power_senders(
5858
]
5959
return senders
6060

61+
def frequency_senders(
62+
comp_ids: list[int],
63+
) -> list[Sender[Sample[Quantity]]]:
64+
senders: list[Sender[Sample[Quantity]]] = []
65+
for comp_id in comp_ids:
66+
name = f"{comp_id}:{ComponentMetricId.FREQUENCY}"
67+
senders.append(self._channel_registry.new_sender(name))
68+
self._basic_receivers[name] = [
69+
self._channel_registry.new_receiver(name) for _ in range(namespaces)
70+
]
71+
return senders
72+
6173
self._bat_inverter_power_senders = power_senders(bat_inverter_ids)
74+
self._bat_inverter_frequency_senders = frequency_senders(bat_inverter_ids)
6275
self._pv_inverter_power_senders = power_senders(pv_inverter_ids)
6376
self._ev_power_senders = power_senders(evc_ids)
6477
self._chp_power_senders = power_senders(chp_ids)
6578
self._meter_power_senders = power_senders(meter_ids)
79+
self._meter_frequency_senders = frequency_senders(meter_ids)
6680
self._non_existing_component_sender = power_senders(
6781
[NON_EXISTING_COMPONENT_ID]
6882
)[0]
@@ -159,6 +173,20 @@ async def send_pv_inverter_power(self, values: list[float | None]) -> None:
159173
sample = Sample(self._next_ts, None if not value else Quantity(value))
160174
await chan.send(sample)
161175

176+
async def send_meter_frequency(self, values: list[float | None]) -> None:
177+
"""Send the given values as resampler output for meter frequency."""
178+
assert len(values) == len(self._meter_frequency_senders)
179+
for sender, value in zip(self._meter_frequency_senders, values):
180+
sample = Sample(self._next_ts, None if not value else Quantity(value))
181+
await sender.send(sample)
182+
183+
async def send_bat_inverter_frequency(self, values: list[float | None]) -> None:
184+
"""Send the given values as resampler output for battery inverter frequency."""
185+
assert len(values) == len(self._bat_inverter_frequency_senders)
186+
for chan, value in zip(self._bat_inverter_frequency_senders, values):
187+
sample = Sample(self._next_ts, None if not value else Quantity(value))
188+
await chan.send(sample)
189+
162190
async def send_evc_power(self, values: list[float | None]) -> None:
163191
"""Send the given values as resampler output for EV Charger power."""
164192
assert len(values) == len(self._ev_power_senders)

0 commit comments

Comments
 (0)