Skip to content

Commit

Permalink
Fetch and expose microgrid ID and location (#708)
Browse files Browse the repository at this point in the history
Fixes #265
  • Loading branch information
daniel-zullo-frequenz authored Nov 7, 2023
2 parents 05adc38 + d0284c9 commit 39854a8
Show file tree
Hide file tree
Showing 8 changed files with 185 additions and 2 deletions.
2 changes: 2 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/frequenz/sdk/microgrid/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -161,4 +161,5 @@ async def initialize(host: str, port: int, resampler_config: ResamplerConfig) ->
"grid",
"frequency",
"logical_meter",
"metadata",
]
40 changes: 40 additions & 0 deletions src/frequenz/sdk/microgrid/client/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions src/frequenz/sdk/microgrid/connection_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .client import MicrogridApiClient
from .client._client import MicrogridGrpcClient
from .component_graph import ComponentGraph, _MicrogridComponentGraph
from .metadata import Location, Metadata

# Not public default host and port
_DEFAULT_MICROGRID_HOST = "[::1]"
Expand Down Expand Up @@ -74,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
Expand Down Expand Up @@ -103,6 +122,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.
Expand All @@ -112,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.
Expand All @@ -133,9 +173,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)


Expand Down
50 changes: 50 additions & 0 deletions src/frequenz/sdk/microgrid/metadata.py
Original file line number Diff line number Diff line change
@@ -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."""
36 changes: 36 additions & 0 deletions tests/microgrid/test_microgrid_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -82,23 +83,42 @@ 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.
Args:
_: 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",
Expand Down Expand Up @@ -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)
Expand All @@ -148,25 +173,36 @@ 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.
Args:
_: 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"
13 changes: 12 additions & 1 deletion tests/utils/mock_microgrid_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand All @@ -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)
Expand Down

0 comments on commit 39854a8

Please sign in to comment.