Skip to content

Commit 8460b64

Browse files
committed
Add Bounds wrapper
Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 02be569 commit 8460b64

File tree

4 files changed

+207
-0
lines changed

4 files changed

+207
-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+
# License: 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+
# License: 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: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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+
@pytest.mark.parametrize(
37+
"lower, upper",
38+
[
39+
(None, None),
40+
(10.0, None),
41+
(None, -10.0),
42+
(-10.0, 10.0),
43+
(10.0, 10.0),
44+
(-10.0, -10.0),
45+
(0.0, 10.0),
46+
(-10, 0.0),
47+
(0.0, 0.0),
48+
],
49+
)
50+
def test_creation(lower: float, upper: float) -> None:
51+
"""Test creation of Bounds with valid values."""
52+
bounds = Bounds(lower=lower, upper=upper)
53+
assert bounds.lower == lower
54+
assert bounds.upper == upper
55+
56+
57+
def test_invalid_values() -> None:
58+
"""Test that Bounds creation fails with invalid values."""
59+
with pytest.raises(
60+
ValueError,
61+
match=re.escape(
62+
"Lower bound (10.0) must be less than or equal to upper bound (-10.0)"
63+
),
64+
):
65+
Bounds(lower=10.0, upper=-10.0)
66+
67+
68+
def test_str_representation() -> None:
69+
"""Test string representation of Bounds."""
70+
bounds = Bounds(lower=-10.0, upper=10.0)
71+
assert str(bounds) == "[-10.0, 10.0]"
72+
73+
74+
def test_equality() -> None:
75+
"""Test equality comparison of Bounds objects."""
76+
bounds1 = Bounds(lower=-10.0, upper=10.0)
77+
bounds2 = Bounds(lower=-10.0, upper=10.0)
78+
bounds3 = Bounds(lower=-5.0, upper=5.0)
79+
80+
assert bounds1 == bounds2
81+
assert bounds1 != bounds3
82+
assert bounds2 != bounds3
83+
84+
85+
def test_hash() -> None:
86+
"""Test that Bounds objects can be used in sets and as dictionary keys."""
87+
bounds1 = Bounds(lower=-10.0, upper=10.0)
88+
bounds2 = Bounds(lower=-10.0, upper=10.0)
89+
bounds3 = Bounds(lower=-5.0, upper=5.0)
90+
91+
bounds_set = {bounds1, bounds2, bounds3}
92+
assert len(bounds_set) == 2 # bounds1 and bounds2 are equal
93+
94+
bounds_dict = {bounds1: "test1", bounds3: "test2"}
95+
assert len(bounds_dict) == 2
96+
97+
98+
@pytest.mark.parametrize(
99+
"case",
100+
[
101+
ProtoConversionTestCase(
102+
name="full",
103+
has_lower=True,
104+
has_upper=True,
105+
lower=-10.0,
106+
upper=10.0,
107+
),
108+
ProtoConversionTestCase(
109+
name="no_upper_bound",
110+
has_lower=True,
111+
has_upper=False,
112+
lower=-10.0,
113+
upper=None,
114+
),
115+
ProtoConversionTestCase(
116+
name="no_lower_bound",
117+
has_lower=False,
118+
has_upper=True,
119+
lower=None,
120+
upper=10.0,
121+
),
122+
ProtoConversionTestCase(
123+
name="no_both_bounds",
124+
has_lower=False,
125+
has_upper=False,
126+
lower=None,
127+
upper=None,
128+
),
129+
],
130+
ids=lambda case: case.name,
131+
)
132+
def test_from_proto(case: ProtoConversionTestCase) -> None:
133+
"""Test conversion from protobuf message to Bounds."""
134+
proto = bounds_pb2.Bounds()
135+
if case.has_lower and case.lower is not None:
136+
proto.lower = case.lower
137+
if case.has_upper and case.upper is not None:
138+
proto.upper = case.upper
139+
140+
bounds = bounds_from_proto(proto)
141+
142+
assert bounds.lower == case.lower
143+
assert bounds.upper == case.upper

0 commit comments

Comments
 (0)