Skip to content

Commit f585f0c

Browse files
committed
Implement SetComponentPowerActive/Reactive
These 2 calls are identically, they just affect active or reactive power respectively, so there are a few utility function that are shared, as well as test cases. For test cases we need to use a couple of hacks to share them. * `*_case.py` files for `set_reactive_power` are symlinks to `set_active_power()` * Both have their own `_config.py` file where we define the classes used for the tests, so we can share them, as only the request/response objects change between both. * Because of the client test framework works, `_config` must be imported as a top-level module. * We need to ignore `pylint` and `mypy` checks when importing, as they can't find the `_config` module at the top-level. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent c3ad475 commit f585f0c

16 files changed

+441
-3
lines changed

src/frequenz/client/microgrid/_client.py

Lines changed: 185 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,21 @@
77

88
import asyncio
99
import itertools
10+
import math
1011
from dataclasses import replace
11-
from typing import Any
12+
from datetime import datetime, timedelta
13+
from typing import Any, assert_never
1214

13-
from frequenz.api.microgrid.v1 import microgrid_pb2_grpc
14-
from frequenz.client.base import channel, client, retry, streaming
15+
from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc
16+
from frequenz.client.base import channel, client, conversion, retry, streaming
1517
from frequenz.client.common.microgrid.components import ComponentId
1618
from google.protobuf.empty_pb2 import Empty
1719
from typing_extensions import override
1820

1921
from ._exception import ClientNotConnected
2022
from ._microgrid_info import MicrogridInfo
2123
from ._microgrid_info_proto import microgrid_info_from_proto
24+
from .component._component import Component
2225

2326
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
2427
"""The default timeout for gRPC calls made by this client (in seconds)."""
@@ -153,3 +156,182 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly)
153156
)
154157

