From e41654353f65f09d480fffe0f80116c1fcbebf5f Mon Sep 17 00:00:00 2001 From: Daniel Zullo Date: Fri, 6 Oct 2023 22:09:41 +0200 Subject: [PATCH 1/6] Add microgrid metadata types The microgrid metadata is needed to retrieve the ID and location of the microgrid. Signed-off-by: Daniel Zullo --- pyproject.toml | 1 + src/frequenz/sdk/microgrid/__init__.py | 3 +- src/frequenz/sdk/microgrid/metadata.py | 50 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/frequenz/sdk/microgrid/metadata.py diff --git a/pyproject.toml b/pyproject.toml index a8dfb0fb4..dd2c8057f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "numpy >= 1.24.2, < 2", "protobuf >= 4.21.6, < 5", "pydantic >= 2.3, < 3", + "timezonefinder >= 6.2.0, < 7", "tqdm >= 4.38.0, < 5", "typing_extensions >= 4.6.1, < 5", "watchfiles >= 0.15.0", diff --git a/src/frequenz/sdk/microgrid/__init__.py b/src/frequenz/sdk/microgrid/__init__.py index 17cf75312..50e767d0d 100644 --- a/src/frequenz/sdk/microgrid/__init__.py +++ b/src/frequenz/sdk/microgrid/__init__.py @@ -124,7 +124,7 @@ from ..actor import ResamplerConfig from ..timeseries.grid import initialize as initialize_grid -from . import _data_pipeline, client, component, connection_manager, fuse +from . import _data_pipeline, client, component, connection_manager, fuse, metadata from ._data_pipeline import ( battery_pool, ev_charger_pool, @@ -161,4 +161,5 @@ async def initialize(host: str, port: int, resampler_config: ResamplerConfig) -> "grid", "frequency", "logical_meter", + "metadata", ] diff --git a/src/frequenz/sdk/microgrid/metadata.py b/src/frequenz/sdk/microgrid/metadata.py new file mode 100644 index 000000000..804844430 --- /dev/null +++ b/src/frequenz/sdk/microgrid/metadata.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH + +"""Metadata that describes a microgrid.""" + +from dataclasses import dataclass +from zoneinfo import ZoneInfo + +from timezonefinder import TimezoneFinder + +_timezone_finder = TimezoneFinder() + + +@dataclass(frozen=True, kw_only=True) +class Location: + """Metadata for the location of microgrid.""" + + latitude: float | None = None + """The latitude of the microgrid in degree.""" + + longitude: float | None = None + """The longitude of the microgrid in degree.""" + + timezone: ZoneInfo | None = None + """The timezone of the microgrid. + + The timezone will be set to None if the latitude or longitude points + are not set or the timezone cannot be found given the location points. + """ + + def __post_init__(self) -> None: + """Initialize the timezone of the microgrid.""" + if self.latitude is None or self.longitude is None or self.timezone is not None: + return + + timezone = _timezone_finder.timezone_at(lat=self.latitude, lng=self.longitude) + if timezone: + # The dataclass is frozen, so it needs to use __setattr__ to set the timezone. + object.__setattr__(self, "timezone", ZoneInfo(key=timezone)) + + +@dataclass(frozen=True, kw_only=True) +class Metadata: + """Metadata for the microgrid.""" + + microgrid_id: int | None = None + """The ID of the microgrid.""" + + location: Location | None = None + """The location of the microgrid.""" From bd02b9d9cc7e4d9ea41a7ddc7320431d2491a035 Mon Sep 17 00:00:00 2001 From: Daniel Zullo Date: Fri, 6 Oct 2023 21:39:14 +0200 Subject: [PATCH 2/6] Add function to retrieve the microgrid metadata So far the microgrid metadata is needed to get the ID and the location of the microgrid. The metadata is retrieved from the microgrid API through the gRPC call GetMicrogridMetadata. Signed-off-by: Daniel Zullo --- src/frequenz/sdk/microgrid/client/_client.py | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/frequenz/sdk/microgrid/client/_client.py b/src/frequenz/sdk/microgrid/client/_client.py index daa0dfd4c..5029df990 100644 --- a/src/frequenz/sdk/microgrid/client/_client.py +++ b/src/frequenz/sdk/microgrid/client/_client.py @@ -15,6 +15,7 @@ from frequenz.api.microgrid import microgrid_pb2 as microgrid_pb from frequenz.api.microgrid.microgrid_pb2_grpc import MicrogridStub from frequenz.channels import Broadcast, Receiver, Sender +from google.protobuf.empty_pb2 import Empty # pylint: disable=no-name-in-module from ..._internal._constants import RECEIVER_MAX_SIZE from ..component import ( @@ -30,6 +31,7 @@ _component_metadata_from_protobuf, _component_type_from_protobuf, ) +from ..metadata import Location, Metadata from ._connection import Connection from ._retry import LinearBackoff, RetryStrategy @@ -62,6 +64,14 @@ async def components(self) -> Iterable[Component]: Iterator whose elements are all the components in the microgrid. """ + @abstractmethod + async def metadata(self) -> Metadata: + """Fetch the microgrid metadata. + + Returns: + the microgrid metadata. + """ + @abstractmethod async def connections( self, @@ -259,6 +269,36 @@ async def components(self) -> Iterable[Component]: return result + async def metadata(self) -> Metadata: + """Fetch the microgrid metadata. + + If there is an error fetching the metadata, the microgrid ID and + location will be set to None. + + Returns: + the microgrid metadata. + """ + microgrid_metadata: microgrid_pb.MicrogridMetadata | None = None + try: + microgrid_metadata = await self.api.GetMicrogridMetadata( + Empty(), + timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), + ) # type: ignore[misc] + except grpc.aio.AioRpcError: + _logger.exception("The microgrid metadata is not available.") + + if not microgrid_metadata: + return Metadata() + + location: Location | None = None + if microgrid_metadata.location: + location = Location( + latitude=microgrid_metadata.location.latitude, + longitude=microgrid_metadata.location.longitude, + ) + + return Metadata(microgrid_id=microgrid_metadata.microgrid_id, location=location) + async def connections( self, starts: set[int] | None = None, From 8d82f9079577f2a6aa5c6f808db9f730119c1875 Mon Sep 17 00:00:00 2001 From: Daniel Zullo Date: Fri, 6 Oct 2023 21:54:27 +0200 Subject: [PATCH 3/6] Fetch metadata when connecting to the microgrid The ConnectionManager retrieves the metadata at initialization when connecting to the microgrid. Signed-off-by: Daniel Zullo --- src/frequenz/sdk/microgrid/connection_manager.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/frequenz/sdk/microgrid/connection_manager.py b/src/frequenz/sdk/microgrid/connection_manager.py index 98d21775a..36d4cde54 100644 --- a/src/frequenz/sdk/microgrid/connection_manager.py +++ b/src/frequenz/sdk/microgrid/connection_manager.py @@ -16,6 +16,7 @@ from .client import MicrogridApiClient from .client._client import MicrogridGrpcClient from .component_graph import ComponentGraph, _MicrogridComponentGraph +from .metadata import Metadata # Not public default host and port _DEFAULT_MICROGRID_HOST = "[::1]" @@ -103,6 +104,9 @@ def __init__( # So create empty graph here, and update it in `run` method. self._graph = _MicrogridComponentGraph() + self._metadata: Metadata + """The metadata of the microgrid.""" + @property def api_client(self) -> MicrogridApiClient: """Get MicrogridApiClient. @@ -133,9 +137,11 @@ async def _update_api(self, host: str, port: int) -> None: target = f"{host}:{port}" grpc_channel = grpcaio.insecure_channel(target) self._api = MicrogridGrpcClient(grpc_channel, target) + self._metadata = await self._api.metadata() await self._graph.refresh_from_api(self._api) async def _initialize(self) -> None: + self._metadata = await self._api.metadata() await self._graph.refresh_from_api(self._api) From 9bcaeeb25f04f216b07e402fd161c5283865de8e Mon Sep 17 00:00:00 2001 From: Daniel Zullo Date: Fri, 6 Oct 2023 21:58:05 +0200 Subject: [PATCH 4/6] Expose microgrid ID and location Signed-off-by: Daniel Zullo --- .../sdk/microgrid/connection_manager.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/frequenz/sdk/microgrid/connection_manager.py b/src/frequenz/sdk/microgrid/connection_manager.py index 36d4cde54..7136ab5e7 100644 --- a/src/frequenz/sdk/microgrid/connection_manager.py +++ b/src/frequenz/sdk/microgrid/connection_manager.py @@ -16,7 +16,7 @@ from .client import MicrogridApiClient from .client._client import MicrogridGrpcClient from .component_graph import ComponentGraph, _MicrogridComponentGraph -from .metadata import Metadata +from .metadata import Location, Metadata # Not public default host and port _DEFAULT_MICROGRID_HOST = "[::1]" @@ -75,6 +75,24 @@ def component_graph(self) -> ComponentGraph: component graph """ + @property + @abstractmethod + def microgrid_id(self) -> int | None: + """Get the ID of the microgrid if available. + + Returns: + the ID of the microgrid if available, None otherwise. + """ + + @property + @abstractmethod + def location(self) -> Location | None: + """Get the location of the microgrid if available. + + Returns: + the location of the microgrid if available, None otherwise. + """ + async def _update_api(self, host: str, port: int) -> None: self._host = host self._port = port @@ -116,6 +134,24 @@ def api_client(self) -> MicrogridApiClient: """ return self._api + @property + def microgrid_id(self) -> int | None: + """Get the ID of the microgrid if available. + + Returns: + the ID of the microgrid if available, None otherwise. + """ + return self._metadata.microgrid_id + + @property + def location(self) -> Location | None: + """Get the location of the microgrid if available. + + Returns: + the location of the microgrid if available, None otherwise. + """ + return self._metadata.location + @property def component_graph(self) -> ComponentGraph: """Get component graph. From dd9a4da0bef66e718cf64a97590ec299f2495180 Mon Sep 17 00:00:00 2001 From: Daniel Zullo Date: Fri, 6 Oct 2023 21:59:15 +0200 Subject: [PATCH 5/6] Amend tests to reflect microgrid metadata changes Signed-off-by: Daniel Zullo --- tests/microgrid/test_microgrid_api.py | 36 +++++++++++++++++++++++++++ tests/utils/mock_microgrid_client.py | 13 +++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/microgrid/test_microgrid_api.py b/tests/microgrid/test_microgrid_api.py index 0fc787c6b..c30ca387e 100644 --- a/tests/microgrid/test_microgrid_api.py +++ b/tests/microgrid/test_microgrid_api.py @@ -11,6 +11,7 @@ import pytest from frequenz.sdk.microgrid import connection_manager +from frequenz.sdk.microgrid import metadata as meta from frequenz.sdk.microgrid.client import Connection from frequenz.sdk.microgrid.component import Component, ComponentCategory @@ -82,12 +83,29 @@ def connections(self) -> list[list[Connection]]: ] return connections + @pytest.fixture + def metadata(self) -> meta.Metadata: + """Fetch the microgrid metadata. + + Returns: + the microgrid metadata. + """ + mock_timezone_finder = MagicMock() + mock_timezone_finder.timezone_at.return_value = "Europe/Berlin" + meta._timezone_finder = mock_timezone_finder # pylint: disable=protected-access + + return meta.Metadata( + microgrid_id=8, + location=meta.Location(latitude=52.520008, longitude=13.404954), + ) + @mock.patch("grpc.aio.insecure_channel") async def test_connection_manager( self, _: MagicMock, components: list[list[Component]], connections: list[list[Connection]], + metadata: meta.Metadata, ) -> None: """Test microgrid api. @@ -95,10 +113,12 @@ async def test_connection_manager( _: insecure channel mock from `mock.patch` components: components connections: connections + metadata: the metadata of the microgrid """ microgrid_client = MagicMock() microgrid_client.components = AsyncMock(side_effect=components) microgrid_client.connections = AsyncMock(side_effect=connections) + microgrid_client.metadata = AsyncMock(return_value=metadata) with mock.patch( "frequenz.sdk.microgrid.connection_manager.MicrogridGrpcClient", @@ -137,6 +157,11 @@ async def test_connection_manager( assert set(graph.components()) == set(components[0]) assert set(graph.connections()) == set(connections[0]) + assert api.microgrid_id == metadata.microgrid_id + assert api.location == metadata.location + assert api.location and api.location.timezone + assert api.location.timezone.key == "Europe/Berlin" + # It should not be possible to initialize method once again with pytest.raises(AssertionError): await connection_manager.initialize("127.0.0.1", 10001) @@ -148,12 +173,16 @@ async def test_connection_manager( assert set(graph.components()) == set(components[0]) assert set(graph.connections()) == set(connections[0]) + assert api.microgrid_id == metadata.microgrid_id + assert api.location == metadata.location + @mock.patch("grpc.aio.insecure_channel") async def test_connection_manager_another_method( self, _: MagicMock, components: list[list[Component]], connections: list[list[Connection]], + metadata: meta.Metadata, ) -> None: """Test if the api was not deallocated. @@ -161,12 +190,19 @@ async def test_connection_manager_another_method( _: insecure channel mock components: components connections: connections + metadata: the metadata of the microgrid """ microgrid_client = MagicMock() microgrid_client.components = AsyncMock(return_value=[]) microgrid_client.connections = AsyncMock(return_value=[]) + microgrid_client.get_metadata = AsyncMock(return_value=None) api = connection_manager.get() graph = api.component_graph assert set(graph.components()) == set(components[0]) assert set(graph.connections()) == set(connections[0]) + + assert api.microgrid_id == metadata.microgrid_id + assert api.location == metadata.location + assert api.location and api.location.timezone + assert api.location.timezone.key == "Europe/Berlin" diff --git a/tests/utils/mock_microgrid_client.py b/tests/utils/mock_microgrid_client.py index 0ea7a0147..60c999609 100644 --- a/tests/utils/mock_microgrid_client.py +++ b/tests/utils/mock_microgrid_client.py @@ -26,12 +26,19 @@ _MicrogridComponentGraph, ) from frequenz.sdk.microgrid.connection_manager import ConnectionManager +from frequenz.sdk.microgrid.metadata import Location class MockMicrogridClient: """Class that mocks MicrogridClient behavior.""" - def __init__(self, components: set[Component], connections: set[Connection]): + def __init__( + self, + components: set[Component], + connections: set[Connection], + microgrid_id: int = 8, + location: Location = Location(latitude=52.520008, longitude=13.404954), + ): """Create mock microgrid with given components and connections. This simulates microgrid. @@ -43,6 +50,8 @@ def __init__(self, components: set[Component], connections: set[Connection]): Args: components: List of the microgrid components connections: List of the microgrid connections + microgrid_id: the ID of the microgrid + location: the location of the microgrid """ self._component_graph = _MicrogridComponentGraph(components, connections) @@ -66,6 +75,8 @@ def __init__(self, components: set[Component], connections: set[Connection]): kwargs: dict[str, Any] = { "api_client": mock_api, "component_graph": self._component_graph, + "microgrid_id": microgrid_id, + "location": location, } self._mock_microgrid = MagicMock(spec=ConnectionManager, **kwargs) From d0284c9542185202efc023b149cc156b2681c036 Mon Sep 17 00:00:00 2001 From: Daniel Zullo Date: Fri, 6 Oct 2023 22:36:59 +0200 Subject: [PATCH 6/6] Update release notes Signed-off-by: Daniel Zullo --- RELEASE_NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6c69f6253..a8cc6c329 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -75,6 +75,8 @@ This version ships an experimental version of the **Power Manager**, adds prelim * All development branches now have their documentation published (there is no `next` version anymore). * Fix the order of the documentation versions. +- The `ConnectionManager` fetches microgrid metadata when connecting to the microgrid and exposes `microgrid_id` and `location` properties of the connected microgrid. + ## Bug Fixes - Fix rendering of diagrams in the documentation.