Skip to content

Commit d5783b8

Browse files
committed
Add wrappers for sensor data
Again, these wrappers are done with 0.17 in mind, and trying to be forward-compatible, although sensor data in 0.15 is already more similar to 0.17 (compared to component data). Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 678a279 commit d5783b8

File tree

4 files changed

+724
-6
lines changed

4 files changed

+724
-6
lines changed

src/frequenz/client/microgrid/_sensor_proto.py

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,25 @@
44
"""Loading of SensorDataSamples objects from protobuf messages."""
55

66
import logging
7+
from collections.abc import Set
8+
from datetime import datetime
79

810
from frequenz.api.common import components_pb2
9-
from frequenz.api.microgrid import microgrid_pb2
11+
from frequenz.api.microgrid import common_pb2, microgrid_pb2, sensor_pb2
12+
from frequenz.client.base import conversion
1013

1114
from ._id import SensorId
1215
from ._lifetime import Lifetime
13-
from .sensor import Sensor
16+
from ._util import enum_from_proto
17+
from .sensor import (
18+
Sensor,
19+
SensorDataSamples,
20+
SensorErrorCode,
21+
SensorMetric,
22+
SensorMetricSample,
23+
SensorStateCode,
24+
SensorStateSample,
25+
)
1426

1527
_logger = logging.getLogger(__name__)
1628

@@ -90,3 +102,88 @@ def sensor_from_proto_with_issues(
90102
model_name=model_name,
91103
operational_lifetime=Lifetime(),
92104
)
105+
106+
107+
def sensor_data_samples_from_proto(
108+
message: microgrid_pb2.ComponentData,
109+
metrics: Set[sensor_pb2.SensorMetric.ValueType],
110+
) -> SensorDataSamples:
111+
"""Convert a protobuf component data message to a sensor data object.
112+
113+
Args:
114+
message: The protobuf message to convert.
115+
metrics: A set of metrics to filter the samples.
116+
117+
Returns:
118+
The resulting `SensorDataSamples` object.
119+
"""
120+
# At some point it might make sense to also log issues found in the samples, but
121+
# using a naive approach like in `component_from_proto` might spam the logs too
122+
# much, as we can receive several samples per second, and if a component is in
123+
# a unrecognized state for long, it will mean we will emit the same log message
124+
# again and again.
125+
ts = conversion.to_datetime(message.ts)
126+
return SensorDataSamples(
127+
sensor_id=SensorId(message.id),
128+
metrics=[
129+
sensor_metric_sample_from_proto(ts, sample)
130+
for sample in message.sensor.data.sensor_data
131+
if sample.sensor_metric in metrics
132+
],
133+
states=[sensor_state_sample_from_proto(ts, message.sensor)],
134+
)
135+
136+
137+
def sensor_metric_sample_from_proto(
138+
sampled_at: datetime, message: sensor_pb2.SensorData
139+
) -> SensorMetricSample:
140+
"""Convert a protobuf message to a `SensorMetricSample` object.
141+
142+
Args:
143+
sampled_at: The time at which the sample was taken.
144+
message: The protobuf message to convert.
145+
146+
Returns:
147+
The resulting `SensorMetricSample` object.
148+
"""
149+
return SensorMetricSample(
150+
sampled_at=sampled_at,
151+
metric=enum_from_proto(message.sensor_metric, SensorMetric),
152+
value=message.value,
153+
)
154+
155+
156+
def sensor_state_sample_from_proto(
157+
sampled_at: datetime, message: sensor_pb2.Sensor
158+
) -> SensorStateSample:
159+
"""Convert a protobuf message to a `SensorStateSample` object.
160+
161+
Args:
162+
sampled_at: The time at which the sample was taken.
163+
message: The protobuf message to convert.
164+
165+
Returns:
166+
The resulting `SensorStateSample` object.
167+
"""
168+
# In v0.15 the enum has 3 values, UNSPECIFIED, OK, and ERROR. In v0.17
169+
# (common v0.6), it also have 3 values with the same tags, but OK is renamed
170+
# to ON, so this conversion should work fine for both versions.
171+
state = enum_from_proto(message.state.component_state, SensorStateCode)
172+
errors: set[SensorErrorCode | int] = set()
173+
warnings: set[SensorErrorCode | int] = set()
174+
for error in message.errors:
175+
match error.level:
176+
case common_pb2.ErrorLevel.ERROR_LEVEL_CRITICAL:
177+
errors.add(enum_from_proto(error.code, SensorErrorCode))
178+
case common_pb2.ErrorLevel.ERROR_LEVEL_WARN:
179+
warnings.add(enum_from_proto(error.code, SensorErrorCode))
180+
case _:
181+
# If we don´t know the level we treat it as an error just to be safe.
182+
errors.add(enum_from_proto(error.code, SensorErrorCode))
183+
184+
return SensorStateSample(
185+
sampled_at=sampled_at,
186+
states=frozenset([state]),
187+
warnings=frozenset(warnings),
188+
errors=frozenset(errors),
189+
)

