Skip to content

Commit 7442b55

Browse files
committed
Add MetricSample
This will be used to list electrical components. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 6840ced commit 7442b55

File tree

4 files changed

+547
-27
lines changed

4 files changed

+547
-27
lines changed

src/frequenz/client/microgrid/metrics/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55

66
from ._bounds import Bounds
77
from ._metric import Metric
8-
from ._sample import AggregatedMetricValue, AggregationMethod
8+
from ._sample import AggregatedMetricValue, AggregationMethod, MetricSample
99

1010
__all__ = [
1111
"AggregatedMetricValue",
1212
"AggregationMethod",
1313
"Bounds",
1414
"Metric",
15+
"MetricSample",
1516
]

src/frequenz/client/microgrid/metrics/_sample.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
import enum
77
from collections.abc import Sequence
88
from dataclasses import dataclass
9+
from datetime import datetime
10+
from typing import assert_never
11+
12+
from ._bounds import Bounds
13+
from ._metric import Metric
914

1015

1116
@enum.unique
@@ -59,3 +64,111 @@ def __str__(self) -> str:
5964
extra.append(f"num_raw:{len(self.raw_values)}")
6065
extra_str = f"<{' '.join(extra)}>" if extra else ""
6166
return f"avg:{self.avg}{extra_str}"
67+
68+
69+
@dataclass(frozen=True, kw_only=True)
70+
class MetricSample:
71+
"""A sampled metric.
72+
73+
This represents a single sample of a specific metric, the value of which is either
74+
measured at a particular time. The real-time system-defined bounds are optional and
75+
may not always be present or set.
76+
77+
Note: Relationship Between Bounds and Metric Samples
78+
Suppose a metric sample for active power has a lower-bound of -10,000 W, and an
79+
upper-bound of 10,000 W. For the system to accept a charge command, clients need
80+
to request current values within the bounds.
81+
"""
82+
83+
sampled_at: datetime
84+
"""The moment when the metric was sampled."""
85+
86+
metric: Metric | int
87+
"""The metric that was sampled."""
88+
89+
# In the protocol this is float | AggregatedMetricValue, but for live data we can't
90+
# receive the AggregatedMetricValue, so we limit this to float for now.
91+
value: float | AggregatedMetricValue | None
92+
"""The value of the sampled metric."""
93+
94+
bounds: list[Bounds]
95+
"""The bounds that apply to the metric sample.
96+
97+
These bounds adapt in real-time to reflect the operating conditions at the time of
98+
aggregation or derivation.
99+
100+
In the case of certain components like batteries, multiple bounds might exist. These
101+
multiple bounds collectively extend the range of allowable values, effectively
102+
forming a union of all given bounds. In such cases, the value of the metric must be
103+
within at least one of the bounds.
104+
105+
In accordance with the passive sign convention, bounds that limit discharge would
106+
have negative numbers, while those limiting charge, such as for the State of Power
107+
(SoP) metric, would be positive. Hence bounds can have positive and negative values
108+
depending on the metric they represent.
109+
110+
Example:
111+
The diagram below illustrates the relationship between the bounds.
112+
113+
```
114+
bound[0].lower bound[1].upper
115+
<-------|============|------------------|============|--------->
116+
bound[0].upper bound[1].lower
117+
118+
---- values here are disallowed and will be rejected
119+
==== values here are allowed and will be accepted
120+
```
121+
"""
122+
123+
connection: str | None = None
124+
"""The electrical connection within the component from which the metric was sampled.
125+
126+
This will be present when the same `Metric` can be obtained from multiple
127+
electrical connections within the component. Knowing the connection can help in
128+
certain control and monitoring applications.
129+
130+
In cases where the component has just one connection for a metric, then the
131+
connection is `None`.
132+
133+
Example:
134+
A hybrid inverter can have a DC string for a battery and another DC string for a
135+
PV array. The connection names could resemble, say, `dc_battery_0` and
136+
``dc_pv_0`. A metric like DC voltage can be obtained from both connections. For
137+
an application to determine the SoC of the battery using the battery voltage,
138+
which connection the voltage metric was sampled from is important.
139+
"""
140+
141+
def as_single_value(
142+
self, *, aggregation_method: AggregationMethod = AggregationMethod.AVG
143+
) -> float | None:
144+
"""Return the value of this sample as a single value.
145+
146+
if [`value`][frequenz.client.microgrid.metrics.MetricSample.value] is a `float`,
147+
it is returned as is. If `value` is an
148+
[`AggregatedMetricValue`][frequenz.client.microgrid.metrics.AggregatedMetricValue],
149+
the value is aggregated using the provided `aggregation_method`.
150+
151+
Args:
152+
aggregation_method: The method to use to aggregate the value when `value` is
153+
a `AggregatedMetricValue`.
154+
155+
Returns:
156+
The value of the sample as a single value, or `None` if the value is `None`.
157+
"""
158+
match self.value:
159+
case float() | int():
160+
return self.value
161+
case AggregatedMetricValue():
162+
match aggregation_method:
163+
case AggregationMethod.AVG:
164+
return self.value.avg
165+
case AggregationMethod.MIN:
166+
return self.value.min
167+
case AggregationMethod.MAX:
168+
return self.value.max
169+
case unexpected:
170+
assert_never(unexpected)
171+
case None:
172+
return None
173+
case unexpected:
174+
assert_never(unexpected)

