Repository navigation
Add Sensor wrappers
#290
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Add Sensor wrappers
#290
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
131 changes: 131 additions & 0 deletions
131
src/frequenz/client/common/microgrid/sensors/_sensor.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}" |
4 changes: 4 additions & 0 deletions
4
src/frequenz/client/common/microgrid/sensors/proto/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # License: MIT | ||
| # Copyright © 2026 Frequenz Energy-as-a-Service GmbH | ||
|
|
||
| """Microgrid sensor objects from/to proto conversion functions.""" |
10 changes: 10 additions & 0 deletions
10
src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ] |
40 changes: 40 additions & 0 deletions
40
src/frequenz/client/common/microgrid/sensors/proto/v1alpha8/_sensor.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # License: MIT | ||
| # Copyright © 2026 Frequenz Energy-as-a-Service GmbH | ||
|
|
||
| """Tests for the microgrid.sensors package.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # License: MIT | ||
| # Copyright © 2026 Frequenz Energy-as-a-Service GmbH | ||
|
|
||
| """Tests for the microgrid.sensors.proto package.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # License: MIT | ||
| # Copyright © 2026 Frequenz Energy-as-a-Service GmbH | ||
|
|
||
| """Tests for the microgrid.sensors.proto.v1alpha8 package.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.