diff --git a/src/frequenz/client/microgrid/__init__.py b/src/frequenz/client/microgrid/__init__.py index 8b1a3e26..f8559a75 100644 --- a/src/frequenz/client/microgrid/__init__.py +++ b/src/frequenz/client/microgrid/__init__.py @@ -11,6 +11,7 @@ DEFAULT_CHANNEL_OPTIONS, DEFAULT_GRPC_CALL_TIMEOUT, MicrogridApiClient, + Validity, ) from ._delivery_area import DeliveryArea, EnergyMarketCodeType from ._exception import ( @@ -69,4 +70,5 @@ "ServiceUnavailable", "UnknownError", "UnrecognizedGrpcStatus", + "Validity", ] diff --git a/src/frequenz/client/microgrid/_client.py b/src/frequenz/client/microgrid/_client.py index 7ba65b6a..fa67b438 100644 --- a/src/frequenz/client/microgrid/_client.py +++ b/src/frequenz/client/microgrid/_client.py @@ -6,12 +6,17 @@ from __future__ import annotations import asyncio +import enum import itertools +import math +from collections.abc import Iterable from dataclasses import replace -from typing import Any +from datetime import datetime, timedelta +from typing import Any, assert_never -from frequenz.api.microgrid.v1 import microgrid_pb2_grpc -from frequenz.client.base import channel, client, retry, streaming +from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2 +from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc +from frequenz.client.base import channel, client, conversion, retry, streaming from frequenz.client.common.microgrid.components import ComponentId from google.protobuf.empty_pb2 import Empty from typing_extensions import override @@ -19,6 +24,9 @@ from ._exception import ClientNotConnected from ._microgrid_info import MicrogridInfo from ._microgrid_info_proto import microgrid_info_from_proto +from .component._component import Component +from .metrics._bounds import Bounds +from .metrics._metric import Metric DEFAULT_GRPC_CALL_TIMEOUT = 60.0 """The default timeout for gRPC calls made by this client (in seconds).""" @@ -139,7 +147,7 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly) The information about the local microgrid. Raises: - ApiClientError: If the are any errors communicating with the Microgrid API, + ApiClientError: If there are any errors communicating with the Microgrid API, most likely a subclass of [GrpcError][frequenz.client.microgrid.GrpcError]. """ @@ -153,3 +161,314 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly) ) return microgrid_info_from_proto(microgrid.microgrid) + + async def set_component_power_active( # noqa: DOC502 (raises ApiClientError indirectly) + self, + component: ComponentId | Component, + power: float, + *, + request_lifetime: timedelta | None = None, + validate_arguments: bool = True, + ) -> datetime | None: + """Set the active power output of a component. + + The power output can be negative or positive, depending on whether the component + is supposed to be discharging or charging, respectively. + + The power output is specified in watts. + + The return value is the timestamp until which the given power command will + stay in effect. After this timestamp, the component's active power will be + set to 0, if the API receives no further command to change it before then. + By default, this timestamp will be set to the current time plus 60 seconds. + + Note: + The target component may have a resolution of more than 1 W. E.g., an + inverter may have a resolution of 88 W. In such cases, the magnitude of + power will be floored to the nearest multiple of the resolution. + + Args: + component: The component to set the output active power of. + power: The output active power level, in watts. Negative values are for + discharging, and positive values are for charging. + request_lifetime: The duration, until which the request will stay in effect. + This duration has to be between 10 seconds and 15 minutes (including + both limits), otherwise the request will be rejected. It has + a resolution of a second, so fractions of a second will be rounded for + `timedelta` objects, and it is interpreted as seconds for `int` objects. + If not provided, it usually defaults to 60 seconds. + validate_arguments: Whether to validate the arguments before sending the + request. If `True` a `ValueError` will be raised if an argument is + invalid without even sending the request to the server, if `False`, the + request will be sent without validation. + + Returns: + The timestamp until which the given power command will stay in effect, or + `None` if it was not provided by the server. + + Raises: + ApiClientError: If there are any errors communicating with the Microgrid API, + most likely a subclass of + [GrpcError][frequenz.client.microgrid.GrpcError]. + """ + lifetime_seconds = _delta_to_seconds(request_lifetime) + + if validate_arguments: + _validate_set_power_args(power=power, request_lifetime=lifetime_seconds) + + response = await client.call_stub_method( + self, + lambda: self.stub.SetComponentPowerActive( + microgrid_pb2.SetComponentPowerActiveRequest( + component_id=_get_component_id(component), + power=power, + request_lifetime=lifetime_seconds, + ), + timeout=DEFAULT_GRPC_CALL_TIMEOUT, + ), + method_name="SetComponentPowerActive", + ) + + if response.HasField("valid_until"): + return conversion.to_datetime(response.valid_until) + + return None + + async def set_component_power_reactive( # noqa: DOC502 (raises ApiClientError indirectly) + self, + component: ComponentId | Component, + power: float, + *, + request_lifetime: timedelta | None = None, + validate_arguments: bool = True, + ) -> datetime | None: + """Set the reactive power output of a component. + + We follow the polarity specified in the IEEE 1459-2010 standard + definitions, where: + + - Positive reactive is inductive (current is lagging the voltage) + - Negative reactive is capacitive (current is leading the voltage) + + The power output is specified in VAr. + + The return value is the timestamp until which the given power command will + stay in effect. After this timestamp, the component's reactive power will + be set to 0, if the API receives no further command to change it before + then. By default, this timestamp will be set to the current time plus 60 + seconds. + + Note: + The target component may have a resolution of more than 1 VAr. E.g., an + inverter may have a resolution of 88 VAr. In such cases, the magnitude of + power will be floored to the nearest multiple of the resolution. + + Args: + component: The component to set the output reactive power of. + power: The output reactive power level, in VAr. The standard of polarity is + as per the IEEE 1459-2010 standard definitions: positive reactive is + inductive (current is lagging the voltage); negative reactive is + capacitive (current is leading the voltage). + request_lifetime: The duration, until which the request will stay in effect. + This duration has to be between 10 seconds and 15 minutes (including + both limits), otherwise the request will be rejected. It has + a resolution of a second, so fractions of a second will be rounded for + `timedelta` objects, and it is interpreted as seconds for `int` objects. + If not provided, it usually defaults to 60 seconds. + validate_arguments: Whether to validate the arguments before sending the + request. If `True` a `ValueError` will be raised if an argument is + invalid without even sending the request to the server, if `False`, the + request will be sent without validation. + + Returns: + The timestamp until which the given power command will stay in effect, or + `None` if it was not provided by the server. + + Raises: + ApiClientError: If there are any errors communicating with the Microgrid API, + most likely a subclass of + [GrpcError][frequenz.client.microgrid.GrpcError]. + """ + lifetime_seconds = _delta_to_seconds(request_lifetime) + + if validate_arguments: + _validate_set_power_args(power=power, request_lifetime=lifetime_seconds) + + response = await client.call_stub_method( + self, + lambda: self.stub.SetComponentPowerReactive( + microgrid_pb2.SetComponentPowerReactiveRequest( + component_id=_get_component_id(component), + power=power, + request_lifetime=lifetime_seconds, + ), + timeout=DEFAULT_GRPC_CALL_TIMEOUT, + ), + method_name="SetComponentPowerReactive", + ) + + if response.HasField("valid_until"): + return conversion.to_datetime(response.valid_until) + + return None + + async def add_component_bounds( # noqa: DOC502 (Raises ApiClientError indirectly) + self, + component: ComponentId | Component, + target: Metric | int, + bounds: Iterable[Bounds], + *, + validity: Validity | None = None, + ) -> datetime | None: + """Add inclusion bounds for a given metric of a given component. + + The bounds are used to define the acceptable range of values for a metric + of a component. The added bounds are kept only temporarily, and removed + automatically after some expiry time. + + Inclusion bounds give the range that the system will try to keep the + metric within. If the metric goes outside of these bounds, the system will + try to bring it back within the bounds. + If the bounds for a metric are `[[lower_1, upper_1], [lower_2, upper_2]]`, + then this metric's `value` needs to comply with the constraints `lower_1 <= + value <= upper_1` OR `lower_2 <= value <= upper_2`. + + If multiple inclusion bounds have been provided for a metric, then the + overlapping bounds are merged into a single bound, and non-overlapping + bounds are kept separate. + + Example: + If the bounds are [[0, 10], [5, 15], [20, 30]], then the resulting bounds + will be [[0, 15], [20, 30]]. + + The following diagram illustrates how bounds are applied: + + ``` + lower_1 upper_1 + <----|========|--------|========|--------> + lower_2 upper_2 + ``` + + The bounds in this example are `[[lower_1, upper_1], [lower_2, upper_2]]`. + + ``` + ---- values here are considered out of range. + ==== values here are considered within range. + ``` + + Note: + For power metrics, regardless of the bounds, 0W is always allowed. + + Args: + component: The component to add bounds to. + target: The target metric whose bounds have to be added. + bounds: The bounds to add to the target metric. Overlapping pairs of bounds + are merged into a single pair of bounds, and non-overlapping ones are + kept separated. + validity: The duration for which the given bounds will stay in effect. + If `None`, then the bounds will be removed after some default time + decided by the server, typically 5 seconds. + + The duration for which the bounds are valid. If not provided, the + bounds are considered to be valid indefinitely. + + Returns: + The timestamp until which the given bounds will stay in effect, or `None` if + if it was not provided by the server. + + Raises: + ApiClientError: If there are any errors communicating with the Microgrid API, + most likely a subclass of + [GrpcError][frequenz.client.microgrid.GrpcError]. + """ + extra_args = {} + if validity is not None: + extra_args["validity_duration"] = validity.value + response = await client.call_stub_method( + self, + lambda: self.stub.AddComponentBounds( + microgrid_pb2.AddComponentBoundsRequest( + component_id=_get_component_id(component), + target_metric=_get_metric_value(target), + bounds=( + bounds_pb2.Bounds( + lower=bound.lower, + upper=bound.upper, + ) + for bound in bounds + ), + **extra_args, + ), + timeout=DEFAULT_GRPC_CALL_TIMEOUT, + ), + method_name="AddComponentBounds", + ) + + if response.HasField("ts"): + return conversion.to_datetime(response.ts) + + return None + + +class Validity(enum.Enum): + """The duration for which a given list of bounds will stay in effect.""" + + FIVE_SECONDS = ( + microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_5_SECONDS + ) + """The bounds will stay in effect for 5 seconds.""" + + ONE_MINUTE = ( + microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_1_MINUTE + ) + """The bounds will stay in effect for 1 minute.""" + + FIVE_MINUTES = ( + microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_5_MINUTES + ) + """The bounds will stay in effect for 5 minutes.""" + + FIFTEEN_MINUTES = ( + microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_15_MINUTES + ) + """The bounds will stay in effect for 15 minutes.""" + + +def _get_component_id(component: ComponentId | Component) -> int: + """Get the component ID from a component or component ID.""" + match component: + case ComponentId(): + return int(component) + case Component(): + return int(component.id) + case unexpected: + assert_never(unexpected) + + +def _get_metric_value(metric: Metric | int) -> metric_sample_pb2.Metric.ValueType: + """Get the metric ID from a metric or metric ID.""" + match metric: + case Metric(): + return metric_sample_pb2.Metric.ValueType(metric.value) + case int(): + return metric_sample_pb2.Metric.ValueType(metric) + case unexpected: + assert_never(unexpected) + + +def _delta_to_seconds(delta: timedelta | None) -> int | None: + """Convert a `timedelta` to seconds (or `None` if `None`).""" + return round(delta.total_seconds()) if delta is not None else None + + +def _validate_set_power_args(*, power: float, request_lifetime: int | None) -> None: + """Validate the request lifetime.""" + if math.isnan(power): + raise ValueError("power cannot be NaN") + if request_lifetime is not None: + minimum_lifetime = 10 # 10 seconds + maximum_lifetime = 900 # 15 minutes + if not minimum_lifetime <= request_lifetime <= maximum_lifetime: + raise ValueError( + "request_lifetime must be between 10 seconds and 15 minutes" + ) diff --git a/src/frequenz/client/microgrid/_lifetime_proto.py b/src/frequenz/client/microgrid/_lifetime_proto.py new file mode 100644 index 00000000..c4667634 --- /dev/null +++ b/src/frequenz/client/microgrid/_lifetime_proto.py @@ -0,0 +1,26 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Loading of Lifetime objects from protobuf messages.""" + +from frequenz.api.common.v1.microgrid import lifetime_pb2 +from frequenz.client.base.conversion import to_datetime + +from ._lifetime import Lifetime + + +def lifetime_from_proto( + message: lifetime_pb2.Lifetime, +) -> Lifetime: + """Create a [`Lifetime`][frequenz.client.microgrid.Lifetime] from a protobuf message.""" + start = ( + to_datetime(message.start_timestamp) + if message.HasField("start_timestamp") + else None + ) + end = ( + to_datetime(message.end_timestamp) + if message.HasField("end_timestamp") + else None + ) + return Lifetime(start=start, end=end) diff --git a/src/frequenz/client/microgrid/component/__init__.py b/src/frequenz/client/microgrid/component/__init__.py new file mode 100644 index 00000000..9543e938 --- /dev/null +++ b/src/frequenz/client/microgrid/component/__init__.py @@ -0,0 +1,14 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""All classes and functions related to microgrid components.""" + +from ._category import ComponentCategory +from ._component import Component +from ._status import ComponentStatus + +__all__ = [ + "Component", + "ComponentCategory", + "ComponentStatus", +] diff --git a/src/frequenz/client/microgrid/component/_category.py b/src/frequenz/client/microgrid/component/_category.py new file mode 100644 index 00000000..1f5bff0e --- /dev/null +++ b/src/frequenz/client/microgrid/component/_category.py @@ -0,0 +1,80 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""The component categories that can be used in a microgrid.""" + +import enum + +from frequenz.api.common.v1.microgrid.components import components_pb2 + + +@enum.unique +class ComponentCategory(enum.Enum): + """The known categories of components that can be present in a microgrid.""" + + UNSPECIFIED = components_pb2.COMPONENT_CATEGORY_UNSPECIFIED + """The component category is unspecified, probably due to an error in the message.""" + + GRID = components_pb2.COMPONENT_CATEGORY_GRID + """The point where the local microgrid is connected to the grid.""" + + METER = components_pb2.COMPONENT_CATEGORY_METER + """A meter, for measuring electrical metrics, e.g., current, voltage, etc.""" + + INVERTER = components_pb2.COMPONENT_CATEGORY_INVERTER + """An electricity generator, with batteries or solar energy.""" + + CONVERTER = components_pb2.COMPONENT_CATEGORY_CONVERTER + """A DC-DC converter.""" + + BATTERY = components_pb2.COMPONENT_CATEGORY_BATTERY + """A storage system for electrical energy, used by inverters.""" + + EV_CHARGER = components_pb2.COMPONENT_CATEGORY_EV_CHARGER + """A station for charging electrical vehicles.""" + + CRYPTO_MINER = components_pb2.COMPONENT_CATEGORY_CRYPTO_MINER + """A crypto miner.""" + + ELECTROLYZER = components_pb2.COMPONENT_CATEGORY_ELECTROLYZER + """An electrolyzer for converting water into hydrogen and oxygen.""" + + CHP = components_pb2.COMPONENT_CATEGORY_CHP + """A heat and power combustion plant (CHP stands for combined heat and power).""" + + RELAY = components_pb2.COMPONENT_CATEGORY_RELAY + """A relay. + + Relays generally have two states: open (connected) and closed (disconnected). + They are generally placed in front of a component, e.g., an inverter, to + control whether the component is connected to the grid or not. + """ + + PRECHARGER = components_pb2.COMPONENT_CATEGORY_PRECHARGER + """A precharge module. + + Precharging involves gradually ramping up the DC voltage to prevent any + potential damage to sensitive electrical components like capacitors. + + While many inverters and batteries come equipped with in-built precharging + mechanisms, some may lack this feature. In such cases, we need to use + external precharging modules. + """ + + FUSE = components_pb2.COMPONENT_CATEGORY_FUSE + """A fuse.""" + + VOLTAGE_TRANSFORMER = components_pb2.COMPONENT_CATEGORY_VOLTAGE_TRANSFORMER + """A voltage transformer. + + Voltage transformers are used to step up or step down the voltage, keeping + the power somewhat constant by increasing or decreasing the current. If voltage is + stepped up, current is stepped down, and vice versa. + + Note: + Voltage transformers have efficiency losses, so the output power is + always less than the input power. + """ + + HVAC = components_pb2.COMPONENT_CATEGORY_HVAC + """A Heating, Ventilation, and Air Conditioning (HVAC) system.""" diff --git a/src/frequenz/client/microgrid/component/_component.py b/src/frequenz/client/microgrid/component/_component.py new file mode 100644 index 00000000..1c182e5e --- /dev/null +++ b/src/frequenz/client/microgrid/component/_component.py @@ -0,0 +1,157 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Base component from which all other components inherit.""" + +import dataclasses +import logging +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Any, Self + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from .._lifetime import Lifetime +from ..metrics._bounds import Bounds +from ..metrics._metric import Metric +from ._category import ComponentCategory +from ._status import ComponentStatus + +_logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Component: # pylint: disable=too-many-instance-attributes + """A base class for all components.""" + + id: ComponentId + """This component's ID.""" + + microgrid_id: MicrogridId + """The ID of the microgrid this component belongs to.""" + + category: ComponentCategory | int + """The category of this component. + + Note: + This should not be used normally, you should test if a component + [`isinstance`][] of a concrete component class instead. + + It is only provided for using with a newer version of the API where the client + doesn't know about a new category yet, and in case some low level code needs to + know the category of a component. + """ + + status: ComponentStatus | int = ComponentStatus.UNSPECIFIED + """The status of this component. + + Tip: + You can also use + [`is_active_now()`][frequenz.client.microgrid.component.Component.is_active_now] + or + [`is_active_at()`][frequenz.client.microgrid.component.Component.is_active_at], + which also checks if the component is operational. + """ + + name: str | None = None + """The name of this component.""" + + manufacturer: str | None = None + """The manufacturer of this component.""" + + model_name: str | None = None + """The model name of this component.""" + + operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime) + """The operational lifetime of this component.""" + + rated_bounds: Mapping[Metric | int, Bounds] = dataclasses.field( + default_factory=dict, + # dict is not hashable, so we don't use this field to calculate the hash. This + # shouldn't be a problem since it is very unlikely that two components with all + # other attributes being equal would have different category specific metadata, + # so hash collisions should be still very unlikely. + hash=False, + ) + """List of rated bounds present for the component identified by Metric.""" + + category_specific_metadata: Mapping[str, Any] = dataclasses.field( + default_factory=dict, + # dict is not hashable, so we don't use this field to calculate the hash. This + # shouldn't be a problem since it is very unlikely that two components with all + # other attributes being equal would have different category specific metadata, + # so hash collisions should be still very unlikely. + hash=False, + ) + """The category specific metadata of this component. + + Note: + This should not be used normally, it is only useful when accessing a newer + version of the API where the client doesn't know about the new metadata fields + yet. + """ + + def __new__(cls, *_: Any, **__: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is Component: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + def is_active_at(self, timestamp: datetime) -> bool: + """Check whether this component is active at a specific timestamp. + + A component is considered active if it is in the active state and is + operational at the given timestamp. The operational lifetime is used to + determine whether the component is operational at the given timestamp. + + If a component has an unspecified status, it is assumed to be active + and a warning is logged. + + Args: + timestamp: The timestamp to check. + + Returns: + Whether this component is active at the given timestamp. + """ + if self.status is ComponentStatus.UNSPECIFIED: + _logger.warning( + "Component %s has an unspecified status. Assuming it is active.", + self, + ) + return self.operational_lifetime.is_operational_at(timestamp) + + return ( + self.status is ComponentStatus.ACTIVE + and self.operational_lifetime.is_operational_at(timestamp) + ) + + def is_active_now(self) -> bool: + """Check whether this component is currently active. + + A component is considered active if it is in the active state and is + operational at the current time. The operational lifetime is used to + determine whether the component is operational at the current time. + + If a component has an unspecified status, it is assumed to be active + and a warning is logged. + + Returns: + Whether this component is active at the current time. + """ + return self.is_active_at(datetime.now(timezone.utc)) + + @property + def identity(self) -> tuple[ComponentId, MicrogridId]: + """The identity of this component. + + This uses the component ID and microgrid ID to identify a component + without considering the other attributes, so even if a component state + changed, the identity remains the same. + """ + return (self.id, self.microgrid_id) + + def __str__(self) -> str: + """Return a human-readable string representation of this instance.""" + name = f":{self.name}" if self.name else "" + return f"{self.id}<{type(self).__name__}>{name}" diff --git a/src/frequenz/client/microgrid/component/_status.py b/src/frequenz/client/microgrid/component/_status.py new file mode 100644 index 00000000..727ee248 --- /dev/null +++ b/src/frequenz/client/microgrid/component/_status.py @@ -0,0 +1,22 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Status for a component.""" + +import enum + +from frequenz.api.common.v1.microgrid.components import components_pb2 + + +@enum.unique +class ComponentStatus(enum.Enum): + """The known statuses of a component.""" + + UNSPECIFIED = components_pb2.COMPONENT_STATUS_UNSPECIFIED + """The status is unspecified.""" + + ACTIVE = components_pb2.COMPONENT_STATUS_ACTIVE + """The component is active.""" + + INACTIVE = components_pb2.COMPONENT_STATUS_INACTIVE + """The component is inactive.""" diff --git a/src/frequenz/client/microgrid/metrics/__init__.py b/src/frequenz/client/microgrid/metrics/__init__.py new file mode 100644 index 00000000..85c80fff --- /dev/null +++ b/src/frequenz/client/microgrid/metrics/__init__.py @@ -0,0 +1,15 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Metrics definitions.""" + +from ._bounds import Bounds +from ._metric import Metric +from ._sample import AggregatedMetricValue, AggregationMethod + +__all__ = [ + "AggregatedMetricValue", + "AggregationMethod", + "Bounds", + "Metric", +] diff --git a/src/frequenz/client/microgrid/metrics/_bounds.py b/src/frequenz/client/microgrid/metrics/_bounds.py new file mode 100644 index 00000000..de59bde5 --- /dev/null +++ b/src/frequenz/client/microgrid/metrics/_bounds.py @@ -0,0 +1,45 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + + +"""Definitions for bounds.""" + +import dataclasses + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Bounds: + """A set of lower and upper bounds for any metric. + + The lower bound must be less than or equal to the upper bound. + + The units of the bounds are always the same as the related metric. + """ + + lower: float | None = None + """The lower bound. + + If `None`, there is no lower bound. + """ + + upper: float | None = None + """The upper bound. + + If `None`, there is no upper bound. + """ + + def __post_init__(self) -> None: + """Validate these bounds.""" + if self.lower is None: + return + if self.upper is None: + return + if self.lower > self.upper: + raise ValueError( + f"Lower bound ({self.lower}) must be less than or equal to upper " + f"bound ({self.upper})" + ) + + def __str__(self) -> str: + """Return a string representation of these bounds.""" + return f"[{self.lower}, {self.upper}]" diff --git a/src/frequenz/client/microgrid/metrics/_bounds_proto.py b/src/frequenz/client/microgrid/metrics/_bounds_proto.py new file mode 100644 index 00000000..2a40f64a --- /dev/null +++ b/src/frequenz/client/microgrid/metrics/_bounds_proto.py @@ -0,0 +1,17 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Loading of Bounds objects from protobuf messages.""" + + +from frequenz.api.common.v1.metrics import bounds_pb2 + +from ._bounds import Bounds + + +def bounds_from_proto(message: bounds_pb2.Bounds) -> Bounds: + """Create a `Bounds` from a protobuf message.""" + return Bounds( + lower=message.lower if message.HasField("lower") else None, + upper=message.upper if message.HasField("upper") else None, + ) diff --git a/src/frequenz/client/microgrid/metrics/_metric.py b/src/frequenz/client/microgrid/metrics/_metric.py new file mode 100644 index 00000000..4754e88c --- /dev/null +++ b/src/frequenz/client/microgrid/metrics/_metric.py @@ -0,0 +1,264 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Supported metrics for microgrid components.""" + + +import enum + +from frequenz.api.common.v1.metrics import metric_sample_pb2 + + +@enum.unique +class Metric(enum.Enum): + """List of supported metrics. + + Note: AC energy metrics information + - This energy metric is reported directly from the component, and not a + result of aggregations in our systems. If a component does not have this + metric, this field cannot be populated. + + - Components that provide energy metrics reset this metric from time to + time. This behaviour is specific to each component model. E.g., some + components reset it on UTC 00:00:00. + + - This energy metric does not specify the start time of the accumulation + period,and therefore can be inconsistent. + """ + + UNSPECIFIED = metric_sample_pb2.METRIC_UNSPECIFIED + """The metric is unspecified (this should not be used).""" + + DC_VOLTAGE = metric_sample_pb2.METRIC_DC_VOLTAGE + """The direct current voltage.""" + + DC_CURRENT = metric_sample_pb2.METRIC_DC_CURRENT + """The direct current current.""" + + DC_POWER = metric_sample_pb2.METRIC_DC_POWER + """The direct current power.""" + + AC_FREQUENCY = metric_sample_pb2.METRIC_AC_FREQUENCY + """The alternating current frequency.""" + + AC_VOLTAGE = metric_sample_pb2.METRIC_AC_VOLTAGE + """The alternating current electric potential difference.""" + + AC_VOLTAGE_PHASE_1_N = metric_sample_pb2.METRIC_AC_VOLTAGE_PHASE_1_N + """The alternating current electric potential difference between phase 1 and neutral.""" + + AC_VOLTAGE_PHASE_2_N = metric_sample_pb2.METRIC_AC_VOLTAGE_PHASE_2_N + """The alternating current electric potential difference between phase 2 and neutral.""" + + AC_VOLTAGE_PHASE_3_N = metric_sample_pb2.METRIC_AC_VOLTAGE_PHASE_3_N + """The alternating current electric potential difference between phase 3 and neutral.""" + + AC_VOLTAGE_PHASE_1_PHASE_2 = metric_sample_pb2.METRIC_AC_VOLTAGE_PHASE_1_PHASE_2 + """The alternating current electric potential difference between phase 1 and phase 2.""" + + AC_VOLTAGE_PHASE_2_PHASE_3 = metric_sample_pb2.METRIC_AC_VOLTAGE_PHASE_2_PHASE_3 + """The alternating current electric potential difference between phase 2 and phase 3.""" + + AC_VOLTAGE_PHASE_3_PHASE_1 = metric_sample_pb2.METRIC_AC_VOLTAGE_PHASE_3_PHASE_1 + """The alternating current electric potential difference between phase 3 and phase 1.""" + + AC_CURRENT = metric_sample_pb2.METRIC_AC_CURRENT + """The alternating current current.""" + + AC_CURRENT_PHASE_1 = metric_sample_pb2.METRIC_AC_CURRENT_PHASE_1 + """The alternating current current in phase 1.""" + + AC_CURRENT_PHASE_2 = metric_sample_pb2.METRIC_AC_CURRENT_PHASE_2 + """The alternating current current in phase 2.""" + + AC_CURRENT_PHASE_3 = metric_sample_pb2.METRIC_AC_CURRENT_PHASE_3 + """The alternating current current in phase 3.""" + + AC_APPARENT_POWER = metric_sample_pb2.METRIC_AC_APPARENT_POWER + """The alternating current apparent power.""" + + AC_APPARENT_POWER_PHASE_1 = metric_sample_pb2.METRIC_AC_APPARENT_POWER_PHASE_1 + """The alternating current apparent power in phase 1.""" + + AC_APPARENT_POWER_PHASE_2 = metric_sample_pb2.METRIC_AC_APPARENT_POWER_PHASE_2 + """The alternating current apparent power in phase 2.""" + + AC_APPARENT_POWER_PHASE_3 = metric_sample_pb2.METRIC_AC_APPARENT_POWER_PHASE_3 + """The alternating current apparent power in phase 3.""" + + AC_ACTIVE_POWER = metric_sample_pb2.METRIC_AC_ACTIVE_POWER + """The alternating current active power.""" + + AC_ACTIVE_POWER_PHASE_1 = metric_sample_pb2.METRIC_AC_ACTIVE_POWER_PHASE_1 + """The alternating current active power in phase 1.""" + + AC_ACTIVE_POWER_PHASE_2 = metric_sample_pb2.METRIC_AC_ACTIVE_POWER_PHASE_2 + """The alternating current active power in phase 2.""" + + AC_ACTIVE_POWER_PHASE_3 = metric_sample_pb2.METRIC_AC_ACTIVE_POWER_PHASE_3 + """The alternating current active power in phase 3.""" + + AC_REACTIVE_POWER = metric_sample_pb2.METRIC_AC_REACTIVE_POWER + """The alternating current reactive power.""" + + AC_REACTIVE_POWER_PHASE_1 = metric_sample_pb2.METRIC_AC_REACTIVE_POWER_PHASE_1 + """The alternating current reactive power in phase 1.""" + + AC_REACTIVE_POWER_PHASE_2 = metric_sample_pb2.METRIC_AC_REACTIVE_POWER_PHASE_2 + """The alternating current reactive power in phase 2.""" + + AC_REACTIVE_POWER_PHASE_3 = metric_sample_pb2.METRIC_AC_REACTIVE_POWER_PHASE_3 + """The alternating current reactive power in phase 3.""" + + AC_POWER_FACTOR = metric_sample_pb2.METRIC_AC_POWER_FACTOR + """The alternating current power factor.""" + + AC_POWER_FACTOR_PHASE_1 = metric_sample_pb2.METRIC_AC_POWER_FACTOR_PHASE_1 + """The alternating current power factor in phase 1.""" + + AC_POWER_FACTOR_PHASE_2 = metric_sample_pb2.METRIC_AC_POWER_FACTOR_PHASE_2 + """The alternating current power factor in phase 2.""" + + AC_POWER_FACTOR_PHASE_3 = metric_sample_pb2.METRIC_AC_POWER_FACTOR_PHASE_3 + """The alternating current power factor in phase 3.""" + + AC_APPARENT_ENERGY = metric_sample_pb2.METRIC_AC_APPARENT_ENERGY + """The alternating current apparent energy.""" + + AC_APPARENT_ENERGY_PHASE_1 = metric_sample_pb2.METRIC_AC_APPARENT_ENERGY_PHASE_1 + """The alternating current apparent energy in phase 1.""" + + AC_APPARENT_ENERGY_PHASE_2 = metric_sample_pb2.METRIC_AC_APPARENT_ENERGY_PHASE_2 + """The alternating current apparent energy in phase 2.""" + + AC_APPARENT_ENERGY_PHASE_3 = metric_sample_pb2.METRIC_AC_APPARENT_ENERGY_PHASE_3 + """The alternating current apparent energy in phase 3.""" + + AC_ACTIVE_ENERGY = metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY + """The alternating current active energy.""" + + AC_ACTIVE_ENERGY_PHASE_1 = metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_PHASE_1 + """The alternating current active energy in phase 1.""" + + AC_ACTIVE_ENERGY_PHASE_2 = metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_PHASE_2 + """The alternating current active energy in phase 2.""" + + AC_ACTIVE_ENERGY_PHASE_3 = metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_PHASE_3 + """The alternating current active energy in phase 3.""" + + AC_ACTIVE_ENERGY_CONSUMED = metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_CONSUMED + """The alternating current active energy consumed.""" + + AC_ACTIVE_ENERGY_CONSUMED_PHASE_1 = ( + metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_CONSUMED_PHASE_1 + ) + """The alternating current active energy consumed in phase 1.""" + + AC_ACTIVE_ENERGY_CONSUMED_PHASE_2 = ( + metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_CONSUMED_PHASE_2 + ) + """The alternating current active energy consumed in phase 2.""" + + AC_ACTIVE_ENERGY_CONSUMED_PHASE_3 = ( + metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_CONSUMED_PHASE_3 + ) + """The alternating current active energy consumed in phase 3.""" + + AC_ACTIVE_ENERGY_DELIVERED = metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_DELIVERED + """The alternating current active energy delivered.""" + + AC_ACTIVE_ENERGY_DELIVERED_PHASE_1 = ( + metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_DELIVERED_PHASE_1 + ) + """The alternating current active energy delivered in phase 1.""" + + AC_ACTIVE_ENERGY_DELIVERED_PHASE_2 = ( + metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_DELIVERED_PHASE_2 + ) + """The alternating current active energy delivered in phase 2.""" + + AC_ACTIVE_ENERGY_DELIVERED_PHASE_3 = ( + metric_sample_pb2.METRIC_AC_ACTIVE_ENERGY_DELIVERED_PHASE_3 + ) + """The alternating current active energy delivered in phase 3.""" + + AC_REACTIVE_ENERGY = metric_sample_pb2.METRIC_AC_REACTIVE_ENERGY + """The alternating current reactive energy.""" + + AC_REACTIVE_ENERGY_PHASE_1 = metric_sample_pb2.METRIC_AC_REACTIVE_ENERGY_PHASE_1 + """The alternating current reactive energy in phase 1.""" + + AC_REACTIVE_ENERGY_PHASE_2 = metric_sample_pb2.METRIC_AC_REACTIVE_ENERGY_PHASE_2 + """The alternating current reactive energy in phase 2.""" + + AC_REACTIVE_ENERGY_PHASE_3 = metric_sample_pb2.METRIC_AC_REACTIVE_ENERGY_PHASE_3 + """The alternating current reactive energy in phase 3.""" + + AC_TOTAL_HARMONIC_DISTORTION_CURRENT = ( + metric_sample_pb2.METRIC_AC_TOTAL_HARMONIC_DISTORTION_CURRENT + ) + """The alternating current total harmonic distortion current.""" + + AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_1 = ( + metric_sample_pb2.METRIC_AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_1 + ) + """The alternating current total harmonic distortion current in phase 1.""" + + AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_2 = ( + metric_sample_pb2.METRIC_AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_2 + ) + """The alternating current total harmonic distortion current in phase 2.""" + + AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_3 = ( + metric_sample_pb2.METRIC_AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_3 + ) + """The alternating current total harmonic distortion current in phase 3.""" + + BATTERY_CAPACITY = metric_sample_pb2.METRIC_BATTERY_CAPACITY + """The capacity of the battery.""" + + BATTERY_SOC_PCT = metric_sample_pb2.METRIC_BATTERY_SOC_PCT + """The state of charge of the battery as a percentage.""" + + BATTERY_TEMPERATURE = metric_sample_pb2.METRIC_BATTERY_TEMPERATURE + """The temperature of the battery.""" + + INVERTER_TEMPERATURE = metric_sample_pb2.METRIC_INVERTER_TEMPERATURE + """The temperature of the inverter.""" + + INVERTER_TEMPERATURE_CABINET = metric_sample_pb2.METRIC_INVERTER_TEMPERATURE_CABINET + """The temperature of the inverter cabinet.""" + + INVERTER_TEMPERATURE_HEATSINK = ( + metric_sample_pb2.METRIC_INVERTER_TEMPERATURE_HEATSINK + ) + """The temperature of the inverter heatsink.""" + + INVERTER_TEMPERATURE_TRANSFORMER = ( + metric_sample_pb2.METRIC_INVERTER_TEMPERATURE_TRANSFORMER + ) + """The temperature of the inverter transformer.""" + + EV_CHARGER_TEMPERATURE = metric_sample_pb2.METRIC_EV_CHARGER_TEMPERATURE + """The temperature of the EV charger.""" + + SENSOR_WIND_SPEED = metric_sample_pb2.METRIC_SENSOR_WIND_SPEED + """The speed of the wind measured.""" + + SENSOR_WIND_DIRECTION = metric_sample_pb2.METRIC_SENSOR_WIND_DIRECTION + """The direction of the wind measured.""" + + SENSOR_TEMPERATURE = metric_sample_pb2.METRIC_SENSOR_TEMPERATURE + """The temperature measured.""" + + SENSOR_RELATIVE_HUMIDITY = metric_sample_pb2.METRIC_SENSOR_RELATIVE_HUMIDITY + """The relative humidity measured.""" + + SENSOR_DEW_POINT = metric_sample_pb2.METRIC_SENSOR_DEW_POINT + """The dew point measured.""" + + SENSOR_AIR_PRESSURE = metric_sample_pb2.METRIC_SENSOR_AIR_PRESSURE + """The air pressure measured.""" + + SENSOR_IRRADIANCE = metric_sample_pb2.METRIC_SENSOR_IRRADIANCE + """The irradiance measured.""" diff --git a/src/frequenz/client/microgrid/metrics.py b/src/frequenz/client/microgrid/metrics/_sample.py similarity index 100% rename from src/frequenz/client/microgrid/metrics.py rename to src/frequenz/client/microgrid/metrics/_sample.py diff --git a/src/frequenz/client/microgrid/metrics/_sample_proto.py b/src/frequenz/client/microgrid/metrics/_sample_proto.py new file mode 100644 index 00000000..cfd2a038 --- /dev/null +++ b/src/frequenz/client/microgrid/metrics/_sample_proto.py @@ -0,0 +1,27 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Loading of MetricSample and AggregatedMetricValue objects from protobuf messages.""" + +from frequenz.api.common.v1.metrics import metric_sample_pb2 + +from ._sample import AggregatedMetricValue + + +def aggregated_metric_sample_from_proto( + message: metric_sample_pb2.AggregatedMetricValue, +) -> AggregatedMetricValue: + """Convert a protobuf message to a `AggregatedMetricValue` object. + + Args: + message: The protobuf message to convert. + + Returns: + The resulting `AggregatedMetricValue` object. + """ + return AggregatedMetricValue( + avg=message.avg_value, + min=message.min_value if message.HasField("min_value") else None, + max=message.max_value if message.HasField("max_value") else None, + raw_values=message.raw_values, + ) diff --git a/tests/client_test_cases/add_component_bounds/error_case.py b/tests/client_test_cases/add_component_bounds/error_case.py new file mode 100644 index 00000000..51f89858 --- /dev/null +++ b/tests/client_test_cases/add_component_bounds/error_case.py @@ -0,0 +1,29 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test add_component_bounds call with error.""" + +from typing import Any + +from frequenz.client.common.microgrid.components import ComponentId +from grpc import StatusCode + +from frequenz.client.microgrid import PermissionDenied +from frequenz.client.microgrid.metrics import Bounds, Metric +from tests.util import make_grpc_error + +client_args = (ComponentId(1), Metric.AC_VOLTAGE, [Bounds(lower=200.0, upper=250.0)]) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + # We are not testing the request here, just the error handling + + +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/add_component_bounds/no_validity_case.py b/tests/client_test_cases/add_component_bounds/no_validity_case.py new file mode 100644 index 00000000..bff849b0 --- /dev/null +++ b/tests/client_test_cases/add_component_bounds/no_validity_case.py @@ -0,0 +1,35 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for add_component_bounds call without validity.""" + +from typing import Any + +from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2 +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid.metrics import Bounds, Metric + +client_args = (ComponentId(1), Metric.AC_VOLTAGE, [Bounds(lower=200.0, upper=250.0)]) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.AddComponentBoundsRequest( + component_id=1, + target_metric=metric_sample_pb2.Metric.METRIC_AC_VOLTAGE, + bounds=[bounds_pb2.Bounds(lower=200.0, upper=250.0)], + # No validity field + ), + timeout=60.0, + ) + + +grpc_response = microgrid_pb2.AddComponentBoundsResponse() + + +def assert_client_result(result: Any) -> None: + """Assert that the client result is None as expected.""" + assert result is None diff --git a/tests/client_test_cases/add_component_bounds/success_case.py b/tests/client_test_cases/add_component_bounds/success_case.py new file mode 100644 index 00000000..23749044 --- /dev/null +++ b/tests/client_test_cases/add_component_bounds/success_case.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for successful component bounds addition.""" + +from datetime import datetime, timezone +from typing import Any + +from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2 +from frequenz.api.microgrid.v1 import microgrid_pb2 +from frequenz.client.base.conversion import to_timestamp +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid import Validity +from frequenz.client.microgrid._client import DEFAULT_GRPC_CALL_TIMEOUT +from frequenz.client.microgrid.metrics import Bounds, Metric + +client_args = ( + ComponentId(1), + Metric.DC_VOLTAGE, + [Bounds(lower=200.0, upper=250.0)], +) +client_kwargs = { + "validity": Validity.FIFTEEN_MINUTES, +} + +PbValidity = microgrid_pb2.ComponentBoundsValidityDuration + +valid_until = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.AddComponentBoundsRequest( + component_id=1, + target_metric=metric_sample_pb2.Metric.METRIC_DC_VOLTAGE, + bounds=[bounds_pb2.Bounds(lower=200.0, upper=250.0)], + validity_duration=PbValidity.COMPONENT_BOUNDS_VALIDITY_DURATION_15_MINUTES, + ), + timeout=DEFAULT_GRPC_CALL_TIMEOUT, + ) + + +grpc_response = microgrid_pb2.AddComponentBoundsResponse(ts=to_timestamp(valid_until)) + + +def assert_client_result(result: datetime) -> None: + """Assert that the client result matches the expected valid_until datetime.""" + assert result == valid_until diff --git a/tests/client_test_cases/set_component_power_active/_config.py b/tests/client_test_cases/set_component_power_active/_config.py new file mode 100644 index 00000000..8584ad4a --- /dev/null +++ b/tests/client_test_cases/set_component_power_active/_config.py @@ -0,0 +1,9 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Configuration for `SetComponentPowerActive` test cases.""" + + +from frequenz.api.microgrid.v1 import microgrid_pb2 + +RESPONSE_CLASS = microgrid_pb2.SetComponentPowerActiveResponse diff --git a/tests/client_test_cases/set_component_power_active/invalid_big_lifetime_case.py b/tests/client_test_cases/set_component_power_active/invalid_big_lifetime_case.py new file mode 100644 index 00000000..c2522898 --- /dev/null +++ b/tests/client_test_cases/set_component_power_active/invalid_big_lifetime_case.py @@ -0,0 +1,31 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test case with invalid power and validate_arguments=False.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +# pylint: disable-next=import-error +from _config import RESPONSE_CLASS # type: ignore[import-not-found] +from frequenz.client.common.microgrid.components import ComponentId + +client_kwargs = { + "component": ComponentId(1), + "power": 1000.0, + "request_lifetime": timedelta(minutes=15.01), +} + + +def assert_stub_method_call(stub_method: AsyncMock) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_not_called() + + +grpc_response = RESPONSE_CLASS() + + +def assert_client_exception(result: Exception) -> None: + """Assert that the client raises a ValueError.""" + assert isinstance(result, ValueError) + assert str(result) == "request_lifetime must be between 10 seconds and 15 minutes" diff --git a/tests/client_test_cases/set_component_power_active/invalid_power_case.py b/tests/client_test_cases/set_component_power_active/invalid_power_case.py new file mode 100644 index 00000000..cf9710d1 --- /dev/null +++ b/tests/client_test_cases/set_component_power_active/invalid_power_case.py @@ -0,0 +1,31 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test case with invalid power and validate_arguments=False.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +# pylint: disable-next=import-error +from _config import RESPONSE_CLASS # type: ignore[import-not-found] +from frequenz.client.common.microgrid.components import ComponentId + +client_kwargs = { + "component": ComponentId(1), + "power": float("nan"), + "request_lifetime": timedelta(seconds=60), +} + + +def assert_stub_method_call(stub_method: AsyncMock) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_not_called() + + +grpc_response = RESPONSE_CLASS() + + +def assert_client_exception(result: Exception) -> None: + """Assert that the client raises a ValueError.""" + assert isinstance(result, ValueError) + assert str(result) == "power cannot be NaN" diff --git a/tests/client_test_cases/set_component_power_active/invalid_power_no_validate_case.py b/tests/client_test_cases/set_component_power_active/invalid_power_no_validate_case.py new file mode 100644 index 00000000..7bd473ba --- /dev/null +++ b/tests/client_test_cases/set_component_power_active/invalid_power_no_validate_case.py @@ -0,0 +1,40 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test case with invalid power and validate_arguments=False.""" + +import math +from datetime import timedelta +from typing import Any + +# pylint: disable-next=import-error +from _config import RESPONSE_CLASS # type: ignore[import-not-found] +from frequenz.client.common.microgrid.components import ComponentId + +client_kwargs = { + "component": ComponentId(1), + "power": float("nan"), + "request_lifetime": timedelta(seconds=60), + "validate_arguments": False, +} + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + # We can't use float("nan") when comparing here because nan != nan + # so instead of using assert_called_once_with, we use assert_called_once + # and then check the arguments manually + stub_method.assert_called_once() + request = stub_method.call_args[0][0] + assert request.component_id == 1 + assert math.isnan(request.power) + assert request.request_lifetime == 60 + assert stub_method.call_args[1]["timeout"] == 60.0 + + +grpc_response = RESPONSE_CLASS() + + +def assert_client_result(result: None) -> None: + """Assert that the client result is None.""" + assert result is None diff --git a/tests/client_test_cases/set_component_power_active/invalid_small_lifetime_case.py b/tests/client_test_cases/set_component_power_active/invalid_small_lifetime_case.py new file mode 100644 index 00000000..74625494 --- /dev/null +++ b/tests/client_test_cases/set_component_power_active/invalid_small_lifetime_case.py @@ -0,0 +1,31 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test case with invalid power and validate_arguments=False.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +# pylint: disable-next=import-error +from _config import RESPONSE_CLASS # type: ignore[import-not-found] +from frequenz.client.common.microgrid.components import ComponentId + +client_kwargs = { + "component": ComponentId(1), + "power": 1000.0, + "request_lifetime": timedelta(seconds=1), +} + + +def assert_stub_method_call(stub_method: AsyncMock) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_not_called() + + +grpc_response = RESPONSE_CLASS() + + +def assert_client_exception(result: Exception) -> None: + """Assert that the client raises a ValueError.""" + assert isinstance(result, ValueError) + assert str(result) == "request_lifetime must be between 10 seconds and 15 minutes" diff --git a/tests/client_test_cases/set_component_power_active/no_lifetime_case.py b/tests/client_test_cases/set_component_power_active/no_lifetime_case.py new file mode 100644 index 00000000..81855837 --- /dev/null +++ b/tests/client_test_cases/set_component_power_active/no_lifetime_case.py @@ -0,0 +1,35 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test set_component_power_active with no lifetime: result should be None.""" + +from typing import Any + +import pytest + +# pylint: disable-next=import-error +from _config import RESPONSE_CLASS # type: ignore[import-not-found] +from frequenz.client.common.microgrid.components import ComponentId + +client_args = (ComponentId(1), 1000.0) + + +# No 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() + request = stub_method.call_args[0][0] + assert request.component_id == 1 + assert request.power == pytest.approx(1000.0) + assert stub_method.call_args[1]["timeout"] == 60.0 + + +grpc_response = RESPONSE_CLASS() + + +def assert_client_result(result: Any) -> None: # noqa: D103 + """Assert that the client result is None when no lifetime is provided.""" + assert result is None + assert result is None diff --git a/tests/client_test_cases/set_component_power_active/success_case.py b/tests/client_test_cases/set_component_power_active/success_case.py new file mode 100644 index 00000000..e24d0f8d --- /dev/null +++ b/tests/client_test_cases/set_component_power_active/success_case.py @@ -0,0 +1,38 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for successful set_component_power_active call.""" + +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest + +# pylint: disable-next=import-error +from _config import RESPONSE_CLASS # type: ignore[import-not-found] +from frequenz.client.base import conversion +from frequenz.client.common.microgrid.components import ComponentId + +client_args = (ComponentId(1), 1000.0) +client_kwargs = {"request_lifetime": timedelta(minutes=9.0)} + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once() + request = stub_method.call_args[0][0] + assert request.component_id == 1 + assert request.power == pytest.approx(1000.0) + assert request.request_lifetime == pytest.approx(60.0 * 9.0) + assert stub_method.call_args[1]["timeout"] == 60.0 + + +expiry_time = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) +grpc_response = RESPONSE_CLASS(valid_until=conversion.to_timestamp(expiry_time)) + + +def assert_client_result(result: datetime) -> None: + """Assert that the client result matches the expected expiry time.""" + assert result == expiry_time + """Assert that the client result matches the expected expiry time.""" + assert result == expiry_time diff --git a/tests/client_test_cases/set_component_power_reactive/_config.py b/tests/client_test_cases/set_component_power_reactive/_config.py new file mode 100644 index 00000000..cf80d632 --- /dev/null +++ b/tests/client_test_cases/set_component_power_reactive/_config.py @@ -0,0 +1,9 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Configuration for `SetComponentPowerReactive` test cases.""" + + +from frequenz.api.microgrid.v1 import microgrid_pb2 + +RESPONSE_CLASS = microgrid_pb2.SetComponentPowerReactiveResponse diff --git a/tests/client_test_cases/set_component_power_reactive/invalid_big_lifetime_case.py b/tests/client_test_cases/set_component_power_reactive/invalid_big_lifetime_case.py new file mode 120000 index 00000000..c6ec68db --- /dev/null +++ b/tests/client_test_cases/set_component_power_reactive/invalid_big_lifetime_case.py @@ -0,0 +1 @@ +../set_component_power_active/invalid_big_lifetime_case.py \ No newline at end of file diff --git a/tests/client_test_cases/set_component_power_reactive/invalid_power_case.py b/tests/client_test_cases/set_component_power_reactive/invalid_power_case.py new file mode 120000 index 00000000..053e1351 --- /dev/null +++ b/tests/client_test_cases/set_component_power_reactive/invalid_power_case.py @@ -0,0 +1 @@ +../set_component_power_active/invalid_power_case.py \ No newline at end of file diff --git a/tests/client_test_cases/set_component_power_reactive/invalid_power_no_validate_case.py b/tests/client_test_cases/set_component_power_reactive/invalid_power_no_validate_case.py new file mode 120000 index 00000000..ebd36663 --- /dev/null +++ b/tests/client_test_cases/set_component_power_reactive/invalid_power_no_validate_case.py @@ -0,0 +1 @@ +../set_component_power_active/invalid_power_no_validate_case.py \ No newline at end of file diff --git a/tests/client_test_cases/set_component_power_reactive/invalid_small_lifetime_case.py b/tests/client_test_cases/set_component_power_reactive/invalid_small_lifetime_case.py new file mode 120000 index 00000000..4a0fcc23 --- /dev/null +++ b/tests/client_test_cases/set_component_power_reactive/invalid_small_lifetime_case.py @@ -0,0 +1 @@ +../set_component_power_active/invalid_small_lifetime_case.py \ No newline at end of file diff --git a/tests/client_test_cases/set_component_power_reactive/no_lifetime_case.py b/tests/client_test_cases/set_component_power_reactive/no_lifetime_case.py new file mode 120000 index 00000000..e8a558d8 --- /dev/null +++ b/tests/client_test_cases/set_component_power_reactive/no_lifetime_case.py @@ -0,0 +1 @@ +../set_component_power_active/no_lifetime_case.py \ No newline at end of file diff --git a/tests/client_test_cases/set_component_power_reactive/success_case.py b/tests/client_test_cases/set_component_power_reactive/success_case.py new file mode 120000 index 00000000..c01c9bb1 --- /dev/null +++ b/tests/client_test_cases/set_component_power_reactive/success_case.py @@ -0,0 +1 @@ +../set_component_power_active/success_case.py \ No newline at end of file diff --git a/tests/component/__init__.py b/tests/component/__init__.py new file mode 100644 index 00000000..aa9ba201 --- /dev/null +++ b/tests/component/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for components.""" diff --git a/tests/component/test_component.py b/tests/component/test_component.py new file mode 100644 index 00000000..4b2b2532 --- /dev/null +++ b/tests/component/test_component.py @@ -0,0 +1,295 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Component base class and its functionality.""" + +from datetime import datetime, timezone +from typing import Literal +from unittest.mock import Mock, patch + +import pytest +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.components import ComponentId + +from frequenz.client.microgrid import Lifetime +from frequenz.client.microgrid.component._category import ComponentCategory +from frequenz.client.microgrid.component._component import Component +from frequenz.client.microgrid.component._status import ComponentStatus +from frequenz.client.microgrid.metrics._bounds import Bounds +from frequenz.client.microgrid.metrics._metric import Metric + + +class _TestComponent(Component): + """A simple component implementation for testing.""" + + category: Literal[ComponentCategory.UNSPECIFIED] = ComponentCategory.UNSPECIFIED + + +def test_base_creation_fails() -> None: + """Test that Component base class cannot be instantiated directly.""" + with pytest.raises(TypeError, match="Cannot instantiate Component directly"): + _ = Component( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + category=ComponentCategory.UNSPECIFIED, + ) + + +def test_creation_with_defaults() -> None: + """Test component default values.""" + component = _TestComponent( + id=ComponentId(1), + microgrid_id=MicrogridId(2), + category=ComponentCategory.UNSPECIFIED, + ) + + assert component.status == ComponentStatus.UNSPECIFIED + assert component.name is None + assert component.manufacturer is None + assert component.model_name is None + assert component.operational_lifetime == Lifetime() + assert component.rated_bounds == {} + assert component.category_specific_metadata == {} + + +def test_creation_full() -> None: + """Test component creation with all attributes.""" + bounds = Bounds(lower=-100.0, upper=100.0) + rated_bounds: dict[Metric | int, Bounds] = {Metric.AC_ACTIVE_POWER: bounds} + metadata = {"key1": "value1", "key2": 42} + + component = _TestComponent( + id=ComponentId(1), + microgrid_id=MicrogridId(2), + category=ComponentCategory.UNSPECIFIED, + name="test-component", + manufacturer="Test Manufacturer", + model_name="Test Model", + rated_bounds=rated_bounds, + category_specific_metadata=metadata, + ) + + assert component.name == "test-component" + assert component.manufacturer == "Test Manufacturer" + assert component.model_name == "Test Model" + assert component.rated_bounds == rated_bounds + assert component.category_specific_metadata == metadata + + +@pytest.mark.parametrize( + "name,expected_str", + [ + (None, "CID1<_TestComponent>"), + ("test-component", "CID1<_TestComponent>:test-component"), + ], + ids=["no-name", "with-name"], +) +def test_str(name: str | None, expected_str: str) -> None: + """Test string representation of a component.""" + component = _TestComponent( + id=ComponentId(1), + microgrid_id=MicrogridId(2), + category=ComponentCategory.UNSPECIFIED, + name=name, + ) + assert str(component) == expected_str + + +@pytest.mark.parametrize("status", list(ComponentStatus), ids=lambda s: s.name) +@pytest.mark.parametrize( + "lifetime_active", [True, False], ids=["operational", "not-operational"] +) +def test_active_at( + status: ComponentStatus, lifetime_active: bool, caplog: pytest.LogCaptureFixture +) -> None: + """Test active_at behavior with different status and lifetime combinations.""" + caplog.set_level("WARNING") + + mock_lifetime = Mock(spec=Lifetime) + mock_lifetime.is_operational_at.return_value = lifetime_active + + component = _TestComponent( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + category=ComponentCategory.UNSPECIFIED, + status=status, + operational_lifetime=mock_lifetime, + ) + + test_time = datetime.now(timezone.utc) + expected = status != ComponentStatus.INACTIVE and lifetime_active + assert component.is_active_at(test_time) == expected + + if status in (ComponentStatus.ACTIVE, ComponentStatus.UNSPECIFIED): + mock_lifetime.is_operational_at.assert_called_once_with(test_time) + else: + mock_lifetime.is_operational_at.assert_not_called() + + if status is ComponentStatus.UNSPECIFIED: + assert "unspecified status" in caplog.text.lower() + + +@patch("frequenz.client.microgrid.component._component.datetime") +def test_is_active_now(mock_datetime: Mock) -> None: + """Test is_active_now method.""" + now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_datetime.now.side_effect = lambda tz: now.replace(tzinfo=tz) + mock_lifetime = Mock(spec=Lifetime) + mock_lifetime.is_operational_at.return_value = True + component = _TestComponent( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + category=ComponentCategory.UNSPECIFIED, + status=ComponentStatus.ACTIVE, + operational_lifetime=mock_lifetime, + ) + + assert component.is_active_now() is True + + mock_lifetime.is_operational_at.assert_called_once_with(now) + + +COMPONENT = _TestComponent( + id=ComponentId(1), + microgrid_id=MicrogridId(1), + category=ComponentCategory.UNSPECIFIED, + status=ComponentStatus.ACTIVE, + name="test", + manufacturer="Test Mfg", + model_name="Model A", + rated_bounds={Metric.AC_ACTIVE_POWER: Bounds(lower=-100.0, upper=100.0)}, + category_specific_metadata={"key": "value"}, +) + +DIFFERENT_NONHASHABLE = _TestComponent( + id=COMPONENT.id, + microgrid_id=COMPONENT.microgrid_id, + category=COMPONENT.category, + status=COMPONENT.status, + name=COMPONENT.name, + manufacturer=COMPONENT.manufacturer, + model_name=COMPONENT.model_name, + rated_bounds={Metric.AC_ACTIVE_POWER: Bounds(lower=-200.0, upper=200.0)}, + category_specific_metadata={"different": "metadata"}, +) + +DIFFERENT_STATUS = _TestComponent( + id=COMPONENT.id, + microgrid_id=COMPONENT.microgrid_id, + category=COMPONENT.category, + status=ComponentStatus.INACTIVE, + name=COMPONENT.name, + manufacturer=COMPONENT.manufacturer, + model_name=COMPONENT.model_name, + rated_bounds=COMPONENT.rated_bounds, + category_specific_metadata=COMPONENT.category_specific_metadata, +) + +DIFFERENT_NAME = _TestComponent( + id=COMPONENT.id, + microgrid_id=COMPONENT.microgrid_id, + category=COMPONENT.category, + status=COMPONENT.status, + name="different", + manufacturer=COMPONENT.manufacturer, + model_name=COMPONENT.model_name, + rated_bounds=COMPONENT.rated_bounds, + category_specific_metadata=COMPONENT.category_specific_metadata, +) + +DIFFERENT_ID = _TestComponent( + id=ComponentId(2), + microgrid_id=COMPONENT.microgrid_id, + category=COMPONENT.category, + status=COMPONENT.status, + name=COMPONENT.name, + manufacturer=COMPONENT.manufacturer, + model_name=COMPONENT.model_name, + rated_bounds=COMPONENT.rated_bounds, + category_specific_metadata=COMPONENT.category_specific_metadata, +) + +DIFFERENT_MICROGRID_ID = _TestComponent( + id=COMPONENT.id, + microgrid_id=MicrogridId(2), + category=COMPONENT.category, + status=COMPONENT.status, + name=COMPONENT.name, + manufacturer=COMPONENT.manufacturer, + model_name=COMPONENT.model_name, + rated_bounds=COMPONENT.rated_bounds, + category_specific_metadata=COMPONENT.category_specific_metadata, +) + +DIFFERENT_BOTH_ID = _TestComponent( + id=ComponentId(2), + microgrid_id=MicrogridId(2), + category=COMPONENT.category, + status=COMPONENT.status, + name=COMPONENT.name, + manufacturer=COMPONENT.manufacturer, + model_name=COMPONENT.model_name, + rated_bounds=COMPONENT.rated_bounds, + category_specific_metadata=COMPONENT.category_specific_metadata, +) + + +@pytest.mark.parametrize( + "comp,expected", + [ + pytest.param(COMPONENT, True, id="self"), + pytest.param(DIFFERENT_NONHASHABLE, False, id="other-nonhashable"), + pytest.param(DIFFERENT_STATUS, False, id="other-status"), + pytest.param(DIFFERENT_NAME, False, id="other-name"), + pytest.param(DIFFERENT_ID, False, id="other-id"), + pytest.param(DIFFERENT_MICROGRID_ID, False, id="other-microgrid-id"), + pytest.param(DIFFERENT_BOTH_ID, False, id="other-both-ids"), + ], + ids=lambda o: str(o.id) if isinstance(o, Component) else str(o), +) +def test_equality(comp: Component, expected: bool) -> None: + """Test component equality.""" + assert (COMPONENT == comp) is expected + assert (comp == COMPONENT) is expected + assert (COMPONENT != comp) is not expected + assert (comp != COMPONENT) is not expected + + +@pytest.mark.parametrize( + "comp,expected", + [ + pytest.param(COMPONENT, True, id="self"), + pytest.param(DIFFERENT_NONHASHABLE, True, id="other-nonhashable"), + pytest.param(DIFFERENT_STATUS, True, id="other-status"), + pytest.param(DIFFERENT_NAME, True, id="other-name"), + pytest.param(DIFFERENT_ID, False, id="other-id"), + pytest.param(DIFFERENT_MICROGRID_ID, False, id="other-microgrid-id"), + pytest.param(DIFFERENT_BOTH_ID, False, id="other-both-ids"), + ], +) +def test_identity(comp: Component, expected: bool) -> None: + """Test component identity.""" + assert (COMPONENT.identity == comp.identity) is expected + assert comp.identity == (comp.id, comp.microgrid_id) + + +ALL_COMPONENTS_PARAMS = [ + pytest.param(COMPONENT, id="comp"), + pytest.param(DIFFERENT_NONHASHABLE, id="nonhashable"), + pytest.param(DIFFERENT_STATUS, id="status"), + pytest.param(DIFFERENT_NAME, id="name"), + pytest.param(DIFFERENT_ID, id="id"), + pytest.param(DIFFERENT_MICROGRID_ID, id="microgrid_id"), + pytest.param(DIFFERENT_BOTH_ID, id="both_ids"), +] + + +@pytest.mark.parametrize("comp1", ALL_COMPONENTS_PARAMS) +@pytest.mark.parametrize("comp2", ALL_COMPONENTS_PARAMS) +def test_hash(comp1: Component, comp2: Component) -> None: + """Test that the hash is consistent.""" + # We can only say the hash are the same if the components 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) diff --git a/tests/metrics/test_bounds.py b/tests/metrics/test_bounds.py new file mode 100644 index 00000000..cf783028 --- /dev/null +++ b/tests/metrics/test_bounds.py @@ -0,0 +1,143 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Bounds class.""" + +import re +from dataclasses import dataclass + +import pytest +from frequenz.api.common.v1.metrics import bounds_pb2 + +from frequenz.client.microgrid.metrics import Bounds +from frequenz.client.microgrid.metrics._bounds_proto import bounds_from_proto + + +@dataclass(frozen=True, kw_only=True) +class ProtoConversionTestCase: + """Test case for protobuf conversion.""" + + name: str + """Description of the test case.""" + + has_lower: bool + """Whether to include lower bound in the protobuf message.""" + + has_upper: bool + """Whether to include upper bound in the protobuf message.""" + + lower: float | None + """The lower bound value to set.""" + + upper: float | None + """The upper bound value to set.""" + + +@pytest.mark.parametrize( + "lower, upper", + [ + (None, None), + (10.0, None), + (None, -10.0), + (-10.0, 10.0), + (10.0, 10.0), + (-10.0, -10.0), + (0.0, 10.0), + (-10, 0.0), + (0.0, 0.0), + ], +) +def test_creation(lower: float, upper: float) -> None: + """Test creation of Bounds with valid values.""" + bounds = Bounds(lower=lower, upper=upper) + assert bounds.lower == lower + assert bounds.upper == upper + + +def test_invalid_values() -> None: + """Test that Bounds creation fails with invalid values.""" + with pytest.raises( + ValueError, + match=re.escape( + "Lower bound (10.0) must be less than or equal to upper bound (-10.0)" + ), + ): + Bounds(lower=10.0, upper=-10.0) + + +def test_str_representation() -> None: + """Test string representation of Bounds.""" + bounds = Bounds(lower=-10.0, upper=10.0) + assert str(bounds) == "[-10.0, 10.0]" + + +def test_equality() -> None: + """Test equality comparison of Bounds objects.""" + bounds1 = Bounds(lower=-10.0, upper=10.0) + bounds2 = Bounds(lower=-10.0, upper=10.0) + bounds3 = Bounds(lower=-5.0, upper=5.0) + + assert bounds1 == bounds2 + assert bounds1 != bounds3 + assert bounds2 != bounds3 + + +def test_hash() -> None: + """Test that Bounds objects can be used in sets and as dictionary keys.""" + bounds1 = Bounds(lower=-10.0, upper=10.0) + bounds2 = Bounds(lower=-10.0, upper=10.0) + bounds3 = Bounds(lower=-5.0, upper=5.0) + + bounds_set = {bounds1, bounds2, bounds3} + assert len(bounds_set) == 2 # bounds1 and bounds2 are equal + + bounds_dict = {bounds1: "test1", bounds3: "test2"} + assert len(bounds_dict) == 2 + + +@pytest.mark.parametrize( + "case", + [ + ProtoConversionTestCase( + name="full", + has_lower=True, + has_upper=True, + lower=-10.0, + upper=10.0, + ), + ProtoConversionTestCase( + name="no_upper_bound", + has_lower=True, + has_upper=False, + lower=-10.0, + upper=None, + ), + ProtoConversionTestCase( + name="no_lower_bound", + has_lower=False, + has_upper=True, + lower=None, + upper=10.0, + ), + ProtoConversionTestCase( + name="no_both_bounds", + has_lower=False, + has_upper=False, + lower=None, + upper=None, + ), + ], + ids=lambda case: case.name, +) +def test_from_proto(case: ProtoConversionTestCase) -> None: + """Test conversion from protobuf message to Bounds.""" + proto = bounds_pb2.Bounds() + if case.has_lower and case.lower is not None: + proto.lower = case.lower + if case.has_upper and case.upper is not None: + proto.upper = case.upper + + bounds = bounds_from_proto(proto) + + assert bounds.lower == case.lower + assert bounds.upper == case.upper diff --git a/tests/metrics/test_sample.py b/tests/metrics/test_sample.py new file mode 100644 index 00000000..e4fe59c4 --- /dev/null +++ b/tests/metrics/test_sample.py @@ -0,0 +1,127 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Sample class and related classes.""" + +from dataclasses import dataclass, field + +import pytest +from frequenz.api.common.v1.metrics import metric_sample_pb2 + +from frequenz.client.microgrid.metrics import AggregatedMetricValue, AggregationMethod +from frequenz.client.microgrid.metrics._sample_proto import ( + aggregated_metric_sample_from_proto, +) + + +@dataclass(frozen=True, kw_only=True) +class _AggregatedValueTestCase: + """Test case for AggregatedMetricValue protobuf conversion.""" + + name: str + """The description of the test case.""" + + avg_value: float + """The average value to set.""" + + has_min: bool = True + """Whether to include min value.""" + + has_max: bool = True + """Whether to include max value.""" + + min_value: float | None = None + """The minimum value to set.""" + + max_value: float | None = None + """The maximum value to set.""" + + raw_values: list[float] = field(default_factory=list) + """The raw values to include.""" + + +def test_aggregation_method_values() -> None: + """Test that AggregationMethod enum has the expected values.""" + assert AggregationMethod.AVG.value == "avg" + assert AggregationMethod.MIN.value == "min" + assert AggregationMethod.MAX.value == "max" + + +def test_aggregated_metric_value() -> None: + """Test AggregatedMetricValue creation and string representation.""" + # Test with full data + value = AggregatedMetricValue( + avg=5.0, + min=1.0, + max=10.0, + raw_values=[1.0, 5.0, 10.0], + ) + assert value.avg == 5.0 + assert value.min == 1.0 + assert value.max == 10.0 + assert list(value.raw_values) == [1.0, 5.0, 10.0] + assert str(value) == "avg:5.0" + + # Test with minimal data (only avg required) + value = AggregatedMetricValue( + avg=5.0, + min=None, + max=None, + raw_values=[], + ) + assert value.avg == 5.0 + assert value.min is None + assert value.max is None + assert not value.raw_values + assert str(value) == "avg:5.0" + + +@pytest.mark.parametrize( + "case", + [ + _AggregatedValueTestCase( + name="full", + avg_value=5.0, + min_value=1.0, + max_value=10.0, + raw_values=[1.0, 5.0, 10.0], + ), + _AggregatedValueTestCase( + name="minimal", + avg_value=5.0, + has_min=False, + has_max=False, + ), + _AggregatedValueTestCase( + name="only_min", + avg_value=5.0, + has_max=False, + min_value=1.0, + ), + _AggregatedValueTestCase( + name="only_max", + avg_value=5.0, + has_min=False, + max_value=10.0, + ), + ], + ids=lambda case: case.name, +) +def test_aggregated_metric_value_from_proto(case: _AggregatedValueTestCase) -> None: + """Test conversion from protobuf message to AggregatedMetricValue.""" + proto = metric_sample_pb2.AggregatedMetricValue( + avg_value=case.avg_value, + ) + if case.has_min and case.min_value is not None: + proto.min_value = case.min_value + if case.has_max and case.max_value is not None: + proto.max_value = case.max_value + if case.raw_values: + proto.raw_values.extend(case.raw_values) + + value = aggregated_metric_sample_from_proto(proto) + + assert value.avg == case.avg_value + assert value.min == (case.min_value if case.has_min else None) + assert value.max == (case.max_value if case.has_max else None) + assert list(value.raw_values) == case.raw_values diff --git a/tests/test_client.py b/tests/test_client.py index b77ed8ff..c242ad1d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -96,3 +96,42 @@ async def test_get_microgrid_info( ) -> None: """Test get_microgrid_info method.""" await spec.test_unary_unary_call(client, "GetMicrogridMetadata") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "spec", + get_test_specs("set_component_power_active", tests_dir=TESTS_DIR), + ids=str, +) +async def test_set_component_power_active( + client: MicrogridApiClient, spec: ApiClientTestCaseSpec +) -> None: + """Test set_component_power_active method.""" + await spec.test_unary_unary_call(client, "SetComponentPowerActive") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "spec", + get_test_specs("set_component_power_reactive", tests_dir=TESTS_DIR), + ids=str, +) +async def test_set_component_power_reactive( + client: MicrogridApiClient, spec: ApiClientTestCaseSpec +) -> None: + """Test set_component_power_reactive method.""" + await spec.test_unary_unary_call(client, "SetComponentPowerReactive") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "spec", + get_test_specs("add_component_bounds", tests_dir=TESTS_DIR), + ids=str, +) +async def test_add_bounds( + client: MicrogridApiClient, spec: ApiClientTestCaseSpec +) -> None: + """Test add_bounds method.""" + await spec.test_unary_unary_call(client, "AddComponentBounds") diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py index db025558..ef9fd6fb 100644 --- a/tests/test_lifetime.py +++ b/tests/test_lifetime.py @@ -1,15 +1,19 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Tests for the Lifetime class.""" +"""Tests for the Lifetime class and its protobuf conversion.""" from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum, auto +from typing import Any import pytest +from frequenz.api.common.v1.microgrid import lifetime_pb2 +from google.protobuf import timestamp_pb2 from frequenz.client.microgrid import Lifetime +from frequenz.client.microgrid._lifetime_proto import lifetime_from_proto class _Time(Enum): @@ -65,6 +69,20 @@ class _ActivityTestCase: """The expected operational state.""" +@dataclass(frozen=True, kw_only=True) +class _ProtoConversionTestCase: + """Test case for protobuf conversion.""" + + name: str + """The description of the test case.""" + + include_start: bool + """Whether to include start timestamp.""" + + include_end: bool + """Whether to include end timestamp.""" + + @dataclass(frozen=True, kw_only=True) class _FixedLifetimeTestCase: """Test case for fixed lifetime activity testing.""" @@ -136,13 +154,7 @@ def future(now: datetime) -> datetime: ids=lambda case: case.name, ) def test_creation(now: datetime, future: datetime, case: _LifetimeTestCase) -> None: - """Test creating Lifetime instances with various parameters. - - Args: - now: Current datetime fixture - future: Future datetime fixture - case: Test case parameters - """ + """Test creating Lifetime instances with various parameters.""" lifetime = Lifetime( start=now if case.start else None, end=future if case.end else None, @@ -303,3 +315,51 @@ def test_active_at_with_fixed_lifetime( }[case.test_time] assert lifetime.is_operational_at(test_time) == case.expected_operational + + +@pytest.mark.parametrize( + "case", + [ + _ProtoConversionTestCase( + name="both timestamps", include_start=True, include_end=True + ), + _ProtoConversionTestCase( + name="only start timestamp", include_start=True, include_end=False + ), + _ProtoConversionTestCase( + name="only end timestamp", include_start=False, include_end=True + ), + _ProtoConversionTestCase( + name="no timestamps", include_start=False, include_end=False + ), + ], + ids=lambda case: case.name, +) +def test_from_proto( + now: datetime, future: datetime, case: _ProtoConversionTestCase +) -> None: + """Test conversion from protobuf message to Lifetime.""" + now_ts = timestamp_pb2.Timestamp() + now_ts.FromDatetime(now) + + future_ts = timestamp_pb2.Timestamp() + future_ts.FromDatetime(future) + + proto_kwargs: dict[str, Any] = {} + if case.include_start: + proto_kwargs["start_timestamp"] = now_ts + if case.include_end: + proto_kwargs["end_timestamp"] = future_ts + + proto = lifetime_pb2.Lifetime(**proto_kwargs) + lifetime = lifetime_from_proto(proto) + + if case.include_start: + assert lifetime.start == now + else: + assert lifetime.start is None + + if case.include_end: + assert lifetime.end == future + else: + assert lifetime.end is None diff --git a/tests/test_metrics.py b/tests/test_metrics.py deleted file mode 100644 index 51078ea2..00000000 --- a/tests/test_metrics.py +++ /dev/null @@ -1,42 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Tests for the Sample class and related classes.""" - -from frequenz.client.microgrid.metrics import AggregatedMetricValue, AggregationMethod - - -def test_aggregation_method_values() -> None: - """Test that AggregationMethod enum has the expected values.""" - assert AggregationMethod.AVG.value == "avg" - assert AggregationMethod.MIN.value == "min" - assert AggregationMethod.MAX.value == "max" - - -def test_aggregated_metric_value() -> None: - """Test AggregatedMetricValue creation and string representation.""" - # Test with full data - value = AggregatedMetricValue( - avg=5.0, - min=1.0, - max=10.0, - raw_values=[1.0, 5.0, 10.0], - ) - assert value.avg == 5.0 - assert value.min == 1.0 - assert value.max == 10.0 - assert list(value.raw_values) == [1.0, 5.0, 10.0] - assert str(value) == "avg:5.0" - - # Test with minimal data (only avg required) - value = AggregatedMetricValue( - avg=5.0, - min=None, - max=None, - raw_values=[], - ) - assert value.avg == 5.0 - assert value.min is None - assert value.max is None - assert not value.raw_values - assert str(value) == "avg:5.0"