From c3dc820864cba22f89c3ebc0ee1a4dd32598efe6 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 18 Sep 2026 16:20:01 +0000 Subject: [PATCH 1/4] Add the `Sensor` wrapper type The assets and microgrid clients each carry their own `Sensor` wrapper, so v0.4.1 should provide the shared one. It follows `Microgrid` and `ElectricalComponent`: a frozen keyword-only dataclass guarded by `_allow_construction`, an `operational_lifetime` typed `Lifetime | InvalidLifetime` (defaulting to an unbounded lifetime, as a missing wire field means) with the raising `get_operational_lifetime()`, `is_operational_at()` and `is_operational_now()` accessors, an `identity` property, and a `SID1:name` string form. The fields follow the wire names of `v1alpha8` `Sensor`: `model` (not the downstream `model_name`), and `name` as a plain `str` that is empty when unset, like every other wrapper here. The deprecated `manufacturer` and `model_name` wire fields are not wrapped, as `ElectricalComponent` does for its own copies of them. Signed-off-by: Leandro Lucarella --- .../common/microgrid/sensors/__init__.py | 2 + .../common/microgrid/sensors/_sensor.py | 131 ++++++++++++ tests/microgrid/sensors/__init__.py | 4 + tests/microgrid/sensors/test_sensor.py | 190 ++++++++++++++++++ 4 files changed, 327 insertions(+) create mode 100644 src/frequenz/client/common/microgrid/sensors/_sensor.py create mode 100644 tests/microgrid/sensors/__init__.py create mode 100644 tests/microgrid/sensors/test_sensor.py diff --git a/src/frequenz/client/common/microgrid/sensors/__init__.py b/src/frequenz/client/common/microgrid/sensors/__init__.py index 8b5fa18c..bece60df 100644 --- a/src/frequenz/client/common/microgrid/sensors/__init__.py +++ b/src/frequenz/client/common/microgrid/sensors/__init__.py @@ -4,7 +4,9 @@ """Frequenz microgrid sensors definition.""" from ._id import SensorId +from ._sensor import Sensor __all__ = [ + "Sensor", "SensorId", ] diff --git a/src/frequenz/client/common/microgrid/sensors/_sensor.py b/src/frequenz/client/common/microgrid/sensors/_sensor.py new file mode 100644 index 00000000..691c5136 --- /dev/null +++ b/src/frequenz/client/common/microgrid/sensors/_sensor.py @@ -0,0 +1,131 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Definition of a microgrid sensor.""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import assert_never + +from .._ids import MicrogridId +from .._lifetime import InvalidLifetime, InvalidLifetimeError, Lifetime +from ._id import SensorId + + +@dataclass(frozen=True, kw_only=True) +class Sensor: + """A sensor that measures a physical metric 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 + """The unique identifier of the sensor.""" + + microgrid_id: MicrogridId + """The unique identifier of the parent microgrid.""" + + name: str + """The name of the sensor. + + An empty string when the wire did not set it. + """ + + model: str + """The model of the sensor. + + This includes both the manufacturer and the model name. + """ + + operational_lifetime: Lifetime | InvalidLifetime = field(default_factory=Lifetime) + """The operational lifetime of the sensor. + + An [`InvalidLifetime`][....InvalidLifetime] preserves malformed wire data. + + Tip: + Prefer [`get_operational_lifetime()`][..get_operational_lifetime] when + a valid lifetime is required. + """ + + _allow_construction: bool = field( + default=False, repr=False, compare=False, hash=False + ) + """Internal guard allowing construction only via the `sensor_from_proto` converter.""" + + def __post_init__(self) -> None: + """Reject direct construction of this read-only type. + + Raises: + TypeError: If the instance was not created via the + [`sensor_from_proto`][...proto.v1alpha8.sensor_from_proto] + converter. + """ + if not self._allow_construction: + raise TypeError( + f"{type(self).__name__} cannot be constructed directly; obtain " + "instances via the sensor_from_proto converter." + ) + + def get_operational_lifetime(self) -> Lifetime: + """Return the operational lifetime as a valid `Lifetime`. + + Returns: + The valid operational lifetime. + + Raises: + InvalidLifetimeError: If malformed lifetime data was received. The + offending value is available on the exception's `lifetime` + attribute. + """ + match self.operational_lifetime: + case InvalidLifetime() as invalid: + raise InvalidLifetimeError(self, "operational_lifetime", invalid) + case Lifetime() as valid: + return valid + case unknown: + assert_never(unknown) + + def is_operational_at(self, timestamp: datetime) -> bool: # noqa: DOC502 + """Check whether this sensor is operational at a specific timestamp. + + Args: + timestamp: The timestamp to check. + + Returns: + Whether this sensor is operational at the given timestamp. + + Raises: + InvalidLifetimeError: If malformed lifetime data was received. The + offending value is available on the exception's `lifetime` + attribute. + """ + return self.get_operational_lifetime().is_operational_at(timestamp) + + def is_operational_now(self) -> bool: # noqa: DOC502 + """Check whether this sensor is currently operational. + + Returns: + Whether this sensor is operational at the current time. + + Raises: + InvalidLifetimeError: If malformed lifetime data was received. The + offending value is available on the exception's `lifetime` + attribute. + """ + return self.is_operational_at(datetime.now(timezone.utc)) + + @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 the ID of this sensor as a string, followed by its name if any.""" + name = f":{self.name}" if self.name else "" + return f"{self.id}{name}" diff --git a/tests/microgrid/sensors/__init__.py b/tests/microgrid/sensors/__init__.py new file mode 100644 index 00000000..c9c9ab25 --- /dev/null +++ b/tests/microgrid/sensors/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the microgrid.sensors package.""" diff --git a/tests/microgrid/sensors/test_sensor.py b/tests/microgrid/sensors/test_sensor.py new file mode 100644 index 00000000..a0ebe0f6 --- /dev/null +++ b/tests/microgrid/sensors/test_sensor.py @@ -0,0 +1,190 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Sensor type.""" + +import dataclasses +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest + +from frequenz.client.common.microgrid import ( + InvalidLifetime, + InvalidLifetimeError, + Lifetime, + MicrogridId, +) +from frequenz.client.common.microgrid.sensors import Sensor, SensorId + + +def _make_sensor( + *, + sensor_id: int = 1234, + microgrid_id: int = 5678, + name: str = "Test Sensor", + model: str = "ACME Thermometer", + operational_lifetime: Lifetime | InvalidLifetime = Lifetime(), +) -> Sensor: + """Build a Sensor bypassing the construction guard.""" + return Sensor( + id=SensorId(sensor_id), + microgrid_id=MicrogridId(microgrid_id), + name=name, + model=model, + operational_lifetime=operational_lifetime, + _allow_construction=True, + ) + + +def test_creation() -> None: + """Test Sensor creation with all fields.""" + lifetime = Lifetime(start_time=datetime(2025, 1, 1, tzinfo=timezone.utc)) + sensor = _make_sensor(operational_lifetime=lifetime) + + assert sensor.id == SensorId(1234) + assert sensor.microgrid_id == MicrogridId(5678) + assert sensor.name == "Test Sensor" + assert sensor.model == "ACME Thermometer" + assert sensor.operational_lifetime is lifetime + + +def test_creation_defaults_to_unbounded_lifetime() -> None: + """The operational lifetime defaults to an unbounded `Lifetime`.""" + sensor = Sensor( + id=SensorId(1234), + microgrid_id=MicrogridId(5678), + name="", + model="", + _allow_construction=True, + ) + assert sensor.operational_lifetime == Lifetime() + + +def test_direct_construction_raises() -> None: + """Constructing a Sensor without the gate flag raises TypeError.""" + with pytest.raises(TypeError, match="cannot be constructed directly"): + Sensor( + id=SensorId(1234), + microgrid_id=MicrogridId(5678), + name="", + model="", + ) + + +def test_replace_preserves_construction() -> None: + """`dataclasses.replace` on a gated instance works.""" + sensor = _make_sensor() + replaced = dataclasses.replace(sensor, name="renamed") + assert replaced.name == "renamed" + assert replaced.id == sensor.id + + +def test_frozen() -> None: + """Sensor instances are immutable.""" + sensor = _make_sensor() + with pytest.raises(dataclasses.FrozenInstanceError): + sensor.name = "other" # type: ignore[misc] + + +@pytest.mark.parametrize( + "name,expected_str", + [ + pytest.param("Test Sensor", "SID1234:Test Sensor", id="with-name"), + pytest.param("", "SID1234", id="empty-name"), + ], +) +def test_str(name: str, expected_str: str) -> None: + """Test string representation of Sensor.""" + assert str(_make_sensor(name=name)) == expected_str + + +def test_get_operational_lifetime_returns_valid() -> None: + """`get_operational_lifetime()` returns a valid lifetime unchanged.""" + lifetime = Lifetime() + sensor = _make_sensor(operational_lifetime=lifetime) + assert sensor.get_operational_lifetime() is lifetime + + +def test_get_operational_lifetime_raises_invalid() -> None: + """`get_operational_lifetime()` raises with the malformed lifetime attached.""" + invalid = InvalidLifetime( + start_time=datetime(2025, 2, 1, tzinfo=timezone.utc), + end_time=datetime(2025, 1, 1, tzinfo=timezone.utc), + ) + sensor = _make_sensor(operational_lifetime=invalid) + + with pytest.raises(InvalidLifetimeError) as exc_info: + sensor.get_operational_lifetime() + assert exc_info.value.lifetime is invalid + + +def test_get_operational_lifetime_error_is_value_error() -> None: + """The `InvalidLifetimeError` raised by the accessor is also a `ValueError`.""" + sensor = _make_sensor(operational_lifetime=InvalidLifetime()) + with pytest.raises(ValueError): + sensor.get_operational_lifetime() + + +@pytest.mark.parametrize( + "is_operational", [True, False], ids=["operational", "not-operational"] +) +def test_is_operational_at(is_operational: bool) -> None: + """`is_operational_at()` delegates to the lifetime.""" + mock_lifetime = Mock(spec=Lifetime) + mock_lifetime.is_operational_at.return_value = is_operational + sensor = _make_sensor(operational_lifetime=mock_lifetime) + timestamp = datetime(2025, 1, 1, tzinfo=timezone.utc) + + assert sensor.is_operational_at(timestamp) is is_operational + + mock_lifetime.is_operational_at.assert_called_once_with(timestamp) + + +def test_is_operational_at_raises_for_invalid_lifetime() -> None: + """`is_operational_at()` raises when the lifetime is invalid.""" + sensor = _make_sensor(operational_lifetime=InvalidLifetime()) + with pytest.raises(InvalidLifetimeError): + sensor.is_operational_at(datetime(2025, 1, 1, tzinfo=timezone.utc)) + + +@patch("frequenz.client.common.microgrid.sensors._sensor.datetime") +def test_is_operational_now(mock_datetime: Mock) -> None: + """`is_operational_now()` checks the lifetime at the current UTC time.""" + now = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + mock_datetime.now.side_effect = lambda tz: now.replace(tzinfo=tz) + mock_lifetime = Mock(spec=Lifetime) + mock_lifetime.is_operational_at.return_value = True + sensor = _make_sensor(operational_lifetime=mock_lifetime) + + assert sensor.is_operational_now() is True + + mock_lifetime.is_operational_at.assert_called_once_with(now) + + +def test_is_operational_now_raises_for_invalid_lifetime() -> None: + """`is_operational_now()` raises when the lifetime is invalid.""" + sensor = _make_sensor(operational_lifetime=InvalidLifetime()) + with pytest.raises(InvalidLifetimeError): + sensor.is_operational_now() + + +def test_identity() -> None: + """The identity is the sensor ID and microgrid ID only.""" + sensor = _make_sensor() + + assert sensor.identity == (SensorId(1234), MicrogridId(5678)) + assert sensor.identity == _make_sensor(name="other", model="other").identity + assert sensor.identity != _make_sensor(sensor_id=1).identity + assert sensor.identity != _make_sensor(microgrid_id=1).identity + + +def test_equality_and_hash() -> None: + """Equal sensors compare equal and hash the same; the guard is ignored.""" + sensor = _make_sensor() + same = _make_sensor() + different = _make_sensor(name="other") + + assert sensor == same + assert hash(sensor) == hash(same) + assert sensor != different From a95d17cfdcc12e03b3d3e2b874e672265099211d Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 18 Sep 2026 16:20:09 +0000 Subject: [PATCH 2/4] Add `sensor_from_proto` for `v1alpha8` Add the `microgrid.sensors.proto.v1alpha8` package with the conversion function for the `frequenz.api.common.v1alpha8.microgrid.sensors.Sensor` message. Malformed input is kept in the returned object: a malformed operational lifetime becomes an `InvalidLifetime` through `lifetime_from_proto`, and a missing one an unbounded `Lifetime`, as `electrical_component_from_proto` does. Signed-off-by: Leandro Lucarella --- .../microgrid/sensors/proto/__init__.py | 4 + .../sensors/proto/v1alpha8/__init__.py | 10 ++ .../sensors/proto/v1alpha8/_sensor.py | 40 ++++++ tests/microgrid/sensors/proto/__init__.py | 4 + .../sensors/proto/v1alpha8/__init__.py | 4 + .../sensors/proto/v1alpha8/test_sensor.py | 135 ++++++++++++++++++ 6 files changed, 197 insertions(+) create mode 100644 src/frequenz/client/common/microgrid/sensors/proto/__init__.py create mode 100644 src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/__init__.py create mode 100644 src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/_sensor.py create mode 100644 tests/microgrid/sensors/proto/__init__.py create mode 100644 tests/microgrid/sensors/proto/v1alpha8/__init__.py create mode 100644 tests/microgrid/sensors/proto/v1alpha8/test_sensor.py diff --git a/src/frequenz/client/common/microgrid/sensors/proto/__init__.py b/src/frequenz/client/common/microgrid/sensors/proto/__init__.py new file mode 100644 index 00000000..d955c44a --- /dev/null +++ b/src/frequenz/client/common/microgrid/sensors/proto/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Microgrid sensor objects from/to proto conversion functions.""" diff --git a/src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/__init__.py b/src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/__init__.py new file mode 100644 index 00000000..e85a7c19 --- /dev/null +++ b/src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/__init__.py @@ -0,0 +1,10 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Conversion of microgrid sensor objects from/to protobuf v1alpha8.""" + +from ._sensor import sensor_from_proto + +__all__ = [ + "sensor_from_proto", +] diff --git a/src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/_sensor.py b/src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/_sensor.py new file mode 100644 index 00000000..3e8af4de --- /dev/null +++ b/src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/_sensor.py @@ -0,0 +1,40 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Loading of Sensor objects from protobuf messages.""" + +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 + +from ...._ids import MicrogridId +from ...._lifetime import InvalidLifetime, Lifetime +from ....proto.v1alpha8._lifetime import lifetime_from_proto +from ..._id import SensorId +from ..._sensor import Sensor + + +def sensor_from_proto(message: sensors_pb2.Sensor) -> Sensor: + """Convert a protobuf message to a [`Sensor`][....Sensor] object. + + Malformed input is surfaced through the returned object rather than a side + channel: a malformed operational lifetime becomes an + [`InvalidLifetime`][.....InvalidLifetime]. A missing operational lifetime + becomes an unbounded [`Lifetime`][.....Lifetime]. + + Args: + message: The protobuf message to convert. + + Returns: + The corresponding [`Sensor`][....Sensor] object. + """ + operational_lifetime: Lifetime | InvalidLifetime = Lifetime() + if message.HasField("operational_lifetime"): + operational_lifetime = lifetime_from_proto(message.operational_lifetime) + + return Sensor( + id=SensorId(message.id), + microgrid_id=MicrogridId(message.microgrid_id), + name=message.name, + model=message.model, + operational_lifetime=operational_lifetime, + _allow_construction=True, + ) diff --git a/tests/microgrid/sensors/proto/__init__.py b/tests/microgrid/sensors/proto/__init__.py new file mode 100644 index 00000000..62e48425 --- /dev/null +++ b/tests/microgrid/sensors/proto/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the microgrid.sensors.proto package.""" diff --git a/tests/microgrid/sensors/proto/v1alpha8/__init__.py b/tests/microgrid/sensors/proto/v1alpha8/__init__.py new file mode 100644 index 00000000..6e0d746e --- /dev/null +++ b/tests/microgrid/sensors/proto/v1alpha8/__init__.py @@ -0,0 +1,4 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the microgrid.sensors.proto.v1alpha8 package.""" diff --git a/tests/microgrid/sensors/proto/v1alpha8/test_sensor.py b/tests/microgrid/sensors/proto/v1alpha8/test_sensor.py new file mode 100644 index 00000000..dd8093cb --- /dev/null +++ b/tests/microgrid/sensors/proto/v1alpha8/test_sensor.py @@ -0,0 +1,135 @@ +# License: MIT +# Copyright © 2026 Frequenz Energy-as-a-Service GmbH + +"""Tests for the Sensor protobuf conversion.""" + +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +import pytest +from frequenz.api.common.v1alpha8.microgrid import lifetime_pb2 +from frequenz.api.common.v1alpha8.microgrid.sensors import sensors_pb2 + +# pylint: disable-next=no-name-in-module +from google.protobuf.timestamp_pb2 import Timestamp + +from frequenz.client.common.microgrid import ( + InvalidLifetime, + InvalidLifetimeError, + Lifetime, + MicrogridId, +) +from frequenz.client.common.microgrid.sensors import Sensor, SensorId +from frequenz.client.common.microgrid.sensors.proto.v1alpha8 import ( + sensor_from_proto, +) + + +def test_from_proto_all_fields() -> None: + """Every wire field is converted into the wrapper.""" + start = datetime(2025, 1, 1, tzinfo=timezone.utc) + proto = sensors_pb2.Sensor( + id=1234, + microgrid_id=5678, + name="Test Sensor", + model="ACME Thermometer", + operational_lifetime=lifetime_pb2.Lifetime( + start_timestamp=Timestamp(seconds=int(start.timestamp())) + ), + ) + + sensor = sensor_from_proto(proto) + + assert isinstance(sensor, Sensor) + assert sensor.id == SensorId(1234) + assert sensor.microgrid_id == MicrogridId(5678) + assert sensor.name == "Test Sensor" + assert sensor.model == "ACME Thermometer" + assert sensor.operational_lifetime == Lifetime(start_time=start) + assert sensor.get_operational_lifetime() == Lifetime(start_time=start) + + +def test_from_proto_empty_strings() -> None: + """Unset string fields are kept as empty strings.""" + sensor = sensor_from_proto(sensors_pb2.Sensor(id=1, microgrid_id=2)) + + assert sensor.name == "" + assert sensor.model == "" + + +def test_from_proto_missing_lifetime_is_unbounded() -> None: + """A missing operational lifetime becomes an unbounded `Lifetime`.""" + sensor = sensor_from_proto(sensors_pb2.Sensor(id=1, microgrid_id=2)) + + assert sensor.operational_lifetime == Lifetime() + assert sensor.is_operational_now() is True + + +def test_from_proto_empty_lifetime_is_unbounded() -> None: + """A present but empty operational lifetime becomes an unbounded `Lifetime`.""" + proto = sensors_pb2.Sensor( + id=1, microgrid_id=2, operational_lifetime=lifetime_pb2.Lifetime() + ) + + sensor = sensor_from_proto(proto) + + assert sensor.operational_lifetime == Lifetime() + + +@patch( + "frequenz.client.common.microgrid.sensors.proto.v1alpha8._sensor.lifetime_from_proto" +) +def test_from_proto_delegates_lifetime(mock_lifetime_from_proto: Mock) -> None: + """The lifetime conversion is delegated to `lifetime_from_proto`.""" + lifetime = Lifetime() + mock_lifetime_from_proto.return_value = lifetime + proto = sensors_pb2.Sensor( + id=1, microgrid_id=2, operational_lifetime=lifetime_pb2.Lifetime() + ) + + sensor = sensor_from_proto(proto) + + mock_lifetime_from_proto.assert_called_once_with(proto.operational_lifetime) + assert sensor.operational_lifetime is lifetime + + +@patch( + "frequenz.client.common.microgrid.sensors.proto.v1alpha8._sensor.lifetime_from_proto" +) +def test_from_proto_missing_lifetime_skips_delegation( + mock_lifetime_from_proto: Mock, +) -> None: + """A missing lifetime does not call `lifetime_from_proto`.""" + sensor_from_proto(sensors_pb2.Sensor(id=1, microgrid_id=2)) + + mock_lifetime_from_proto.assert_not_called() + + +@pytest.mark.parametrize( + "lifetime", + [ + pytest.param( + lifetime_pb2.Lifetime( + start_timestamp=Timestamp(seconds=200), + end_timestamp=Timestamp(seconds=100), + ), + id="reversed-range", + ), + pytest.param( + lifetime_pb2.Lifetime(start_timestamp=Timestamp(seconds=0, nanos=-1)), + id="negative-nanos", + ), + ], +) +def test_from_proto_malformed_lifetime_is_preserved( + lifetime: lifetime_pb2.Lifetime, +) -> None: + """A malformed operational lifetime is preserved as an `InvalidLifetime`.""" + proto = sensors_pb2.Sensor(id=1, microgrid_id=2, operational_lifetime=lifetime) + + sensor = sensor_from_proto(proto) + + assert isinstance(sensor.operational_lifetime, InvalidLifetime) + with pytest.raises(InvalidLifetimeError) as exc_info: + sensor.get_operational_lifetime() + assert exc_info.value.lifetime is sensor.operational_lifetime From f8d4fa755b5d48b984a46d4c6b2a7fc4a0a11b4e Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 18 Sep 2026 16:20:09 +0000 Subject: [PATCH 3/4] Document the `Sensor` wrapper Add the New Features release note and list `Sensor` in the User Guide overview next to `SensorId`. Signed-off-by: Leandro Lucarella --- docs/user-guide/overview.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/overview.md b/docs/user-guide/overview.md index e57c4280..ad8e7f1e 100644 --- a/docs/user-guide/overview.md +++ b/docs/user-guide/overview.md @@ -72,8 +72,13 @@ See the [API Reference](../reference/frequenz/client/common/index.md) for the co ## Sensors -This namespace provides [`SensorId`][frequenz.client.common.microgrid.sensors.SensorId] -for sensor identities. +Use [`Sensor`][frequenz.client.common.microgrid.sensors.Sensor] for a sensor +that measures a physical metric in the microgrid's surroundings, with +[`SensorId`][frequenz.client.common.microgrid.sensors.SensorId] for sensor +identities. Its operational lifetime is a +[`Lifetime`][frequenz.client.common.microgrid.Lifetime] or an +[`InvalidLifetime`][frequenz.client.common.microgrid.InvalidLifetime], resolved +by `get_operational_lifetime()`. ## Common types From c48e1e8c31507fb67ab024f81cae8084b6d68528 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Fri, 25 Sep 2026 17:09:18 +0200 Subject: [PATCH 4/4] Update release notes Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 13cf6c95..35ea0f5e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -223,6 +223,8 @@ There are a few intentional hard breaks too, all listed in the Upgrading section * Added a new `frequenz.client.common.microgrid.Microgrid` type with a raising `is_active()` method, together with the `frequenz.client.common.microgrid.proto.v1alpha8.microgrid_from_proto` conversion function. +* Added a new `frequenz.client.common.microgrid.sensors.Sensor` type, with an `operational_lifetime` typed `Lifetime | InvalidLifetime` and the raising `get_operational_lifetime()`, `is_operational_at()` and `is_operational_now()` accessors, together with the `frequenz.client.common.microgrid.sensors.proto.v1alpha8.sensor_from_proto` conversion function. + * Added a new `frequenz.client.common.microgrid.electrical_components` package, featuring a `ElectricalComponent` class hierarchy and its families (battery, inverter, EV charger, etc.), and `ElectricalComponentConnection` class hierarchy, including `v1alpha8` proto conversion functions. The class of a component is its identity; components don't carry category or type attributes. The only exceptions are the error-recovery classes `UnrecognizedElectricalComponent` and `MismatchedCategoryElectricalComponent` (with a raw protobuf `category` value) and `UnrecognizedBattery`, `UnrecognizedInverter` and `UnrecognizedEvCharger` (with a raw protobuf `type` value), which preserve the raw protobuf values received from the protocol version used to load them.