Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Convert to betterproto #37

Merged
merged 10 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH

[build-system]
requires = [
"setuptools == 68.1.0",
"setuptools_scm[toml] == 7.1.0",
"frequenz-repo-config[lib] == 0.9.1",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is repo-config removed here?

Copy link
Contributor Author

@llucax llucax May 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including repo-config as a build dependency in every project was actually a mistake, it was only needed for API projects and now that this is taken care by setuptools-betterproto (if we do the switch), it will not be used by any project. My idea is to split repo-config even further in the future, it was an experiment and now that we have a much clear picture about what we need from it, it is a good time to split it into smaller projects and use those smaller projects only where necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is unrelated to this PR, though, I just did it now because I realized it was a mistake to include repo-config in the build dependencies right after creating setuptools-betterproto.

]
requires = ["setuptools == 68.1.0", "setuptools_scm[toml] == 7.1.0"]
build-backend = "setuptools.build_meta"

[project]
Expand Down Expand Up @@ -36,11 +32,10 @@ classifiers = [
]
requires-python = ">= 3.11, < 4"
dependencies = [
"frequenz-api-microgrid >= 0.15.3, < 0.16.0",
"betterproto == 2.0.0b6",
"frequenz-channels >= 1.0.0-rc1, < 2.0.0",
"frequenz-client-base[grpclib] >= 0.4.0, < 0.5",
"grpcio >= 1.54.2, < 2",
"protobuf >= 4.21.6, < 6",
"frequenz-microgrid-betterproto >= 0.15.3.1, < 0.16",
"timezonefinder >= 6.2.0, < 7",
"typing-extensions >= 4.5.0, < 5",
]
Expand Down
139 changes: 53 additions & 86 deletions src/frequenz/client/microgrid/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,19 @@

import asyncio
import logging
from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
from typing import Any, TypeVar, cast

import grpc.aio

# pylint: disable=no-name-in-module
from frequenz.api.common.components_pb2 import ComponentCategory as PbComponentCategory
from frequenz.api.common.metrics_pb2 import Bounds as PbBounds
from frequenz.api.microgrid.microgrid_pb2 import ComponentData as PbComponentData
from frequenz.api.microgrid.microgrid_pb2 import ComponentFilter as PbComponentFilter
from frequenz.api.microgrid.microgrid_pb2 import ComponentIdParam as PbComponentIdParam
from frequenz.api.microgrid.microgrid_pb2 import ComponentList as PbComponentList
from frequenz.api.microgrid.microgrid_pb2 import ConnectionFilter as PbConnectionFilter
from frequenz.api.microgrid.microgrid_pb2 import ConnectionList as PbConnectionList
from frequenz.api.microgrid.microgrid_pb2 import (
MicrogridMetadata as PbMicrogridMetadata,
)
from frequenz.api.microgrid.microgrid_pb2 import SetBoundsParam as PbSetBoundsParam
from frequenz.api.microgrid.microgrid_pb2 import (
SetPowerActiveParam as PbSetPowerActiveParam,
)
from frequenz.api.microgrid.microgrid_pb2_grpc import MicrogridStub
from collections.abc import Callable, Iterable
from typing import Any, TypeVar

# pylint: enable=no-name-in-module
import grpclib
import grpclib.client
from betterproto.lib.google import protobuf as pb_google
from frequenz.channels import Receiver
from frequenz.client.base import retry, streaming
from google.protobuf.empty_pb2 import Empty # pylint: disable=no-name-in-module
from google.protobuf.timestamp_pb2 import Timestamp # pylint: disable=no-name-in-module
from frequenz.microgrid.betterproto.frequenz.api import microgrid as pb_microgrid
from frequenz.microgrid.betterproto.frequenz.api.common import (
components as pb_components,
)
from frequenz.microgrid.betterproto.frequenz.api.common import metrics as pb_metrics

