Skip to content

Commit 3c69ffe

Browse files
committed
Implement AddComponentBounds
Signed-off-by: Leandro Lucarella <[email protected]>
1 parent f585f0c commit 3c69ffe

File tree

6 files changed

+266
-0
lines changed

6 files changed

+266
-0
lines changed

src/frequenz/client/microgrid/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
DEFAULT_CHANNEL_OPTIONS,
1212
DEFAULT_GRPC_CALL_TIMEOUT,
1313
MicrogridApiClient,
14+
Validity,
1415
)
1516
from ._delivery_area import DeliveryArea, EnergyMarketCodeType
1617
from ._exception import (
@@ -69,4 +70,5 @@
6970
"ServiceUnavailable",
7071
"UnknownError",
7172
"UnrecognizedGrpcStatus",
73+
"Validity",
7274
]

src/frequenz/client/microgrid/_client.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66
from __future__ import annotations
77

88
import asyncio
9+
import enum
910
import itertools
1011
import math
12+
from collections.abc import Iterable
1113
from dataclasses import replace
1214
from datetime import datetime, timedelta
1315
from typing import Any, assert_never
1416

17+
from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2
1518
from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc
1619
from frequenz.client.base import channel, client, conversion, retry, streaming
1720
from frequenz.client.common.microgrid.components import ComponentId
@@ -22,6 +25,8 @@
2225
from ._microgrid_info import MicrogridInfo
2326
from ._microgrid_info_proto import microgrid_info_from_proto
2427
from .component._component import Component
28+
from .metrics._bounds import Bounds
29+
from .metrics._metric import Metric
2530

