Skip to content

Commit bd5828f

Browse files
authored
Add gRPC calls to control components (#158)
2 parents 919aab3 + 3c69ffe commit bd5828f

37 files changed

+2012
-54
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: 323 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,27 @@
66
from __future__ import annotations
77

88
import asyncio
9+
import enum
910
import itertools
11+
import math
12+
from collections.abc import Iterable
1013
from dataclasses import replace
11-
from typing import Any
14+
from datetime import datetime, timedelta
15+
from typing import Any, assert_never
1216

13-
from frequenz.api.microgrid.v1 import microgrid_pb2_grpc
14-
from frequenz.client.base import channel, client, retry, streaming
17+
from frequenz.api.common.v1.metrics import bounds_pb2, metric_sample_pb2
18+
from frequenz.api.microgrid.v1 import microgrid_pb2, microgrid_pb2_grpc
19+
from frequenz.client.base import channel, client, conversion, retry, streaming
1520
from frequenz.client.common.microgrid.components import ComponentId
1621
from google.protobuf.empty_pb2 import Empty
1722
from typing_extensions import override
1823

1924
from ._exception import ClientNotConnected
2025
from ._microgrid_info import MicrogridInfo
2126
from ._microgrid_info_proto import microgrid_info_from_proto
27+
from .component._component import Component
28+
from .metrics._bounds import Bounds
29+
from .metrics._metric import Metric
2230

2331
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
2432
"""The default timeout for gRPC calls made by this client (in seconds)."""
@@ -139,7 +147,7 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly)
139147
The information about the local microgrid.
140148
141149
Raises:
142-
ApiClientError: If the are any errors communicating with the Microgrid API,
150+
ApiClientError: If there are any errors communicating with the Microgrid API,
143151
most likely a subclass of
144152
[GrpcError][frequenz.client.microgrid.GrpcError].
145153
"""
@@ -153,3 +161,314 @@ async def get_microgrid_info( # noqa: DOC502 (raises ApiClientError indirectly)
153161
)
154162

155163
return microgrid_info_from_proto(microgrid.microgrid)
164+
165+
async def set_component_power_active( # noqa: DOC502 (raises ApiClientError indirectly)
166+
self,
167+
component: ComponentId | Component,
168+
power: float,
169+
*,
170+
request_lifetime: timedelta | None = None,
171+
validate_arguments: bool = True,
172+
) -> datetime | None:
173+
"""Set the active power output of a component.
174+
175+
The power output can be negative or positive, depending on whether the component
176+
is supposed to be discharging or charging, respectively.
177+
178+
The power output is specified in watts.
179+
180+
The return value is the timestamp until which the given power command will
181+
stay in effect. After this timestamp, the component's active power will be
182+
set to 0, if the API receives no further command to change it before then.
183+
By default, this timestamp will be set to the current time plus 60 seconds.
184+
185+
Note:
186+
The target component may have a resolution of more than 1 W. E.g., an
187+
inverter may have a resolution of 88 W. In such cases, the magnitude of
188+
power will be floored to the nearest multiple of the resolution.
189+
190+
Args:
191+
component: The component to set the output active power of.
192+
power: The output active power level, in watts. Negative values are for
193+
discharging, and positive values are for charging.
194+
request_lifetime: The duration, until which the request will stay in effect.
195+
This duration has to be between 10 seconds and 15 minutes (including
196+
both limits), otherwise the request will be rejected. It has
197+
a resolution of a second, so fractions of a second will be rounded for
198+
`timedelta` objects, and it is interpreted as seconds for `int` objects.
199+
If not provided, it usually defaults to 60 seconds.
200+
validate_arguments: Whether to validate the arguments before sending the
201+
request. If `True` a `ValueError` will be raised if an argument is
202+
invalid without even sending the request to the server, if `False`, the
203+
request will be sent without validation.
204+
205+
Returns:
206+
The timestamp until which the given power command will stay in effect, or
207+
`None` if it was not provided by the server.
208+
209+
Raises:
210+
ApiClientError: If there are any errors communicating with the Microgrid API,
211+
most likely a subclass of
212+
[GrpcError][frequenz.client.microgrid.GrpcError].
213+
"""
214+
lifetime_seconds = _delta_to_seconds(request_lifetime)
215+
216+
if validate_arguments:
217+
_validate_set_power_args(power=power, request_lifetime=lifetime_seconds)
218+
219+
response = await client.call_stub_method(
220+
self,
221+
lambda: self.stub.SetComponentPowerActive(
222+
microgrid_pb2.SetComponentPowerActiveRequest(
223+
component_id=_get_component_id(component),
224+
power=power,
225+
request_lifetime=lifetime_seconds,
226+
),
227+
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
228+
),
229+
method_name="SetComponentPowerActive",
230+
)
231+
232+
if response.HasField("valid_until"):
233+
return conversion.to_datetime(response.valid_until)
234+
235+
return None
236+
237+
async def set_component_power_reactive( # noqa: DOC502 (raises ApiClientError indirectly)
238+
self,
239+
component: ComponentId | Component,
240+
power: float,
241+
*,
242+
request_lifetime: timedelta | None = None,
243+
validate_arguments: bool = True,
244+
) -> datetime | None:
245+
"""Set the reactive power output of a component.
246+
247+
We follow the polarity specified in the IEEE 1459-2010 standard
248+
definitions, where:
249+
250+
- Positive reactive is inductive (current is lagging the voltage)
251+
- Negative reactive is capacitive (current is leading the voltage)
252+
253+
The power output is specified in VAr.
254+
255+
The return value is the timestamp until which the given power command will
256+
stay in effect. After this timestamp, the component's reactive power will
257+
be set to 0, if the API receives no further command to change it before
258+
then. By default, this timestamp will be set to the current time plus 60
259+
seconds.
260+
261+
Note:
262+
The target component may have a resolution of more than 1 VAr. E.g., an
263+
inverter may have a resolution of 88 VAr. In such cases, the magnitude of
264+
power will be floored to the nearest multiple of the resolution.
265+
266+
Args:
267+
component: The component to set the output reactive power of.
268+
power: The output reactive power level, in VAr. The standard of polarity is
269+
as per the IEEE 1459-2010 standard definitions: positive reactive is
270+
inductive (current is lagging the voltage); negative reactive is
271+
capacitive (current is leading the voltage).
272+
request_lifetime: The duration, until which the request will stay in effect.
273+
This duration has to be between 10 seconds and 15 minutes (including
274+
both limits), otherwise the request will be rejected. It has
275+
a resolution of a second, so fractions of a second will be rounded for
276+
`timedelta` objects, and it is interpreted as seconds for `int` objects.
277+
If not provided, it usually defaults to 60 seconds.
278+
validate_arguments: Whether to validate the arguments before sending the
279+
request. If `True` a `ValueError` will be raised if an argument is
280+
invalid without even sending the request to the server, if `False`, the
281+
request will be sent without validation.
282+
283+
Returns:
284+
The timestamp until which the given power command will stay in effect, or
285+
`None` if it was not provided by the server.
286+
287+
Raises:
288+
ApiClientError: If there are any errors communicating with the Microgrid API,
289+
most likely a subclass of
290+
[GrpcError][frequenz.client.microgrid.GrpcError].
291+
"""
292+
lifetime_seconds = _delta_to_seconds(request_lifetime)
293+
294+
if validate_arguments:
295+
_validate_set_power_args(power=power, request_lifetime=lifetime_seconds)
296+
297+
response = await client.call_stub_method(
298+
self,
299+
lambda: self.stub.SetComponentPowerReactive(
300+
microgrid_pb2.SetComponentPowerReactiveRequest(
301+
component_id=_get_component_id(component),
302+
power=power,
303+
request_lifetime=lifetime_seconds,
304+
),
305+
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
306+
),
307+
method_name="SetComponentPowerReactive",
308+
)
309+
310+
if response.HasField("valid_until"):
311+
return conversion.to_datetime(response.valid_until)
312+
313+
return None
314+
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+
436+
437+
def _get_component_id(component: ComponentId | Component) -> int:
438+
"""Get the component ID from a component or component ID."""
439+
match component:
440+
case ComponentId():
441+
return int(component)
442+
case Component():
443+
return int(component.id)
444+
case unexpected:
445+
assert_never(unexpected)
446+
447+
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+
459+
def _delta_to_seconds(delta: timedelta | None) -> int | None:
460+
"""Convert a `timedelta` to seconds (or `None` if `None`)."""
461+
return round(delta.total_seconds()) if delta is not None else None
462+
463+
464+
def _validate_set_power_args(*, power: float, request_lifetime: int | None) -> None:
465+
"""Validate the request lifetime."""
466+
if math.isnan(power):
467+
raise ValueError("power cannot be NaN")
468+
if request_lifetime is not None:
469+
minimum_lifetime = 10 # 10 seconds
470+
maximum_lifetime = 900 # 15 minutes
471+
if not minimum_lifetime <= request_lifetime <= maximum_lifetime:
472+
raise ValueError(
473+
"request_lifetime must be between 10 seconds and 15 minutes"
474+
)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Loading of Lifetime objects from protobuf messages."""
5+
6+
from frequenz.api.common.v1.microgrid import lifetime_pb2
7+
from frequenz.client.base.conversion import to_datetime
8+
9+
from ._lifetime import Lifetime
10+
11+
12+
def lifetime_from_proto(
13+
message: lifetime_pb2.Lifetime,
14+
) -> Lifetime:
15+
"""Create a [`Lifetime`][frequenz.client.microgrid.Lifetime] from a protobuf message."""
16+
start = (
17+
to_datetime(message.start_timestamp)
18+
if message.HasField("start_timestamp")
19+
else None
20+
)
21+
end = (
22+
to_datetime(message.end_timestamp)
23+
if message.HasField("end_timestamp")
24+
else None
25+
)
26+
return Lifetime(start=start, end=end)

0 commit comments

Comments
 (0)