from ._component import (
Component,
Expand Down Expand Up @@ -67,7 +52,7 @@ class ApiClient:

def __init__(
self,
grpc_channel: grpc.aio.Channel,
grpc_channel: grpclib.client.Channel,
target: str,
retry_strategy: retry.Strategy | None = None,
) -> None:
Expand All @@ -84,7 +69,7 @@ def __init__(
self.target = target
"""The location (as "host:port") of the microgrid API gRPC server."""

self.api = MicrogridStub(grpc_channel)
self.api = pb_microgrid.MicrogridStub(grpc_channel)
"""The gRPC stub for the microgrid API."""

self._broadcasters: dict[int, streaming.GrpcStreamBroadcaster[Any, Any]] = {}
Expand All @@ -101,22 +86,19 @@ async def components(self) -> Iterable[Component]:
when the api call exceeded the timeout.
"""
try:
# grpc.aio is missing types and mypy thinks this is not awaitable,
# but it is
component_list = await cast(
Awaitable[PbComponentList],
self.api.ListComponents(
PbComponentFilter(),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
),
component_list = await self.api.list_components(
pb_microgrid.ComponentFilter(),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
)

except grpc.aio.AioRpcError as err:
except grpclib.GRPCError as err:
raise ClientError(
f"Failed to list components. Microgrid API: {self.target}. Err: {err.details()}"
f"Failed to list components. Microgrid API: {self.target}. Err: {err}"
) from err

components_only = filter(
lambda c: c.category is not PbComponentCategory.COMPONENT_CATEGORY_SENSOR,
lambda c: c.category
is not pb_components.ComponentCategory.COMPONENT_CATEGORY_SENSOR,
component_list.components,
)
result: Iterable[Component] = map(
Expand All @@ -140,16 +122,13 @@ async def metadata(self) -> Metadata:
Returns:
the microgrid metadata.
"""
microgrid_metadata: PbMicrogridMetadata | None = None
microgrid_metadata: pb_microgrid.MicrogridMetadata | None = None
try:
microgrid_metadata = await cast(
Awaitable[PbMicrogridMetadata],
self.api.GetMicrogridMetadata(
Empty(),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
),
microgrid_metadata = await self.api.get_microgrid_metadata(
pb_google.Empty(),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
)
except grpc.aio.AioRpcError:
except grpclib.GRPCError:
_logger.exception("The microgrid metadata is not available.")

if not microgrid_metadata:
Expand Down Expand Up @@ -184,23 +163,18 @@ async def connections(
ClientError: If the connection to the Microgrid API cannot be established or
when the api call exceeded the timeout.
"""
connection_filter = PbConnectionFilter(starts=starts, ends=ends)
connection_filter = pb_microgrid.ConnectionFilter(starts=starts, ends=ends)
try:
valid_components, all_connections = await asyncio.gather(
self.components(),
# grpc.aio is missing types and mypy thinks this is not
# awaitable, but it is
cast(
Awaitable[PbConnectionList],
self.api.ListConnections(
connection_filter,
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
),
self.api.list_connections(
connection_filter,
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
),
)
except grpc.aio.AioRpcError as err:
except grpclib.GRPCError as err:
raise ClientError(
f"Failed to list connections. Microgrid API: {self.target}. Err: {err.details()}"
f"Failed to list connections. Microgrid API: {self.target}. Err: {err}"
) from err
# Filter out the components filtered in `components` method.
# id=0 is an exception indicating grid component.
Expand All @@ -223,7 +197,7 @@ async def _new_component_data_receiver(
*,
component_id: int,
expected_category: ComponentCategory,
transform: Callable[[PbComponentData], _ComponentDataT],
transform: Callable[[pb_microgrid.ComponentData], _ComponentDataT],
maxsize: int,
) -> Receiver[_ComponentDataT]:
"""Return a new broadcaster receiver for a given `component_id`.
Expand All @@ -250,13 +224,8 @@ async def _new_component_data_receiver(
if broadcaster is None:
broadcaster = streaming.GrpcStreamBroadcaster(
f"raw-component-data-{component_id}",
# We need to cast here because grpc says StreamComponentData is
# a grpc.CallIterator[PbComponentData] which is not an AsyncIterator,
# but it is a grpc.aio.UnaryStreamCall[..., PbComponentData], which it
# is.
lambda: cast(
AsyncIterator[PbComponentData],
self.api.StreamComponentData(PbComponentIdParam(id=component_id)),
lambda: self.api.stream_component_data(
pb_microgrid.ComponentIdParam(id=component_id)
),
transform,
retry_strategy=self._retry_strategy,
Expand Down Expand Up @@ -409,16 +378,15 @@ async def set_power(self, component_id: int, power_w: float) -> None:
when the api call exceeded the timeout.
"""
try:
await cast(
Awaitable[Empty],
self.api.SetPowerActive(
PbSetPowerActiveParam(component_id=component_id, power=power_w),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
await self.api.set_power_active(
pb_microgrid.SetPowerActiveParam(
component_id=component_id, power=power_w
),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
)
except grpc.aio.AioRpcError as err:
except grpclib.GRPCError as err:
raise ClientError(
f"Failed to set power. Microgrid API: {self.target}. Err: {err.details()}"
f"Failed to set power. Microgrid API: {self.target}. Err: {err}"
) from err

async def set_bounds(
Expand All @@ -427,7 +395,7 @@ async def set_bounds(
lower: float,
upper: float,
) -> None:
"""Send `PbSetBoundsParam`s received from a channel to the Microgrid service.
"""Send `SetBoundsParam`s received from a channel to the Microgrid service.

Args:
component_id: ID of the component to set bounds for.
Expand All @@ -446,28 +414,27 @@ async def set_bounds(
if lower > 0:
raise ValueError(f"Lower bound {lower} must be less than or equal to 0.")

target_metric = PbSetBoundsParam.TargetMetric.TARGET_METRIC_POWER_ACTIVE
target_metric = (
pb_microgrid.SetBoundsParamTargetMetric.TARGET_METRIC_POWER_ACTIVE
)
try:
await cast(
Awaitable[Timestamp],
self.api.AddInclusionBounds(
PbSetBoundsParam(
component_id=component_id,
target_metric=target_metric,
bounds=PbBounds(lower=lower, upper=upper),
),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
await self.api.add_inclusion_bounds(
pb_microgrid.SetBoundsParam(
component_id=component_id,
target_metric=target_metric,
bounds=pb_metrics.Bounds(lower=lower, upper=upper),
),
timeout=int(DEFAULT_GRPC_CALL_TIMEOUT),
)
except grpc.aio.AioRpcError as err:
except grpclib.GRPCError as err:
_logger.error(
"set_bounds write failed: %s, for message: %s, api: %s. Err: %s",
err,
next,
api_details,
err.details(),
err,
)
raise ClientError(
f"Failed to set inclusion bounds. Microgrid API: {self.target}. "
f"Err: {err.details()}"
f"Err: {err}"
) from err
47 changes: 21 additions & 26 deletions src/frequenz/client/microgrid/_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,8 @@
from dataclasses import dataclass
from enum import Enum

# pylint: disable=no-name-in-module
from frequenz.api.common.components_pb2 import ComponentCategory as PbComponentCategory
from frequenz.api.microgrid.grid_pb2 import Metadata as PbGridMetadata
from frequenz.api.microgrid.inverter_pb2 import Metadata as PbInverterMetadata
from frequenz.api.microgrid.inverter_pb2 import Type as PbInverterType

# pylint: enable=no-name-in-module
from frequenz.microgrid.betterproto.frequenz.api.common import components
from frequenz.microgrid.betterproto.frequenz.api.microgrid import grid, inverter


class ComponentType(Enum):
Expand All @@ -22,22 +17,22 @@ class ComponentType(Enum):
class InverterType(ComponentType):
"""Enum representing inverter types."""

NONE = PbInverterType.TYPE_UNSPECIFIED
NONE = inverter.Type.TYPE_UNSPECIFIED
"""Unspecified inverter type."""

BATTERY = PbInverterType.TYPE_BATTERY
BATTERY = inverter.Type.TYPE_BATTERY
"""Battery inverter."""

SOLAR = PbInverterType.TYPE_SOLAR
SOLAR = inverter.Type.TYPE_SOLAR
"""Solar inverter."""

HYBRID = PbInverterType.TYPE_HYBRID
HYBRID = inverter.Type.TYPE_HYBRID
"""Hybrid inverter."""


def component_type_from_protobuf(
component_category: PbComponentCategory.ValueType,
component_metadata: PbInverterMetadata,
component_category: components.ComponentCategory,
component_metadata: inverter.Metadata,
) -> ComponentType | None:
"""Convert a protobuf InverterType message to Component enum.

Expand All @@ -53,7 +48,7 @@ def component_type_from_protobuf(
# ComponentType values in the protobuf definition are not unique across categories
# as of v0.11.0, so we need to check the component category first, before doing any
# component type checks.
if component_category == PbComponentCategory.COMPONENT_CATEGORY_INVERTER:
if component_category == components.ComponentCategory.COMPONENT_CATEGORY_INVERTER:
if not any(int(t.value) == int(component_metadata.type) for t in InverterType):
return None

Expand All @@ -65,30 +60,30 @@ def component_type_from_protobuf(
class ComponentCategory(Enum):
"""Possible types of microgrid component."""

NONE = PbComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED
NONE = components.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED
"""Unspecified component category."""

GRID = PbComponentCategory.COMPONENT_CATEGORY_GRID
GRID = components.ComponentCategory.COMPONENT_CATEGORY_GRID
"""Grid component."""

METER = PbComponentCategory.COMPONENT_CATEGORY_METER
METER = components.ComponentCategory.COMPONENT_CATEGORY_METER
"""Meter component."""

INVERTER = PbComponentCategory.COMPONENT_CATEGORY_INVERTER
INVERTER = components.ComponentCategory.COMPONENT_CATEGORY_INVERTER
"""Inverter component."""

BATTERY = PbComponentCategory.COMPONENT_CATEGORY_BATTERY
BATTERY = components.ComponentCategory.COMPONENT_CATEGORY_BATTERY
"""Battery component."""

EV_CHARGER = PbComponentCategory.COMPONENT_CATEGORY_EV_CHARGER
EV_CHARGER = components.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER
"""EV charger component."""

CHP = PbComponentCategory.COMPONENT_CATEGORY_CHP
CHP = components.ComponentCategory.COMPONENT_CATEGORY_CHP
"""CHP component."""


def component_category_from_protobuf(
component_category: PbComponentCategory.ValueType,
component_category: components.ComponentCategory,
) -> ComponentCategory:
"""Convert a protobuf ComponentCategory message to ComponentCategory enum.

Expand All @@ -105,7 +100,7 @@ def component_category_from_protobuf(
a valid component category as it does not form part of the
microgrid itself)
"""
if component_category == PbComponentCategory.COMPONENT_CATEGORY_SENSOR:
if component_category == components.ComponentCategory.COMPONENT_CATEGORY_SENSOR:
raise ValueError("Cannot create a component from a sensor!")

if not any(t.value == component_category for t in ComponentCategory):
Expand Down Expand Up @@ -136,8 +131,8 @@ class GridMetadata(ComponentMetadata):


def component_metadata_from_protobuf(
component_category: PbComponentCategory.ValueType,
component_metadata: PbGridMetadata,
component_category: components.ComponentCategory,
component_metadata: grid.Metadata,
) -> GridMetadata | None:
"""Convert a protobuf GridMetadata message to GridMetadata class.

Expand All @@ -150,7 +145,7 @@ def component_metadata_from_protobuf(
Returns:
GridMetadata instance corresponding to the protobuf message.
"""
if component_category == PbComponentCategory.COMPONENT_CATEGORY_GRID:
if component_category == components.ComponentCategory.COMPONENT_CATEGORY_GRID:
max_current = component_metadata.rated_fuse_current
fuse = Fuse(max_current)
return GridMetadata(fuse)
Expand Down
Loading