diff --git a/pyproject.toml b/pyproject.toml index d3015749..e483582a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,12 +36,12 @@ classifiers = [ ] requires-python = ">= 3.11, < 4" dependencies = [ - "frequenz-api-microgrid >= 0.15.5, < 0.16.0", - "frequenz-channels >= 1.0.0-rc1, < 2.0.0", - "frequenz-client-base >= 0.8.0, < 0.12.0", + "frequenz-api-microgrid >= 0.17.2, < 0.18.0", + "frequenz-channels >= 1.6.1, < 2.0.0", + "frequenz-client-base >= 0.10.0, < 0.12.0", "frequenz-client-common >= 0.3.2, < 0.4.0", - "grpcio >= 1.63.0, < 2", - "protobuf >= 5.26.1, < 7", + "grpcio >= 1.72.1, < 2", + "protobuf >= 6.31.1, < 7", "timezonefinder >= 6.2.0, < 7", "typing-extensions >= 4.13.0, < 5", ] diff --git a/src/frequenz/client/microgrid/__init__.py b/src/frequenz/client/microgrid/__init__.py index 5b4c53de..8b1a3e26 100644 --- a/src/frequenz/client/microgrid/__init__.py +++ b/src/frequenz/client/microgrid/__init__.py @@ -7,39 +7,12 @@ """ -from ._client import MicrogridApiClient -from ._component import ( - Component, - ComponentCategory, - ComponentMetadata, - ComponentMetricId, - ComponentType, - Fuse, - GridMetadata, - InverterType, +from ._client import ( + DEFAULT_CHANNEL_OPTIONS, + DEFAULT_GRPC_CALL_TIMEOUT, + MicrogridApiClient, ) -from ._component_data import ( - BatteryData, - ComponentData, - EVChargerData, - InverterData, - MeterData, -) -from ._component_error import ( - BatteryError, - BatteryErrorCode, - ErrorLevel, - InverterError, - InverterErrorCode, -) -from ._component_states import ( - BatteryComponentState, - BatteryRelayState, - EVChargerCableState, - EVChargerComponentState, - InverterComponentState, -) -from ._connection import Connection +from ._delivery_area import DeliveryArea, EnergyMarketCodeType from ._exception import ( ApiClientError, ClientNotConnected, @@ -63,45 +36,27 @@ UnrecognizedGrpcStatus, ) from ._lifetime import Lifetime -from ._metadata import Location, Metadata +from ._location import Location +from ._microgrid_info import MicrogridInfo, MicrogridStatus __all__ = [ "ApiClientError", - "BatteryComponentState", - "BatteryData", - "BatteryError", - "BatteryErrorCode", - "BatteryRelayState", "ClientNotConnected", - "Component", - "ComponentCategory", - "ComponentData", - "ComponentMetadata", - "ComponentMetricId", - "ComponentType", - "Connection", + "DEFAULT_CHANNEL_OPTIONS", + "DEFAULT_GRPC_CALL_TIMEOUT", "DataLoss", - "EVChargerCableState", - "EVChargerComponentState", - "EVChargerData", + "DeliveryArea", + "EnergyMarketCodeType", "EntityAlreadyExists", "EntityNotFound", - "ErrorLevel", - "Fuse", - "GridMetadata", "GrpcError", "InternalError", "InvalidArgument", - "InverterComponentState", - "InverterData", - "InverterError", - "InverterErrorCode", - "InverterType", "Lifetime", "Location", - "Metadata", - "MeterData", "MicrogridApiClient", + "MicrogridInfo", + "MicrogridStatus", "OperationAborted", "OperationCancelled", "OperationNotImplemented", diff --git a/src/frequenz/client/microgrid/_client.py b/src/frequenz/client/microgrid/_client.py index 4f8cdcb7..7ba65b6a 100644 --- a/src/frequenz/client/microgrid/_client.py +++ b/src/frequenz/client/microgrid/_client.py @@ -7,51 +7,22 @@ import asyncio import itertools -import logging -from collections.abc import Callable, Iterable, Set from dataclasses import replace -from functools import partial -from typing import Any, NotRequired, TypedDict, TypeVar, assert_never +from typing import Any -from frequenz.api.common import components_pb2, metrics_pb2 -from frequenz.api.microgrid import microgrid_pb2, microgrid_pb2_grpc, sensor_pb2 -from frequenz.channels import Receiver +from frequenz.api.microgrid.v1 import microgrid_pb2_grpc from frequenz.client.base import channel, client, retry, streaming -from frequenz.client.common.microgrid import MicrogridId from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.common.microgrid.sensors import SensorId from google.protobuf.empty_pb2 import Empty from typing_extensions import override -from ._component import ( - Component, - ComponentCategory, - component_category_from_protobuf, - component_metadata_from_protobuf, - component_type_from_protobuf, -) -from ._component_data import ( - BatteryData, - ComponentData, - EVChargerData, - InverterData, - MeterData, -) -from ._connection import Connection -from ._constants import RECEIVER_MAX_SIZE -from ._exception import ApiClientError, ClientNotConnected -from ._metadata import Location, Metadata -from ._sensor_proto import sensor_data_samples_from_proto, sensor_from_proto -from .sensor import Sensor, SensorDataSamples, SensorMetric +from ._exception import ClientNotConnected +from ._microgrid_info import MicrogridInfo +from ._microgrid_info_proto import microgrid_info_from_proto DEFAULT_GRPC_CALL_TIMEOUT = 60.0 """The default timeout for gRPC calls made by this client (in seconds).""" -_ComponentDataT = TypeVar("_ComponentDataT", bound=ComponentData) -"""Type variable resolving to any ComponentData sub-class.""" - -_logger = logging.getLogger(__name__) - DEFAULT_CHANNEL_OPTIONS = replace( channel.ChannelOptions(), ssl=channel.SslOptions(enabled=False) @@ -104,9 +75,7 @@ def __init__( ] = {} self._sensor_data_broadcasters: dict[ str, - streaming.GrpcStreamBroadcaster[ - microgrid_pb2.ComponentData, SensorDataSamples - ], + streaming.GrpcStreamBroadcaster[Any, Any], ] = {} self._retry_strategy = retry_strategy @@ -157,519 +126,30 @@ async def __aexit__( ) return result - async def components( # noqa: DOC502 (raises ApiClientError indirectly) + async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly) self, - ) -> Iterable[Component]: - """Fetch all the components present in the microgrid. + ) -> MicrogridInfo: + """Retrieve information about the local microgrid. - Returns: - Iterator whose elements are all the components in the microgrid. - - Raises: - ApiClientError: If the are any errors communicating with the Microgrid API, - most likely a subclass of - [GrpcError][frequenz.client.microgrid.GrpcError]. - """ - component_list = await client.call_stub_method( - self, - lambda: self.stub.ListComponents( - microgrid_pb2.ComponentFilter(), - timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), - ), - method_name="ListComponents", - ) - - components_only = filter( - lambda c: c.category - is not components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - component_list.components, - ) - result: Iterable[Component] = map( - lambda c: Component( - ComponentId(c.id), - component_category_from_protobuf(c.category), - component_type_from_protobuf(c.category, c.inverter), - component_metadata_from_protobuf(c.category, c.grid), - ), - components_only, - ) - - return result - - async def list_sensors( # noqa: DOC502 (raises ApiClientError indirectly) - self, - ) -> Iterable[Sensor]: - """Fetch all the sensors present in the microgrid. + This consists of information about the overall microgrid, for example, the + microgrid ID and its location. It does not include information about the + electrical components or sensors in the microgrid. Returns: - Iterator whose elements are all the sensors in the microgrid. + The information about the local microgrid. Raises: ApiClientError: If the are any errors communicating with the Microgrid API, most likely a subclass of [GrpcError][frequenz.client.microgrid.GrpcError]. """ - component_list = await client.call_stub_method( + microgrid = await client.call_stub_method( self, - lambda: self.stub.ListComponents( - microgrid_pb2.ComponentFilter( - categories=[ - components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR - ] - ), - timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), - ), - method_name="ListComponents", - ) - return map(sensor_from_proto, component_list.components) - - 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_pb2.MicrogridMetadata | None = None - try: - microgrid_metadata = await client.call_stub_method( - self, - lambda: self.stub.GetMicrogridMetadata( - Empty(), - timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), - ), - method_name="GetMicrogridMetadata", - ) - except ApiClientError: - _logger.exception("The microgrid metadata is not available.") - - if not microgrid_metadata: - return Metadata() - - location: Location | None = None - if microgrid_metadata.HasField("location"): - location = Location( - latitude=microgrid_metadata.location.latitude, - longitude=microgrid_metadata.location.longitude, - ) - - return Metadata( - microgrid_id=MicrogridId(microgrid_metadata.microgrid_id), location=location - ) - - async def connections( # noqa: DOC502 (raises ApiClientError indirectly) - self, - starts: Set[ComponentId] = frozenset(), - ends: Set[ComponentId] = frozenset(), - ) -> Iterable[Connection]: - """Fetch the connections between components in the microgrid. - - Args: - starts: if set and non-empty, only include connections whose start - value matches one of the provided component IDs - ends: if set and non-empty, only include connections whose end value - matches one of the provided component IDs - - Returns: - Microgrid connections matching the provided start and end filters. - - Raises: - ApiClientError: If the are any errors communicating with the Microgrid API, - most likely a subclass of - [GrpcError][frequenz.client.microgrid.GrpcError]. - """ - # Convert ComponentId to raw int for the API call - start_ids = {int(start) for start in starts} - end_ids = {int(end) for end in ends} - - connection_filter = microgrid_pb2.ConnectionFilter( - starts=start_ids, ends=end_ids - ) - valid_components, all_connections = await asyncio.gather( - self.components(), - client.call_stub_method( - self, - lambda: self.stub.ListConnections( - connection_filter, - timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), - ), - method_name="ListConnections", + lambda: self.stub.GetMicrogridMetadata( + Empty(), + timeout=DEFAULT_GRPC_CALL_TIMEOUT, ), + method_name="GetMicrogridMetadata", ) - # Filter out the components filtered in `components` method. - # id=0 is an exception indicating grid component. - valid_ids = {int(c.component_id) for c in valid_components} - valid_ids.add(0) - - connections = filter( - lambda c: (c.start in valid_ids and c.end in valid_ids), - all_connections.connections, - ) - - result: Iterable[Connection] = map( - lambda c: Connection(ComponentId(c.start), ComponentId(c.end)), connections - ) - - return result - - async def _new_component_data_receiver( - self, - *, - component_id: ComponentId, - expected_category: ComponentCategory, - transform: Callable[[microgrid_pb2.ComponentData], _ComponentDataT], - maxsize: int, - ) -> Receiver[_ComponentDataT]: - """Return a new broadcaster receiver for a given `component_id`. - - If a broadcaster for the given `component_id` doesn't exist, it creates a new - one. - - Args: - component_id: id of the component to get data for. - expected_category: Category of the component to get data for. - transform: A method for transforming raw component data into the - desired output type. - maxsize: Size of the receiver's buffer. - - Returns: - The new receiver for the given `component_id`. - """ - await self._expect_category( - component_id, - expected_category, - ) - - broadcaster = self._broadcasters.get(component_id) - if broadcaster is None: - broadcaster = streaming.GrpcStreamBroadcaster( - f"raw-component-data-{component_id}", - lambda: aiter( - self.stub.StreamComponentData( - microgrid_pb2.ComponentIdParam(id=int(component_id)) - ) - ), - transform, - retry_strategy=self._retry_strategy, - # We don't expect any data stream to end, so if it is exhausted for any - # reason we want to keep retrying - retry_on_exhausted_stream=True, - ) - self._broadcasters[component_id] = broadcaster - return broadcaster.new_receiver(maxsize=maxsize) - - async def _expect_category( - self, - component_id: ComponentId, - expected_category: ComponentCategory, - ) -> None: - """Check if the given component_id is of the expected type. - - Raises: - ValueError: if the given id is unknown or has a different type. - - Args: - component_id: Component id to check. - expected_category: Component category that the given id is expected - to have. - """ - try: - comp = next( - comp - for comp in await self.components() - if comp.component_id == component_id - ) - except StopIteration as exc: - raise ValueError(f"Unable to find {component_id}") from exc - - if comp.category != expected_category: - raise ValueError( - f"{component_id} is a {comp.category.name.lower()}" - f", not a {expected_category.name.lower()}." - ) - - async def meter_data( # noqa: DOC502 (ValueError is raised indirectly by _expect_category) - self, - component_id: ComponentId, - maxsize: int = RECEIVER_MAX_SIZE, - ) -> Receiver[MeterData]: - """Return a channel receiver that provides a `MeterData` stream. - - Raises: - ValueError: if the given id is unknown or has a different type. - - Args: - component_id: id of the meter to get data for. - maxsize: Size of the receiver's buffer. - - Returns: - A channel receiver that provides realtime meter data. - """ - return await self._new_component_data_receiver( - component_id=component_id, - expected_category=ComponentCategory.METER, - transform=MeterData.from_proto, - maxsize=maxsize, - ) - - async def battery_data( # noqa: DOC502 (ValueError is raised indirectly by _expect_category) - self, - component_id: ComponentId, - maxsize: int = RECEIVER_MAX_SIZE, - ) -> Receiver[BatteryData]: - """Return a channel receiver that provides a `BatteryData` stream. - - Raises: - ValueError: if the given id is unknown or has a different type. - - Args: - component_id: id of the battery to get data for. - maxsize: Size of the receiver's buffer. - - Returns: - A channel receiver that provides realtime battery data. - """ - return await self._new_component_data_receiver( - component_id=component_id, - expected_category=ComponentCategory.BATTERY, - transform=BatteryData.from_proto, - maxsize=maxsize, - ) - - async def inverter_data( # noqa: DOC502 (ValueError is raised indirectly by _expect_category) - self, - component_id: ComponentId, - maxsize: int = RECEIVER_MAX_SIZE, - ) -> Receiver[InverterData]: - """Return a channel receiver that provides an `InverterData` stream. - - Raises: - ValueError: if the given id is unknown or has a different type. - - Args: - component_id: id of the inverter to get data for. - maxsize: Size of the receiver's buffer. - - Returns: - A channel receiver that provides realtime inverter data. - """ - return await self._new_component_data_receiver( - component_id=component_id, - expected_category=ComponentCategory.INVERTER, - transform=InverterData.from_proto, - maxsize=maxsize, - ) - - async def ev_charger_data( # noqa: DOC502 (ValueError is raised indirectly by _expect_category) - self, - component_id: ComponentId, - maxsize: int = RECEIVER_MAX_SIZE, - ) -> Receiver[EVChargerData]: - """Return a channel receiver that provides an `EvChargeData` stream. - - Raises: - ValueError: if the given id is unknown or has a different type. - - Args: - component_id: id of the ev charger to get data for. - maxsize: Size of the receiver's buffer. - - Returns: - A channel receiver that provides realtime ev charger data. - """ - return await self._new_component_data_receiver( - component_id=component_id, - expected_category=ComponentCategory.EV_CHARGER, - transform=EVChargerData.from_proto, - maxsize=maxsize, - ) - - async def set_power( # noqa: DOC502 (raises ApiClientError indirectly) - self, component_id: ComponentId, power_w: float - ) -> None: - """Send request to the Microgrid to set power for component. - - If power > 0, then component will be charged with this power. - If power < 0, then component will be discharged with this power. - If power == 0, then stop charging or discharging component. - - - Args: - component_id: id of the component to set power. - power_w: power to set for the component. - - Raises: - ApiClientError: If the are any errors communicating with the Microgrid API, - most likely a subclass of - [GrpcError][frequenz.client.microgrid.GrpcError]. - """ - await client.call_stub_method( - self, - lambda: self.stub.SetPowerActive( - microgrid_pb2.SetPowerActiveParam( - component_id=int(component_id), power=power_w - ), - timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), - ), - method_name="SetPowerActive", - ) - - async def set_reactive_power( # noqa: DOC502 (raises ApiClientError indirectly) - self, component_id: ComponentId, reactive_power_var: float - ) -> None: - """Send request to the Microgrid to set reactive power for component. - - Negative values are for inductive (lagging) power , and positive values are for - capacitive (leading) power. - - Args: - component_id: id of the component to set power. - reactive_power_var: reactive power to set for the component. - - Raises: - ApiClientError: If the are any errors communicating with the Microgrid API, - most likely a subclass of - [GrpcError][frequenz.client.microgrid.GrpcError]. - """ - await client.call_stub_method( - self, - lambda: self.stub.SetPowerReactive( - microgrid_pb2.SetPowerReactiveParam( - component_id=int(component_id), power=reactive_power_var - ), - timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), - ), - method_name="SetPowerReactive", - ) - - async def set_bounds( # noqa: DOC503 (raises ApiClientError indirectly) - self, - component_id: ComponentId, - lower: float, - upper: float, - ) -> None: - """Send `SetBoundsParam`s received from a channel to the Microgrid service. - - Args: - component_id: ID of the component to set bounds for. - lower: Lower bound to be set for the component. - upper: Upper bound to be set for the component. - - Raises: - ValueError: when upper bound is less than 0, or when lower bound is - greater than 0. - ApiClientError: If the are any errors communicating with the Microgrid API, - most likely a subclass of - [GrpcError][frequenz.client.microgrid.GrpcError]. - """ - if upper < 0: - raise ValueError(f"Upper bound {upper} must be greater than or equal to 0.") - if lower > 0: - raise ValueError(f"Lower bound {lower} must be less than or equal to 0.") - - target_metric = ( - microgrid_pb2.SetBoundsParam.TargetMetric.TARGET_METRIC_POWER_ACTIVE - ) - await client.call_stub_method( - self, - lambda: self.stub.AddInclusionBounds( - microgrid_pb2.SetBoundsParam( - component_id=int(component_id), - target_metric=target_metric, - bounds=metrics_pb2.Bounds(lower=lower, upper=upper), - ), - timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), - ), - method_name="AddInclusionBounds", - ) - - # noqa: DOC502 (Raises ApiClientError indirectly) - def stream_sensor_data( - self, - sensor: SensorId | Sensor, - metrics: Iterable[SensorMetric | int] | None = None, - *, - buffer_size: int = 50, - ) -> Receiver[SensorDataSamples]: - """Stream data samples from a sensor. - - Warning: - Sensors may not support all metrics. If a sensor does not support - a given metric, then the returned data stream will not contain that metric. - - There is no way to tell if a metric is not being received because the - sensor does not support it or because there is a transient issue when - retrieving the metric from the sensor. - - The supported metrics by a sensor can even change with time, for example, - if a sensor is updated with new firmware. - - Args: - sensor: The sensor to stream data from. - metrics: If not `None`, only the specified metrics will be retrieved. - Otherwise all available metrics will be retrieved. - buffer_size: The maximum number of messages to buffer in the returned - receiver. After this limit is reached, the oldest messages will be - dropped. - - Returns: - A receiver to retrieve data from the sensor. - """ - sensor_id = _get_sensor_id(sensor) - key = str(sensor_id) - - class _ExtraArgs(TypedDict): - metrics: NotRequired[frozenset[sensor_pb2.SensorMetric.ValueType]] - - extra_args: _ExtraArgs = {} - if metrics is not None: - extra_args["metrics"] = frozenset( - [_get_sensor_metric_value(m) for m in metrics] - ) - # We use the frozenset because iterables are not hashable - key += f"{hash(extra_args['metrics'])}" - - broadcaster = self._sensor_data_broadcasters.get(key) - if broadcaster is None: - client_id = hex(id(self))[2:] - stream_name = f"microgrid-client-{client_id}-sensor-data-{key}" - broadcaster = streaming.GrpcStreamBroadcaster( - stream_name, - lambda: aiter( - self.stub.StreamComponentData( - microgrid_pb2.ComponentIdParam(id=sensor_id), - timeout=DEFAULT_GRPC_CALL_TIMEOUT, - ) - ), - partial(sensor_data_samples_from_proto, **extra_args), - retry_strategy=self._retry_strategy, - ) - self._sensor_data_broadcasters[key] = broadcaster - return broadcaster.new_receiver(maxsize=buffer_size) - - -def _get_sensor_id(sensor: SensorId | Sensor) -> int: - """Get the sensor ID from a sensor or sensor ID.""" - match sensor: - case SensorId(): - return int(sensor) - case Sensor(): - return int(sensor.id) - case unexpected: - assert_never(unexpected) - - -def _get_sensor_metric_value( - metric: SensorMetric | int, -) -> sensor_pb2.SensorMetric.ValueType: - """Get the sensor metric ID from a sensor metric or sensor metric ID.""" - match metric: - case SensorMetric(): - return sensor_pb2.SensorMetric.ValueType(metric.value) - case int(): - return sensor_pb2.SensorMetric.ValueType(metric) - case unexpected: - assert_never(unexpected) + return microgrid_info_from_proto(microgrid.microgrid) diff --git a/src/frequenz/client/microgrid/_component.py b/src/frequenz/client/microgrid/_component.py deleted file mode 100644 index 6aa42a20..00000000 --- a/src/frequenz/client/microgrid/_component.py +++ /dev/null @@ -1,264 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Defines the components that can be used in a microgrid.""" - -from dataclasses import dataclass -from enum import Enum - -from frequenz.api.common import components_pb2 -from frequenz.api.microgrid import grid_pb2, inverter_pb2 -from frequenz.client.common.microgrid.components import ComponentId - - -class ComponentType(Enum): - """A base class from which individual component types are derived.""" - - -class InverterType(ComponentType): - """Enum representing inverter types.""" - - NONE = inverter_pb2.Type.TYPE_UNSPECIFIED - """Unspecified inverter type.""" - - BATTERY = inverter_pb2.Type.TYPE_BATTERY - """Battery inverter.""" - - SOLAR = inverter_pb2.Type.TYPE_SOLAR - """Solar inverter.""" - - HYBRID = inverter_pb2.Type.TYPE_HYBRID - """Hybrid inverter.""" - - -def component_type_from_protobuf( - component_category: components_pb2.ComponentCategory.ValueType, - component_metadata: inverter_pb2.Metadata, -) -> ComponentType | None: - """Convert a protobuf InverterType message to Component enum. - - For internal-only use by the `microgrid` package. - - Args: - component_category: category the type belongs to. - component_metadata: protobuf metadata to fetch type from. - - Returns: - Enum value corresponding to the protobuf message. - """ - # 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 - == components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER - ): - if not any(int(t.value) == int(component_metadata.type) for t in InverterType): - return None - - return InverterType(component_metadata.type) - - return None - - -class ComponentCategory(Enum): - """Possible types of microgrid component.""" - - NONE = components_pb2.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED - """Unspecified component category.""" - - GRID = components_pb2.ComponentCategory.COMPONENT_CATEGORY_GRID - """Grid component.""" - - METER = components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER - """Meter component.""" - - INVERTER = components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER - """Inverter component.""" - - BATTERY = components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY - """Battery component.""" - - EV_CHARGER = components_pb2.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER - """EV charger component.""" - - CHP = components_pb2.ComponentCategory.COMPONENT_CATEGORY_CHP - """CHP component.""" - - -def component_category_from_protobuf( - component_category: components_pb2.ComponentCategory.ValueType, -) -> ComponentCategory: - """Convert a protobuf ComponentCategory message to ComponentCategory enum. - - For internal-only use by the `microgrid` package. - - Args: - component_category: protobuf enum to convert - - Returns: - Enum value corresponding to the protobuf message. - - Raises: - ValueError: if `component_category` is a sensor (this is not considered - a valid component category as it does not form part of the - microgrid itself) - """ - if component_category == components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR: - raise ValueError("Cannot create a component from a sensor!") - - if not any(t.value == component_category for t in ComponentCategory): - return ComponentCategory.NONE - - return ComponentCategory(component_category) - - -@dataclass(frozen=True) -class Fuse: - """Fuse data class.""" - - max_current: float - """Rated current of the fuse.""" - - -@dataclass(frozen=True) -class ComponentMetadata: - """Base class for component metadata classes.""" - - fuse: Fuse | None = None - """The fuse at the grid connection point.""" - - -@dataclass(frozen=True) -class GridMetadata(ComponentMetadata): - """Metadata for a grid connection point.""" - - -def component_metadata_from_protobuf( - component_category: components_pb2.ComponentCategory.ValueType, - component_metadata: grid_pb2.Metadata, -) -> GridMetadata | None: - """Convert a protobuf GridMetadata message to GridMetadata class. - - For internal-only use by the `microgrid` package. - - Args: - component_category: category the type belongs to. - component_metadata: protobuf metadata to fetch type from. - - Returns: - GridMetadata instance corresponding to the protobuf message. - """ - if component_category == components_pb2.ComponentCategory.COMPONENT_CATEGORY_GRID: - max_current = component_metadata.rated_fuse_current - fuse = Fuse(max_current) - return GridMetadata(fuse) - - return None - - -@dataclass(frozen=True) -class Component: - """Metadata for a single microgrid component.""" - - component_id: ComponentId - """The ID of this component.""" - - category: ComponentCategory - """The category of this component.""" - - type: ComponentType | None = None - """The type of this component.""" - - metadata: ComponentMetadata | None = None - """The metadata of this component.""" - - def is_valid(self) -> bool: - """Check if this instance contains valid data. - - Returns: - `True` if `id > 0` and `type` is a valid `ComponentCategory`, or if `id - == 0` and `type` is `GRID`, `False` otherwise - """ - return ( - int(self.component_id) > 0 - and any(t == self.category for t in ComponentCategory) - ) or (int(self.component_id) == 0 and self.category == ComponentCategory.GRID) - - def __hash__(self) -> int: - """Compute a hash of this instance, obtained by hashing the `component_id` field. - - Returns: - Hash of this instance. - """ - return hash(self.component_id) - - -class ComponentMetricId(Enum): - """An enum representing the various metrics available in the microgrid.""" - - ACTIVE_POWER = "active_power" - """Active power.""" - - ACTIVE_POWER_PHASE_1 = "active_power_phase_1" - """Active power in phase 1.""" - ACTIVE_POWER_PHASE_2 = "active_power_phase_2" - """Active power in phase 2.""" - ACTIVE_POWER_PHASE_3 = "active_power_phase_3" - """Active power in phase 3.""" - - REACTIVE_POWER = "reactive_power" - """Reactive power.""" - - REACTIVE_POWER_PHASE_1 = "reactive_power_phase_1" - """Reactive power in phase 1.""" - REACTIVE_POWER_PHASE_2 = "reactive_power_phase_2" - """Reactive power in phase 2.""" - REACTIVE_POWER_PHASE_3 = "reactive_power_phase_3" - """Reactive power in phase 3.""" - - CURRENT_PHASE_1 = "current_phase_1" - """Current in phase 1.""" - CURRENT_PHASE_2 = "current_phase_2" - """Current in phase 2.""" - CURRENT_PHASE_3 = "current_phase_3" - """Current in phase 3.""" - - VOLTAGE_PHASE_1 = "voltage_phase_1" - """Voltage in phase 1.""" - VOLTAGE_PHASE_2 = "voltage_phase_2" - """Voltage in phase 2.""" - VOLTAGE_PHASE_3 = "voltage_phase_3" - """Voltage in phase 3.""" - - FREQUENCY = "frequency" - - SOC = "soc" - """State of charge.""" - SOC_LOWER_BOUND = "soc_lower_bound" - """Lower bound of state of charge.""" - SOC_UPPER_BOUND = "soc_upper_bound" - """Upper bound of state of charge.""" - CAPACITY = "capacity" - """Capacity.""" - - POWER_INCLUSION_LOWER_BOUND = "power_inclusion_lower_bound" - """Power inclusion lower bound.""" - POWER_EXCLUSION_LOWER_BOUND = "power_exclusion_lower_bound" - """Power exclusion lower bound.""" - POWER_EXCLUSION_UPPER_BOUND = "power_exclusion_upper_bound" - """Power exclusion upper bound.""" - POWER_INCLUSION_UPPER_BOUND = "power_inclusion_upper_bound" - """Power inclusion upper bound.""" - - ACTIVE_POWER_INCLUSION_LOWER_BOUND = "active_power_inclusion_lower_bound" - """Active power inclusion lower bound.""" - ACTIVE_POWER_EXCLUSION_LOWER_BOUND = "active_power_exclusion_lower_bound" - """Active power exclusion lower bound.""" - ACTIVE_POWER_EXCLUSION_UPPER_BOUND = "active_power_exclusion_upper_bound" - """Active power exclusion upper bound.""" - ACTIVE_POWER_INCLUSION_UPPER_BOUND = "active_power_inclusion_upper_bound" - """Active power inclusion upper bound.""" - - TEMPERATURE = "temperature" - """Temperature.""" diff --git a/src/frequenz/client/microgrid/_component_data.py b/src/frequenz/client/microgrid/_component_data.py deleted file mode 100644 index b72b4d2e..00000000 --- a/src/frequenz/client/microgrid/_component_data.py +++ /dev/null @@ -1,595 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Component data types for data coming from a microgrid.""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from datetime import datetime, timezone -from typing import Self - -from frequenz.api.microgrid import microgrid_pb2 -from frequenz.client.common.microgrid.components import ComponentId - -from ._component_error import BatteryError, InverterError -from ._component_states import ( - BatteryComponentState, - BatteryRelayState, - EVChargerCableState, - EVChargerComponentState, - InverterComponentState, -) - - -@dataclass(frozen=True) -class ComponentData(ABC): - """A private base class for strongly typed component data classes.""" - - component_id: ComponentId - """The ID identifying this component in the microgrid.""" - - timestamp: datetime - """The timestamp of when the data was measured.""" - - # The `raw` attribute is excluded from the constructor as it can only be provided - # when instantiating `ComponentData` using the `from_proto` method, which reads - # data from a protobuf message. The whole protobuf message is stored as the `raw` - # attribute. When `ComponentData` is not instantiated from a protobuf message, - # i.e. using the constructor, `raw` will be set to `None`. - raw: microgrid_pb2.ComponentData | None = field(default=None, init=False) - """Raw component data as decoded from the wire.""" - - def _set_raw(self, raw: microgrid_pb2.ComponentData) -> None: - """Store raw protobuf message. - - It is preferred to keep the dataclasses immutable (frozen) and make the `raw` - attribute read-only, which is why the approach of writing to `__dict__` - was used, instead of mutating the `self.raw = raw` attribute directly. - - Args: - raw: raw component data as decoded from the wire. - """ - self.__dict__["raw"] = raw - - @classmethod - @abstractmethod - def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self: - """Create ComponentData from a protobuf message. - - Args: - raw: raw component data as decoded from the wire. - - Returns: - The instance created from the protobuf message. - """ - - -@dataclass(frozen=True) -class MeterData(ComponentData): - """A wrapper class for holding meter data.""" - - active_power: float - """The total active 3-phase AC power, in Watts (W). - - Represented in the passive sign convention. - - * Positive means consumption from the grid. - * Negative means supply into the grid. - """ - - active_power_per_phase: tuple[float, float, float] - """The per-phase AC active power for phase 1, 2, and 3 respectively, in Watt (W). - - Represented in the passive sign convention. - - * Positive means consumption from the grid. - * Negative means supply into the grid. - """ - - reactive_power: float - """The total reactive 3-phase AC power, in Volt-Ampere Reactive (VAr). - - * Positive power means capacitive (current leading w.r.t. voltage). - * Negative power means inductive (current lagging w.r.t. voltage). - """ - - reactive_power_per_phase: tuple[float, float, float] - """The per-phase AC reactive power, in Volt-Ampere Reactive (VAr). - - The provided values are for phase 1, 2, and 3 respectively. - - * Positive power means capacitive (current leading w.r.t. voltage). - * Negative power means inductive (current lagging w.r.t. voltage). - """ - - current_per_phase: tuple[float, float, float] - """AC current in Amperes (A) for phase/line 1,2 and 3 respectively. - - Represented in the passive sign convention. - - * Positive means consumption from the grid. - * Negative means supply into the grid. - """ - - voltage_per_phase: tuple[float, float, float] - """The ac voltage in volts (v) between the line and the neutral wire for phase/line - 1,2 and 3 respectively. - """ - - frequency: float - """The AC power frequency in Hertz (Hz).""" - - @classmethod - def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self: - """Create MeterData from a protobuf message. - - Args: - raw: raw component data as decoded from the wire. - - Returns: - Instance of MeterData created from the protobuf message. - """ - meter_data = cls( - component_id=ComponentId(raw.id), - timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc), - active_power=raw.meter.data.ac.power_active.value, - active_power_per_phase=( - raw.meter.data.ac.phase_1.power_active.value, - raw.meter.data.ac.phase_2.power_active.value, - raw.meter.data.ac.phase_3.power_active.value, - ), - reactive_power=raw.meter.data.ac.power_reactive.value, - reactive_power_per_phase=( - raw.meter.data.ac.phase_1.power_reactive.value, - raw.meter.data.ac.phase_2.power_reactive.value, - raw.meter.data.ac.phase_3.power_reactive.value, - ), - current_per_phase=( - raw.meter.data.ac.phase_1.current.value, - raw.meter.data.ac.phase_2.current.value, - raw.meter.data.ac.phase_3.current.value, - ), - voltage_per_phase=( - raw.meter.data.ac.phase_1.voltage.value, - raw.meter.data.ac.phase_2.voltage.value, - raw.meter.data.ac.phase_3.voltage.value, - ), - frequency=raw.meter.data.ac.frequency.value, - ) - meter_data._set_raw(raw=raw) - return meter_data - - -@dataclass(frozen=True) -class BatteryData(ComponentData): # pylint: disable=too-many-instance-attributes - """A wrapper class for holding battery data.""" - - soc: float - """Battery's overall SoC in percent (%).""" - - soc_lower_bound: float - """The SoC below which discharge commands will be blocked by the system, - in percent (%). - """ - - soc_upper_bound: float - """The SoC above which charge commands will be blocked by the system, - in percent (%). - """ - - capacity: float - """The capacity of the battery in Wh (Watt-hour).""" - - power_inclusion_lower_bound: float - """Lower inclusion bound for battery power in watts. - - This is the lower limit of the range within which power requests are allowed for the - battery. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - power_exclusion_lower_bound: float - """Lower exclusion bound for battery power in watts. - - This is the lower limit of the range within which power requests are not allowed for - the battery. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - power_inclusion_upper_bound: float - """Upper inclusion bound for battery power in watts. - - This is the upper limit of the range within which power requests are allowed for the - battery. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - power_exclusion_upper_bound: float - """Upper exclusion bound for battery power in watts. - - This is the upper limit of the range within which power requests are not allowed for - the battery. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - temperature: float - """The (average) temperature reported by the battery, in Celsius (°C).""" - - relay_state: BatteryRelayState - """State of the battery relay.""" - - component_state: BatteryComponentState - """State of the battery.""" - - errors: list[BatteryError] - """List of errors in protobuf struct.""" - - @classmethod - def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self: - """Create BatteryData from a protobuf message. - - Args: - raw: raw component data as decoded from the wire. - - Returns: - Instance of BatteryData created from the protobuf message. - """ - raw_power = raw.battery.data.dc.power - battery_data = cls( - component_id=ComponentId(raw.id), - timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc), - soc=raw.battery.data.soc.avg, - soc_lower_bound=raw.battery.data.soc.system_inclusion_bounds.lower, - soc_upper_bound=raw.battery.data.soc.system_inclusion_bounds.upper, - capacity=raw.battery.properties.capacity, - power_inclusion_lower_bound=raw_power.system_inclusion_bounds.lower, - power_exclusion_lower_bound=raw_power.system_exclusion_bounds.lower, - power_inclusion_upper_bound=raw_power.system_inclusion_bounds.upper, - power_exclusion_upper_bound=raw_power.system_exclusion_bounds.upper, - temperature=raw.battery.data.temperature.avg, - relay_state=BatteryRelayState.from_pb(raw.battery.state.relay_state), - component_state=BatteryComponentState.from_pb( - raw.battery.state.component_state - ), - errors=[BatteryError.from_pb(e) for e in raw.battery.errors], - ) - battery_data._set_raw(raw=raw) - return battery_data - - -@dataclass(frozen=True) -class InverterData(ComponentData): # pylint: disable=too-many-instance-attributes - """A wrapper class for holding inverter data.""" - - active_power: float - """The total active 3-phase AC power, in Watts (W). - - Represented in the passive sign convention. - - * Positive means consumption from the grid. - * Negative means supply into the grid. - """ - - active_power_per_phase: tuple[float, float, float] - """The per-phase AC active power for phase 1, 2, and 3 respectively, in Watt (W). - - Represented in the passive sign convention. - - * Positive means consumption from the grid. - * Negative means supply into the grid. - """ - - reactive_power: float - """The total reactive 3-phase AC power, in Volt-Ampere Reactive (VAr). - - * Positive power means capacitive (current leading w.r.t. voltage). - * Negative power means inductive (current lagging w.r.t. voltage). - """ - - reactive_power_per_phase: tuple[float, float, float] - """The per-phase AC reactive power, in Volt-Ampere Reactive (VAr). - - The provided values are for phase 1, 2, and 3 respectively. - - * Positive power means capacitive (current leading w.r.t. voltage). - * Negative power means inductive (current lagging w.r.t. voltage). - """ - - current_per_phase: tuple[float, float, float] - """AC current in Amperes (A) for phase/line 1, 2 and 3 respectively. - - Represented in the passive sign convention. - - * Positive means consumption from the grid. - * Negative means supply into the grid. - """ - - voltage_per_phase: tuple[float, float, float] - """The AC voltage in Volts (V) between the line and the neutral wire for - phase/line 1, 2 and 3 respectively. - """ - - active_power_inclusion_lower_bound: float - """Lower inclusion bound for inverter power in watts. - - This is the lower limit of the range within which power requests are allowed for the - inverter. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - active_power_exclusion_lower_bound: float - """Lower exclusion bound for inverter power in watts. - - This is the lower limit of the range within which power requests are not allowed for - the inverter. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - active_power_inclusion_upper_bound: float - """Upper inclusion bound for inverter power in watts. - - This is the upper limit of the range within which power requests are allowed for the - inverter. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - active_power_exclusion_upper_bound: float - """Upper exclusion bound for inverter power in watts. - - This is the upper limit of the range within which power requests are not allowed for - the inverter. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - frequency: float - """AC frequency, in Hertz (Hz).""" - - component_state: InverterComponentState - """State of the inverter.""" - - errors: list[InverterError] - """List of errors from the component.""" - - @classmethod - def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self: - """Create InverterData from a protobuf message. - - Args: - raw: raw component data as decoded from the wire. - - Returns: - Instance of InverterData created from the protobuf message. - """ - raw_power = raw.inverter.data.ac.power_active - inverter_data = cls( - component_id=ComponentId(raw.id), - timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc), - active_power=raw.inverter.data.ac.power_active.value, - active_power_per_phase=( - raw.inverter.data.ac.phase_1.power_active.value, - raw.inverter.data.ac.phase_2.power_active.value, - raw.inverter.data.ac.phase_3.power_active.value, - ), - reactive_power=raw.inverter.data.ac.power_reactive.value, - reactive_power_per_phase=( - raw.inverter.data.ac.phase_1.power_reactive.value, - raw.inverter.data.ac.phase_2.power_reactive.value, - raw.inverter.data.ac.phase_3.power_reactive.value, - ), - current_per_phase=( - raw.inverter.data.ac.phase_1.current.value, - raw.inverter.data.ac.phase_2.current.value, - raw.inverter.data.ac.phase_3.current.value, - ), - voltage_per_phase=( - raw.inverter.data.ac.phase_1.voltage.value, - raw.inverter.data.ac.phase_2.voltage.value, - raw.inverter.data.ac.phase_3.voltage.value, - ), - active_power_inclusion_lower_bound=raw_power.system_inclusion_bounds.lower, - active_power_exclusion_lower_bound=raw_power.system_exclusion_bounds.lower, - active_power_inclusion_upper_bound=raw_power.system_inclusion_bounds.upper, - active_power_exclusion_upper_bound=raw_power.system_exclusion_bounds.upper, - frequency=raw.inverter.data.ac.frequency.value, - component_state=InverterComponentState.from_pb( - raw.inverter.state.component_state - ), - errors=[InverterError.from_pb(e) for e in raw.inverter.errors], - ) - - inverter_data._set_raw(raw=raw) - return inverter_data - - -@dataclass(frozen=True) -class EVChargerData(ComponentData): # pylint: disable=too-many-instance-attributes - """A wrapper class for holding ev_charger data.""" - - active_power: float - """The total active 3-phase AC power, in Watts (W). - - Represented in the passive sign convention. - - * Positive means consumption from the grid. - * Negative means supply into the grid. - """ - - active_power_per_phase: tuple[float, float, float] - """The per-phase AC active power for phase 1, 2, and 3 respectively, in Watt (W). - - Represented in the passive sign convention. - - * Positive means consumption from the grid. - * Negative means supply into the grid. - """ - - current_per_phase: tuple[float, float, float] - """AC current in Amperes (A) for phase/line 1,2 and 3 respectively. - - Represented in the passive sign convention. - - * Positive means consumption from the grid. - * Negative means supply into the grid. - """ - - reactive_power: float - """The total reactive 3-phase AC power, in Volt-Ampere Reactive (VAr). - - * Positive power means capacitive (current leading w.r.t. voltage). - * Negative power means inductive (current lagging w.r.t. voltage). - """ - - reactive_power_per_phase: tuple[float, float, float] - """The per-phase AC reactive power, in Volt-Ampere Reactive (VAr). - - The provided values are for phase 1, 2, and 3 respectively. - - * Positive power means capacitive (current leading w.r.t. voltage). - * Negative power means inductive (current lagging w.r.t. voltage). - """ - - voltage_per_phase: tuple[float, float, float] - """The AC voltage in Volts (V) between the line and the neutral - wire for phase/line 1,2 and 3 respectively. - """ - - active_power_inclusion_lower_bound: float - """Lower inclusion bound for EV charger power in watts. - - This is the lower limit of the range within which power requests are allowed for the - EV charger. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - active_power_exclusion_lower_bound: float - """Lower exclusion bound for EV charger power in watts. - - This is the lower limit of the range within which power requests are not allowed for - the EV charger. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - active_power_inclusion_upper_bound: float - """Upper inclusion bound for EV charger power in watts. - - This is the upper limit of the range within which power requests are allowed for the - EV charger. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - active_power_exclusion_upper_bound: float - """Upper exclusion bound for EV charger power in watts. - - This is the upper limit of the range within which power requests are not allowed for - the EV charger. - - See [`frequenz.api.common.metrics_pb2.Metric.system_inclusion_bounds`][] and - [`frequenz.api.common.metrics_pb2.Metric.system_exclusion_bounds`][] for more - details. - """ - - frequency: float - """AC frequency, in Hertz (Hz).""" - - cable_state: EVChargerCableState - """The state of the ev charger's cable.""" - - component_state: EVChargerComponentState - """The state of the ev charger.""" - - @classmethod - def from_proto(cls, raw: microgrid_pb2.ComponentData) -> Self: - """Create EVChargerData from a protobuf message. - - Args: - raw: raw component data as decoded from the wire. - - Returns: - Instance of EVChargerData created from the protobuf message. - """ - raw_power = raw.ev_charger.data.ac.power_active - ev_charger_data = cls( - component_id=ComponentId(raw.id), - timestamp=raw.ts.ToDatetime(tzinfo=timezone.utc), - active_power=raw_power.value, - active_power_per_phase=( - raw.ev_charger.data.ac.phase_1.power_active.value, - raw.ev_charger.data.ac.phase_2.power_active.value, - raw.ev_charger.data.ac.phase_3.power_active.value, - ), - reactive_power=raw.ev_charger.data.ac.power_reactive.value, - reactive_power_per_phase=( - raw.ev_charger.data.ac.phase_1.power_reactive.value, - raw.ev_charger.data.ac.phase_2.power_reactive.value, - raw.ev_charger.data.ac.phase_3.power_reactive.value, - ), - current_per_phase=( - raw.ev_charger.data.ac.phase_1.current.value, - raw.ev_charger.data.ac.phase_2.current.value, - raw.ev_charger.data.ac.phase_3.current.value, - ), - voltage_per_phase=( - raw.ev_charger.data.ac.phase_1.voltage.value, - raw.ev_charger.data.ac.phase_2.voltage.value, - raw.ev_charger.data.ac.phase_3.voltage.value, - ), - active_power_inclusion_lower_bound=raw_power.system_inclusion_bounds.lower, - active_power_exclusion_lower_bound=raw_power.system_exclusion_bounds.lower, - active_power_inclusion_upper_bound=raw_power.system_inclusion_bounds.upper, - active_power_exclusion_upper_bound=raw_power.system_exclusion_bounds.upper, - cable_state=EVChargerCableState.from_pb(raw.ev_charger.state.cable_state), - component_state=EVChargerComponentState.from_pb( - raw.ev_charger.state.component_state - ), - frequency=raw.ev_charger.data.ac.frequency.value, - ) - ev_charger_data._set_raw(raw=raw) - return ev_charger_data - - def is_ev_connected(self) -> bool: - """Check whether an EV is connected to the charger. - - Returns: - When the charger is not in an error state, whether an EV is connected to - the charger. - """ - return self.component_state not in ( - EVChargerComponentState.AUTHORIZATION_REJECTED, - EVChargerComponentState.ERROR, - ) and self.cable_state in ( - EVChargerCableState.EV_LOCKED, - EVChargerCableState.EV_PLUGGED, - ) diff --git a/src/frequenz/client/microgrid/_component_error.py b/src/frequenz/client/microgrid/_component_error.py deleted file mode 100644 index 0ad9f12a..00000000 --- a/src/frequenz/client/microgrid/_component_error.py +++ /dev/null @@ -1,213 +0,0 @@ -# License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH - -"""Common definitions for the microgrid API.""" - -from dataclasses import dataclass -from enum import Enum -from typing import Self - -from frequenz.api.microgrid import battery_pb2, common_pb2, inverter_pb2 - - -class ErrorLevel(Enum): - """Error level.""" - - UNSPECIFIED = common_pb2.ErrorLevel.ERROR_LEVEL_UNSPECIFIED - """Unspecified component error.""" - - WARN = common_pb2.ErrorLevel.ERROR_LEVEL_WARN - """Action must be taken to prevent a severe error from occurring in the future.""" - - CRITICAL = common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL - """A severe error that causes the component to fail. Immediate action must be taken.""" - - @classmethod - def from_pb(cls, code: common_pb2.ErrorLevel.ValueType) -> Self: - """Convert a protobuf error level value to this enum. - - Args: - code: The protobuf error level to convert. - - Returns: - The enum value corresponding to the protobuf message. - """ - try: - return cls(code) - except ValueError: - return cls(cls.UNSPECIFIED) - - -class BatteryErrorCode(Enum): - """Battery error code.""" - - UNSPECIFIED = battery_pb2.ErrorCode.ERROR_CODE_UNSPECIFIED - """Unspecified battery error code.""" - - HIGH_CURRENT_CHARGE = battery_pb2.ErrorCode.ERROR_CODE_HIGH_CURRENT_CHARGE - """Charge current is too high.""" - - HIGH_CURRENT_DISCHARGE = battery_pb2.ErrorCode.ERROR_CODE_HIGH_CURRENT_DISCHARGE - """Discharge current is too high.""" - - HIGH_VOLTAGE = battery_pb2.ErrorCode.ERROR_CODE_HIGH_VOLTAGE - """Voltage is too high.""" - - LOW_VOLTAGE = battery_pb2.ErrorCode.ERROR_CODE_LOW_VOLTAGE - """Voltage is too low.""" - - HIGH_TEMPERATURE = battery_pb2.ErrorCode.ERROR_CODE_HIGH_TEMPERATURE - """Temperature is too high.""" - - LOW_TEMPERATURE = battery_pb2.ErrorCode.ERROR_CODE_LOW_TEMPERATURE - """Temperature is too low.""" - - HIGH_HUMIDITY = battery_pb2.ErrorCode.ERROR_CODE_HIGH_HUMIDITY - """Humidity is too high.""" - - EXCEEDED_SOP_CHARGE = battery_pb2.ErrorCode.ERROR_CODE_EXCEEDED_SOP_CHARGE - """Charge current has exceeded component bounds.""" - - EXCEEDED_SOP_DISCHARGE = battery_pb2.ErrorCode.ERROR_CODE_EXCEEDED_SOP_DISCHARGE - """Discharge current has exceeded component bounds.""" - - SYSTEM_IMBALANCE = battery_pb2.ErrorCode.ERROR_CODE_SYSTEM_IMBALANCE - """The battery blocks are not balanced with respect to each other.""" - - LOW_SOH = battery_pb2.ErrorCode.ERROR_CODE_LOW_SOH - """The State of health is low.""" - - BLOCK_ERROR = battery_pb2.ErrorCode.ERROR_CODE_BLOCK_ERROR - """One or more battery blocks have failed.""" - - CONTROLLER_ERROR = battery_pb2.ErrorCode.ERROR_CODE_CONTROLLER_ERROR - """The battery controller has failed.""" - - RELAY_ERROR = battery_pb2.ErrorCode.ERROR_CODE_RELAY_ERROR - """The battery's DC relays have failed.""" - - RELAY_CYCLE_LIMIT_REACHED = ( - battery_pb2.ErrorCode.ERROR_CODE_RELAY_CYCLE_LIMIT_REACHED - ) - """The battery's DC relays have reached the cycles limit in its lifetime specifications.""" - - FUSE_ERROR = battery_pb2.ErrorCode.ERROR_CODE_FUSE_ERROR - """The battery's fuse has failed.""" - - EXTERNAL_POWER_SWITCH_ERROR = ( - battery_pb2.ErrorCode.ERROR_CODE_EXTERNAL_POWER_SWITCH_ERROR - ) - """The eternal power switch has failed.""" - - PRECHARGE_ERROR = battery_pb2.ErrorCode.ERROR_CODE_PRECHARGE_ERROR - """The precharge operation has failed.""" - - SYSTEM_PLAUSIBILITY_ERROR = ( - battery_pb2.ErrorCode.ERROR_CODE_SYSTEM_PLAUSIBILITY_ERROR - ) - """System plausibility checks have failed.""" - - SYSTEM_UNDERVOLTAGE_SHUTDOWN = ( - battery_pb2.ErrorCode.ERROR_CODE_SYSTEM_UNDERVOLTAGE_SHUTDOWN - ) - """System shut down due to extremely low voltage.""" - - CALIBRATION_NEEDED = battery_pb2.ErrorCode.ERROR_CODE_CALIBRATION_NEEDED - """The battery requires a calibration to reset its measurements.""" - - @classmethod - def from_pb(cls, code: battery_pb2.ErrorCode.ValueType) -> Self: - """Convert a protobuf error code value to this enum. - - Args: - code: The protobuf error code to convert. - - Returns: - The enum value corresponding to the protobuf message. - """ - try: - return cls(code) - except ValueError: - return cls(cls.UNSPECIFIED) - - -@dataclass(frozen=True, kw_only=True) -class BatteryError: - """A battery error.""" - - code: BatteryErrorCode = BatteryErrorCode.UNSPECIFIED - """The error code.""" - - level: ErrorLevel = ErrorLevel.UNSPECIFIED - """The error level.""" - - message: str = "" - """The error message.""" - - @classmethod - def from_pb(cls, raw: battery_pb2.Error) -> Self: - """Create a new instance using a protobuf message to get the values. - - Args: - raw: The protobuf message to get the values from. - - Returns: - The new instance with the values from the protobuf message. - """ - return cls( - code=BatteryErrorCode.from_pb(raw.code), - level=ErrorLevel.from_pb(raw.level), - message=raw.msg, - ) - - -class InverterErrorCode(Enum): - """Inverter error code.""" - - UNSPECIFIED = inverter_pb2.ErrorCode.ERROR_CODE_UNSPECIFIED - """Unspecified inverter error code.""" - - @classmethod - def from_pb(cls, code: inverter_pb2.ErrorCode.ValueType) -> Self: - """Convert a protobuf error code value to this enum. - - Args: - code: The protobuf error code to convert. - - Returns: - The enum value corresponding to the protobuf message. - """ - try: - return cls(code) - except ValueError: - return cls(cls.UNSPECIFIED) - - -@dataclass(frozen=True, kw_only=True) -class InverterError: - """An inverter error.""" - - code: InverterErrorCode = InverterErrorCode.UNSPECIFIED - """The error code.""" - - level: ErrorLevel = ErrorLevel.UNSPECIFIED - """The error level.""" - - message: str = "" - """The error message.""" - - @classmethod - def from_pb(cls, raw: inverter_pb2.Error) -> Self: - """Create a new instance using a protobuf message to get the values. - - Args: - raw: The protobuf message to get the values from. - - Returns: - The new instance with the values from the protobuf message. - """ - return cls( - code=InverterErrorCode.from_pb(raw.code), - level=ErrorLevel.from_pb(raw.level), - message=raw.msg, - ) diff --git a/src/frequenz/client/microgrid/_component_states.py b/src/frequenz/client/microgrid/_component_states.py deleted file mode 100644 index 7c5177e8..00000000 --- a/src/frequenz/client/microgrid/_component_states.py +++ /dev/null @@ -1,253 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Defines states of components that can be used in a microgrid.""" - -from enum import Enum -from typing import Self - -from frequenz.api.microgrid import battery_pb2, ev_charger_pb2, inverter_pb2 - - -class BatteryComponentState(Enum): - """Component states of a battery.""" - - UNSPECIFIED = battery_pb2.ComponentState.COMPONENT_STATE_UNSPECIFIED - """Unspecified component state.""" - - OFF = battery_pb2.ComponentState.COMPONENT_STATE_OFF - """The battery is switched off.""" - - IDLE = battery_pb2.ComponentState.COMPONENT_STATE_IDLE - """The battery is idle.""" - - CHARGING = battery_pb2.ComponentState.COMPONENT_STATE_CHARGING - """The battery is consuming electrical energy.""" - - DISCHARGING = battery_pb2.ComponentState.COMPONENT_STATE_DISCHARGING - """The battery is generating electrical energy.""" - - ERROR = battery_pb2.ComponentState.COMPONENT_STATE_ERROR - """The battery is in a faulty state.""" - - LOCKED = battery_pb2.ComponentState.COMPONENT_STATE_LOCKED - """The battery is online, but currently unavailable. - - Possibly due to a pre-scheduled maintenance, or waiting for a resource to be loaded. - """ - - SWITCHING_ON = battery_pb2.ComponentState.COMPONENT_STATE_SWITCHING_ON - """ - The battery is starting up and needs some time to become fully operational. - """ - - SWITCHING_OFF = battery_pb2.ComponentState.COMPONENT_STATE_SWITCHING_OFF - """The battery is switching off and needs some time to fully shut down.""" - - UNKNOWN = battery_pb2.ComponentState.COMPONENT_STATE_UNKNOWN - """A state is provided by the component, but it is not one of the above states.""" - - @classmethod - def from_pb(cls, state: battery_pb2.ComponentState.ValueType) -> Self: - """Convert a protobuf state value to this enum. - - Args: - state: The protobuf component state to convert. - - Returns: - The enum value corresponding to the protobuf message. - """ - try: - return cls(state) - except ValueError: - return cls(cls.UNKNOWN) - - -class BatteryRelayState(Enum): - """Relay states of a battery.""" - - UNSPECIFIED = battery_pb2.RelayState.RELAY_STATE_UNSPECIFIED - """Unspecified relay state.""" - - OPENED = battery_pb2.RelayState.RELAY_STATE_OPENED - """The relays are open, and the DC power line to the inverter is disconnected.""" - - PRECHARGING = battery_pb2.RelayState.RELAY_STATE_PRECHARGING - """The relays are closing, and the DC power line to the inverter is being connected.""" - - CLOSED = battery_pb2.RelayState.RELAY_STATE_CLOSED - """The relays are closed, and the DC power line to the inverter is connected.""" - - ERROR = battery_pb2.RelayState.RELAY_STATE_ERROR - """The relays are in an error state.""" - - LOCKED = battery_pb2.RelayState.RELAY_STATE_LOCKED - """The relays are locked, and should be available to accept commands shortly.""" - - @classmethod - def from_pb(cls, state: battery_pb2.RelayState.ValueType) -> Self: - """Convert a protobuf state value to this enum. - - Args: - state: The protobuf component state to convert. - - Returns: - The enum value corresponding to the protobuf message. - """ - try: - return cls(state) - except ValueError: - return cls(cls.UNSPECIFIED) - - -class EVChargerCableState(Enum): - """Cable states of an EV Charger.""" - - UNSPECIFIED = ev_charger_pb2.CableState.CABLE_STATE_UNSPECIFIED - """Unspecified cable state.""" - - UNPLUGGED = ev_charger_pb2.CableState.CABLE_STATE_UNPLUGGED - """The cable is unplugged.""" - - CHARGING_STATION_PLUGGED = ( - ev_charger_pb2.CableState.CABLE_STATE_CHARGING_STATION_PLUGGED - ) - """The cable is plugged into the charging station.""" - - CHARGING_STATION_LOCKED = ( - ev_charger_pb2.CableState.CABLE_STATE_CHARGING_STATION_LOCKED - ) - """The cable is plugged into the charging station and locked.""" - - EV_PLUGGED = ev_charger_pb2.CableState.CABLE_STATE_EV_PLUGGED - """The cable is plugged into the EV.""" - - EV_LOCKED = ev_charger_pb2.CableState.CABLE_STATE_EV_LOCKED - """The cable is plugged into the EV and locked.""" - - @classmethod - def from_pb(cls, state: ev_charger_pb2.CableState.ValueType) -> Self: - """Convert a protobuf state value to this enum. - - Args: - state: The protobuf cable state to convert. - - Returns: - The enum value corresponding to the protobuf message. - """ - try: - return cls(state) - except ValueError: - return cls(cls.UNSPECIFIED) - - -class EVChargerComponentState(Enum): - """Component State of an EV Charger.""" - - UNSPECIFIED = ev_charger_pb2.ComponentState.COMPONENT_STATE_UNSPECIFIED - """Unspecified component state.""" - - STARTING = ev_charger_pb2.ComponentState.COMPONENT_STATE_STARTING - """The component is starting.""" - - NOT_READY = ev_charger_pb2.ComponentState.COMPONENT_STATE_NOT_READY - """The component is not ready.""" - - READY = ev_charger_pb2.ComponentState.COMPONENT_STATE_READY - """The component is ready.""" - - CHARGING = ev_charger_pb2.ComponentState.COMPONENT_STATE_CHARGING - """The component is charging.""" - - DISCHARGING = ev_charger_pb2.ComponentState.COMPONENT_STATE_DISCHARGING - """The component is discharging.""" - - ERROR = ev_charger_pb2.ComponentState.COMPONENT_STATE_ERROR - """The component is in error state.""" - - AUTHORIZATION_REJECTED = ( - ev_charger_pb2.ComponentState.COMPONENT_STATE_AUTHORIZATION_REJECTED - ) - """The component rejected authorization.""" - - INTERRUPTED = ev_charger_pb2.ComponentState.COMPONENT_STATE_INTERRUPTED - """The component is interrupted.""" - - UNKNOWN = ev_charger_pb2.ComponentState.COMPONENT_STATE_UNKNOWN - """A state is provided by the component, but it is not one of the above states.""" - - @classmethod - def from_pb(cls, state: ev_charger_pb2.ComponentState.ValueType) -> Self: - """Convert a protobuf state value to this enum. - - Args: - state: The protobuf component state to convert. - - Returns: - The enum value corresponding to the protobuf message. - """ - try: - return cls(state) - except ValueError: - return cls(cls.UNKNOWN) - - -class InverterComponentState(Enum): - """Component states of an inverter.""" - - UNSPECIFIED = inverter_pb2.ComponentState.COMPONENT_STATE_UNSPECIFIED - """Unspecified component state.""" - - OFF = inverter_pb2.ComponentState.COMPONENT_STATE_OFF - """Inverter is switched off.""" - - SWITCHING_ON = inverter_pb2.ComponentState.COMPONENT_STATE_SWITCHING_ON - """The PbInverteris starting up and needs some time to become fully operational.""" - - SWITCHING_OFF = inverter_pb2.ComponentState.COMPONENT_STATE_SWITCHING_OFF - """The PbInverteris switching off and needs some time to fully shut down.""" - - STANDBY = inverter_pb2.ComponentState.COMPONENT_STATE_STANDBY - """The PbInverteris in a standby state, and is disconnected from the grid. - - When connected to the grid, it run a few tests, and move to the `IDLE` state. - """ - - IDLE = inverter_pb2.ComponentState.COMPONENT_STATE_IDLE - """The inverter is idle.""" - - CHARGING = inverter_pb2.ComponentState.COMPONENT_STATE_CHARGING - """The inverter is consuming electrical energy to charge batteries. - - Applicable to `BATTERY` and `HYBRID` inverters only. - """ - - DISCHARGING = inverter_pb2.ComponentState.COMPONENT_STATE_DISCHARGING - """The inverter is generating electrical energy.""" - - ERROR = inverter_pb2.ComponentState.COMPONENT_STATE_ERROR - """The inverter is in a faulty state.""" - - UNAVAILABLE = inverter_pb2.ComponentState.COMPONENT_STATE_UNAVAILABLE - """The inverter is online, but currently unavailable. - - Possibly due to a pre- scheduled maintenance. - """ - - UNKNOWN = inverter_pb2.ComponentState.COMPONENT_STATE_UNKNOWN - """A state is provided by the component, but it is not one of the above states.""" - - @classmethod - def from_pb(cls, state: inverter_pb2.ComponentState.ValueType) -> Self: - """Convert a protobuf state value to this enum. - - Args: - state: The protobuf component state to convert. - - Returns: - The enum value corresponding to the protobuf message. - """ - try: - return cls(state) - except ValueError: - return cls(cls.UNKNOWN) diff --git a/src/frequenz/client/microgrid/_connection.py b/src/frequenz/client/microgrid/_connection.py deleted file mode 100644 index b0b3dac7..00000000 --- a/src/frequenz/client/microgrid/_connection.py +++ /dev/null @@ -1,29 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Defines the connections between microgrid components.""" - - -from dataclasses import dataclass - -from frequenz.client.common.microgrid.components import ComponentId - - -@dataclass(frozen=True) -class Connection: - """Metadata for a connection between microgrid components.""" - - start: ComponentId - """The component ID that represents the start component of the connection.""" - - end: ComponentId - """The component ID that represents the end component of the connection.""" - - def is_valid(self) -> bool: - """Check if this instance contains valid data. - - Returns: - `True` if `start >= 0`, `end > 0`, and `start != end`, `False` - otherwise. - """ - return int(self.start) >= 0 and int(self.end) > 0 and self.start != self.end diff --git a/src/frequenz/client/microgrid/_constants.py b/src/frequenz/client/microgrid/_constants.py deleted file mode 100644 index 068359d1..00000000 --- a/src/frequenz/client/microgrid/_constants.py +++ /dev/null @@ -1,21 +0,0 @@ -# License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH - -"""Module with constants shared between instances of the sdk. - -To be replaced by ConfigManager. -""" - -RECEIVER_MAX_SIZE: int = 50 -"""Default buffer size of the receiver.""" - -WAIT_FOR_COMPONENT_DATA_SEC: float = 2 -"""Delay the start of the application to wait for the data.""" - -MAX_BATTERY_DATA_AGE_SEC: float = 2 -"""Max time difference for the battery or inverter data to be considered as reliable. - -If battery or inverter stopped sending data, then this is the maximum time when its -last message should be considered as valid. After that time, component data -should not be used. -""" diff --git a/src/frequenz/client/microgrid/_delivery_area.py b/src/frequenz/client/microgrid/_delivery_area.py new file mode 100644 index 00000000..1746f608 --- /dev/null +++ b/src/frequenz/client/microgrid/_delivery_area.py @@ -0,0 +1,89 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Delivery area information for the energy market.""" + +import enum +from dataclasses import dataclass + +from frequenz.api.common.v1.grid import delivery_area_pb2 + + +@enum.unique +class EnergyMarketCodeType(enum.Enum): + """The identification code types used in the energy market. + + CodeType specifies the type of identification code used for uniquely + identifying various entities such as delivery areas, market participants, + and grid components within the energy market. + + This enumeration aims to + offer compatibility across different jurisdictional standards. + + Note: Understanding Code Types + Different regions or countries may have their own standards for uniquely + identifying various entities within the energy market. For example, in + Europe, the Energy Identification Code (EIC) is commonly used for this + purpose. + + Note: Extensibility + New code types can be added to this enum to accommodate additional regional + standards, enhancing the API's adaptability. + + Danger: Validation Required + The chosen code type should correspond correctly with the `code` field in + the relevant message objects, such as `DeliveryArea` or `Counterparty`. + Failure to match the code type with the correct code could lead to + processing errors. + """ + + UNSPECIFIED = delivery_area_pb2.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED + """Unspecified type. This value is a placeholder and should not be used.""" + + EUROPE_EIC = delivery_area_pb2.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC + """European Energy Identification Code Standard.""" + + US_NERC = delivery_area_pb2.ENERGY_MARKET_CODE_TYPE_US_NERC + """North American Electric Reliability Corporation identifiers.""" + + +@dataclass(frozen=True, kw_only=True) +class DeliveryArea: + """A geographical or administrative region where electricity deliveries occur. + + DeliveryArea represents the geographical or administrative region, usually defined + and maintained by a Transmission System Operator (TSO), where electricity deliveries + for a contract occur. + + The concept is important to energy trading as it delineates the agreed-upon delivery + location. Delivery areas can have different codes based on the jurisdiction in + which they operate. + + Note: Jurisdictional Differences + This is typically represented by specific codes according to local jurisdiction. + + In Europe, this is represented by an + [EIC](https://en.wikipedia.org/wiki/Energy_Identification_Code) (Energy + Identification Code). [List of + EICs](https://www.entsoe.eu/data/energy-identification-codes-eic/eic-approved-codes/). + """ + + code: str | None + """The code representing the unique identifier for the delivery area.""" + + code_type: EnergyMarketCodeType | int + """Type of code used for identifying the delivery area itself. + + This code could be extended in the future, in case an unknown code type is + encountered, a plain integer value is used to represent it. + """ + + def __str__(self) -> str: + """Return a human-readable string representation of this instance.""" + code = self.code or "" + code_type = ( + f"type={self.code_type}" + if isinstance(self.code_type, int) + else self.code_type.name + ) + return f"{code}[{code_type}]" diff --git a/src/frequenz/client/microgrid/_delivery_area_proto.py b/src/frequenz/client/microgrid/_delivery_area_proto.py new file mode 100644 index 00000000..7824757c --- /dev/null +++ b/src/frequenz/client/microgrid/_delivery_area_proto.py @@ -0,0 +1,44 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Loading of DeliveryArea objects from protobuf messages.""" + +import logging + +from frequenz.api.common.v1.grid import delivery_area_pb2 + +from ._delivery_area import DeliveryArea, EnergyMarketCodeType +from ._util import enum_from_proto + +_logger = logging.getLogger(__name__) + + +def delivery_area_from_proto(message: delivery_area_pb2.DeliveryArea) -> DeliveryArea: + """Convert a protobuf delivery area message to a delivery area object. + + Args: + message: The protobuf message to convert. + + Returns: + The resulting delivery area object. + """ + issues: list[str] = [] + + code = message.code or None + if code is None: + issues.append("code is empty") + + code_type = enum_from_proto(message.code_type, EnergyMarketCodeType) + if code_type is EnergyMarketCodeType.UNSPECIFIED: + issues.append("code_type is unspecified") + elif isinstance(code_type, int): + issues.append("code_type is unrecognized") + + if issues: + _logger.warning( + "Found issues in delivery area: %s | Protobuf message:\n%s", + ", ".join(issues), + message, + ) + + return DeliveryArea(code=code, code_type=code_type) diff --git a/src/frequenz/client/microgrid/_location.py b/src/frequenz/client/microgrid/_location.py new file mode 100644 index 00000000..af565e08 --- /dev/null +++ b/src/frequenz/client/microgrid/_location.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Location information for a microgrid.""" + + +import logging +from dataclasses import dataclass +from functools import cached_property +from zoneinfo import ZoneInfo + +import timezonefinder + +_timezone_finder = timezonefinder.TimezoneFinder() +_logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class Location: + """A location of a microgrid.""" + + latitude: float | None + """The latitude of the microgrid in degree.""" + + longitude: float | None + """The longitude of the microgrid in degree.""" + + country_code: str | None + """The country code of the microgrid in ISO 3166-1 Alpha 2 format.""" + + @cached_property + def timezone(self) -> ZoneInfo | None: + """The timezone of the microgrid, or `None` if it could not be determined.""" + if self.latitude is None or self.longitude is None: + _logger.warning( + "Latitude (%s) or longitude (%s) missing, cannot determine timezone" + ) + return None + timezone = _timezone_finder.timezone_at(lat=self.latitude, lng=self.longitude) + return ZoneInfo(key=timezone) if timezone else None + + def __str__(self) -> str: + """Return the short string representation of this instance.""" + country = self.country_code or "" + lat = f"{self.latitude:.2f}" if self.latitude is not None else "?" + lon = f"{self.longitude:.2f}" if self.longitude is not None else "?" + coordinates = "" + if self.latitude is not None or self.longitude is not None: + coordinates = f":({lat}, {lon})" + return f"{country}{coordinates}" diff --git a/src/frequenz/client/microgrid/_location_proto.py b/src/frequenz/client/microgrid/_location_proto.py new file mode 100644 index 00000000..301fadaf --- /dev/null +++ b/src/frequenz/client/microgrid/_location_proto.py @@ -0,0 +1,47 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Loading of Location objects from protobuf messages.""" + +import logging + +from frequenz.api.common.v1 import location_pb2 + +from ._location import Location + +_logger = logging.getLogger(__name__) + + +def location_from_proto(message: location_pb2.Location) -> Location: + """Convert a protobuf location message to a location object. + + Args: + message: The protobuf message to convert. + + Returns: + The resulting location object. + """ + issues: list[str] = [] + + latitude: float | None = message.latitude if -90 <= message.latitude <= 90 else None + if latitude is None: + issues.append("latitude out of range [-90, 90]") + + longitude: float | None = ( + message.longitude if -180 <= message.longitude <= 180 else None + ) + if longitude is None: + issues.append("longitude out of range [-180, 180]") + + country_code = message.country_code or None + if country_code is None: + issues.append("country code is empty") + + if issues: + _logger.warning( + "Found issues in location: %s | Protobuf message:\n%s", + ", ".join(issues), + message, + ) + + return Location(latitude=latitude, longitude=longitude, country_code=country_code) diff --git a/src/frequenz/client/microgrid/_metadata.py b/src/frequenz/client/microgrid/_metadata.py deleted file mode 100644 index 135dafca..00000000 --- a/src/frequenz/client/microgrid/_metadata.py +++ /dev/null @@ -1,53 +0,0 @@ -# License: MIT -# Copyright © 2023 Frequenz Energy-as-a-Service GmbH - -"""Metadata that describes a microgrid.""" - -from dataclasses import dataclass -from zoneinfo import ZoneInfo - -from frequenz.client.common.microgrid import MicrogridId -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. - - If not passed during construction (or `None` is passed), and there is a `longitude` - and `latitude`, then the timezone wil be looked up in a database based on the - coordinates. This lookup could fail, in which case the timezone will still be - `None`. - """ - - 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: MicrogridId | None = None - """The ID of the microgrid.""" - - location: Location | None = None - """The location of the microgrid.""" diff --git a/src/frequenz/client/microgrid/_microgrid_info.py b/src/frequenz/client/microgrid/_microgrid_info.py new file mode 100644 index 00000000..b1f65c9a --- /dev/null +++ b/src/frequenz/client/microgrid/_microgrid_info.py @@ -0,0 +1,87 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Definition of a microgrid.""" + +import datetime +import enum +import logging +from dataclasses import dataclass +from functools import cached_property + +from frequenz.api.common.v1.microgrid import microgrid_pb2 +from frequenz.client.common.microgrid import EnterpriseId, MicrogridId + +from ._delivery_area import DeliveryArea +from ._location import Location + +_logger = logging.getLogger(__name__) + + +@enum.unique +class MicrogridStatus(enum.Enum): + """The possible statuses for a microgrid.""" + + UNSPECIFIED = microgrid_pb2.MICROGRID_STATUS_UNSPECIFIED + """The status is unspecified. This should not be used.""" + + ACTIVE = microgrid_pb2.MICROGRID_STATUS_ACTIVE + """The microgrid is active.""" + + INACTIVE = microgrid_pb2.MICROGRID_STATUS_INACTIVE + """The microgrid is inactive.""" + + +@dataclass(frozen=True, kw_only=True) +class MicrogridInfo: + """A localized grouping of electricity generation, energy storage, and loads. + + A microgrid is a localized grouping of electricity generation, energy storage, and + loads that normally operates connected to a traditional centralized grid. + + Each microgrid has a unique identifier and is associated with an enterprise account. + + A key feature is that it has a physical location and is situated in a delivery area. + + Note: Key Concepts + - Physical Location: Geographical coordinates specify the exact physical + location of the microgrid. + - Delivery Area: Each microgrid is part of a broader delivery area, which is + crucial for energy trading and compliance. + """ + + id: MicrogridId + """The unique identifier of the microgrid.""" + + enterprise_id: EnterpriseId + """The unique identifier linking this microgrid to its parent enterprise account.""" + + name: str | None + """Name of the microgrid.""" + + delivery_area: DeliveryArea | None + """The delivery area where the microgrid is located, as identified by a specific code.""" + + location: Location | None + """Physical location of the microgrid, in geographical co-ordinates.""" + + status: MicrogridStatus | int + """The current status of the microgrid.""" + + create_timestamp: datetime.datetime + """The UTC timestamp indicating when the microgrid was initially created.""" + + @cached_property + def is_active(self) -> bool: + """Whether the microgrid is active.""" + if self.status is MicrogridStatus.UNSPECIFIED: + # Because this is a cached property, the warning will only be logged once. + _logger.warning( + "Microgrid %s has an unspecified status. Assuming it is active.", self + ) + return self.status in (MicrogridStatus.ACTIVE, MicrogridStatus.UNSPECIFIED) + + def __str__(self) -> str: + """Return the ID of this microgrid as a string.""" + name = f":{self.name}" if self.name else "" + return f"{self.id}{name}" diff --git a/src/frequenz/client/microgrid/_microgrid_info_proto.py b/src/frequenz/client/microgrid/_microgrid_info_proto.py new file mode 100644 index 00000000..0640cdeb --- /dev/null +++ b/src/frequenz/client/microgrid/_microgrid_info_proto.py @@ -0,0 +1,79 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Loading of MicrogridInfo objects from protobuf messages.""" + + +import logging + +from frequenz.api.common.v1.microgrid import microgrid_pb2 +from frequenz.client.base import conversion +from frequenz.client.common.microgrid import EnterpriseId, MicrogridId + +from ._delivery_area import DeliveryArea +from ._delivery_area_proto import delivery_area_from_proto +from ._location import Location +from ._location_proto import location_from_proto +from ._microgrid_info import MicrogridInfo, MicrogridStatus +from ._util import enum_from_proto + +_logger = logging.getLogger(__name__) + + +def microgrid_info_from_proto(message: microgrid_pb2.Microgrid) -> MicrogridInfo: + """Convert a protobuf microgrid message to a microgrid object. + + Args: + message: The protobuf message to convert. + + Returns: + The resulting microgrid object. + """ + major_issues: list[str] = [] + minor_issues: list[str] = [] + + delivery_area: DeliveryArea | None = None + if message.HasField("delivery_area"): + delivery_area = delivery_area_from_proto(message.delivery_area) + else: + major_issues.append("delivery_area is missing") + + location: Location | None = None + if message.HasField("location"): + location = location_from_proto(message.location) + else: + major_issues.append("location is missing") + + name = message.name or None + if name is None: + minor_issues.append("name is empty") + + status = enum_from_proto(message.status, MicrogridStatus) + if status is MicrogridStatus.UNSPECIFIED: + major_issues.append("status is unspecified") + elif isinstance(status, int): + major_issues.append("status is unrecognized") + + if major_issues: + _logger.warning( + "Found issues in microgrid: %s | Protobuf message:\n%s", + ", ".join(major_issues), + message, + ) + + if minor_issues: + _logger.debug( + "Found minor issues in microgrid: %s | Protobuf message:\n%s", + ", ".join(minor_issues), + message, + ) + + return MicrogridInfo( + id=MicrogridId(message.id), + enterprise_id=EnterpriseId(message.enterprise_id), + name=message.name or None, + delivery_area=delivery_area, + location=location, + status=status, + create_timestamp=conversion.to_datetime(message.create_timestamp), + ) diff --git a/src/frequenz/client/microgrid/_sensor_proto.py b/src/frequenz/client/microgrid/_sensor_proto.py deleted file mode 100644 index 9df23299..00000000 --- a/src/frequenz/client/microgrid/_sensor_proto.py +++ /dev/null @@ -1,190 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Loading of SensorDataSamples objects from protobuf messages.""" - -import logging -from collections.abc import Set -from datetime import datetime - -from frequenz.api.common import components_pb2 -from frequenz.api.microgrid import common_pb2, microgrid_pb2, sensor_pb2 -from frequenz.client.base import conversion -from frequenz.client.common.microgrid.sensors import SensorId - -from ._lifetime import Lifetime -from ._util import enum_from_proto -from .sensor import ( - Sensor, - SensorDataSamples, - SensorErrorCode, - SensorMetric, - SensorMetricSample, - SensorStateCode, - SensorStateSample, -) - -_logger = logging.getLogger(__name__) - - -def sensor_from_proto(message: microgrid_pb2.Component) -> Sensor: - """Convert a protobuf message to a `Sensor` instance. - - Args: - message: The protobuf message. - - Returns: - The resulting sensor instance. - """ - major_issues: list[str] = [] - minor_issues: list[str] = [] - - sensor = sensor_from_proto_with_issues( - message, major_issues=major_issues, minor_issues=minor_issues - ) - - if major_issues: - _logger.warning( - "Found issues in sensor: %s | Protobuf message:\n%s", - ", ".join(major_issues), - message, - ) - if minor_issues: - _logger.debug( - "Found minor issues in sensor: %s | Protobuf message:\n%s", - ", ".join(minor_issues), - message, - ) - - return sensor - - -def sensor_from_proto_with_issues( - message: microgrid_pb2.Component, - *, - major_issues: list[str], - minor_issues: list[str], -) -> Sensor: - """Convert a protobuf message to a sensor instance and collect issues. - - Args: - message: The protobuf message. - major_issues: A list to append major issues to. - minor_issues: A list to append minor issues to. - - Returns: - The resulting sensor instance. - """ - sensor_id = SensorId(message.id) - - name = message.name or None - if name is None: - minor_issues.append("name is empty") - - manufacturer = message.manufacturer or None - if manufacturer is None: - minor_issues.append("manufacturer is empty") - - model_name = message.model_name or None - if model_name is None: - minor_issues.append("model_name is empty") - - if ( - message.category - is not components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR - ): - major_issues.append(f"unexpected category for sensor ({message.category})") - - return Sensor( - id=sensor_id, - name=name, - manufacturer=manufacturer, - model_name=model_name, - operational_lifetime=Lifetime(), - ) - - -def sensor_data_samples_from_proto( - message: microgrid_pb2.ComponentData, - metrics: Set[sensor_pb2.SensorMetric.ValueType] | None = None, -) -> SensorDataSamples: - """Convert a protobuf component data message to a sensor data object. - - Args: - message: The protobuf message to convert. - metrics: If not `None`, only the specified metrics will be retrieved. - Otherwise all available metrics will be retrieved. - - Returns: - The resulting `SensorDataSamples` object. - """ - # At some point it might make sense to also log issues found in the samples, but - # using a naive approach like in `component_from_proto` might spam the logs too - # much, as we can receive several samples per second, and if a component is in - # a unrecognized state for long, it will mean we will emit the same log message - # again and again. - ts = conversion.to_datetime(message.ts) - return SensorDataSamples( - sensor_id=SensorId(message.id), - metrics=[ - sensor_metric_sample_from_proto(ts, sample) - for sample in message.sensor.data.sensor_data - if metrics is None or sample.sensor_metric in metrics - ], - states=[sensor_state_sample_from_proto(ts, message.sensor)], - ) - - -def sensor_metric_sample_from_proto( - sampled_at: datetime, message: sensor_pb2.SensorData -) -> SensorMetricSample: - """Convert a protobuf message to a `SensorMetricSample` object. - - Args: - sampled_at: The time at which the sample was taken. - message: The protobuf message to convert. - - Returns: - The resulting `SensorMetricSample` object. - """ - return SensorMetricSample( - sampled_at=sampled_at, - metric=enum_from_proto(message.sensor_metric, SensorMetric), - value=message.value, - ) - - -def sensor_state_sample_from_proto( - sampled_at: datetime, message: sensor_pb2.Sensor -) -> SensorStateSample: - """Convert a protobuf message to a `SensorStateSample` object. - - Args: - sampled_at: The time at which the sample was taken. - message: The protobuf message to convert. - - Returns: - The resulting `SensorStateSample` object. - """ - # In v0.15 the enum has 3 values, UNSPECIFIED, OK, and ERROR. In v0.17 - # (common v0.6), it also have 3 values with the same tags, but OK is renamed - # to ON, so this conversion should work fine for both versions. - state = enum_from_proto(message.state.component_state, SensorStateCode) - errors: set[SensorErrorCode | int] = set() - warnings: set[SensorErrorCode | int] = set() - for error in message.errors: - match error.level: - case common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL: - errors.add(enum_from_proto(error.code, SensorErrorCode)) - case common_pb2.ErrorLevel.ERROR_LEVEL_WARN: - warnings.add(enum_from_proto(error.code, SensorErrorCode)) - case _: - # If we don´t know the level we treat it as an error just to be safe. - errors.add(enum_from_proto(error.code, SensorErrorCode)) - - return SensorStateSample( - sampled_at=sampled_at, - states=frozenset([state]), - warnings=frozenset(warnings), - errors=frozenset(errors), - ) diff --git a/src/frequenz/client/microgrid/sensor.py b/src/frequenz/client/microgrid/sensor.py deleted file mode 100644 index a2a1ba4f..00000000 --- a/src/frequenz/client/microgrid/sensor.py +++ /dev/null @@ -1,234 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Microgrid sensors. - -This package provides classes and utilities for working with different types of -sensors in a microgrid environment. [`Sensor`][frequenz.client.microgrid.sensor.Sensor]s -measure various physical metrics in the surrounding environment, such as temperature, -humidity, and solar irradiance. - -# Streaming Sensor Data Samples - -This package also provides several data structures for handling sensor readings -and states: - -* [`SensorDataSamples`][frequenz.client.microgrid.sensor.SensorDataSamples]: - Represents a collection of sensor data samples. -* [`SensorErrorCode`][frequenz.client.microgrid.sensor.SensorErrorCode]: - Defines error codes that a sensor can report. -* [`SensorMetric`][frequenz.client.microgrid.sensor.SensorMetric]: Enumerates - the different metrics a sensor can measure (e.g., temperature, voltage). -* [`SensorMetricSample`][frequenz.client.microgrid.sensor.SensorMetricSample]: - Represents a single sample of a sensor metric, including its value and - timestamp. -* [`SensorStateCode`][frequenz.client.microgrid.sensor.SensorStateCode]: - Defines codes representing the operational state of a sensor. -* [`SensorStateSample`][frequenz.client.microgrid.sensor.SensorStateSample]: - Represents a single sample of a sensor's state, including its state code - and timestamp. -""" - -import dataclasses -import enum -from dataclasses import dataclass -from datetime import datetime -from typing import assert_never - -from frequenz.api.microgrid import sensor_pb2 -from frequenz.client.common.microgrid.sensors import SensorId - -from ._lifetime import Lifetime -from .metrics import AggregatedMetricValue, AggregationMethod - - -@dataclasses.dataclass(frozen=True, kw_only=True) -class Sensor: - """Measures environmental metrics in the microgrid.""" - - id: SensorId - """This sensor's ID.""" - - name: str | None = None - """The name of this sensor.""" - - manufacturer: str | None = None - """The manufacturer of this sensor.""" - - model_name: str | None = None - """The model name of this sensor.""" - - operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime) - """The operational lifetime of this sensor.""" - - @property - def identity(self) -> SensorId: - """The identity of this sensor. - - This uses the sensor ID to identify a sensor without considering the - other attributes, so even if a sensor state changed, the identity - remains the same. - """ - return self.id - - def __str__(self) -> str: - """Return a human-readable string representation of this instance.""" - name = f":{self.name}" if self.name else "" - return f"<{type(self).__name__}:{self.id}{name}>" - - -@enum.unique -class SensorMetric(enum.Enum): - """The metrics that can be reported by sensors in the microgrid. - - These metrics correspond to various sensor readings primarily related to - environmental conditions and physical measurements. - """ - - UNSPECIFIED = sensor_pb2.SENSOR_METRIC_UNSPECIFIED - """Default value (this should not be normally used and usually indicates an issue).""" - - TEMPERATURE = sensor_pb2.SENSOR_METRIC_TEMPERATURE - """Temperature, in Celsius (°C).""" - - HUMIDITY = sensor_pb2.SENSOR_METRIC_HUMIDITY - """Humidity, in percentage (%).""" - - PRESSURE = sensor_pb2.SENSOR_METRIC_PRESSURE - """Pressure, in Pascal (Pa).""" - - IRRADIANCE = sensor_pb2.SENSOR_METRIC_IRRADIANCE - """Irradiance / Radiation flux, in watts per square meter (W / m²).""" - - VELOCITY = sensor_pb2.SENSOR_METRIC_VELOCITY - """Velocity, in meters per second (m / s).""" - - ACCELERATION = sensor_pb2.SENSOR_METRIC_ACCELERATION - """Acceleration in meters per second per second (m / s²).""" - - ANGLE = sensor_pb2.SENSOR_METRIC_ANGLE - """Angle, in degrees with respect to the (magnetic) North (°).""" - - DEW_POINT = sensor_pb2.SENSOR_METRIC_DEW_POINT - """Dew point, in Celsius (°C). - - The temperature at which the air becomes saturated with water vapor. - """ - - -@enum.unique -class SensorStateCode(enum.Enum): - """The various states that a sensor can be in.""" - - UNSPECIFIED = sensor_pb2.COMPONENT_STATE_UNSPECIFIED - """Default value (this should not be normally used and usually indicates an issue).""" - - ON = sensor_pb2.COMPONENT_STATE_OK - """The sensor is up and running.""" - - ERROR = sensor_pb2.COMPONENT_STATE_ERROR - """The sensor is in an error state.""" - - -@enum.unique -class SensorErrorCode(enum.Enum): - """The various errors that can occur in sensors.""" - - UNSPECIFIED = sensor_pb2.ERROR_CODE_UNSPECIFIED - """Default value (this should not be normally used and usually indicates an issue).""" - - -@dataclass(frozen=True, kw_only=True) -class SensorStateSample: - """A sample of state, warnings, and errors for a sensor at a specific time.""" - - sampled_at: datetime - """The time at which this state was sampled.""" - - states: frozenset[SensorStateCode | int] - """The set of states of the sensor. - - If the reported state is not known by the client (it could happen when using an - older version of the client with a newer version of the server), it will be - represented as an `int` and **not** the - [`SensorStateCode.UNSPECIFIED`][frequenz.client.microgrid.sensor.SensorStateCode.UNSPECIFIED] - value (this value is used only when the state is not known by the server). - """ - - warnings: frozenset[SensorErrorCode | int] - """The set of warnings for the sensor.""" - - errors: frozenset[SensorErrorCode | int] - """The set of errors for the sensor. - - This set will only contain errors if the sensor is in an error state. - """ - - -@dataclass(frozen=True, kw_only=True) -class SensorMetricSample: - """A sample of a sensor metric at a specific time. - - This represents a single sample of a specific metric, the value of which is either - measured at a particular time. - """ - - sampled_at: datetime - """The moment when the metric was sampled.""" - - metric: SensorMetric | int - """The metric that was sampled.""" - - # In the protocol this is float | AggregatedMetricValue, but for live data we can't - # receive the AggregatedMetricValue, so we limit this to float for now. - value: float | AggregatedMetricValue | None - """The value of the sampled metric.""" - - def as_single_value( - self, *, aggregation_method: AggregationMethod = AggregationMethod.AVG - ) -> float | None: - """Return the value of this sample as a single value. - - if [`value`][frequenz.client.microgrid.sensor.SensorMetricSample.value] is a `float`, - it is returned as is. If `value` is an - [`AggregatedMetricValue`][frequenz.client.microgrid.metrics.AggregatedMetricValue], - the value is aggregated using the provided `aggregation_method`. - - Args: - aggregation_method: The method to use to aggregate the value when `value` is - a `AggregatedMetricValue`. - - Returns: - The value of the sample as a single value, or `None` if the value is `None`. - """ - match self.value: - case float() | int(): - return self.value - case AggregatedMetricValue(): - match aggregation_method: - case AggregationMethod.AVG: - return self.value.avg - case AggregationMethod.MIN: - return self.value.min - case AggregationMethod.MAX: - return self.value.max - case unexpected: - assert_never(unexpected) - case None: - return None - case unexpected: - assert_never(unexpected) - - -@dataclass(frozen=True, kw_only=True) -class SensorDataSamples: - """An aggregate of multiple metrics, states, and errors of a sensor.""" - - sensor_id: SensorId - """The unique identifier of the sensor.""" - - metrics: list[SensorMetricSample] - """The metrics sampled from the sensor.""" - - states: list[SensorStateSample] - """The states sampled from the sensor.""" diff --git a/tests/client_test_cases/get_microgrid_info/defaults_case.py b/tests/client_test_cases/get_microgrid_info/defaults_case.py new file mode 100644 index 00000000..5776eef8 --- /dev/null +++ b/tests/client_test_cases/get_microgrid_info/defaults_case.py @@ -0,0 +1,42 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for successful microgrid info retrieval.""" + +from datetime import datetime, timezone +from typing import Any +from unittest.mock import AsyncMock + +from frequenz.api.common.v1.microgrid import microgrid_pb2 as microgrid_common_pb2 +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.common.microgrid import EnterpriseId, MicrogridId +from google.protobuf.empty_pb2 import Empty + +from frequenz.client.microgrid import MicrogridInfo, MicrogridStatus + +# No client_args or client_kwargs needed for this call + + +def assert_stub_method_call(stub_method: AsyncMock) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with(Empty(), timeout=60.0) + + +create_timestamp = datetime(2023, 1, 1, tzinfo=timezone.utc) +grpc_response = microgrid_pb2.GetMicrogridMetadataResponse( + microgrid=microgrid_common_pb2.Microgrid() +) + + +def assert_client_result(result: Any) -> None: + """Assert that the client result matches the expected MicrogridInfo.""" + assert result == MicrogridInfo( + id=MicrogridId(0), + enterprise_id=EnterpriseId(0), + name=None, + status=MicrogridStatus.UNSPECIFIED, + location=None, + delivery_area=None, + create_timestamp=datetime(1970, 1, 1, 0, 0, tzinfo=timezone.utc), + ) + assert result.is_active diff --git a/tests/client_test_cases/get_microgrid_info/error_case.py b/tests/client_test_cases/get_microgrid_info/error_case.py new file mode 100644 index 00000000..ed42f25c --- /dev/null +++ b/tests/client_test_cases/get_microgrid_info/error_case.py @@ -0,0 +1,28 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for microgrid info retrieval with error.""" + +from typing import Any + +from google.protobuf.empty_pb2 import Empty +from grpc import StatusCode + +from frequenz.client.microgrid import PermissionDenied +from tests.util import make_grpc_error + +# No client_args or client_kwargs needed for this call + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with(Empty(), timeout=60.0) + + +grpc_response = make_grpc_error(StatusCode.PERMISSION_DENIED) + + +def assert_client_exception(exception: Exception) -> None: + """Assert that the client exception matches the expected error.""" + assert isinstance(exception, PermissionDenied) + assert exception.grpc_error == grpc_response diff --git a/tests/client_test_cases/get_microgrid_info/full_case.py b/tests/client_test_cases/get_microgrid_info/full_case.py new file mode 100644 index 00000000..4f1e9f98 --- /dev/null +++ b/tests/client_test_cases/get_microgrid_info/full_case.py @@ -0,0 +1,71 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for successful microgrid info retrieval.""" + +from datetime import datetime, timezone +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from frequenz.api.common.v1 import location_pb2 +from frequenz.api.common.v1.grid import delivery_area_pb2 +from frequenz.api.common.v1.microgrid import microgrid_pb2 as microgrid_common_pb2 +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.base.conversion import to_timestamp +from frequenz.client.common.microgrid import EnterpriseId, MicrogridId +from google.protobuf.empty_pb2 import Empty + +from frequenz.client.microgrid import ( + DeliveryArea, + EnergyMarketCodeType, + Location, + MicrogridInfo, + MicrogridStatus, +) + +# No client_args or client_kwargs needed for this call + + +def assert_stub_method_call(stub_method: AsyncMock) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with(Empty(), timeout=60.0) + + +create_timestamp = datetime(2023, 1, 1, tzinfo=timezone.utc) +grpc_response = microgrid_pb2.GetMicrogridMetadataResponse( + microgrid=microgrid_common_pb2.Microgrid( + id=1234, + enterprise_id=5678, + name="Test Microgrid", + delivery_area=delivery_area_pb2.DeliveryArea( + code="Test Delivery Area", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC, + ), + location=location_pb2.Location( + latitude=37.7749, longitude=-122.4194, country_code="DE" + ), + status=microgrid_common_pb2.MICROGRID_STATUS_INACTIVE, + create_timestamp=to_timestamp(create_timestamp), + ) +) + + +def assert_client_result(result: Any) -> None: + """Assert that the client result matches the expected MicrogridInfo.""" + assert result == MicrogridInfo( + id=MicrogridId(1234), + enterprise_id=EnterpriseId(5678), + name="Test Microgrid", + delivery_area=DeliveryArea( + code="Test Delivery Area", code_type=EnergyMarketCodeType.EUROPE_EIC + ), + location=Location( + latitude=pytest.approx(37.7749), # type: ignore[arg-type] + longitude=pytest.approx(-122.4194), # type: ignore[arg-type] + country_code="DE", + ), + status=MicrogridStatus.INACTIVE, + create_timestamp=datetime(2023, 1, 1, tzinfo=timezone.utc), + ) + assert not result.is_active diff --git a/tests/test_client.py b/tests/test_client.py index d1029839..b77ed8ff 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,1086 +1,98 @@ # License: MIT # Copyright © 2022 Frequenz Energy-as-a-Service GmbH -"""Tests for the microgrid client thin wrapper.""" +"""Tests for the MicrogridApiClient class.""" -# We are going to split these tests in the future, but for now... -# pylint: disable=too-many-lines +from __future__ import annotations -import logging from collections.abc import AsyncIterator -from datetime import datetime, timezone -from typing import Any -from unittest import mock +from pathlib import Path -import grpc.aio import pytest -from frequenz.api.common import components_pb2, metrics_pb2 -from frequenz.api.microgrid import grid_pb2, inverter_pb2, microgrid_pb2, sensor_pb2 -from frequenz.client.base import conversion, retry -from frequenz.client.common.microgrid import MicrogridId -from frequenz.client.common.microgrid.components import ComponentId -from frequenz.client.common.microgrid.sensors import SensorId -from google.protobuf.empty_pb2 import Empty +from frequenz.api.microgrid.v1 import microgrid_pb2_grpc +from frequenz.client.base.channel import ChannelOptions, KeepAliveOptions, SslOptions +from frequenz.client.base.retry import LinearBackoff from frequenz.client.microgrid import ( - ApiClientError, - BatteryData, - Component, - ComponentCategory, - ComponentData, - Connection, - EVChargerData, - Fuse, - GridMetadata, - InverterData, - InverterType, - MeterData, + DEFAULT_CHANNEL_OPTIONS, + ClientNotConnected, MicrogridApiClient, ) -from frequenz.client.microgrid.sensor import ( - Sensor, - SensorDataSamples, - SensorMetric, - SensorMetricSample, - SensorStateCode, - SensorStateSample, -) - - -class _TestClient(MicrogridApiClient): - def __init__(self, *, retry_strategy: retry.Strategy | None = None) -> None: - # Here we sadly can't use spec=MicrogridStub because the generated stub typing - # is a mess, and for some reason inspection of gRPC methods doesn't work. - # This is also why we need to explicitly create the AsyncMock objects for every - # call. - mock_stub = mock.MagicMock(name="stub") - mock_stub.ListComponents = mock.AsyncMock("ListComponents") - mock_stub.ListConnections = mock.AsyncMock("ListConnections") - mock_stub.SetPowerActive = mock.AsyncMock("SetPowerActive") - mock_stub.SetPowerReactive = mock.AsyncMock("SetPowerReactive") - mock_stub.AddInclusionBounds = mock.AsyncMock("AddInclusionBounds") - mock_stub.StreamComponentData = mock.Mock("StreamComponentData") - mock_stub.GetMicrogridMetadata = mock.AsyncMock("GetMicrogridMetadata") - super().__init__("grpc://mock_host:1234", retry_strategy=retry_strategy) - self.mock_stub = mock_stub - self._stub = mock_stub # pylint: disable=protected-access - - -@pytest.fixture -async def client() -> AsyncIterator[_TestClient]: - """Return a test client.""" - async with _TestClient( - retry_strategy=retry.LinearBackoff(interval=0.0, jitter=0.0, limit=6) - ) as client_instance: - yield client_instance - - -async def test_components(client: _TestClient) -> None: - """Test the components() method.""" - server_response = microgrid_pb2.ComponentList() - client.mock_stub.ListComponents.return_value = server_response - assert set(await client.components()) == set() - - server_response.components.append( - microgrid_pb2.Component( - id=0, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER - ) - ) - assert set(await client.components()) == { - Component(ComponentId(0), ComponentCategory.METER) - } - - server_response.components.append( - microgrid_pb2.Component( - id=0, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY - ) - ) - assert set(await client.components()) == { - Component(ComponentId(0), ComponentCategory.METER), - Component(ComponentId(0), ComponentCategory.BATTERY), - } - - server_response.components.append( - microgrid_pb2.Component( - id=0, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER - ) - ) - assert set(await client.components()) == { - Component(ComponentId(0), ComponentCategory.METER), - Component(ComponentId(0), ComponentCategory.BATTERY), - Component(ComponentId(0), ComponentCategory.METER), - } - - # sensors are not counted as components by the API client - server_response.components.append( - microgrid_pb2.Component( - id=1, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR - ) - ) - assert set(await client.components()) == { - Component(ComponentId(0), ComponentCategory.METER), - Component(ComponentId(0), ComponentCategory.BATTERY), - Component(ComponentId(0), ComponentCategory.METER), - } - - _replace_components( - server_response, - [ - microgrid_pb2.Component( - id=9, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER - ), - microgrid_pb2.Component( - id=99, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, - ), - microgrid_pb2.Component( - id=666, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - ), - microgrid_pb2.Component( - id=999, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, - ), - ], - ) - assert set(await client.components()) == { - Component(ComponentId(9), ComponentCategory.METER), - Component(ComponentId(99), ComponentCategory.INVERTER, InverterType.NONE), - Component(ComponentId(999), ComponentCategory.BATTERY), - } - - _replace_components( - server_response, - [ - microgrid_pb2.Component( - id=99, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - ), - microgrid_pb2.Component( - id=100, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED, - ), - microgrid_pb2.Component( - id=104, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER, - ), - microgrid_pb2.Component( - id=105, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, - ), - microgrid_pb2.Component( - id=106, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, - ), - microgrid_pb2.Component( - id=107, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER, - ), - microgrid_pb2.Component( - id=999, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - ), - microgrid_pb2.Component( - id=101, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_GRID, - grid=grid_pb2.Metadata(rated_fuse_current=int(123.0)), - ), - ], - ) - - grid_fuse = Fuse(123.0) - - assert set(await client.components()) == { - Component(ComponentId(100), ComponentCategory.NONE), - Component( - ComponentId(101), - ComponentCategory.GRID, - None, - GridMetadata(fuse=grid_fuse), - ), - Component(ComponentId(104), ComponentCategory.METER), - Component(ComponentId(105), ComponentCategory.INVERTER, InverterType.NONE), - Component(ComponentId(106), ComponentCategory.BATTERY), - Component(ComponentId(107), ComponentCategory.EV_CHARGER), - } - - _replace_components( - server_response, - [ - microgrid_pb2.Component( - id=9, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER - ), - microgrid_pb2.Component( - id=666, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - ), - microgrid_pb2.Component( - id=999, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, - ), - microgrid_pb2.Component( - id=99, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, - inverter=inverter_pb2.Metadata( - type=components_pb2.InverterType.INVERTER_TYPE_BATTERY - ), - ), - ], - ) - - assert set(await client.components()) == { - Component(ComponentId(9), ComponentCategory.METER), - Component(ComponentId(99), ComponentCategory.INVERTER, InverterType.BATTERY), - Component(ComponentId(999), ComponentCategory.BATTERY), - } - - -async def test_components_grpc_error(client: _TestClient) -> None: - """Test the components() method when the gRPC call fails.""" - client.mock_stub.ListComponents.side_effect = grpc.aio.AioRpcError( - mock.MagicMock(name="mock_status"), - mock.MagicMock(name="mock_initial_metadata"), - mock.MagicMock(name="mock_trailing_metadata"), - "fake grpc details", - "fake grpc debug_error_string", - ) - with pytest.raises( - ApiClientError, - match=r"Failed calling 'ListComponents' on 'grpc://mock_host:1234': .* " - r">: fake grpc details " - r"\(fake grpc debug_error_string\)", - ): - await client.components() - - -async def test_connections(client: _TestClient) -> None: - """Test the connections() method.""" - - def assert_filter(*, starts: set[int], ends: set[int]) -> None: - client.mock_stub.ListConnections.assert_called_once() - filter_ = client.mock_stub.ListConnections.call_args[0][0] - assert isinstance(filter_, microgrid_pb2.ConnectionFilter) - assert set(filter_.starts) == starts - assert set(filter_.ends) == ends - - components_response = microgrid_pb2.ComponentList() - connections_response = microgrid_pb2.ConnectionList() - client.mock_stub.ListComponents.return_value = components_response - client.mock_stub.ListConnections.return_value = connections_response - assert set(await client.connections()) == set() - assert_filter(starts=set(), ends=set()) - - connections_response.connections.append(microgrid_pb2.Connection(start=0, end=0)) - assert set(await client.connections()) == { - Connection(ComponentId(0), ComponentId(0)) - } - - components_response.components.extend( - [ - microgrid_pb2.Component( - id=7, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, - ), - microgrid_pb2.Component( - id=9, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER, - ), - ] - ) - connections_response.connections.append(microgrid_pb2.Connection(start=7, end=9)) - assert set(await client.connections()) == { - Connection(ComponentId(0), ComponentId(0)), - Connection(ComponentId(7), ComponentId(9)), - } - - connections_response.connections.append(microgrid_pb2.Connection(start=0, end=0)) - assert set(await client.connections()) == { - Connection(ComponentId(0), ComponentId(0)), - Connection(ComponentId(7), ComponentId(9)), - Connection(ComponentId(0), ComponentId(0)), - } - - _replace_connections( - connections_response, - [ - microgrid_pb2.Connection(start=999, end=9), - microgrid_pb2.Connection(start=99, end=19), - microgrid_pb2.Connection(start=909, end=101), - microgrid_pb2.Connection(start=99, end=91), - ], - ) - for component_id in [999, 99, 19, 909, 101, 91]: - components_response.components.append( - microgrid_pb2.Component( - id=component_id, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, - ) - ) - assert set(await client.connections()) == { - Connection(ComponentId(999), ComponentId(9)), - Connection(ComponentId(99), ComponentId(19)), - Connection(ComponentId(909), ComponentId(101)), - Connection(ComponentId(99), ComponentId(91)), - } - - for component_id in [1, 2, 3, 4, 5, 6, 7, 8]: - components_response.components.append( - microgrid_pb2.Component( - id=component_id, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY, - ) - ) - _replace_connections( - connections_response, - [ - microgrid_pb2.Connection(start=1, end=2), - microgrid_pb2.Connection(start=2, end=3), - microgrid_pb2.Connection(start=2, end=4), - microgrid_pb2.Connection(start=2, end=5), - microgrid_pb2.Connection(start=4, end=3), - microgrid_pb2.Connection(start=4, end=5), - microgrid_pb2.Connection(start=4, end=6), - microgrid_pb2.Connection(start=5, end=4), - microgrid_pb2.Connection(start=5, end=7), - microgrid_pb2.Connection(start=5, end=8), - ], - ) - assert set(await client.connections()) == { - Connection(ComponentId(1), ComponentId(2)), - Connection(ComponentId(2), ComponentId(3)), - Connection(ComponentId(2), ComponentId(4)), - Connection(ComponentId(2), ComponentId(5)), - Connection(ComponentId(4), ComponentId(3)), - Connection(ComponentId(4), ComponentId(5)), - Connection(ComponentId(4), ComponentId(6)), - Connection(ComponentId(5), ComponentId(4)), - Connection(ComponentId(5), ComponentId(7)), - Connection(ComponentId(5), ComponentId(8)), - } - - # passing empty sets is the same as passing `None`, - # filter is ignored - client.mock_stub.reset_mock() - await client.connections(starts=set(), ends=set()) - assert_filter(starts=set(), ends=set()) - - # include filter for connection start - client.mock_stub.reset_mock() - await client.connections(starts={ComponentId(1), ComponentId(2)}) - assert_filter(starts={1, 2}, ends=set()) - - client.mock_stub.reset_mock() - await client.connections(starts={ComponentId(2)}) - assert_filter(starts={2}, ends=set()) - - # include filter for connection end - client.mock_stub.reset_mock() - await client.connections(ends={ComponentId(1)}) - assert_filter(starts=set(), ends={1}) - - client.mock_stub.reset_mock() - await client.connections(ends={ComponentId(2), ComponentId(4), ComponentId(5)}) - assert_filter(starts=set(), ends={2, 4, 5}) - - # different filters combine with AND logic - client.mock_stub.reset_mock() - await client.connections( - starts={ComponentId(1), ComponentId(2), ComponentId(4)}, - ends={ComponentId(4), ComponentId(5), ComponentId(6)}, - ) - assert_filter(starts={1, 2, 4}, ends={4, 5, 6}) - - -async def test_connections_grpc_error(client: _TestClient) -> None: - """Test the components() method when the gRPC call fails.""" - client.mock_stub.ListConnections.side_effect = grpc.aio.AioRpcError( - mock.MagicMock(name="mock_status"), - mock.MagicMock(name="mock_initial_metadata"), - mock.MagicMock(name="mock_trailing_metadata"), - "fake grpc details", - "fake grpc debug_error_string", - ) - with pytest.raises( - ApiClientError, - match=r"Failed calling 'ListConnections' on 'grpc://mock_host:1234': .* " - r">: fake grpc details " - r"\(fake grpc debug_error_string\)", - ): - await client.connections() - - -async def test_metadata_success(client: _TestClient) -> None: - """Test the metadata() method with a successful gRPC call.""" - mock_metadata_response = microgrid_pb2.MicrogridMetadata( - microgrid_id=123, - location=microgrid_pb2.Location(latitude=40.7128, longitude=-74.0060), - ) - client.mock_stub.GetMicrogridMetadata.return_value = mock_metadata_response - - metadata = await client.metadata() - - assert metadata.microgrid_id == MicrogridId(123) - assert metadata.location is not None - assert metadata.location.latitude == pytest.approx(40.7128) - assert metadata.location.longitude == pytest.approx(-74.0060) - client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60) - - -async def test_metadata_no_location(client: _TestClient) -> None: - """Test the metadata() method when location is not set in the response.""" - mock_metadata_response = microgrid_pb2.MicrogridMetadata(microgrid_id=456) - client.mock_stub.GetMicrogridMetadata.return_value = mock_metadata_response - - metadata = await client.metadata() - - assert metadata.microgrid_id == MicrogridId(456) - assert metadata.location is None - client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60) - - -async def test_metadata_empty_response(client: _TestClient) -> None: - """Test the metadata() method when the server returns an empty response.""" - client.mock_stub.GetMicrogridMetadata.return_value = None - - metadata = await client.metadata() - - assert metadata.microgrid_id is None - assert metadata.location is None - client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60) - - -async def test_metadata_grpc_error( - client: _TestClient, caplog: pytest.LogCaptureFixture -) -> None: - """Test the metadata() method when the gRPC call fails.""" - caplog.set_level(logging.WARNING) - client.mock_stub.GetMicrogridMetadata.side_effect = grpc.aio.AioRpcError( - mock.MagicMock(name="mock_status"), - mock.MagicMock(name="mock_initial_metadata"), - mock.MagicMock(name="mock_trailing_metadata"), - "fake grpc details for metadata", - "fake grpc debug_error_string for metadata", - ) - - metadata = await client.metadata() - - assert metadata.microgrid_id is None - assert metadata.location is None - client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60) - assert len(caplog.records) == 1 - assert caplog.records[0].levelname == "ERROR" - assert "The microgrid metadata is not available." in caplog.records[0].message - assert caplog.records[0].exc_text is not None - assert "fake grpc details for metadata" in caplog.records[0].exc_text - - -async def test_list_sensors(client: _TestClient) -> None: - """Test the list_sensors() method.""" - server_response = microgrid_pb2.ComponentList() - client.mock_stub.ListComponents.return_value = server_response - assert set(await client.list_sensors()) == set() - - # Add a sensor - sensor_component = microgrid_pb2.Component( - id=201, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - sensor=sensor_pb2.Metadata( - type=components_pb2.SensorType.SENSOR_TYPE_ACCELEROMETER, - ), - ) - server_response.components.append(sensor_component) - assert set(await client.list_sensors()) == { - Sensor(id=SensorId(201)), - } - - # Add another sensor - sensor_component_2 = microgrid_pb2.Component( - id=202, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - sensor=sensor_pb2.Metadata( - type=components_pb2.SensorType.SENSOR_TYPE_HYGROMETER - ), - ) - server_response.components.append(sensor_component_2) - assert set(await client.list_sensors()) == { - Sensor(id=SensorId(201)), - Sensor(id=SensorId(202)), - } - - # Add a non-sensor component to the mock response from ListSensors - # The client.list_sensors() method should filter this out if it's robust, - # or the ListSensors RPC itself should only return sensor components. - meter_component = microgrid_pb2.Component( - id=203, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER - ) - server_response.components.append(meter_component) - # Assert that only SENSOR category components are returned by client.list_sensors() - assert set(await client.list_sensors()) == { - Sensor(id=SensorId(201)), - Sensor(id=SensorId(202)), - Sensor(id=SensorId(203)), - } - # Clean up: remove the meter component from the mock response - server_response.components.pop() - - _replace_components( - server_response, - [ - microgrid_pb2.Component( - id=204, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - sensor=sensor_pb2.Metadata( - type=components_pb2.SensorType.SENSOR_TYPE_ANEMOMETER - ), - ), - microgrid_pb2.Component( - id=205, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - sensor=sensor_pb2.Metadata( - type=components_pb2.SensorType.SENSOR_TYPE_PYRANOMETER - ), - ), - ], - ) - assert set(await client.list_sensors()) == { - Sensor(id=SensorId(204)), - Sensor(id=SensorId(205)), - } - - -async def test_list_sensors_grpc_error(client: _TestClient) -> None: - """Test the list_sensors() method when the gRPC call fails.""" - client.mock_stub.GetMicrogridMetadata.return_value = ( - microgrid_pb2.MicrogridMetadata(microgrid_id=101) - ) - client.mock_stub.ListComponents.side_effect = grpc.aio.AioRpcError( - mock.MagicMock(name="mock_status"), - mock.MagicMock(name="mock_initial_metadata"), - mock.MagicMock(name="mock_trailing_metadata"), - "fake grpc details", - "fake grpc debug_error_string", - ) - with pytest.raises( - ApiClientError, - match=r"Failed calling 'ListComponents' on 'grpc://mock_host:1234': .* " - r">: fake grpc details " - r"\(fake grpc debug_error_string\)", - ): - await client.list_sensors() - - -@pytest.fixture -def meter83() -> microgrid_pb2.Component: - """Return a test meter component.""" - return microgrid_pb2.Component( - id=83, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER - ) - - -@pytest.fixture -def battery38() -> microgrid_pb2.Component: - """Return a test battery component.""" - return microgrid_pb2.Component( - id=38, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY - ) - - -@pytest.fixture -def inverter99() -> microgrid_pb2.Component: - """Return a test inverter component.""" - return microgrid_pb2.Component( - id=99, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER - ) +from .util import ApiClientTestCaseSpec, get_test_specs, patch_client_class -@pytest.fixture -def ev_charger101() -> microgrid_pb2.Component: - """Return a test EV charger component.""" - return microgrid_pb2.Component( - id=101, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER - ) +# pylint: disable=protected-access - -@pytest.fixture -def sensor201() -> microgrid_pb2.Component: - """Return a test sensor component.""" - return microgrid_pb2.Component( - id=201, - category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, - sensor=sensor_pb2.Metadata( - type=components_pb2.SensorType.SENSOR_TYPE_THERMOMETER - ), - ) +TESTS_DIR = Path(__file__).parent / "client_test_cases" @pytest.fixture -def component_list( - meter83: microgrid_pb2.Component, - battery38: microgrid_pb2.Component, - inverter99: microgrid_pb2.Component, - ev_charger101: microgrid_pb2.Component, - sensor201: microgrid_pb2.Component, -) -> list[microgrid_pb2.Component]: - """Return a list of test components.""" - return [meter83, battery38, inverter99, ev_charger101, sensor201] - - -@pytest.mark.parametrize("method", ["meter_data", "battery_data", "inverter_data"]) -async def test_data_component_not_found(method: str, client: _TestClient) -> None: - """Test the meter_data() method.""" - client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList() - - # It should raise a ValueError for a missing component_id - with pytest.raises(ValueError, match="Unable to find CID20"): - await getattr(client, method)(ComponentId(20)) - - -@pytest.mark.parametrize( - "method, component_id", - [ - ("meter_data", ComponentId(38)), - ("battery_data", ComponentId(83)), - ("inverter_data", ComponentId(83)), - ("ev_charger_data", ComponentId(99)), - ], -) -async def test_data_bad_category( - method: str, - component_id: ComponentId, - component_list: list[microgrid_pb2.Component], - client: _TestClient, -) -> None: - """Test the meter_data() method.""" - client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( - components=component_list - ) - - # It should raise a ValueError for a wrong component category - with pytest.raises( - ValueError, match=f"{component_id} is a .*, not a {method[:-5]}" - ): - await getattr(client, method)(component_id) - - -@pytest.mark.parametrize( - "method, component_id, component_class", - [ - ("meter_data", ComponentId(83), MeterData), - ("battery_data", ComponentId(38), BatteryData), - ("inverter_data", ComponentId(99), InverterData), - ("ev_charger_data", ComponentId(101), EVChargerData), - ], -) -async def test_component_data( - method: str, - component_id: ComponentId, - component_class: type[ComponentData], - component_list: list[microgrid_pb2.Component], - client: _TestClient, -) -> None: - """Test the meter_data() method.""" - client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( - components=component_list - ) - - async def stream_data( - *args: Any, **kwargs: Any # pylint: disable=unused-argument - ) -> AsyncIterator[microgrid_pb2.ComponentData]: - yield microgrid_pb2.ComponentData(id=int(component_id)) - - client.mock_stub.StreamComponentData.side_effect = stream_data - receiver = await getattr(client, method)(component_id) - latest = await receiver.receive() - assert isinstance(latest, component_class) - assert latest.component_id == component_id - - -@pytest.mark.parametrize( - "method, component_id, component_class", - [ - ("meter_data", ComponentId(83), MeterData), - ("battery_data", ComponentId(38), BatteryData), - ("inverter_data", ComponentId(99), InverterData), - ("ev_charger_data", ComponentId(101), EVChargerData), - ], -) -# pylint: disable-next=too-many-arguments,too-many-positional-arguments -async def test_component_data_grpc_error( - method: str, - component_id: ComponentId, - component_class: type[ComponentData], - component_list: list[microgrid_pb2.Component], - caplog: pytest.LogCaptureFixture, - client: _TestClient, -) -> None: - """Test the components() method when the gRPC call fails.""" - caplog.set_level(logging.WARNING) - client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( - components=component_list - ) - - num_calls = 0 - - async def stream_data( - *args: Any, **kwargs: Any # pylint: disable=unused-argument - ) -> AsyncIterator[microgrid_pb2.ComponentData]: - nonlocal num_calls - num_calls += 1 - if num_calls % 2: - raise grpc.aio.AioRpcError( - mock.MagicMock(name="mock_status"), - mock.MagicMock(name="mock_initial_metadata"), - mock.MagicMock(name="mock_trailing_metadata"), - f"fake grpc details num_calls={num_calls}", - "fake grpc debug_error_string", - ) - yield microgrid_pb2.ComponentData(id=int(component_id)) - - client.mock_stub.StreamComponentData.side_effect = stream_data - receiver = await getattr(client, method)(component_id) - latest = await receiver.receive() - assert isinstance(latest, component_class) - assert latest.component_id == component_id - - latest = await receiver.receive() - assert isinstance(latest, component_class) - assert latest.component_id == component_id - - latest = await receiver.receive() - assert isinstance(latest, component_class) - assert latest.component_id == component_id - - # This is not super portable, it will change if the GrpcStreamBroadcaster changes, - # but without this there isn't much to check by this test. - assert len(caplog.record_tuples) == 6 - for n, log_tuple in enumerate(caplog.record_tuples): - assert log_tuple[0] == "frequenz.client.base.streaming" - assert log_tuple[1] == logging.WARNING - assert ( - f"raw-component-data-{component_id}: connection ended, retrying" - in log_tuple[2] +async def client() -> AsyncIterator[MicrogridApiClient]: + """Fixture that provides a MicrogridApiClient with a mock gRPC stub and channel.""" + with patch_client_class(MicrogridApiClient, microgrid_pb2_grpc.MicrogridStub): + client = MicrogridApiClient( + "grpc://localhost:1234", + # Retry very fast to avoid long test times, and also not too many + # times to avoid test hanging forever. + retry_strategy=LinearBackoff(interval=0.0, jitter=0.0, limit=10), ) - if n % 2: - assert "Stream exhausted" in log_tuple[2] - else: - assert f"fake grpc details num_calls={n+1}" in log_tuple[2] + async with client: + yield client -@pytest.mark.parametrize("power_w", [0, 0.0, 12, -75, 0.1, -0.0001, 134.0]) -async def test_set_power_ok( - power_w: float, meter83: microgrid_pb2.Component, client: _TestClient -) -> None: - """Test if charge is able to charge component.""" - client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( - components=[meter83] - ) - - component_id = ComponentId(83) - await client.set_power(component_id=component_id, power_w=power_w) - client.mock_stub.SetPowerActive.assert_called_once() - call_args = client.mock_stub.SetPowerActive.call_args[0] - assert call_args[0] == microgrid_pb2.SetPowerActiveParam( - component_id=int(component_id), power=power_w - ) +@patch_client_class(MicrogridApiClient, microgrid_pb2_grpc.MicrogridStub) +def test_init_defaults() -> None: + """Test that MicrogridApiClient initializes correctly with defaults (connected).""" + client = MicrogridApiClient("grpc://localhost:1234") + assert client.server_url == "grpc://localhost:1234" + assert client.is_connected is True + assert client.stub is not None + assert client.channel_defaults == DEFAULT_CHANNEL_OPTIONS + assert client._retry_strategy is None # pylint: disable=protected-access -async def test_set_power_grpc_error(client: _TestClient) -> None: - """Test set_power() raises ApiClientError when the gRPC call fails.""" - client.mock_stub.SetPowerActive.side_effect = grpc.aio.AioRpcError( - mock.MagicMock(name="mock_status"), - mock.MagicMock(name="mock_initial_metadata"), - mock.MagicMock(name="mock_trailing_metadata"), - "fake grpc details", - "fake grpc debug_error_string", - ) - with pytest.raises( - ApiClientError, - match=r"Failed calling 'SetPowerActive' on 'grpc://mock_host:1234': .* " - r">: fake grpc details " - r"\(fake grpc debug_error_string\)", - ): - await client.set_power(component_id=ComponentId(83), power_w=100.0) - +@patch_client_class(MicrogridApiClient, microgrid_pb2_grpc.MicrogridStub) +def test_init_not_connected() -> None: + """Test that MicrogridApiClient initializes correctly when not connected.""" + client = MicrogridApiClient("grpc://localhost:1234", connect=False) + assert client.server_url == "grpc://localhost:1234" + assert client.is_connected is False + with pytest.raises(ClientNotConnected) as excinfo: + _ = client.stub + assert "client is not connected" in str(excinfo.value).lower() + assert "grpc://localhost:1234" in str(excinfo.value) -@pytest.mark.parametrize( - "reactive_power_var", - [0, 0.0, 12, -75, 0.1, -0.0001, 134.0], -) -async def test_set_reactive_power_ok( - reactive_power_var: float, meter83: microgrid_pb2.Component, client: _TestClient -) -> None: - """Test if charge is able to charge component.""" - client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( - components=[meter83] - ) - component_id = ComponentId(83) - await client.set_reactive_power( - component_id=component_id, reactive_power_var=reactive_power_var - ) - client.mock_stub.SetPowerReactive.assert_called_once() - call_args = client.mock_stub.SetPowerReactive.call_args[0] - assert call_args[0] == microgrid_pb2.SetPowerReactiveParam( - component_id=int(component_id), power=reactive_power_var +@patch_client_class(MicrogridApiClient, microgrid_pb2_grpc.MicrogridStub) +def test_init_with_defaults() -> None: + """Test that MicrogridApiClient initializes correctly with custom defaults.""" + options = ChannelOptions( + ssl=SslOptions(enabled=False), + port=1234, + keep_alive=KeepAliveOptions(enabled=False), ) + assert options != DEFAULT_CHANNEL_OPTIONS + client = MicrogridApiClient("grpc://localhost:1234", channel_defaults=options) + assert client.channel_defaults == options -async def test_set_reactive_power_grpc_error(client: _TestClient) -> None: - """Test set_power() raises ApiClientError when the gRPC call fails.""" - client.mock_stub.SetPowerReactive.side_effect = grpc.aio.AioRpcError( - mock.MagicMock(name="mock_status"), - mock.MagicMock(name="mock_initial_metadata"), - mock.MagicMock(name="mock_trailing_metadata"), - "fake grpc details", - "fake grpc debug_error_string", +@patch_client_class(MicrogridApiClient, microgrid_pb2_grpc.MicrogridStub) +def test_init_with_custom_retry_strategy() -> None: + """Test that MicrogridApiClient initializes correctly with custom retry strategy.""" + retry_strategy = LinearBackoff(interval=0.1, jitter=0.1, limit=5) + client = MicrogridApiClient( + "grpc://localhost:1234", retry_strategy=retry_strategy, connect=False ) - with pytest.raises( - ApiClientError, - match=r"Failed calling 'SetPowerReactive' on 'grpc://mock_host:1234': .* " - r">: fake grpc details " - r"\(fake grpc debug_error_string\)", - ): - await client.set_reactive_power( - component_id=ComponentId(83), reactive_power_var=100.0 - ) + client._retry_strategy = retry_strategy # pylint: disable=protected-access @pytest.mark.parametrize( - "bounds", - [ - metrics_pb2.Bounds(lower=0.0, upper=0.0), - metrics_pb2.Bounds(lower=0.0, upper=2.0), - metrics_pb2.Bounds(lower=-10.0, upper=0.0), - metrics_pb2.Bounds(lower=-10.0, upper=2.0), - ], + "spec", + get_test_specs("get_microgrid_info", tests_dir=TESTS_DIR), ids=str, ) -async def test_set_bounds_ok( - bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component, client: _TestClient -) -> None: - """Test if charge is able to charge component.""" - client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( - components=[inverter99] - ) - - component_id = ComponentId(99) - await client.set_bounds(component_id, bounds.lower, bounds.upper) - client.mock_stub.AddInclusionBounds.assert_called_once() - call_args = client.mock_stub.AddInclusionBounds.call_args[0] - assert call_args[0] == microgrid_pb2.SetBoundsParam( - component_id=int(component_id), - target_metric=microgrid_pb2.SetBoundsParam.TargetMetric.TARGET_METRIC_POWER_ACTIVE, - bounds=bounds, - ) - - -@pytest.mark.parametrize( - "bounds", - [ - metrics_pb2.Bounds(lower=0.0, upper=-2.0), - metrics_pb2.Bounds(lower=10.0, upper=-2.0), - metrics_pb2.Bounds(lower=10.0, upper=0.0), - ], - ids=str, -) -async def test_set_bounds_fail( - bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component, client: _TestClient -) -> None: - """Test if charge is able to charge component.""" - client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( - components=[inverter99] - ) - - with pytest.raises(ValueError): - await client.set_bounds(ComponentId(99), bounds.lower, bounds.upper) - client.mock_stub.AddInclusionBounds.assert_not_called() - - -async def test_set_bounds_grpc_error(client: _TestClient) -> None: - """Test set_bounds() raises ApiClientError when the gRPC call fails.""" - client.mock_stub.AddInclusionBounds.side_effect = grpc.aio.AioRpcError( - mock.MagicMock(name="mock_status"), - mock.MagicMock(name="mock_initial_metadata"), - mock.MagicMock(name="mock_trailing_metadata"), - "fake grpc details", - "fake grpc debug_error_string", - ) - with pytest.raises( - ApiClientError, - match=r"Failed calling 'AddInclusionBounds' on 'grpc://mock_host:1234': .* " - r">: fake grpc details " - r"\(fake grpc debug_error_string\)", - ): - await client.set_bounds(ComponentId(99), 0.0, 100.0) - - -async def test_stream_sensor_data_one_metric( - sensor201: microgrid_pb2.Component, client: _TestClient -) -> None: - """Test successful streaming of sensor data.""" - now = datetime.now(timezone.utc) - - async def stream_data_impl( - *_: Any, **__: Any - ) -> AsyncIterator[microgrid_pb2.ComponentData]: - yield microgrid_pb2.ComponentData( - id=int(sensor201.id), - ts=conversion.to_timestamp(now), - sensor=sensor_pb2.Sensor( - state=sensor_pb2.State( - component_state=sensor_pb2.ComponentState.COMPONENT_STATE_OK - ), - data=sensor_pb2.Data( - sensor_data=[ - sensor_pb2.SensorData( - value=1.0, - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, - ), - sensor_pb2.SensorData( - value=2.0, - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_PRESSURE, - ), - ], - ), - ), - ) - - client.mock_stub.StreamComponentData.side_effect = stream_data_impl - receiver = client.stream_sensor_data( - SensorId(sensor201.id), [SensorMetric.TEMPERATURE] - ) - sample = await receiver.receive() - - assert isinstance(sample, SensorDataSamples) - assert int(sample.sensor_id) == sensor201.id - assert sample.states == [ - SensorStateSample( - sampled_at=now, - states=frozenset({SensorStateCode.ON}), - warnings=frozenset(), - errors=frozenset(), - ) - ] - assert sample.metrics == [ - SensorMetricSample(sampled_at=now, metric=SensorMetric.TEMPERATURE, value=1.0) - ] - - -async def test_stream_sensor_data_all_metrics( - sensor201: microgrid_pb2.Component, client: _TestClient -) -> None: - """Test successful streaming of sensor data.""" - now = datetime.now(timezone.utc) - - async def stream_data_impl( - *_: Any, **__: Any - ) -> AsyncIterator[microgrid_pb2.ComponentData]: - yield microgrid_pb2.ComponentData( - id=int(sensor201.id), - ts=conversion.to_timestamp(now), - sensor=sensor_pb2.Sensor( - state=sensor_pb2.State( - component_state=sensor_pb2.ComponentState.COMPONENT_STATE_OK - ), - data=sensor_pb2.Data( - sensor_data=[ - sensor_pb2.SensorData( - value=1.0, - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, - ), - sensor_pb2.SensorData( - value=2.0, - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_PRESSURE, - ), - ], - ), - ), - ) - - client.mock_stub.StreamComponentData.side_effect = stream_data_impl - receiver = client.stream_sensor_data(SensorId(sensor201.id)) - sample = await receiver.receive() - - assert isinstance(sample, SensorDataSamples) - assert int(sample.sensor_id) == sensor201.id - assert sample.states == [ - SensorStateSample( - sampled_at=now, - states=frozenset({SensorStateCode.ON}), - warnings=frozenset(), - errors=frozenset(), - ) - ] - assert sample.metrics == [ - SensorMetricSample(sampled_at=now, metric=SensorMetric.TEMPERATURE, value=1.0), - SensorMetricSample(sampled_at=now, metric=SensorMetric.PRESSURE, value=2.0), - ] - - -async def test_stream_sensor_data_grpc_error( - sensor201: microgrid_pb2.Component, caplog: pytest.LogCaptureFixture -) -> None: - """Test stream_sensor_data() when the gRPC call fails and retries.""" - caplog.set_level(logging.WARNING) - - num_calls = 0 - - async def stream_data_error_impl( - *_: Any, **__: Any - ) -> AsyncIterator[microgrid_pb2.ComponentData]: - nonlocal num_calls - num_calls += 1 - if num_calls <= 2: # Fail first two times - raise grpc.aio.AioRpcError( - mock.MagicMock(name="mock_status"), - mock.MagicMock(name="mock_initial_metadata"), - mock.MagicMock(name="mock_trailing_metadata"), - f"fake grpc details stream_sensor_data num_calls={num_calls}", - "fake grpc debug_error_string", - ) - # Succeed on the third call - yield microgrid_pb2.ComponentData(id=int(sensor201.id)) - - async with _TestClient( - retry_strategy=retry.LinearBackoff(interval=0.0, jitter=0.0, limit=3) - ) as client: - client.mock_stub.StreamComponentData.side_effect = stream_data_error_impl - receiver = client.stream_sensor_data( - SensorId(sensor201.id), [SensorMetric.TEMPERATURE] - ) - sample = await receiver.receive() # Should succeed after retries - - assert isinstance(sample, SensorDataSamples) - assert int(sample.sensor_id) == sensor201.id - - assert num_calls == 3 # Check that it was called 3 times (1 initial + 2 retries) - # Check log messages for retries - assert "connection ended, retrying" in caplog.text - assert "fake grpc details stream_sensor_data num_calls=1" in caplog.text - assert "fake grpc details stream_sensor_data num_calls=2" in caplog.text - - -def _clear_components(component_list: microgrid_pb2.ComponentList) -> None: - while component_list.components: - component_list.components.pop() - - -def _replace_components( - component_list: microgrid_pb2.ComponentList, - components: list[microgrid_pb2.Component], -) -> None: - _clear_components(component_list) - component_list.components.extend(components) - - -def _clear_connections(connection_list: microgrid_pb2.ConnectionList) -> None: - while connection_list.connections: - connection_list.connections.pop() - - -def _replace_connections( - connection_list: microgrid_pb2.ConnectionList, - connections: list[microgrid_pb2.Connection], +async def test_get_microgrid_info( + client: MicrogridApiClient, spec: ApiClientTestCaseSpec ) -> None: - _clear_connections(connection_list) - connection_list.connections.extend(connections) + """Test get_microgrid_info method.""" + await spec.test_unary_unary_call(client, "GetMicrogridMetadata") diff --git a/tests/test_component.py b/tests/test_component.py deleted file mode 100644 index 0fdc527d..00000000 --- a/tests/test_component.py +++ /dev/null @@ -1,98 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Tests for the microgrid component wrapper.""" - -import pytest -from frequenz.api.common import components_pb2 -from frequenz.client.common.microgrid.components import ComponentId - -from frequenz.client.microgrid import ( - Component, - ComponentCategory, -) -from frequenz.client.microgrid._component import component_category_from_protobuf - - -def test_component_category_from_protobuf() -> None: - """Test the creating component category from protobuf.""" - assert ( - component_category_from_protobuf( - components_pb2.ComponentCategory.COMPONENT_CATEGORY_UNSPECIFIED - ) - == ComponentCategory.NONE - ) - - assert ( - component_category_from_protobuf( - components_pb2.ComponentCategory.COMPONENT_CATEGORY_GRID - ) - == ComponentCategory.GRID - ) - - assert ( - component_category_from_protobuf( - components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER - ) - == ComponentCategory.METER - ) - - assert ( - component_category_from_protobuf( - components_pb2.ComponentCategory.COMPONENT_CATEGORY_INVERTER - ) - == ComponentCategory.INVERTER - ) - - assert ( - component_category_from_protobuf( - components_pb2.ComponentCategory.COMPONENT_CATEGORY_BATTERY - ) - == ComponentCategory.BATTERY - ) - - assert ( - component_category_from_protobuf( - components_pb2.ComponentCategory.COMPONENT_CATEGORY_EV_CHARGER - ) - == ComponentCategory.EV_CHARGER - ) - - assert component_category_from_protobuf(666) == ComponentCategory.NONE # type: ignore - - with pytest.raises(ValueError): - component_category_from_protobuf( - components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR - ) - - -# pylint: disable=invalid-name -def test_Component() -> None: - """Test the component category.""" - c0 = Component(ComponentId(0), ComponentCategory.GRID) - assert c0.is_valid() - - c1 = Component(ComponentId(1), ComponentCategory.GRID) - assert c1.is_valid() - - c4 = Component(ComponentId(4), ComponentCategory.METER) - assert c4.is_valid() - - c5 = Component(ComponentId(5), ComponentCategory.INVERTER) - assert c5.is_valid() - - c6 = Component(ComponentId(6), ComponentCategory.BATTERY) - assert c6.is_valid() - - c7 = Component(ComponentId(7), ComponentCategory.EV_CHARGER) - assert c7.is_valid() - - with pytest.raises(ValueError): - # Should raise error with negative ID - Component(ComponentId(-1), ComponentCategory.GRID) - - invalid_type = Component(ComponentId(666), -1) # type: ignore - assert not invalid_type.is_valid() - - another_invalid_type = Component(ComponentId(666), 666) # type: ignore - assert not another_invalid_type.is_valid() diff --git a/tests/test_component_data.py b/tests/test_component_data.py deleted file mode 100644 index ce6ad559..00000000 --- a/tests/test_component_data.py +++ /dev/null @@ -1,101 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Tests for the microgrid component data.""" - -from datetime import datetime, timezone - -import pytest -from frequenz.api.common import metrics_pb2 -from frequenz.api.common.metrics import electrical_pb2 -from frequenz.api.microgrid import inverter_pb2, microgrid_pb2 -from frequenz.client.common.microgrid.components import ComponentId -from google.protobuf import timestamp_pb2 - -from frequenz.client.microgrid import ( - ComponentData, - InverterComponentState, - InverterData, - InverterError, -) - - -def test_component_data_abstract_class() -> None: - """Verify the base class ComponentData may not be instantiated.""" - with pytest.raises(TypeError): - # pylint: disable=abstract-class-instantiated - ComponentData(ComponentId(0), datetime.now(timezone.utc)) # type: ignore - - -def test_inverter_data() -> None: - """Verify the constructor for the InverterData class.""" - seconds = 1234567890 - - raw = microgrid_pb2.ComponentData( - id=5, - ts=timestamp_pb2.Timestamp(seconds=seconds), - inverter=inverter_pb2.Inverter( - state=inverter_pb2.State( - component_state=inverter_pb2.ComponentState.COMPONENT_STATE_DISCHARGING - ), - errors=[inverter_pb2.Error(msg="error message")], - data=inverter_pb2.Data( - ac=electrical_pb2.AC( - frequency=metrics_pb2.Metric(value=50.1), - power_active=metrics_pb2.Metric( - value=100.2, - system_exclusion_bounds=metrics_pb2.Bounds( - lower=-501.0, upper=501.0 - ), - system_inclusion_bounds=metrics_pb2.Bounds( - lower=-51_000.0, upper=51_000.0 - ), - ), - power_reactive=metrics_pb2.Metric( - value=200.3, - system_exclusion_bounds=metrics_pb2.Bounds( - lower=-502.0, upper=502.0 - ), - system_inclusion_bounds=metrics_pb2.Bounds( - lower=-52_000.0, upper=52_000.0 - ), - ), - phase_1=electrical_pb2.AC.ACPhase( - current=metrics_pb2.Metric(value=12.3), - voltage=metrics_pb2.Metric(value=229.8), - power_active=metrics_pb2.Metric(value=33.1), - power_reactive=metrics_pb2.Metric(value=10.1), - ), - phase_2=electrical_pb2.AC.ACPhase( - current=metrics_pb2.Metric(value=23.4), - voltage=metrics_pb2.Metric(value=230.0), - power_active=metrics_pb2.Metric(value=33.3), - power_reactive=metrics_pb2.Metric(value=10.2), - ), - phase_3=electrical_pb2.AC.ACPhase( - current=metrics_pb2.Metric(value=34.5), - voltage=metrics_pb2.Metric(value=230.2), - power_active=metrics_pb2.Metric(value=33.8), - power_reactive=metrics_pb2.Metric(value=10.3), - ), - ), - ), - ), - ) - - inv_data = InverterData.from_proto(raw) - assert inv_data.component_id == ComponentId(5) - assert inv_data.timestamp == datetime.fromtimestamp(seconds, timezone.utc) - assert inv_data.component_state is InverterComponentState.DISCHARGING - assert inv_data.errors == [InverterError(message="error message")] - assert inv_data.frequency == pytest.approx(50.1) - assert inv_data.active_power == pytest.approx(100.2) - assert inv_data.active_power_per_phase == pytest.approx((33.1, 33.3, 33.8)) - assert inv_data.reactive_power == pytest.approx(200.3) - assert inv_data.reactive_power_per_phase == pytest.approx((10.1, 10.2, 10.3)) - assert inv_data.current_per_phase == pytest.approx((12.3, 23.4, 34.5)) - assert inv_data.voltage_per_phase == pytest.approx((229.8, 230.0, 230.2)) - assert inv_data.active_power_inclusion_lower_bound == pytest.approx(-51_000.0) - assert inv_data.active_power_inclusion_upper_bound == pytest.approx(51_000.0) - assert inv_data.active_power_exclusion_lower_bound == pytest.approx(-501.0) - assert inv_data.active_power_exclusion_upper_bound == pytest.approx(501.0) diff --git a/tests/test_connection.py b/tests/test_connection.py deleted file mode 100644 index 0621a844..00000000 --- a/tests/test_connection.py +++ /dev/null @@ -1,30 +0,0 @@ -# License: MIT -# Copyright © 2022 Frequenz Energy-as-a-Service GmbH - -"""Tests for the microgrid Connection type.""" - -from frequenz.client.common.microgrid.components import ComponentId - -from frequenz.client.microgrid import Connection - - -# pylint: disable=invalid-name -def test_Connection() -> None: - """Test the microgrid Connection type.""" - c00 = Connection(ComponentId(0), ComponentId(0)) - assert not c00.is_valid() - - c01 = Connection(ComponentId(0), ComponentId(1)) - assert c01.is_valid() - - c10 = Connection(ComponentId(1), ComponentId(0)) - assert not c10.is_valid() - - c11 = Connection(ComponentId(1), ComponentId(1)) - assert not c11.is_valid() - - c12 = Connection(ComponentId(1), ComponentId(2)) - assert c12.is_valid() - - c21 = Connection(ComponentId(2), ComponentId(1)) - assert c21.is_valid() diff --git a/tests/test_delivery_area.py b/tests/test_delivery_area.py new file mode 100644 index 00000000..2d9fbb4c --- /dev/null +++ b/tests/test_delivery_area.py @@ -0,0 +1,164 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the DeliveryArea class and its protobuf conversion.""" + +from dataclasses import dataclass + +import pytest +from frequenz.api.common.v1.grid import delivery_area_pb2 + +from frequenz.client.microgrid import DeliveryArea, EnergyMarketCodeType +from frequenz.client.microgrid._delivery_area_proto import delivery_area_from_proto + + +@dataclass(frozen=True, kw_only=True) +class _DeliveryAreaTestCase: + """Test case for DeliveryArea creation.""" + + name: str + """Description of the test case.""" + + code: str | None + """The code to use for the delivery area.""" + + code_type: EnergyMarketCodeType | int + """The type of code being used.""" + + expected_str: str + """Expected string representation.""" + + +@dataclass(frozen=True, kw_only=True) +class _ProtoConversionTestCase: + """Test case for protobuf conversion.""" + + name: str + """Description of the test case.""" + + code: str | None + """The code to set in the protobuf message.""" + + code_type: int + """The code type to set in the protobuf message.""" + + expected_code: str | None + """Expected code in the resulting DeliveryArea.""" + + expected_code_type: EnergyMarketCodeType | int + """Expected code type in the resulting DeliveryArea.""" + + expect_warning: bool + """Whether to expect a warning during conversion.""" + + +@pytest.mark.parametrize( + "case", + [ + _DeliveryAreaTestCase( + name="valid_EIC_code", + code="10Y1001A1001A450", + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="10Y1001A1001A450[EUROPE_EIC]", + ), + _DeliveryAreaTestCase( + name="valid_NERC_code", + code="PJM", + code_type=EnergyMarketCodeType.US_NERC, + expected_str="PJM[US_NERC]", + ), + _DeliveryAreaTestCase( + name="no_code", + code=None, + code_type=EnergyMarketCodeType.EUROPE_EIC, + expected_str="[EUROPE_EIC]", + ), + _DeliveryAreaTestCase( + name="unspecified_code_type", + code="TEST", + code_type=EnergyMarketCodeType.UNSPECIFIED, + expected_str="TEST[UNSPECIFIED]", + ), + _DeliveryAreaTestCase( + name="unknown_code_type", + code="TEST", + code_type=999, + expected_str="TEST[type=999]", + ), + ], + ids=lambda case: case.name, +) +def test_creation(case: _DeliveryAreaTestCase) -> None: + """Test creating DeliveryArea instances with various parameters.""" + area = DeliveryArea(code=case.code, code_type=case.code_type) + assert area.code == case.code + assert area.code_type == case.code_type + assert str(area) == case.expected_str + + +@pytest.mark.parametrize( + "case", + [ + _ProtoConversionTestCase( + name="valid_EIC_code", + code="10Y1001A1001A450", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC, + expected_code="10Y1001A1001A450", + expected_code_type=EnergyMarketCodeType.EUROPE_EIC, + expect_warning=False, + ), + _ProtoConversionTestCase( + name="valid_NERC_code", + code="PJM", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_US_NERC, + expected_code="PJM", + expected_code_type=EnergyMarketCodeType.US_NERC, + expect_warning=False, + ), + _ProtoConversionTestCase( + name="no_code", + code=None, + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC, + expected_code=None, + expected_code_type=EnergyMarketCodeType.EUROPE_EIC, + expect_warning=True, + ), + _ProtoConversionTestCase( + name="unspecified_code_type", + code="TEST", + code_type=delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_UNSPECIFIED, + expected_code="TEST", + expected_code_type=EnergyMarketCodeType.UNSPECIFIED, + expect_warning=True, + ), + _ProtoConversionTestCase( + name="unknown_code_type", + code="TEST", + code_type=999, + expected_code="TEST", + expected_code_type=999, + expect_warning=True, + ), + ], + ids=lambda case: case.name, +) +def test_from_proto( + caplog: pytest.LogCaptureFixture, case: _ProtoConversionTestCase +) -> None: + """Test conversion from protobuf message to DeliveryArea.""" + # We do the type-ignore here because we want to test the case of an + # arbitrary int too. + proto = delivery_area_pb2.DeliveryArea( + code=case.code or "", code_type=case.code_type # type: ignore[arg-type] + ) + with caplog.at_level("WARNING"): + area = delivery_area_from_proto(proto) + + assert area.code == case.expected_code + assert area.code_type == case.expected_code_type + + if case.expect_warning: + assert len(caplog.records) > 0 + assert "Found issues in delivery area" in caplog.records[0].message + else: + assert len(caplog.records) == 0 diff --git a/tests/test_location.py b/tests/test_location.py new file mode 100644 index 00000000..a63dc5ed --- /dev/null +++ b/tests/test_location.py @@ -0,0 +1,175 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the microgrid metadata types.""" + +from collections.abc import Iterator +from dataclasses import dataclass +from unittest.mock import MagicMock, patch +from zoneinfo import ZoneInfo + +import pytest +from frequenz.api.common.v1 import location_pb2 + +from frequenz.client.microgrid import Location +from frequenz.client.microgrid._location_proto import location_from_proto + + +@dataclass(frozen=True, kw_only=True) +class _ProtoConversionTestCase: # pylint: disable=too-many-instance-attributes + """Test case for protobuf conversion.""" + + name: str + """The description of the test case.""" + + latitude: float + """The latitude to set in the protobuf message.""" + + longitude: float + """The longitude to set in the protobuf message.""" + + country_code: str + """The country code to set in the protobuf message.""" + + expected_none_latitude: bool = False + """The latitude is expected to be None.""" + + expected_none_longitude: bool = False + """The longitude is expected to be None.""" + + expected_none_country_code: bool = False + """The country code is expected to be None.""" + + expect_warning: bool = False + """Whether to expect a warning during conversion.""" + + +@pytest.fixture +def timezone_finder() -> Iterator[MagicMock]: + """Return a mock timezone finder.""" + with patch( + "frequenz.client.microgrid._location._timezone_finder", autospec=True + ) as mock_timezone_finder: + yield mock_timezone_finder + + +def test_timezone_not_looked_up_if_unused(timezone_finder: MagicMock) -> None: + """Test the location timezone is not looked up if it is not used.""" + location = Location(latitude=52.52, longitude=13.405, country_code="DE") + + assert location.latitude == 52.52 + assert location.longitude == 13.405 + assert location.country_code == "DE" + timezone_finder.timezone_at.assert_not_called() + + +def test_timezone_looked_up_but_not_found(timezone_finder: MagicMock) -> None: + """Test the location timezone is not looked up if it is not used.""" + timezone_finder.timezone_at.return_value = None + + location = Location(latitude=52.52, longitude=13.405, country_code="DE") + + assert location.timezone is None + timezone_finder.timezone_at.assert_called_once_with(lat=52.52, lng=13.405) + + +def test_timezone_looked_up_and_found(timezone_finder: MagicMock) -> None: + """Test the location timezone is not looked up if it is not used.""" + timezone_finder.timezone_at.return_value = "Europe/Berlin" + + location = Location(latitude=52.52, longitude=13.405, country_code="DE") + + assert location.timezone == ZoneInfo(key="Europe/Berlin") + timezone_finder.timezone_at.assert_called_once_with(lat=52.52, lng=13.405) + + +@pytest.mark.parametrize( + "case", + [ + _ProtoConversionTestCase( + name="valid", + latitude=52.52, + longitude=13.405, + country_code="DE", + ), + _ProtoConversionTestCase( + name="boundary_latitude", + latitude=90.0, + longitude=13.405, + country_code="DE", + ), + _ProtoConversionTestCase( + name="boundary_longitude", + latitude=52.52, + longitude=180.0, + country_code="DE", + ), + _ProtoConversionTestCase( + name="invalid_latitude", + latitude=91.0, + longitude=13.405, + country_code="DE", + expected_none_latitude=True, + expect_warning=True, + ), + _ProtoConversionTestCase( + name="invalid_longitude", + latitude=52.52, + longitude=181.0, + country_code="DE", + expected_none_longitude=True, + expect_warning=True, + ), + _ProtoConversionTestCase( + name="empty_country_code", + latitude=52.52, + longitude=13.405, + country_code="", + expected_none_country_code=True, + expect_warning=True, + ), + _ProtoConversionTestCase( + name="all_invalid", + latitude=-91.0, + longitude=181.0, + country_code="", + expected_none_latitude=True, + expected_none_longitude=True, + expected_none_country_code=True, + expect_warning=True, + ), + ], + ids=lambda case: case.name, +) +def test_from_proto( + caplog: pytest.LogCaptureFixture, case: _ProtoConversionTestCase +) -> None: + """Test conversion from protobuf message to Location.""" + proto = location_pb2.Location( + latitude=case.latitude, + longitude=case.longitude, + country_code=case.country_code, + ) + with caplog.at_level("WARNING"): + location = location_from_proto(proto) + + if case.expected_none_latitude: + assert location.latitude is None + else: + assert location.latitude == pytest.approx(case.latitude) + + if case.expected_none_longitude: + assert location.longitude is None + else: + assert location.longitude == pytest.approx(case.longitude) + + if case.expected_none_country_code: + assert location.country_code is None + else: + assert location.country_code == case.country_code + + if case.expect_warning: + assert len(caplog.records) > 0 + assert "Found issues in location:" in caplog.records[0].message + else: + assert len(caplog.records) == 0 diff --git a/tests/test_metadata.py b/tests/test_metadata.py deleted file mode 100644 index 1276d2b5..00000000 --- a/tests/test_metadata.py +++ /dev/null @@ -1,111 +0,0 @@ -# License: MIT -# Copyright © 2024 Frequenz Energy-as-a-Service GmbH - -"""Tests for the microgrid metadata types.""" - -from collections.abc import Iterator -from unittest.mock import MagicMock, patch -from zoneinfo import ZoneInfo - -import pytest -from frequenz.client.common.microgrid import MicrogridId - -from frequenz.client.microgrid import Location, Metadata - - -@pytest.fixture -def timezone_finder() -> Iterator[MagicMock]: - """Return a mock timezone finder.""" - with patch( - "frequenz.client.microgrid._metadata._timezone_finder", autospec=True - ) as mock_timezone_finder: - yield mock_timezone_finder - - -@pytest.mark.parametrize( - "latitude, longitude, timezone", - [ - (None, None, None), - (52.52, None, None), - (None, 13.405, None), - (None, None, ZoneInfo(key="UTC")), - (52.52, None, ZoneInfo(key="UTC")), - (None, 13.405, ZoneInfo(key="UTC")), - (52.52, 13.405, ZoneInfo(key="UTC")), - ], - ids=str, -) -def test_location_timezone_not_looked_up_if_not_possible_or_necessary( - timezone_finder: MagicMock, - latitude: float | None, - longitude: float | None, - timezone: ZoneInfo | None, -) -> None: - """Test the location timezone is not looked up if is not necessary or possible.""" - timezone_finder.timezone_at.return_value = "Europe/Berlin" - - location = Location(latitude=latitude, longitude=longitude, timezone=timezone) - - assert location.latitude == latitude - assert location.longitude == longitude - assert location.timezone == timezone - timezone_finder.timezone_at.assert_not_called() - - -@pytest.mark.parametrize("timezone", [None, "Europe/Berlin"], ids=str) -def test_location_timezone_lookup( - timezone_finder: MagicMock, timezone: str | None -) -> None: - """Test the location timezone is looked up if not provided and there is enough info.""" - timezone_finder.timezone_at.return_value = timezone - - location = Location(latitude=52.52, longitude=13.405) - - if timezone is None: - assert location.timezone is None - else: - assert location.timezone == ZoneInfo(key=timezone) - timezone_finder.timezone_at.assert_called_once_with(lat=52.52, lng=13.405) - - -def test_metadata_initialization() -> None: - """Test initialization of Metadata class.""" - # Test with no parameters - metadata = Metadata() - assert metadata.microgrid_id is None - assert metadata.location is None - - # Test with only microgrid_id - microgrid_id = MicrogridId(42) - metadata = Metadata(microgrid_id=microgrid_id) - assert metadata.microgrid_id == microgrid_id - assert metadata.location is None - - # Test with only location - location = Location(latitude=52.52, longitude=13.405) - metadata = Metadata(location=location) - assert metadata.microgrid_id is None - assert metadata.location == location - - # Test with both parameters - metadata = Metadata(microgrid_id=microgrid_id, location=location) - assert metadata.microgrid_id == microgrid_id - assert metadata.location == location - - -def test_metadata_microgrid_id_validation() -> None: - """Test validation of microgrid_id in Metadata class.""" - # Valid microgrid_id should work - metadata = Metadata(microgrid_id=MicrogridId(0)) - assert metadata.microgrid_id == MicrogridId(0) - - metadata = Metadata(microgrid_id=MicrogridId(42)) - assert metadata.microgrid_id == MicrogridId(42) - - # None should be accepted as a valid value - metadata = Metadata(microgrid_id=None) - assert metadata.microgrid_id is None - - # Negative IDs should raise ValueError - with pytest.raises(ValueError): - Metadata(microgrid_id=MicrogridId(-1)) diff --git a/tests/test_microgrid_info.py b/tests/test_microgrid_info.py new file mode 100644 index 00000000..8b9a90f6 --- /dev/null +++ b/tests/test_microgrid_info.py @@ -0,0 +1,317 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for MicrogridInfo class.""" + +from dataclasses import dataclass +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest +from frequenz.api.common.v1.grid import delivery_area_pb2 +from frequenz.api.common.v1.microgrid import microgrid_pb2 +from frequenz.client.common.microgrid import EnterpriseId, MicrogridId + +from frequenz.client.microgrid import ( + DeliveryArea, + EnergyMarketCodeType, + Location, + MicrogridInfo, + MicrogridStatus, +) +from frequenz.client.microgrid._microgrid_info_proto import microgrid_info_from_proto + + +@dataclass(frozen=True, kw_only=True) +class _ProtoConversionTestCase: + """Test case for protobuf conversion.""" + + name: str + """Description of the test case.""" + + has_delivery_area: bool + """Whether to include delivery area in the protobuf message.""" + + has_location: bool + """Whether to include location in the protobuf message.""" + + has_name: bool + """Whether to include name in the protobuf message.""" + + status: MicrogridStatus | int + """The status to set in the protobuf message.""" + + expected_log: tuple[str, str] | None = None + """Whether to expect a log during conversion (level, message).""" + + +def test_creation() -> None: + """Test MicrogridInfo creation with all fields.""" + now = datetime.now(timezone.utc) + info = MicrogridInfo( + id=MicrogridId(1234), + enterprise_id=EnterpriseId(5678), + name="Test Microgrid", + delivery_area=DeliveryArea( + code="DE123", code_type=EnergyMarketCodeType.EUROPE_EIC + ), + location=Location(latitude=52.52, longitude=13.405, country_code="DE"), + status=MicrogridStatus.ACTIVE, + create_timestamp=now, + ) + + assert info.id == MicrogridId(1234) + assert info.enterprise_id == EnterpriseId(5678) + assert info.name == "Test Microgrid" + assert info.delivery_area is not None + assert info.delivery_area.code == "DE123" + assert info.delivery_area.code_type == EnergyMarketCodeType.EUROPE_EIC + assert info.location is not None + assert info.location.latitude is not None + assert info.location.latitude == pytest.approx(52.52) + assert info.location.longitude is not None + assert info.location.longitude == pytest.approx(13.405) + assert info.location.country_code == "DE" + assert info.status == MicrogridStatus.ACTIVE + assert info.create_timestamp == now + assert info.is_active is True + + +def test_creation_without_optionals() -> None: + """Test MicrogridInfo creation with only required fields.""" + now = datetime.now(timezone.utc) + info = MicrogridInfo( + id=MicrogridId(1234), + enterprise_id=EnterpriseId(5678), + name=None, + delivery_area=None, + location=None, + status=MicrogridStatus.ACTIVE, + create_timestamp=now, + ) + + assert info.id == MicrogridId(1234) + assert info.enterprise_id == EnterpriseId(5678) + assert info.name is None + assert info.delivery_area is None + assert info.location is None + assert info.status == MicrogridStatus.ACTIVE + assert info.create_timestamp == now + assert info.is_active is True + + +@pytest.mark.parametrize( + "status,expected_active", + [ + pytest.param(MicrogridStatus.ACTIVE, True, id="ACTIVE"), + pytest.param(MicrogridStatus.INACTIVE, False, id="INACTIVE"), + pytest.param(MicrogridStatus.UNSPECIFIED, True, id="UNSPECIFIED"), + ], +) +def test_is_active_property(status: MicrogridStatus, expected_active: bool) -> None: + """Test the is_active property for different status values.""" + now = datetime.now(timezone.utc) + info = MicrogridInfo( + id=MicrogridId(1234), + enterprise_id=EnterpriseId(5678), + name=None, + delivery_area=None, + location=None, + status=status, + create_timestamp=now, + ) + assert info.is_active is expected_active + + +@pytest.mark.parametrize( + "name,expected_str", + [ + pytest.param("Test Grid", "MID1234:Test Grid", id="with-name"), + pytest.param(None, "MID1234", id="none-name"), + pytest.param("", "MID1234", id="empty-name"), + ], +) +def test_str(name: str | None, expected_str: str) -> None: + """Test string representation of MicrogridInfo.""" + now = datetime.now(timezone.utc) + info = MicrogridInfo( + id=MicrogridId(1234), + enterprise_id=EnterpriseId(5678), + name=name, + delivery_area=None, + location=None, + status=MicrogridStatus.ACTIVE, + create_timestamp=now, + ) + assert str(info) == expected_str + + +@pytest.mark.parametrize( + "case", + [ + _ProtoConversionTestCase( + name="full", + has_delivery_area=True, + has_location=True, + has_name=True, + status=MicrogridStatus.ACTIVE, + ), + _ProtoConversionTestCase( + name="no_delivery_area", + has_delivery_area=False, + has_location=True, + has_name=True, + status=MicrogridStatus.ACTIVE, + expected_log=( + "WARNING", + "Found issues in microgrid: delivery_area is missing", + ), + ), + _ProtoConversionTestCase( + name="no_location", + has_delivery_area=True, + has_location=False, + has_name=True, + status=MicrogridStatus.ACTIVE, + expected_log=("WARNING", "Found issues in microgrid: location is missing"), + ), + _ProtoConversionTestCase( + name="empty_name", + has_delivery_area=True, + has_location=True, + has_name=False, + status=MicrogridStatus.ACTIVE, + expected_log=("DEBUG", "Found minor issues in microgrid: name is empty"), + ), + _ProtoConversionTestCase( + name="unspecified_status", + has_delivery_area=True, + has_location=True, + has_name=True, + status=MicrogridStatus.UNSPECIFIED, + expected_log=( + "WARNING", + "Found issues in microgrid: status is unspecified", + ), + ), + _ProtoConversionTestCase( + name="unrecognized_status", + has_delivery_area=True, + has_location=True, + has_name=True, + status=999, # Unknown status value + expected_log=( + "WARNING", + "Found issues in microgrid: status is unrecognized", + ), + ), + ], + ids=lambda case: case.name, +) +@patch("frequenz.client.microgrid._microgrid_info_proto.delivery_area_from_proto") +@patch("frequenz.client.microgrid._microgrid_info_proto.location_from_proto") +@patch("frequenz.client.microgrid._microgrid_info_proto.enum_from_proto") +@patch("frequenz.client.microgrid._microgrid_info_proto.conversion.to_datetime") +# pylint: disable-next=too-many-arguments,too-many-positional-arguments,too-many-branches +def test_from_proto( + mock_to_datetime: Mock, + mock_enum_from_proto: Mock, + mock_location_from_proto: Mock, + mock_delivery_area_from_proto: Mock, + caplog: pytest.LogCaptureFixture, + case: _ProtoConversionTestCase, +) -> None: + """Test conversion from protobuf message to MicrogridInfo.""" + now = datetime.now(timezone.utc) + mock_to_datetime.return_value = now + + if isinstance(case.status, MicrogridStatus): + mock_enum_from_proto.return_value = case.status + else: + mock_enum_from_proto.return_value = case.status + + mock_location = ( + Location( + latitude=52.52, + longitude=13.405, + country_code="DE", + ) + if case.has_location + else None + ) + mock_location_from_proto.return_value = mock_location + + mock_delivery_area = ( + DeliveryArea( + code="DE123", + code_type=EnergyMarketCodeType.EUROPE_EIC, + ) + if case.has_delivery_area + else None + ) + mock_delivery_area_from_proto.return_value = mock_delivery_area + + proto = microgrid_pb2.Microgrid( + id=1234, + enterprise_id=5678, + name="Test Grid" if case.has_name else "", + # We use a ignore because we want to pass an arbitrary int here + status=( + case.status.value # type: ignore[arg-type] + if isinstance(case.status, MicrogridStatus) + else case.status + ), + ) + + # Add optional fields if needed + if case.has_delivery_area: + proto.delivery_area.code = "DE123" + proto.delivery_area.code_type = ( + delivery_area_pb2.EnergyMarketCodeType.ENERGY_MARKET_CODE_TYPE_EUROPE_EIC + ) + + if case.has_location: + proto.location.latitude = 52.52 + proto.location.longitude = 13.405 + proto.location.country_code = "DE" + + # Run the conversion + with caplog.at_level("DEBUG"): + info = microgrid_info_from_proto(proto) + + # Verify the result + assert info.id == MicrogridId(1234) + assert info.enterprise_id == EnterpriseId(5678) + assert info.create_timestamp == now + + if case.has_name: + assert info.name == "Test Grid" + else: + assert info.name is None + + # Verify mock calls + mock_to_datetime.assert_called_once_with(proto.create_timestamp) + mock_enum_from_proto.assert_called_once_with(proto.status, MicrogridStatus) + + if case.has_delivery_area: + mock_delivery_area_from_proto.assert_called_once_with(proto.delivery_area) + assert info.delivery_area == mock_delivery_area + else: + mock_delivery_area_from_proto.assert_not_called() + assert info.delivery_area is None + + if case.has_location: + mock_location_from_proto.assert_called_once_with(proto.location) + assert info.location == mock_location + else: + mock_location_from_proto.assert_not_called() + assert info.location is None + + # Verify logging behavior + if case.expected_log: + expected_level, expected_message = case.expected_log + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == expected_level + assert expected_message in caplog.records[0].message + else: + assert len(caplog.records) == 0 diff --git a/tests/test_sensor.py b/tests/test_sensor.py deleted file mode 100644 index f86a2c11..00000000 --- a/tests/test_sensor.py +++ /dev/null @@ -1,220 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Tests for the Sensor and sensor data classes.""" - -from datetime import datetime, timedelta, timezone -from typing import Any - -import pytest -from frequenz.client.common.microgrid.sensors import SensorId - -from frequenz.client.microgrid import Lifetime -from frequenz.client.microgrid.metrics import ( - AggregatedMetricValue, - AggregationMethod, -) -from frequenz.client.microgrid.sensor import Sensor, SensorMetric, SensorMetricSample - - -@pytest.fixture -def now() -> datetime: - """Get the current time.""" - return datetime.now(timezone.utc) - - -def test_sensor_creation_defaults() -> None: - """Test Sensor defaults are as expected.""" - sensor = Sensor(id=SensorId(1)) - - assert sensor.id == SensorId(1) - assert sensor.name is None - assert sensor.manufacturer is None - assert sensor.model_name is None - assert sensor.operational_lifetime == Lifetime() - - -def test_sensor_creation_full(now: datetime) -> None: - """Test Sensor creation with all fields.""" - start = now - end = start + timedelta(days=1) - sensor = Sensor( - id=SensorId(1), - name="test-sensor", - manufacturer="Test Manufacturer", - model_name="Test Model", - operational_lifetime=Lifetime( - start=start, - end=end, - ), - ) - - assert sensor.id == SensorId(1) - assert sensor.name == "test-sensor" - assert sensor.manufacturer == "Test Manufacturer" - assert sensor.model_name == "Test Model" - assert sensor.operational_lifetime.start == start - assert sensor.operational_lifetime.end == end - - -@pytest.mark.parametrize( - "name,expected_str", - [(None, ""), ("test-sensor", "")], - ids=["no-name", "with-name"], -) -def test_sensor_str(name: str | None, expected_str: str) -> None: - """Test string representation of a sensor.""" - sensor = Sensor( - id=SensorId(1), - name=name, - manufacturer="Test Manufacturer", - model_name="Test Model", - operational_lifetime=Lifetime( - start=datetime.now(timezone.utc), - end=datetime.now(timezone.utc) + timedelta(days=1), - ), - ) - assert str(sensor) == expected_str - - -_SENSOR = Sensor( - id=SensorId(1), - name="test", - manufacturer="Test Mfg", - model_name="Model A", -) - -_DIFFERENT_NAME = Sensor( - id=_SENSOR.id, - name="different", - manufacturer=_SENSOR.manufacturer, - model_name=_SENSOR.model_name, -) - -_DIFFERENT_ID = Sensor( - id=SensorId(2), - name=_SENSOR.name, - manufacturer=_SENSOR.manufacturer, - model_name=_SENSOR.model_name, -) - - -@pytest.mark.parametrize( - "comp,expected", - [ - pytest.param(_SENSOR, True, id="self"), - pytest.param(_DIFFERENT_NAME, False, id="other-name"), - pytest.param(_DIFFERENT_ID, False, id="other-id"), - ], - ids=lambda o: str(o.id) if isinstance(o, Sensor) else str(o), -) -def test_sensor_equality(comp: Sensor, expected: bool) -> None: - """Test sensor equality.""" - assert (_SENSOR == comp) is expected - assert (comp == _SENSOR) is expected - assert (_SENSOR != comp) is not expected - assert (comp != _SENSOR) is not expected - - -@pytest.mark.parametrize( - "comp,expected", - [ - pytest.param(_SENSOR, True, id="self"), - pytest.param(_DIFFERENT_NAME, True, id="other-name"), - pytest.param(_DIFFERENT_ID, False, id="other-id"), - ], -) -def test_sensor_identity(comp: Sensor, expected: bool) -> None: - """Test sensor identity.""" - assert (_SENSOR.identity == comp.identity) is expected - assert comp.identity == comp.id - - -_ALL_SENSORS_PARAMS = [ - pytest.param(_SENSOR, id="comp"), - pytest.param(_DIFFERENT_NAME, id="name"), - pytest.param(_DIFFERENT_ID, id="id"), -] - - -@pytest.mark.parametrize("comp1", _ALL_SENSORS_PARAMS) -@pytest.mark.parametrize("comp2", _ALL_SENSORS_PARAMS) -def test_sensor_hash(comp1: Sensor, comp2: Sensor) -> None: - """Test that the Sensor hash is consistent.""" - # We can only say the hash are the same if the sensors are equal, if they - # are not, they could still have the same hash (and they will if they have - # only different non-hashable attributes) - if comp1 == comp2: - assert hash(comp1) == hash(comp2) - - -@pytest.mark.parametrize( - "metric,value", - [ - (SensorMetric.TEMPERATURE, 5.0), - ( - SensorMetric.HUMIDITY, - AggregatedMetricValue( - avg=5.0, - min=1.0, - max=10.0, - raw_values=[1.0, 5.0, 10.0], - ), - ), - (SensorMetric.DEW_POINT, None), - ], -) -def test_metric_sample_creation( - now: datetime, metric: SensorMetric, value: float | AggregatedMetricValue | None -) -> None: - """Test MetricSample creation with different value types.""" - sample = SensorMetricSample(sampled_at=now, metric=metric, value=value) - assert sample.sampled_at == now - assert sample.metric == metric - assert sample.value == value - - -@pytest.mark.parametrize( - "value,method_results", - [ - ( - 5.0, - { - AggregationMethod.AVG: 5.0, - AggregationMethod.MIN: 5.0, - AggregationMethod.MAX: 5.0, - }, - ), - ( - AggregatedMetricValue( - avg=5.0, - min=1.0, - max=10.0, - raw_values=[1.0, 5.0, 10.0], - ), - { - AggregationMethod.AVG: 5.0, - AggregationMethod.MIN: 1.0, - AggregationMethod.MAX: 10.0, - }, - ), - ( - None, - { - AggregationMethod.AVG: None, - AggregationMethod.MIN: None, - AggregationMethod.MAX: None, - }, - ), - ], -) -def test_metric_sample_as_single_value( - now: datetime, value: Any, method_results: dict[AggregationMethod, float | None] -) -> None: - """Test MetricSample.as_single_value with different value types and methods.""" - sample = SensorMetricSample( - sampled_at=now, metric=SensorMetric.TEMPERATURE, value=value - ) - - for method, expected in method_results.items(): - assert sample.as_single_value(aggregation_method=method) == expected diff --git a/tests/test_sensor_proto.py b/tests/test_sensor_proto.py deleted file mode 100644 index 3afa56df..00000000 --- a/tests/test_sensor_proto.py +++ /dev/null @@ -1,536 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Tests for protobuf conversion of sensor and sensor data objects.""" - -from collections.abc import Sequence -from dataclasses import dataclass, field -from datetime import datetime, timezone -from unittest.mock import Mock, patch - -import pytest -from frequenz.api.common import components_pb2 -from frequenz.api.microgrid import common_pb2, microgrid_pb2, sensor_pb2 -from frequenz.client.base import conversion -from frequenz.client.common.microgrid.sensors import SensorId - -from frequenz.client.microgrid import Lifetime -from frequenz.client.microgrid._sensor_proto import ( - sensor_data_samples_from_proto, - sensor_from_proto, - sensor_from_proto_with_issues, - sensor_metric_sample_from_proto, - sensor_state_sample_from_proto, -) -from frequenz.client.microgrid.sensor import ( - Sensor, - SensorDataSamples, - SensorErrorCode, - SensorMetric, - SensorMetricSample, - SensorStateCode, - SensorStateSample, -) - - -@pytest.fixture -def now() -> datetime: - """Return a fixed datetime object for testing.""" - return datetime.now(timezone.utc) - - -@pytest.fixture -def sensor_id() -> SensorId: - """Provide a test sensor ID.""" - return SensorId(42) - - -@patch("frequenz.client.microgrid._sensor_proto.sensor_from_proto_with_issues") -def test_sensor_from_proto( - mock_sensor_from_proto_with_issues: Mock, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test main sensor conversion from protobuf.""" - mock_proto = Mock(name="PbSensor", spec=microgrid_pb2.Component) - mock_sensor = Mock(name="Sensor", spec=Sensor) - captured_major_issues: list[str] | None = None - captured_minor_issues: list[str] | None = None - - def _fake_sensor_from_proto_with_issues( - _: microgrid_pb2.Component, major_issues: list[str], minor_issues: list[str] - ) -> Sensor: - """Fake function to simulate sensor conversion.""" - nonlocal captured_major_issues - nonlocal captured_minor_issues - captured_major_issues = major_issues - captured_minor_issues = minor_issues - - major_issues.append("major issue") - minor_issues.append("minor issue") - return mock_sensor - - mock_sensor_from_proto_with_issues.side_effect = _fake_sensor_from_proto_with_issues - - with caplog.at_level("DEBUG"): - sensor = sensor_from_proto(mock_proto) - - assert sensor is mock_sensor - mock_sensor_from_proto_with_issues.assert_called_once_with( - mock_proto, - # We need to use the same instance here because it was mutated (it was called - # with empty lists but they were mutated in the function) - major_issues=captured_major_issues, - minor_issues=captured_minor_issues, - ) - assert captured_major_issues == ["major issue"] - assert captured_minor_issues == ["minor issue"] - assert len(caplog.records) == 2 - assert caplog.records[0].levelname == "WARNING" - assert "Found issues in sensor: major issue" in caplog.records[0].message - assert caplog.records[1].levelname == "DEBUG" - assert "Found minor issues in sensor: minor issue" in caplog.records[1].message - - -@dataclass(frozen=True, kw_only=True) -class _SensorTestCase: # pylint: disable=too-many-instance-attributes - """Test case for sensor protobuf conversion.""" - - test_id: str - """Description of the test case.""" - - missing_optional_fields: bool = False - """Whether to include name, manufacturer and model_name in the protobuf message.""" - - missing_metadata: bool = False - """Whether to include sensor metadata in the protobuf message.""" - - has_wrong_category: bool = False - """Whether to include sensor metadata in the protobuf message.""" - - expected_minor_issues: Sequence[str] = tuple() - """Minor issues expected in the sensor.""" - - expected_major_issues: Sequence[str] = tuple() - """Major issues expected in the sensor.""" - - -@patch("frequenz.client.microgrid._sensor_proto.Sensor") -@pytest.mark.parametrize( - "case", - [ - _SensorTestCase(test_id="full"), - _SensorTestCase( - test_id="missing_metadata", - missing_optional_fields=True, - expected_minor_issues=[ - "name is empty", - "manufacturer is empty", - "model_name is empty", - ], - ), - _SensorTestCase( - test_id="wrong_category", - has_wrong_category=True, - expected_major_issues=[ - "unexpected category for sensor (10)", - ], - ), - _SensorTestCase( - test_id="missing_sensor_metadata", - missing_metadata=True, - # This is actually fine, we don't use the metadata - ), - _SensorTestCase( - test_id="all_wrong", - missing_metadata=True, - has_wrong_category=True, - missing_optional_fields=True, - expected_major_issues=[ - "unexpected category for sensor (10)", - ], - expected_minor_issues=[ - "name is empty", - "manufacturer is empty", - "model_name is empty", - ], - ), - ], - ids=lambda case: case.test_id, -) -# pylint: disable-next=too-many-locals,too-many-arguments,too-many-positional-arguments -def test_sensor_from_proto_with_issues( - mock_sensor: Mock, case: _SensorTestCase, sensor_id: SensorId -) -> None: - """Test sensor conversion with metadata matching check.""" - major_issues: list[str] = [] - minor_issues: list[str] = [] - - proto = microgrid_pb2.Component( - id=int(sensor_id), - category=( - components_pb2.ComponentCategory.COMPONENT_CATEGORY_CHP - if case.has_wrong_category - else components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR - ), - ) - - if not case.missing_optional_fields: - proto.name = "test_sensor" - proto.manufacturer = "test_manufacturer" - proto.model_name = "test_model" - if not case.missing_metadata: - proto.sensor.CopyFrom( - sensor_pb2.Metadata( - type=components_pb2.SensorType.SENSOR_TYPE_ACCELEROMETER - ) - ) - - _ = sensor_from_proto_with_issues( - proto, - major_issues=major_issues, - minor_issues=minor_issues, - ) - - assert major_issues == list(case.expected_major_issues) - assert minor_issues == list(case.expected_minor_issues) - - mock_sensor.assert_called_once_with( - id=sensor_id, - name=proto.name or None, - manufacturer=proto.manufacturer or None, - model_name=proto.model_name or None, - operational_lifetime=Lifetime(), - ) - - -@dataclass(frozen=True, kw_only=True) -class _SensorMetricSampleTestCase: - """Test case for sensor_metric_sample_from_proto.""" - - test_id: str - proto_metric_value: sensor_pb2.SensorMetric.ValueType | int - proto_value: float - expected_metric: SensorMetric | int - expected_value: float - - -@pytest.mark.parametrize( - "case", - [ - _SensorMetricSampleTestCase( - test_id="valid_metric", - proto_metric_value=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, - proto_value=25.5, - expected_metric=SensorMetric.TEMPERATURE, - expected_value=25.5, - ), - _SensorMetricSampleTestCase( - test_id="unrecognized_metric", - proto_metric_value=999, - proto_value=10.0, - expected_metric=999, - expected_value=10.0, - ), - ], - ids=lambda case: case.test_id, -) -def test_sensor_metric_sample_from_proto( - case: _SensorMetricSampleTestCase, now: datetime -) -> None: - """Test sensor_metric_sample_from_proto with different inputs.""" - proto_metric = sensor_pb2.SensorData( - sensor_metric=case.proto_metric_value, # type: ignore[arg-type] - value=case.proto_value, - ) - result = sensor_metric_sample_from_proto(now, proto_metric) - - assert isinstance(result, SensorMetricSample) - assert result.sampled_at == now - assert result.metric == case.expected_metric - assert result.value == case.expected_value - - -@dataclass(frozen=True, kw_only=True) -class _SensorStateSampleTestCase: - """Test case for sensor_state_sample_from_proto.""" - - test_id: str - proto_state_code: sensor_pb2.ComponentState.ValueType - proto_errors: list[sensor_pb2.Error] = field(default_factory=list) - expected_state_code: SensorStateCode | int - expected_errors_set: frozenset[SensorErrorCode | int] - expected_warnings_set: frozenset[SensorErrorCode | int] - - -@pytest.mark.parametrize( - "case", - [ - _SensorStateSampleTestCase( - test_id="state_on_no_errors", - proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_OK, - expected_state_code=SensorStateCode.ON, - expected_errors_set=frozenset(), - expected_warnings_set=frozenset(), - ), - _SensorStateSampleTestCase( - test_id="state_error_critical_error", - proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_ERROR, - proto_errors=[ - sensor_pb2.Error( - # Code only have UNSPECIFIED for now - level=common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL, - msg="Critical error", - ) - ], - expected_state_code=SensorStateCode.ERROR, - expected_errors_set=frozenset([SensorErrorCode.UNSPECIFIED]), - expected_warnings_set=frozenset(), - ), - _SensorStateSampleTestCase( - test_id="state_on_warning", - proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_OK, - proto_errors=[ - sensor_pb2.Error( - # We use some numeric unrecognized code for the warning - code=999, # type: ignore[arg-type] - level=common_pb2.ErrorLevel.ERROR_LEVEL_WARN, - msg="Warning", - ) - ], - expected_state_code=SensorStateCode.ON, - expected_errors_set=frozenset(), - expected_warnings_set=frozenset([999]), - ), - _SensorStateSampleTestCase( - test_id="state_on_critical_and_warning", - proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_OK, - proto_errors=[ - sensor_pb2.Error( - code=999, # type: ignore[arg-type] - level=common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL, - msg="Critical error", - ), - sensor_pb2.Error( - code=666, # type: ignore[arg-type] - level=common_pb2.ErrorLevel.ERROR_LEVEL_WARN, - msg="Warning", - ), - ], - expected_state_code=SensorStateCode.ON, - expected_errors_set=frozenset([999]), - expected_warnings_set=frozenset([666]), - ), - _SensorStateSampleTestCase( - test_id="state_on_unspecified_level_error", - proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_OK, - proto_errors=[ - sensor_pb2.Error( - code=999, # type: ignore[arg-type] - level=common_pb2.ErrorLevel.ERROR_LEVEL_UNSPECIFIED, - msg="Unspecified error", - ) - ], - expected_state_code=SensorStateCode.ON, - expected_errors_set=frozenset([999]), - expected_warnings_set=frozenset(), - ), - _SensorStateSampleTestCase( - test_id="unrecognized_state_code", - proto_state_code=999, # type: ignore[arg-type] - expected_state_code=999, # Expected to be the integer itself - expected_errors_set=frozenset(), - expected_warnings_set=frozenset(), - ), - ], - ids=lambda case: case.test_id, -) -def test_sensor_state_sample_from_proto( - case: _SensorStateSampleTestCase, now: datetime -) -> None: - """Test conversion of state, errors, and warnings.""" - proto_sensor_comp_data = sensor_pb2.Sensor( - state=sensor_pb2.State(component_state=case.proto_state_code), - errors=case.proto_errors, - ) - - result = sensor_state_sample_from_proto(now, proto_sensor_comp_data) - - assert isinstance(result, SensorStateSample) - assert result.sampled_at == now - assert result.states == frozenset([case.expected_state_code]) - assert result.errors == case.expected_errors_set - assert result.warnings == case.expected_warnings_set - - -@dataclass(frozen=True, kw_only=True) -class _SensorDataSamplesTestCase: # pylint: disable=too-many-instance-attributes - """Test case for sensor_data_samples_from_proto.""" - - test_id: str - proto_sensor_data: list[sensor_pb2.SensorData] = field(default_factory=list) - filter_metrics_pb_values: set[sensor_pb2.SensorMetric.ValueType] - expected_metrics_count: int - expected_first_metric_details: tuple[SensorMetric, float] | None - proto_state_code: sensor_pb2.ComponentState.ValueType = ( - sensor_pb2.ComponentState.COMPONENT_STATE_OK - ) - proto_errors: list[sensor_pb2.Error] = field(default_factory=list) - expected_state_code: SensorStateCode | int = SensorStateCode.ON - expected_errors_set: frozenset[SensorErrorCode | int] = frozenset() - expected_warnings_set: frozenset[SensorErrorCode | int] = frozenset() - - -@pytest.mark.parametrize( - "case", - [ - _SensorDataSamplesTestCase( - test_id="one_metric_match_filter", - proto_sensor_data=[ - sensor_pb2.SensorData( - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, - value=20.0, - ) - ], - filter_metrics_pb_values={ - sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE - }, - expected_metrics_count=1, - expected_first_metric_details=(SensorMetric.TEMPERATURE, 20.0), - ), - _SensorDataSamplesTestCase( - test_id="two_metrics_filter_one", - proto_sensor_data=[ - sensor_pb2.SensorData( - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, - value=20.0, - ), - sensor_pb2.SensorData( - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_HUMIDITY, - value=60.0, - ), - ], - filter_metrics_pb_values={ - sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE - }, - expected_metrics_count=1, - expected_first_metric_details=(SensorMetric.TEMPERATURE, 20.0), - ), - _SensorDataSamplesTestCase( - test_id="two_metrics_filter_both", - proto_sensor_data=[ - sensor_pb2.SensorData( - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, - value=20.0, - ), - sensor_pb2.SensorData( - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_HUMIDITY, - value=60.0, - ), - ], - filter_metrics_pb_values={ - sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, - sensor_pb2.SensorMetric.SENSOR_METRIC_HUMIDITY, - }, - expected_metrics_count=2, - expected_first_metric_details=( - SensorMetric.TEMPERATURE, - 20.0, - ), # Checks first, assumes order - ), - _SensorDataSamplesTestCase( - test_id="filter_none_empty_set", - proto_sensor_data=[ - sensor_pb2.SensorData( - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, - value=20.0, - ) - ], - filter_metrics_pb_values=set(), # Empty filter set - expected_metrics_count=0, - expected_first_metric_details=None, - ), - _SensorDataSamplesTestCase( - test_id="filter_none_other_metric", - proto_sensor_data=[ - sensor_pb2.SensorData( - sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, - value=20.0, - ) - ], - filter_metrics_pb_values={ - sensor_pb2.SensorMetric.SENSOR_METRIC_HUMIDITY - }, # Filter for other metric - expected_metrics_count=0, - expected_first_metric_details=None, - ), - _SensorDataSamplesTestCase( - test_id="no_metrics_in_proto", - filter_metrics_pb_values={ - sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE - }, - expected_metrics_count=0, - expected_first_metric_details=None, - ), - _SensorDataSamplesTestCase( - test_id="state_details_propagation", - filter_metrics_pb_values=set(), - expected_metrics_count=0, - expected_first_metric_details=None, - proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_ERROR, - proto_errors=[ - sensor_pb2.Error( - code=sensor_pb2.ErrorCode.ERROR_CODE_UNSPECIFIED, # The only option for now - level=common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL, - msg="Error message", - ) - ], - expected_state_code=SensorStateCode.ERROR, - expected_errors_set=frozenset([SensorErrorCode.UNSPECIFIED]), - ), - ], - ids=lambda case: case.test_id, -) -def test_sensor_data_samples_from_proto( - case: _SensorDataSamplesTestCase, - now: datetime, -) -> None: - """Test metric filtering and overall structure of SensorDataSamples.""" - sensor_id_val = 123 - proto_sensor_data = microgrid_pb2.ComponentData( - id=sensor_id_val, - ts=conversion.to_timestamp(now), - sensor=sensor_pb2.Sensor( - data=sensor_pb2.Data(sensor_data=case.proto_sensor_data), - state=sensor_pb2.State(component_state=case.proto_state_code), - errors=case.proto_errors, - ), - ) - - result = sensor_data_samples_from_proto( - proto_sensor_data, case.filter_metrics_pb_values - ) - - assert isinstance(result, SensorDataSamples) - assert result.sensor_id == SensorId(sensor_id_val) - assert len(result.metrics) == case.expected_metrics_count - - if case.expected_metrics_count > 0 and case.expected_first_metric_details: - expected_sample = SensorMetricSample( - sampled_at=now, - metric=case.expected_first_metric_details[0], - value=case.expected_first_metric_details[1], - ) - # Basic check of the first metric, assumes order and content correctness - # More comprehensive checks could iterate through all expected metrics. - assert result.metrics[0] == expected_sample - for metric_sample in result.metrics: - assert metric_sample.sampled_at == now - - # Check state part - assert len(result.states) == 1 - state_sample = result.states[0] - assert isinstance(state_sample, SensorStateSample) - assert state_sample.sampled_at == now - assert state_sample.states == frozenset([case.expected_state_code]) - assert state_sample.errors == case.expected_errors_set - assert state_sample.warnings == case.expected_warnings_set diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 00000000..05c42d0c --- /dev/null +++ b/tests/util.py @@ -0,0 +1,1326 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +# pylint: disable=too-many-lines + +r'''Utilities for testing client implementations. + +This module provides utilities to create and structure test case files for +testing gRPC client methods. These utilities are designed to simplify the +process of testing client-side gRPC logic by mocking gRPC stub interactions and +providing a framework for asserting client behavior. + +The primary tools you'll use from this module are: + +* [`get_test_specs()`][tests.util.get_test_specs]: Discovers and prepares test + cases based on files you create in a conventional directory structure. +* [`ApiClientTestCaseSpec`][tests.util.ApiClientTestCaseSpec]: Objects returned + by `get_test_specs()`. You'll call methods like `test_unary_unary_call()` or + `test_unary_stream_call()` on these objects to run individual test scenarios. + +While these are the main interfaces for test definitions, the framework also +utilizes other helpers you probably want to use: + +* [`patch_client_class()`][tests.util.patch_client_class]: For setting up client + mocks, often used within `pytest` fixtures to prepare a client instance for + testing. +* [`make_grpc_error()`][tests.util.make_grpc_error]: For creating mock gRPC + error objects to simulate API failures. + +# Quick Start Guide + +This section provides a fast track to writing your first client tests using this +framework. + +## Directory Structure + +Organize your test case files as follows: + +```text +tests/ +├── test_some_client.py # Your main pytest file for the client +└── client_test_cases/ # Root directory for all client method test cases + └── some_unary_method/ # Subdirectory for a specific unary client method + | ├── success_case.py + | └── error_case.py + └── some_stream_method/ # Subdirectory for a specific streaming client method + ├── success_case.py + ├── call_error_case.py + └── stream_error_case.py +``` + +## Main Test File (`test_some_client.py`) + +```python title="test_some_client.py" +from collections.abc import AsyncIterator +from pathlib import Path + +import pytest + +# Replace with your actual client and stub +from some_protobuf_generated_files import some_api_pb2_grpc # type: ignore +from some_client_module import SomeApiClient # type: ignore +# Replace with your retry strategy if needed +from frequenz.client.base.retry import LinearBackoff + +from tests.util import ApiClientTestCaseSpec, get_test_specs, patch_client_class + +# Define the directory where your test case files are stored +TESTS_DIR = Path(__file__).parent / "client_test_cases" + +@pytest.fixture +async def client(mocker: pytest_mock.MockerFixture) -> AsyncIterator[SomeApiClient]: + """Fixture that provides a SomeApiClient with a mock gRPC stub.""" + # The patch_client_class utility replaces the client's `connect` method + # to inject a mock stub, avoiding real network calls. + with patch_client_class( + SomeApiClient, some_api_pb2_grpc.SomeApiStub + ) as patched_client_class: + # Initialize your client, potentially with a fast retry strategy for tests + instance = patched_client_class( + "grpc://localhost:1234", # Mock server URL, not really used + retry_strategy=LinearBackoff(interval=0.0, jitter=0.0, limit=3), + ) + async with instance: + yield instance + +@pytest.mark.parametrize( + "spec", + # "some_unary_method" must match your SomeApiClient method name, and also + # the subdirectory name in client_test_cases/. + get_test_specs("some_unary_method", tests_dir=TESTS_DIR), + ids=str, # Use the test case file name as the test ID +) +async def test_some_unary_method( + client: SomeApiClient, spec: ApiClientTestCaseSpec +) -> None: + """Test the some_unary_method client call.""" + # "ActualGrpcMethodName" is the name of the method on the gRPC stub. + await spec.test_unary_unary_call(client, "ActualGrpcMethodName") + +@pytest.mark.parametrize( + "spec", get_test_specs("some_stream_method", tests_dir=TESTS_DIR), ids=str, +) +async def test_some_stream_method( + client: SomeApiClient, spec: ApiClientTestCaseSpec +) -> None: + """Test the some_stream_method client call.""" + # "ActualGrpcStreamMethodName" is the name of the streaming method on the + # gRPC stub. + await spec.test_unary_stream_call(client, "ActualGrpcStreamMethodName") +``` + +## Test Case File Examples + +### Unary Method Test Cases + +Example: Success test case + ```python title="client_test_cases/some_unary_method/success_case.py" + # Replace with your actual protobuf message types + from some_api_pb2 import SomeRequest, SomeResponse # type: ignore + # If client transforms the response + from some_client_module import SomeClientReturnType # type: ignore + + # Arguments to pass to your client.some_unary_method() + client_args = ("some_id",) + client_kwargs = {"param": True} + + # Function to assert the gRPC request sent by the client + def assert_stub_method_call(request: SomeRequest) -> None: + assert isinstance(request, SomeRequest) + assert request.id == "some_id" + assert request.parameter is True + + # Expected gRPC response from the stub + grpc_response = SomeResponse(data="mocked_data") + + # Function to assert the result returned by your client method + def assert_client_result(result: SomeClientReturnType) -> None: + # If your client method returns the raw gRPC response: + # assert result == grpc_response + # If your client method processes the response: + assert isinstance(result, SomeClientReturnType) + assert result.processed_data == "mocked_data_processed" + ``` + +Example: Error test case + ```python title="client_test_cases/some_unary_method/error_case.py" + from grpc import StatusCode + from some_api_pb2 import SomeRequest # type: ignore + from some_client_module import ClientSpecificError # type: ignore + from grpc.aio import AioRpcError + + from tests.util import make_grpc_error + + client_args = ("non_existent_id",) + client_kwargs = {} + + def assert_stub_method_call(request: SomeRequest) -> None: + assert request.id == "non_existent_id" + + # Simulate a gRPC error + grpc_response = make_grpc_error(StatusCode.NOT_FOUND, details="Item not found") + + # Assert that the client raises a specific exception + def assert_client_exception(exception: Exception) -> None: + assert isinstance(exception, ClientSpecificError) + assert "not found" in str(exception).lower() + assert isinstance(exception.__cause__, AioRpcError) + assert exception.__cause__.code() == StatusCode.NOT_FOUND + ``` + +### Streaming Method Test Cases + +Example: Success test case + ```python title="client_test_cases/some_stream_method/success_case.py" + from frequenz.channels import Receiver, ReceiverStoppedError + import pytest # For pytest.raises + + from some_api_pb2 import SomeStreamRequest, SomeStreamResponse # type: ignore + # Type of items from client stream + from some_client_module import SomeClientStreamItemType # type: ignore + + client_args = (10,) # e.g., client.some_stream_method(10) + client_kwargs = {} + + def assert_stub_method_call(request: SomeStreamRequest) -> None: + assert isinstance(request, SomeStreamRequest) + assert request.count == 10 + + # For streams, grpc_response can be an iterable of response messages + grpc_response = [ + SomeStreamResponse(item_id=1, value="first"), + SomeStreamResponse(item_id=2, value="second"), + ] + # Alternatively, it can be a generator function, async iterable, etc. + # (see detailed guide below). + + async def assert_client_result( + receiver: Receiver[SomeClientStreamItemType] + ) -> None: + result = await receiver.receive() + assert isinstance(result, SomeClientStreamItemType) + assert result.id == 1 and result.val == "first_processed" + + result = await receiver.receive() + assert result.id == 2 and result.val == "second_processed" + + with pytest.raises(ReceiverStoppedError): + await receiver.receive() + ``` + +Example: Error on stream initiation + ```python title="client_test_cases/some_stream_method/call_error_case.py" + # Replace with your actual protobuf message types and client error + from some_api_pb2 import SomeStreamRequest # type: ignore + from some_client_module import ClientSpecificError # type: ignore + from grpc import StatusCode + from grpc.aio import AioRpcError + + from tests.util import make_grpc_error + + # Arguments to pass to your client.some_stream_method() + client_args = (10,) # e.g., client.some_stream_method(10) + client_kwargs = {} + + # Function to assert the gRPC request sent by the client + def assert_stub_method_call(request: SomeStreamRequest) -> None: + assert isinstance(request, SomeStreamRequest) + assert request.count == 10 # Example assertion + + # Simulate a gRPC error when the stream is initiated + grpc_response = make_grpc_error(StatusCode.UNAVAILABLE, details="Service temporarily down") + + # Assert that the client method itself raises an exception immediately + def assert_client_exception(exception: Exception) -> None: + assert isinstance(exception, ClientSpecificError) # Or the specific error you expect + assert "unavailable" in str(exception).lower() or \ + "service temporarily down" in str(exception).lower() + assert isinstance(exception.__cause__, AioRpcError) + assert exception.__cause__.code() == StatusCode.UNAVAILABLE + ``` + +Example: Error during the stream + ```python title="client_test_cases/some_stream_method/stream_error_case.py" + from collections.abc import AsyncIterator + import pytest # For pytest.raises + + # Replace with your actual protobuf message types and client error + from some_api_pb2 import SomeStreamRequest, SomeStreamResponse # type: ignore + from some_client_module import ClientSpecificError, SomeClientStreamItemType # type: ignore + from grpc import StatusCode + from grpc.aio import AioRpcError + from frequenz.channels import Receiver # If client returns a receiver + + from tests.util import make_grpc_error + + # Arguments to pass to your client.some_stream_method() + client_args = (10,) # e.g., client.some_stream_method(10) + client_kwargs = {} + + # Function to assert the gRPC request sent by the client + def assert_stub_method_call(request: SomeStreamRequest) -> None: + assert isinstance(request, SomeStreamRequest) + assert request.count == 10 # Example assertion + + # This example simulates an error after the first item is received. + _iterations = 0 + async def grpc_response() -> AsyncIterator[Any]: + global _iterations # pylint: disable=global-statement + _iterations += 1 + if _iterations == 1: + raise make_grpc_error(StatusCode.INTERNAL) + yield microgrid_pb2.ReceiveComponentDataStreamResponse( + data=components_pb2.ComponentData(component_id=1, metric_samples=[], states=[]), + ) + + + # Assert that the client handles the error as expected. + async def assert_client_result( + receiver: Receiver[SomeClientStreamItemType] # Or your client's return type + ) -> None: + # Check for the first successfully received item + result = await receiver.receive() + assert isinstance(result, SomeClientStreamItemType) + assert result.id == 1 and result.val == "first_ok_processed" # Example processing + + # Expect an error when trying to receive the next item + with pytest.raises(ClientSpecificError) as exc_info: + await receiver.receive() + + assert "internal" in str(exc_info.value).lower() # Check error message + assert isinstance(exc_info.value.__cause__, AioRpcError) + assert exc_info.value.__cause__.code() == StatusCode.INTERNAL + ``` + +# In-Depth Guide to Writing Test Cases + +This guide walks you through the process of setting up and writing test cases +for your gRPC client methods. + +## Set Up Your Main Test File + +Start by creating a Python file for your client tests, typically named +`test_.py` (e.g., `test_some_client.py`) in your `tests/` +directory. + +In this file: + +1. **Import necessary modules**: `pytest`, your client class (e.g., + `SomeApiClient`), and from this `tests.util` module: + [`ApiClientTestCaseSpec`][tests.util.ApiClientTestCaseSpec], + [`get_test_specs()`][tests.util.get_test_specs], and usually + [`patch_client_class()`][tests.util.patch_client_class]. +2. **Define `TESTS_DIR`**: Create a `pathlib.Path` object pointing to the + directory where your test case definition files will reside. A common + convention is `Path(__file__).parent / "client_test_cases"`. +3. **Create a `client` fixture**: This `pytest` fixture should provide an + instance of your client, properly mocked to prevent actual network calls. + Use the [`patch_client_class()`][tests.util.patch_client_class] utility as + shown in the Quick Start Guide. This fixture will be injected into your test + functions. + +## Write Test Functions for Each Client Method + +For each client method you want to test (e.g., `get_component_info`), create a +corresponding asynchronous test function in your main test file (e.g., +`async def test_get_component_info(...)`). + +1. Parameterize with `get_test_specs`: + * Use the `@pytest.mark.parametrize("spec", ...)` decorator. + + * Call `get_test_specs("client_method_name", tests_dir=TESTS_DIR)`. + + * `"client_method_name"`: This string should match the name of the + subdirectory you'll create under `TESTS_DIR` for this method's test + cases. It often corresponds to the Python name of your client method + (e.g., `"get_component_info"`). + * `tests_dir=TESTS_DIR`: Pass the path defined in Step 1. + + * Optionally, use `ids=str` in `parametrize` to get clearer test names in + `pytest` output, based on the test case filenames. + +2. **Call the appropriate `spec` method**: + * The `spec` parameter injected into your test function is an instance of + `ApiClientTestCaseSpec`. + * If the client method performs a **unary-unary** gRPC call (single request, + single response), call: + `await spec.test_unary_unary_call(client, "ActualGrpcMethodName")` + * If the client method performs a **unary-stream** gRPC call (single + request, stream of responses), call: + `await spec.test_unary_stream_call(client, "ActualGrpcStreamMethodName")` + * `client`: The client instance from your fixture. + * `"ActualGrpcMethodName"`: This is a string representing the **exact name + of the method on the gRPC stub** that your client method calls + internally (e.g., `"GetComponentInfo"` if the stub has a method + `Stub.GetComponentInfo(...)`). + +## Authoring Test Case Files + +For each client method, you'll create multiple test case files, each +representing a specific scenario (e.g., successful call, specific error, +particular input leading to unique behavior). + +### Directory Structure + +Under your `TESTS_DIR` (e.g., `tests/client_test_cases/`), create a +subdirectory named exactly as the first argument you passed to +`get_test_specs` (e.g., `get_component_info/`). + +### Test Case Files + +Inside this method-specific subdirectory, create Python files (`.py`) for each +test scenario. The filenames should be descriptive (e.g., `success_case.py`, +`not_found_error_case.py`, `empty_list_response_case.py`). By default, +`get_test_specs` looks for files ending in `_case.py`. + +### Variables in a Test Case File + +Each test case `.py` file is a module that defines several top-level variables. +These variables instruct the test framework on how to mock the gRPC call and +what to assert. + +#### `client_args` (Optional) + +* **Type**: `tuple[Any, ...]` +* **Default**: `()` (empty tuple) +* **Purpose**: Positional arguments to pass to your client method when it's + called during the test. + Example: + ```python + client_args = (12345, "component_type_filter") + ``` + +#### `client_kwargs` (Optional) + +* **Type**: `dict[str, Any]` +* **Default**: `{}` (empty dictionary) +* **Purpose**: Keyword arguments to pass to your client method. + Example: + ```python + client_kwargs = {"timeout_sec": 10, "include_details": True} + ``` + +#### `assert_stub_method_call` (Required) + +* **Type**: `Callable[[RequestProto], None]` where `RequestProto` is the type + of the gRPC request message for the stub method being called. +* **Purpose**: A function that validates the request object sent by your client + method to the gRPC stub. This function receives the actual gRPC request + object (as prepared by your client code) as its sole argument. Use `assert` + statements within this function to check its fields and values. + Example: + ```python + from some_api_pb2 import GetComponentInfoRequest # Your request protobuf type + + def assert_stub_method_call(request: GetComponentInfoRequest) -> None: + assert isinstance(request, GetComponentInfoRequest) + assert request.component_id == 12345 + assert request.type_filter == "component_type_filter" + ``` + +#### `grpc_response` (Required) + +This variable defines the mock response or error the gRPC stub should produce. + +* **Type**: Varies based on call type (unary/stream) and expected outcome + (success/error). +* **Purpose**: To simulate the gRPC service's behavior. + +* **For Unary-Unary Calls**: + * **Successful Response**: The `grpc_response` should be an instance of the + expected gRPC response protobuf message. + Example: + ```python + from some_api_pb2 import ComponentInfoResponse + grpc_response = ComponentInfoResponse(name="Test Component", status="ACTIVE") + ``` + * **gRPC Error**: If the gRPC call itself is expected to fail (e.g., + `StatusCode.NOT_FOUND`), `grpc_response` should be an exception + instance, typically `grpc.aio.AioRpcError`. Use the + [`make_grpc_error`][tests.util.make_grpc_error] utility for this. + Example: + ```python + from grpc import StatusCode + from tests.util import make_grpc_error + grpc_response = make_grpc_error(StatusCode.NOT_FOUND, details="Component not found") + ``` + +* **For Unary-Stream Calls**: + * **Successful Stream**: `grpc_response` can be any of the following to + represent the stream of messages from the server: + * An **iterable** of response protobuf messages (e.g., a `list` or + `tuple`). + * A **synchronous generator function** that yields response messages. + * An **asynchronous iterable** of response protobuf messages. + * An **asynchronous generator function** that yields response messages. + * A **callable** that, when called, returns any of the above + (iterable, generator, etc.). + Examples: + ```python + from some_api_pb2 import StreamDataItem + # List of messages + grpc_response = [ + StreamDataItem(value=1.0, timestamp=100), + StreamDataItem(value=1.1, timestamp=101), + ] + + # Generator function + def generate_responses(): + yield StreamDataItem(value=2.0, timestamp=200) + yield StreamDataItem(value=2.1, timestamp=201) + grpc_response = generate_responses + + # Async generator function + async def generate_async_responses(): + yield StreamDataItem(value=3.0, timestamp=300) + # Simulate async work if needed: await asyncio.sleep(0.01) + yield StreamDataItem(value=3.1, timestamp=301) + grpc_response = generate_async_responses + ``` + * **gRPC Error (for the stream call itself)**: If the initial gRPC call to + start the stream is expected to fail, `grpc_response` should be an + exception instance (e.g., `AioRpcError` from `make_grpc_error`). + Example: + ```python + from grpc import StatusCode + from tests.util import make_grpc_error + grpc_response = make_grpc_error(StatusCode.UNAUTHENTICATED, details="Missing auth token") + ``` + *Note*: If an error is expected *during* an otherwise successful stream + (i.e., the stream starts, sends some items, then an error occurs that the + client should handle via the stream itself), this should typically be + modeled by having your iterable/generator raise the error at the + appropriate point, or by having the client method itself catch and + transform this into a client-level exception that + `assert_client_exception` can check. + +#### Asserting Client Behavior (Choose ONE) + +You must define *exactly one* of the following two variables to assert the +final outcome of your client method call. + +##### `assert_client_result` (Conditional) + +* **Type**: `Callable[[ClientResultType], None | Awaitable[None]]` where + `ClientResultType` is the type of the value returned by your client method. +* **Purpose**: To validate the result returned by your client method if it's + expected to complete successfully. This function receives the client + method's return value as its argument. It can be a synchronous or an + asynchronous function (e.g., if you need to `await` something while + asserting, or if the client returns an async iterator that needs to be + consumed). +* **When to use**: When `grpc_response` is configured for a successful gRPC + call (not an exception), and you expect your client method to process this + and return a value or an async iterator. + Example (Unary): + ```python + from some_client_module import ProcessedComponentInfo + def assert_client_result(result: ProcessedComponentInfo) -> None: + assert isinstance(result, ProcessedComponentInfo) + assert result.name_upper == "TEST COMPONENT" + ``` + Example (Stream - consuming an async iterator): + ```python + from some_client_module import ClientStreamItem + async def assert_client_result(stream: AsyncIterator[ClientStreamItem]) -> None: + items = [item async for item in stream] + assert len(items) == 2 + assert items[0].value_plus_one == 2.0 + ``` + +##### `assert_client_exception` (Conditional) + +* **Type**: `Callable[[Exception], None]` +* **Purpose**: To validate an exception that you expect your client method to + raise. This function receives the actual exception object raised by the + client method as its argument. +* **When to use**: When `grpc_response` is an exception (simulating a gRPC + error), or when your client method's internal logic is expected to raise an + exception based on the gRPC response or other conditions. + Example: + ```python + from some_client_module import ClientSpecificError + from grpc import StatusCode + from grpc.aio import AioRpcError + + def assert_client_exception(exception: Exception) -> None: + assert isinstance(exception, ClientSpecificError) + assert "Component could not be found" in str(exception) + # Optionally, check the cause if your client wraps gRPC errors + assert isinstance(exception.__cause__, AioRpcError) + assert exception.__cause__.code() == StatusCode.NOT_FOUND + ``` + +## Understanding Test Execution + +When you run `pytest`, here's how your test cases are processed: + +1. **Discovery**: `pytest` discovers your `test_...()` functions. +2. **Parameterization**: For each such function, `get_test_specs()` is called. + It scans the specified subdirectory in `TESTS_DIR` for `*_case.py` files. +3. **Spec Creation**: For each found file, an `ApiClientTestCaseSpec` object is + created. This object holds metadata about the test case file. +4. **Test Invocation**: `pytest` calls your test function once for each + `ApiClientTestCaseSpec` object. +5. **Execution via `spec` methods**: + * Inside your test function, when you call + `await spec.test_unary_unary_call(...)` or + `await spec.test_unary_stream_call(...)`: + a. **Loading**: The `spec` object loads the Python module corresponding + to the current test case file (e.g., `success_case.py`). + b. **Parsing**: It reads the variables (`client_args`, `grpc_response`, + etc.) you defined in that file. + c. **Mocking**: The gRPC stub method (e.g., + `client.stub.ActualGrpcMethodName`) on your client instance is + patched with a mock. + d. **Stub Behavior**: This mock is configured to behave according to + your `grpc_response` variable (i.e., return the specified value(s), + stream items, or raise the specified exception). + e. **Client Call**: Your actual client method (e.g., + `client.get_component_info()`) is called using the `client_args` and + `client_kwargs` from the test case file. + f. **Request Assertion**: After your client method calls the (now + mocked) stub method, your `assert_stub_method_call` function is + invoked with the gRPC request object that your client constructed and + sent to the stub. + g. **Outcome Assertion**: + * If the client method call resulted in an exception: If + `assert_client_exception` is defined in your test case file, it's + called with the raised exception. If `assert_client_result` was + defined instead, the test fails (and vice-versa). + * If the client method call returned a result: If + `assert_client_result` is defined, it's called with the returned + result. If `assert_client_exception` was defined instead, the + test fails. +6. **Pass/Fail**: If all assertions within `assert_stub_method_call` and the + relevant `assert_client_result`/`assert_client_exception` pass, and no + unexpected exceptions occur, the individual test scenario passes. + +This structured approach allows for clear separation of test logic (in your +main test file) from test data and specific scenario definitions (in your test +case files), promoting maintainability and readability. +''' + +from __future__ import annotations + +import asyncio +import functools +import gc +import importlib +import inspect +import itertools +import logging +import sys +from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Iterable +from contextlib import AsyncExitStack, ContextDecorator, aclosing +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Generic, Protocol, TypeVar, get_args, get_origin +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from frequenz.client.base.client import BaseApiClient +from grpc import StatusCode +from grpc.aio import AioRpcError, Channel, Metadata + +_logger = logging.getLogger(__name__) + +StubT = TypeVar("StubT") +"""Type variable for the gRPC stub type.""" + +ClientT = TypeVar("ClientT", bound=BaseApiClient[Any]) +"""Type variable for the client type.""" + + +@dataclass(frozen=True, kw_only=True) +class ApiClientTestCase: + """A single test case for a gRPC client method.""" + + client_args: tuple[Any, ...] + """The positional arguments to use when calling the client method being tested.""" + + client_kwargs: dict[str, Any] + """The keyword arguments to use when calling the client method being tested.""" + + assert_stub_method_call: Callable[[Any], None] + """The assertion function to validate the gRPC request done by the client. + + The assertion function takes the actual gRPC request that was done, and should + make assertions on it to validate that it matches the expected request. + """ + + grpc_response: Any + """The response or exception to use to mock the gRPC call. + + If this is an exception, it will be raised when the gRPC call is made. + If this is a value, it will be returned as the response. + """ + + assert_client_result: ( + Callable[[Any], None] | Callable[[Any], Awaitable[None]] | None + ) = None + """The assertion function to validate the result returned by the client. + + The assertion function takes the actual result returned by the client method, + and it should make assertions on it to validate that it matches the expected + result. + + This is only used if the gRPC call does not raise an exception. + """ + + assert_client_exception: Callable[[Exception], None] | None = None + """The assertion function to validate the exception raised by the client. + + The assertion function takes the actual exception raised by the client method, + and it should make assertions on it to validate that it matches the expected + exception. + + This is only used if the gRPC call raises an exception. + """ + + def __post_init__(self) -> None: + """Post-initialization checks for the TestCase class.""" + if self.assert_client_result is None and self.assert_client_exception is None: + raise ValueError( + "Either assert_client_result or assert_client_exception must be provided." + ) + if ( + self.assert_client_result is not None + and self.assert_client_exception is not None + ): + raise ValueError( + "Only one of assert_client_result or assert_client_exception must be provided." + ) + + +@dataclass(frozen=True, kw_only=True) +class ApiClientTestCaseSpec: + """A specification for a test case. + + This is used to load the test case data from a file and run the test. + """ + + name: str + """The name of the test case.""" + + client_method_name: str + """The name of the gRPC client method being tested.""" + + path: Path + """The absolute path to the test case file.""" + + relative_path: Path + """The test case file path relative to current working directory.""" + + def __str__(self) -> str: + """Return a string representation of the test case specification.""" + return self.name + + def load_test_module(self) -> Any: + """Return the loaded test case module from the test case file.""" + module_name = self.path.stem + if module_name in sys.modules: + raise ValueError( + f"The module name for test case {self.name} is already in use" + ) + + # Register the module name with pytest to allow for better error reporting + # when the test case fails. + pytest.register_assert_rewrite(module_name) + + # We load the module as a top-level module to avoid requiring adding + # `__init__.py` files to the test directories. We make sure to unload + # the module (and other modules that might have been loaded by the test + # case) after the test case is run to avoid polluting the module namespace. + original_modules = sys.modules.copy() + original_sys_path = sys.path.copy() + sys.path.insert(0, str(self.path.parent)) + try: + module = importlib.import_module(module_name) + except ImportError as exc: + raise ImportError( + f"Test case {self.name} could not be imported from {self.relative_path}, " + f"make sure the file exists and is a valid Python module: {exc}" + ) from exc + finally: + sys.path = original_sys_path + sys.modules = original_modules + importlib.invalidate_caches() + gc.collect() + + return module + + def load_test_case(self) -> ApiClientTestCase: + """Return the loaded test case from the test case file.""" + module = self.load_test_module() + + required_attrs = ["assert_stub_method_call", "grpc_response"] + if missing_attrs := [ + attr for attr in required_attrs if not hasattr(module, attr) + ]: + raise AttributeError( + f"Test case file {self.relative_path} is missing required attributes: " + + ", ".join(missing_attrs) + ) + + try: + test_case = ApiClientTestCase( + client_args=getattr(module, "client_args", ()), + client_kwargs=getattr(module, "client_kwargs", {}), + assert_stub_method_call=module.assert_stub_method_call, + grpc_response=module.grpc_response, + assert_client_result=getattr(module, "assert_client_result", None), + assert_client_exception=getattr( + module, "assert_client_exception", None + ), + ) + except ValueError as exc: + raise ValueError( + f"Test case file {self.relative_path} is invalid: {exc}" + ) from exc + + return test_case + + async def test_call( + self, + *, + client: ClientProtocol, + stub_method_name: str, + call_client_method: Callable[ + [ClientProtocol, str, ApiClientTestCase, AsyncExitStack], + Awaitable[tuple[MagicMock, Any, Exception | None]], + ], + exit_stack: AsyncExitStack, + ) -> None: + """Run a test for a unary-unary gRPC call.""" + _logger.debug( + "Running test case %r for `%s()` (%s)", + self.name, + self.client_method_name, + stub_method_name, + ) + test_case = self.load_test_case() + _logger.debug("Loaded test case %r from %s", self.name, self.relative_path) + client_should_raise = test_case.assert_client_exception is not None + + # Call the client method and collect the result/exception + stub_method_mock, client_result, client_raised_exception = ( + await call_client_method(client, stub_method_name, test_case, exit_stack) + ) + + if client_raised_exception is not None: + if not client_should_raise: + # Expected a result, but got an exception. Test premise failed. + # We raise an AssertionError here to indicate that the test case + # failed, but we chain it to the original exception to keep the + # original traceback. + # We need to check this before running the assert_stub_method_call() because + # if an exception was raised, the stub method might not have been + # called at all. + _logger.debug( + "Raising AssertionError because the client raised an unexpected exception: %r", + client_raised_exception, + ) + raise AssertionError( + f"{self.relative_path}: The client call to method {self.client_method_name}() " + f"raised an exception {client_raised_exception!r}, but a result was expected " + "(the test case provided a assert_client_result() function and not a " + "assert_client_exception() function)" + ) from client_raised_exception + + _logger.debug( + "The client raised an expected exception, calling `assert_client_exception(%r)`", + client_raised_exception, + ) + # Expected an exception, and got one, so run the user's + # assertion function on the exception before we validate the + # gRPC call, because if the wrong exception was raised, the stub + # method might not have been called at all. + # We also chain the exception to the original exception to keep the + # original traceback for a better debugging experience. + assert test_case.assert_client_exception is not None + try: + test_case.assert_client_exception(client_raised_exception) + except AssertionError as err: + raise err from client_raised_exception + + # Validate the gRPC stub call was made correctly + # This will report any failed assertions as a test FAIL, and any other + # unexpected exception as a test ERROR, always pointing to the exact + # location where the issue originated. + test_case.assert_stub_method_call(stub_method_mock) + + if client_raised_exception is None: + if client_should_raise: + # Expected an exception, but got a result. Test premise failed. + pytest.fail( + f"{self.relative_path}: The client call to method " + f"{self.client_method_name}() didn't raise the expected exception " + f"{test_case.grpc_response!r}, instead it returned {client_result!r}", + pytrace=False, + ) + + # Expected a result, and got one, so run the user's assertion + # function on the result. + elif test_case.assert_client_result is None: + pytest.fail( + f"{self.relative_path}: The client method " + f"{self.client_method_name}() returned a result, but an " + "exception was expected (the test case provided a " + "assert_client_exception() function and not a " + "assert_client_result() function)", + pytrace=False, + ) + + if inspect.iscoroutinefunction(test_case.assert_client_result): + _logger.debug("Awaiting `assert_client_result(%r)`", client_result) + async with asyncio.timeout(60): + await test_case.assert_client_result(client_result) + else: + _logger.debug("Calling `assert_client_result(%r)`", client_result) + test_case.assert_client_result(client_result) + + async def test_unary_unary_call( + self, + client: ClientProtocol, + stub_method_name: str, + ) -> None: + """Run a test for a unary-unary gRPC call.""" + async with AsyncExitStack() as exit_stack: + await self.test_call( + client=client, + stub_method_name=stub_method_name, + call_client_method=self.call_unary_method, + exit_stack=exit_stack, + ) + + async def test_unary_stream_call( + self, + client: ClientProtocol, + stub_method_name: str, + ) -> None: + """Run a test for a unary-stream gRPC call.""" + async with AsyncExitStack() as exit_stack: + await self.test_call( + client=client, + stub_method_name=stub_method_name, + call_client_method=self.call_stream_method, + exit_stack=exit_stack, + ) + + async def call_unary_method( + self, + client: ClientProtocol, + stub_method_name: str, + test_case: ApiClientTestCase, + _: AsyncExitStack, + ) -> tuple[AsyncMock, Any, Exception | None]: + """Call a unary method on the client.""" + _logger.debug("Preparing stub gRPC unary call `%s()`", stub_method_name) + # Prepare the mock for the gRPC stub method + stub_method_mock = AsyncMock(name=stub_method_name) + if isinstance(test_case.grpc_response, Exception): + stub_method_mock.side_effect = test_case.grpc_response + else: + stub_method_mock.return_value = test_case.grpc_response + _logger.debug( + "Patching %s.%s with %s", client.stub, stub_method_name, stub_method_mock + ) + setattr(client.stub, stub_method_name, stub_method_mock) + + # Call the client method and collect the result/exception + client_method = getattr(client, self.client_method_name) + # We use a separate variable for the result if it is an exception to be able + # to support weird cases where the method actually returns an exception + # instead of raising it. + client_result: Any = None + client_raised_exception: Exception | None = None + try: + _logger.debug( + "Calling client method `%s(*%r, **%r)`", + self.client_method_name, + test_case.client_args, + test_case.client_kwargs, + ) + client_result = await client_method( + *test_case.client_args, **test_case.client_kwargs + ) + _logger.debug("Client method result: %r", client_result) + except Exception as err: # pylint: disable=broad-exception-caught + _logger.debug("Client method raised an exception: %r", err) + client_raised_exception = err + + return (stub_method_mock, client_result, client_raised_exception) + + async def call_stream_method( + self, + client: ClientProtocol, + stub_method_name: str, + test_case: ApiClientTestCase, + exit_stack: AsyncExitStack, + ) -> tuple[MagicMock, Any, Exception | None]: + """Call a stream method on the client.""" + _logger.debug("Preparing stub gRPC stream call `%s()`", stub_method_name) + stub_method_mock = MagicMock(name=stub_method_name) + + if isinstance(test_case.grpc_response, Exception): + _logger.debug( + "`grpc_response` is an exception, setting as side_effect: %r", + test_case.grpc_response, + ) + stub_method_mock.side_effect = test_case.grpc_response + else: + + def create_response_wrapper(*_: Any, **__: Any) -> AsyncIterator[Any]: + """Create a response wrapper for the gRPC response.""" + wrapper = _IterableResponseWrapper(test_case.grpc_response) + exit_stack.push_async_exit(aclosing(wrapper)) + return wrapper + + stub_method_mock.side_effect = create_response_wrapper + _logger.debug( + "Patching %s.%s with %s", client.stub, stub_method_name, stub_method_mock + ) + setattr(client.stub, stub_method_name, stub_method_mock) + + # Call the client method and collect the result/exception + client_method = getattr(client, self.client_method_name) + # We use a separate variable for the result if it is an exception to be able + # to support weird cases where the method actually returns an exception + # instead of raising it. + client_result: Any = None + client_raised_exception: Exception | None = None + try: + _logger.debug( + "Calling client method `%s(*%r, **%r)`", + self.client_method_name, + test_case.client_args, + test_case.client_kwargs, + ) + client_result = client_method( + *test_case.client_args, **test_case.client_kwargs + ) + _logger.debug("Client method result: %r", client_result) + except Exception as err: # pylint: disable=broad-exception-caught + _logger.debug("Client method raised an exception: %r", err) + client_raised_exception = err + + # Yield control to allow the gRPC streamer to start running + await asyncio.sleep(0) + + return (stub_method_mock, client_result, client_raised_exception) + + +def get_test_specs( + client_method_name: str, + *, + tests_dir: str | Path, + suffixes: Iterable[str] = ("_case",), +) -> Iterable[ApiClientTestCaseSpec]: + """Get all test names for a specific stub call. + + Args: + client_method_name: The name of the client method being tested. + tests_dir: The directory where the test cases are located (inside the + `client_method_name` sub-directory). + suffixes: The file suffixes to look for. + + Returns: + A iterable of test case specs. + + Raises: + ValueError: If the test directory does not exist or is not a directory, + the `test_cases_subdir` is not a relative path, or if no test files + are found in the test directory. + """ + tests_dir = Path(tests_dir) + if not tests_dir.is_absolute(): + raise ValueError(f"{tests_dir} must be an absolute path") + + test_dir = tests_dir / client_method_name + if not test_dir.exists(): + raise ValueError(f"Tests directory {test_dir} does not exist") + if not test_dir.is_dir(): + raise ValueError(f"Tests directory {test_dir} is not a directory") + + specs = list( + itertools.chain( + ( + ApiClientTestCaseSpec( + name=p.stem[: -len(suffix)], + client_method_name=client_method_name, + path=p.resolve(), + relative_path=p.relative_to(Path.cwd()), + ) + for suffix in suffixes + for p in test_dir.glob(f"*{suffix}.py") + ) + ) + ) + if not specs: + globs = [f"*{suffix}.py" for suffix in suffixes] + raise ValueError( + f"No test files found in {test_dir} matching {', '.join(globs)}" + ) + + return specs + + +class ClientProtocol(Protocol): + """Protocol for client objects with a stub property.""" + + @property + def stub(self) -> Any: + """Return the gRPC stub.""" + ... # pylint: disable=unnecessary-ellipsis + + +def make_grpc_error( + code: StatusCode, + *, + initial_metadata: Metadata = Metadata(), + trailing_metadata: Metadata = Metadata(), + details: str | None = None, + debug_error_string: str | None = None, +) -> AioRpcError: + """Create a gRPC error for testing purposes.""" + return AioRpcError( + code=code, + initial_metadata=initial_metadata, + trailing_metadata=trailing_metadata, + details=details, + debug_error_string=debug_error_string, + ) + + +# generic_cls uses Any because it doesn't really take a `type` (which might be +# what looks more intuitive), technically is a `typing._GenericAlias`, but this +# is not a public API and we don't want to depend on it. There is also +# `types.GenericAlias` but this one is only used for built-in generics, like +# `list[int]`, so we can't use it either. +@functools.lru_cache(maxsize=1024) +def is_subclass_of_generic(cls: type[Any], generic_cls: Any) -> bool: + """Return whether `cls` is a subclass of a parameterized generic `generic_cls`. + + Check at runtime whether `cls` is a subclass of a parameterized generic + `generic_cls`., e.g. `is_subclass_generic(DerivedInt, GenericBase[int])`. + + Args: + cls: The class to check. + generic_cls: The parameterized generic type to check against. + + Returns: + True if `cls` is a subclass of `generic_cls`, False otherwise. + + Raises: + TypeError: If `generic_cls` is not a parameterized generic type. + """ + # Check if 'generic_cls' is actually a parameterized generic type + # (like list[int], GenericBase[str], etc.). + # get_origin returns None for non-generics or non-parameterized generics. + origin = get_origin(generic_cls) + if origin is None: + raise TypeError(f"generic_cls {generic_cls!r} must be a parameterized generic") + + # First check the raw generic relationship (e.g., is DerivedInt a subclass + # of GenericBase?). + if not issubclass(cls, origin): + return False + + # Inspect __orig_bases__ throughout the MRO (Method Resolution Order). + # This handles inheritance chains correctly (sub-sub classes). + # We iterate through getmro(cls) to check not just direct parents, but all + # ancestors. + for base in inspect.getmro(cls): + # __orig_bases__ stores the base classes *as they were written*, + # including type parameters. Might not exist on all classes (like 'object'). + # getattr avoids an AttributeError if __orig_bases__ is missing. + # Python3.12 provides types.get_original_bases(cls) to get __orig_bases__, + # this can be updated when we drop support for older versions. + for orig_base in getattr(base, "__orig_bases__", ()): + # Check if the origin of this specific original base matches our + # target origin AND if the arguments match our target arguments. + # get_args returns a tuple, so this correctly handles multi-generic + # bases by comparing tuples element-wise (e.g., (str, int) == (str, + # int)). + if get_origin(orig_base) is origin and get_args(orig_base) == get_args( + generic_cls + ): + return True + + return False + + +class patch_client_class( # pylint: disable=invalid-name + ContextDecorator, Generic[ClientT, StubT] +): + """Patches the client class for testing. + + This avoids the class to really connect anywhere, and creates a mock + channel and stub instead. + + It can be used as a context manager or decorator. + + Example: Usage as a context manager + + ```python + @patch_client_class(SomeApiClient, SomeApiStub) + def test_some_function(client_class: SomeApiClient): + client = client_class(...) + client.stub.some_method.return_value = ... + # Your test code here + ``` + + Example: Usage as a decorator + ```python + def test_some_function(): + with patch_client_class(SomeApiClient, SomeApiStub) as client_class: + client = client_class(...) + client.stub.some_method.return_value = ... + # Your test code here + ``` + """ + + def __init__(self, client_class: type[ClientT], stub_class: type[StubT]) -> None: + """Context manager that patches the client for testing. + + Args: + client_class: The client class to patch. + stub_class: The stub class to patch. + """ + # We need the type ignores here because: + # 1. mypy doesn't consider types hashable (needed for the + # is_subclass_of_generic cache), but they are, based on their memory + # address, which is enough for us. + # 2. mypy expect classes, TypeVar or other type expressions, but we are + # using a *regular variable* here. In general this is wrong, and + # can't be properly type checked, but it does what it should at + # runtime. + assert is_subclass_of_generic( + client_class, BaseApiClient[stub_class] # type: ignore[valid-type] + ) + self._client_class: type[ClientT] = client_class + self._patched_client_class = patch.object( + client_class, "connect", autospec=True, side_effect=self._fake_connect + ) + + def __enter__(self) -> type[ClientT]: + """Enter the context manager.""" + self._patched_client_class.__enter__() + return self._client_class + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit the context manager.""" + self._patched_client_class.__exit__(*args, **kwargs) + + def _fake_connect( + self, + client: ClientT, + server_url: str | None = None, + auth_key: str | None = None, # pylint: disable=unused-argument + sign_secret: str | None = None, # pylint: disable=unused-argument + ) -> None: + """Fake connect method that does nothing.""" + # pylint: disable=protected-access + if server_url is not None and server_url != client._server_url: # URL changed + client._server_url = server_url + elif client.is_connected: + return + client._channel = MagicMock(name="_channel", spec=Channel) + # We don't spec the stub because we would need the `AsyncStub` for that, + # but it only exists for type hints, so it can't be used at runtime. + client._stub = MagicMock(name="_stub") + # pylint: enable=protected-access + + +async def _iter_to_async_iter(it: Iterable[Any]) -> AsyncIterator[Any]: + """Return an async iterator from an iterable.""" + for item in it: + yield item + + +class _IterableResponseWrapper(AsyncIterator[Any]): + """Wrap a response to make it an async iterator. + + Supports + """ + + def __init__(self, response: Any) -> None: + """Initialize the wrapper with the response.""" + self._response = response + self._iter_is_async = False + self._iter_is_generator = False + self._iter: Any + + if inspect.isasyncgenfunction(response): + _logger.debug( + "`grpc_response` is an async generator function: %r", response + ) + self._iter_is_async = True + self._iter_is_generator = True + self._iter = response() + elif inspect.isgeneratorfunction(response): + _logger.debug("`grpc_response` is a generator function: %r", response) + self._iter_is_generator = True + self._iter = response() + elif inspect.isasyncgen(response): + _logger.debug("`grpc_response` is an async generator: %r", response) + self._iter_is_async = True + self._iter_is_generator = True + self._iter = response + elif inspect.isgenerator(response): + _logger.debug("`grpc_response` is a generator: %r", response) + self._iter_is_generator = True + self._iter = response + elif isinstance(response, AsyncIterable): + _logger.debug("`grpc_response` is an async iterable: %r", response) + self._iter_is_async = True + self._iter = aiter(response) + # We check for str and bytes here because they are iterable, but it + # would be very unlikely that users want to use them as iterator. + # If they do, they can just use grpc_response = iter([...]) to explicitly + # create an iterator from it. + elif isinstance(response, (str, bytes)): + _logger.debug( + "`grpc_response` is a string or bytes, wrapping in a list as an iterator: %r", + response, + ) + self._iter = iter([response]) + elif isinstance(response, Iterable): + _logger.debug("`grpc_response` is an iterable: %r", response) + self._iter = iter(response) + else: + _logger.debug( + "`grpc_response` is not iterable, wrapping in a list as an iterator: %r", + response, + ) + self._iter = iter([response]) + + def __aiter__(self) -> _IterableResponseWrapper: + """Return the iterator.""" + return self + + async def __anext__(self) -> Any: + """Return the next item from the iterator.""" + if self._iter_is_async: + _logger.debug("`grpc_response` is async, awaiting next item") + return await anext(self._iter) + + try: + _logger.debug("`grpc_response` is sync, getting next item without await") + return next(self._iter) + except StopIteration as exc: + raise StopAsyncIteration from exc + + async def aclose(self) -> None: + """Close the iterator.""" + if self._iter_is_generator: + if self._iter_is_async: + _logger.debug( + "`grpc_response` is async generator, awaiting for `aclose()`" + ) + await self._iter.aclose() + else: + _logger.debug("`grpc_response` is generator, calling `close()`") + self._iter.close()