2631
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
2732
"""The default timeout for gRPC calls made by this client (in seconds)."""
@@ -307,6 +312,127 @@ async def set_component_power_reactive( # noqa: DOC502 (raises ApiClientError i
307312

308313
return None
309314

315+
async def add_component_bounds( # noqa: DOC502 (Raises ApiClientError indirectly)
316+
self,
317+
component: ComponentId | Component,
318+
target: Metric | int,
319+
bounds: Iterable[Bounds],
320+
*,
321+
validity: Validity | None = None,
322+
) -> datetime | None:
323+
"""Add inclusion bounds for a given metric of a given component.
324+
325+
The bounds are used to define the acceptable range of values for a metric
326+
of a component. The added bounds are kept only temporarily, and removed
327+
automatically after some expiry time.
328+
329+
Inclusion bounds give the range that the system will try to keep the
330+
metric within. If the metric goes outside of these bounds, the system will
331+
try to bring it back within the bounds.
332+
If the bounds for a metric are `[[lower_1, upper_1], [lower_2, upper_2]]`,
333+
then this metric's `value` needs to comply with the constraints `lower_1 <=
334+
value <= upper_1` OR `lower_2 <= value <= upper_2`.
335+
336+
If multiple inclusion bounds have been provided for a metric, then the
337+
overlapping bounds are merged into a single bound, and non-overlapping
338+
bounds are kept separate.
339+
340+
Example:
341+
If the bounds are [[0, 10], [5, 15], [20, 30]], then the resulting bounds
342+
will be [[0, 15], [20, 30]].
343+
344+
The following diagram illustrates how bounds are applied:
345+
346+
```
347+
lower_1 upper_1
348+
<----|========|--------|========|-------->
349+
lower_2 upper_2
350+
```
351+
352+
The bounds in this example are `[[lower_1, upper_1], [lower_2, upper_2]]`.
353+
354+
```
355+
---- values here are considered out of range.
356+
==== values here are considered within range.
357+
```
358+
359+
Note:
360+
For power metrics, regardless of the bounds, 0W is always allowed.
361+
362+
Args:
363+
component: The component to add bounds to.
364+
target: The target metric whose bounds have to be added.
365+
bounds: The bounds to add to the target metric. Overlapping pairs of bounds
366+
are merged into a single pair of bounds, and non-overlapping ones are
367+
kept separated.
368+
validity: The duration for which the given bounds will stay in effect.
369+
If `None`, then the bounds will be removed after some default time
370+
decided by the server, typically 5 seconds.
371+
372+
The duration for which the bounds are valid. If not provided, the
373+
bounds are considered to be valid indefinitely.
374+
375+
Returns:
376+
The timestamp until which the given bounds will stay in effect, or `None` if
377+
if it was not provided by the server.
378+
379+
Raises:
380+
ApiClientError: If there are any errors communicating with the Microgrid API,
381+
most likely a subclass of
382+
[GrpcError][frequenz.client.microgrid.GrpcError].
383+
"""
384+
extra_args = {}
385+
if validity is not None:
386+
extra_args["validity_duration"] = validity.value
387+
response = await client.call_stub_method(
388+
self,
389+
lambda: self.stub.AddComponentBounds(
390+
microgrid_pb2.AddComponentBoundsRequest(
391+
component_id=_get_component_id(component),
392+
target_metric=_get_metric_value(target),
393+
bounds=(
394+
bounds_pb2.Bounds(
395+
lower=bound.lower,
396+
upper=bound.upper,
397+
)
398+
for bound in bounds
399+
),
400+
**extra_args,
401+
),
402+
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
403+
),
404+
method_name="AddComponentBounds",
405+
)
406+
407+
if response.HasField("ts"):
408+
return conversion.to_datetime(response.ts)
409+
410+
return None
411+
412+
413+
class Validity(enum.Enum):
414+
"""The duration for which a given list of bounds will stay in effect."""
415+
416+
FIVE_SECONDS = (
417+
microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_5_SECONDS
418+
)
419+
"""The bounds will stay in effect for 5 seconds."""
420+
421+
ONE_MINUTE = (
422+
microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_1_MINUTE
423+
)
424+
"""The bounds will stay in effect for 1 minute."""
425+
426+
FIVE_MINUTES = (
427+
microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_5_MINUTES
428+
)
429+
"""The bounds will stay in effect for 5 minutes."""
430+
431+
FIFTEEN_MINUTES = (
432+
microgrid_pb2.ComponentBoundsValidityDuration.COMPONENT_BOUNDS_VALIDITY_DURATION_15_MINUTES
433+
)
434+
"""The bounds will stay in effect for 15 minutes."""
435+
310436

311437
def _get_component_id(component: ComponentId | Component) -> int:
312438
"""Get the component ID from a component or component ID."""
@@ -319,6 +445,17 @@ def _get_component_id(component: ComponentId | Component) -> int:
319445
assert_never(unexpected)
320446

321447

448+
def _get_metric_value(metric: Metric | int) -> metric_sample_pb2.Metric.ValueType:
449+
"""Get the metric ID from a metric or metric ID."""
450+
match metric:
451+
case Metric():
452+
return metric_sample_pb2.Metric.ValueType(metric.value)
453+
case int():
454+
return metric_sample_pb2.Metric.ValueType(metric)
455+
case unexpected:
456+
assert_never(unexpected)
457+
458+
322459
def _delta_to_seconds(delta: timedelta | None) -> int | None:
323460
"""Convert a `timedelta` to seconds (or `None` if `None`)."""
324461
return round(delta.total_seconds()) if delta is not None else None
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test add_component_bounds call with error."""
5+
6+
from typing import Any
7+
8+
from frequenz.client.common.microgrid.components import ComponentId
9+
from grpc import StatusCode
10+
11+
from frequenz.client.microgrid import PermissionDenied
12+
from frequenz.client.microgrid.metrics import Bounds, Metric
13+
from tests.util import make_grpc_error
14+
15+
client_args = (ComponentId(1), Metric.AC_VOLTAGE, [Bounds(lower=200.0, upper=250.0)])
16+
17+
18+
def assert_stub_method_call(stub_method: Any) -> None:
19+
"""Assert that the gRPC request matches the expected request."""
20+
# We are not testing the request here, just the error handling
21+
22+
23+
grpc_response = make_grpc_error(StatusCode.PERMISSION_DENIED)
24+
25+
26+
def assert_client_exception(exception: Exception) -> None:
27+
"""Assert that the client exception matches the expected error."""
28+
assert isinstance(exception, PermissionDenied)
29+
assert exception.grpc_error == grpc_response
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test data for add_component_bounds call without validity."""
5+
6+
from typing import Any
7+
8+
from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2
9+
from frequenz.api.microgrid.v1 import microgrid_pb2
10+
from frequenz.client.common.microgrid.components import ComponentId
11+
12+
from frequenz.client.microgrid.metrics import Bounds, Metric
13+
14+
client_args = (ComponentId(1), Metric.AC_VOLTAGE, [Bounds(lower=200.0, upper=250.0)])
15+
16+
17+
def assert_stub_method_call(stub_method: Any) -> None:
18+
"""Assert that the gRPC request matches the expected request."""
19+
stub_method.assert_called_once_with(
20+
microgrid_pb2.AddComponentBoundsRequest(
21+
component_id=1,
22+
target_metric=metric_sample_pb2.Metric.METRIC_AC_VOLTAGE,
23+
bounds=[bounds_pb2.Bounds(lower=200.0, upper=250.0)],
24+
# No validity field
25+
),
26+
timeout=60.0,
27+
)
28+
29+
30+
grpc_response = microgrid_pb2.AddComponentBoundsResponse()
31+
32+
33+
def assert_client_result(result: Any) -> None:
34+
"""Assert that the client result is None as expected."""
35+
assert result is None
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+
"""Test data for successful component bounds addition."""
5+
6+
from datetime import datetime, timezone
7+
from typing import Any
8+
9+
from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2
10+
from frequenz.api.microgrid.v1 import microgrid_pb2
11+
from frequenz.client.base.conversion import to_timestamp
12+
from frequenz.client.common.microgrid.components import ComponentId
13+
14+
from frequenz.client.microgrid import Validity
15+
from frequenz.client.microgrid._client import DEFAULT_GRPC_CALL_TIMEOUT
16+
from frequenz.client.microgrid.metrics import Bounds, Metric
17+
18+
client_args = (
19+
ComponentId(1),
20+
Metric.DC_VOLTAGE,
21+
[Bounds(lower=200.0, upper=250.0)],
22+
)
23+
client_kwargs = {
24+
"validity": Validity.FIFTEEN_MINUTES,
25+
}
26+
27+
PbValidity = microgrid_pb2.ComponentBoundsValidityDuration
28+
29+
valid_until = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
30+
31+
32+
def assert_stub_method_call(stub_method: Any) -> None:
33+
"""Assert that the gRPC request matches the expected request."""
34+
stub_method.assert_called_once_with(
35+
microgrid_pb2.AddComponentBoundsRequest(
36+
component_id=1,
37+
target_metric=metric_sample_pb2.Metric.METRIC_DC_VOLTAGE,
38+
bounds=[bounds_pb2.Bounds(lower=200.0, upper=250.0)],
39+
validity_duration=PbValidity.COMPONENT_BOUNDS_VALIDITY_DURATION_15_MINUTES,
40+
),
41+
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
42+
)
43+
44+
45+
grpc_response = microgrid_pb2.AddComponentBoundsResponse(ts=to_timestamp(valid_until))
46+
47+
48+
def assert_client_result(result: datetime) -> None:
49+
"""Assert that the client result matches the expected valid_until datetime."""
50+
assert result == valid_until

tests/test_client.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,16 @@ async def test_set_component_power_reactive(
122122
) -> None:
123123
"""Test set_component_power_reactive method."""
124124
await spec.test_unary_unary_call(client, "SetComponentPowerReactive")
125+
126+
127+
@pytest.mark.asyncio
128+
@pytest.mark.parametrize(
129+
"spec",
130+
get_test_specs("add_component_bounds", tests_dir=TESTS_DIR),
131+
ids=str,
132+
)
133+
async def test_add_bounds(
134+
client: MicrogridApiClient, spec: ApiClientTestCaseSpec
135+
) -> None:
136+
"""Test add_bounds method."""
137+
await spec.test_unary_unary_call(client, "AddComponentBounds")

0 commit comments

Comments
 (0)