diff --git a/pyproject.toml b/pyproject.toml index ccf1d16..b32f1f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ description = "High-level interface to grid pools for the Frequenz platform." readme = "README.md" license = { text = "MIT" } keywords = ["frequenz", "python", "lib", "library", "gridpool"] -# TODO(cookiecutter): Remove and add more classifiers if appropriate classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -26,9 +25,10 @@ classifiers = [ "Typing :: Typed", ] requires-python = ">= 3.11, < 4" -# TODO(cookiecutter): Remove and add more dependencies if appropriate dependencies = [ "typing-extensions >= 4.14.1, < 5", + "frequenz-microgrid-component-graph @ git+https://github.com/shsms/frequenz-microgrid-component-graph-python.git@c4a458e06e541846de0a25142d5b821dd7934f47", + "frequenz-client-assets @ git+https://github.com/shsms/frequenz-client-assets-python@bbf09104df24ddfac497e2a3dd66fe68cfdacd25" ] dynamic = ["version"] @@ -36,7 +36,6 @@ dynamic = ["version"] name = "Frequenz Energy-as-a-Service GmbH" email = "floss@frequenz.com" -# TODO(cookiecutter): Remove and add more optional dependencies if appropriate [project.optional-dependencies] dev-flake8 = [ "flake8 == 7.3.0", diff --git a/src/frequenz/gridpool/__init__.py b/src/frequenz/gridpool/__init__.py index 362fe14..31343c5 100644 --- a/src/frequenz/gridpool/__init__.py +++ b/src/frequenz/gridpool/__init__.py @@ -1,25 +1,8 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""High-level interface to grid pools for the Frequenz platform.. +"""High-level interface to grid pools for the Frequenz platform.""" -TODO(cookiecutter): Add a more descriptive module description. -""" +from ._graph_generator import ComponentGraphGenerator - -# TODO(cookiecutter): Remove this function -def delete_me(*, blow_up: bool = False) -> bool: - """Do stuff for demonstration purposes. - - Args: - blow_up: If True, raise an exception. - - Returns: - True if no exception was raised. - - Raises: - RuntimeError: if blow_up is True. - """ - if blow_up: - raise RuntimeError("This function should be removed!") - return True +__all__ = ["ComponentGraphGenerator"] diff --git a/src/frequenz/gridpool/_graph_generator.py b/src/frequenz/gridpool/_graph_generator.py new file mode 100644 index 0000000..1a83138 --- /dev/null +++ b/src/frequenz/gridpool/_graph_generator.py @@ -0,0 +1,90 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Formula generation from assets API component/connection configurations.""" + +from typing import cast + +from frequenz.client.assets import AssetsApiClient +from frequenz.client.assets.electrical_component import ( + ComponentConnection, + ElectricalComponent, +) +from frequenz.client.base import channel +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.electrical_components import ElectricalComponentId +from frequenz.microgrid_component_graph import ComponentGraph + + +class ComponentGraphGenerator: + """Generates component graphs for microgrids using the Assets API.""" + + def __init__( # pylint: disable=too-many-arguments + self, + server_url: str, + *, + auth_key: str | None = None, + sign_secret: str | None = None, + channel_defaults: channel.ChannelOptions = channel.ChannelOptions(), + connect: bool = True, + ) -> None: + """Initialize this instance. + + Args: + server_url: The location of the microgrid API server in the form of a URL. + The following format is expected: + "grpc://hostname{:`port`}{?ssl=`ssl`}", + where the `port` should be an int between 0 and 65535 (defaulting to + 9090) and `ssl` should be a boolean (defaulting to `true`). + For example: `grpc://localhost:1090?ssl=true`. + auth_key: The authentication key to use for the connection. + sign_secret: The secret to use for signing requests. + channel_defaults: The default options use to create the channel when not + specified in the URL. + connect: Whether to connect to the server as soon as a client instance is + created. If `False`, the client will not connect to the server until + [connect()][frequenz.client.base.client.BaseApiClient.connect] is + called. + """ + self._client: AssetsApiClient = AssetsApiClient( + server_url, + auth_key=auth_key, + sign_secret=sign_secret, + channel_defaults=channel_defaults, + connect=connect, + ) + + async def get_component_graph( + self, microgrid_id: MicrogridId + ) -> ComponentGraph[ + ElectricalComponent, ComponentConnection, ElectricalComponentId + ]: + """Generate a component graph for the given microgrid ID. + + Args: + microgrid_id: The ID of the microgrid to generate the graph for. + + Returns: + The component graph representing the microgrid's electrical + components and their connections. + + Raises: + ValueError: If any component connections could not be loaded. + """ + components = await self._client.list_microgrid_electrical_components( + microgrid_id + ) + connections = ( + await self._client.list_microgrid_electrical_component_connections( + microgrid_id + ) + ) + + if any(c is None for c in connections): + raise ValueError("Failed to load all electrical component connections.") + + graph = ComponentGraph[ + ElectricalComponent, ComponentConnection, ElectricalComponentId + ](components, cast(list[ComponentConnection], connections)) + + return graph diff --git a/tests/test_gridpool.py b/tests/test_gridpool.py index 0eea1e6..550589c 100644 --- a/tests/test_gridpool.py +++ b/tests/test_gridpool.py @@ -2,17 +2,66 @@ # Copyright © 2025 Frequenz Energy-as-a-Service GmbH """Tests for the frequenz.gridpool package.""" -import pytest -from frequenz.gridpool import delete_me +from unittest.mock import AsyncMock, MagicMock, patch +from frequenz.client.assets import AssetsApiClient +from frequenz.client.assets.electrical_component import ( + ComponentConnection, + GridConnectionPoint, + Meter, + SolarInverter, +) +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.electrical_components import ElectricalComponentId -def test_gridpool_succeeds() -> None: # TODO(cookiecutter): Remove - """Test that the delete_me function succeeds.""" - assert delete_me() is True +from frequenz.gridpool._graph_generator import ComponentGraphGenerator -def test_gridpool_fails() -> None: # TODO(cookiecutter): Remove - """Test that the delete_me function fails.""" - with pytest.raises(RuntimeError, match="This function should be removed!"): - delete_me(blow_up=True) +async def test_formula_generation() -> None: + """Test that the frequenz.gridpool package loads correctly.""" + assets_client_mock = MagicMock(spec=AssetsApiClient) + assets_client_mock.list_microgrid_electrical_components = AsyncMock( + return_value=[ + GridConnectionPoint( + id=ElectricalComponentId(1), + microgrid_id=MicrogridId(10), + rated_fuse_current=100, + ), + Meter( + id=ElectricalComponentId(2), + microgrid_id=MicrogridId(10), + ), + Meter( + id=ElectricalComponentId(3), + microgrid_id=MicrogridId(10), + ), + SolarInverter( + id=ElectricalComponentId(4), + microgrid_id=MicrogridId(10), + ), + ] + ) + assets_client_mock.list_microgrid_electrical_component_connections = AsyncMock( + return_value=[ + ComponentConnection( + source=ElectricalComponentId(1), + destination=ElectricalComponentId(2), + ), + ComponentConnection( + source=ElectricalComponentId(1), + destination=ElectricalComponentId(3), + ), + ComponentConnection( + source=ElectricalComponentId(2), + destination=ElectricalComponentId(4), + ), + ] + ) + + g = ComponentGraphGenerator("grpc://never.where:-55", connect=False) + with patch.object(g, "_client", assets_client_mock): + graph = await g.get_component_graph(MicrogridId(10)) + + assert graph.grid_formula() == "COALESCE(#2, #4, 0.0) + #3" + assert graph.pv_formula(None) == "COALESCE(#4, #2, 0.0)"