src/frequenz/client/microgrid/metrics/_sample_proto.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@
33

44
"""Loading of MetricSample and AggregatedMetricValue objects from protobuf messages."""
55

6-
from frequenz.api.common.v1.metrics import metric_sample_pb2
6+
from collections.abc import Sequence
77

8-
from ._sample import AggregatedMetricValue
8+
from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2
9+
from frequenz.client.base import conversion
10+
11+
from .._util import enum_from_proto
12+
from ..metrics._bounds import Bounds
13+
from ..metrics._metric import Metric
14+
from ._bounds_proto import bounds_from_proto
15+
from ._sample import AggregatedMetricValue, MetricSample
916

1017

1118
def aggregated_metric_sample_from_proto(
@@ -25,3 +32,78 @@ def aggregated_metric_sample_from_proto(
2532
max=message.max_value if message.HasField("max_value") else None,
2633
raw_values=message.raw_values,
2734
)
35+
36+
37+
def metric_sample_from_proto_with_issues(
38+
message: metric_sample_pb2.MetricSample,
39+
*,
40+
major_issues: list[str],
41+
minor_issues: list[str],
42+
) -> MetricSample:
43+
"""Convert a protobuf message to a `MetricSample` object.
44+
45+
Args:
46+
message: The protobuf message to convert.
47+
major_issues: A list to append major issues to.
48+
minor_issues: A list to append minor issues to.
49+
50+
Returns:
51+
The resulting `MetricSample` object.
52+
"""
53+
value: float | AggregatedMetricValue | None = None
54+
if message.HasField("value"):
55+
match message.value.WhichOneof("metric_value_variant"):
56+
case "simple_metric":
57+
value = message.value.simple_metric.value
58+
case "aggregated_metric":
59+
value = aggregated_metric_sample_from_proto(
60+
message.value.aggregated_metric
61+
)
62+
63+
metric = enum_from_proto(message.metric, Metric)
64+
65+
return MetricSample(
66+
sampled_at=conversion.to_datetime(message.sampled_at),
67+
metric=metric,
68+
value=value,
69+
bounds=_metric_bounds_from_proto(
70+
metric,
71+
message.bounds,
72+
major_issues=major_issues,
73+
minor_issues=minor_issues,
74+
),
75+
connection=message.source or None,
76+
)
77+
78+
79+
def _metric_bounds_from_proto(
80+
metric: Metric | int,
81+
messages: Sequence[bounds_pb2.Bounds],
82+
*,
83+
major_issues: list[str],
84+
minor_issues: list[str], # pylint:disable=unused-argument
85+
) -> list[Bounds]:
86+
"""Convert a sequence of bounds messages to a list of `Bounds`.
87+
88+
Args:
89+
metric: The metric for which the bounds are defined, used for logging issues.
90+
messages: The sequence of bounds messages.
91+
major_issues: A list to append major issues to.
92+
minor_issues: A list to append minor issues to.
93+
94+
Returns:
95+
The resulting list of `Bounds`.
96+
"""
97+
bounds: list[Bounds] = []
98+
for pb_bound in messages:
99+
try:
100+
bound = bounds_from_proto(pb_bound)
101+
except ValueError as exc:
102+
metric_name = metric if isinstance(metric, int) else metric.name
103+
major_issues.append(
104+
f"bounds for {metric_name} is invalid ({exc}), ignoring these bounds"
105+
)
106+
continue
107+
bounds.append(bound)
108+
109+
return bounds

0 commit comments

Comments
 (0)