Skip to content

Commit 39854a8

Browse files
Fetch and expose microgrid ID and location (#708)
Fixes #265
2 parents 05adc38 + d0284c9 commit 39854a8

File tree

8 files changed

+185
-2
lines changed

8 files changed

+185
-2
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ This version ships an experimental version of the **Power Manager**, adds prelim
7575
* All development branches now have their documentation published (there is no `next` version anymore).
7676
* Fix the order of the documentation versions.
7777

78+
- The `ConnectionManager` fetches microgrid metadata when connecting to the microgrid and exposes `microgrid_id` and `location` properties of the connected microgrid.
79+
7880
## Bug Fixes
7981

8082
- Fix rendering of diagrams in the documentation.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ dependencies = [
3838
"numpy >= 1.24.2, < 2",
3939
"protobuf >= 4.21.6, < 5",
4040
"pydantic >= 2.3, < 3",
41+
"timezonefinder >= 6.2.0, < 7",
4142
"tqdm >= 4.38.0, < 5",
4243
"typing_extensions >= 4.6.1, < 5",
4344
"watchfiles >= 0.15.0",

src/frequenz/sdk/microgrid/__init__.py

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

125125
from ..actor import ResamplerConfig
126126
from ..timeseries.grid import initialize as initialize_grid
127-
from . import _data_pipeline, client, component, connection_manager, fuse
127+
from . import _data_pipeline, client, component, connection_manager, fuse, metadata
128128
from ._data_pipeline import (
129129
battery_pool,
130130
ev_charger_pool,
@@ -161,4 +161,5 @@ async def initialize(host: str, port: int, resampler_config: ResamplerConfig) ->
161161
"grid",
162162
"frequency",
163163
"logical_meter",
164+
"metadata",
164165
]

src/frequenz/sdk/microgrid/client/_client.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from frequenz.api.microgrid import microgrid_pb2 as microgrid_pb
1616
from frequenz.api.microgrid.microgrid_pb2_grpc import MicrogridStub
1717
from frequenz.channels import Broadcast, Receiver, Sender
18+
from google.protobuf.empty_pb2 import Empty # pylint: disable=no-name-in-module
1819

1920
from ..._internal._constants import RECEIVER_MAX_SIZE
2021
from ..component import (
@@ -30,6 +31,7 @@
3031
_component_metadata_from_protobuf,
3132
_component_type_from_protobuf,
3233
)
34+
from ..metadata import Location, Metadata
3335
from ._connection import Connection
3436
from ._retry import LinearBackoff, RetryStrategy
3537

@@ -62,6 +64,14 @@ async def components(self) -> Iterable[Component]:
6264
Iterator whose elements are all the components in the microgrid.
6365
"""
6466

67+
@abstractmethod
68+
async def metadata(self) -> Metadata:
69+
"""Fetch the microgrid metadata.
70+
71+
Returns:
72+
the microgrid metadata.
73+
"""
74+
6575
@abstractmethod
6676
async def connections(
6777
self,
@@ -259,6 +269,36 @@ async def components(self) -> Iterable[Component]:
259269

260270
return result
261271

272+
async def metadata(self) -> Metadata:
273+
"""Fetch the microgrid metadata.
274+
275+
If there is an error fetching the metadata, the microgrid ID and
276+
location will be set to None.
277+
278+
Returns:
279+
the microgrid metadata.
280+
"""
281+
microgrid_metadata: microgrid_pb.MicrogridMetadata | None = None
282+
try:
283+
microgrid_metadata = await self.api.GetMicrogridMetadata(
284+
Empty(),
285+
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
286+
) # type: ignore[misc]
287+
except grpc.aio.AioRpcError:
288+
_logger.exception("The microgrid metadata is not available.")
289+
290+
if not microgrid_metadata:
291+
return Metadata()
292+
293+
location: Location | None = None
294+
if microgrid_metadata.location:
295+
location = Location(
296+
latitude=microgrid_metadata.location.latitude,
297+
longitude=microgrid_metadata.location.longitude,
298+
)
299+
300+
return Metadata(microgrid_id=microgrid_metadata.microgrid_id, location=location)
301+
262302
async def connections(
263303
self,
264304
starts: set[int] | None = None,

src/frequenz/sdk/microgrid/connection_manager.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .client import MicrogridApiClient
1717
from .client._client import MicrogridGrpcClient
1818
from .component_graph import ComponentGraph, _MicrogridComponentGraph
19+
from .metadata import Location, Metadata
1920

2021
# Not public default host and port
2122
_DEFAULT_MICROGRID_HOST = "[::1]"
@@ -74,6 +75,24 @@ def component_graph(self) -> ComponentGraph:
7475
component graph
7576
"""
7677

78+
@property
79+
@abstractmethod
80+
def microgrid_id(self) -> int | None:
81+
"""Get the ID of the microgrid if available.
82+
83+
Returns:
84+
the ID of the microgrid if available, None otherwise.
85+
"""
86+
87+
@property
88+
@abstractmethod
89+
def location(self) -> Location | None:
90+
"""Get the location of the microgrid if available.
91+
92+
Returns:
93+
the location of the microgrid if available, None otherwise.
94+
"""
95+
7796
async def _update_api(self, host: str, port: int) -> None:
7897
self._host = host
7998
self._port = port
@@ -103,6 +122,9 @@ def __init__(
103122
# So create empty graph here, and update it in `run` method.
104123
self._graph = _MicrogridComponentGraph()
105124

125+
self._metadata: Metadata
126+
"""The metadata of the microgrid."""
127+
106128
@property
107129
def api_client(self) -> MicrogridApiClient:
108130
"""Get MicrogridApiClient.
@@ -112,6 +134,24 @@ def api_client(self) -> MicrogridApiClient:
112134
"""
113135
return self._api
114136

137+
@property
138+
def microgrid_id(self) -> int | None:
139+
"""Get the ID of the microgrid if available.
140+
141+
Returns:
142+
the ID of the microgrid if available, None otherwise.
143+
"""
144+
return self._metadata.microgrid_id
145+
146+
@property
147+
def location(self) -> Location | None:
148+
"""Get the location of the microgrid if available.
149+
150+
Returns:
151+
the location of the microgrid if available, None otherwise.
152+
"""
153+
return self._metadata.location
154+
115155
@property
116156
def component_graph(self) -> ComponentGraph:
117157
"""Get component graph.
@@ -133,9 +173,11 @@ async def _update_api(self, host: str, port: int) -> None:
133173
target = f"{host}:{port}"
134174
grpc_channel = grpcaio.insecure_channel(target)
135175
self._api = MicrogridGrpcClient(grpc_channel, target)
176+
self._metadata = await self._api.metadata()
136177
await self._graph.refresh_from_api(self._api)
137178

138179
async def _initialize(self) -> None:
180+
self._metadata = await self._api.metadata()
139181
await self._graph.refresh_from_api(self._api)
140182

141183

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# License: MIT
2+
# Copyright © 2023 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Metadata that describes a microgrid."""
5+
6+
from dataclasses import dataclass
7+
from zoneinfo import ZoneInfo
8+
9+
from timezonefinder import TimezoneFinder
10+
11+
_timezone_finder = TimezoneFinder()
12+
13+
14+
@dataclass(frozen=True, kw_only=True)
15+
class Location:
16+
"""Metadata for the location of microgrid."""
17+
18+
latitude: float | None = None
19+
"""The latitude of the microgrid in degree."""
20+
21+
longitude: float | None = None
22+
"""The longitude of the microgrid in degree."""
23+
24+
timezone: ZoneInfo | None = None
25+
"""The timezone of the microgrid.
26+
27+
The timezone will be set to None if the latitude or longitude points
28+
are not set or the timezone cannot be found given the location points.
29+
"""
30+
31+
def __post_init__(self) -> None:
32+
"""Initialize the timezone of the microgrid."""
33+
if self.latitude is None or self.longitude is None or self.timezone is not None:
34+
return
35+
36+
timezone = _timezone_finder.timezone_at(lat=self.latitude, lng=self.longitude)
37+
if timezone:
38+
# The dataclass is frozen, so it needs to use __setattr__ to set the timezone.
39+
object.__setattr__(self, "timezone", ZoneInfo(key=timezone))
40+
41+
42+
@dataclass(frozen=True, kw_only=True)
43+
class Metadata:
44+
"""Metadata for the microgrid."""
45+
46+
microgrid_id: int | None = None
47+
"""The ID of the microgrid."""
48+
49+
location: Location | None = None
50+
"""The location of the microgrid."""

tests/microgrid/test_microgrid_api.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import pytest
1212

1313
from frequenz.sdk.microgrid import connection_manager
14+
from frequenz.sdk.microgrid import metadata as meta
1415
from frequenz.sdk.microgrid.client import Connection
1516
from frequenz.sdk.microgrid.component import Component, ComponentCategory
1617

@@ -82,23 +83,42 @@ def connections(self) -> list[list[Connection]]:
8283
]
8384
return connections
8485

86+
@pytest.fixture
87+
def metadata(self) -> meta.Metadata:
88+
"""Fetch the microgrid metadata.
89+
90+
Returns:
91+
the microgrid metadata.
92+
"""
93+
mock_timezone_finder = MagicMock()
94+
mock_timezone_finder.timezone_at.return_value = "Europe/Berlin"
95+
meta._timezone_finder = mock_timezone_finder # pylint: disable=protected-access
96+
97+
return meta.Metadata(
98+
microgrid_id=8,
99+
location=meta.Location(latitude=52.520008, longitude=13.404954),
100+
)
101+
85102
@mock.patch("grpc.aio.insecure_channel")
86103
async def test_connection_manager(
87104
self,
88105
_: MagicMock,
89106
components: list[list[Component]],
90107
connections: list[list[Connection]],
108+
metadata: meta.Metadata,
91109
) -> None:
92110
"""Test microgrid api.
93111
94112
Args:
95113
_: insecure channel mock from `mock.patch`
96114
components: components
97115
connections: connections
116+
metadata: the metadata of the microgrid
98117
"""
99118
microgrid_client = MagicMock()
100119
microgrid_client.components = AsyncMock(side_effect=components)
101120
microgrid_client.connections = AsyncMock(side_effect=connections)
121+
microgrid_client.metadata = AsyncMock(return_value=metadata)
102122

103123
with mock.patch(
104124
"frequenz.sdk.microgrid.connection_manager.MicrogridGrpcClient",
@@ -137,6 +157,11 @@ async def test_connection_manager(
137157
assert set(graph.components()) == set(components[0])
138158
assert set(graph.connections()) == set(connections[0])
139159

160+
assert api.microgrid_id == metadata.microgrid_id
161+
assert api.location == metadata.location
162+
assert api.location and api.location.timezone
163+
assert api.location.timezone.key == "Europe/Berlin"
164+
140165
# It should not be possible to initialize method once again
141166
with pytest.raises(AssertionError):
142167
await connection_manager.initialize("127.0.0.1", 10001)
@@ -148,25 +173,36 @@ async def test_connection_manager(
148173
assert set(graph.components()) == set(components[0])
149174
assert set(graph.connections()) == set(connections[0])
150175

176+
assert api.microgrid_id == metadata.microgrid_id
177+
assert api.location == metadata.location
178+
151179
@mock.patch("grpc.aio.insecure_channel")
152180
async def test_connection_manager_another_method(
153181
self,
154182
_: MagicMock,
155183
components: list[list[Component]],
156184
connections: list[list[Connection]],
185+
metadata: meta.Metadata,
157186
) -> None:
158187
"""Test if the api was not deallocated.
159188
160189
Args:
161190
_: insecure channel mock
162191
components: components
163192
connections: connections
193+
metadata: the metadata of the microgrid
164194
"""
165195
microgrid_client = MagicMock()
166196
microgrid_client.components = AsyncMock(return_value=[])
167197
microgrid_client.connections = AsyncMock(return_value=[])
198+
microgrid_client.get_metadata = AsyncMock(return_value=None)
168199

169200
api = connection_manager.get()
170201
graph = api.component_graph
171202
assert set(graph.components()) == set(components[0])
172203
assert set(graph.connections()) == set(connections[0])
204+
205+
assert api.microgrid_id == metadata.microgrid_id
206+
assert api.location == metadata.location
207+
assert api.location and api.location.timezone
208+
assert api.location.timezone.key == "Europe/Berlin"

tests/utils/mock_microgrid_client.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,19 @@
2626
_MicrogridComponentGraph,
2727
)
2828
from frequenz.sdk.microgrid.connection_manager import ConnectionManager
29+
from frequenz.sdk.microgrid.metadata import Location
2930

3031

3132
class MockMicrogridClient:
3233
"""Class that mocks MicrogridClient behavior."""
3334

34-
def __init__(self, components: set[Component], connections: set[Connection]):
35+
def __init__(
36+
self,
37+
components: set[Component],
38+
connections: set[Connection],
39+
microgrid_id: int = 8,
40+
location: Location = Location(latitude=52.520008, longitude=13.404954),
41+
):
3542
"""Create mock microgrid with given components and connections.
3643
3744
This simulates microgrid.
@@ -43,6 +50,8 @@ def __init__(self, components: set[Component], connections: set[Connection]):
4350
Args:
4451
components: List of the microgrid components
4552
connections: List of the microgrid connections
53+
microgrid_id: the ID of the microgrid
54+
location: the location of the microgrid
4655
"""
4756
self._component_graph = _MicrogridComponentGraph(components, connections)
4857

@@ -66,6 +75,8 @@ def __init__(self, components: set[Component], connections: set[Connection]):
6675
kwargs: dict[str, Any] = {
6776
"api_client": mock_api,
6877
"component_graph": self._component_graph,
78+
"microgrid_id": microgrid_id,
79+
"location": location,
6980
}
7081

7182
self._mock_microgrid = MagicMock(spec=ConnectionManager, **kwargs)

0 commit comments

Comments
 (0)