From ce1468985578e5448f2838e676839575458c45d0 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 18 Dec 2025 12:09:22 +0100 Subject: [PATCH 1/8] Fix typos in client method docstrings Signed-off-by: Leandro Lucarella --- src/frequenz/client/microgrid/_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/frequenz/client/microgrid/_client.py b/src/frequenz/client/microgrid/_client.py index d7de556..fc29861 100644 --- a/src/frequenz/client/microgrid/_client.py +++ b/src/frequenz/client/microgrid/_client.py @@ -213,7 +213,7 @@ async def list_components( # noqa: DOC502 (raises ApiClientError indirectly) Iterator whose elements are all the components in the local microgrid. Raises: - ApiClientError: If the are any errors communicating with the Microgrid API, + ApiClientError: If there are any errors communicating with the Microgrid API, most likely a subclass of [GrpcError][frequenz.client.microgrid.GrpcError]. """ @@ -269,7 +269,7 @@ async def list_connections( # noqa: DOC502 (raises ApiClientError indirectly) Iterator whose elements are all the connections in the local microgrid. Raises: - ApiClientError: If the are any errors communicating with the Microgrid API, + ApiClientError: If there are any errors communicating with the Microgrid API, most likely a subclass of [GrpcError][frequenz.client.microgrid.GrpcError]. """ From 5e280b8455547b1502d360b868f471a492d10cb3 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 20 Nov 2025 16:26:07 +0000 Subject: [PATCH 2/8] Add `Sensor` dataclass and proto conversion for sensor metadata Signed-off-by: Leandro Lucarella --- .../client/microgrid/sensor/__init__.py | 15 ++ .../client/microgrid/sensor/_sensor.py | 57 ++++++ .../client/microgrid/sensor/_sensor_proto.py | 93 ++++++++++ tests/sensor/test_sensor.py | 166 ++++++++++++++++++ tests/sensor/test_sensor_proto.py | 153 ++++++++++++++++ 5 files changed, 484 insertions(+) create mode 100644 src/frequenz/client/microgrid/sensor/__init__.py create mode 100644 src/frequenz/client/microgrid/sensor/_sensor.py create mode 100644 src/frequenz/client/microgrid/sensor/_sensor_proto.py create mode 100644 tests/sensor/test_sensor.py create mode 100644 tests/sensor/test_sensor_proto.py diff --git a/src/frequenz/client/microgrid/sensor/__init__.py b/src/frequenz/client/microgrid/sensor/__init__.py new file mode 100644 index 0000000..41d7071 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/__init__.py @@ -0,0 +1,15 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Sensor types and utilities. + +This module provides classes and utilities for working with sensors in a +microgrid environment. Sensors measure various physical metrics in the +surrounding environment, such as temperature, humidity, and solar irradiance. +""" + +from ._sensor import Sensor + +__all__ = [ + "Sensor", +] diff --git a/src/frequenz/client/microgrid/sensor/_sensor.py b/src/frequenz/client/microgrid/sensor/_sensor.py new file mode 100644 index 0000000..7ed949d --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_sensor.py @@ -0,0 +1,57 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Sensor definition.""" + +from dataclasses import dataclass, field + +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.sensors import SensorId + +from .._lifetime import Lifetime + + +@dataclass(frozen=True, kw_only=True) +class Sensor: + """A sensor that measures physical metrics in the microgrid's surroundings. + + Sensors are not part of the electrical infrastructure but provide + environmental data such as temperature, humidity, and solar irradiance. + """ + + id: SensorId + """A unique identifier for the sensor.""" + + microgrid_id: MicrogridId + """Unique identifier of the parent microgrid.""" + + name: str | None = None + """An optional name for the sensor.""" + + manufacturer: str | None = None + """The sensor manufacturer.""" + + model_name: str | None = None + """The model name of the sensor.""" + + operational_lifetime: Lifetime = field(default_factory=Lifetime) + """The operational lifetime of the sensor.""" + + @property + def identity(self) -> tuple[SensorId, MicrogridId]: + """The identity of this sensor. + + This uses the sensor ID and microgrid 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, self.microgrid_id) + + def __str__(self) -> str: + """Return a human-readable string representation of this instance. + + Returns: + A string representation of this sensor. + """ + name = f":{self.name}" if self.name else "" + return f"<{type(self).__name__}:{self.id}{name}>" diff --git a/src/frequenz/client/microgrid/sensor/_sensor_proto.py b/src/frequenz/client/microgrid/sensor/_sensor_proto.py new file mode 100644 index 0000000..d7eb838 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_sensor_proto.py @@ -0,0 +1,93 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Loading of Sensor objects from protobuf messages.""" + +import logging + +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.sensors import SensorId + +from .._lifetime import Lifetime +from .._lifetime_proto import lifetime_from_proto +from ._sensor import Sensor + +_logger = logging.getLogger(__name__) + + +def sensor_from_proto(message: sensors_pb2.Sensor) -> Sensor: + """Convert a protobuf message to a `Sensor` instance. + + Args: + message: The protobuf message. + + Returns: + The resulting sensor instance. + """ + major_issues: list[str] = [] + minor_issues: list[str] = [] + + sensor = sensor_from_proto_with_issues( + message, major_issues=major_issues, minor_issues=minor_issues + ) + + if major_issues: + _logger.warning( + "Found issues in sensor: %s | Protobuf message:\n%s", + ", ".join(major_issues), + message, + ) + if minor_issues: + _logger.debug( + "Found minor issues in sensor: %s | Protobuf message:\n%s", + ", ".join(minor_issues), + message, + ) + + return sensor + + +def sensor_from_proto_with_issues( + message: sensors_pb2.Sensor, + *, + major_issues: list[str], # pylint: disable=unused-argument + minor_issues: list[str], +) -> Sensor: + """Convert a protobuf message to a sensor instance and collect issues. + + Args: + message: The protobuf message. + major_issues: A list to append major issues to. + minor_issues: A list to append minor issues to. + + Returns: + The resulting sensor instance. + """ + sensor_id = SensorId(message.id) + microgrid_id = MicrogridId(message.microgrid_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") + + operational_lifetime = Lifetime() + if message.HasField("operational_lifetime"): + operational_lifetime = lifetime_from_proto(message.operational_lifetime) + + return Sensor( + id=sensor_id, + microgrid_id=microgrid_id, + name=name, + manufacturer=manufacturer, + model_name=model_name, + operational_lifetime=operational_lifetime, + ) diff --git a/tests/sensor/test_sensor.py b/tests/sensor/test_sensor.py new file mode 100644 index 0000000..dfe504b --- /dev/null +++ b/tests/sensor/test_sensor.py @@ -0,0 +1,166 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Sensor class.""" + +from datetime import datetime, timezone +from unittest.mock import Mock + +import pytest +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.sensors import SensorId + +from frequenz.client.microgrid import Lifetime +from frequenz.client.microgrid.sensor import Sensor + + +def test_creation_with_defaults() -> None: + """Test sensor creation with default values.""" + sensor = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + ) + + assert sensor.id == SensorId(1) + assert sensor.microgrid_id == MicrogridId(2) + assert sensor.name is None + assert sensor.manufacturer is None + assert sensor.model_name is None + assert sensor.operational_lifetime == Lifetime() + + +def test_creation_full() -> None: + """Test sensor creation with all attributes.""" + sensor = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + name="temperature-sensor-1", + manufacturer="Acme Corp", + model_name="TempSense 3000", + ) + + assert sensor.id == SensorId(1) + assert sensor.microgrid_id == MicrogridId(2) + assert sensor.name == "temperature-sensor-1" + assert sensor.manufacturer == "Acme Corp" + assert sensor.model_name == "TempSense 3000" + + +@pytest.mark.parametrize( + "name,expected_str", + [ + (None, ""), + ("temp-sensor", ""), + ], + ids=["no-name", "with-name"], +) +def test_str(name: str | None, expected_str: str) -> None: + """Test string representation of a sensor.""" + sensor = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + name=name, + ) + assert str(sensor) == expected_str + + +def test_identity() -> None: + """Test sensor identity property.""" + sensor1 = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + name="sensor-a", + ) + sensor2 = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + name="sensor-b", # Different name + ) + sensor3 = Sensor( + id=SensorId(2), + microgrid_id=MicrogridId(2), + name="sensor-a", + ) + + # Same id and microgrid_id = same identity + assert sensor1.identity == sensor2.identity + assert sensor1.identity == (SensorId(1), MicrogridId(2)) + + # Different id = different identity + assert sensor1.identity != sensor3.identity + + +def test_equality() -> None: + """Test sensor equality.""" + sensor1 = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + name="sensor-a", + manufacturer="Mfg A", + ) + sensor2 = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + name="sensor-a", + manufacturer="Mfg A", + ) + sensor3 = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + name="sensor-b", # Different name + manufacturer="Mfg A", + ) + + # Same attributes = equal + assert sensor1 == sensor2 + assert sensor2 == sensor1 + + # Different attributes = not equal + assert sensor1 != sensor3 + assert sensor3 != sensor1 + + +def test_hash() -> None: + """Test sensor hashing.""" + sensor1 = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + name="sensor-a", + ) + sensor2 = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(2), + name="sensor-a", + ) + sensor3 = Sensor( + id=SensorId(2), + microgrid_id=MicrogridId(2), + name="sensor-a", + ) + + # Equal sensors have equal hashes + assert hash(sensor1) == hash(sensor2) + + # Can be used in sets and dicts + sensor_set = {sensor1, sensor2, sensor3} + assert len(sensor_set) == 2 # sensor1 and sensor2 are the same + + +@pytest.mark.parametrize( + "is_operational", [True, False], ids=["operational", "not-operational"] +) +def test_operational_at(is_operational: bool) -> None: + """Test is_operational_at behavior.""" + mock_lifetime = Mock(spec=Lifetime) + mock_lifetime.is_operational_at.return_value = is_operational + + sensor = Sensor( + id=SensorId(1), + microgrid_id=MicrogridId(1), + operational_lifetime=mock_lifetime, + ) + + test_time = datetime.now(timezone.utc) + assert sensor.operational_lifetime.is_operational_at(test_time) == is_operational + + mock_lifetime.is_operational_at.assert_called_once_with(test_time) diff --git a/tests/sensor/test_sensor_proto.py b/tests/sensor/test_sensor_proto.py new file mode 100644 index 0000000..bdce55a --- /dev/null +++ b/tests/sensor/test_sensor_proto.py @@ -0,0 +1,153 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for protobuf conversion of Sensor objects.""" + +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.sensors import SensorId +from google.protobuf.timestamp_pb2 import Timestamp + +from frequenz.client.microgrid import Lifetime +from frequenz.client.microgrid.sensor._sensor_proto import ( + sensor_from_proto, + sensor_from_proto_with_issues, +) + + +def test_sensor_from_proto_complete() -> None: + """Test parsing of a complete sensor proto.""" + proto = sensors_pb2.Sensor( + id=1, + microgrid_id=2, + name="temp-sensor-1", + manufacturer="Acme Corp", + model_name="TempSense 3000", + ) + proto.operational_lifetime.start_timestamp.CopyFrom( + Timestamp(seconds=1696118400) # 2023-10-01T00:00:00Z + ) + proto.operational_lifetime.end_timestamp.CopyFrom( + Timestamp(seconds=1727740800) # 2024-10-01T00:00:00Z + ) + + sensor = sensor_from_proto(proto) + + assert sensor.id == SensorId(1) + assert sensor.microgrid_id == MicrogridId(2) + assert sensor.name == "temp-sensor-1" + assert sensor.manufacturer == "Acme Corp" + assert sensor.model_name == "TempSense 3000" + assert sensor.operational_lifetime != Lifetime() # Non-default lifetime + + +def test_sensor_from_proto_minimal() -> None: + """Test parsing of a minimal sensor proto with defaults.""" + proto = sensors_pb2.Sensor( + id=1, + microgrid_id=2, + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + sensor = sensor_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert sensor.id == SensorId(1) + assert sensor.microgrid_id == MicrogridId(2) + assert sensor.name is None + assert sensor.manufacturer is None + assert sensor.model_name is None + assert sensor.operational_lifetime == Lifetime() + + assert not major_issues + assert sorted(minor_issues) == sorted( + [ + "name is empty", + "manufacturer is empty", + "model_name is empty", + ] + ) + + +def test_sensor_from_proto_empty_strings() -> None: + """Test parsing with empty strings for optional fields.""" + proto = sensors_pb2.Sensor( + id=1, + microgrid_id=2, + name="", + manufacturer="", + model_name="", + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + sensor = sensor_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert sensor.name is None + assert sensor.manufacturer is None + assert sensor.model_name is None + + assert not major_issues + assert sorted(minor_issues) == sorted( + [ + "name is empty", + "manufacturer is empty", + "model_name is empty", + ] + ) + + +def test_sensor_from_proto_partial() -> None: + """Test parsing with some optional fields set.""" + proto = sensors_pb2.Sensor( + id=1, + microgrid_id=2, + name="sensor-1", + # manufacturer not set + model_name="Model X", + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + sensor = sensor_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert sensor.name == "sensor-1" + assert sensor.manufacturer is None + assert sensor.model_name == "Model X" + + assert not major_issues + assert minor_issues == ["manufacturer is empty"] + + +def test_sensor_from_proto_with_lifetime() -> None: + """Test parsing with operational lifetime set.""" + proto = sensors_pb2.Sensor( + id=1, + microgrid_id=2, + name="sensor-1", + ) + proto.operational_lifetime.start_timestamp.CopyFrom( + Timestamp(seconds=1696118400) # 2023-10-01T00:00:00Z + ) + + major_issues: list[str] = [] + minor_issues: list[str] = [] + + sensor = sensor_from_proto_with_issues( + proto, major_issues=major_issues, minor_issues=minor_issues + ) + + assert sensor.operational_lifetime != Lifetime() + # Minor issues for manufacturer and model_name + assert not major_issues + assert "manufacturer is empty" in minor_issues + assert "model_name is empty" in minor_issues From 18fe5e0bcc560b0d6bae3b45e9f66fe95f73a01e Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 20 Nov 2025 16:29:00 +0000 Subject: [PATCH 3/8] Add `list_sensors()` method to `MicrogridApiClient` Signed-off-by: Leandro Lucarella --- src/frequenz/client/microgrid/_client.py | 49 +++++++++++++++ .../list_sensors/empty_case.py | 26 ++++++++ .../list_sensors/error_case.py | 30 ++++++++++ .../list_sensors/filter_case.py | 60 +++++++++++++++++++ .../list_sensors/success_case.py | 58 ++++++++++++++++++ tests/test_client.py | 13 ++++ 6 files changed, 236 insertions(+) create mode 100644 tests/client_test_cases/list_sensors/empty_case.py create mode 100644 tests/client_test_cases/list_sensors/error_case.py create mode 100644 tests/client_test_cases/list_sensors/filter_case.py create mode 100644 tests/client_test_cases/list_sensors/success_case.py diff --git a/src/frequenz/client/microgrid/_client.py b/src/frequenz/client/microgrid/_client.py index fc29861..6971034 100644 --- a/src/frequenz/client/microgrid/_client.py +++ b/src/frequenz/client/microgrid/_client.py @@ -23,6 +23,7 @@ from frequenz.client.base import channel, client, conversion, retry, streaming from frequenz.client.base.exception import ApiClientError from frequenz.client.common.microgrid.components import ComponentId +from frequenz.client.common.microgrid.sensors import SensorId from google.protobuf.empty_pb2 import Empty from grpc.aio import AioRpcError from typing_extensions import override @@ -40,6 +41,8 @@ from .component._types import ComponentTypes from .metrics._bounds import Bounds from .metrics._metric import Metric +from .sensor._sensor import Sensor +from .sensor._sensor_proto import sensor_from_proto DEFAULT_GRPC_CALL_TIMEOUT = 60.0 """The default timeout for gRPC calls made by this client (in seconds).""" @@ -296,6 +299,41 @@ async def list_connections( # noqa: DOC502 (raises ApiClientError indirectly) if conn is not None ) + async def list_sensors( # noqa: DOC502 (raises ApiClientError indirectly) + self, + *, + sensors: Iterable[SensorId | Sensor] = (), + ) -> Iterable[Sensor]: + """Fetch all the sensors present in the local microgrid. + + Sensors are devices that measure physical properties in the microgrid's + surroundings, such as temperature, humidity, and solar irradiance. Unlike + electrical components, sensors are not part of the electrical infrastructure. + + Args: + sensors: The sensors to fetch. If empty, all sensors are fetched. + + Returns: + Iterator whose elements are all the sensors in the local microgrid. + + Raises: + ApiClientError: If there are any errors communicating with the Microgrid API, + most likely a subclass of + [GrpcError][frequenz.client.microgrid.GrpcError]. + """ + response = await client.call_stub_method( + self, + lambda: self.stub.ListSensors( + microgrid_pb2.ListSensorRequest( + sensor_ids=map(_get_sensor_id, sensors), + ), + timeout=DEFAULT_GRPC_CALL_TIMEOUT, + ), + method_name="ListSensors", + ) + + return map(sensor_from_proto, response.sensors) + # pylint: disable-next=fixme # TODO: Unifi set_component_power_active and set_component_power_reactive, or at # least use a common implementation. @@ -663,6 +701,17 @@ def _get_component_id(component: ComponentId | Component) -> int: assert_never(unexpected) +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_metric_value(metric: Metric | int) -> metrics_pb2.Metric.ValueType: """Get the metric ID from a metric or metric ID.""" match metric: diff --git a/tests/client_test_cases/list_sensors/empty_case.py b/tests/client_test_cases/list_sensors/empty_case.py new file mode 100644 index 0000000..dbfdf0a --- /dev/null +++ b/tests/client_test_cases/list_sensors/empty_case.py @@ -0,0 +1,26 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for empty sensor list.""" + +from typing import Any + +from frequenz.api.microgrid.v1alpha18 import microgrid_pb2 + +client_args = () + +grpc_response = microgrid_pb2.ListSensorsResponse(sensors=[]) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListSensorRequest(sensor_ids=[]), + timeout=60.0, + ) + + +async def assert_client_result(result: Any) -> None: + """Assert that the client result is an empty list.""" + sensors = list(result) + assert len(sensors) == 0 diff --git a/tests/client_test_cases/list_sensors/error_case.py b/tests/client_test_cases/list_sensors/error_case.py new file mode 100644 index 0000000..003a0ba --- /dev/null +++ b/tests/client_test_cases/list_sensors/error_case.py @@ -0,0 +1,30 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for error case in sensor listing.""" + +from typing import Any + +import grpc +from frequenz.api.microgrid.v1alpha18 import microgrid_pb2 + +from tests.util import make_grpc_error + +client_args = () + +grpc_response = make_grpc_error(grpc.StatusCode.INTERNAL) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListSensorRequest(sensor_ids=[]), + timeout=60.0, + ) + + +def assert_client_exception(exception: Exception) -> None: + """Assert that the client raised the expected exception.""" + from frequenz.client.microgrid import InternalError + + assert isinstance(exception, InternalError) diff --git a/tests/client_test_cases/list_sensors/filter_case.py b/tests/client_test_cases/list_sensors/filter_case.py new file mode 100644 index 0000000..ce91003 --- /dev/null +++ b/tests/client_test_cases/list_sensors/filter_case.py @@ -0,0 +1,60 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for filtered sensor listing.""" + +from typing import Any + +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.api.microgrid.v1alpha18 import microgrid_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.sensors import SensorId + +from frequenz.client.microgrid.sensor import Sensor + +# Filter by sensor IDs 1 and 3 +client_kwargs = { + "sensors": [ + SensorId(1), + Sensor(id=SensorId(3), microgrid_id=MicrogridId(100)), + ], +} + +grpc_response = microgrid_pb2.ListSensorsResponse( + sensors=[ + sensors_pb2.Sensor( + id=1, + microgrid_id=100, + name="Temperature Sensor 1", + manufacturer="Acme Corp", + model_name="TMP-100", + ), + sensors_pb2.Sensor( + id=3, + microgrid_id=100, + name="Pressure Sensor 1", + manufacturer="Acme Corp", + model_name="PRS-300", + ), + ] +) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListSensorRequest(sensor_ids=[1, 3]), + timeout=60.0, + ) + + +async def assert_client_result(result: Any) -> None: + """Assert that the client result matches expected filtered sensors.""" + sensors = list(result) + assert len(sensors) == 2 + + assert sensors[0].id == SensorId(1) + assert sensors[0].name == "Temperature Sensor 1" + + assert sensors[1].id == SensorId(3) + assert sensors[1].name == "Pressure Sensor 1" diff --git a/tests/client_test_cases/list_sensors/success_case.py b/tests/client_test_cases/list_sensors/success_case.py new file mode 100644 index 0000000..9b05a42 --- /dev/null +++ b/tests/client_test_cases/list_sensors/success_case.py @@ -0,0 +1,58 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for successful sensor listing.""" + +from typing import Any + +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.api.microgrid.v1alpha18 import microgrid_pb2 +from frequenz.client.common.microgrid import MicrogridId +from frequenz.client.common.microgrid.sensors import SensorId + +client_args = () + +grpc_response = microgrid_pb2.ListSensorsResponse( + sensors=[ + sensors_pb2.Sensor( + id=1, + microgrid_id=100, + name="Temperature Sensor 1", + manufacturer="Acme Corp", + model_name="TMP-100", + ), + sensors_pb2.Sensor( + id=2, + microgrid_id=100, + name="Humidity Sensor 1", + manufacturer="Acme Corp", + model_name="HUM-200", + ), + ] +) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ListSensorRequest(sensor_ids=[]), + timeout=60.0, + ) + + +async def assert_client_result(result: Any) -> None: + """Assert that the client result matches expected sensors.""" + sensors = list(result) + assert len(sensors) == 2 + + assert sensors[0].id == SensorId(1) + assert sensors[0].microgrid_id == MicrogridId(100) + assert sensors[0].name == "Temperature Sensor 1" + assert sensors[0].manufacturer == "Acme Corp" + assert sensors[0].model_name == "TMP-100" + + assert sensors[1].id == SensorId(2) + assert sensors[1].microgrid_id == MicrogridId(100) + assert sensors[1].name == "Humidity Sensor 1" + assert sensors[1].manufacturer == "Acme Corp" + assert sensors[1].model_name == "HUM-200" diff --git a/tests/test_client.py b/tests/test_client.py index 0635c80..5bc6142 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -179,3 +179,16 @@ async def test_receive_component_data_samples_stream( await spec.test_unary_stream_call( client, "ReceiveElectricalComponentTelemetryStream" ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "spec", + get_test_specs("list_sensors", tests_dir=TESTS_DIR), + ids=str, +) +async def test_list_sensors( + client: MicrogridApiClient, spec: ApiClientTestCaseSpec +) -> None: + """Test list_sensors method.""" + await spec.test_unary_unary_call(client, "ListSensors") From 9347ad6a79e555c6c0ca972b63fd5211547e06f4 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 20 Nov 2025 16:30:38 +0000 Subject: [PATCH 4/8] Add `SensorStateSnapshot` and related diagnostic types Signed-off-by: Leandro Lucarella --- src/frequenz/client/microgrid/__init__.py | 1 - .../client/microgrid/sensor/__init__.py | 10 + .../client/microgrid/sensor/_state.py | 84 ++++++++ .../client/microgrid/sensor/_state_proto.py | 75 +++++++ tests/sensor/test_state.py | 201 ++++++++++++++++++ tests/sensor/test_state_proto.py | 166 +++++++++++++++ 6 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 src/frequenz/client/microgrid/sensor/_state.py create mode 100644 src/frequenz/client/microgrid/sensor/_state_proto.py create mode 100644 tests/sensor/test_state.py create mode 100644 tests/sensor/test_state_proto.py diff --git a/src/frequenz/client/microgrid/__init__.py b/src/frequenz/client/microgrid/__init__.py index f8559a7..a8c7097 100644 --- a/src/frequenz/client/microgrid/__init__.py +++ b/src/frequenz/client/microgrid/__init__.py @@ -6,7 +6,6 @@ This package provides a low-level interface for interacting with the microgrid API. """ - from ._client import ( DEFAULT_CHANNEL_OPTIONS, DEFAULT_GRPC_CALL_TIMEOUT, diff --git a/src/frequenz/client/microgrid/sensor/__init__.py b/src/frequenz/client/microgrid/sensor/__init__.py index 41d7071..45ea1b9 100644 --- a/src/frequenz/client/microgrid/sensor/__init__.py +++ b/src/frequenz/client/microgrid/sensor/__init__.py @@ -9,7 +9,17 @@ """ from ._sensor import Sensor +from ._state import ( + SensorDiagnostic, + SensorDiagnosticCode, + SensorStateCode, + SensorStateSnapshot, +) __all__ = [ "Sensor", + "SensorDiagnostic", + "SensorDiagnosticCode", + "SensorStateCode", + "SensorStateSnapshot", ] diff --git a/src/frequenz/client/microgrid/sensor/_state.py b/src/frequenz/client/microgrid/sensor/_state.py new file mode 100644 index 0000000..cdce7d5 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_state.py @@ -0,0 +1,84 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Sensor state-related types.""" + +import enum +from collections.abc import Sequence, Set +from dataclasses import dataclass +from datetime import datetime + + +@enum.unique +class SensorStateCode(enum.Enum): + """Sensor state code. + + Represents the operational state of a sensor. + """ + + UNSPECIFIED = 0 + """Unspecified state.""" + + OK = 1 + """Sensor is operating normally.""" + + ERROR = 2 + """Sensor is in an error state.""" + + +@enum.unique +class SensorDiagnosticCode(enum.Enum): + """Sensor diagnostic code. + + Provides additional diagnostic information about warnings or errors. + """ + + UNSPECIFIED = 0 + """Unspecified diagnostic code.""" + + UNKNOWN = 1 + """Unknown diagnostic issue.""" + + INTERNAL = 2 + """Internal sensor error.""" + + +@dataclass(frozen=True, kw_only=True) +class SensorDiagnostic: + """Diagnostic information for a sensor warning or error. + + Provides detailed information about issues affecting a sensor. + """ + + diagnostic_code: SensorDiagnosticCode | int + """The diagnostic code identifying the type of issue.""" + + message: str | None = None + """Optional human-readable message describing the issue.""" + + vendor_diagnostic_code: str | None = None + """Optional vendor-specific diagnostic code.""" + + +@dataclass(frozen=True, kw_only=True) +class SensorStateSnapshot: + """A snapshot of a sensor's operational state at a specific time. + + Contains the sensor's state codes and any associated diagnostic information. + """ + + origin_time: datetime + """The timestamp when this state snapshot was recorded.""" + + states: Set[SensorStateCode | int] + """Set of state codes active at the snapshot time.""" + + warnings: Sequence[SensorDiagnostic] + """Sequence of active warnings with diagnostic information.""" + + errors: Sequence[SensorDiagnostic] + """Sequence of active errors with diagnostic information.""" + + # Disable hashing for this class (mypy doesn't seem to understand assigining to + # None, but it is documented in __hash__ docs). + __hash__ = None # type: ignore[assignment] diff --git a/src/frequenz/client/microgrid/sensor/_state_proto.py b/src/frequenz/client/microgrid/sensor/_state_proto.py new file mode 100644 index 0000000..30609f7 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_state_proto.py @@ -0,0 +1,75 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Proto conversion for sensor state types.""" + +from datetime import timezone + +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.client.base.conversion import to_datetime + +from ._state import ( + SensorDiagnostic, + SensorDiagnosticCode, + SensorStateCode, + SensorStateSnapshot, +) + + +def sensor_diagnostic_from_proto( + proto: sensors_pb2.SensorDiagnostic, +) -> SensorDiagnostic: + """Convert a proto SensorDiagnostic to a SensorDiagnostic. + + Args: + proto: The proto SensorDiagnostic to convert. + + Returns: + The converted SensorDiagnostic. + """ + diagnostic_code: SensorDiagnosticCode | int = proto.diagnostic_code + try: + diagnostic_code = SensorDiagnosticCode(diagnostic_code) + except ValueError: + pass # Keep as int if unrecognized + + return SensorDiagnostic( + diagnostic_code=diagnostic_code, + message=proto.message if proto.message else None, + vendor_diagnostic_code=( + proto.vendor_diagnostic_code if proto.vendor_diagnostic_code else None + ), + ) + + +def sensor_state_snapshot_from_proto( + proto: sensors_pb2.SensorStateSnapshot, +) -> SensorStateSnapshot: + """Convert a proto SensorStateSnapshot to a SensorStateSnapshot. + + Args: + proto: The proto SensorStateSnapshot to convert. + + Returns: + The converted SensorStateSnapshot. + """ + # Convert states + states = frozenset( + ( + SensorStateCode(int(state)) + if int(state) in [s.value for s in SensorStateCode] + else int(state) + ) + for state in proto.states + ) + + # Convert warnings and errors + warnings = [sensor_diagnostic_from_proto(w) for w in proto.warnings] + errors = [sensor_diagnostic_from_proto(e) for e in proto.errors] + + return SensorStateSnapshot( + origin_time=to_datetime(proto.origin_time).replace(tzinfo=timezone.utc), + states=states, + warnings=warnings, + errors=errors, + ) diff --git a/tests/sensor/test_state.py b/tests/sensor/test_state.py new file mode 100644 index 0000000..c3011aa --- /dev/null +++ b/tests/sensor/test_state.py @@ -0,0 +1,201 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for sensor state types.""" + +from collections.abc import Hashable +from datetime import datetime, timezone + +import pytest + +from frequenz.client.microgrid.sensor import ( + SensorDiagnostic, + SensorDiagnosticCode, + SensorStateCode, + SensorStateSnapshot, +) + + +def test_sensor_diagnostic_creation_minimal() -> None: + """Test SensorDiagnostic creation with minimal fields.""" + diag = SensorDiagnostic(diagnostic_code=SensorDiagnosticCode.INTERNAL) + + assert diag.diagnostic_code == SensorDiagnosticCode.INTERNAL + assert diag.message is None + assert diag.vendor_diagnostic_code is None + + +def test_sensor_diagnostic_creation_full() -> None: + """Test SensorDiagnostic creation with all fields.""" + diag = SensorDiagnostic( + diagnostic_code=SensorDiagnosticCode.INTERNAL, + message="Internal sensor error occurred", + vendor_diagnostic_code="ACME-ERR-001", + ) + + assert diag.diagnostic_code == SensorDiagnosticCode.INTERNAL + assert diag.message == "Internal sensor error occurred" + assert diag.vendor_diagnostic_code == "ACME-ERR-001" + + +def test_sensor_diagnostic_with_int_code() -> None: + """Test SensorDiagnostic with integer diagnostic code.""" + diag = SensorDiagnostic( + diagnostic_code=999, # Custom vendor code + message="Custom error", + ) + + assert diag.diagnostic_code == 999 + assert diag.message == "Custom error" + + +def test_sensor_diagnostic_equality() -> None: + """Test SensorDiagnostic equality.""" + diag1 = SensorDiagnostic( + diagnostic_code=SensorDiagnosticCode.INTERNAL, + message="Error", + ) + diag2 = SensorDiagnostic( + diagnostic_code=SensorDiagnosticCode.INTERNAL, + message="Error", + ) + diag3 = SensorDiagnostic( + diagnostic_code=SensorDiagnosticCode.UNKNOWN, + message="Error", + ) + + assert diag1 == diag2 + assert diag1 != diag3 + + +def test_sensor_diagnostic_hash() -> None: + """Test SensorDiagnostic hashing for use in sets.""" + diag1 = SensorDiagnostic(diagnostic_code=SensorDiagnosticCode.INTERNAL) + diag2 = SensorDiagnostic(diagnostic_code=SensorDiagnosticCode.INTERNAL) + diag3 = SensorDiagnostic(diagnostic_code=SensorDiagnosticCode.UNKNOWN) + + assert hash(diag1) == hash(diag2) + diag_set = {diag1, diag2, diag3} + assert len(diag_set) == 2 + + +def test_sensor_state_snapshot_creation() -> None: + """Test SensorStateSnapshot creation.""" + now = datetime.now(timezone.utc) + warning1 = SensorDiagnostic( + diagnostic_code=SensorDiagnosticCode.UNKNOWN, + message="Minor issue", + ) + error1 = SensorDiagnostic( + diagnostic_code=SensorDiagnosticCode.INTERNAL, + message="Critical issue", + ) + + snapshot = SensorStateSnapshot( + origin_time=now, + states=frozenset([SensorStateCode.OK, SensorStateCode.ERROR]), + warnings=[warning1], + errors=[error1], + ) + + assert snapshot.origin_time == now + assert SensorStateCode.OK in snapshot.states + assert SensorStateCode.ERROR in snapshot.states + assert len(snapshot.warnings) == 1 + assert warning1 in snapshot.warnings + assert len(snapshot.errors) == 1 + assert error1 in snapshot.errors + + +def test_sensor_state_snapshot_empty_diagnostics() -> None: + """Test SensorStateSnapshot with empty diagnostics.""" + now = datetime.now(timezone.utc) + + snapshot = SensorStateSnapshot( + origin_time=now, + states=frozenset([SensorStateCode.OK]), + warnings=(), + errors=(), + ) + + assert snapshot.origin_time == now + assert len(snapshot.states) == 1 + assert len(snapshot.warnings) == 0 + assert len(snapshot.errors) == 0 + + +def test_sensor_state_snapshot_with_int_states() -> None: + """Test SensorStateSnapshot with integer state codes.""" + now = datetime.now(timezone.utc) + + snapshot = SensorStateSnapshot( + origin_time=now, + states=frozenset([1, 999]), # Mix of enum value and custom code + warnings=(), + errors=(), + ) + + assert 1 in snapshot.states # SensorStateCode.OK + assert 999 in snapshot.states # Custom state code + + +def test_sensor_state_snapshot_equality() -> None: + """Test SensorStateSnapshot equality.""" + now = datetime.now(timezone.utc) + diag = SensorDiagnostic(diagnostic_code=SensorDiagnosticCode.INTERNAL) + + snapshot1 = SensorStateSnapshot( + origin_time=now, + states=frozenset([SensorStateCode.OK]), + warnings=[diag], + errors=(), + ) + snapshot2 = SensorStateSnapshot( + origin_time=now, + states=frozenset([SensorStateCode.OK]), + warnings=[diag], + errors=(), + ) + + assert snapshot1 == snapshot2 + + +def test_sensor_state_snapshot_immutable() -> None: + """Test that SensorStateSnapshot is immutable.""" + now = datetime.now(timezone.utc) + snapshot = SensorStateSnapshot( + origin_time=now, + states=frozenset([SensorStateCode.OK]), + warnings=(), + errors=(), + ) + + # Should not be able to modify frozen dataclass + with pytest.raises(AttributeError): + snapshot.origin_time = datetime.now(timezone.utc) # type: ignore[misc] + + +def test_sensor_state_snapshot_not_hashable() -> None: + """Test that SensorStateSnapshot is not hashable.""" + now = datetime.now(timezone.utc) + snapshot = SensorStateSnapshot( + origin_time=now, + states=frozenset([SensorStateCode.OK]), + warnings=(), + errors=(), + ) + + # Should not be able to modify frozen dataclass + with pytest.raises(TypeError): + _ = hash(snapshot) + + assert not isinstance(snapshot, Hashable) + + +def test_sensor_diagnostic_immutable() -> None: + """Test that SensorDiagnostic is immutable.""" + diag = SensorDiagnostic(diagnostic_code=SensorDiagnosticCode.INTERNAL) + + # Should not be able to modify frozen dataclass + with pytest.raises(AttributeError): + diag.message = "new message" # type: ignore[misc] diff --git a/tests/sensor/test_state_proto.py b/tests/sensor/test_state_proto.py new file mode 100644 index 0000000..8f5ae1a --- /dev/null +++ b/tests/sensor/test_state_proto.py @@ -0,0 +1,166 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for proto conversion of sensor state types.""" + +from datetime import datetime, timezone + +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from google.protobuf.timestamp_pb2 import Timestamp + +from frequenz.client.microgrid.sensor import SensorDiagnosticCode, SensorStateCode +from frequenz.client.microgrid.sensor._state_proto import ( + sensor_diagnostic_from_proto, + sensor_state_snapshot_from_proto, +) + + +def test_sensor_diagnostic_from_proto() -> None: + """Test converting SensorDiagnostic from proto.""" + proto = sensors_pb2.SensorDiagnostic( + diagnostic_code=sensors_pb2.SENSOR_DIAGNOSTIC_CODE_INTERNAL, + message="Internal error", + vendor_diagnostic_code="VENDOR-001", + ) + + diag = sensor_diagnostic_from_proto(proto) + + assert diag.diagnostic_code == SensorDiagnosticCode.INTERNAL + assert diag.message == "Internal error" + assert diag.vendor_diagnostic_code == "VENDOR-001" + + +def test_sensor_diagnostic_from_proto_minimal() -> None: + """Test converting minimal SensorDiagnostic from proto.""" + proto = sensors_pb2.SensorDiagnostic( + diagnostic_code=sensors_pb2.SENSOR_DIAGNOSTIC_CODE_UNKNOWN, + ) + + diag = sensor_diagnostic_from_proto(proto) + + assert diag.diagnostic_code == SensorDiagnosticCode.UNKNOWN + assert diag.message is None + assert diag.vendor_diagnostic_code is None + + +def test_sensor_diagnostic_from_proto_empty_strings() -> None: + """Test converting SensorDiagnostic with empty strings.""" + proto = sensors_pb2.SensorDiagnostic( + diagnostic_code=sensors_pb2.SENSOR_DIAGNOSTIC_CODE_INTERNAL, + message="", + vendor_diagnostic_code="", + ) + + diag = sensor_diagnostic_from_proto(proto) + + assert diag.diagnostic_code == SensorDiagnosticCode.INTERNAL + assert diag.message is None + assert diag.vendor_diagnostic_code is None + + +def test_sensor_diagnostic_from_proto_unknown_code() -> None: + """Test converting SensorDiagnostic with unknown diagnostic code.""" + proto = sensors_pb2.SensorDiagnostic( + diagnostic_code=999, # type: ignore[arg-type] + message="Unknown error", + ) + + diag = sensor_diagnostic_from_proto(proto) + + assert diag.diagnostic_code == 999 # Preserved as int + assert diag.message == "Unknown error" + + +def test_sensor_state_snapshot_from_proto() -> None: + """Test converting SensorStateSnapshot from proto.""" + proto = sensors_pb2.SensorStateSnapshot() + proto.origin_time.CopyFrom(Timestamp(seconds=1696118400)) # 2023-10-01T00:00:00Z + proto.states.append(sensors_pb2.SENSOR_STATE_CODE_OK) + proto.states.append(sensors_pb2.SENSOR_STATE_CODE_ERROR) + + warning = proto.warnings.add() + warning.diagnostic_code = sensors_pb2.SENSOR_DIAGNOSTIC_CODE_UNKNOWN + warning.message = "Warning message" + + error = proto.errors.add() + error.diagnostic_code = sensors_pb2.SENSOR_DIAGNOSTIC_CODE_INTERNAL + error.message = "Error message" + + snapshot = sensor_state_snapshot_from_proto(proto) + + assert snapshot.origin_time == datetime(2023, 10, 1, 0, 0, 0, tzinfo=timezone.utc) + assert SensorStateCode.OK in snapshot.states + assert SensorStateCode.ERROR in snapshot.states + assert len(snapshot.warnings) == 1 + warning_diag = next(iter(snapshot.warnings)) + assert warning_diag.diagnostic_code == SensorDiagnosticCode.UNKNOWN + assert warning_diag.message == "Warning message" + assert len(snapshot.errors) == 1 + error_diag = next(iter(snapshot.errors)) + assert error_diag.diagnostic_code == SensorDiagnosticCode.INTERNAL + assert error_diag.message == "Error message" + + +def test_sensor_state_snapshot_from_proto_empty() -> None: + """Test converting empty SensorStateSnapshot from proto.""" + proto = sensors_pb2.SensorStateSnapshot() + proto.origin_time.CopyFrom(Timestamp(seconds=1696118400)) + # No states, warnings, or errors + + snapshot = sensor_state_snapshot_from_proto(proto) + + assert snapshot.origin_time == datetime(2023, 10, 1, 0, 0, 0, tzinfo=timezone.utc) + assert len(snapshot.states) == 0 + assert len(snapshot.warnings) == 0 + assert len(snapshot.errors) == 0 + + +def test_sensor_state_snapshot_from_proto_unknown_state() -> None: + """Test converting SensorStateSnapshot with unknown state codes.""" + proto = sensors_pb2.SensorStateSnapshot() + proto.origin_time.CopyFrom(Timestamp(seconds=1696118400)) + proto.states.append(sensors_pb2.SENSOR_STATE_CODE_OK) + proto.states.append(999) # type: ignore[arg-type] + + snapshot = sensor_state_snapshot_from_proto(proto) + + assert SensorStateCode.OK in snapshot.states + assert 999 in snapshot.states # Preserved as int + assert len(snapshot.states) == 2 + + +def test_sensor_state_snapshot_from_proto_multiple_diagnostics() -> None: + """Test converting SensorStateSnapshot with multiple diagnostics.""" + proto = sensors_pb2.SensorStateSnapshot() + proto.origin_time.CopyFrom(Timestamp(seconds=1696118400)) + proto.states.append(sensors_pb2.SENSOR_STATE_CODE_ERROR) + + # Multiple warnings + warning1 = proto.warnings.add() + warning1.diagnostic_code = sensors_pb2.SENSOR_DIAGNOSTIC_CODE_UNKNOWN + warning1.message = "Warning 1" + + warning2 = proto.warnings.add() + warning2.diagnostic_code = sensors_pb2.SENSOR_DIAGNOSTIC_CODE_INTERNAL + warning2.message = "Warning 2" + + # Multiple errors + error1 = proto.errors.add() + error1.diagnostic_code = sensors_pb2.SENSOR_DIAGNOSTIC_CODE_INTERNAL + error1.message = "Error 1" + + error2 = proto.errors.add() + error2.diagnostic_code = sensors_pb2.SENSOR_DIAGNOSTIC_CODE_UNKNOWN + error2.message = "Error 2" + + snapshot = sensor_state_snapshot_from_proto(proto) + + assert len(snapshot.warnings) == 2 + assert len(snapshot.errors) == 2 + # Check that all messages are present + warning_messages = {w.message for w in snapshot.warnings} + assert "Warning 1" in warning_messages + assert "Warning 2" in warning_messages + error_messages = {e.message for e in snapshot.errors} + assert "Error 1" in error_messages + assert "Error 2" in error_messages From c68c0fd54ab5ecec0abd4895731591046150af63 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Mon, 1 Dec 2025 10:42:42 +0100 Subject: [PATCH 5/8] Add `SensorTelemetry` dataclass and proto conversion Signed-off-by: Leandro Lucarella --- .../client/microgrid/sensor/__init__.py | 2 + .../client/microgrid/sensor/_telemetry.py | 33 ++++ .../microgrid/sensor/_telemetry_proto.py | 49 +++++ tests/sensor/test_telemetry.py | 146 ++++++++++++++ tests/sensor/test_telemetry_proto.py | 182 ++++++++++++++++++ 5 files changed, 412 insertions(+) create mode 100644 src/frequenz/client/microgrid/sensor/_telemetry.py create mode 100644 src/frequenz/client/microgrid/sensor/_telemetry_proto.py create mode 100644 tests/sensor/test_telemetry.py create mode 100644 tests/sensor/test_telemetry_proto.py diff --git a/src/frequenz/client/microgrid/sensor/__init__.py b/src/frequenz/client/microgrid/sensor/__init__.py index 45ea1b9..9ce261a 100644 --- a/src/frequenz/client/microgrid/sensor/__init__.py +++ b/src/frequenz/client/microgrid/sensor/__init__.py @@ -15,6 +15,7 @@ SensorStateCode, SensorStateSnapshot, ) +from ._telemetry import SensorTelemetry __all__ = [ "Sensor", @@ -22,4 +23,5 @@ "SensorDiagnosticCode", "SensorStateCode", "SensorStateSnapshot", + "SensorTelemetry", ] diff --git a/src/frequenz/client/microgrid/sensor/_telemetry.py b/src/frequenz/client/microgrid/sensor/_telemetry.py new file mode 100644 index 0000000..3a328c4 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_telemetry.py @@ -0,0 +1,33 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Sensor telemetry types.""" + +from collections.abc import Sequence +from dataclasses import dataclass + +from frequenz.client.common.microgrid.sensors import SensorId + +from ..metrics._sample import MetricSample +from ._state import SensorStateSnapshot + + +@dataclass(frozen=True, kw_only=True) +class SensorTelemetry: + """Telemetry data from a sensor. + + Contains metric measurements and state snapshots for a specific sensor. + """ + + sensor_id: SensorId + """The unique identifier of the sensor that produced this telemetry.""" + + metric_samples: Sequence[MetricSample] + """List of metric measurements from the sensor.""" + + state_snapshots: Sequence[SensorStateSnapshot] + """List of state snapshots indicating the sensor's operational status.""" + + # Disable hashing for this class (mypy doesn't seem to understand assigining to + # None, but it is documented in __hash__ docs). + __hash__ = None # type: ignore[assignment] diff --git a/src/frequenz/client/microgrid/sensor/_telemetry_proto.py b/src/frequenz/client/microgrid/sensor/_telemetry_proto.py new file mode 100644 index 0000000..4fe5e11 --- /dev/null +++ b/src/frequenz/client/microgrid/sensor/_telemetry_proto.py @@ -0,0 +1,49 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Proto conversion for sensor telemetry.""" + +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.client.common.microgrid.sensors import SensorId + +from ..metrics._sample_proto import metric_sample_from_proto_with_issues +from ._state_proto import sensor_state_snapshot_from_proto +from ._telemetry import SensorTelemetry + + +def sensor_telemetry_from_proto( + proto: sensors_pb2.SensorTelemetry, +) -> SensorTelemetry: + """Convert a proto SensorTelemetry to a SensorTelemetry. + + Args: + proto: The proto SensorTelemetry to convert. + + Returns: + The converted SensorTelemetry. + """ + # Convert metric samples + # Using empty issue lists as we're not handling issues at this level + # In a future improvement, we could expose issues to the caller + major_issues: list[str] = [] + minor_issues: list[str] = [] + + metric_samples = [ + metric_sample_from_proto_with_issues( + sample, + major_issues=major_issues, + minor_issues=minor_issues, + ) + for sample in proto.metric_samples + ] + + # Convert state snapshots + state_snapshots = [ + sensor_state_snapshot_from_proto(snapshot) for snapshot in proto.state_snapshots + ] + + return SensorTelemetry( + sensor_id=SensorId(proto.sensor_id), + metric_samples=metric_samples, + state_snapshots=state_snapshots, + ) diff --git a/tests/sensor/test_telemetry.py b/tests/sensor/test_telemetry.py new file mode 100644 index 0000000..7d452d8 --- /dev/null +++ b/tests/sensor/test_telemetry.py @@ -0,0 +1,146 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for SensorTelemetry dataclass.""" + +from collections.abc import Hashable +from datetime import datetime, timezone + +import pytest +from frequenz.client.common.microgrid.sensors import SensorId + +from frequenz.client.microgrid.metrics import MetricSample +from frequenz.client.microgrid.sensor import ( + SensorDiagnostic, + SensorDiagnosticCode, + SensorStateCode, + SensorStateSnapshot, + SensorTelemetry, +) + + +def test_sensor_telemetry_creation() -> None: + """Test SensorTelemetry creation.""" + now = datetime.now(timezone.utc) + + sample1 = MetricSample(metric=1, sampled_at=now, value=25.5, bounds=[]) + sample2 = MetricSample(metric=2, sampled_at=now, value=60.0, bounds=[]) + + diagnostic = SensorDiagnostic(diagnostic_code=SensorDiagnosticCode.INTERNAL) + snapshot = SensorStateSnapshot( + origin_time=now, + states={SensorStateCode.OK}, + warnings=(), + errors=[diagnostic], + ) + + telemetry = SensorTelemetry( + sensor_id=SensorId(1), + metric_samples=[sample1, sample2], + state_snapshots=[snapshot], + ) + + assert telemetry.sensor_id == SensorId(1) + assert len(telemetry.metric_samples) == 2 + assert sample1 in telemetry.metric_samples + assert sample2 in telemetry.metric_samples + assert len(telemetry.state_snapshots) == 1 + assert snapshot in telemetry.state_snapshots + + +def test_sensor_telemetry_empty() -> None: + """Test SensorTelemetry with empty collections.""" + telemetry = SensorTelemetry( + sensor_id=SensorId(1), + metric_samples=(), + state_snapshots=(), + ) + + assert telemetry.sensor_id == SensorId(1) + assert len(telemetry.metric_samples) == 0 + assert len(telemetry.state_snapshots) == 0 + + +def test_sensor_telemetry_multiple_snapshots() -> None: + """Test SensorTelemetry with multiple state snapshots.""" + now = datetime.now(timezone.utc) + + snapshot1 = SensorStateSnapshot( + origin_time=now, + states={SensorStateCode.OK}, + warnings=(), + errors=(), + ) + + snapshot2 = SensorStateSnapshot( + origin_time=now, + states={SensorStateCode.ERROR}, + warnings=(), + errors=(), + ) + + telemetry = SensorTelemetry( + sensor_id=SensorId(1), + metric_samples=(), + state_snapshots=[snapshot1, snapshot2], + ) + + assert len(telemetry.state_snapshots) == 2 + assert snapshot1 in telemetry.state_snapshots + assert snapshot2 in telemetry.state_snapshots + + +def test_sensor_telemetry_equality() -> None: + """Test SensorTelemetry equality.""" + now = datetime.now(timezone.utc) + sample = MetricSample(metric=1, sampled_at=now, value=25.5, bounds=[]) + snapshot = SensorStateSnapshot( + origin_time=now, + states={SensorStateCode.OK}, + warnings=(), + errors=(), + ) + + telemetry1 = SensorTelemetry( + sensor_id=SensorId(1), + metric_samples=[sample], + state_snapshots=[snapshot], + ) + + telemetry2 = SensorTelemetry( + sensor_id=SensorId(1), + metric_samples=[sample], + state_snapshots=[snapshot], + ) + + telemetry3 = SensorTelemetry( + sensor_id=SensorId(2), # Different sensor ID + metric_samples=[sample], + state_snapshots=[snapshot], + ) + + assert telemetry1 == telemetry2 + assert telemetry1 != telemetry3 + + +def test_sensor_telemetry_not_hashable() -> None: + """Test that SensorTelemetry is not hashable.""" + now = datetime.now(timezone.utc) + sample = MetricSample(metric=1, sampled_at=now, value=25.5, bounds=[]) + snapshot = SensorStateSnapshot( + origin_time=now, + states=frozenset([SensorStateCode.OK]), + warnings=(), + errors=(), + ) + telemetry = SensorTelemetry( + sensor_id=SensorId(1), + metric_samples=[sample], + state_snapshots=[snapshot], + ) + + # Should not be able to modify frozen dataclass + with pytest.raises(TypeError): + _ = hash(telemetry) + + assert not isinstance(telemetry, Hashable) diff --git a/tests/sensor/test_telemetry_proto.py b/tests/sensor/test_telemetry_proto.py new file mode 100644 index 0000000..3607b08 --- /dev/null +++ b/tests/sensor/test_telemetry_proto.py @@ -0,0 +1,182 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Tests for proto conversion of SensorTelemetry.""" + + +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.client.common.microgrid.sensors import SensorId +from google.protobuf.timestamp_pb2 import Timestamp + +from frequenz.client.microgrid.sensor import SensorDiagnosticCode, SensorStateCode +from frequenz.client.microgrid.sensor._telemetry_proto import ( + sensor_telemetry_from_proto, +) + + +def test_sensor_telemetry_from_proto_complete() -> None: + """Test converting complete SensorTelemetry from proto.""" + proto = sensors_pb2.SensorTelemetry(sensor_id=1) + + # Add metric samples + sample1 = proto.metric_samples.add() + sample1.metric = 1 # type: ignore[assignment] + sample1.sample_time.CopyFrom(Timestamp(seconds=1696118400)) + sample1.value.simple_metric.value = 25.5 + + sample2 = proto.metric_samples.add() + sample2.metric = 2 # type: ignore[assignment] + sample2.sample_time.CopyFrom(Timestamp(seconds=1696118400)) + sample2.value.simple_metric.value = 60.0 + + # Add state snapshot + snapshot = proto.state_snapshots.add() + snapshot.origin_time.CopyFrom(Timestamp(seconds=1696118400)) + snapshot.states.append(sensors_pb2.SENSOR_STATE_CODE_OK) + + warning = snapshot.warnings.add() + warning.diagnostic_code = sensors_pb2.SENSOR_DIAGNOSTIC_CODE_UNKNOWN + warning.message = "Minor issue" + + telemetry = sensor_telemetry_from_proto(proto) + + assert telemetry.sensor_id == SensorId(1) + assert len(telemetry.metric_samples) == 2 + assert len(telemetry.state_snapshots) == 1 + + # Check metric samples + metric_values = {s.value for s in telemetry.metric_samples} + assert 25.5 in metric_values + assert 60.0 in metric_values + + # Check state snapshot + snapshot_obj = next(iter(telemetry.state_snapshots)) + assert SensorStateCode.OK in snapshot_obj.states + assert len(snapshot_obj.warnings) == 1 + warning_obj = next(iter(snapshot_obj.warnings)) + assert warning_obj.diagnostic_code == SensorDiagnosticCode.UNKNOWN + + +def test_sensor_telemetry_from_proto_empty() -> None: + """Test converting empty SensorTelemetry from proto.""" + proto = sensors_pb2.SensorTelemetry(sensor_id=1) + + telemetry = sensor_telemetry_from_proto(proto) + + assert telemetry.sensor_id == SensorId(1) + assert len(telemetry.metric_samples) == 0 + assert len(telemetry.state_snapshots) == 0 + + +def test_sensor_telemetry_from_proto_only_metrics() -> None: + """Test converting SensorTelemetry with only metric samples.""" + proto = sensors_pb2.SensorTelemetry(sensor_id=1) + + sample = proto.metric_samples.add() + sample.metric = 1 # type: ignore[assignment] + sample.sample_time.CopyFrom(Timestamp(seconds=1696118400)) + sample.value.simple_metric.value = 25.5 + + telemetry = sensor_telemetry_from_proto(proto) + + assert telemetry.sensor_id == SensorId(1) + assert len(telemetry.metric_samples) == 1 + assert len(telemetry.state_snapshots) == 0 + + +def test_sensor_telemetry_from_proto_only_state() -> None: + """Test converting SensorTelemetry with only state snapshots.""" + proto = sensors_pb2.SensorTelemetry(sensor_id=1) + + snapshot = proto.state_snapshots.add() + snapshot.origin_time.CopyFrom(Timestamp(seconds=1696118400)) + snapshot.states.append(sensors_pb2.SENSOR_STATE_CODE_OK) + + telemetry = sensor_telemetry_from_proto(proto) + + assert telemetry.sensor_id == SensorId(1) + assert len(telemetry.metric_samples) == 0 + assert len(telemetry.state_snapshots) == 1 + + +def test_sensor_telemetry_from_proto_multiple_snapshots() -> None: + """Test converting SensorTelemetry with multiple state snapshots.""" + proto = sensors_pb2.SensorTelemetry(sensor_id=1) + + # First snapshot + snapshot1 = proto.state_snapshots.add() + snapshot1.origin_time.CopyFrom(Timestamp(seconds=1696118400)) + snapshot1.states.append(sensors_pb2.SENSOR_STATE_CODE_OK) + + # Second snapshot + snapshot2 = proto.state_snapshots.add() + snapshot2.origin_time.CopyFrom(Timestamp(seconds=1696118460)) # 1 min later + snapshot2.states.append(sensors_pb2.SENSOR_STATE_CODE_ERROR) + + telemetry = sensor_telemetry_from_proto(proto) + + assert telemetry.sensor_id == SensorId(1) + assert len(telemetry.state_snapshots) == 2 + + # Verify both snapshots are present + states = {next(iter(s.states)) for s in telemetry.state_snapshots if s.states} + assert SensorStateCode.OK in states + assert SensorStateCode.ERROR in states + + +def test_sensor_telemetry_from_proto_multiple_metrics() -> None: + """Test converting SensorTelemetry with multiple metric samples.""" + proto = sensors_pb2.SensorTelemetry(sensor_id=1) + + for i in range(5): + sample = proto.metric_samples.add() + sample.metric = i + 1 # type: ignore[assignment] + sample.sample_time.CopyFrom(Timestamp(seconds=1696118400 + i * 10)) + sample.value.simple_metric.value = float(i * 10) + + telemetry = sensor_telemetry_from_proto(proto) + + assert telemetry.sensor_id == SensorId(1) + assert len(telemetry.metric_samples) == 5 + + # Verify all values are present + metric_values = {s.value for s in telemetry.metric_samples} + assert metric_values == {0.0, 10.0, 20.0, 30.0, 40.0} + + +def test_sensor_telemetry_from_proto_complex() -> None: + """Test converting SensorTelemetry with complex data.""" + proto = sensors_pb2.SensorTelemetry(sensor_id=42) + + # Multiple metric samples + for i in range(3): + sample = proto.metric_samples.add() + sample.metric = i + 1 # type: ignore[assignment] + sample.sample_time.CopyFrom(Timestamp(seconds=1696118400)) + sample.value.simple_metric.value = float(i * 5) + + # Multiple state snapshots with diagnostics + snapshot1 = proto.state_snapshots.add() + snapshot1.origin_time.CopyFrom(Timestamp(seconds=1696118400)) + snapshot1.states.append(sensors_pb2.SENSOR_STATE_CODE_ERROR) + + error1 = snapshot1.errors.add() + error1.diagnostic_code = sensors_pb2.SENSOR_DIAGNOSTIC_CODE_INTERNAL + error1.message = "Critical error" + + snapshot2 = proto.state_snapshots.add() + snapshot2.origin_time.CopyFrom(Timestamp(seconds=1696118460)) + snapshot2.states.append(sensors_pb2.SENSOR_STATE_CODE_OK) + + telemetry = sensor_telemetry_from_proto(proto) + + assert telemetry.sensor_id == SensorId(42) + assert len(telemetry.metric_samples) == 3 + assert len(telemetry.state_snapshots) == 2 + + # Find the snapshot with errors + error_snapshot = next(s for s in telemetry.state_snapshots if s.errors) + assert len(error_snapshot.errors) == 1 + error_obj = next(iter(error_snapshot.errors)) + assert error_obj.diagnostic_code == SensorDiagnosticCode.INTERNAL + assert error_obj.message == "Critical error" From 079f2db436dda3fec7ba85072bb0c97b85eeb64c Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 18 Dec 2025 10:57:18 +0100 Subject: [PATCH 6/8] Improve sensor module documentation Now that all symbols are defined, we can enhance the module-level documentation to provide a clearer overview of the package's purpose and the data structures it offers for handling sensor telemetry. Signed-off-by: Leandro Lucarella --- .../client/microgrid/sensor/__init__.py | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/frequenz/client/microgrid/sensor/__init__.py b/src/frequenz/client/microgrid/sensor/__init__.py index 9ce261a..701e22f 100644 --- a/src/frequenz/client/microgrid/sensor/__init__.py +++ b/src/frequenz/client/microgrid/sensor/__init__.py @@ -1,11 +1,32 @@ # License: MIT # Copyright © 2025 Frequenz Energy-as-a-Service GmbH -"""Sensor types and utilities. +"""Microgrid sensors. -This module provides classes and utilities for working with sensors in a -microgrid environment. Sensors measure various physical metrics in the -surrounding environment, such as temperature, humidity, and solar irradiance. +This package provides classes and utilities for working with different types of +sensors in a microgrid environment. [`Sensor`][frequenz.client.microgrid.sensor.Sensor]s +measure various physical metrics in the surrounding environment, such as temperature, +humidity, and solar irradiance. + +# Sensor Telemetry + +This package also provides several data structures for handling sensor readings +and states: + +* [`SensorTelemetry`][frequenz.client.microgrid.sensor.SensorTelemetry]: + Represents a collection of measurements and states from a sensor at a specific + point in time, including [metric + samples][frequenz.client.microgrid.metrics.MetricSample] and [state + snapshots][frequenz.client.microgrid.sensor.SensorStateSnapshot]. +* [`SensorStateSnapshot`][frequenz.client.microgrid.sensor.SensorStateSnapshot]: + Contains the sensor's state codes and any associated diagnostic information. +* [`SensorDiagnostic`][frequenz.client.microgrid.sensor.SensorDiagnostic]: + Represents a diagnostic message from a sensor, including an error code and + optional additional information. +* [`SensorDiagnosticCode`][frequenz.client.microgrid.sensor.SensorDiagnosticCode]: + Defines error codes that a sensor can report. +* [`SensorStateCode`][frequenz.client.microgrid.sensor.SensorStateCode]: + Defines codes representing the operational state of a sensor. """ from ._sensor import Sensor From 08172c47581df6dce1f4b6a3e63f626c162fd546 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 20 Nov 2025 16:33:15 +0000 Subject: [PATCH 7/8] Add `receive_sensor_telemetry_stream()` method to `MicrogridApiClient` Signed-off-by: Leandro Lucarella --- src/frequenz/client/microgrid/_client.py | 88 +++++++- .../empty_case.py | 51 +++++ .../error_case.py | 86 ++++++++ .../filter_metrics_case.py | 75 +++++++ .../success_case.py | 194 ++++++++++++++++++ tests/test_client.py | 13 ++ 6 files changed, 498 insertions(+), 9 deletions(-) create mode 100644 tests/client_test_cases/receive_sensor_telemetry_stream/empty_case.py create mode 100644 tests/client_test_cases/receive_sensor_telemetry_stream/error_case.py create mode 100644 tests/client_test_cases/receive_sensor_telemetry_stream/filter_metrics_case.py create mode 100644 tests/client_test_cases/receive_sensor_telemetry_stream/success_case.py diff --git a/src/frequenz/client/microgrid/_client.py b/src/frequenz/client/microgrid/_client.py index 6971034..0a07c0c 100644 --- a/src/frequenz/client/microgrid/_client.py +++ b/src/frequenz/client/microgrid/_client.py @@ -12,7 +12,7 @@ from collections.abc import Iterable from dataclasses import replace from datetime import datetime, timedelta -from typing import Any, assert_never +from typing import Any, assert_never, cast from frequenz.api.common.v1alpha8.metrics import bounds_pb2, metrics_pb2 from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( @@ -43,6 +43,8 @@ from .metrics._metric import Metric from .sensor._sensor import Sensor from .sensor._sensor_proto import sensor_from_proto +from .sensor._telemetry import SensorTelemetry +from .sensor._telemetry_proto import sensor_telemetry_from_proto DEFAULT_GRPC_CALL_TIMEOUT = 60.0 """The default timeout for gRPC calls made by this client (in seconds).""" @@ -103,7 +105,10 @@ def __init__( ] = {} self._sensor_data_broadcasters: dict[ str, - streaming.GrpcStreamBroadcaster[Any, Any], + streaming.GrpcStreamBroadcaster[ + microgrid_pb2.ReceiveSensorTelemetryStreamResponse, + SensorTelemetry, + ], ] = {} self._retry_strategy = retry_strategy @@ -126,16 +131,19 @@ async def __aexit__( exc_tb: Any | None, ) -> bool | None: """Close the gRPC channel and stop all broadcasters.""" + all_broadcasters = cast( + list[streaming.GrpcStreamBroadcaster[Any, Any]], + list( + itertools.chain( + self._component_data_broadcasters.values(), + self._sensor_data_broadcasters.values(), + ) + ), + ) exceptions = list( exc for exc in await asyncio.gather( - *( - broadcaster.stop() - for broadcaster in itertools.chain( - self._component_data_broadcasters.values(), - self._sensor_data_broadcasters.values(), - ) - ), + *(broadcaster.stop() for broadcaster in all_broadcasters), return_exceptions=True, ) if isinstance(exc, BaseException) @@ -670,6 +678,68 @@ def receive_component_data_samples_stream( self._component_data_broadcasters[key] = broadcaster return broadcaster.new_receiver(maxsize=buffer_size) + def receive_sensor_telemetry_stream( + self, + sensor: SensorId | Sensor, + metrics: Iterable[Metric | int], + *, + buffer_size: int = 50, + ) -> Receiver[SensorTelemetry]: + """Stream telemetry data 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 telemetry stream from the sensor. + """ + sensor_id = _get_sensor_id(sensor) + metrics_set = frozenset([_get_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}" + # Alias to avoid too long lines linter errors + # pylint: disable-next=invalid-name + Request = microgrid_pb2.ReceiveSensorTelemetryStreamRequest + broadcaster = streaming.GrpcStreamBroadcaster( + stream_name, + lambda: aiter( + self.stub.ReceiveSensorTelemetryStream( + Request( + sensor_id=sensor_id, + filter=Request.SensorTelemetryStreamFilter( + metrics=metrics_set + ), + ), + ) + ), + lambda msg: sensor_telemetry_from_proto(msg.telemetry), + retry_strategy=self._retry_strategy, + ) + self._sensor_data_broadcasters[key] = broadcaster + return broadcaster.new_receiver(maxsize=buffer_size) + # pylint: disable-next=fixme # TODO: Remove this enum, now AugmentElectricalComponentBounds takes a simple timeout as diff --git a/tests/client_test_cases/receive_sensor_telemetry_stream/empty_case.py b/tests/client_test_cases/receive_sensor_telemetry_stream/empty_case.py new file mode 100644 index 0000000..20f3419 --- /dev/null +++ b/tests/client_test_cases/receive_sensor_telemetry_stream/empty_case.py @@ -0,0 +1,51 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for empty sensor telemetry stream.""" + +from typing import Any, TypeAlias + +import pytest +from frequenz.api.common.v1alpha8.metrics import metrics_pb2 +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.api.microgrid.v1alpha18 import microgrid_pb2 +from frequenz.channels import Receiver, ReceiverStoppedError +from frequenz.client.common.microgrid.sensors import SensorId + +from frequenz.client.microgrid.sensor import SensorTelemetry + +client_args = (SensorId(1), [metrics_pb2.Metric.METRIC_AC_CURRENT]) + +_Filter: TypeAlias = ( + microgrid_pb2.ReceiveSensorTelemetryStreamRequest.SensorTelemetryStreamFilter +) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ReceiveSensorTelemetryStreamRequest( + sensor_id=1, + filter=_Filter(metrics=[metrics_pb2.Metric.METRIC_AC_CURRENT]), + ) + ) + + +# The mock response from the server +grpc_response = microgrid_pb2.ReceiveSensorTelemetryStreamResponse( + telemetry=sensors_pb2.SensorTelemetry( + sensor_id=1, metric_samples=[], state_snapshots=[] + ), +) + + +# The expected result from the client method +async def assert_client_result(receiver: Receiver[Any]) -> None: + """Assert that the client result matches the expected empty data.""" + result = await receiver.receive() + assert result == SensorTelemetry( + sensor_id=SensorId(1), metric_samples=[], state_snapshots=[] + ) + + with pytest.raises(ReceiverStoppedError): + await receiver.receive() diff --git a/tests/client_test_cases/receive_sensor_telemetry_stream/error_case.py b/tests/client_test_cases/receive_sensor_telemetry_stream/error_case.py new file mode 100644 index 0000000..555e94b --- /dev/null +++ b/tests/client_test_cases/receive_sensor_telemetry_stream/error_case.py @@ -0,0 +1,86 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for sensor telemetry stream with error.""" + +import enum +from collections.abc import AsyncIterator +from typing import Any, TypeAlias + +import pytest +from frequenz.api.common.v1alpha8.metrics import metrics_pb2 +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.api.microgrid.v1alpha18 import microgrid_pb2 +from frequenz.channels import Receiver, ReceiverStoppedError +from frequenz.client.common.microgrid.sensors import SensorId +from grpc import StatusCode + +from frequenz.client.microgrid.sensor import SensorTelemetry +from tests.util import make_grpc_error + +client_args = (SensorId(1), [metrics_pb2.Metric.METRIC_AC_VOLTAGE]) + +_Filter: TypeAlias = ( + microgrid_pb2.ReceiveSensorTelemetryStreamRequest.SensorTelemetryStreamFilter +) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ReceiveSensorTelemetryStreamRequest( + sensor_id=1, + filter=_Filter(metrics=[metrics_pb2.Metric.METRIC_AC_VOLTAGE]), + ) + ) + + +@enum.unique +class _State(enum.Enum): + """State of the gRPC response simulation.""" + + INITIAL = "initial" + ERROR = "error" + RECEIVING = "receiving" + + +_iterations: int = 0 +_state: _State = _State.INITIAL + + +async def grpc_response() -> AsyncIterator[Any]: + """Simulate a gRPC response with an error on the first iteration.""" + global _iterations, _state # pylint: disable=global-statement + + _iterations += 1 + if _iterations == 1: + _state = _State.ERROR + raise make_grpc_error(StatusCode.UNAVAILABLE) + + _state = _State.RECEIVING + for _ in range(3): + yield microgrid_pb2.ReceiveSensorTelemetryStreamResponse( + telemetry=sensors_pb2.SensorTelemetry( + sensor_id=1, metric_samples=[], state_snapshots=[] + ), + ) + + +# The expected result from the client method (exception in this case) +async def assert_client_result(receiver: Receiver[Any]) -> None: + """Assert that the client can keep receiving data after an error.""" + assert _state is _State.ERROR + + async for result in receiver: + assert result == SensorTelemetry( + sensor_id=SensorId(1), metric_samples=[], state_snapshots=[] + ) + # We need the type ignore here because mypy doesn't realize _state is + # global and updated from outside this function, so it wrongly narrows + # its type to `Literal[_State.ERROR]`, and complaining about the + # impossibility of overlapping with _STATE.RECEIVING. + # https://github.com/python/mypy/issues/19283 + assert _state is _State.RECEIVING # type: ignore[comparison-overlap] + + with pytest.raises(ReceiverStoppedError): + await receiver.receive() diff --git a/tests/client_test_cases/receive_sensor_telemetry_stream/filter_metrics_case.py b/tests/client_test_cases/receive_sensor_telemetry_stream/filter_metrics_case.py new file mode 100644 index 0000000..9f7b2e6 --- /dev/null +++ b/tests/client_test_cases/receive_sensor_telemetry_stream/filter_metrics_case.py @@ -0,0 +1,75 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for sensor telemetry stream with metric filtering.""" + +from datetime import datetime, timezone +from typing import Any, TypeAlias + +import pytest +from frequenz.api.common.v1alpha8.metrics import metrics_pb2 +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.api.microgrid.v1alpha18 import microgrid_pb2 +from frequenz.channels import Receiver, ReceiverStoppedError +from frequenz.client.base.conversion import to_timestamp +from frequenz.client.common.microgrid.sensors import SensorId + +from frequenz.client.microgrid.metrics import Metric, MetricSample +from frequenz.client.microgrid.sensor import SensorTelemetry + +client_args = ( + SensorId(1), + [metrics_pb2.Metric.METRIC_AC_VOLTAGE], +) + +_Filter: TypeAlias = ( + microgrid_pb2.ReceiveSensorTelemetryStreamRequest.SensorTelemetryStreamFilter +) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once_with( + microgrid_pb2.ReceiveSensorTelemetryStreamRequest( + sensor_id=1, + filter=_Filter(metrics=[metrics_pb2.Metric.METRIC_AC_VOLTAGE]), + ) + ) + + +timestamp = datetime(2023, 10, 1, 12, 0, 0, tzinfo=timezone.utc) +timestamp_proto = to_timestamp(timestamp) +grpc_response = microgrid_pb2.ReceiveSensorTelemetryStreamResponse( + telemetry=sensors_pb2.SensorTelemetry( + sensor_id=1, + metric_samples=[ + metrics_pb2.MetricSample( + metric=metrics_pb2.Metric.METRIC_AC_VOLTAGE, + sample_time=timestamp_proto, + value=metrics_pb2.MetricValueVariant( + simple_metric=metrics_pb2.SimpleMetricValue(value=230.5), + ), + ), + ], + ), +) + + +async def assert_client_result(receiver: Receiver[Any]) -> None: + """Assert that the client result contains only the filtered metric.""" + result = await receiver.receive() + assert result == SensorTelemetry( + sensor_id=SensorId(1), + metric_samples=[ + MetricSample( + metric=Metric.AC_VOLTAGE, + sampled_at=timestamp, + value=pytest.approx(230.5), # type: ignore[arg-type] + bounds=[], + ), + ], + state_snapshots=[], + ) + + with pytest.raises(ReceiverStoppedError): + await receiver.receive() diff --git a/tests/client_test_cases/receive_sensor_telemetry_stream/success_case.py b/tests/client_test_cases/receive_sensor_telemetry_stream/success_case.py new file mode 100644 index 0000000..559da99 --- /dev/null +++ b/tests/client_test_cases/receive_sensor_telemetry_stream/success_case.py @@ -0,0 +1,194 @@ +# License: MIT +# Copyright © 2025 Frequenz Energy-as-a-Service GmbH + +"""Test data for successful sensor telemetry stream.""" + +from datetime import datetime, timezone +from typing import Any + +import pytest +from frequenz.api.common.v1alpha8.metrics import metrics_pb2 +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 +from frequenz.api.microgrid.v1alpha18 import microgrid_pb2 +from frequenz.channels import Receiver, ReceiverStoppedError +from frequenz.client.base.conversion import to_timestamp +from frequenz.client.common.microgrid.sensors import SensorId + +from frequenz.client.microgrid.metrics import Metric, MetricSample +from frequenz.client.microgrid.metrics._sample import AggregatedMetricValue +from frequenz.client.microgrid.sensor import ( + SensorStateCode, + SensorStateSnapshot, + SensorTelemetry, +) + +client_args = ( + SensorId(1), + [ + metrics_pb2.Metric.METRIC_AC_VOLTAGE, + metrics_pb2.Metric.METRIC_AC_CURRENT, + ], +) + + +def assert_stub_method_call(stub_method: Any) -> None: + """Assert that the gRPC request matches the expected request.""" + stub_method.assert_called_once() + called_args, called_kwargs = stub_method.call_args + assert called_kwargs == {} + assert len(called_args) == 1 + + req = called_args[0] + assert isinstance(req, microgrid_pb2.ReceiveSensorTelemetryStreamRequest) + assert req.sensor_id == 1 + + # The order of metrics in the filter is not guaranteed, so compare as a set. + expected_metrics = { + metrics_pb2.Metric.METRIC_AC_VOLTAGE, + metrics_pb2.Metric.METRIC_AC_CURRENT, + } + assert set(req.filter.metrics) == expected_metrics + + +timestamp = datetime(2023, 10, 1, 12, 0, 0, tzinfo=timezone.utc) +timestamp_proto = to_timestamp(timestamp) +grpc_response = [ + microgrid_pb2.ReceiveSensorTelemetryStreamResponse( + telemetry=sensors_pb2.SensorTelemetry( + sensor_id=1, + metric_samples=[ + metrics_pb2.MetricSample( + metric=metrics_pb2.Metric.METRIC_AC_VOLTAGE, + sample_time=timestamp_proto, + value=metrics_pb2.MetricValueVariant( + simple_metric=metrics_pb2.SimpleMetricValue(value=230.5), + ), + ), + metrics_pb2.MetricSample( + metric=metrics_pb2.Metric.METRIC_AC_CURRENT, + sample_time=timestamp_proto, + value=metrics_pb2.MetricValueVariant( + aggregated_metric=metrics_pb2.AggregatedMetricValue( + min_value=10.0, + max_value=10.5, + avg_value=10.2, + raw_values=[10.0, 10.1, 10.2, 10.3, 10.4, 10.5], + ), + ), + ), + ], + state_snapshots=[ + sensors_pb2.SensorStateSnapshot( + origin_time=timestamp_proto, + states=[sensors_pb2.SENSOR_STATE_CODE_OK], + ) + ], + ), + ), + microgrid_pb2.ReceiveSensorTelemetryStreamResponse( + telemetry=sensors_pb2.SensorTelemetry( + sensor_id=1, + metric_samples=[ + metrics_pb2.MetricSample( + metric=metrics_pb2.Metric.METRIC_AC_VOLTAGE, + sample_time=timestamp_proto, + value=metrics_pb2.MetricValueVariant( + simple_metric=metrics_pb2.SimpleMetricValue(value=231.5), + ), + ), + metrics_pb2.MetricSample( + metric=metrics_pb2.Metric.METRIC_AC_CURRENT, + sample_time=timestamp_proto, + value=metrics_pb2.MetricValueVariant( + aggregated_metric=metrics_pb2.AggregatedMetricValue( + min_value=12.0, + max_value=12.5, + avg_value=12.2, + raw_values=[12.0, 12.1, 12.2, 12.3, 12.4, 12.5], + ), + ), + ), + ], + state_snapshots=[ + sensors_pb2.SensorStateSnapshot( + origin_time=timestamp_proto, + states=[sensors_pb2.SENSOR_STATE_CODE_OK], + ) + ], + ), + ), +] + + +async def assert_client_result(receiver: Receiver[Any]) -> None: + """Assert that the client result matches the expected SensorTelemetry.""" + result = await receiver.receive() + assert result == SensorTelemetry( + sensor_id=SensorId(1), + metric_samples=[ + MetricSample( + metric=Metric.AC_VOLTAGE, + sampled_at=timestamp, + value=pytest.approx(230.5), # type: ignore[arg-type] + bounds=[], + ), + MetricSample( + metric=Metric.AC_CURRENT, + sampled_at=timestamp, + value=AggregatedMetricValue( + min=pytest.approx(10.0), # type: ignore[arg-type] + max=pytest.approx(10.5), # type: ignore[arg-type] + avg=pytest.approx(10.2), # type: ignore[arg-type] + raw_values=pytest.approx( # type: ignore[arg-type] + [10.0, 10.1, 10.2, 10.3, 10.4, 10.5] + ), + ), + bounds=[], + ), + ], + state_snapshots=[ + SensorStateSnapshot( + origin_time=timestamp, + states={SensorStateCode.OK}, + warnings=[], + errors=[], + ) + ], + ) + + result = await receiver.receive() + assert result == SensorTelemetry( + sensor_id=SensorId(1), + metric_samples=[ + MetricSample( + metric=Metric.AC_VOLTAGE, + sampled_at=timestamp, + value=pytest.approx(231.5), # type: ignore[arg-type] + bounds=[], + ), + MetricSample( + metric=Metric.AC_CURRENT, + sampled_at=timestamp, + value=AggregatedMetricValue( + min=pytest.approx(12.0), # type: ignore[arg-type] + max=pytest.approx(12.5), # type: ignore[arg-type] + avg=pytest.approx(12.2), # type: ignore[arg-type] + raw_values=pytest.approx( # type: ignore[arg-type] + [12.0, 12.1, 12.2, 12.3, 12.4, 12.5] + ), + ), + bounds=[], + ), + ], + state_snapshots=[ + SensorStateSnapshot( + origin_time=timestamp, + states={SensorStateCode.OK}, + warnings=[], + errors=[], + ) + ], + ) + + with pytest.raises(ReceiverStoppedError): + await receiver.receive() diff --git a/tests/test_client.py b/tests/test_client.py index 5bc6142..ec2a7ee 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -192,3 +192,16 @@ async def test_list_sensors( ) -> None: """Test list_sensors method.""" await spec.test_unary_unary_call(client, "ListSensors") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "spec", + get_test_specs("receive_sensor_telemetry_stream", tests_dir=TESTS_DIR), + ids=str, +) +async def test_receive_sensor_telemetry_stream( + client: MicrogridApiClient, spec: ApiClientTestCaseSpec +) -> None: + """Test receive_sensor_telemetry_stream method.""" + await spec.test_unary_stream_call(client, "ReceiveSensorTelemetryStream") From b8a0d640683f26a96451deff50c9545a897f1aee Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Thu, 18 Dec 2025 10:20:39 +0100 Subject: [PATCH 8/8] Update release notes And leave them ready for the v0.18.2 release. Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 174 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 169 insertions(+), 5 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6ebae30..0b48b67 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,12 +2,176 @@ ## Summary -This release adds a new `WindTurbine` component type. +This release reintroduces sensor support that was temporarily removed in the v0.18.0 release. The new sensor API has been redesigned to fit the updated component and metrics model introduced in v0.18.0. -## Upgrading +## New Features -- If you are using `match` and doing exhaustive matching on the `Component` types, you will get `mypy` errors and will need to handle the new `WindTurbine` type. +- New `sensor` module (`frequenz.client.microgrid.sensor`) with sensor related types. +- New `MicrogridApiClient` methods -## New Features + * `list_sensors()`: fetch sensor metadata. + * `receive_sensor_telemetry_stream()`: stream sensor telemetry data. + +Example: + +```python +import asyncio +from frequenz.client.microgrid import MicrogridApiClient +from frequenz.client.microgrid.metrics import Metric + +URL = "grpc://[::1]:62060" + +async def main() -> None: + print(f"Connecting to {URL}...") + async with MicrogridApiClient(URL) as client: + print("Listing available sensors...") + sensors = list(await client.list_sensors()) + + if not sensors: + print("No sensors found.") + return + + print(f"Found {len(sensors)}: {sensors}.") + print() + + sensor = sensors[0] + print(f"Streaming telemetry from sensor {sensor.id} ({sensor.name})...") + telemetry_stream = client.receive_sensor_telemetry_stream( + sensors[0].id, list(Metric) + ) + async for telemetry in telemetry_stream: + print(f"\tReceived: {telemetry}") + +asyncio.run(main()) +``` + +## Upgrading (from v0.9) + +### Sensor support restored with new API + +Sensor support that was removed in v0.18.0 is now back, but with a redesigned API that aligns with the v0.18.0 component and metrics model. + +#### `list_sensors()` + +The method name remains the same, but the signature and return type have changed: + +```python +# Old v0.9.1 API +sensors: Iterable[Sensor] = await client.list_sensors() + +# New v0.18.2 API (same method name, different types) +from frequenz.client.common.microgrid.sensors import SensorId +from frequenz.client.microgrid.sensor import Sensor + +sensors: Iterable[Sensor] = await client.list_sensors() + +# You can also filter by sensor IDs +sensors = await client.list_sensors(sensors=[SensorId(1), SensorId(2)]) +``` +The `Sensor` class now provides a new attribute `microgrid_id: MicrogridId` and the `identity` property now returns a tuple `(SensorId, MicrogridId)` instead of just `SensorId`. + +#### `stream_sensor_data()` → `receive_sensor_telemetry_stream()` + +The streaming method has been renamed and its return type changed: + +```python +# Old v0.9.1 API +from frequenz.client.microgrid.sensor import SensorDataSamples, SensorMetric + +receiver: Receiver[SensorDataSamples] = client.stream_sensor_data( + sensor=SensorId(1), + metrics=[SensorMetric.TEMPERATURE, SensorMetric.HUMIDITY], # optional +) + +async for samples in receiver: + # samples.metric_samples, samples.state_sample, etc. + ... + +# New v0.18.2 API +from frequenz.client.microgrid.sensor import SensorTelemetry +from frequenz.client.microgrid.metrics import Metric + +receiver: Receiver[SensorTelemetry] = client.receive_sensor_telemetry_stream( + sensor=SensorId(1), + metrics=[Metric.TEMPERATURE, Metric.AC_VOLTAGE], # required +) + +async for telemetry in receiver: + # telemetry.sensor_id: SensorId + # telemetry.metric_samples: Sequence[MetricSample] + # telemetry.state_snapshots: Sequence[SensorStateSnapshot] + for sample in telemetry.metric_samples: + print(f"{sample.metric}: {sample.value} at {sample.sampled_at}") + ... +``` + +Key differences: + +- **Method renamed**: `stream_sensor_data()` → `receive_sensor_telemetry_stream()` +- **Metrics parameter is now required**: You must specify which metrics to stream. The old API allowed `None` to stream all metrics. +- **Uses unified `Metric` enum**: The old `SensorMetric` enum is removed. Use `frequenz.client.microgrid.metrics.Metric` instead. +- **Return type changed**: `SensorDataSamples` → `SensorTelemetry` +- **State samples changed**: `SensorStateSample` → `SensorStateSnapshot` with different structure (see below) + +#### Sensor state types + +The sensor state types have been redesigned: + +| Old v0.9.1 type | New v0.18.2 type | +|---------------------------|-------------------------------------------| +| `SensorMetric` | *Removed* — use `Metric` | +| `SensorStateCode` | `SensorStateCode` (different values) | +| `SensorErrorCode` | `SensorDiagnosticCode` | +| `SensorStateSample` | `SensorStateSnapshot` | +| `SensorMetricSample` | `MetricSample` | +| `SensorDataSamples` | `SensorTelemetry` | + +The new `SensorStateSnapshot` structure: + +```python +@dataclass(frozen=True, kw_only=True) +class SensorStateSnapshot: + origin_time: datetime # was `sampled_at` + states: Set[SensorStateCode | int] # was `frozenset` + warnings: Sequence[SensorDiagnostic] # new + errors: Sequence[SensorDiagnostic] # replaces error codes +``` + +The new `SensorDiagnostic` provides richer error/warning information: + +```python +@dataclass(frozen=True, kw_only=True) +class SensorDiagnostic: + diagnostic_code: SensorDiagnosticCode | int + message: str | None + vendor_diagnostic_code: str | None +``` + +#### Import changes + +Update your imports for sensor types: + +```python +# Old v0.9.1 +from frequenz.client.microgrid.sensor import ( + Sensor, + SensorDataSamples, + SensorErrorCode, + SensorMetric, + SensorMetricSample, + SensorStateCode, + SensorStateSample, +) -- Add `WindTurbine` component type. +# New v0.18.2 +from frequenz.client.microgrid.sensor import ( + Sensor, + SensorDiagnostic, + SensorDiagnosticCode, + SensorStateCode, + SensorStateSnapshot, + SensorTelemetry, +) +from frequenz.client.microgrid.metrics import Metric, MetricSample +from frequenz.client.common.microgrid.sensors import SensorId +```