Skip to content

Commit a6d52cf

Browse files
committed
Add a Sensor class
This class will be used to list and retrieve sensors information from the microgrid. The class is based on version 0.17 of the microgrid API instead of the current 0.15 version, so users can already use the new interface and make migrating to 0.17 more straight forward. It is also implemented having in mind that sensor types will be removed in future releases (and it is actually not being set properly in the 0.15 service), so sensor types are being ignored. To be compatible with 0.17, sensors also have a "fake" lifetime that is not really retrieved from the microgrid (it is filled with a "eternal" lifetime, so sensors are present always). Signed-off-by: Leandro Lucarella <[email protected]>
1 parent b13f84e commit a6d52cf

File tree

4 files changed

+477
-0
lines changed

4 files changed

+477
-0
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Loading of SensorDataSamples objects from protobuf messages."""
5+
6+
import logging
7+
8+
from frequenz.api.common import components_pb2
9+
from frequenz.api.microgrid import microgrid_pb2
10+
11+
from ._id import SensorId
12+
from ._lifetime import Lifetime
13+
from .sensor import Sensor
14+
15+
_logger = logging.getLogger(__name__)
16+
17+
18+
def sensor_from_proto(message: microgrid_pb2.Component) -> Sensor:
19+
"""Convert a protobuf message to a `Sensor` instance.
20+
21+
Args:
22+
message: The protobuf message.
23+
24+
Returns:
25+
The resulting sensor instance.
26+
"""
27+
major_issues: list[str] = []
28+
minor_issues: list[str] = []
29+
30+
sensor = sensor_from_proto_with_issues(
31+
message, major_issues=major_issues, minor_issues=minor_issues
32+
)
33+
34+
if major_issues:
35+
_logger.warning(
36+
"Found issues in sensor: %s | Protobuf message:\n%s",
37+
", ".join(major_issues),
38+
message,
39+
)
40+
if minor_issues:
41+
_logger.debug(
42+
"Found minor issues in sensor: %s | Protobuf message:\n%s",
43+
", ".join(minor_issues),
44+
message,
45+
)
46+
47+
return sensor
48+
49+
50+
def sensor_from_proto_with_issues(
51+
message: microgrid_pb2.Component,
52+
*,
53+
major_issues: list[str],
54+
minor_issues: list[str],
55+
) -> Sensor:
56+
"""Convert a protobuf message to a sensor instance and collect issues.
57+
58+
Args:
59+
message: The protobuf message.
60+
major_issues: A list to append major issues to.
61+
minor_issues: A list to append minor issues to.
62+
63+
Returns:
64+
The resulting sensor instance.
65+
"""
66+
sensor_id = SensorId(message.id)
67+
68+
name = message.name or None
69+
if name is None:
70+
minor_issues.append("name is empty")
71+
72+
manufacturer = message.manufacturer or None
73+
if manufacturer is None:
74+
minor_issues.append("manufacturer is empty")
75+
76+
model_name = message.model_name or None
77+
if model_name is None:
78+
minor_issues.append("model_name is empty")
79+
80+
if (
81+
message.category
82+
is not components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR
83+
):
84+
major_issues.append(f"unexpected category for sensor ({message.category})")
85+
86+
return Sensor(
87+
id=sensor_id,
88+
name=name,
89+
manufacturer=manufacturer,
90+
model_name=model_name,
91+
operational_lifetime=Lifetime(),
92+
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Microgrid sensors.
5+
6+
This package provides classes and utilities for working with different types of
7+
sensors in a microgrid environment. [`Sensor`][frequenz.client.microgrid.sensor.Sensor]s
8+
measure various physical metrics in the surrounding environment, such as temperature,
9+
humidity, and solar irradiance.
10+
"""
11+
12+
import dataclasses
13+
14+
from ._id import SensorId
15+
from ._lifetime import Lifetime
16+
17+
18+
@dataclasses.dataclass(frozen=True, kw_only=True)
19+
class Sensor:
20+
"""Measures environmental metrics in the microgrid."""
21+
22+
id: SensorId
23+
"""This sensor's ID."""
24+
25+
name: str | None = None
26+
"""The name of this sensor."""
27+
28+
manufacturer: str | None = None
29+
"""The manufacturer of this sensor."""
30+
31+
model_name: str | None = None
32+
"""The model name of this sensor."""
33+
34+
operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime)
35+
"""The operational lifetime of this sensor."""
36+
37+
@property
38+
def identity(self) -> SensorId:
39+
"""The identity of this sensor.
40+
41+
This uses the sensor ID to identify a sensor without considering the
42+
other attributes, so even if a sensor state changed, the identity
43+
remains the same.
44+
"""
45+
return self.id
46+
47+
def __str__(self) -> str:
48+
"""Return a human-readable string representation of this instance."""
49+
name = f":{self.name}" if self.name else ""
50+
return f"<{type(self).__name__}:{self.id}{name}>"

