diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6d8a3d4..51f89df 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,7 @@ ## New Features - +- The `MicrogridApiClient` can now list sensor retrieving their metadata (`list_sensors()`) and can stream sensor data (`stream_sensor_data()`). ## Bug Fixes diff --git a/src/frequenz/client/microgrid/__init__.py b/src/frequenz/client/microgrid/__init__.py index c88e224..5b4c53d 100644 --- a/src/frequenz/client/microgrid/__init__.py +++ b/src/frequenz/client/microgrid/__init__.py @@ -62,7 +62,7 @@ UnknownError, UnrecognizedGrpcStatus, ) -from ._id import ComponentId, MicrogridId +from ._lifetime import Lifetime from ._metadata import Location, Metadata __all__ = [ @@ -76,7 +76,6 @@ "Component", "ComponentCategory", "ComponentData", - "ComponentId", "ComponentMetadata", "ComponentMetricId", "ComponentType", @@ -98,11 +97,11 @@ "InverterError", "InverterErrorCode", "InverterType", + "Lifetime", "Location", "Metadata", "MeterData", "MicrogridApiClient", - "MicrogridId", "OperationAborted", "OperationCancelled", "OperationNotImplemented", diff --git a/src/frequenz/client/microgrid/_client.py b/src/frequenz/client/microgrid/_client.py index affdd31..f1cb65c 100644 --- a/src/frequenz/client/microgrid/_client.py +++ b/src/frequenz/client/microgrid/_client.py @@ -6,13 +6,15 @@ from __future__ import annotations import asyncio +import itertools import logging from collections.abc import Callable, Iterable, Set from dataclasses import replace -from typing import Any, TypeVar +from functools import partial +from typing import Any, NotRequired, TypedDict, TypeVar, assert_never from frequenz.api.common import components_pb2, metrics_pb2 -from frequenz.api.microgrid import microgrid_pb2, microgrid_pb2_grpc +from frequenz.api.microgrid import microgrid_pb2, microgrid_pb2_grpc, sensor_pb2 from frequenz.channels import Receiver from frequenz.client.base import channel, client, retry, streaming from google.protobuf.empty_pb2 import Empty @@ -35,8 +37,10 @@ from ._connection import Connection from ._constants import RECEIVER_MAX_SIZE from ._exception import ApiClientError, ClientNotConnected -from ._id import ComponentId, MicrogridId from ._metadata import Location, Metadata +from ._sensor_proto import sensor_data_samples_from_proto, sensor_from_proto +from .id import ComponentId, MicrogridId, SensorId +from .sensor import Sensor, SensorDataSamples, SensorMetric DEFAULT_GRPC_CALL_TIMEOUT = 60.0 """The default timeout for gRPC calls made by this client (in seconds).""" @@ -96,6 +100,12 @@ def __init__( self._broadcasters: dict[ ComponentId, streaming.GrpcStreamBroadcaster[Any, Any] ] = {} + self._sensor_data_broadcasters: dict[ + str, + streaming.GrpcStreamBroadcaster[ + microgrid_pb2.ComponentData, SensorDataSamples + ], + ] = {} self._retry_strategy = retry_strategy @property @@ -117,15 +127,22 @@ async def __aexit__( exc_tb: Any | None, ) -> bool | None: """Close the gRPC channel and stop all broadcasters.""" - exceptions = [ + exceptions = list( exc for exc in await asyncio.gather( - *(broadcaster.stop() for broadcaster in self._broadcasters.values()), + *( + broadcaster.stop() + for broadcaster in itertools.chain( + self._broadcasters.values(), + self._sensor_data_broadcasters.values(), + ) + ), return_exceptions=True, ) if isinstance(exc, BaseException) - ] + ) self._broadcasters.clear() + self._sensor_data_broadcasters.clear() result = None try: @@ -177,6 +194,33 @@ async def components( # noqa: DOC502 (raises ApiClientError indirectly) return result + async def list_sensors( # noqa: DOC502 (raises ApiClientError indirectly) + self, + ) -> Iterable[Sensor]: + """Fetch all the sensors present in the microgrid. + + Returns: + Iterator whose elements are all the sensors in the microgrid. + + Raises: + ApiClientError: If the are any errors communicating with the Microgrid API, + most likely a subclass of + [GrpcError][frequenz.client.microgrid.GrpcError]. + """ + component_list = await client.call_stub_method( + self, + lambda: self.stub.ListComponents( + microgrid_pb2.ComponentFilter( + categories=[ + components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR + ] + ), + timeout=int(DEFAULT_GRPC_CALL_TIMEOUT), + ), + method_name="ListComponents", + ) + return map(sensor_from_proto, component_list.components) + async def metadata(self) -> Metadata: """Fetch the microgrid metadata. @@ -539,3 +583,91 @@ async def set_bounds( # noqa: DOC503 (raises ApiClientError indirectly) ), method_name="AddInclusionBounds", ) + + # noqa: DOC502 (Raises ApiClientError indirectly) + def stream_sensor_data( + self, + sensor: SensorId | Sensor, + metrics: Iterable[SensorMetric | int] | None = None, + *, + buffer_size: int = 50, + ) -> Receiver[SensorDataSamples]: + """Stream data samples from a sensor. + + Warning: + Sensors may not support all metrics. If a sensor does not support + a given metric, then the returned data stream will not contain that metric. + + There is no way to tell if a metric is not being received because the + sensor does not support it or because there is a transient issue when + retrieving the metric from the sensor. + + The supported metrics by a sensor can even change with time, for example, + if a sensor is updated with new firmware. + + Args: + sensor: The sensor to stream data from. + metrics: If not `None`, only the specified metrics will be retrieved. + Otherwise all available metrics will be retrieved. + buffer_size: The maximum number of messages to buffer in the returned + receiver. After this limit is reached, the oldest messages will be + dropped. + + Returns: + A receiver to retrieve data from the sensor. + """ + sensor_id = _get_sensor_id(sensor) + key = str(sensor_id) + + class _ExtraArgs(TypedDict): + metrics: NotRequired[frozenset[sensor_pb2.SensorMetric.ValueType]] + + extra_args: _ExtraArgs = {} + if metrics is not None: + extra_args["metrics"] = frozenset( + [_get_sensor_metric_value(m) for m in metrics] + ) + # We use the frozenset because iterables are not hashable + key += f"{hash(extra_args['metrics'])}" + + broadcaster = self._sensor_data_broadcasters.get(key) + if broadcaster is None: + client_id = hex(id(self))[2:] + stream_name = f"microgrid-client-{client_id}-sensor-data-{key}" + broadcaster = streaming.GrpcStreamBroadcaster( + stream_name, + lambda: aiter( + self.stub.StreamComponentData( + microgrid_pb2.ComponentIdParam(id=sensor_id), + timeout=DEFAULT_GRPC_CALL_TIMEOUT, + ) + ), + partial(sensor_data_samples_from_proto, **extra_args), + retry_strategy=self._retry_strategy, + ) + self._sensor_data_broadcasters[key] = broadcaster + return broadcaster.new_receiver(maxsize=buffer_size) + + +def _get_sensor_id(sensor: SensorId | Sensor) -> int: + """Get the sensor ID from a sensor or sensor ID.""" + match sensor: + case SensorId(): + return int(sensor) + case Sensor(): + return int(sensor.id) + case unexpected: + assert_never(unexpected) + + +def _get_sensor_metric_value( + metric: SensorMetric | int, +) -> sensor_pb2.SensorMetric.ValueType: + """Get the sensor metric ID from a sensor metric or sensor metric ID.""" + match metric: + case SensorMetric(): + return sensor_pb2.SensorMetric.ValueType(metric.value) + case int(): + return sensor_pb2.SensorMetric.ValueType(metric) + case unexpected: + assert_never(unexpected) diff --git a/src/frequenz/client/microgrid/_component.py b/src/frequenz/client/microgrid/_component.py index 32b6bce..ceed501 100644 --- a/src/frequenz/client/microgrid/_component.py +++ b/src/frequenz/client/microgrid/_component.py @@ -9,7 +9,7 @@ from frequenz.api.common import components_pb2 from frequenz.api.microgrid import grid_pb2, inverter_pb2 -from ._id import ComponentId +from .id import ComponentId class ComponentType(Enum): diff --git a/src/frequenz/client/microgrid/_component_data.py b/src/frequenz/client/microgrid/_component_data.py index 0784ba5..983021f 100644 --- a/src/frequenz/client/microgrid/_component_data.py +++ b/src/frequenz/client/microgrid/_component_data.py @@ -18,7 +18,7 @@ EVChargerComponentState, InverterComponentState, ) -from ._id import ComponentId +from .id import ComponentId @dataclass(frozen=True) diff --git a/src/frequenz/client/microgrid/_connection.py b/src/frequenz/client/microgrid/_connection.py index afcd2b4..343098b 100644 --- a/src/frequenz/client/microgrid/_connection.py +++ b/src/frequenz/client/microgrid/_connection.py @@ -6,7 +6,7 @@ from dataclasses import dataclass -from ._id import ComponentId +from .id import ComponentId @dataclass(frozen=True) diff --git a/src/frequenz/client/microgrid/_id.py b/src/frequenz/client/microgrid/_id.py deleted file mode 100644 index 9e1b77f..0000000 --- a/src/frequenz/client/microgrid/_id.py +++ /dev/null @@ -1,107 +0,0 @@ -# License: MIT -# Copyright © 2025 Frequenz Energy-as-a-Service GmbH - -"""Strongly typed IDs for microgrids and components.""" - - -from typing import final - - -@final -class MicrogridId: - """A unique identifier for a microgrid.""" - - def __init__(self, id_: int, /) -> None: - """Initialize this instance. - - Args: - id_: The numeric unique identifier of the microgrid. - - Raises: - ValueError: If the ID is negative. - """ - if id_ < 0: - raise ValueError("Microgrid ID can't be negative.") - self._id = id_ - - def __int__(self) -> int: - """Return the numeric ID of this instance.""" - return self._id - - def __eq__(self, other: object) -> bool: - """Check if this instance is equal to another object.""" - # This is not an unidiomatic typecheck, that's an odd name for the check. - # isinstance() returns True for subclasses, which is not what we want here. - # pylint: disable-next=unidiomatic-typecheck - return type(other) is MicrogridId and self._id == other._id - - def __lt__(self, other: object) -> bool: - """Check if this instance is less than another object.""" - # pylint: disable-next=unidiomatic-typecheck - if type(other) is MicrogridId: - return self._id < other._id - return NotImplemented - - def __hash__(self) -> int: - """Return the hash of this instance.""" - # We include the class because we explicitly want to avoid the same ID to give - # the same hash for different classes of IDs - return hash((MicrogridId, self._id)) - - def __repr__(self) -> str: - """Return the string representation of this instance.""" - return f"{type(self).__name__}({self._id!r})" - - def __str__(self) -> str: - """Return the short string representation of this instance.""" - return f"MID{self._id}" - - -@final -class ComponentId: - """A unique identifier for a microgrid component.""" - - def __init__(self, id_: int, /) -> None: - """Initialize this instance. - - Args: - id_: The numeric unique identifier of the microgrid component. - - Raises: - ValueError: If the ID is negative. - """ - if id_ < 0: - raise ValueError("Component ID can't be negative.") - self._id = id_ - - def __int__(self) -> int: - """Return the numeric ID of this instance.""" - return self._id - - def __eq__(self, other: object) -> bool: - """Check if this instance is equal to another object.""" - # This is not an unidiomatic typecheck, that's an odd name for the check. - # isinstance() returns True for subclasses, which is not what we want here. - # pylint: disable-next=unidiomatic-typecheck - return type(other) is ComponentId and self._id == other._id - - def __lt__(self, other: object) -> bool: - """Check if this instance is less than another object.""" - # pylint: disable-next=unidiomatic-typecheck - if type(other) is ComponentId: - return self._id < other._id - return NotImplemented - - def __hash__(self) -> int: - """Return the hash of this instance.""" - # We include the class because we explicitly want to avoid the same ID to give - # the same hash for different classes of IDs - return hash((ComponentId, self._id)) - - def __repr__(self) -> str: - """Return the string representation of this instance.""" - return f"{type(self).__name__}({self._id!r})" - - def __str__(self) -> str: - """Return the short string representation of this instance.""" - return f"CID{self._id}" diff --git a/src/frequenz/client/microgrid/_lifetime.py b/src/frequenz/client/microgrid/_lifetime.py new file mode 100644 index 0000000..fa1ab04 --- /dev/null +++ b/src/frequenz/client/microgrid/_lifetime.py @@ -0,0 +1,52 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Lifetime of a microgrid asset.""" + + +from dataclasses import dataclass +from datetime import datetime, timezone + + +@dataclass(frozen=True, kw_only=True) +class Lifetime: + """An active operational period of a microgrid asset. + + Warning: + The [`end`][frequenz.client.microgrid.Lifetime.end] timestamp indicates that the + asset has been permanently removed from the system. + """ + + start: datetime | None = None + """The moment when the asset became operationally active. + + If `None`, the asset is considered to be active in any past moment previous to the + [`end`][frequenz.client.microgrid.Lifetime.end]. + """ + + end: datetime | None = None + """The moment when the asset's operational activity ceased. + + If `None`, the asset is considered to be active with no plans to be deactivated. + """ + + def __post_init__(self) -> None: + """Validate this lifetime.""" + if self.start is not None and self.end is not None and self.start > self.end: + raise ValueError("Start must be before or equal to end.") + + def is_operational_at(self, timestamp: datetime) -> bool: + """Check whether this lifetime is active at a specific timestamp.""" + # Handle start time - it's not active if start is in the future + if self.start is not None and self.start > timestamp: + return False + # Handle end time - active up to and including end time + if self.end is not None: + return self.end >= timestamp + # self.end is None, and either self.start is None or self.start <= timestamp, + # so it is active at this timestamp + return True + + def is_operational_now(self) -> bool: + """Whether this lifetime is currently active.""" + return self.is_operational_at(datetime.now(timezone.utc)) diff --git a/src/frequenz/client/microgrid/_metadata.py b/src/frequenz/client/microgrid/_metadata.py index b0b7454..3af91ec 100644 --- a/src/frequenz/client/microgrid/_metadata.py +++ b/src/frequenz/client/microgrid/_metadata.py @@ -8,7 +8,7 @@ from timezonefinder import TimezoneFinder -from ._id import MicrogridId +from .id import MicrogridId _timezone_finder = TimezoneFinder() diff --git a/src/frequenz/client/microgrid/_sensor_proto.py b/src/frequenz/client/microgrid/_sensor_proto.py new file mode 100644 index 0000000..f70a53d --- /dev/null +++ b/src/frequenz/client/microgrid/_sensor_proto.py @@ -0,0 +1,189 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Loading of SensorDataSamples objects from protobuf messages.""" + +import logging +from collections.abc import Set +from datetime import datetime + +from frequenz.api.common import components_pb2 +from frequenz.api.microgrid import common_pb2, microgrid_pb2, sensor_pb2 +from frequenz.client.base import conversion + +from ._lifetime import Lifetime +from ._util import enum_from_proto +from .id import SensorId +from .sensor import ( + Sensor, + SensorDataSamples, + SensorErrorCode, + SensorMetric, + SensorMetricSample, + SensorStateCode, + SensorStateSample, +) + +_logger = logging.getLogger(__name__) + + +def sensor_from_proto(message: microgrid_pb2.Component) -> Sensor: + """Convert a protobuf message to a `Sensor` instance. + + Args: + message: The protobuf message. + + Returns: + The resulting sensor instance. + """ + major_issues: list[str] = [] + minor_issues: list[str] = [] + + sensor = sensor_from_proto_with_issues( + message, major_issues=major_issues, minor_issues=minor_issues + ) + + if major_issues: + _logger.warning( + "Found issues in sensor: %s | Protobuf message:\n%s", + ", ".join(major_issues), + message, + ) + if minor_issues: + _logger.debug( + "Found minor issues in sensor: %s | Protobuf message:\n%s", + ", ".join(minor_issues), + message, + ) + + return sensor + + +def sensor_from_proto_with_issues( + message: microgrid_pb2.Component, + *, + major_issues: list[str], + minor_issues: list[str], +) -> Sensor: + """Convert a protobuf message to a sensor instance and collect issues. + + Args: + message: The protobuf message. + major_issues: A list to append major issues to. + minor_issues: A list to append minor issues to. + + Returns: + The resulting sensor instance. + """ + sensor_id = SensorId(message.id) + + name = message.name or None + if name is None: + minor_issues.append("name is empty") + + manufacturer = message.manufacturer or None + if manufacturer is None: + minor_issues.append("manufacturer is empty") + + model_name = message.model_name or None + if model_name is None: + minor_issues.append("model_name is empty") + + if ( + message.category + is not components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR + ): + major_issues.append(f"unexpected category for sensor ({message.category})") + + return Sensor( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + + +def sensor_data_samples_from_proto( + message: microgrid_pb2.ComponentData, + metrics: Set[sensor_pb2.SensorMetric.ValueType], +) -> SensorDataSamples: + """Convert a protobuf component data message to a sensor data object. + + Args: + message: The protobuf message to convert. + metrics: A set of metrics to filter the samples. + + Returns: + The resulting `SensorDataSamples` object. + """ + # At some point it might make sense to also log issues found in the samples, but + # using a naive approach like in `component_from_proto` might spam the logs too + # much, as we can receive several samples per second, and if a component is in + # a unrecognized state for long, it will mean we will emit the same log message + # again and again. + ts = conversion.to_datetime(message.ts) + return SensorDataSamples( + sensor_id=SensorId(message.id), + metrics=[ + sensor_metric_sample_from_proto(ts, sample) + for sample in message.sensor.data.sensor_data + if sample.sensor_metric in metrics + ], + states=[sensor_state_sample_from_proto(ts, message.sensor)], + ) + + +def sensor_metric_sample_from_proto( + sampled_at: datetime, message: sensor_pb2.SensorData +) -> SensorMetricSample: + """Convert a protobuf message to a `SensorMetricSample` object. + + Args: + sampled_at: The time at which the sample was taken. + message: The protobuf message to convert. + + Returns: + The resulting `SensorMetricSample` object. + """ + return SensorMetricSample( + sampled_at=sampled_at, + metric=enum_from_proto(message.sensor_metric, SensorMetric), + value=message.value, + ) + + +def sensor_state_sample_from_proto( + sampled_at: datetime, message: sensor_pb2.Sensor +) -> SensorStateSample: + """Convert a protobuf message to a `SensorStateSample` object. + + Args: + sampled_at: The time at which the sample was taken. + message: The protobuf message to convert. + + Returns: + The resulting `SensorStateSample` object. + """ + # In v0.15 the enum has 3 values, UNSPECIFIED, OK, and ERROR. In v0.17 + # (common v0.6), it also have 3 values with the same tags, but OK is renamed + # to ON, so this conversion should work fine for both versions. + state = enum_from_proto(message.state.component_state, SensorStateCode) + errors: set[SensorErrorCode | int] = set() + warnings: set[SensorErrorCode | int] = set() + for error in message.errors: + match error.level: + case common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL: + errors.add(enum_from_proto(error.code, SensorErrorCode)) + case common_pb2.ErrorLevel.ERROR_LEVEL_WARN: + warnings.add(enum_from_proto(error.code, SensorErrorCode)) + case _: + # If we don´t know the level we treat it as an error just to be safe. + errors.add(enum_from_proto(error.code, SensorErrorCode)) + + return SensorStateSample( + sampled_at=sampled_at, + states=frozenset([state]), + warnings=frozenset(warnings), + errors=frozenset(errors), + ) diff --git a/src/frequenz/client/microgrid/_util.py b/src/frequenz/client/microgrid/_util.py new file mode 100644 index 0000000..e543914 --- /dev/null +++ b/src/frequenz/client/microgrid/_util.py @@ -0,0 +1,47 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Utility functions.""" + +import enum +from typing import TypeVar + +EnumT = TypeVar("EnumT", bound=enum.Enum) +"""A type variable that is bound to an enum.""" + + +def enum_from_proto(value: int, enum_type: type[EnumT]) -> 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 + ``` + + Args: + value: The protobuf int enum value. + enum_type: The python enum type to convert to, + typically an enum class. + + Returns: + The resulting python enum value if the protobuf value is known, otherwise + the input value converted to a plain `int`. + """ + try: + return enum_type(value) + except ValueError: + return value diff --git a/src/frequenz/client/microgrid/id.py b/src/frequenz/client/microgrid/id.py new file mode 100644 index 0000000..b1b1aa2 --- /dev/null +++ b/src/frequenz/client/microgrid/id.py @@ -0,0 +1,218 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +r'''Provides strongly-typed unique identifiers for entities. + +This module offers a base class, +[`BaseId`][frequenz.client.microgrid.id.BaseId], which can be subclassed to +create distinct ID types for different components or concepts within a system. +These IDs ensure type safety, meaning that an ID for one type of entity (e.g., a +sensor) cannot be mistakenly used where an ID for another type (e.g., a +microgrid) is expected. + +# Creating Custom ID Types + +To define a new ID type, create a class that inherits from +[`BaseId`][frequenz.client.microgrid.id.BaseId] and provide a unique +`str_prefix` as a keyword argument in the class definition. This prefix is used +in the string representation of the ID and must be unique across all ID types. + +Note: + The `str_prefix` must be unique across all ID types. If you try to use a + prefix that is already registered, a `ValueError` will be raised when defining + the class. + +To encourage consistency, the class name must end with the suffix "Id" (e.g., +`MyNewId`). This check can be bypassed by passing `allow_custom_name=True` when +defining the class (e.g., `class MyCustomName(BaseId, str_prefix="MCN", +allow_custom_name=True):`). + +Tip: + Use the [`@typing.final`][typing.final] decorator to prevent subclassing of + ID classes. + +Example: Creating a standard ID type + ```python + from typing import final + from frequenz.client.microgrid.id import BaseId + + @final + class InverterId(BaseId, str_prefix="INV"): + """A unique identifier for an inverter.""" + + inv_id = InverterId(123) + print(inv_id) # Output: INV123 + print(int(inv_id)) # Output: 123 + ``` + +Example: Creating an ID type with a non-standard name + ```python + from typing import final + from frequenz.client.microgrid.id import BaseId + + @final + class CustomNameForId(BaseId, str_prefix="CST", allow_custom_name=True): + """An ID with a custom name, not ending in 'Id'.""" + + custom_id = CustomNameForId(456) + print(custom_id) # Output: CST456 + print(int(custom_id)) # Output: 456 + ``` + +# Predefined ID Types + +This module predefines the following ID types: + +- [`ComponentId`][frequenz.client.microgrid.id.ComponentId]: For identifying + generic components. +- [`MicrogridId`][frequenz.client.microgrid.id.MicrogridId]: For identifying + microgrids. +- [`SensorId`][frequenz.client.microgrid.id.SensorId]: For identifying sensors. +''' + + +from typing import Any, ClassVar, Self, cast, final + + +class BaseId: + """A base class for unique identifiers. + + Subclasses must provide a unique `str_prefix` keyword argument during + definition, which is used in the string representation of the ID. + + By default, subclass names must end with "Id". This can be overridden by + passing `allow_custom_name=True` during class definition. + + For more information and examples, see the [module's + documentation][frequenz.client.microgrid.id]. + """ + + _id: int + _str_prefix: ClassVar[str] + _registered_prefixes: ClassVar[set[str]] = set() + + def __new__(cls, *_: Any, **__: Any) -> Self: + """Create a new instance of the ID class, only if it is a subclass of BaseId.""" + if cls is BaseId: + raise TypeError("BaseId cannot be instantiated directly. Use a subclass.") + return super().__new__(cls) + + def __init_subclass__( + cls, + *, + str_prefix: str, + allow_custom_name: bool = False, + **kwargs: Any, + ) -> None: + """Initialize a subclass, set its string prefix, and perform checks. + + Args: + str_prefix: The string prefix for the ID type (e.g., "MID"). + Must be unique across all ID types. + allow_custom_name: If True, bypasses the check that the class name + must end with "Id". Defaults to False. + **kwargs: Forwarded to the parent's __init_subclass__. + + Raises: + ValueError: If the `str_prefix` is already registered by another + ID type. + TypeError: If `allow_custom_name` is False and the class name + does not end with "Id". + """ + super().__init_subclass__(**kwargs) + + if str_prefix in BaseId._registered_prefixes: + raise ValueError( + f"Prefix '{str_prefix}' is already registered. " + "ID prefixes must be unique." + ) + BaseId._registered_prefixes.add(str_prefix) + + if not allow_custom_name and not cls.__name__.endswith("Id"): + raise TypeError( + f"Class name '{cls.__name__}' for an ID class must end with 'Id' " + "(e.g., 'SomeId'), or use `allow_custom_name=True`." + ) + + cls._str_prefix = str_prefix + + def __init__(self, id_: int, /) -> None: + """Initialize this instance. + + Args: + id_: The numeric unique identifier. + + Raises: + ValueError: If the ID is negative. + """ + if id_ < 0: + raise ValueError(f"{type(self).__name__} can't be negative.") + self._id = id_ + + @property + def str_prefix(self) -> str: + """The prefix used for the string representation of this ID.""" + return self._str_prefix + + def __int__(self) -> int: + """Return the numeric ID of this instance.""" + return self._id + + def __eq__(self, other: object) -> bool: + """Check if this instance is equal to another object. + + Equality is defined as being of the exact same type and having the same + underlying ID. + """ + # pylint thinks this is not an unidiomatic typecheck, but in this case + # it is not. isinstance() returns True for subclasses, which is not + # what we want here, as different ID types should never be equal. + # pylint: disable-next=unidiomatic-typecheck + if type(other) is not type(self): + return NotImplemented + # We already checked type(other) is type(self), but mypy doesn't + # understand that, so we need to cast it to Self. + other_id = cast(Self, other) + return self._id == other_id._id + + def __lt__(self, other: object) -> bool: + """Check if this instance is less than another object. + + Comparison is only defined between instances of the exact same type. + """ + # pylint: disable-next=unidiomatic-typecheck + if type(other) is not type(self): + return NotImplemented + other_id = cast(Self, other) + return self._id < other_id._id + + def __hash__(self) -> int: + """Return the hash of this instance. + + The hash is based on the exact type and the underlying ID to ensure + that IDs of different types but with the same numeric value have different hashes. + """ + return hash((type(self), self._id)) + + def __repr__(self) -> str: + """Return the string representation of this instance.""" + return f"{type(self).__name__}({self._id!r})" + + def __str__(self) -> str: + """Return the short string representation of this instance.""" + return f"{type(self)._str_prefix}{self._id}" + + +@final +class MicrogridId(BaseId, str_prefix="MID"): + """A unique identifier for a microgrid.""" + + +@final +class ComponentId(BaseId, str_prefix="CID"): + """A unique identifier for a microgrid component.""" + + +@final +class SensorId(BaseId, str_prefix="SID"): + """A unique identifier for a microgrid sensor.""" diff --git a/src/frequenz/client/microgrid/metrics.py b/src/frequenz/client/microgrid/metrics.py new file mode 100644 index 0000000..02dc718 --- /dev/null +++ b/src/frequenz/client/microgrid/metrics.py @@ -0,0 +1,61 @@ +# 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 + + +@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 + 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_values: 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_values) > 0: + extra.append(f"num_raw:{len(self.raw_values)}") + extra_str = f"<{' '.join(extra)}>" if extra else "" + return f"avg:{self.avg}{extra_str}" diff --git a/src/frequenz/client/microgrid/sensor.py b/src/frequenz/client/microgrid/sensor.py new file mode 100644 index 0000000..f057d39 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor.py @@ -0,0 +1,234 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Microgrid sensors. + +This package provides classes and utilities for working with different types of +sensors in a microgrid environment. [`Sensor`][frequenz.client.microgrid.sensor.Sensor]s +measure various physical metrics in the surrounding environment, such as temperature, +humidity, and solar irradiance. + +# Streaming Sensor Data Samples + +This package also provides several data structures for handling sensor readings +and states: + +* [`SensorDataSamples`][frequenz.client.microgrid.sensor.SensorDataSamples]: + Represents a collection of sensor data samples. +* [`SensorErrorCode`][frequenz.client.microgrid.sensor.SensorErrorCode]: + Defines error codes that a sensor can report. +* [`SensorMetric`][frequenz.client.microgrid.sensor.SensorMetric]: Enumerates + the different metrics a sensor can measure (e.g., temperature, voltage). +* [`SensorMetricSample`][frequenz.client.microgrid.sensor.SensorMetricSample]: + Represents a single sample of a sensor metric, including its value and + timestamp. +* [`SensorStateCode`][frequenz.client.microgrid.sensor.SensorStateCode]: + Defines codes representing the operational state of a sensor. +* [`SensorStateSample`][frequenz.client.microgrid.sensor.SensorStateSample]: + Represents a single sample of a sensor's state, including its state code + and timestamp. +""" + +import dataclasses +import enum +from dataclasses import dataclass +from datetime import datetime +from typing import assert_never + +from frequenz.api.microgrid import sensor_pb2 + +from ._lifetime import Lifetime +from .id import SensorId +from .metrics import AggregatedMetricValue, AggregationMethod + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Sensor: + """Measures environmental metrics in the microgrid.""" + + id: SensorId + """This sensor's ID.""" + + name: str | None = None + """The name of this sensor.""" + + manufacturer: str | None = None + """The manufacturer of this sensor.""" + + model_name: str | None = None + """The model name of this sensor.""" + + operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime) + """The operational lifetime of this sensor.""" + + @property + def identity(self) -> SensorId: + """The identity of this sensor. + + This uses the sensor ID to identify a sensor without considering the + other attributes, so even if a sensor state changed, the identity + remains the same. + """ + return self.id + + def __str__(self) -> str: + """Return a human-readable string representation of this instance.""" + name = f":{self.name}" if self.name else "" + return f"<{type(self).__name__}:{self.id}{name}>" + + +@enum.unique +class SensorMetric(enum.Enum): + """The metrics that can be reported by sensors in the microgrid. + + These metrics correspond to various sensor readings primarily related to + environmental conditions and physical measurements. + """ + + UNSPECIFIED = sensor_pb2.SENSOR_METRIC_UNSPECIFIED + """Default value (this should not be normally used and usually indicates an issue).""" + + TEMPERATURE = sensor_pb2.SENSOR_METRIC_TEMPERATURE + """Temperature, in Celsius (°C).""" + + HUMIDITY = sensor_pb2.SENSOR_METRIC_HUMIDITY + """Humidity, in percentage (%).""" + + PRESSURE = sensor_pb2.SENSOR_METRIC_PRESSURE + """Pressure, in Pascal (Pa).""" + + IRRADIANCE = sensor_pb2.SENSOR_METRIC_IRRADIANCE + """Irradiance / Radiation flux, in watts per square meter (W / m²).""" + + VELOCITY = sensor_pb2.SENSOR_METRIC_VELOCITY + """Velocity, in meters per second (m / s).""" + + ACCELERATION = sensor_pb2.SENSOR_METRIC_ACCELERATION + """Acceleration in meters per second per second (m / s²).""" + + ANGLE = sensor_pb2.SENSOR_METRIC_ANGLE + """Angle, in degrees with respect to the (magnetic) North (°).""" + + DEW_POINT = sensor_pb2.SENSOR_METRIC_DEW_POINT + """Dew point, in Celsius (°C). + + The temperature at which the air becomes saturated with water vapor. + """ + + +@enum.unique +class SensorStateCode(enum.Enum): + """The various states that a sensor can be in.""" + + UNSPECIFIED = sensor_pb2.COMPONENT_STATE_UNSPECIFIED + """Default value (this should not be normally used and usually indicates an issue).""" + + ON = sensor_pb2.COMPONENT_STATE_OK + """The sensor is up and running.""" + + ERROR = sensor_pb2.COMPONENT_STATE_ERROR + """The sensor is in an error state.""" + + +@enum.unique +class SensorErrorCode(enum.Enum): + """The various errors that can occur in sensors.""" + + UNSPECIFIED = sensor_pb2.ERROR_CODE_UNSPECIFIED + """Default value (this should not be normally used and usually indicates an issue).""" + + +@dataclass(frozen=True, kw_only=True) +class SensorStateSample: + """A sample of state, warnings, and errors for a sensor at a specific time.""" + + sampled_at: datetime + """The time at which this state was sampled.""" + + states: frozenset[SensorStateCode | int] + """The set of states of the sensor. + + If the reported state is not known by the client (it could happen when using an + older version of the client with a newer version of the server), it will be + represented as an `int` and **not** the + [`SensorStateCode.UNSPECIFIED`][frequenz.client.microgrid.sensor.SensorStateCode.UNSPECIFIED] + value (this value is used only when the state is not known by the server). + """ + + warnings: frozenset[SensorErrorCode | int] + """The set of warnings for the sensor.""" + + errors: frozenset[SensorErrorCode | int] + """The set of errors for the sensor. + + This set will only contain errors if the sensor is in an error state. + """ + + +@dataclass(frozen=True, kw_only=True) +class SensorMetricSample: + """A sample of a sensor metric at a specific time. + + This represents a single sample of a specific metric, the value of which is either + measured at a particular time. + """ + + sampled_at: datetime + """The moment when the metric was sampled.""" + + metric: SensorMetric | int + """The metric that was sampled.""" + + # In the protocol this is float | AggregatedMetricValue, but for live data we can't + # receive the AggregatedMetricValue, so we limit this to float for now. + value: float | AggregatedMetricValue | None + """The value of the sampled metric.""" + + def as_single_value( + self, *, aggregation_method: AggregationMethod = AggregationMethod.AVG + ) -> float | None: + """Return the value of this sample as a single value. + + if [`value`][frequenz.client.microgrid.sensor.SensorMetricSample.value] is a `float`, + it is returned as is. If `value` is an + [`AggregatedMetricValue`][frequenz.client.microgrid.metrics.AggregatedMetricValue], + the value is aggregated using the provided `aggregation_method`. + + Args: + aggregation_method: The method to use to aggregate the value when `value` is + a `AggregatedMetricValue`. + + Returns: + The value of the sample as a single value, or `None` if the value is `None`. + """ + match self.value: + case float() | int(): + return self.value + case AggregatedMetricValue(): + match aggregation_method: + case AggregationMethod.AVG: + return self.value.avg + case AggregationMethod.MIN: + return self.value.min + case AggregationMethod.MAX: + return self.value.max + case unexpected: + assert_never(unexpected) + case None: + return None + case unexpected: + assert_never(unexpected) + + +@dataclass(frozen=True, kw_only=True) +class SensorDataSamples: + """An aggregate of multiple metrics, states, and errors of a sensor.""" + + sensor_id: SensorId + """The unique identifier of the sensor.""" + + metrics: list[SensorMetricSample] + """The metrics sampled from the sensor.""" + + states: list[SensorStateSample] + """The states sampled from the sensor.""" diff --git a/tests/test_client.py b/tests/test_client.py index ab210ed..81e96d2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,16 +3,20 @@ """Tests for the microgrid client thin wrapper.""" +# We are going to split these tests in the future, but for now... +# pylint: disable=too-many-lines + import logging from collections.abc import AsyncIterator +from datetime import datetime, timezone from typing import Any from unittest import mock import grpc.aio import pytest from frequenz.api.common import components_pb2, metrics_pb2 -from frequenz.api.microgrid import grid_pb2, inverter_pb2, microgrid_pb2 -from frequenz.client.base import retry +from frequenz.api.microgrid import grid_pb2, inverter_pb2, microgrid_pb2, sensor_pb2 +from frequenz.client.base import conversion, retry from google.protobuf.empty_pb2 import Empty from frequenz.client.microgrid import ( @@ -21,7 +25,6 @@ Component, ComponentCategory, ComponentData, - ComponentId, Connection, EVChargerData, Fuse, @@ -30,7 +33,15 @@ InverterType, MeterData, MicrogridApiClient, - MicrogridId, +) +from frequenz.client.microgrid.id import ComponentId, MicrogridId, SensorId +from frequenz.client.microgrid.sensor import ( + Sensor, + SensorDataSamples, + SensorMetric, + SensorMetricSample, + SensorStateCode, + SensorStateSample, ) @@ -461,6 +472,101 @@ async def test_metadata_grpc_error( assert "fake grpc details for metadata" in caplog.records[0].exc_text +async def test_list_sensors(client: _TestClient) -> None: + """Test the list_sensors() method.""" + server_response = microgrid_pb2.ComponentList() + client.mock_stub.ListComponents.return_value = server_response + assert set(await client.list_sensors()) == set() + + # Add a sensor + sensor_component = microgrid_pb2.Component( + id=201, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, + sensor=sensor_pb2.Metadata( + type=components_pb2.SensorType.SENSOR_TYPE_ACCELEROMETER, + ), + ) + server_response.components.append(sensor_component) + assert set(await client.list_sensors()) == { + Sensor(id=SensorId(201)), + } + + # Add another sensor + sensor_component_2 = microgrid_pb2.Component( + id=202, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, + sensor=sensor_pb2.Metadata( + type=components_pb2.SensorType.SENSOR_TYPE_HYGROMETER + ), + ) + server_response.components.append(sensor_component_2) + assert set(await client.list_sensors()) == { + Sensor(id=SensorId(201)), + Sensor(id=SensorId(202)), + } + + # Add a non-sensor component to the mock response from ListSensors + # The client.list_sensors() method should filter this out if it's robust, + # or the ListSensors RPC itself should only return sensor components. + meter_component = microgrid_pb2.Component( + id=203, category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_METER + ) + server_response.components.append(meter_component) + # Assert that only SENSOR category components are returned by client.list_sensors() + assert set(await client.list_sensors()) == { + Sensor(id=SensorId(201)), + Sensor(id=SensorId(202)), + Sensor(id=SensorId(203)), + } + # Clean up: remove the meter component from the mock response + server_response.components.pop() + + _replace_components( + server_response, + [ + microgrid_pb2.Component( + id=204, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, + sensor=sensor_pb2.Metadata( + type=components_pb2.SensorType.SENSOR_TYPE_ANEMOMETER + ), + ), + microgrid_pb2.Component( + id=205, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, + sensor=sensor_pb2.Metadata( + type=components_pb2.SensorType.SENSOR_TYPE_PYRANOMETER + ), + ), + ], + ) + assert set(await client.list_sensors()) == { + Sensor(id=SensorId(204)), + Sensor(id=SensorId(205)), + } + + +async def test_list_sensors_grpc_error(client: _TestClient) -> None: + """Test the list_sensors() method when the gRPC call fails.""" + client.mock_stub.GetMicrogridMetadata.return_value = ( + microgrid_pb2.MicrogridMetadata(microgrid_id=101) + ) + client.mock_stub.ListComponents.side_effect = grpc.aio.AioRpcError( + mock.MagicMock(name="mock_status"), + mock.MagicMock(name="mock_initial_metadata"), + mock.MagicMock(name="mock_trailing_metadata"), + "fake grpc details", + "fake grpc debug_error_string", + ) + with pytest.raises( + ApiClientError, + match=r"Failed calling 'ListComponents' on 'grpc://mock_host:1234': .* " + r">: fake grpc details " + r"\(fake grpc debug_error_string\)", + ): + await client.list_sensors() + + @pytest.fixture def meter83() -> microgrid_pb2.Component: """Return a test meter component.""" @@ -493,15 +599,28 @@ def ev_charger101() -> microgrid_pb2.Component: ) +@pytest.fixture +def sensor201() -> microgrid_pb2.Component: + """Return a test sensor component.""" + return microgrid_pb2.Component( + id=201, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, + sensor=sensor_pb2.Metadata( + type=components_pb2.SensorType.SENSOR_TYPE_THERMOMETER + ), + ) + + @pytest.fixture def component_list( meter83: microgrid_pb2.Component, battery38: microgrid_pb2.Component, inverter99: microgrid_pb2.Component, ev_charger101: microgrid_pb2.Component, + sensor201: microgrid_pb2.Component, ) -> list[microgrid_pb2.Component]: """Return a list of test components.""" - return [meter83, battery38, inverter99, ev_charger101] + return [meter83, battery38, inverter99, ev_charger101, sensor201] @pytest.mark.parametrize("method", ["meter_data", "battery_data", "inverter_data"]) @@ -793,6 +912,97 @@ async def test_set_bounds_grpc_error(client: _TestClient) -> None: await client.set_bounds(ComponentId(99), 0.0, 100.0) +async def test_stream_sensor_data_success( + sensor201: microgrid_pb2.Component, client: _TestClient +) -> None: + """Test successful streaming of sensor data.""" + now = datetime.now(timezone.utc) + + async def stream_data_impl( + *_: Any, **__: Any + ) -> AsyncIterator[microgrid_pb2.ComponentData]: + yield microgrid_pb2.ComponentData( + id=int(sensor201.id), + ts=conversion.to_timestamp(now), + sensor=sensor_pb2.Sensor( + state=sensor_pb2.State( + component_state=sensor_pb2.ComponentState.COMPONENT_STATE_OK + ), + data=sensor_pb2.Data( + sensor_data=[ + sensor_pb2.SensorData( + value=1.0, + sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, + ) + ], + ), + ), + ) + + client.mock_stub.StreamComponentData.side_effect = stream_data_impl + receiver = client.stream_sensor_data( + SensorId(sensor201.id), [SensorMetric.TEMPERATURE] + ) + sample = await receiver.receive() + + assert isinstance(sample, SensorDataSamples) + assert int(sample.sensor_id) == sensor201.id + assert sample.states == [ + SensorStateSample( + sampled_at=now, + states=frozenset({SensorStateCode.ON}), + warnings=frozenset(), + errors=frozenset(), + ) + ] + assert sample.metrics == [ + SensorMetricSample(sampled_at=now, metric=SensorMetric.TEMPERATURE, value=1.0) + ] + + +async def test_stream_sensor_data_grpc_error( + sensor201: microgrid_pb2.Component, caplog: pytest.LogCaptureFixture +) -> None: + """Test stream_sensor_data() when the gRPC call fails and retries.""" + caplog.set_level(logging.WARNING) + + num_calls = 0 + + async def stream_data_error_impl( + *_: Any, **__: Any + ) -> AsyncIterator[microgrid_pb2.ComponentData]: + nonlocal num_calls + num_calls += 1 + if num_calls <= 2: # Fail first two times + raise grpc.aio.AioRpcError( + mock.MagicMock(name="mock_status"), + mock.MagicMock(name="mock_initial_metadata"), + mock.MagicMock(name="mock_trailing_metadata"), + f"fake grpc details stream_sensor_data num_calls={num_calls}", + "fake grpc debug_error_string", + ) + # Succeed on the third call + yield microgrid_pb2.ComponentData(id=int(sensor201.id)) + + async with _TestClient( + retry_strategy=retry.LinearBackoff(interval=0.0, jitter=0.0, limit=3) + ) as client: + client.mock_stub.StreamComponentData.side_effect = stream_data_error_impl + receiver = client.stream_sensor_data( + SensorId(sensor201.id), [SensorMetric.TEMPERATURE] + ) + sample = await receiver.receive() # Should succeed after retries + + assert isinstance(sample, SensorDataSamples) + assert int(sample.sensor_id) == sensor201.id + + assert num_calls == 3 # Check that it was called 3 times (1 initial + 2 retries) + # Check log messages for retries + assert "connection ended, retrying" in caplog.text + assert "fake grpc details stream_sensor_data num_calls=1" in caplog.text + assert "fake grpc details stream_sensor_data num_calls=2" in caplog.text + + def _clear_components(component_list: microgrid_pb2.ComponentList) -> None: while component_list.components: component_list.components.pop() diff --git a/tests/test_component.py b/tests/test_component.py index 86e0aa1..624e16d 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -9,9 +9,9 @@ from frequenz.client.microgrid import ( Component, ComponentCategory, - ComponentId, ) from frequenz.client.microgrid._component import component_category_from_protobuf +from frequenz.client.microgrid.id import ComponentId def test_component_category_from_protobuf() -> None: diff --git a/tests/test_component_data.py b/tests/test_component_data.py index fcc5dc7..f3205ed 100644 --- a/tests/test_component_data.py +++ b/tests/test_component_data.py @@ -13,11 +13,11 @@ from frequenz.client.microgrid import ( ComponentData, - ComponentId, InverterComponentState, InverterData, InverterError, ) +from frequenz.client.microgrid.id import ComponentId def test_component_data_abstract_class() -> None: diff --git a/tests/test_connection.py b/tests/test_connection.py index 6bc6c43..630c6e9 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -3,7 +3,8 @@ """Tests for the microgrid Connection type.""" -from frequenz.client.microgrid import ComponentId, Connection +from frequenz.client.microgrid import Connection +from frequenz.client.microgrid.id import ComponentId # pylint: disable=invalid-name diff --git a/tests/test_id.py b/tests/test_id.py index ff5476a..fa860ec 100644 --- a/tests/test_id.py +++ b/tests/test_id.py @@ -7,7 +7,7 @@ import pytest -from frequenz.client.microgrid import ComponentId, MicrogridId +from frequenz.client.microgrid.id import ComponentId, MicrogridId, SensorId @dataclass(frozen=True) @@ -16,13 +16,13 @@ class IdTypeInfo: id_class: type str_prefix: str - error_prefix: str # Define all ID types to test here ID_TYPES: list[IdTypeInfo] = [ - IdTypeInfo(MicrogridId, "MID", "Microgrid"), - IdTypeInfo(ComponentId, "CID", "Component"), + IdTypeInfo(MicrogridId, "MID"), + IdTypeInfo(ComponentId, "CID"), + IdTypeInfo(SensorId, "SID"), ] @@ -41,7 +41,7 @@ def test_valid_id(self, type_info: IdTypeInfo) -> None: def test_negative_id_raises(self, type_info: IdTypeInfo) -> None: """Test that creating a negative ID raises ValueError.""" - error_msg = f"{type_info.error_prefix} ID can't be negative" + error_msg = f"{type_info.id_class.__name__} can't be negative" with pytest.raises(ValueError, match=error_msg): type_info.id_class(-1) diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py new file mode 100644 index 0000000..db02555 --- /dev/null +++ b/tests/test_lifetime.py @@ -0,0 +1,305 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Lifetime class.""" + +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum, auto + +import pytest + +from frequenz.client.microgrid import Lifetime + + +class _Time(Enum): + """Types of time points used in tests.""" + + PAST = auto() + """A time point in the past.""" + + NOW = auto() + """The current time point.""" + + FUTURE = auto() + """A time point in the future.""" + + +@dataclass(frozen=True, kw_only=True) +class _LifetimeTestCase: + """Test case for Lifetime creation and validation.""" + + name: str + """The description of the test case.""" + + start: bool + """Whether to include start time.""" + + end: bool + """Whether to include end time.""" + + expected_start: bool + """Whether start should be set.""" + + expected_end: bool + """Whether end should be set.""" + + expected_operational: bool + """The expected operational state.""" + + +@dataclass(frozen=True, kw_only=True) +class _ActivityTestCase: + """Test case for Lifetime activity state.""" + + name: str + """The description of the test case.""" + + start_type: _Time | None + """The type of start time.""" + + end_type: _Time | None + """The type of end time.""" + + expected_operational: bool + """The expected operational state.""" + + +@dataclass(frozen=True, kw_only=True) +class _FixedLifetimeTestCase: + """Test case for fixed lifetime activity testing.""" + + name: str + """The description of the test case.""" + + test_time: _Time + """The type of time point to test.""" + + expected_operational: bool + """The expected operational state.""" + + +@pytest.fixture +def now() -> datetime: + """Fixture to provide current UTC time.""" + return datetime.now(timezone.utc) + + +@pytest.fixture +def past(now: datetime) -> datetime: + """Fixture to provide a past time.""" + return now.replace(year=now.year - 1) + + +@pytest.fixture +def future(now: datetime) -> datetime: + """Fixture to provide a future time.""" + return now.replace(year=now.year + 1) + + +@pytest.mark.parametrize( + "case", + [ + _LifetimeTestCase( + name="full", + start=True, + end=True, + expected_start=True, + expected_end=True, + expected_operational=True, + ), + _LifetimeTestCase( + name="only_start", + start=True, + end=False, + expected_start=True, + expected_end=False, + expected_operational=True, + ), + _LifetimeTestCase( + name="only_end", + start=False, + end=True, + expected_start=False, + expected_end=True, + expected_operational=True, + ), + _LifetimeTestCase( + name="no_dates", + start=False, + end=False, + expected_start=False, + expected_end=False, + expected_operational=True, + ), + ], + 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 + """ + lifetime = Lifetime( + start=now if case.start else None, + end=future if case.end else None, + ) + assert (lifetime.start is not None) == case.expected_start + if case.expected_start: + assert lifetime.start == now + assert (lifetime.end is not None) == case.expected_end + if case.expected_end: + assert lifetime.end == future + assert lifetime.is_operational_now() == case.expected_operational + + +@pytest.mark.parametrize("start", [None, *_Time], ids=lambda x: f"start_{x}") +@pytest.mark.parametrize("end", [None, *_Time], ids=lambda x: f"end_{x}") +def test_validation( + past: datetime, + now: datetime, + future: datetime, + start: _Time | None, + end: _Time | None, +) -> None: + """Test validation of Lifetime parameters.""" + time_map = { + _Time.PAST: past, + _Time.NOW: now, + _Time.FUTURE: future, + None: None, + } + + start_time = time_map[start] + end_time = time_map[end] + + # Invalid combinations are when end is before start + should_fail = ( + start is not None + and end is not None + and ( + (start == _Time.NOW and end == _Time.PAST) + or (start == _Time.FUTURE and end == _Time.PAST) + or (start == _Time.FUTURE and end == _Time.NOW) + ) + ) + + if should_fail: + with pytest.raises(ValueError, match="Start must be before or equal to end."): + Lifetime(start=start_time, end=end_time) + else: + lifetime = Lifetime(start=start_time, end=end_time) + # Verify the timestamps are set correctly + assert lifetime.start == start_time + assert lifetime.end == end_time + + +@pytest.mark.parametrize( + "case", + [ + _ActivityTestCase( + name="past_start-no_end", + start_type=_Time.PAST, + end_type=None, + expected_operational=True, + ), + _ActivityTestCase( + name="past_start-future_end", + start_type=_Time.PAST, + end_type=_Time.FUTURE, + expected_operational=True, + ), + _ActivityTestCase( + name="future_start-no_end", + start_type=_Time.FUTURE, + end_type=None, + expected_operational=False, + ), + _ActivityTestCase( + name="past_start-past_end", + start_type=_Time.PAST, + end_type=_Time.PAST, + expected_operational=False, + ), + _ActivityTestCase( + name="now_start-no_end", + start_type=_Time.NOW, + end_type=None, + expected_operational=True, + ), + _ActivityTestCase( + name="no_start-now_end", + start_type=None, + end_type=_Time.NOW, + expected_operational=True, + ), + _ActivityTestCase( + name="now_start-now_end", + start_type=_Time.NOW, + end_type=_Time.NOW, + expected_operational=True, + ), + _ActivityTestCase( + name="no_start-past_end", + start_type=None, + end_type=_Time.PAST, + expected_operational=False, + ), + ], + ids=lambda case: case.name, +) +def test_active_property( + past: datetime, future: datetime, now: datetime, case: _ActivityTestCase +) -> None: + """Test the active property of Lifetime.""" + start_time = { + _Time.PAST: past, + _Time.FUTURE: future, + _Time.NOW: now, + None: None, + }[case.start_type] + + end_time = { + _Time.PAST: past, + _Time.FUTURE: future, + _Time.NOW: now, + None: None, + }[case.end_type] + + lifetime = Lifetime(start=start_time, end=end_time) + assert lifetime.is_operational_at(now) == case.expected_operational + + +@pytest.mark.parametrize( + "case", + [ + _FixedLifetimeTestCase( + name="past", test_time=_Time.PAST, expected_operational=True + ), + _FixedLifetimeTestCase( + name="now", test_time=_Time.NOW, expected_operational=True + ), + _FixedLifetimeTestCase( + name="future", test_time=_Time.FUTURE, expected_operational=True + ), + ], + ids=lambda case: case.name, +) +def test_active_at_with_fixed_lifetime( + past: datetime, + future: datetime, + now: datetime, + case: _FixedLifetimeTestCase, +) -> None: + """Test active_at with different timestamps for a fixed lifetime period.""" + lifetime = Lifetime(start=past, end=future) + test_time = { + _Time.PAST: past, + _Time.NOW: now, + _Time.FUTURE: future, + }[case.test_time] + + assert lifetime.is_operational_at(test_time) == case.expected_operational diff --git a/tests/test_metadata.py b/tests/test_metadata.py index cf73704..7fcb537 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -9,7 +9,8 @@ import pytest -from frequenz.client.microgrid import Location, Metadata, MicrogridId +from frequenz.client.microgrid import Location, Metadata +from frequenz.client.microgrid.id import MicrogridId @pytest.fixture diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..51078ea --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,42 @@ +# 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" diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 0000000..66067f7 --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,220 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Sensor and sensor data classes.""" + +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest + +from frequenz.client.microgrid import Lifetime +from frequenz.client.microgrid.id import SensorId +from frequenz.client.microgrid.metrics import ( + AggregatedMetricValue, + AggregationMethod, +) +from frequenz.client.microgrid.sensor import Sensor, SensorMetric, SensorMetricSample + + +@pytest.fixture +def now() -> datetime: + """Get the current time.""" + return datetime.now(timezone.utc) + + +def test_sensor_creation_defaults() -> None: + """Test Sensor defaults are as expected.""" + sensor = Sensor(id=SensorId(1)) + + assert sensor.id == SensorId(1) + assert sensor.name is None + assert sensor.manufacturer is None + assert sensor.model_name is None + assert sensor.operational_lifetime == Lifetime() + + +def test_sensor_creation_full(now: datetime) -> None: + """Test Sensor creation with all fields.""" + start = now + end = start + timedelta(days=1) + sensor = Sensor( + id=SensorId(1), + name="test-sensor", + manufacturer="Test Manufacturer", + model_name="Test Model", + operational_lifetime=Lifetime( + start=start, + end=end, + ), + ) + + assert sensor.id == SensorId(1) + assert sensor.name == "test-sensor" + assert sensor.manufacturer == "Test Manufacturer" + assert sensor.model_name == "Test Model" + assert sensor.operational_lifetime.start == start + assert sensor.operational_lifetime.end == end + + +@pytest.mark.parametrize( + "name,expected_str", + [(None, ""), ("test-sensor", "")], + ids=["no-name", "with-name"], +) +def test_sensor_str(name: str | None, expected_str: str) -> None: + """Test string representation of a sensor.""" + sensor = Sensor( + id=SensorId(1), + name=name, + manufacturer="Test Manufacturer", + model_name="Test Model", + operational_lifetime=Lifetime( + start=datetime.now(timezone.utc), + end=datetime.now(timezone.utc) + timedelta(days=1), + ), + ) + assert str(sensor) == expected_str + + +_SENSOR = Sensor( + id=SensorId(1), + name="test", + manufacturer="Test Mfg", + model_name="Model A", +) + +_DIFFERENT_NAME = Sensor( + id=_SENSOR.id, + name="different", + manufacturer=_SENSOR.manufacturer, + model_name=_SENSOR.model_name, +) + +_DIFFERENT_ID = Sensor( + id=SensorId(2), + name=_SENSOR.name, + manufacturer=_SENSOR.manufacturer, + model_name=_SENSOR.model_name, +) + + +@pytest.mark.parametrize( + "comp,expected", + [ + pytest.param(_SENSOR, True, id="self"), + pytest.param(_DIFFERENT_NAME, False, id="other-name"), + pytest.param(_DIFFERENT_ID, False, id="other-id"), + ], + ids=lambda o: str(o.id) if isinstance(o, Sensor) else str(o), +) +def test_sensor_equality(comp: Sensor, expected: bool) -> None: + """Test sensor equality.""" + assert (_SENSOR == comp) is expected + assert (comp == _SENSOR) is expected + assert (_SENSOR != comp) is not expected + assert (comp != _SENSOR) is not expected + + +@pytest.mark.parametrize( + "comp,expected", + [ + pytest.param(_SENSOR, True, id="self"), + pytest.param(_DIFFERENT_NAME, True, id="other-name"), + pytest.param(_DIFFERENT_ID, False, id="other-id"), + ], +) +def test_sensor_identity(comp: Sensor, expected: bool) -> None: + """Test sensor identity.""" + assert (_SENSOR.identity == comp.identity) is expected + assert comp.identity == comp.id + + +_ALL_SENSORS_PARAMS = [ + pytest.param(_SENSOR, id="comp"), + pytest.param(_DIFFERENT_NAME, id="name"), + pytest.param(_DIFFERENT_ID, id="id"), +] + + +@pytest.mark.parametrize("comp1", _ALL_SENSORS_PARAMS) +@pytest.mark.parametrize("comp2", _ALL_SENSORS_PARAMS) +def test_sensor_hash(comp1: Sensor, comp2: Sensor) -> None: + """Test that the Sensor hash is consistent.""" + # We can only say the hash are the same if the sensors are equal, if they + # are not, they could still have the same hash (and they will if they have + # only different non-hashable attributes) + if comp1 == comp2: + assert hash(comp1) == hash(comp2) + + +@pytest.mark.parametrize( + "metric,value", + [ + (SensorMetric.TEMPERATURE, 5.0), + ( + SensorMetric.HUMIDITY, + AggregatedMetricValue( + avg=5.0, + min=1.0, + max=10.0, + raw_values=[1.0, 5.0, 10.0], + ), + ), + (SensorMetric.DEW_POINT, None), + ], +) +def test_metric_sample_creation( + now: datetime, metric: SensorMetric, value: float | AggregatedMetricValue | None +) -> None: + """Test MetricSample creation with different value types.""" + sample = SensorMetricSample(sampled_at=now, metric=metric, value=value) + assert sample.sampled_at == now + assert sample.metric == metric + assert sample.value == value + + +@pytest.mark.parametrize( + "value,method_results", + [ + ( + 5.0, + { + AggregationMethod.AVG: 5.0, + AggregationMethod.MIN: 5.0, + AggregationMethod.MAX: 5.0, + }, + ), + ( + AggregatedMetricValue( + avg=5.0, + min=1.0, + max=10.0, + raw_values=[1.0, 5.0, 10.0], + ), + { + AggregationMethod.AVG: 5.0, + AggregationMethod.MIN: 1.0, + AggregationMethod.MAX: 10.0, + }, + ), + ( + None, + { + AggregationMethod.AVG: None, + AggregationMethod.MIN: None, + AggregationMethod.MAX: None, + }, + ), + ], +) +def test_metric_sample_as_single_value( + now: datetime, value: Any, method_results: dict[AggregationMethod, float | None] +) -> None: + """Test MetricSample.as_single_value with different value types and methods.""" + sample = SensorMetricSample( + sampled_at=now, metric=SensorMetric.TEMPERATURE, value=value + ) + + for method, expected in method_results.items(): + assert sample.as_single_value(aggregation_method=method) == expected diff --git a/tests/test_sensor_proto.py b/tests/test_sensor_proto.py new file mode 100644 index 0000000..1dbe57d --- /dev/null +++ b/tests/test_sensor_proto.py @@ -0,0 +1,536 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for protobuf conversion of sensor and sensor data objects.""" + +from collections.abc import Sequence +from dataclasses import dataclass, field +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest +from frequenz.api.common import components_pb2 +from frequenz.api.microgrid import common_pb2, microgrid_pb2, sensor_pb2 +from frequenz.client.base import conversion + +from frequenz.client.microgrid import Lifetime +from frequenz.client.microgrid._sensor_proto import ( + sensor_data_samples_from_proto, + sensor_from_proto, + sensor_from_proto_with_issues, + sensor_metric_sample_from_proto, + sensor_state_sample_from_proto, +) +from frequenz.client.microgrid.id import SensorId +from frequenz.client.microgrid.sensor import ( + Sensor, + SensorDataSamples, + SensorErrorCode, + SensorMetric, + SensorMetricSample, + SensorStateCode, + SensorStateSample, +) + + +@pytest.fixture +def now() -> datetime: + """Return a fixed datetime object for testing.""" + return datetime.now(timezone.utc) + + +@pytest.fixture +def sensor_id() -> SensorId: + """Provide a test sensor ID.""" + return SensorId(42) + + +@patch("frequenz.client.microgrid._sensor_proto.sensor_from_proto_with_issues") +def test_sensor_from_proto( + mock_sensor_from_proto_with_issues: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test main sensor conversion from protobuf.""" + mock_proto = Mock(name="PbSensor", spec=microgrid_pb2.Component) + mock_sensor = Mock(name="Sensor", spec=Sensor) + captured_major_issues: list[str] | None = None + captured_minor_issues: list[str] | None = None + + def _fake_sensor_from_proto_with_issues( + _: microgrid_pb2.Component, major_issues: list[str], minor_issues: list[str] + ) -> Sensor: + """Fake function to simulate sensor conversion.""" + nonlocal captured_major_issues + nonlocal captured_minor_issues + captured_major_issues = major_issues + captured_minor_issues = minor_issues + + major_issues.append("major issue") + minor_issues.append("minor issue") + return mock_sensor + + mock_sensor_from_proto_with_issues.side_effect = _fake_sensor_from_proto_with_issues + + with caplog.at_level("DEBUG"): + sensor = sensor_from_proto(mock_proto) + + assert sensor is mock_sensor + mock_sensor_from_proto_with_issues.assert_called_once_with( + mock_proto, + # We need to use the same instance here because it was mutated (it was called + # with empty lists but they were mutated in the function) + major_issues=captured_major_issues, + minor_issues=captured_minor_issues, + ) + assert captured_major_issues == ["major issue"] + assert captured_minor_issues == ["minor issue"] + assert len(caplog.records) == 2 + assert caplog.records[0].levelname == "WARNING" + assert "Found issues in sensor: major issue" in caplog.records[0].message + assert caplog.records[1].levelname == "DEBUG" + assert "Found minor issues in sensor: minor issue" in caplog.records[1].message + + +@dataclass(frozen=True, kw_only=True) +class _SensorTestCase: # pylint: disable=too-many-instance-attributes + """Test case for sensor protobuf conversion.""" + + test_id: str + """Description of the test case.""" + + missing_optional_fields: bool = False + """Whether to include name, manufacturer and model_name in the protobuf message.""" + + missing_metadata: bool = False + """Whether to include sensor metadata in the protobuf message.""" + + has_wrong_category: bool = False + """Whether to include sensor metadata in the protobuf message.""" + + expected_minor_issues: Sequence[str] = tuple() + """Minor issues expected in the sensor.""" + + expected_major_issues: Sequence[str] = tuple() + """Major issues expected in the sensor.""" + + +@patch("frequenz.client.microgrid._sensor_proto.Sensor") +@pytest.mark.parametrize( + "case", + [ + _SensorTestCase(test_id="full"), + _SensorTestCase( + test_id="missing_metadata", + missing_optional_fields=True, + expected_minor_issues=[ + "name is empty", + "manufacturer is empty", + "model_name is empty", + ], + ), + _SensorTestCase( + test_id="wrong_category", + has_wrong_category=True, + expected_major_issues=[ + "unexpected category for sensor (10)", + ], + ), + _SensorTestCase( + test_id="missing_sensor_metadata", + missing_metadata=True, + # This is actually fine, we don't use the metadata + ), + _SensorTestCase( + test_id="all_wrong", + missing_metadata=True, + has_wrong_category=True, + missing_optional_fields=True, + expected_major_issues=[ + "unexpected category for sensor (10)", + ], + expected_minor_issues=[ + "name is empty", + "manufacturer is empty", + "model_name is empty", + ], + ), + ], + ids=lambda case: case.test_id, +) +# pylint: disable-next=too-many-locals,too-many-arguments,too-many-positional-arguments +def test_sensor_from_proto_with_issues( + mock_sensor: Mock, case: _SensorTestCase, sensor_id: SensorId +) -> None: + """Test sensor conversion with metadata matching check.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + + proto = microgrid_pb2.Component( + id=int(sensor_id), + category=( + components_pb2.ComponentCategory.COMPONENT_CATEGORY_CHP + if case.has_wrong_category + else components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR + ), + ) + + if not case.missing_optional_fields: + proto.name = "test_sensor" + proto.manufacturer = "test_manufacturer" + proto.model_name = "test_model" + if not case.missing_metadata: + proto.sensor.CopyFrom( + sensor_pb2.Metadata( + type=components_pb2.SensorType.SENSOR_TYPE_ACCELEROMETER + ) + ) + + _ = sensor_from_proto_with_issues( + proto, + major_issues=major_issues, + minor_issues=minor_issues, + ) + + assert major_issues == list(case.expected_major_issues) + assert minor_issues == list(case.expected_minor_issues) + + mock_sensor.assert_called_once_with( + id=sensor_id, + name=proto.name or None, + manufacturer=proto.manufacturer or None, + model_name=proto.model_name or None, + operational_lifetime=Lifetime(), + ) + + +@dataclass(frozen=True, kw_only=True) +class _SensorMetricSampleTestCase: + """Test case for sensor_metric_sample_from_proto.""" + + test_id: str + proto_metric_value: sensor_pb2.SensorMetric.ValueType | int + proto_value: float + expected_metric: SensorMetric | int + expected_value: float + + +@pytest.mark.parametrize( + "case", + [ + _SensorMetricSampleTestCase( + test_id="valid_metric", + proto_metric_value=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, + proto_value=25.5, + expected_metric=SensorMetric.TEMPERATURE, + expected_value=25.5, + ), + _SensorMetricSampleTestCase( + test_id="unrecognized_metric", + proto_metric_value=999, + proto_value=10.0, + expected_metric=999, + expected_value=10.0, + ), + ], + ids=lambda case: case.test_id, +) +def test_sensor_metric_sample_from_proto( + case: _SensorMetricSampleTestCase, now: datetime +) -> None: + """Test sensor_metric_sample_from_proto with different inputs.""" + proto_metric = sensor_pb2.SensorData( + sensor_metric=case.proto_metric_value, # type: ignore[arg-type] + value=case.proto_value, + ) + result = sensor_metric_sample_from_proto(now, proto_metric) + + assert isinstance(result, SensorMetricSample) + assert result.sampled_at == now + assert result.metric == case.expected_metric + assert result.value == case.expected_value + + +@dataclass(frozen=True, kw_only=True) +class _SensorStateSampleTestCase: + """Test case for sensor_state_sample_from_proto.""" + + test_id: str + proto_state_code: sensor_pb2.ComponentState.ValueType + proto_errors: list[sensor_pb2.Error] = field(default_factory=list) + expected_state_code: SensorStateCode | int + expected_errors_set: frozenset[SensorErrorCode | int] + expected_warnings_set: frozenset[SensorErrorCode | int] + + +@pytest.mark.parametrize( + "case", + [ + _SensorStateSampleTestCase( + test_id="state_on_no_errors", + proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_OK, + expected_state_code=SensorStateCode.ON, + expected_errors_set=frozenset(), + expected_warnings_set=frozenset(), + ), + _SensorStateSampleTestCase( + test_id="state_error_critical_error", + proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_ERROR, + proto_errors=[ + sensor_pb2.Error( + # Code only have UNSPECIFIED for now + level=common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL, + msg="Critical error", + ) + ], + expected_state_code=SensorStateCode.ERROR, + expected_errors_set=frozenset([SensorErrorCode.UNSPECIFIED]), + expected_warnings_set=frozenset(), + ), + _SensorStateSampleTestCase( + test_id="state_on_warning", + proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_OK, + proto_errors=[ + sensor_pb2.Error( + # We use some numeric unrecognized code for the warning + code=999, # type: ignore[arg-type] + level=common_pb2.ErrorLevel.ERROR_LEVEL_WARN, + msg="Warning", + ) + ], + expected_state_code=SensorStateCode.ON, + expected_errors_set=frozenset(), + expected_warnings_set=frozenset([999]), + ), + _SensorStateSampleTestCase( + test_id="state_on_critical_and_warning", + proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_OK, + proto_errors=[ + sensor_pb2.Error( + code=999, # type: ignore[arg-type] + level=common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL, + msg="Critical error", + ), + sensor_pb2.Error( + code=666, # type: ignore[arg-type] + level=common_pb2.ErrorLevel.ERROR_LEVEL_WARN, + msg="Warning", + ), + ], + expected_state_code=SensorStateCode.ON, + expected_errors_set=frozenset([999]), + expected_warnings_set=frozenset([666]), + ), + _SensorStateSampleTestCase( + test_id="state_on_unspecified_level_error", + proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_OK, + proto_errors=[ + sensor_pb2.Error( + code=999, # type: ignore[arg-type] + level=common_pb2.ErrorLevel.ERROR_LEVEL_UNSPECIFIED, + msg="Unspecified error", + ) + ], + expected_state_code=SensorStateCode.ON, + expected_errors_set=frozenset([999]), + expected_warnings_set=frozenset(), + ), + _SensorStateSampleTestCase( + test_id="unrecognized_state_code", + proto_state_code=999, # type: ignore[arg-type] + expected_state_code=999, # Expected to be the integer itself + expected_errors_set=frozenset(), + expected_warnings_set=frozenset(), + ), + ], + ids=lambda case: case.test_id, +) +def test_sensor_state_sample_from_proto( + case: _SensorStateSampleTestCase, now: datetime +) -> None: + """Test conversion of state, errors, and warnings.""" + proto_sensor_comp_data = sensor_pb2.Sensor( + state=sensor_pb2.State(component_state=case.proto_state_code), + errors=case.proto_errors, + ) + + result = sensor_state_sample_from_proto(now, proto_sensor_comp_data) + + assert isinstance(result, SensorStateSample) + assert result.sampled_at == now + assert result.states == frozenset([case.expected_state_code]) + assert result.errors == case.expected_errors_set + assert result.warnings == case.expected_warnings_set + + +@dataclass(frozen=True, kw_only=True) +class _SensorDataSamplesTestCase: # pylint: disable=too-many-instance-attributes + """Test case for sensor_data_samples_from_proto.""" + + test_id: str + proto_sensor_data: list[sensor_pb2.SensorData] = field(default_factory=list) + filter_metrics_pb_values: set[sensor_pb2.SensorMetric.ValueType] + expected_metrics_count: int + expected_first_metric_details: tuple[SensorMetric, float] | None + proto_state_code: sensor_pb2.ComponentState.ValueType = ( + sensor_pb2.ComponentState.COMPONENT_STATE_OK + ) + proto_errors: list[sensor_pb2.Error] = field(default_factory=list) + expected_state_code: SensorStateCode | int = SensorStateCode.ON + expected_errors_set: frozenset[SensorErrorCode | int] = frozenset() + expected_warnings_set: frozenset[SensorErrorCode | int] = frozenset() + + +@pytest.mark.parametrize( + "case", + [ + _SensorDataSamplesTestCase( + test_id="one_metric_match_filter", + proto_sensor_data=[ + sensor_pb2.SensorData( + sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, + value=20.0, + ) + ], + filter_metrics_pb_values={ + sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE + }, + expected_metrics_count=1, + expected_first_metric_details=(SensorMetric.TEMPERATURE, 20.0), + ), + _SensorDataSamplesTestCase( + test_id="two_metrics_filter_one", + proto_sensor_data=[ + sensor_pb2.SensorData( + sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, + value=20.0, + ), + sensor_pb2.SensorData( + sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_HUMIDITY, + value=60.0, + ), + ], + filter_metrics_pb_values={ + sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE + }, + expected_metrics_count=1, + expected_first_metric_details=(SensorMetric.TEMPERATURE, 20.0), + ), + _SensorDataSamplesTestCase( + test_id="two_metrics_filter_both", + proto_sensor_data=[ + sensor_pb2.SensorData( + sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, + value=20.0, + ), + sensor_pb2.SensorData( + sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_HUMIDITY, + value=60.0, + ), + ], + filter_metrics_pb_values={ + sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, + sensor_pb2.SensorMetric.SENSOR_METRIC_HUMIDITY, + }, + expected_metrics_count=2, + expected_first_metric_details=( + SensorMetric.TEMPERATURE, + 20.0, + ), # Checks first, assumes order + ), + _SensorDataSamplesTestCase( + test_id="filter_none_empty_set", + proto_sensor_data=[ + sensor_pb2.SensorData( + sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, + value=20.0, + ) + ], + filter_metrics_pb_values=set(), # Empty filter set + expected_metrics_count=0, + expected_first_metric_details=None, + ), + _SensorDataSamplesTestCase( + test_id="filter_none_other_metric", + proto_sensor_data=[ + sensor_pb2.SensorData( + sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE, + value=20.0, + ) + ], + filter_metrics_pb_values={ + sensor_pb2.SensorMetric.SENSOR_METRIC_HUMIDITY + }, # Filter for other metric + expected_metrics_count=0, + expected_first_metric_details=None, + ), + _SensorDataSamplesTestCase( + test_id="no_metrics_in_proto", + filter_metrics_pb_values={ + sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE + }, + expected_metrics_count=0, + expected_first_metric_details=None, + ), + _SensorDataSamplesTestCase( + test_id="state_details_propagation", + filter_metrics_pb_values=set(), + expected_metrics_count=0, + expected_first_metric_details=None, + proto_state_code=sensor_pb2.ComponentState.COMPONENT_STATE_ERROR, + proto_errors=[ + sensor_pb2.Error( + code=sensor_pb2.ErrorCode.ERROR_CODE_UNSPECIFIED, # The only option for now + level=common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL, + msg="Error message", + ) + ], + expected_state_code=SensorStateCode.ERROR, + expected_errors_set=frozenset([SensorErrorCode.UNSPECIFIED]), + ), + ], + ids=lambda case: case.test_id, +) +def test_sensor_data_samples_from_proto( + case: _SensorDataSamplesTestCase, + now: datetime, +) -> None: + """Test metric filtering and overall structure of SensorDataSamples.""" + sensor_id_val = 123 + proto_sensor_data = microgrid_pb2.ComponentData( + id=sensor_id_val, + ts=conversion.to_timestamp(now), + sensor=sensor_pb2.Sensor( + data=sensor_pb2.Data(sensor_data=case.proto_sensor_data), + state=sensor_pb2.State(component_state=case.proto_state_code), + errors=case.proto_errors, + ), + ) + + result = sensor_data_samples_from_proto( + proto_sensor_data, case.filter_metrics_pb_values + ) + + assert isinstance(result, SensorDataSamples) + assert result.sensor_id == SensorId(sensor_id_val) + assert len(result.metrics) == case.expected_metrics_count + + if case.expected_metrics_count > 0 and case.expected_first_metric_details: + expected_sample = SensorMetricSample( + sampled_at=now, + metric=case.expected_first_metric_details[0], + value=case.expected_first_metric_details[1], + ) + # Basic check of the first metric, assumes order and content correctness + # More comprehensive checks could iterate through all expected metrics. + assert result.metrics[0] == expected_sample + for metric_sample in result.metrics: + assert metric_sample.sampled_at == now + + # Check state part + assert len(result.states) == 1 + state_sample = result.states[0] + assert isinstance(state_sample, SensorStateSample) + assert state_sample.sampled_at == now + assert state_sample.states == frozenset([case.expected_state_code]) + assert state_sample.errors == case.expected_errors_set + assert state_sample.warnings == case.expected_warnings_set