155158
return microgrid_info_from_proto(microgrid.microgrid)
159+
160+
async def set_component_power_active( # noqa: DOC502 (raises ApiClientError indirectly)
161+
self,
162+
component: ComponentId | Component,
163+
power: float,
164+
*,
165+
request_lifetime: timedelta | None = None,
166+
validate_arguments: bool = True,
167+
) -> datetime | None:
168+
"""Set the active power output of a component.
169+
170+
The power output can be negative or positive, depending on whether the component
171+
is supposed to be discharging or charging, respectively.
172+
173+
The power output is specified in watts.
174+
175+
The return value is the timestamp until which the given power command will
176+
stay in effect. After this timestamp, the component's active power will be
177+
set to 0, if the API receives no further command to change it before then.
178+
By default, this timestamp will be set to the current time plus 60 seconds.
179+
180+
Note:
181+
The target component may have a resolution of more than 1 W. E.g., an
182+
inverter may have a resolution of 88 W. In such cases, the magnitude of
183+
power will be floored to the nearest multiple of the resolution.
184+
185+
Args:
186+
component: The component to set the output active power of.
187+
power: The output active power level, in watts. Negative values are for
188+
discharging, and positive values are for charging.
189+
request_lifetime: The duration, until which the request will stay in effect.
190+
This duration has to be between 10 seconds and 15 minutes (including
191+
both limits), otherwise the request will be rejected. It has
192+
a resolution of a second, so fractions of a second will be rounded for
193+
`timedelta` objects, and it is interpreted as seconds for `int` objects.
194+
If not provided, it usually defaults to 60 seconds.
195+
validate_arguments: Whether to validate the arguments before sending the
196+
request. If `True` a `ValueError` will be raised if an argument is
197+
invalid without even sending the request to the server, if `False`, the
198+
request will be sent without validation.
199+
200+
Returns:
201+
The timestamp until which the given power command will stay in effect, or
202+
`None` if it was not provided by the server.
203+
204+
Raises:
205+
ApiClientError: If there are any errors communicating with the Microgrid API,
206+
most likely a subclass of
207+
[GrpcError][frequenz.client.microgrid.GrpcError].
208+
"""
209+
lifetime_seconds = _delta_to_seconds(request_lifetime)
210+
211+
if validate_arguments:
212+
_validate_set_power_args(power=power, request_lifetime=lifetime_seconds)
213+
214+
response = await client.call_stub_method(
215+
self,
216+
lambda: self.stub.SetComponentPowerActive(
217+
microgrid_pb2.SetComponentPowerActiveRequest(
218+
component_id=_get_component_id(component),
219+
power=power,
220+
request_lifetime=lifetime_seconds,
221+
),
222+
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
223+
),
224+
method_name="SetComponentPowerActive",
225+
)
226+
227+
if response.HasField("valid_until"):
228+
return conversion.to_datetime(response.valid_until)
229+
230+
return None
231+
232+
async def set_component_power_reactive( # noqa: DOC502 (raises ApiClientError indirectly)
233+
self,
234+
component: ComponentId | Component,
235+
power: float,
236+
*,
237+
request_lifetime: timedelta | None = None,
238+
validate_arguments: bool = True,
239+
) -> datetime | None:
240+
"""Set the reactive power output of a component.
241+
242+
We follow the polarity specified in the IEEE 1459-2010 standard
243+
definitions, where:
244+
245+
- Positive reactive is inductive (current is lagging the voltage)
246+
- Negative reactive is capacitive (current is leading the voltage)
247+
248+
The power output is specified in VAr.
249+
250+
The return value is the timestamp until which the given power command will
251+
stay in effect. After this timestamp, the component's reactive power will
252+
be set to 0, if the API receives no further command to change it before
253+
then. By default, this timestamp will be set to the current time plus 60
254+
seconds.
255+
256+
Note:
257+
The target component may have a resolution of more than 1 VAr. E.g., an
258+
inverter may have a resolution of 88 VAr. In such cases, the magnitude of
259+
power will be floored to the nearest multiple of the resolution.
260+
261+
Args:
262+
component: The component to set the output reactive power of.
263+
power: The output reactive power level, in VAr. The standard of polarity is
264+
as per the IEEE 1459-2010 standard definitions: positive reactive is
265+
inductive (current is lagging the voltage); negative reactive is
266+
capacitive (current is leading the voltage).
267+
request_lifetime: The duration, until which the request will stay in effect.
268+
This duration has to be between 10 seconds and 15 minutes (including
269+
both limits), otherwise the request will be rejected. It has
270+
a resolution of a second, so fractions of a second will be rounded for
271+
`timedelta` objects, and it is interpreted as seconds for `int` objects.
272+
If not provided, it usually defaults to 60 seconds.
273+
validate_arguments: Whether to validate the arguments before sending the
274+
request. If `True` a `ValueError` will be raised if an argument is
275+
invalid without even sending the request to the server, if `False`, the
276+
request will be sent without validation.
277+
278+
Returns:
279+
The timestamp until which the given power command will stay in effect, or
280+
`None` if it was not provided by the server.
281+
282+
Raises:
283+
ApiClientError: If there are any errors communicating with the Microgrid API,
284+
most likely a subclass of
285+
[GrpcError][frequenz.client.microgrid.GrpcError].
286+
"""
287+
lifetime_seconds = _delta_to_seconds(request_lifetime)
288+
289+
if validate_arguments:
290+
_validate_set_power_args(power=power, request_lifetime=lifetime_seconds)
291+
292+
response = await client.call_stub_method(
293+
self,
294+
lambda: self.stub.SetComponentPowerReactive(
295+
microgrid_pb2.SetComponentPowerReactiveRequest(
296+
component_id=_get_component_id(component),
297+
power=power,
298+
request_lifetime=lifetime_seconds,
299+
),
300+
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
301+
),
302+
method_name="SetComponentPowerReactive",
303+
)
304+
305+
if response.HasField("valid_until"):
306+
return conversion.to_datetime(response.valid_until)
307+
308+
return None
309+
310+
311+
def _get_component_id(component: ComponentId | Component) -> int:
312+
"""Get the component ID from a component or component ID."""
313+
match component:
314+
case ComponentId():
315+
return int(component)
316+
case Component():
317+
return int(component.id)
318+
case unexpected:
319+
assert_never(unexpected)
320+
321+
322+
def _delta_to_seconds(delta: timedelta | None) -> int | None:
323+
"""Convert a `timedelta` to seconds (or `None` if `None`)."""
324+
return round(delta.total_seconds()) if delta is not None else None
325+
326+
327+
def _validate_set_power_args(*, power: float, request_lifetime: int | None) -> None:
328+
"""Validate the request lifetime."""
329+
if math.isnan(power):
330+
raise ValueError("power cannot be NaN")
331+
if request_lifetime is not None:
332+
minimum_lifetime = 10 # 10 seconds
333+
maximum_lifetime = 900 # 15 minutes
334+
if not minimum_lifetime <= request_lifetime <= maximum_lifetime:
335+
raise ValueError(
336+
"request_lifetime must be between 10 seconds and 15 minutes"
337+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Configuration for `SetComponentPowerActive` test cases."""
5+
6+
7+
from frequenz.api.microgrid.v1 import microgrid_pb2
8+
9+
RESPONSE_CLASS = microgrid_pb2.SetComponentPowerActiveResponse
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test case with invalid power and validate_arguments=False."""
5+
6+
from datetime import timedelta
7+
from unittest.mock import AsyncMock
8+
9+
# pylint: disable-next=import-error
10+
from _config import RESPONSE_CLASS # type: ignore[import-not-found]
11+
from frequenz.client.common.microgrid.components import ComponentId
12+
13+
client_kwargs = {
14+
"component": ComponentId(1),
15+
"power": 1000.0,
16+
"request_lifetime": timedelta(minutes=15.01),
17+
}
18+
19+
20+
def assert_stub_method_call(stub_method: AsyncMock) -> None:
21+
"""Assert that the gRPC request matches the expected request."""
22+
stub_method.assert_not_called()
23+
24+
25+
grpc_response = RESPONSE_CLASS()
26+
27+
28+
def assert_client_exception(result: Exception) -> None:
29+
"""Assert that the client raises a ValueError."""
30+
assert isinstance(result, ValueError)
31+
assert str(result) == "request_lifetime must be between 10 seconds and 15 minutes"
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test case with invalid power and validate_arguments=False."""
5+
6+
from datetime import timedelta
7+
from unittest.mock import AsyncMock
8+
9+
# pylint: disable-next=import-error
10+
from _config import RESPONSE_CLASS # type: ignore[import-not-found]
11+
from frequenz.client.common.microgrid.components import ComponentId
12+
13+
client_kwargs = {
14+
"component": ComponentId(1),
15+
"power": float("nan"),
16+
"request_lifetime": timedelta(seconds=60),
17+
}
18+
19+
20+
def assert_stub_method_call(stub_method: AsyncMock) -> None:
21+
"""Assert that the gRPC request matches the expected request."""
22+
stub_method.assert_not_called()
23+
24+
25+
grpc_response = RESPONSE_CLASS()
26+
27+
28+
def assert_client_exception(result: Exception) -> None:
29+
"""Assert that the client raises a ValueError."""
30+
assert isinstance(result, ValueError)
31+
assert str(result) == "power cannot be NaN"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test case with invalid power and validate_arguments=False."""
5+
6+
import math
7+
from datetime import timedelta
8+
from typing import Any
9+
10+
# pylint: disable-next=import-error
11+
from _config import RESPONSE_CLASS # type: ignore[import-not-found]
12+
from frequenz.client.common.microgrid.components import ComponentId
13+
14+
client_kwargs = {
15+
"component": ComponentId(1),
16+
"power": float("nan"),
17+
"request_lifetime": timedelta(seconds=60),
18+
"validate_arguments": False,
19+
}
20+
21+
22+
def assert_stub_method_call(stub_method: Any) -> None:
23+
"""Assert that the gRPC request matches the expected request."""
24+
# We can't use float("nan") when comparing here because nan != nan
25+
# so instead of using assert_called_once_with, we use assert_called_once
26+
# and then check the arguments manually
27+
stub_method.assert_called_once()
28+
request = stub_method.call_args[0][0]
29+
assert request.component_id == 1
30+
assert math.isnan(request.power)
31+
assert request.request_lifetime == 60
32+
assert stub_method.call_args[1]["timeout"] == 60.0
33+
34+
35+
grpc_response = RESPONSE_CLASS()
36+
37+
38+
def assert_client_result(result: None) -> None:
39+
"""Assert that the client result is None."""
40+
assert result is None
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# License: MIT
2+
# Copyright © 2025 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test case with invalid power and validate_arguments=False."""
5+
6+
from datetime import timedelta
7+
from unittest.mock import AsyncMock
8+
9+
# pylint: disable-next=import-error
10+
from _config import RESPONSE_CLASS # type: ignore[import-not-found]
11+
from frequenz.client.common.microgrid.components import ComponentId
12+
13+
client_kwargs = {
14+
"component": ComponentId(1),
15+
"power": 1000.0,
16+
"request_lifetime": timedelta(seconds=1),
17+
}
18+
19+
20+
def assert_stub_method_call(stub_method: AsyncMock) -> None:
21+
"""Assert that the gRPC request matches the expected request."""
22+
stub_method.assert_not_called()
23+
24+
25+
grpc_response = RESPONSE_CLASS()
26+
27+
28+
def assert_client_exception(result: Exception) -> None:
29+
"""Assert that the client raises a ValueError."""
30+
assert isinstance(result, ValueError)
31+
assert str(result) == "request_lifetime must be between 10 seconds and 15 minutes"
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 set_component_power_active with no lifetime: result should be None."""
5+
6+
from typing import Any
7+
8+
import pytest
9+
10+
# pylint: disable-next=import-error
11+
from _config import RESPONSE_CLASS # type: ignore[import-not-found]
12+
from frequenz.client.common.microgrid.components import ComponentId
13+
14+
client_args = (ComponentId(1), 1000.0)
15+
16+
17+
# No client_kwargs needed for this call
18+
19+
20+
def assert_stub_method_call(stub_method: Any) -> None:
21+
"""Assert that the gRPC request matches the expected request."""
22+
stub_method.assert_called_once()
23+
request = stub_method.call_args[0][0]
24+
assert request.component_id == 1
25+
assert request.power == pytest.approx(1000.0)
26+
assert stub_method.call_args[1]["timeout"] == 60.0
27+
28+
29+
grpc_response = RESPONSE_CLASS()
30+
31+
32+
def assert_client_result(result: Any) -> None: # noqa: D103
33+
"""Assert that the client result is None when no lifetime is provided."""
34+
assert result is None
35+
assert result is None

0 commit comments

Comments
 (0)