diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 085f031f..4f4f88e2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,8 +10,9 @@ ## New Features - +- The client can now list sensor retrieving their metadata (`MicrogridApiClient.list_sensors()`) and can stream sensor data (`MicrogridApiClient.stream_sensor_data()`). ## Bug Fixes - +- When retrieving the microgrid metadata using `metadata()`, if the location was empty in the protobuf message, a wrong location with long=0, lat=0 was used. Now the location will be properly set to `None` in that case. +- The client now does some missing cleanup (stopping background tasks) when disconnecting (and when used as a context manager). diff --git a/src/frequenz/client/microgrid/__init__.py b/src/frequenz/client/microgrid/__init__.py index c88e2244..d2dd99f4 100644 --- a/src/frequenz/client/microgrid/__init__.py +++ b/src/frequenz/client/microgrid/__init__.py @@ -62,7 +62,8 @@ UnknownError, UnrecognizedGrpcStatus, ) -from ._id import ComponentId, MicrogridId +from ._id import ComponentId, MicrogridId, SensorId +from ._lifetime import Lifetime from ._metadata import Location, Metadata __all__ = [ @@ -98,6 +99,7 @@ "InverterError", "InverterErrorCode", "InverterType", + "Lifetime", "Location", "Metadata", "MeterData", @@ -112,6 +114,7 @@ "OperationUnauthenticated", "PermissionDenied", "ResourceExhausted", + "SensorId", "ServiceUnavailable", "UnknownError", "UnrecognizedGrpcStatus", diff --git a/src/frequenz/client/microgrid/_client.py b/src/frequenz/client/microgrid/_client.py index 9aa73547..7bf4cbf4 100644 --- a/src/frequenz/client/microgrid/_client.py +++ b/src/frequenz/client/microgrid/_client.py @@ -6,16 +6,23 @@ 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 typing import Any, 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 +from typing_extensions import override + +from frequenz.client.microgrid._id import SensorId +from frequenz.client.microgrid.sensor._base import Sensor +from frequenz.client.microgrid.sensor._data import SensorDataSamples, SensorMetric +from frequenz.client.microgrid.sensor._data_proto import sensor_data_samples_from_proto from ._component import ( Component, @@ -36,6 +43,8 @@ from ._exception import ApiClientError, ClientNotConnected from ._id import ComponentId, MicrogridId from ._metadata import Location, Metadata +from .sensor._proto import sensor_from_proto +from .sensor._types import SensorTypes DEFAULT_GRPC_CALL_TIMEOUT = 60.0 """The default timeout for gRPC calls made by this client (in seconds).""" @@ -95,6 +104,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 @@ -108,6 +123,42 @@ def stub(self) -> microgrid_pb2_grpc.MicrogridAsyncStub: # type-checker, so it can only be used for type hints. return self._stub # type: ignore + @override + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any | None, + ) -> bool | None: + """Close the gRPC channel and stop all broadcasters.""" + exceptions = list( + exc + for exc in await asyncio.gather( + *( + 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: + result = await super().__aexit__(exc_type, exc_val, exc_tb) + except Exception as exc: # pylint: disable=broad-except + exceptions.append(exc) + if exceptions: + raise BaseExceptionGroup( + "Error while disconnecting from the microgrid API", exceptions + ) + return result + async def components( # noqa: DOC502 (raises ApiClientError indirectly) self, ) -> Iterable[Component]: @@ -147,6 +198,33 @@ async def components( # noqa: DOC502 (raises ApiClientError indirectly) return result + async def list_sensors( # noqa: DOC502 (raises ApiClientError indirectly) + self, + ) -> Iterable[SensorTypes]: + """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. @@ -173,7 +251,7 @@ async def metadata(self) -> Metadata: return Metadata() location: Location | None = None - if microgrid_metadata.location: + if microgrid_metadata.HasField("location"): location = Location( latitude=microgrid_metadata.location.latitude, longitude=microgrid_metadata.location.longitude, @@ -509,3 +587,83 @@ 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], + *, + buffer_size: int = 50, + ) -> Receiver[SensorDataSamples]: + """Stream data samples from a sensor. + + At least one metric must be specified. If no metric is specified, then the + stream will raise an error. + + 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: List of metrics to return. Only the specified metrics will be + returned. + 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: + The data stream from the sensor. + """ + sensor_id = _get_sensor_id(sensor) + metrics_set = frozenset([_get_sensor_metric_value(m) for m in metrics]) + key = f"{sensor_id}-{hash(metrics_set)}" + 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, + ) + ), + lambda msg: sensor_data_samples_from_proto(msg, metrics_set), + 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/_id.py b/src/frequenz/client/microgrid/_id.py index 9e1b77f8..24fc93e0 100644 --- a/src/frequenz/client/microgrid/_id.py +++ b/src/frequenz/client/microgrid/_id.py @@ -1,7 +1,7 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Strongly typed IDs for microgrids and components.""" +"""Strongly typed IDs for microgrids, components and sensors.""" from typing import final @@ -105,3 +105,53 @@ def __repr__(self) -> str: def __str__(self) -> str: """Return the short string representation of this instance.""" return f"CID{self._id}" + + +@final +class SensorId: + """A unique identifier for a microgrid sensor.""" + + def __init__(self, id_: int, /) -> None: + """Initialize this instance. + + Args: + id_: The numeric unique identifier of the microgrid sensor. + + Raises: + ValueError: If the ID is negative. + """ + if id_ < 0: + raise ValueError("Sensor 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 SensorId 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 SensorId: + 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((SensorId, 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"SID{self._id}" diff --git a/src/frequenz/client/microgrid/_lifetime.py b/src/frequenz/client/microgrid/_lifetime.py new file mode 100644 index 00000000..0512f1d4 --- /dev/null +++ b/src/frequenz/client/microgrid/_lifetime.py @@ -0,0 +1,51 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Lifetime of a microgrid asset.""" + + +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import cached_property + + +@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 active_at(self, timestamp: datetime) -> bool: + """Check whether this lifetime is active at a specific timestamp.""" + if self.start is not None and self.start > timestamp: + return False + if self.end is not None: + return self.end >= timestamp + # Both are None, so it is always active + return True + + @cached_property + def active(self) -> bool: + """Whether this lifetime is currently active.""" + return self.active_at(datetime.now(timezone.utc)) diff --git a/src/frequenz/client/microgrid/_util.py b/src/frequenz/client/microgrid/_util.py new file mode 100644 index 00000000..c974e867 --- /dev/null +++ b/src/frequenz/client/microgrid/_util.py @@ -0,0 +1,50 @@ +# 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`. + """ + # A protobuf enum value is a NewType of int, so we make sure we have a pure int at + # this point. + value = int(value) + try: + return enum_type(value) + except ValueError: + return value diff --git a/src/frequenz/client/microgrid/metrics.py b/src/frequenz/client/microgrid/metrics.py new file mode 100644 index 00000000..02dc7185 --- /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/__init__.py b/src/frequenz/client/microgrid/sensor/__init__.py new file mode 100644 index 00000000..6148f8b9 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/__init__.py @@ -0,0 +1,196 @@ +# 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. Sensors measure various physical metrics in +the surrounding environment, such as temperature, humidity, and solar +irradiance. + +# Sensor Class Hierarchy + +All sensors in this package inherit from the base +[`Sensor`][frequenz.client.microgrid.sensor.Sensor] class, which provides common +attributes and functionality. + +The sensors are divided into two main categories: + +## Well-known Sensors + +These are well-defined sensor types that directly inherit from `Sensor`: + +* [`Accelerometer`][frequenz.client.microgrid.sensor.Accelerometer]: Measures + acceleration. +* [`Anemometer`][frequenz.client.microgrid.sensor.Anemometer]: Measures wind + velocity and direction. +* [`Barometer`][frequenz.client.microgrid.sensor.Barometer]: Measures + atmospheric pressure. +* [`GeneralSensor`][frequenz.client.microgrid.sensor.GeneralSensor]: A sensor + type that doesn't fit into other categories. +* [`Hygrometer`][frequenz.client.microgrid.sensor.Hygrometer]: Measures + humidity. +* [`Pyranometer`][frequenz.client.microgrid.sensor.Pyranometer]: Measures solar + irradiance. +* [`Thermometer`][frequenz.client.microgrid.sensor.Thermometer]: Measures + temperature. + +## Problematic Sensors + +These special types handle cases where sensor data from the API cannot be +cleanly mapped to a well-known sensor type. They inherit from +[`ProblematicSensor`][frequenz.client.microgrid.sensor.ProblematicSensor], +(which itself inherits from `Sensor`): + +* [`UnspecifiedSensor`][frequenz.client.microgrid.sensor.UnspecifiedSensor]: + Used when the sensor's type is not specified in the API data. +* [`UnrecognizedSensor`][frequenz.client.microgrid.sensor.UnrecognizedSensor]: + Used when the sensor type specified in the API is unknown to this client + version (e.g., a new sensor type was added after this client was released). +* [`MismatchedCategorySensor`][frequenz.client.microgrid.sensor.MismatchedCategorySensor]: + Used when a sensor has the wrong `COMPONENT_CATEGORY_SENSOR` category in the + API and there is also no sensor metadata to determine the correct sensor + type. + +# Working with Sensors + +The [`SensorTypes`][frequenz.client.microgrid.sensor.SensorTypes] type alias +represents a union of all possible sensor types. You can use Python's type +checking features to work with different sensor types. + +A [`ProblematicSensorTypes`][frequenz.client.microgrid.sensor.ProblematicSensorTypes] +type alias is also provided, which includes all problematic sensor types. + +This allows you to handle sensors that may not fit into the well-known sensor +types as a group. + +Example: Using match statements to process sensors + The match statement can be used to handle different sensor types in a clean and + efficient way. The type-checker can then do [exhaustiveness + checking](https://mypy.readthedocs.io/en/stable/literal_types.html#id3) to + ensure that all possible sensor types are handled. + + ```python + from typing import assert_never + + def process_sensor(sensor: SensorTypes) -> None: + match sensor: + case Accelerometer(): + print("Processing acceleration sensor") + case Anemometer(): + print("Processing wind sensor") + case Barometer(): + print("Processing pressure sensor") + case GeneralSensor(): + print("Processing general sensor") + case Hygrometer(): + print("Processing humidity sensor") + case Pyranometer(): + print("Processing solar irradiance sensor") + case Thermometer(): + print("Processing temperature sensor") + case UnspecifiedSensor(): + print("Processing unknown sensor") + case UnrecognizedSensor(): + print("Processing unrecognized sensor") + case MismatchedCategorySensor(): + print("Processing misconfigured sensor") + case unexpected: + assert_never(unexpected) + ``` + +Example: Processing problematic sensors as a group + ```python + from typing import assert_never + + def process_sensor(sensor: SensorTypes) -> None: + match sensor: + case Accelerometer(): + print("Processing acceleration sensor") + case Anemometer(): + print("Processing wind sensor") + case Barometer(): + print("Processing pressure sensor") + case GeneralSensor(): + print("Processing general sensor") + case Hygrometer(): + print("Processing humidity sensor") + case Pyranometer(): + print("Processing solar irradiance sensor") + case Thermometer(): + print("Processing temperature sensor") + case ProblematicSensor(): + print("Processing unknown sensor") + case unexpected: + assert_never(unexpected) + ``` + +# 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. +""" + +from ._accelerometer import Accelerometer +from ._anemometer import Anemometer +from ._barometer import Barometer +from ._base import Sensor +from ._category import SensorCategory +from ._data import ( + SensorDataSamples, + SensorErrorCode, + SensorMetric, + SensorMetricSample, + SensorStateCode, + SensorStateSample, +) +from ._general_sensor import GeneralSensor +from ._hygrometer import Hygrometer +from ._problematic import ( + MismatchedCategorySensor, + ProblematicSensor, + UnrecognizedSensor, + UnspecifiedSensor, +) +from ._pyranometer import Pyranometer +from ._thermometer import Thermometer +from ._types import ProblematicSensorTypes, SensorTypes + +__all__ = [ + "Accelerometer", + "Anemometer", + "Barometer", + "GeneralSensor", + "Hygrometer", + "MismatchedCategorySensor", + "ProblematicSensor", + "ProblematicSensorTypes", + "Pyranometer", + "Sensor", + "SensorCategory", + "SensorDataSamples", + "SensorErrorCode", + "SensorMetric", + "SensorMetricSample", + "SensorStateCode", + "SensorStateSample", + "SensorTypes", + "Thermometer", + "UnrecognizedSensor", + "UnspecifiedSensor", +] diff --git a/src/frequenz/client/microgrid/sensor/_accelerometer.py b/src/frequenz/client/microgrid/sensor/_accelerometer.py new file mode 100644 index 00000000..152446aa --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_accelerometer.py @@ -0,0 +1,21 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""An accelerometer sensor.""" + +import dataclasses +from typing import Literal + +from ._base import Sensor +from ._category import SensorCategory + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Accelerometer(Sensor): + """An accelerometer sensor. + + Measures acceleration. + """ + + category: Literal[SensorCategory.ACCELEROMETER] = SensorCategory.ACCELEROMETER + """The category of this sensor.""" diff --git a/src/frequenz/client/microgrid/sensor/_anemometer.py b/src/frequenz/client/microgrid/sensor/_anemometer.py new file mode 100644 index 00000000..6997ecc2 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_anemometer.py @@ -0,0 +1,21 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""An anemometer sensor.""" + +import dataclasses +from typing import Literal + +from ._base import Sensor +from ._category import SensorCategory + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Anemometer(Sensor): + """An anemometer sensor. + + Measures wind velocity and direction. + """ + + category: Literal[SensorCategory.ANEMOMETER] = SensorCategory.ANEMOMETER + """The category of this sensor.""" diff --git a/src/frequenz/client/microgrid/sensor/_barometer.py b/src/frequenz/client/microgrid/sensor/_barometer.py new file mode 100644 index 00000000..638951bc --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_barometer.py @@ -0,0 +1,21 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A barometer sensor.""" + +import dataclasses +from typing import Literal + +from ._base import Sensor +from ._category import SensorCategory + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Barometer(Sensor): + """A barometer sensor. + + Measures pressure. + """ + + category: Literal[SensorCategory.BAROMETER] = SensorCategory.BAROMETER + """The category of this sensor.""" diff --git a/src/frequenz/client/microgrid/sensor/_base.py b/src/frequenz/client/microgrid/sensor/_base.py new file mode 100644 index 00000000..ffd461e9 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_base.py @@ -0,0 +1,81 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Base sensor from which all other sensors inherit.""" + +import dataclasses +from datetime import datetime, timezone +from functools import cached_property +from typing import Any, Self + +from .._id import SensorId +from .._lifetime import Lifetime +from ._category import SensorCategory + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Sensor: + """A base class for all sensors. + + A sensor measures a physical metric in the microgrid's surrounding + environment. + """ + + id: SensorId + """This sensor's ID.""" + + category: SensorCategory | int + """The category of this sensor. + + Note: + This should not be used normally, you should test if a sensor + [`isinstance`][] of a concrete sensor class instead. + + It is only provided for using with a newer version of the API where the + client doesn't know about a new category yet (i.e. for use with + [`UnrecognizedSensor`][frequenz.client.microgrid.sensor.UnrecognizedSensor]) + and in case some low level code needs to know the category of a sensor. + """ + + 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.""" + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is Sensor: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + def active_at(self, timestamp: datetime) -> bool: + """Check whether this sensor is active at a specific timestamp.""" + return self.operational_lifetime.active_at(timestamp) + + @cached_property + def active(self) -> bool: + """Whether this sensor is currently active.""" + return self.active_at(datetime.now(timezone.utc)) + + @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"{self.id}<{type(self).__name__}>{name}" diff --git a/src/frequenz/client/microgrid/sensor/_category.py b/src/frequenz/client/microgrid/sensor/_category.py new file mode 100644 index 00000000..362e6576 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_category.py @@ -0,0 +1,35 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""The component categories that can be used in a microgrid.""" + +import enum + + +@enum.unique +class SensorCategory(enum.Enum): + """The known categories of sensors that can be present in a microgrid.""" + + UNSPECIFIED = 0 + """Unspecified sensor category.""" + + THERMOMETER = 1 + """Thermometer (temperature sensor).""" + + HYGROMETER = 2 + """Hygrometer (humidity sensor).""" + + BAROMETER = 3 + """Barometer (pressure sensor).""" + + PYRANOMETER = 4 + """Pyranometer (solar irradiance sensor).""" + + ANEMOMETER = 5 + """Anemometer (wind velocity and direction sensor).""" + + ACCELEROMETER = 6 + """Accelerometer (acceleration sensor).""" + + GENERAL = 7 + """General sensors, which do not fall in any of the above categories.""" diff --git a/src/frequenz/client/microgrid/sensor/_data.py b/src/frequenz/client/microgrid/sensor/_data.py new file mode 100644 index 00000000..7274c6d7 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_data.py @@ -0,0 +1,183 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Definitions for sensor data.""" + +import enum +from dataclasses import dataclass +from datetime import datetime +from typing import assert_never + +from frequenz.client.microgrid.metrics import ( + AggregatedMetricValue, + AggregationMethod, +) + +from .._id import SensorId + + +@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 = 0 + """Default value (this should not be normally used and usually indicates an issue).""" + + TEMPERATURE = 1 + """Temperature, in Celsius (°C).""" + + HUMIDITY = 2 + """Humidity, in percentage (%).""" + + PRESSURE = 3 + """Pressure, in Pascal (Pa).""" + + IRRADIANCE = 4 + """Irradiance / Radiation flux, in watts per square meter (W / m²).""" + + VELOCITY = 5 + """Velocity, in meters per second (m / s).""" + + ACCELERATION = 6 + """Acceleration in meters per second per second (m / s²).""" + + ANGLE = 7 + """Angle, in degrees with respect to the (magnetic) North (°).""" + + DEW_POINT = 8 + """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 = 0 + """Default value (this should not be normally used and usually indicates an issue).""" + + ON = 1 + """The sensor is up and running.""" + + ERROR = 2 + """The sensor is in an error state.""" + + +@enum.unique +class SensorErrorCode(enum.Enum): + """The various errors that can occur in sensors.""" + + UNSPECIFIED = 0 + """Default value (this should not be normally used and usually indicates an issue).""" + + UNKNOWN = 1 + """An unknown or undefined error. + + This is used when the error can be retrieved from the sensor but it doesn't match + any known error or can't be interpreted for some reason. + """ + + INTERNAL = 2 + """An internal error within the sensor.""" + + +@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/src/frequenz/client/microgrid/sensor/_data_proto.py b/src/frequenz/client/microgrid/sensor/_data_proto.py new file mode 100644 index 00000000..620f6243 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_data_proto.py @@ -0,0 +1,106 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Loading of SensorDataSamples objects from protobuf messages.""" + +from collections.abc import Set +from datetime import datetime + +from frequenz.api.microgrid import common_pb2, microgrid_pb2, sensor_pb2 +from frequenz.client.base import conversion + +from .._id import SensorId +from .._util import enum_from_proto +from ._data import ( + SensorDataSamples, + SensorErrorCode, + SensorMetric, + SensorMetricSample, + SensorStateCode, + SensorStateSample, +) + + +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/sensor/_general_sensor.py b/src/frequenz/client/microgrid/sensor/_general_sensor.py new file mode 100644 index 00000000..18b1186a --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_general_sensor.py @@ -0,0 +1,21 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A general sensor.""" + +import dataclasses +from typing import Literal + +from ._base import Sensor +from ._category import SensorCategory + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class GeneralSensor(Sensor): + """A general sensor. + + A sensor that does not fall into any other specific category. + """ + + category: Literal[SensorCategory.GENERAL] = SensorCategory.GENERAL + """The category of this sensor.""" diff --git a/src/frequenz/client/microgrid/sensor/_hygrometer.py b/src/frequenz/client/microgrid/sensor/_hygrometer.py new file mode 100644 index 00000000..cbdbc537 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_hygrometer.py @@ -0,0 +1,21 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A hygrometer sensor.""" + +import dataclasses +from typing import Literal + +from ._base import Sensor +from ._category import SensorCategory + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Hygrometer(Sensor): + """A hygrometer sensor. + + Measures humidity. + """ + + category: Literal[SensorCategory.HYGROMETER] = SensorCategory.HYGROMETER + """The category of this sensor.""" diff --git a/src/frequenz/client/microgrid/sensor/_problematic.py b/src/frequenz/client/microgrid/sensor/_problematic.py new file mode 100644 index 00000000..3409e47c --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_problematic.py @@ -0,0 +1,55 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Sensors that have a problem and can't be mapped to a known Sensor type.""" + +import dataclasses +from typing import Any, Literal, Self + +from frequenz.client.microgrid._component import ComponentCategory + +from ._base import Sensor +from ._category import SensorCategory + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class ProblematicSensor(Sensor): + """An abstract sensor with a problem.""" + + # pylint: disable-next=unused-argument + def __new__(cls, *args: Any, **kwargs: Any) -> Self: + """Prevent instantiation of this class.""" + if cls is ProblematicSensor: + raise TypeError(f"Cannot instantiate {cls.__name__} directly") + return super().__new__(cls) + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnspecifiedSensor(ProblematicSensor): + """A sensor of unspecified type.""" + + category: Literal[SensorCategory.UNSPECIFIED] = SensorCategory.UNSPECIFIED + """The category of this sensor.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class UnrecognizedSensor(ProblematicSensor): + """A sensor of an unrecognized type.""" + + category: int + """The category of this sensor.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class MismatchedCategorySensor(ProblematicSensor): + """A sensor with a mismatch in the category. + + This sensor has a component category different than COMPONENT_CATEGORY_SENSOR. + doesn't match the declared category. + """ + + category: Literal[SensorCategory.UNSPECIFIED] = SensorCategory.UNSPECIFIED + """The category of this sensor.""" + + component_category: ComponentCategory | int + """The actual category of this sensor.""" diff --git a/src/frequenz/client/microgrid/sensor/_proto.py b/src/frequenz/client/microgrid/sensor/_proto.py new file mode 100644 index 00000000..71ac8272 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_proto.py @@ -0,0 +1,203 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Loading of Sensor objects from protobuf messages.""" + +import logging +from typing import assert_never + +from frequenz.api.common.components_pb2 import ComponentCategory as PbComponentCategory +from frequenz.api.microgrid import microgrid_pb2 + +from frequenz.client.microgrid._component import ComponentCategory + +from .._id import SensorId +from .._lifetime import Lifetime +from .._util import enum_from_proto +from ._accelerometer import Accelerometer +from ._anemometer import Anemometer +from ._barometer import Barometer +from ._category import SensorCategory +from ._general_sensor import GeneralSensor +from ._hygrometer import Hygrometer +from ._problematic import ( + MismatchedCategorySensor, + UnrecognizedSensor, + UnspecifiedSensor, +) +from ._pyranometer import Pyranometer +from ._thermometer import Thermometer +from ._types import SensorTypes + +_logger = logging.getLogger(__name__) + + +def sensor_from_proto(message: microgrid_pb2.Component) -> SensorTypes: + """Convert a protobuf message to a `SensorTypes` 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], +) -> SensorTypes: + """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") + + # Check the component category is the expected sensor category + category_is_wrong = False + if message.category is not PbComponentCategory.COMPONENT_CATEGORY_SENSOR: + major_issues.append(f"unexpected category for sensor ({message.category})") + category_is_wrong = True + + # Get the sensor category from the component sensor metadata + metadata = message.WhichOneof("metadata") + if metadata != "sensor": + major_issues.append(f"wrong sensor metadata ({metadata!r})") + if category_is_wrong: + return MismatchedCategorySensor( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + category=SensorCategory.UNSPECIFIED, + component_category=enum_from_proto(message.category, ComponentCategory), + ) + return UnspecifiedSensor( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + + category = enum_from_proto(message.sensor.type, SensorCategory) + match category: + case SensorCategory.UNSPECIFIED: + major_issues.append("category is unspecified") + return UnspecifiedSensor( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + case int(): + major_issues.append("category is unrecognized") + return UnrecognizedSensor( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + category=category, + operational_lifetime=Lifetime(), + ) + case SensorCategory.ACCELEROMETER: + return Accelerometer( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + case SensorCategory.ANEMOMETER: + return Anemometer( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + case SensorCategory.BAROMETER: + return Barometer( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + case SensorCategory.GENERAL: + return GeneralSensor( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + case SensorCategory.HYGROMETER: + return Hygrometer( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + case SensorCategory.PYRANOMETER: + return Pyranometer( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + case SensorCategory.THERMOMETER: + return Thermometer( + id=sensor_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=Lifetime(), + ) + case unexpected_category: + assert_never(unexpected_category) diff --git a/src/frequenz/client/microgrid/sensor/_pyranometer.py b/src/frequenz/client/microgrid/sensor/_pyranometer.py new file mode 100644 index 00000000..8cffd487 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_pyranometer.py @@ -0,0 +1,21 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A pyranometer sensor.""" + +import dataclasses +from typing import Literal + +from ._base import Sensor +from ._category import SensorCategory + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Pyranometer(Sensor): + """A pyranometer sensor. + + Measures solar irradiance. + """ + + category: Literal[SensorCategory.PYRANOMETER] = SensorCategory.PYRANOMETER + """The category of this sensor.""" diff --git a/src/frequenz/client/microgrid/sensor/_thermometer.py b/src/frequenz/client/microgrid/sensor/_thermometer.py new file mode 100644 index 00000000..f6aac5da --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_thermometer.py @@ -0,0 +1,21 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""A thermometer sensor.""" + +import dataclasses +from typing import Literal + +from ._base import Sensor +from ._category import SensorCategory + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class Thermometer(Sensor): + """A thermometer sensor. + + Measures temperature. + """ + + category: Literal[SensorCategory.THERMOMETER] = SensorCategory.THERMOMETER + """The category of this sensor.""" diff --git a/src/frequenz/client/microgrid/sensor/_types.py b/src/frequenz/client/microgrid/sensor/_types.py new file mode 100644 index 00000000..ec095922 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_types.py @@ -0,0 +1,36 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""All known sensor types.""" + +from typing import TypeAlias + +from ._accelerometer import Accelerometer +from ._anemometer import Anemometer +from ._barometer import Barometer +from ._general_sensor import GeneralSensor +from ._hygrometer import Hygrometer +from ._problematic import ( + MismatchedCategorySensor, + UnrecognizedSensor, + UnspecifiedSensor, +) +from ._pyranometer import Pyranometer +from ._thermometer import Thermometer + +ProblematicSensorTypes: TypeAlias = ( + UnrecognizedSensor | UnspecifiedSensor | MismatchedCategorySensor +) +"""All possible sensor types that have a problem.""" + +SensorTypes: TypeAlias = ( + Accelerometer + | Anemometer + | Barometer + | GeneralSensor + | Hygrometer + | ProblematicSensorTypes + | Pyranometer + | Thermometer +) +"""All possible sensor types.""" diff --git a/tests/sensor/__init__.py b/tests/sensor/__init__.py new file mode 100644 index 00000000..dc03cac6 --- /dev/null +++ b/tests/sensor/__init__.py @@ -0,0 +1 @@ +"""Tests for components.""" diff --git a/tests/sensor/test_accelerometer.py b/tests/sensor/test_accelerometer.py new file mode 100644 index 00000000..682fd009 --- /dev/null +++ b/tests/sensor/test_accelerometer.py @@ -0,0 +1,24 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Accelerometer sensor.""" + +from frequenz.client.microgrid import SensorId +from frequenz.client.microgrid.sensor import Accelerometer, SensorCategory + + +def test_init() -> None: + """Test Accelerometer sensor initialization.""" + sensor_id = SensorId(1) + sensor = Accelerometer( + id=sensor_id, + name="test_accelerometer", + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert sensor.id == sensor_id + assert sensor.name == "test_accelerometer" + assert sensor.manufacturer == "test_manufacturer" + assert sensor.model_name == "test_model" + assert sensor.category == SensorCategory.ACCELEROMETER diff --git a/tests/sensor/test_anemometer.py b/tests/sensor/test_anemometer.py new file mode 100644 index 00000000..2110f387 --- /dev/null +++ b/tests/sensor/test_anemometer.py @@ -0,0 +1,24 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Anemometer sensor.""" + +from frequenz.client.microgrid import SensorId +from frequenz.client.microgrid.sensor import Anemometer, SensorCategory + + +def test_init() -> None: + """Test Anemometer sensor initialization.""" + sensor_id = SensorId(1) + sensor = Anemometer( + id=sensor_id, + name="test_anemometer", + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert sensor.id == sensor_id + assert sensor.name == "test_anemometer" + assert sensor.manufacturer == "test_manufacturer" + assert sensor.model_name == "test_model" + assert sensor.category == SensorCategory.ANEMOMETER diff --git a/tests/sensor/test_barometer.py b/tests/sensor/test_barometer.py new file mode 100644 index 00000000..9db28ee3 --- /dev/null +++ b/tests/sensor/test_barometer.py @@ -0,0 +1,24 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Barometer sensor.""" + +from frequenz.client.microgrid import SensorId +from frequenz.client.microgrid.sensor import Barometer, SensorCategory + + +def test_init() -> None: + """Test Barometer sensor initialization.""" + sensor_id = SensorId(1) + sensor = Barometer( + id=sensor_id, + name="test_barometer", + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert sensor.id == sensor_id + assert sensor.name == "test_barometer" + assert sensor.manufacturer == "test_manufacturer" + assert sensor.model_name == "test_model" + assert sensor.category == SensorCategory.BAROMETER diff --git a/tests/sensor/test_base.py b/tests/sensor/test_base.py new file mode 100644 index 00000000..52b5edff --- /dev/null +++ b/tests/sensor/test_base.py @@ -0,0 +1,196 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Sensor base class and its functionality.""" + +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest + +from frequenz.client.microgrid import Lifetime, SensorId +from frequenz.client.microgrid.sensor._base import Sensor +from frequenz.client.microgrid.sensor._category import SensorCategory + + +# Test sensor subclass +class _FakeSensor(Sensor): + """A simple sensor implementation for testing.""" + + +def test_creation() -> None: + """Test that Sensor base class cannot be instantiated directly.""" + with pytest.raises(TypeError, match="Cannot instantiate Sensor directly"): + _ = Sensor( + id=SensorId(1), + category=SensorCategory.UNSPECIFIED, + ) + + +@pytest.mark.parametrize( + "name,expected_str", + [(None, "SID1<_FakeSensor>"), ("test-sensor", "SID1<_FakeSensor>:test-sensor")], + ids=["no-name", "with-name"], +) +def test_str(name: str | None, expected_str: str) -> None: + """Test string representation of a sensor.""" + sensor = _FakeSensor( + id=SensorId(1), + category=SensorCategory.UNSPECIFIED, + name=name, + ) + assert str(sensor) == expected_str + + +def test_metadata() -> None: + """Test sensor metadata fields.""" + sensor = _FakeSensor( + id=SensorId(1), + category=SensorCategory.UNSPECIFIED, + name="test-sensor", + manufacturer="Test Manufacturer", + model_name="Test Model", + ) + + assert sensor.name == "test-sensor" + assert sensor.manufacturer == "Test Manufacturer" + assert sensor.model_name == "Test Model" + + +def test_default_values() -> None: + """Test sensor default values.""" + sensor = _FakeSensor( + id=SensorId(1), + category=SensorCategory.UNSPECIFIED, + ) + + assert sensor.name is None + assert sensor.manufacturer is None + assert sensor.model_name is None + assert sensor.operational_lifetime == Lifetime() + + +@pytest.mark.parametrize("lifetime_active", [True, False], ids=["active", "inactive"]) +def test_active_at(lifetime_active: bool, caplog: pytest.LogCaptureFixture) -> None: + """Test active_at behavior with different status and lifetime combinations.""" + caplog.set_level("WARNING") + + mock_lifetime = Mock(spec=Lifetime) + mock_lifetime.active_at.return_value = lifetime_active + + sensor = _FakeSensor( + id=SensorId(1), + category=SensorCategory.UNSPECIFIED, + operational_lifetime=mock_lifetime, + ) + + test_time = datetime.now(timezone.utc) + + expected = lifetime_active + assert sensor.active_at(test_time) == expected + + mock_lifetime.active_at.assert_called_once_with(test_time) + + +def test_active() -> None: + """Test that active property uses active_at with current time.""" + fixed_now = datetime.now(timezone.utc) + mock_lifetime = Mock(spec=Lifetime) + mock_lifetime.active_at.return_value = True + + with patch("frequenz.client.microgrid.sensor._base.datetime") as mock_datetime: + mock_datetime.now.return_value = fixed_now + sensor = _FakeSensor( + id=SensorId(1), + category=SensorCategory.UNSPECIFIED, + operational_lifetime=mock_lifetime, + ) + + assert sensor.active is True + + mock_lifetime.active_at.assert_called_once_with(fixed_now) + + +SENSOR = _FakeSensor( + id=SensorId(1), + category=SensorCategory.UNSPECIFIED, + name="test", + manufacturer="Test Mfg", + model_name="Model A", +) + +DIFFERENT_NAME = _FakeSensor( + id=SENSOR.id, + category=SENSOR.category, + name="different", + manufacturer=SENSOR.manufacturer, + model_name=SENSOR.model_name, +) + +DIFFERENT_ID = _FakeSensor( + id=SensorId(2), + category=SENSOR.category, + name=SENSOR.name, + manufacturer=SENSOR.manufacturer, + model_name=SENSOR.model_name, +) + +DIFFERENT_BOTH_ID = _FakeSensor( + id=SensorId(2), + category=SENSOR.category, + 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"), + pytest.param(DIFFERENT_BOTH_ID, False, id="other-both-ids"), + ], + ids=lambda o: str(o.id) if isinstance(o, Sensor) else str(o), +) +def test_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"), + pytest.param(DIFFERENT_BOTH_ID, False, id="other-both-ids"), + ], +) +def test_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.param(DIFFERENT_BOTH_ID, id="both_ids"), +] + + +@pytest.mark.parametrize("comp1", ALL_SENSORS_PARAMS) +@pytest.mark.parametrize("comp2", ALL_SENSORS_PARAMS) +def test_hash(comp1: Sensor, comp2: Sensor) -> None: + """Test that the 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) diff --git a/tests/sensor/test_data.py b/tests/sensor/test_data.py new file mode 100644 index 00000000..9b1b1180 --- /dev/null +++ b/tests/sensor/test_data.py @@ -0,0 +1,93 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Sample class and related classes.""" + +from datetime import datetime, timezone +from typing import Any + +import pytest + +from frequenz.client.microgrid.metrics import ( + AggregatedMetricValue, + AggregationMethod, +) +from frequenz.client.microgrid.sensor import SensorMetric, SensorMetricSample + + +@pytest.fixture +def now() -> datetime: + """Get the current time.""" + return datetime.now(timezone.utc) + + +@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/sensor/test_data_proto.py b/tests/sensor/test_data_proto.py new file mode 100644 index 00000000..ca511629 --- /dev/null +++ b/tests/sensor/test_data_proto.py @@ -0,0 +1,365 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for protobuf conversion of sensor data objects.""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone + +import pytest +from frequenz.api.microgrid import common_pb2, microgrid_pb2, sensor_pb2 +from frequenz.client.base import conversion + +from frequenz.client.microgrid._id import SensorId +from frequenz.client.microgrid.sensor._data import ( + SensorDataSamples, + SensorErrorCode, + SensorMetric, + SensorMetricSample, + SensorStateCode, + SensorStateSample, +) +from frequenz.client.microgrid.sensor._data_proto import ( + sensor_data_samples_from_proto, + sensor_metric_sample_from_proto, + sensor_state_sample_from_proto, +) + + +@pytest.fixture +def now() -> datetime: + """Return a fixed datetime object for testing.""" + return datetime.now(timezone.utc) + + +@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_component_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_component_data, case.filter_metrics_pb_values + ) + + assert isinstance(result, SensorDataSamples) + assert result.sensor_id == SensorId(sensor_id_val) + assert len(result.metrics) == case.expected_metrics_count + + if case.expected_metrics_count > 0 and case.expected_first_metric_details: + expected_sample = SensorMetricSample( + sampled_at=now, + metric=case.expected_first_metric_details[0], + value=case.expected_first_metric_details[1], + ) + # Basic check of the first metric, assumes order and content correctness + # More comprehensive checks could iterate through all expected metrics. + assert result.metrics[0] == expected_sample + for metric_sample in result.metrics: + assert metric_sample.sampled_at == now + + # Check state part + assert len(result.states) == 1 + state_sample = result.states[0] + assert isinstance(state_sample, SensorStateSample) + assert state_sample.sampled_at == now + assert state_sample.states == frozenset([case.expected_state_code]) + assert state_sample.errors == case.expected_errors_set + assert state_sample.warnings == case.expected_warnings_set diff --git a/tests/sensor/test_general_sensor.py b/tests/sensor/test_general_sensor.py new file mode 100644 index 00000000..a25a550c --- /dev/null +++ b/tests/sensor/test_general_sensor.py @@ -0,0 +1,24 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for GeneralSensor sensor.""" + +from frequenz.client.microgrid import SensorId +from frequenz.client.microgrid.sensor import GeneralSensor, SensorCategory + + +def test_init() -> None: + """Test GeneralSensor sensor initialization.""" + sensor_id = SensorId(1) + sensor = GeneralSensor( + id=sensor_id, + name="test_general_sensor", + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert sensor.id == sensor_id + assert sensor.name == "test_general_sensor" + assert sensor.manufacturer == "test_manufacturer" + assert sensor.model_name == "test_model" + assert sensor.category == SensorCategory.GENERAL diff --git a/tests/sensor/test_hygrometer.py b/tests/sensor/test_hygrometer.py new file mode 100644 index 00000000..1cdb40e4 --- /dev/null +++ b/tests/sensor/test_hygrometer.py @@ -0,0 +1,24 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Hygrometer sensor.""" + +from frequenz.client.microgrid import SensorId +from frequenz.client.microgrid.sensor import Hygrometer, SensorCategory + + +def test_init() -> None: + """Test Hygrometer sensor initialization.""" + sensor_id = SensorId(1) + sensor = Hygrometer( + id=sensor_id, + name="test_hygrometer", + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert sensor.id == sensor_id + assert sensor.name == "test_hygrometer" + assert sensor.manufacturer == "test_manufacturer" + assert sensor.model_name == "test_model" + assert sensor.category == SensorCategory.HYGROMETER diff --git a/tests/sensor/test_proto.py b/tests/sensor/test_proto.py new file mode 100644 index 00000000..1b4b3459 --- /dev/null +++ b/tests/sensor/test_proto.py @@ -0,0 +1,350 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for protobuf conversion of sensor objects.""" + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import assert_never +from unittest.mock import Mock, patch + +import pytest +from frequenz.api.common import components_pb2 +from frequenz.api.microgrid import microgrid_pb2, sensor_pb2 + +from frequenz.client.microgrid import ComponentCategory, Lifetime, SensorId +from frequenz.client.microgrid.sensor import Accelerometer, SensorCategory +from frequenz.client.microgrid.sensor._proto import ( + sensor_from_proto, + sensor_from_proto_with_issues, +) + +_BAD_COMPONENT_CATEGORY_PB = components_pb2.ComponentCategory.COMPONENT_CATEGORY_CHP +_BAD_COMPONENT_CATEGORY = ComponentCategory.CHP + + +@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.""" + + has_name: bool = True + """Whether to include name in the protobuf message.""" + + has_manufacturer: bool = True + """Whether to include manufacturer in the protobuf message.""" + + has_model_name: bool = True + """Whether to include model name in the protobuf message.""" + + category: SensorCategory | int = SensorCategory.ACCELEROMETER + """The sensor category to set.""" + + has_mismatched_category: bool = False + """Whether to include mismatched category in the protobuf message.""" + + has_sensor_metadata: bool = True + """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.""" + + +@pytest.fixture +def sensor_id() -> SensorId: + """Provide a test sensor ID.""" + return SensorId(42) + + +# pylintt: disable=too-many-arguments,too-many-positional-arguments + + +@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="Sensor", spec=microgrid_pb2.Component) + mock_sensor = Mock(name="Accelerometer", spec=Accelerometer) + captured_major_issues: list[str] | None = None + captured_minor_issues: list[str] | None = None + + def _fake_sensor_from_proto_with_issues( + message: microgrid_pb2.Component, # pylint: disable=unused-argument + major_issues: list[str], + minor_issues: list[str], + ) -> Accelerometer: + """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 + + +@patch("frequenz.client.microgrid.sensor._proto.UnspecifiedSensor") +@patch("frequenz.client.microgrid.sensor._proto.UnrecognizedSensor") +@patch("frequenz.client.microgrid.sensor._proto.MismatchedCategorySensor") +@patch("frequenz.client.microgrid.sensor._proto.Accelerometer") +@patch("frequenz.client.microgrid.sensor._proto.Anemometer") +@patch("frequenz.client.microgrid.sensor._proto.Barometer") +@patch("frequenz.client.microgrid.sensor._proto.GeneralSensor") +@patch("frequenz.client.microgrid.sensor._proto.Hygrometer") +@patch("frequenz.client.microgrid.sensor._proto.Pyranometer") +@patch("frequenz.client.microgrid.sensor._proto.Thermometer") +@pytest.mark.parametrize( + "case", + [ + _SensorTestCase( + test_id="complete", + ), + _SensorTestCase( + test_id="missing_metadata", + has_name=False, + has_manufacturer=False, + has_model_name=False, + expected_minor_issues=[ + "name is empty", + "manufacturer is empty", + "model_name is empty", + ], + ), + _SensorTestCase( + test_id="unspecified_category", + category=SensorCategory.UNSPECIFIED, + expected_major_issues=["category is unspecified"], + ), + _SensorTestCase( + test_id="unrecognized_category", + category=999, + expected_major_issues=["category is unrecognized"], + ), + _SensorTestCase( + test_id="missing_sensor_metadata", + category=SensorCategory.UNSPECIFIED, + has_sensor_metadata=False, + expected_major_issues=[ + "wrong sensor metadata (None)", + ], + ), + _SensorTestCase( + test_id="category_mismatch", + category=SensorCategory.UNSPECIFIED, + has_sensor_metadata=False, + has_mismatched_category=True, + expected_major_issues=[ + f"unexpected category for sensor ({_BAD_COMPONENT_CATEGORY_PB})", + "wrong sensor metadata (None)", + ], + ), + _SensorTestCase( + test_id="accelerometer", + category=SensorCategory.ACCELEROMETER, + ), + _SensorTestCase( + test_id="anemometer", + category=SensorCategory.ANEMOMETER, + ), + _SensorTestCase( + test_id="barometer", + category=SensorCategory.BAROMETER, + ), + _SensorTestCase( + test_id="general_sensor", + category=SensorCategory.GENERAL, + ), + _SensorTestCase( + test_id="hygrometer", + category=SensorCategory.HYGROMETER, + ), + _SensorTestCase( + test_id="pyranometer", + category=SensorCategory.PYRANOMETER, + ), + _SensorTestCase( + test_id="thermometer", + category=SensorCategory.THERMOMETER, + ), + ], + ids=lambda case: case.test_id, +) +# pylint: disable-next=too-many-locals,too-many-arguments,too-many-positional-arguments +def test_component_from_proto_with_issues( + mock_thermometer: Mock, + mock_pyranometer: Mock, + mock_hygrometer: Mock, + mock_general_sensor: Mock, + mock_barometer: Mock, + mock_anemometer: Mock, + mock_accelerometer: Mock, + mock_mismatched_category: Mock, + mock_unrecognized: Mock, + mock_unspecified: Mock, + case: _SensorTestCase, + sensor_id: SensorId, +) -> None: + """Test component conversion with metadata matching check.""" + major_issues: list[str] = [] + minor_issues: list[str] = [] + + proto = microgrid_pb2.Component( + id=int(sensor_id), + category=( + _BAD_COMPONENT_CATEGORY_PB + if case.has_mismatched_category + else components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR + ), + ) + + if case.has_name: + proto.name = "test_component" + if case.has_manufacturer: + proto.manufacturer = "test_manufacturer" + if case.has_model_name: + proto.model_name = "test_model" + if case.has_sensor_metadata: + proto.sensor.CopyFrom( + sensor_pb2.Metadata( + type=( + case.category.value # type: ignore[arg-type] + if isinstance(case.category, SensorCategory) + else case.category + ) + ) + ) + + _ = 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) + + if not case.has_sensor_metadata: + if case.has_mismatched_category: + mock_mismatched_category.assert_called_once_with( + id=sensor_id, + name=proto.name or None, + manufacturer=proto.manufacturer or None, + model_name=proto.model_name or None, + category=case.category, + component_category=_BAD_COMPONENT_CATEGORY, + operational_lifetime=Lifetime(), + ) + return + mock_unspecified.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(), + ) + + match case.category: + case SensorCategory.UNSPECIFIED: + mock_unspecified.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(), + ) + case SensorCategory.ACCELEROMETER: + mock_accelerometer.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(), + ) + case SensorCategory.ANEMOMETER: + mock_anemometer.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(), + ) + case SensorCategory.BAROMETER: + mock_barometer.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(), + ) + case SensorCategory.GENERAL: + mock_general_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(), + ) + case SensorCategory.HYGROMETER: + mock_hygrometer.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(), + ) + case SensorCategory.PYRANOMETER: + mock_pyranometer.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(), + ) + case SensorCategory.THERMOMETER: + mock_thermometer.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(), + ) + case int(): + mock_unrecognized.assert_called_once_with( + id=sensor_id, + category=case.category, + name=proto.name or None, + manufacturer=proto.manufacturer or None, + model_name=proto.model_name or None, + operational_lifetime=Lifetime(), + ) + case unhandled: + assert_never(unhandled) diff --git a/tests/sensor/test_pyranometer.py b/tests/sensor/test_pyranometer.py new file mode 100644 index 00000000..cbfce01a --- /dev/null +++ b/tests/sensor/test_pyranometer.py @@ -0,0 +1,24 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Pyranometer sensor.""" + +from frequenz.client.microgrid import SensorId +from frequenz.client.microgrid.sensor import Pyranometer, SensorCategory + + +def test_init() -> None: + """Test Pyranometer sensor initialization.""" + sensor_id = SensorId(1) + sensor = Pyranometer( + id=sensor_id, + name="test_pyranometer", + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert sensor.id == sensor_id + assert sensor.name == "test_pyranometer" + assert sensor.manufacturer == "test_manufacturer" + assert sensor.model_name == "test_model" + assert sensor.category == SensorCategory.PYRANOMETER diff --git a/tests/sensor/test_thermometer.py b/tests/sensor/test_thermometer.py new file mode 100644 index 00000000..d8075dea --- /dev/null +++ b/tests/sensor/test_thermometer.py @@ -0,0 +1,24 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for Thermometer sensor.""" + +from frequenz.client.microgrid import SensorId +from frequenz.client.microgrid.sensor import SensorCategory, Thermometer + + +def test_init() -> None: + """Test Thermometer sensor initialization.""" + sensor_id = SensorId(1) + sensor = Thermometer( + id=sensor_id, + name="test_thermometer", + manufacturer="test_manufacturer", + model_name="test_model", + ) + + assert sensor.id == sensor_id + assert sensor.name == "test_thermometer" + assert sensor.manufacturer == "test_manufacturer" + assert sensor.model_name == "test_model" + assert sensor.category == SensorCategory.THERMOMETER diff --git a/tests/test_client.py b/tests/test_client.py index a6a3e960..b96e5257 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -3,17 +3,21 @@ """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 contextlib import AsyncExitStack +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 ( ApiClientError, @@ -30,6 +34,20 @@ InverterType, MeterData, MicrogridApiClient, + MicrogridId, + SensorId, +) +from frequenz.client.microgrid.sensor import ( + Accelerometer, + Anemometer, + Hygrometer, + MismatchedCategorySensor, + Pyranometer, + SensorDataSamples, + SensorMetric, + SensorMetricSample, + SensorStateCode, + SensorStateSample, ) @@ -46,14 +64,23 @@ def __init__(self, *, retry_strategy: retry.Strategy | None = None) -> None: mock_stub.SetPowerReactive = mock.AsyncMock("SetPowerReactive") mock_stub.AddInclusionBounds = mock.AsyncMock("AddInclusionBounds") mock_stub.StreamComponentData = mock.Mock("StreamComponentData") + mock_stub.GetMicrogridMetadata = mock.AsyncMock("GetMicrogridMetadata") super().__init__("grpc://mock_host:1234", retry_strategy=retry_strategy) self.mock_stub = mock_stub self._stub = mock_stub # pylint: disable=protected-access -async def test_components() -> None: +@pytest.fixture +async def client() -> AsyncIterator[_TestClient]: + """Return a test client.""" + async with _TestClient( + retry_strategy=retry.LinearBackoff(interval=0.0, jitter=0.0, limit=6) + ) as client_instance: + yield client_instance + + +async def test_components(client: _TestClient) -> None: """Test the components() method.""" - client = _TestClient() server_response = microgrid_pb2.ComponentList() client.mock_stub.ListComponents.return_value = server_response assert set(await client.components()) == set() @@ -212,9 +239,8 @@ async def test_components() -> None: } -async def test_components_grpc_error() -> None: +async def test_components_grpc_error(client: _TestClient) -> None: """Test the components() method when the gRPC call fails.""" - client = _TestClient() client.mock_stub.ListComponents.side_effect = grpc.aio.AioRpcError( mock.MagicMock(name="mock_status"), mock.MagicMock(name="mock_initial_metadata"), @@ -231,9 +257,8 @@ async def test_components_grpc_error() -> None: await client.components() -async def test_connections() -> None: +async def test_connections(client: _TestClient) -> None: """Test the connections() method.""" - client = _TestClient() def assert_filter(*, starts: set[int], ends: set[int]) -> None: client.mock_stub.ListConnections.assert_called_once() @@ -370,9 +395,8 @@ def assert_filter(*, starts: set[int], ends: set[int]) -> None: assert_filter(starts={1, 2, 4}, ends={4, 5, 6}) -async def test_connections_grpc_error() -> None: +async def test_connections_grpc_error(client: _TestClient) -> None: """Test the components() method when the gRPC call fails.""" - client = _TestClient() client.mock_stub.ListConnections.side_effect = grpc.aio.AioRpcError( mock.MagicMock(name="mock_status"), mock.MagicMock(name="mock_initial_metadata"), @@ -389,6 +413,169 @@ async def test_connections_grpc_error() -> None: await client.connections() +async def test_metadata_success(client: _TestClient) -> None: + """Test the metadata() method with a successful gRPC call.""" + mock_metadata_response = microgrid_pb2.MicrogridMetadata( + microgrid_id=123, + location=microgrid_pb2.Location(latitude=40.7128, longitude=-74.0060), + ) + client.mock_stub.GetMicrogridMetadata.return_value = mock_metadata_response + + metadata = await client.metadata() + + assert metadata.microgrid_id == MicrogridId(123) + assert metadata.location is not None + assert metadata.location.latitude == pytest.approx(40.7128) + assert metadata.location.longitude == pytest.approx(-74.0060) + client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60) + + +async def test_metadata_no_location(client: _TestClient) -> None: + """Test the metadata() method when location is not set in the response.""" + mock_metadata_response = microgrid_pb2.MicrogridMetadata(microgrid_id=456) + client.mock_stub.GetMicrogridMetadata.return_value = mock_metadata_response + + metadata = await client.metadata() + + assert metadata.microgrid_id == MicrogridId(456) + assert metadata.location is None + client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60) + + +async def test_metadata_empty_response(client: _TestClient) -> None: + """Test the metadata() method when the server returns an empty response.""" + client.mock_stub.GetMicrogridMetadata.return_value = None + + metadata = await client.metadata() + + assert metadata.microgrid_id is None + assert metadata.location is None + client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60) + + +async def test_metadata_grpc_error( + client: _TestClient, caplog: pytest.LogCaptureFixture +) -> None: + """Test the metadata() method when the gRPC call fails.""" + caplog.set_level(logging.WARNING) + client.mock_stub.GetMicrogridMetadata.side_effect = grpc.aio.AioRpcError( + mock.MagicMock(name="mock_status"), + mock.MagicMock(name="mock_initial_metadata"), + mock.MagicMock(name="mock_trailing_metadata"), + "fake grpc details for metadata", + "fake grpc debug_error_string for metadata", + ) + + metadata = await client.metadata() + + assert metadata.microgrid_id is None + assert metadata.location is None + client.mock_stub.GetMicrogridMetadata.assert_called_once_with(Empty(), timeout=60) + assert len(caplog.records) == 1 + assert caplog.records[0].levelname == "ERROR" + assert "The microgrid metadata is not available." in caplog.records[0].message + assert caplog.records[0].exc_text is not None + assert "fake grpc details for metadata" in caplog.records[0].exc_text + + +async def test_list_sensors(client: _TestClient) -> None: + """Test the list_sensors() method.""" + server_response = microgrid_pb2.ComponentList() + client.mock_stub.ListComponents.return_value = server_response + assert set(await client.list_sensors()) == set() + + # Add a sensor + sensor_component = microgrid_pb2.Component( + id=201, + category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR, + sensor=sensor_pb2.Metadata( + type=components_pb2.SensorType.SENSOR_TYPE_ACCELEROMETER, + ), + ) + server_response.components.append(sensor_component) + assert set(await client.list_sensors()) == { + Accelerometer(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()) == { + Accelerometer(id=SensorId(201)), + Hygrometer(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()) == { + Accelerometer(id=SensorId(201)), + Hygrometer(id=SensorId(202)), + MismatchedCategorySensor( + id=SensorId(203), + component_category=ComponentCategory.METER, + ), + } + # 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()) == { + Anemometer(id=SensorId(204)), + Pyranometer(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.""" @@ -421,21 +608,33 @@ 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"]) -async def test_data_component_not_found(method: str) -> None: +async def test_data_component_not_found(method: str, client: _TestClient) -> None: """Test the meter_data() method.""" - client = _TestClient() client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList() # It should raise a ValueError for a missing component_id @@ -456,9 +655,9 @@ async def test_data_bad_category( method: str, component_id: ComponentId, component_list: list[microgrid_pb2.Component], + client: _TestClient, ) -> None: """Test the meter_data() method.""" - client = _TestClient() client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( components=component_list ) @@ -484,9 +683,9 @@ async def test_component_data( component_id: ComponentId, component_class: type[ComponentData], component_list: list[microgrid_pb2.Component], + client: _TestClient, ) -> None: """Test the meter_data() method.""" - client = _TestClient() client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( components=component_list ) @@ -498,13 +697,9 @@ async def stream_data( client.mock_stub.StreamComponentData.side_effect = stream_data receiver = await getattr(client, method)(component_id) - async with AsyncExitStack() as stack: - stack.push_async_callback( - client._broadcasters[component_id].stop # pylint: disable=protected-access - ) - latest = await receiver.receive() - assert isinstance(latest, component_class) - assert latest.component_id == component_id + latest = await receiver.receive() + assert isinstance(latest, component_class) + assert latest.component_id == component_id @pytest.mark.parametrize( @@ -516,18 +711,17 @@ async def stream_data( ("ev_charger_data", ComponentId(101), EVChargerData), ], ) +# pylint: disable-next=too-many-arguments,too-many-positional-arguments async def test_component_data_grpc_error( method: str, component_id: ComponentId, component_class: type[ComponentData], component_list: list[microgrid_pb2.Component], caplog: pytest.LogCaptureFixture, + client: _TestClient, ) -> None: """Test the components() method when the gRPC call fails.""" caplog.set_level(logging.WARNING) - client = _TestClient( - retry_strategy=retry.LinearBackoff(interval=0.0, jitter=0.0, limit=6) - ) client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( components=component_list ) @@ -551,21 +745,17 @@ async def stream_data( client.mock_stub.StreamComponentData.side_effect = stream_data receiver = await getattr(client, method)(component_id) - async with AsyncExitStack() as stack: - stack.push_async_callback( - client._broadcasters[component_id].stop # pylint: disable=protected-access - ) - latest = await receiver.receive() - assert isinstance(latest, component_class) - assert latest.component_id == component_id + latest = await receiver.receive() + assert isinstance(latest, component_class) + assert latest.component_id == component_id - latest = await receiver.receive() - assert isinstance(latest, component_class) - assert latest.component_id == component_id + latest = await receiver.receive() + assert isinstance(latest, component_class) + assert latest.component_id == component_id - latest = await receiver.receive() - assert isinstance(latest, component_class) - assert latest.component_id == component_id + latest = await receiver.receive() + assert isinstance(latest, component_class) + assert latest.component_id == component_id # This is not super portable, it will change if the GrpcStreamBroadcaster changes, # but without this there isn't much to check by this test. @@ -584,9 +774,10 @@ async def stream_data( @pytest.mark.parametrize("power_w", [0, 0.0, 12, -75, 0.1, -0.0001, 134.0]) -async def test_set_power_ok(power_w: float, meter83: microgrid_pb2.Component) -> None: +async def test_set_power_ok( + power_w: float, meter83: microgrid_pb2.Component, client: _TestClient +) -> None: """Test if charge is able to charge component.""" - client = _TestClient() client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( components=[meter83] ) @@ -600,9 +791,8 @@ async def test_set_power_ok(power_w: float, meter83: microgrid_pb2.Component) -> ) -async def test_set_power_grpc_error() -> None: +async def test_set_power_grpc_error(client: _TestClient) -> None: """Test set_power() raises ApiClientError when the gRPC call fails.""" - client = _TestClient() client.mock_stub.SetPowerActive.side_effect = grpc.aio.AioRpcError( mock.MagicMock(name="mock_status"), mock.MagicMock(name="mock_initial_metadata"), @@ -624,10 +814,9 @@ async def test_set_power_grpc_error() -> None: [0, 0.0, 12, -75, 0.1, -0.0001, 134.0], ) async def test_set_reactive_power_ok( - reactive_power_var: float, meter83: microgrid_pb2.Component + reactive_power_var: float, meter83: microgrid_pb2.Component, client: _TestClient ) -> None: """Test if charge is able to charge component.""" - client = _TestClient() client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( components=[meter83] ) @@ -643,9 +832,8 @@ async def test_set_reactive_power_ok( ) -async def test_set_reactive_power_grpc_error() -> None: +async def test_set_reactive_power_grpc_error(client: _TestClient) -> None: """Test set_power() raises ApiClientError when the gRPC call fails.""" - client = _TestClient() client.mock_stub.SetPowerReactive.side_effect = grpc.aio.AioRpcError( mock.MagicMock(name="mock_status"), mock.MagicMock(name="mock_initial_metadata"), @@ -675,10 +863,9 @@ async def test_set_reactive_power_grpc_error() -> None: ids=str, ) async def test_set_bounds_ok( - bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component + bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component, client: _TestClient ) -> None: """Test if charge is able to charge component.""" - client = _TestClient() client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( components=[inverter99] ) @@ -704,10 +891,9 @@ async def test_set_bounds_ok( ids=str, ) async def test_set_bounds_fail( - bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component + bounds: metrics_pb2.Bounds, inverter99: microgrid_pb2.Component, client: _TestClient ) -> None: """Test if charge is able to charge component.""" - client = _TestClient() client.mock_stub.ListComponents.return_value = microgrid_pb2.ComponentList( components=[inverter99] ) @@ -717,9 +903,8 @@ async def test_set_bounds_fail( client.mock_stub.AddInclusionBounds.assert_not_called() -async def test_set_bounds_grpc_error() -> None: - """Test the components() method when the gRPC call fails.""" - client = _TestClient() +async def test_set_bounds_grpc_error(client: _TestClient) -> None: + """Test set_bounds() raises ApiClientError when the gRPC call fails.""" client.mock_stub.AddInclusionBounds.side_effect = grpc.aio.AioRpcError( mock.MagicMock(name="mock_status"), mock.MagicMock(name="mock_initial_metadata"), @@ -736,6 +921,97 @@ async def test_set_bounds_grpc_error() -> 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_id.py b/tests/test_id.py index ff5476ab..541995de 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 import ComponentId, MicrogridId, SensorId @dataclass(frozen=True) @@ -23,6 +23,7 @@ class IdTypeInfo: ID_TYPES: list[IdTypeInfo] = [ IdTypeInfo(MicrogridId, "MID", "Microgrid"), IdTypeInfo(ComponentId, "CID", "Component"), + IdTypeInfo(SensorId, "SID", "Sensor"), ] diff --git a/tests/test_lifetime.py b/tests/test_lifetime.py new file mode 100644 index 00000000..98a7c6c8 --- /dev/null +++ b/tests/test_lifetime.py @@ -0,0 +1,322 @@ +# 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_active: bool + """The expected active 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_active: bool + """The expected active 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_active: bool + """The expected active 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_active=True, + ), + _LifetimeTestCase( + name="only_start", + start=True, + end=False, + expected_start=True, + expected_end=False, + expected_active=True, + ), + _LifetimeTestCase( + name="only_end", + start=False, + end=True, + expected_start=False, + expected_end=True, + expected_active=True, + ), + _LifetimeTestCase( + name="no_dates", + start=False, + end=False, + expected_start=False, + expected_end=False, + expected_active=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.active == case.expected_active + + +@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. + + Args: + past: Past datetime fixture + now: Current datetime fixture + future: Future datetime fixture + start: Start time type + end: End time type + """ + 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_active=True, + ), + _ActivityTestCase( + name="past_start-future_end", + start_type=_Time.PAST, + end_type=_Time.FUTURE, + expected_active=True, + ), + _ActivityTestCase( + name="future_start-no_end", + start_type=_Time.FUTURE, + end_type=None, + expected_active=False, + ), + _ActivityTestCase( + name="past_start-past_end", + start_type=_Time.PAST, + end_type=_Time.PAST, + expected_active=False, + ), + _ActivityTestCase( + name="now_start-no_end", + start_type=_Time.NOW, + end_type=None, + expected_active=True, + ), + _ActivityTestCase( + name="no_start-now_end", + start_type=None, + end_type=_Time.NOW, + expected_active=True, + ), + _ActivityTestCase( + name="now_start-now_end", + start_type=_Time.NOW, + end_type=_Time.NOW, + expected_active=True, + ), + ], + ids=lambda case: case.name, +) +def test_active_property( + past: datetime, future: datetime, now: datetime, case: _ActivityTestCase +) -> None: + """Test the active property of Lifetime. + + Args: + past: Past datetime fixture + future: Future datetime fixture + now: Current datetime fixture + case: Test case parameters + """ + 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.active_at(now) == case.expected_active + + +@pytest.mark.parametrize( + "case", + [ + _FixedLifetimeTestCase(name="past", test_time=_Time.PAST, expected_active=True), + _FixedLifetimeTestCase(name="now", test_time=_Time.NOW, expected_active=True), + _FixedLifetimeTestCase( + name="future", test_time=_Time.FUTURE, expected_active=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. + + Tests a lifetime from past to future and checks activity at different points: + - Past timestamp: should be active + - Current timestamp: should be active + - Future timestamp (equal to end): should be active (inclusive) + + Args: + past: Past datetime fixture + future: Future datetime fixture + now: Current datetime fixture + case: Test case parameters + """ + lifetime = Lifetime(start=past, end=future) + test_time = { + _Time.PAST: past, + _Time.NOW: now, + _Time.FUTURE: future, + }[case.test_time] + + assert lifetime.active_at(test_time) == case.expected_active diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 00000000..413a9ac3 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,82 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Sample class and related classes.""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone + +import pytest + +from frequenz.client.microgrid.metrics import ( + AggregatedMetricValue, + AggregationMethod, +) + + +@dataclass(frozen=True, kw_only=True) +class AggregatedValueTestCase: + """Test case for AggregatedMetricValue protobuf conversion.""" + + name: str + """The description of the test case.""" + + avg_value: float + """The average value to set.""" + + has_min: bool = True + """Whether to include min value.""" + + has_max: bool = True + """Whether to include max value.""" + + min_value: float | None = None + """The minimum value to set.""" + + max_value: float | None = None + """The maximum value to set.""" + + raw_values: list[float] = field(default_factory=list) + """The raw values to include.""" + + +@pytest.fixture +def now() -> datetime: + """Get the current time.""" + return datetime.now(timezone.utc) + + +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"