tests/test_sensor.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the Sensor and sensor data classes."""
5+
6+
from datetime import datetime, timedelta, timezone
7+
8+
import pytest
9+
10+
from frequenz.client.microgrid import Lifetime, SensorId
11+
from frequenz.client.microgrid.sensor import Sensor
12+
13+
14+
@pytest.fixture
15+
def now() -> datetime:
16+
"""Get the current time."""
17+
return datetime.now(timezone.utc)
18+
19+
20+
def test_sensor_creation_defaults() -> None:
21+
"""Test Sensor defaults are as expected."""
22+
sensor = Sensor(id=SensorId(1))
23+
24+
assert sensor.id == SensorId(1)
25+
assert sensor.name is None
26+
assert sensor.manufacturer is None
27+
assert sensor.model_name is None
28+
assert sensor.operational_lifetime == Lifetime()
29+
30+
31+
def test_sensor_creation_full(now: datetime) -> None:
32+
"""Test Sensor creation with all fields."""
33+
start = now
34+
end = start + timedelta(days=1)
35+
sensor = Sensor(
36+
id=SensorId(1),
37+
name="test-sensor",
38+
manufacturer="Test Manufacturer",
39+
model_name="Test Model",
40+
operational_lifetime=Lifetime(
41+
start=start,
42+
end=end,
43+
),
44+
)
45+
46+
assert sensor.id == SensorId(1)
47+
assert sensor.name == "test-sensor"
48+
assert sensor.manufacturer == "Test Manufacturer"
49+
assert sensor.model_name == "Test Model"
50+
assert sensor.operational_lifetime.start == start
51+
assert sensor.operational_lifetime.end == end
52+
53+
54+
@pytest.mark.parametrize(
55+
"name,expected_str",
56+
[(None, "<Sensor:SID1>"), ("test-sensor", "<Sensor:SID1:test-sensor>")],
57+
ids=["no-name", "with-name"],
58+
)
59+
def test_sensor_str(name: str | None, expected_str: str) -> None:
60+
"""Test string representation of a sensor."""
61+
sensor = Sensor(
62+
id=SensorId(1),
63+
name=name,
64+
manufacturer="Test Manufacturer",
65+
model_name="Test Model",
66+
operational_lifetime=Lifetime(
67+
start=datetime.now(timezone.utc),
68+
end=datetime.now(timezone.utc) + timedelta(days=1),
69+
),
70+
)
71+
assert str(sensor) == expected_str
72+
73+
74+
_SENSOR = Sensor(
75+
id=SensorId(1),
76+
name="test",
77+
manufacturer="Test Mfg",
78+
model_name="Model A",
79+
)
80+
81+
_DIFFERENT_NAME = Sensor(
82+
id=_SENSOR.id,
83+
name="different",
84+
manufacturer=_SENSOR.manufacturer,
85+
model_name=_SENSOR.model_name,
86+
)
87+
88+
_DIFFERENT_ID = Sensor(
89+
id=SensorId(2),
90+
name=_SENSOR.name,
91+
manufacturer=_SENSOR.manufacturer,
92+
model_name=_SENSOR.model_name,
93+
)
94+
95+
_DIFFERENT_BOTH_ID = Sensor(
96+
id=SensorId(2),
97+
name=_SENSOR.name,
98+
manufacturer=_SENSOR.manufacturer,
99+
model_name=_SENSOR.model_name,
100+
)
101+
102+
103+
@pytest.mark.parametrize(
104+
"comp,expected",
105+
[
106+
pytest.param(_SENSOR, True, id="self"),
107+
pytest.param(_DIFFERENT_NAME, False, id="other-name"),
108+
pytest.param(_DIFFERENT_ID, False, id="other-id"),
109+
pytest.param(_DIFFERENT_BOTH_ID, False, id="other-both-ids"),
110+
],
111+
ids=lambda o: str(o.id) if isinstance(o, Sensor) else str(o),
112+
)
113+
def test_sensor_equality(comp: Sensor, expected: bool) -> None:
114+
"""Test sensor equality."""
115+
assert (_SENSOR == comp) is expected
116+
assert (comp == _SENSOR) is expected
117+
assert (_SENSOR != comp) is not expected
118+
assert (comp != _SENSOR) is not expected
119+
120+
121+
@pytest.mark.parametrize(
122+
"comp,expected",
123+
[
124+
pytest.param(_SENSOR, True, id="self"),
125+
pytest.param(_DIFFERENT_NAME, True, id="other-name"),
126+
pytest.param(_DIFFERENT_ID, False, id="other-id"),
127+
pytest.param(_DIFFERENT_BOTH_ID, False, id="other-both-ids"),
128+
],
129+
)
130+
def test_sensor_identity(comp: Sensor, expected: bool) -> None:
131+
"""Test sensor identity."""
132+
assert (_SENSOR.identity == comp.identity) is expected
133+
assert comp.identity == comp.id
134+
135+
136+
_ALL_SENSORS_PARAMS = [
137+
pytest.param(_SENSOR, id="comp"),
138+
pytest.param(_DIFFERENT_NAME, id="name"),
139+
pytest.param(_DIFFERENT_ID, id="id"),
140+
pytest.param(_DIFFERENT_BOTH_ID, id="both_ids"),
141+
]
142+
143+
144+
@pytest.mark.parametrize("comp1", _ALL_SENSORS_PARAMS)
145+
@pytest.mark.parametrize("comp2", _ALL_SENSORS_PARAMS)
146+
def test_sensor_hash(comp1: Sensor, comp2: Sensor) -> None:
147+
"""Test that the Sensor hash is consistent."""
148+
# We can only say the hash are the same if the sensors are equal, if they
149+
# are not, they could still have the same hash (and they will if they have
150+
# only different non-hashable attributes)
151+
if comp1 == comp2:
152+
assert hash(comp1) == hash(comp2)

0 commit comments

Comments
 (0)