src/frequenz/client/microgrid/sensor.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,37 @@
77
sensors in a microgrid environment. [`Sensor`][frequenz.client.microgrid.sensor.Sensor]s
88
measure various physical metrics in the surrounding environment, such as temperature,
99
humidity, and solar irradiance.
10+
11+
# Streaming Sensor Data Samples
12+
13+
This package also provides several data structures for handling sensor readings
14+
and states:
15+
16+
* [`SensorDataSamples`][frequenz.client.microgrid.sensor.SensorDataSamples]:
17+
Represents a collection of sensor data samples.
18+
* [`SensorErrorCode`][frequenz.client.microgrid.sensor.SensorErrorCode]:
19+
Defines error codes that a sensor can report.
20+
* [`SensorMetric`][frequenz.client.microgrid.sensor.SensorMetric]: Enumerates
21+
the different metrics a sensor can measure (e.g., temperature, voltage).
22+
* [`SensorMetricSample`][frequenz.client.microgrid.sensor.SensorMetricSample]:
23+
Represents a single sample of a sensor metric, including its value and
24+
timestamp.
25+
* [`SensorStateCode`][frequenz.client.microgrid.sensor.SensorStateCode]:
26+
Defines codes representing the operational state of a sensor.
27+
* [`SensorStateSample`][frequenz.client.microgrid.sensor.SensorStateSample]:
28+
Represents a single sample of a sensor's state, including its state code
29+
and timestamp.
1030
"""
1131

1232
import dataclasses
33+
import enum
34+
from dataclasses import dataclass
35+
from datetime import datetime
36+
from typing import assert_never
1337

1438
from ._id import SensorId
1539
from ._lifetime import Lifetime
40+
from .metrics import AggregatedMetricValue, AggregationMethod
1641

1742

1843
@dataclasses.dataclass(frozen=True, kw_only=True)
@@ -48,3 +73,170 @@ def __str__(self) -> str:
4873
"""Return a human-readable string representation of this instance."""
4974
name = f":{self.name}" if self.name else ""
5075
return f"<{type(self).__name__}:{self.id}{name}>"
76+
77+
78+
@enum.unique
79+
class SensorMetric(enum.Enum):
80+
"""The metrics that can be reported by sensors in the microgrid.
81+
82+
These metrics correspond to various sensor readings primarily related to
83+
environmental conditions and physical measurements.
84+
"""
85+
86+
UNSPECIFIED = 0
87+
"""Default value (this should not be normally used and usually indicates an issue)."""
88+
89+
TEMPERATURE = 1
90+
"""Temperature, in Celsius (°C)."""
91+
92+
HUMIDITY = 2
93+
"""Humidity, in percentage (%)."""
94+
95+
PRESSURE = 3
96+
"""Pressure, in Pascal (Pa)."""
97+
98+
IRRADIANCE = 4
99+
"""Irradiance / Radiation flux, in watts per square meter (W / m²)."""
100+
101+
VELOCITY = 5
102+
"""Velocity, in meters per second (m / s)."""
103+
104+
ACCELERATION = 6
105+
"""Acceleration in meters per second per second (m / s²)."""
106+
107+
ANGLE = 7
108+
"""Angle, in degrees with respect to the (magnetic) North (°)."""
109+
110+
DEW_POINT = 8
111+
"""Dew point, in Celsius (°C).
112+
113+
The temperature at which the air becomes saturated with water vapor.
114+
"""
115+
116+
117+
@enum.unique
118+
class SensorStateCode(enum.Enum):
119+
"""The various states that a sensor can be in."""
120+
121+
UNSPECIFIED = 0
122+
"""Default value (this should not be normally used and usually indicates an issue)."""
123+
124+
ON = 1
125+
"""The sensor is up and running."""
126+
127+
ERROR = 2
128+
"""The sensor is in an error state."""
129+
130+
131+
@enum.unique
132+
class SensorErrorCode(enum.Enum):
133+
"""The various errors that can occur in sensors."""
134+
135+
UNSPECIFIED = 0
136+
"""Default value (this should not be normally used and usually indicates an issue)."""
137+
138+
UNKNOWN = 1
139+
"""An unknown or undefined error.
140+
141+
This is used when the error can be retrieved from the sensor but it doesn't match
142+
any known error or can't be interpreted for some reason.
143+
"""
144+
145+
INTERNAL = 2
146+
"""An internal error within the sensor."""
147+
148+
149+
@dataclass(frozen=True, kw_only=True)
150+
class SensorStateSample:
151+
"""A sample of state, warnings, and errors for a sensor at a specific time."""
152+
153+
sampled_at: datetime
154+
"""The time at which this state was sampled."""
155+
156+
states: frozenset[SensorStateCode | int]
157+
"""The set of states of the sensor.
158+
159+
If the reported state is not known by the client (it could happen when using an
160+
older version of the client with a newer version of the server), it will be
161+
represented as an `int` and **not** the
162+
[`SensorStateCode.UNSPECIFIED`][frequenz.client.microgrid.sensor.SensorStateCode.UNSPECIFIED]
163+
value (this value is used only when the state is not known by the server).
164+
"""
165+
166+
warnings: frozenset[SensorErrorCode | int]
167+
"""The set of warnings for the sensor."""
168+
169+
errors: frozenset[SensorErrorCode | int]
170+
"""The set of errors for the sensor.
171+
172+
This set will only contain errors if the sensor is in an error state.
173+
"""
174+
175+
176+
@dataclass(frozen=True, kw_only=True)
177+
class SensorMetricSample:
178+
"""A sample of a sensor metric at a specific time.
179+
180+
This represents a single sample of a specific metric, the value of which is either
181+
measured at a particular time.
182+
"""
183+
184+
sampled_at: datetime
185+
"""The moment when the metric was sampled."""
186+
187+
metric: SensorMetric | int
188+
"""The metric that was sampled."""
189+
190+
# In the protocol this is float | AggregatedMetricValue, but for live data we can't
191+
# receive the AggregatedMetricValue, so we limit this to float for now.
192+
value: float | AggregatedMetricValue | None
193+
"""The value of the sampled metric."""
194+
195+
def as_single_value(
196+
self, *, aggregation_method: AggregationMethod = AggregationMethod.AVG
197+
) -> float | None:
198+
"""Return the value of this sample as a single value.
199+
200+
if [`value`][frequenz.client.microgrid.sensor.SensorMetricSample.value] is a `float`,
201+
it is returned as is. If `value` is an
202+
[`AggregatedMetricValue`][frequenz.client.microgrid.metrics.AggregatedMetricValue],
203+
the value is aggregated using the provided `aggregation_method`.
204+
205+
Args:
206+
aggregation_method: The method to use to aggregate the value when `value` is
207+
a `AggregatedMetricValue`.
208+
209+
Returns:
210+
The value of the sample as a single value, or `None` if the value is `None`.
211+
"""
212+
match self.value:
213+
case float() | int():
214+
return self.value
215+
case AggregatedMetricValue():
216+
match aggregation_method:
217+
case AggregationMethod.AVG:
218+
return self.value.avg
219+
case AggregationMethod.MIN:
220+
return self.value.min
221+
case AggregationMethod.MAX:
222+
return self.value.max
223+
case unexpected:
224+
assert_never(unexpected)
225+
case None:
226+
return None
227+
case unexpected:
228+
assert_never(unexpected)
229+
230+
231+
@dataclass(frozen=True, kw_only=True)
232+
class SensorDataSamples:
233+
"""An aggregate of multiple metrics, states, and errors of a sensor."""
234+
235+
sensor_id: SensorId
236+
"""The unique identifier of the sensor."""
237+
238+
metrics: list[SensorMetricSample]
239+
"""The metrics sampled from the sensor."""
240+
241+
states: list[SensorStateSample]
242+
"""The states sampled from the sensor."""

0 commit comments

Comments
 (0)