diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ce384fc..2f4ef16 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,7 +2,7 @@ ## Summary - +This release introduces the initial version of the Assets API client, enabling retrieval of asset-related data. ## Upgrading @@ -10,7 +10,12 @@ ## New Features - +Introducing the initial version of the Assets API client, designed to streamline access to asset-related data. + +* Supports querying asset components and retrieving their metrics efficiently. +* Provides a structured data representation while retaining raw protobuf responses. +* Currently focused on retrieving asset data for individual components. +* Examples are provided to guide users through the basic usage of the client. ## Bug Fixes diff --git a/examples/client.py b/examples/client.py new file mode 100644 index 0000000..cd2d09b --- /dev/null +++ b/examples/client.py @@ -0,0 +1,86 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Examples usage of PlatformAssets API.""" + +import argparse +import asyncio +from pprint import pprint + +from frequenz.client.assets._client import AssetsApiClient + + +async def main( + microgrid_id: int, + component_ids: list[int], + categories: list[int] | None, + source_component_ids: list[int] | None, + destination_component_ids: list[int] | None, +) -> None: + """Test the AssetsApiClient. + + Args: + microgrid_id: The ID of the microgrid to query. + component_ids: List of component IDs to filter. + categories: List of component categories to filter. + source_component_ids: List of source component IDs to filter. + destination_component_ids: List of destination component IDs to filter. + """ + server_url = "localhost:50052" + client = AssetsApiClient(server_url) + + print("########################################################") + print("Fetching microgrid details") + + microgrid_details = await client.get_microgrid_details(microgrid_id) + pprint(microgrid_details) + + print("########################################################") + print("Listing microgrid components") + + components = await client.list_microgrid_component_connections( + microgrid_id, component_ids, categories + ) + pprint(components) + + print("########################################################") + print("Listing microgrid component connections") + + connections = await client.list_microgrid_component_connections( + microgrid_id, source_component_ids, destination_component_ids + ) + pprint(connections) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("microgrid_id", type=int, help="Microgrid ID") + parser.add_argument( + "component_ids", nargs="*", type=int, help="List of component IDs to filter" + ) + parser.add_argument( + "categories", nargs="*", type=str, help="List of component categories to filter" + ) + parser.add_argument( + "source_component_ids", + nargs="*", + type=int, + help="List of source component IDs to filter", + ) + parser.add_argument( + "destination_component_ids", + nargs="*", + type=int, + help="List of destination component IDs to filter", + ) + + args = parser.parse_args() + asyncio.run( + main( + args.microgrid_id, + args.component_ids, + args.categories, + args.source_component_ids, + args.destination_component_ids, + ) + ) diff --git a/pyproject.toml b/pyproject.toml index 090a280..b7878fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ classifiers = [ requires-python = ">= 3.11, < 4" dependencies = [ "typing-extensions >= 4.12.2, < 5", + "frequenz-api-assets @ git+https://github.com/frequenz-floss/frequenz-api-assets.git@v0.x.x", + "frequenz-client-common >= 0.3.0, < 1", + "frequenz-client-base >= 0.11.0, < 1", ] dynamic = ["version"] @@ -82,6 +85,10 @@ dev = [ "frequenz-client-assets[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]", ] +examples = [ + "grpcio >= 1.51.1, < 2", +] + [project.urls] Documentation = "https://frequenz-floss.github.io/frequenz-client-assets-python/" Changelog = "https://github.com/frequenz-floss/frequenz-client-assets-python/releases" @@ -146,7 +153,15 @@ disable = [ ] [tool.pytest.ini_options] -addopts = "-W=all -Werror -Wdefault::DeprecationWarning -Wdefault::PendingDeprecationWarning -vv" +addopts = "-vv" +filterwarnings = [ + "error", + "once::DeprecationWarning", + "once::PendingDeprecationWarning", + # We use a raw string (single quote) to avoid the need to escape special + # chars as this is a regex + 'ignore:Protobuf gencode version .*exactly one major version older.*:UserWarning', +] testpaths = ["tests", "src"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" diff --git a/src/frequenz/client/assets/__init__.py b/src/frequenz/client/assets/__init__.py index fafdb9d..2c933c2 100644 --- a/src/frequenz/client/assets/__init__.py +++ b/src/frequenz/client/assets/__init__.py @@ -13,7 +13,6 @@ def delete_me(*, blow_up: bool = False) -> bool: Returns: True if no exception was raised. - Raises: RuntimeError: if blow_up is True. """ diff --git a/src/frequenz/client/assets/_client.py b/src/frequenz/client/assets/_client.py new file mode 100644 index 0000000..9d61c8a --- /dev/null +++ b/src/frequenz/client/assets/_client.py @@ -0,0 +1,343 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Client for requests to the PlatformAssets API.""" + +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Iterable, Optional + +# pylint: disable=no-name-in-module +from frequenz.api.assets.v1.assets_pb2 import ( + GetMicrogridRequest as PBGetMicrogridRequest, +) +from frequenz.api.assets.v1.assets_pb2 import ( + GetMicrogridResponse as PBGetMicrogridResponse, +) +from frequenz.api.assets.v1.assets_pb2 import ( + ListMicrogridElectricalComponentConnectionsRequest as PBLMECCRequest, +) +from frequenz.api.assets.v1.assets_pb2 import ( + ListMicrogridElectricalComponentConnectionsResponse as PBLMECCResponse, +) +from frequenz.api.assets.v1.assets_pb2 import ( + ListMicrogridElectricalComponentsRequest as PBListMicrogridElectricalComponentsRequest, +) +from frequenz.api.assets.v1.assets_pb2 import ( + ListMicrogridElectricalComponentsResponse as PBListMicrogridElectricalComponentsResponse, +) +from frequenz.api.assets.v1.assets_pb2_grpc import PlatformAssetsStub +from frequenz.api.common.v1.grid.delivery_area_pb2 import DeliveryArea as PBDeliveryArea +from frequenz.api.common.v1.grid.delivery_area_pb2 import ( + EnergyMarketCodeType as PBEnergyMarketCodeType, +) +from frequenz.api.common.v1.location_pb2 import Location as PBLocation +from frequenz.api.common.v1.microgrid.electrical_components.electrical_components_pb2 import ( + ElectricalComponent as PBElectricalComponent, +) +from frequenz.api.common.v1.microgrid.electrical_components.electrical_components_pb2 import ( + ElectricalComponentCategory as PBElectricalComponentCategory, +) +from frequenz.api.common.v1.microgrid.electrical_components.electrical_components_pb2 import ( + ElectricalComponentCategorySpecificInfo as PBElectricalComponentCategorySpecificInfo, +) +from frequenz.api.common.v1.microgrid.electrical_components.electrical_components_pb2 import ( + ElectricalComponentConnection as PBElectricalComponentConnection, +) +from frequenz.api.common.v1.microgrid.microgrid_pb2 import Microgrid as PBMicrogrid +from frequenz.api.common.v1.microgrid.microgrid_pb2 import ( + MicrogridStatus as PBMicrogridStatus, +) +from frequenz.client.base.client import BaseApiClient, call_stub_method +from frequenz.client.base.exception import ClientNotConnected + +# pylint: enable=no-name-in-module + + +# pylint: disable=too-many-instance-attributes +@dataclass(frozen=True) +class Microgrid: + """Structured wrapper for a microgrid object. + + Attributes: + id: The ID of the microgrid. + enterprise_id: The ID of the enterprise that owns the microgrid. + name: The name of the microgrid. + delivery_area_code: The code of the delivery area. + delivery_area_code_type: The type of the delivery area code. + latitude: The latitude of the microgrid. + longitude: The longitude of the microgrid. + country_code: The country code of the microgrid. + status: The status of the microgrid. + create_time: The timestamp when the microgrid was created. + """ + + id: int + enterprise_id: int + name: str + delivery_area_code: str | None + delivery_area_code_type: str | None + latitude: float | None + longitude: float | None + country_code: str | None + status: str + create_time: datetime + + @staticmethod + def from_proto(pb: PBMicrogrid) -> "Microgrid": + """Convert a protobuf Microgrid object to a Microgrid dataclass.""" + delivery_area: Optional[PBDeliveryArea] = ( + pb.delivery_area if pb.HasField("delivery_area") else None + ) + location: Optional[PBLocation] = ( + pb.location if pb.HasField("location") else None + ) + return Microgrid( + id=pb.id, + enterprise_id=pb.enterprise_id, + name=pb.name, + delivery_area_code=delivery_area.code if delivery_area else None, + delivery_area_code_type=( + PBEnergyMarketCodeType.Name(delivery_area.code_type) + if delivery_area + else None + ), + latitude=location.latitude if location else None, + longitude=location.longitude if location else None, + country_code=location.country_code if location else None, + status=PBMicrogridStatus.Name(pb.status), + create_time=pb.create_timestamp.ToDatetime().replace(tzinfo=timezone.utc), + ) + + +# pylint: disable=too-many-instance-attributes +@dataclass +class ElectricalComponent: + """Structured wrapper for an electrical component object. + + Attributes: + id: The ID of the electrical component. + microgrid_id: The ID of the microgrid this component belongs to. + name: The name of the electrical component. + category: The category of the electrical component. + category_specific_info: Specific information about the component's category. + manufacturer: The manufacturer of the electrical component. + model_name: The model name of the electrical component. + control_mode: The control mode of the electrical component. + start_time: The start time of the component's operational lifetime. + end_time: The end time of the component's operational lifetime. + metric_config_bounds: Configuration bounds for metrics associated + with the component. + """ + + id: int + microgrid_id: int + name: str + category: str + category_specific_info: dict[str, Any] + manufacturer: str + model_name: str + control_mode: str + start_time: Optional[datetime] + end_time: Optional[datetime] + metric_config_bounds: list[dict[str, Any]] + + @classmethod + def from_proto(cls, pb: PBElectricalComponent) -> "ElectricalComponent": + """Convert a protobuf object to an ElectricalComponent dataclass.""" + category_specific_info = cls._parse_category_specific_info( + pb.category_specific_info + ) + lifetime = pb.operational_lifetime + metric_bounds = [ + { + "metric": b.metric.name, + "lower_bound": ( + b.config_bounds.lower.value + if b.config_bounds.HasField("lower") + else None + ), + "upper_bound": ( + b.config_bounds.upper.value + if b.config_bounds.HasField("upper") + else None + ), + } + for b in pb.metric_config_bounds + ] + + return cls( + id=pb.id, + microgrid_id=pb.microgrid_id, + name=pb.name, + category=PBElectricalComponentCategory.Name(pb.category), + category_specific_info=category_specific_info, + manufacturer=pb.manufacturer, + model_name=pb.model_name, + control_mode=( + pb.control_mode.name + if hasattr(pb, "control_mode") and pb.control_mode is not None + else "ELECTRICAL_COMPONENT_CONTROL_MODE_UNSPECIFIED" + ), + start_time=( + lifetime.start_timestamp.ToDatetime() + if lifetime.HasField("start_timestamp") + else None + ), + end_time=( + lifetime.end_timestamp.ToDatetime() + if lifetime.HasField("end_timestamp") + else None + ), + metric_config_bounds=metric_bounds, + ) + + @staticmethod + def _parse_category_specific_info( + variant: PBElectricalComponentCategorySpecificInfo, + ) -> dict[str, Any]: + field = variant.WhichOneof("kind") + if field is None: + return {} + return {"kind": field} + + +@dataclass +class ElectricalComponentConnection: + """Structured wrapper for an electrical component connection object.""" + + source_component_id: int + destination_component_id: int + start_time: Optional[datetime] + end_time: Optional[datetime] + + @classmethod + def from_proto( + cls, pb: PBElectricalComponentConnection + ) -> "ElectricalComponentConnection": + """Convert a protobuf object to an ElectricalComponentConnection dataclass.""" + lifetime = pb.operational_lifetime + return cls( + source_component_id=pb.source_component_id, + destination_component_id=pb.destination_component_id, + start_time=( + lifetime.start_timestamp.ToDatetime() + if lifetime.HasField("start_timestamp") + else None + ), + end_time=( + lifetime.end_timestamp.ToDatetime() + if lifetime.HasField("end_timestamp") + else None + ), + ) + + +class AssetsApiClient(BaseApiClient[PlatformAssetsStub]): + """A client for the Frequenz PlatformAssets service.""" + + def __init__( + self, server_url: str, key: str | None = None, connect: bool = True + ) -> None: + """Initialize the client. + + Args: + server_url: The URL of the PlatformAssets service. + key: The API key for authentication (optional). + connect: Whether to establish a connection immediately. + """ + super().__init__(server_url, PlatformAssetsStub, connect=connect) + + self._metadata = [("key", key)] if key else [] + + @property + def stub(self) -> PlatformAssetsStub: + """Return the gRPC stub for the PlatformAssets service.""" + if self.channel is None or self._stub is None: + raise ClientNotConnected(server_url=self.server_url, operation="stub") + return self._stub + + async def get_microgrid_details(self, microgrid_id: int) -> Microgrid: + """Fetch and parse a microgrid as a Microgrid dataclass.""" + pb_microgrid = await self._get_microgrid(microgrid_id) + return Microgrid.from_proto(pb_microgrid) + + async def _get_microgrid(self, microgrid_id: int) -> PBMicrogrid: + """Fetch details of a specific microgrid.""" + request = PBGetMicrogridRequest(microgrid_id=microgrid_id) + + response: PBGetMicrogridResponse = await call_stub_method( + self, lambda: self.stub.GetMicrogrid(request, metadata=self._metadata) + ) + return response.microgrid + + async def list_electrical_components( + self, + microgrid_id: int, + component_ids: Optional[list[int]] = None, + categories: Optional[Iterable[PBElectricalComponentCategory.ValueType]] = None, + ) -> list[ElectricalComponent]: + """List electrical components of a microgrid by iterating over component IDs. + + Args: + microgrid_id: The ID of the microgrid to fetch components for. + component_ids: The individual component IDs to fetch. + categories: Optional categories to filter by. + + Returns: + A list of ElectricalComponent dataclass instances. + """ + request = PBListMicrogridElectricalComponentsRequest( + microgrid_id=microgrid_id, + component_ids=component_ids, + categories=categories, + ) + + pb_response: PBListMicrogridElectricalComponentsResponse = ( + await call_stub_method( + self, + lambda: self.stub.ListMicrogridElectricalComponents( + request, metadata=self._metadata + ), + ) + ) + + return [ + ElectricalComponent.from_proto(pb_component) + for pb_component in pb_response.components + ] + + async def list_microgrid_component_connections( + self, + microgrid_id: int, + source_component_ids: Optional[list[int]] = None, + destination_component_ids: Optional[list[int]] = None, + ) -> list[ElectricalComponentConnection]: + """List electrical connections between components in a microgrid. + + Args: + microgrid_id: The ID of the microgrid to fetch component connections for. + source_component_ids: The IDs of the source components to fetch connections for. + destination_component_ids: The IDs of the destination components to fetch + connections for. + + Returns: + A list of ElectricalComponentConnection dataclass instances. + """ + request = PBLMECCRequest( + microgrid_id=microgrid_id, + source_component_ids=source_component_ids, + destination_component_ids=destination_component_ids, + ) + + pb_response: PBLMECCResponse = await call_stub_method( + self, + lambda: self.stub.ListMicrogridElectricalComponentConnections( + request, metadata=self._metadata + ), + ) + + return [ + ElectricalComponentConnection.from_proto(pb_conn) + for pb_conn in pb_response.connections + ]