Skip to content

Commit 316fa15

Browse files
committed
Add Bounds wrapper
Signed-off-by: Leandro Lucarella <[email protected]>
1 parent b6afe43 commit 316fa15

File tree

4 files changed

+193
-0
lines changed

4 files changed

+193
-0
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33

44
"""Metrics definitions."""
55

6+
from ._bounds import Bounds
67
from ._metric import Metric
78
from ._sample import AggregatedMetricValue, AggregationMethod
89

910
__all__ = [
1011
"AggregatedMetricValue",
1112
"AggregationMethod",
13+
"Bounds",
1214
"Metric",
1315
]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Licese: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
5+
"""Definitions for bounds."""
6+
7+
import dataclasses
8+
9+
10+
@dataclasses.dataclass(frozen=True, kw_only=True)
11+
class Bounds:
12+
"""A set of lower and upper bounds for any metric.
13+
14+
The lower bound must be less than or equal to the upper bound.
15+
16+
The units of the bounds are always the same as the related metric.
17+
"""
18+
19+
lower: float | None = None
20+
"""The lower bound.
21+
22+
If `None`, there is no lower bound.
23+
"""
24+
25+
upper: float | None = None
26+
"""The upper bound.
27+
28+
If `None`, there is no upper bound.
29+
"""
30+
31+
def __post_init__(self) -> None:
32+
"""Validate these bounds."""
33+
if self.lower is None:
34+
return
35+
if self.upper is None:
36+
return
37+
if self.lower > self.upper:
38+
raise ValueError(
39+
f"Lower bound ({self.lower}) must be less than or equal to upper "
40+
f"bound ({self.upper})"
41+
)
42+
43+
def __str__(self) -> str:
44+
"""Return a string representation of these bounds."""
45+
return f"[{self.lower}, {self.upper}]"
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Licese: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Loading of Bounds objects from protobuf messages."""
5+
6+
7+
from frequenz.api.common.v1.metrics import bounds_pb2
8+
9+
from ._bounds import Bounds
10+
11+
12+
def bounds_from_proto(message: bounds_pb2.Bounds) -> Bounds:
13+
"""Create a `Bounds` from a protobuf message."""
14+
return Bounds(
15+
lower=message.lower if message.HasField("lower") else None,
16+
upper=message.upper if message.HasField("upper") else None,
17+
)

tests/metrics/test_bounds.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for the Bounds class."""
5+
6+
import re
7+
from dataclasses import dataclass
8+
9+
import pytest
10+
from frequenz.api.common.v1.metrics import bounds_pb2
11+
12+
from frequenz.client.microgrid.metrics import Bounds
13+
from frequenz.client.microgrid.metrics._bounds_proto import bounds_from_proto
14+
15+
16+
@dataclass(frozen=True, kw_only=True)
17+
class ProtoConversionTestCase:
18+
"""Test case for protobuf conversion."""
19+
20+
name: str
21+
"""Description of the test case."""
22+
23+
has_lower: bool
24+
"""Whether to include lower bound in the protobuf message."""
25+
26+
has_upper: bool
27+
"""Whether to include upper bound in the protobuf message."""
28+
29+
lower: float | None
30+
"""The lower bound value to set."""
31+
32+
upper: float | None
33+
"""The upper bound value to set."""
34+
35+
36+
def test_creation() -> None:
37+
"""Test creation of Bounds with valid values."""
38+
bounds = Bounds(lower=-10.0, upper=10.0)
39+
assert bounds.lower == -10.0
40+
assert bounds.upper == 10.0
41+
42+
43+
def test_invalid_values() -> None:
44+
"""Test that Bounds creation fails with invalid values."""
45+
with pytest.raises(
46+
ValueError,
47+
match=re.escape(
48+
"Lower bound (10.0) must be less than or equal to upper bound (-10.0)"
49+
),
50+
):
51+
Bounds(lower=10.0, upper=-10.0)
52+
53+
54+
def test_str_representation() -> None:
55+
"""Test string representation of Bounds."""
56+
bounds = Bounds(lower=-10.0, upper=10.0)
57+
assert str(bounds) == "[-10.0, 10.0]"
58+
59+
60+
def test_equality() -> None:
61+
"""Test equality comparison of Bounds objects."""
62+
bounds1 = Bounds(lower=-10.0, upper=10.0)
63+
bounds2 = Bounds(lower=-10.0, upper=10.0)
64+
bounds3 = Bounds(lower=-5.0, upper=5.0)
65+
66+
assert bounds1 == bounds2
67+
assert bounds1 != bounds3
68+
assert bounds2 != bounds3
69+
70+
71+
def test_hash() -> None:
72+
"""Test that Bounds objects can be used in sets and as dictionary keys."""
73+
bounds1 = Bounds(lower=-10.0, upper=10.0)
74+
bounds2 = Bounds(lower=-10.0, upper=10.0)
75+
bounds3 = Bounds(lower=-5.0, upper=5.0)
76+
77+
bounds_set = {bounds1, bounds2, bounds3}
78+
assert len(bounds_set) == 2 # bounds1 and bounds2 are equal
79+
80+
bounds_dict = {bounds1: "test1", bounds3: "test2"}
81+
assert len(bounds_dict) == 2
82+
83+
84+
@pytest.mark.parametrize(
85+
"case",
86+
[
87+
ProtoConversionTestCase(
88+
name="full",
89+
has_lower=True,
90+
has_upper=True,
91+
lower=-10.0,
92+
upper=10.0,
93+
),
94+
ProtoConversionTestCase(
95+
name="no_upper_bound",
96+
has_lower=True,
97+
has_upper=False,
98+
lower=-10.0,
99+
upper=None,
100+
),
101+
ProtoConversionTestCase(
102+
name="no_lower_bound",
103+
has_lower=False,
104+
has_upper=True,
105+
lower=None,
106+
upper=10.0,
107+
),
108+
ProtoConversionTestCase(
109+
name="no_both_bounds",
110+
has_lower=False,
111+
has_upper=False,
112+
lower=None,
113+
upper=None,
114+
),
115+
],
116+
ids=lambda case: case.name,
117+
)
118+
def test_from_proto(case: ProtoConversionTestCase) -> None:
119+
"""Test conversion from protobuf message to Bounds."""
120+
proto = bounds_pb2.Bounds()
121+
if case.has_lower and case.lower is not None:
122+
proto.lower = case.lower
123+
if case.has_upper and case.upper is not None:
124+
proto.upper = case.upper
125+
126+
bounds = bounds_from_proto(proto)
127+
128+
assert bounds.lower == case.lower
129+
assert bounds.upper == case.upper

0 commit comments

Comments
 (0)