diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2776a66..558627e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,20 +2,21 @@ ## Summary -This is the same release as v0.3.5 but with prefixes in `Event` enum values removed. The v0.3.5 release will be yanked from PyPI and it should not be used. +This release main change is the introduction of a new `metrics` package compatible with the common API v0.8 (`v1alpha8`) (the old `metric` package, in singular, still works with the old v0.5/`v1` version). -## Upgrading +## Deprecations -- The `pagination.Params` class is deprecated; use the protobuf message directly. -- The `pagination.Info` class is deprecated in favor of the new `pagination.PaginationInfo` class. +- The old `frequenz.client.common.enum_proto` module is now deprecated, please use `frequenz.client.common.proto.enum_from_proto` instead. ## New Features -- Mapping for the new `Event` message has been added. -- Add new common API enums for `ElectricalComponent` (previously `Components`). - -- Added `v1alpha8` variants of the pagination data structures. - -## Bug Fixes - -- Updated display of protobuf version warnings +- New `frequenz.client.common.common.proto` module with conversion utilities for protobuf types: + - `enum_from_proto()` (moved from `enum_proto). + - `datetime_to_proto()` and `datetime_from_proto()` functions to convert between Python `datetime` and protobuf `Timestamp` (imported from `frequenz-client-base`. +- New `metrics` package compatible with API v0.8, which includes: + - `Metric` enum with all supported metrics. + - `MetricSample` dataclass to represent metric samples. + - `AggregatedMetricValue` dataclass to represent derived statistical summaries. + - `Bounds` dataclass to represent bounds for metrics. + - `MetricConnection` and `MetricConnectionCategory` to represent connections from which metrics are obtained. + - `proto` submodule with conversion functions to/from protobuf types. diff --git a/pyproject.toml b/pyproject.toml index 86a83c1..117e0b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "typing-extensions >= 4.13.0, < 5", "frequenz-api-common >= 0.8.0, < 1", "frequenz-core >= 1.0.2, < 2", + "protobuf >= 6.33.1, < 7", ] dynamic = ["version"] @@ -60,6 +61,7 @@ dev-mkdocs = [ dev-mypy = [ "mypy == 1.18.2", "types-Markdown == 3.9.0.20250906", + "types-protobuf == 6.32.1.20251105", # For checking the noxfile, docs/ script, and tests "frequenz-client-common[dev-mkdocs,dev-noxfile,dev-pytest]", ] @@ -72,6 +74,7 @@ dev-pylint = [ dev-pytest = [ "pytest == 8.4.2", "frequenz-repo-config[extra-lint-examples] == 0.13.6", + "hypothesis == 6.140.3", "pytest-mock == 3.15.1", "pytest-asyncio == 1.2.0", "async-solipsism == 0.8", diff --git a/src/frequenz/client/common/enum_proto.py b/src/frequenz/client/common/enum_proto.py index 789f185..aabf197 100644 --- a/src/frequenz/client/common/enum_proto.py +++ b/src/frequenz/client/common/enum_proto.py @@ -6,6 +6,8 @@ import enum from typing import Literal, TypeVar, overload +from typing_extensions import deprecated + EnumT = TypeVar("EnumT", bound=enum.Enum) """A type variable that is bound to an enum.""" @@ -22,6 +24,10 @@ def enum_from_proto( ) -> EnumT | int: ... +@deprecated( + "frequenz.client.common.enum_proto.enum_from_proto is deprecated. " + "Please use frequenz.client.common.proto.enum_from_proto instead." +) def enum_from_proto( value: int, enum_type: type[EnumT], *, allow_invalid: bool = True ) -> EnumT | int: diff --git a/src/frequenz/client/common/metric/__init__.py b/src/frequenz/client/common/metric/__init__.py index 168ec9f..8afef80 100644 --- a/src/frequenz/client/common/metric/__init__.py +++ b/src/frequenz/client/common/metric/__init__.py @@ -142,7 +142,10 @@ class Metric(enum.Enum): SENSOR_IRRADIANCE = PBMetric.METRIC_SENSOR_IRRADIANCE @classmethod - @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") + @deprecated( + "frequenz.client.common.metric.Metric.from_proto() is deprecated. " + "Use frequenz.client.common.proto.enum_from_proto instead." + ) def from_proto(cls, metric: PBMetric.ValueType) -> Self: """Convert a protobuf Metric value to Metric enum. diff --git a/src/frequenz/client/common/metrics/__init__.py b/src/frequenz/client/common/metrics/__init__.py new file mode 100644 index 0000000..45a2bbb --- /dev/null +++ b/src/frequenz/client/common/metrics/__init__.py @@ -0,0 +1,24 @@ +# 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, + MetricConnection, + MetricConnectionCategory, + MetricSample, +) + +__all__ = [ + "AggregatedMetricValue", + "AggregationMethod", + "Bounds", + "Metric", + "MetricConnection", + "MetricConnectionCategory", + "MetricSample", +] diff --git a/src/frequenz/client/common/metrics/_bounds.py b/src/frequenz/client/common/metrics/_bounds.py new file mode 100644 index 0000000..de59bde --- /dev/null +++ b/src/frequenz/client/common/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/common/metrics/_metric.py b/src/frequenz/client/common/metrics/_metric.py new file mode 100644 index 0000000..e180296 --- /dev/null +++ b/src/frequenz/client/common/metrics/_metric.py @@ -0,0 +1,278 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Supported metrics for microgrid components.""" + + +import enum + +from frequenz.api.common.v1alpha8.metrics import metrics_pb2 + + +@enum.unique +class Metric(enum.Enum): + """List of supported metrics. + + Metric units are as follows: + + * `VOLTAGE`: V (Volts) + * `CURRENT`: A (Amperes) + * `POWER_ACTIVE`: W (Watts) + * `POWER_APPARENT`: VA (Volt-Amperes) + * `POWER_REACTIVE`: VAr (Volt-Amperes reactive) + * `ENERGY_ACTIVE`: Wh (Watt-hours) + * `ENERGY_APPARENT`: VAh (Volt-Ampere hours) + * `ENERGY_REACTIVE`: VArh (Volt-Ampere reactive hours) + * `FREQUENCY`: Hz (Hertz) + * `TEMPERATURE`: °C (Degree Celsius) + * `BATTERY_SOC_PCT`: % (percentage) + * `BATTERY_CAPACITY`: Wh (Watt-hours) + * `FACTOR`: no unit + + 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 = metrics_pb2.METRIC_UNSPECIFIED + """The metric is unspecified (this should not be used).""" + + DC_VOLTAGE = metrics_pb2.METRIC_DC_VOLTAGE + """The DC voltage.""" + + DC_CURRENT = metrics_pb2.METRIC_DC_CURRENT + """The DC current.""" + + DC_POWER = metrics_pb2.METRIC_DC_POWER + """The DC power.""" + + AC_FREQUENCY = metrics_pb2.METRIC_AC_FREQUENCY + """The AC frequency.""" + + AC_VOLTAGE = metrics_pb2.METRIC_AC_VOLTAGE + """The AC electric potential difference.""" + + AC_VOLTAGE_PHASE_1_N = metrics_pb2.METRIC_AC_VOLTAGE_PHASE_1_N + """The AC electric potential difference between phase 1 and neutral.""" + + AC_VOLTAGE_PHASE_2_N = metrics_pb2.METRIC_AC_VOLTAGE_PHASE_2_N + """The AC electric potential difference between phase 2 and neutral.""" + + AC_VOLTAGE_PHASE_3_N = metrics_pb2.METRIC_AC_VOLTAGE_PHASE_3_N + """The AC electric potential difference between phase 3 and neutral.""" + + AC_VOLTAGE_PHASE_1_PHASE_2 = metrics_pb2.METRIC_AC_VOLTAGE_PHASE_1_PHASE_2 + """The AC electric potential difference between phase 1 and phase 2.""" + + AC_VOLTAGE_PHASE_2_PHASE_3 = metrics_pb2.METRIC_AC_VOLTAGE_PHASE_2_PHASE_3 + """The AC electric potential difference between phase 2 and phase 3.""" + + AC_VOLTAGE_PHASE_3_PHASE_1 = metrics_pb2.METRIC_AC_VOLTAGE_PHASE_3_PHASE_1 + """The AC electric potential difference between phase 3 and phase 1.""" + + AC_CURRENT = metrics_pb2.METRIC_AC_CURRENT + """The AC current.""" + + AC_CURRENT_PHASE_1 = metrics_pb2.METRIC_AC_CURRENT_PHASE_1 + """The AC current in phase 1.""" + + AC_CURRENT_PHASE_2 = metrics_pb2.METRIC_AC_CURRENT_PHASE_2 + """The AC current in phase 2.""" + + AC_CURRENT_PHASE_3 = metrics_pb2.METRIC_AC_CURRENT_PHASE_3 + """The AC current in phase 3.""" + + AC_POWER_APPARENT = metrics_pb2.METRIC_AC_POWER_APPARENT + """The AC apparent power.""" + + AC_POWER_APPARENT_PHASE_1 = metrics_pb2.METRIC_AC_POWER_APPARENT_PHASE_1 + """The AC apparent power in phase 1.""" + + AC_POWER_APPARENT_PHASE_2 = metrics_pb2.METRIC_AC_POWER_APPARENT_PHASE_2 + """The AC apparent power in phase 2.""" + + AC_POWER_APPARENT_PHASE_3 = metrics_pb2.METRIC_AC_POWER_APPARENT_PHASE_3 + """The AC apparent power in phase 3.""" + + AC_POWER_ACTIVE = metrics_pb2.METRIC_AC_POWER_ACTIVE + """The AC active power.""" + + AC_POWER_ACTIVE_PHASE_1 = metrics_pb2.METRIC_AC_POWER_ACTIVE_PHASE_1 + """The AC active power in phase 1.""" + + AC_POWER_ACTIVE_PHASE_2 = metrics_pb2.METRIC_AC_POWER_ACTIVE_PHASE_2 + """The AC active power in phase 2.""" + + AC_POWER_ACTIVE_PHASE_3 = metrics_pb2.METRIC_AC_POWER_ACTIVE_PHASE_3 + """The AC active power in phase 3.""" + + AC_POWER_REACTIVE = metrics_pb2.METRIC_AC_POWER_REACTIVE + """The AC reactive power.""" + + AC_POWER_REACTIVE_PHASE_1 = metrics_pb2.METRIC_AC_POWER_REACTIVE_PHASE_1 + """The AC reactive power in phase 1.""" + + AC_POWER_REACTIVE_PHASE_2 = metrics_pb2.METRIC_AC_POWER_REACTIVE_PHASE_2 + """The AC reactive power in phase 2.""" + + AC_POWER_REACTIVE_PHASE_3 = metrics_pb2.METRIC_AC_POWER_REACTIVE_PHASE_3 + """The AC reactive power in phase 3.""" + + AC_POWER_FACTOR = metrics_pb2.METRIC_AC_POWER_FACTOR + """The AC power factor.""" + + AC_POWER_FACTOR_PHASE_1 = metrics_pb2.METRIC_AC_POWER_FACTOR_PHASE_1 + """The AC power factor in phase 1.""" + + AC_POWER_FACTOR_PHASE_2 = metrics_pb2.METRIC_AC_POWER_FACTOR_PHASE_2 + """The AC power factor in phase 2.""" + + AC_POWER_FACTOR_PHASE_3 = metrics_pb2.METRIC_AC_POWER_FACTOR_PHASE_3 + """The AC power factor in phase 3.""" + + AC_ENERGY_APPARENT = metrics_pb2.METRIC_AC_ENERGY_APPARENT + """The AC apparent energy.""" + + AC_ENERGY_APPARENT_PHASE_1 = metrics_pb2.METRIC_AC_ENERGY_APPARENT_PHASE_1 + """The AC apparent energy in phase 1.""" + + AC_ENERGY_APPARENT_PHASE_2 = metrics_pb2.METRIC_AC_ENERGY_APPARENT_PHASE_2 + """The AC apparent energy in phase 2.""" + + AC_ENERGY_APPARENT_PHASE_3 = metrics_pb2.METRIC_AC_ENERGY_APPARENT_PHASE_3 + """The AC apparent energy in phase 3.""" + + AC_ENERGY_ACTIVE = metrics_pb2.METRIC_AC_ENERGY_ACTIVE + """The AC active energy.""" + + AC_ENERGY_ACTIVE_PHASE_1 = metrics_pb2.METRIC_AC_ENERGY_ACTIVE_PHASE_1 + """The AC active energy in phase 1.""" + + AC_ENERGY_ACTIVE_PHASE_2 = metrics_pb2.METRIC_AC_ENERGY_ACTIVE_PHASE_2 + """The AC active energy in phase 2.""" + + AC_ENERGY_ACTIVE_PHASE_3 = metrics_pb2.METRIC_AC_ENERGY_ACTIVE_PHASE_3 + """The AC active energy in phase 3.""" + + AC_ENERGY_ACTIVE_CONSUMED = metrics_pb2.METRIC_AC_ENERGY_ACTIVE_CONSUMED + """The AC active energy consumed.""" + + AC_ENERGY_ACTIVE_CONSUMED_PHASE_1 = ( + metrics_pb2.METRIC_AC_ENERGY_ACTIVE_CONSUMED_PHASE_1 + ) + """The AC active energy consumed in phase 1.""" + + AC_ENERGY_ACTIVE_CONSUMED_PHASE_2 = ( + metrics_pb2.METRIC_AC_ENERGY_ACTIVE_CONSUMED_PHASE_2 + ) + """The AC active energy consumed in phase 2.""" + + AC_ENERGY_ACTIVE_CONSUMED_PHASE_3 = ( + metrics_pb2.METRIC_AC_ENERGY_ACTIVE_CONSUMED_PHASE_3 + ) + """The AC active energy consumed in phase 3.""" + + AC_ENERGY_ACTIVE_DELIVERED = metrics_pb2.METRIC_AC_ENERGY_ACTIVE_DELIVERED + """The AC active energy delivered.""" + + AC_ENERGY_ACTIVE_DELIVERED_PHASE_1 = ( + metrics_pb2.METRIC_AC_ENERGY_ACTIVE_DELIVERED_PHASE_1 + ) + """The AC active energy delivered in phase 1.""" + + AC_ENERGY_ACTIVE_DELIVERED_PHASE_2 = ( + metrics_pb2.METRIC_AC_ENERGY_ACTIVE_DELIVERED_PHASE_2 + ) + """The AC active energy delivered in phase 2.""" + + AC_ENERGY_ACTIVE_DELIVERED_PHASE_3 = ( + metrics_pb2.METRIC_AC_ENERGY_ACTIVE_DELIVERED_PHASE_3 + ) + """The AC active energy delivered in phase 3.""" + + AC_ENERGY_REACTIVE = metrics_pb2.METRIC_AC_ENERGY_REACTIVE + """The AC reactive energy.""" + + AC_ENERGY_REACTIVE_PHASE_1 = metrics_pb2.METRIC_AC_ENERGY_REACTIVE_PHASE_1 + """The AC reactive energy in phase 1.""" + + AC_ENERGY_REACTIVE_PHASE_2 = metrics_pb2.METRIC_AC_ENERGY_REACTIVE_PHASE_2 + """The AC reactive energy in phase 2.""" + + AC_ENERGY_REACTIVE_PHASE_3 = metrics_pb2.METRIC_AC_ENERGY_REACTIVE_PHASE_3 + """The AC reactive energy in phase 3.""" + + AC_TOTAL_HARMONIC_DISTORTION_CURRENT = ( + metrics_pb2.METRIC_AC_TOTAL_HARMONIC_DISTORTION_CURRENT + ) + """The AC total harmonic distortion current.""" + + AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_1 = ( + metrics_pb2.METRIC_AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_1 + ) + """The AC total harmonic distortion current in phase 1.""" + + AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_2 = ( + metrics_pb2.METRIC_AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_2 + ) + """The AC total harmonic distortion current in phase 2.""" + + AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_3 = ( + metrics_pb2.METRIC_AC_TOTAL_HARMONIC_DISTORTION_CURRENT_PHASE_3 + ) + """The AC total harmonic distortion current in phase 3.""" + + BATTERY_CAPACITY = metrics_pb2.METRIC_BATTERY_CAPACITY + """The capacity of the battery.""" + + BATTERY_SOC_PCT = metrics_pb2.METRIC_BATTERY_SOC_PCT + """The state of charge of the battery as a percentage.""" + + BATTERY_TEMPERATURE = metrics_pb2.METRIC_BATTERY_TEMPERATURE + """The temperature of the battery.""" + + INVERTER_TEMPERATURE = metrics_pb2.METRIC_INVERTER_TEMPERATURE + """The temperature of the inverter.""" + + INVERTER_TEMPERATURE_CABINET = metrics_pb2.METRIC_INVERTER_TEMPERATURE_CABINET + """The temperature of the inverter cabinet.""" + + INVERTER_TEMPERATURE_HEATSINK = metrics_pb2.METRIC_INVERTER_TEMPERATURE_HEATSINK + """The temperature of the inverter heatsink.""" + + INVERTER_TEMPERATURE_TRANSFORMER = ( + metrics_pb2.METRIC_INVERTER_TEMPERATURE_TRANSFORMER + ) + """The temperature of the inverter transformer.""" + + EV_CHARGER_TEMPERATURE = metrics_pb2.METRIC_EV_CHARGER_TEMPERATURE + """The temperature of the EV charger.""" + + SENSOR_WIND_SPEED = metrics_pb2.METRIC_SENSOR_WIND_SPEED + """The speed of the wind measured.""" + + SENSOR_WIND_DIRECTION = metrics_pb2.METRIC_SENSOR_WIND_DIRECTION + """The direction of the wind measured.""" + + SENSOR_TEMPERATURE = metrics_pb2.METRIC_SENSOR_TEMPERATURE + """The temperature measured.""" + + SENSOR_RELATIVE_HUMIDITY = metrics_pb2.METRIC_SENSOR_RELATIVE_HUMIDITY + """The relative humidity measured.""" + + SENSOR_DEW_POINT = metrics_pb2.METRIC_SENSOR_DEW_POINT + """The dew point measured.""" + + SENSOR_AIR_PRESSURE = metrics_pb2.METRIC_SENSOR_AIR_PRESSURE + """The air pressure measured.""" + + SENSOR_IRRADIANCE = metrics_pb2.METRIC_SENSOR_IRRADIANCE + """The irradiance measured.""" diff --git a/src/frequenz/client/common/metrics/_sample.py b/src/frequenz/client/common/metrics/_sample.py new file mode 100644 index 0000000..82e19fe --- /dev/null +++ b/src/frequenz/client/common/metrics/_sample.py @@ -0,0 +1,233 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Definition to work with metric sample values.""" + +import enum +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import assert_never + +from frequenz.api.common.v1alpha8.metrics import metrics_pb2 + +from ._bounds import Bounds +from ._metric import Metric + + +@enum.unique +class AggregationMethod(enum.Enum): + """The type of the aggregated value.""" + + AVG = "avg" + """The average value of the metric.""" + + MIN = "min" + """The minimum value of the metric.""" + + MAX = "max" + """The maximum value of the metric.""" + + +@dataclass(frozen=True, kw_only=True) +class AggregatedMetricValue: + """Encapsulates derived statistical summaries of a single metric. + + The message allows for the reporting of statistical summaries — minimum, + maximum, and average values - as well as the complete list of individual + samples if available. + + This message represents derived metrics and contains fields for statistical + summaries—minimum, maximum, and average values. Individual measurements are + optional, accommodating scenarios where only subsets of this information + are available. + """ + + avg: float + """The derived average value of the metric.""" + + min: float | None + """The minimum measured value of the metric.""" + + max: float | None + """The maximum measured value of the metric.""" + + raw: Sequence[float] + """All the raw individual values (it might be empty if not provided by the component).""" + + def __str__(self) -> str: + """Return the short string representation of this instance.""" + extra: list[str] = [] + if self.min is not None: + extra.append(f"min:{self.min}") + if self.max is not None: + extra.append(f"max:{self.max}") + if len(self.raw) > 0: + extra.append(f"num_raw:{len(self.raw)}") + extra_str = f"<{' '.join(extra)}>" if extra else "" + return f"avg:{self.avg}{extra_str}" + + +@enum.unique +class MetricConnectionCategory(enum.Enum): + """The categories of connections from which metrics can be obtained.""" + + UNSPECIFIED = metrics_pb2.METRIC_CONNECTION_CATEGORY_UNSPECIFIED + """The connection category was not specified (do not use).""" + + OTHER = metrics_pb2.METRIC_CONNECTION_CATEGORY_OTHER + """A generic connection for metrics that do not fit into any other category.""" + + BATTERY = metrics_pb2.METRIC_CONNECTION_CATEGORY_BATTERY + """A connection to a metric representing a battery.""" + + PV = metrics_pb2.METRIC_CONNECTION_CATEGORY_PV + """A connection to a metric representing a PV (photovoltaic) array or string.""" + + AMBIENT = metrics_pb2.METRIC_CONNECTION_CATEGORY_AMBIENT + """A connection to a metric representing ambient conditions.""" + + CABINET = metrics_pb2.METRIC_CONNECTION_CATEGORY_CABINET + """A connection to a metric representing a cabinet or an enclosure.""" + + HEATSINK = metrics_pb2.METRIC_CONNECTION_CATEGORY_HEATSINK + """A connection to a metric representing a heatsink.""" + + TRANSFORMER = metrics_pb2.METRIC_CONNECTION_CATEGORY_TRANSFORMER + """A connection to a metric representing a transformer.""" + + +@dataclass(frozen=True, kw_only=True) +class MetricConnection: + """A connection to a metric representing from which a metric was obtained.""" + + category: MetricConnectionCategory | int + """The category of the connection from which the metric was obtained.""" + + name: str | None = None + """The name of the specific connection from which the metric was obtained. + + This is expected to be populated when the same `Metric` variant can be obtained from + multiple distinct inputs or connection points on the component. Knowing the + connection for the metric can help in certain control and monitoring applications. + """ + + def __str__(self) -> str: + """Return a string representation of this connection.""" + category_name = ( + str(self.category) + if isinstance(self.category, int) + else f"" + ) + if self.name is not None: + return f"{category_name}({self.name})" + return category_name + + +@dataclass(frozen=True, kw_only=True) +class MetricSample: + """A sampled metric. + + This represents a single sample of a specific metric, the value of which is either + measured at a particular time. The real-time system-defined bounds are optional and + may not always be present or set. + + Note: Relationship Between Bounds and Metric Samples + Suppose a metric sample for active power has a lower-bound of -10,000 W, and an + upper-bound of 10,000 W. For the system to accept a charge command, clients need + to request current values within the bounds. + """ + + sample_time: datetime + """The moment when the metric was sampled.""" + + metric: Metric | 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.""" + + bounds: list[Bounds] + """The bounds that apply to the metric sample. + + These bounds adapt in real-time to reflect the operating conditions at the time of + aggregation or derivation. + + In the case of certain components like batteries, multiple bounds might exist. These + multiple bounds collectively extend the range of allowable values, effectively + forming a union of all given bounds. In such cases, the value of the metric must be + within at least one of the bounds. + + In accordance with the passive sign convention, bounds that limit discharge would + have negative numbers, while those limiting charge, such as for the State of Power + (SoP) metric, would be positive. Hence bounds can have positive and negative values + depending on the metric they represent. + + Example: + The diagram below illustrates the relationship between the bounds. + + ``` + bound[0].lower bound[1].upper + <-------|============|------------------|============|---------> + bound[0].upper bound[1].lower + + ---- values here are disallowed and will be rejected + ==== values here are allowed and will be accepted + ``` + """ + + connection: MetricConnection | None = None + """The electrical connection within the component from which the metric was sampled. + + This will be present when the same `Metric` can be obtained from multiple electrical + connections within the component. Knowing the connection can help in certain control + and monitoring applications. + + In cases where the component has just one connection for a metric, then the + connection is `None`. + + Example: + A hybrid inverter can have a DC string for a battery and another DC string for a + PV array. The connection names could resemble, say, `dc_battery_0` (category + `BATTERY`) and `dc_pv_0` (category `PV`). A metric like DC voltage can be + obtained from both connections. For an application to determine the SoC of the + battery using the battery voltage, which connection the voltage metric was + sampled from is important. + """ + + 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.common.metrics.MetricSample.value] is a `float`, + it is returned as is. If `value` is an + [`AggregatedMetricValue`][frequenz.client.common.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) diff --git a/src/frequenz/client/common/metrics/proto/__init__.py b/src/frequenz/client/common/metrics/proto/__init__.py new file mode 100644 index 0000000..4dcefa6 --- /dev/null +++ b/src/frequenz/client/common/metrics/proto/__init__.py @@ -0,0 +1,19 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Metrics objects to proto conversion functions.""" + +from ._bounds import bounds_from_proto, bounds_from_proto_with_issues +from ._sample import ( + aggregated_metric_sample_from_proto, + metric_connection_from_proto_with_issues, + metric_sample_from_proto_with_issues, +) + +__all__ = [ + "aggregated_metric_sample_from_proto", + "bounds_from_proto", + "bounds_from_proto_with_issues", + "metric_connection_from_proto_with_issues", + "metric_sample_from_proto_with_issues", +] diff --git a/src/frequenz/client/common/metrics/proto/_bounds.py b/src/frequenz/client/common/metrics/proto/_bounds.py new file mode 100644 index 0000000..835aaed --- /dev/null +++ b/src/frequenz/client/common/metrics/proto/_bounds.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Loading of Bounds objects from protobuf messages.""" + + +from frequenz.api.common.v1alpha8.metrics import bounds_pb2 + +from .._bounds import Bounds + + +def bounds_from_proto(message: bounds_pb2.Bounds) -> Bounds: # noqa: DOC502 + """Create a `Bounds` object from a protobuf message. + + Args: + message: The protobuf message to convert. + + Returns: + The corresponding `Bounds` object. + + Raises: + ValueError: If the message is not valid. + """ + return Bounds( + lower=message.lower if message.HasField("lower") else None, + upper=message.upper if message.HasField("upper") else None, + ) + + +def bounds_from_proto_with_issues( + message: bounds_pb2.Bounds, + *, + major_issues: list[str], + minor_issues: list[str], # pylint: disable=unused-argument +) -> Bounds | None: # noqa: DOC502 + """Create a `Bounds` object from a protobuf message, collecting issues. + + Args: + message: The protobuf message to convert. + major_issues: A list to append major issues to. + minor_issues: A list to append minor issues to. + + Returns: + The corresponding `Bounds` object. + """ + try: + return bounds_from_proto(message) + except ValueError as exc: + major_issues.append(str(exc)) + return None diff --git a/src/frequenz/client/common/metrics/proto/_sample.py b/src/frequenz/client/common/metrics/proto/_sample.py new file mode 100644 index 0000000..23bdd36 --- /dev/null +++ b/src/frequenz/client/common/metrics/proto/_sample.py @@ -0,0 +1,150 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Loading of MetricSample and AggregatedMetricValue objects from protobuf messages.""" + +from collections.abc import Sequence + +from frequenz.api.common.v1alpha8.metrics import bounds_pb2, metrics_pb2 + +from ...proto import datetime_from_proto, enum_from_proto +from .._bounds import Bounds +from .._metric import Metric +from .._sample import ( + AggregatedMetricValue, + MetricConnection, + MetricConnectionCategory, + MetricSample, +) +from ._bounds import bounds_from_proto + + +def aggregated_metric_sample_from_proto( + message: metrics_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=message.raw_values, + ) + + +def metric_connection_from_proto_with_issues( + message: metrics_pb2.MetricConnection, + *, + major_issues: list[str], + minor_issues: list[str], +) -> MetricConnection: + """Convert a protobuf message to a `MetricConnection` object. + + Args: + message: The protobuf message to convert. + major_issues: A list to append major issues to. + minor_issues: A list to append minor issues to. + + Returns: + The resulting `MetricConnection` object. + """ + category = enum_from_proto(message.category, MetricConnectionCategory) + + match category: + case MetricConnectionCategory.UNSPECIFIED: + major_issues.append("unspecified category") + case int(): + minor_issues.append(f"unrecognized category {category}") + + return MetricConnection( + category=category, + name=message.name or None, + ) + + +def metric_sample_from_proto_with_issues( + message: metrics_pb2.MetricSample, + *, + major_issues: list[str], + minor_issues: list[str], +) -> MetricSample: + """Convert a protobuf message to a `MetricSample` object. + + Args: + message: The protobuf message to convert. + major_issues: A list to append major issues to. + minor_issues: A list to append minor issues to. + + Returns: + The resulting `MetricSample` object. + """ + sample_time = datetime_from_proto(message.sample_time) + + metric = enum_from_proto(message.metric, Metric) + + value: float | AggregatedMetricValue | None = None + if message.HasField("value"): + match message.value.WhichOneof("metric_value_variant"): + case "simple_metric": + value = message.value.simple_metric.value + case "aggregated_metric": + value = aggregated_metric_sample_from_proto( + message.value.aggregated_metric + ) + + bounds = _metric_bounds_from_proto( + metric, message.bounds, major_issues=major_issues, minor_issues=minor_issues + ) + + connection = None + if message.HasField("connection"): + connection = metric_connection_from_proto_with_issues( + message.connection, major_issues=major_issues, minor_issues=minor_issues + ) + + return MetricSample( + sample_time=sample_time, + metric=metric, + value=value, + bounds=bounds, + connection=connection, + ) + + +def _metric_bounds_from_proto( + metric: Metric | int, + messages: Sequence[bounds_pb2.Bounds], + *, + major_issues: list[str], + minor_issues: list[str], # pylint:disable=unused-argument +) -> list[Bounds]: + """Convert a sequence of bounds messages to a list of `Bounds`. + + Args: + metric: The metric for which the bounds are defined, used for logging issues. + messages: The sequence of bounds messages. + major_issues: A list to append major issues to. + minor_issues: A list to append minor issues to. + + Returns: + The resulting list of `Bounds`. + """ + bounds: list[Bounds] = [] + for pb_bound in messages: + try: + bound = bounds_from_proto(pb_bound) + except ValueError as exc: + metric_name = metric if isinstance(metric, int) else metric.name + major_issues.append( + f"bounds for {metric_name} is invalid ({exc}), ignoring these bounds" + ) + continue + bounds.append(bound) + + return bounds diff --git a/src/frequenz/client/common/microgrid/components/__init__.py b/src/frequenz/client/common/microgrid/components/__init__.py index 346edcc..a9ace07 100644 --- a/src/frequenz/client/common/microgrid/components/__init__.py +++ b/src/frequenz/client/common/microgrid/components/__init__.py @@ -105,7 +105,10 @@ class ComponentCategory(enum.Enum): """A Heating, Ventilation, and Air Conditioning (HVAC) system.""" @classmethod - @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") + @deprecated( + "frequenz.client.common.microgrid.components.ComponentCategory.from_proto() is deprecated. " + "Use frequenz.client.common.proto.enum_from_proto instead." + ) def from_proto( cls, component_category: PBComponentCategory.ValueType ) -> ComponentCategory: @@ -211,7 +214,11 @@ class ComponentStateCode(enum.Enum): """The precharger circuit is closed, allowing full current to flow to the main circuit.""" @classmethod - @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") + @deprecated( + "frequenz.client.common.microgrid.components.ComponentStateCode." + "from_proto() is deprecated. " + "Use frequenz.client.common.proto.enum_from_proto instead." + ) def from_proto( cls, component_state: PBComponentStateCode.ValueType ) -> ComponentStateCode: @@ -390,7 +397,11 @@ class ComponentErrorCode(enum.Enum): times.""" @classmethod - @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") + @deprecated( + "frequenz.client.common.microgrid.components." + "ComponentErrorCode.from_proto() is deprecated. " + "Use frequenz.client.common.proto.enum_from_proto instead." + ) def from_proto( cls, component_error_code: PBComponentErrorCode.ValueType ) -> ComponentErrorCode: diff --git a/src/frequenz/client/common/microgrid/electrical_components/__init__.py b/src/frequenz/client/common/microgrid/electrical_components/__init__.py index 4efed51..d0091cf 100644 --- a/src/frequenz/client/common/microgrid/electrical_components/__init__.py +++ b/src/frequenz/client/common/microgrid/electrical_components/__init__.py @@ -89,7 +89,11 @@ class ElectricalComponentCategory(enum.Enum): """A heating, ventilation, and air conditioning (HVAC) system.""" @classmethod - @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") + @deprecated( + "frequenz.client.common.microgrid.electrical_components." + "ElectricalComponentCategory.from_proto() is deprecated. " + "Use frequenz.client.common.proto.enum_from_proto instead." + ) def from_proto( cls, component_category: PBElectricalComponentCategory.ValueType ) -> ElectricalComponentCategory: @@ -217,7 +221,11 @@ class ElectricalComponentStateCode(enum.Enum): """The precharger circuit is closed, allowing full current to flow to the main circuit.""" @classmethod - @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") + @deprecated( + "frequenz.client.common.microgrid.electrical_components." + "ElectricalComponentStateCode.from_proto() is deprecated. " + "Use frequenz.client.common.proto.enum_from_proto instead." + ) def from_proto( cls, component_state: PBElectricalComponentStateCode.ValueType ) -> ElectricalComponentStateCode: @@ -432,7 +440,11 @@ class ElectricalComponentDiagnosticCode(enum.Enum): times.""" @classmethod - @deprecated("Use `frequenz.client.common.enum_proto.enum_from_proto` instead.") + @deprecated( + "frequenz.client.common.microgrid.electrical_components." + "ElectricalComponentDiagnosticCode.from_proto() is deprecated. " + "Use frequenz.client.common.proto.enum_from_proto instead." + ) def from_proto( cls, component_error_code: PBElectricalComponentDiagnosticCode.ValueType ) -> ElectricalComponentDiagnosticCode: diff --git a/src/frequenz/client/common/pagination/__init__.py b/src/frequenz/client/common/pagination/__init__.py index 54cd5f6..db42354 100644 --- a/src/frequenz/client/common/pagination/__init__.py +++ b/src/frequenz/client/common/pagination/__init__.py @@ -24,9 +24,9 @@ @deprecated( - "Params is deprecated, use " - "frequenz.api.common.v1.pagination.pagination_params_pb2.PaginationParams" - " from the API directly instead.", + "frequenz.client.common.pagination.Params is deprecated. " + "Use frequenz.api.common.v1.pagination.pagination_params_pb2.PaginationParams " + "from the API directly instead.", ) @dataclass(frozen=True, kw_only=True) class Params: @@ -65,7 +65,8 @@ def to_proto(self) -> PBPaginationParams: @deprecated( - "Info is deprecated, use PaginationInfo instead.", + "frequenz.client.common.pagination.Info is deprecated. " + "Use frequenz.client.common.pagination.PaginationInfo instead.", ) @dataclass(frozen=True, kw_only=True) class Info: diff --git a/src/frequenz/client/common/proto/__init__.py b/src/frequenz/client/common/proto/__init__.py new file mode 100644 index 0000000..3aec502 --- /dev/null +++ b/src/frequenz/client/common/proto/__init__.py @@ -0,0 +1,13 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""General utilities for converting common types to/from protobuf types.""" + +from ._enum import enum_from_proto +from ._timestamp import datetime_from_proto, datetime_to_proto + +__all__ = [ + "enum_from_proto", + "datetime_from_proto", + "datetime_to_proto", +] diff --git a/src/frequenz/client/common/proto/_enum.py b/src/frequenz/client/common/proto/_enum.py new file mode 100644 index 0000000..789f185 --- /dev/null +++ b/src/frequenz/client/common/proto/_enum.py @@ -0,0 +1,76 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Conversion of protobuf int enums to Python enums.""" + +import enum +from typing import Literal, TypeVar, overload + +EnumT = TypeVar("EnumT", bound=enum.Enum) +"""A type variable that is bound to an enum.""" + + +@overload +def enum_from_proto( + value: int, enum_type: type[EnumT], *, allow_invalid: Literal[False] +) -> EnumT: ... + + +@overload +def enum_from_proto( + value: int, enum_type: type[EnumT], *, allow_invalid: Literal[True] = True +) -> EnumT | int: ... + + +def enum_from_proto( + value: int, enum_type: type[EnumT], *, allow_invalid: bool = True +) -> EnumT | int: + """Convert a protobuf int enum value to a python enum. + + Example: + ```python + import enum + + from proto import proto_pb2 # Just an example. pylint: disable=import-error + + @enum.unique + class SomeEnum(enum.Enum): + # These values should match the protobuf enum values. + UNSPECIFIED = 0 + SOME_VALUE = 1 + + enum_value = enum_from_proto(proto_pb2.SomeEnum.SOME_ENUM_SOME_VALUE, SomeEnum) + # -> SomeEnum.SOME_VALUE + + enum_value = enum_from_proto(42, SomeEnum) + # -> 42 + + enum_value = enum_from_proto( + proto_pb2.SomeEnum.SOME_ENUM_UNKNOWN_VALUE, SomeEnum, allow_invalid=False + ) + # -> ValueError + ``` + + Args: + value: The protobuf int enum value. + enum_type: The python enum type to convert to. + allow_invalid: If `True`, return the value as an `int` if the value is not + a valid member of the enum (this allows for forward-compatibility with new + enum values defined in the protocol but not added to the Python enum yet). + If `False`, raise a `ValueError` if the value is not a valid member of the + enum. + + Returns: + The resulting python enum value if the protobuf value is known, otherwise + the input value converted to a plain `int`. + + Raises: + ValueError: If `allow_invalid` is `False` and the value is not a valid member + of the enum. + """ + try: + return enum_type(value) + except ValueError: + if allow_invalid: + return value + raise diff --git a/src/frequenz/client/common/proto/_timestamp.py b/src/frequenz/client/common/proto/_timestamp.py new file mode 100644 index 0000000..a0da8a8 --- /dev/null +++ b/src/frequenz/client/common/proto/_timestamp.py @@ -0,0 +1,69 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Helper functions to convert protobuf Timestamp <-> Python datetime.""" + +from datetime import datetime, timezone +from typing import overload + +from google.protobuf import timestamp_pb2 + + +@overload +def datetime_to_proto(dt: datetime) -> timestamp_pb2.Timestamp: + """Convert a datetime to a protobuf Timestamp. + + Args: + dt: datetime object to convert + + Returns: + datetime converted to Timestamp + """ + + +@overload +def datetime_to_proto(dt: None) -> None: + """Overload to handle None values. + + Args: + dt: None + + Returns: + None + """ + + +def datetime_to_proto(dt: datetime | None) -> timestamp_pb2.Timestamp | None: + """Convert a datetime to a protobuf Timestamp. + + Returns None if dt is None. + + Args: + dt: datetime object to convert + + Returns: + datetime converted to Timestamp + """ + if dt is None: + return None + + ts = timestamp_pb2.Timestamp() + ts.FromDatetime(dt) + return ts + + +def datetime_from_proto( + ts: timestamp_pb2.Timestamp, tz: timezone = timezone.utc +) -> datetime: + """Convert a protobuf Timestamp to a datetime. + + Args: + ts: Timestamp object to convert + tz: Timezone to use for the datetime + + Returns: + Timestamp converted to datetime + """ + # Add microseconds and add nanoseconds converted to microseconds + microseconds = int(ts.nanos / 1000) + return datetime.fromtimestamp(ts.seconds + microseconds * 1e-6, tz=tz) diff --git a/tests/metrics/__init__.py b/tests/metrics/__init__.py new file mode 100644 index 0000000..b2c93e3 --- /dev/null +++ b/tests/metrics/__init__.py @@ -0,0 +1 @@ +"""Tests for the metrics package.""" diff --git a/tests/metrics/proto/__init__.py b/tests/metrics/proto/__init__.py new file mode 100644 index 0000000..1aa6a7f --- /dev/null +++ b/tests/metrics/proto/__init__.py @@ -0,0 +1 @@ +"""Tests for the proto package.""" diff --git a/tests/metrics/proto/test_bounds.py b/tests/metrics/proto/test_bounds.py new file mode 100644 index 0000000..8fb5dc9 --- /dev/null +++ b/tests/metrics/proto/test_bounds.py @@ -0,0 +1,124 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Bounds class protobuf conversion.""" + +from dataclasses import dataclass + +import pytest +from frequenz.api.common.v1alpha8.metrics import bounds_pb2 + +from frequenz.client.common.metrics.proto import ( + bounds_from_proto, + bounds_from_proto_with_issues, +) + + +@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( + "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 + + +def test_from_proto_with_issues_valid() -> None: + """Test bounds_from_proto_with_issues with valid bounds.""" + proto = bounds_pb2.Bounds() + proto.lower = -10.0 + proto.upper = 10.0 + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + bounds = bounds_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert bounds is not None + assert bounds.lower == -10.0 + assert bounds.upper == 10.0 + assert not major_issues + assert not minor_issues + + +def test_from_proto_with_issues_invalid() -> None: + """Test bounds_from_proto_with_issues with invalid bounds (lower > upper).""" + proto = bounds_pb2.Bounds() + proto.lower = 10.0 + proto.upper = -10.0 + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + bounds = bounds_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert bounds is None + assert len(major_issues) == 1 + assert ( + "Lower bound (10.0) must be less than or equal to upper bound (-10.0)" + in major_issues[0] + ) + assert not minor_issues diff --git a/tests/metrics/proto/test_sample_aggregated_value.py b/tests/metrics/proto/test_sample_aggregated_value.py new file mode 100644 index 0000000..182fdfd --- /dev/null +++ b/tests/metrics/proto/test_sample_aggregated_value.py @@ -0,0 +1,88 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for AggregatedMetricValue protobuf conversion.""" + +from dataclasses import dataclass, field + +import pytest +from frequenz.api.common.v1alpha8.metrics import metrics_pb2 + +from frequenz.client.common.metrics.proto import aggregated_metric_sample_from_proto + + +@dataclass(frozen=True, kw_only=True) +class _TestCase: + """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: list[float] = field(default_factory=list) + """The raw values to include.""" + + +@pytest.mark.parametrize( + "case", + [ + _TestCase( + name="full", + avg_value=5.0, + min_value=1.0, + max_value=10.0, + raw=[1.0, 5.0, 10.0], + ), + _TestCase( + name="minimal", + avg_value=5.0, + has_min=False, + has_max=False, + ), + _TestCase( + name="only_min", + avg_value=5.0, + has_max=False, + min_value=1.0, + ), + _TestCase( + name="only_max", + avg_value=5.0, + has_min=False, + max_value=10.0, + ), + ], + ids=lambda case: case.name, +) +def test_from_proto(case: _TestCase) -> None: + """Test conversion from protobuf message to AggregatedMetricValue.""" + proto = metrics_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: + proto.raw_values.extend(case.raw) + + 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) == case.raw diff --git a/tests/metrics/proto/test_sample_metric_connection.py b/tests/metrics/proto/test_sample_metric_connection.py new file mode 100644 index 0000000..d563322 --- /dev/null +++ b/tests/metrics/proto/test_sample_metric_connection.py @@ -0,0 +1,91 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for MetricConnection protobuf conversion.""" + +from frequenz.api.common.v1alpha8.metrics import metrics_pb2 + +from frequenz.client.common.metrics import MetricConnectionCategory +from frequenz.client.common.metrics.proto import ( + metric_connection_from_proto_with_issues, +) + + +def test_with_unspecified_category() -> None: + """Test conversion with UNSPECIFIED category reports major issue.""" + proto = metrics_pb2.MetricConnection( + category=metrics_pb2.METRIC_CONNECTION_CATEGORY_UNSPECIFIED, + name="some_connection", + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + connection = metric_connection_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert connection.category == MetricConnectionCategory.UNSPECIFIED + assert connection.name == "some_connection" + assert major_issues == ["unspecified category"] + assert not minor_issues + + +def test_with_unrecognized_category() -> None: + """Test conversion with unrecognized category reports minor issue.""" + proto = metrics_pb2.MetricConnection( + category=9999, # type: ignore[arg-type] + name="unknown_connection", + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + connection = metric_connection_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert connection.category == 9999 + assert connection.name == "unknown_connection" + assert not major_issues + assert minor_issues == ["unrecognized category 9999"] + + +def test_with_valid_category() -> None: + """Test conversion with valid category does not report issues.""" + proto = metrics_pb2.MetricConnection( + category=metrics_pb2.METRIC_CONNECTION_CATEGORY_BATTERY, + name="dc_battery_0", + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + connection = metric_connection_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert connection.category == MetricConnectionCategory.BATTERY + assert connection.name == "dc_battery_0" + assert not major_issues + assert not minor_issues + + +def test_with_empty_name() -> None: + """Test conversion with empty name becomes None.""" + proto = metrics_pb2.MetricConnection( + category=metrics_pb2.METRIC_CONNECTION_CATEGORY_PV, + name="", + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + connection = metric_connection_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert connection.category == MetricConnectionCategory.PV + assert connection.name is None + assert not major_issues + assert not minor_issues diff --git a/tests/metrics/proto/test_sample_metric_sample.py b/tests/metrics/proto/test_sample_metric_sample.py new file mode 100644 index 0000000..fe48490 --- /dev/null +++ b/tests/metrics/proto/test_sample_metric_sample.py @@ -0,0 +1,202 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for MetricSample protobuf conversion.""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Final + +import pytest +from frequenz.api.common.v1alpha8.metrics import bounds_pb2, metrics_pb2 +from google.protobuf.timestamp_pb2 import Timestamp + +from frequenz.client.common.metrics import ( + AggregatedMetricValue, + Bounds, + Metric, + MetricConnection, + MetricConnectionCategory, + MetricSample, +) +from frequenz.client.common.metrics.proto import metric_sample_from_proto_with_issues + +DATETIME: Final[datetime] = datetime(2023, 3, 15, 12, 0, 0, tzinfo=timezone.utc) +TIMESTAMP: Final[Timestamp] = Timestamp(seconds=int(DATETIME.timestamp())) + + +@dataclass(frozen=True, kw_only=True) +class _TestCase: + """Test case for MetricSample protobuf conversion.""" + + name: str + """The description of the test case.""" + + proto_message: metrics_pb2.MetricSample + """The input protobuf message.""" + + expected_sample: MetricSample + """The expected MetricSample object.""" + + expected_major_issues: list[str] = field(default_factory=list) + """Expected major issues during conversion.""" + + expected_minor_issues: list[str] = field(default_factory=list) + """Expected minor issues during conversion.""" + + +@pytest.mark.parametrize( + "case", + [ + _TestCase( + name="simple_value", + proto_message=metrics_pb2.MetricSample( + sample_time=TIMESTAMP, + metric=Metric.AC_POWER_ACTIVE.value, + value=metrics_pb2.MetricValueVariant( + simple_metric=metrics_pb2.SimpleMetricValue(value=5.0) + ), + ), + expected_sample=MetricSample( + sample_time=DATETIME, + metric=Metric.AC_POWER_ACTIVE, + value=5.0, + bounds=[], + connection=None, + ), + ), + _TestCase( + name="aggregated_value", + proto_message=metrics_pb2.MetricSample( + sample_time=TIMESTAMP, + metric=Metric.AC_POWER_ACTIVE.value, + value=metrics_pb2.MetricValueVariant( + aggregated_metric=metrics_pb2.AggregatedMetricValue( + avg_value=5.0, min_value=1.0, max_value=10.0 + ) + ), + ), + expected_sample=MetricSample( + sample_time=DATETIME, + metric=Metric.AC_POWER_ACTIVE, + value=AggregatedMetricValue(avg=5.0, min=1.0, max=10.0, raw=[]), + bounds=[], + connection=None, + ), + ), + _TestCase( + name="no_value", + proto_message=metrics_pb2.MetricSample( + sample_time=TIMESTAMP, + metric=Metric.AC_POWER_ACTIVE.value, + ), + expected_sample=MetricSample( + sample_time=DATETIME, + metric=Metric.AC_POWER_ACTIVE, + value=None, + bounds=[], + connection=None, + ), + ), + _TestCase( + name="unrecognized_metric", + proto_message=metrics_pb2.MetricSample( + sample_time=TIMESTAMP, + metric=999, # type: ignore[arg-type] + value=metrics_pb2.MetricValueVariant( + simple_metric=metrics_pb2.SimpleMetricValue(value=5.0) + ), + ), + expected_sample=MetricSample( + sample_time=DATETIME, metric=999, value=5.0, bounds=[], connection=None + ), + ), + _TestCase( + name="with_valid_bounds", + proto_message=metrics_pb2.MetricSample( + sample_time=TIMESTAMP, + metric=Metric.AC_POWER_ACTIVE.value, + value=metrics_pb2.MetricValueVariant( + simple_metric=metrics_pb2.SimpleMetricValue(value=5.0) + ), + bounds=[bounds_pb2.Bounds(lower=-10.0, upper=10.0)], + ), + expected_sample=MetricSample( + sample_time=DATETIME, + metric=Metric.AC_POWER_ACTIVE, + value=5.0, + bounds=[Bounds(lower=-10.0, upper=10.0)], + connection=None, + ), + ), + _TestCase( + name="with_invalid_bounds", + proto_message=metrics_pb2.MetricSample( + sample_time=TIMESTAMP, + metric=Metric.AC_POWER_ACTIVE.value, + value=metrics_pb2.MetricValueVariant( + simple_metric=metrics_pb2.SimpleMetricValue(value=5.0) + ), + bounds=[ + bounds_pb2.Bounds(lower=-10.0, upper=10.0), + bounds_pb2.Bounds(lower=10.0, upper=-10.0), # Invalid + ], + ), + expected_sample=MetricSample( + sample_time=DATETIME, + metric=Metric.AC_POWER_ACTIVE, + value=5.0, + bounds=[Bounds(lower=-10.0, upper=10.0)], # Invalid bounds are ignored + connection=None, + ), + expected_major_issues=[ + ( + "bounds for AC_POWER_ACTIVE is invalid (Lower bound (10.0) must be " + "less than or equal to upper bound (-10.0)), ignoring these bounds" + ) + ], + ), + _TestCase( + name="with_connection", + proto_message=metrics_pb2.MetricSample( + sample_time=TIMESTAMP, + metric=Metric.AC_POWER_ACTIVE.value, + value=metrics_pb2.MetricValueVariant( + simple_metric=metrics_pb2.SimpleMetricValue(value=5.0) + ), + connection=metrics_pb2.MetricConnection( + category=metrics_pb2.METRIC_CONNECTION_CATEGORY_BATTERY, + name="dc_battery_0", + ), + ), + expected_sample=MetricSample( + sample_time=DATETIME, + metric=Metric.AC_POWER_ACTIVE, + value=5.0, + bounds=[], + connection=MetricConnection( + category=MetricConnectionCategory.BATTERY, name="dc_battery_0" + ), + ), + ), + ], + ids=lambda case: case.name, +) +def test_from_proto_with_issues(case: _TestCase) -> None: + """Test conversion from protobuf message to MetricSample.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + + # The timestamp in the expected sample needs to match the one from proto conversion + # We use a fixed timestamp in test cases, so this is fine. + # If dynamic timestamps were used, we'd need to adjust here or in the fixture. + + sample = metric_sample_from_proto_with_issues( + case.proto_message, + major_issues=major_issues, + minor_issues=minor_issues, + ) + + assert sample == case.expected_sample + assert major_issues == case.expected_major_issues + assert minor_issues == case.expected_minor_issues diff --git a/tests/metrics/test_bounds.py b/tests/metrics/test_bounds.py new file mode 100644 index 0000000..f33b22d --- /dev/null +++ b/tests/metrics/test_bounds.py @@ -0,0 +1,72 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Bounds class.""" + +import re + +import pytest + +from frequenz.client.common.metrics import Bounds + + +@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 diff --git a/tests/metrics/test_sample_aggregated_value.py b/tests/metrics/test_sample_aggregated_value.py new file mode 100644 index 0000000..e672b4d --- /dev/null +++ b/tests/metrics/test_sample_aggregated_value.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for AggregatedMetricValue class.""" + +import pytest + +from frequenz.client.common.metrics import AggregatedMetricValue + + +@pytest.mark.parametrize( + "avg, min_val, max_val, raw, expected_str", + [ + pytest.param( + 5.0, + 1.0, + 10.0, + [1.0, 5.0, 10.0], + "avg:5.0", + id="full_data", + ), + pytest.param( + 5.0, + None, + None, + [], + "avg:5.0", + id="minimal_data", + ), + ], +) +def test_creation_and_str( + avg: float, + min_val: float | None, + max_val: float | None, + raw: list[float], + expected_str: str, +) -> None: + """Test AggregatedMetricValue creation and string representation.""" + value = AggregatedMetricValue( + avg=avg, + min=min_val, + max=max_val, + raw=raw, + ) + assert value.avg == avg + assert value.min == min_val + assert value.max == max_val + assert list(value.raw) == raw + assert str(value) == expected_str diff --git a/tests/metrics/test_sample_metric_connection.py b/tests/metrics/test_sample_metric_connection.py new file mode 100644 index 0000000..d13f5b6 --- /dev/null +++ b/tests/metrics/test_sample_metric_connection.py @@ -0,0 +1,100 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for MetricConnection and MetricConnectionCategory classes.""" + +import pytest + +from frequenz.client.common.metrics import MetricConnection, MetricConnectionCategory + + +@pytest.mark.parametrize( + "category,name,expected_str", + [ + pytest.param( + MetricConnectionCategory.BATTERY, + None, + "", + id="enum_category_no_name", + ), + pytest.param( + MetricConnectionCategory.PV, + "dc_pv_0", + "(dc_pv_0)", + id="enum_category_with_name", + ), + pytest.param( + 999, + None, + "999", + id="int_category_no_name", + ), + pytest.param( + 999, + "unknown_connection", + "999(unknown_connection)", + id="int_category_with_name", + ), + ], +) +def test_str_representation( + category: MetricConnectionCategory | int, + name: str | None, + expected_str: str, +) -> None: + """Test string representation of MetricConnection.""" + connection = MetricConnection(category=category, name=name) + assert str(connection) == expected_str + + +def test_creation_with_enum_category() -> None: + """Test MetricConnection creation with enum category.""" + connection = MetricConnection( + category=MetricConnectionCategory.BATTERY, + name="dc_battery_0", + ) + assert connection.category == MetricConnectionCategory.BATTERY + assert connection.name == "dc_battery_0" + + +def test_creation_with_int_category() -> None: + """Test MetricConnection creation with int category (unrecognized).""" + connection = MetricConnection( + category=999, + name="unknown", + ) + assert connection.category == 999 + assert connection.name == "unknown" + + +def test_creation_default_name() -> None: + """Test MetricConnection creation with default name.""" + connection = MetricConnection(category=MetricConnectionCategory.AMBIENT) + assert connection.category == MetricConnectionCategory.AMBIENT + assert connection.name is None + + +def test_equality() -> None: + """Test equality of MetricConnection objects.""" + conn1 = MetricConnection( + category=MetricConnectionCategory.BATTERY, name="dc_battery_0" + ) + conn2 = MetricConnection( + category=MetricConnectionCategory.BATTERY, name="dc_battery_0" + ) + conn3 = MetricConnection(category=MetricConnectionCategory.PV, name="dc_pv_0") + assert conn1 == conn2 + assert conn1 != conn3 + + +def test_hash() -> None: + """Test that MetricConnection objects can be used in sets and as dict keys.""" + conn1 = MetricConnection( + category=MetricConnectionCategory.BATTERY, name="dc_battery_0" + ) + conn2 = MetricConnection( + category=MetricConnectionCategory.BATTERY, name="dc_battery_0" + ) + conn3 = MetricConnection(category=MetricConnectionCategory.PV, name="dc_pv_0") + conn_set = {conn1, conn2, conn3} + assert len(conn_set) == 2 # conn1 and conn2 are equal diff --git a/tests/metrics/test_sample_metric_sample.py b/tests/metrics/test_sample_metric_sample.py new file mode 100644 index 0000000..c3be654 --- /dev/null +++ b/tests/metrics/test_sample_metric_sample.py @@ -0,0 +1,140 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for MetricSample class.""" + +from datetime import datetime, timezone + +import pytest + +from frequenz.client.common.metrics import ( + AggregatedMetricValue, + AggregationMethod, + Bounds, + Metric, + MetricConnection, + MetricSample, +) + + +@pytest.fixture +def now() -> datetime: + """Get the current time.""" + return datetime.now(timezone.utc) + + +@pytest.mark.parametrize( + "value,connection", + [ + pytest.param( + 5.0, + None, + id="simple_value", + ), + pytest.param( + AggregatedMetricValue( + avg=5.0, + min=1.0, + max=10.0, + raw=[1.0, 5.0, 10.0], + ), + "dc_battery_0", + id="aggregated_value", + ), + pytest.param( + None, + None, + id="none_value", + ), + ], +) +def test_creation( + now: datetime, + value: float | AggregatedMetricValue | None, + connection: MetricConnection | None, +) -> None: + """Test MetricSample creation with different value types.""" + bounds = [Bounds(lower=-10.0, upper=10.0)] + sample = MetricSample( + sample_time=now, + metric=Metric.AC_POWER_ACTIVE, + value=value, + bounds=bounds, + connection=connection, + ) + assert sample.sample_time == now + assert sample.metric == Metric.AC_POWER_ACTIVE + assert sample.value == value + assert sample.bounds == bounds + assert sample.connection == connection + + +@pytest.mark.parametrize( + "value, method_results", + [ + pytest.param( + 5.0, + { + AggregationMethod.AVG: 5.0, + AggregationMethod.MIN: 5.0, + AggregationMethod.MAX: 5.0, + }, + id="simple_value", + ), + pytest.param( + AggregatedMetricValue( + avg=5.0, + min=1.0, + max=10.0, + raw=[1.0, 5.0, 10.0], + ), + { + AggregationMethod.AVG: 5.0, + AggregationMethod.MIN: 1.0, + AggregationMethod.MAX: 10.0, + }, + id="aggregated_value", + ), + pytest.param( + None, + { + AggregationMethod.AVG: None, + AggregationMethod.MIN: None, + AggregationMethod.MAX: None, + }, + id="none_value", + ), + ], +) +def test_as_single_value( + now: datetime, + value: float | AggregatedMetricValue | None, + method_results: dict[AggregationMethod, float | None], +) -> None: + """Test MetricSample.as_single_value with different value types and methods.""" + bounds = [Bounds(lower=-10.0, upper=10.0)] + + sample = MetricSample( + sample_time=now, + metric=Metric.AC_POWER_ACTIVE, + value=value, + bounds=bounds, + ) + + for method, expected in method_results.items(): + assert sample.as_single_value(aggregation_method=method) == expected + + +def test_multiple_bounds(now: datetime) -> None: + """Test MetricSample creation with multiple bounds.""" + bounds = [ + Bounds(lower=-10.0, upper=-5.0), + Bounds(lower=5.0, upper=10.0), + ] + sample = MetricSample( + sample_time=now, + metric=Metric.AC_POWER_ACTIVE, + value=7.0, + bounds=bounds, + ) + assert sample.bounds == bounds diff --git a/tests/proto/test_datetime.py b/tests/proto/test_datetime.py new file mode 100644 index 0000000..0025d7f --- /dev/null +++ b/tests/proto/test_datetime.py @@ -0,0 +1,69 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Test conversion helper functions.""" + +from datetime import datetime, timezone + +# pylint: disable=no-name-in-module +from google.protobuf.timestamp_pb2 import Timestamp + +# pylint: enable=no-name-in-module +from hypothesis import given +from hypothesis import strategies as st + +from frequenz.client.common.proto import datetime_from_proto, datetime_to_proto + +# Strategy for generating datetime objects +datetime_strategy = st.datetimes( + min_value=datetime(1970, 1, 1), + max_value=datetime(9999, 12, 31), + timezones=st.just(timezone.utc), +) + +# Strategy for generating Timestamp objects +timestamp_strategy = st.builds( + Timestamp, + seconds=st.integers( + min_value=0, + max_value=int(datetime(9999, 12, 31, tzinfo=timezone.utc).timestamp()), + ), +) + + +@given(datetime_strategy) +def test_to_timestamp_with_datetime(dt: datetime) -> None: + """Test conversion from datetime to Timestamp.""" + ts = datetime_to_proto(dt) + assert ts is not None + converted_back_dt = datetime_from_proto(ts) + assert dt.tzinfo == converted_back_dt.tzinfo + assert dt.timestamp() == converted_back_dt.timestamp() + + +def test_to_timestamp_with_none() -> None: + """Test that passing None returns None.""" + assert datetime_to_proto(None) is None + + +@given(timestamp_strategy) +def test_to_datetime(ts: Timestamp) -> None: + """Test conversion from Timestamp to datetime.""" + dt = datetime_from_proto(ts) + assert dt is not None + # Convert back to Timestamp and compare + converted_back_ts = datetime_to_proto(dt) + assert ts.seconds == converted_back_ts.seconds + + +@given(datetime_strategy) +def test_no_none_datetime(dt: datetime) -> None: + """Test behavior of type hinting.""" + ts: Timestamp = datetime_to_proto(dt) + dt_none: datetime | None = None + + # The test would fail without the ignore comment as it should. + ts2: Timestamp = datetime_to_proto(dt_none) # type: ignore + + assert ts is not None + assert ts2 is None diff --git a/tests/proto/test_enum.py b/tests/proto/test_enum.py new file mode 100644 index 0000000..309a2b7 --- /dev/null +++ b/tests/proto/test_enum.py @@ -0,0 +1,50 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for enum_from_proto utility.""" + +import enum + +import pytest + +from frequenz.client.common.proto import enum_from_proto + + +class _TestEnum(enum.Enum): + """A test enum for enum_from_proto tests.""" + + ZERO = 0 + ONE = 1 + TWO = 2 + + +@pytest.mark.parametrize("enum_member", _TestEnum) +def test_valid_allow_invalid(enum_member: _TestEnum) -> None: + """Test conversion of valid enum values.""" + assert enum_from_proto(enum_member.value, _TestEnum) == enum_member + assert ( + enum_from_proto(enum_member.value, _TestEnum, allow_invalid=True) == enum_member + ) + + +@pytest.mark.parametrize("value", [42, -1]) +def test_invalid_allow_invalid(value: int) -> None: + """Test unknown values with allow_invalid=True (default).""" + assert enum_from_proto(value, _TestEnum) == value + assert enum_from_proto(value, _TestEnum, allow_invalid=True) == value + + +@pytest.mark.parametrize("enum_member", _TestEnum) +def test_valid_disallow_invalid(enum_member: _TestEnum) -> None: + """Test unknown values with allow_invalid=False (should raise ValueError).""" + assert ( + enum_from_proto(enum_member.value, _TestEnum, allow_invalid=False) + == enum_member + ) + + +@pytest.mark.parametrize("value", [42, -1]) +def test_invalid_disallow(value: int) -> None: + """Test unknown values with allow_invalid=False (should raise ValueError).""" + with pytest.raises(ValueError, match=rf"^{value} is not a valid _TestEnum$"): + enum_from_proto(value, _TestEnum, allow_invalid=False) diff --git a/tests/test_client_common.py b/tests/test_client_common.py index e9d865d..cd4ccf0 100644 --- a/tests/test_client_common.py +++ b/tests/test_client_common.py @@ -3,6 +3,8 @@ """Tests for the frequenz.client.common package.""" +import pytest + from frequenz.client.common.microgrid.components import ( ComponentCategory, ComponentErrorCode, @@ -13,16 +15,19 @@ def test_components() -> None: """Test the components.""" for category in ComponentCategory: - assert ComponentCategory.from_proto(category.to_proto()) == category + with pytest.deprecated_call(): + assert ComponentCategory.from_proto(category.to_proto()) == category def test_component_state_code() -> None: """Test the component state code.""" for state_code in ComponentStateCode: - assert ComponentStateCode.from_proto(state_code.to_proto()) == state_code + with pytest.deprecated_call(): + assert ComponentStateCode.from_proto(state_code.to_proto()) == state_code def test_component_error_code() -> None: """Test the component error code.""" for error_code in ComponentErrorCode: - assert ComponentErrorCode.from_proto(error_code.to_proto()) == error_code + with pytest.deprecated_call(): + assert ComponentErrorCode.from_proto(error_code.to_proto()) == error_code diff --git a/tests/test_enum_proto.py b/tests/test_enum_proto.py index a6ef59e..e3a0890 100644 --- a/tests/test_enum_proto.py +++ b/tests/test_enum_proto.py @@ -19,32 +19,7 @@ class _TestEnum(enum.Enum): @pytest.mark.parametrize("enum_member", _TestEnum) -def test_valid_allow_invalid(enum_member: _TestEnum) -> None: +def test_deprecated(enum_member: _TestEnum) -> None: """Test conversion of valid enum values.""" - assert enum_from_proto(enum_member.value, _TestEnum) == enum_member - assert ( - enum_from_proto(enum_member.value, _TestEnum, allow_invalid=True) == enum_member - ) - - -@pytest.mark.parametrize("value", [42, -1]) -def test_invalid_allow_invalid(value: int) -> None: - """Test unknown values with allow_invalid=True (default).""" - assert enum_from_proto(value, _TestEnum) == value - assert enum_from_proto(value, _TestEnum, allow_invalid=True) == value - - -@pytest.mark.parametrize("enum_member", _TestEnum) -def test_valid_disallow_invalid(enum_member: _TestEnum) -> None: - """Test unknown values with allow_invalid=False (should raise ValueError).""" - assert ( - enum_from_proto(enum_member.value, _TestEnum, allow_invalid=False) - == enum_member - ) - - -@pytest.mark.parametrize("value", [42, -1]) -def test_invalid_disallow(value: int) -> None: - """Test unknown values with allow_invalid=False (should raise ValueError).""" - with pytest.raises(ValueError, match=rf"^{value} is not a valid _TestEnum$"): - enum_from_proto(value, _TestEnum, allow_invalid=False) + with pytest.deprecated_call(): + enum_from_proto(enum_member.value, _TestEnum) diff --git a/tests/test_streaming.py b/tests/test_streaming.py index c7d7a1c..ceb8c07 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -3,7 +3,7 @@ """Tests for the frequenz.client.common.streaming package.""" -from frequenz.client.common.enum_proto import enum_from_proto +from frequenz.client.common.proto import enum_from_proto from frequenz.client.common.streaming import Event